feat: refactor editor image block upload logic (#5159)

* feat: refactor editor image block upload logic
pull/5224/head v2.12.0-alpha.1
Takagi 2024-01-19 17:39:06 +08:00 committed by GitHub
parent df8bb3399a
commit 14580b96b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 959 additions and 232 deletions

View File

@ -1,10 +1,8 @@
import type { Attachment, Group, Policy } from "@halo-dev/api-client";
import { computed, nextTick, type Ref } from "vue";
import { ref, watch } from "vue";
import type { AttachmentLike } from "@halo-dev/console-shared";
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import type { Content, Editor } from "@halo-dev/richtext-editor";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { useClipboard } from "@vueuse/core";
@ -28,10 +26,6 @@ interface useAttachmentControlReturn {
handleReset: () => void;
}
interface useAttachmentSelectReturn {
onAttachmentSelect: (attachments: AttachmentLike[]) => void;
}
export function useAttachmentControl(filterOptions: {
policy?: Ref<Policy | undefined>;
group?: Ref<Group | undefined>;
@ -219,86 +213,6 @@ export function useAttachmentControl(filterOptions: {
};
}
export function useAttachmentSelect(
editor: Ref<Editor | undefined>
): useAttachmentSelectReturn {
const onAttachmentSelect = (attachments: AttachmentLike[]) => {
const contents: Content[] = attachments
.map((attachment) => {
if (typeof attachment === "string") {
return {
type: "image",
attrs: {
src: attachment,
},
};
}
if ("url" in attachment) {
return {
type: "image",
attrs: {
src: attachment.url,
alt: attachment.type,
},
};
}
if ("spec" in attachment) {
const { mediaType, displayName } = attachment.spec;
const { permalink } = attachment.status || {};
if (mediaType?.startsWith("image/")) {
return {
type: "image",
attrs: {
src: permalink,
alt: displayName,
},
};
}
if (mediaType?.startsWith("video/")) {
return {
type: "video",
attrs: {
src: permalink,
},
};
}
if (mediaType?.startsWith("audio/")) {
return {
type: "audio",
attrs: {
src: permalink,
},
};
}
return {
type: "text",
marks: [
{
type: "link",
attrs: {
href: permalink,
},
},
],
text: displayName,
};
}
})
.filter(Boolean) as Content[];
editor.value?.chain().focus().insertContent(contents).run();
};
return {
onAttachmentSelect,
};
}
export function useAttachmentPermalinkCopy(
attachment: Ref<Attachment | undefined>
) {

View File

@ -41,6 +41,7 @@ import { useContentSnapshot } from "@console/composables/use-content-snapshot";
import { useSaveKeybinding } from "@console/composables/use-save-keybinding";
import { useSessionKeepAlive } from "@/composables/use-session-keep-alive";
import { usePermission } from "@/utils/permission";
import type { AxiosRequestConfig } from "axios";
const router = useRouter();
const { t } = useI18n();
@ -380,7 +381,7 @@ useSaveKeybinding(handleSave);
useSessionKeepAlive();
// Upload image
async function handleUploadImage(file: File) {
async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
if (!currentUserHasPermission(["uc:attachments:manage"])) {
return;
}
@ -388,11 +389,14 @@ async function handleUploadImage(file: File) {
await handleSave();
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost({
file,
singlePageName: formState.value.page.metadata.name,
waitForPermalink: true,
});
const { data } = await apiClient.uc.attachment.createAttachmentForPost(
{
file,
singlePageName: formState.value.page.metadata.name,
waitForPermalink: true,
},
options
);
return data;
}
</script>

View File

@ -41,6 +41,7 @@ import { useContentSnapshot } from "@console/composables/use-content-snapshot";
import { useSaveKeybinding } from "@console/composables/use-save-keybinding";
import { useSessionKeepAlive } from "@/composables/use-session-keep-alive";
import { usePermission } from "@/utils/permission";
import type { AxiosRequestConfig } from "axios";
const router = useRouter();
const { t } = useI18n();
@ -405,7 +406,7 @@ useSaveKeybinding(handleSave);
useSessionKeepAlive();
// Upload image
async function handleUploadImage(file: File) {
async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
if (!currentUserHasPermission(["uc:attachments:manage"])) {
return;
}
@ -414,11 +415,14 @@ async function handleUploadImage(file: File) {
await handleSave();
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost({
file,
postName: formState.value.post.metadata.name,
waitForPermalink: true,
});
const { data } = await apiClient.uc.attachment.createAttachmentForPost(
{
file,
postName: formState.value.post.metadata.name,
waitForPermalink: true,
},
options
);
return data;
}
</script>

View File

@ -80,7 +80,6 @@
"cropperjs": "^1.5.13",
"dayjs": "^1.11.7",
"emoji-mart": "^5.3.3",
"fastq": "^1.15.0",
"floating-vue": "2.0.0-beta.24",
"fuse.js": "^6.6.2",
"jsencrypt": "^3.3.2",

View File

@ -70,6 +70,7 @@ import IconNotificationBadgeLine from "~icons/ri/notification-badge-line";
import IconLogoutCircleRLine from "~icons/ri/logout-circle-r-line";
import IconAccountCircleLine from "~icons/ri/account-circle-line";
import IconSettings3Line from "~icons/ri/settings-3-line";
import IconImageAddLine from "~icons/ri/image-add-line";
export {
IconDashboard,
@ -144,4 +145,5 @@ export {
IconLogoutCircleRLine,
IconAccountCircleLine,
IconSettings3Line,
IconImageAddLine,
};

View File

@ -18,3 +18,4 @@ export * from "./tiptap";
export * from "./extensions";
export * from "./components";
export * from "./utils";
// TODO: export * from "./types";

View File

@ -12,6 +12,7 @@ export {
textblockTypeInputRule,
wrappingInputRule,
} from "./vue-3";
export { Editor as CoreEditor } from "./core";
export {
type Command as PMCommand,
InputRule as PMInputRule,

View File

@ -137,9 +137,6 @@ importers:
emoji-mart:
specifier: ^5.3.3
version: 5.3.3
fastq:
specifier: ^1.15.0
version: 1.15.0
floating-vue:
specifier: 2.0.0-beta.24
version: 2.0.0-beta.24(vue@3.3.4)
@ -335,7 +332,7 @@ importers:
version: 0.14.15(@vue/compiler-sfc@3.3.4)
vite:
specifier: ^4.2.3
version: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
version: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
vite-plugin-externals:
specifier: ^0.6.2
version: 0.6.2(vite@4.2.3)
@ -6876,7 +6873,7 @@ packages:
'@babel/core': 7.20.12
'@babel/plugin-transform-typescript': 7.22.5(@babel/core@7.20.12)
'@vue/babel-plugin-jsx': 1.1.1(@babel/core@7.20.12)
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
vue: 3.3.4
transitivePeerDependencies:
- supports-color
@ -6889,7 +6886,7 @@ packages:
vite: ^4.0.0
vue: ^3.2.25
dependencies:
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
vue: 3.3.4
dev: true
@ -17044,7 +17041,7 @@ packages:
mlly: 1.4.0
pathe: 1.1.1
picocolors: 1.0.0
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
transitivePeerDependencies:
- '@types/node'
- less
@ -17112,7 +17109,7 @@ packages:
es-module-lexer: 0.4.1
fs-extra: 10.1.0
magic-string: 0.25.9
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
dev: true
/vite-plugin-html@3.2.0(vite@4.2.3):
@ -17132,7 +17129,7 @@ packages:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
dev: true
/vite-plugin-pwa@0.16.4(vite@4.2.3)(workbox-build@7.0.0)(workbox-window@7.0.0):
@ -17146,7 +17143,7 @@ packages:
debug: 4.3.4(supports-color@8.1.1)
fast-glob: 3.2.12
pretty-bytes: 6.0.0
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
workbox-build: 7.0.0
workbox-window: 7.0.0
transitivePeerDependencies:
@ -17163,7 +17160,7 @@ packages:
fast-glob: 3.2.12
fs-extra: 11.1.1
picocolors: 1.0.0
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
dev: true
/vite@3.2.4(@types/node@18.13.0)(sass@1.60.0):
@ -17236,6 +17233,41 @@ packages:
optionalDependencies:
fsevents: 2.3.2
/vite@4.2.3(@types/node@18.13.0)(sass@1.60.0):
resolution: {integrity: sha512-kLU+m2q0Y434Y1kCy3TchefAdtFso0ILi0dLyFV8Us3InXTU11H/B5ZTqCKIQHzSKNxVG/yEx813EA9f1imQ9A==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
'@types/node': 18.13.0
esbuild: 0.17.19
postcss: 8.4.31
resolve: 1.22.1
rollup: 3.28.0
sass: 1.60.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/vitest@0.18.1(@vitest/ui@0.34.1)(c8@7.12.0)(jsdom@20.0.3)(sass@1.60.0):
resolution: {integrity: sha512-4F/1K/Vn4AvJwe7i2YblR02PT5vMKcw9KN4unDq2KD0YcSxX0B/6D6Qu9PJaXwVuxXMFTQ5ovd4+CQaW3bwofA==}
engines: {node: '>=v14.16.0'}
@ -17333,7 +17365,7 @@ packages:
strip-literal: 1.3.0
tinybench: 2.5.0
tinypool: 0.7.0
vite: 4.2.3(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)
vite: 4.2.3(@types/node@18.13.0)(sass@1.60.0)
vite-node: 0.34.1(@types/node@18.13.0)(sass@1.60.0)
why-is-node-running: 2.2.2
transitivePeerDependencies:

View File

@ -50,6 +50,8 @@ import {
DecorationSet,
ExtensionListKeymap,
} from "@halo-dev/richtext-editor";
// ui custom extension
import { UiExtensionImage, UiExtensionUpload } from "./extensions";
import {
IconCalendar,
IconCharacterRecognition,
@ -78,18 +80,18 @@ import {
type ComputedRef,
} from "vue";
import { formatDatetime } from "@/utils/date";
import { useAttachmentSelect } from "@console/modules/contents/attachments/composables/use-attachment";
import * as fastq from "fastq";
import type { queueAsPromised } from "fastq";
import { useAttachmentSelect } from "./composables/use-attachment";
import type { Attachment } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { i18n } from "@/locales";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule } from "@halo-dev/console-shared";
import type { AttachmentLike, PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn, useLocalStorage } from "@vueuse/core";
import { onBeforeUnmount } from "vue";
import { usePermission } from "@/utils/permission";
import type { AxiosRequestConfig } from "axios";
import { getContents } from "./utils/attachment";
const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
@ -98,7 +100,10 @@ const props = withDefaults(
defineProps<{
raw?: string;
content: string;
uploadImage?: (file: File) => Promise<Attachment>;
uploadImage?: (
file: File,
options?: AxiosRequestConfig
) => Promise<Attachment>;
}>(),
{
raw: "",
@ -117,6 +122,21 @@ const owner = inject<ComputedRef<string | undefined>>("owner");
const publishTime = inject<ComputedRef<string | undefined>>("publishTime");
const permalink = inject<ComputedRef<string | undefined>>("permalink");
declare module "@halo-dev/richtext-editor" {
interface Commands<ReturnType> {
global: {
openAttachmentSelector: (
callback: (attachments: AttachmentLike[]) => void,
options?: {
accepts?: string[];
min?: number;
max?: number;
}
) => ReturnType;
};
}
}
interface HeadingNode {
id: string;
level: number;
@ -137,12 +157,29 @@ const selectedHeadingNode = ref<HeadingNode>();
const extraActiveId = ref("toc");
const attachmentSelectorModal = ref(false);
const { onAttachmentSelect, attachmentResult } = useAttachmentSelect();
const editor = shallowRef<Editor>();
const { pluginModules } = usePluginModuleStore();
const showSidebar = useLocalStorage("halo:editor:show-sidebar", true);
const initAttachmentOptions = {
accepts: ["*/*"],
min: undefined,
max: undefined,
};
const attachmentOptions = ref<{
accepts?: string[];
min?: number;
max?: number;
}>(initAttachmentOptions);
const handleCloseAttachmentSelectorModal = () => {
attachmentOptions.value = initAttachmentOptions;
};
onMounted(() => {
const extensionsFromPlugins: AnyExtension[] = [];
pluginModules.forEach((pluginModule: PluginModule) => {
@ -166,6 +203,10 @@ onMounted(() => {
emit("update", html);
}, 250);
const image = currentUserHasPermission(["uc:attachments:manage"])
? UiExtensionImage
: ExtensionImage;
editor.value = new Editor({
content: props.raw,
extensions: [
@ -188,12 +229,13 @@ onMounted(() => {
ExtensionOrderedList,
ExtensionStrike,
ExtensionText,
ExtensionImage.configure({
image.configure({
inline: true,
allowBase64: false,
HTMLAttributes: {
loading: "lazy",
},
uploadImage: props.uploadImage,
}),
ExtensionTaskList,
ExtensionLink.configure({
@ -260,7 +302,16 @@ onMounted(() => {
title: i18n.global.t(
"core.components.default_editor.toolbox.attachment"
),
action: () => (attachmentSelectorModal.value = true),
action: () => {
editor.commands.openAttachmentSelector((attachment) => {
editor
.chain()
.focus()
.insertContent(getContents(attachment))
.run();
});
return true;
},
},
},
];
@ -284,6 +335,22 @@ onMounted(() => {
},
};
},
addCommands() {
return {
openAttachmentSelector: (callback, options) => () => {
if (options) {
attachmentOptions.value = options;
}
attachmentSelectorModal.value = true;
attachmentResult.updateAttachment = (
attachments: AttachmentLike[]
) => {
callback(attachments);
};
return true;
},
};
},
}),
ExtensionDraggable,
ExtensionColumns,
@ -320,96 +387,12 @@ onMounted(() => {
},
}),
ExtensionListKeymap,
UiExtensionUpload,
],
autofocus: "start",
onUpdate: () => {
debounceOnUpdate();
},
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) => {
const types = Array.from(event.clipboardData?.types || []);
if (["text/plain", "text/html"].includes(types[0])) {
return;
}
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();
},
});
});
},
},
});
});
@ -417,33 +400,11 @@ onBeforeUnmount(() => {
editor.value?.destroy();
});
// image drag and paste upload
type Task = {
file: File;
process: (permalink: string) => void;
};
const uploadQueue: queueAsPromised<Task> = fastq.promise(asyncWorker, 1);
async function asyncWorker(arg: Task): Promise<void> {
if (!props.uploadImage) {
return;
}
const attachmentData = await props.uploadImage(arg.file);
if (attachmentData.status?.permalink) {
arg.process(attachmentData.status.permalink);
}
}
const handleSelectHeadingNode = (node: HeadingNode) => {
selectedHeadingNode.value = node;
document.getElementById(node.id)?.scrollIntoView({ behavior: "smooth" });
};
const { onAttachmentSelect } = useAttachmentSelect(editor);
watch(
() => props.raw,
() => {
@ -467,8 +428,10 @@ const currentLocale = i18n.global.locale.value as
<template>
<div>
<AttachmentSelectorModal
v-bind="attachmentOptions"
v-model:visible="attachmentSelectorModal"
@select="onAttachmentSelect"
@close="handleCloseAttachmentSelectorModal"
/>
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
<template v-if="showSidebar" #extra>

View File

@ -0,0 +1,26 @@
import type { AttachmentLike } from "@halo-dev/console-shared";
interface useAttachmentSelectReturn {
onAttachmentSelect: (attachments: AttachmentLike[]) => void;
attachmentResult: AttachmentResult;
}
export interface AttachmentResult {
updateAttachment: (attachments: AttachmentLike[]) => void;
}
export function useAttachmentSelect(): useAttachmentSelectReturn {
const attachmentResult = {
updateAttachment: (attachments: AttachmentLike[]) => {
return attachments;
},
};
const onAttachmentSelect = (attachmentLikes: AttachmentLike[]) => {
attachmentResult.updateAttachment(attachmentLikes);
};
return {
onAttachmentSelect,
attachmentResult,
};
}

View File

@ -0,0 +1,402 @@
<script lang="ts" setup>
import {
Editor,
type Node,
type PMNode,
type Decoration,
} from "@halo-dev/richtext-editor";
import { NodeViewWrapper } from "@halo-dev/richtext-editor";
import { computed, onMounted, ref } from "vue";
import Image from "./index";
import { watch } from "vue";
import { fileToBase64, uploadFile } from "../../utils/upload";
import type { Attachment } from "@halo-dev/api-client";
import type { AttachmentLike } from "@halo-dev/console-shared";
import { useFileDialog } from "@vueuse/core";
import { onUnmounted } from "vue";
import {
VButton,
VSpace,
IconImageAddLine,
VDropdown,
} from "@halo-dev/components";
import { getAttachmentUrl } from "../../utils/attachment";
import { i18n } from "@/locales";
const props = defineProps<{
editor: Editor;
node: PMNode;
decorations: Decoration[];
selected: boolean;
extension: Node;
getPos: () => number;
updateAttributes: (attributes: Record<string, unknown>) => void;
deleteNode: () => void;
}>();
const src = computed({
get: () => {
return props.node?.attrs.src;
},
set: (src: string) => {
props.updateAttributes({
src: src,
});
},
});
const alt = computed({
get: () => {
return props.node?.attrs.alt;
},
set: (alt: string) => {
props.updateAttributes({ alt: alt });
},
});
const href = computed({
get: () => {
return props.node?.attrs.href;
},
set: (href: string) => {
props.updateAttributes({ href: href });
},
});
const originalFile = ref<File>();
const fileBase64 = ref<string>();
const uploadProgress = ref<number | undefined>(undefined);
const retryFlag = ref<boolean>(false);
const controller = ref<AbortController>();
const initSrc = ref<string>();
const initialization = computed(() => {
return !src.value && !fileBase64.value;
});
const handleEnterSetSrc = () => {
if (!initSrc.value) {
return;
}
props.updateAttributes({ src: initSrc.value });
};
const openAttachmentSelector = () => {
props.editor.commands.openAttachmentSelector(
(attachments: AttachmentLike[]) => {
if (attachments.length > 0) {
const attachment = attachments[0];
const attachmentAttr = getAttachmentUrl(attachment);
props.updateAttributes({
src: attachmentAttr.url,
alt: attachmentAttr.name,
});
}
},
{
accepts: ["image/*"],
min: 1,
max: 1,
}
);
};
const { open, reset, onChange } = useFileDialog({
accept: "image/*",
multiple: false,
});
const handleUploadAbort = () => {
if (!controller.value) {
return;
}
controller.value.abort();
resetUpload();
};
const handleUploadRetry = () => {
if (!originalFile.value) {
return;
}
handleUploadImage(originalFile.value);
};
const handleUploadImage = async (file: File) => {
originalFile.value = file;
fileBase64.value = await fileToBase64(file);
retryFlag.value = false;
controller.value = new AbortController();
uploadFile(file, props.extension.options.uploadImage, {
controller: controller.value,
onUploadProgress: (progress) => {
uploadProgress.value = progress;
},
onFinish: (attachment?: Attachment) => {
if (attachment) {
props.updateAttributes({
src: attachment.status?.permalink,
});
}
resetUpload();
},
onError: () => {
retryFlag.value = true;
},
});
};
const resetUpload = () => {
reset();
originalFile.value = undefined;
fileBase64.value = undefined;
uploadProgress.value = undefined;
controller.value?.abort();
controller.value = undefined;
if (props.getPos()) {
props.updateAttributes({
width: undefined,
height: undefined,
});
}
};
const handleResetInit = () => {
resetUpload();
props.updateAttributes({
src: "",
file: undefined,
});
};
onChange((files) => {
if (!files) {
return;
}
if (files.length > 0) {
handleUploadImage(files[0]);
}
});
watch(
() => props.node?.attrs.file,
async (file) => {
if (!file) {
return;
}
handleUploadImage(file);
},
{
immediate: true,
}
);
const aspectRatio = ref<number>(0);
const resizeRef = ref<HTMLDivElement>();
function onImageLoaded() {
if (!resizeRef.value) return;
aspectRatio.value =
resizeRef.value.clientWidth / resizeRef.value.clientHeight;
}
onMounted(() => {
if (!resizeRef.value) return;
let startX: number, startWidth: number;
resizeRef.value.addEventListener("mousedown", function (e) {
startX = e.clientX;
startWidth = resizeRef.value?.clientWidth || 1;
document.documentElement.addEventListener("mousemove", doDrag, false);
document.documentElement.addEventListener("mouseup", stopDrag, false);
});
function doDrag(e: MouseEvent) {
if (!resizeRef.value) return;
const newWidth = Math.min(
startWidth + e.clientX - startX,
resizeRef.value.parentElement?.clientWidth || 0
);
const width = newWidth.toFixed(0) + "px";
const height = (newWidth / aspectRatio.value).toFixed(0) + "px";
props.editor
.chain()
.updateAttributes(Image.name, { width, height })
.setNodeSelection(props.getPos())
.focus()
.run();
}
function stopDrag() {
document.documentElement.removeEventListener("mousemove", doDrag, false);
document.documentElement.removeEventListener("mouseup", stopDrag, false);
}
});
onUnmounted(() => {
handleUploadAbort();
});
</script>
<template>
<node-view-wrapper as="div" class="inline-block w-full">
<div
ref="resizeRef"
class="group relative inline-block max-w-full overflow-hidden rounded-md text-center"
:class="{
'rounded ring-2': selected,
'resize-x': !initialization,
}"
:style="{
width: initialization ? '100%' : node.attrs.width,
height: initialization ? '100%' : node.attrs.height,
}"
>
<div v-if="src || fileBase64" class="relative">
<img
:src="src || fileBase64"
:title="node.attrs.title"
:alt="alt"
:href="href"
class="h-full w-full"
@load="onImageLoaded"
/>
<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"
>
<VButton size="sm" type="secondary" @click="handleResetInit">
{{
$t(
"core.components.default_editor.extensions.image.operations.replace.button"
)
}}
</VButton>
</div>
<div
v-if="fileBase64"
class="absolute top-0 h-full w-full bg-black bg-opacity-20"
>
<div class="absolute top-[50%] w-full space-y-2 text-white">
<template v-if="retryFlag">
<div class="px-10">
<div
class="relative h-4 w-full overflow-hidden rounded-full bg-gray-200"
>
<div class="h-full w-full bg-red-600"></div>
<div
class="absolute left-[50%] top-0 -translate-x-[50%] text-xs leading-4 text-white"
>
{{
$t(
"core.components.default_editor.extensions.image.upload.error"
)
}}
</div>
</div>
</div>
<div
class="inline-block cursor-pointer text-sm hover:opacity-70"
@click="handleUploadRetry"
>
{{
$t(
"core.components.default_editor.extensions.image.upload.click_retry"
)
}}
</div>
</template>
<template v-else>
<div class="px-10">
<div
class="relative h-4 w-full overflow-hidden rounded-full bg-gray-200"
>
<div
class="h-full bg-primary"
:style="{
width: `${uploadProgress || 0}%`,
}"
></div>
<div
class="absolute left-[50%] top-0 -translate-x-[50%] text-xs leading-4 text-white"
>
{{
uploadProgress
? `${uploadProgress}%`
: `${$t(
"core.components.default_editor.extensions.image.upload.loading"
)}...`
}}
</div>
</div>
</div>
<div
class="inline-block cursor-pointer text-sm hover:opacity-70"
@click="handleUploadAbort"
>
{{ $t("core.common.buttons.cancel") }}
</div>
</template>
</div>
</div>
</div>
<div v-else>
<div class="flex w-full items-center justify-center">
<div
class="flex h-64 w-full cursor-pointer flex-col items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50"
>
<div
class="flex flex-col items-center justify-center space-y-7 pb-6 pt-5"
>
<div
class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/20"
>
<IconImageAddLine class="text-xl text-primary" />
</div>
<VSpace>
<VButton @click="open()">
{{ $t("core.common.buttons.upload") }}
</VButton>
<VButton @click="openAttachmentSelector">
{{
$t(
"core.components.default_editor.extensions.image.attachment.title"
)
}}</VButton
>
<VDropdown>
<VButton>{{
$t(
"core.components.default_editor.extensions.image.permalink.title"
)
}}</VButton>
<template #popper>
<input
v-model="initSrc"
class="block w-full rounded-md border border-gray-300 bg-gray-50 px-2 py-1.5 text-sm text-gray-900 hover:bg-gray-100"
:placeholder="
i18n.global.t(
'core.components.default_editor.extensions.image.permalink.placeholder'
)
"
@keydown.enter="handleEnterSetSrc"
/>
</template>
</VDropdown>
</VSpace>
</div>
</div>
</div>
</div>
</div>
</node-view-wrapper>
</template>

View File

@ -0,0 +1,62 @@
import { ExtensionImage, VueNodeViewRenderer } from "@halo-dev/richtext-editor";
import ImageView from "./ImageView.vue";
import type { AxiosRequestConfig } from "axios";
import type { Attachment } from "@halo-dev/api-client";
interface UiImageOptions {
uploadImage?: (
file: File,
options?: AxiosRequestConfig
) => Promise<Attachment>;
}
const Image = ExtensionImage.extend<UiImageOptions>({
addOptions() {
const { parent } = this;
return {
...parent?.(),
uploadImage: undefined,
};
},
addAttributes() {
return {
...this.parent?.(),
file: {
default: null,
},
width: {
default: "100%",
parseHTML: (element) => {
const width =
element.getAttribute("width") || element.style.width || null;
return width;
},
renderHTML: (attributes) => {
return {
width: attributes.width,
};
},
},
height: {
default: "100%",
parseHTML: (element) => {
const height =
element.getAttribute("height") || element.style.height || null;
return height;
},
renderHTML: (attributes) => {
return {
height: attributes.height,
};
},
},
};
},
addNodeView() {
return VueNodeViewRenderer(ImageView);
},
});
export default Image;

View File

@ -0,0 +1,3 @@
import UiExtensionImage from "./image";
import UiExtensionUpload from "./upload";
export { UiExtensionImage, UiExtensionUpload };

View File

@ -0,0 +1,73 @@
import {
CoreEditor,
Extension,
Plugin,
PluginKey,
} from "@halo-dev/richtext-editor";
import { handleFileEvent } from "../../utils/upload";
export const Upload = Extension.create({
name: "upload",
addProseMirrorPlugins() {
const { editor }: { editor: CoreEditor } = this;
return [
new Plugin({
key: new PluginKey("upload"),
props: {
handlePaste: (view, event: ClipboardEvent) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.clipboardData) {
return false;
}
const files = Array.from(event.clipboardData.files);
if (files.length) {
event.preventDefault();
files.forEach((file) => {
handleFileEvent({ editor, file });
});
return true;
}
return false;
},
handleDrop: (view, event) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.dataTransfer) {
return false;
}
const hasFiles = event.dataTransfer.files.length > 0;
if (!hasFiles) {
return false;
}
event.preventDefault();
const files = Array.from(event.dataTransfer.files) as File[];
if (files.length) {
event.preventDefault();
files.forEach((file: File) => {
handleFileEvent({ editor, file });
});
return true;
}
return false;
},
},
}),
];
},
});
export default Upload;

View File

@ -0,0 +1,95 @@
import type { Content } from "@halo-dev/richtext-editor";
import type { AttachmentLike } from "@halo-dev/console-shared";
export function getContents(attachments: AttachmentLike[]): Content[] {
return attachments
.map((attachment) => {
if (typeof attachment === "string") {
return {
type: "image",
attrs: {
src: attachment,
},
};
}
if ("url" in attachment) {
return {
type: "image",
attrs: {
src: attachment.url,
alt: attachment.type,
},
};
}
if ("spec" in attachment) {
const { mediaType, displayName } = attachment.spec;
const { permalink } = attachment.status || {};
if (mediaType?.startsWith("image/")) {
return {
type: "image",
attrs: {
src: permalink,
alt: displayName,
},
};
}
if (mediaType?.startsWith("video/")) {
return {
type: "video",
attrs: {
src: permalink,
},
};
}
if (mediaType?.startsWith("audio/")) {
return {
type: "audio",
attrs: {
src: permalink,
},
};
}
return {
type: "text",
marks: [
{
type: "link",
attrs: {
href: permalink,
},
},
],
text: displayName,
};
}
})
.filter(Boolean) as Content[];
}
export interface AttachmentAttr {
url?: string;
name?: string;
}
export function getAttachmentUrl(attachment: AttachmentLike): AttachmentAttr {
let permalink: string | undefined = undefined;
let displayName: string | undefined = undefined;
if (typeof attachment === "string") {
permalink = attachment;
} else if ("url" in attachment) {
permalink = attachment.url;
} else if ("spec" in attachment) {
permalink = attachment.status?.permalink;
displayName = attachment.spec.displayName;
}
return {
url: permalink,
name: displayName,
};
}

View File

@ -0,0 +1,99 @@
// image drag and paste upload
import { CoreEditor } from "@halo-dev/richtext-editor";
import type { Attachment } from "@halo-dev/api-client";
import Image from "../extensions/image";
import type { AxiosRequestConfig } from "axios";
export interface FileProps {
file: File;
editor: CoreEditor;
}
/**
* Handles file events, determining if the file is an image and triggering the appropriate upload process.
*
* @param {FileProps} { file, editor } - File and editor instances
* @returns {boolean} - True if a file is handled, otherwise false
*/
export const handleFileEvent = ({ file, editor }: FileProps) => {
if (!file) {
return false;
}
if (file.type.startsWith("image/")) {
uploadImage({ file, editor });
return true;
}
return true;
};
/**
* Uploads an image file and inserts it into the editor.
*
* @param {FileProps} { file, editor } - File to be uploaded and the editor instance
*/
export const uploadImage = ({ file, editor }: FileProps) => {
const { view } = editor;
const node = view.props.state.schema.nodes[Image.name].create({
file: file,
});
editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(node));
};
export interface UploadFetchResponse {
controller: AbortController;
onUploadProgress: (progress: number) => void;
onFinish: (attachment?: Attachment) => void;
onError: (error: Error) => void;
}
/**
* Uploads a file with progress monitoring, cancellation support, and callbacks for completion and errors.
*
* @param {File} file - File to be uploaded
* @param {Function} upload - Function to handle the file upload, should return a Promise
* @returns {Promise<UploadFetchResponse>} - Returns an object with control and callback methods
*/
export const uploadFile = async (
file: File,
upload: (file: File, options?: AxiosRequestConfig) => Promise<Attachment>,
uploadResponse: UploadFetchResponse
) => {
const { signal } = uploadResponse.controller;
upload(file, {
signal,
onUploadProgress(progressEvent) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
uploadResponse.onUploadProgress(progress);
},
})
.then((attachment) => {
uploadResponse.onFinish(attachment);
})
.catch((error) => {
uploadResponse.onError(error);
});
};
/**
* Converts a file to a Base64 string.
*
* @param {File} file - File to be converted
* @returns {Promise<string>} - A promise that resolves with the Base64 string
*/
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function () {
resolve(reader.result as string);
};
reader.onerror = function (error) {
reject(error);
};
reader.readAsDataURL(file);
});
}

