mirror of https://github.com/halo-dev/halo
729 lines
22 KiB
Vue
729 lines
22 KiB
Vue
<script lang="ts" setup>
|
|
import {
|
|
Extension,
|
|
RichTextEditor,
|
|
ExtensionBlockquote,
|
|
ExtensionBold,
|
|
ExtensionBulletList,
|
|
ExtensionCode,
|
|
ExtensionDocument,
|
|
ExtensionDropcursor,
|
|
ExtensionGapcursor,
|
|
ExtensionHardBreak,
|
|
ExtensionHeading,
|
|
ExtensionHistory,
|
|
ExtensionHorizontalRule,
|
|
ExtensionItalic,
|
|
ExtensionOrderedList,
|
|
ExtensionStrike,
|
|
ExtensionText,
|
|
ExtensionImage,
|
|
ExtensionTaskList,
|
|
ExtensionLink,
|
|
ExtensionTextAlign,
|
|
ExtensionUnderline,
|
|
ExtensionTable,
|
|
ExtensionSubscript,
|
|
ExtensionSuperscript,
|
|
ExtensionPlaceholder,
|
|
ExtensionHighlight,
|
|
ExtensionCommands,
|
|
ExtensionIframe,
|
|
ExtensionVideo,
|
|
ExtensionAudio,
|
|
ExtensionCodeBlock,
|
|
ToolbarItem,
|
|
lowlight,
|
|
type AnyExtension,
|
|
Editor,
|
|
ToolbarSubItem,
|
|
} from "@halo-dev/richtext-editor";
|
|
import {
|
|
IconCalendar,
|
|
IconCharacterRecognition,
|
|
IconFolder,
|
|
IconLink,
|
|
IconUserFollow,
|
|
Toast,
|
|
VTabItem,
|
|
VTabs,
|
|
} from "@halo-dev/components";
|
|
import AttachmentSelectorModal from "@/modules/contents/attachments/components/AttachmentSelectorModal.vue";
|
|
import ExtensionCharacterCount from "@tiptap/extension-character-count";
|
|
import MdiFileImageBox from "~icons/mdi/file-image-box";
|
|
import MdiVideoPlusOutline from "~icons/mdi/video-plus-outline";
|
|
import MdiImagePlusOutline from "~icons/mdi/image-plus-outline";
|
|
import MdiVolume from "~icons/mdi/volume";
|
|
import MdiFormatHeader1 from "~icons/mdi/format-header-1";
|
|
import MdiFormatHeader2 from "~icons/mdi/format-header-2";
|
|
import MdiFormatHeader3 from "~icons/mdi/format-header-3";
|
|
import MdiFormatHeader4 from "~icons/mdi/format-header-4";
|
|
import MdiFormatHeader5 from "~icons/mdi/format-header-5";
|
|
import MdiFormatHeader6 from "~icons/mdi/format-header-6";
|
|
import {
|
|
inject,
|
|
markRaw,
|
|
nextTick,
|
|
ref,
|
|
watch,
|
|
onMounted,
|
|
shallowRef,
|
|
type ComputedRef,
|
|
} from "vue";
|
|
import { formatDatetime } from "@/utils/date";
|
|
import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment";
|
|
import { apiClient } from "@/utils/api-client";
|
|
import * as fastq from "fastq";
|
|
import type { queueAsPromised } from "fastq";
|
|
import type { Attachment } from "@halo-dev/api-client";
|
|
import { useFetchAttachmentPolicy } from "@/modules/contents/attachments/composables/use-attachment-policy";
|
|
import { useI18n } from "vue-i18n";
|
|
import { i18n } from "@/locales";
|
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
|
|
import { usePluginModuleStore, type PluginModule } from "@/stores/plugin";
|
|
|
|
const { t } = useI18n();
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
raw?: string;
|
|
content: string;
|
|
}>(),
|
|
{
|
|
raw: "",
|
|
content: "",
|
|
}
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
(event: "update:raw", value: string): void;
|
|
(event: "update:content", value: string): void;
|
|
(event: "update", value: string): void;
|
|
}>();
|
|
|
|
const owner = inject<ComputedRef<string | undefined>>("owner");
|
|
const publishTime = inject<ComputedRef<string | undefined>>("publishTime");
|
|
const permalink = inject<ComputedRef<string | undefined>>("permalink");
|
|
|
|
interface HeadingNode {
|
|
id: string;
|
|
level: number;
|
|
text: string;
|
|
}
|
|
|
|
const headingIcons = {
|
|
1: markRaw(MdiFormatHeader1),
|
|
2: markRaw(MdiFormatHeader2),
|
|
3: markRaw(MdiFormatHeader3),
|
|
4: markRaw(MdiFormatHeader4),
|
|
5: markRaw(MdiFormatHeader5),
|
|
6: markRaw(MdiFormatHeader6),
|
|
};
|
|
|
|
const headingNodes = ref<HeadingNode[]>();
|
|
const selectedHeadingNode = ref<HeadingNode>();
|
|
const extraActiveId = ref("toc");
|
|
const attachmentSelectorModal = ref(false);
|
|
|
|
const editor = shallowRef<Editor>();
|
|
|
|
const { pluginModules } = usePluginModuleStore();
|
|
|
|
onMounted(() => {
|
|
const extensionsFromPlugins: AnyExtension[] = [];
|
|
pluginModules.forEach((pluginModule: PluginModule) => {
|
|
const { extensionPoints } = pluginModule;
|
|
if (!extensionPoints?.["default:editor:extension:create"]) {
|
|
return;
|
|
}
|
|
|
|
const extensions = extensionPoints[
|
|
"default:editor:extension:create"
|
|
]() as [];
|
|
|
|
extensionsFromPlugins.push(...extensions);
|
|
});
|
|
|
|
editor.value = new Editor({
|
|
content: props.raw,
|
|
extensions: [
|
|
ExtensionBlockquote,
|
|
ExtensionBold,
|
|
ExtensionBulletList,
|
|
ExtensionCode,
|
|
ExtensionDocument,
|
|
ExtensionDropcursor,
|
|
ExtensionGapcursor,
|
|
ExtensionHardBreak,
|
|
ExtensionHeading,
|
|
ExtensionHistory,
|
|
ExtensionHorizontalRule,
|
|
ExtensionItalic,
|
|
ExtensionOrderedList,
|
|
ExtensionStrike,
|
|
ExtensionText,
|
|
ExtensionImage.configure({
|
|
inline: true,
|
|
allowBase64: false,
|
|
HTMLAttributes: {
|
|
loading: "lazy",
|
|
},
|
|
}),
|
|
ExtensionTaskList,
|
|
ExtensionLink.configure({
|
|
autolink: true,
|
|
openOnClick: false,
|
|
}),
|
|
ExtensionTextAlign.configure({
|
|
types: ["heading", "paragraph"],
|
|
}),
|
|
ExtensionUnderline,
|
|
ExtensionTable.configure({
|
|
resizable: true,
|
|
}),
|
|
ExtensionSubscript,
|
|
ExtensionSuperscript,
|
|
ExtensionPlaceholder.configure({
|
|
placeholder: t(
|
|
"core.components.default_editor.extensions.placeholder.options.placeholder"
|
|
),
|
|
}),
|
|
ExtensionHighlight,
|
|
ExtensionCommands,
|
|
ExtensionCodeBlock.configure({
|
|
lowlight,
|
|
}),
|
|
ExtensionIframe,
|
|
ExtensionVideo,
|
|
ExtensionAudio,
|
|
ExtensionCharacterCount,
|
|
...extensionsFromPlugins,
|
|
Extension.create({
|
|
addGlobalAttributes() {
|
|
return [
|
|
{
|
|
types: ["heading"],
|
|
attributes: {
|
|
id: {
|
|
default: null,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
},
|
|
}),
|
|
Extension.create({
|
|
addOptions() {
|
|
return {
|
|
getToolbarItems: ({ editor }: { editor: Editor }) => {
|
|
return {
|
|
priority: 220,
|
|
component: ToolbarItem,
|
|
props: {
|
|
editor,
|
|
isActive: false,
|
|
icon: markRaw(MdiFileImageBox),
|
|
title: i18n.global.t(
|
|
"core.components.default_editor.toolbar.attachment"
|
|
),
|
|
},
|
|
children: [
|
|
{
|
|
priority: 10,
|
|
component: ToolbarSubItem,
|
|
props: {
|
|
editor,
|
|
isActive: false,
|
|
icon: markRaw(IconFolder),
|
|
title: i18n.global.t(
|
|
"core.components.default_editor.toolbar.select_attachment"
|
|
),
|
|
action: () => (attachmentSelectorModal.value = true),
|
|
},
|
|
},
|
|
{
|
|
priority: 20,
|
|
component: ToolbarSubItem,
|
|
props: {
|
|
editor,
|
|
isActive: false,
|
|
icon: markRaw(MdiImagePlusOutline),
|
|
title: i18n.global.t(
|
|
"core.components.default_editor.toolbar.insert_image"
|
|
),
|
|
action: () => {
|
|
editor.chain().focus().setImage({ src: "" }).run();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
priority: 30,
|
|
component: ToolbarSubItem,
|
|
props: {
|
|
editor,
|
|
isActive: false,
|
|
icon: markRaw(MdiVideoPlusOutline),
|
|
title: i18n.global.t(
|
|
"core.components.default_editor.toolbar.insert_video"
|
|
),
|
|
action: () => {
|
|
editor.chain().focus().setVideo({ src: "" }).run();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
priority: 40,
|
|
component: ToolbarSubItem,
|
|
props: {
|
|
editor,
|
|
isActive: false,
|
|
icon: markRaw(MdiVolume),
|
|
title: i18n.global.t(
|
|
"core.components.default_editor.toolbar.insert_audio"
|
|
),
|
|
action: () => {
|
|
editor.chain().focus().setAudio({ src: "" }).run();
|
|
},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
],
|
|
autofocus: "start",
|
|
onUpdate: () => {
|
|
emit("update:raw", editor.value?.getHTML() + "");
|
|
emit("update:content", editor.value?.getHTML() + "");
|
|
emit("update", editor.value?.getHTML() + "");
|
|
nextTick(() => {
|
|
handleGenerateTableOfContent();
|
|
});
|
|
},
|
|
editorProps: {
|
|
handleDrop: (view, event: DragEvent, _, moved) => {
|
|
if (!moved && event.dataTransfer && event.dataTransfer.files) {
|
|
const images = Array.from(event.dataTransfer.files).filter((file) =>
|
|
file.type.startsWith("image/")
|
|
) as File[];
|
|
|
|
if (images.length === 0) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
images.forEach((file, index) => {
|
|
uploadQueue.push({
|
|
file,
|
|
process: (url: string) => {
|
|
const { schema } = view.state;
|
|
const coordinates = view.posAtCoords({
|
|
left: event.clientX,
|
|
top: event.clientY,
|
|
});
|
|
|
|
if (!coordinates) return;
|
|
|
|
const node = schema.nodes.image.create({
|
|
src: url,
|
|
});
|
|
|
|
const transaction = view.state.tr.insert(
|
|
coordinates.pos + index,
|
|
node
|
|
);
|
|
|
|
editor.value?.view.dispatch(transaction);
|
|
},
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
handlePaste: (view, event: ClipboardEvent) => {
|
|
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();
|
|
},
|
|
});
|
|
});
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
// image drag and paste upload
|
|
const { policies } = useFetchAttachmentPolicy();
|
|
|
|
type Task = {
|
|
file: File;
|
|
process: (permalink: string) => void;
|
|
};
|
|
|
|
const uploadQueue: queueAsPromised<Task> = fastq.promise(asyncWorker, 1);
|
|
|
|
async function asyncWorker(arg: Task): Promise<void> {
|
|
if (!policies.value?.length) {
|
|
Toast.warning(
|
|
t(
|
|
"core.components.default_editor.upload_attachment.toast.no_available_policy"
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { data: attachmentData } = await apiClient.attachment.uploadAttachment({
|
|
file: arg.file,
|
|
policyName: policies.value[0].metadata.name,
|
|
});
|
|
|
|
const permalink = await handleFetchPermalink(attachmentData, 3);
|
|
|
|
if (permalink) {
|
|
arg.process(permalink);
|
|
}
|
|
}
|
|
|
|
const handleFetchPermalink = async (
|
|
attachment: Attachment,
|
|
maxRetry: number
|
|
): Promise<string | undefined> => {
|
|
if (maxRetry === 0) {
|
|
Toast.error(
|
|
t(
|
|
"core.components.default_editor.upload_attachment.toast.failed_fetch_permalink",
|
|
{ display_name: attachment.spec.displayName }
|
|
)
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const { data } =
|
|
await apiClient.extension.storage.attachment.getstorageHaloRunV1alpha1Attachment(
|
|
{
|
|
name: attachment.metadata.name,
|
|
}
|
|
);
|
|
|
|
if (data.status?.permalink) {
|
|
return data.status.permalink;
|
|
}
|
|
|
|
return await new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
const permalink = handleFetchPermalink(attachment, maxRetry - 1);
|
|
clearTimeout(timer);
|
|
resolve(permalink);
|
|
}, 300);
|
|
});
|
|
};
|
|
|
|
const handleGenerateTableOfContent = () => {
|
|
if (!editor.value) {
|
|
return;
|
|
}
|
|
|
|
const headings: HeadingNode[] = [];
|
|
const transaction = editor.value.state.tr;
|
|
|
|
editor.value.state.doc.descendants((node, pos) => {
|
|
if (node.type.name === "heading") {
|
|
const id = `heading-${headings.length + 1}`;
|
|
|
|
if (node.attrs.id !== id) {
|
|
transaction?.setNodeMarkup(pos, undefined, {
|
|
...node.attrs,
|
|
id,
|
|
});
|
|
}
|
|
|
|
headings.push({
|
|
level: node.attrs.level,
|
|
text: node.textContent,
|
|
id,
|
|
});
|
|
}
|
|
});
|
|
|
|
transaction.setMeta("addToHistory", false);
|
|
transaction.setMeta("preventUpdate", true);
|
|
|
|
editor.value.view.dispatch(transaction);
|
|
|
|
headingNodes.value = headings;
|
|
|
|
if (!selectedHeadingNode.value) {
|
|
selectedHeadingNode.value = headings[0];
|
|
}
|
|
};
|
|
|
|
const handleSelectHeadingNode = (node: HeadingNode) => {
|
|
selectedHeadingNode.value = node;
|
|
document.getElementById(node.id)?.scrollIntoView({ behavior: "smooth" });
|
|
};
|
|
|
|
const { onAttachmentSelect } = useAttachmentSelect(editor);
|
|
|
|
watch(
|
|
() => props.raw,
|
|
() => {
|
|
if (props.raw !== editor.value?.getHTML()) {
|
|
editor.value?.commands.setContent(props.raw);
|
|
nextTick(() => {
|
|
handleGenerateTableOfContent();
|
|
});
|
|
}
|
|
},
|
|
{
|
|
immediate: true,
|
|
}
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<AttachmentSelectorModal
|
|
v-model:visible="attachmentSelectorModal"
|
|
@select="onAttachmentSelect"
|
|
/>
|
|
<RichTextEditor
|
|
v-if="editor"
|
|
:editor="editor"
|
|
:locale="i18n.global.locale.value"
|
|
:content-styles="{
|
|
width: 'calc(100% - 18rem)',
|
|
}"
|
|
>
|
|
<template #extra>
|
|
<OverlayScrollbarsComponent
|
|
element="div"
|
|
:options="{ scrollbars: { autoHide: 'scroll' } }"
|
|
class="h-full w-72 border-l bg-white"
|
|
defer
|
|
>
|
|
<VTabs v-model:active-id="extraActiveId" type="outline">
|
|
<VTabItem
|
|
id="toc"
|
|
:label="$t('core.components.default_editor.tabs.toc.title')"
|
|
>
|
|
<div class="p-1 pt-0">
|
|
<ul v-if="headingNodes?.length" class="space-y-1">
|
|
<li
|
|
v-for="(node, index) in headingNodes"
|
|
:key="index"
|
|
:class="[
|
|
{ 'bg-gray-100': node.id === selectedHeadingNode?.id },
|
|
]"
|
|
class="group cursor-pointer truncate rounded-base px-1.5 py-1 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
@click="handleSelectHeadingNode(node)"
|
|
>
|
|
<div
|
|
:style="{
|
|
paddingLeft: `${(node.level - 1) * 0.8}rem`,
|
|
}"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<component
|
|
:is="headingIcons[node.level]"
|
|
class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white"
|
|
:class="[
|
|
{ '!bg-white': node.id === selectedHeadingNode?.id },
|
|
]"
|
|
/>
|
|
<span class="flex-1 truncate">{{ node.text }}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<div v-else class="flex flex-col items-center py-10">
|
|
<span class="text-sm text-gray-600">
|
|
{{ $t("core.components.default_editor.tabs.toc.empty") }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</VTabItem>
|
|
<VTabItem
|
|
id="information"
|
|
:label="$t('core.components.default_editor.tabs.detail.title')"
|
|
>
|
|
<div class="flex flex-col gap-2 p-1 pt-0">
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div
|
|
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div
|
|
class="text-sm text-gray-500 group-hover:text-gray-900"
|
|
>
|
|
{{
|
|
$t(
|
|
"core.components.default_editor.tabs.detail.fields.character_count"
|
|
)
|
|
}}
|
|
</div>
|
|
<div class="rounded bg-gray-200 p-0.5">
|
|
<IconCharacterRecognition
|
|
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="text-base font-medium text-gray-900">
|
|
{{ editor.storage.characterCount.characters() }}
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div
|
|
class="text-sm text-gray-500 group-hover:text-gray-900"
|
|
>
|
|
{{
|
|
$t(
|
|
"core.components.default_editor.tabs.detail.fields.word_count"
|
|
)
|
|
}}
|
|
</div>
|
|
<div class="rounded bg-gray-200 p-0.5">
|
|
<IconCharacterRecognition
|
|
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="text-base font-medium text-gray-900">
|
|
{{ editor.storage.characterCount.words() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="publishTime" class="grid grid-cols-1 gap-2">
|
|
<div
|
|
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div
|
|
class="text-sm text-gray-500 group-hover:text-gray-900"
|
|
>
|
|
{{
|
|
$t(
|
|
"core.components.default_editor.tabs.detail.fields.publish_time"
|
|
)
|
|
}}
|
|
</div>
|
|
<div class="rounded bg-gray-200 p-0.5">
|
|
<IconCalendar
|
|
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="text-base font-medium text-gray-900">
|
|
{{
|
|
formatDatetime(publishTime) ||
|
|
$t(
|
|
"core.components.default_editor.tabs.detail.fields.draft"
|
|
)
|
|
}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="owner" class="grid grid-cols-1 gap-2">
|
|
<div
|
|
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div
|
|
class="text-sm text-gray-500 group-hover:text-gray-900"
|
|
>
|
|
{{
|
|
$t(
|
|
"core.components.default_editor.tabs.detail.fields.owner"
|
|
)
|
|
}}
|
|
</div>
|
|
<div class="rounded bg-gray-200 p-0.5">
|
|
<IconUserFollow
|
|
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="text-base font-medium text-gray-900">
|
|
{{ owner }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="permalink" class="grid grid-cols-1 gap-2">
|
|
<div
|
|
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div
|
|
class="text-sm text-gray-500 group-hover:text-gray-900"
|
|
>
|
|
{{
|
|
$t(
|
|
"core.components.default_editor.tabs.detail.fields.permalink"
|
|
)
|
|
}}
|
|
</div>
|
|
<div class="rounded bg-gray-200 p-0.5">
|
|
<IconLink
|
|
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<a
|
|
:href="permalink"
|
|
:title="permalink"
|
|
target="_blank"
|
|
class="text-sm text-gray-900 hover:text-blue-600"
|
|
>
|
|
{{ permalink }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</VTabItem>
|
|
</VTabs>
|
|
</OverlayScrollbarsComponent>
|
|
</template>
|
|
</RichTextEditor>
|
|
</template>
|