7
7
ImageInfo ,
8
8
ImageState ,
9
9
} from '../../../text-editor.types' ;
10
- import { Node , Slice , Fragment } from 'prosemirror-model' ;
10
+ import { Node } from 'prosemirror-model' ;
11
11
import { imageCache } from './node' ;
12
12
13
13
export const pluginKey = new PluginKey ( 'imageInserterPlugin' ) ;
@@ -27,8 +27,8 @@ export const createImageInserterPlugin = (
27
27
return new Plugin ( {
28
28
key : pluginKey ,
29
29
props : {
30
- handlePaste : ( view , event , slice ) => {
31
- return processPasteEvent ( view , event , slice ) ;
30
+ handlePaste : ( view , event ) => {
31
+ return processPasteEvent ( view , event ) ;
32
32
} ,
33
33
handleDOMEvents : {
34
34
imagePasted : ( _ , event ) => {
@@ -78,14 +78,16 @@ const findAndHandleRemovedImages = (
78
78
79
79
for ( const removedKey of removedKeys ) {
80
80
const removedImage = previousImages [ removedKey ] ;
81
+ const fileInfoId = removedImage . attrs . fileInfoId ;
82
+
81
83
const imageInfo : ImageInfo = {
82
- fileInfoId : removedImage . attrs . fileInfoId ,
84
+ fileInfoId : fileInfoId ,
83
85
src : removedImage . attrs . src ,
84
86
state : removedImage . attrs . state ,
85
87
} ;
86
88
imageRemovedCallback ( imageInfo ) ;
87
89
88
- imageCache . delete ( removedImage . attrs . fileInfoId ) ;
90
+ imageCache . delete ( fileInfoId ) ;
89
91
}
90
92
} ;
91
93
@@ -107,6 +109,8 @@ const createThumbnailInserter =
107
109
const { state, dispatch } = view ;
108
110
const { schema } = state ;
109
111
112
+ // create a blob URL from the base64 data
113
+
110
114
const placeholderNode = schema . nodes . image . create ( {
111
115
src : base64Data ,
112
116
alt : fileInfo . filename ,
@@ -167,62 +171,6 @@ const createFailedThumbnailInserter =
167
171
dispatch ( tr ) ;
168
172
} ;
169
173
170
- /**
171
- * Check if a given ProseMirror node or fragment contains any image nodes.
172
- * @param node - The ProseMirror node or fragment to check.
173
- * @returns A boolean indicating whether the node contains any image nodes.
174
- */
175
- const isImageNode = ( node : Node | Fragment ) : boolean => {
176
- if ( node instanceof Node ) {
177
- if ( node . type . name === 'image' ) {
178
- return true ;
179
- }
180
-
181
- let found = false ;
182
- node . content . forEach ( ( child ) => {
183
- if ( isImageNode ( child ) ) {
184
- found = true ;
185
- }
186
- } ) ;
187
-
188
- return found ;
189
- } else if ( node instanceof Fragment ) {
190
- let found = false ;
191
- node . forEach ( ( child ) => {
192
- if ( isImageNode ( child ) ) {
193
- found = true ;
194
- }
195
- } ) ;
196
-
197
- return found ;
198
- }
199
-
200
- return false ;
201
- } ;
202
-
203
- /**
204
- * Filter out image nodes from a ProseMirror fragment.
205
- * @param fragment - The ProseMirror fragment to filter.
206
- * @returns A new fragment with image nodes removed.
207
- */
208
- const filterImageNodes = ( fragment : Fragment ) : Fragment => {
209
- const filteredChildren : Node [ ] = [ ] ;
210
-
211
- fragment . forEach ( ( child ) => {
212
- if ( ! isImageNode ( child ) ) {
213
- if ( child . content . size > 0 ) {
214
- const filteredContent = filterImageNodes ( child . content ) ;
215
- const newNode = child . copy ( filteredContent ) ;
216
- filteredChildren . push ( newNode ) ;
217
- } else {
218
- filteredChildren . push ( child ) ;
219
- }
220
- }
221
- } ) ;
222
-
223
- return Fragment . fromArray ( filteredChildren ) ;
224
- } ;
225
-
226
174
/**
227
175
* Process a paste event and trigger an imagePasted event if an image file is pasted.
228
176
* If an HTML image element is pasted, this image is filtered out from the slice content.
@@ -234,7 +182,6 @@ const filterImageNodes = (fragment: Fragment): Fragment => {
234
182
const processPasteEvent = (
235
183
view : EditorView ,
236
184
event : ClipboardEvent ,
237
- slice : Slice ,
238
185
) : boolean => {
239
186
const clipboardData = event . clipboardData ;
240
187
if ( ! clipboardData ) {
@@ -261,19 +208,253 @@ const processPasteEvent = (
261
208
}
262
209
}
263
210
264
- const filteredSlice = new Slice (
265
- filterImageNodes ( slice . content ) ,
266
- slice . openStart ,
267
- slice . openEnd ,
211
+ const htmlContent = clipboardData . getData ( 'text/html' ) ;
212
+ if ( htmlContent ) {
213
+ const imagesSources = extractImagesFromHTML ( htmlContent ) ;
214
+ if ( imagesSources . length > 0 ) {
215
+ for ( const src of imagesSources ) {
216
+ processImageSource ( view , src ) ;
217
+ }
218
+ }
219
+ }
220
+
221
+ return false ;
222
+ } ;
223
+
224
+ /**
225
+ * Extract image sources from HTML content
226
+ *
227
+ * @param htmlContent - The HTML content to extract images from
228
+ * @returns An array of image source URLs
229
+ */
230
+ const extractImagesFromHTML = ( htmlContent : string ) : string [ ] => {
231
+ const sources : string [ ] = [ ] ;
232
+ const tempDiv = document . createElement ( 'div' ) ;
233
+ tempDiv . innerHTML = htmlContent ;
234
+
235
+ const imgElements = tempDiv . querySelectorAll ( 'img' ) ;
236
+ imgElements . forEach ( ( img ) => {
237
+ const src = img . getAttribute ( 'src' ) ;
238
+ if ( src ) {
239
+ sources . push ( src ) ;
240
+ }
241
+ } ) ;
242
+
243
+ return sources ;
244
+ } ;
245
+
246
+ /**
247
+ * Process an image source by detecting its type and handling it accordingly
248
+ *
249
+ * @param view - The ProseMirror editor view
250
+ * @param src - The image source URL or data URL
251
+ */
252
+ const processImageSource = ( view : EditorView , src : string ) : void => {
253
+ const sourceType = detectImageSourceType ( src ) ;
254
+
255
+ switch ( sourceType ) {
256
+ case 'data-url' :
257
+ processDataUrlImage ( view , src ) ;
258
+ break ;
259
+ case 'external-url' :
260
+ processExternalUrlImage ( view , src ) ;
261
+ break ;
262
+ case 'unknown' :
263
+ default :
264
+ console . warn ( 'Unknown image source type:' , src ) ;
265
+ break ;
266
+ }
267
+ } ;
268
+
269
+ /**
270
+ * Detect the type of image source
271
+ *
272
+ * @param src - The image source
273
+ * @returns The detected source type
274
+ */
275
+ const detectImageSourceType = (
276
+ src : string ,
277
+ ) : 'data-url' | 'external-url' | 'unknown' => {
278
+ if ( src . startsWith ( 'data:image/' ) ) {
279
+ return 'data-url' ;
280
+ } else if ( / ^ h t t p s ? : \/ \/ / i. exec ( src ) || src . startsWith ( '//' ) ) {
281
+ return 'external-url' ;
282
+ } else {
283
+ return 'unknown' ;
284
+ }
285
+ } ;
286
+
287
+ /**
288
+ * Process a data URL image
289
+ *
290
+ * @param view - The editor view
291
+ * @param dataUrl - The data URL of the image
292
+ */
293
+ const processDataUrlImage = ( view : EditorView , dataUrl : string ) : void => {
294
+ // Extract mime type from data URL
295
+ const mimeMatch = / ^ d a t a : ( [ ^ ; ] + ) ; / . exec ( dataUrl ) ;
296
+ const mimeType = mimeMatch ? mimeMatch [ 1 ] : 'image/png' ;
297
+ const extension = mimeType . split ( '/' ) [ 1 ] || 'png' ;
298
+
299
+ // Create a blob from the data URL
300
+ const regex = / ^ d a t a : ( [ ^ ; ] + ) ; b a s e 6 4 , ( .+ ) $ / ;
301
+ const matches = regex . exec ( dataUrl ) ;
302
+ if ( ! matches ) {
303
+ console . error ( 'Invalid data URL format' ) ;
304
+
305
+ return ;
306
+ }
307
+
308
+ const base64Data = matches [ 2 ] ;
309
+ const binaryString = atob ( base64Data ) ;
310
+ const bytes = new Uint8Array ( binaryString . length ) ;
311
+
312
+ for ( let i = 0 ; i < binaryString . length ; i ++ ) {
313
+ bytes [ i ] = binaryString . charCodeAt ( i ) ;
314
+ }
315
+
316
+ const blob = new Blob ( [ bytes ] , { type : mimeType } ) ;
317
+ const fileName = `pasted-image-${ Date . now ( ) } .${ extension } ` ;
318
+ const file = new File ( [ blob ] , fileName , { type : mimeType } ) ;
319
+
320
+ const reader = new FileReader ( ) ;
321
+ reader . onloadend = ( ) => {
322
+ dispatchImagePastedEvent ( view , reader . result as string , file ) ;
323
+ } ;
324
+
325
+ reader . readAsDataURL ( blob ) ;
326
+ } ;
327
+
328
+ /**
329
+ * Process an external URL image
330
+ *
331
+ * @param view - The editor view
332
+ * @param url - The URL of the image
333
+ */
334
+ const processExternalUrlImage = ( view : EditorView , url : string ) : void => {
335
+ fetch ( url )
336
+ . then ( ( response ) => {
337
+ if ( ! response . ok ) {
338
+ throw new Error (
339
+ `Failed to fetch image: ${ response . statusText } ` ,
340
+ ) ;
341
+ }
342
+
343
+ return response . blob ( ) ;
344
+ } )
345
+ . then ( ( blob ) => {
346
+ const fileType = blob . type || 'image/png' ;
347
+ const extension = fileType . split ( '/' ) [ 1 ] || 'png' ;
348
+ const fileName =
349
+ url . split ( '/' ) . pop ( ) ||
350
+ `external-image-${ Date . now ( ) } .${ extension } ` ;
351
+ const file = new File ( [ blob ] , fileName , { type : fileType } ) ;
352
+
353
+ const reader = new FileReader ( ) ;
354
+ reader . onloadend = ( ) => {
355
+ dispatchImagePastedEvent ( view , reader . result as string , file ) ;
356
+ } ;
357
+
358
+ reader . readAsDataURL ( blob ) ;
359
+ } )
360
+ . catch ( ( error ) => {
361
+ console . error ( 'Error processing external image:' , error ) ;
362
+ } ) ;
363
+ } ;
364
+
365
+ /**
366
+ * Create and dispatch an imagePasted event
367
+ *
368
+ * @param view - The editor view
369
+ * @param base64Data - The base64 data of the image
370
+ * @param file - The file object
371
+ */
372
+ const dispatchImagePastedEvent = (
373
+ view : EditorView ,
374
+ base64Data : string ,
375
+ file : File ,
376
+ ) : void => {
377
+ view . dom . dispatchEvent (
378
+ new CustomEvent ( 'imagePasted' , {
379
+ detail : imageInserterFactory (
380
+ view ,
381
+ base64Data ,
382
+ createFileInfo ( file ) ,
383
+ ) ,
384
+ } ) ,
268
385
) ;
386
+ } ;
269
387
270
- if ( filteredSlice . content . childCount < slice . content . childCount ) {
271
- const { state, dispatch } = view ;
272
- const tr = state . tr . replaceSelection ( filteredSlice ) ;
273
- dispatch ( tr ) ;
388
+ /**
389
+ * Convert a URL or data URL to a Blob
390
+ *
391
+ * @param url - The URL or data URL to convert
392
+ * @returns A Promise that resolves to a Blob or null if conversion fails
393
+ */
394
+ const urlToBlob = async ( url : string ) : Promise < Blob | null > => {
395
+ try {
396
+ const response = await fetch ( url ) ;
397
+ if ( ! response . ok ) {
398
+ return null ;
399
+ }
400
+
401
+ return await response . blob ( ) ;
402
+ } catch ( error ) {
403
+ console . error ( 'Error converting URL to blob:' , error ) ;
274
404
275
- return true ;
405
+ return null ;
276
406
}
407
+ } ;
408
+
409
+ /**
410
+ * Creates a smaller thumbnail from an image data URL
411
+ * @param dataUrl - Original image data URL
412
+ * @param maxWidth - Maximum width of thumbnail
413
+ * @param maxHeight - Maximum height of thumbnail
414
+ * @param quality - JPEG quality (0-1)
415
+ * @returns Promise resolving to a smaller thumbnail data URL
416
+ */
417
+ const createOptimizedThumbnail = (
418
+ dataUrl : string ,
419
+ maxWidth = 300 ,
420
+ maxHeight = 300 ,
421
+ quality = 0.7 ,
422
+ ) : Promise < string > => {
423
+ return new Promise ( ( resolve ) => {
424
+ const img = new Image ( ) ;
425
+ img . onload = ( ) => {
426
+ // Calculate new dimensions while maintaining aspect ratio
427
+ let width = img . width ;
428
+ let height = img . height ;
429
+
430
+ if ( width > maxWidth ) {
431
+ height = ( height * maxWidth ) / width ;
432
+ width = maxWidth ;
433
+ }
434
+
435
+ if ( height > maxHeight ) {
436
+ width = ( width * maxHeight ) / height ;
437
+ height = maxHeight ;
438
+ }
277
439
278
- return files . length > 0 ;
440
+ // Create canvas and draw resized image
441
+ const canvas = document . createElement ( 'canvas' ) ;
442
+ canvas . width = width ;
443
+ canvas . height = height ;
444
+ const ctx = canvas . getContext ( '2d' ) ;
445
+ ctx . drawImage ( img , 0 , 0 , width , height ) ;
446
+
447
+ // Convert to JPEG for better compression
448
+ const thumbnailDataUrl = canvas . toDataURL ( 'image/jpeg' , quality ) ;
449
+ resolve ( thumbnailDataUrl ) ;
450
+ } ;
451
+
452
+ // Handle potential loading errors
453
+ img . onerror = ( ) => {
454
+ console . warn ( 'Failed to create thumbnail, using original' ) ;
455
+ resolve ( dataUrl ) ;
456
+ } ;
457
+
458
+ img . src = dataUrl ;
459
+ } ) ;
279
460
} ;
0 commit comments