Explorar el Código

feat(文档大纲): 优化标题块和编号逻辑,增强标题唯一性和层级处理

Zhang Yice hace 1 mes
padre
commit
1f0cf93a52

+ 60 - 13
src/components/Editor/DocumentOutline.tsx

@@ -107,15 +107,41 @@ function addHeadingNumbers(node: OutlineNode, numberMap: Map<string, string>): O
107 107
  */
108 108
 export const DocumentOutline: React.FC<DocumentOutlineProps> = ({ visible = true }) => {
109 109
   const headings = useEditorStore(
110
-    useShallow((state) =>
111
-      state.blocks
112
-        .filter((block): block is HeadingBlock => block.type === 'heading')
110
+    useShallow((state) => {
111
+      const seenIds = new Set<string>();
112
+      return state.blocks
113
+        .filter(
114
+          (block): block is HeadingBlock =>
115
+            block.type === 'heading' &&
116
+            typeof block.id === 'string' &&
117
+            block.id.length > 0 &&
118
+            Number.isInteger(block.level) &&
119
+            block.level >= 1 &&
120
+            block.level <= 6
121
+        )
113 122
         .sort((left, right) => left.block_order - right.block_order)
114
-    )
123
+        .filter((heading) => {
124
+          if (seenIds.has(heading.id)) return false;
125
+          seenIds.add(heading.id);
126
+          return true;
127
+        });
128
+    })
115 129
   );
116 130
   const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
131
+  const selectBlock = useEditorStore((state) => state.selectBlock);
117 132
   const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
118 133
   const [autoExpandParent, setAutoExpandParent] = useState(true);
134
+  const highlightTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
135
+  const highlightedElementRef = React.useRef<HTMLElement | null>(null);
136
+
137
+  React.useEffect(() => {
138
+    return () => {
139
+      if (highlightTimerRef.current) {
140
+        clearTimeout(highlightTimerRef.current);
141
+      }
142
+      highlightedElementRef.current?.classList.remove('block-highlight');
143
+    };
144
+  }, []);
119 145
 
120 146
   // 构建树结构
121 147
   const treeData = useMemo(() => {
@@ -139,21 +165,42 @@ export const DocumentOutline: React.FC<DocumentOutlineProps> = ({ visible = true
139 165
   }, [treeData]);
140 166
 
141 167
   // 处理节点点击
142
-  const handleSelect = useCallback((selectedKeys: React.Key[]) => {
143
-    if (selectedKeys.length === 0) return;
168
+  const handleSelect = useCallback(
169
+    (selectedKeys: React.Key[]) => {
170
+      if (selectedKeys.length === 0) {
171
+        selectBlock(null);
172
+        return;
173
+      }
174
+
175
+      const blockId = selectedKeys[0];
176
+      if (typeof blockId !== 'string') return;
144 177
 
145
-    const blockId = selectedKeys[0] as string;
146
-    const element = document.querySelector(`[data-block-id="${blockId}"]`);
178
+      const element = Array.from(document.querySelectorAll<HTMLElement>('[data-block-id]')).find(
179
+        (candidate) => candidate.dataset.blockId === blockId
180
+      );
147 181
 
148
-    if (element) {
182
+      if (!element) {
183
+        selectBlock(null);
184
+        return;
185
+      }
186
+
187
+      selectBlock(blockId);
149 188
       element.scrollIntoView({ behavior: 'smooth', block: 'center' });
150
-      // 可以添加高亮效果
189
+      highlightedElementRef.current?.classList.remove('block-highlight');
190
+      if (highlightTimerRef.current) {
191
+        clearTimeout(highlightTimerRef.current);
192
+      }
151 193
       element.classList.add('block-highlight');
152
-      setTimeout(() => {
194
+      highlightedElementRef.current = element;
195
+      highlightTimerRef.current = setTimeout(() => {
153 196
         element.classList.remove('block-highlight');
197
+        if (highlightedElementRef.current === element) {
198
+          highlightedElementRef.current = null;
199
+        }
154 200
       }, 2000);
155
-    }
156
-  }, []);
201
+    },
202
+    [selectBlock]
203
+  );
157 204
 
158 205
   // 处理展开/折叠
159 206
   const handleExpand = useCallback((expandedKeys: React.Key[]) => {

+ 2 - 1
src/components/Editor/blocks/HeadingBlock.tsx

@@ -356,7 +356,8 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
356 356
     );
357 357
   }, [block.id, block.style, addBlock]);
358 358
 
359
-  const Tag = `h${block.level}` as keyof JSX.IntrinsicElements;
359
+  const headingLevel = Math.min(6, Math.max(1, Math.trunc(Number(block.level) || 1)));
360
+  const Tag = `h${headingLevel}` as keyof JSX.IntrinsicElements;
360 361
   const isEmpty =
361 362
     typeof block.content === 'string'
362 363
       ? block.content.trim().length === 0

+ 15 - 1
src/utils/headingNumbering.ts

@@ -3,10 +3,24 @@ import type { DocumentBlock, HeadingBlock } from '../types/editor';
3 3
 export function getHeadingNumberMap(blocks: DocumentBlock[]): Map<string, string> {
4 4
   const counters: number[] = [];
5 5
   const numbers = new Map<string, string>();
6
+  const seenIds = new Set<string>();
6 7
 
7 8
   blocks
8
-    .filter((block): block is HeadingBlock => block.type === 'heading')
9
+    .filter(
10
+      (block): block is HeadingBlock =>
11
+        block.type === 'heading' &&
12
+        typeof block.id === 'string' &&
13
+        block.id.length > 0 &&
14
+        Number.isInteger(block.level) &&
15
+        block.level >= 1 &&
16
+        block.level <= 6
17
+    )
9 18
     .sort((a, b) => a.block_order - b.block_order)
19
+    .filter((heading) => {
20
+      if (seenIds.has(heading.id)) return false;
21
+      seenIds.add(heading.id);
22
+      return true;
23
+    })
10 24
     .forEach((heading) => {
11 25
       const levelIndex = heading.level - 1;
12 26
       counters.length = heading.level;