Skip to content

Commit 2ba0b58

Browse files
authored
fix(rich-markdown-editor): reliable image selection + serialization/paste polish (#5590)
* fix(rich-markdown-editor): reliable image selection + resize and broken-image polish - Reactive editability. The editor runs with shouldRerenderOnTransaction:false, so a node view that read editor.isEditable once at render kept a stale value after setEditable() toggled (e.g. an agent stream settling into the doc), leaving a pasted image showing read-only affordances and code blocks stuck on their read-only label until a full refresh. A shared useEditorEditable hook subscribes to the editor's update/transaction events so both node views track editability reactively. - Deterministic click-to-select. A handleClickOn plugin sets the image's NodeSelection on a plain click so selecting never depends on ProseMirror's click-vs-drag arbitration; grab-anywhere drag-reorder is kept, and modified clicks (Cmd/Ctrl to follow a linked badge) fall through. - Resize commits once. The width previews in local state during the drag and commits to the node once on pointer-up (or pointer-cancel), so a resize is a single undo step and an interrupted drag isn't lost. - Broken-image placeholder. A src that fails to load renders as a visible box with its alt text and stays selectable, instead of collapsing to a bare broken-icon. * fix(rich-markdown-editor): keep bare URLs and autolinks bare on serialize The normalizing serializer rewrote a bare URL or <url>/<email> autolink to [url](url) / [a@b.com](mailto:a@b.com) on every save, churning every README's links. postProcessSerializedMarkdown now collapses a link back to its bare form when the visible text already equals the destination (a plain http(s) URL, or an email behind mailto:) — GFM re-autolinks it, so the round-trip is identical with a far quieter diff. Titled links, explicit links, and any link inside a fenced/inline code region are left untouched. Idempotent. * feat(rich-markdown-editor): linkify a selection when a URL is pasted over it Pasting a single URL (or a bare www. host / email) over a non-empty text selection within one block now wraps the selection in a link, keeping the visible text. www. gets https://, an email gets mailto:, and the href is scheme-sanitized (javascript:/data: rejected; mailto: requires a real user@host address). Collapsed carets, cross-block selections, multi-word pastes, node selections, and code contexts fall through to normal paste. * chore(rich-markdown-editor): drop useless String.raw in highlight.ts biome 2.0's noUselessStringRaw flags HIGHLIGHT_BODY — its pattern has no escape sequences, so String.raw is equivalent to a plain template literal (byte-identical value; interpolated into the other String.raw regexes unchanged). Pre-existing on staging; the repo-wide lint gate blocks CI on it.
1 parent 9ee499e commit 2ba0b58

8 files changed

Lines changed: 255 additions & 22 deletions

File tree

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap
1515
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
1616
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
1717
import { detectLanguage } from './detect-language'
18+
import { useEditorEditable } from './use-editor-editable'
1819

1920
const PLAIN = 'plain'
2021
const MERMAID = 'mermaid'
@@ -59,6 +60,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
5960
const [editingInline, setEditingInline] = useState(false)
6061
const [peekSource, setPeekSource] = useState(false)
6162
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
63+
const editable = useEditorEditable(editor)
6264

6365
const explicitLanguage = node.attrs.language as string | null
6466
const text = node.textContent
@@ -68,7 +70,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
6870
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
6971
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
7072
useEffect(() => {
71-
if (!isMermaid || !editor.isEditable) {
73+
if (!isMermaid || !editable) {
7274
setEditingInline(false)
7375
return
7476
}
@@ -91,9 +93,9 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
9193
editor.off('focus', sync)
9294
editor.off('blur', sync)
9395
}
94-
}, [editor, getPos, isMermaid])
96+
}, [editor, getPos, isMermaid, editable])
9597

96-
const showSource = editor.isEditable ? editingInline : peekSource
98+
const showSource = editable ? editingInline : peekSource
9799
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
98100

99101
// Skip language detection on the mermaid path — the picker/label never render there.
@@ -104,7 +106,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
104106
'Plain text'
105107

