feat: add editor extension point (#781)

#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

添加编辑器的扩展点,用于扩展集成其他编辑器。

定义一个扩展点的方式:

```ts
export default definePlugin({
  extensionPoints: {
    "editor:create": () => {
      return [
        {
          name: "stackedit",
          displayName: "StackEdit",
          component: markRaw(StackEdit),
          rawType: "markdown",
        },
      ];
    },
  },
});
```

其中 `component` 字段即编辑器组件对象,需要包含 `raw`、`content` 的 prop。

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2881

#### Screenshots:

<img width="1664" alt="image" src="https://user-images.githubusercontent.com/21301288/208406097-60258cba-cff6-436f-bd50-6d8c27ea9a53.png">
<img width="1662" alt="image" src="https://user-images.githubusercontent.com/21301288/208406174-d4649365-3448-4581-a452-f9781502eac6.png">
<img width="1920" alt="image" src="https://user-images.githubusercontent.com/21301288/208407570-db10e956-cd6a-4e0d-801e-b794ad0261bc.png">
<img width="1920" alt="image" src="https://user-images.githubusercontent.com/21301288/208407607-fd595957-5278-40c2-a3b5-fb73c1de429c.png">

#### Special notes for your reviewer:

目前可用于测试的插件:

1. [plugin-stackedit-1.0.0-SNAPSHOT.jar.zip](https://github.com/halo-dev/console/files/10258488/plugin-stackedit-1.0.0-SNAPSHOT.jar.zip)
2. [plugin-bytemd-1.0.0-SNAPSHOT.jar.zip](https://github.com/halo-dev/console/files/10258490/plugin-bytemd-1.0.0-SNAPSHOT.jar.zip)

测试方式:

1. Console 需要 `pnpm build:packages`。
2. 在 Console 的插件管理上传以上插件。
3. 新建若干文章,使用不同的编辑器。
4. 检查是否能够正常发布和编辑。
5. 检查编辑的时候,是否正确使用了之前的编辑器。
6. 检查主题端是否渲染正常。

一些实现细节:

1. 为了支持更新文章时能够选择发布时的编辑器,会在 post 的 `metadata.annotations` 添加一条 `content.halo.run/preferred-editor` 用于标记使用的什么编辑器。如果编辑器不存在,会使用 content 的 `rawType` 来匹配。
2. 目前没有全局默认编辑器设置,只能在新建文章的时候选择。

#### Does this PR introduce a user-facing change?

```release-note
Console 端支持扩展集成其他编辑器
```
pull/789/head^2
Ryan Wang 2022-12-22 12:14:29 +08:00 committed by GitHub
parent d575b6698b
commit a396aad87f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 394 additions and 41 deletions

View File

@ -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
<template>
<div>
<textarea :value="raw" @input="onRawUpdate" />
<div v-html="content" />
</div>
</template>
<script lang="ts" setup>
import { watch } from "vue";
import marked from "marked";
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;
}>();
function onRawUpdate(e: Event) {
const raw = (e.target as HTMLTextAreaElement).value;
emit("update:raw", raw);
}
watch(
() => props.raw,
() => {
emit("update:content", marked(props.raw));
}
);
</script>
```

View File

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

View File

@ -0,0 +1,8 @@
import type { Component } from "vue";
export interface EditorProvider {
name: string;
displayName: string;
component: Component;
rawType: string;
}

View File

@ -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<AttachmentSelectProvider[]>;
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
}
export interface PluginModule {

View File

@ -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<ComputedRef<string | undefined>>("owner");
const publishTime = inject<ComputedRef<string | undefined>>("publishTime");
const permalink = inject<ComputedRef<string | undefined>>("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();
});

View File

@ -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<EditorProvider[]>;
}
export function useEditorExtensionPoints(): useEditorExtensionPointsReturn {
// resolve plugin extension points
const { pluginModules } = usePluginModuleStore();
const editorProviders = ref<EditorProvider[]>([
{
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,
};
}

View File

@ -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<EditorProvider>();
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<ComputedRef<string | undefined>>(
"owner",
computed(() => formState.value.page.spec.owner)
);
provide<ComputedRef<string | undefined>>(
"publishTime",
computed(() => formState.value.page.spec.publishTime)
);
provide<ComputedRef<string | undefined>>(
"permalink",
computed(() => formState.value.page.status?.permalink)
);
const routeQueryName = useRouteQuery<string>("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 } =
</template>
</VPageHeader>
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
<DefaultEditor
v-model="formState.content.raw"
:owner="formState.page.spec.owner"
:permalink="formState.page.status?.permalink"
:publish-time="formState.page.spec.publishTime"
<component
:is="currentEditorProvider.component"
v-if="currentEditorProvider"
v-model:raw="formState.content.raw"
v-model:content="formState.content.content"
class="h-full"
@update="handleSetContentCache"
/>
</div>

View File

@ -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(() => {
<VButton :route="{ name: 'DeletedSinglePages' }" size="sm">
回收站
</VButton>
<FloatingDropdown
v-if="editorProviders.length > 1"
v-permission="['system:singlepages:manage']"
>
<VButton type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-for="(editorProvider, index) in editorProviders"
:key="index"
v-close-popper
block
type="default"
:route="{
name: 'SinglePageEditor',
query: { editor: editorProvider.name },
}"
>
{{ editorProvider.displayName }}
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
<VButton
v-else
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="secondary"

View File

@ -8,21 +8,34 @@ import {
VPageHeader,
VSpace,
Toast,
Dialog,
} from "@halo-dev/components";
import DefaultEditor from "@/components/editor/DefaultEditor.vue";
import PostSettingModal from "./components/PostSettingModal.vue";
import PostPreviewModal from "./components/PostPreviewModal.vue";
import type { Post, PostRequest } from "@halo-dev/api-client";
import { computed, onMounted, ref, toRef } from "vue";
import {
computed,
nextTick,
onMounted,
provide,
ref,
toRef,
type ComputedRef,
} from "vue";
import type { EditorProvider } from "@halo-dev/console-shared";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
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";
const router = useRouter();
const { editorProviders } = useEditorExtensionPoints();
const currentEditorProvider = ref<EditorProvider>();
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<ComputedRef<string | undefined>>(
"owner",
computed(() => formState.value.post.spec.owner)
);
provide<ComputedRef<string | undefined>>(
"publishTime",
computed(() => formState.value.post.spec.publishTime)
);
provide<ComputedRef<string | undefined>>(
"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 } =
</template>
</VPageHeader>
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
<DefaultEditor
v-model="formState.content.raw"
:owner="formState.post.spec.owner"
:permalink="formState.post.status?.permalink"
:publish-time="formState.post.spec.publishTime"
<component
:is="currentEditorProvider.component"
v-if="currentEditorProvider"
v-model:raw="formState.content.raw"
v-model:content="formState.content.content"
class="h-full"
@update="handleSetContentCache"
/>
</div>

View File

@ -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<ListedPostList>({
page: 1,
size: 20,
@ -476,7 +479,40 @@ const hasFilters = computed(() => {
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
<VButton :route="{ name: 'DeletedPosts' }" size="sm">回收站</VButton>
<FloatingDropdown
v-if="editorProviders.length > 1"
v-permission="['system:posts:manage']"
>
<VButton type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-for="(editorProvider, index) in editorProviders"
:key="index"
v-close-popper
block
type="default"
:route="{
name: 'PostEditor',
query: { editor: editorProvider.name },
}"
>
{{ editorProvider.displayName }}
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
<VButton
v-else
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
type="secondary"