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