mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
b3361569b4
commit
9b52da3664
6 changed files with 141 additions and 50 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
26
src/renderer/src/components/editor/markdown-anchor-scroll.ts
Normal file
26
src/renderer/src/components/editor/markdown-anchor-scroll.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue