mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 09:36:37 +02:00
refactor(markdown): clean up a bit the sync rendering
This commit is contained in:
63
apps/client/src/widgets/type_widgets/code/Markdown.spec.ts
Normal file
63
apps/client/src/widgets/type_widgets/code/Markdown.spec.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user