diff --git a/apps/client/src/widgets/type_widgets/code/Markdown.spec.ts b/apps/client/src/widgets/type_widgets/code/Markdown.spec.ts new file mode 100644 index 0000000000..15369b8f43 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/code/Markdown.spec.ts @@ -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(""); + expect(html).toContain("
  • item
  • "); + }); + + 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"'); + }); +}); diff --git a/apps/client/src/widgets/type_widgets/code/Markdown.tsx b/apps/client/src/widgets/type_widgets/code/Markdown.tsx index 257f7b39bb..b42162a0b5 100644 --- a/apps/client/src/widgets/type_widgets/code/Markdown.tsx +++ b/apps/client/src/widgets/type_widgets/code/Markdown.tsx @@ -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(`
    ${marked.parser(sub)}
    `); - } - 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(null); + useSyncedScrolling(previewRef); + + return ( + + )} + /> + ); +} + +//#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) { 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 ( - - )} - /> - ); + }, [ 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(`
    ${marked.parser(sub)}
    `); + } + return parts.join(""); +} +//#endregion