diff --git a/docs/extension-points/editor.md b/docs/extension-points/editor.md new file mode 100644 index 000000000..21dd63df8 --- /dev/null +++ b/docs/extension-points/editor.md @@ -0,0 +1,73 @@ +# 编辑器集成扩展点 + +## 定义方式 + +```ts +import MarkdownEditor from "./components/MarkdownEditor.vue" + +export default definePlugin({ + extensionPoints: { + "editor:create": () => { + return [ + { + name: "markdown-editor", + displayName: "Markdown", + component: markRaw(MarkdownEditor), + rawType: "markdown", + }, + ]; + }, + }, +}); +``` + +- name: 编辑器名称,用于标识编辑器 +- displayName: 编辑器显示名称 +- component: 编辑器组件 +- rawType: 编辑器支持的原始类型,可以完全由插件定义。但必须保证最终能够将渲染后的 html 设置到 content 中。 + +## 组件 + +组件必须设置两个 `v-model` 绑定。即 `v-model:raw` 和 `v-model:content`,以下是示例: + +```vue + + + + + + + + +``` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0e22b9e4b..9f18df67a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,3 +3,4 @@ export * from "./types/menus"; export * from "./core/plugins"; export * from "./states/pages"; export * from "./states/attachment-selector"; +export * from "./states/editor"; diff --git a/packages/shared/src/states/editor.ts b/packages/shared/src/states/editor.ts new file mode 100644 index 000000000..56aa3d123 --- /dev/null +++ b/packages/shared/src/states/editor.ts @@ -0,0 +1,8 @@ +import type { Component } from "vue"; + +export interface EditorProvider { + name: string; + displayName: string; + component: Component; + rawType: string; +} diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 5629a4cd2..8217b4acc 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -2,6 +2,7 @@ import type { Component } from "vue"; import type { RouteRecordRaw, RouteRecordName } from "vue-router"; import type { FunctionalPage } from "../states/pages"; import type { AttachmentSelectProvider } from "../states/attachment-selector"; +import type { EditorProvider } from ".."; export interface RouteRecordAppend { parentName: RouteRecordName; @@ -14,6 +15,8 @@ export interface ExtensionPoint { "attachment:selector:create"?: () => | AttachmentSelectProvider[] | Promise; + + "editor:create"?: () => EditorProvider[] | Promise; } export interface PluginModule { diff --git a/src/components/editor/DefaultEditor.vue b/src/components/editor/DefaultEditor.vue index 7ddc351b8..b8ce7a963 100644 --- a/src/components/editor/DefaultEditor.vue +++ b/src/components/editor/DefaultEditor.vue @@ -89,30 +89,39 @@ 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 { computed, markRaw, nextTick, ref, watch } from "vue"; +import { + computed, + inject, + markRaw, + nextTick, + ref, + watch, + type ComputedRef, +} from "vue"; import { formatDatetime } from "@/utils/date"; import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment"; const props = withDefaults( defineProps<{ - modelValue?: string; - owner?: string; - permalink?: string; - publishTime?: string | null; + raw?: string; + content: string; }>(), { - modelValue: "", - owner: undefined, - permalink: undefined, - publishTime: undefined, + raw: "", + content: "", } ); const emit = defineEmits<{ - (event: "update:modelValue", value: string): void; + (event: "update:raw", value: string): void; + (event: "update:content", value: string): void; (event: "update", value: string): void; }>(); +const owner = inject>("owner"); +const publishTime = inject>("publishTime"); +const permalink = inject>("permalink"); + interface HeadingNode { id: string; level: number; @@ -134,7 +143,7 @@ const extraActiveId = ref("toc"); const attachmentSelectorModal = ref(false); const editor = useEditor({ - content: props.modelValue, + content: props.raw, extensions: [ ExtensionBlockquote, ExtensionBold, @@ -226,7 +235,8 @@ const editor = useEditor({ ], autofocus: "start", onUpdate: () => { - emit("update:modelValue", editor.value?.getHTML() + ""); + emit("update:raw", editor.value?.getHTML() + ""); + emit("update:content", editor.value?.getHTML() + ""); emit("update", editor.value?.getHTML() + ""); nextTick(() => { handleGenerateTableOfContent(); @@ -340,10 +350,10 @@ const handleSelectHeadingNode = (node: HeadingNode) => { const { onAttachmentSelect } = useAttachmentSelect(editor); watch( - () => props.modelValue, + () => props.raw, () => { - if (props.modelValue !== editor.value?.getHTML()) { - editor.value?.commands.setContent(props.modelValue); + if (props.raw !== editor.value?.getHTML()) { + editor.value?.commands.setContent(props.raw); nextTick(() => { handleGenerateTableOfContent(); }); diff --git a/src/composables/use-editor-extension-points.ts b/src/composables/use-editor-extension-points.ts new file mode 100644 index 000000000..70761d133 --- /dev/null +++ b/src/composables/use-editor-extension-points.ts @@ -0,0 +1,43 @@ +import DefaultEditor from "@/components/editor/DefaultEditor.vue"; +import { usePluginModuleStore } from "@/stores/plugin"; +import type { EditorProvider, PluginModule } from "@halo-dev/console-shared"; +import { markRaw, onMounted, ref, type Ref } from "vue"; + +interface useEditorExtensionPointsReturn { + editorProviders: Ref; +} + +export function useEditorExtensionPoints(): useEditorExtensionPointsReturn { + // resolve plugin extension points + const { pluginModules } = usePluginModuleStore(); + + const editorProviders = ref([ + { + name: "default", + displayName: "默认编辑器", + component: markRaw(DefaultEditor), + rawType: "HTML", + }, + ]); + + onMounted(() => { + pluginModules.forEach((pluginModule: PluginModule) => { + const { extensionPoints } = pluginModule; + if (!extensionPoints?.["editor:create"]) { + return; + } + + const providers = extensionPoints["editor:create"]() as EditorProvider[]; + + if (providers) { + providers.forEach((provider) => { + editorProviders.value.push(provider); + }); + } + }); + }); + + return { + editorProviders, + }; +} diff --git a/src/modules/contents/pages/SinglePageEditor.vue b/src/modules/contents/pages/SinglePageEditor.vue index 571335ecb..97fcb6695 100644 --- a/src/modules/contents/pages/SinglePageEditor.vue +++ b/src/modules/contents/pages/SinglePageEditor.vue @@ -8,21 +8,34 @@ import { VButton, IconSave, Toast, + Dialog, } from "@halo-dev/components"; -import DefaultEditor from "@/components/editor/DefaultEditor.vue"; import SinglePageSettingModal from "./components/SinglePageSettingModal.vue"; import PostPreviewModal from "../posts/components/PostPreviewModal.vue"; import type { SinglePage, SinglePageRequest } from "@halo-dev/api-client"; -import { computed, onMounted, ref, toRef } from "vue"; +import { + computed, + nextTick, + onMounted, + provide, + ref, + toRef, + type ComputedRef, +} from "vue"; import { apiClient } from "@/utils/api-client"; import { useRouteQuery } from "@vueuse/router"; import cloneDeep from "lodash.clonedeep"; import { useRouter } from "vue-router"; import { randomUUID } from "@/utils/id"; import { useContentCache } from "@/composables/use-content-cache"; +import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points"; +import type { EditorProvider } from "@halo-dev/console-shared"; const router = useRouter(); +const { editorProviders } = useEditorExtensionPoints(); +const currentEditorProvider = ref(); + const initialFormState: SinglePageRequest = { page: { spec: { @@ -47,6 +60,7 @@ const initialFormState: SinglePageRequest = { kind: "SinglePage", metadata: { name: randomUUID(), + annotations: {}, }, }, content: { @@ -66,15 +80,26 @@ const isUpdateMode = computed(() => { return !!formState.value.page.metadata.creationTimestamp; }); +// provide some data to editor +provide>( + "owner", + computed(() => formState.value.page.spec.owner) +); +provide>( + "publishTime", + computed(() => formState.value.page.spec.publishTime) +); +provide>( + "permalink", + computed(() => formState.value.page.status?.permalink) +); + const routeQueryName = useRouteQuery("name"); const handleSave = async () => { try { saving.value = true; - // Set rendered content - formState.value.content.content = formState.value.content.raw; - //Set default title and slug if (!formState.value.page.spec.title) { formState.value.page.spec.title = "无标题页面"; @@ -116,9 +141,6 @@ const handlePublish = async () => { try { publishing.value = true; - // Set rendered content - formState.value.content.content = formState.value.content.raw; - if (isUpdateMode.value) { const { name: singlePageName } = formState.value.page.metadata; const { permalink } = formState.value.page.status || {}; @@ -171,6 +193,38 @@ const handleFetchContent = async () => { snapshotName: formState.value.page.spec.headSnapshot, }); + // get editor provider + if (!currentEditorProvider.value) { + const preferredEditor = editorProviders.value.find( + (provider) => + provider.name === + formState.value.page.metadata.annotations?.[ + "content.halo.run/preferred-editor" + ] + ); + const provider = + preferredEditor || + editorProviders.value.find( + (provider) => provider.rawType === data.rawType + ); + if (provider) { + currentEditorProvider.value = provider; + formState.value.page.metadata.annotations = { + ...formState.value.page.metadata.annotations, + "content.halo.run/preferred-editor": provider.name, + }; + } else { + Dialog.warning({ + title: "警告", + description: `未找到符合 ${data.rawType} 格式的编辑器,请检查是否已安装编辑器插件`, + onConfirm: () => { + router.back(); + }, + }); + } + await nextTick(); + } + formState.value.content = Object.assign(formState.value.content, data); }; @@ -203,6 +257,7 @@ const onSettingPublished = (singlePage: SinglePage) => { handlePublish(); }; +const editor = useRouteQuery("editor"); onMounted(async () => { if (routeQueryName.value) { const { data: singlePage } = @@ -213,7 +268,21 @@ onMounted(async () => { // fetch single page content await handleFetchContent(); + } else { + // Set default editor + const provider = + editorProviders.value.find( + (provider) => provider.name === editor.value + ) || editorProviders.value[0]; + if (provider) { + currentEditorProvider.value = provider; + formState.value.content.rawType = provider.rawType; + } + formState.value.page.metadata.annotations = { + "content.halo.run/preferred-editor": provider.name, + }; } + handleResetCache(); }); @@ -281,11 +350,12 @@ const { handleSetContentCache, handleResetCache, handleClearCache } = - diff --git a/src/modules/contents/pages/layouts/PageLayout.vue b/src/modules/contents/pages/layouts/PageLayout.vue index 7d36e276a..d1971728a 100644 --- a/src/modules/contents/pages/layouts/PageLayout.vue +++ b/src/modules/contents/pages/layouts/PageLayout.vue @@ -11,10 +11,13 @@ import { } from "@halo-dev/components"; import BasicLayout from "@/layouts/BasicLayout.vue"; import { useRoute, useRouter } from "vue-router"; +import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points"; const route = useRoute(); const router = useRouter(); +const { editorProviders } = useEditorExtensionPoints(); + interface PageTab { id: string; label: string; @@ -75,7 +78,38 @@ watchEffect(() => { 回收站 + + + + + + 新建 + + + + + + {{ editorProvider.displayName }} + + + + + (); + const initialFormState: PostRequest = { post: { spec: { @@ -49,6 +62,7 @@ const initialFormState: PostRequest = { kind: "Post", metadata: { name: randomUUID(), + annotations: {}, }, }, content: { @@ -68,13 +82,24 @@ const isUpdateMode = computed(() => { return !!formState.value.post.metadata.creationTimestamp; }); +// provide some data to editor +provide>( + "owner", + computed(() => formState.value.post.spec.owner) +); +provide>( + "publishTime", + computed(() => formState.value.post.spec.publishTime) +); +provide>( + "permalink", + computed(() => formState.value.post.status?.permalink) +); + const handleSave = async () => { try { saving.value = true; - // Set rendered content - formState.value.content.content = formState.value.content.raw; - // Set default title and slug if (!formState.value.post.spec.title) { formState.value.post.spec.title = "无标题文章"; @@ -116,9 +141,6 @@ const handlePublish = async () => { try { publishing.value = true; - // Set rendered content - formState.value.content.content = formState.value.content.raw; - if (isUpdateMode.value) { const { name: postName } = formState.value.post.metadata; const { permalink } = formState.value.post.status || {}; @@ -176,6 +198,42 @@ const handleFetchContent = async () => { snapshotName: formState.value.post.spec.headSnapshot, }); + // get editor provider + if (!currentEditorProvider.value) { + const preferredEditor = editorProviders.value.find( + (provider) => + provider.name === + formState.value.post.metadata.annotations?.[ + "content.halo.run/preferred-editor" + ] + ); + + const provider = + preferredEditor || + editorProviders.value.find( + (provider) => provider.rawType === data.rawType + ); + + if (provider) { + currentEditorProvider.value = provider; + + formState.value.post.metadata.annotations = { + ...formState.value.post.metadata.annotations, + "content.halo.run/preferred-editor": provider.name, + }; + } else { + Dialog.warning({ + title: "警告", + description: `未找到符合 ${data.rawType} 格式的编辑器,请检查是否已安装编辑器插件`, + onConfirm: () => { + router.back(); + }, + }); + } + + await nextTick(); + } + formState.value.content = Object.assign(formState.value.content, data); }; @@ -210,6 +268,7 @@ const onSettingPublished = (post: Post) => { // Get post data when the route contains the name parameter const name = useRouteQuery("name"); +const editor = useRouteQuery("editor"); onMounted(async () => { if (name.value) { // fetch post @@ -221,6 +280,21 @@ onMounted(async () => { // fetch post content await handleFetchContent(); + } else { + // Set default editor + const provider = + editorProviders.value.find( + (provider) => provider.name === editor.value + ) || editorProviders.value[0]; + + if (provider) { + currentEditorProvider.value = provider; + formState.value.content.rawType = provider.rawType; + } + + formState.value.post.metadata.annotations = { + "content.halo.run/preferred-editor": provider.name, + }; } handleResetCache(); }); @@ -289,11 +363,12 @@ const { handleSetContentCache, handleResetCache, handleClearCache } = - diff --git a/src/modules/contents/posts/PostList.vue b/src/modules/contents/posts/PostList.vue index 2a36cc0cf..596421fd9 100644 --- a/src/modules/contents/posts/PostList.vue +++ b/src/modules/contents/posts/PostList.vue @@ -45,9 +45,12 @@ import FilterTag from "@/components/filter/FilterTag.vue"; import FilteCleanButton from "@/components/filter/FilterCleanButton.vue"; import { getNode } from "@formkit/core"; import TagDropdownSelector from "@/components/dropdown-selector/TagDropdownSelector.vue"; +import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points"; const { currentUserHasPermission } = usePermission(); +const { editorProviders } = useEditorExtensionPoints(); + const posts = ref({ page: 1, size: 20, @@ -476,7 +479,40 @@ const hasFilters = computed(() => { 分类 标签 回收站 + + + + + + + 新建 + + + + + + {{ editorProvider.displayName }} + + + + + +