+
{imagePreview ? (
diff --git a/apps/web/src/lib/editor-heading-anchor.test.mjs b/apps/web/src/lib/editor-heading-anchor.test.mjs
new file mode 100644
index 00000000..f8a38ef8
--- /dev/null
+++ b/apps/web/src/lib/editor-heading-anchor.test.mjs
@@ -0,0 +1,139 @@
+import { beforeAll, describe, expect, test } from "bun:test";
+import { parseHTML } from "linkedom";
+import {
+ activateHeadingAnchorClick,
+ collectHeadingAnchors,
+ findHeadingAnchor,
+ parseHeadingAnchorHref,
+} from "./editor-heading-anchor.ts";
+
+beforeAll(() => {
+ const window = parseHTML("");
+ globalThis.document = window.document;
+ globalThis.HTMLElement = window.HTMLElement;
+ globalThis.Element = window.Element;
+ globalThis.Node = window.Node;
+ globalThis.HTMLDetailsElement = window.HTMLDetailsElement;
+});
+
+const docWith = (entries) => ({
+ descendants(callback) {
+ for (const entry of entries) callback(entry.node, entry.pos);
+ },
+});
+
+const heading = (text, pos) => ({
+ pos,
+ node: { type: { name: "heading" }, textContent: text },
+});
+
+const paragraph = (text, pos) => ({
+ pos,
+ node: { type: { name: "paragraph" }, textContent: text },
+});
+
+describe("parseHeadingAnchorHref", () => {
+ test("reads a bare heading fragment and decodes it", () => {
+ expect(parseHeadingAnchorHref("#简介")).toBe("简介");
+ expect(parseHeadingAnchorHref("#%E7%AE%80%E4%BB%8B")).toBe("简介");
+ expect(parseHeadingAnchorHref("#Hello%20World")).toBe("Hello World");
+ expect(parseHeadingAnchorHref(" #hello-world ")).toBe("hello-world");
+ });
+
+ test("leaves note links, web links, and empty hashes alone", () => {
+ expect(parseHeadingAnchorHref("#memo=memo_abc")).toBeNull();
+ expect(parseHeadingAnchorHref("#memo=")).toBeNull();
+ expect(parseHeadingAnchorHref("https://example.com/docs#简介")).toBeNull();
+ expect(parseHeadingAnchorHref("/docs#简介")).toBeNull();
+ expect(parseHeadingAnchorHref("#")).toBeNull();
+ expect(parseHeadingAnchorHref("")).toBeNull();
+ expect(parseHeadingAnchorHref(null)).toBeNull();
+ });
+});
+
+describe("findHeadingAnchor", () => {
+ const headings = collectHeadingAnchors(docWith([
+ heading("简介", 1),
+ paragraph("正文", 8),
+ heading("Hello World", 12),
+ heading("1. 安装", 30),
+ heading("简介", 48),
+ heading(" 简介: ", 60),
+ ]));
+
+ test("matches Chinese text, encoded slugs, and punctuation", () => {
+ expect(findHeadingAnchor(headings, "简介")?.pos).toBe(1);
+ expect(findHeadingAnchor(headings, "hello-world")?.pos).toBe(12);
+ expect(findHeadingAnchor(headings, "Hello World")?.pos).toBe(12);
+ expect(findHeadingAnchor(headings, "1-安装")?.pos).toBe(30);
+ expect(findHeadingAnchor(headings, "简介:")?.pos).toBe(1);
+ });
+
+ test("uses the first heading when the title is repeated", () => {
+ expect(findHeadingAnchor(headings, "简介")?.pos).toBe(1);
+ expect(findHeadingAnchor(headings, "没有这一节")).toBeNull();
+ });
+});
+
+describe("activateHeadingAnchorClick", () => {
+ const anchor = (href) => {
+ const link = document.createElement("a");
+ link.setAttribute("href", href);
+ link.textContent = "跳转";
+ return link;
+ };
+
+ const click = (target, button = 0) => {
+ const event = {
+ button,
+ target,
+ prevented: false,
+ stopped: false,
+ preventDefault() { this.prevented = true; },
+ stopPropagation() { this.stopped = true; },
+ };
+ return event;
+ };
+
+ test("scrolls to the heading and does not follow the link", () => {
+ const element = document.createElement("h2");
+ const scrolled = [];
+ element.scrollIntoView = (options) => scrolled.push(options);
+ const details = document.createElement("details");
+ details.append(element);
+ const view = {
+ state: {
+ doc: docWith([heading("安装", 4)]),
+ },
+ nodeDOM: () => element,
+ domAtPos: () => ({ node: element }),
+ dispatch() {},
+ focus() {},
+ };
+ const event = click(anchor("#安装"));
+ expect(activateHeadingAnchorClick(event, view)).toBe(true);
+ expect(event.prevented).toBe(true);
+ expect(event.stopped).toBe(true);
+ expect(details.open).toBe(true);
+ expect(scrolled).toEqual([{ behavior: "smooth", block: "start" }]);
+ });
+
+ test("stays put when the heading is missing", () => {
+ const event = click(anchor("#没有"));
+ const view = { state: { doc: docWith([heading("简介", 1)]) } };
+ expect(activateHeadingAnchorClick(event, view)).toBe(true);
+ expect(event.prevented).toBe(true);
+ });
+
+ test("does not consume note links, web links, or non-primary clicks", () => {
+ const view = { state: { doc: docWith([heading("简介", 1)]) } };
+ for (const event of [
+ click(anchor("#memo=memo_abc")),
+ click(anchor("https://example.com/docs#简介")),
+ click(anchor("#简介"), 2),
+ ]) {
+ expect(activateHeadingAnchorClick(event, view)).toBe(false);
+ expect(event.prevented).toBe(false);
+ }
+ });
+});
diff --git a/apps/web/src/lib/editor-heading-anchor.ts b/apps/web/src/lib/editor-heading-anchor.ts
new file mode 100644
index 00000000..311d710f
--- /dev/null
+++ b/apps/web/src/lib/editor-heading-anchor.ts
@@ -0,0 +1,125 @@
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { TextSelection } from "@tiptap/pm/state";
+import type { EditorView } from "@tiptap/pm/view";
+
+/**
+ * In-note Markdown heading links such as `[章节](#章节名)`.
+ * The match is computed at click time from heading text. Ids are not stored.
+ */
+
+export type HeadingAnchor = {
+ pos: number;
+ text: string;
+};
+
+type HeadingAnchorClick = {
+ button: number;
+ target: EventTarget | null;
+ preventDefault: () => void;
+ stopPropagation: () => void;
+};
+
+/** Bare `#fragment` only. Note links and every other href stay untouched. */
+export const parseHeadingAnchorHref = (href: string | null | undefined): string | null => {
+ if (!href) return null;
+ const trimmed = href.trim();
+ if (!trimmed.startsWith("#") || trimmed.startsWith("#memo=")) return null;
+ const encoded = trimmed.slice(1);
+ if (!encoded) return null;
+ let decoded = encoded;
+ try {
+ decoded = decodeURIComponent(encoded.replace(/\+/g, "%20"));
+ } catch {
+ decoded = encoded;
+ }
+ const fragment = decoded.trim();
+ return fragment || null;
+};
+
+/** GitHub-style heading key: case-folded, with spaces and punctuation collapsed. */
+export const headingAnchorKey = (value: string): string =>
+ value
+ .trim()
+ .normalize("NFKC")
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
+ .replace(/^-+|-+$/g, "");
+
+export const collectHeadingAnchors = (doc: ProseMirrorNode): HeadingAnchor[] => {
+ const headings: HeadingAnchor[] = [];
+ doc.descendants((node, pos) => {
+ if (node.type.name !== "heading") return;
+ const text = node.textContent.trim();
+ if (text) headings.push({ pos, text });
+ });
+ return headings;
+};
+
+/** First heading whose text matches the fragment. Later duplicates stay put. */
+export const findHeadingAnchor = (
+ headings: readonly HeadingAnchor[],
+ fragment: string,
+): HeadingAnchor | null => {
+ const key = headingAnchorKey(fragment);
+ if (!key) return null;
+ return headings.find((heading) => headingAnchorKey(heading.text) === key) ?? null;
+};
+
+const headingElement = (view: EditorView, pos: number): HTMLElement | null => {
+ try {
+ const direct = view.nodeDOM(pos);
+ if (direct instanceof HTMLElement) return direct;
+ const found = view.domAtPos(pos);
+ if (found.node instanceof HTMLElement) return found.node;
+ return found.node.parentElement;
+ } catch {
+ return null;
+ }
+};
+
+const revealHeading = (element: HTMLElement) => {
+ let parent = element.parentElement;
+ while (parent) {
+ if (parent.tagName === "DETAILS") (parent as HTMLDetailsElement).open = true;
+ parent = parent.parentElement;
+ }
+};
+
+export const scrollViewToHeading = (view: EditorView, pos: number) => {
+ const element = headingElement(view, pos);
+ if (element) revealHeading(element);
+ try {
+ const max = view.state.doc.content.size;
+ const target = Math.min(pos + 1, max);
+ if (target >= 1) {
+ const selection = TextSelection.near(view.state.doc.resolve(target));
+ // Place the caret first. A selection update cancels an in-flight smooth scroll.
+ view.dispatch(view.state.tr.setSelection(selection).scrollIntoView());
+ view.focus();
+ }
+ } catch {
+ // The heading can still scroll into view when the caret cannot move.
+ }
+ element?.scrollIntoView({ behavior: "smooth", block: "start" });
+};
+
+/**
+ * Consume a primary click on an in-note heading link.
+ * Returns false for every other click so note links, web links, and attachments proceed.
+ */
+export const activateHeadingAnchorClick = (
+ event: HeadingAnchorClick,
+ view: EditorView | null | undefined,
+): boolean => {
+ if (event.button !== 0) return false;
+ const target = event.target instanceof Element ? event.target.closest("a[href]") : null;
+ if (!target) return false;
+ const fragment = parseHeadingAnchorHref(target.getAttribute("href"));
+ if (!fragment) return false;
+ event.preventDefault();
+ event.stopPropagation();
+ if (!view) return true;
+ const match = findHeadingAnchor(collectHeadingAnchors(view.state.doc), fragment);
+ if (match) scrollViewToHeading(view, match.pos);
+ return true;
+};