From a1e4ce93e820bfa9be0f7d5f913c59c3909c9660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8A=B9=E9=9C=B2=E8=8C=B6=E6=9F=92?= Date: Mon, 29 Jun 2026 15:42:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?release(image-toolbox):=20v2.2.2=20?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E7=89=88=E6=9C=AC=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增功能: - 图形工具新增平行四边形绘制 - 文字属性面板新增删除线复选框支持 2. 问题修复: - 修复部分编辑操作首次撤销无反应的问题 - 修复打开新图片后撤销可能恢复上一张图片的问题 - 修复JPEG/WebP保存时实际写入PNG数据的问题 - 修复ZTools端选中文字后缺少描边位置设置的问题 - 修复裁剪撤销/重做时裁剪范围恢复不正确的问题 - 修复清除所有马赛克后无法撤销恢复的问题 3. 代码优化: - 抽离公共工具函数到helpers模块,消除重复代码 - 重构渲染、边界计算、颜色处理等通用逻辑 - 完善组件销毁逻辑,正确解绑事件监听 - 优化历史栈去重和恢复逻辑 - 重构导出模块,支持宿主原生保存对话框 - 统一属性面板的转义工具函数使用 - 调整文件扩展名配置格式 --- .../Image-Toolbox/core/src/CanvasManager.js | 48 ++++-- .../Image-Toolbox/core/src/HistoryManager.js | 29 +++- plugins/Image-Toolbox/core/src/ToolManager.js | 5 +- plugins/Image-Toolbox/core/src/index.js | 12 -- .../core/src/modules/BrushModule.js | 38 ++-- .../core/src/modules/CropModule.js | 55 +----- .../core/src/modules/EraserModule.js | 3 +- .../core/src/modules/ExportModule.js | 26 ++- .../core/src/modules/MosaicModule.js | 162 ++++++------------ .../core/src/modules/SelectModule.js | 12 +- .../core/src/modules/ShapeModule.js | 58 ++++--- .../core/src/modules/TextModule.js | 20 ++- .../Image-Toolbox/core/src/updateRecords.js | 21 +++ .../Image-Toolbox/core/src/utils/helpers.js | 133 ++++++++++++++ plugins/Image-Toolbox/core/src/utils/host.js | 99 +---------- plugins/Image-Toolbox/plugin.json | 14 +- .../src/adapters/host/ZtoolsHostAdapter.js | 14 ++ plugins/Image-Toolbox/src/index.js | 37 +++- plugins/Image-Toolbox/src/ui/AccountPage.js | 22 +-- plugins/Image-Toolbox/src/ui/LayerPanel.js | 96 +++++------ plugins/Image-Toolbox/src/ui/OptionsBar.js | 24 ++- plugins/Image-Toolbox/src/ui/PropertyPanel.js | 69 +++++--- plugins/Image-Toolbox/src/ui/SidePanelTabs.js | 14 +- plugins/Image-Toolbox/src/ui/StatusBar.js | 40 +++-- plugins/Image-Toolbox/src/ui/Toolbar.js | 36 ++-- 25 files changed, 592 insertions(+), 495 deletions(-) create mode 100644 plugins/Image-Toolbox/core/src/utils/helpers.js diff --git a/plugins/Image-Toolbox/core/src/CanvasManager.js b/plugins/Image-Toolbox/core/src/CanvasManager.js index e2e5bc5c..78ea4560 100644 --- a/plugins/Image-Toolbox/core/src/CanvasManager.js +++ b/plugins/Image-Toolbox/core/src/CanvasManager.js @@ -23,6 +23,8 @@ class CanvasManager { this.zoomLevel = 1; this._historySaveTimer = null; this._isCropMode = false; + this._resizeObserver = null; + this._boundResize = null; } // ── 生命周期 ── @@ -48,6 +50,14 @@ class CanvasManager { * 销毁画布,释放内存 */ destroy() { + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + if (this._boundResize) { + window.removeEventListener('resize', this._boundResize); + this._boundResize = null; + } if (this._historySaveTimer) { clearTimeout(this._historySaveTimer); } @@ -293,10 +303,12 @@ class CanvasManager { 'objectCaching', 'strokeLineCap', 'strokeLineJoin', + '_strokePosition', '_layerName', '_layerNameAuto', '_layerBaseName', '_layerKind', + '_layerShapeType', '_layerColorPresetName', '_layerWidthPresetName', '_layerPresetName', @@ -326,31 +338,34 @@ class CanvasManager { return; } - // 提取 canvas 级别的 clipPath + // 提取 canvas 级别的 clipPath,避免修改历史栈里的原始快照对象。 const canvasClipPathData = json._canvasClipPath; - delete json._canvasClipPath; + const snapshot = { ...json }; + delete snapshot._canvasClipPath; + + this.canvas.loadFromJSON(snapshot, () => { + const finishRestore = () => { + // 恢复 originalImage 引用 + const objs = this.canvas.getObjects(); + this.originalImage = objs.find(o => o.type === 'image' && o._originalImage) || objs[0]; + this.canvas.renderAll(); + eventBus.emit('canvas:restored'); + resolve(); + }; - this.canvas.loadFromJSON(json, () => { - // 恢复 canvas.clipPath if (canvasClipPathData) { fabric.util.enlivenObjects([canvasClipPathData], (objects) => { this.canvas.clipPath = objects[0] || null; if (this.canvas.clipPath) { this.canvas.clipPath.absolutePositioned = true; } - this.canvas.renderAll(); + finishRestore(); }); } else { // 快照中没有 clipPath → 清除画布上已有的(撤消裁切的关键) this.canvas.clipPath = null; + finishRestore(); } - - // 恢复 originalImage 引用 - const objs = this.canvas.getObjects(); - this.originalImage = objs.find(o => o.type === 'image' && o._originalImage) || objs[0]; - this.canvas.renderAll(); - eventBus.emit('canvas:restored'); - resolve(); }); }); } @@ -441,17 +456,16 @@ class CanvasManager { }); // 窗口大小变化 - window.addEventListener('resize', () => { - this._updateCanvasSize(); - }); + this._boundResize = () => this._updateCanvasSize(); + window.addEventListener('resize', this._boundResize); // 使用 ResizeObserver 监听容器变化 const container = this.canvas.wrapperEl?.parentElement; if (container && window.ResizeObserver) { - const observer = new ResizeObserver(() => { + this._resizeObserver = new ResizeObserver(() => { this._updateCanvasSize(); }); - observer.observe(container); + this._resizeObserver.observe(container); } } } diff --git a/plugins/Image-Toolbox/core/src/HistoryManager.js b/plugins/Image-Toolbox/core/src/HistoryManager.js index 1cc6471a..d4fb3315 100644 --- a/plugins/Image-Toolbox/core/src/HistoryManager.js +++ b/plugins/Image-Toolbox/core/src/HistoryManager.js @@ -24,6 +24,9 @@ class HistoryManager { const json = this._cm.toJSON(); if (!json) return; + const last = this.undoStack[this.undoStack.length - 1]; + if (last && this._isSameSnapshot(last, json)) return; + this.undoStack.push(json); // 限制栈大小 @@ -46,14 +49,23 @@ class HistoryManager { this._isRestoring = true; - // 保存当前状态到重做栈 const currentJson = this._cm.toJSON(); + let prevJson = this.undoStack.pop(); + + while (prevJson && currentJson && this._isSameSnapshot(prevJson, currentJson) && this.undoStack.length > 0) { + prevJson = this.undoStack.pop(); + } + + if (!prevJson || (currentJson && this._isSameSnapshot(prevJson, currentJson))) { + this._isRestoring = false; + this._notify(); + return; + } + if (currentJson) { this.redoStack.push(currentJson); } - // 恢复上一个状态 - const prevJson = this.undoStack.pop(); try { await this._restoreState(prevJson); } catch (err) { @@ -137,6 +149,17 @@ class HistoryManager { undoCount: this.undoStack.length, }); } + + _isSameSnapshot(a, b) { + if (a === b) return true; + if (!a || !b) return false; + + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch (err) { + return false; + } + } } export default HistoryManager; diff --git a/plugins/Image-Toolbox/core/src/ToolManager.js b/plugins/Image-Toolbox/core/src/ToolManager.js index 3f23ce27..a30d5a12 100644 --- a/plugins/Image-Toolbox/core/src/ToolManager.js +++ b/plugins/Image-Toolbox/core/src/ToolManager.js @@ -21,7 +21,7 @@ class ToolManager { * @param {import('./HistoryManager.js').default} historyManager * @param {object} [options] * @param {Array} [options.tools] - 外部注入的工具定义列表 - * @param {import('./interfaces/HostAdapter.js').default} [options.host] + * @param {object} [options.host] */ constructor(canvasManager, historyManager, options = {}) { this._cm = canvasManager; @@ -45,7 +45,7 @@ class ToolManager { /** * 注入 host adapter。 - * @param {import('./interfaces/HostAdapter.js').default} host + * @param {object} host */ setHost(host) { this._host = host; @@ -221,6 +221,7 @@ class ToolManager { destroy() { Object.values(this._modules).forEach(m => { if (m.deactivate) m.deactivate(); + if (m.destroy) m.destroy(); }); this._modules = {}; this._currentTool = null; diff --git a/plugins/Image-Toolbox/core/src/index.js b/plugins/Image-Toolbox/core/src/index.js index 6d041dfe..049c0311 100644 --- a/plugins/Image-Toolbox/core/src/index.js +++ b/plugins/Image-Toolbox/core/src/index.js @@ -1,17 +1,5 @@ // ═══════════════════════════════════════════════════════ // @img-toolbox/core — 公共无环境依赖入口 -// 这里只导出平台无关的状态和接口;Fabric/browser 运行时见 ./runtime/fabric.js // ═══════════════════════════════════════════════════════ -// ── 基础设施 ── export { default as eventBus, EventBus } from './EventBus.js'; -export { default as EditorContext } from './EditorContext.js'; - -// ── 状态存储 ── -export { default as HistoryStore } from './HistoryStore.js'; -export { default as LayerStore } from './LayerStore.js'; -export { default as ToolRegistry } from './ToolRegistry.js'; - -// ── 接口 ── -export { default as HostAdapter } from './interfaces/HostAdapter.js'; -export { default as EditorEngineAdapter } from './interfaces/EditorEngineAdapter.js'; diff --git a/plugins/Image-Toolbox/core/src/modules/BrushModule.js b/plugins/Image-Toolbox/core/src/modules/BrushModule.js index 7b81fe73..6fb5fa2b 100644 --- a/plugins/Image-Toolbox/core/src/modules/BrushModule.js +++ b/plugins/Image-Toolbox/core/src/modules/BrushModule.js @@ -1,5 +1,6 @@ import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, escapeAttr, normalizeColor, requestRender as _requestRender } from '../utils/helpers.js'; /** * 画笔模块 - 使用 Fabric 自由绘制生成可编辑的 path 图层。 @@ -61,7 +62,7 @@ class BrushModule extends BaseModule { } setColor(color) { - this.options.color = this._normalizeColor(color, this.options.color); + this.options.color = normalizeColor(color, this.options.color); this._applyBrushOptions(); this._updateCursorPreviewStyle(); } @@ -95,7 +96,7 @@ class BrushModule extends BaseModule { } getOptionsBarHTML() { - const color = this._normalizeColor(this.options.color); + const color = normalizeColor(this.options.color); const width = this.options.width; return ` @@ -121,7 +122,7 @@ class BrushModule extends BaseModule {
画笔工具
- +
@@ -257,13 +258,7 @@ class BrushModule extends BaseModule { } _requestRender() { - const canvas = this.canvasManager.canvas; - if (!canvas) return; - if (typeof canvas.requestRenderAll === 'function') { - canvas.requestRenderAll(); - } else { - canvas.renderAll(); - } + _requestRender(this.canvasManager.canvas); } _ensureBrush() { @@ -284,7 +279,7 @@ class BrushModule extends BaseModule { } _getColorPresetButton(preset, label, color, currentColor) { - const normalized = this._normalizeColor(color); + const normalized = normalizeColor(color); const active = currentColor === normalized ? ' active' : ''; return `
+
+ + +
+
+ + +
@@ -217,6 +224,10 @@ class PropertyPanel {
+
+ + +
+ ${value} +
+ `; + }).join(''); + + const scopeTitle = this._getFilterScope() === 'all' + ? `调色 (${this._getTargetImages().length} 个图片图层)` + : '调色'; + + return ` + ${scopeControl} +
${scopeTitle}
+ ${sliders} +
+ +
+ `; + } + + // ── 滤镜预设点击(顶部预设栏) ── + applyPreset(presetName) { + if (!presetName || !presetName.startsWith('filter-')) return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + this.history?.saveState?.(); + targets.forEach(image => applyFilterPreset(image, presetName)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + eventBus.emit('canvas:objectModified', targets[0]); + } + + // ── 调色滑块变化(属性面板) ── + onToolPropertyChange(prop, value, { eventType } = {}) { + if (prop === 'filterScope') { + if (eventType !== 'change') return false; + this.options.filterScope = value === 'all' ? 'all' : 'current'; + eventBus.emit('tool:propertiesChanged'); + return true; + } + + if (!prop || !prop.startsWith('filter:')) return false; + + const targets = this._getTargetImages(); + if (targets.length === 0) return false; + + const type = prop.slice('filter:'.length); + const uiValue = parseInt(value, 10); + if (!Number.isFinite(uiValue)) return false; + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + + if (eventType === 'change') { + eventBus.emit('canvas:objectModified', targets[0]); + } + return false; // 不刷新属性面板(避免滑块失焦) + } + + // ── 重置按钮(属性面板) ── + onToolPropertyAction(action, { eventType } = {}) { + if (action !== 'filter-reset') return; + if (eventType !== 'click') return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + this.history?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + eventBus.emit('canvas:objectModified', targets[0]); + } + + _getScopeControlHTML() { + const scope = this._getFilterScope(); + return ` +
作用范围
+
+ + +
+ `; + } + + _getFilterScope() { + return this.options.filterScope === 'all' ? 'all' : 'current'; + } + + _getTargetImages() { + if (this._getFilterScope() === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveImage(); + return active ? [active] : []; + } + + _getReferenceImage() { + if (this._getFilterScope() === 'all') { + return this._getActiveImage() || this._getAllImages()[0] || null; + } + + return this._getActiveImage(); + } + + _getAllImages() { + const canvas = this.canvasManager?.canvas; + if (!canvas) return []; + + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + // ── 获取当前选中的图片图层 ── + _getActiveImage() { + const active = this.canvasManager?.getActiveObject?.(); + if (!active || active.type === 'activeSelection') return null; + return active.type === 'image' ? active : null; + } +} + +export default ColorModule; diff --git a/plugins/Image-Toolbox/core/src/modules/EraserModule.js b/plugins/Image-Toolbox/core/src/modules/EraserModule.js index 2016832b..11919d31 100644 --- a/plugins/Image-Toolbox/core/src/modules/EraserModule.js +++ b/plugins/Image-Toolbox/core/src/modules/EraserModule.js @@ -228,6 +228,10 @@ class EraserModule extends BaseModule { objectCaching: false, }); + if (typeof target._layerLocked === 'boolean') { + image._layerLocked = target._layerLocked; + } + if (layerName) { image._layerName = layerName; image._layerNameAuto = false; diff --git a/plugins/Image-Toolbox/core/src/modules/SelectModule.js b/plugins/Image-Toolbox/core/src/modules/SelectModule.js index 0c7439b3..451876af 100644 --- a/plugins/Image-Toolbox/core/src/modules/SelectModule.js +++ b/plugins/Image-Toolbox/core/src/modules/SelectModule.js @@ -1,4 +1,5 @@ -import BaseModule from './BaseModule.js'; +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; import { requestRender as _requestRender } from '../utils/helpers.js'; /** @@ -15,8 +16,8 @@ class SelectModule extends BaseModule { canvas.selection = true; canvas.defaultCursor = 'default'; - // 恢复所有对象的交互性,确保属性面板可以编辑(而非只读) - this._restoreObjectsInteractivity(); + // 启用未锁定图层的交互性,确保新建图层切回移动/框选后可选中。 + this._enableEditableLayerInteractivity(); } deactivate() { @@ -121,12 +122,15 @@ class SelectModule extends BaseModule { const canvas = this.canvasManager.canvas; const active = canvas?.getActiveObject(); if (!active) return []; + const originalImage = this.canvasManager.originalImage; if (active.type === 'activeSelection' && typeof active.getObjects === 'function') { - return active.getObjects().filter(obj => !obj.excludeFromHistory); + // 多选时排除背景,避免框选覆盖层时误带上整张底图;单独选中背景仍可变换。 + return active.getObjects().filter(obj => !obj.excludeFromHistory && obj !== originalImage); } - return active.excludeFromHistory ? [] : [active]; + if (active.excludeFromHistory) return []; + return [active]; } _getCommonAngle(targets) { diff --git a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js index 4f603266..3623278b 100644 --- a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js +++ b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js @@ -60,10 +60,16 @@ class ShapeModule extends BaseModule { canvas.discardActiveObject(); canvas.defaultCursor = 'crosshair'; - canvas.upperCanvasEl?.addEventListener('mousedown', this._boundMouseDown); - canvas.upperCanvasEl?.addEventListener('mousemove', this._boundMouseMove); - canvas.upperCanvasEl?.addEventListener('mouseup', this._boundMouseUp); - canvas.upperCanvasEl?.addEventListener('mouseout', this._boundMouseOut); + // 禁用 Fabric.js 的目标查找,防止拖选绘制时意外移动图层 + canvas.skipTargetFind = true; + + // 使用 Fabric.js 合成事件(与其他模块一致), + // 确保在 Fabric 内部处理完事件后才接收,避免原生 DOM 事件与 + // Fabric.js 内部 __onMouseDown 同时处理同一事件导致的状态冲突 + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + canvas.on('mouse:out', this._boundMouseOut); eventBus.emit('module:activated', 'shape'); } @@ -71,10 +77,12 @@ class ShapeModule extends BaseModule { deactivate() { const canvas = this.canvasManager.canvas; if (canvas) { - canvas.upperCanvasEl?.removeEventListener('mousedown', this._boundMouseDown); - canvas.upperCanvasEl?.removeEventListener('mousemove', this._boundMouseMove); - canvas.upperCanvasEl?.removeEventListener('mouseup', this._boundMouseUp); - canvas.upperCanvasEl?.removeEventListener('mouseout', this._boundMouseOut); + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + canvas.off('mouse:out', this._boundMouseOut); + // 重置 skipTargetFind,避免影响后续工具(如 SelectModule) + canvas.skipTargetFind = false; this._removePreviewShape(); this._isDrawing = false; this._startPoint = null; @@ -283,12 +291,14 @@ class ShapeModule extends BaseModule { } _onMouseDown(e) { - if (e.button !== 0) return; + // Fabric.js 合成事件:e.e 是原生 DOM 事件,e.pointer 是画布坐标 + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; const canvas = this.canvasManager.canvas; if (!canvas) return; - const pointer = canvas.getPointer(e); + const pointer = e.pointer || canvas.getPointer(nativeEvent); this._isDrawing = true; this._startPoint = { x: pointer.x, y: pointer.y }; this.history.saveState(); @@ -301,7 +311,7 @@ class ShapeModule extends BaseModule { const canvas = this.canvasManager.canvas; if (!canvas) return; - const pointer = canvas.getPointer(e); + const pointer = e.pointer || canvas.getPointer(e.e); const endPoint = { x: pointer.x, y: pointer.y }; // 移除旧的预览形状 @@ -322,7 +332,7 @@ class ShapeModule extends BaseModule { const canvas = this.canvasManager.canvas; if (!canvas) return; - const pointer = canvas.getPointer(e); + const pointer = e.pointer || canvas.getPointer(e.e); const endPoint = { x: pointer.x, y: pointer.y }; // 移除预览形状 diff --git a/plugins/Image-Toolbox/core/src/updateRecords.js b/plugins/Image-Toolbox/core/src/updateRecords.js index cd1d0f62..351140c6 100644 --- a/plugins/Image-Toolbox/core/src/updateRecords.js +++ b/plugins/Image-Toolbox/core/src/updateRecords.js @@ -1,4 +1,26 @@ export const updateRecords = [ + { + version: '2.3', + date: '2026-07-09', + changes: { + added: [ + { text: '新增「调色」工具,支持滤镜预设以及亮度、对比度、饱和度、色相、模糊等参数调整', platforms: null } + ], + fixed: [ + { text: '修复图形工具拖选绘制时部分情况下意外移动图层的问题', platforms: null }, + { text: '修复添加文字后第一次撤销无反应的问题', platforms: null }, + { text: '修复撤销/重做时偶尔出现画布状态异常的问题', platforms: null }, + { text: '修复切换或停用工具后,已锁定图层可能被意外解锁的问题', platforms: null }, + { text: '修复使用画笔、图形、马赛克等工具新建图层后,切回移动/框选工具无法直接选中图层的问题', platforms: null }, + { text: '修复移动/框选工具选中背景图片后,无法调整宽高、位置、旋转或拖拽变换的问题', platforms: null }, + { text: '修复使用文字工具编辑文字时,按快捷键会意外切换工具的问题', platforms: null } + ], + improved: [], + adjusted: [ + ], + removed: [] + } + }, { version: '2.2.2', date: '2026-06-29', diff --git a/plugins/Image-Toolbox/core/src/utils/filters.js b/plugins/Image-Toolbox/core/src/utils/filters.js new file mode 100644 index 00000000..05d42ff6 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/filters.js @@ -0,0 +1,296 @@ +/** + * 滤镜工具 — 统一管理 fabric.Image 的滤镜读写与预设 + * + * Fabric.js 5.3.0 提供以下常用滤镜(均挂在 fabric.Image.filters 命名空间下): + * Brightness 亮度 -1 ~ 1(0 为原样) + * Contrast 对比度 -1 ~ 1 + * Saturation 饱和度 -1 ~ 1 + * HueRotation 色相旋转 弧度(0 ~ 2π,可取负值) + * Blur 高斯模糊 0 ~ 1 + * Sepia 棕褐 0 ~ 1(值越大越深) + * Grayscale 灰度 0 ~ 1(1 为完全黑白) + * Vibrance 自然饱和 -1 ~ 1 + * + * 每个 fabric.Image 实例都有一个 `filters` 数组,滤镜对象按顺序应用。 + * 本模块约定:同一类型的滤镜在数组中只保留一个实例,覆盖式更新。 + */ + +import { clamp } from './helpers.js'; + +/** 滤镜类型 → fabric.Image.filters 类名 映射 */ +const FILTER_CLASS_NAME = { + brightness: 'Brightness', + contrast: 'Contrast', + saturation: 'Saturation', + hue: 'HueRotation', + blur: 'Blur', + sepia: 'Sepia', + grayscale: 'Grayscale', + vibrance: 'Vibrance', +}; + +/** 滤镜类型 → 该滤镜在 fabric 中的属性名 */ +const FILTER_ATTR_NAME = { + brightness: 'brightness', + contrast: 'contrast', + saturation: 'saturation', + hue: 'rotation', + blur: 'blur', + sepia: 'sepia', + grayscale: 'grayscale', + vibrance: 'vibrance', +}; + +/** + * UI 滑块范围定义(百分比或度数) + * 仅这 5 项在属性面板暴露滑块;sepia / grayscale 仅通过预设触发 + */ +export const FILTER_RANGES = { + brightness: { min: -100, max: 100, step: 1, default: 0 }, + contrast: { min: -100, max: 100, step: 1, default: 0 }, + saturation: { min: -100, max: 100, step: 1, default: 0 }, + hue: { min: -180, max: 180, step: 1, default: 0 }, + blur: { min: 0, max: 100, step: 1, default: 0 }, +}; + +/** 仅通过预设使用的滤镜类型 → 默认值(用于应用预设时重置) */ +const PRESET_ONLY_DEFAULTS = { + sepia: 0, + grayscale: 0, + vibrance: 0, +}; + +/** 应用预设时需要重置的全部滤镜类型 */ +const ALL_RESETTABLE_TYPES = [...Object.keys(FILTER_RANGES), ...Object.keys(PRESET_ONLY_DEFAULTS)]; + +/** + * 将 UI 滑块值(百分比或度数)换算为 fabric 滤镜实际值 + * @param {string} type + * @param {number} uiValue + * @returns {number} + */ +export function uiToFilterValue(type, uiValue) { + switch (type) { + case 'brightness': + case 'contrast': + case 'saturation': + case 'sepia': + case 'grayscale': + case 'vibrance': + // 0~100 → 0~1(brightness/contrast/saturation/vibrance 允许 -100~100) + return clamp(uiValue, -100, 100) / 100; + case 'hue': + // -180°~180° → -π~π 弧度 + return clamp(uiValue, -180, 180) * Math.PI / 180; + case 'blur': + return clamp(uiValue, 0, 100) / 100; + default: + return uiValue; + } +} + +/** + * 将 fabric 滤镜值换算为 UI 滑块值 + * @param {string} type + * @param {number} filterValue + * @returns {number} + */ +export function filterToUiValue(type, filterValue) { + switch (type) { + case 'brightness': + case 'contrast': + case 'saturation': + case 'sepia': + case 'grayscale': + case 'vibrance': + return Math.round((filterValue || 0) * 100); + case 'hue': + return Math.round((filterValue || 0) * 180 / Math.PI); + case 'blur': + return Math.round((filterValue || 0) * 100); + default: + return Math.round(filterValue || 0); + } +} + +function getFilterClass(type) { + const className = FILTER_CLASS_NAME[type]; + if (!className) return null; + return (typeof fabric !== 'undefined' && fabric.Image && fabric.Image.filters) + ? fabric.Image.filters[className] + : null; +} + +/** + * 读取图片上某类滤镜的当前值(无则返回默认值) + * @param {fabric.Image} image + * @param {string} type + * @returns {number} UI 滑块值 + */ +export function getFilterUiValue(image, type) { + if (!image || !Array.isArray(image.filters)) { + return FILTER_RANGES[type] ? FILTER_RANGES[type].default : 0; + } + + const className = FILTER_CLASS_NAME[type]; + const filter = image.filters.find(f => f && f.type === className); + if (!filter) return FILTER_RANGES[type] ? FILTER_RANGES[type].default : 0; + + const attr = FILTER_ATTR_NAME[type]; + return filterToUiValue(type, filter[attr]); +} + +/** + * 设置/替换图片上的某类滤镜 + * @param {fabric.Image} image + * @param {string} type + * @param {number} uiValue + */ +export function setFilter(image, type, uiValue) { + if (!image) return; + + if (!Array.isArray(image.filters)) { + image.filters = []; + } + + const FilterClass = getFilterClass(type); + if (!FilterClass) return; + + // 移除同类型旧滤镜 + const className = FILTER_CLASS_NAME[type]; + image.filters = image.filters.filter(f => !(f && f.type === className)); + + // 默认值不写入(避免无效滤镜堆积) + const defaultValue = FILTER_RANGES[type] + ? FILTER_RANGES[type].default + : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0); + if (uiValue === defaultValue) { + applyFilters(image); + return; + } + + const filterValue = uiToFilterValue(type, uiValue); + const attr = FILTER_ATTR_NAME[type]; + const filter = new FilterClass({ [attr]: filterValue }); + image.filters.push(filter); + applyFilters(image); +} + +/** + * 清除图片上的所有滤镜 + * @param {fabric.Image} image + */ +export function clearFilters(image) { + if (!image) return; + image.filters = []; + applyFilters(image); +} + +/** + * 应用滤镜到图片(触发重新渲染) + * @param {fabric.Image} image + */ +function applyFilters(image) { + if (typeof image.applyFilters === 'function') { + image.applyFilters(); + } +} + +/** + * 判断图片是否完全无滤镜 + * @param {fabric.Image} image + * @returns {boolean} + */ +export function hasNoFilters(image) { + if (!image || !Array.isArray(image.filters)) return true; + return image.filters.length === 0; +} + +// ── 预设 ── + +/** + * 滤镜预设定义 + * 每个预设的 filters 字段为 { type: uiValue } 映射,未列出的类型重置为默认值 + */ +export const FILTER_PRESETS = [ + { + preset: 'filter-original', + label: '原图', + filters: {}, + }, + { + preset: 'filter-warm', + label: '暖色', + filters: { brightness: 6, saturation: 18, hue: -12 }, + }, + { + preset: 'filter-cool', + label: '冷色', + filters: { brightness: 2, saturation: -8, hue: 14 }, + }, + { + preset: 'filter-vintage', + label: '复古', + filters: { brightness: 4, contrast: -8, saturation: -12, sepia: 55 }, + }, + { + preset: 'filter-bw', + label: '黑白', + filters: { grayscale: 100, contrast: 8 }, + }, + { + preset: 'filter-vivid', + label: '鲜艳', + filters: { saturation: 40, contrast: 14, brightness: 4 }, + }, + { + preset: 'filter-soft', + label: '柔光', + filters: { brightness: 10, contrast: -16, blur: 6 }, + }, + { + preset: 'filter-sharp', + label: '锐利', + filters: { contrast: 26, saturation: 10, brightness: -2 }, + }, +]; + +/** + * 应用滤镜预设到图片 + * @param {fabric.Image} image + * @param {string} presetName + */ +export function applyFilterPreset(image, presetName) { + if (!image) return false; + const preset = FILTER_PRESETS.find(p => p.preset === presetName); + if (!preset) return false; + + // 重置全部为默认值,再应用预设覆盖 + ALL_RESETTABLE_TYPES.forEach(type => { + const value = preset.filters[type] != null + ? preset.filters[type] + : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); + setFilter(image, type, value); + }); + return true; +} + +/** + * 判断当前图片是否匹配某个预设(用于预设按钮高亮) + * @param {fabric.Image} image + * @param {string} presetName + * @returns {boolean} + */ +export function isPresetActive(image, presetName) { + if (!image) return false; + const preset = FILTER_PRESETS.find(p => p.preset === presetName); + if (!preset) return false; + + return ALL_RESETTABLE_TYPES.every(type => { + const expected = preset.filters[type] != null + ? preset.filters[type] + : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); + const actual = getFilterUiValue(image, type); + return Math.abs(actual - expected) < 0.5; + }); +} diff --git a/plugins/Image-Toolbox/plugin.json b/plugins/Image-Toolbox/plugin.json index 8fa38951..d33c5ae9 100644 --- a/plugins/Image-Toolbox/plugin.json +++ b/plugins/Image-Toolbox/plugin.json @@ -2,7 +2,7 @@ "name": "image-toolbox-ztools", "title": "图片工具箱", "description": "马赛克、剪切、加字等图片编辑功能", - "version": "2.2.2", + "version": "2.3", "main": "src/index.html", "logo": "logo.png", "preload": "preload.js", @@ -23,19 +23,11 @@ "type": "files", "label": "编辑图片", "fileType": "file", - "extensions": [ - "png", - "jpg", - "jpeg", - "webp", - "bmp", - "gif", - "svg" - ], + "extensions": ["png", "jpg", "jpeg", "webp", "bmp", "gif", "svg"], "minLength": 1, "maxLength": 1 } ] } ] -} \ No newline at end of file +} diff --git a/plugins/Image-Toolbox/src/index.js b/plugins/Image-Toolbox/src/index.js index 4e9986f6..7e7b2207 100644 --- a/plugins/Image-Toolbox/src/index.js +++ b/plugins/Image-Toolbox/src/index.js @@ -22,7 +22,7 @@ import AccountPage, { EDITOR_SIDE_PANEL_POSITION_KEY, EDITOR_SIDE_PANEL_POSITIONS, } from './ui/AccountPage.js'; -import ZtoolsHostAdapter from './adapters/host/ZtoolsHostAdapter.js'; +import UtoolsHostAdapter from './adapters/host/UtoolsHostAdapter.js'; import { initTheme } from '../core/src/utils/theme.js'; // ═══════════════════════════════════════ @@ -81,7 +81,7 @@ class App { this.historyManager = new HistoryManager(this.canvasManager, 30); // 4. 初始化工具管理器(注入 host adapter) - this.hostAdapter = new ZtoolsHostAdapter(); + this.hostAdapter = new UtoolsHostAdapter(); this.toolManager = new ToolManager(this.canvasManager, this.historyManager, { host: this.hostAdapter, }); @@ -306,6 +306,16 @@ class App { // 工具快捷键 if (!e.ctrlKey && !e.metaKey) { + // 检查焦点是否在 HTML 表单元素上(输入框、下拉框等) + const activeElement = document.activeElement; + const tagName = activeElement?.tagName?.toUpperCase(); + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return; + if (activeElement?.isContentEditable) return; + + // Fabric.js 文字编辑状态下也不触发 + const active = this.canvasManager?.getActiveObject(); + if (active && active.isEditing) return; + const tools = this.toolManager?.getTools() || []; const tool = tools.find(t => t.shortcut === e.key.toUpperCase()); if (tool) { diff --git a/plugins/Image-Toolbox/src/style.css b/plugins/Image-Toolbox/src/style.css index 6bbf7f1c..0d53e0ec 100644 --- a/plugins/Image-Toolbox/src/style.css +++ b/plugins/Image-Toolbox/src/style.css @@ -1015,6 +1015,13 @@ html, body { line-height: 1.2; } +.options-hint { + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.4; + white-space: nowrap; +} + .shape-picker-trigger { display: inline-flex; align-items: center; @@ -1188,6 +1195,16 @@ html, body { border-color: #7c878d; } +/* 滤镜预设按钮(选项栏) */ +.filter-preset-btn { + min-width: 44px; +} + +.filter-preset-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .brush-color-btn { display: inline-flex; align-items: center; @@ -1379,6 +1396,7 @@ html, body { } .side-tabs__pane #property-panel, +.side-tabs__pane #color-panel, .side-tabs__pane #layer-panel { height: 100%; min-height: 0; @@ -1409,6 +1427,7 @@ html, body { .side-tabs--split .side-tabs__pane[data-panel-pane="layer"] { flex: 3; height: auto; + border-top: 1px solid var(--color-border); } .panel { @@ -1586,6 +1605,17 @@ html, body { opacity: 0.88; } +/* 调色重置按钮行 */ +.property-item--actions { + justify-content: flex-end; + padding: 4px 6px; +} + +.property-item--actions .property-btn { + flex: 0 0 auto; + padding: 4px 12px; +} + .property-item label { font-size: 10px; color: var(--color-text-secondary); diff --git a/plugins/Image-Toolbox/src/ui/ColorPanel.js b/plugins/Image-Toolbox/src/ui/ColorPanel.js new file mode 100644 index 00000000..fcf77f9e --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/ColorPanel.js @@ -0,0 +1,262 @@ +import { eventBus } from '../../core/src/index.js'; +import { FILTER_RANGES, FILTER_PRESETS, getFilterUiValue, setFilter, clearFilters, applyFilterPreset, isPresetActive } from '../../core/src/utils/filters.js'; + +/** + * 调色面板 — 侧栏「调色」Tab + * 选中图片图层时显示滤镜预设、亮度/对比度/饱和度/色相/模糊滑块与重置按钮。 + * 非图片图层或未选中时显示提示文本。 + */ +class ColorPanel { + constructor(containerEl, canvasManager, historyManager) { + this._el = containerEl; + this._cm = canvasManager; + this._hm = historyManager; + this._filterScope = 'all'; + this._eventBusUnsubscribers = []; + + this._render(); + this._bindEvents(); + this._update(); + } + + _render() { + this._el.innerHTML = ` +
+
+
选中图片图层以调色
+
+
+ `; + } + + _bindEvents() { + this._eventBusUnsubscribers.push( + eventBus.on('canvas:selectionCreated', () => this._update()), + eventBus.on('canvas:selectionUpdated', () => this._update()), + eventBus.on('canvas:selectionCleared', () => this._update()), + eventBus.on('layer:selected', () => this._update()), + eventBus.on('canvas:objectModified', () => this._update()), + eventBus.on('canvas:restored', () => this._update()), + eventBus.on('image:loaded', () => this._clearHint()) + ); + + this._el.addEventListener('input', (e) => this._handleEvent(e)); + this._el.addEventListener('change', (e) => this._handleEvent(e)); + this._el.addEventListener('click', (e) => this._handleEvent(e)); + } + + _update() { + const bodyEl = this._el.querySelector('#color-panel-body'); + if (!bodyEl) return; + + const reference = this._getReferenceImage(); + if (reference) { + bodyEl.innerHTML = this._getColorAdjustHTML(reference); + } else { + const hint = this._getAllImages().length > 0 + ? '选中图片图层以调色' + : '当前画布没有可调色的图片图层'; + bodyEl.innerHTML = `${this._getScopeControlHTML()}
${hint}
`; + } + } + + _clearHint() { + this._update(); + } + + _getColorAdjustHTML(active) { + const items = [ + { type: 'brightness', label: '亮度' }, + { type: 'contrast', label: '对比' }, + { type: 'saturation',label: '饱和' }, + { type: 'hue', label: '色相' }, + { type: 'blur', label: '模糊' }, + ]; + + const sliders = items.map(({ type, label }) => { + const range = FILTER_RANGES[type]; + const value = getFilterUiValue(active, type); + return ` +
+ + + ${value} +
+ `; + }).join(''); + + const filterPresets = FILTER_PRESETS.map(preset => { + const targets = this._getTargetImages(); + const isActive = this._filterScope === 'all' + ? targets.length > 0 && targets.every(image => isPresetActive(image, preset.preset)) + : isPresetActive(active, preset.preset); + return ``; + }).join(''); + + const scopeTitle = this._filterScope === 'all' + ? `调色 (${this._getTargetImages().length} 个图片图层)` + : '调色'; + + return ` + ${this._getScopeControlHTML()} +
滤镜
+
${filterPresets}
+
${scopeTitle}
+ ${sliders} +
+ +
+ `; + } + + _handleEvent(e) { + // 滤镜预设按钮(一键应用) + const presetTarget = e.target.closest('[data-preset]'); + if (presetTarget && this._el.contains(presetTarget)) { + const preset = presetTarget.dataset.preset; + if (preset && preset.startsWith('filter-')) { + if (e.type !== 'click') return; + this._applyFilterPreset(preset); + return; + } + } + + // 调色滑块 / 重置 + const target = e.target.closest('[data-prop]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + if (!prop) return; + + if (prop === 'filterScope') { + if (e.type !== 'change') return; + this._filterScope = target.value === 'all' ? 'all' : 'current'; + this._update(); + return; + } + + if (!prop.startsWith('filter:')) return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + const value = target.value; + + // 重置按钮 + if (prop === 'filter:reset') { + if (e.type !== 'click') return; + this._hm?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + return; + } + + // 滑块 + const type = prop.slice('filter:'.length); + const uiValue = parseInt(value, 10); + if (!Number.isFinite(uiValue)) return; + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + + if (target.nextElementSibling && target.nextElementSibling.classList.contains('property-value')) { + target.nextElementSibling.textContent = String(uiValue); + } + + this._requestRender(); + + if (e.type === 'change') { + this._notifyObjectChanged(targets[0]); + } + } + + _applyFilterPreset(presetName) { + const targets = this._getTargetImages(); + if (targets.length === 0) return; + this._hm?.saveState?.(); + targets.forEach(image => applyFilterPreset(image, presetName)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + } + + _getScopeControlHTML() { + return ` +
作用范围
+
+ + +
+ `; + } + + _getTargetImages() { + if (this._filterScope === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveObject(); + return active && active.type === 'image' && active.type !== 'activeSelection' ? [active] : []; + } + + _getReferenceImage() { + if (this._filterScope === 'all') { + const active = this._getActiveObject(); + return active?.type === 'image' ? active : this._getAllImages()[0] || null; + } + + const active = this._getActiveObject(); + return active?.type === 'image' ? active : null; + } + + _getAllImages() { + const canvas = this._cm?.canvas; + if (!canvas) return []; + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _notifyObjectChanged(active) { + eventBus.emit('canvas:objectModified', active); + } + + _requestRender() { + const canvas = this._cm?.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default ColorPanel; diff --git a/plugins/Image-Toolbox/src/ui/LayerPanel.js b/plugins/Image-Toolbox/src/ui/LayerPanel.js index bc6ffed5..c01da791 100644 --- a/plugins/Image-Toolbox/src/ui/LayerPanel.js +++ b/plugins/Image-Toolbox/src/ui/LayerPanel.js @@ -362,7 +362,7 @@ class LayerPanel { ${eyeIcon} ${icon} ${layerName} - ${lockIcon} + ${lockIcon} `; } diff --git a/plugins/Image-Toolbox/src/ui/PropertyPanel.js b/plugins/Image-Toolbox/src/ui/PropertyPanel.js index 8091f95c..ed4d8c1d 100644 --- a/plugins/Image-Toolbox/src/ui/PropertyPanel.js +++ b/plugins/Image-Toolbox/src/ui/PropertyPanel.js @@ -68,6 +68,15 @@ class PropertyPanel { const module = this._tm.getCurrentModule(); const active = this._getActiveObject(); + // 模块可声明 overridePropertyPanel 接管属性面板(即使有选中对象) + if (module?.overridePropertyPanel && typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + if (active?.excludeFromProperty && module && typeof module.getPropertyPanelHTML === 'function') { const html = module.getPropertyPanelHTML(); if (html) { @@ -103,7 +112,8 @@ class PropertyPanel { const isText = this._isTextObject(active); const isBackground = !!meta?.isBackground; const locked = meta ? meta.locked : (active.selectable === false && active.evented === false); - const editDisabled = (isBackground || locked) ? ' disabled' : ''; + // 背景图层的锁定只限制删除、改名和排序,仍允许显式编辑几何参数。 + const editDisabled = (locked && !isBackground) ? ' disabled' : ''; const renameDisabled = (!meta || isBackground) ? ' disabled' : ''; const lockDisabled = isBackground ? ' disabled' : ''; const opacity = active.opacity == null ? 1 : active.opacity; @@ -171,7 +181,7 @@ class PropertyPanel { html += `
- ${Math.round(opacity * 100)}%
@@ -283,6 +293,7 @@ class PropertyPanel { const moduleAction = target.dataset.moduleAction; const modulePreset = target.dataset.modulePreset; if (!prop && !moduleProp && !moduleAction && !modulePreset) return; + // 普通属性控件只在 input/change 事件中处理;click 事件仅放行模块动作/预设 if (e.type === 'click' && !moduleAction && !modulePreset) return; const module = this._tm.getCurrentModule(); @@ -433,7 +444,8 @@ class PropertyPanel { const handled = module.onToolPropertyChange(prop, value, { eventType: e.type }); if (target.type === 'range' && target.nextElementSibling) { - target.nextElementSibling.textContent = `${value}${target.dataset.valueSuffix || 'px'}`; + const suffix = target.dataset.valueSuffix != null ? target.dataset.valueSuffix : 'px'; + target.nextElementSibling.textContent = `${value}${suffix}`; } if (handled !== false && target.dataset.refreshProperty === 'true') { diff --git a/plugins/Image-Toolbox/src/ui/Toolbar.js b/plugins/Image-Toolbox/src/ui/Toolbar.js index d8ab55ba..b2b79f97 100644 --- a/plugins/Image-Toolbox/src/ui/Toolbar.js +++ b/plugins/Image-Toolbox/src/ui/Toolbar.js @@ -19,6 +19,7 @@ class Toolbar { select: ``, mosaic: ``, crop: ``, + color: ``, brush: ``, eraser: ``, text: ``, @@ -38,7 +39,7 @@ class Toolbar { let currentGroup = null; // Render tool groups in a stable order. - const groupOrder = ['edit', 'annotate', 'view', 'action']; + const groupOrder = ['edit', 'adjust', 'redact', 'annotate', 'view', 'action']; for (const group of groupOrder) { const groupTools = tools.filter(t => t.group === group); if (groupTools.length === 0) continue;