diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index d71e4ff3..bca3cc7c 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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); diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 25571fda..7f6ab7c8 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -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 = ({ /> )} -
+
{ + activateHeadingAnchorClick(event, editor && !editor.isDestroyed ? editor.view : null); + }} + >
diff --git a/apps/web/src/components/PublicSharePage.tsx b/apps/web/src/components/PublicSharePage.tsx index a45c2c06..3b65ed36 100644 --- a/apps/web/src/components/PublicSharePage.tsx +++ b/apps/web/src/components/PublicSharePage.tsx @@ -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) => { + activateHeadingAnchorClick(event, editor && !editor.isDestroyed ? editor.view : null); + }; + return ( -
+
{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; +};