Skip to content

Commit 5de66db

Browse files
authored
fix(rich-markdown-editor): stop image dupe-uploads on drag/paste; fix link color under bold/italic/strikethrough/code (#5573)
* fix(rich-markdown-editor): stop drag/paste of an existing image from re-uploading a duplicate Dragging an image block to reorder it, or copy-pasting an already-hosted image within the editor, both re-uploaded the image as a brand-new file instead of reusing/moving the existing one: - Dragging an <img> to reorder it is a native HTML5 drag; browsers synthesize an image File into event.dataTransfer for it (the same mechanism that lets you drag a web image to your desktop), indistinguishable from a real external drop by dataTransfer contents alone. Our handleDrop treated that File as a genuinely new image, uploaded it, and inserted a duplicate node — while ProseMirror's own default move logic never got to run, so the original was left behind too. Fixed by checking `view.dragging` (ProseMirror's own signal that a drop follows a dragstart within this same view) and bailing out to let its default move logic run. - The same browser behavior applies to copy-paste: selecting a rendered <img> already on the page and pressing Cmd+C puts BOTH `text/html` (the real node, with its real hosted src) AND a synthesized image File onto the clipboard. Our handlePaste preferred the File, re-uploading and inserting a new node rather than cloning the original (silently dropping width/href/title in the process). Fixed by preferring the HTML sibling — via the existing extractEmbeddedFileRef helper — whenever it already names one of our own hosted files. * fix(rich-markdown-editor): fix link color lost under bold/italic/strikethrough/code strong/em/del/s/code each set their own explicit `color` for the plain (no-link) case. Nested inside a link, that explicit rule on the mark itself always wins over the color inherited from the ancestor <a> — an inherited value never beats an element's own explicit rule, regardless of how specific the ancestor's selector is. So an italic (or bold/struck-through/inline-code) link rendered in the mark's plain-text color instead of the link's blue. Adds an explicit `.rich-markdown-prose a <mark>` / `.rich-markdown-prose <mark> a` override, covering both DOM nesting orders since ProseMirror's mark order (and so which nests outside the other) depends on which was toggled first, not a fixed schema order. Only `color` is touched — each mark's own font-weight/font-style/text-decoration/background composes normally underneath. 24 new tests load the real, shipped CSS into jsdom and assert against getComputedStyle for every mark x both nesting directions x multi-mark stacks, plus regression guards that a mark with no link keeps its own color and a link elsewhere in the doc doesn't bleed color into unrelated text. Verified all 11 color-assertion tests fail against the pre-fix CSS. * fix(rich-markdown-editor): fix real-world gaps in the image dupe-upload fix Found while re-verifying the drag/paste dupe-upload fix against the actual rendered DOM before trusting it: - hasHostedImageHtml's predicate only recognized the *persisted* src shape (extractEmbeddedFileRef, e.g. /api/files/view/...). The DOM (and so a same-page copy's clipboard html) always contains resolveImageSrc's REWRITTEN inline-route URL instead (/api/workspaces/{id}/files/inline?key=.../?fileId=..., or the public-share equivalent) — a shape the fix never recognized, so it silently never engaged for a real browser copy. Added isInlineRouteSrc to also recognize it, verified end-to-end against the real resolveImageSrc output (not a hand-typed guess). - The img-src regex only matched quoted attribute values; an unquoted src (valid HTML) fell through to the old re-upload path instead of being recognized as hosted. - The paste bypass fired on ANY hosted image found in the html, even when the clipboard also offered additional image files — a genuinely mixed paste (the hosted image plus a separate new one) would have the new file silently dropped instead of uploaded. Narrowed to only bypass when exactly one image file is offered. - The drop bypass checked view.dragging unconditionally, including for the plain-file swallow branch below it — a stale view.dragging (ProseMirror clears it up to ~50ms late via dragend when a prior internal drag was dropped outside the view) could suppress swallowing an unrelated non-image file drop (e.g. a PDF) in that window, letting it fall through to the browser default. Gated on images.length > 0 so staleness can only ever affect the image-specific path it exists for. Extracted the paste/drop bypass decisions into shouldSkipPasteUpload/shouldSkipDropUpload so they're unit-testable without mounting the full editor component. * fix(rich-markdown-editor): fix the entire class of mark-color-vs-ambient-color bugs, not just links Auditing every explicit `color` in this file for the same failure mode (an element's own explicit color always wins over an inherited one, regardless of ancestor specificity) surfaced a second, previously-unfixed instance: bold/italic text inside an h6 heading showed the brighter --text-primary instead of h6's own intentionally dimmer --text-secondary, since strong/em hardcoded --text-primary as their default. strong/em's color was always redundant with the prose root's own default anyway — removing it entirely (matching the highlight/`mark` rule's existing `color: inherit` convention in this same file) lets normal CSS inheritance carry the correct color through from ANY ambient context, not just links: a link's blue, h6's dimmer tone, or any future colored container this file doesn't know about yet. `code` has the same redundant color, also removed. del/s genuinely need their own dimmer default (distinct from the prose default), so they keep an explicit color plus the link-color override — now the only mark that needs one, since strong/em/ code no longer set a competing color to override in the first place. 10 new tests cover every heading level x strong/em/code/del/s x link, including 2 that fail against the pre-fix CSS (bold/italic and inline-code inside h6 both incorrectly showed --text-primary). * fix(rich-markdown-editor): stop paste-clone from persisting the display-layer image URL Cursor caught a real correctness bug in the paste/drop dupe-upload fix: bypassing to the editor's DEFAULT html-based paste for an already-hosted image made it re-parse the clipboard html's <img src>, which is resolveImageSrc's REWRITTEN *display* URL, not the real persisted one — baking that display-only URL into the document. Public share, export, and referenced-by-doc tracking only recognize the persisted shape, so the pasted image would silently vanish from all three. Fixed by no longer letting default paste construct the node at all: findHostedImageAttrs walks the CURRENT doc for an existing image node whose *resolved* src matches the clipboard html's, and returns that node's real, persisted attrs (src, width, href, title — everything) to clone ourselves. Falls through to a normal upload (always correct, just occasionally redundant) if no match is found, rather than ever trusting the html's src directly. Also merged the paste- and drop-specific skip checks into one shouldSkipFileUpload, and switched the drop side off `view.dragging` entirely (Greptile: it can go briefly stale, up to ~50ms, when a prior internal drag was dropped outside the view, which could suppress upload of an unrelated new file dropped in that window) — now purely a function of what the current event's html/images actually contain, which the drop's own default move logic (relocating the real node, never re-parsing html) was never at risk from in the first place.
1 parent fac8ec0 commit 5de66db

