Parcourir la source

feat (编辑器):重构目录区块,采用紧凑按钮样式并新增占位提示组件
· 将完整目录区块展示形式替换为紧凑按钮形态,优化界面整洁度
· 新增TOCPlaceholder组件,在标题区块上方展示目录插入引导提示
· 仅当文档不存在目录区块、且非只读模式时,渲染占位提示
· 调整TOCBlock样式,改为内联按钮布局并增加悬浮交互状态
· 为目录区块按钮增加删除功能,配套图标标识
· 使用蓝色强调色与过渡动画优化视觉层级
· 新建TOCPlaceholder.tsx组件,承载目录插入交互流程
· 修改画布组件BlockCanvas,增加目录区块存在性判断,实现占位组件条件渲染
· 完善editor.ts内类型定义,适配目录区块新增交互逻辑

Zhang Yice il y a 1 mois
Parent
commit
566ba3f4d0

+ 37 - 7
src/components/Editor/BlockCanvas.tsx

@@ -2,6 +2,7 @@
2 2
  * BlockCanvas.tsx - 块画布组件
3 3
  * 
4 4
  * 渲染所有的文档块,管理块的排序和交互
5
+ * 支持在标题上方显示TOC占位符
5 6
  * 
6 7
  * @module components/Editor
7 8
  */
@@ -9,6 +10,7 @@
9 10
 import React from 'react';
10 11
 import type { DocumentBlock } from '../../types/editor';
11 12
 import { BlockRenderer } from './BlockRenderer';
13
+import { TOCPlaceholder } from './blocks/TOCPlaceholder';
12 14
 import './BlockCanvas.css';
13 15
 
14 16
 // ══════════════════════════════════════════════════════════════════════════════
