Bladeren bron

feat(编辑器): 实现标题折叠、列表编号与富文本功能增强

- 添加标题折叠功能,支持按标题层级展开/收起下级内容
- 实现独立的标题编号与有序列表编号系统,支持多层级列表
- 新增 headingNumbering.ts 和 listNumbering.ts 工具模块用于编号计算
- 优化 BlockCanvas 中的块渲染逻辑,支持条件显示和列表标记显示
- 增强 BlockRenderer 组件,添加折叠状态和列表标记显示
- 更新各块组件(HeadingBlock、ParagraphBlock 等)样式与交互
- 优化富文本编辑器工具栏与样式处理
- 改进文档大纲显示逻辑
- 移除下载服务中的 URL 信任检查逻辑,简化文档预览与下载流程
- 完善文档设计文档,明确列表编号的独立性
Zhang Yice 1 maand geleden
bovenliggende
commit
a36bc3b19e

+ 2 - 2
docs/新编辑器功能设计.md

@@ -26,9 +26,9 @@
26 26
 ### 2.2 标题和段落
27 27
 
28 28
 - 标题支持 H1 到 H6。
29
-- 段落支持普通文本、有序列表和无序列表元数据。
29
+- 段落支持普通文本、有序列表和无序列表元数据;列表编号与标题编号彼此独立
30 30
 - 块菜单支持插入正文、标题、图片、表格、复制、移动和删除等操作。
31
-- Enter 可按当前格式创建后续块;有序列表会计算下一个序号。
31
+- Enter 可按当前格式创建后续块;有序列表按列表层级计算后续序号。
32 32
 - 标题和段落都使用 `RichTextEditor` 编辑 `RichText[]`。
33 33
 
34 34
 ### 2.3 富文本

+ 0 - 9
src/components/ChatPanel/MessageItem.tsx

@@ -20,7 +20,6 @@ import { useChatStore } from '../../stores/chatStore';
20 20
 import {
21 21
   downloadBlob,
22 22
   getFileNameFromContentDisposition,
23
-  isAllowedHttpUrl,
24 23
 } from '../../utils/download';
25 24
 
26 25
 const { Text } = Typography;
