mirror of https://github.com/halo-dev/halo-admin
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 https://github.com/halo-dev/halo/issues/3109 Fixes https://github.com/halo-dev/halo/issues/2946 #### Screenshots: #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note Console 端的默认编辑器支持拖拽或者粘贴图片上传 ```pull/836/head
parent
658a8ce4ee
commit
8f6d543f4d
|
@ -63,6 +63,7 @@
|
||||||
"colorjs.io": "^0.4.2",
|
"colorjs.io": "^0.4.2",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"emoji-mart": "^5.3.3",
|
"emoji-mart": "^5.3.3",
|
||||||
|
"fastq": "^1.15.0",
|
||||||
"floating-vue": "2.0.0-beta.20",
|
"floating-vue": "2.0.0-beta.20",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
|
|
@ -62,6 +62,7 @@ importers:
|
||||||
eslint: ^8.28.0
|
eslint: ^8.28.0
|
||||||
eslint-plugin-cypress: ^2.12.1
|
eslint-plugin-cypress: ^2.12.1
|
||||||
eslint-plugin-vue: ^9.8.0
|
eslint-plugin-vue: ^9.8.0
|
||||||
|
fastq: ^1.15.0
|
||||||
floating-vue: 2.0.0-beta.20
|
floating-vue: 2.0.0-beta.20
|
||||||
fuse.js: ^6.6.2
|
fuse.js: ^6.6.2
|
||||||
husky: ^8.0.2
|
husky: ^8.0.2
|
||||||
|
@ -131,6 +132,7 @@ importers:
|
||||||
colorjs.io: 0.4.2
|
colorjs.io: 0.4.2
|
||||||
dayjs: 1.11.6
|
dayjs: 1.11.6
|
||||||
emoji-mart: 5.3.3
|
emoji-mart: 5.3.3
|
||||||
|
fastq: 1.15.0
|
||||||
floating-vue: 2.0.0-beta.20_vue@3.2.45
|
floating-vue: 2.0.0-beta.20_vue@3.2.45
|
||||||
fuse.js: 6.6.2
|
fuse.js: 6.6.2
|
||||||
lodash.clonedeep: 4.5.0
|
lodash.clonedeep: 4.5.0
|
||||||
|
@ -2728,7 +2730,7 @@ packages:
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.13.0
|
fastq: 1.15.0
|
||||||
|
|
||||||
/@polka/url/1.0.0-next.21:
|
/@polka/url/1.0.0-next.21:
|
||||||
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
|
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
|
||||||
|
@ -5977,8 +5979,8 @@ packages:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/fastq/1.13.0:
|
/fastq/1.15.0:
|
||||||
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
|
resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.0.4
|
reusify: 1.0.4
|
||||||
|
|
||||||
|
|
|
@ -81,6 +81,7 @@ import {
|
||||||
IconCharacterRecognition,
|
IconCharacterRecognition,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconUserFollow,
|
IconUserFollow,
|
||||||
|
Toast,
|
||||||
VTabItem,
|
VTabItem,
|
||||||
VTabs,
|
VTabs,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
|
@ -104,6 +105,11 @@ import {
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { formatDatetime } from "@/utils/date";
|
import { formatDatetime } from "@/utils/date";
|
||||||
import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment";
|
import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import * as fastq from "fastq";
|
||||||
|
import type { queueAsPromised } from "fastq";
|
||||||
|
import type { Attachment } from "@halo-dev/api-client";
|
||||||
|
import { useFetchAttachmentPolicy } from "@/modules/contents/attachments/composables/use-attachment-policy";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -168,6 +174,7 @@ const editor = useEditor({
|
||||||
ExtensionText,
|
ExtensionText,
|
||||||
ExtensionImage.configure({
|
ExtensionImage.configure({
|
||||||
inline: true,
|
inline: true,
|
||||||
|
allowBase64: false,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
loading: "lazy",
|
loading: "lazy",
|
||||||
},
|
},
|
||||||
|
@ -250,8 +257,144 @@ const editor = useEditor({
|
||||||
handleGenerateTableOfContent();
|
handleGenerateTableOfContent();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
editorProps: {
|
||||||
|
handleDrop: (view, event: DragEvent, _, moved) => {
|
||||||
|
if (!moved && event.dataTransfer && event.dataTransfer.files) {
|
||||||
|
const images = Array.from(event.dataTransfer.files).filter((file) =>
|
||||||
|
file.type.startsWith("image/")
|
||||||
|
) as File[];
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
images.forEach((file, index) => {
|
||||||
|
uploadQueue.push({
|
||||||
|
file,
|
||||||
|
process: (url: string) => {
|
||||||
|
const { schema } = view.state;
|
||||||
|
const coordinates = view.posAtCoords({
|
||||||
|
left: event.clientX,
|
||||||
|
top: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coordinates) return;
|
||||||
|
|
||||||
|
const node = schema.nodes.image.create({
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transaction = view.state.tr.insert(
|
||||||
|
coordinates.pos + index,
|
||||||
|
node
|
||||||
|
);
|
||||||
|
|
||||||
|
editor.value?.view.dispatch(transaction);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
handlePaste: (view, event: ClipboardEvent, slice) => {
|
||||||
|
const images = Array.from(event.clipboardData?.items || [])
|
||||||
|
.map((item) => {
|
||||||
|
return item.getAsFile();
|
||||||
|
})
|
||||||
|
.filter((file) => {
|
||||||
|
return file && file.type.startsWith("image/");
|
||||||
|
}) as File[];
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
images.forEach((file) => {
|
||||||
|
uploadQueue.push({
|
||||||
|
file,
|
||||||
|
process: (url: string) => {
|
||||||
|
editor.value
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
attrs: {
|
||||||
|
src: url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// image drag and paste upload
|
||||||
|
const { policies } = useFetchAttachmentPolicy({ fetchOnMounted: true });
|
||||||
|
|
||||||
|
type Task = {
|
||||||
|
file: File;
|
||||||
|
process: (permalink: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadQueue: queueAsPromised<Task> = fastq.promise(asyncWorker, 1);
|
||||||
|
|
||||||
|
async function asyncWorker(arg: Task): Promise<void> {
|
||||||
|
if (!policies.value.length) {
|
||||||
|
Toast.warning("目前没有可用的存储策略");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: attachmentData } = await apiClient.attachment.uploadAttachment({
|
||||||
|
file: arg.file,
|
||||||
|
policyName: policies.value[0].metadata.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const permalink = await handleFetchPermalink(attachmentData, 3);
|
||||||
|
|
||||||
|
if (permalink) {
|
||||||
|
arg.process(permalink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchPermalink = async (
|
||||||
|
attachment: Attachment,
|
||||||
|
maxRetry: number
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (maxRetry === 0) {
|
||||||
|
Toast.error(`获取附件永久链接失败:${attachment.spec.displayName}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } =
|
||||||
|
await apiClient.extension.storage.attachment.getstorageHaloRunV1alpha1Attachment(
|
||||||
|
{
|
||||||
|
name: attachment.metadata.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.status?.permalink) {
|
||||||
|
return data.status.permalink;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const permalink = handleFetchPermalink(attachment, maxRetry - 1);
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(permalink);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toolbarMenuItems = computed(() => {
|
const toolbarMenuItems = computed(() => {
|
||||||
if (!editor.value) return [];
|
if (!editor.value) return [];
|
||||||
return [
|
return [
|
||||||
|
|
Loading…
Reference in New Issue