From 12e07dbfcdb1edc752d43bf286c04e8410244ae1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 17 Apr 2026 07:00:07 +0300 Subject: [PATCH] feat(markdown): basic block highlighting in preview --- .../widgets/type_widgets/code/Markdown.css | 12 +++++- .../widgets/type_widgets/code/Markdown.tsx | 39 +++++++++++++++++++ packages/codemirror/src/index.ts | 21 ++++++++-- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/code/Markdown.css b/apps/client/src/widgets/type_widgets/code/Markdown.css index 02d4d0105f..88f1aba621 100644 --- a/apps/client/src/widgets/type_widgets/code/Markdown.css +++ b/apps/client/src/widgets/type_widgets/code/Markdown.css @@ -7,9 +7,19 @@ box-sizing: border-box; height: 100%; overflow: auto; - padding: 0.5em; + padding: 0.5em 1em; user-select: text; } + .markdown-preview [data-source-line] { + border-left: 2px solid transparent; + padding-left: 0.5em; + margin-left: -0.5em; + } + + [data-source-line].markdown-preview-active { + border-left-color: var(--main-text-color); + } + } diff --git a/apps/client/src/widgets/type_widgets/code/Markdown.tsx b/apps/client/src/widgets/type_widgets/code/Markdown.tsx index 3d6e1ab2a5..158cd39ddb 100644 --- a/apps/client/src/widgets/type_widgets/code/Markdown.tsx +++ b/apps/client/src/widgets/type_widgets/code/Markdown.tsx @@ -18,6 +18,7 @@ export default function Markdown(props: TypeWidgetProps) { const editorRef = useRef(null); useSyncedScrolling(editorRef, previewRef); + useSyncedHighlight(editorRef, previewRef, html); return ( , previewRef: }, [ editorRef, previewRef ]); } +/** + * Highlights the preview block that corresponds to the editor's active line, + * matching the built-in `cm-activeLine` behavior. Re-runs when the rendered + * HTML changes so newly inserted blocks pick up the current cursor position. + */ +function useSyncedHighlight(editorRef: RefObject, previewRef: RefObject, html: string) { + useEffect(() => { + const view = editorRef.current; + const preview = previewRef.current; + if (!view || !preview) return; + + let current: HTMLElement | null = null; + + function update() { + if (!view || !preview) return; + const activeLine = view.state.doc.lineAt(view.state.selection.main.head).number; + + const blocks = preview.querySelectorAll("[data-source-line]"); + let match: HTMLElement | null = null; + for (const el of blocks) { + if (parseInt(el.dataset.sourceLine!, 10) <= activeLine) match = el; + else break; + } + + if (match === current) return; + current?.classList.remove("markdown-preview-active"); + match?.classList.add("markdown-preview-active"); + current = match; + } + + update(); + const unsubscribe = view.addUpdateListener((v) => { + if (v.selectionSet || v.docChanged) update(); + }); + return unsubscribe; + }, [ editorRef, previewRef, html ]); +} + /** * Render markdown and tag each top-level block with its 1-indexed source line, * so the preview can be scrolled to match the editor. Marked does not emit diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts index 0fa8023ddb..de4f9f7a58 100644 --- a/packages/codemirror/src/index.ts +++ b/packages/codemirror/src/index.ts @@ -104,16 +104,14 @@ export default class CodeMirror extends EditorView { ]; } + extensions.push(EditorView.updateListener.of((v) => this.#onDocumentUpdated(v))); + if (!config.readOnly) { // Logic specific to editable notes if (config.placeholder) { extensions.push(placeholder(config.placeholder)); } - if (config.onContentChanged) { - extensions.push(EditorView.updateListener.of((v) => this.#onDocumentUpdated(v))); - } - extensions.push(historyCompartment.of(history())); } else { // Logic specific to read-only notes @@ -142,6 +140,21 @@ export default class CodeMirror extends EditorView { if (v.docChanged) { this.config.onContentChanged?.(); } + for (const listener of this.#updateListeners) listener(v); + } + + #updateListeners: Array<(v: ViewUpdate) => void> = []; + + /** + * Subscribe to view updates (doc changes, selection changes, viewport changes, etc.). + * Returns an unsubscribe function. The listener will not fire after the view is destroyed. + */ + addUpdateListener(listener: (v: ViewUpdate) => void): () => void { + this.#updateListeners.push(listener); + return () => { + const i = this.#updateListeners.indexOf(listener); + if (i >= 0) this.#updateListeners.splice(i, 1); + }; } getText() {