feat(markdown): basic block highlighting in preview

This commit is contained in:
Elian Doran
2026-04-17 07:00:07 +03:00
parent dbe37730c3
commit 12e07dbfcd
3 changed files with 67 additions and 5 deletions

View File

@@ -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);
}
}

View File

@@ -18,6 +18,7 @@ export default function Markdown(props: TypeWidgetProps) {
const editorRef = useRef<VanillaCodeMirror>(null);
useSyncedScrolling(editorRef, previewRef);
useSyncedHighlight(editorRef, previewRef, html);
return (
<SplitEditor
@@ -86,6 +87,44 @@ function useSyncedScrolling(editorRef: RefObject<VanillaCodeMirror>, 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<VanillaCodeMirror>, previewRef: RefObject<HTMLDivElement>, 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<HTMLElement>("[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

View File

@@ -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() {