mirror of https://github.com/halo-dev/halo-admin
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
parent
d575b6698b
commit
a396aad87f
|
@ -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>
|
||||||
|
```
|
|
@ -3,3 +3,4 @@ export * from "./types/menus";
|
||||||
export * from "./core/plugins";
|
export * from "./core/plugins";
|
||||||
export * from "./states/pages";
|
export * from "./states/pages";
|
||||||
export * from "./states/attachment-selector";
|
export * from "./states/attachment-selector";
|
||||||
|
export * from "./states/editor";
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { Component } from "vue";
|
||||||
|
|
||||||
|
export interface EditorProvider {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
component: Component;
|
||||||
|
rawType: string;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import type { Component } from "vue";
|
||||||
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
|
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
|
||||||
import type { FunctionalPage } from "../states/pages";
|
import type { FunctionalPage } from "../states/pages";
|
||||||
import type { AttachmentSelectProvider } from "../states/attachment-selector";
|
import type { AttachmentSelectProvider } from "../states/attachment-selector";
|
||||||
|
import type { EditorProvider } from "..";
|
||||||
|
|
||||||
export interface RouteRecordAppend {
|
export interface RouteRecordAppend {
|
||||||
parentName: RouteRecordName;
|
parentName: RouteRecordName;
|
||||||
|
@ -14,6 +15,8 @@ export interface ExtensionPoint {
|
||||||
"attachment:selector:create"?: () =>
|
"attachment:selector:create"?: () =>
|
||||||
| AttachmentSelectProvider[]
|
| AttachmentSelectProvider[]
|
||||||
| Promise<AttachmentSelectProvider[]>;
|
| Promise<AttachmentSelectProvider[]>;
|
||||||
|
|
||||||
|
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginModule {
|
export interface PluginModule {
|
||||||
|
|
|
@ -89,30 +89,39 @@ import MdiFormatHeader3 from "~icons/mdi/format-header-3";
|
||||||
import MdiFormatHeader4 from "~icons/mdi/format-header-4";
|
import MdiFormatHeader4 from "~icons/mdi/format-header-4";
|
||||||
import MdiFormatHeader5 from "~icons/mdi/format-header-5";
|
import MdiFormatHeader5 from "~icons/mdi/format-header-5";
|
||||||
import MdiFormatHeader6 from "~icons/mdi/format-header-6";
|
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 { formatDatetime } from "@/utils/date";
|
||||||
import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment";
|
import { useAttachmentSelect } from "@/modules/contents/attachments/composables/use-attachment";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: string;
|
raw?: string;
|
||||||
owner?: string;
|
content: string;
|
||||||
permalink?: string;
|
|
||||||
publishTime?: string | null;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
raw: "",
|
||||||
owner: undefined,
|
content: "",
|
||||||
permalink: undefined,
|
|
||||||
publishTime: undefined,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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;
|
(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 {
|
interface HeadingNode {
|
||||||
id: string;
|
id: string;
|
||||||
level: number;
|
level: number;
|
||||||
|
@ -134,7 +143,7 @@ const extraActiveId = ref("toc");
|
||||||
const attachmentSelectorModal = ref(false);
|
const attachmentSelectorModal = ref(false);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
content: props.modelValue,
|
content: props.raw,
|
||||||
extensions: [
|
extensions: [
|
||||||
ExtensionBlockquote,
|
ExtensionBlockquote,
|
||||||
ExtensionBold,
|
ExtensionBold,
|
||||||
|
@ -226,7 +235,8 @@ const editor = useEditor({
|
||||||
],
|
],
|
||||||
autofocus: "start",
|
autofocus: "start",
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
emit("update:modelValue", editor.value?.getHTML() + "");
|
emit("update:raw", editor.value?.getHTML() + "");
|
||||||
|
emit("update:content", editor.value?.getHTML() + "");
|
||||||
emit("update", editor.value?.getHTML() + "");
|
emit("update", editor.value?.getHTML() + "");
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
handleGenerateTableOfContent();
|
handleGenerateTableOfContent();
|
||||||
|
@ -340,10 +350,10 @@ const handleSelectHeadingNode = (node: HeadingNode) => {
|
||||||
const { onAttachmentSelect } = useAttachmentSelect(editor);
|
const { onAttachmentSelect } = useAttachmentSelect(editor);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.raw,
|
||||||
() => {
|
() => {
|
||||||
if (props.modelValue !== editor.value?.getHTML()) {
|
if (props.raw !== editor.value?.getHTML()) {
|
||||||
editor.value?.commands.setContent(props.modelValue);
|
editor.value?.commands.setContent(props.raw);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
handleGenerateTableOfContent();
|
handleGenerateTableOfContent();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -8,21 +8,34 @@ import {
|
||||||
VButton,
|
VButton,
|
||||||
IconSave,
|
IconSave,
|
||||||
Toast,
|
Toast,
|
||||||
|
Dialog,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import DefaultEditor from "@/components/editor/DefaultEditor.vue";
|
|
||||||
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
|
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
|
||||||
import PostPreviewModal from "../posts/components/PostPreviewModal.vue";
|
import PostPreviewModal from "../posts/components/PostPreviewModal.vue";
|
||||||
import type { SinglePage, SinglePageRequest } from "@halo-dev/api-client";
|
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 { apiClient } from "@/utils/api-client";
|
||||||
import { useRouteQuery } from "@vueuse/router";
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
import cloneDeep from "lodash.clonedeep";
|
import cloneDeep from "lodash.clonedeep";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { randomUUID } from "@/utils/id";
|
import { randomUUID } from "@/utils/id";
|
||||||
import { useContentCache } from "@/composables/use-content-cache";
|
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 router = useRouter();
|
||||||
|
|
||||||
|
const { editorProviders } = useEditorExtensionPoints();
|
||||||
|
const currentEditorProvider = ref<EditorProvider>();
|
||||||
|
|
||||||
const initialFormState: SinglePageRequest = {
|
const initialFormState: SinglePageRequest = {
|
||||||
page: {
|
page: {
|
||||||
spec: {
|
spec: {
|
||||||
|
@ -47,6 +60,7 @@ const initialFormState: SinglePageRequest = {
|
||||||
kind: "SinglePage",
|
kind: "SinglePage",
|
||||||
metadata: {
|
metadata: {
|
||||||
name: randomUUID(),
|
name: randomUUID(),
|
||||||
|
annotations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
|
@ -66,15 +80,26 @@ const isUpdateMode = computed(() => {
|
||||||
return !!formState.value.page.metadata.creationTimestamp;
|
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 routeQueryName = useRouteQuery<string>("name");
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
|
||||||
// Set rendered content
|
|
||||||
formState.value.content.content = formState.value.content.raw;
|
|
||||||
|
|
||||||
//Set default title and slug
|
//Set default title and slug
|
||||||
if (!formState.value.page.spec.title) {
|
if (!formState.value.page.spec.title) {
|
||||||
formState.value.page.spec.title = "无标题页面";
|
formState.value.page.spec.title = "无标题页面";
|
||||||
|
@ -116,9 +141,6 @@ const handlePublish = async () => {
|
||||||
try {
|
try {
|
||||||
publishing.value = true;
|
publishing.value = true;
|
||||||
|
|
||||||
// Set rendered content
|
|
||||||
formState.value.content.content = formState.value.content.raw;
|
|
||||||
|
|
||||||
if (isUpdateMode.value) {
|
if (isUpdateMode.value) {
|
||||||
const { name: singlePageName } = formState.value.page.metadata;
|
const { name: singlePageName } = formState.value.page.metadata;
|
||||||
const { permalink } = formState.value.page.status || {};
|
const { permalink } = formState.value.page.status || {};
|
||||||
|
@ -171,6 +193,38 @@ const handleFetchContent = async () => {
|
||||||
snapshotName: formState.value.page.spec.headSnapshot,
|
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);
|
formState.value.content = Object.assign(formState.value.content, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -203,6 +257,7 @@ const onSettingPublished = (singlePage: SinglePage) => {
|
||||||
handlePublish();
|
handlePublish();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editor = useRouteQuery("editor");
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (routeQueryName.value) {
|
if (routeQueryName.value) {
|
||||||
const { data: singlePage } =
|
const { data: singlePage } =
|
||||||
|
@ -213,7 +268,21 @@ onMounted(async () => {
|
||||||
|
|
||||||
// fetch single page content
|
// fetch single page content
|
||||||
await handleFetchContent();
|
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();
|
handleResetCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -281,11 +350,12 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
|
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
|
||||||
<DefaultEditor
|
<component
|
||||||
v-model="formState.content.raw"
|
:is="currentEditorProvider.component"
|
||||||
:owner="formState.page.spec.owner"
|
v-if="currentEditorProvider"
|
||||||
:permalink="formState.page.status?.permalink"
|
v-model:raw="formState.content.raw"
|
||||||
:publish-time="formState.page.spec.publishTime"
|
v-model:content="formState.content.content"
|
||||||
|
class="h-full"
|
||||||
@update="handleSetContentCache"
|
@update="handleSetContentCache"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,10 +11,13 @@ import {
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { editorProviders } = useEditorExtensionPoints();
|
||||||
|
|
||||||
interface PageTab {
|
interface PageTab {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -75,7 +78,38 @@ watchEffect(() => {
|
||||||
<VButton :route="{ name: 'DeletedSinglePages' }" size="sm">
|
<VButton :route="{ name: 'DeletedSinglePages' }" size="sm">
|
||||||
回收站
|
回收站
|
||||||
</VButton>
|
</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
|
<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']"
|
v-permission="['system:singlepages:manage']"
|
||||||
:route="{ name: 'SinglePageEditor' }"
|
:route="{ name: 'SinglePageEditor' }"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|
|
@ -8,21 +8,34 @@ import {
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
VSpace,
|
VSpace,
|
||||||
Toast,
|
Toast,
|
||||||
|
Dialog,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import DefaultEditor from "@/components/editor/DefaultEditor.vue";
|
|
||||||
import PostSettingModal from "./components/PostSettingModal.vue";
|
import PostSettingModal from "./components/PostSettingModal.vue";
|
||||||
import PostPreviewModal from "./components/PostPreviewModal.vue";
|
import PostPreviewModal from "./components/PostPreviewModal.vue";
|
||||||
import type { Post, PostRequest } from "@halo-dev/api-client";
|
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 cloneDeep from "lodash.clonedeep";
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
import { useRouteQuery } from "@vueuse/router";
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { randomUUID } from "@/utils/id";
|
import { randomUUID } from "@/utils/id";
|
||||||
import { useContentCache } from "@/composables/use-content-cache";
|
import { useContentCache } from "@/composables/use-content-cache";
|
||||||
|
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { editorProviders } = useEditorExtensionPoints();
|
||||||
|
const currentEditorProvider = ref<EditorProvider>();
|
||||||
|
|
||||||
const initialFormState: PostRequest = {
|
const initialFormState: PostRequest = {
|
||||||
post: {
|
post: {
|
||||||
spec: {
|
spec: {
|
||||||
|
@ -49,6 +62,7 @@ const initialFormState: PostRequest = {
|
||||||
kind: "Post",
|
kind: "Post",
|
||||||
metadata: {
|
metadata: {
|
||||||
name: randomUUID(),
|
name: randomUUID(),
|
||||||
|
annotations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
|
@ -68,13 +82,24 @@ const isUpdateMode = computed(() => {
|
||||||
return !!formState.value.post.metadata.creationTimestamp;
|
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 () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
|
||||||
// Set rendered content
|
|
||||||
formState.value.content.content = formState.value.content.raw;
|
|
||||||
|
|
||||||
// Set default title and slug
|
// Set default title and slug
|
||||||
if (!formState.value.post.spec.title) {
|
if (!formState.value.post.spec.title) {
|
||||||
formState.value.post.spec.title = "无标题文章";
|
formState.value.post.spec.title = "无标题文章";
|
||||||
|
@ -116,9 +141,6 @@ const handlePublish = async () => {
|
||||||
try {
|
try {
|
||||||
publishing.value = true;
|
publishing.value = true;
|
||||||
|
|
||||||
// Set rendered content
|
|
||||||
formState.value.content.content = formState.value.content.raw;
|
|
||||||
|
|
||||||
if (isUpdateMode.value) {
|
if (isUpdateMode.value) {
|
||||||
const { name: postName } = formState.value.post.metadata;
|
const { name: postName } = formState.value.post.metadata;
|
||||||
const { permalink } = formState.value.post.status || {};
|
const { permalink } = formState.value.post.status || {};
|
||||||
|
@ -176,6 +198,42 @@ const handleFetchContent = async () => {
|
||||||
snapshotName: formState.value.post.spec.headSnapshot,
|
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);
|
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
|
// Get post data when the route contains the name parameter
|
||||||
const name = useRouteQuery("name");
|
const name = useRouteQuery("name");
|
||||||
|
const editor = useRouteQuery("editor");
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (name.value) {
|
if (name.value) {
|
||||||
// fetch post
|
// fetch post
|
||||||
|
@ -221,6 +280,21 @@ onMounted(async () => {
|
||||||
|
|
||||||
// fetch post content
|
// fetch post content
|
||||||
await handleFetchContent();
|
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();
|
handleResetCache();
|
||||||
});
|
});
|
||||||
|
@ -289,11 +363,12 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
|
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
|
||||||
<DefaultEditor
|
<component
|
||||||
v-model="formState.content.raw"
|
:is="currentEditorProvider.component"
|
||||||
:owner="formState.post.spec.owner"
|
v-if="currentEditorProvider"
|
||||||
:permalink="formState.post.status?.permalink"
|
v-model:raw="formState.content.raw"
|
||||||
:publish-time="formState.post.spec.publishTime"
|
v-model:content="formState.content.content"
|
||||||
|
class="h-full"
|
||||||
@update="handleSetContentCache"
|
@update="handleSetContentCache"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -45,9 +45,12 @@ import FilterTag from "@/components/filter/FilterTag.vue";
|
||||||
import FilteCleanButton from "@/components/filter/FilterCleanButton.vue";
|
import FilteCleanButton from "@/components/filter/FilterCleanButton.vue";
|
||||||
import { getNode } from "@formkit/core";
|
import { getNode } from "@formkit/core";
|
||||||
import TagDropdownSelector from "@/components/dropdown-selector/TagDropdownSelector.vue";
|
import TagDropdownSelector from "@/components/dropdown-selector/TagDropdownSelector.vue";
|
||||||
|
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
|
||||||
|
|
||||||
const { currentUserHasPermission } = usePermission();
|
const { currentUserHasPermission } = usePermission();
|
||||||
|
|
||||||
|
const { editorProviders } = useEditorExtensionPoints();
|
||||||
|
|
||||||
const posts = ref<ListedPostList>({
|
const posts = ref<ListedPostList>({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 20,
|
size: 20,
|
||||||
|
@ -476,7 +479,40 @@ const hasFilters = computed(() => {
|
||||||
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
|
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
|
||||||
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
|
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
|
||||||
<VButton :route="{ name: 'DeletedPosts' }" 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
|
<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']"
|
v-permission="['system:posts:manage']"
|
||||||
:route="{ name: 'PostEditor' }"
|
:route="{ name: 'PostEditor' }"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|
Loading…
Reference in New Issue