mirror of https://github.com/halo-dev/halo
Add external asset transfer for editor attachments (#7687)
#### What type of PR is this? /area ui /area editor /kind feature /milestone 2.21.x #### What this PR does / why we need it: Support transfer external assets in the editor to the attachment library. Currently, it supports individual images, videos, and audio files. <img width="845" height="167" alt="image" src="https://github.com/user-attachments/assets/930c6207-60f5-491a-afbd-c3f75b0d76a6" /> in progress: - [ ] Batch transferring of all external assets. #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2335 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 支持转存编辑器中的外部资源到附件库 ```pull/7700/head
parent
7d51f38d96
commit
3105c53b6f
|
@ -1,4 +1,10 @@
|
|||
import { useGlobalInfoStore } from "@/stores/global-info";
|
||||
import { ucApiClient } from "@halo-dev/api-client";
|
||||
import { Toast } from "@halo-dev/components";
|
||||
import type { AttachmentLike } from "@halo-dev/console-shared";
|
||||
import { computed, ref, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { AttachmentAttr } from "../utils/attachment";
|
||||
|
||||
interface useAttachmentSelectReturn {
|
||||
onAttachmentSelect: (attachments: AttachmentLike[]) => void;
|
||||
|
@ -24,3 +30,56 @@ export function useAttachmentSelect(): useAttachmentSelectReturn {
|
|||
attachmentResult,
|
||||
};
|
||||
}
|
||||
|
||||
export function useExternalAssetsTransfer(
|
||||
src: Ref<string | undefined>,
|
||||
callback: (attachment: AttachmentAttr) => void
|
||||
) {
|
||||
const { globalInfo } = useGlobalInfoStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isExternalAsset = computed(() => {
|
||||
if (src.value?.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!globalInfo?.externalUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !src.value?.startsWith(globalInfo?.externalUrl);
|
||||
});
|
||||
|
||||
const transferring = ref(false);
|
||||
|
||||
async function handleTransfer() {
|
||||
if (!src.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
transferring.value = true;
|
||||
|
||||
const { data } =
|
||||
await ucApiClient.storage.attachment.externalTransferAttachment1({
|
||||
ucUploadFromUrlRequest: {
|
||||
url: src.value,
|
||||
},
|
||||
waitForPermalink: true,
|
||||
});
|
||||
|
||||
callback({
|
||||
url: data.status?.permalink,
|
||||
name: data.spec.displayName,
|
||||
});
|
||||
|
||||
Toast.success(t("core.common.toast.save_success"));
|
||||
|
||||
transferring.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
isExternalAsset,
|
||||
transferring,
|
||||
handleTransfer,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||
import { VButton } from "@halo-dev/components";
|
||||
import type { NodeViewProps } from "@halo-dev/richtext-editor";
|
||||
import { computed, ref } from "vue";
|
||||
import RiFileMusicLine from "~icons/ri/file-music-line";
|
||||
import { EditorLinkObtain } from "../../components";
|
||||
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
||||
import { useExternalAssetsTransfer } from "../../composables/use-attachment";
|
||||
import type { AttachmentAttr } from "../../utils/attachment";
|
||||
|
||||
const props = defineProps<NodeViewProps>();
|
||||
|
@ -64,6 +66,9 @@ const handleResetInit = () => {
|
|||
file: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const { isExternalAsset, transferring, handleTransfer } =
|
||||
useExternalAssetsTransfer(src, handleSetExternalLink);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -92,8 +97,28 @@ const handleResetInit = () => {
|
|||
></audio>
|
||||
<div
|
||||
v-if="src"
|
||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end gap-2 bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||
>
|
||||
<HasPermission :permissions="['uc:attachments:manage']">
|
||||
<VButton
|
||||
v-if="isExternalAsset"
|
||||
v-tooltip="
|
||||
$t(
|
||||
'core.components.default_editor.extensions.upload.operations.transfer.tooltip'
|
||||
)
|
||||
"
|
||||
:loading="transferring"
|
||||
size="sm"
|
||||
ghost
|
||||
@click="handleTransfer"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.extensions.upload.operations.transfer.button"
|
||||
)
|
||||
}}
|
||||
</VButton>
|
||||
</HasPermission>
|
||||
<VButton size="xs" type="secondary" @click="handleResetInit">
|
||||
{{
|
||||
$t(
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
<script lang="ts" setup>
|
||||
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||
import { IconImageAddLine, VButton } from "@halo-dev/components";
|
||||
import { type NodeViewProps } from "@halo-dev/richtext-editor";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { EditorLinkObtain } from "../../components";
|
||||
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
||||
import { useExternalAssetsTransfer } from "../../composables/use-attachment";
|
||||
import { type AttachmentAttr } from "../../utils/attachment";
|
||||
import { fileToBase64 } from "../../utils/upload";
|
||||
import Image from "./index";
|
||||
|
@ -143,6 +145,9 @@ onMounted(() => {
|
|||
document.documentElement.removeEventListener("mouseup", stopDrag, false);
|
||||
}
|
||||
});
|
||||
|
||||
const { isExternalAsset, transferring, handleTransfer } =
|
||||
useExternalAssetsTransfer(src, handleSetExternalLink);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -171,8 +176,29 @@ onMounted(() => {
|
|||
|
||||
<div
|
||||
v-if="src"
|
||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end gap-2 bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||
>
|
||||
<HasPermission :permissions="['uc:attachments:manage']">
|
||||
<VButton
|
||||
v-if="isExternalAsset"
|
||||
v-tooltip="
|
||||
$t(
|
||||
'core.components.default_editor.extensions.upload.operations.transfer.tooltip'
|
||||
)
|
||||
"
|
||||
:loading="transferring"
|
||||
size="sm"
|
||||
ghost
|
||||
@click="handleTransfer"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.extensions.upload.operations.transfer.button"
|
||||
)
|
||||
}}
|
||||
</VButton>
|
||||
</HasPermission>
|
||||
|
||||
<VButton size="sm" type="secondary" @click="handleResetInit">
|
||||
{{
|
||||
$t(
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
import { i18n } from "@/locales";
|
||||
import { Dialog, Toast } from "@halo-dev/components";
|
||||
import {
|
||||
CoreEditor,
|
||||
Extension,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
PMNode,
|
||||
Slice,
|
||||
} from "@halo-dev/richtext-editor";
|
||||
import { UiExtensionAudio, UiExtensionImage, UiExtensionVideo } from "..";
|
||||
import {
|
||||
batchUploadExternalLink,
|
||||
containsFileClipboardIdentifier,
|
||||
handleFileEvent,
|
||||
isExternalAsset,
|
||||
} from "../../utils/upload";
|
||||
|
||||
export const Upload = Extension.create({
|
||||
|
@ -19,7 +26,7 @@ export const Upload = Extension.create({
|
|||
new Plugin({
|
||||
key: new PluginKey("upload"),
|
||||
props: {
|
||||
handlePaste: (view, event: ClipboardEvent) => {
|
||||
handlePaste: (view, event: ClipboardEvent, slice: Slice) => {
|
||||
if (view.props.editable && !view.props.editable(view.state)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -28,6 +35,25 @@ export const Upload = Extension.create({
|
|||
return false;
|
||||
}
|
||||
|
||||
const externalNodes = getAllExternalNodes(slice);
|
||||
if (externalNodes.length > 0) {
|
||||
Dialog.info({
|
||||
title: i18n.global.t("core.common.text.tip"),
|
||||
description: i18n.global.t(
|
||||
"core.components.default_editor.extensions.upload.operations.transfer_in_batch.description"
|
||||
),
|
||||
confirmText: i18n.global.t("core.common.buttons.confirm"),
|
||||
cancelText: i18n.global.t("core.common.buttons.cancel"),
|
||||
async onConfirm() {
|
||||
await batchUploadExternalLink(editor, externalNodes);
|
||||
|
||||
Toast.success(
|
||||
i18n.global.t("core.common.toast.save_success")
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const types = event.clipboardData.types;
|
||||
// Only process when a single file is pasted.
|
||||
if (types.length > 1) {
|
||||
|
@ -87,4 +113,33 @@ export const Upload = Extension.create({
|
|||
},
|
||||
});
|
||||
|
||||
const checkExternalLinkNodeTypes = [
|
||||
UiExtensionAudio.name,
|
||||
UiExtensionVideo.name,
|
||||
UiExtensionImage.name,
|
||||
];
|
||||
export function getAllExternalNodes(
|
||||
slice: Slice
|
||||
): { node: PMNode; pos: number; index: number; parent: PMNode | null }[] {
|
||||
const externalNodes: {
|
||||
node: PMNode;
|
||||
pos: number;
|
||||
index: number;
|
||||
parent: PMNode | null;
|
||||
}[] = [];
|
||||
slice.content.descendants((node, pos, parent, index) => {
|
||||
if (checkExternalLinkNodeTypes.includes(node.type.name)) {
|
||||
if (isExternalAsset(node.attrs.src)) {
|
||||
externalNodes.push({
|
||||
node,
|
||||
pos,
|
||||
parent,
|
||||
index,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return externalNodes;
|
||||
}
|
||||
|
||||
export default Upload;
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||
import { VButton } from "@halo-dev/components";
|
||||
import type { NodeViewProps } from "@halo-dev/richtext-editor";
|
||||
import { computed, ref } from "vue";
|
||||
import RiVideoAddLine from "~icons/ri/video-add-line";
|
||||
import { EditorLinkObtain } from "../../components";
|
||||
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
||||
import { useExternalAssetsTransfer } from "../../composables/use-attachment";
|
||||
import type { AttachmentAttr } from "../../utils/attachment";
|
||||
|
||||
const props = defineProps<NodeViewProps>();
|
||||
|
@ -68,6 +70,9 @@ const handleResetInit = () => {
|
|||
file: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const { isExternalAsset, transferring, handleTransfer } =
|
||||
useExternalAssetsTransfer(src, handleSetExternalLink);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -97,8 +102,28 @@ const handleResetInit = () => {
|
|||
></video>
|
||||
<div
|
||||
v-if="src"
|
||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end gap-2 bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||
>
|
||||
<HasPermission :permissions="['uc:attachments:manage']">
|
||||
<VButton
|
||||
v-if="isExternalAsset"
|
||||
v-tooltip="
|
||||
$t(
|
||||
'core.components.default_editor.extensions.upload.operations.transfer.tooltip'
|
||||
)
|
||||
"
|
||||
:loading="transferring"
|
||||
size="sm"
|
||||
ghost
|
||||
@click="handleTransfer"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.extensions.upload.operations.transfer.button"
|
||||
)
|
||||
}}
|
||||
</VButton>
|
||||
</HasPermission>
|
||||
<VButton size="sm" type="secondary" @click="handleResetInit">
|
||||
{{
|
||||
$t(
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
// image drag and paste upload
|
||||
import { usePermission } from "@/utils/permission";
|
||||
import type { Attachment } from "@halo-dev/api-client";
|
||||
import { CoreEditor } from "@halo-dev/richtext-editor";
|
||||
import { ucApiClient, type Attachment } from "@halo-dev/api-client";
|
||||
import { CoreEditor, PMNode } from "@halo-dev/richtext-editor";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import { chunk } from "lodash-es";
|
||||
import ExtensionAudio from "../extensions/audio";
|
||||
import Image from "../extensions/image";
|
||||
import ExtensionVideo from "../extensions/video";
|
||||
|
@ -147,3 +148,65 @@ export function containsFileClipboardIdentifier(types: readonly string[]) {
|
|||
const fileTypes = ["files", "application/x-moz-file", "public.file-url"];
|
||||
return types.some((type) => fileTypes.includes(type.toLowerCase()));
|
||||
}
|
||||
|
||||
export async function batchUploadExternalLink(
|
||||
editor: CoreEditor,
|
||||
nodes: { node: PMNode; pos: number; index: number; parent: PMNode | null }[]
|
||||
) {
|
||||
const chunks = chunk(nodes, 5);
|
||||
|
||||
for (const chunkNodes of chunks) {
|
||||
await Promise.all(
|
||||
chunkNodes.map((node) => uploadExternalLink(editor, node))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadExternalLink(
|
||||
editor: CoreEditor,
|
||||
nodeWithPos: {
|
||||
node: PMNode;
|
||||
pos: number;
|
||||
index: number;
|
||||
parent: PMNode | null;
|
||||
}
|
||||
) {
|
||||
const { node, pos } = nodeWithPos;
|
||||
const { src } = node.attrs;
|
||||
|
||||
if (!isExternalAsset(src)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } =
|
||||
await ucApiClient.storage.attachment.externalTransferAttachment1({
|
||||
ucUploadFromUrlRequest: {
|
||||
url: src,
|
||||
},
|
||||
waitForPermalink: true,
|
||||
});
|
||||
|
||||
const url = data.status?.permalink;
|
||||
const name = data.spec.displayName;
|
||||
const tr = editor.view.state.tr;
|
||||
tr.setNodeMarkup(pos, node.type, {
|
||||
...node.attrs,
|
||||
src: url,
|
||||
name,
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} catch (error) {
|
||||
console.error("Failed to upload external link:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isExternalAsset(src: string) {
|
||||
if (src?.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
return !src?.startsWith(currentOrigin);
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ core:
|
|||
fields:
|
||||
schedule_publish:
|
||||
tooltip: Schedule publish
|
||||
comments-with-pending: ({count} pending comments)
|
||||
comments-with-pending: " ({count} pending comments)"
|
||||
settings:
|
||||
fields:
|
||||
publish_time:
|
||||
|
@ -710,6 +710,11 @@ core:
|
|||
operations:
|
||||
replace:
|
||||
button: Replace
|
||||
transfer:
|
||||
button: Save locally
|
||||
tooltip: This resource is detected as an external resource. Click this button to save it to the attachment library.
|
||||
transfer_in_batch:
|
||||
description: External link detected. Would you like to automatically upload it to the attachment library?
|
||||
toolbox:
|
||||
show_hide_sidebar: Show/Hide Sidebar
|
||||
title_placeholder: Please enter the title
|
||||
|
|
|
@ -1800,6 +1800,15 @@ core:
|
|||
operations:
|
||||
replace:
|
||||
button: Replace
|
||||
transfer:
|
||||
button: Save locally
|
||||
tooltip: >-
|
||||
This resource is detected as an external resource. Click this
|
||||
button to save it to the attachment library.
|
||||
transfer_in_batch:
|
||||
description: >-
|
||||
External link detected. Would you like to automatically upload
|
||||
it to the attachment library?
|
||||
toolbox:
|
||||
attachment: Attachment
|
||||
show_hide_sidebar: Show/Hide Sidebar
|
||||
|
|
|
@ -1665,6 +1665,11 @@ core:
|
|||
operations:
|
||||
replace:
|
||||
button: 替换
|
||||
transfer:
|
||||
button: 转存
|
||||
tooltip: 检测到此资源为外部资源,点击此按钮以转存到附件库
|
||||
transfer_in_batch:
|
||||
description: 检测到具有外部链接,是否需要自动上传到附件库?
|
||||
toolbox:
|
||||
attachment: 选择附件
|
||||
show_hide_sidebar: 显示 / 隐藏侧边栏
|
||||
|
|
|
@ -1650,6 +1650,11 @@ core:
|
|||
operations:
|
||||
replace:
|
||||
button: 替換
|
||||
transfer:
|
||||
button: 轉存
|
||||
tooltip: 偵測到此資源為外部資源,點擊此按鈕以轉存到附件庫
|
||||
transfer_in_batch:
|
||||
description: 檢測到具有外部連結,是否需要自動上傳到附件庫?
|
||||
toolbox:
|
||||
attachment: 選擇附件
|
||||
show_hide_sidebar: 顯示 / 隱藏側邊欄
|
||||
|
|
Loading…
Reference in New Issue