fix: fix anchor positioning for identical table of contents names (#5101)

#### What type of PR is this?

/kind bug
/area editor
/milestone 2.12.x

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

重写了对默认编辑器标题的 id 生成逻辑。目前将会在对标题进行任意的修改之后,对所有的标题进行 id 计算,用以解决当标题名称具有重复时,生成了相同的 id.

需要注意的是,由于需要对任意标题进行修改之后才会进行生效,因此已经存在重名标题 id 的问题时,需要修改任意的标题使其生效。

#### How to test it?

在文章内新增多个相同内容的标题,查看是否可以正常跳转。

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

Fixes #5068 

#### Does this PR introduce a user-facing change?
```release-note
解决默认编辑器中具有重名标题时,锚点只会跳转至首个的问题。
```
pull/5114/head
Takagi 2023-12-26 18:48:06 +08:00 committed by GitHub
parent a1fe8c3f6b
commit e7789929ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 45 additions and 22 deletions

View File

@ -15,17 +15,13 @@ 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";
import { AttrStep, Plugin, PluginKey } from "@/tiptap";
import { generateAnchorId } 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),
@ -282,27 +278,39 @@ const Blockquote = TiptapHeading.extend<ExtensionOptions & HeadingOptions>({
return [TiptapParagraph];
},
addProseMirrorPlugins() {
let beforeComposition: boolean | undefined = undefined;
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,
})
);
}
appendTransaction: (transactions, oldState, newState) => {
const isChangeHeading = transactions.some((transaction) => {
const composition = this.editor.view.composing;
if (beforeComposition !== undefined && !composition) {
beforeComposition = undefined;
return true;
}
if (transaction.docChanged) {
beforeComposition = composition;
const selection = transaction.selection;
const { $from } = selection;
const node = $from.parent;
return node.type.name === Blockquote.name && !composition;
}
return false;
});
if (isChangeHeading) {
const tr = newState.tr;
const headingIds: string[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name === Blockquote.name) {
const id = generateAnchorId(node.textContent, headingIds);
tr.step(new AttrStep(pos, "id", id));
headingIds.push(id);
}
});
return DecorationSet.create(doc, decorations);
},
return tr;
}
return undefined;
},
}),
];

View File

@ -3,3 +3,18 @@ export function generateAnchor(text: string) {
String(text).trim().toLowerCase().replace(/\s+/g, "-")
);
}
export const generateAnchorId = (text: string, ids: string[]) => {
const originId = generateAnchor(text);
let id = originId;
while (ids.includes(id)) {
const temporarySuffix = id.replace(originId, "");
const match = temporarySuffix.match(/-(\d+)$/);
if (match) {
id = `${originId}-${Number(match[1]) + 1}`;
} else {
id = `${originId}-1`;
}
}
return id;
};