From e7789929ec4ad034b8c57137260bb70b665ef1a4 Mon Sep 17 00:00:00 2001
From: Takagi <1103069291@qq.com>
Date: Tue, 26 Dec 2023 18:48:06 +0800
Subject: [PATCH] fix: fix anchor positioning for identical table of contents
 names (#5101)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

#### 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
解决默认编辑器中具有重名标题时,锚点只会跳转至首个的问题。
```
---
 .../editor/src/extensions/heading/index.ts    | 52 +++++++++++--------
 console/packages/editor/src/utils/anchor.ts   | 15 ++++++
 2 files changed, 45 insertions(+), 22 deletions(-)

diff --git a/console/packages/editor/src/extensions/heading/index.ts b/console/packages/editor/src/extensions/heading/index.ts
index 3b0aa003f..4452b69fa 100644
--- a/console/packages/editor/src/extensions/heading/index.ts
+++ b/console/packages/editor/src/extensions/heading/index.ts
@@ -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;
         },
       }),
     ];
diff --git a/console/packages/editor/src/utils/anchor.ts b/console/packages/editor/src/utils/anchor.ts
index 3845a5ac4..9f22954fb 100644
--- a/console/packages/editor/src/utils/anchor.ts
+++ b/console/packages/editor/src/utils/anchor.ts
@@ -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;
+};