Skip to content

Commit 8c5cc52

Browse files
committed
refactor(rich-editor): decouple headless bundle, fix linked-image round-trip, a11y
- Split mention-node into the schema-only `MarkdownMention` (mention-node.ts, no React/registry) and the live `MentionChip` node view (mention-chip.tsx); move the live factory to editor-extensions.ts and inject node views via DI. The headless round-trip path (markdown-parse/normalize-content/round-trip-safety) no longer pulls the 269-block registry — it now bundles for the browser with zero node-builtin deps. - A sized + linked image serializes as `[![alt](src)](href)` (dropping the unrepresentable size) instead of `[<img>](href)`, which the tokenizer can't reparse — the link is preserved, no silent data loss. Also escape the href title symmetrically. - Wire the suggestion menus as an ARIA combobox: while open, the editor gets aria-haspopup/expanded/controls and an aria-activedescendant tracking the active option, so screen readers announce it; cleared on close. Empty state is a role=status live region.
1 parent 31d1c92 commit 8c5cc52

16 files changed

Lines changed: 215 additions & 127 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616
import { Editor } from '@tiptap/core'
1717
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
18-
import { createMarkdownEditorExtensions } from './extensions'
18+
import { createMarkdownEditorExtensions } from './editor-extensions'
1919
import {
2020
applyFrontmatter,
2121
postProcessSerializedMarkdown,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Extensions } from '@tiptap/core'
2+
import Placeholder from '@tiptap/extension-placeholder'
3+
import { CodeBlockWithLanguage } from './code-block'
4+
import { CodeBlockHighlight } from './code-highlight'
5+
import { createMarkdownContentExtensions } from './extensions'
6+
import { ResizableImage } from './image'
7+
import { RichMarkdownKeymap } from './keymap'
8+
import { MarkdownPaste } from './markdown-paste'
9+
import { Mention } from './mention/mention'
10+
import { MentionChip } from './mention/mention-chip'
11+
import { SlashCommand } from './slash-command/slash-command'
12+
13+
interface MarkdownEditorExtensionOptions {
14+
placeholder: string
15+
}
16+
17+
/**
18+
* The full extension set for the live editor: the content extensions with their React node-view nodes
19+
* injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions —
20+
* `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu),
21+
* `RichMarkdownKeymap`, `MarkdownPaste`, and `Placeholder`.
22+
*
23+
* Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls
24+
* in for brand icons) stay out of the headless round-trip path, which only needs the schema.
25+
*/
26+
export function createMarkdownEditorExtensions({
27+
placeholder,
28+
}: MarkdownEditorExtensionOptions): Extensions {
29+
return [
30+
...createMarkdownContentExtensions({
31+
codeBlock: CodeBlockWithLanguage,
32+
image: ResizableImage,
33+
mention: MentionChip,
34+
}),
35+
CodeBlockHighlight,
36+
SlashCommand,
37+
Mention,
38+
RichMarkdownKeymap,
39+
MarkdownPaste,
40+
Placeholder.configure({ placeholder }),
41+
]
42+
}

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

