fix: resolve the issue of generated titles missing id (#4997)

#### What type of PR is this?

/kind bug
/area console
/milestone 2.12.x

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

在 #4975 中,使用 `decorations` 重写了 heading,但由于 `decorations` 只会给 nodeview 增加 id,因此会导致 render 后的 html 中不存在 id。
默认编辑器 heading 渲染时,自动增加 id。

#### How to test it?

使用默认编辑器编辑时,增加 Heading。保存后查看主题端对应的 heading 是否存在 id。

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

Fixes #4994 

#### Does this PR introduce a user-facing change?
```release-note
解决渲染后的 Heading 中不存在 id 的问题。
```
pull/5007/head
Takagi 2023-12-07 11:42:07 +08:00 committed by GitHub
parent 5b5fe71544
commit 1f5bef71ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 48 additions and 45 deletions

View File

@ -1,4 +1,4 @@
import type { Editor, Range } from "@/tiptap/vue-3"; import { mergeAttributes, type Editor, type Range } from "@/tiptap/vue-3";
import TiptapParagraph from "@/extensions/paragraph"; import TiptapParagraph from "@/extensions/paragraph";
import TiptapHeading from "@tiptap/extension-heading"; import TiptapHeading from "@tiptap/extension-heading";
import type { HeadingOptions } from "@tiptap/extension-heading"; import type { HeadingOptions } from "@tiptap/extension-heading";
@ -15,8 +15,24 @@ import MdiFormatHeader6 from "~icons/mdi/format-header-6";
import { markRaw } from "vue"; import { markRaw } from "vue";
import { i18n } from "@/locales"; import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types"; import type { ExtensionOptions } from "@/types";
import { Decoration, DecorationSet, Plugin, PluginKey } from "@/tiptap";
import { ExtensionHeading } from "..";
import { generateAnchor } from "@/utils";
const Blockquote = TiptapHeading.extend<ExtensionOptions & HeadingOptions>({ const Blockquote = TiptapHeading.extend<ExtensionOptions & HeadingOptions>({
renderHTML({ node, HTMLAttributes }) {
const hasLevel = this.options.levels.includes(node.attrs.level);
const level = hasLevel ? node.attrs.level : this.options.levels[0];
const id = generateAnchor(node.textContent);
HTMLAttributes.id = id;
return [
`h${level}`,
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addOptions() { addOptions() {
return { return {
...this.parent?.(), ...this.parent?.(),
@ -265,6 +281,32 @@ const Blockquote = TiptapHeading.extend<ExtensionOptions & HeadingOptions>({
addExtensions() { addExtensions() {
return [TiptapParagraph]; return [TiptapParagraph];
}, },
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("generate-heading-id"),
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (node.type.name === ExtensionHeading.name) {
const id = generateAnchor(node.textContent);
if (node.attrs.id !== id) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
id,
})
);
}
}
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
}); });
export default Blockquote; export default Blockquote;

View File

@ -1 +1,2 @@
export * from "./delete-node"; export * from "./delete-node";
export * from "./anchor";

View File

@ -47,7 +47,6 @@ import {
ToolbarItem, ToolbarItem,
Plugin, Plugin,
PluginKey, PluginKey,
Decoration,
DecorationSet, DecorationSet,
} from "@halo-dev/richtext-editor"; } from "@halo-dev/richtext-editor";
import { import {
@ -90,7 +89,6 @@ import type { PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn, useLocalStorage } from "@vueuse/core"; import { useDebounceFn, useLocalStorage } from "@vueuse/core";
import { onBeforeUnmount } from "vue"; import { onBeforeUnmount } from "vue";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { generateAnchor } from "@/utils/anchor";
const { t } = useI18n(); const { t } = useI18n();
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
@ -295,27 +293,17 @@ onMounted(() => {
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
new Plugin({ new Plugin({
key: new PluginKey("generate-heading-id"), key: new PluginKey("get-heading-id"),
props: { props: {
decorations: (state) => { decorations: (state) => {
const headings: HeadingNode[] = []; const headings: HeadingNode[] = [];
const { doc } = state; const { doc } = state;
const decorations: Decoration[] = []; doc.descendants((node) => {
doc.descendants((node, pos) => {
if (node.type.name === ExtensionHeading.name) { if (node.type.name === ExtensionHeading.name) {
const id = generateAnchor(node.textContent);
if (node.attrs.id !== id) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
id,
})
);
}
headings.push({ headings.push({
level: node.attrs.level, level: node.attrs.level,
text: node.textContent, text: node.textContent,
id, id: node.attrs.id,
}); });
} }
}); });
@ -323,7 +311,7 @@ onMounted(() => {
if (!selectedHeadingNode.value) { if (!selectedHeadingNode.value) {
selectedHeadingNode.value = headings[0]; selectedHeadingNode.value = headings[0];
} }
return DecorationSet.create(doc, decorations); return DecorationSet.empty;
}, },
}, },
}), }),

View File

@ -1,28 +0,0 @@
import { describe, it, expect } from "vitest";
import { generateAnchor } from "../anchor";
describe("generateAnchor", () => {
it("should handle basic text", () => {
expect(generateAnchor("Hello World")).toBe("hello-world");
});
it("should trim whitespace", () => {
expect(generateAnchor(" Hello World ")).toBe("hello-world");
});
it("should replace multiple spaces with a single dash", () => {
expect(generateAnchor("Hello World")).toBe("hello-world");
});
it("should handle Chinese characters", () => {
expect(generateAnchor("你好")).toBe("%E4%BD%A0%E5%A5%BD");
});
it("should handle special characters", () => {
expect(generateAnchor("Hello@#World$")).toBe("hello%40%23world%24");
});
it("should handle empty string", () => {
expect(generateAnchor("")).toBe("");
});
});