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 + }) + } +}