106108
const toggleSource = () => {
107-
if (!editor.isEditable) {
109+
if (!editable) {
108110
setPeekSource((value) => !value)
109111
return
110112
}
@@ -144,7 +146,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
144146
</button>
145147
)}
146148
{!isMermaid &&
147-
(editor.isEditable ? (
149+
(editable ? (
148150
// Editable: a language picker. Read-only: a static label — selecting a language calls
149151
// updateAttributes, which would mutate a doc that must not change.
150152
<DropdownMenu onOpenChange={setMenuOpen}>
@@ -179,7 +181,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
179181
{label}
180182
</span>
181183
))}
182-
{!isMermaid && editor.isEditable && (
184+
{!isMermaid && editable && (
183185
<button
184186
type='button'
185187
aria-label='Toggle line wrap'

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Plugin } from '@tiptap/pm/state'
1313
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
1414
* the closing `==` still terminates the run.
1515
*/
16-
const HIGHLIGHT_BODY = String.raw`(?:[^=]|=(?!=))+?`
16+
const HIGHLIGHT_BODY = `(?:[^=]|=(?!=))+?`
1717
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
1818
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
1919
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)

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

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from 'react'
22
import { cn } from '@sim/emcn'
33
import type { JSONContent } from '@tiptap/core'
44
import { Image } from '@tiptap/extension-image'
5+
import { NodeSelection, Plugin } from '@tiptap/pm/state'
56
import type { ReactNodeViewProps } from '@tiptap/react'
67
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
78
import { useFileContentSource } from '@/hooks/use-file-content-source'
89
import { normalizeLinkHref } from './markdown-fidelity'
10+
import { useEditorEditable } from './use-editor-editable'
911

1012
const MIN_WIDTH = 64
1113

@@ -168,7 +170,12 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
168170
const source = useFileContentSource()
169171
const imageRef = useRef<HTMLImageElement>(null)
170172
const dragAbortRef = useRef<AbortController | null>(null)
173+
const dragWidthRef = useRef<number | null>(null)
171174
const [dragging, setDragging] = useState(false)
175+
/** Live width during a resize drag; kept out of the doc so the whole resize is one undo step. */
176+
const [dragWidth, setDragWidth] = useState<number | null>(null)
177+
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
178+
const [failed, setFailed] = useState(false)
172179
const attrs = node.attrs as {
173180
src?: string
174181
alt?: string
@@ -178,6 +185,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
178185
}
179186

180187
useEffect(() => () => dragAbortRef.current?.abort(), [])
188+
useEffect(() => setFailed(false), [attrs.src])
181189

182190
const startResize = (event: React.PointerEvent) => {
183191
event.preventDefault()
@@ -195,31 +203,42 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
195203
'pointermove',
196204
(move) => {
197205
const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX)))
198-
updateAttributes({ width: String(next) })
199-
},
200-
{ signal }
201-
)
202-
window.addEventListener(
203-
'pointerup',
204-
() => {
205-
setDragging(false)
206-
controller.abort()
206+
dragWidthRef.current = next
207+
setDragWidth(next)
207208
},
208209
{ signal }
209210
)
211+
const finish = () => {
212+
const finalWidth = dragWidthRef.current
213+
setDragging(false)
214+
setDragWidth(null)
215+
dragWidthRef.current = null
216+
controller.abort()
217+
if (finalWidth !== null) updateAttributes({ width: String(finalWidth) })
218+
}
219+
window.addEventListener('pointerup', finish, { signal })
220+
window.addEventListener('pointercancel', finish, { signal })
210221
}
211222

212-
const widthStyle = attrs.width
213-
? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width }
223+
const committedWidth = attrs.width
224+
? /^\d+$/.test(attrs.width)
225+
? `${attrs.width}px`
226+
: attrs.width
214227
: undefined
228+
const widthStyle =
229+
dragWidth !== null
230+
? { width: `${dragWidth}px` }
231+
: committedWidth
232+
? { width: committedWidth }
233+
: undefined
215234

216235
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
217236
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
218237
const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '')
219238

220239
// Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move,
221240
// mutating a doc that must not change. The image still renders (and follows its link on click).
222-
const editable = editor.isEditable
241+
const editable = useEditorEditable(editor)
223242

