HandleImagePaste / HandleImageDrop
当你希望 PowerEditor 支持“直接粘贴图片”或“把图片拖进编辑器”时,推荐在编辑器外层容器监听原生 paste / drop 事件,然后把图片文件读成 Data URL,再插入到编辑器中。
这种方式和 Fab3 里的 editorBlock.vue 思路一致:
- 在宿主容器上绑定原生事件
- 从
ClipboardEvent或DragEvent里提取图片文件 - 用
FileReader转成 base64 Data URL - 通过编辑器命令插入
<img> - 如果你还配置了
imgInterceptor,插入后的图片还能继续走上传、替换、状态提示等流程
适用场景
- 用户按
Ctrl + V/Cmd + V直接粘贴截图 - 用户把本地图片文件拖进编辑器
- 希望先本地预览,再交给
imgInterceptor上传
实现思路
整体流程如下:
- 在组件挂载后给编辑器容器绑定
paste和drop - 过滤出
image/*类型文件 - 把文件转成 Data URL
- 调用 Tiptap
insertContent()插入图片 - 在组件卸载时移除监听器
可直接运行的示例
这个 demo 就是完整可运行的文档示例:
- 把图片拖进编辑器区域
- 在编辑器聚焦后直接粘贴截图
- 或点击按钮选择本地图片文件
Waiting for images: drag and drop, paste, or file picker all work.
完整示例
下面这个示例可以直接放到你的业务组件里。它同时支持图片粘贴和拖拽,并保留 imgInterceptor 的扩展能力。
vue
<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
这版写法更接近 Fab3 里的 editorBlock.vue。
js
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);
},
};和 imgInterceptor 的关系
handleImagePaste / handleImageDrop 负责“把本地图片插进编辑器”。imgInterceptor 负责“图片插入后如何处理”。
推荐组合方式:
handleImagePaste/handleImageDrop:把图片转成 Data URL 并插入imgInterceptor:上传到服务器、显示进度、替换成远程地址
这样职责会比较清晰。
常见问题
1. 为什么要监听外层容器,而不是只监听编辑器内部 DOM?
因为业务组件通常更容易拿到外层容器,也更方便统一解绑事件。
只要事件冒泡路径能覆盖到编辑器区域,就可以正常工作。
2. 为什么插入的是 Data URL?
因为 Data URL 能立即预览,不需要等上传完成。
之后再通过 imgInterceptor 把临时地址替换成真正的远程地址。
3. 拖拽图片后为什么要 preventDefault()?
如果不阻止默认行为,浏览器可能直接打开图片文件,而不是把它插入编辑器。
4. 多张图片可以一次插入吗?
可以。上面的示例已经支持一次读取多个文件,并逐个插入。
最小接入示例
如果你已经有 PowerEditor 实例,最少只需要下面三步:
js
this.$el.addEventListener("paste", this.handleImagePaste, true);
this.$el.addEventListener("drop", this.handleImageDrop, true);
this.$el.removeEventListener("paste", this.handleImagePaste, true);再加上:
getClipboardImageFiles()readFilesAsDataUrls()insertImages()
就能把图片粘贴和拖拽接进来。