diff --git a/package.json b/package.json index 6260c1c6d..ead794ee6 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "colorjs.io": "^0.4.2", "dayjs": "^1.11.6", "emoji-mart": "^5.3.3", + "fastq": "^1.15.0", "floating-vue": "2.0.0-beta.20", "fuse.js": "^6.6.2", "lodash.clonedeep": "^4.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c3acbfec..65a9e14c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,7 @@ importers: eslint: ^8.28.0 eslint-plugin-cypress: ^2.12.1 eslint-plugin-vue: ^9.8.0 + fastq: ^1.15.0 floating-vue: 2.0.0-beta.20 fuse.js: ^6.6.2 husky: ^8.0.2 @@ -131,6 +132,7 @@ importers: colorjs.io: 0.4.2 dayjs: 1.11.6 emoji-mart: 5.3.3 + fastq: 1.15.0 floating-vue: 2.0.0-beta.20_vue@3.2.45 fuse.js: 6.6.2 lodash.clonedeep: 4.5.0 @@ -2728,7 +2730,7 @@ packages: engines: {node: '>= 8'} dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.13.0 + fastq: 1.15.0 /@polka/url/1.0.0-next.21: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} @@ -5977,8 +5979,8 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true - /fastq/1.13.0: - resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} + /fastq/1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 diff --git a/src/components/editor/DefaultEditor.vue b/src/components/editor/DefaultEditor.vue index fecdd571f..37bb8e53b 100644 --- a/src/components/editor/DefaultEditor.vue +++ b/src/components/editor/DefaultEditor.vue @@ -81,6 +81,7 @@ import { IconCharacterRecognition, IconLink, IconUserFollow, + Toast, VTabItem, VTabs, } from "@halo-dev/components"; @@ -104,6 +105,11 @@ import { } from "vue"; import { formatDatetime } from "@/utils/date"; 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( defineProps<{ @@ -168,6 +174,7 @@ const editor = useEditor({ ExtensionText, ExtensionImage.configure({ inline: true, + allowBase64: false, HTMLAttributes: { loading: "lazy", }, @@ -250,8 +257,144 @@ const editor = useEditor({ 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 = fastq.promise(asyncWorker, 1); + +async function asyncWorker(arg: Task): Promise { + 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 => { + 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(() => { if (!editor.value) return []; return [