HandleImagePaste / HandleImageDrop
If you want PowerEditor to support image paste and image drag-and-drop, a practical approach is to listen to native paste and drop events on the outer container, convert image files to Data URLs, and then insert them into the editor.
This follows the same idea used in Fab3's editorBlock.vue:
- Bind native events on the host container
- Extract image files from
ClipboardEventorDragEvent - Convert files to base64 Data URLs with
FileReader - Insert
<img>through editor commands - Let
imgInterceptorcontinue with upload, replacement, and status updates if needed
Use Cases
- Users paste screenshots with
Ctrl + VorCmd + V - Users drag local image files into the editor
- You want instant local preview before uploading through
imgInterceptor
How It Works
The flow is straightforward:
- Bind
pasteanddropafter the component is mounted - Filter
image/*files - Convert files to Data URLs
- Use Tiptap
insertContent()to insert images - Remove listeners when the component is unmounted
Live Demo
This demo is interactive on the docs page itself:
- Drop image files into the editor area
- Focus the editor and paste a screenshot
- Or use the button to pick local image files
Waiting for images: drag and drop, paste, or file picker all work.
Full Example
The example below can be used directly in your app. It supports both image paste and drag-and-drop, and still works well with imgInterceptor.
<template>
<div ref="container" class="editor-host">
<power-editor
ref="editor"
v-model="content"
:theme="theme"
:img-interceptor="imgInterceptor"
style="width: 100%;"
/>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from "vue";
const container = ref(null);
const editor = ref(null);
const theme = ref("light");
const content = ref("<p>Paste or drop an image here.</p>");
const getClipboardImageFiles = (items = []) => {
const imageFiles = [];
for (const item of items) {
if (item.kind !== "file") continue;
if (!item.type || !item.type.startsWith("image/")) continue;
const file = item.getAsFile ? item.getAsFile() : null;
if (file) imageFiles.push(file);
}
return imageFiles;
};
const readFilesAsDataUrls = async (files = []) => {
return await Promise.all(
files.map(
(file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = () => reject(new Error("Read image file failed"));
reader.readAsDataURL(file);
})
)
);
};
const insertImages = (dataUrls = []) => {
if (!dataUrls.length) return;
const tiptap = editor.value?.editor?.();
if (!tiptap) return;
dataUrls.forEach((src) => {
tiptap
.chain()
.focus()
.insertContent(`<img src="${src}" theme="${theme.value}"></img>`)
.run();
});
};
const handleImagePaste = async (event) => {
const imageFiles = getClipboardImageFiles(event?.clipboardData?.items || []);
if (imageFiles.length === 0) return;
event.preventDefault();
try {
const dataUrls = await readFilesAsDataUrls(imageFiles);
insertImages(dataUrls);
} catch (error) {
console.error(error);
}
};
const handleImageDrop = async (event) => {
const files = Array.from(event?.dataTransfer?.files || []).filter(
(file) => file.type && file.type.startsWith("image/")
);
if (files.length === 0) return;
event.preventDefault();
try {
const dataUrls = await readFilesAsDataUrls(files);
insertImages(dataUrls);
} catch (error) {
console.error(error);
}
};
const bindNativeImageEvents = () => {
if (!container.value) return;
container.value.addEventListener("paste", handleImagePaste, true);
container.value.addEventListener("drop", handleImageDrop, true);
};
const unbindNativeImageEvents = () => {
if (!container.value) return;
container.value.removeEventListener("paste", handleImagePaste, true);
container.value.removeEventListener("drop", handleImageDrop, true);
};
const imgInterceptor = async ({
getImage,
interceptImage,
showStatus,
updateStatus,
updateImage,
updateLock,
}) => {
const src = getImage();
if (!src || !src.startsWith("data:image")) {
return;
}
const originalSrc = interceptImage("");
showStatus(true);
updateLock(true);
updateStatus(true, 0, "Uploading Image...");
try {
await new Promise((resolve) => setTimeout(resolve, 600));
updateStatus(true, 100, "Upload Complete");
updateImage(originalSrc);
} catch (error) {
console.error(error);
updateImage(originalSrc);
} finally {
showStatus(false);
updateLock(false);
}
};
onMounted(() => {
bindNativeImageEvents();
});
onBeforeUnmount(() => {
unbindNativeImageEvents();
});
</script>Options API Version
This version is closer to the pattern used in Fab3's editorBlock.vue.
export default {
methods: {
getEditor() {
return this.$refs.editor;
},
getClipboardImageFiles(items = []) {
const imageFiles = [];
for (const item of items) {
if (item.kind !== "file") continue;
if (!item.type || !item.type.startsWith("image/")) continue;
const file = item.getAsFile ? item.getAsFile() : null;
if (file) imageFiles.push(file);
}
return imageFiles;
},
async readFilesAsDataUrls(files = []) {
return await Promise.all(
files.map(
(file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = () => reject(new Error("Read image file failed"));
reader.readAsDataURL(file);
})
)
);
},
insertImages(dataUrls = []) {
const tiptap = this.getEditor()?.editor?.();
if (!tiptap) return;
dataUrls.forEach((src) => {
tiptap
.chain()
.focus()
.insertContent(`<img src="${src}" theme="${this.theme}"></img>`)
.run();
});
},
async handleImagePaste(event) {
const imageFiles = this.getClipboardImageFiles(
event?.clipboardData?.items || []
);
if (imageFiles.length === 0) return;
event.preventDefault();
const dataUrls = await this.readFilesAsDataUrls(imageFiles);
this.insertImages(dataUrls);
},
async handleImageDrop(event) {
const files = Array.from(event?.dataTransfer?.files || []).filter(
(file) => file.type && file.type.startsWith("image/")
);
if (files.length === 0) return;
event.preventDefault();
const dataUrls = await this.readFilesAsDataUrls(files);
this.insertImages(dataUrls);
},
bindNativeImageEvents() {
this.$nextTick(() => {
if (!this.$el) return;
this.$el.addEventListener("paste", this.handleImagePaste, true);
this.$el.addEventListener("drop", this.handleImageDrop, true);
});
},
},
mounted() {
this.bindNativeImageEvents();
},
beforeUnmount() {
if (!this.$el) return;
this.$el.removeEventListener("paste", this.handleImagePaste, true);
this.$el.removeEventListener("drop", this.handleImageDrop, true);
},
};Relationship With imgInterceptor
handleImagePaste and handleImageDrop are responsible for inserting local images into the editor.imgInterceptor is responsible for what happens after the image is inserted.
A good separation is:
handleImagePaste/handleImageDrop: turn files into Data URLs and insert themimgInterceptor: upload to the server, show progress, and replace the final URL
FAQ
1. Why bind to the outer container instead of only the inner editor DOM?
Because the outer container is usually easier to access from business components, and it is also a cleaner place to bind and unbind native events.
2. Why insert Data URLs first?
Because they can be previewed immediately. Later, imgInterceptor can replace them with remote URLs after upload.
3. Why call preventDefault() on drop?
Without it, the browser may open the dropped image file instead of inserting it into the editor.
4. Can this insert multiple images at once?
Yes. The examples above already support reading multiple files and inserting them one by one.
Minimal Setup
If you already have a PowerEditor instance, the minimum setup is:
this.$el.addEventListener("paste", this.handleImagePaste, true);
this.$el.addEventListener("drop", this.handleImageDrop, true);
this.$el.removeEventListener("paste", this.handleImagePaste, true);Then add:
getClipboardImageFiles()readFilesAsDataUrls()insertImages()
and image paste / drag-and-drop will work.