Skip to content

Commit 77fc0f3

Browse files
committed
fix(files): restore same-page anchor links in the rich markdown editor
Headings rendered by the TipTap editor had no slug ids (the old MarkdownPreview got them from rehype-slug), so in-document table-of-contents links like [section](#section) had no targets. Resolve the slug to its heading on click (GitHub-style, duplicate-disambiguated) and scroll to it, with zero per-keystroke cost.
1 parent 8c0b5bb commit 77fc0f3

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { Editor } from '@tiptap/core'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { createMarkdownContentExtensions } from './extensions'
7+
import { findHeadingPos, slugifyHeading } from './heading-anchors'
8+
9+
let editor: Editor | null = null
10+
afterEach(() => {
11+
editor?.destroy()
12+
editor = null
13+
})
14+
15+
/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */
16+
function docOf(markdown: string) {
17+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
18+
editor.commands.setContent(markdown, { contentType: 'markdown' })
19+
return editor.state.doc
20+
}
21+
22+
describe('slugifyHeading', () => {
23+
it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => {
24+
expect(slugifyHeading('Getting Started')).toBe('getting-started')
25+
expect(slugifyHeading('API Reference!')).toBe('api-reference')
26+
expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out')
27+
expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun')
28+
})
29+
30+
it('returns an empty string for punctuation-only text', () => {
31+
expect(slugifyHeading('!!!')).toBe('')
32+
expect(slugifyHeading('')).toBe('')
33+
})
34+
})
35+
36+
describe('findHeadingPos', () => {
37+
it('resolves a fragment slug to its heading position', () => {
38+
const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore')
39+
expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0)
40+
expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro'))
41+
})
42+
43+
it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => {
44+
const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
45+
const first = findHeadingPos(doc, 'notes')
46+
const second = findHeadingPos(doc, 'notes-1')
47+
const third = findHeadingPos(doc, 'notes-2')
48+
expect(first).toBeGreaterThanOrEqual(0)
49+
expect(second).toBeGreaterThan(first)
50+
expect(third).toBeGreaterThan(second)
51+
})
52+
53+
it('returns -1 when no heading matches', () => {
54+
const doc = docOf('# Only Heading\n\nbody')
55+
expect(findHeadingPos(doc, 'missing')).toBe(-1)
56+
})
57+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
2+
3+
/**
4+
* Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so
5+
* that `[label](#slug)` fragment links — written against how GitHub renders the same markdown —
6+
* resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview.
7+
*/
8+
export function slugifyHeading(text: string): string {
9+
return text
10+
.toLowerCase()
11+
.trim()
12+
.replace(/[^\w\s-]/g, '')
13+
.replace(/\s+/g, '-')
14+
.replace(/-+/g, '-')
15+
}
16+
17+
/**
18+
* The document position of the heading a `#slug` fragment link targets, or -1 if none matches.
19+
* Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate
20+
* slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, …
21+
*/
22+
export function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
23+
const seen = new Map<string, number>()
24+
let found = -1
25+
doc.descendants((node, pos) => {
26+
if (found >= 0) return false
27+
if (node.type.name !== 'heading') return true
28+
const base = slugifyHeading(node.textContent)
29+
if (!base) return true
30+
const n = seen.get(base) ?? 0
31+
seen.set(base, n + 1)
32+
if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos
33+
return found < 0
34+
})
35+
return found
36+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { SaveStatus } from '@/hooks/use-autosave'
1111
import { PreviewLoadingFrame } from '../preview-shared'
1212
import { useEditableFileContent } from '../use-editable-file-content'
1313
import { createMarkdownEditorExtensions } from './extensions'
14+
import { findHeadingPos } from './heading-anchors'
1415
import { extractImageFiles } from './image-paste'
1516
import {
1617
applyFrontmatter,
@@ -241,6 +242,17 @@ export function LoadedRichMarkdownEditor({
241242
// Editing: require a modifier so a plain click can place the cursor. Read-only (a reader, e.g.
242243
// the public share page): a plain click follows the link.
243244
if (view.editable && !(event.metaKey || event.ctrlKey)) return false
245+
// Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab,
246+
// restoring the table-of-contents links that worked via rehype-slug in the old preview.
247+
if (href.startsWith('#')) {
248+
const pos = findHeadingPos(view.state.doc, href.slice(1))
249+
if (pos < 0) return false
250+
;(view.nodeDOM(pos) as HTMLElement | null)?.scrollIntoView({
251+
behavior: 'smooth',
252+
block: 'start',
253+
})
254+
return true
255+
}
244256
const normalized = normalizeLinkHref(href)
245257
if (!normalized) return false
246258
window.open(normalized, '_blank', 'noopener,noreferrer')

0 commit comments

Comments
 (0)