feat(editor): support clicking anchor links to jump to headings

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).
This commit is contained in:
Jinjing 2026-04-20 18:30:51 -07:00
parent b3361569b4
commit 9b52da3664
6 changed files with 141 additions and 50 deletions

View file

@ -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",

View file

@ -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

View file

@ -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<HTMLAnchorElement>): 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({
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkFrontmatter]}
rehypePlugins={[rehypeHighlight]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
>
{content}
</Markdown>

View file

@ -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: () => {

View file

@ -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
}
}
}

View file

@ -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(() => {