Skip to content

Commit c623437

Browse files
feat(text-editor): add support for pasting inline images
1 parent 0c57616 commit c623437

13 files changed

+765
-109
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Component, h, State } from '@stencil/core';
2+
import { ImageInserter, FileInfo } from '@limetech/lime-elements';
3+
/**
4+
* Handling inline images
5+
*
6+
* To allow users to paste images directly into the text editor, you can
7+
* listen to the `imagePasted` event, which is triggered when an image file
8+
* is pasted into the editor.
9+
*
10+
* The `imagePasted` event contains an `ImageInserter` object, which you can
11+
* use to insert a thumbnail of the pasted image into the editor. The
12+
* `ImageInserter` object also contains the image file, which you can upload
13+
* to an external file storage. When the image is uploaded, you can insert
14+
* the src url of the uploaded image into the editor using the `insertImage`
15+
* method. If the image upload fails, you can insert an error thumbnail
16+
* using the `insertErrorThumbnail` method.
17+
*/
18+
@Component({
19+
tag: 'limel-example-text-editor-with-inline-images',
20+
shadow: true,
21+
})
22+
export class TextEditorWithInlineImagesExample {
23+
@State()
24+
private value: string = 'Copy an image file and paste it here.';
25+
26+
@State()
27+
private readonly = false;
28+
29+
public render() {
30+
return [
31+
<limel-text-editor
32+
value={this.value}
33+
onChange={this.handleChange}
34+
readonly={this.readonly}
35+
onImagePasted={this.handleImagePasted}
36+
onImageRemoved={this.handleImageRemoved}
37+
contentType="html"
38+
/>,
39+
<limel-example-controls>
40+
<limel-checkbox
41+
checked={this.readonly}
42+
label="Readonly"
43+
onChange={this.setReadonly}
44+
/>
45+
</limel-example-controls>,
46+
];
47+
}
48+
49+
private setReadonly = (event: CustomEvent<boolean>) => {
50+
event.stopPropagation();
51+
this.readonly = event.detail;
52+
};
53+
54+
private handleChange = (event: CustomEvent<string>) => {
55+
this.value = event.detail;
56+
};
57+
58+
private handleImagePasted = async (event: CustomEvent<ImageInserter>) => {
59+
const imageInserter = event.detail;
60+
61+
imageInserter.insertThumbnail();
62+
const file = await this.uploadImage(imageInserter.fileInfo);
63+
if (file) {
64+
imageInserter.insertImage(file.src);
65+
} else {
66+
imageInserter.insertErrorThumbnail();
67+
}
68+
};
69+
70+
private uploadImage = async (fileInfo: FileInfo): Promise<any> => {
71+
try {
72+
// Upload image to external file storage
73+
} catch (error) {
74+
console.error('Failed to upload image', error);
75+
}
76+
};
77+
78+
private handleImageRemoved = (event: CustomEvent<string>) => {
79+
const src = event.detail;
80+
81+
try {
82+
// Delete image from external file storage
83+
} catch (error) {
84+
console.error('Failed to delete image', error);
85+
}
86+
};
87+
}

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

