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 'katex/dist/katex.min.css'
|
||||||
import ArticleLink from './ArticleLink'
|
import ArticleLink from './ArticleLink'
|
||||||
import rehypeHighlightOffsets from '../utils/highlightOffsets'
|
import rehypeHighlightOffsets from '../utils/highlightOffsets'
|
||||||
|
import remarkTipTerms from '../utils/tipTerms'
|
||||||
|
import TipTerm from './TipTerm'
|
||||||
import { markdownImageUrl } from '../utils/uploads'
|
import { markdownImageUrl } from '../utils/uploads'
|
||||||
import './RichText.css'
|
import './RichText.css'
|
||||||
|
|
||||||
|
|
@ -62,10 +64,16 @@ export default function RichText({
|
||||||
onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start))
|
onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start))
|
||||||
} : undefined}>
|
} : undefined}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath, remarkTipTerms]}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
components={{
|
components={{
|
||||||
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
|
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 }) => {
|
a: ({ node, href, children, ...props }) => {
|
||||||
const target = linkArticles && internalArticle(href)
|
const target = linkArticles && internalArticle(href)
|
||||||
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>
|
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,21 @@ describe('highlights across rendered markdown', () => {
|
||||||
expect(active.dataset.start).toBe('0')
|
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 () => {
|
it('removes a highlight on right-click, and ignores plain text', async () => {
|
||||||
const onRemoveHighlight = vi.fn()
|
const onRemoveHighlight = vi.fn()
|
||||||
const { container } = mount({
|
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 = [
|
const SECTIONS = [
|
||||||
{ id: 'links', label: 'Article links' },
|
{ id: 'links', label: 'Article links' },
|
||||||
|
{ id: 'tips', label: 'Tips in the prose' },
|
||||||
{ id: 'tutor', label: 'The AI tutor' },
|
{ id: 'tutor', label: 'The AI tutor' },
|
||||||
{ id: 'blueprint', label: 'Exams and blueprints' },
|
{ id: 'blueprint', label: 'Exams and blueprints' },
|
||||||
{ id: 'deleting', label: 'Deleting things' },
|
{ id: 'deleting', label: 'Deleting things' },
|
||||||
|
|
@ -119,6 +120,33 @@ export default function HandbookPage() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</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">
|
<section id="tutor">
|
||||||
<h2>The AI tutor</h2>
|
<h2>The AI tutor</h2>
|
||||||
<p>
|
<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