feat(web): jump to headings from in-note markdown links

Clicking a link such as [章节](#章节名) scrolls to the matching heading in the current note instead of opening a new window.
This commit is contained in:
tianma-if committed 2026-10-21 18:42:27 +08:00
1 parent 55d7e48225
commit 3261010cd5
5 files changed
+290 -3

No files matched your search

+13 -1
View File
@@ -209,6 +209,7 @@ import {
shouldShowEditorLinkOpenHint,
type EditorLinkOpenMode,
} from "@/lib/editor-link-click";
import { activateHeadingAnchorClick, parseHeadingAnchorHref } from "@/lib/editor-heading-anchor";
import {
formatMarkdownLink,
insertMarkdownSnippet,
@@ -1358,6 +1359,11 @@ const RichEditorPane = ({
return false;
}
// In-note `#heading` links scroll inside this note. They must not open a new window.
if (activateHeadingAnchorClick(event, _view)) {
return true;
}
// Read preference/viewport at click time so settings changes apply without remounting the editor.
const isMobile =
typeof window !== "undefined" && window.matchMedia(MOBILE_EDITOR_QUERY).matches;
@@ -1793,7 +1799,8 @@ const RichEditorPane = ({
const showEditorLinkOpenHint = useCallback((target: EventTarget | null) => {
const link = getEditorNavigableLinkFromEventTarget(target);
if (parseMemoLinkHref(link?.getAttribute("href"))) {
const href = link?.getAttribute("href");
if (parseMemoLinkHref(href) || parseHeadingAnchorHref(href)) {
setNoteLinkHintPosition(null);
return;
}
@@ -1840,6 +1847,11 @@ const RichEditorPane = ({
return;
}
if (activateHeadingAnchorClick(event, editor && !editor.isDestroyed ? editor.view : null)) {
setNoteLinkHintPosition(null);
return;
}
if (event.button === 0 && !event.ctrlKey && !event.metaKey) {
showEditorLinkOpenHint(event.target);
@@ -14,6 +14,7 @@ import { insertUploadedResources } from "@/lib/resource-insertion";
import { getResourceInsertionTarget } from "@/lib/resource-insertion-target";
import { clearMobileEditorUndoHistory, getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import { EdgeEverLink } from "@edgeever/shared/editor-link";
import { activateHeadingAnchorClick } from "@/lib/editor-heading-anchor";
import { createInlineFieldExtension } from "@/components/editor/InlineField";
import {
MobileEditorFallback,
@@ -1037,7 +1038,12 @@ export const MobileStandaloneTiptapEditor = ({
/>
)}
<div className="edgeever-mobile-tiptap-editor">
<div
className="edgeever-mobile-tiptap-editor"
onClickCapture={(event) => {
activateHeadingAnchorClick(event, editor && !editor.isDestroyed ? editor.view : null);
}}
>
<EditorContent editor={editor} />
</div>
+6 -1
View File
@@ -27,6 +27,7 @@ import {
getMemoDisplayTitle,
} from "@edgeever/shared";
import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales";
import { activateHeadingAnchorClick } from "@/lib/editor-heading-anchor";
import { applyEditorBodyFontPreference } from "@/lib/editor-body-font";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { PdfAttachment } from "@/components/editor/PdfAttachment";
@@ -118,8 +119,12 @@ const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }
setImagePreview({ alt: target.alt, url: target.currentSrc });
};
const jumpToHeading = (event: MouseEvent<HTMLDivElement>) => {
activateHeadingAnchorClick(event, editor && !editor.isDestroyed ? editor.view : null);
};
return (
<div onDoubleClick={previewImage}>
<div onClickCapture={jumpToHeading} onDoubleClick={previewImage}>
<EditorContent editor={editor} />
{imagePreview ? (
<Suspense fallback={null}>
@@ -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("<!DOCTYPE html><html><body></body></html>");
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);
}
});
});
+125
View File
@@ -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<HTMLAnchorElement>("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;
};