@@ -8,6 +8,8 @@ import { createRoot, type Root } from 'react-dom/client'
88import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
99import * as Y from 'yjs'
1010import { 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
1214let host : HTMLDivElement
1315let 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+
103148describe ( '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