From a7659134403f4a226a675e9a8555b5292cff5e5c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 19:05:12 +0200 Subject: [PATCH] feat: ==key points==, highlighted in yellow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A summary where nothing is emphasised is a shorter article, not a revision aid. An educator can now run a highlighter over the sentence that matters, from the editor's toolbar or by typing the markers, and the reader sees it in yellow — most usefully in the high-yield view, which is what it is for. A remark plugin rather than a rewrite of the source string, for the same reason as tip terms: a reader's own highlights and the read-aloud cursor are stored as offsets into the raw markdown, and editing the string before it is parsed would shift every offset after the first key point. Splitting the parsed text node keeps each piece pointing where it came from. ``, not a styled span: find-in-page and print already know what it means, and so does a screen reader. A lone `==` stays the characters it is — "Sodium == 140" is a lab value — and a highlight may not run across a line, so one stray marker cannot swallow a section. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/RichEditor.jsx | 24 +++++++- frontend/src/components/RichText.css | 10 +++ frontend/src/components/RichText.jsx | 6 +- frontend/src/components/RichText.test.jsx | 27 +++++++++ frontend/src/utils/keyPoints.js | 74 +++++++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/keyPoints.js diff --git a/frontend/src/components/RichEditor.jsx b/frontend/src/components/RichEditor.jsx index df48da9..9e5e560 100644 --- a/frontend/src/components/RichEditor.jsx +++ b/frontend/src/components/RichEditor.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react' -import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core' +import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core' import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBulletListCommand, wrapInOrderedListCommand, wrapInBlockquoteCommand, insertHrCommand } from '@milkdown/preset-commonmark' import { gfm, insertTableCommand, toggleStrikethroughCommand } from '@milkdown/preset-gfm' import { history, undoCommand, redoCommand } from '@milkdown/plugin-history' @@ -18,6 +18,26 @@ function Toolbar() { if (editor) editor.action(callCommand(cmd, payload)) } + // `==like this==`. Milkdown has no highlight mark of its own and CommonMark + // has no syntax for one, so this writes the markers the reader's renderer + // looks for — the same thing an author would type by hand, minus the typing. + const highlight = () => { + const editor = getEditor() + if (!editor) return + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const { state, dispatch } = view + const { from, to, empty } = state.selection + // Nothing selected: leave a pair of markers with the caret between them, + // rather than silently doing nothing to a button that was just pressed. + const inner = empty ? '' : state.doc.textBetween(from, to, ' ') + const tr = state.tr.insertText(`==${inner}==`, from, to) + if (empty) tr.setSelection(state.selection.constructor.near(tr.doc.resolve(from + 2))) + dispatch(tr) + view.focus() + }) + } + const btn = { background: 'none', border: '1px solid #e2e8f0', borderRadius: 4, padding: '4px 8px', cursor: 'pointer', fontSize: '0.82rem', color: '#475569', lineHeight: 1 } const sep = { width: 1, height: 20, background: '#e2e8f0', margin: '0 4px' } @@ -29,6 +49,8 @@ function Toolbar() { +
diff --git a/frontend/src/components/RichText.css b/frontend/src/components/RichText.css index 5954e75..706ad7b 100644 --- a/frontend/src/components/RichText.css +++ b/frontend/src/components/RichText.css @@ -30,3 +30,13 @@ /* KaTeX paints its own colours; these keep it on the page's. */ .rich-text .katex { font-size: 1.02em; color: inherit; } .rich-text .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; } + +/* `==key point==` — the highlighter an educator runs over the sentence that + matters. Yellow because that is the gesture being copied; a token would be + theme-correct and would not read as a highlighter in either theme. The text + colour is set alongside it so the dark theme does not put dark ink on it. */ +.rich-text mark.key-point { + background: #fde68a; color: #422006; + padding: 0 2px; border-radius: 2px; + box-decoration-break: clone; -webkit-box-decoration-break: clone; +} diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx index ecb815d..986582b 100644 --- a/frontend/src/components/RichText.jsx +++ b/frontend/src/components/RichText.jsx @@ -7,6 +7,7 @@ import 'katex/dist/katex.min.css' import ArticleLink from './ArticleLink' import rehypeHighlightOffsets from '../utils/highlightOffsets' import remarkTipTerms from '../utils/tipTerms' +import remarkKeyPoints from '../utils/keyPoints' import TipTerm from './TipTerm' import { markdownImageUrl } from '../utils/uploads' import './RichText.css' @@ -92,6 +93,9 @@ export default function RichText({ if (target) return {children} return {children} }, + // `==key point==`, highlighted. Passed through with its class rather than + // left to the default, so the yellow is ours and not the browser's. + mark: ({ node, children, ...props }) => {children}, // A wide table is the reason this exists; it scrolls inside its own box // rather than pushing the page sideways. table: ({ node, ...props }) => ( @@ -108,7 +112,7 @@ export default function RichText({ onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start)) } : undefined}> {linkArticles ? expandWikiLinks(value) : (value || '')} diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx index 995fded..e8f42f2 100644 --- a/frontend/src/components/RichText.test.jsx +++ b/frontend/src/components/RichText.test.jsx @@ -134,3 +134,30 @@ describe('highlights across rendered markdown', () => { expect(container.querySelector('[data-manual-highlight-id]')).toBeNull() }) }) + +describe('==key points==', () => { + it('highlights the phrase and leaves the prose around it alone', () => { + const { container } = mount({ value: 'Give ==IVIG within 10 days== of fever onset.' }) + const mark = container.querySelector('mark.key-point') + expect(mark).toHaveTextContent('IVIG within 10 days') + expect(container.textContent).toBe('Give IVIG within 10 days of fever onset.') + }) + + it('highlights more than one in a sentence', () => { + const { container } = mount({ value: '==First== then ==second==.' }) + expect(container.querySelectorAll('mark.key-point')).toHaveLength(2) + }) + + it('leaves a lone marker as the characters an author typed', () => { + // A lab value, not an unclosed highlight — and one stray marker must never + // swallow the rest of a section. + const { container } = mount({ value: 'Sodium == 140 mmol/L today.' }) + expect(container.querySelector('mark')).toBeNull() + expect(container.textContent).toContain('== 140') + }) + + it('does not run across a line break', () => { + const { container } = mount({ value: 'Start ==here\nand end here== later' }) + expect(container.querySelector('mark')).toBeNull() + }) +}) diff --git a/frontend/src/utils/keyPoints.js b/frontend/src/utils/keyPoints.js new file mode 100644 index 0000000..6e31420 --- /dev/null +++ b/frontend/src/utils/keyPoints.js @@ -0,0 +1,74 @@ +/** + * `==the one thing to remember==` — a key point, highlighted in yellow. + * + * Written by whoever maintains the article, in the prose itself, so it travels + * with the sentence it is about rather than living in a separate field that + * goes stale the moment the paragraph is rewritten. It reads as a highlighter + * run over a textbook, which is exactly the gesture being copied, and it is + * most of the point of the high-yield view — a summary where nothing is + * emphasised is a shorter article, not a revision aid. + * + * A remark plugin rather than a rewrite of the source string, for the same + * reason as `tipTerms`: highlights a *reader* has drawn, and the read-aloud + * cursor, are stored as offsets into the raw markdown. Editing the string + * before it is parsed would shift every offset after the first key point. + * Splitting the parsed text node keeps each piece pointing where it came from. + * + * `` rather than a styled span, because that is the element that means + * this — a screen reader can say so, and a browser's own find-in-page and + * print styles already know what to do with it. + */ + +// No newline inside, so an unclosed `==` cannot swallow the rest of a section, +// and at least one character, so `====` is four literal equals signs. A run of +// them as a Setext heading underline never matches: those sit alone on a line. +const KEY = /==(?!=)([^\n=]{1,400}?)==/g + +const walk = (node, visit) => { + if (!Array.isArray(node.children)) return + node.children = node.children.flatMap(child => { + walk(child, visit) + return visit(child) ?? [child] + }) +} + +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 remarkKeyPoints() { + 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 + KEY.lastIndex = 0 + for (let match = KEY.exec(node.value); match; match = KEY.exec(node.value)) { + const [whole, phrase] = match + if (match.index > last) out.push(text(node.value.slice(last, match.index), at(last), anchor)) + out.push({ + type: 'keyPoint', + data: { hName: 'mark', hProperties: { className: 'key-point' } }, + // The phrase begins two characters in, past the `==`, so a reader's + // own highlight drawn over it still lands on the same characters. + children: [text(phrase, at(match.index + 2), 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 + }) + } +}