Skip to content

Commit 42a1689

Browse files
committed
temp: inserter - handle image pasting
1 parent 6d7e67b commit 42a1689

File tree

1 file changed

+253
-72
lines changed
  • src/components/text-editor/prosemirror-adapter/plugins/image

1 file changed

+253
-72
lines changed

src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts

Lines changed: 253 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ImageInfo,
88
ImageState,
99
} from '../../../text-editor.types';
10-
import { Node, Slice, Fragment } from 'prosemirror-model';
10+
import { Node } from 'prosemirror-model';
1111
import { imageCache } from './node';
1212

1313
export const pluginKey = new PluginKey('imageInserterPlugin');
@@ -27,8 +27,8 @@ export const createImageInserterPlugin = (
2727
return new Plugin({
2828
key: pluginKey,
2929
props: {
30-
handlePaste: (view, event, slice) => {
31-
return processPasteEvent(view, event, slice);
30+
handlePaste: (view, event) => {
31+
return processPasteEvent(view, event);
3232
},
3333
handleDOMEvents: {
3434
imagePasted: (_, event) => {
@@ -78,14 +78,16 @@ const findAndHandleRemovedImages = (
7878

7979
for (const removedKey of removedKeys) {
8080
const removedImage = previousImages[removedKey];
81+
const fileInfoId = removedImage.attrs.fileInfoId;
82+
8183
const imageInfo: ImageInfo = {
82-
fileInfoId: removedImage.attrs.fileInfoId,
84+
fileInfoId: fileInfoId,
8385
src: removedImage.attrs.src,
8486
state: removedImage.attrs.state,
8587
};
8688
imageRemovedCallback(imageInfo);
8789

88-
imageCache.delete(removedImage.attrs.fileInfoId);
90+
imageCache.delete(fileInfoId);
8991
}
9092
};
9193

@@ -107,6 +109,8 @@ const createThumbnailInserter =
107109
const { state, dispatch } = view;
108110
const { schema } = state;
109111

112+
// create a blob URL from the base64 data
113+
110114
const placeholderNode = schema.nodes.image.create({
111115
src: base64Data,
112116
alt: fileInfo.filename,
@@ -167,62 +171,6 @@ const createFailedThumbnailInserter =
167171
dispatch(tr);
168172
};
169173

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-
226174
/**
227175
* Process a paste event and trigger an imagePasted event if an image file is pasted.
228176
* 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 => {
234182
const processPasteEvent = (
235183
view: EditorView,
236184
event: ClipboardEvent,
237-
slice: Slice,
238185
): boolean => {
239186
const clipboardData = event.clipboardData;
240187
if (!clipboardData) {
@@ -261,19 +208,253 @@ const processPasteEvent = (
261208
}
262209
}
263210

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 (/^https?:\/\//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 = /^data:([^;]+);/.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 = /^data:([^;]+);base64,(.+)$/;
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+
}),
268385
);
386+
};
269387

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);
274404

275-
return true;
405+
return null;
276406
}
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+
}
277439

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+
});
279460
};

0 commit comments

Comments
 (0)