Skip to content

Commit b7d87c8

Browse files
committed
feat(file-viewer): linked images, typed-link input rule, drag-to-reorder, churn fixes
- image: round-trip linked images/badges via an href attr + custom markdown tokenizer; make the image a drag handle so it can be grabbed and reordered - link-input-rule: convert typed [text](url) to a link on the closing paren (normalized href) - markdown-paste: render pasted markdown as rich content, guarded against code blocks - round-trip-safety: behavioral link-count check replaces the static linked-image rejection - extensions: trim the table serializer's blank lines to stop interior-table whitespace churn
1 parent 89a269e commit b7d87c8

11 files changed

Lines changed: 639 additions & 24 deletions

File tree

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block'
1515
import { CodeBlockHighlight } from './code-highlight'
1616
import { MarkdownImage, ResizableImage } from './image'
1717
import { EditorKeymap } from './keymap'
18+
import { MarkdownLinkInputRule } from './link-input-rule'
19+
import { MarkdownPaste } from './markdown-paste'
1820
import { SlashCommand } from './slash-command/slash-command'
1921

2022
/**
@@ -29,14 +31,22 @@ const InlineCode = Code.extend({ excludes: '' })
2931
* joins cells with `|` without escaping, so a cell containing a literal pipe silently splits
3032
* into phantom columns on round-trip (data loss). Escaping must happen on the `table` node —
3133
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly.
34+
*
35+
* The upstream serializer also wraps the table in its own leading/trailing blank lines; left in,
36+
* the block joiner adds another, so an interior table churns its surrounding whitespace to
37+
* `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single
38+
* blank-line separator — without touching blank lines inside fenced code (those live in the code
39+
* node's text, not here).
3240
*/
3341
const PipeSafeTable = Table.extend({
3442
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
3543
renderTableToMarkdown(node, {
3644
...h,
3745
renderChildren: (nodes, separator) =>
3846
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
39-
}),
47+
})
48+
.replace(/^\n+/, '')
49+
.replace(/\n+$/, ''),
4050
})
4151

