Skip to content

Commit f477faa

Browse files
authored
fix(rich-markdown-editor): make drag-reorder of an image actually move it, on real browser payloads (#5617)
* fix(rich-markdown-editor): make drag-reorder of an image actually move it, on real browser payloads Reported on latest staging (deploy verified via CodePipeline): dragging an image duplicates it instead of moving it, and a click with a few px of hand jitter — which the draggable <img> turns into a native drag+drop-on-self — destroys the selection and duplicates too, reading as "I can't select this image anymore". Reproduced in real Chromium with real mouse input (micro-drag becomes dragstart, never click) and with the real drag payload shape. Two compounding root causes, both empirically pinned: - TipTap's node-view dragstart bypasses ProseMirror's drag serialization entirely (verified in @tiptap/core source: onDragStart only sets a drag image and NodeSelects the node — no PM text/html, no view.dragging). What the drop actually carries is the BROWSER's native enrichment: an image File plus text/html whose <img src> is the ABSOLUTE rendered URL. - Both hosted-image recognizers (extractEmbeddedFileRef and isInlineRouteSrc) reject absolute URLs, so the #5573 skip-check never matched on real drags: the drop fell into the upload branch (duplicate; original never moves). Falling through to PM instead would be no better: with view.dragging unset its default drop PARSES the html into a copy — persisting the display-layer src that share/export tracking don't recognize — and never deletes the original. Fix, at the mechanism level: - Normalize clipboard/dataTransfer srcs origin-relative before comparing (toSameOriginPath), keyed off window.location.origin deliberately rather than getBaseUrl(): the browser serializes against the origin the page is ACTUALLY viewed on, which legitimately diverges from the configured NEXT_PUBLIC_APP_URL (localhost dev, previews, apex-vs-www). Cross-origin srcs are never treated as ours. Applied to isInlineRouteSrc, hasHostedImageHtml, and findHostedImageAttrs (the paste-clone path had the same absolute-URL gap for browser-native "Copy Image"). - handleDrop performs the internal move itself when the drop's html references the currently-selected image node (htmlReferencesSrc — TipTap's dragstart guarantees that selection): same delete → map → insert shape as ProseMirror's own move, ending NodeSelected. Drop-on-self is a no-op that keeps the ring — which is what a jittery click now resolves to. Empirical before/after (real-Chromium harness driving the real editor + engine): pre-fix the drag leaves the original in place and uploads a duplicate; post-fix the node moves exactly once, nothing uploads, and the moved image stays selected. Paste-clone verified for both relative (PM copy) and absolute (native Copy Image) payloads. 604 unit tests pass including 11 new ones for the origin-aware helpers. * fix(rich-markdown-editor): no-op invalid drop points, match external-image identity by absolute URL Greptile round 1, both real: - dropPoint can return null (no valid insertion point); the raw coords.pos fallback could make tr.insert throw — PM's own null-fallback is only safe because it uses the forgiving replaceRangeWith. A null drop point is now a handled no-op: the node stays put, still selected. - A doc image with a cross-origin src (README badge, CDN image) failed the same-origin identity check, so drag-reordering IT still fell into the duplicate path. htmlReferencesSrc now compares full ABSOLUTE URLs — identity is the question there, not hosted-by-us membership — while the hosted-recognition helpers stay same-origin-scoped. New regression test fails pre-fix. Also folded the remaining inline comments into the handleDrop TSDoc and gave IMG_SRC_RE / INLINE_ROUTE_QUERY_KEYS proper TSDoc (production diff is now TSDoc-only).
1 parent a2f868c commit f477faa

3 files changed

Lines changed: 252 additions & 28 deletions

File tree

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
extractImgSrcs,
1515
findHostedImageAttrs,
1616
hasHostedImageHtml,
17+
htmlReferencesSrc,
1718
isInlineRouteSrc,
1819
shouldSkipFileUpload,
20+
toSameOriginPath,
1921
} from './image-paste'
2022