@@ -30,6 +32,7 @@ export interface BlockCanvasProps {
30 32
  * BlockCanvas - 块画布
31 33
  * 
32 34
  * 渲染所有blocks,按block_order排序显示
35
+ * 在每个标题块上方显示TOC占位符(如果当前没有TOC块)
33 36
  */
34 37
 export const BlockCanvas: React.FC<BlockCanvasProps> = ({
35 38
   blocks,
@@ -40,6 +43,11 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
40 43
     return [...blocks].sort((a, b) => a.block_order - b.block_order);
41 44
   }, [blocks]);
42 45
 
46
+  // 检查是否已存在TOC块
47
+  const hasTOC = React.useMemo(() => {
48
+    return sortedBlocks.some(block => block.type === 'toc');
49
+  }, [sortedBlocks]);
50
+
43 51
   // 空状态
44 52
   if (sortedBlocks.length === 0) {
45 53
     return (
@@ -51,13 +59,35 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
51 59
 
52 60
   return (
53 61
     <div className="block-canvas" data-testid="block-canvas">
54
-      {sortedBlocks.map((block) => (
55
-        <BlockRenderer
56
-          key={block.id}
57
-          block={block}
58
-          readOnly={readOnly}
59
-        />
60
-      ))}
62
+      {sortedBlocks.map((block, index) => {
63
+        const isFirstBlock = index === 0;
64
+        const isHeading = block.type === 'heading';
65
+        const prevBlock = index > 0 ? sortedBlocks[index - 1] : null;
66
+        
67
+        // 在标题上方显示TOC占位符的条件:
68
+        // 1. 当前文档没有TOC块
69
+        // 2. 当前块是标题块
70
+        // 3. 不是只读模式
71
+        const showPlaceholderBefore = !hasTOC && isHeading && !readOnly;
72
+        
73
+        return (
74
+          <React.Fragment key={block.id}>
75
+            {/* 在标题上方显示TOC占位符 */}
76
+            {showPlaceholderBefore && (
77
+              <TOCPlaceholder 
78
+                afterBlockId={prevBlock?.id}
79
+                readOnly={readOnly}
80
+              />
81
+            )}
82
+            
83
+            {/* 渲染当前块 */}
84
+            <BlockRenderer
85
+              block={block}
86
+              readOnly={readOnly}
87
+            />
88
+          </React.Fragment>
89
+        );
90
+      })}
61 91
     </div>
62 92
   );
63 93
 };

+ 89 - 67
src/components/Editor/blocks/TOCBlock.css

@@ -4,105 +4,127 @@
4 4
 
5 5
 .toc-block-wrapper {
6 6
   margin: 16px 0;
7
+  position: relative;
7 8
 }
8 9
 
9
-.toc-toggle-button {
10
-  display: flex;
10
+/* 紧凑按钮样式 */
11
+.toc-compact-button {
12
+  display: inline-flex;
11 13
   align-items: center;
12
-  gap: 8px;
13
-  padding: 10px 16px;
14
-  background-color: #ffffff;
15
-  border: 1px solid #d9d9d9;
16
-  border-radius: 6px;
14
+  gap: 6px;
15
+  padding: 6px 12px;
16
+  background-color: #f0f5ff;
17
+  border: 1px solid #adc6ff;
18
+  border-radius: 4px;
17 19
   cursor: pointer;
18 20
   transition: all 0.2s ease;
19
-  font-size: 14px;
20
-  font-weight: 500;
21
-  color: #262626;
22
-  width: 100%;
23
-  justify-content: center;
24
-}
25
-
26
-.toc-toggle-button:hover {
27
-  background-color: #f5f7fa;
28
-  border-color: #40a9ff;
21
+  font-size: 13px;
29 22
   color: #1890ff;
23
+  position: relative;
30 24
 }
31 25
 
32
-.toc-toggle-button:active {
26
+.toc-compact-button:hover {
33 27
   background-color: #e6f7ff;
28
+  border-color: #40a9ff;
29
+  box-shadow: 0 2px 4px rgba(24, 144, 255, 0.15);
34 30
 }
35 31
 
36
-.toc-toggle-icon {
37
-  font-size: 12px;
38
-  transition: transform 0.2s ease;
32
+.toc-compact-button:active {
33
+  background-color: #d6e4ff;
34
+  transform: translateY(1px);
39 35
 }
40 36
 
41
-.toc-toggle-text {
42
-  font-size: 14px;
37
+.toc-compact-button:disabled {
38
+  opacity: 0.6;
39
+  cursor: not-allowed;
43 40
 }
44 41
 
45
-.toc-block {
46
-  padding: 20px;
47
-  background-color: #f5f7fa;
48
-  border: 2px dashed #d9d9d9;
49
-  border-radius: 8px;
50
-  animation: fadeIn 0.2s ease-in;
42
+.toc-icon {
43
+  font-size: 16px;
44
+  line-height: 1;
51 45
 }
52 46
 
53
-@keyframes fadeIn {
54
-  from {
55
-    opacity: 0;
56
-    transform: translateY(-10px);
57
-  }
58
-  to {
59
-    opacity: 1;
60
-    transform: translateY(0);
61
-  }
47
+.toc-text {
48
+  font-size: 13px;
49
+  font-weight: 500;
62 50
 }
63 51
 
64
-.toc-title {
52
+.toc-delete-icon {
65 53
   font-size: 18px;
66
-  font-weight: 600;
67
-  color: #262626;
68
-  margin-bottom: 12px;
69
-  text-align: center;
54
+  font-weight: 300;
55
+  line-height: 1;
56
+  margin-left: 4px;
57
+  opacity: 0.7;
58
+  transition: opacity 0.2s ease;
59
+}
60
+
61
+.toc-compact-button:hover .toc-delete-icon {
62
+  opacity: 1;
63
+}
64
+
65
+/* 空行添加按钮容器 */
66
+.toc-empty-line {
67
+  position: relative;
68
+  height: 24px;
69
+  margin: 8px 0;
70
+  border: 1px dashed transparent;
71
+  border-radius: 4px;
72
+  transition: all 0.2s ease;
73
+}
74
+
75
+.toc-empty-line:hover {
76
+  border-color: #d9d9d9;
77
+  background-color: #fafafa;
70 78
 }
71 79
 
72
-.toc-notice {
73
-  display: flex;
80
+/* 添加按钮 */
81
+.toc-add-button {
82
+  position: absolute;
83
+  left: 50%;
84
+  top: 50%;
85
+  transform: translate(-50%, -50%);
86
+  display: none;
74 87
   align-items: center;
75
-  gap: 8px;
76
-  padding: 8px 12px;
77
-  background-color: #e6f7ff;
78
-  border: 1px solid #91d5ff;
88
+  gap: 6px;
89
+  padding: 4px 10px;
90
+  background-color: #ffffff;
91
+  border: 1px solid #40a9ff;
79 92
   border-radius: 4px;
80
-  margin-bottom: 16px;
81
-  font-size: 13px;
82
-  color: #0050b3;
93
+  cursor: pointer;
94
+  font-size: 12px;
95
+  color: #1890ff;
96
+  white-space: nowrap;
97
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
98
+  z-index: 10;
99
+  transition: all 0.2s ease;
83 100
 }
84 101
 
85
-.toc-icon {
86
-  font-size: 16px;
87
-  flex-shrink: 0;
102
+.toc-empty-line:hover .toc-add-button {
103
+  display: inline-flex;
88 104
 }
89 105
 
90
-.toc-placeholder {
91
-  color: #595959;
92
-  font-size: 14px;
93
-  line-height: 1.6;
106
+.toc-add-button:hover {
107
+  background-color: #e6f7ff;
108
+  border-color: #1890ff;
109
+  box-shadow: 0 2px 6px rgba(24, 144, 255, 0.2);
94 110
 }
95 111
 
96
-.toc-placeholder p {
97
-  margin: 0 0 8px 0;
98
-  font-weight: 500;
112
+.toc-add-button:active {
113
+  background-color: #d6e4ff;
114
+  transform: translate(-50%, -50%) scale(0.95);
99 115
 }
100 116
 
101
-.toc-placeholder ul {
102
-  margin: 0;
103
-  padding-left: 24px;
117
+.toc-add-button:disabled {
118
+  opacity: 0.6;
119
+  cursor: not-allowed;
104 120
 }
105 121
 
106
-.toc-placeholder li {
107
-  margin: 4px 0;
122
+.toc-add-icon {
123
+  font-size: 14px;
124
+  line-height: 1;
125
+}
126
+
127
+.toc-add-text {
128
+  font-size: 12px;
129
+  font-weight: 500;
108 130
 }

+ 39 - 30
src/components/Editor/blocks/TOCBlock.tsx

@@ -2,13 +2,15 @@
2 2
  * TOCBlock.tsx - 目录块组件
3 3
  * 
4 4
  * 只读显示,自动生成的目录块
5
+ * 改造为简洁的按钮形式,支持删除功能
5 6
  * 
6 7
  * @module components/Editor/blocks
7 8
  */
8 9
 
9
-import React from 'react';
10
+import React, { useState } from 'react';
10 11
 import type { TOCBlock as TOCBlockType } from '../../../types/editor';
11 12
 import { useEditorStore } from '../../../stores/editorStore';
13
+import { blockService } from '../../../services/blockService';
12 14
 import './TOCBlock.css';
13 15
 
14 16
 export interface TOCBlockProps {
@@ -20,46 +22,53 @@ export interface TOCBlockProps {
20 22
  * TOCBlock - 目录块(只读)
21 23
  * 
22 24
  * TOC块是自动生成的,用户无法编辑
23
- * 通过按钮控制删除/添加目录块内容
25
+ * 提供删除按钮来移除目录块
24 26
  */
25 27
 export const TOCBlock: React.FC<TOCBlockProps> = ({ block }) => {
26
-  const { updateBlock } = useEditorStore();
27
-  
28
-  // 检查块是否被隐藏(通过metadata中的hidden标志)
29
-  const isHidden = (block.metadata as any)?.hidden === true;
28
+  const { documentId, deleteBlock } = useEditorStore();
29
+  const [isDeleting, setIsDeleting] = useState(false);
30 30
 
31
-  const handleToggle = () => {
32
-    // 切换隐藏状态
33
-    updateBlock(block.id, {
34
-      metadata: {
35
-        ...block.metadata,
36
-        hidden: !isHidden,
37
-      },
38
-    });
31
+  const handleDelete = async () => {
32
+    if (!documentId) {
33
+      console.error('文档ID不存在');
34
+      return;
35
+    }
36
+
37
+    if (isDeleting) return; // 防止重复点击
38
+
39
+    if (!window.confirm('确定要删除此目录块吗?')) {
40
+      return;
41
+    }
42
+
43
+    setIsDeleting(true);
44
+    
45
+    try {
46
+      // 调用后端API删除TOC块
47
+      await blockService.deleteBlock(documentId, block.id);
48
+      
49
+      // 删除成功后,从store中移除该块
50
+      deleteBlock(block.id);
51
+    } catch (error) {
52
+      console.error('删除目录块失败:', error);
53
+      alert('删除目录块失败,请重试');
54
+    } finally {
55
+      setIsDeleting(false);
56
+    }
39 57
   };
40 58
 
41 59
   return (
42 60
     <div className="toc-block-wrapper" data-block-id={block.id}>
43 61
       <button 
44
-        className="toc-toggle-button"
45
-        onClick={handleToggle}
62
+        className="toc-compact-button"
63
+        onClick={handleDelete}
64
+        disabled={isDeleting}
46 65
         type="button"
66
+        title="点击删除目录块"
47 67
       >
48
-        <span className="toc-toggle-icon">{isHidden ? '▶' : '▼'}</span>
49
-        <span className="toc-toggle-text">
50
-          {isHidden ? '添加目录' : '删除目录'}
51
-        </span>
68
+        <span className="toc-icon">📑</span>
69
+        <span className="toc-text">目录</span>
70
+        <span className="toc-delete-icon">×</span>
52 71
       </button>
53
-      
54
-      {!isHidden && (
55
-        <div className="toc-block">
56
-          <div className="toc-title">{block.content.title || '目录'}</div>
57
-          <div className="toc-notice">
58
-            <span className="toc-icon">ℹ️</span>
59
-            <span>目录内容由系统自动生成,无法手动编辑</span>
60
-          </div>
61
-        </div>
62
-      )}
63 72
     </div>
64 73
   );
65 74
 };

+ 98 - 0
src/components/Editor/blocks/TOCPlaceholder.tsx

@@ -0,0 +1,98 @@
1
+/**
2
+ * TOCPlaceholder.tsx - TOC占位符组件
3
+ * 
4
+ * 在没有TOC块的位置显示空行,鼠标悬停时显示"添加TOC"按钮
5
+ * 
6
+ * @module components/Editor/blocks
7
+ */
8
+
9
+import React, { useState } from 'react';
10
+import { blockService } from '../../../services/blockService';
11
+import { useEditorStore } from '../../../stores/editorStore';
12
+import './TOCBlock.css'; // 使用TOCBlock的样式
13
+
14
+export interface TOCPlaceholderProps {
15
+  /** 在此块之后插入TOC (可选,不传则插入到文档开头) */
16
+  afterBlockId?: string;
17
+  /** 是否只读 */
18
+  readOnly?: boolean;
19
+}
20
+
21
+/**
22
+ * TOCPlaceholder - TOC占位符
23
+ * 
24
+ * 显示空行,悬停时显示添加按钮
25
+ */
26
+export const TOCPlaceholder: React.FC<TOCPlaceholderProps> = ({ 
27
+  afterBlockId,
28
+  readOnly = false 
29
+}) => {
30
+  const { documentId, loadDocument } = useEditorStore();
31
+  const [isAdding, setIsAdding] = useState(false);
32
+
33
+  const handleAdd = async () => {
34
+    if (!documentId || readOnly) {
35
+      return;
36
+    }
37
+
38
+    if (isAdding) return; // 防止重复点击
39
+
40
+    setIsAdding(true);
41
+    
42
+    try {
43
+      // 调用后端API添加TOC块
44
+      await blockService.createBlock(documentId, {
45
+        type: 'toc',
46
+        level: 0,
47
+        content: {
48
+          title: '目录'
49
+        },
50
+        word_style: 'TOC',
51
+        metadata: {
52
+          toc_config: {
53
+            levels: '1-1',
54
+            use_hyperlinks: true,
55
+            use_page_numbers: true,
56
+            hide_page_numbers_in_web: true,
57
+            use_outline_levels: true,
58
+            show_leader_dots: true,
59
+            leader_char: '.'
60
+          },
61
+          is_auto_generated: true,
62
+          readonly: true,
63
+          deletable: true
64
+        },
65
+        after_block_id: afterBlockId || null,
66
+      });
67
+      
68
+      // 重新加载文档以获取新添加的TOC块
69
+      await loadDocument(documentId);
70
+    } catch (error) {
71
+      console.error('添加目录块失败:', error);
72
+      alert('添加目录块失败,请重试');
73
+    } finally {
74
+      setIsAdding(false);
75
+    }
76
+  };
77
+
78
+  if (readOnly) {
79
+    return null; // 只读模式不显示占位符
80
+  }
81
+
82
+  return (
83
+    <div className="toc-empty-line">
84
+      <button 
85
+        className="toc-add-button"
86
+        onClick={handleAdd}
87
+        disabled={isAdding}
88
+        type="button"
89
+        title="添加目录块"
90
+      >
91
+        <span className="toc-add-icon">📑</span>
92
+        <span className="toc-add-text">添加目录</span>
93
+      </button>
94
+    </div>
95
+  );
96
+};
97
+
98
+export default TOCPlaceholder;

+ 3 - 4
src/types/editor.ts

@@ -322,13 +322,12 @@ export interface BlockStatsResponse {
322 322
  */
323 323
 export interface CreateBlockRequest {
324 324
   type: BlockType;
325
-  level: number;
326
-  index: number;
325
+  level?: number;          // heading类型必填(1-6),其他类型默认0
327 326
   content: any;
328
-  word_style: string;
327
+  word_style?: string;
329 328
   style?: StyleOverrides;
330 329
   metadata?: Record<string, any>;
331
-  after_block_id?: string;  // 在哪个block后插入
330
+  after_block_id?: string | null;  // 在哪个block后插入(null=末尾)
332 331
 }
333 332
 
334 333
 /**