@@ -65,9 +65,10 @@ function editor() {
6565async function submit (
6666 method : 'paste' | 'drop' ,
6767 target : Editor ,
68- files = [ new File ( [ 'image' ] , 'image.png' , { type : 'image/png' } ) ]
68+ files = [ new File ( [ 'image' ] , 'image.png' , { type : 'image/png' } ) ] ,
69+ selection : number | { from : number ; to : number } = 8
6970) {
70- await act ( async ( ) => target . commands . setTextSelection ( 8 ) )
71+ await act ( async ( ) => target . commands . setTextSelection ( selection ) )
7172 if ( method === 'drop' ) vi . spyOn ( target . view , 'posAtCoords' ) . mockReturnValue ( { pos : 8 , inside : 0 } )
7273 const transfer = { files, items : [ ] , types : [ 'Files' ] , getData : ( ) => '' }
7374 const event = new Event ( method , { bubbles : true , cancelable : true } )
@@ -81,6 +82,112 @@ async function submit(
8182}
8283
8384describe ( '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+
130+ it ( 'replaces the selected range only after a pasted image finishes uploading' , async ( ) => {
131+ const pending = Promise . withResolvers < { url : string ; alt : string } > ( )
132+ upload . mockReturnValueOnce ( pending . promise )
133+ await render ( )
134+ const owner = editor ( )
135+ const before = owner . getJSON ( )
136+ await submit ( 'paste' , owner , undefined , { from : 8 , to : 14 } )
137+ expect ( owner . getJSON ( ) ) . toEqual ( before )
138+ await act ( async ( ) =>
139+ pending . resolve ( { url : 'https://sim.ai/replacement.png' , alt : 'Replacement' } )
140+ )
141+ expect ( owner . state . doc . textContent ) . toBe ( 'before after' )
142+ expect ( host . querySelectorAll ( 'img' ) ) . toHaveLength ( 1 )
143+ await act ( async ( ) => owner . commands . undo ( ) )
144+ expect ( owner . getJSON ( ) ) . toEqual ( before )
145+ } )
146+
147+ it . each ( [ 'paste' , 'drop' ] as const ) (
148+ '%s preserves the whole HTML slice when the payload also contains a bitmap file' ,
149+ async ( method ) => {
150+ await render ( )
151+ const owner = editor ( )
152+ await act ( async ( ) => owner . commands . setTextSelection ( { from : 8 , to : 14 } ) )
153+ if ( method === 'drop' )
154+ vi . spyOn ( owner . view , 'posAtCoords' ) . mockReturnValue ( { pos : 8 , inside : 0 } )
155+ const html =
156+ '<p>Lead</p><h2>Caption</h2><p><a href="https://sim.ai/link"><img src="https://sim.ai/one.png" alt="One" width="123"></a>Tail<img src="https://sim.ai/two.png" alt="Two" width="234"></p>'
157+ const transfer = {
158+ files : [ new File ( [ 'image' ] , 'image.png' , { type : 'image/png' } ) ] ,
159+ items : [ ] ,
160+ types : [ 'text/html' , 'text/plain' , 'Files' ] ,
161+ getData : ( type : string ) =>
162+ type === 'text/html' ? html : type === 'text/plain' ? 'CaptionTail' : '' ,
163+ }
164+ const event = new MouseEvent ( method , { bubbles : true , cancelable : true } )
165+ Object . defineProperty ( event , method === 'paste' ? 'clipboardData' : 'dataTransfer' , {
166+ value : transfer ,
167+ } )
168+ await act ( async ( ) => owner . view . dom . dispatchEvent ( event ) )
169+ expect ( event . defaultPrevented ) . toBe ( true )
170+ expect ( upload ) . not . toHaveBeenCalled ( )
171+ expect ( host . querySelector ( 'h2' ) ?. textContent ) . toBe ( 'Caption' )
172+ expect ( owner . state . doc . textContent ) . toContain ( 'Tail' )
173+ expect ( Array . from ( host . querySelectorAll ( 'img' ) ) . map ( ( image ) => image . alt ) ) . toEqual ( [
174+ 'One' ,
175+ 'Two' ,
176+ ] )
177+ const images : Array < { src : string ; width : string ; href : string | null } > = [ ]
178+ owner . state . doc . descendants ( ( node ) => {
179+ if ( node . type . name === 'image' || node . type . name === 'inlineImage' ) {
180+ images . push ( { src : node . attrs . src , width : node . attrs . width , href : node . attrs . href } )
181+ }
182+ } )
183+ expect ( images ) . toEqual ( [
184+ { src : 'https://sim.ai/one.png' , width : '123' , href : 'https://sim.ai/link' } ,
185+ { src : 'https://sim.ai/two.png' , width : '234' , href : null } ,
186+ ] )
187+ expect ( ( ) => owner . state . doc . check ( ) ) . not . toThrow ( )
188+ }
189+ )
190+
84191 it ( 'invalidates an upload even when streaming ends before completion' , async ( ) => {
85192 const pending = Promise . withResolvers < { url : string ; alt : string } > ( )
86193 upload . mockReturnValueOnce ( pending . promise )
0 commit comments