@@ -158,10 +157,6 @@ const MessageItem: React.FC<MessageItemProps> = memo(
158 157
      */
159 158
     const handlePreviewClick = useCallback(async () => {
160 159
       if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
161
-      if (!isAllowedHttpUrl(exportRecord.downloadUrl)) {
162
-        antdMessage.error('文档地址不受信任,无法打开');
163
-        return;
164
-      }
165 160
 
166 161
       try {
167 162
         setIsCreatingDocument(true);
@@ -231,10 +226,6 @@ const MessageItem: React.FC<MessageItemProps> = memo(
231 226
       async (e: React.MouseEvent) => {
232 227
         e.stopPropagation(); // Prevent card click
233 228
         if (!exportRecord?.downloadUrl) return;
234
-        if (!isAllowedHttpUrl(exportRecord.downloadUrl)) {
235
-          antdMessage.error('文档地址不受信任,无法下载');
236
-          return;
237
-        }
238 229
         
239 230
         try {
240 231
           // Extract recordId from downloadUrl

+ 68 - 5
src/components/Editor/BlockCanvas.tsx

@@ -11,6 +11,8 @@ import React from 'react';
11 11
 import type { DocumentBlock } from '../../types/editor';
12 12
 import { BlockRenderer } from './BlockRenderer';
13 13
 import { TOCPlaceholder } from './blocks/TOCPlaceholder';
14
+import { getHeadingNumberMap } from '../../utils/headingNumbering';
15
+import { formatOrderedListMarker, getOrderedListNumberMap } from '../../utils/listNumbering';
14 16
 import './BlockCanvas.css';
15 17
 
16 18
 // ══════════════════════════════════════════════════════════════════════════════
@@ -38,7 +40,8 @@ export const BlockCanvas = React.memo(function BlockCanvas({
38 40
   blocks,
39 41
   readOnly = false,
40 42
 }: BlockCanvasProps) {
41
-  const { sortedBlocks, hasTOC, firstHeadingIndex } = React.useMemo(() => {
43
+  const [collapsedHeadingIds, setCollapsedHeadingIds] = React.useState<Set<string>>(new Set());
44
+  const { sortedBlocks, hasTOC, firstHeadingIndex, headingNumbers, orderedListNumbers, collapsibleHeadingIds } = React.useMemo(() => {
42 45
     const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
43 46
     let tocFound = false;
44 47
     let headingIndex = -1;
@@ -50,15 +53,53 @@ export const BlockCanvas = React.memo(function BlockCanvas({
50 53
       }
51 54
     });
52 55
 
56
+    const collapsible = new Set<string>();
57
+    sorted.forEach((block, index) => {
58
+      if (block.type !== 'heading') return;
59
+      for (let nextIndex = index + 1; nextIndex < sorted.length; nextIndex += 1) {
60
+        const nextBlock = sorted[nextIndex];
61
+        if (nextBlock.type === 'heading' && nextBlock.level <= block.level) break;
62
+        collapsible.add(block.id);
63
+        break;
64
+      }
65
+    });
66
+
53 67
     return {
54 68
       sortedBlocks: sorted,
55 69
       hasTOC: tocFound,
70
+      headingNumbers: getHeadingNumberMap(sorted),
56 71
       firstHeadingIndex: headingIndex,
72
+      orderedListNumbers: getOrderedListNumberMap(sorted),
73
+      collapsibleHeadingIds: collapsible,
57 74
     };
58 75
   }, [blocks]);
59 76
 
77
+  const visibleBlocks = React.useMemo(() => {
78
+    const hiddenByLevel: number[] = [];
79
+    return sortedBlocks.filter((block) => {
80
+      if (block.type === 'heading') {
81
+        while (hiddenByLevel.length > 0 && hiddenByLevel[hiddenByLevel.length - 1] >= block.level) {
82
+          hiddenByLevel.pop();
83
+        }
84
+        const isHidden = hiddenByLevel.length > 0;
85
+        if (collapsedHeadingIds.has(block.id)) hiddenByLevel.push(block.level);
86
+        return !isHidden;
87
+      }
88
+      return hiddenByLevel.length === 0;
89
+    });
90
+  }, [sortedBlocks, collapsedHeadingIds]);
91
+
92
+  const toggleHeadingCollapse = React.useCallback((headingId: string) => {
93
+    setCollapsedHeadingIds((current) => {
94
+      const next = new Set(current);
95
+      if (next.has(headingId)) next.delete(headingId);
96
+      else next.add(headingId);
97
+      return next;
98
+    });
99
+  }, []);
100
+
60 101
   // 空状态
61
-  if (sortedBlocks.length === 0) {
102
+  if (visibleBlocks.length === 0) {
62 103
     return (
63 104
       <div className="block-canvas-empty">
64 105
         <p>文档为空,点击工具栏添加内容</p>
@@ -68,8 +109,8 @@ export const BlockCanvas = React.memo(function BlockCanvas({
68 109
 
69 110
   return (
70 111
     <div className="block-canvas" data-testid="block-canvas">
71
-      {sortedBlocks.map((block, index) => {
72
-        const prevBlock = index > 0 ? sortedBlocks[index - 1] : null;
112
+      {visibleBlocks.map((block, index) => {
113
+        const prevBlock = index > 0 ? visibleBlocks[index - 1] : null;
73 114
         
74 115
         // 在标题上方显示TOC占位符的条件:
75 116
         // 1. 当前文档没有TOC块
@@ -92,7 +133,29 @@ export const BlockCanvas = React.memo(function BlockCanvas({
92 133
             <BlockRenderer
93 134
               block={block}
94 135
               index={index}
95
-              blockCount={sortedBlocks.length}
136
+              blockCount={visibleBlocks.length}
137
+              listMarker={block.type === 'heading'
138
+                ? block.metadata.list_type === 'ordered'
139
+                  ? `${headingNumbers.get(block.id) ?? '1'}.`
140
+                  : block.metadata.list_type === 'unordered'
141
+                    ? '•'
142
+                    : undefined
143
+                : block.type === 'paragraph'
144
+                  ? block.metadata.list_type === 'ordered'
145
+                  ? formatOrderedListMarker(
146
+                    orderedListNumbers.get(block.id) ?? 1,
147
+                    block.metadata.list_level ?? 0,
148
+                  )
149
+                  : block.metadata.list_type === 'unordered'
150
+                    ? '•'
151
+                    : undefined
152
+                  : undefined
153
+                }
154
+              collapsible={block.type === 'heading' && collapsibleHeadingIds.has(block.id)}
155
+              collapsed={block.type === 'heading' && collapsedHeadingIds.has(block.id)}
156
+              onToggleCollapse={block.type === 'heading'
157
+                ? () => toggleHeadingCollapse(block.id)
158
+                : undefined}
96 159
               readOnly={readOnly}
97 160
             />
98 161
           </React.Fragment>

+ 17 - 0
src/components/Editor/BlockRenderer.tsx

@@ -27,6 +27,14 @@ export interface BlockRendererProps {
27 27
   blockCount?: number;
28 28
   /** 是否只读 */
29 29
   readOnly?: boolean;
30
+  /** 标题是否存在可折叠内容 */
31
+  collapsible?: boolean;
32
+  /** 标题内容是否已折叠 */
33
+  collapsed?: boolean;
34
+  /** 段落列表标记 */
35
+  listMarker?: string;
36
+  /** 切换标题折叠状态 */
37
+  onToggleCollapse?: () => void;
30 38
 }
31 39
 
32 40
 // ══════════════════════════════════════════════════════════════════════════════
@@ -42,6 +50,10 @@ export const BlockRenderer = React.memo(function BlockRenderer({
42 50
   block,
43 51
   index,
44 52
   blockCount,
53
+  collapsible,
54
+  collapsed,
55
+  listMarker,
56
+  onToggleCollapse,
45 57
   readOnly = false,
46 58
 }: BlockRendererProps) {
47 59
   switch (block.type) {
@@ -51,6 +63,10 @@ export const BlockRenderer = React.memo(function BlockRenderer({
51 63
           block={block}
52 64
           index={index ?? 0}
53 65
           blockCount={blockCount ?? 0}
66
+          listMarker={listMarker}
67
+          collapsible={collapsible}
68
+          collapsed={collapsed}
69
+          onToggleCollapse={onToggleCollapse}
54 70
           readOnly={readOnly}
55 71
         />
56 72
       );
@@ -62,6 +78,7 @@ export const BlockRenderer = React.memo(function BlockRenderer({
62 78
           index={index ?? 0}
63 79
           blockCount={blockCount ?? 0}
64 80
           readOnly={readOnly}
81
+          listMarker={listMarker}
65 82
         />
66 83
       );
67 84
     

+ 17 - 2
src/components/Editor/DocumentOutline.tsx

@@ -20,6 +20,7 @@ import {
20 20
 import type { HeadingBlock } from '../../types/editor';
21 21
 import { useEditorStore } from '../../stores/editorStore';
22 22
 import './DocumentOutline.css';
23
+import { getHeadingNumberMap } from '../../utils/headingNumbering';
23 24
 
24 25
 // ══════════════════════════════════════════════════════════════════════════════
25 26
 // Types
@@ -91,6 +92,17 @@ function buildOutlineTree(headings: HeadingBlock[]): OutlineNode[] {
91 92
   return root;
92 93
 }
93 94
 
95
+function addHeadingNumbers(
96
+  node: OutlineNode,
97
+  numberMap: Map<string, string>,
98
+): OutlineNode {
99
+  return {
100
+    ...node,
101
+    title: `${numberMap.get(node.blockId) || ''} ${node.title}`.trim(),
102
+    children: node.children?.map((child) => addHeadingNumbers(child, numberMap)),
103
+  };
104
+}
105
+
94 106
 // ══════════════════════════════════════════════════════════════════════════════
95 107
 // Component
96 108
 // ══════════════════════════════════════════════════════════════════════════════
@@ -108,12 +120,15 @@ export const DocumentOutline: React.FC<DocumentOutlineProps> = ({
108 120
 
109 121
   // 提取所有标题
110 122
   const headings = useMemo(() => {
111
-    return blocks.filter((block): block is HeadingBlock => block.type === 'heading');
123
+    return blocks
124
+      .filter((block): block is HeadingBlock => block.type === 'heading')
125
+      .sort((left, right) => left.block_order - right.block_order);
112 126
   }, [blocks]);
113 127
 
114 128
   // 构建树结构
115 129
   const treeData = useMemo(() => {
116
-    return buildOutlineTree(headings);
130
+    const numberMap = getHeadingNumberMap(headings);
131
+    return buildOutlineTree(headings).map((node) => addHeadingNumbers(node, numberMap));
117 132
   }, [headings]);
118 133
 
119 134
   // 获取所有节点的key

+ 10 - 7
src/components/Editor/RichTextEditor/RichTextEditor.css

@@ -3,19 +3,22 @@
3 3
  */
4 4
 
5 5
 .rich-text-editor-wrapper {
6
-  position: relative;
6
+  display: contents;
7 7
   width: 100%;
8 8
   isolation: isolate;
9 9
 }
10 10
 
11
-/* 连接正文与左侧工具按钮,避免鼠标经过间隙时触发 hover 离开 */
12
-.rich-text-editor-wrapper::before {
13
-  content: '';
11
+.rich-text-toolbar-anchor {
14 12
   position: absolute;
13
+  z-index: 1100;
15 14
   top: 0;
16
-  left: -12px;
17
-  width: 12px;
18
-  height: 100%;
15
+  left: 0;
16
+  width: 0;
17
+  height: 0;
18
+  pointer-events: none;
19
+}
20
+
21
+.rich-text-toolbar-anchor .rich-text-toolbar {
19 22
   pointer-events: auto;
20 23
 }
21 24
 

+ 33 - 63
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -13,32 +13,6 @@ import { richTextToHtml, htmlToRichText } from '../../../utils/richTextConverter
13 13
 import { RichTextToolbar } from './RichTextToolbar';
14 14
 import './RichTextEditor.css';
15 15
 
16
-function getEditorTextWithLineBreaks(node: Node): string {
17
-  if (node.nodeType === 3) {
18
-    return node.textContent || '';
19
-  }
20
-
21
-  if (node.nodeType !== 1 && node.nodeType !== 11) {
22
-    return '';
23
-  }
24
-  const element = node as HTMLElement;
25
-  if (element.tagName === 'BR') {
26
-    return '\n';
27
-  }
28
-
29
-  const text = Array.from(node.childNodes).map(getEditorTextWithLineBreaks).join('');
30
-  const blockTags = new Set(['DIV', 'P', 'LI']);
31
-  return element.tagName && blockTags.has(element.tagName) ? `${text}\n` : text;
32
-}
33
-
34
-function getNextOrderedListNumber(text: string): number {
35
-  const itemNumbers = Array.from(text.matchAll(/(?:^|\n)\s*(\d+)\.(?=\s|$)/g))
36
-    .map((match) => Number(match[1]))
37
-    .filter(Number.isFinite);
38
-
39
-  return itemNumbers.length > 0 ? Math.max(...itemNumbers) + 1 : 1;
40
-}
41
-
42 16
 // ══════════════════════════════════════════════════════════════════════════════
43 17
 // Component Props
44 18
 // ══════════════════════════════════════════════════════════════════════════════
@@ -62,6 +36,8 @@ export interface RichTextEditorProps {
62 36
   onBlur?: () => void;
63 37
   /** 当前 block 内容格式 */
64 38
   currentContentFormat?: 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`;
39
+  /** 当前 block 的列表格式,可与标题格式同时存在 */
40
+  currentListType?: 'ordered' | 'unordered';
65 41
   /** block 内容格式变更回调 */
66 42
   onContentFormatChange?: (format: 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`) => void;
67 43
   /** 删除当前块(工具栏删除按钮在无选区时使用) */
@@ -72,8 +48,10 @@ export interface RichTextEditorProps {
72 48
   currentAlign?: 'left' | 'center' | 'right' | 'justify';
73 49
   /** Enter键回调 - 用于创建新块 */
74 50
   onEnter?: () => void;
75
-  /** Enter键回调(有序列表) - 传递下一个序号 */
76
-  onEnterOrderedList?: (nextNumber: number) => void;
51
+  /** 有序列表回车创建下一项 */
52
+  onEnterOrderedList?: () => void;
53
+  /** 空的有序列表项再次回车时退出列表 */
54
+  onExitOrderedList?: () => void;
77 55
   /** 表格上下文(可选) - 用于表格单元格编辑 */
78 56
   tableContext?: {
79 57
     block: TableBlock;
@@ -128,12 +106,14 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
128 106
   autoFocus = false,
129 107
   onBlur,
130 108
   currentContentFormat,
109
+  currentListType,
131 110
   onContentFormatChange,
132 111
   onDelete,
133 112
   onAlignChange,
134 113
   currentAlign,
135 114
   onEnter,
136 115
   onEnterOrderedList,
116
+  onExitOrderedList,
137 117
   tableContext,
138 118
   hasContent,
139 119
 }) => {
@@ -143,7 +123,6 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
143 123
   const [isToolbarHovered, setIsToolbarHovered] = useState(false);
144 124
   const [isEditorFocused, setIsEditorFocused] = useState(false);
145 125
   const [hasTextSelection, setHasTextSelection] = useState(false);
146
-  const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -40 });
147 126
   const isComposingRef = useRef(false);
148 127
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
149 128
   const formatFrameRef = useRef<number | null>(null);
@@ -309,26 +288,19 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
309 288
 
310 289
   // ── 处理键盘快捷键 ─────────────────────────────────────────────────────────
311 290
   const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
312
-    // 有序列表的Enter处理 - 创建新块并自动递增序号
313
-    if (e.key === 'Enter' && currentContentFormat === 'ordered-list' && !e.nativeEvent.isComposing) {
291
+    const isOrderedListEditor = Boolean(onEnterOrderedList || onExitOrderedList);
292
+    if (e.key === 'Enter' && isOrderedListEditor && !e.nativeEvent.isComposing) {
314 293
       e.preventDefault();
315
-
316
-      const selection = window.getSelection();
317
-      if (!editorRef.current || !selection || selection.rangeCount === 0) return;
318
-
319
-      // 获取当前内容的最大序号
320
-      const editorText = getEditorTextWithLineBreaks(editorRef.current);
321
-      const nextNumber = getNextOrderedListNumber(editorText);
322
-
323
-      // 调用回调创建新的有序列表块
324
-      if (onEnterOrderedList) {
325
-        onEnterOrderedList(nextNumber);
294
+      if ((editorRef.current?.textContent ?? '').trim().length === 0) {
295
+        onExitOrderedList?.();
296
+      } else {
297
+        onEnterOrderedList?.();
326 298
       }
327 299
       return;
328 300
     }
329 301
 
330 302
     // 普通段落的Enter处理 - 插入新块
331
-    if (e.key === 'Enter' && !e.nativeEvent.isComposing && !singleLine && onEnter) {
303
+    if (e.key === 'Enter' && !e.nativeEvent.isComposing && onEnter) {
332 304
       e.preventDefault();
333 305
       onEnter();
334 306
       return;
@@ -360,7 +332,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
360 332
           break;
361 333
       }
362 334
     }
363
-  }, [singleLine, currentContentFormat, handleInput, onEnter, onEnterOrderedList]);
335
+  }, [singleLine, handleInput, onEnter, onEnterOrderedList, onExitOrderedList]);
364 336
 
365 337
   // ── 处理选中文本(显示工具栏) ───────────────────────────────────────────────
366 338
   const handleMouseUp = useCallback(() => {
@@ -385,10 +357,6 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
385 357
       return;
386 358
     }
387 359
 
388
-    setToolbarPosition({
389
-      top: 0,
390
-      left: -40,
391
-    });
392 360
     setHasTextSelection(true);
393 361
     
394 362
   }, [readOnly, tableContext]);
@@ -449,21 +417,23 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
449 417
 
450 418
       {/* 浮动工具栏放在编辑内容之后,避免 contenteditable 重绘时覆盖面板。 */}
451 419
       {!readOnly && hasContent !== false && (
452
-        <RichTextToolbar
453
-          position={toolbarPosition}
454
-          editorElement={editorElement}
455
-          visible={isEditorHovered || isToolbarHovered || isEditorFocused || hasTextSelection}
456
-          onHoverChange={handleToolbarHoverChange}
457
-          onClose={() => setHasTextSelection(false)}
458
-          onFormat={handleFormatChange}
459
-          currentContentFormat={currentContentFormat}
460
-          onContentFormatChange={onContentFormatChange}
461
-          onDelete={onDelete}
462
-          onAlignChange={onAlignChange}
463
-          currentAlign={currentAlign}
464
-          baseStyle={baseStyle}
465
-          tableContext={tableContext}
466
-        />
420
+        <div className="rich-text-toolbar-anchor">
421
+          <RichTextToolbar
422
+            editorElement={editorElement}
423
+            visible={isEditorHovered || isToolbarHovered || isEditorFocused || hasTextSelection}
424
+            onHoverChange={handleToolbarHoverChange}
425
+            onClose={() => setHasTextSelection(false)}
426
+            onFormat={handleFormatChange}
427
+            currentContentFormat={currentContentFormat}
428
+            currentListType={currentListType}
429
+            onContentFormatChange={onContentFormatChange}
430
+            onDelete={onDelete}
431
+            onAlignChange={onAlignChange}
432
+            currentAlign={currentAlign}
433
+            baseStyle={baseStyle}
434
+            tableContext={tableContext}
435
+          />
436
+        </div>
467 437
       )}
468 438
     </div>
469 439
   );

+ 5 - 1
src/components/Editor/RichTextEditor/RichTextToolbar.css

@@ -3,10 +3,11 @@
3 3
  */
4 4
 
5 5
 .rich-text-toolbar {
6
-  position: fixed;
6
+  position: absolute;
7 7
   z-index: 1100;
8 8
   width: 208px;
9 9
   top: 0;
10
+  left: -36px;
10 11
   padding: 7px 7px 6px;
11 12
   overflow: visible;
12 13
   color: #20242b;
@@ -28,6 +29,9 @@
28 29
   width: 28px;
29 30
   height: 28px;
30 31
   padding: 0;
32
+  background: transparent;
33
+  border-color: transparent;
34
+  box-shadow: none;
31 35
   border-radius: 4px;
32 36
 }
33 37
 

+ 44 - 21
src/components/Editor/RichTextEditor/RichTextToolbar.tsx

@@ -9,7 +9,6 @@
9 9
  */
10 10
 
11 11
 import React, { useEffect, useRef, useState, useCallback } from 'react';
12
-import { createPortal } from 'react-dom';
13 12
 import { Tooltip, InputNumber, Popover, Dropdown } from 'antd';
14 13
 import {
15 14
   BoldOutlined,
@@ -36,9 +35,7 @@ import './RichTextToolbar.css';
36 35
 // ══════════════════════════════════════════════════════════════════════════════
37 36
 
38 37
 export interface RichTextToolbarProps {
39
-  /** 工具栏位置 */
40
-  position: { top: number; left: number };
41
-  /** 编辑器元素,用于计算脱离 contenteditable 后的固定定位 */
38
+  /** 编辑器元素,用于选区和弹层边界判断 */
42 39
   editorElement?: HTMLElement | null;
43 40
   /** 收起状态下是否显示启动按钮 */
44 41
   visible?: boolean;
@@ -50,6 +47,8 @@ export interface RichTextToolbarProps {
50 47
   onFormat: () => void;
51 48
   /** 当前 block 内容格式 */
52 49
   currentContentFormat?: 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`;
50
+  /** 当前 block 的列表格式,可与标题格式同时存在 */
51
+  currentListType?: 'ordered' | 'unordered';
53 52
   /** block 内容格式变更回调 */
54 53
   onContentFormatChange?: (format: 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`) => void;
55 54
   /** 删除当前块 */
@@ -292,13 +291,13 @@ type ContentFormat = 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-
292 291
 // ══════════════════════════════════════════════════════════════════════════════
293 292
 
294 293
 export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
295
-  position,
296 294
   editorElement,
297 295
   visible = false,
298 296
   onHoverChange,
299 297
   onClose,
300 298
   onFormat,
301 299
   currentContentFormat,
300
+  currentListType,
302 301
   onContentFormatChange,
303 302
   onDelete,
304 303
   onAlignChange,
@@ -751,13 +750,6 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
751 750
       return;
752 751
     }
753 752
 
754
-    // 对于列表格式,直接调用回调,不需要选中文本
755
-    if (format === 'ordered-list') {
756
-      onContentFormatChange?.(format);
757
-      onFormat();
758
-      return;
759
-    }
760
-
761 753
     // 对于段落和标题格式,也直接调用回调
762 754
     onContentFormatChange?.(format);
763 755
     onFormat();
@@ -879,7 +871,13 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
879 871
                 H{level}
880 872
               </ToolbarButton>
881 873
             ))}
882
-            <ToolbarButton title="有序列表" active={currentContentFormat === 'ordered-list'} onClick={() => handleContentFormatChange('ordered-list')}>
874
+            <ToolbarButton
875
+              title={currentListType === 'ordered' ? '取消有序列表' : '有序列表'}
876
+              active={currentListType === 'ordered'}
877
+              onClick={() => handleContentFormatChange(
878
+                currentListType === 'ordered' ? 'paragraph' : 'ordered-list'
879
+              )}
880
+            >
883 881
               <OrderedListOutlined />
884 882
             </ToolbarButton>
885 883
           </div>
@@ -982,20 +980,19 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
982 980
     >
983 981
       <div
984 982
         className={`rich-text-toolbar collapsed${visible ? ' visible' : ''}`}
985
-        style={{
986
-          top: `${(editorElement?.getBoundingClientRect().top ?? 0) + position.top}px`,
987
-          left: `${(editorElement?.getBoundingClientRect().left ?? 0) + position.left}px`,
988
-        }}
989 983
         onMouseDown={handleLauncherMouseDown}
990 984
         onMouseEnter={() => onHoverChange?.(true)}
991 985
         onMouseLeave={() => onHoverChange?.(false)}
992 986
       >
993
-        <ToolbarLauncher expanded={isExpanded} />
987
+          <ToolbarLauncher
988
+            expanded={isExpanded}
989
+            contentFormat={currentContentFormat}
990
+          />
994 991
       </div>
995 992
     </Dropdown>
996 993
   );
997 994
 
998
-  return typeof document === 'undefined' ? toolbar : createPortal(toolbar, document.body);
995
+  return toolbar;
999 996
 };
1000 997
 
1001 998
 interface ToolbarButtonProps {
@@ -1024,7 +1021,33 @@ function ToolbarButton({ title, active = false, disabled = false, onClick, child
1024 1021
   );
1025 1022
 }
1026 1023
 
1027
-export function ToolbarLauncher({ onClick, expanded = false }: { onClick?: () => void; expanded?: boolean }) {
1024
+type ToolbarContentFormat = RichTextToolbarProps['currentContentFormat'];
1025
+
1026
+function getLauncherContent(contentFormat: ToolbarContentFormat) {
1027
+  if (contentFormat === 'ordered-list') {
1028
+    return <OrderedListOutlined />;
1029
+  }
1030
+
1031
+  if (contentFormat === 'unordered-list') {
1032
+    return <span aria-hidden="true">•</span>;
1033
+  }
1034
+
1035
+  if (contentFormat?.startsWith('heading-')) {
1036
+    return `H${contentFormat.slice('heading-'.length)}`;
1037
+  }
1038
+
1039
+  return 'T';
1040
+}
1041
+
1042
+export function ToolbarLauncher({
1043
+  onClick,
1044
+  expanded = false,
1045
+  contentFormat = 'paragraph',
1046
+}: {
1047
+  onClick?: () => void;
1048
+  expanded?: boolean;
1049
+  contentFormat?: ToolbarContentFormat;
1050
+}) {
1028 1051
   return (
1029 1052
     <button
1030 1053
       type="button"
@@ -1034,7 +1057,7 @@ export function ToolbarLauncher({ onClick, expanded = false }: { onClick?: () =>
1034 1057
       title="打开格式工具栏"
1035 1058
       onClick={onClick}
1036 1059
     >
1037
-      <FontSizeOutlined />
1060
+      <span className="toolbar-launcher-content">{getLauncherContent(contentFormat)}</span>
1038 1061
     </button>
1039 1062
   );
1040 1063
 }

+ 73 - 14
src/components/Editor/blocks/HeadingBlock.css

@@ -4,44 +4,103 @@
4 4
 
5 5
 .heading-block-wrapper {
6 6
   position: relative;
7
-  margin-bottom: 16px;
8 7
   padding-left: 0;
9 8
 }
10 9
 
11 10
 .heading-block {
11
+  position: relative;
12
+  display: flex;
13
+  align-items: baseline;
14
+  gap: 0.25em;
12 15
   margin: 0;
13
-  padding: 8px 0;
16
+  padding: 5px 0;
14 17
   color: #262626;
15 18
   font-weight: 600;
16 19
   line-height: 1.4;
17 20
 }
18 21
 
22
+.heading-number {
23
+  flex: 0 0 auto;
24
+  color: #1455d9;
25
+  font-weight: 600;
26
+  font-variant-numeric: tabular-nums;
27
+}
28
+
29
+.heading-collapse-toggle {
30
+  flex: 0 0 1em;
31
+  width: 1em;
32
+  padding: 0;
33
+  border: 0;
34
+  background: transparent;
35
+  color: #1455d9;
36
+  cursor: pointer;
37
+  font-size: 0.58em;
38
+  line-height: 1;
39
+}
40
+
41
+.heading-collapse-toggle:hover {
42
+  color: #1455d9;
43
+}
44
+
45
+.heading-list-marker {
46
+  flex: 0 0 auto;
47
+  color: #1455d9;
48
+  font-weight: 600;
49
+  font-variant-numeric: tabular-nums;
50
+}
51
+
19 52
 .heading-1 {
20
-  font-size: 32px;
21
-  margin-bottom: 16px;
53
+  font-size: 20px;
54
+  margin-bottom: 12px;
55
+}
56
+
57
+.heading-1 .rich-text-editor {
58
+  font-size: 20px !important;
59
+  line-height: 1.4 !important;
22 60
 }
23 61
 
24 62
 .heading-2 {
25
-  font-size: 28px;
26
-  margin-bottom: 14px;
63
+  font-size: 18px;
64
+  margin-bottom: 10px;
65
+}
66
+
67
+.heading-2 .rich-text-editor {
68
+  font-size: 18px !important;
69
+  line-height: 1.4 !important;
27 70
 }
28 71
 
29 72
 .heading-3 {
30
-  font-size: 24px;
31
-  margin-bottom: 12px;
73
+  font-size: 16px;
74
+  margin-bottom: 8px;
75
+}
76
+
77
+.heading-3 .rich-text-editor {
78
+  font-size: 16px !important;
79
+  line-height: 1.4 !important;
32 80
 }
33 81
 
34 82
 .heading-4 {
35
-  font-size: 20px;
36
-  margin-bottom: 10px;
83
+  font-size: 15px;
84
+  margin-bottom: 7px;
85
+}
86
+
87
+.heading-4 .rich-text-editor {
88
+  font-size: 15px !important;
89
+  line-height: 1.4 !important;
37 90
 }
38 91
 
39 92
 .heading-5 {
40
-  font-size: 18px;
41
-  margin-bottom: 8px;
93
+  font-size: 14px;
94
+  margin-bottom: 6px;
95
+}
96
+
97
+.heading-5 .rich-text-editor,
98
+.heading-6 .rich-text-editor {
99
+  font-size: 14px !important;
100
+  line-height: 1.4 !important;
42 101
 }
43 102
 
44 103
 .heading-6 {
45
-  font-size: 16px;
46
-  margin-bottom: 8px;
104
+  font-size: 14px;
105
+  margin-bottom: 6px;
47 106
 }

+ 63 - 10
src/components/Editor/blocks/HeadingBlock.tsx

@@ -17,6 +17,10 @@ export interface HeadingBlockProps {
17 17
   block: HeadingBlockType;
18 18
   index: number;
19 19
   blockCount: number;
20
+  listMarker?: string;
21
+  collapsible?: boolean;
22
+  collapsed?: boolean;
23
+  onToggleCollapse?: () => void;
20 24
   readOnly?: boolean;
21 25
 }
22 26
 
@@ -27,6 +31,10 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
27 31
   block, 
28 32
   index,
29 33
   blockCount,
34
+  listMarker,
35
+  collapsible = false,
36
+  collapsed = false,
37
+  onToggleCollapse,
30 38
   readOnly,
31 39
 }) => {
32 40
   const updateBlock = useEditorStore((state) => state.updateBlock);
@@ -69,22 +77,53 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
69 77
   const handleContentFormatChange = useCallback(async (
70 78
     format: 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`
71 79
   ) => {
72
-    const isParagraph = format === 'paragraph' || format === 'ordered-list' || format === 'unordered-list';
73
-    const isOrderedList = format === 'ordered-list';
74
-    const level = isParagraph
75
-      ? 0
76
-      : Number(format.split('-')[1]) as 1 | 2 | 3 | 4 | 5 | 6;
80
+    if (format === 'ordered-list' || format === 'unordered-list') {
81
+      updateBlock(block.id, {
82
+        content: block.content,
83
+        metadata: {
84
+          ...block.metadata,
85
+          list_type: format === 'ordered-list' ? 'ordered' : 'unordered',
86
+          list_level: block.metadata.list_level ?? 0,
87
+        },
88
+      });
89
+
90
+      try {
91
+        await saveBlock(block.id);
92
+        message.success('已设为标题列表');
93
+      } catch (error) {
94
+        message.error(error instanceof Error ? error.message : '列表格式更新失败');
95
+      }
96
+      return;
97
+    }
98
+
99
+    if (format === 'paragraph' && block.metadata.list_type) {
100
+      const metadata = { ...block.metadata };
101
+      delete metadata.list_type;
102
+      delete metadata.list_level;
103
+      updateBlock(block.id, { content: block.content, metadata });
104
+
105
+      try {
106
+        await saveBlock(block.id);
107
+        message.success('已取消标题列表');
108
+      } catch (error) {
109
+        message.error(error instanceof Error ? error.message : '列表格式更新失败');
110
+      }
111
+      return;
112
+    }
113
+
114
+    const isParagraph = format === 'paragraph';
115
+    const level = Number(format.split('-')[1]) as 1 | 2 | 3 | 4 | 5 | 6;
77 116
     const style = block.style.align ? { align: block.style.align } : {};
78 117
 
79 118
     updateBlock(block.id, isParagraph
80 119
       ? {
120
+          content: block.content,
81 121
           type: 'paragraph',
82 122
           level: 0,
83 123
           word_style: 'Normal',
84 124
           style,
85 125
           metadata: {
86 126
             parent_heading_id: block.metadata.parent_id ?? null,
87
-            ...(isOrderedList ? { list_type: 'ordered' } : format === 'unordered-list' ? { list_type: 'unordered' } : {}),
88 127
           },
89 128
         }
90 129
       : {
@@ -97,12 +136,12 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
97 136
     try {
98 137
       await saveBlock(block.id);
99 138
       message.success(
100
-        isOrderedList ? '已设为有序列表' : isParagraph ? '已设为正文' : `已设为${level}级标题`
139
+        isParagraph ? '已设为正文' : `已设为${level}级标题`
101 140
       );
102 141
     } catch (error) {
103 142
       message.error(error instanceof Error ? error.message : '标题格式更新失败');
104 143
     }
105
-  }, [block.id, block.metadata.parent_id, block.style.align, saveBlock, updateBlock]);
144
+  }, [block.content, block.id, block.metadata, block.style.align, saveBlock, updateBlock]);
106 145
 
107 146
   // ── 块操作回调 ──────────────────────────────────────────────────────────
108 147
 
@@ -197,12 +236,13 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
197 236
       {
198 237
         type: 'paragraph' as const,
199 238
         level: 0,
200
-        content: '1. ',
239
+        content: '',
201 240
         word_style: 'Normal',
202 241
         style: {},
203 242
         metadata: {
204 243
           parent_heading_id: block.id,
205 244
           list_type: 'ordered',
245
+          list_level: 0,
206 246
         },
207 247
       },
208 248
       block.id
@@ -327,11 +367,24 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
327 367
         />
328 368
       )}
329 369
 
330
-      <Tag className={`heading-block heading-${block.level}`}>
370
+      <Tag className={`heading-block heading-${block.level}${listMarker ? ' heading-block-list-item' : ''}`}>
371
+        {collapsible && (
372
+          <button
373
+            type="button"
374
+            className="heading-collapse-toggle"
375
+            aria-label={collapsed ? '展开标题内容' : '折叠标题内容'}
376
+            aria-expanded={!collapsed}
377
+            onClick={onToggleCollapse}
378
+          >
379
+            <span aria-hidden="true">{collapsed ? '▶' : '▼'}</span>
380
+          </button>
381
+        )}
382
+        {listMarker && <span className="heading-list-marker" aria-hidden="true">{listMarker}</span>}
331 383
         <RichTextEditor
332 384
           value={block.content}
333 385
           onChange={handleChange}
334 386
           currentContentFormat={`heading-${block.level}`}
387
+          currentListType={block.metadata.list_type}
335 388
           onContentFormatChange={handleContentFormatChange}
336 389
           onDelete={handleDelete}
337 390
           onAlignChange={handleAlignChange}

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

@@ -331,12 +331,13 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
331 331
       {
332 332
         type: 'paragraph' as const,
333 333
         level: 0,
334
-        content: '1. ',
334
+        content: '',
335 335
         word_style: 'Normal',
336 336
         style: {},
337 337
         metadata: {
338 338
           parent_heading_id: null,
339 339
           list_type: 'ordered',
340
+          list_level: 0,
340 341
         },
341 342
       },
342 343
       block.id

+ 23 - 0
src/components/Editor/blocks/ParagraphBlock.css

@@ -9,6 +9,7 @@
9 9
 }
10 10
 
11 11
 .paragraph-block {
12
+  position: relative;
12 13
   color: #262626;
13 14
   font-size: 14px;
14 15
   line-height: 1.8;
@@ -16,3 +17,25 @@
16 17
   word-break: break-word;
17 18
   padding: 4px 0;
18 19
 }
20
+
21
+.paragraph-block-list-item {
22
+  display: grid;
23
+  grid-template-columns: 28px minmax(0, 1fr);
24
+  align-items: flex-start;
25
+  column-gap: 4px;
26
+}
27
+
28
+.paragraph-list-marker {
29
+  width: 28px;
30
+  text-align: right;
31
+  color: #1455d9;
32
+  font-size: 14px;
33
+  font-weight: 600;
34
+  font-variant-numeric: tabular-nums;
35
+  line-height: 1.8;
36
+}
37
+
38
+.paragraph-block-list-item .rich-text-editor {
39
+  flex: 1;
40
+  min-width: 0;
41
+}

+ 44 - 18
src/components/Editor/blocks/ParagraphBlock.tsx

@@ -17,6 +17,7 @@ export interface ParagraphBlockProps {
17 17
   block: ParagraphBlockType;
18 18
   index: number;
19 19
   blockCount: number;
20
+  listMarker?: string;
20 21
   readOnly?: boolean;
21 22
 }
22 23
 
@@ -27,6 +28,7 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
27 28
   block, 
28 29
   index,
29 30
   blockCount,
31
+  listMarker,
30 32
   readOnly,
31 33
 }) => {
32 34
   const updateBlock = useEditorStore((state) => state.updateBlock);
@@ -69,10 +71,18 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
69 71
     format: 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`
70 72
   ) => {
71 73
     if (format === 'paragraph' || format === 'ordered-list' || format === 'unordered-list') {
74
+      const metadata = { ...block.metadata };
75
+      delete metadata.list_type;
76
+
72 77
       updateBlock(block.id, {
78
+        content: block.content,
73 79
         metadata: {
74
-          ...block.metadata,
75
-          list_type: format === 'ordered-list' ? 'ordered' : format === 'unordered-list' ? 'unordered' : undefined,
80
+          ...metadata,
81
+          ...(format === 'ordered-list'
82
+            ? { list_type: 'ordered' as const }
83
+            : format === 'unordered-list'
84
+              ? { list_type: 'unordered' as const }
85
+              : {}),
76 86
         },
77 87
       });
78 88
 
@@ -100,7 +110,7 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
100 110
     } catch (error) {
101 111
       message.error(error instanceof Error ? error.message : '标题格式更新失败');
102 112
     }
103
-  }, [block.id, block.metadata, block.style.align, saveBlock, updateBlock]);
113
+  }, [block.content, block.id, block.metadata, block.style.align, saveBlock, updateBlock]);
104 114
 
105 115
   // ── 块操作回调 ──────────────────────────────────────────────────────────
106 116
 
@@ -197,18 +207,19 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
197 207
       {
198 208
         type: 'paragraph' as const,
199 209
         level: 0,
200
-        content: '1. ',
210
+        content: '',
201 211
         word_style: block.word_style,
202 212
         style: block.style,
203 213
         metadata: {
204 214
           parent_heading_id: block.metadata.parent_heading_id || null,
205 215
           list_type: 'ordered',
216
+          list_level: block.metadata.list_level ?? 0,
206 217
         },
207 218
       },
208 219
       block.id
209 220
     );
210 221
     message.success('已插入有序列表');
211
-  }, [block.id, block.word_style, block.style, block.metadata.parent_heading_id, addBlock]);
222
+  }, [block.id, block.word_style, block.style, block.metadata, addBlock]);
212 223
 
213 224
   const handleInsertImage = useCallback((dataUrl: string, fileName: string, width: number, height: number) => {
214 225
     addBlock(
@@ -294,32 +305,41 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
294 305
         style: block.style, // 继承对齐方式等样式
295 306
         metadata: {
296 307
           parent_heading_id: block.metadata.parent_heading_id || null,
297
-          // 注意:普通段落不继承 list_type,避免变成列表
308
+          ...(block.metadata.list_type ? { list_type: block.metadata.list_type } : {}),
309
+          ...(block.metadata.list_level !== undefined ? { list_level: block.metadata.list_level } : {}),
298 310
         },
299 311
       },
300 312
       block.id // 在当前块后插入
301 313
     );
302 314
   }, [block.id, block.word_style, block.style, block.metadata, addBlock]);
303 315
 
304
-  // ── Enter键处理(有序列表) - 插入新的列表项块 ─────────────────────────────
305
-  const handleEnterOrderedList = useCallback((nextNumber: number) => {
306
-    // 有序列表块换行时,插入一个新的有序列表块,内容以序号开头
316
+  const handleEnterOrderedList = useCallback(() => {
307 317
     addBlock(
308 318
       {
309 319
         type: 'paragraph' as const,
310 320
         level: 0,
311
-        content: `${nextNumber}. `, // 新块的内容以序号开头
312
-        word_style: block.word_style, // 继承样式名称
313
-        style: block.style, // 继承对齐方式等样式
321
+        content: '',
322
+        word_style: block.word_style,
323
+        style: block.style,
314 324
         metadata: {
315 325
           parent_heading_id: block.metadata.parent_heading_id || null,
316
-          list_type: 'ordered', // 保持为有序列表
326
+          list_type: 'ordered',
327
+          list_level: block.metadata.list_level ?? 0,
317 328
         },
318 329
       },
319
-      block.id // 在当前块后插入
330
+      block.id
320 331
     );
321 332
   }, [block.id, block.word_style, block.style, block.metadata, addBlock]);
322 333
 
334
+  const handleExitOrderedList = useCallback(() => {
335
+    updateBlock(block.id, {
336
+      word_style: 'Normal',
337
+      metadata: {
338
+        parent_heading_id: block.metadata.parent_heading_id || null,
339
+      },
340
+    });
341
+  }, [block.id, block.metadata.parent_heading_id, updateBlock]);
342
+
323 343
   const isEmpty = typeof block.content === 'string'
324 344
     ? block.content.trim().length === 0
325 345
     : block.content.every((segment) => segment.text.trim().length === 0);
@@ -346,23 +366,29 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
346 366
         />
347 367
       )}
348 368
 
349
-      <div className="paragraph-block">
369
+      <div
370
+        className={`paragraph-block${listMarker ? ' paragraph-block-list-item' : ''}`}
371
+        style={block.metadata.list_level ? { paddingLeft: `${block.metadata.list_level * 24}px` } : undefined}
372
+      >
373
+        {listMarker && <span className="paragraph-list-marker" aria-hidden="true">{listMarker}</span>}
350 374
         <RichTextEditor
351 375
           value={block.content}
352 376
           onChange={handleChange}
353 377
           currentContentFormat={
354
-            (block.metadata as Record<string, unknown>).list_type === 'ordered'
378
+            block.metadata.list_type === 'ordered'
355 379
               ? 'ordered-list'
356
-              : (block.metadata as Record<string, unknown>).list_type === 'unordered'
380
+              : block.metadata.list_type === 'unordered'
357 381
                 ? 'unordered-list'
358 382
                 : 'paragraph'
359 383
           }
384
+                  currentListType={block.metadata.list_type}
360 385
           onContentFormatChange={handleContentFormatChange}
361 386
           onDelete={handleDelete}
362 387
           onAlignChange={handleAlignChange}
363 388
           currentAlign={block.style?.align}
364 389
           onEnter={handleEnter}
365
-          onEnterOrderedList={handleEnterOrderedList}
390
+          onEnterOrderedList={block.metadata.list_type === 'ordered' ? handleEnterOrderedList : undefined}
391
+          onExitOrderedList={block.metadata.list_type === 'ordered' ? handleExitOrderedList : undefined}
366 392
           readOnly={readOnly}
367 393
           hasContent={!isEmpty}
368 394
           placeholder="输入段落内容..."

+ 15 - 7
src/services/clientExportService.ts

@@ -1,6 +1,7 @@
1 1
 import type { DocumentBlock, RichText, TableBlock } from '../types/editor';
2 2
 import { downloadBlob, safeFileName } from '../utils/download';
3 3
 import { getTableVisualCellPositions } from '../utils/blockOperations';
4
+import { formatOrderedListMarker, getOrderedListNumberMap } from '../utils/listNumbering';
4 5
 
5 6
 function toPlainText(content: string | RichText[]): string {
6 7
   return typeof content === 'string'
@@ -43,15 +44,21 @@ function tableToMarkdown(block: TableBlock): string {
43 44
   ].join('\n');
44 45
 }
45 46
 
46
-function blockToMarkdown(block: DocumentBlock): string {
47
+function blockToMarkdown(block: DocumentBlock, orderedListNumbers: Map<string, number>): string {
47 48
   switch (block.type) {
48 49
     case 'heading':
49 50
       return `${'#'.repeat(block.level)} ${toPlainText(block.content)}`;
50 51
     case 'paragraph': {
51 52
       const text = toPlainText(block.content);
52
-      return block.metadata.list_type === 'ordered' && !/^\d+\.\s/.test(text)
53
-        ? `1. ${text}`
54
-        : text;
53
+      const listLevel = block.metadata.list_level ?? 0;
54
+      const indent = '  '.repeat(listLevel);
55
+      if (block.metadata.list_type === 'ordered') {
56
+        return `${indent}${formatOrderedListMarker(orderedListNumbers.get(block.id) ?? 1, listLevel)} ${text}`;
57
+      }
58
+      if (block.metadata.list_type === 'unordered') {
59
+        return `${indent}- ${text}`;
60
+      }
61
+      return text;
55 62
     }
56 63
     case 'table':
57 64
       return tableToMarkdown(block);
@@ -70,9 +77,10 @@ export function exportBlocksToMarkdown(blocks: DocumentBlock[], title: string):
70 77
 }
71 78
 
72 79
 export function blocksToMarkdown(blocks: DocumentBlock[]): string {
73
-  return [...blocks]
74
-    .sort((left, right) => left.block_order - right.block_order)
75
-    .map(blockToMarkdown)
80
+  const sortedBlocks = [...blocks].sort((left, right) => left.block_order - right.block_order);
81
+  const orderedListNumbers = getOrderedListNumberMap(sortedBlocks);
82
+  return sortedBlocks
83
+    .map((block) => blockToMarkdown(block, orderedListNumbers))
76 84
     .filter(Boolean)
77 85
     .join('\n\n');
78 86
 }

+ 3 - 0
src/types/editor.ts

@@ -75,6 +75,8 @@ export interface HeadingBlock extends BaseBlock {
75 75
   content: string | RichText[];
76 76
   metadata: {
77 77
     parent_id: string | null;
78
+    list_type?: 'ordered' | 'unordered';
79
+    list_level?: number;
78 80
   };
79 81
 }
80 82
 
@@ -90,6 +92,7 @@ export interface ParagraphBlock extends BaseBlock {
90 92
   metadata: {
91 93
     parent_heading_id: string | null;
92 94
     list_type?: 'ordered' | 'unordered';
95
+    list_level?: number;
93 96
   };
94 97
 }
95 98
 

+ 0 - 18
src/utils/download.ts

@@ -1,23 +1,5 @@
1 1
 const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
2 2
 
3
-export function isAllowedHttpUrl(value: string): boolean {
4
-  try {
5
-    const url = new URL(value, window.location.origin);
6
-    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
7
-      return false;
8
-    }
9
-
10
-    if (!/^(https?:)?\/\//i.test(value)) {
11
-      return true;
12
-    }
13
-
14
-    const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
15
-    return url.origin === new URL(configuredBaseUrl).origin;
16
-  } catch {
17
-    return false;
18
-  }
19
-}
20
-
21 3
 export function safeFileName(fileName: string, fallback = 'download'): string {
22 4
   const normalized = Array.from(fileName, (character) =>
23 5
     character.charCodeAt(0) < 32 ? '_' : character

+ 26 - 0
src/utils/headingNumbering.ts

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

+ 69 - 0
src/utils/listNumbering.ts

@@ -0,0 +1,69 @@
1
+import type { DocumentBlock, ParagraphBlock } from '../types/editor';
2
+
3
+function getListLevel(block: ParagraphBlock): number {
4
+  const value = block.metadata.list_level;
5
+  return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
6
+}
7
+
8
+export function getOrderedListNumberMap(blocks: DocumentBlock[]): Map<string, number> {
9
+  const counters: number[] = [];
10
+  const numbers = new Map<string, number>();
11
+  let previousListLevel: number | null = null;
12
+
13
+  [...blocks]
14
+    .sort((left, right) => left.block_order - right.block_order)
15
+    .forEach((block) => {
16
+      if (block.type !== 'paragraph' || block.metadata.list_type !== 'ordered') {
17
+        previousListLevel = null;
18
+        return;
19
+      }
20
+
21
+      const level = getListLevel(block);
22
+      counters.length = Math.max(counters.length, level + 1);
23
+      if (previousListLevel === null || level > previousListLevel) {
24
+        counters[level] = 1;
25
+      } else if (level === previousListLevel) {
26
+        counters[level] = (counters[level] || 0) + 1;
27
+      } else {
28
+        counters[level] = (counters[level] || 0) + 1;
29
+      }
30
+
31
+      for (let index = level + 1; index < counters.length; index += 1) {
32
+        counters[index] = 0;
33
+      }
34
+
35
+      numbers.set(block.id, counters[level]);
36
+      previousListLevel = level;
37
+    });
38
+
39
+  return numbers;
40
+}
41
+
42
+export function formatOrderedListMarker(number: number, level: number): string {
43
+  if (level === 1) {
44
+    let value = number;
45
+    let marker = '';
46
+    while (value > 0) {
47
+      value -= 1;
48
+      marker = String.fromCharCode(97 + (value % 26)) + marker;
49
+      value = Math.floor(value / 26);
50
+    }
51
+    return `${marker}.`;
52
+  }
53
+
54
+  if (level >= 2) {
55
+    const romanValues: Array<[number, string]> = [
56
+      [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
57
+    ];
58
+    let remaining = number;
59
+    return `${romanValues.reduce((marker, [value, symbol]) => {
60
+      while (remaining >= value) {
61
+        remaining -= value;
62
+        marker += symbol;
63
+      }
64
+      return marker;
65
+    }, '')}.`;
66
+  }
67
+
68
+  return `${number}.`;
69
+}