View File

@ -1381,6 +1381,19 @@ core:
placeholder:
options:
placeholder: Enter / to select input type.
image:
upload:
error: Upload failed
click_retry: Click to retry
loading: Loading
attachment:
title: Attachment Library
permalink:
title: Input Link
placeholder: Input link and press Enter to confirm
operations:
replace:
button: Replace
toolbox:
attachment: Attachment
show_hide_sidebar: Show/Hide Sidebar

View File

@ -1329,6 +1329,19 @@ core:
placeholder:
options:
placeholder: 输入 / 以选择输入类型
image:
upload:
error: 上传失败
click_retry: 点击重试
loading: 等待中
attachment:
title: 附件库
permalink:
title: 输入链接
placeholder: 输入链接,按回车确定
operations:
replace:
button: 替换
toolbox:
attachment: 选择附件
show_hide_sidebar: 显示 / 隐藏侧边栏

View File

@ -1295,6 +1295,19 @@ core:
placeholder:
options:
placeholder: 輸入 / 以選擇輸入類型
image:
upload:
error: 上傳失敗
click_retry: 點擊重試
loading: 等待中
attachment:
title: 附件庫
permalink:
title: 輸入連結
placeholder: 輸入連結,按回車確定
operations:
replace:
button: 替换
toolbox:
attachment: 選擇附件
show_hide_sidebar: 顯示 / 隱藏側邊欄

