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