Skip to content

Commit 6eaf365

Browse files
committed
fix(file-editor): retain rich fragments through image uploads
1 parent e035cfa commit 6eaf365

8 files changed

Lines changed: 234 additions & 45 deletions

File tree

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,17 +233,23 @@ afterEach(async () => {
233233
})
234234

235235
describe('loaded rich editor lifecycle', () => {
236-
it.each(['paste', 'drop'] as const)(
237-
'%s uploads a standalone display-only image from another document',
238-
async (method) => {
236+
it.each([
237+
{ method: 'paste', caption: false },
238+
{ method: 'drop', caption: false },
239+
{ method: 'paste', caption: true },
240+
{ method: 'drop', caption: true },
241+
] as const)(
242+
'$method uploads a display-only image from another document (caption: $caption)',
243+
async ({ method, caption }) => {
239244
await render('before TARGET after')
240245
const editor = getEditor()
241246
await act(async () => editor.commands.setTextSelection({ from: 8, to: 14 }))
242247
if (method === 'drop')
243248
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
244249
const src = '/api/workspaces/another-workspace/files/inline?fileId=another-image'
245250
const image = new File(['image'], 'image.png', { type: 'image/png' })
246-
const html = `<img src="${window.location.origin}${src}">`
251+
const img = `<img src="${window.location.origin}${src}" alt="Original" width="140">`
252+
const html = caption ? `<p>Caption<a href="/destination">${img}</a></p><p>Tail</p>` : img
247253
uploadFile.mockResolvedValueOnce({ file: { url: '/api/files/view/uploaded-image' } })
248254
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
249255
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
@@ -263,11 +269,19 @@ describe('loaded rich editor lifecycle', () => {
263269
})
264270
expect(editor.getMarkdown()).toContain('/api/files/view/uploaded-image')
265271
expect(editor.getMarkdown()).not.toContain('/inline?')
266-
const storedSrc = editor.getJSON().content?.find((node) => node.type === 'image')?.attrs?.src
272+
let storedSrc = ''
273+
editor.state.doc.descendants((node) => {
274+
if (node.type.name === 'image' || node.type.name === 'inlineImage')
275+
storedSrc = node.attrs.src
276+
})
267277
expect(extractEmbeddedFileRef(storedSrc)).toEqual({ fileId: 'uploaded-image' })
268278
expect(editor.state.doc.textContent).toBe(
269-
method === 'paste' ? 'before after' : 'before TARGET after'
279+
`before ${caption ? 'CaptionTail' : ''}${method === 'paste' ? '' : 'TARGET'} after`
270280
)
281+
expect(editor.view.dom.querySelector('img')?.getAttribute('alt')).toBe('Original')
282+
expect(editor.getMarkdown()).toContain('width="140"')
283+
if (caption)
284+
expect(editor.view.dom.querySelector('a')?.getAttribute('href')).toBe('/destination')
271285
}
272286
)
273287

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,51 @@ async function submit(
8282
}
8383

8484
describe('field upload completion boundary', () => {
85+
it.each(['paste', 'drop'] as const)(
86+
'%s uploads a non-portable image without losing its accompanying fragment',
87+
async (method) => {
88+
const pending = Promise.withResolvers<{ url: string; alt: string }>()
89+
upload.mockReturnValueOnce(pending.promise)
90+
await render()
91+
const owner = editor()
92+
const before = owner.getJSON()
93+
await act(async () => owner.commands.setTextSelection({ from: 8, to: 14 }))
94+
if (method === 'drop')
95+
vi.spyOn(owner.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
96+
const html =
97+
'<p>Lead</p><h2>Caption</h2><p><a href="/destination"><img src="/api/workspaces/source/files/inline?fileId=image" alt="Original alt" width="123"></a><strong>Tail</strong><img src="/other.png" alt="Other"></p>'
98+
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
99+
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
100+
value: {
101+
files: [new File(['image'], 'image.png', { type: 'image/png' })],
102+
items: [],
103+
types: ['Files', 'text/html'],
104+
getData: (type: string) => (type === 'text/html' ? html : ''),
105+
},
106+
})
107+
await act(async () => owner.view.dom.dispatchEvent(event))
108+
expect(upload).toHaveBeenCalledOnce()
109+
expect(owner.getJSON()).toEqual(before)
110+
await act(async () => owner.commands.insertContentAt(1, 'prefix '))
111+
await act(async () => pending.resolve({ url: '/api/files/view/uploaded', alt: 'New alt' }))
112+
expect(owner.state.doc.textContent).toBe(
113+
method === 'paste'
114+
? 'prefix before LeadCaptionTail after'
115+
: 'prefix before LeadCaptionTailTARGET after'
116+
)
117+
expect(host.querySelector('h2')?.textContent).toBe('Caption')
118+
expect(host.querySelector('strong')?.textContent).toBe('Tail')
119+
expect(host.querySelector('a')?.getAttribute('href')).toBe('/destination')
120+
expect(
121+
Array.from(host.querySelectorAll('img')).map((image) => image.getAttribute('src'))
122+
).toEqual(['/api/files/view/uploaded', '/other.png'])
123+
expect(host.querySelector('img')?.getAttribute('alt')).toBe('Original alt')
124+
expect(owner.getMarkdown()).toContain('width="123"')
125+
expect(owner.getMarkdown()).not.toContain('/inline?')
126+
expect(() => owner.state.doc.check()).not.toThrow()
127+
}
128+
)
129+
85130
it('replaces the selected range only after a pasted image finishes uploading', async () => {
86131
const pending = Promise.withResolvers<{ url: string; alt: string }>()
87132
upload.mockReturnValueOnce(pending.promise)

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions'
88
import {
99
extractImageFiles,
10+
getImageFileFallback,
1011
normalizePastedImageSources,
12+
resolveImageFileFallback,
1113
toSameOriginPath,
1214
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste'
1315

@@ -98,6 +100,42 @@ describe('normalizePastedImageSources', () => {
98100
}
99101
}
100102

103+
it.each([
104+
'blob:https://editor.example/temporary-image',
105+
'/api/workspaces/source/files/inline?fileId=image',
106+
'/api/files/public/share/inline?fileId=image',
107+
])('resolves a bitmap for repeated non-portable images inside a complete fragment: %s', (src) => {
108+
withEditor((editor, parse) => {
109+
const html = (source: string) =>
110+
`<h2>Heading <img src="${source}" width="120" alt="First"></h2><p><a href="/destination"><img src="${source}" width="240" alt="Second"></a>Caption<img src="/other.png"></p>`
111+
const slice = parse(html(src))
112+
const fallback = getImageFileFallback(slice, [imageFile()])
113+
expect(fallback).not.toBeNull()
114+
expect(fallback?.source).toBe(src)
115+
expect(
116+
resolveImageFileFallback(fallback!, '/api/files/view/uploaded').eq(
117+
parse(html('/api/files/view/uploaded'))
118+
)
119+
).toBe(true)
120+
expect(editor.state.doc.textContent).toContain('Existing')
121+
})
122+
})
123+
124+
it('does not associate bitmap bytes with an ambiguous source or file ordering', () => {
125+
withEditor((_editor, parse) => {
126+
const twoSources = parse(
127+
'<p><img src="blob:https://editor.example/a"><img src="blob:https://editor.example/b"></p>'
128+
)
129+
expect(getImageFileFallback(twoSources, [imageFile()])).toBeNull()
130+
expect(
131+
getImageFileFallback(twoSources, [imageFile('one.png'), imageFile('two.png')])
132+
).toBeNull()
133+
expect(
134+
getImageFileFallback(parse('<p>Caption<img src="/portable.png"></p>'), [imageFile()])
135+
).toBeNull()
136+
})
137+
})
138+
101139
it('preserves the full fragment, image attributes, links and open boundaries', () => {
102140
withEditor((editor, parse) => {
103141
const html = (src: string) =>

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

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ function runtimeOrigin(): string {
1717
return typeof window === 'undefined' ? '' : window.location.origin
1818
}
1919

20-
/** A native image copy can carry bytes for a display URL that cannot survive sharing or reload. */
21-
export function needsImageFileFallback(slice: Slice, files: readonly File[]): boolean {
22-
if (files.length !== 1 || slice.content.childCount !== 1) return false
23-
let node = slice.content.firstChild!
24-
if (node.type.name === 'paragraph' && node.childCount === 1) node = node.firstChild!
25-
if (!isImageNode(node) || typeof node.attrs.src !== 'string') return false
20+
export interface ImageFileFallback {
21+
slice: Slice
22+
source: string
23+
}
24+
25+
function isNonPortableImageSource(src: string): boolean {
2626
try {
27-
const url = new URL(node.attrs.src, runtimeOrigin() || 'http://placeholder')
27+
const url = new URL(src, runtimeOrigin() || 'http://placeholder')
2828
return (
2929
url.protocol === 'blob:' ||
3030
/^\/api\/(?:workspaces\/[^/]+\/files|files\/public\/[^/]+)\/inline$/.test(url.pathname)
@@ -34,6 +34,30 @@ export function needsImageFileFallback(slice: Slice, files: readonly File[]): bo
3434
}
3535
}
3636

37+
/** Associate a single attached bitmap with its source, without guessing a multi-file ordering. */
38+
export function getImageFileFallback(
39+
slice: Slice,
40+
files: readonly File[]
41+
): ImageFileFallback | null {
42+
if (files.length !== 1) return null
43+
const sources = new Set<string>()
44+
slice.content.descendants((node) => {
45+
if (
46+
isImageNode(node) &&
47+
typeof node.attrs.src === 'string' &&
48+
isNonPortableImageSource(node.attrs.src)
49+
) {
50+
sources.add(node.attrs.src)
51+
}
52+
})
53+
return sources.size === 1 ? { slice, source: sources.values().next().value! } : null
54+
}
55+
56+
/** Replace the uploaded image's source while retaining the original fragment and node attributes. */
57+
export function resolveImageFileFallback(fallback: ImageFileFallback, uploadedSrc: string): Slice {
58+
return mapImageSources(fallback.slice, (src) => (src === fallback.source ? uploadedSrc : src))
59+
}
60+
3761
/** Normalize native clipboard URLs against the actual page origin, never another host. */
3862
export function toSameOriginPath(src: string, origin = runtimeOrigin()): string | null {
3963
try {
@@ -70,14 +94,20 @@ export function normalizePastedImageSources(
7094
const path = toSameOriginPath(resolved, origin)
7195
if (path) sources.set(path, node.attrs.src)
7296
})
97+
return mapImageSources(slice, (source) => {
98+
const path = toSameOriginPath(source, origin)
99+
return (path && (sources.get(path) ?? (extractEmbeddedFileRef(path) ? path : null))) || source
100+
})
101+
}
102+
103+
function mapImageSources(slice: Slice, map: (src: string) => string): Slice {
73104
let changed = false
74105
const normalize = (fragment: Fragment): Fragment => {
75106
const children: Node[] = []
76107
fragment.forEach((node) => {
77108
if (isImageNode(node) && typeof node.attrs.src === 'string') {
78-
const path = toSameOriginPath(node.attrs.src, origin)
79-
const src = path && (sources.get(path) ?? (extractEmbeddedFileRef(path) ? path : null))
80-
if (src && src !== node.attrs.src) {
109+
const src = map(node.attrs.src)
110+
if (src !== node.attrs.src) {
81111
changed = true
82112
children.push(node.type.create({ ...node.attrs, src }, node.content, node.marks))
83113
} else children.push(node)

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import { Editor } from '@tiptap/core'
33
import Collaboration from '@tiptap/extension-collaboration'
44
import { undoDepth } from '@tiptap/pm/history'
5-
import { afterEach, describe, expect, it } from 'vitest'
5+
import { DOMParser } from '@tiptap/pm/model'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
67
import * as Y from 'yjs'
78
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
89
import {
@@ -13,6 +14,7 @@ import {
1314
ImageUploadPlaceholders,
1415
removeImageUpload,
1516
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload'
17+
import { createRichMarkdownPasteAdmission } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission'
1618

1719
let editor: Editor
1820
afterEach(() => editor?.destroy())
@@ -26,6 +28,33 @@ function mount(content = '<p>abcd</p>') {
2628
}
2729

2830
describe('image upload anchors', () => {
31+
it('rechecks the paste budget when an uploaded fragment completes after intervening edits', () => {
32+
const rejected = vi.fn()
33+
editor = new Editor({
34+
extensions: [
35+
...createMarkdownContentExtensions(),
36+
ImageUploadPlaceholders,
37+
createRichMarkdownPasteAdmission({
38+
maxResultBytes: 100,
39+
maxResultCharacters: 100,
40+
getCurrentText: () => editor.getMarkdown(),
41+
onRejected: rejected,
42+
}),
43+
],
44+
content: '<p>Seed</p>',
45+
})
46+
const [id] = beginImageUploads(editor, { from: 5, to: 5 }, ['image'])
47+
editor.commands.insertContentAt(1, 'x'.repeat(70))
48+
const before = editor.getJSON()
49+
const container = document.createElement('div')
50+
container.innerHTML = '<p>Pasted caption <img src="/new.png"></p>'
51+
const fragment = DOMParser.fromSchema(editor.schema).parseSlice(container)
52+
expect(finishImageUpload(editor, id, '/new.png', 'image', fragment)).toBe(false)
53+
expect(rejected).toHaveBeenCalledWith('paste')
54+
expect(editor.getJSON()).toEqual(before)
55+
expect(findImageUpload(editor, id)).toBeNull()
56+
})
57+
2958
it.each([false, true])(
3059
'safely cancels an anchor replaced by a peer update (unrelated paragraph=%s)',
3160
(unrelated) => {

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,29 +156,42 @@ export function removeImageUpload(editor: Editor, id: string): void {
156156
}
157157

158158
/**
159-
* Commit one image at its surviving range. The first successful image replaces the captured content;
159+
* Commit uploaded content at its surviving range. The first successful upload replaces the captured content;
160160
* its queued siblings become insertion anchors after it. Cancellation and failure never delete text.
161161
*/
162-
export function finishImageUpload(editor: Editor, id: string, src: string, alt: string): boolean {
162+
export function finishImageUpload(
163+
editor: Editor,
164+
id: string,
165+
src: string,
166+
alt: string,
167+
fragment?: Slice
168+
): boolean {
163169
if (editor.isDestroyed) return false
164170
const upload = uploadPlaceholderKey.getState(editor.state)?.uploads.get(id)
165171
if (!upload) return false
166172
if (!editor.isEditable) {
167173
removeImageUpload(editor, id)
168174
return false
169175
}
170-
const inserted = editor
176+
const ran = editor
171177
.chain()
172-
.insertContentAt(
173-
{ from: upload.from, to: upload.to },
174-
{ type: imageTypeAt(editor.state.doc, upload.from), attrs: { src, alt } },
175-
{ updateSelection: false }
176-
)
178+
.command(({ tr, commands }) => {
179+
if (fragment) {
180+
tr.replaceRange(upload.from, upload.to, fragment).setMeta('uiEvent', 'paste')
181+
return true
182+
}
183+
return commands.insertContentAt(
184+
{ from: upload.from, to: upload.to },
185+
{ type: imageTypeAt(editor.state.doc, upload.from), attrs: { src, alt } },
186+
{ updateSelection: false }
187+
)
188+
})
177189
.command(({ tr }) => {
178190
tr.setMeta(uploadPlaceholderKey, { complete: id } satisfies UploadPlaceholderAction)
179191
return true
180192
})
181193
.run()
194+
const inserted = ran && findImageUpload(editor, id) === null
182195
if (!inserted) removeImageUpload(editor, id)
183196
return inserted
184197
}

0 commit comments

Comments
 (0)