5 files changed

Lines changed: 670 additions & 6 deletions

File tree

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

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { describe, expect, it } from 'vitest'
5-
import { extractImageFiles } from './image-paste'
4+
import { Editor } from '@tiptap/core'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
7+
import {
8+
createPublicFileContentSource,
9+
createWorkspaceFileContentSource,
10+
} from '@/hooks/use-file-content-source'
11+
import { createMarkdownEditorExtensions } from './editor-extensions'
12+
import {
13+
extractImageFiles,
14+
extractImgSrcs,
15+
findHostedImageAttrs,
16+
hasHostedImageHtml,
17+
isInlineRouteSrc,
18+
shouldSkipFileUpload,
19+
} from './image-paste'
20+
21+
// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
22+
beforeEach(() => {
23+
vi.stubGlobal(
24+
'ResizeObserver',
25+
class {
26+
observe() {}
27+
unobserve() {}
28+
disconnect() {}
29+
}
30+
)
31+
Element.prototype.scrollIntoView = vi.fn()
32+
document.elementFromPoint = vi.fn(() => null)
33+
})
634

735
function imageFile(name = 'shot.png'): File {
836
return new File([''], name, { type: 'image/png' })
@@ -54,3 +82,209 @@ describe('extractImageFiles', () => {
5482
expect(result).toEqual([])
5583
})
5684
})
85+
86+
describe('hasHostedImageHtml', () => {
87+
const isHosted = (src: string) => src.startsWith('/api/files/view/')
88+
89+
it('detects an <img> whose src is recognized as one of our own hosted files', () => {
90+
expect(hasHostedImageHtml('<img src="/api/files/view/wf_abc" alt="x">', isHosted)).toBe(true)
91+
})
92+
93+
it('is false when the html has no img, or the img src is not one of ours', () => {
94+
expect(hasHostedImageHtml('<p>hello</p>', isHosted)).toBe(false)
95+
expect(hasHostedImageHtml('<img src="https://other-site.com/photo.jpg">', isHosted)).toBe(false)
96+
expect(hasHostedImageHtml('', isHosted)).toBe(false)
97+
})
98+
99+
it('matches a hosted img among multiple candidates', () => {
100+
expect(
101+
hasHostedImageHtml(
102+
'<img src="https://other-site.com/a.png"><img src="/api/files/view/wf_abc">',
103+
isHosted
104+
)
105+
).toBe(true)
106+
})
107+
108+
// Regression: the browser doesn't put the node's persisted `attrs.src` (`/api/files/view/...`)
109+
// onto the clipboard when a rendered <img> is copied — it puts the actual DOM `src`, which is
110+
// `resolveImageSrc`'s REWRITTEN display URL (`/…/files/inline?key=…`/`?fileId=…`). A predicate
111+
// that only recognizes the persisted shape (as `extractEmbeddedFileRef` alone does) never matches
112+
// a real same-page copy, silently falling through to the re-upload path it exists to avoid.
113+
it('recognizes the real rendered <img src> end-to-end, not just the persisted reference shape', () => {
114+
const ws = createWorkspaceFileContentSource('ws-1')
115+
const renderedFromKey = ws.resolveImageSrc(
116+
'/api/files/serve/workspace/ws-1/1700000000000-deadbeefdeadbeef-photo.png'
117+
)
118+
const renderedFromFileId = ws.resolveImageSrc('/api/files/view/wf_abc')
119+
expect(renderedFromKey).toMatch(/^\/api\/workspaces\/ws-1\/files\/inline\?key=/)
120+
expect(renderedFromFileId).toBe('/api/workspaces/ws-1/files/inline?fileId=wf_abc')
121+
122+
// extractEmbeddedFileRef alone (the persisted-content recognizer) does NOT match either
123+
// rendered form — that's the exact gap isInlineRouteSrc closes.
124+
expect(extractEmbeddedFileRef(renderedFromKey as string)).toBeNull()
125+
expect(extractEmbeddedFileRef(renderedFromFileId as string)).toBeNull()
126+
127+
const isHostedReal = (src: string) => extractEmbeddedFileRef(src) !== null
128+
expect(hasHostedImageHtml(`<img src="${renderedFromKey}">`, isHostedReal)).toBe(true)
129+
expect(hasHostedImageHtml(`<img src="${renderedFromFileId}">`, isHostedReal)).toBe(true)
130+
})
131+
132+
it('recognizes the public-share inline route too', () => {
133+
const pub = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
134+
const rendered = pub.resolveImageSrc('/api/files/view/wf_abc')
135+
expect(rendered).toBe('/api/files/public/tok_1/inline?fileId=wf_abc')
136+
expect(hasHostedImageHtml(`<img src="${rendered}">`, () => false)).toBe(true)
137+
})
138+
139+
it('matches a valid unquoted src attribute (unquoted attribute values are valid HTML)', () => {
140+
expect(hasHostedImageHtml('<img src=/api/files/view/wf_abc>', isHosted)).toBe(true)
141+
expect(hasHostedImageHtml("<img alt='x' src=/api/files/view/wf_abc alt=y>", isHosted)).toBe(
142+
true
143+
)
144+
expect(hasHostedImageHtml('<img src=https://other-site.com/a.png>', isHosted)).toBe(false)
145+
})
146+
147+
it('matches single-quoted src attributes too', () => {
148+
expect(hasHostedImageHtml("<img src='/api/files/view/wf_abc'>", isHosted)).toBe(true)
149+
})
150+
})
151+
152+
describe('extractImgSrcs', () => {
153+
it('extracts every img src in document order, including duplicates', () => {
154+
expect(
155+
extractImgSrcs('<img src="/a.png"><p>text</p><img src="/b.png"><img src="/a.png">')
156+
).toEqual(['/a.png', '/b.png', '/a.png'])
157+
})
158+
159+
it('returns an empty array for html with no img', () => {
160+
expect(extractImgSrcs('<p>hello</p>')).toEqual([])
161+
expect(extractImgSrcs('')).toEqual([])
162+
})
163+
})
164+
165+
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
166+
const isHosted = (src: string) => src.startsWith('/api/files/view/')
167+
const hostedHtml = '<img src="/api/files/view/wf_abc">'
168+
169+
it('skips upload for a single already-hosted image', () => {
170+
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
171+
})
172+
173+
it('does not skip when there is no html, or the html is not one of ours', () => {
174+
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
175+
expect(shouldSkipFileUpload([imageFile()], '<img src="https://x.com/a.png">', isHosted)).toBe(
176+
false
177+
)
178+
})
179+
180+
it('does not skip when there are no files to upload in the first place', () => {
181+
expect(shouldSkipFileUpload([], hostedHtml, isHosted)).toBe(false)
182+
})
183+
184+
// Regression: a genuinely mixed paste/drop (the hosted image plus a separate new one) must
185+
// still upload the new file — bailing out entirely here would silently drop it.
186+
it('does not skip a mixed paste/drop carrying more than one image file', () => {
187+
expect(
188+
shouldSkipFileUpload([imageFile('a.png'), imageFile('b.png')], hostedHtml, isHosted)
189+
).toBe(false)
190+
})
191+
192+
// Regression: this must be content-based (the accompanying html), not keyed off any mutable
193+
// per-drag flag like ProseMirror's `view.dragging` — that flag can go briefly stale (cleared up
194+
// to ~50ms late via `dragend` when a prior internal drag was dropped outside the view), and a
195+
// flag-based check would incorrectly suppress upload of an unrelated new file dropped in that
196+
// window. This function only reacts to what THIS specific event's `html`/`images` contain.
197+
it('is a pure function of the images/html actually offered, independent of any drag-session flag', () => {
198+
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
199+
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
200+
})
201+
})
202+
203+
describe('isInlineRouteSrc', () => {
204+
it('recognizes the workspace- and public-scoped inline route with key or fileId', () => {
205+
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fa.png')).toBe(
206+
true
207+
)
208+
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?fileId=wf_abc')).toBe(true)
209+
expect(isInlineRouteSrc('/api/files/public/tok_1/inline?fileId=wf_abc')).toBe(true)
210+
})
211+
212+
it('rejects non-inline paths, unrecognized query params, and external/absolute origins', () => {
213+
expect(isInlineRouteSrc('/api/files/serve/workspace/ws-1/a.png')).toBe(false)
214+
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline')).toBe(false)
215+
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?other=1')).toBe(false)
216+
expect(isInlineRouteSrc('https://other-site.com/files/inline?key=x')).toBe(false)
217+
expect(isInlineRouteSrc('data:image/png;base64,aaaa')).toBe(false)
218+
})
219+
})
220+
221+
describe('findHostedImageAttrs', () => {
222+
const ws = createWorkspaceFileContentSource('ws-1')
223+
224+
function docWithImages(...attrs: Array<Record<string, unknown>>): Editor {
225+
return new Editor({
226+
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
227+
content: {
228+
type: 'doc',
229+
content: attrs.map((a) => ({ type: 'image', attrs: a })),
230+
},
231+
})
232+
}
233+
234+
// Regression: this is the exact mechanism that avoids persisting the display-layer inline URL
235+
// (Cursor's "Paste persists display image URLs" finding) — cloning the REAL node's attrs rather
236+
// than re-deriving a node from the clipboard html's rewritten src.
237+
it('finds the existing node whose RESOLVED (display) src matches, and returns its REAL persisted attrs', () => {
238+
const persistedSrc = '/api/files/view/wf_abc'
239+
const editor = docWithImages({ src: persistedSrc, alt: 'photo', width: '300' })
240+
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
241+
expect(renderedSrc).not.toBe(persistedSrc) // sanity: the rendered form really differs
242+
243+
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
244+
expect(match).not.toBeNull()
245+
expect(match?.src).toBe(persistedSrc) // the REAL persisted src, not the rendered one
246+
expect(match?.alt).toBe('photo')
247+
expect(match?.width).toBe('300')
248+
})
249+
250+
it('returns null when no node in the doc resolves to any target src', () => {
251+
const editor = docWithImages({ src: '/api/files/view/wf_other' })
252+
const match = findHostedImageAttrs(
253+
editor.state.doc,
254+
['/api/workspaces/ws-1/files/inline?fileId=wf_abc'],
255+
ws.resolveImageSrc
256+
)
257+
expect(match).toBeNull()
258+
})
259+
260+
it('returns null for an empty doc or an empty target list', () => {
261+
const editor = docWithImages()
262+
expect(findHostedImageAttrs(editor.state.doc, ['/anything'], ws.resolveImageSrc)).toBeNull()
263+
const editorWithImage = docWithImages({ src: '/api/files/view/wf_abc' })
264+
expect(findHostedImageAttrs(editorWithImage.state.doc, [], ws.resolveImageSrc)).toBeNull()
265+
})
266+
267+
it('matches the first of several images, not just the last', () => {
268+
const editor = docWithImages(
269+
{ src: '/api/files/view/wf_one', alt: 'one' },
270+
{ src: '/api/files/view/wf_two', alt: 'two' }
271+
)
272+
const renderedTwo = ws.resolveImageSrc('/api/files/view/wf_two') as string
273+
const match = findHostedImageAttrs(editor.state.doc, [renderedTwo], ws.resolveImageSrc)
274+
expect(match?.alt).toBe('two')
275+
})
276+
277+
it('returns a defensive copy, not a live reference to the node attrs object', () => {
278+
const persistedSrc = '/api/files/view/wf_abc'
279+
const editor = docWithImages({ src: persistedSrc, alt: 'photo' })
280+
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
281+
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
282+
expect(match).not.toBeNull()
283+
if (match) match.alt = 'mutated'
284+
let originalAlt: unknown
285+
editor.state.doc.descendants((node) => {
286+
if (node.type.name === 'image') originalAlt = node.attrs.alt
287+
})
288+
expect(originalAlt).toBe('photo')
289+
})
290+
})

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,121 @@ export function extractImageFiles(transfer: DataTransfer | null): File[] {
1212
.map((item) => item.getAsFile())
1313
.filter((file): file is File => file !== null)
1414
}
15+
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.
19+
const IMG_SRC_RE = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
20+
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
21+
22+
/**
23+
* True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
24+
* rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
25+
* or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
26+
* 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.
32+
*/
33+
export function isInlineRouteSrc(src: string): boolean {
34+
try {
35+
const parsed = new URL(src, 'http://placeholder')
36+
if (parsed.origin !== 'http://placeholder') return false
37+
if (!parsed.pathname.endsWith('/inline')) return false
38+
for (const key of parsed.searchParams.keys()) {
39+
if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
40+
}
41+
return false
42+
} catch {
43+
return false
44+
}
45+
}
46+
47+
/**
48+
* Extracts every `<img>` `src` value found in `html`, in document order (may contain duplicates).
49+
*/
50+
export function extractImgSrcs(html: string): string[] {
51+
const srcs: string[] = []
52+
for (const match of html.matchAll(IMG_SRC_RE)) {
53+
const src = match[1] ?? match[2] ?? match[3]
54+
if (src) srcs.push(src)
55+
}
56+
return srcs
57+
}
58+
59+
/**
60+
* True when `html` contains an `<img>` whose `src` is already one of our own hosted workspace file
61+
* references. Copying a rendered `<img>` that's already on the page (e.g. Cmd+C after clicking it to
62+
* select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
63+
* `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
64+
* that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
65+
*/
66+
export function hasHostedImageHtml(html: string, isHostedRef: (src: string) => boolean): boolean {
67+
return extractImgSrcs(html).some((src) => isHostedRef(src) || isInlineRouteSrc(src))
68+
}
69+
70+
/**
71+
* True when a paste or drop should be diverted away from the upload-from-file path — it carries
72+
* exactly one image file, and the accompanying `text/html` shows it's a same-page copy of an
73+
* already-hosted image (see {@link hasHostedImageHtml}) rather than a genuinely new external image.
74+
* Content-based (not `view.dragging`-based, for the drop case): `view.dragging` can go briefly stale
75+
* (cleared up to ~50ms late by ProseMirror's own `dragend` handler when a prior internal drag was
76+
* dropped outside this view) and must never suppress upload of an unrelated, genuinely new file that
77+
* happens to land in that window — this check only reacts to what THIS specific event's `html`
78+
* actually contains. Gated on exactly one file — a genuinely mixed paste/drop (the hosted image plus a
79+
* separate new one) must still upload the new file, not have the whole paste/drop diverted.
80+
*/
81+
export function shouldSkipFileUpload(
82+
images: File[],
83+
html: string,
84+
isHostedRef: (src: string) => boolean
85+
): boolean {
86+
return images.length === 1 && Boolean(html) && hasHostedImageHtml(html, isHostedRef)
87+
}
88+
89+
/** Minimal shape of a ProseMirror image node — just enough to read its type name and attrs. */
90+
interface ImageLikeNode {
91+
type: { name: string }
92+
attrs: Record<string, unknown>
93+
}
94+
95+
/** Minimal shape of a ProseMirror doc — just enough to walk its nodes. */
96+
interface DescendantsDoc {
97+
descendants: (callback: (node: ImageLikeNode) => boolean | undefined) => void
98+
}
99+
100+
/**
101+
* Finds the first `image` node already in `doc` whose *rendered* src (`resolveImageSrc(node.attrs.src)`)
102+
* matches one of `targetSrcs`, and returns its attrs — a defensive copy, safe to hand straight to
103+
* `insertContentAt`. Returns `null` if no match is found (e.g. the source node was deleted, or this is
104+
* genuinely a different document than the one the html was copied from).
105+
*
106+
* Used to clone a same-page copy/drag of an already-hosted image faithfully — the exact persisted
107+
* `src` (and every other attribute: width, height, href, title…) — rather than re-deriving a node from
108+
* the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
109+
* real persisted one. Inserting a node built from that display URL would bake it into the document,
110+
* 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.
112+
*/
113+
export function findHostedImageAttrs(
114+
doc: DescendantsDoc,
115+
targetSrcs: string[],
116+
resolveImageSrc: (src: string | undefined) => string | undefined
117+
): Record<string, unknown> | null {
118+
const targets = new Set(targetSrcs)
119+
let found: Record<string, unknown> | null = null
120+
doc.descendants((node) => {
121+
if (found) return false
122+
if (node.type.name === 'image') {
123+
const resolved = resolveImageSrc(node.attrs.src as string | undefined)
124+
if (resolved && targets.has(resolved)) {
125+
found = { ...node.attrs }
126+
return false
127+
}
128+
}
129+
return true
130+
})
131+
return found
132+
}

0 commit comments

Comments
 (0)