refactor(markdown): clean up a bit the sync rendering

This commit is contained in:
Elian Doran
2026-04-17 06:46:04 +03:00
parent 3417f82e27
commit dc57d6131a
2 changed files with 113 additions and 37 deletions

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { renderWithSourceLines } from "./Markdown.js";
describe("renderWithSourceLines", () => {
function extractLines(html: string): number[] {
return [ ...html.matchAll(/data-source-line="(\d+)"/g) ].map((m) => parseInt(m[1], 10));
}
it("returns empty string for empty input", () => {
expect(renderWithSourceLines("")).toBe("");
});
it("tags a single block as line 1", () => {
const html = renderWithSourceLines("hello");
expect(extractLines(html)).toEqual([ 1 ]);
expect(html).toContain("hello");
});
it("assigns correct source lines to consecutive blocks separated by blank lines", () => {
const src = [
"# Heading", // line 1
"", // line 2
"A paragraph.", // line 3
"", // line 4
"Another one." // line 5
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 3, 5 ]);
});
it("counts multi-line blocks so subsequent blocks get the right line", () => {
const src = [
"```", // 1
"code", // 2
"more code", // 3
"```", // 4
"", // 5
"after" // 6
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 6 ]);
});
it("renders standard markdown constructs inside the wrappers", () => {
const html = renderWithSourceLines("# Heading\n\n- item\n");
expect(html).toContain("<h1");
expect(html).toContain("Heading");
expect(html).toContain("<ul>");
expect(html).toContain("<li>item</li>");
});
it("preserves reference-style links across per-block parsing", () => {
const src = [
"[trilium][t]", // 1
"", // 2
"[t]: https://example.com"
].join("\n");
const html = renderWithSourceLines(src);
expect(html).toContain('href="https://example.com"');
});
});

View File

@@ -3,6 +3,7 @@ import "./Markdown.css";
import VanillaCodeMirror from "@triliumnext/codemirror";
import DOMPurify from "dompurify";
import { Marked, type TokensList } from "marked";
import { RefObject } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import SplitEditor from "../helpers/SplitEditor";
@@ -10,31 +11,38 @@ import { TypeWidgetProps } from "../type_widget";
const marked = new Marked({ breaks: true, gfm: true });
/**
* 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
* source positions (markedjs/marked#1267) so we count newlines in `raw` ourselves.
*/
function renderWithSourceLines(src: string): string {
const tokens = marked.lexer(src);
let line = 1;
const parts: string[] = [];
for (const token of tokens) {
const startLine = line;
line += (token.raw.match(/\n/g) ?? []).length;
if (token.type === "space") continue;
const sub = [ token ] as unknown as TokensList;
(sub as TokensList).links = tokens.links;
parts.push(`<div data-source-line="${startLine}">${marked.parser(sub)}</div>`);
}
return parts.join("");
}
export default function Markdown(props: TypeWidgetProps) {
const [ content, setContent ] = useState("");
const html = useMemo(() => DOMPurify.sanitize(renderWithSourceLines(content), { ADD_ATTR: [ "data-source-line" ] }), [ content ]);
const previewRef = useRef<HTMLDivElement>(null);
useSyncedScrolling(previewRef);
return (
<SplitEditor
noteType="code"
{...props}
onContentChanged={setContent}
previewContent={(
<div
ref={previewRef}
className="markdown-preview"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
/>
);
}
//#region Synced scrolling
/**
* One-directional (editor → preview) scroll sync. On editor scroll, finds the
* top visible source line via the CodeMirror `EditorView`, then scrolls the
* preview so the block tagged with that line is at the top — interpolating to
* the next block for smoothness.
*/
function useSyncedScrolling(previewRef: RefObject<HTMLDivElement>) {
useEffect(() => {
let rafId = 0;
let scroller: HTMLElement | null = null;
@@ -90,21 +98,26 @@ export default function Markdown(props: TypeWidgetProps) {
cancelAnimationFrame(rafId);
scroller?.removeEventListener("scroll", onScroll);
};
}, []);
return (
<SplitEditor
noteType="code"
{...props}
onContentChanged={setContent}
previewContent={(
<div
ref={previewRef}
className="markdown-preview"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
/>
);
}, [ previewRef ]);
}
/**
* 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
* source positions (markedjs/marked#1267) so we count newlines in `raw` ourselves.
*/
export function renderWithSourceLines(src: string): string {
const tokens = marked.lexer(src);
let line = 1;
const parts: string[] = [];
for (const token of tokens) {
const startLine = line;
line += (token.raw.match(/\n/g) ?? []).length;
if (token.type === "space") continue;
const sub = [ token ] as unknown as TokensList;
(sub as TokensList).links = tokens.links;
parts.push(`<div data-source-line="${startLine}">${marked.parser(sub)}</div>`);
}
return parts.join("");
}
//#endregion