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:
1 parent
55d7e48225
commit
3261010cd5
5 files changed
+290
-3
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user