mirror of https://github.com/halo-dev/halo
feat: refactor editor image block upload logic (#5159)
* feat: refactor editor image block upload logicpull/5224/head v2.12.0-alpha.1
parent
df8bb3399a
commit
14580b96b0
|
@ -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>
|
||||
) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -18,3 +18,4 @@ export * from "./tiptap";
|
|||
export * from "./extensions";
|
||||
export * from "./components";
|
||||
export * from "./utils";
|
||||
// TODO: export * from "./types";
|
||||
|
|
|
@ -12,6 +12,7 @@ export {
|
|||
textblockTypeInputRule,
|
||||
wrappingInputRule,
|
||||
} from "./vue-3";
|
||||
export { Editor as CoreEditor } from "./core";
|
||||
export {
|
||||
type Command as PMCommand,
|
||||
InputRule as PMInputRule,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
import UiExtensionImage from "./image";
|
||||
import UiExtensionUpload from "./upload";
|
||||
export { UiExtensionImage, UiExtensionUpload };
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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: 显示 / 隐藏侧边栏
|
||||
|
|
|
@ -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: 顯示 / 隱藏側邊欄
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue