feat: tips in the prose, underlined where they are needed
`{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}`
in any stem, option, explanation or article renders the phrase with a
dotted underline; clicking it opens the sentence where the word is.
Nothing is revealed until asked for, and Esc or a second click closes it.
The question already had an Attending tip — one panel, for the whole
question, reached from the toolbar. This is the other half: the point
that belongs to one word, said next to that word.
Done as a remark plugin rather than a search-and-replace on the source,
because highlights and the read-aloud cursor are stored as offsets into
the raw text; rewriting the string before it is parsed would move every
offset after the first tip. The split pieces carry line and column as
well as offset — a point with only one of the three is discarded
downstream, which cost the first version every highlight past the tip.
There is a test for exactly that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2c6b821f6f
commit
f048b1f4b6
7 changed files with 262 additions and 1 deletions
|
|
@ -5,6 +5,8 @@ import rehypeKatex from 'rehype-katex'
|
|||
import 'katex/dist/katex.min.css'
|
||||
import ArticleLink from './ArticleLink'
|
||||
import rehypeHighlightOffsets from '../utils/highlightOffsets'
|
||||
import remarkTipTerms from '../utils/tipTerms'
|
||||
import TipTerm from './TipTerm'
|
||||
import { markdownImageUrl } from '../utils/uploads'
|
||||
import './RichText.css'
|
||||
|
||||
|
|
@ -62,10 +64,16 @@ export default function RichText({
|
|||
onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start))
|
||||
} : undefined}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkTipTerms]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={{
|
||||
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
|
||||
// `{{phrase|tip}}` — a teaching point that opens where the phrase is.
|
||||
span: ({ node, children, ...props }) => (
|
||||
props.className === 'tip-term'
|
||||
? <TipTerm tip={props['data-tip']}>{children}</TipTerm>
|
||||
: <span {...props}>{children}</span>
|
||||
),
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
const target = linkArticles && internalArticle(href)
|
||||
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>
|
||||
|
|
|
|||
|
|
@ -100,6 +100,21 @@ describe('highlights across rendered markdown', () => {
|
|||
expect(active.dataset.start).toBe('0')
|
||||
})
|
||||
|
||||
it('a tip in the stem does not move the offsets after it', () => {
|
||||
// The marker is 33 characters wide; text past it must still be found where
|
||||
// it was stored, or every highlight drawn before the tip was written would
|
||||
// land in the wrong place.
|
||||
const withTip = 'A 9-year-old {{boy|Sex matters here}} is brought to the office.'
|
||||
const start = withTip.indexOf('office')
|
||||
const { container } = mount({
|
||||
value: withTip, textId: 'q1::question',
|
||||
highlights: [{ start, end: start + 6 }],
|
||||
})
|
||||
const active = container.querySelector('.manual-highlight-active')
|
||||
expect(active).toHaveTextContent('office')
|
||||
expect(active.dataset.start).toBe(String(start))
|
||||
})
|
||||
|
||||
it('removes a highlight on right-click, and ignores plain text', async () => {
|
||||
const onRemoveHighlight = vi.fn()
|
||||
const { container } = mount({
|
||||
|
|
|
|||
29
frontend/src/components/TipTerm.css
Normal file
29
frontend/src/components/TipTerm.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* The phrase reads as prose until you look for it: a dotted rule under the
|
||||
words, not a coloured link. Following it is a detour, not the way on. */
|
||||
.tip-anchor { position: relative; display: inline; }
|
||||
|
||||
.tip-term {
|
||||
display: inline; padding: 0; margin: 0; border: 0; background: none;
|
||||
font: inherit; color: inherit; cursor: pointer; text-align: inherit;
|
||||
border-bottom: 1.5px dotted var(--primary);
|
||||
}
|
||||
.tip-term:hover, .tip-term.is-open { background: var(--tip-wash, rgba(253, 224, 71, 0.28)); }
|
||||
.tip-term:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 2px; }
|
||||
|
||||
.tip-card {
|
||||
position: absolute; z-index: 60; top: calc(100% + 7px); left: 0;
|
||||
display: flex; gap: 9px; width: max-content; max-width: min(360px, 80vw);
|
||||
padding: 11px 13px; text-align: left;
|
||||
background: var(--card-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--primary);
|
||||
border-radius: 10px; box-shadow: 0 10px 26px rgba(15, 23, 42, 0.16);
|
||||
font-size: 0.86rem; font-weight: 400; line-height: 1.55; white-space: normal;
|
||||
}
|
||||
.tip-card.is-above { top: auto; bottom: calc(100% + 7px); }
|
||||
.tip-card-mark { color: var(--primary); flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* On a narrow screen a card pinned to the word runs off the edge; it takes
|
||||
the width it has instead. */
|
||||
.tip-card { left: 0; right: 0; width: auto; max-width: none; }
|
||||
}
|
||||
53
frontend/src/components/TipTerm.jsx
Normal file
53
frontend/src/components/TipTerm.jsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import './TipTerm.css'
|
||||
|
||||
/**
|
||||
* An underlined phrase that opens its teaching point where it stands.
|
||||
*
|
||||
* Click rather than hover: a tip is a sentence worth reading, and a card that
|
||||
* appears because the pointer crossed a word is a card that vanishes while you
|
||||
* read it. It is also the only behaviour a touch screen has.
|
||||
*
|
||||
* Opening one closes the last, so a stem does not fill with panels.
|
||||
*/
|
||||
export default function TipTerm({ tip, children }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [above, setAbove] = useState(false)
|
||||
const anchor = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
|
||||
const onDown = (event) => {
|
||||
if (!anchor.current?.contains(event.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const toggle = () => {
|
||||
// Flip the card above the phrase when there is no room beneath it.
|
||||
const box = anchor.current?.getBoundingClientRect?.()
|
||||
if (box) setAbove(window.innerHeight - box.bottom < 160)
|
||||
setOpen(value => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="tip-anchor" ref={anchor}>
|
||||
<button type="button" className={`tip-term${open ? ' is-open' : ''}`}
|
||||
aria-expanded={open} onClick={toggle}>
|
||||
{children}
|
||||
</button>
|
||||
{open && (
|
||||
<span className={`tip-card${above ? ' is-above' : ''}`} role="note">
|
||||
<span className="tip-card-mark" aria-hidden="true">⚕</span>
|
||||
<span className="tip-card-text">{tip}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/TipTerm.test.jsx
Normal file
45
frontend/src/components/TipTerm.test.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, it } from 'vitest'
|
||||
import RichText from './RichText'
|
||||
|
||||
const stem = 'A 2-year-old has {{stridor|Inspiratory stridor is extrathoracic until proven otherwise}} at rest.'
|
||||
|
||||
it('underlines the phrase and keeps the tip closed until asked', async () => {
|
||||
render(<RichText value={stem} />)
|
||||
const term = screen.getByRole('button', { name: 'stridor' })
|
||||
expect(term).toHaveClass('tip-term')
|
||||
expect(term).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument()
|
||||
// The marker itself never reaches the reader.
|
||||
expect(screen.queryByText(/\{\{/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the teaching point where the phrase is, and closes again', async () => {
|
||||
render(<RichText value={stem} />)
|
||||
const term = screen.getByRole('button', { name: 'stridor' })
|
||||
await userEvent.click(term)
|
||||
expect(screen.getByRole('note')).toHaveTextContent('extrathoracic until proven otherwise')
|
||||
await userEvent.click(term)
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
render(<RichText value={stem} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'stridor' }))
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves prose that merely looks like a marker alone', () => {
|
||||
render(<RichText value={'Give 0.1 mL/kg {{of} 1:1000 adrenaline.'} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/1:1000 adrenaline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the text around several tips in order', async () => {
|
||||
render(<RichText value={'{{One|first tip}} then {{Two|second tip}} end.'} />)
|
||||
expect(screen.getByRole('button', { name: 'One' })).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Two' }))
|
||||
expect(screen.getByRole('note')).toHaveTextContent('second tip')
|
||||
})
|
||||
|
|
@ -15,6 +15,7 @@ import './HandbookPage.css'
|
|||
|
||||
const SECTIONS = [
|
||||
{ id: 'links', label: 'Article links' },
|
||||
{ id: 'tips', label: 'Tips in the prose' },
|
||||
{ id: 'tutor', label: 'The AI tutor' },
|
||||
{ id: 'blueprint', label: 'Exams and blueprints' },
|
||||
{ id: 'deleting', label: 'Deleting things' },
|
||||
|
|
@ -119,6 +120,33 @@ export default function HandbookPage() {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
<section id="tips">
|
||||
<h2>Tips in the prose</h2>
|
||||
<p>
|
||||
A phrase in a stem, an option, an explanation or an article can carry
|
||||
one sentence of teaching behind it. Written as{' '}
|
||||
<code>{'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}</code>{' '}
|
||||
— the phrase first, then a bar, then the tip.
|
||||
</p>
|
||||
<p>
|
||||
The reader sees <strong>stridor</strong> underlined with a dotted
|
||||
rule. Clicking it opens the sentence where the word is and clicking
|
||||
again closes it; so does <kbd>Esc</kbd>. Nothing is revealed until
|
||||
asked for, which is the point: a tip in the open is just more stem.
|
||||
</p>
|
||||
<p>
|
||||
Use it for the thing a learner would otherwise have to be told
|
||||
afterwards — why that vital sign matters, what the eponym is. When
|
||||
the answer runs longer than a sentence it belongs in an article, and
|
||||
a cross-reference is the better tool.
|
||||
</p>
|
||||
<p className="hb-warn">
|
||||
A phrase may not contain a bar or a brace, and a tip may not contain
|
||||
braces. Anything that does not match the shape exactly is left in the
|
||||
text as written — a stray <code>{'{'}</code> in a formula is safe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="tutor">
|
||||
<h2>The AI tutor</h2>
|
||||
<p>
|
||||
|
|
|
|||
83
frontend/src/utils/tipTerms.js
Normal file
83
frontend/src/utils/tipTerms.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* `{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}`
|
||||
* — a word in the prose that carries a teaching point behind it.
|
||||
*
|
||||
* The reader sees the phrase underlined; the tip appears where the phrase is,
|
||||
* on a click, rather than sending them to a panel elsewhere on the page and
|
||||
* back. It is the same idea as a cross-reference, minus the article: sometimes
|
||||
* what the learner needs is one sentence, and writing a whole article to hang
|
||||
* it on is how a sentence goes unwritten.
|
||||
*
|
||||
* A remark plugin rather than a search-and-replace on the source, because
|
||||
* highlights and the read-aloud cursor are stored as offsets into the raw
|
||||
* text. Rewriting the string before it is parsed would move every offset after
|
||||
* the first tip. Splitting the parsed text node instead keeps each piece
|
||||
* pointing at where it came from.
|
||||
*/
|
||||
|
||||
// The phrase may not contain a bar or a brace; the tip may not contain braces.
|
||||
// Both are deliberate: it keeps a stray `{` in a formula from eating the rest
|
||||
// of a stem.
|
||||
const TIP = /\{\{([^{}|]+)\|([^{}]+)\}\}/g
|
||||
|
||||
const walk = (node, visit) => {
|
||||
if (!Array.isArray(node.children)) return
|
||||
node.children = node.children.flatMap(child => {
|
||||
walk(child, visit)
|
||||
return visit(child) ?? [child]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions for the pieces a split leaves behind.
|
||||
*
|
||||
* Line and column travel with the offset because the tools downstream discard
|
||||
* a point that carries only one of the three — which is how the first version
|
||||
* of this lost every highlight past the first tip. Column is exact for a
|
||||
* single-line run and approximate across a wrapped one; nothing reads it, and
|
||||
* an offset with no column at all is read as no position.
|
||||
*/
|
||||
const pointAt = (offset, anchor) => ({
|
||||
line: anchor.line, column: anchor.column + (offset - anchor.offset), offset,
|
||||
})
|
||||
|
||||
const span = (from, to, anchor) => (from == null || !anchor ? undefined
|
||||
: { start: pointAt(from, anchor), end: pointAt(to, anchor) })
|
||||
|
||||
const text = (value, from, anchor) => ({
|
||||
type: 'text', value, position: span(from, from + value.length, anchor),
|
||||
})
|
||||
|
||||
export default function remarkTipTerms() {
|
||||
return (tree) => {
|
||||
walk(tree, (node) => {
|
||||
if (node.type !== 'text' || !node.value.includes('{{')) return null
|
||||
const anchor = node.position?.start
|
||||
const base = anchor?.offset
|
||||
const at = (index) => (base == null ? null : base + index)
|
||||
const out = []
|
||||
let last = 0
|
||||
TIP.lastIndex = 0
|
||||
for (let match = TIP.exec(node.value); match; match = TIP.exec(node.value)) {
|
||||
const [whole, phrase, tip] = match
|
||||
if (match.index > last) out.push(text(node.value.slice(last, match.index), at(last), anchor))
|
||||
// The phrase sits after `{{` inside the marker; giving it that offset
|
||||
// keeps a highlight drawn over it landing on the same characters.
|
||||
const phraseAt = at(match.index + 2)
|
||||
out.push({
|
||||
type: 'tipTerm',
|
||||
data: {
|
||||
hName: 'span',
|
||||
hProperties: { className: 'tip-term', 'data-tip': tip.trim() },
|
||||
},
|
||||
children: [text(phrase, phraseAt, anchor)],
|
||||
position: span(at(match.index), at(match.index + whole.length), anchor),
|
||||
})
|
||||
last = match.index + whole.length
|
||||
}
|
||||
if (!out.length) return null
|
||||
if (last < node.value.length) out.push(text(node.value.slice(last), at(last), anchor))
|
||||
return out
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue