feat: 新增图片工具箱插件,支持马赛克、裁剪、加字等编辑功能#269
Conversation
本提交新增了完整的图片工具箱插件,包含以下核心功能: 1. 五区编辑器布局:工具栏、选项栏、画布区、属性/图层面板和状态栏 2. 基础编辑功能:马赛克、裁剪、文字标注、画笔、橡皮擦 3. 图形绘制功能:支持矩形、圆形、星星等多种图形 4. 图层管理、历史撤销/重做、缩放控制 5. 多平台适配:支持uTools和ZTools客户端 6. 主题切换、系统字体读取等人性化功能
There was a problem hiding this comment.
Code Review
This pull request introduces the core logic and UI components for an 'Image Toolbox' image editing plugin built on Fabric.js, featuring modules for drawing, erasing, cropping, text, shapes, and mosaic effects. The reviewer identified several critical issues across the codebase: a memory leak in CanvasManager due to an uncleaned ResizeObserver; missing _originalImage markers on loaded/replaced background images which breaks state restoration; race conditions in HistoryManager's synchronous handling of asynchronous undo/redo operations; a bug in BaseModule that breaks layer locking by blindly resetting object interactivity; and multiple instances in TextModule and MosaicModule where history states are saved after operations rather than before, resulting in an unresponsive first undo action.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| this.originalImage = fabricImg; | ||
| this.canvas.clear(); | ||
| this.canvas.add(fabricImg); | ||
| this.canvas.renderAll(); |
There was a problem hiding this comment.
在 fromJSON 恢复画布状态时,代码通过 objs.find(o => o.type === 'image' && o._originalImage) 来寻找并恢复原始背景图的引用。然而,在 loadImage 加载图片时,并没有在 fabricImg 上设置 _originalImage = true 属性。这会导致 fromJSON 无法正确识别原始背景图,从而退化为使用 objs[0],在多图层或复杂操作下可能导致引用错误。\n建议在加载图片成功后显式设置 fabricImg._originalImage = true。
this.originalImage = fabricImg;\n fabricImg._originalImage = true;\n this.canvas.clear();\n this.canvas.add(fabricImg);\n this.canvas.renderAll();| undo() { | ||
| if (!this.canUndo()) return; | ||
|
|
||
| this._isRestoring = true; | ||
|
|
||
| // 保存当前状态到重做栈 | ||
| const currentJson = this._cm.toJSON(); | ||
| if (currentJson) { | ||
| this.redoStack.push(currentJson); | ||
| } | ||
|
|
||
| // 恢复上一个状态 | ||
| const prevJson = this.undoStack.pop(); | ||
| this._restoreState(prevJson); | ||
|
|
||
| this._isRestoring = false; | ||
| this._notify(); | ||
| } | ||
|
|
||
| /** | ||
| * 重做 | ||
| */ | ||
| redo() { | ||
| if (!this.canRedo()) return; | ||
|
|
||
| this._isRestoring = true; | ||
|
|
||
| // 保存当前状态到撤销栈 | ||
| const currentJson = this._cm.toJSON(); | ||
| if (currentJson) { | ||
| this.undoStack.push(currentJson); | ||
| } | ||
|
|
||
| // 恢复下一个状态 | ||
| const nextJson = this.redoStack.pop(); | ||
| this._restoreState(nextJson); | ||
|
|
||
| this._isRestoring = false; | ||
| this._notify(); | ||
| } |
There was a problem hiding this comment.
HistoryManager 的 undo() 和 redo() 方法是同步执行的,但它们调用的 _restoreState 内部执行了异步的 this._cm.fromJSON(json)。由于没有等待 Promise 的解析,this._isRestoring 会在画布实际恢复完成之前被立即重置为 false。这会导致在异步加载画布物件期间触发的事件(如 object:added)错误地触发 saveState(),从而向历史栈中写入重复或损坏的快照。\n建议将 undo() 和 redo() 改为等待 fromJSON 异步完成后再重置 _isRestoring 状态。
/**\n * 撤销\n */\n undo() {\n if (!this.canUndo()) return;\n\n this._isRestoring = true;\n\n // 保存当前状态到重做栈\n const currentJson = this._cm.toJSON();\n if (currentJson) {\n this.redoStack.push(currentJson);\n }\n\n // 恢复上一个状态\n const prevJson = this.undoStack.pop();\n this._cm.fromJSON(prevJson)\n .catch(err => {\n console.error('[HistoryManager] 恢复状态失败:', err);\n })\n .finally(() => {\n this._isRestoring = false;\n this._notify();\n });\n }\n\n /**\n * 重做\n */\n redo() {\n if (!this.canRedo()) return;\n\n this._isRestoring = true;\n\n // 保存当前状态到撤销栈\n const currentJson = this._cm.toJSON();\n if (currentJson) {\n this.undoStack.push(currentJson);\n }\n\n // 恢复下一个状态\n const nextJson = this.redoStack.pop();\n this._cm.fromJSON(nextJson)\n .catch(err => {\n console.error('[HistoryManager] 恢复状态失败:', err);\n })\n .finally(() => {\n this._isRestoring = false;\n this._notify();\n });\n }| _setObjectsInteractivity(interactive) { | ||
| const objects = this.canvasManager.canvas.getObjects(); | ||
| objects.forEach(obj => { | ||
| obj.set({ selectable: interactive, evented: interactive }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
BaseModule 的 _setObjectsInteractivity(true) 方法在工具停用时会盲目地将所有物件的 selectable 和 evented 设置为 true。这会彻底破坏图层的“锁定”功能(以及背景图层默认锁定的行为),因为一旦切换或停用任何工具,原本被用户锁定的图层都会被强制解锁并变得可选中。\n建议在禁用交互前保存每个物件原本的 selectable 和 evented 状态,并在恢复交互时还原它们。
_setObjectsInteractivity(interactive) {\n const objects = this.canvasManager.canvas.getObjects();\n objects.forEach(obj => {\n if (!interactive) {\n // 仅在未保存过原始状态时保存,避免重复覆盖\n if (obj._prevSelectable === undefined) {\n obj._prevSelectable = obj.selectable !== false;\n }\n if (obj._prevEvented === undefined) {\n obj._prevEvented = obj.evented !== false;\n }\n obj.set({ selectable: false, evented: false });\n } else {\n // 恢复原始状态,默认值为 true\n const sel = obj._prevSelectable !== undefined ? obj._prevSelectable : true;\n const eve = obj._prevEvented !== undefined ? obj._prevEvented : true;\n obj.set({ selectable: sel, evented: eve });\n // 清理临时属性\n delete obj._prevSelectable;\n delete obj._prevEvented;\n }\n });\n }| canvas.add(textObj); | ||
| canvas.setActiveObject(textObj); | ||
| canvas.renderAll(); | ||
| recordFontUsage(opts.fontFamily); | ||
|
|
||
| // 进入编辑模式 | ||
| setTimeout(() => { | ||
| textObj.enterEditing(); | ||
| textObj.selectAll(); | ||
| }, 50); | ||
|
|
||
| this.history.saveState(); | ||
| return textObj; |
There was a problem hiding this comment.
TextModule 在添加文字对象之后才调用 this.history.saveState()。这与 BrushModule 和 ShapeModule 在操作之前保存状态的逻辑不一致。这种不一致会导致 undoStack 的栈顶存储的是当前画布的最新状态,使得用户第一次按下“撤销”时没有任何反应(因为恢复了相同的状态),并且导致整个历史记录栈发生偏移。\n建议在将文字对象添加到画布之前保存历史状态。
this.history.saveState();\n\n canvas.add(textObj);\n canvas.setActiveObject(textObj);\n canvas.renderAll();\n recordFontUsage(opts.fontFamily);\n\n // 进入编辑模式\n setTimeout(() => {\n textObj.enterEditing();\n textObj.selectAll();\n }, 50);\n\n return textObj;| * 对指定矩形区域应用马赛克/模糊(框选模式使用) | ||
| */ | ||
| applyMosaic(rect) { | ||
| rect = this._clipRectToEditableImage(rect); | ||
| if (!rect || rect.width < 1 || rect.height < 1) return; | ||
|
|
||
| this._createDynamicMosaicOverlay({ rect, maskType: 'rect' }); |
There was a problem hiding this comment.
与 TextModule 类似,MosaicModule 的 applyMosaic 也是在创建马赛克覆盖层之后才保存状态,这会导致撤销系统出现偏差(第一次撤销无响应)。\n建议在创建马赛克覆盖层之前调用 _saveStateWithCanvasClipPath() 保存状态。
| * 对指定矩形区域应用马赛克/模糊(框选模式使用) | |
| */ | |
| applyMosaic(rect) { | |
| rect = this._clipRectToEditableImage(rect); | |
| if (!rect || rect.width < 1 || rect.height < 1) return; | |
| this._createDynamicMosaicOverlay({ rect, maskType: 'rect' }); | |
| applyMosaic(rect) {\n rect = this._clipRectToEditableImage(rect);\n if (!rect || rect.width < 1 || rect.height < 1) return;\n\n this._saveStateWithCanvasClipPath();\n this._createDynamicMosaicOverlay({ rect, maskType: 'rect' });\n } |
| _finishBrush(e) { | ||
| this._isDrawing = false; | ||
| this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e)); | ||
| this._updateLiveBrushOverlay(); | ||
|
|
||
| if (this._brushPoints.length === 0) return; | ||
| this._brushPoints = []; | ||
|
|
||
| if (!this._liveBrushOverlay) return; | ||
|
|
||
| this._liveBrushOverlay = null; | ||
| this._saveStateWithCanvasClipPath(); | ||
| } |
There was a problem hiding this comment.
由于我们已经将画笔马赛克的状态保存逻辑移到了 _startBrush(涂抹开始前),因此在 _finishBrush(涂抹结束)时不再需要调用 _saveStateWithCanvasClipPath(),否则会导致状态重复保存。\n建议在此处移除该调用。
_finishBrush(e) {\n this._isDrawing = false;\n this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e));\n this._updateLiveBrushOverlay();\n\n if (this._brushPoints.length === 0) return;\n this._brushPoints = [];\n\n if (!this._liveBrushOverlay) return;\n\n this._liveBrushOverlay = null;\n }| /** | ||
| * 清除所有马赛克覆盖层 | ||
| */ | ||
| clearAllMosaics() { | ||
| const canvas = this.canvasManager.canvas; | ||
| const overlays = canvas.getObjects().filter( | ||
| o => o.id && o.id.startsWith('mosaic_') | ||
| ); | ||
| overlays.forEach(o => canvas.remove(o)); |
There was a problem hiding this comment.
在清除所有马赛克时,应该在执行清除操作之前保存当前状态,以便用户可以撤销清除操作。\n建议将 _saveStateWithCanvasClipPath() 移到清除逻辑之前。
clearAllMosaics() {\n const canvas = this.canvasManager.canvas;\n const overlays = canvas.getObjects().filter(\n o => o.id && o.id.startsWith('mosaic_')\n );\n this._saveStateWithCanvasClipPath();\n overlays.forEach(o => canvas.remove(o));\n canvas.renderAll();\n }| destroy() { | ||
| if (this._historySaveTimer) { | ||
| clearTimeout(this._historySaveTimer); | ||
| } | ||
| if (this.canvas) { | ||
| this.canvas.dispose(); | ||
| this.canvas = null; | ||
| } | ||
| this.originalImage = null; | ||
| } |
There was a problem hiding this comment.
在 CanvasManager 的 _bindEvents 方法中创建了 ResizeObserver 监听器,但在 destroy 方法中并没有对其进行断开(disconnect)或清理。这会导致内存泄漏,因为 ResizeObserver 会一直持有容器元素的引用,且其回调函数也持有 CanvasManager 实例的引用。\n建议在 destroy 方法中显式调用 disconnect() 释放资源。
destroy() {\n if (this._historySaveTimer) {\n clearTimeout(this._historySaveTimer);\n }\n if (this._resizeObserver) {\n this._resizeObserver.disconnect();\n this._resizeObserver = null;\n }\n if (this.canvas) {\n this.canvas.dispose();\n this.canvas = null;\n }\n this.originalImage = null;\n }| // 使用 ResizeObserver 监听容器变化 | ||
| const container = this.canvas.wrapperEl?.parentElement; | ||
| if (container && window.ResizeObserver) { | ||
| const observer = new ResizeObserver(() => { | ||
| this._updateCanvasSize(); | ||
| }); | ||
| observer.observe(container); | ||
| } |
There was a problem hiding this comment.
为了配合 destroy 方法中对 ResizeObserver 的清理,避免内存泄漏,建议将创建的 ResizeObserver 实例保存到 this._resizeObserver 属性中。
| // 使用 ResizeObserver 监听容器变化 | |
| const container = this.canvas.wrapperEl?.parentElement; | |
| if (container && window.ResizeObserver) { | |
| const observer = new ResizeObserver(() => { | |
| this._updateCanvasSize(); | |
| }); | |
| observer.observe(container); | |
| } | |
| // 使用 ResizeObserver 监听容器变化\n const container = this.canvas.wrapperEl?.parentElement;\n if (container && window.ResizeObserver) {\n this._resizeObserver = new ResizeObserver(() => {\n this._updateCanvasSize();\n });\n this._resizeObserver.observe(container);\n } |
| if (this.originalImage) { | ||
| this.canvas.remove(this.originalImage); | ||
| } | ||
| this.originalImage = fabricImg; | ||
| this.canvas.insertAt(fabricImg, 1); // 放到最底层但保留背景 | ||
| this.canvas.renderAll(); | ||
| resolve(fabricImg); |
There was a problem hiding this comment.
在 replaceImage 方法中,同样存在未设置 fabricImg._originalImage = true 的问题。此外,硬编码 insertAt(fabricImg, 1) 可能会导致层级混乱(因为 loadImage 时原图是在索引 0 处)。\n建议动态获取原图的索引位置,并在相同位置插入新图,同时设置 _originalImage 属性。
let index = 0;\n if (this.originalImage) {\n index = this.canvas.getObjects().indexOf(this.originalImage);\n this.canvas.remove(this.originalImage);\n }\n this.originalImage = fabricImg;\n fabricImg._originalImage = true;\n this.canvas.insertAt(fabricImg, index);\n this.canvas.renderAll();本次更新包含: 1. 新增三角形和双箭头图形工具 2. 文字描边新增内外两种位置参数 3. 修复橡皮擦工具切换图层后仍作用于原图层的问题 4. 改为异步加载系统字体,解决首次进入文字工具卡顿问题 5. 修复撤销/重做时历史记录损坏导致画布异常的问题 6. 修复切换/停用工具时图层被意外解锁的问题 7. 优化画布原图恢复逻辑,修复历史记录序列化问题
本提交新增了完整的图片工具箱插件,包含以下核心功能: