From 3a0fa521be3e6a8d216ef093f201cc5db841f155 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:35:28 -0700 Subject: [PATCH] feat(editor): support clicking anchor links to jump to headings (#879) Anchor-only links (#heading) now scroll to the matching heading in both the markdown preview and rich editor. Preview uses rehype-slug to stamp heading ids; the rich editor walks headings with the same stateful GithubSlugger for parity (including duplicate-heading suffixes). --- package.json | 2 + pnpm-lock.yaml | 52 +++++++++++-- .../src/components/editor/MarkdownPreview.tsx | 25 +++++- .../components/editor/RichMarkdownEditor.tsx | 78 +++++++++---------- .../editor/markdown-anchor-scroll.ts | 26 +++++++ .../src/components/editor/useLinkBubble.ts | 8 +- 6 files changed, 141 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-anchor-scroll.ts diff --git a/package.json b/package.json index e5f0f132..68df776f 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "cmdk": "^1.1.1", "dompurify": "^3.4.0", "electron-updater": "^6.8.3", + "github-slugger": "^2.0.0", "hosted-git-info": "^9.0.2", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", @@ -87,6 +88,7 @@ "radix-ui": "^1.4.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-slug": "^6.0.0", "remark-breaks": "^4.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32c8b61d..4227fde9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: electron-updater: specifier: ^6.8.3 version: 6.8.3 + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 hosted-git-info: specifier: ^9.0.2 version: 9.0.2 @@ -148,6 +151,9 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -190,7 +196,7 @@ importers: version: 1.59.1 '@stablyai/playwright-test': specifier: ^2.1.13 - version: 2.1.13(@playwright/test@1.59.1)(zod@3.25.76) + version: 2.1.13(@playwright/test@1.59.1)(zod@4.3.6) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) @@ -3946,6 +3952,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4004,12 +4013,18 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -5268,6 +5283,9 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} @@ -7873,27 +7891,27 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@stablyai/playwright-base@2.1.13(@playwright/test@1.59.1)(zod@3.25.76)': + '@stablyai/playwright-base@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)': dependencies: '@playwright/test': 1.59.1 jpeg-js: 0.4.4 p-retry: 4.6.2 pngjs: 7.0.0 optionalDependencies: - zod: 3.25.76 + zod: 4.3.6 - '@stablyai/playwright-test@2.1.13(@playwright/test@1.59.1)(zod@3.25.76)': + '@stablyai/playwright-test@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)': dependencies: '@playwright/test': 1.59.1 - '@stablyai/playwright': 2.1.13(@playwright/test@1.59.1)(zod@3.25.76) - '@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@3.25.76) + '@stablyai/playwright': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6) + '@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6) transitivePeerDependencies: - zod - '@stablyai/playwright@2.1.13(@playwright/test@1.59.1)(zod@3.25.76)': + '@stablyai/playwright@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)': dependencies: '@playwright/test': 1.59.1 - '@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@3.25.76) + '@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6) transitivePeerDependencies: - zod @@ -9843,6 +9861,8 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -9920,6 +9940,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -9944,6 +9968,10 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -11513,6 +11541,14 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.1 + unist-util-visit: 5.1.0 + remark-breaks@4.0.0: dependencies: '@types/mdast': 4.0.4 diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 352da71a..1df8e894 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -3,6 +3,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkFrontmatter from 'remark-frontmatter' import rehypeHighlight from 'rehype-highlight' +import rehypeSlug from 'rehype-slug' import { extractFrontMatter } from './markdown-frontmatter' import { ChevronDown, ChevronUp, X } from 'lucide-react' import type { Components } from 'react-markdown' @@ -249,7 +250,27 @@ export default function MarkdownPreview({ const components: Components = { a: ({ href, children, ...props }) => { const handleClick = (event: React.MouseEvent): void => { - if (!href || href.startsWith('#')) { + if (!href) { + return + } + + // Why: anchor links target headings within the same preview. rehype-slug + // adds matching id attributes to headings so querySelector can find them. + // No modifier key required — same-page scroll is non-destructive. + if (href.startsWith('#')) { + event.preventDefault() + // Why: anchors in markdown are often URL-encoded (e.g. `#%C3%A9-foo`) + // while rehype-slug produces unicode ids, so decode before matching. + let id = href.slice(1) + try { + id = decodeURIComponent(id) + } catch { + // Malformed %-escapes: fall back to the raw fragment. + } + const el = rootRef.current?.querySelector(`[id="${CSS.escape(id)}"]`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } return } @@ -442,7 +463,7 @@ export default function MarkdownPreview({ {content} diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 068f0b39..398764d2 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -30,6 +30,7 @@ import { absolutePathToFileUri as toFileUrlForOsEscape, resolveMarkdownLinkTarget } from './markdown-internal-links' +import { scrollToAnchorInEditor } from './markdown-anchor-scroll' type RichMarkdownEditorProps = { fileId: string @@ -167,50 +168,49 @@ export default function RichMarkdownEditor({ // OS default handler. Cmd/Ctrl+Shift-click is the OS escape hatch, kept // symmetric with MarkdownPreview. Without a modifier the click falls // through to TipTap's default cursor-positioning behavior. - handleClick: (_view, _pos, event) => { + // Why: ProseMirror fires handleClick before updating the selection, so + // ed.isActive('link') reads the *old* cursor position. We resolve the + // link mark directly at the clicked pos instead. + handleClick: (view, pos, event) => { const ed = editorRef.current - if (!ed) { + const modKey = isMac ? event.metaKey : event.ctrlKey + if (!ed || !modKey) { return false } - const modKey = isMac ? event.metaKey : event.ctrlKey - if (modKey && ed.isActive('link')) { - const href = (ed.getAttributes('link').href as string) || '' - if (!href) { - return false - } - if (event.shiftKey) { - const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) - if (!classified) { - return true - } - if (classified.kind === 'external') { - void window.api.shell.openUrl(classified.url) - return true - } - if (classified.kind === 'markdown') { - void window.api.shell.pathExists(classified.absolutePath).then((exists) => { - if (!exists) { - toast.error(`File not found: ${classified.relativePath}`) - return - } - void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath)) - }) - return true - } - if (classified.kind === 'file') { - void window.api.shell.openFileUri(classified.uri) - return true - } - return true - } - void activateMarkdownLink(href, { - sourceFilePath: filePath, - worktreeId, - worktreeRoot - }) + const linkMark = view.state.doc + .resolve(pos) + .marks() + .find((m) => m.type.name === 'link') + const href = linkMark ? (linkMark.attrs.href as string) || '' : '' + if (!href) { + return false + } + if (href.startsWith('#')) { + scrollToAnchorInEditor(rootRef.current, href.slice(1)) return true } - return false + if (event.shiftKey) { + const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) + if (!classified) { + return true + } + if (classified.kind === 'external') { + void window.api.shell.openUrl(classified.url) + } else if (classified.kind === 'markdown') { + void window.api.shell.pathExists(classified.absolutePath).then((exists) => { + if (!exists) { + toast.error(`File not found: ${classified.relativePath}`) + return + } + void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath)) + }) + } else if (classified.kind === 'file') { + void window.api.shell.openFileUri(classified.uri) + } + return true + } + void activateMarkdownLink(href, { sourceFilePath: filePath, worktreeId, worktreeRoot }) + return true } }, onFocus: () => { diff --git a/src/renderer/src/components/editor/markdown-anchor-scroll.ts b/src/renderer/src/components/editor/markdown-anchor-scroll.ts new file mode 100644 index 00000000..edca0fde --- /dev/null +++ b/src/renderer/src/components/editor/markdown-anchor-scroll.ts @@ -0,0 +1,26 @@ +import GithubSlugger from 'github-slugger' + +// Why: rehype-slug generates heading ids using a stateful GithubSlugger that +// appends numeric suffixes to duplicate headings (foo, foo-1, foo-2). To keep +// the editor's anchor matching in parity with the preview, we must use the +// same stateful slugger — the stateless `slug()` helper would miss suffixes +// and silently land on the wrong heading. +export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string): void { + if (!root || !anchor) { + return + } + let decoded = anchor + try { + decoded = decodeURIComponent(anchor) + } catch { + // Malformed %-escapes: fall back to the raw fragment. + } + const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6') + const slugger = new GithubSlugger() + for (const heading of headings) { + if (slugger.slug(heading.textContent ?? '') === decoded) { + heading.scrollIntoView({ behavior: 'smooth', block: 'start' }) + return + } + } +} diff --git a/src/renderer/src/components/editor/useLinkBubble.ts b/src/renderer/src/components/editor/useLinkBubble.ts index 35d1dd36..a91b199b 100644 --- a/src/renderer/src/components/editor/useLinkBubble.ts +++ b/src/renderer/src/components/editor/useLinkBubble.ts @@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/react' import { getLinkBubblePosition } from './RichMarkdownLinkBubble' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { useAppStore } from '@/store' +import { scrollToAnchorInEditor } from './markdown-anchor-scroll' /** * Extracts link-editing action handlers from the editor component to reduce @@ -100,6 +101,10 @@ export function useLinkBubble( if (!linkBubble?.href) { return } + if (linkBubble.href.startsWith('#')) { + scrollToAnchorInEditor(rootRef.current, linkBubble.href.slice(1)) + return + } void activateMarkdownLink(linkBubble.href, { sourceFilePath: linkContext.sourceFilePath, worktreeId: linkContext.worktreeId, @@ -110,7 +115,8 @@ export function useLinkBubble( linkBubble?.href, linkContext.sourceFilePath, linkContext.worktreeId, - linkContext.worktreeRoot + linkContext.worktreeRoot, + rootRef ]) const toggleLinkFromToolbar = useCallback(() => {