瀏覽代碼

feat(编辑器): 优化性能与安全性,增强图片处理和滚动体验

- 优化MessageList滚动性能,使用requestAnimationFrame防止重复滚动帧
- 添加content-visibility优化块渲染性能,改进大文档加载速度
- 简化标题折叠逻辑,提高代码可维护性和执行效率
- 优化DocumentOutline使用useShallow选择器,减少不必要的重新渲染
- 增强图片安全验证,统一使用isSafeImageSource检查图片来源
- 提取sanitizeImageAlt和normalizeImageDimension工具函数,改进代码复用
- 完善图片上传流程,统一图片尺寸处理和文件名清理逻辑
- 优化表格单元格样式和工具栏交互体验
- 改进编辑器状态管理,增强数据验证和一致性
Zhang Yice 1 月之前
父節點
當前提交
b29fc7f7cc

+ 15 - 1
src/components/ChatPanel/MessageList.tsx

@@ -68,6 +68,7 @@ const MessageList: React.FC<MessageListProps> = ({ className }) => {
68 68
   const containerRef = useRef<HTMLDivElement>(null);
69 69
   // Ref for the invisible sentinel element at the bottom of the list
70 70
   const bottomRef = useRef<HTMLDivElement>(null);
71
+  const scrollFrameRef = useRef<number | null>(null);
71 72
   // Track whether the user is near the bottom so we know if we should auto-scroll
72 73
   const isNearBottomRef = useRef(true);
73 74
 
@@ -93,7 +94,20 @@ const MessageList: React.FC<MessageListProps> = ({ className }) => {
93 94
    * Scroll to the bottom of the message list.
94 95
    */
95 96
   const scrollToBottom = useCallback(() => {
96
-    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
97
+    if (scrollFrameRef.current !== null) {
98
+      cancelAnimationFrame(scrollFrameRef.current);
99
+    }
100
+
101
+    scrollFrameRef.current = requestAnimationFrame(() => {
102
+      scrollFrameRef.current = null;
103
+      bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
104
+    });
105
+  }, []);
106
+
107
+  useEffect(() => () => {
108
+    if (scrollFrameRef.current !== null) {
109
+      cancelAnimationFrame(scrollFrameRef.current);
110
+    }
97 111
   }, []);
98 112
 
99 113
   /**

+ 2 - 0
src/components/Editor/BlockCanvas.css

@@ -29,6 +29,8 @@
29 29
   position: relative;
30 30
   max-width: 100%;
31 31
   overflow: visible; /* 允许块菜单显示在外部 */
32
+  content-visibility: auto;
33
+  contain-intrinsic-size: 0 120px;
32 34
 }
33 35
 
34 36
 /* 滚动条样式 - Word风格 */

+ 6 - 4
src/components/Editor/BlockCanvas.tsx

@@ -56,11 +56,13 @@ export const BlockCanvas = React.memo(function BlockCanvas({
56 56
     const collapsible = new Set<string>();
57 57
     sorted.forEach((block, index) => {
58 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;
59
+
60
+      const nextBlock = sorted[index + 1];
61
+      if (
62
+        nextBlock &&
63
+        (nextBlock.type !== 'heading' || nextBlock.level > block.level)
64
+      ) {
62 65
         collapsible.add(block.id);
63
-        break;
64 66
       }
65 67
     });
66 68
 

+ 8 - 8
src/components/Editor/DocumentOutline.tsx

@@ -19,6 +19,7 @@ import {
19 19
 } from '@ant-design/icons';
20 20
 import type { HeadingBlock } from '../../types/editor';
21 21
 import { useEditorStore } from '../../stores/editorStore';
22
+import { useShallow } from 'zustand/react/shallow';
22 23
 import './DocumentOutline.css';
23 24
 import { getHeadingNumberMap } from '../../utils/headingNumbering';
24 25
 
@@ -113,18 +114,17 @@ function addHeadingNumbers(
113 114
 export const DocumentOutline: React.FC<DocumentOutlineProps> = ({
114 115
   visible = true,
115 116
 }) => {
116
-  const blocks = useEditorStore((state) => state.blocks);
117
+  const headings = useEditorStore(
118
+    useShallow((state) =>
119
+      state.blocks
120
+        .filter((block): block is HeadingBlock => block.type === 'heading')
121
+        .sort((left, right) => left.block_order - right.block_order)
122
+    )
123
+  );
117 124
   const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
118 125
   const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
119 126
   const [autoExpandParent, setAutoExpandParent] = useState(true);
120 127
 
121
-  // 提取所有标题
122
-  const headings = useMemo(() => {
123
-    return blocks
124
-      .filter((block): block is HeadingBlock => block.type === 'heading')
125
-      .sort((left, right) => left.block_order - right.block_order);
126
-  }, [blocks]);
127
-
128 128
   // 构建树结构
129 129
   const treeData = useMemo(() => {
130 130
     const numberMap = getHeadingNumberMap(headings);

+ 10 - 10
src/components/Editor/blocks/BlockMenu.tsx

@@ -10,7 +10,12 @@ import React from 'react';
10 10
 import { Button, Dropdown, Upload, message } from 'antd';
11 11
 import type { MenuProps } from 'antd';
12 12
 import type { RcFile } from 'antd/es/upload/interface';
13
-import { validateImageDimensions, validateImageUpload } from '../../../utils/imageUpload';
13
+import {
14
+  isSafeImageSource,
15
+  sanitizeImageAlt,
16
+  validateImageDimensions,
17
+  validateImageUpload,
18
+} from '../../../utils/imageUpload';
14 19
 import {
15 20
   DeleteOutlined,
16 21
   PlusOutlined,
@@ -85,7 +90,9 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
85 90
     const reader = new FileReader();
86 91
     reader.onload = (e) => {
87 92
       const dataUrl = e.target?.result as string;
88
-      if (!isMountedRef.current || typeof dataUrl !== 'string') return;
93
+      if (!isMountedRef.current || typeof dataUrl !== 'string' || !isSafeImageSource(dataUrl)) {
94
+        return;
95
+      }
89 96
 
90 97
       // 使用Image对象获取图片尺寸
91 98
       const img = new Image();
@@ -103,14 +110,7 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
103 110
         const aspectRatio = img.height / img.width;
104 111
         const widthCm = Math.min(maxWidthCm, img.width / 37.795); // 37.795 px ≈ 1cm
105 112
         const heightCm = widthCm * aspectRatio;
106
-        const safeFileName = Array.from(file.name)
107
-          .filter((character) => {
108
-            const codePoint = character.codePointAt(0) ?? 0;
109
-            return codePoint > 31 && codePoint !== 127;
110
-          })
111
-          .join('')
112
-          .trim()
113
-          .slice(0, 200) || '未命名图片';
113
+        const safeFileName = sanitizeImageAlt(file.name);
114 114
 
115 115
         // 调用插入图片回调
116 116
         onInsertImage?.(dataUrl, safeFileName, parseFloat(widthCm.toFixed(2)), parseFloat(heightCm.toFixed(2)));

+ 27 - 15
src/components/Editor/blocks/ImageBlock.tsx

@@ -18,6 +18,8 @@ import { BlockMenu } from './BlockMenu';
18 18
 import { ToolbarLauncher } from '../RichTextEditor/RichTextToolbar';
19 19
 import {
20 20
   isSafeImageSource,
21
+  normalizeImageDimension,
22
+  sanitizeImageAlt,
21 23
   validateImageDimensions,
22 24
   validateImageUpload,
23 25
 } from '../../../utils/imageUpload';
@@ -70,7 +72,7 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
70 72
       const reader = new FileReader();
71 73
       reader.onload = (e) => {
72 74
         const dataUrl = e.target?.result as string;
73
-        if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) {
75
+        if (typeof dataUrl !== 'string' || !isSafeImageSource(dataUrl)) {
74 76
           message.error('图片格式无效');
75 77
           return;
76 78
         }
@@ -87,7 +89,7 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
87 89
             content: dataUrl,
88 90
             metadata: {
89 91
               ...block.metadata,
90
-              alt: file.name,
92
+              alt: sanitizeImageAlt(file.name),
91 93
             },
92 94
           });
93 95
           message.success('图片上传成功');
@@ -132,19 +134,25 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
132 134
   );
133 135
 
134 136
   // 计算图片尺寸
135
-  const getImageSize = useCallback(() => {
137
+  const imageSize = (() => {
136 138
     const { width, height, unit } = block.style;
137 139
     const unitMap = { cm: 37.795, inch: 96, px: 1 };
138 140
     const multiplier = unitMap[unit] || 1;
141
+    const safeWidth = normalizeImageDimension(width, 15);
142
+    const safeHeight = height > 0 ? normalizeImageDimension(height, safeWidth) : 0;
139 143
     
140 144
     return {
141
-      width: `${width * multiplier}px`,
142
-      height: height ? `${height * multiplier}px` : 'auto',
145
+      width: `${safeWidth * multiplier}px`,
146
+      height: safeHeight ? `${safeHeight * multiplier}px` : 'auto',
143 147
     };
144
-  }, [block.style]);
145
-
146
-  const imageAspectRatio = block.style.width > 0 && block.style.height > 0
147
-    ? `${block.style.width} / ${block.style.height}`
148
+  })();
149
+
150
+  const safeImageWidth = normalizeImageDimension(block.style.width, 15);
151
+  const safeImageHeight = block.style.height > 0
152
+    ? normalizeImageDimension(block.style.height, safeImageWidth)
153
+    : 0;
154
+  const imageAspectRatio = safeImageWidth > 0 && safeImageHeight > 0
155
+    ? `${safeImageWidth} / ${safeImageHeight}`
148 156
     : undefined;
149 157
 
150 158
   // 开始拖拽调整尺寸
@@ -174,18 +182,23 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
174 182
     const newHeight = newWidth * aspectRatio;
175 183
 
176 184
     // 转换为当前单位
177
-    const { unit } = block.style;
185
+    const currentBlock = useEditorStore.getState().blocks.find(
186
+      (candidate) => candidate.id === block.id && candidate.type === 'image',
187
+    );
188
+    if (!currentBlock || currentBlock.type !== 'image') return;
189
+
190
+    const { unit } = currentBlock.style;
178 191
     const unitMap = { cm: 37.795, inch: 96, px: 1 };
179 192
     const multiplier = unitMap[unit] || 1;
180 193
 
181 194
     updateBlock(block.id, {
182 195
       style: {
183
-        ...block.style,
196
+        ...currentBlock.style,
184 197
         width: parseFloat((newWidth / multiplier).toFixed(2)),
185 198
         height: parseFloat((newHeight / multiplier).toFixed(2)),
186 199
       },
187 200
     });
188
-  }, [block.id, block.style, updateBlock]);
201
+  }, [block.id, updateBlock]);
189 202
 
190 203
   // 拖拽调整中
191 204
   const handleResizeMove = useCallback((e: MouseEvent) => {
@@ -417,8 +430,6 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
417 430
     message.success('已插入表格');
418 431
   }, [block.id, addBlock]);
419 432
 
420
-  const imageSize = getImageSize();
421
-
422 433
   return (
423 434
     <div
424 435
       className="image-block-wrapper"
@@ -521,7 +532,8 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
521 532
             <img
522 533
               ref={imageRef}
523 534
               src={block.content}
524
-              alt={block.metadata.alt}
535
+              alt={sanitizeImageAlt(block.metadata.alt || '图片')}
536
+              referrerPolicy="no-referrer"
525 537
               style={{
526 538
                 maxWidth: '100%',
527 539
                 ...imageSize,

+ 23 - 3
src/components/Editor/blocks/TableBlock.tsx

@@ -12,6 +12,7 @@ import { TableToolbar } from './TableToolbar';
12 12
 import { TableResizeHandle } from './TableResizeHandle';
13 13
 import { useTableResize } from '../../../hooks/useTableResize';
14 14
 import {
15
+  expandTableSelectionForMergedCells,
15 16
   getTableCellRangeForVisualBounds,
16 17
   getTableCellReference,
17 18
   getTableVisualCellPositions,
@@ -42,6 +43,17 @@ function getVisualSelection(
42 43
   };
43 44
 }
44 45
 
46
+function getExpandedVisualSelection(
47
+  anchor: TableVisualCellPosition | undefined,
48
+  target: TableVisualCellPosition | undefined,
49
+  positions: Map<string, TableVisualCellPosition>,
50
+) {
51
+  const visualRange = getVisualSelection(anchor, target);
52
+  return visualRange
53
+    ? expandTableSelectionForMergedCells(visualRange, positions)
54
+    : null;
55
+}
56
+
45 57
 /**
46 58
  * TableBlock - 表格块(完整实现)
47 59
  */
@@ -229,7 +241,11 @@ export const TableBlock: React.FC<TableBlockProps> = ({
229 241
         colIndex,
230 242
         visualCellPositions,
231 243
       )?.visual;
232
-      const visualRange = getVisualSelection(anchorPosition, targetPosition);
244
+      const visualRange = getExpandedVisualSelection(
245
+        anchorPosition,
246
+        targetPosition,
247
+        visualCellPositions,
248
+      );
233 249
       if (visualRange) {
234 250
         setSelectedVisualRange(visualRange);
235 251
         setSelectedRange(getTableCellRangeForVisualBounds(block, visualRange));
@@ -255,7 +271,11 @@ export const TableBlock: React.FC<TableBlockProps> = ({
255 271
       colIndex,
256 272
       visualCellPositions,
257 273
     )?.visual;
258
-    const visualRange = getVisualSelection(anchorPosition ?? undefined, targetPosition);
274
+    const visualRange = getExpandedVisualSelection(
275
+      anchorPosition ?? undefined,
276
+      targetPosition,
277
+      visualCellPositions,
278
+    );
259 279
     if (visualRange) {
260 280
       setSelectedVisualRange(visualRange);
261 281
       setSelectedRange(getTableCellRangeForVisualBounds(block, visualRange));
@@ -438,7 +458,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
438 458
 
439 459
         <table
440 460
           ref={tableRef}
441
-          className={`table-block${resizeState?.isResizing ? ' table-resizing' : ''}${isSelecting ? ' table-selecting' : ''}`}
461
+          className={`table-block${resizeState?.isResizing ? ' table-resizing' : ''}${isSelecting ? ' table-selecting' : ''}${selectedVisualRange ? ' table-has-selection' : ''}`}
442 462
           style={{
443 463
             width: tableWidthStyle,
444 464
             tableLayout: 'fixed',

+ 6 - 2
src/components/Editor/blocks/TableCell.css

@@ -16,8 +16,12 @@
16 16
 }
17 17
 
18 18
 .table-cell.selected {
19
-  background-color: rgba(24, 144, 255, 0.1);
20
-  box-shadow: inset 0 0 0 1px #1890ff;
19
+  background-color: rgba(22, 119, 255, 0.16);
20
+  box-shadow: none;
21
+}
22
+
23
+.table-block.table-has-selection .table-cell.selected {
24
+  background-color: rgba(22, 119, 255, 0.16);
21 25
 }
22 26
 
23 27
 /* 单元格内的富文本编辑器 */

+ 1 - 1
src/components/Editor/blocks/TableToolbar.tsx

@@ -218,7 +218,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
218 218
   // 合并单元格
219 219
   const handleMergeCells = useCallback(() => {
220 220
     if (!isMultiCellRange || !visualCellRange) {
221
-      message.warning('请先选择要合并的单元格范围(Shift+点击)');
221
+      message.warning('请先拖动或按住 Shift 点击选择要合并的单元格范围');
222 222
       return;
223 223
     }
224 224
 

+ 23 - 1
src/stores/chatStore.ts

@@ -33,6 +33,10 @@ import { shouldTriggerWorkflow, triggerDocumentWorkflow } from '../services/work
33 33
 const STORAGE_KEY = 'ax-chat-sessions';
34 34
 const MAX_SESSIONS = 50; // Maximum number of sessions to keep
35 35
 const MAX_MESSAGES_PER_SESSION = 200;
36
+const STORAGE_WRITE_DELAY = 150;
37
+
38
+let pendingStorageWrite: ReturnType<typeof setTimeout> | null = null;
39
+let pendingSessions: ChatSession[] | null = null;
36 40
 
37 41
 // ── Utility Functions ──────────────────────────────────────────────────────
38 42
 
@@ -89,7 +93,7 @@ const loadSessionsFromStorage = (): ChatSession[] => {
89 93
 /**
90 94
  * Save sessions to localStorage
91 95
  */
92
-const saveSessionsToStorage = (sessions: ChatSession[]): void => {
96
+const writeSessionsToStorage = (sessions: ChatSession[]): void => {
93 97
   try {
94 98
     // Keep only the most recent MAX_SESSIONS
95 99
     const sessionsToSave = [...sessions]
@@ -106,6 +110,24 @@ const saveSessionsToStorage = (sessions: ChatSession[]): void => {
106 110
   }
107 111
 };
108 112
 
113
+const scheduleSessionsStorageSave = (sessions: ChatSession[]): void => {
114
+  pendingSessions = sessions;
115
+
116
+  if (pendingStorageWrite !== null) {
117
+    clearTimeout(pendingStorageWrite);
118
+  }
119
+
120
+  pendingStorageWrite = setTimeout(() => {
121
+    pendingStorageWrite = null;
122
+    if (pendingSessions) {
123
+      writeSessionsToStorage(pendingSessions);
124
+      pendingSessions = null;
125
+    }
126
+  }, STORAGE_WRITE_DELAY);
127
+};
128
+
129
+const saveSessionsToStorage = scheduleSessionsStorageSave;
130
+
109 131
 /**
110 132
  * Generate session title from first message
111 133
  */

+ 2 - 1
src/stores/editorStore.ts

@@ -1132,6 +1132,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1132 1132
     
1133 1133
     // 计算新的哈希值
1134 1134
     const newHash = computeBlockHash(updatedBlock);
1135
+    const previousHash = computeBlockHash(blocks[blockIndex]);
1135 1136
     const originalHash = blockHashes.get(id);
1136 1137
     
1137 1138
     // 比较哈希值,只有真正改变时才标记为脏块
@@ -1150,7 +1151,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1150 1151
       dirtyBlocks: newDirtyBlocks,
1151 1152
       hasModified: newDirtyBlocks.size > 0,
1152 1153
     });
1153
-    if (newHash !== computeBlockHash(blocks[blockIndex])) {
1154
+    if (newHash !== previousHash) {
1154 1155
       pushHistory(previousBlocks, get().selectedBlockId);
1155 1156
     }
1156 1157
     

+ 93 - 0
src/utils/blockOperations.ts

@@ -136,6 +136,99 @@ export interface TableCellReference {
136 136
   visual: TableVisualCellPosition;
137 137
 }
138 138
 
139
+/**
140
+ * Expand a visual selection until every intersecting merged cell is fully included.
141
+ * A column selection must remain a column selection; it should not expand to an
142
+ * entire row just because one merged cell crosses the selected column.
143
+ */
144
+export function expandTableSelectionForMergedCells(
145
+  bounds: TableVisualCellPosition,
146
+  positions: Map<string, TableVisualCellPosition>,
147
+): TableVisualCellPosition {
148
+  const expanded = { ...bounds };
149
+  let changed = true;
150
+
151
+  while (changed) {
152
+    changed = false;
153
+
154
+    for (const position of positions.values()) {
155
+      const intersects = position.rowStart <= expanded.rowEnd
156
+        && position.rowEnd >= expanded.rowStart
157
+        && position.colStart <= expanded.colEnd
158
+        && position.colEnd >= expanded.colStart;
159
+      if (!intersects) continue;
160
+
161
+      const next = {
162
+        rowStart: Math.min(expanded.rowStart, position.rowStart),
163
+        rowEnd: Math.max(expanded.rowEnd, position.rowEnd),
164
+        colStart: Math.min(expanded.colStart, position.colStart),
165
+        colEnd: Math.max(expanded.colEnd, position.colEnd),
166
+      };
167
+
168
+      if (
169
+        next.rowStart !== expanded.rowStart
170
+        || next.rowEnd !== expanded.rowEnd
171
+        || next.colStart !== expanded.colStart
172
+        || next.colEnd !== expanded.colEnd
173
+      ) {
174
+        Object.assign(expanded, next);
175
+        changed = true;
176
+      }
177
+    }
178
+  }
179
+
180
+  return expanded;
181
+}
182
+
183
+/**
184
+ * 整行合并单元格参与选择时,按文档表格的视觉行为扩展整行选区。
185
+ * 这样选区不会只高亮合并单元格本身,而是覆盖该视觉行的所有列。
186
+ */
187
+export function expandTableSelectionForFullRowMerges(
188
+  table: TableBlock,
189
+  bounds: TableVisualCellPosition,
190
+  positions: Map<string, TableVisualCellPosition> = getTableVisualCellPositions(table),
191
+): TableVisualCellPosition {
192
+  const visualColumnEnd = Math.max(
193
+    ...Array.from(positions.values(), (position) => position.colEnd),
194
+    -1,
195
+  );
196
+  let touchesFullVisualRow = false;
197
+
198
+  for (let rowIndex = bounds.rowStart; rowIndex <= bounds.rowEnd; rowIndex += 1) {
199
+    const intervals = Array.from(positions.values())
200
+      .filter((position) => position.rowStart <= rowIndex && position.rowEnd >= rowIndex)
201
+      .map((position) => ({
202
+        start: position.colStart,
203
+        end: position.colEnd,
204
+        isMerged: position.rowEnd > position.rowStart || position.colEnd > position.colStart,
205
+      }))
206
+      .sort((left, right) => left.start - right.start);
207
+
208
+    let coveredEnd = -1;
209
+    let hasMergedCell = false;
210
+    for (const interval of intervals) {
211
+      if (interval.start > coveredEnd + 1) break;
212
+      coveredEnd = Math.max(coveredEnd, interval.end);
213
+      hasMergedCell = hasMergedCell || interval.isMerged;
214
+      if (coveredEnd >= visualColumnEnd && hasMergedCell) {
215
+        touchesFullVisualRow = true;
216
+        break;
217
+      }
218
+    }
219
+
220
+    if (touchesFullVisualRow) break;
221
+  }
222
+
223
+  if (!touchesFullVisualRow) return bounds;
224
+
225
+  return {
226
+    ...bounds,
227
+    colStart: 0,
228
+    colEnd: visualColumnEnd,
229
+  };
230
+}
231
+
139 232
 export function getTableVisualCellPositions(table: TableBlock): Map<string, TableVisualCellPosition> {
140 233
   const occupied: boolean[][] = [];
141 234
   const positions = new Map<string, TableVisualCellPosition>();

+ 39 - 3
src/utils/imageUpload.ts

@@ -1,6 +1,8 @@
1 1
 const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
2
+export const MAX_IMAGE_DATA_URL_LENGTH = Math.ceil(MAX_IMAGE_SIZE_BYTES * 4 / 3) + 128;
2 3
 export const MAX_IMAGE_DIMENSION = 10000;
3 4
 export const MAX_IMAGE_PIXELS = 40_000_000;
5
+export const MAX_IMAGE_DISPLAY_SIZE = 50;
4 6
 
5 7
 const ALLOWED_IMAGE_TYPES = new Set([
6 8
   'image/jpeg',
@@ -11,13 +13,30 @@ const ALLOWED_IMAGE_TYPES = new Set([
11 13
 ]);
12 14
 
13 15
 const ALLOWED_DATA_IMAGE_PREFIX = /^data:image\/(?:jpeg|png|gif|webp|bmp);base64,/i;
16
+const BASE64_DATA_PATTERN = /^[a-z0-9+/]*={0,2}$/i;
14 17
 
15 18
 export function isSafeImageSource(source: string): boolean {
16 19
   const value = source.trim();
17 20
   if (!value) return false;
18
-  if (ALLOWED_DATA_IMAGE_PREFIX.test(value)) return true;
19
-  if (/^https?:\/\//i.test(value)) return true;
20
-  return value.startsWith('/') || value.startsWith('./');
21
+
22
+  if (ALLOWED_DATA_IMAGE_PREFIX.test(value)) {
23
+    const encodedData = value.slice(value.indexOf(',') + 1);
24
+    return encodedData.length <= MAX_IMAGE_DATA_URL_LENGTH
25
+      && BASE64_DATA_PATTERN.test(encodedData);
26
+  }
27
+
28
+  try {
29
+    const currentOrigin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
30
+    const url = new URL(value, currentOrigin);
31
+    if (url.username || url.password) return false;
32
+    if (url.protocol === 'http:' || url.protocol === 'https:') {
33
+      return !value.startsWith('//') || url.origin === currentOrigin;
34
+    }
35
+    return url.origin === currentOrigin
36
+      && (value.startsWith('/') || value.startsWith('./'));
37
+  } catch {
38
+    return false;
39
+  }
21 40
 }
22 41
 
23 42
 export function validateImageUpload(file: Pick<File, 'size' | 'type'>): string | null {
@@ -46,4 +65,21 @@ export function validateImageDimensions(width: number, height: number): string |
46 65
   }
47 66
 
48 67
   return null;
68
+}
69
+
70
+export function normalizeImageDimension(value: number, fallback: number): number {
71
+  if (!Number.isFinite(value) || value <= 0) return fallback;
72
+  return Math.min(value, MAX_IMAGE_DISPLAY_SIZE);
73
+}
74
+
75
+export function sanitizeImageAlt(fileName: string): string {
76
+  const sanitized = Array.from(fileName, (character) => {
77
+    const codePoint = character.codePointAt(0) ?? 0;
78
+    return codePoint < 32 || codePoint === 127 ? '_' : character;
79
+  })
80
+    .join('')
81
+    .trim()
82
+    .slice(0, 200);
83
+
84
+  return sanitized || '未命名图片';
49 85
 }