View File

@ -76,6 +76,10 @@ axiosInstance.interceptors.response.use(
return response;
},
async (error: AxiosError<ProblemDetail>) => {
if (error.code === "ERR_CANCELED") {
return Promise.reject(error);
}
if (/Network Error/.test(error.message)) {
// @ts-ignore
Toast.error(i18n.global.t("core.common.toast.network_error"));

View File

@ -34,6 +34,7 @@ import { provide } from "vue";
import type { ComputedRef } from "vue";
import { useSessionKeepAlive } from "@/composables/use-session-keep-alive";
import { usePermission } from "@/utils/permission";
import type { AxiosRequestConfig } from "axios";
const router = useRouter();
const { t } = useI18n();
@ -334,7 +335,7 @@ function onUpdatePostSuccess(data: Post) {
}
// Upload image
async function handleUploadImage(file: File) {
async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
if (!currentUserHasPermission(["uc:attachments:manage"])) {
return;
}
@ -359,11 +360,14 @@ async function handleUploadImage(file: File) {
await onCreatePostSuccess(data);
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost({
file,
postName: formState.value.metadata.name,
waitForPermalink: true,
});
const { data } = await apiClient.uc.attachment.createAttachmentForPost(
{
file,
postName: formState.value.metadata.name,
waitForPermalink: true,
},
options
);
return data;
}

View File

@ -1,4 +1,4 @@
import type { PostSpecVisibleEnum } from "packages/api-client/dist";
import type { PostSpecVisibleEnum } from "@halo-dev/api-client";
export interface PostFormState {
title: string;