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
Ryan Wang 2025-08-17 22:43:09 +08:00 committed by GitHub
parent 7d51f38d96
commit 3105c53b6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 284 additions and 7 deletions

View File

@ -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,
};
}

View File

@ -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(

View File

@ -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(

View File

@ -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;

View File

@ -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(

View File

@ -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);
}

View File

@ -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

View File

@ -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

View File

@ -1665,6 +1665,11 @@ core:
operations:
replace:
button: 替换
transfer:
button: 转存
tooltip: 检测到此资源为外部资源,点击此按钮以转存到附件库
transfer_in_batch:
description: 检测到具有外部链接,是否需要自动上传到附件库?
toolbox:
attachment: 选择附件
show_hide_sidebar: 显示 / 隐藏侧边栏

View File

@ -1650,6 +1650,11 @@ core:
operations:
replace:
button: 替換
transfer:
button: 轉存
tooltip: 偵測到此資源為外部資源,點擊此按鈕以轉存到附件庫
transfer_in_batch:
description: 檢測到具有外部連結,是否需要自動上傳到附件庫?
toolbox:
attachment: 選擇附件
show_hide_sidebar: 顯示 / 隱藏側邊欄