4252
interface MarkdownEditorExtensionOptions {
@@ -79,6 +89,7 @@ export function createMarkdownContentExtensions({
7989
TableRow,
8090
TableHeader,
8191
TableCell,
92+
MarkdownLinkInputRule,
8293
Markdown,
8394
]
8495
}
@@ -96,6 +107,7 @@ export function createMarkdownEditorExtensions({
96107
CodeBlockHighlight,
97108
SlashCommand,
98109
EditorKeymap,
110+
MarkdownPaste,
99111
Placeholder.configure({ placeholder }),
100112
]
101113
}

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

Lines changed: 120 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
66

77
const MIN_WIDTH = 64
88

9+
/**
10+
* A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical
11+
* form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an
12+
* image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize
13+
* the whole construct ourselves and hang the link target on the image node's `href` attribute, so it
14+
* round-trips losslessly (and the file stays editable rather than opening read-only).
15+
*/
16+
const LINKED_IMAGE_RE =
17+
/^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/
18+
919
/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
1020
function escapeAttr(value: string): string {
1121
return value
@@ -18,25 +28,68 @@ function escapeAttr(value: string): string {
1828
/**
1929
* Serialize an image to markdown when it has no explicit size, and to an HTML `<img>` tag when
2030
* it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
21-
* preserve its dimensions. Unsized images stay clean `![alt](src)`.
31+
* preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is
32+
* wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`.
2233
*/
2334
function imageMarkdown(node: JSONContent): string {
2435
const attrs = node.attrs ?? {}
2536
const src = typeof attrs.src === 'string' ? attrs.src : ''
2637
const alt = typeof attrs.alt === 'string' ? attrs.alt : ''
2738
const title = typeof attrs.title === 'string' ? attrs.title : ''
39+
const href = typeof attrs.href === 'string' ? attrs.href : ''
40+
const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : ''
2841
const width = attrs.width
2942
const height = attrs.height
43+
let image: string
3044
if (width || height) {
3145
const parts = [`src="${escapeAttr(src)}"`]
3246
if (alt) parts.push(`alt="${escapeAttr(alt)}"`)
3347
if (title) parts.push(`title="${escapeAttr(title)}"`)
3448
if (width) parts.push(`width="${escapeAttr(String(width))}"`)
3549
if (height) parts.push(`height="${escapeAttr(String(height))}"`)
36-
return `<img ${parts.join(' ')}>`
50+
image = `<img ${parts.join(' ')}>`
51+
} else {
52+
const titlePart = title ? ` "${title}"` : ''
53+
image = `![${alt}](${src}${titlePart})`
54+
}
55+
if (!href) return image
56+
const hrefTitlePart = hrefTitle ? ` "${hrefTitle}"` : ''
57+
return `[${image}](${href}${hrefTitlePart})`
58+
}
59+
60+
interface MarkdownImageToken {
61+
/** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */
62+
src?: string
63+
alt?: string
64+
title?: string | null
65+
/** Built-in image token holds the source URL here; our linked token holds the link target. */
66+
href?: string
67+
hrefTitle?: string | null
68+
/** Built-in image token holds the alt text here. */
69+
text?: string
70+
}
71+
72+
/** Map both the built-in image token and our linked-image token onto the image node's attributes. */
73+
function parseImageToken(token: MarkdownImageToken): JSONContent {
74+
const isLinked = typeof token.src === 'string'
75+
return {
76+
type: 'image',
77+
attrs: isLinked
78+
? {
79+
src: token.src,
80+
alt: token.alt ?? '',
81+
title: token.title ?? null,
82+
href: token.href ?? null,
83+
hrefTitle: token.hrefTitle ?? null,
84+
}
85+
: {
86+
src: token.href ?? '',
87+
alt: token.text ?? '',
88+
title: token.title ?? null,
89+
href: null,
90+
hrefTitle: null,
91+
},
3792
}
38-
const titlePart = title ? ` "${title}"` : ''
39-
return `![${alt}](${src}${titlePart})`
4093
}
4194

4295
const widthAttr = {
@@ -53,14 +106,44 @@ const heightAttr = {
53106
attributes.height ? { height: String(attributes.height) } : {},
54107
}
55108

109+
/** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
110+
const hrefAttr = { default: null, rendered: false }
111+
const hrefTitleAttr = { default: null, rendered: false }
112+
56113
/**
57-
* Image node that carries optional `width`/`height` and serializes them as an HTML `<img>` tag.
58-
* Shared by the headless round-trip path (no node view) and the live {@link ResizableImage}.
114+
* Image node that carries optional `width`/`height` (serialized as an HTML `<img>` tag) and an
115+
* optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless
116+
* round-trip path (no node view) and the live {@link ResizableImage}.
59117
*/
60118
export const MarkdownImage = Image.extend({
61119
addAttributes() {
62-
return { ...this.parent?.(), width: widthAttr, height: heightAttr }
120+
return {
121+
...this.parent?.(),
122+
width: widthAttr,
123+
height: heightAttr,
124+
href: hrefAttr,
125+
hrefTitle: hrefTitleAttr,
126+
}
63127
},
128+
markdownTokenizer: {
129+
name: 'image',
130+
level: 'inline',
131+
start: (src: string) => src.indexOf('[!['),
132+
tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => {
133+
const match = LINKED_IMAGE_RE.exec(src)
134+
if (!match) return undefined
135+
return {
136+
type: 'image',
137+
raw: match[0],
138+
alt: match[1] ?? '',
139+
src: match[2],
140+
title: match[3] ?? null,
141+
href: match[4],
142+
hrefTitle: match[5] ?? null,
143+
}
144+
},
145+
},
146+
parseMarkdown: parseImageToken,
64147
renderMarkdown: imageMarkdown,
65148
})
66149

@@ -72,7 +155,13 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP
72155
const imageRef = useRef<HTMLImageElement>(null)
73156
const dragAbortRef = useRef<AbortController | null>(null)
74157
const [dragging, setDragging] = useState(false)
75-
const attrs = node.attrs as { src?: string; alt?: string; title?: string; width?: string | null }
158+
const attrs = node.attrs as {
159+
src?: string
160+
alt?: string
161+
title?: string
162+
width?: string | null
163+
href?: string | null
164+
}
76165

77166
useEffect(() => () => dragAbortRef.current?.abort(), [])
78167

@@ -110,17 +199,31 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP
110199
? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width }
111200
: undefined
112201

202+
const image = (
203+
<img
204+
ref={imageRef}
205+
src={attrs.src}
206+
alt={attrs.alt ?? ''}
207+
title={attrs.title ?? undefined}
208+
// The image itself is the drag handle — grab anywhere on it to reorder. (The node view's
209+
// wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
210+
// the resize button sits outside this element, so it keeps its own pointer behavior.)
211+
draggable
212+
data-drag-handle
213+
style={widthStyle}
214+
className='block max-w-full cursor-grab rounded-lg border border-[var(--border)]'
215+
/>
216+
)
217+
113218
return (
114219
<NodeViewWrapper className='relative my-4 inline-block leading-none'>
115-
<img
116-
ref={imageRef}
117-
src={attrs.src}
118-
alt={attrs.alt ?? ''}
119-
title={attrs.title ?? undefined}
120-
draggable={false}
121-
style={widthStyle}
122-
className='block max-w-full rounded-lg border border-[var(--border)]'
123-
/>
220+
{attrs.href ? (
221+
<a href={attrs.href} rel='noopener noreferrer' className='block'>
222+
{image}
223+
</a>
224+
) : (
225+
image
226+
)}
124227
{(selected || dragging) && (
125228
<button
126229
type='button'
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Typing a markdown link `[text](url)` should convert to a real link mark on the closing `)`.
5+
* Input rules only fire on real text input, so these drive the editor's `handleTextInput` path
6+
* (NOT `insertContent`, which bypasses input rules).
7+
*/
8+
import { Editor } from '@tiptap/core'
9+
import { afterEach, describe, expect, it } from 'vitest'
10+
import { createMarkdownContentExtensions } from './extensions'
11+
12+
let editor: Editor | null = null
13+
14+
afterEach(() => {
15+
editor?.destroy()
16+
editor = null
17+
})
18+
19+
function mount(): Editor {
20+
return new Editor({ extensions: createMarkdownContentExtensions() })
21+
}
22+
23+
/** Type `prefix` (no input rules), then simulate typing the final char so the input rule fires. */
24+
function typeWithFinalChar(ed: Editor, prefix: string, finalChar: string): boolean {
25+
ed.commands.setContent('', { contentType: 'markdown' })
26+
ed.commands.insertContent(prefix)
27+
const pos = ed.state.selection.from
28+
return ed.view.someProp('handleTextInput', (fn) => fn(ed.view, pos, pos, finalChar)) === true
29+
}
30+
31+
describe('typed markdown link input rule', () => {
32+
it('converts [text](url) to a link mark on the closing paren', () => {
33+
editor = mount()
34+
typeWithFinalChar(editor, '[hi](https://example.com', ')')
35+
const json = JSON.stringify(editor.getJSON())
36+
expect(json).toContain('"type":"link"')
37+
expect(json).toContain('"href":"https://example.com"')
38+
expect(editor.getText()).toBe('hi')
39+
})
40+
41+
it('normalizes a bare domain to https (parity with paste)', () => {
42+
editor = mount()
43+
typeWithFinalChar(editor, '[site](www.example.com', ')')
44+
expect(JSON.stringify(editor.getJSON())).toContain('"href":"https://www.example.com"')
45+
})
46+
47+
it('preserves a link title', () => {
48+
editor = mount()
49+
typeWithFinalChar(editor, '[t](https://e.com "the title"', ')')
50+
const json = JSON.stringify(editor.getJSON())
51+
expect(json).toContain('"href":"https://e.com"')
52+
expect(json).toContain('"title":"the title"')
53+
})
54+
55+
it('refuses an unsafe scheme (leaves it literal)', () => {
56+
editor = mount()
57+
typeWithFinalChar(editor, '[x](javascript:alert(1)', ')')
58+
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
59+
})
60+
61+
it('does not fire inside a code block', () => {
62+
editor = mount()
63+
editor.commands.setContent('```\n\n```', { contentType: 'markdown' })
64+
editor.commands.setTextSelection(2)
65+
const pos = editor.state.selection.from
66+
editor.commands.insertContent('[x](https://e.com')
67+
editor.view.someProp('handleTextInput', (fn) => fn(editor!.view, pos, pos, ')'))
68+
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
69+
})
70+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Extension, InputRule } from '@tiptap/core'
2+
import { normalizeLinkHref } from './markdown-fidelity'
3+
4+
/**
5+
* Typed markdown link: `[text](url)` or `[text](url "title")`, completed by the closing `)`. The URL
6+
* is space-free (markdown requires `<url>` for spaces, which this intentionally skips). StarterKit's
7+
* Link ships no input rule — only paste/autolink — so without this, typed link syntax stays literal.
8+
*/
9+
const LINK_INPUT_RULE = /\[([^\]]+)]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/
10+
11+
/**
12+
* Converts a typed markdown link into a real link mark on the closing `)`. The visible text is the
13+
* first capture group (so `markInputRule`, which keeps the *last* group, can't express this); the
14+
* href comes from the second group, normalized through {@link normalizeLinkHref} so a bare domain
15+
* gets `https://` and an unsafe scheme (`javascript:`) is refused (left as literal text). Skipped
16+
* inside code blocks, where the brackets are literal source.
17+
*/
18+
export const MarkdownLinkInputRule = Extension.create({
19+
name: 'markdownLinkInputRule',
20+
21+
addInputRules() {
22+
return [
23+
new InputRule({
24+
find: LINK_INPUT_RULE,
25+
handler: ({ state, range, match }) => {
26+
if (state.selection.$from.parent.type.spec.code) return null
27+
const linkType = state.schema.marks.link
28+
if (!linkType) return null
29+
const [fullMatch, text, rawHref, title] = match
30+
const href = normalizeLinkHref(rawHref ?? '')
31+
if (!href || !text) return null
32+
33+
const { tr } = state
34+
const textStart = range.from + fullMatch.indexOf(text)
35+
const textEnd = textStart + text.length
36+
if (textEnd < range.to) tr.delete(textEnd, range.to)
37+
if (textStart > range.from) tr.delete(range.from, textStart)
38+
const markEnd = range.from + text.length
39+
tr.addMark(range.from, markEnd, linkType.create({ href, title: title || null }))
40+
tr.removeStoredMark(linkType)
41+
},
42+
}),
43+
]
44+
},
45+
})

0 commit comments

Comments
 (0)