224243
const image = (
225244
<img
@@ -233,9 +252,13 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
233252
draggable={editable}
234253
data-drag-handle={editable ? '' : undefined}
235254
style={widthStyle}
255+
onError={() => setFailed(true)}
256+
onLoad={() => setFailed(false)}
236257
className={cn(
237258
'block max-w-full rounded-lg border border-[var(--border)]',
238-
editable && 'cursor-grab'
259+
editable && 'cursor-grab',
260+
failed &&
261+
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
239262
)}
240263
/>
241264
)
@@ -275,4 +298,28 @@ export const ResizableImage = MarkdownImage.extend({
275298
addNodeView() {
276299
return ReactNodeViewRenderer(ResizableImageView)
277300
},
301+
/**
302+
* Guarantee a plain click on the image forms a node selection. The image body is also a native drag
303+
* source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on
304+
* drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes
305+
* with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526).
306+
* Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and
307+
* modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's
308+
* `handleClick` / default behavior.
309+
*/
310+
addProseMirrorPlugins() {
311+
const nodeName = this.name
312+
return [
313+
new Plugin({
314+
props: {
315+
handleClickOn(view, _pos, node, nodePos, event) {
316+
if (!view.editable || node.type.name !== nodeName) return false
317+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
318+
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
319+
return true
320+
},
321+
},
322+
}),
323+
]
324+
},
278325
})

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@ const BOM = '\uFEFF'
88
const FRONTMATTER_REGEX = /^---\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n)*/
99
const ESCAPED_CALLOUT_REGEX = /^(\s*>(?:\s*>)*\s*)\\\[!([A-Za-z]+)\\\]/gm
1010