2123
// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
@@ -288,3 +290,97 @@ describe('findHostedImageAttrs', () => {
288290
expect(originalAlt).toBe('photo')
289291
})
290292
})
293+
294+
describe('origin-aware src normalization (browser-native drag/copy enrichment uses ABSOLUTE urls)', () => {
295+
const ORIGIN = 'https://www.staging.sim.ai'
296+
297+
it('toSameOriginPath: relative passes through; same-origin absolute → path+query; cross-origin → null', () => {
298+
expect(toSameOriginPath('/api/files/view/wf_a', ORIGIN)).toBe('/api/files/view/wf_a')
299+
expect(toSameOriginPath(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
300+
'/api/workspaces/ws-1/files/inline?fileId=wf_a'
301+
)
302+
expect(toSameOriginPath('https://evil.example.com/api/files/view/wf_a', ORIGIN)).toBeNull()
303+
})
304+
305+
it('isInlineRouteSrc accepts the same-origin ABSOLUTE inline route (the real dragged-img src on staging)', () => {
306+
expect(isInlineRouteSrc(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
307+
true
308+
)
309+
expect(
310+
isInlineRouteSrc(
311+
'https://evil.example.com/api/workspaces/ws-1/files/inline?fileId=wf_a',
312+
ORIGIN
313+
)
314+
).toBe(false)
315+
})
316+
317+
it('hasHostedImageHtml matches absolute same-origin srcs and rejects cross-origin ones', () => {
318+
const isHosted = (src: string) => extractEmbeddedFileRef(src) !== null
319+
expect(hasHostedImageHtml(`<img src="${ORIGIN}/api/files/view/wf_a">`, isHosted, ORIGIN)).toBe(
320+
true
321+
)
322+
expect(
323+
hasHostedImageHtml(
324+
`<img src="${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a">`,
325+
isHosted,
326+
ORIGIN
327+
)
328+
).toBe(true)
329+
expect(
330+
hasHostedImageHtml(
331+
'<img src="https://evil.example.com/api/files/view/wf_a">',
332+
isHosted,
333+
ORIGIN
334+
)
335+
).toBe(false)
336+
})
337+
338+
it('findHostedImageAttrs matches when the clipboard html carries the absolute rendered url', () => {
339+
const ws = createWorkspaceFileContentSource('ws-1')
340+
const editor = new Editor({
341+
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
342+
content: {
343+
type: 'doc',
344+
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_abc', alt: 'photo' } }],
345+
},
346+
})
347+
const absolute = `${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_abc`
348+
const match = findHostedImageAttrs(editor.state.doc, [absolute], ws.resolveImageSrc, ORIGIN)
349+
expect(match?.src).toBe('/api/files/view/wf_abc')
350+
})
351+
})
352+
353+
describe('htmlReferencesSrc (the "this drop is MY dragged image" check)', () => {
354+
const ORIGIN = 'https://www.staging.sim.ai'
355+
const RESOLVED = '/api/workspaces/ws-1/files/inline?fileId=wf_a'
356+
357+
it('true when the html img src is the absolute form of the resolved src', () => {
358+
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
359+
})
360+
361+
it('true for the relative form too (ProseMirror-serialized html)', () => {
362+
expect(htmlReferencesSrc(`<img src="${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
363+
})
364+
365+
// Identity must work for cross-origin srcs too: a doc's image may legitimately point at a CDN or
366+
// badge URL, and dragging THAT node to reorder it must still match — under the previous
367+
// same-origin-path comparison it fell into the duplicate-upload branch instead.
368+
it('matches an external (cross-origin) image against its own exact absolute url', () => {
369+
const EXTERNAL = 'https://cdn.example.com/badge.svg'
370+
expect(htmlReferencesSrc(`<img src="${EXTERNAL}">`, EXTERNAL, ORIGIN)).toBe(true)
371+
expect(
372+
htmlReferencesSrc('<img src="https://cdn.example.com/other.svg">', EXTERNAL, ORIGIN)
373+
).toBe(false)
374+
})
375+
376+
it('false for a different image, empty html, missing resolved src, or cross-origin', () => {
377+
expect(htmlReferencesSrc(`<img src="${ORIGIN}/api/other?fileId=wf_b">`, RESOLVED, ORIGIN)).toBe(
378+
false
379+
)
380+
expect(htmlReferencesSrc('', RESOLVED, ORIGIN)).toBe(false)
381+
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, undefined, ORIGIN)).toBe(false)
382+
expect(
383+
htmlReferencesSrc(`<img src="https://evil.example.com${RESOLVED}">`, RESOLVED, ORIGIN)
384+
).toBe(false)
385+
})
386+
})

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

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,27 +13,63 @@ export function extractImageFiles(transfer: DataTransfer | null): File[] {
1313
.filter((file): file is File => file !== null)
1414
}
1515

16-
// `src` may be double-quoted, single-quoted, or (validly) unquoted per the HTML spec — the browser's
17-
// own clipboard serialization always quotes it, but other producers of `text/html` are not obligated
18-
// to.
16+
/**
17+
* Matches `<img>` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per
18+
* the HTML spec — the browser's own clipboard serialization always quotes it, but other producers
19+
* of `text/html` are not obligated to.
20+
*/
1921
const IMG_SRC_RE = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
22+
23+
/** Query params under which the inline route addresses a workspace file. */
2024
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
2125

26+
/**
27+
* The page's own origin — clipboard/dataTransfer srcs on any other origin are never ours.
28+
* Deliberately `window.location.origin`, NOT `getBaseUrl()`: the browser serializes a dragged/copied
29+
* `<img>`'s URL against the origin the page is ACTUALLY being viewed on, which can legitimately
30+
* differ from the configured `NEXT_PUBLIC_APP_URL` (localhost dev against a shared env, preview
31+
* deploys, apex-vs-www) — comparing against the configured URL would silently fail on any such
32+
* origin, which is the exact bug class this normalization exists to fix.
33+
*/
34+
function runtimeOrigin(): string {
35+
return typeof window === 'undefined' ? '' : window.location.origin
36+
}
37+
38+
/**
39+
* Normalizes a clipboard/dataTransfer img `src` to an origin-relative `pathname?query`, or `null`
40+
* when it belongs to a different origin. The browser's NATIVE drag/copy enrichment (dragging or
41+
* "Copy Image" on a rendered `<img>`) serializes the ABSOLUTE resolved URL —
42+
* `https://host/api/workspaces/…/inline?…` — while everything the app compares against (persisted
43+
* refs, `resolveImageSrc` output) is origin-relative, so both must be brought into the same space
44+
* before comparing. A cross-origin src must never be treated as ours.
45+
*/
46+
export function toSameOriginPath(src: string, origin = runtimeOrigin()): string | null {
47+
try {
48+
const base = origin || 'http://placeholder'
49+
const parsed = new URL(src, base)
50+
if (parsed.origin !== base) return null
51+
return parsed.pathname + parsed.search
52+
} catch {
53+
return null
54+
}
55+
}
56+
2257
/**
2358
* True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
2459
* rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
2560
* or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
2661
* actually rendered into `<img src>`, and so what a same-page copy's `text/html` clipboard payload
27-
* actually contains NOT the raw stored reference `extractEmbeddedFileRef` (in
28-
* `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only matches the persisted `src` before
29-
* that rewrite. Checked separately from (rather than folded into) `extractEmbeddedFileRef` since that
30-
* helper is shared with server-side authorization/export code operating on persisted content, where
31-
* this display-only shape should never legitimately appear.
62+
* actually contains (absolute — see {@link toSameOriginPath}) — NOT the raw stored reference
63+
* `extractEmbeddedFileRef` (in `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only
64+
* matches the persisted `src` before that rewrite. Checked separately from (rather than folded into)
65+
* `extractEmbeddedFileRef` since that helper is shared with server-side authorization/export code
66+
* operating on persisted content, where this display-only shape should never legitimately appear.
3267
*/
33-
export function isInlineRouteSrc(src: string): boolean {
68+
export function isInlineRouteSrc(src: string, origin = runtimeOrigin()): boolean {
69+
const path = toSameOriginPath(src, origin)
70+
if (path === null) return false
3471
try {
35-
const parsed = new URL(src, 'http://placeholder')
36-
if (parsed.origin !== 'http://placeholder') return false
72+
const parsed = new URL(path, 'http://placeholder')
3773
if (!parsed.pathname.endsWith('/inline')) return false
3874
for (const key of parsed.searchParams.keys()) {
3975
if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
@@ -62,9 +98,53 @@ export function extractImgSrcs(html: string): string[] {
6298
* select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
6399
* `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
64100
* that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
101+
* Srcs are normalized origin-relative first ({@link toSameOriginPath}): ProseMirror's own clipboard
102+
* serialization writes the persisted relative src, but the BROWSER's native enrichment writes the
103+
* absolute resolved URL.
65104
*/
66-
export function hasHostedImageHtml(html: string, isHostedRef: (src: string) => boolean): boolean {
67-
return extractImgSrcs(html).some((src) => isHostedRef(src) || isInlineRouteSrc(src))
105+
export function hasHostedImageHtml(
106+
html: string,
107+
isHostedRef: (src: string) => boolean,
108+
origin = runtimeOrigin()
109+
): boolean {
110+
return extractImgSrcs(html).some((src) => {
111+
const path = toSameOriginPath(src, origin)
112+
return path !== null && (isHostedRef(path) || isInlineRouteSrc(path, origin))
113+
})
114+
}
115+
116+
/** Resolves `src` to a full absolute URL against `origin`, or `null` when unparseable. */
117+
function toAbsoluteUrl(src: string, origin: string): string | null {
118+
try {
119+
return new URL(src, origin || 'http://placeholder').href
120+
} catch {
121+
return null
122+
}
123+
}
124+
125+
/**
126+
* True when `html` contains an `<img>` whose src resolves to the same ABSOLUTE URL as
127+
* `resolvedSrc` (a `resolveImageSrc` output for a node already in this document). This is the
128+
* "that drop is MY dragged image" check for internal drag-reorder: TipTap's node-view dragstart
129+
* bypasses ProseMirror's serialization entirely (no PM `text/html`, no `view.dragging`) but
130+
* NodeSelects the dragged image, and the browser's native enrichment carries the absolute rendered
131+
* URL of exactly that node.
132+
*
133+
* Deliberately compares full absolute URLs rather than same-origin paths ({@link toSameOriginPath}):
134+
* identity is the question here, not hosted-by-us membership — a doc's image may legitimately have a
135+
* cross-origin `src` (a README badge, a CDN image), and dragging THAT node to reorder it must match
136+
* too, or it falls into the duplicate-upload path. Relative and absolute spellings of the same URL
137+
* still compare equal because both sides resolve against the page origin.
138+
*/
139+
export function htmlReferencesSrc(
140+
html: string,
141+
resolvedSrc: string | undefined,
142+
origin = runtimeOrigin()
143+
): boolean {
144+
if (!html || !resolvedSrc) return false
145+
const target = toAbsoluteUrl(resolvedSrc, origin)
146+
if (target === null) return false
147+
return extractImgSrcs(html).some((src) => toAbsoluteUrl(src, origin) === target)
68148
}
69149

70150
/**
@@ -108,20 +188,26 @@ interface DescendantsDoc {
108188
* the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
109189
* real persisted one. Inserting a node built from that display URL would bake it into the document,
110190
* which public share/export/referenced-by-doc tracking don't recognize (they only match the persisted
111-
* shape) — this lookup avoids ever constructing such a node in the first place.
191+
* shape) — this lookup avoids ever constructing such a node in the first place. Both sides of the
192+
* comparison are normalized origin-relative ({@link toSameOriginPath}): the clipboard html may carry
193+
* the browser's absolute URLs.
112194
*/
113195
export function findHostedImageAttrs(
114196
doc: DescendantsDoc,
115197
targetSrcs: string[],
116-
resolveImageSrc: (src: string | undefined) => string | undefined
198+
resolveImageSrc: (src: string | undefined) => string | undefined,
199+
origin = runtimeOrigin()
117200
): Record<string, unknown> | null {
118-
const targets = new Set(targetSrcs)
201+
const targets = new Set(
202+
targetSrcs.map((src) => toSameOriginPath(src, origin)).filter((p): p is string => p !== null)
203+
)
119204
let found: Record<string, unknown> | null = null
120205
doc.descendants((node) => {
121206
if (found) return false
122207
if (node.type.name === 'image') {
123208
const resolved = resolveImageSrc(node.attrs.src as string | undefined)
124-
if (resolved && targets.has(resolved)) {
209+
const resolvedPath = resolved ? toSameOriginPath(resolved, origin) : null
210+
if (resolvedPath && targets.has(resolvedPath)) {
125211
found = { ...node.attrs }
126212
return false
127213
}

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

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { memo, useEffect, useRef, useState } from 'react'
44
import { cn, toast } from '@sim/emcn'
55
import type { JSONContent } from '@tiptap/core'
6+
import { Fragment, Slice } from '@tiptap/pm/model'
7+
import { NodeSelection } from '@tiptap/pm/state'
8+
import { dropPoint } from '@tiptap/pm/transform'
69
import type { Editor } from '@tiptap/react'
710
import { EditorContent, useEditor } from '@tiptap/react'
811
import { useRouter } from 'next/navigation'
@@ -19,6 +22,7 @@ import {
1922
extractImageFiles,
2023
extractImgSrcs,
2124
findHostedImageAttrs,
25+
htmlReferencesSrc,
2226
shouldSkipFileUpload,
2327
} from './image-paste'
2428
import {
@@ -216,6 +220,8 @@ export function LoadedRichMarkdownEditor({
216220
const uploadFile = useUploadWorkspaceFile()
217221
const editorInstanceRef = useRef<Editor | null>(null)
218222
const source = useFileContentSource()
223+
const resolveImageSrcRef = useRef(source.resolveImageSrc)
224+
resolveImageSrcRef.current = source.resolveImageSrc
219225

220226
/**
221227
* The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position
@@ -367,22 +373,58 @@ export function LoadedRichMarkdownEditor({
367373
* the browser doesn't navigate away from the editor; internal text drags carry no files and fall
368374
* through to the default behavior.
369375
*
370-
* Dragging an existing image node to reorder it is also an internal drag, but the browser's
371-
* native drag-and-drop synthesizes an image `File` into `event.dataTransfer` for a dragged `<img>`
372-
* (the same mechanism that lets a user drag a web image out to their desktop) — indistinguishable
373-
* from a real external drop by `dataTransfer` files/items alone. When the accompanying `text/html`
374-
* shows it's a same-page drag of an already-hosted image, bail out and let ProseMirror's own
375-
* default move logic run — it relocates the actual existing node object (`dragging.node`), never
376-
* re-parsing html, so (unlike the paste case above) there's no display-vs-persisted src risk here.
377-
* Checked from `html`, not `view.dragging`, so a stale `dragging` flag (ProseMirror clears it up
378-
* to ~50ms late via `dragend` when a prior internal drag was dropped outside this view) can never
379-
* suppress the plain-file swallow guard below for an unrelated drop that happens to land in that
380-
* window — this only reacts to what THIS specific drop's `html` actually contains.
376+
* Drag-REORDER of an image node is the deceptive case. TipTap's node-view dragstart bypasses
377+
* ProseMirror's own drag serialization entirely — no PM `text/html`, no `view.dragging` — but it
378+
* DOES NodeSelect the dragged image; what the drop carries instead is the browser's native
379+
* enrichment for a dragged `<img>`: an image `File` plus `text/html` whose src is the ABSOLUTE
380+
* rendered URL of that exact node. So when the drop's html points at the currently-selected image
381+
* node ({@link htmlReferencesSrc}), this drop IS that node being moved, and the move must be
382+
* performed here: uploading would duplicate it (the original never moves), and falling through to
383+
* ProseMirror is no better — with `view.dragging` unset its default drop PARSES the html into a
384+
* copy (persisting the display-layer src, which share/export tracking don't recognize) and never
385+
* deletes the original. The gate accepts at most one file (not exactly one): some drag transports
386+
* (e.g. CDP-driven input) carry the html alone, and a genuinely external drop can never reference
387+
* the currently-selected node's own resolved src.
388+
*
389+
* The move itself is the same shape as ProseMirror's own: compute the drop point on the
390+
* pre-delete doc, delete the source, map the insert position through that delete. A null
391+
* `dropPoint` (no valid insertion point) is a handled no-op — the node stays put, still
392+
* selected — never a raw-position fallback, which `tr.insert` could throw on (PM's own null
393+
* fallback is only safe because it uses the forgiving `replaceRangeWith`).
394+
*
395+
* PM-serialized drags (a text selection spanning an image, dragged from a textblock) still reach
396+
* the `shouldSkipFileUpload` bail below: PM set `view.dragging` for those itself, so its default
397+
* move logic is correct there.
381398
*/
382399
handleDrop: (view, event) => {
383400
if (!view.editable) return false
384401
const images = extractImageFiles(event.dataTransfer)
385402
const html = event.dataTransfer?.getData('text/html') ?? ''
403+
const { selection } = view.state
404+
if (
405+
images.length <= 1 &&
406+
selection instanceof NodeSelection &&
407+
selection.node.type.name === 'image' &&
408+
htmlReferencesSrc(html, resolveImageSrcRef.current(selection.node.attrs.src))
409+
) {
410+
event.preventDefault()
411+
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
412+
if (!coords) return true
413+
const node = selection.node
414+
const tr = view.state.tr
415+
const insertPos = dropPoint(
416+
view.state.doc,
417+
coords.pos,
418+
new Slice(Fragment.from(node), 0, 0)
419+
)
420+
if (insertPos === null) return true
421+
tr.delete(selection.from, selection.to)
422+
const mapped = tr.mapping.map(insertPos)
423+
tr.insert(mapped, node)
424+
tr.setSelection(NodeSelection.create(tr.doc, mapped))
425+
view.dispatch(tr.scrollIntoView())
426+
return true
427+
}
386428
if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) {
387429
return false
388430
}

0 commit comments

Comments
 (0)