Skip to content
This repository was archived by the owner on Aug 12, 2025. It is now read-only.

Commit 8f6d543

Browse files
authored
feat: default editor supports pasting or dragging pictures to upload (#825)
#### What type of PR is this? /kind feature #### What this PR does / why we need it: 添加复制或者拖拽图片到编辑器上传的支持。 #### Which issue(s) this PR fixes: Fixes halo-dev/halo#3109 Fixes halo-dev/halo#2946 #### Screenshots: #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note Console 端的默认编辑器支持拖拽或者粘贴图片上传 ```
1 parent 658a8ce commit 8f6d543

File tree

3 files changed

+149
-3
lines changed

3 files changed

+149
-3
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"colorjs.io": "^0.4.2",
6464
"dayjs": "^1.11.6",
6565
"emoji-mart": "^5.3.3",
66+
"fastq": "^1.15.0",
6667
"floating-vue": "2.0.0-beta.20",
6768
"fuse.js": "^6.6.2",
6869
"lodash.clonedeep": "^4.5.0",

pnpm-lock.yaml

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/editor/DefaultEditor.vue

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
IconCharacterRecognition,
8282
IconLink,
8383
IconUserFollow,
84+
Toast,
8485
VTabItem,
8586
VTabs,
8687
} from "@halo-dev/components";
@@ -104,6 +105,11 @@ import {
104105
} from "vue";
105106
import { formatDatetime } from "@/utils/date";
106107
import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment";
108+
import { apiClient } from "@/utils/api-client";
109+
import * as fastq from "fastq";
110+
import type { queueAsPromised } from "fastq";
111+
import type { Attachment } from "@halo-dev/api-client";
112+
import { useFetchAttachmentPolicy } from "@/modules/contents/attachments/composables/use-attachment-policy";
107113
108114
const props = withDefaults(
109115
defineProps<{
@@ -168,6 +174,7 @@ const editor = useEditor({
168174
ExtensionText,
169175
ExtensionImage.configure({
170176
inline: true,
177+
allowBase64: false,
171178
HTMLAttributes: {
172179
loading: "lazy",
173180
},
@@ -250,8 +257,144 @@ const editor = useEditor({
250257
handleGenerateTableOfContent();
251258
});
252259
},
260+
editorProps: {
261+
handleDrop: (view, event: DragEvent, _, moved) => {
262+
if (!moved && event.dataTransfer && event.dataTransfer.files) {
263+
const images = Array.from(event.dataTransfer.files).filter((file) =>
264+
file.type.startsWith("image/")
265+
) as File[];
266+
267+
if (images.length === 0) {
268+
return;
269+
}
270+
271+
event.preventDefault();
272+
273+
images.forEach((file, index) => {
274+
uploadQueue.push({
275+
file,
276+
process: (url: string) => {
277+
const { schema } = view.state;
278+
const coordinates = view.posAtCoords({
279+
left: event.clientX,
280+
top: event.clientY,
281+
});
282+
283+
if (!coordinates) return;
284+
285+
const node = schema.nodes.image.create({
286+
src: url,
287+
});
288+
289+
const transaction = view.state.tr.insert(
290+
coordinates.pos + index,
291+
node
292+
);
293+
294+
editor.value?.view.dispatch(transaction);
295+
},
296+
});
297+
});
298+
299+
return true;
300+
}
301+
return false;
302+
},
303+
handlePaste: (view, event: ClipboardEvent, slice) => {
304+
const images = Array.from(event.clipboardData?.items || [])
305+
.map((item) => {
306+
return item.getAsFile();
307+
})
308+
.filter((file) => {
309+
return file && file.type.startsWith("image/");
310+
}) as File[];
311+
312+
if (images.length === 0) {
313+
return;
314+
}
315+
316+
event.preventDefault();
317+
318+
images.forEach((file) => {
319+
uploadQueue.push({
320+
file,
321+
process: (url: string) => {
322+
editor.value
323+
?.chain()
324+
.focus()
325+
.insertContent([
326+
{
327+
type: "image",
328+
attrs: {
329+
src: url,
330+
},
331+
},
332+
])
333+
.run();
334+
},
335+
});
336+
});
337+
},
338+
},
253339
});
254340
341+
// image drag and paste upload
342+
const { policies } = useFetchAttachmentPolicy({ fetchOnMounted: true });
343+
344+
type Task = {
345+
file: File;
346+
process: (permalink: string) => void;
347+
};
348+
349+
const uploadQueue: queueAsPromised<Task> = fastq.promise(asyncWorker, 1);
350+
351+
async function asyncWorker(arg: Task): Promise<void> {
352+
if (!policies.value.length) {
353+
Toast.warning("目前没有可用的存储策略");
354+
return;
355+
}
356+
357+
const { data: attachmentData } = await apiClient.attachment.uploadAttachment({
358+
file: arg.file,
359+
policyName: policies.value[0].metadata.name,
360+
});
361+
362+
const permalink = await handleFetchPermalink(attachmentData, 3);
363+
364+
if (permalink) {
365+
arg.process(permalink);
366+
}
367+
}
368+
369+
const handleFetchPermalink = async (
370+
attachment: Attachment,
371+
maxRetry: number
372+
): Promise<string | undefined> => {
373+
if (maxRetry === 0) {
374+
Toast.error(`获取附件永久链接失败:${attachment.spec.displayName}`);
375+
return undefined;
376+
}
377+
378+
const { data } =
379+
await apiClient.extension.storage.attachment.getstorageHaloRunV1alpha1Attachment(
380+
{
381+
name: attachment.metadata.name,
382+
}
383+
);
384+
385+
if (data.status?.permalink) {
386+
return data.status.permalink;
387+
}
388+
389+
return await new Promise((resolve) => {
390+
const timer = setTimeout(() => {
391+
const permalink = handleFetchPermalink(attachment, maxRetry - 1);
392+
clearTimeout(timer);
393+
resolve(permalink);
394+
}, 300);
395+
});
396+
};
397+
255398
const toolbarMenuItems = computed(() => {
256399
if (!editor.value) return [];
257400
return [

0 commit comments

Comments
 (0)