11+
/**
12+
* Alternates a code region (fenced block or inline span \u2014 never rewritten) with an inline link whose
13+
* destination has no title and isn't angle-bracketed. The code branch is listed first so a link inside
14+
* code is consumed as code and left untouched. The destination stops at `)` / whitespace, so a link
15+
* carrying a title (`[x](url "t")`) never matches and is preserved verbatim.
16+
*/
17+
const CODE_OR_PLAIN_LINK_REGEX =
18+
/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`)|\[([^\]]+)]\(([^)\s<>]+)\)/g
19+
const HTTP_URL_REGEX = /^https?:\/\/\S+$/i
20+
21+
/**
22+
* Collapses an autolinked destination back to its bare form: our normalizing serializer rewrites a bare
23+
* URL or `<url>` autolink to `[url](url)` and a bare email to `[a@b.com](mailto:a@b.com)`, which churns
24+
* every README's links into explicit-link syntax on the first save. When the visible text already equals
25+
* the destination (a plain `http(s)` URL, or an email behind `mailto:`), GFM re-autolinks the bare form,
26+
* so emitting it round-trips identically with a far quieter diff. Links inside code and titled links are
27+
* left untouched (see {@link CODE_OR_PLAIN_LINK_REGEX}).
28+
*/
29+
function collapseAutolinkedUrls(markdown: string): string {
30+
return markdown.replace(CODE_OR_PLAIN_LINK_REGEX, (match, code, text, href) => {
31+
if (code) return code
32+
if (text === href && HTTP_URL_REGEX.test(href)) return href
33+
if (href === `mailto:${text}`) return text
34+
return match
35+
})
36+
}
37+
1138
export interface SplitMarkdown {
1239
/** Out-of-band leading prefix (a BOM and/or the frontmatter block), byte-exact, or `''`. */
1340
frontmatter: string
@@ -87,5 +114,8 @@ export function normalizeLinkHref(href: string): string {
87114
* begins with whitespace.
88115
*/
89116
export function postProcessSerializedMarkdown(markdown: string): string {
90-
return markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]').replace(/\n+$/, '\n')
117+
return collapseAutolinkedUrls(markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')).replace(
118+
/\n+$/,
119+
'\n'
120+
)
91121
}

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,63 @@ describe('markdown paste', () => {
255255
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
256256
})
257257
})
258+
259+
describe('linkify a selection on URL paste', () => {
260+
function linkify(
261+
pasted: string,
262+
from = 1,
263+
to = 10
264+
): { handled: boolean; href?: string; text: string } {
265+
editor = mount()
266+
editor.commands.setContent('select me here', { contentType: 'markdown' })
267+
editor.commands.setTextSelection({ from, to })
268+
const handled = paste(editor, pasted)
269+
return {
270+
handled,
271+
href: JSON.stringify(editor.getJSON()).match(/"href":"([^"]+)"/)?.[1],
272+
text: editor.getText(),
273+
}
274+
}
275+
276+
it('wraps a non-empty text selection in a link when a URL is pasted (keeping the text)', () => {
277+
const r = linkify('https://sim.ai')
278+
expect(r.handled).toBe(true)
279+
expect(r.href).toBe('https://sim.ai')
280+
expect(r.text).toBe('select me here')
281+
})
282+
283+
it('prepends https:// to a bare www host and mailto: to a bare email', () => {
284+
expect(linkify('www.sim.ai').href).toBe('https://www.sim.ai')
285+
expect(linkify('a@b.com').href).toBe('mailto:a@b.com')
286+
})
287+
288+
it('does not linkify a collapsed caret (empty selection)', () => {
289+
const r = linkify('https://sim.ai', 5, 5)
290+
expect(r.handled).toBe(false)
291+
})
292+
293+
it('does not linkify a multi-word paste over a selection', () => {
294+
expect(linkify('not a url just words').handled).toBe(false)
295+
})
296+
297+
it('does not linkify an unsafe javascript: url', () => {
298+
const r = linkify('javascript:alert(1)')
299+
expect(r.handled).toBe(false)
300+
expect(r.href).toBeUndefined()
301+
})
302+
303+
it('links a real mailto: but not a crafted mailto: payload', () => {
304+
expect(linkify('mailto:a@b.com').href).toBe('mailto:a@b.com')
305+
const crafted = linkify('mailto:javascript:alert(1)')
306+
expect(crafted.handled).toBe(false)
307+
expect(crafted.href).toBeUndefined()
308+
})
309+
310+
it('does not linkify a selection spanning multiple blocks', () => {
311+
editor = mount()
312+
editor.commands.setContent('alpha\n\nbeta', { contentType: 'markdown' })
313+
editor.commands.setTextSelection({ from: 3, to: 9 })
314+
expect(paste(editor, 'https://sim.ai')).toBe(false)
315+
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
316+
})
317+
})

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
import { Extension } from '@tiptap/core'
22
import { Plugin } from '@tiptap/pm/state'
3+
import { normalizeLinkHref } from './markdown-fidelity'
34
import { parseMarkdownToDoc } from './markdown-parse'
45

6+
/**
7+
* A single link the paste can wrap a selection in: an http(s) URL, a `mailto:` to a real address, a bare
8+
* `www.` host, or a bare email. `mailto:` requires an actual `user@host.tld` payload so a crafted value
9+
* like `mailto:javascript:…` (no `@`) never matches and falls through to a normal paste.
10+
*/
11+
const HTTP_URL = /^https?:\/\/\S+$/i
12+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
13+
const MAILTO_URL = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i
14+
const BARE_WWW = /^www\.\S+\.\S+$/i
15+
16+
/**
17+
* If pasted text is a single link, return the href to wrap a selection in — `www.` gets `https://`, a
18+
* bare email gets `mailto:`. Returns null for anything else (a multi-word or non-URL paste falls through
19+
* to normal insertion). The caller still runs the result through `normalizeLinkHref` for scheme safety.
20+
*/
21+
function pastedLinkHref(text: string): string | null {
22+
if (HTTP_URL.test(text) || MAILTO_URL.test(text)) return text
23+
if (BARE_WWW.test(text)) return `https://${text}`
24+
if (EMAIL.test(text)) return `mailto:${text}`
25+
return null
26+
}
27+
528
/**
629
* Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
730
* list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
@@ -143,11 +166,21 @@ export const MarkdownPaste = Extension.create({
143166
new Plugin({
144167
props: {
145168
transformPastedHTML: (html) => stripNonContentHtml(html),
146-
handlePaste: (_view, event) => {
169+
handlePaste: (view, event) => {
147170
if (!editor.isEditable) return false
148171
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
149172
const text = event.clipboardData?.getData('text/plain')
150173
if (!text) return false
174+
const { selection } = view.state
175+
if (
176+
!selection.empty &&
177+
selection.$from.sameParent(selection.$to) &&
178+
selection.$from.parent.inlineContent
179+
) {
180+
const href = pastedLinkHref(text.trim())
181+
const safeHref = href ? normalizeLinkHref(href) : ''
182+
if (safeHref) return editor.commands.setLink({ href: safeHref })
183+
}
151184
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
152185
if (language) {
153186
return editor.commands.insertContent({

0 commit comments

Comments
 (0)