mirror of https://github.com/halo-dev/halo
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
parent
5b5fe71544
commit
1f5bef71ac
|
@ -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;
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./delete-node";
|
export * from "./delete-node";
|
||||||
|
export * from "./anchor";
|
||||||
|
|
|
@ -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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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("");
|
|
||||||
});
|
|
||||||
});
|
|
Loading…
Reference in New Issue