-99
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { Plugin, PluginKey, Transaction } from 'prosemirror-state';
2+
import { EditorView } from 'prosemirror-view';
3+
import { createFileInfo } from '../../../../../util/files';
4+
import { FileInfo } from '../../../../../global/shared-types/file.types';
5+
import { ImageInserter } from '../../../text-editor.types';
6+
import { ImageState } from './state';
7+
8+
export const pluginKey = new PluginKey('imageInserterPlugin');
9+
10+
export type ImagePastedCallback = (imageInserter: ImageInserter) => void;
11+
export type ImageRemovedCallback = (src: string) => void;
12+
13+
export const createImageInserterPlugin = (
14+
imagePastedCallback: ImagePastedCallback,
15+
imageRemovedCallback: ImageRemovedCallback,
16+
) => {
17+
return new Plugin({
18+
key: pluginKey,
19+
props: {
20+
handlePaste: (view, event, _) => {
21+
return processPasteEvent(view, event);
22+
},
23+
handleDOMEvents: {
24+
imagePasted: (_, event) => {
25+
imagePastedCallback(event.detail);
26+
},
27+
},
28+
},
29+
state: {
30+
init() {
31+
return { images: [] };
32+
},
33+
apply(tr, pluginState) {
34+
let newState = { ...pluginState };
35+
36+
newState.images = getImagesFromTransaction(tr);
37+
findAndHandleRemovedImages(
38+
imageRemovedCallback,
39+
pluginState.images,
40+
newState.images,
41+
);
42+
43+
return newState;
44+
},
45+
},
46+
});
47+
};
48+
49+
const getImagesFromTransaction = (tr: Transaction): string[] => {
50+
const newImages: string[] = [];
51+
tr.doc.descendants((node) => {
52+
if (
53+
node.type.name === 'image' &&
54+
node.attrs.state === ImageState.SUCCESS
55+
) {
56+
newImages.push(node.attrs.src);
57+
}
58+
});
59+
return newImages;
60+
};
61+
62+
const findAndHandleRemovedImages = (
63+
imageRemovedCallback: ImageRemovedCallback,
64+
previousImages: string[],
65+
newImages: string[],
66+
) => {
67+
const removedImages = previousImages.filter(
68+
(src: string) => !newImages.includes(src),
69+
);
70+
for (const src of removedImages) {
71+
imageRemovedCallback(src);
72+
}
73+
};
74+
75+
export const imageInserterFactory = (
76+
view: EditorView,
77+
base64Data: string,
78+
fileInfo: FileInfo,
79+
): ImageInserter => {
80+
return {
81+
fileInfo: fileInfo,
82+
insertThumbnail: createThumbnailInserter(view, base64Data, fileInfo),
83+
insertImage: createImageInserter(view, fileInfo),
84+
insertErrorThumbnail: createErrorThumbnailInserter(view, fileInfo),
85+
};
86+
};
87+
88+
const createThumbnailInserter =
89+
(view: EditorView, base64Data: string, fileInfo: FileInfo) => () => {
90+
const { state, dispatch } = view;
91+
const { schema } = state;
92+
93+
const placeholderNode = schema.nodes.image.create({
94+
src: base64Data,
95+
alt: fileInfo.filename,
96+
fileInfoId: fileInfo.id,
97+
state: ImageState.LOADING,
98+
});
99+
100+
const transaction = state.tr.replaceSelectionWith(placeholderNode);
101+
102+
dispatch(transaction);
103+
};
104+
105+
const createImageInserter =
106+
(view: EditorView, fileInfo: FileInfo) => (src?: string) => {
107+
const { state, dispatch } = view;
108+
const { schema } = state;
109+
110+
const tr = state.tr;
111+
state.doc.descendants((node, pos) => {
112+
if (node.attrs.fileInfoId === fileInfo.id) {
113+
const imageNode = schema.nodes.image.create({
114+
src: src ? src : node.attrs.src,
115+
alt: fileInfo.filename,
116+
fileInfoId: fileInfo.id,
117+
state: ImageState.SUCCESS,
118+
});
119+
120+
tr.replaceWith(pos, pos + node.nodeSize, imageNode);
121+
122+
return false;
123+
}
124+
});
125+
126+
dispatch(tr);
127+
};
128+
129+
const createErrorThumbnailInserter =
130+
(view: EditorView, fileInfo: FileInfo) => () => {
131+
const { state, dispatch } = view;
132+
const { schema } = state;
133+
134+
const tr = state.tr;
135+
state.doc.descendants((node, pos) => {
136+
if (node.attrs.fileInfoId === fileInfo.id) {
137+
const errorPlaceholderNode = schema.nodes.image.create({
138+
src: node.attrs.src,
139+
alt: fileInfo.filename,
140+
fileInfoId: fileInfo.id,
141+
state: ImageState.FAILED,
142+
});
143+
144+
tr.replaceWith(pos, pos + node.nodeSize, errorPlaceholderNode);
145+
146+
return false;
147+
}
148+
});
149+
150+
dispatch(tr);
151+
};
152+
153+
/**
154+
* Process a paste event and trigger an imagePasted event if an image file is pasted.
155+
* @param view - The ProseMirror editor view.
156+
* @param event - The paste event.
157+
* @returns A boolean; True if an image file was pasted to prevent default paste behavior, otherwise false.
158+
*/
159+
const processPasteEvent = (
160+
view: EditorView,
161+
event: ClipboardEvent,
162+
): boolean => {
163+
const clipboardData = event.clipboardData;
164+
if (!clipboardData) {
165+
return false;
166+
}
167+
168+
const files = Array.from(clipboardData.files || []);
169+
for (const file of files) {
170+
if (file.type.startsWith('image/')) {
171+
const reader = new FileReader();
172+
reader.onloadend = () => {
173+
view.dom.dispatchEvent(
174+
new CustomEvent('imagePasted', {
175+
detail: imageInserterFactory(
176+
view,
177+
reader.result as string,
178+
createFileInfo(file),
179+
),
180+
}),
181+
);
182+
};
183+
184+
reader.readAsDataURL(file);
185+
}
186+
}
187+
188+
return files.length > 0;
189+
};

0 commit comments

Comments
 (0)