diff --git a/frontend/src/components/ArticleLink.css b/frontend/src/components/ArticleLink.css index 6dfa1d3..5b65117 100644 --- a/frontend/src/components/ArticleLink.css +++ b/frontend/src/components/ArticleLink.css @@ -13,6 +13,9 @@ z-index: 40; left: 0; top: calc(100% + 8px); + /* `is-floating` is the card drawn into the body: fixed to the window and + placed from the link's rectangle, so nothing between the two can clip it. + Above everything, because it is summoned deliberately and briefly. */ display: flex; flex-direction: column; gap: 6px; @@ -45,6 +48,8 @@ } .al-card.is-above::before { top: auto; bottom: -9px; } .al-card.is-above { top: auto; bottom: calc(100% + 8px); } +.al-card.is-floating { position: fixed; z-index: 1290; width: min(340px, calc(100vw - 16px)); } +.al-card.is-floating.is-above { top: auto; } .al-card-title { font-weight: 700; font-size: 0.92rem; color: var(--text); } .al-card-excerpt { color: var(--text-muted); } .al-card-meta { font-size: 0.75rem; color: var(--text-subtle); } diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx index 7c65814..987c356 100644 --- a/frontend/src/components/ArticleLink.jsx +++ b/frontend/src/components/ArticleLink.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Link } from 'react-router-dom' import api from '../api/client' import { useSplitView } from '../context/SplitViewContext' @@ -13,6 +14,11 @@ const HOVER_DELAY = 350 // Long enough that crossing a link does not summon a // Long enough to cross the gap between the link and the card's controls, and to // cover the blur-then-focus gap when Tab moves between them. const HIDE_DELAY = 260 +//: What the card is, roughly, for deciding which side of the link it fits on +//: and how far in from the edge it must sit. Matched to ArticleLink.css. +const CARD_WIDTH = 340 +const CARD_HEIGHT = 220 +const GAP = 8 export function fetchPreview(slug) { if (!cache.has(slug)) { @@ -42,6 +48,9 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam const [preview, setPreview] = useState(null) const [open, setOpen] = useState(false) const [above, setAbove] = useState(false) + //: Where the card goes, in window coordinates. It is drawn into the body + //: rather than beside the link, so it needs to be told. + const [at, setAt] = useState(null) const timer = useRef(null) const anchor = useRef(null) const split = useSplitView() @@ -58,9 +67,20 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam const reveal = useCallback(async () => { const data = await fetchPreview(slug) if (!data) return - // Flip the card above the link when there is no room beneath it. const box = anchor.current?.getBoundingClientRect?.() - if (box) setAbove(window.innerHeight - box.bottom < 220) + if (box) { + // Flip the card above the link when there is no room beneath it, and + // keep it inside the window sideways. + const above = window.innerHeight - box.bottom < CARD_HEIGHT + setAbove(above) + const left = Math.min(Math.max(8, box.left), + Math.max(8, window.innerWidth - CARD_WIDTH - 8)) + setAt({ + left, + top: above ? undefined : box.bottom + GAP, + bottom: above ? window.innerHeight - box.top + GAP : undefined, + }) + } setPreview(data) setOpen(true) }, [slug]) @@ -133,8 +153,17 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam {children} - {open && preview && ( - {preview.title} {preview.excerpt && {preview.excerpt}} @@ -158,7 +187,7 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam - )} + ), document.body)} ) } diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx index 3c8feb6..5f0912b 100644 --- a/frontend/src/components/RichText.jsx +++ b/frontend/src/components/RichText.jsx @@ -68,13 +68,10 @@ export default function RichText({ attemptId, className = '', linkArticles = false, - // Whether a cross-reference shows a preview card or simply goes. - // - // A card is not a page: it is a box a few lines tall, often inside a - // flipping panel, and a hover card anchored inside one has nowhere to open — - // it is drawn off the edge or clipped by the box, and the link reads as - // broken because clicking it appears to do nothing. Where there is no room - // to preview, the honest behaviour is to take the reader there. + // Whether a cross-reference shows a preview card or simply goes. The card is + // drawn into the body now, so nothing can clip it and there is no longer a + // place where the preview cannot be shown — this stays for a caller that + // genuinely wants a plain link. previewLinks = true, // Passing a textId turns on the highlight layer: text keeps the source // offsets that highlights and the read-aloud cursor are stored against. diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx index 301eed4..38f71eb 100644 --- a/frontend/src/pages/FlashcardStudyPage.jsx +++ b/frontend/src/pages/FlashcardStudyPage.jsx @@ -1,7 +1,9 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import api from '../api/client' import RichText from '../components/RichText' +import ArticleSplitPane from '../components/ArticleSplitPane' +import { SplitViewProvider } from '../context/SplitViewContext' import ImageFigure from '../components/ImageFigure' export default function FlashcardStudyPage() { @@ -110,13 +112,25 @@ export default function FlashcardStudyPage() { return () => window.removeEventListener('keydown', handler) }, [next, prev, currentCard]) + const [splitTrail, setSplitTrail] = useState([]) + const pushSplit = useCallback((slug) => setSplitTrail( + trail => (trail.at(-1) === slug ? trail : [...trail, slug])), []) + const splitView = useMemo(() => ({ open: pushSplit, inPane: false }), [pushSplit]) + const paneView = useMemo(() => ({ open: pushSplit, inPane: true }), [pushSplit]) + const splitSlug = splitTrail.at(-1) || null + if (loading) return
if (!deck) return null const allDone = known.size === cards.length return ( -
+ /* A card can point at the article it came from, so the article can open + beside it — reading the paragraph behind a card is the natural next + thing to do, and it should not cost you your place in the deck. */ + +
+
{/* The way out, above the title and in the same words as everywhere else in the app — not a grey button at the end of a row of controls that change what you are studying. */} @@ -167,15 +181,15 @@ export default function FlashcardStudyPage() { {!allDone && total > 0 && currentCard && ( <>
{ + if (event.target.closest('a, button, .imgfig, .al-wrap')) return + setFlipped(v => !v) }} - onClick={() => setFlipped(v => !v)} >
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total} @@ -185,7 +199,7 @@ export default function FlashcardStudyPage() { everything else here, so `[[264|respiratory failure]]`, `==key points==` and a figure all work on a card — which is most of what "link cards to things" turns out to mean. */} - +

{/* The picture on the card. A card could carry one — the column is there, the editor accepts one, the API returns it — and no view @@ -202,19 +216,25 @@ export default function FlashcardStudyPage() { )}
- {/* Controls */} -
- - {flipped && ( - <> - - - - )} - -
-
- Keyboard: Space/Enter=flip, ←→=navigate, 1=got it, 2=review + {/* The verdict bar, pinned to the foot of the deck the way the + player's is. A card with a picture on it is taller than the + window, and the two buttons the whole exercise turns on were + below the fold — you scrolled to read, then scrolled back to say + whether you knew it. */} +
+
+ + {flipped ? ( + <> + + + + ) : ( + + )} + +
+

Space or Enter flips · ← → moves · 1 got it · 2 review

)} @@ -222,6 +242,16 @@ export default function FlashcardStudyPage() { {!allDone && total === 0 && (
No cards to show. {mode === 'review' ? 'No cards marked for review.' : ''}
)} +
+ + {splitSlug && ( + + setSplitTrail(trail => trail.slice(0, -1))} + onClose={() => setSplitTrail([])} /> + + )}
+ ) } diff --git a/frontend/src/pages/FlashcardStudyPage.test.jsx b/frontend/src/pages/FlashcardStudyPage.test.jsx index 55f140b..8f2cb1b 100644 --- a/frontend/src/pages/FlashcardStudyPage.test.jsx +++ b/frontend/src/pages/FlashcardStudyPage.test.jsx @@ -69,3 +69,32 @@ it('renders a card face as prose, so a card can carry a link', async () => { expect(within(back.closest('.rich-text')).getByText('C1 inhibitor').tagName).toBe('STRONG') expect(screen.getByText('respiratory failure')).toBeInTheDocument() }) + +it('previews a link on a card, and opens the article beside the deck', async () => { + // A card is a small box with its overflow hidden, so a preview positioned + // inside it was invisible and the link read as doing nothing. The card is + // drawn into the body now, and there is somewhere for Split view to go. + api.get.mockImplementation(url => { + if (url === '/articles/preview/264') return Promise.resolve({ data: { + id: 264, slug: 'respiratory-failure', title: 'Pediatric Respiratory Failure', + excerpt: 'Oxygenation or CO2 clearance that no longer meets demand.', + section_count: 18, status: 'published' } }) + if (url === '/articles/264') return Promise.resolve({ data: { + id: 264, title: 'Pediatric Respiratory Failure', sections: [], status: 'published' } }) + if (url === '/flashcards/3/study') return Promise.resolve({ data: DECK }) + return Promise.resolve({ data: {} }) + }) + mount() + await screen.findByText('Hereditary angioedema') + await userEvent.click(screen.getByText('Hereditary angioedema')) + + const link = await screen.findByText('respiratory failure') + await userEvent.click(link) + await waitFor(() => expect(api.get).toHaveBeenCalledWith('/articles/preview/264')) + const card = await screen.findByRole('tooltip') + expect(within(card).getByRole('link', { name: /new tab/i })) + .toHaveAttribute('href', '/articles/264') + + await userEvent.click(within(card).getByRole('button', { name: /split view/i })) + expect(await screen.findByRole('region', { name: /Split view/ })).toBeInTheDocument() +}) diff --git a/frontend/src/pages/FlashcardsPage.css b/frontend/src/pages/FlashcardsPage.css index 1cff9a5..b4ca40c 100644 --- a/frontend/src/pages/FlashcardsPage.css +++ b/frontend/src/pages/FlashcardsPage.css @@ -7,3 +7,71 @@ .fc-edit-hint { margin: 0; font-size: .76rem; line-height: 1.6; color: var(--text-muted); } .fc-edit-hint code { font-size: .74rem; } .fc-edit-image { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; } + +/* Studying a deck with an article open beside it. The deck keeps the middle, + the article takes the right, and the deck does not reflow as you follow one + reference after another. */ +.fc-study.has-split { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(340px, 40%); + gap: 16px; align-items: start; +} +.fc-study.has-split .article-split-pane { position: sticky; top: 12px; max-height: calc(100dvh - 24px); } + +@media (max-width: 1100px) { + /* Not enough width for both: the article takes the screen, and closing it + puts the card back. */ + .fc-study.has-split { grid-template-columns: minmax(0, 1fr); } + .fc-study.has-split .fc-study-main { display: none; } +} + +/* ── Studying a deck ────────────────────────────────────────────────── + Boxed, like the session player: the deck's header at the top, the verdict at + the foot, and only the card between them scrolls. A card with a picture on + it is taller than the window, and the two buttons the whole exercise turns + on were below the fold. */ +.fc-study-main { display: flex; flex-direction: column; gap: 12px; min-height: 0; } + +.fc-card { + flex: 1; min-height: 0; overflow-y: auto; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; padding: 32px 28px; text-align: center; cursor: pointer; + background: var(--card-bg); border: 2px solid var(--border); + border-radius: var(--card-radius); box-shadow: 0 4px 20px rgba(0, 0, 0, .08); + transition: border-color .2s; +} +.fc-card.is-flipped { border-color: var(--primary); } +.fc-card .imgfig { margin: 0; } + +.fc-foot { + position: sticky; bottom: 0; z-index: 2; + padding: 10px 0 6px; background: var(--bg); + border-top: 1px solid var(--border); +} +.fc-foot-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } +.fc-keys { margin: 8px 0 0; text-align: center; font-size: .74rem; color: var(--text-muted); } + +/* Named rather than styled inline: green for the one that means "done with + this", red-lettered for the one that means "again". */ +.fc-known { background: #22c55e; border-color: #22c55e; color: #fff; } +.fc-known:hover { background: #16a34a; border-color: #16a34a; } +.fc-again { color: #ef4444; border-color: #ef4444; background: none; } +.fc-again:hover { background: #fef2f2; } + +@media (min-width: 900px) { + /* Tall enough to be worth boxing: the header and the verdict stay put and + the card scrolls between them. Below this the page simply scrolls. */ + .fc-study-main { height: calc(100dvh - 150px); } +} + +/* What you can do to one card in the browse list: one strip, wrapping onto a + second line on a narrow screen rather than becoming a column. */ +.fc-row-actions { display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0; align-items: flex-start; } +.fc-row-actions .btn { white-space: nowrap; } +.fc-row-delete { color: var(--text-muted); } +.fc-row-delete:hover { color: var(--wrong-fg, #b91c1c); border-color: currentColor; } + +@media (max-width: 700px) { + /* Under the card rather than beside it: at 390px a strip of four buttons + beside the text leaves the stem two words wide. */ + .fc-row-actions { width: 100%; } +} diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx index 9e7682e..37f55c2 100644 --- a/frontend/src/pages/FlashcardsPage.jsx +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -323,7 +323,7 @@ export default function FlashcardsPage() { {/* Rendered, not printed: a card's faces are prose, so a cross-reference on one is a link here too rather than a pair of brackets and a number. */} - + {studyCard.image_path && (
@@ -500,21 +500,24 @@ export default function FlashcardsPage() {

{card.front}

{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}

-
+ {/* One row, not a column of four grey pills down the side of + every card. They are all one kind of thing — what you can do + to this card — so they read as one control strip. */} +
{deletingCard === card.id ? ( -
- - -
+ <> + + + ) : educator ? ( <> - + ) : null}