Feat/add image toolbox#275
Conversation
本提交新增了完整的图片工具箱插件,包含以下核心功能: 1. 五区编辑器布局:工具栏、选项栏、画布区、属性/图层面板和状态栏 2. 基础编辑功能:马赛克、裁剪、文字标注、画笔、橡皮擦 3. 图形绘制功能:支持矩形、圆形、星星等多种图形 4. 图层管理、历史撤销/重做、缩放控制 5. 多平台适配:支持uTools和ZTools客户端 6. 主题切换、系统字体读取等人性化功能
本次更新包含: 1. 新增三角形和双箭头图形工具 2. 文字描边新增内外两种位置参数 3. 修复橡皮擦工具切换图层后仍作用于原图层的问题 4. 改为异步加载系统字体,解决首次进入文字工具卡顿问题 5. 修复撤销/重做时历史记录损坏导致画布异常的问题 6. 修复切换/停用工具时图层被意外解锁的问题 7. 优化画布原图恢复逻辑,修复历史记录序列化问题
There was a problem hiding this comment.
Code Review
This pull request introduces the 'Image-Toolbox' plugin, a comprehensive image editing tool featuring modules for cropping, drawing, erasing, text annotation, shapes, and mosaic effects, built on Fabric.js and integrated with ZTools. Key feedback focuses on critical bug fixes and performance improvements: correcting the fabric.Image.fromURL signature in CanvasManager.js to prevent CORS security errors during export; resolving an undo/redo state synchronization bug in HistoryManager.js and HistoryStore.js that causes the first undo to be a no-op; removing a BOM character from CanvasManager.js; filtering out temporary preview objects from serialization; and refactoring the font-loading logic in preload.js to use asynchronous operations or background processes to avoid blocking the Electron UI thread.
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.
| reader.onload = (e) => { | ||
| const dataURL = e.target.result; | ||
| // fromURL(url, callback, imgOptions, crossOrigin) | ||
| fabric.Image.fromURL(dataURL, onLoad, undefined, 'anonymous'); |
There was a problem hiding this comment.
在 Fabric.js 5.x 中,fabric.Image.fromURL 的方法签名是 fromURL(url, callback, options)。crossOrigin 参数必须作为第三个参数 options 对象的一个属性传入(例如 { crossOrigin: 'anonymous' })。\n\n当前代码将 'anonymous' 作为第四个参数传入,这在 Fabric.js 5.x 中会被完全忽略,导致跨域图片无法正确设置 CORS 属性。这会在后续导出画布(如 toDataURL)时触发 SecurityError: Tainted canvases may not be exported 浏览器安全错误。\n\n建议将第三个参数修改为 { crossOrigin: 'anonymous' }。
| fabric.Image.fromURL(dataURL, onLoad, undefined, 'anonymous'); | |
| fabric.Image.fromURL(dataURL, onLoad, { crossOrigin: 'anonymous' }); |
| fabric.Image.fromURL( | ||
| source, | ||
| onLoad, | ||
| undefined, // imgOptions | ||
| needCrossOrigin ? 'anonymous' : undefined // crossOrigin | ||
| ); |
| fabric.Image.fromURL(source, (fabricImg, isError) => { | ||
| if (isError || !fabricImg) { | ||
| reject(new Error('替换图片失败')); | ||
| return; | ||
| } | ||
| if (this.originalImage) { | ||
| const index = this.canvas.getObjects().indexOf(this.originalImage); | ||
| this.canvas.remove(this.originalImage); | ||
| this.originalImage = fabricImg; | ||
| fabricImg._originalImage = true; | ||
| this.canvas.insertAt(fabricImg, index >= 0 ? index : 0); | ||
| } else { | ||
| this.originalImage = fabricImg; | ||
| fabricImg._originalImage = true; | ||
| this.canvas.insertAt(fabricImg, 0); | ||
| } | ||
| this.canvas.renderAll(); | ||
| resolve(fabricImg); | ||
| }, undefined, 'anonymous'); |
There was a problem hiding this comment.
同上,在 replaceImage 中,fabric.Image.fromURL 的跨域参数也需要修正为第三个参数的对象属性,以确保替换后的图片能正常导出。
fabric.Image.fromURL(source, (fabricImg, isError) => {\n if (isError || !fabricImg) {\n reject(new Error('替换图片失败'));\n return;\n }\n if (this.originalImage) {\n const index = this.canvas.getObjects().indexOf(this.originalImage);\n this.canvas.remove(this.originalImage);\n this.originalImage = fabricImg;\n fabricImg._originalImage = true;\n this.canvas.insertAt(fabricImg, index >= 0 ? index : 0);\n } else {\n this.originalImage = fabricImg;\n fabricImg._originalImage = true;\n this.canvas.insertAt(fabricImg, 0);\n }\n this.canvas.renderAll();\n resolve(fabricImg);\n }, { crossOrigin: 'anonymous' });| class HistoryManager { | ||
| constructor(canvasManager, maxSteps = 30) { | ||
| this._cm = canvasManager; | ||
| this.undoStack = []; | ||
| this.redoStack = []; | ||
| this.maxSteps = maxSteps; | ||
| this._enabled = true; | ||
| this._isRestoring = false; // 防止恢复时触发保存 | ||
| } | ||
|
|
||
| /** | ||
| * 保存当前画布状态 | ||
| */ | ||
| saveState() { | ||
| if (!this._enabled || this._isRestoring) return; | ||
| if (!this._cm.canvas) return; | ||
|
|
||
| const json = this._cm.toJSON(); | ||
| if (!json) return; | ||
|
|
||
| this.undoStack.push(json); | ||
|
|
||
| // 限制栈大小 | ||
| if (this.undoStack.length > this.maxSteps) { | ||
| this.undoStack.shift(); | ||
| } | ||
|
|
||
| // 新操作清空重做栈 | ||
| this.redoStack = []; | ||
|
|
||
| this._notify(); | ||
| } | ||
|
|
||
| /** | ||
| * 撤销 | ||
| */ | ||
| async undo() { | ||
| if (!this.canUndo()) return; | ||
| if (this._isRestoring) return; // 防止恢复期间重复触发 | ||
|
|
||
| this._isRestoring = true; | ||
|
|
||
| // 保存当前状态到重做栈 | ||
| const currentJson = this._cm.toJSON(); | ||
| if (currentJson) { | ||
| this.redoStack.push(currentJson); | ||
| } | ||
|
|
||
| // 恢复上一个状态 | ||
| const prevJson = this.undoStack.pop(); | ||
| try { | ||
| await this._restoreState(prevJson); | ||
| } catch (err) { | ||
| console.error('[HistoryManager] undo 失败:', err); | ||
| } finally { | ||
| this._isRestoring = false; | ||
| this._notify(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 重做 | ||
| */ | ||
| async redo() { | ||
| if (!this.canRedo()) return; | ||
| if (this._isRestoring) return; // 防止恢复期间重复触发 | ||
|
|
||
| this._isRestoring = true; | ||
|
|
||
| // 保存当前状态到撤销栈 | ||
| const currentJson = this._cm.toJSON(); | ||
| if (currentJson) { | ||
| this.undoStack.push(currentJson); | ||
| } | ||
|
|
||
| // 恢复下一个状态 | ||
| const nextJson = this.redoStack.pop(); | ||
| try { | ||
| await this._restoreState(nextJson); | ||
| } catch (err) { | ||
| console.error('[HistoryManager] redo 失败:', err); | ||
| } finally { | ||
| this._isRestoring = false; | ||
| this._notify(); | ||
| } | ||
| } |
There was a problem hiding this comment.
当前的撤销/重做设计存在一个状态同步 Bug。saveState() 会将当前状态推入 undoStack。当调用 undo() 时,代码会将当前状态推入 redoStack,然后从 undoStack 弹出并恢复栈顶状态。\n\n由于 undoStack 的栈顶正是当前状态,这会导致第一次点击“撤销”时,恢复的依然是当前状态(即无任何变化,No-op),用户必须再次点击“撤销”才能真正回退到上一个状态。这不仅破坏了用户体验,还会导致撤销/重做队列的长度和状态错乱。\n\n解决方案:\n推荐采用标准的“当前状态 + 历史栈”模型:undoStack 只存储过去的状态,redoStack 只存储未来的状态,而当前活动状态保存在一个独立的变量(如 this.currentState)中。每次 saveState() 时,将上一次的 currentState 推入 undoStack,然后更新 currentState 为最新状态。
class HistoryManager {\n constructor(canvasManager, maxSteps = 30) {\n this._cm = canvasManager;\n this.undoStack = [];\n this.redoStack = [];\n this.maxSteps = maxSteps;\n this._enabled = true;\n this._isRestoring = false; // 防止恢复时触发保存\n this.currentState = null; // 记录当前活动状态\n }\n\n /**\n * 保存当前画布状态\n */\n saveState() {\n if (!this._enabled || this._isRestoring) return;\n if (!this._cm.canvas) return;\n\n const json = this._cm.toJSON();\n if (!json) return;\n\n if (this.currentState) {\n this.undoStack.push(this.currentState);\n // 限制栈大小\n if (this.undoStack.length > this.maxSteps) {\n this.undoStack.shift();\n }\n }\n\n this.currentState = json;\n this.redoStack = [];\n\n this._notify();\n }\n\n /**\n * 撤销\n */\n async undo() {\n if (!this.canUndo()) return;\n if (this._isRestoring) return; // 防止恢复期间重复触发\n\n this._isRestoring = true;\n\n if (this.currentState) {\n this.redoStack.push(this.currentState);\n }\n\n const prevJson = this.undoStack.pop();\n this.currentState = prevJson;\n\n try {\n await this._restoreState(prevJson);\n } catch (err) {\n console.error('[HistoryManager] undo 失败:', err);\n } finally {\n this._isRestoring = false;\n this._notify();\n }\n }\n\n /**\n * 重做\n */\n async redo() {\n if (!this.canRedo()) return;\n if (this._isRestoring) return; // 防止恢复期间重复触发\n\n this._isRestoring = true;\n\n if (this.currentState) {\n this.undoStack.push(this.currentState);\n }\n\n const nextJson = this.redoStack.pop();\n this.currentState = nextJson;\n\n try {\n await this._restoreState(nextJson);\n } catch (err) {\n console.error('[HistoryManager] redo 失败:', err);\n } finally {\n this._isRestoring = false;\n this._notify();\n }\n }| export default class HistoryStore { | ||
| /** | ||
| * @param {object} options | ||
| * @param {number} [options.maxSteps=30] | ||
| * @param {function} options.snapshotProvider - () => snapshot(纯 JSON,平台无关) | ||
| * @param {function} options.snapshotRestorer - (snapshot) => Promise<void> | ||
| * @param {import('./EventBus.js').EventBus} [options.eventBus] | ||
| */ | ||
| constructor({ maxSteps = 30, snapshotProvider, snapshotRestorer, eventBus = null } = {}) { | ||
| if (typeof snapshotProvider !== 'function') { | ||
| throw new Error('[HistoryStore] snapshotProvider 必须是函数'); | ||
| } | ||
| if (typeof snapshotRestorer !== 'function') { | ||
| throw new Error('[HistoryStore] snapshotRestorer 必须是函数'); | ||
| } | ||
|
|
||
| this._snapshotProvider = snapshotProvider; | ||
| this._snapshotRestorer = snapshotRestorer; | ||
| this._eventBus = eventBus; | ||
|
|
||
| this._undoStack = []; | ||
| this._redoStack = []; | ||
| this._maxSteps = maxSteps; | ||
| this._enabled = true; | ||
| this._isRestoring = false; | ||
| } | ||
|
|
||
| // ── 保存 ── | ||
|
|
||
| /** | ||
| * 保存当前快照到历史栈。 | ||
| */ | ||
| save() { | ||
| if (!this._enabled || this._isRestoring) return; | ||
|
|
||
| const snapshot = this._snapshotProvider(); | ||
| if (!snapshot) return; | ||
|
|
||
| this._undoStack.push(snapshot); | ||
|
|
||
| if (this._undoStack.length > this._maxSteps) { | ||
| this._undoStack.shift(); | ||
| } | ||
|
|
||
| this._redoStack = []; | ||
| this._notify(); | ||
| } | ||
|
|
||
| // ── 撤销 / 重做 ── | ||
|
|
||
| /** | ||
| * 撤销。 | ||
| * @returns {Promise<boolean>} 是否成功恢复 | ||
| */ | ||
| async undo() { | ||
| if (!this.canUndo()) return false; | ||
|
|
||
| this._isRestoring = true; | ||
|
|
||
| const current = this._snapshotProvider(); | ||
| if (current) { | ||
| this._redoStack.push(current); | ||
| } | ||
|
|
||
| const prev = this._undoStack.pop(); | ||
| let ok = false; | ||
| try { | ||
| await this._snapshotRestorer(prev); | ||
| ok = true; | ||
| } catch (err) { | ||
| console.error('[HistoryStore] 撤销恢复失败:', err); | ||
| } | ||
|
|
||
| this._isRestoring = false; | ||
| this._notify(); | ||
| return ok; | ||
| } | ||
|
|
||
| /** | ||
| * 重做。 | ||
| * @returns {Promise<boolean>} 是否成功恢复 | ||
| */ | ||
| async redo() { | ||
| if (!this.canRedo()) return false; | ||
|
|
||
| this._isRestoring = true; | ||
|
|
||
| const current = this._snapshotProvider(); | ||
| if (current) { | ||
| this._undoStack.push(current); | ||
| } | ||
|
|
||
| const next = this._redoStack.pop(); | ||
| let ok = false; | ||
| try { | ||
| await this._snapshotRestorer(next); | ||
| ok = true; | ||
| } catch (err) { | ||
| console.error('[HistoryStore] 重做恢复失败:', err); | ||
| } | ||
|
|
||
| this._isRestoring = false; | ||
| this._notify(); | ||
| return ok; | ||
| } | ||
|
|
There was a problem hiding this comment.
同 HistoryManager.js,此处的纯状态历史管理 HistoryStore 也存在相同的撤销/重做状态同步 Bug。建议采用相同的“当前状态 + 历史栈”模型进行重构,以避免撤销操作变成无意义的“恢复当前状态”No-op 行为。
export default class HistoryStore {\n /**\n * @param {object} options\n * @param {number} [options.maxSteps=30]\n * @param {function} options.snapshotProvider - () => snapshot(纯 JSON,平台无关)\n * @param {function} options.snapshotRestorer - (snapshot) => Promise<void>\n * @param {import('./EventBus.js').EventBus} [options.eventBus]\n */\n constructor({ maxSteps = 30, snapshotProvider, snapshotRestorer, eventBus = null } = {}) {\n if (typeof snapshotProvider !== 'function') {\n throw new Error('[HistoryStore] snapshotProvider 必须是函数');\n }\n if (typeof snapshotRestorer !== 'function') {\n throw new Error('[HistoryStore] snapshotRestorer 必须是函数');\n }\n\n this._snapshotProvider = snapshotProvider;\n this._snapshotRestorer = snapshotRestorer;\n this._eventBus = eventBus;\n\n this._undoStack = [];\n this._redoStack = [];\n this._maxSteps = maxSteps;\n this._enabled = true;\n this._isRestoring = false;\n this._currentState = null; // 记录当前活动状态\n }\n\n // ── 保存 ──\n\n /**\n * 保存当前快照到历史栈。\n */\n save() {\n if (!this._enabled || this._isRestoring) return;\n\n const snapshot = this._snapshotProvider();\n if (!snapshot) return;\n\n if (this._currentState) {\n this._undoStack.push(this._currentState);\n if (this._undoStack.length > this._maxSteps) {\n this._undoStack.shift();\n }\n }\n\n this._currentState = snapshot;\n this._redoStack = [];\n this._notify();\n }\n\n // ── 撤销 / 重做 ──\n\n /**\n * 撤销。\n * @returns {Promise<boolean>} 是否成功恢复\n */\n async undo() {\n if (!this.canUndo()) return false;\n\n this._isRestoring = true;\n\n if (this._currentState) {\n this._redoStack.push(this._currentState);\n }\n\n const prev = this._undoStack.pop();\n this._currentState = prev;\n let ok = false;\n try {\n await this._snapshotRestorer(prev);\n ok = true;\n } catch (err) {\n console.error('[HistoryStore] 撤销恢复失败:', err);\n }\n\n this._isRestoring = false;\n this._notify();\n return ok;\n }\n\n /**\n * 重做。\n * @returns {Promise<boolean>} 是否成功恢复\n */\n async redo() {\n if (!this.canRedo()) return false;\n\n this._isRestoring = true;\n\n if (this._currentState) {\n this._undoStack.push(this._currentState);\n }\n\n const next = this._redoStack.pop();\n this._currentState = next;\n let ok = false;\n try {\n await this._snapshotRestorer(next);\n ok = true;\n } catch (err) {\n console.error('[HistoryStore] 重做恢复失败:', err);\n }\n\n this._isRestoring = false;\n this._notify();\n return ok;\n }| @@ -0,0 +1,459 @@ | |||
| import eventBus from './EventBus.js'; | |||
| toJSON() { | ||
| if (!this.canvas) return null; | ||
| const json = this.canvas.toJSON([ | ||
| 'clipPath', | ||
| 'filters', | ||
| 'id', | ||
| 'selectable', | ||
| 'evented', | ||
| 'absolutePositioned', | ||
| 'inverted', | ||
| 'objectCaching', | ||
| 'strokeLineCap', | ||
| 'strokeLineJoin', | ||
| '_layerName', | ||
| '_layerNameAuto', | ||
| '_layerBaseName', | ||
| '_layerKind', | ||
| '_layerColorPresetName', | ||
| '_layerWidthPresetName', | ||
| '_layerPresetName', | ||
| '_mosaicDynamic', | ||
| '_mosaicMode', | ||
| '_mosaicSize', | ||
| '_mosaicBlurRadius', | ||
| '_mosaicWidth', | ||
| '_mosaicHeight', | ||
| '_mosaicMaskType', | ||
| '_mosaicBrushPoints', | ||
| '_mosaicBrushSize', | ||
| '_mosaicLassoPoints', | ||
| '_originalImage', | ||
| ]); | ||
| // 手动序列化 canvas.clipPath(Fabric.js canvas.toJSON 不包含此属性) | ||
| if (this.canvas.clipPath) { | ||
| json._canvasClipPath = this.canvas.clipPath.toJSON(CLIP_PATH_SERIALIZED_PROPS); | ||
| } | ||
| return json; | ||
| } |
There was a problem hiding this comment.
在画布序列化为 JSON 时,画布上可能存在一些临时的辅助/预览对象(例如 MosaicModule 中的 _brushPreview、BrushModule 中的 _cursorPreview 等)。这些临时对象虽然设置了 excludeFromHistory 等自定义属性,但 Fabric.js 默认的 canvas.toJSON() 并不会过滤它们,导致它们被序列化进历史记录中。在撤销/重做或恢复状态时,这些临时预览圈会被错误地恢复为画布上的永久对象。\n\n建议在序列化前,临时过滤掉这些带有 excludeFromHistory、excludeFromLayer 或 excludeFromExport 标记的临时对象,序列化后再恢复,以保证历史记录的纯净。
toJSON() {\n if (!this.canvas) return null;\n \n // 临时过滤掉辅助/预览等无需序列化的对象\n const originalObjects = this.canvas._objects;\n this.canvas._objects = originalObjects.filter(\n obj => !obj.excludeFromHistory && !obj.excludeFromLayer && !obj.excludeFromExport\n );\n\n const json = this.canvas.toJSON([\n 'clipPath',\n 'filters',\n 'id',\n 'selectable',\n 'evented',\n 'absolutePositioned',\n 'inverted',\n 'objectCaching',\n 'strokeLineCap',\n 'strokeLineJoin',\n '_layerName',\n '_layerNameAuto',\n '_layerBaseName',\n '_layerKind',\n '_layerColorPresetName',\n '_layerWidthPresetName',\n '_layerPresetName',\n '_mosaicDynamic',\n '_mosaicMode',\n '_mosaicSize',\n '_mosaicBlurRadius',\n '_mosaicWidth',\n '_mosaicHeight',\n '_mosaicMaskType',\n '_mosaicBrushPoints',\n '_mosaicBrushSize',\n '_mosaicLassoPoints',\n '_originalImage',\n ]);\n\n // 恢复原始对象数组\n this.canvas._objects = originalObjects;\n\n // 手动序列化 canvas.clipPath(Fabric.js canvas.toJSON 不包含此属性)\n if (this.canvas.clipPath) {\n json._canvasClipPath = this.canvas.clipPath.toJSON(CLIP_PATH_SERIALIZED_PROPS);\n }\n return json;\n }| const _loadSystemFonts = () => { | ||
| const families = new Map(); | ||
|
|
||
| _getSystemFontFiles().forEach((filePath) => { | ||
| try { | ||
| _readFontFamilies(filePath).forEach((family) => { | ||
| const key = _normalizeFontName(family); | ||
| if (key && !families.has(key)) families.set(key, family); | ||
| }); | ||
| } catch (e) { | ||
| // 个别系统字体可能是旧格式或权限受限,跳过不影响其它字体。 | ||
| } | ||
| }); | ||
|
|
||
| return Array.from(families.values()).sort((a, b) => ( | ||
| a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' }) | ||
| )); | ||
| }; |
There was a problem hiding this comment.
在 _loadSystemFonts 中,代码通过同步的文件系统 API(如 fs.readdirSync、fs.readFileSync)递归扫描系统字体目录,并解析二进制字体文件。虽然在 getSystemFontsAsync 中使用了 setTimeout(..., 0) 将其推迟到下一个事件循环,但由于 Node.js 在 Electron 渲染进程中与 UI 共享同一个主线程,一旦 _loadSystemFonts 开始执行,它依然会完全阻塞 UI 线程数秒钟,导致界面彻底卡死、动画停滞、无法交互。\n\n建议:\n1. 将同步的 fs 操作改写为基于 Promise 的异步操作(如 fs.promises.readdir、fs.promises.readFile),或者:\n2. 使用 Electron 的 utilityProcess、child_process 或 Web Workers 将耗时的字体扫描和解析任务彻底移出主线程,在后台子进程中运行,完成后再通过 IPC 或 MessageChannel 传回结果。这才能实现真正的“非阻塞”异步加载。
1. 新增功能:
- 图形工具新增平行四边形绘制
- 文字属性面板新增删除线复选框支持
2. 问题修复:
- 修复部分编辑操作首次撤销无反应的问题
- 修复打开新图片后撤销可能恢复上一张图片的问题
- 修复JPEG/WebP保存时实际写入PNG数据的问题
- 修复ZTools端选中文字后缺少描边位置设置的问题
- 修复裁剪撤销/重做时裁剪范围恢复不正确的问题
- 修复清除所有马赛克后无法撤销恢复的问题
3. 代码优化:
- 抽离公共工具函数到helpers模块,消除重复代码
- 重构渲染、边界计算、颜色处理等通用逻辑
- 完善组件销毁逻辑,正确解绑事件监听
- 优化历史栈去重和恢复逻辑
- 重构导出模块,支持宿主原生保存对话框
- 统一属性面板的转义工具函数使用
- 调整文件扩展名配置格式
新增
图形工具新增三角形和双箭头图形
文字描边新增位置参数,支持外部、中间、内部三种描边位置
修复
修复橡皮擦工具激活时切换图层后,橡皮擦仍作用在原图层的问题
修复首次进入文字工具时界面卡顿数秒的问题,改为异步加载系统字体列表
修复撤销/重做时历史记录损坏导致画布状态异常的问题
修复切换/停用工具时图层被意外解锁的问题