Lines changed: 23 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { Extensions, JSONContent, MarkdownRendererHelpers } from '@tiptap/core'
1+
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
22
import { Code } from '@tiptap/extension-code'
33
import { TaskItem, TaskList } from '@tiptap/extension-list'
4-
import Placeholder from '@tiptap/extension-placeholder'
54
import {
65
renderTableToMarkdown,
76
Table,
@@ -11,14 +10,11 @@ import {
1110
} from '@tiptap/extension-table'
1211
import { Markdown } from '@tiptap/markdown'
1312
import StarterKit from '@tiptap/starter-kit'
14-
import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block'
15-
import { CodeBlockHighlight } from './code-highlight'
16-
import { MarkdownImage, ResizableImage } from './image'
17-
import { RichMarkdownKeymap } from './keymap'
13+
import { MarkdownCodeBlock } from './code-block'
14+
import { MarkdownImage } from './image'
1815
import { MarkdownLinkInputRule } from './link-input-rule'
19-
import { MarkdownPaste } from './markdown-paste'
20-
import { MarkdownMention, Mention, MentionChip, SIM_LINK_SCHEME } from './mention'
21-
import { SlashCommand } from './slash-command/slash-command'
16+
import { MarkdownMention } from './mention/mention-node'
17+
import { SIM_LINK_SCHEME } from './mention/sim-link'
2218

2319
/**
2420
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
@@ -60,13 +56,16 @@ const PipeSafeTable = Table.extend({
6056
.replace(/\n+$/, ''),
6157
})
6258

63-
interface MarkdownEditorExtensionOptions {
64-
placeholder: string
65-
}
66-
67-
interface ContentExtensionOptions {
68-
/** Use the React node views (code-block language picker, image resize). Off for headless tests. */
69-
nodeViews?: boolean
59+
/**
60+
* Node-view variants the live editor injects in place of the headless defaults — the code-block
61+
* language picker, the resizable image, and the mention chip. They pull React (and, for the mention
62+
* chip, the block registry for brand icons), so the headless round-trip path omits them: passing
63+
* nothing keeps {@link createMarkdownContentExtensions} free of React and the registry.
64+
*/
65+
export interface ContentNodeViews {
66+
codeBlock?: Node
67+
image?: Node
68+
mention?: Node
7069
}
7170

7271
/**
@@ -75,13 +74,13 @@ interface ContentExtensionOptions {
7574
* Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add
7675
* `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown.
7776
*
78-
* The code block is the standalone `CodeBlock` so the live editor can swap in a node view;
79-
* the schema and markdown output are identical either way.
77+
* Headless by default (the `nodeViews` overrides are empty), so importing this module — e.g. for the
78+
* markdown round-trip in `markdown-parse.ts` — never constructs React node views or pulls the block
79+
* registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the
80+
* schema and markdown output are identical either way.
8081
*/
81-
export function createMarkdownContentExtensions({
82-
nodeViews = false,
83-
}: ContentExtensionOptions = {}): Extensions {
84-
const codeBlock = (nodeViews ? CodeBlockWithLanguage : MarkdownCodeBlock).configure({
82+
export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions {
83+
const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({
8584
HTMLAttributes: { class: 'code-editor-theme' },
8685
})
8786
return [
@@ -93,8 +92,8 @@ export function createMarkdownContentExtensions({
9392
}),
9493
InlineCode,
9594
codeBlock,
96-
(nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }),
97-
nodeViews ? MentionChip : MarkdownMention,
95+
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
96+
nodeViews.mention ?? MarkdownMention,
9897
TaskList,
9998
TaskItem.configure({ nested: true }),
10099
PipeSafeTable.configure({ resizable: true }),
@@ -105,22 +104,3 @@ export function createMarkdownContentExtensions({
105104
Markdown,
106105
]
107106
}
108-
109-
/**
110-
* The full extension set for the live editor: the content extensions plus the UI-only
111-
* extensions — `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), and
112-
* `Placeholder`.
113-
*/
114-
export function createMarkdownEditorExtensions({
115-
placeholder,
116-
}: MarkdownEditorExtensionOptions): Extensions {
117-
return [
118-
...createMarkdownContentExtensions({ nodeViews: true }),
119-
CodeBlockHighlight,
120-
SlashCommand,
121-
Mention,
122-
RichMarkdownKeymap,
123-
MarkdownPaste,
124-
Placeholder.configure({ placeholder }),
125-
]
126-
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ function escapeAttr(value: string): string {
3333
* it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
3434
* preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is
3535
* wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`.
36+
*
37+
* A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer
38+
* only recognizes `[![alt](src)](href)`, so emitting `[<img …>](href)` would silently drop the link on
39+
* reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the
40+
* unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge.
3641
*/
3742
function imageMarkdown(node: JSONContent): string {
3843
const attrs = node.attrs ?? {}
@@ -44,7 +49,7 @@ function imageMarkdown(node: JSONContent): string {
4449
const width = attrs.width
4550
const height = attrs.height
4651
let image: string
47-
if (width || height) {
52+
if ((width || height) && !href) {
4853
const parts = [`src="${escapeAttr(src)}"`]
4954
if (alt) parts.push(`alt="${escapeAttr(alt)}"`)
5055
if (title) parts.push(`title="${escapeAttr(title)}"`)
@@ -59,7 +64,9 @@ function imageMarkdown(node: JSONContent): string {
5964
image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})`
6065
}
6166
if (!href) return image
62-
const hrefTitlePart = hrefTitle ? ` "${hrefTitle}"` : ''
67+
// Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the
68+
// image title escaping above).
69+
const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : ''
6370
return `[${image}](${href}${hrefTitlePart})`
6471
}
6572

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { Editor } from '@tiptap/core'
1010
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
1111
import { beforeEach, describe, expect, it, vi } from 'vitest'
12-
import { createMarkdownEditorExtensions } from './extensions'
12+
import { createMarkdownEditorExtensions } from './editor-extensions'
1313
import { MENTION_PLUGIN_KEY } from './mention'
1414
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
1515

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention'
2-
export { MarkdownMention, MentionChip } from './mention-node'
2+
export { MentionChip } from './mention-chip'
3+
export { MarkdownMention } from './mention-node'
34
export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
45
export type { MentionItem, MentionKind } from './types'
56
export { useEditorMentions } from './use-editor-mentions'
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { MouseEvent } from 'react'
2+
import type { ReactNodeViewProps } from '@tiptap/react'
3+
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
4+
import { useParams, useRouter } from 'next/navigation'
5+
import { cn } from '@/lib/core/utils/cn'
6+
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
7+
import { mentionIcon } from './mention-icon'
8+
import { MarkdownMention, type MentionAttrs } from './mention-node'
9+
import { simLinkPath } from './sim-link'
10+
11+
/**
12+
* Mirrors the home chat input's mention rendering (the textarea mirror overlay
13+
* in `prompt-editor.tsx`): a borderless inline icon + label that flows with the
14+
* surrounding prose — no pill background, no padding, normal weight, body text
15+
* color, and a 12px icon. Integration icons keep their brand color via
16+
* {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay
17+
* monochrome through the `--text-icon` fallback below.
18+
*/
19+
const CHIP_CLASS =
20+
'mention-chip mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
21+
22+
/**
23+
* Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted
24+
* into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays
25+
* inert so a click can't navigate away from an unsaved edit. This view pulls the block registry (for
26+
* integration brand icons), so it's kept out of the headless {@link MarkdownMention} module.
27+
*/
28+
function MentionChipView({ node, editor }: ReactNodeViewProps) {
29+
const router = useRouter()
30+
const params = useParams()
31+
const { kind, id, label } = node.attrs as MentionAttrs
32+
const Icon = mentionIcon(kind, id) as StyleableIcon
33+
const iconStyle = getBareIconStyle(Icon)
34+
const navigable = editor.storage.mention?.navigable === true
35+
const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined
36+
const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null
37+
38+
const handleClick = (event: MouseEvent) => {
39+
if (!path || !(event.metaKey || event.ctrlKey)) return
40+
event.preventDefault()
41+
router.push(path)
42+
}
43+
44+
return (
45+
<NodeViewWrapper
46+
as='span'
47+
className={cn(CHIP_CLASS, path && 'cursor-pointer')}
48+
onClick={path ? handleClick : undefined}
49+
title={label}
50+
>
51+
<Icon style={iconStyle} />
52+
<span>{label}</span>
53+
</NodeViewWrapper>
54+
)
55+
}
56+
57+
/** Live mention node with the chip view; same schema + markdown output as the headless one. */
58+
export const MentionChip = MarkdownMention.extend({
59+
addNodeView() {
60+
return ReactNodeViewRenderer(MentionChipView)
61+
},
62+
})

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Editor } from '@tiptap/core'
1111
import { EditorContent, ReactRenderer } from '@tiptap/react'
1212
import { File } from 'lucide-react'
1313
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14-
import { createMarkdownEditorExtensions } from '../extensions'
14+
import { createMarkdownEditorExtensions } from '../editor-extensions'
1515
import { MentionList, type MentionListHandle } from './mention-list'
1616
import { createMentionStore } from './mention-store'
1717
import type { MentionItem } from './types'
@@ -28,11 +28,13 @@ const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) }
2828
describe('MentionList keyboard nav', () => {
2929
let container: HTMLElement
3030
let root: ReturnType<typeof import('react-dom/client').createRoot>
31+
let editor: Editor
3132

3233
beforeEach(async () => {
3334
// jsdom implements neither — both are exercised by scroll-into-view and ProseMirror.
3435
Element.prototype.scrollIntoView = vi.fn()
3536
document.elementFromPoint = vi.fn(() => null)
37+
editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) })
3638
const { createRoot } = await import('react-dom/client')
3739
container = document.createElement('div')
3840
document.body.appendChild(container)
@@ -42,6 +44,7 @@ describe('MentionList keyboard nav', () => {
4244
afterEach(() => {
4345
act(() => root.unmount())
4446
container.remove()
47+
editor.destroy()
4548
})
4649

4750
it('navigates with arrows + inserts on enter once async items have loaded', () => {
@@ -51,7 +54,9 @@ describe('MentionList keyboard nav', () => {
5154

5255
// Menu opens before the workspace data resolves — the store is still empty.
5356
act(() => {
54-
root.render(<MentionList ref={ref} query='' command={command} store={store} />)
57+
root.render(
58+
<MentionList ref={ref} query='' command={command} store={store} editor={editor} />
59+
)
5560
})
5661
expect(ref.current?.onKeyDown(arrowDown)).toBe(false)
5762

@@ -76,7 +81,9 @@ describe('MentionList keyboard nav', () => {
7681
const store = createMentionStore()
7782

7883
act(() => {
79-
root.render(<MentionList ref={ref} query='' command={command} store={store} />)
84+
root.render(
85+
<MentionList ref={ref} query='' command={command} store={store} editor={editor} />
86+
)
8087
})
8188
act(() => store.set(items))
8289

@@ -89,7 +96,6 @@ describe('MentionList keyboard nav', () => {
8996
})
9097

9198
it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => {
92-
const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) })
9399
act(() => {
94100
root.render(<EditorContent editor={editor} />)
95101
})
@@ -98,7 +104,7 @@ describe('MentionList keyboard nav', () => {
98104
const store = createMentionStore()
99105
const renderer = new ReactRenderer<MentionListHandle>(MentionList, {
100106
editor,
101-
props: { query: '', command, store },
107+
props: { query: '', command, store, editor },
102108
})
103109
// Let the portal mount so ReactRenderer captures the imperative handle.
104110
await act(async () => {})
@@ -110,6 +116,5 @@ describe('MentionList keyboard nav', () => {
110116
expect(renderer.ref?.onKeyDown(arrowDown)).toBe(true)
111117

112118
renderer.destroy()
113-
editor.destroy()
114119
})
115120
})

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react'
2+
import type { Editor } from '@tiptap/core'
23
import { SuggestionList } from '../menus/suggestion-list'
34
import {
45
type SuggestionKeyDownHandler,
@@ -16,6 +17,8 @@ interface MentionListProps {
1617
command: (item: MentionItem) => void
1718
/** Live data source the host keeps populated. */
1819
store: MentionStore
20+
/** The editor, wired as the ARIA combobox while the menu is open. */
21+
editor: Editor
1922
}
2023

2124
/** Per-group cap so a large workspace can't flood the menu; filtering still searches the full set. */
@@ -38,7 +41,7 @@ const GROUP_ORDER = [
3841
* `useSyncExternalStore`) rather than props — so the list fills in as async workspace data lands.
3942
*/
4043
export const MentionList = forwardRef<MentionListHandle, MentionListProps>(function MentionList(
41-
{ query, command, store },
44+
{ query, command, store, editor },
4245
ref
4346
) {
4447
const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
@@ -78,6 +81,7 @@ export const MentionList = forwardRef<MentionListHandle, MentionListProps>(funct
7881

7982
return (
8083
<SuggestionList
84+
editor={editor}
8185
containerRef={containerRef}
8286
groups={groups}
8387
activeIndex={activeIndex}

0 commit comments

Comments
 (0)