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 TiptapHeading 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 { i18n } from "@/locales";
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>({
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() {
return {
...this.parent?.(),
@ -265,6 +281,32 @@ const Blockquote = TiptapHeading.extend<ExtensionOptions & HeadingOptions>({
addExtensions() {
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;

View File

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

View File

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