Skip to content

Commit 1c07d6f

Browse files
committed
fix(files): unify image controls and guard collaborative edits
1 parent 69d963c commit 1c07d6f

18 files changed

Lines changed: 971 additions & 450 deletions

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ vi.mock(
7979
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu',
8080
() => ({ TableBubbleMenu: () => null })
8181
)
82+
vi.mock(
83+
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu',
84+
() => ({ ImageBubbleMenu: () => null })
85+
)
8286
vi.mock(
8387
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card',
8488
() => ({ LinkHoverCard: () => null })

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

Lines changed: 303 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { createRoot, type Root } from 'react-dom/client'
88
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99
import * as Y from 'yjs'
1010
import { ResizableImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image'
11+
import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move'
12+
import { ImageBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu'
1113

1214
let host: HTMLDivElement
1315
let root: Root
@@ -39,9 +41,11 @@ beforeEach(async () => {
3941
host = document.createElement('div')
4042
document.body.append(host)
4143
root = createRoot(host)
44+
vi.spyOn(local.view, 'coordsAtPos').mockReturnValue({ top: 10, bottom: 30, left: 10, right: 50 })
4245
await act(async () => {
4346
root.render(
4447
<Tooltip.Provider>
48+
<ImageBubbleMenu editor={local} scrollContainerRef={{ current: host }} />
4549
<EditorContent editor={local} />
4650
</Tooltip.Provider>
4751
)
@@ -97,10 +101,250 @@ function beginResize(): void {
97101
})
98102
pointer(handle, 'pointerdown', 100)
99103
pointer(window, 'pointermove', 160)
104+
expect(host.querySelector('img')).toBe(image)
105+
expect(handle.setPointerCapture).toHaveBeenCalledWith(7)
100106
expect(image.style.width).toBe('260px')
101107
}
102108

109+
function changeDraft(value: string): HTMLInputElement {
110+
const input = host.querySelector<HTMLInputElement>('[aria-label="Image editing"] input')!
111+
act(() => {
112+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
113+
input.dispatchEvent(new Event('input', { bubbles: true }))
114+
})
115+
return input
116+
}
117+
118+
async function addPeerSibling(sameSource = true): Promise<number> {
119+
const position = local.state.doc.firstChild!.nodeSize
120+
peer.commands.insertContentAt(position + 1, {
121+
type: 'image',
122+
attrs: {
123+
src: sameSource ? 'https://sim.ai/image.png' : 'https://sim.ai/second.png',
124+
alt: 'Peer image',
125+
title: 'Sibling identity',
126+
width: '400',
127+
height: '300',
128+
},
129+
})
130+
await receivePeerUpdate()
131+
act(() => local.commands.setNodeSelection(position))
132+
return position
133+
}
134+
135+
function movePeerImage(from: number, to: number): void {
136+
const image = peer.state.doc.nodeAt(from)!
137+
peer.commands.setNodeSelection(from)
138+
vi.spyOn(peer.view, 'posAtCoords').mockReturnValue({ pos: to, inside: 0 })
139+
expect(
140+
moveDraggedImageNode(
141+
peer.view,
142+
new MouseEvent('drop', { clientX: 0, clientY: 0, cancelable: true }) as DragEvent,
143+
{ images: [], html: `<img src="${image.attrs.src}">` }
144+
)
145+
).toBe(true)
146+
}
147+
103148
describe('image interactions during real peer Yjs updates', () => {
149+
it.each(
150+
(['alt', 'href', 'resize'] as const).flatMap((interaction) =>
151+
[false, true].flatMap((sameSource) =>
152+
['target', 'sibling'].map((moved) => ({ interaction, sameSource, moved }))
153+
)
154+
)
155+
)(
156+
'cancels $interaction after a peer moves the $moved image (same source: $sameSource)',
157+
async ({ interaction, sameSource, moved }) => {
158+
const position = await addPeerSibling(sameSource)
159+
let input: HTMLInputElement | undefined
160+
if (interaction === 'resize') beginResize()
161+
else {
162+
const label = interaction === 'alt' ? 'alt text' : 'link'
163+
act(() =>
164+
host.querySelector<HTMLButtonElement>(`[aria-label="Edit image ${label}"]`)!.click()
165+
)
166+
input = changeDraft(
167+
interaction === 'alt' ? 'Draft for original' : 'https://sim.ai/for-original'
168+
)
169+
}
170+
if (moved === 'target') movePeerImage(position, position + 2)
171+
else movePeerImage(position + 1, position)
172+
await receivePeerUpdate()
173+
if (input) {
174+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
175+
await act(async () => {
176+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
177+
})
178+
} else pointer(window, 'pointerup', 160)
179+
expect(local.state.doc.nodeAt(position)?.attrs.alt).toBe('Peer image')
180+
expect(local.getJSON()).toEqual(peer.getJSON())
181+
}
182+
)
183+
184+
it.each(['alt text', 'link'])(
185+
'rejects queued %s Apply before React renders a same-source reorder',
186+
async (field) => {
187+
const position = await addPeerSibling()
188+
act(() =>
189+
host.querySelector<HTMLButtonElement>(`[aria-label="Edit image ${field}"]`)!.click()
190+
)
191+
const input = changeDraft('https://sim.ai/stale-draft')
192+
movePeerImage(position + 1, position)
193+
await act(async () => {
194+
Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(peerDoc))
195+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
196+
})
197+
expect(local.getJSON()).toEqual(peer.getJSON())
198+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
199+
}
200+
)
201+
202+
it('does not revive a draft after images are reordered back', async () => {
203+
const position = await addPeerSibling()
204+
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Edit image alt text"]')!.click())
205+
changeDraft('Stale draft')
206+
movePeerImage(position + 1, position)
207+
await receivePeerUpdate()
208+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
209+
movePeerImage(position + 1, position)
210+
await receivePeerUpdate()
211+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
212+
expect(local.getJSON()).toEqual(peer.getJSON())
213+
})
214+
215+
it('preserves target metadata edits and text edits around an unchanged same-source sibling', async () => {
216+
const position = await addPeerSibling()
217+
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Edit image link"]')!.click())
218+
const input = changeDraft('https://sim.ai/local-link')
219+
peer.commands.setNodeSelection(position)
220+
peer.commands.updateAttributes('image', { alt: 'Peer corrected alt' })
221+
peer.commands.insertContentAt('Earlier heading'.length + 1, ' PEER')
222+
await receivePeerUpdate()
223+
expect(host.querySelector('[aria-label="Image editing"] input')).toBe(input)
224+
await act(async () => {
225+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
226+
})
227+
const currentPosition = local.state.doc.firstChild!.nodeSize
228+
expect(local.state.doc.nodeAt(currentPosition)?.attrs).toMatchObject({
229+
alt: 'Peer corrected alt',
230+
href: 'https://sim.ai/local-link',
231+
})
232+
expect(local.state.doc.nodeAt(currentPosition + 1)?.attrs.alt).toBe('Peer image')
233+
await act(async () => {
234+
Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc))
235+
})
236+
expect(local.getJSON()).toEqual(peer.getJSON())
237+
})
238+
239+
it.each(['alt', 'width', 'src'])(
240+
'cancels conservatively when a sibling image changes its %s',
241+
async (field) => {
242+
const position = await addPeerSibling()
243+
act(() =>
244+
host.querySelector<HTMLButtonElement>('[aria-label="Edit image alt text"]')!.click()
245+
)
246+
const input = changeDraft('Stale draft')
247+
peer.commands.setNodeSelection(position + 1)
248+
peer.commands.updateAttributes('image', {
249+
[field]: field === 'width' ? '500' : 'https://sim.ai/peer-change',
250+
})
251+
await receivePeerUpdate()
252+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
253+
await act(async () => {
254+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
255+
})
256+
expect(local.getJSON()).toEqual(peer.getJSON())
257+
}
258+
)
259+
260+
it.each(
261+
(['alt', 'href', 'resize'] as const).flatMap((interaction) =>
262+
[false, true].flatMap((identical) =>
263+
['before', 'after'].map((side) => ({ interaction, identical, side }))
264+
)
265+
)
266+
)(
267+
'cancels $interaction after a peer inserts $side the image (identical: $identical)',
268+
async ({ interaction, identical, side }) => {
269+
let input: HTMLInputElement | undefined
270+
if (interaction === 'resize') beginResize()
271+
else {
272+
const label = interaction === 'alt' ? 'alt text' : 'link'
273+
act(() =>
274+
host.querySelector<HTMLButtonElement>(`[aria-label="Edit image ${label}"]`)!.click()
275+
)
276+
input = changeDraft(interaction === 'alt' ? 'Local draft' : 'https://sim.ai/local-draft')
277+
}
278+
const originalTarget = localDoc.getXmlFragment('default').get(1)
279+
peer.commands.insertContentAt(imagePosition(peer) + (side === 'after' ? 1 : 0), {
280+
type: 'image',
281+
attrs: identical
282+
? imageAttributes(peer)
283+
: { src: 'https://sim.ai/inserted.png', alt: 'Inserted', width: '400' },
284+
})
285+
await receivePeerUpdate()
286+
expect(localDoc.getXmlFragment('default').get(1)).toBe(originalTarget)
287+
if (input) {
288+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
289+
act(() =>
290+
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
291+
)
292+
} else pointer(window, 'pointerup', 160)
293+
expect(local.getJSON()).toEqual(peer.getJSON())
294+
}
295+
)
296+
297+
it('rejects a queued Apply before React renders the peer insertion', async () => {
298+
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Edit image alt text"]')!.click())
299+
const input = changeDraft('Stale draft')
300+
peer.commands.insertContentAt(imagePosition(peer), {
301+
type: 'image',
302+
attrs: imageAttributes(peer),
303+
})
304+
await act(async () => {
305+
Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(peerDoc))
306+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
307+
})
308+
expect(local.getJSON()).toEqual(peer.getJSON())
309+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
310+
})
311+
312+
it('does not revive a canceled draft when the peer removes their inserted image', async () => {
313+
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Edit image alt text"]')!.click())
314+
changeDraft('Stale draft')
315+
const position = imagePosition(peer)
316+
peer.commands.insertContentAt(position, { type: 'image', attrs: imageAttributes(peer) })
317+
await receivePeerUpdate()
318+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
319+
peer.commands.deleteRange({ from: position, to: position + 1 })
320+
await receivePeerUpdate()
321+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
322+
expect(local.getJSON()).toEqual(peer.getJSON())
323+
})
324+
325+
it.each(['cancel', 'apply', 'unmount'] as const)(
326+
'removes the draft guard listener on %s',
327+
(finish) => {
328+
const subscribe = vi.spyOn(local, 'on')
329+
const unsubscribe = vi.spyOn(local, 'off')
330+
act(() =>
331+
host.querySelector<HTMLButtonElement>('[aria-label="Edit image alt text"]')!.click()
332+
)
333+
const listener = subscribe.mock.calls.find(([event]) => event === 'transaction')?.[1]
334+
expect(listener).toBeTypeOf('function')
335+
if (finish === 'unmount') act(() => root.unmount())
336+
else {
337+
const key = finish === 'cancel' ? 'Escape' : 'Enter'
338+
act(() =>
339+
host
340+
.querySelector('input')!
341+
.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
342+
)
343+
}
344+
expect(unsubscribe).toHaveBeenCalledWith('transaction', listener)
345+
}
346+
)
347+
104348
it.each(['pointerup', 'pointercancel', 'blur', 'unmount'])(
105349
'removes the resize transaction listener after %s',
106350
(finish) => {
@@ -119,7 +363,7 @@ describe('image interactions during real peer Yjs updates', () => {
119363

120364
it('preserves peer alt text when only the local link draft changes', async () => {
121365
act(() =>
122-
host.querySelector<HTMLButtonElement>('button[aria-label="Edit image details"]')!.click()
366+
host.querySelector<HTMLButtonElement>('button[aria-label="Edit image link"]')!.click()
123367
)
124368
const input = host.querySelector<HTMLInputElement>('input[aria-label="Image link URL"]')!
125369
act(() => {
@@ -162,6 +406,64 @@ describe('image interactions during real peer Yjs updates', () => {
162406
expect(local.state.doc.firstChild?.textContent).toBe('Earlier heading PEER')
163407
})
164408

409+
it.each(['alt', 'href', 'unchanged', 'reverted'] as const)(
410+
'preserves peer fields and follows the image through preceding edits: %s',
411+
async (change) => {
412+
const field = change === 'href' ? 'link' : 'alt text'
413+
act(() =>
414+
host.querySelector<HTMLButtonElement>(`button[aria-label="Edit image ${field}"]`)!.click()
415+
)
416+
const input = changeDraft(
417+
change === 'href'
418+
? 'https://sim.ai/local'
419+
: change === 'unchanged'
420+
? 'Original'
421+
: 'Local alt'
422+
)
423+
if (change === 'reverted') changeDraft('Original')
424+
peer.commands.insertContentAt('Earlier heading'.length + 1, ' PEER')
425+
peer.commands.setNodeSelection(imagePosition(peer))
426+
peer.commands.updateAttributes('image', { alt: 'Peer alt', href: 'https://sim.ai/peer' })
427+
await receivePeerUpdate()
428+
expect(host.querySelector('[aria-label="Image editing"] input')).toBe(input)
429+
await act(async () =>
430+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
431+
)
432+
expect(imageAttributes(local)).toMatchObject({
433+
alt: change === 'alt' ? 'Local alt' : 'Peer alt',
434+
href: change === 'href' ? 'https://sim.ai/local' : 'https://sim.ai/peer',
435+
})
436+
expect(local.state.doc.firstChild?.textContent).toBe('Earlier heading PEER')
437+
}
438+
)
439+
440+
it.each(['delete', 'replace', 'identical replacement'] as const)(
441+
'never applies an open draft to a peer replacement: %s',
442+
async (action) => {
443+
act(() =>
444+
host.querySelector<HTMLButtonElement>('[aria-label="Edit image alt text"]')!.click()
445+
)
446+
const input = changeDraft('Uncommitted draft')
447+
const position = imagePosition(peer)
448+
const originalAttributes = imageAttributes(peer)
449+
peer.commands.deleteRange({ from: position, to: position + 1 })
450+
if (action !== 'delete')
451+
peer.commands.insertContentAt(position, {
452+
type: 'image',
453+
attrs:
454+
action === 'replace'
455+
? { ...originalAttributes, alt: 'Replacement' }
456+
: originalAttributes,
457+
})
458+
await receivePeerUpdate()
459+
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
460+
expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull()
461+
expect(imageAttributes(local)?.alt ?? null).toBe(
462+
action === 'delete' ? null : action === 'replace' ? 'Replacement' : 'Original'
463+
)
464+
}
465+
)
466+
165467
it.each([false, true])(
166468
'cancels a resize when the peer replaces the actual image node (identical attributes: %s)',
167469
async (identicalAttributes) => {

0 commit comments

Comments
 (0)