From 9c5290e1639143d88bb51f557064cca8cfab6b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8A=B9=E9=9C=B2=E8=8C=B6=E6=9F=92?= Date: Fri, 21 Aug 2026 18:29:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?Image-Toolbox=202.4.1:=20=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E8=B7=A8=E5=B9=B3=E5=8F=B0=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E3=80=81=E5=9B=BE=E7=89=87=E5=8A=A0=E8=BD=BD=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=BF=9B=E5=85=A5=E4=BF=AE=E5=A4=8D=E3=80=81=E6=BB=9A?= =?UTF-8?q?=E8=BD=AE=E7=BC=A9=E6=94=BE=E4=BB=A5=E9=BC=A0=E6=A0=87=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=BA=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Image-Toolbox/core/src/CanvasManager.js | 244 +++- .../Image-Toolbox/core/src/EditorContext.js | 201 ---- plugins/Image-Toolbox/core/src/EventBus.js | 7 +- .../Image-Toolbox/core/src/HistoryManager.js | 49 +- .../Image-Toolbox/core/src/HistoryStore.js | 156 --- .../Image-Toolbox/core/src/LayerManager.js | 63 +- plugins/Image-Toolbox/core/src/LayerStore.js | 292 ----- plugins/Image-Toolbox/core/src/ToolManager.js | 20 +- .../Image-Toolbox/core/src/ToolRegistry.js | 268 ----- .../core/src/adapters/BaseHostAdapter.js | 470 ++++++++ plugins/Image-Toolbox/core/src/app/App.js | 595 ++++++++++ .../core/src/identity/IdentityClient.js | 326 +++++ plugins/Image-Toolbox/core/src/index.js | 12 - .../src/interfaces/EditorEngineAdapter.js | 130 -- .../core/src/interfaces/HostAdapter.js | 97 -- .../core/src/modules/BaseModule.js | 29 +- .../core/src/modules/BrushModule.js | 38 +- .../core/src/modules/ColorModule.js | 245 ++++ .../core/src/modules/CropModule.js | 105 +- .../core/src/modules/EraserModule.js | 7 +- .../core/src/modules/ExportModule.js | 26 +- .../core/src/modules/MosaicModule.js | 202 ++-- .../core/src/modules/SelectModule.js | 22 +- .../core/src/modules/ShapeModule.js | 127 +- .../core/src/modules/TextModule.js | 26 +- .../Image-Toolbox/core/src/preloadHelpers.js | 1056 +++++++++++++++++ .../Image-Toolbox/core/src/ui/AccountPage.js | 1017 ++++++++++++++++ .../Image-Toolbox/core/src/ui/ColorPanel.js | 275 +++++ .../{ => core}/src/ui/LayerPanel.js | 100 +- .../{ => core}/src/ui/OptionsBar.js | 58 +- .../{ => core}/src/ui/PropertyPanel.js | 91 +- .../{ => core}/src/ui/SidePanelTabs.js | 16 +- .../{ => core}/src/ui/StatusBar.js | 42 +- .../{ => core}/src/ui/Toolbar.js | 175 ++- .../Image-Toolbox/core/src/updateRecords.js | 109 ++ .../Image-Toolbox/core/src/utils/constants.js | 14 +- .../core/src/utils/dynamicMosaic.js | 355 ------ .../Image-Toolbox/core/src/utils/filters.js | 301 +++++ .../Image-Toolbox/core/src/utils/helpers.js | 133 +++ plugins/Image-Toolbox/core/src/utils/host.js | 99 +- plugins/Image-Toolbox/core/src/utils/image.js | 104 -- plugins/Image-Toolbox/plugin.json | 14 +- plugins/Image-Toolbox/preload.js | 709 +---------- .../src/adapters/host/ZtoolsHostAdapter.js | 341 +----- plugins/Image-Toolbox/src/index.js | 515 +------- plugins/Image-Toolbox/src/style.css | 475 +++++++- plugins/Image-Toolbox/src/ui/AccountPage.js | 568 --------- 47 files changed, 6056 insertions(+), 4268 deletions(-) delete mode 100644 plugins/Image-Toolbox/core/src/EditorContext.js delete mode 100644 plugins/Image-Toolbox/core/src/HistoryStore.js delete mode 100644 plugins/Image-Toolbox/core/src/LayerStore.js delete mode 100644 plugins/Image-Toolbox/core/src/ToolRegistry.js create mode 100644 plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js create mode 100644 plugins/Image-Toolbox/core/src/app/App.js create mode 100644 plugins/Image-Toolbox/core/src/identity/IdentityClient.js delete mode 100644 plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js delete mode 100644 plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js create mode 100644 plugins/Image-Toolbox/core/src/modules/ColorModule.js create mode 100644 plugins/Image-Toolbox/core/src/preloadHelpers.js create mode 100644 plugins/Image-Toolbox/core/src/ui/AccountPage.js create mode 100644 plugins/Image-Toolbox/core/src/ui/ColorPanel.js rename plugins/Image-Toolbox/{ => core}/src/ui/LayerPanel.js (86%) rename plugins/Image-Toolbox/{ => core}/src/ui/OptionsBar.js (72%) rename plugins/Image-Toolbox/{ => core}/src/ui/PropertyPanel.js (87%) rename plugins/Image-Toolbox/{ => core}/src/ui/SidePanelTabs.js (90%) rename plugins/Image-Toolbox/{ => core}/src/ui/StatusBar.js (67%) rename plugins/Image-Toolbox/{ => core}/src/ui/Toolbar.js (54%) delete mode 100644 plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js create mode 100644 plugins/Image-Toolbox/core/src/utils/filters.js create mode 100644 plugins/Image-Toolbox/core/src/utils/helpers.js delete mode 100644 plugins/Image-Toolbox/core/src/utils/image.js delete mode 100644 plugins/Image-Toolbox/src/ui/AccountPage.js diff --git a/plugins/Image-Toolbox/core/src/CanvasManager.js b/plugins/Image-Toolbox/core/src/CanvasManager.js index e2e5bc5c2..259faa5de 100644 --- a/plugins/Image-Toolbox/core/src/CanvasManager.js +++ b/plugins/Image-Toolbox/core/src/CanvasManager.js @@ -1,17 +1,8 @@ -import eventBus from './EventBus.js'; +import eventBus from './EventBus.js'; +import { CANVAS_DEFAULTS } from './utils/constants.js'; const CLIP_PATH_SERIALIZED_PROPS = ['clipPath', 'absolutePositioned', 'inverted']; -const DEFAULT_CANVAS_OPTIONS = { - width: 800, - height: 600, - backgroundColor: '#d2d6d9', - preserveObjectStacking: true, - selection: true, - stopContextMenu: true, - fireRightClick: true, -}; - /** * 画布管理器 — 封装 Fabric.js 画布的创建、配置和基础操作 */ @@ -23,6 +14,8 @@ class CanvasManager { this.zoomLevel = 1; this._historySaveTimer = null; this._isCropMode = false; + this._resizeObserver = null; + this._boundResize = null; } // ── 生命周期 ── @@ -37,7 +30,16 @@ class CanvasManager { throw new Error(`[CanvasManager] 找不到画布元素 #${this._canvasElId}`); } - const config = { ...DEFAULT_CANVAS_OPTIONS, ...options }; + const config = { + width: CANVAS_DEFAULTS.WIDTH, + height: CANVAS_DEFAULTS.HEIGHT, + backgroundColor: CANVAS_DEFAULTS.BACKGROUND_COLOR, + preserveObjectStacking: CANVAS_DEFAULTS.PRESERVE_OBJECT_STACKING, + selection: CANVAS_DEFAULTS.SELECTION, + stopContextMenu: CANVAS_DEFAULTS.STOP_CONTEXT_MENU, + fireRightClick: CANVAS_DEFAULTS.FIRE_RIGHT_CLICK, + ...options, + }; this.canvas = new fabric.Canvas(this._canvasElId, config); this._bindEvents(); this._updateCanvasSize(); @@ -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); } @@ -65,7 +75,7 @@ class CanvasManager { * @param {string|File} source - URL / DataURL / File 对象 * @returns {Promise} */ - loadImage(source) { + loadImage(source) { return new Promise((resolve, reject) => { if (!this.canvas) { reject(new Error('画布未初始化')); @@ -74,8 +84,8 @@ class CanvasManager { // 超时保护:防止 fabric.Image.fromURL 永远不回调 const timeout = setTimeout(() => { - reject(new Error('图片加载超时(30s),可能是格式不支持')); - }, 30000); + reject(new Error('图片加载超时(15s),可能是格式不支持或文件过大')); + }, 15000); /** * fabric.Image.fromURL 回调签名: function(fabricImage, isError) @@ -96,7 +106,7 @@ class CanvasManager { fabricImg.width, fabricImg.height, fabricImg.type); this.originalImage = fabricImg; - fabricImg._originalImage = true; + this._applyBackgroundImageProps(fabricImg); this.canvas.clear(); this.canvas.add(fabricImg); this.canvas.renderAll(); @@ -149,11 +159,11 @@ class CanvasManager { const index = this.canvas.getObjects().indexOf(this.originalImage); this.canvas.remove(this.originalImage); this.originalImage = fabricImg; - fabricImg._originalImage = true; + this._applyBackgroundImageProps(fabricImg); this.canvas.insertAt(fabricImg, index >= 0 ? index : 0); } else { this.originalImage = fabricImg; - fabricImg._originalImage = true; + this._applyBackgroundImageProps(fabricImg); this.canvas.insertAt(fabricImg, 0); } this.canvas.renderAll(); @@ -162,6 +172,29 @@ class CanvasManager { }); } + /** + * 给作为背景的 fabric.Image 设置通用属性: + * - 标记为 _originalImage(序列化/恢复时识别) + * - 允许选中/接收事件(用于图层面板和调色工具定位背景图层) + * - 显示变换控制点,允许像普通图层一样拖拽移动、缩放、旋转 + * @param {fabric.Image} fabricImg + */ + _applyBackgroundImageProps(fabricImg) { + fabricImg._originalImage = true; + fabricImg.set({ + selectable: true, + evented: true, + hasControls: true, + hasBorders: true, + lockMovementX: false, + lockMovementY: false, + lockRotation: false, + lockScalingX: false, + lockScalingY: false, + }); + fabricImg.setCoords(); + } + /** * 获取画布当前图片 DataURL * @param {object} [options] - 导出选项 @@ -197,6 +230,7 @@ class CanvasManager { left: (cw - img.width * scale) / 2, top: (ch - img.height * scale) / 2, }); + img.setCoords(); this.canvas.renderAll(); } @@ -217,12 +251,12 @@ class CanvasManager { eventBus.emit('canvas:zoomChanged', this.zoomLevel); } - zoomIn(step = 0.1) { - this.setZoom(this.zoomLevel + step); + zoomIn(step = 0.1, point) { + this.setZoom(this.zoomLevel + step, point); } - zoomOut(step = 0.1) { - this.setZoom(this.zoomLevel - step); + zoomOut(step = 0.1, point) { + this.setZoom(this.zoomLevel - step, point); } resetZoom() { @@ -288,18 +322,28 @@ class CanvasManager { 'id', 'selectable', 'evented', + 'hasControls', + 'hasBorders', + 'lockMovementX', + 'lockMovementY', + 'lockRotation', + 'lockScalingX', + 'lockScalingY', 'absolutePositioned', 'inverted', 'objectCaching', 'strokeLineCap', 'strokeLineJoin', + '_strokePosition', '_layerName', '_layerNameAuto', '_layerBaseName', '_layerKind', + '_layerShapeType', '_layerColorPresetName', '_layerWidthPresetName', '_layerPresetName', + '_layerLocked', '_mosaicDynamic', '_mosaicMode', '_mosaicSize', @@ -326,31 +370,37 @@ 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]; + if (this.originalImage?.type === 'image') { + this._applyBackgroundImageProps(this.originalImage); + } + 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(); }); }); } @@ -367,6 +417,25 @@ class CanvasManager { && point.y >= 0 && point.y <= this.canvas.height; } + /** + * 刷新动态马赛克(由 MosaicModule 注入) + * 此方法在 MosaicModule 激活时设置回调,避免直接依赖模块 + * @param {function():void} callback + */ + setRefreshDynamicMosaics(callback) { + this._refreshDynamicMosaics = callback; + } + + /** + * 调用动态马赛克刷新回调 + * @param {object} options + */ + refreshDynamicMosaics(options) { + if (typeof this._refreshDynamicMosaics === 'function') { + this._refreshDynamicMosaics(options); + } + } + // ── 内部方法 ── _updateCanvasSize() { @@ -381,19 +450,105 @@ class CanvasManager { if (newWidth <= 0 || newHeight <= 0) return; if (this.canvas.width !== newWidth || this.canvas.height !== newHeight) { - this.canvas.setWidth(newWidth); - this.canvas.setHeight(newHeight); - this.canvas.calcOffset(); - - // 如果已加载图片,重新适配 + // 调整画布尺寸时保留编辑内容的相对布局,避免覆盖层与原图错位 if (this.originalImage) { - this.fitToCanvas(); + this._resizePreservingLayout(newWidth, newHeight); + } else { + this.canvas.setWidth(newWidth); + this.canvas.setHeight(newHeight); + this.canvas.calcOffset(); } eventBus.emit('canvas:resized', { width: newWidth, height: newHeight }); } } + /** + * 调整画布尺寸时保留编辑内容的相对布局 + * 计算原图在新画布尺寸下的 fit 变换,将变换差值同步应用到所有覆盖层和裁剪路径, + * 使覆盖层(文字/图形/画笔/马赛克等)与原图的相对位置保持不变 + */ + _resizePreservingLayout(newWidth, newHeight) { + const img = this.originalImage; + const padding = 40; + + // 记录原图当前的变换(可能是 fit 状态,也可能是用户手动调整后的状态) + const oldLeft = img.left; + const oldTop = img.top; + const oldScaleX = img.scaleX; + const oldScaleY = img.scaleY; + + // 计算新画布下的 fit 变换 + const availableW = newWidth - padding * 2; + const availableH = newHeight - padding * 2; + const newScale = Math.min(availableW / img.width, availableH / img.height, 1); + const newLeft = (newWidth - img.width * newScale) / 2; + const newTop = (newHeight - img.height * newScale) / 2; + + // 计算缩放比例(避免除零) + const ratioX = oldScaleX ? newScale / oldScaleX : 1; + const ratioY = oldScaleY ? newScale / oldScaleY : 1; + + // 更新画布尺寸 + this.canvas.setWidth(newWidth); + this.canvas.setHeight(newHeight); + this.canvas.calcOffset(); + + // 将差值应用到所有覆盖层(非原图、非临时对象) + const overlays = this.canvas.getObjects().filter(obj => + obj !== img && + !obj.excludeFromHistory && + !obj.excludeFromLayer + ); + overlays.forEach(obj => { + const relX = obj.left - oldLeft; + const relY = obj.top - oldTop; + obj.set({ + left: newLeft + relX * ratioX, + top: newTop + relY * ratioY, + scaleX: obj.scaleX * ratioX, + scaleY: obj.scaleY * ratioY, + }); + obj.setCoords(); + }); + + // 同步调整画布级裁剪路径,保留裁剪效果与原图的相对位置 + this._transformClipPath(this.canvas.clipPath, oldLeft, oldTop, newLeft, newTop, ratioX, ratioY); + + // 应用新的 fit 变换到原图 + img.set({ + scaleX: newScale, + scaleY: newScale, + left: newLeft, + top: newTop, + }); + img.setCoords(); + + this.canvas.renderAll(); + + // 刷新动态马赛克(基于新的原图位置重新计算) + this.refreshDynamicMosaics({ render: true }); + } + + /** + * 递归调整 clipPath 的位置和缩放,使其跟随原图变换 + */ + _transformClipPath(clipPath, oldLeft, oldTop, newLeft, newTop, ratioX, ratioY) { + if (!clipPath) return; + const relX = (clipPath.left || 0) - oldLeft; + const relY = (clipPath.top || 0) - oldTop; + clipPath.set({ + left: newLeft + relX * ratioX, + top: newTop + relY * ratioY, + scaleX: (clipPath.scaleX == null ? 1 : clipPath.scaleX) * ratioX, + scaleY: (clipPath.scaleY == null ? 1 : clipPath.scaleY) * ratioY, + }); + clipPath.setCoords(); + if (clipPath.clipPath) { + this._transformClipPath(clipPath.clipPath, oldLeft, oldTop, newLeft, newTop, ratioX, ratioY); + } + } + _bindEvents() { if (!this.canvas) return; @@ -441,17 +596,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/EditorContext.js b/plugins/Image-Toolbox/core/src/EditorContext.js deleted file mode 100644 index b2de36cbd..000000000 --- a/plugins/Image-Toolbox/core/src/EditorContext.js +++ /dev/null @@ -1,201 +0,0 @@ -import { EventBus } from './EventBus.js'; - -/** - * EditorContext — 多端共享上下文容器 - * - * 集中持有 EventBus、EngineAdapter、HostAdapter、HistoryStore、LayerStore 等实例。 - * 由应用层(App)创建并注入各模块,避免全局单例和硬编码依赖。 - */ -export default class EditorContext { - /** - * @param {object} options - * @param {import('./interfaces/EditorEngineAdapter.js').default} options.engine - * @param {import('./interfaces/HostAdapter.js').default} [options.host] - * @param {EventBus} [options.eventBus] - * @param {number} [options.maxHistorySteps=30] - */ - constructor({ engine, host = null, eventBus = null, maxHistorySteps = 30 } = {}) { - /** @type {EventBus} */ - this.eventBus = eventBus || new EventBus(); - - /** @type {import('./interfaces/EditorEngineAdapter.js').default} */ - this.engine = engine; - - /** @type {import('./interfaces/HostAdapter.js').default} */ - this.host = host; - - /** @type {number} */ - this.maxHistorySteps = maxHistorySteps; - - /** @type {object[]} 历史栈(snapshot 模式) */ - this._undoStack = []; - - /** @type {object[]} 重做栈 */ - this._redoStack = []; - - /** @type {boolean} */ - this._isRestoring = false; - - /** @type {string|null} 当前激活的工具名 */ - this._activeTool = null; - - /** @type {object} 当前工具选项 */ - this._toolOptions = {}; - } - - // ── 工具管理 ── - - /** - * 激活工具。 - * @param {string} toolName - * @param {object} [options] - */ - setActiveTool(toolName, options = {}) { - this._activeTool = toolName; - this._toolOptions = { ...options }; - this.eventBus.emit('tool:changed', { toolName, options: this._toolOptions }); - } - - /** - * 获取当前工具名。 - * @returns {string|null} - */ - getActiveTool() { - return this._activeTool; - } - - /** - * 获取当前工具选项。 - * @returns {object} - */ - getToolOptions() { - return { ...this._toolOptions }; - } - - /** - * 更新当前工具选项。 - * @param {object} patch - */ - updateToolOptions(patch) { - Object.assign(this._toolOptions, patch); - this.eventBus.emit('tool:optionsChanged', this._toolOptions); - } - - // ── 历史管理(snapshot 模式) ── - - /** - * 保存当前快照到历史栈。 - * @param {object} snapshot - */ - saveSnapshot(snapshot) { - if (this._isRestoring) return; - - this._undoStack.push(snapshot); - - if (this._undoStack.length > this.maxHistorySteps) { - this._undoStack.shift(); - } - - this._redoStack = []; - this._notifyHistory(); - } - - /** - * 撤销。 - * @param {object} currentSnapshot - * @returns {object|null} 恢复的快照,无可撤销时返回 null - */ - undo(currentSnapshot) { - if (this._undoStack.length === 0) return null; - - this._isRestoring = true; - this._redoStack.push(currentSnapshot); - const prev = this._undoStack.pop(); - this._isRestoring = false; - - this._notifyHistory(); - return prev; - } - - /** - * 重做。 - * @param {object} currentSnapshot - * @returns {object|null} 恢复的快照,无可重做时返回 null - */ - redo(currentSnapshot) { - if (this._redoStack.length === 0) return null; - - this._isRestoring = true; - this._undoStack.push(currentSnapshot); - const next = this._redoStack.pop(); - this._isRestoring = false; - - this._notifyHistory(); - return next; - } - - /** @returns {{ canUndo: boolean, canRedo: boolean, undoCount: number }} */ - getHistoryState() { - return { - canUndo: this._undoStack.length > 0, - canRedo: this._redoStack.length > 0, - undoCount: this._undoStack.length, - }; - } - - /** 清空历史栈。 */ - clearHistory() { - this._undoStack = []; - this._redoStack = []; - this._notifyHistory(); - } - - _notifyHistory() { - this.eventBus.emit('history:changed', this.getHistoryState()); - } - - // ── 导出 ── - - /** - * 生成当前画布的 dataURL(委托给 engine adapter)。 - * @param {object} [options] - { format, quality, multiplier, trimToImage } - * @returns {string|null} - */ - exportToDataURL(options = {}) { - return this.engine?.exportToDataURL(options) || null; - } - - /** - * 保存图片(委托给 host adapter)。 - * @param {Blob|string} data - * @param {string} [suggestedName] - * @returns {Promise} - */ - async saveImage(data, suggestedName) { - if (!this.host?.saveImage) return false; - return this.host.saveImage(data, suggestedName); - } - - /** - * 复制图片到剪贴板(委托给 host adapter)。 - * @param {Blob|string} data - * @returns {Promise} - */ - async copyImage(data) { - if (!this.host?.copyImage) return false; - return this.host.copyImage(data); - } - - // ── 生命周期 ── - - /** - * 销毁上下文,释放所有资源。 - */ - destroy() { - this.engine?.destroy(); - this.eventBus.clear(); - this._undoStack = []; - this._redoStack = []; - this._activeTool = null; - } -} diff --git a/plugins/Image-Toolbox/core/src/EventBus.js b/plugins/Image-Toolbox/core/src/EventBus.js index e9ac67abe..b301c4218 100644 --- a/plugins/Image-Toolbox/core/src/EventBus.js +++ b/plugins/Image-Toolbox/core/src/EventBus.js @@ -31,10 +31,13 @@ class EventBus { * @param {object} [context] */ once(event, callback, context) { - const off = this.on(event, (...args) => { + let off = null; + const wrapper = (...args) => { off(); callback.apply(context, args); - }, context); + }; + off = this.on(event, wrapper, context); + return off; } /** diff --git a/plugins/Image-Toolbox/core/src/HistoryManager.js b/plugins/Image-Toolbox/core/src/HistoryManager.js index 1cc6471a4..a90a40dea 100644 --- a/plugins/Image-Toolbox/core/src/HistoryManager.js +++ b/plugins/Image-Toolbox/core/src/HistoryManager.js @@ -1,4 +1,4 @@ -import eventBus from './EventBus.js'; +import eventBus from './EventBus.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,35 @@ class HistoryManager { undoCount: this.undoStack.length, }); } + + _isSameSnapshot(a, b) { + if (a === b) return true; + if (!a || !b) return false; + + try { + // 用轻量级签名对比,过滤掉图片对象的大体积 src(base64), + // 避免对大图做深度序列化导致高频保存历史(自由绘制、拖拽等)时卡顿。 + // src 被替换为「长度:首段:尾段」的内容指纹,仍可识别图片是否被替换。 + return this._snapshotSignature(a) === this._snapshotSignature(b); + } catch (err) { + return false; + } + } + + _snapshotSignature(json) { + // 使用更强的内容指纹:首尾各 32 字符 + 长度 + 中间 16 字符, + // 显著降低碰撞概率,同时仍然避免对大图做完整序列化。 + return JSON.stringify(json, (key, value) => { + if (key === 'src' && typeof value === 'string' && value.length > 64) { + const len = value.length; + const head = value.slice(0, 32); + const tail = value.slice(-32); + const mid = value.slice(Math.floor(len / 2) - 8, Math.floor(len / 2) + 8); + return `${len}:${head}:${mid}:${tail}`; + } + return value; + }); + } } export default HistoryManager; diff --git a/plugins/Image-Toolbox/core/src/HistoryStore.js b/plugins/Image-Toolbox/core/src/HistoryStore.js deleted file mode 100644 index 8c4a30fe3..000000000 --- a/plugins/Image-Toolbox/core/src/HistoryStore.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * HistoryStore — 纯状态历史管理 - * - * 零 DOM、零 fabric、零平台依赖。 - * 通过外部传入的 snapshotProvider / snapshotRestorer 与引擎交互。 - * - * @example - * const store = new HistoryStore({ - * maxSteps: 30, - * snapshotProvider: () => canvas.toJSON(), - * snapshotRestorer: (json) => canvas.loadFromJSON(json), - * eventBus, - * }); - */ -export default class HistoryStore { - /** - * @param {object} options - * @param {number} [options.maxSteps=30] - * @param {function} options.snapshotProvider - () => snapshot(纯 JSON,平台无关) - * @param {function} options.snapshotRestorer - (snapshot) => Promise - * @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} 是否成功恢复 - */ - 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} 是否成功恢复 - */ - 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; - } - - // ── 状态查询 ── - - canUndo() { return this._undoStack.length > 0; } - canRedo() { return this._redoStack.length > 0; } - - getState() { - return { - canUndo: this.canUndo(), - canRedo: this.canRedo(), - undoCount: this._undoStack.length, - redoCount: this._redoStack.length, - }; - } - - // ── 控制 ── - - setEnabled(enabled) { this._enabled = !!enabled; } - isEnabled() { return this._enabled; } - - clear() { - this._undoStack = []; - this._redoStack = []; - this._notify(); - } - - setMaxSteps(max) { this._maxSteps = Math.max(1, max); } - getMaxSteps() { return this._maxSteps; } - - // ── 内部 ── - - _notify() { - if (this._eventBus) { - this._eventBus.emit('history:changed', this.getState()); - } - } -} diff --git a/plugins/Image-Toolbox/core/src/LayerManager.js b/plugins/Image-Toolbox/core/src/LayerManager.js index 4fe3092a9..63ac020e0 100644 --- a/plugins/Image-Toolbox/core/src/LayerManager.js +++ b/plugins/Image-Toolbox/core/src/LayerManager.js @@ -1,4 +1,4 @@ -import eventBus from './EventBus.js'; +import eventBus from './EventBus.js'; /** * 图层管理器 — 管理 Fabric.js 物件的 z-order、显隐、锁定 @@ -7,6 +7,7 @@ class LayerManager { constructor(canvasManager) { this._cm = canvasManager; + this._cm.layerManager = this; this._layers = []; // 图层元数据 [{ id, name, visible, locked, fabricObj }] this._idCounter = 0; } @@ -43,12 +44,14 @@ class LayerManager { meta.fabricObj = obj; this._refreshMetaName(meta, obj, false, newLayers, currentObjects); } + this._refreshMetaState(meta, obj, false); meta.zIndex = objects.length - 1 - i; newLayers.push(meta); } // 背景图层始终在列表末尾(面板最底部) if (this._cm.originalImage) { + this._ensureBackgroundSelectable(this._cm.originalImage); let bgMeta = oldLayers.find(l => l.fabricObj === this._cm.originalImage) || null; if (!bgMeta) { bgMeta = this._createMeta(this._cm.originalImage, true, newLayers, currentObjects); @@ -56,6 +59,7 @@ class LayerManager { bgMeta.fabricObj = this._cm.originalImage; this._refreshMetaName(bgMeta, this._cm.originalImage, true, newLayers, currentObjects); } + this._refreshMetaState(bgMeta, this._cm.originalImage, true); bgMeta.zIndex = 0; newLayers.push(bgMeta); } @@ -91,11 +95,16 @@ class LayerManager { } /** - * 根据 Fabric 对象获取图层元数据 + * 根据 Fabric 对象获取图层元数据(公开接口) + * + * 仅在已同步的 _layers 列表中查找。对于尚未被 syncLayers() 收录的对象 + * (如工具激活期间新增的临时对象),返回 null,由调用方决定回退策略。 + * * @param {fabric.Object} obj * @returns {object|null} */ getLayerByObject(obj) { + if (!obj) return null; return this._findMeta(obj); } @@ -106,13 +115,15 @@ class LayerManager { const meta = { id, name: nameInfo.name, - visible: obj.visible !== false, - locked: isBackground ? true : (!obj.selectable && !obj.evented), + visible: true, + locked: false, fabricObj: obj, zIndex: 0, isBackground, }; + this._refreshMetaState(meta, obj, isBackground); + if (!isBackground) { this._setObjectLayerName(obj, meta.name, nameInfo.auto, nameInfo.baseName); } @@ -120,6 +131,39 @@ class LayerManager { return meta; } + _refreshMetaState(meta, obj, isBackground = false) { + meta.visible = obj.visible !== false; + meta.locked = this._resolveLockedState(obj, isBackground); + meta.isBackground = !!isBackground; + } + + _resolveLockedState(obj, isBackground = false) { + if (isBackground) return true; + if (typeof obj?._layerLocked === 'boolean') return obj._layerLocked; + + // 旧版本把工具激活期间创建的图层也标成 selectable/evented=false。 + // 没有明确锁定标记时按未锁定处理,避免移动/框选工具无法选中这些图层。 + return false; + } + + _ensureBackgroundSelectable(obj) { + if (!obj) return; + + obj._originalImage = true; + obj.set({ + selectable: true, + evented: true, + hasControls: true, + hasBorders: true, + lockMovementX: false, + lockMovementY: false, + lockRotation: false, + lockScalingX: false, + lockScalingY: false, + }); + obj.setCoords(); + } + _refreshMetaName(meta, obj, isBackground = false, newLayers = null, currentObjects = null) { const nameInfo = this._resolveLayerName(obj, isBackground, newLayers, meta, currentObjects); meta.name = nameInfo.name; @@ -378,6 +422,7 @@ class LayerManager { if (!meta || meta.isBackground) return; meta.locked = !!locked; + meta.fabricObj._layerLocked = meta.locked; meta.fabricObj.set({ selectable: !meta.locked, evented: !meta.locked, @@ -398,9 +443,13 @@ class LayerManager { const meta = this._layers.find(l => l.id === layerId); if (!meta) return; - // 即使图层被锁定也触发事件(让橡皮擦等工具能响应图层切换), - // 但不调用 setActiveObject(避免误操作锁定图层)。 - if (!meta.locked) { + // 即使图层被锁定也触发事件(让橡皮擦等工具能响应图层切换)。 + // 普通锁定图层不调用 setActiveObject(避免误操作); + // 但背景图层允许选中和变换;图层锁定只限制删除、改名和排序。 + if (!meta.locked || meta.isBackground) { + if (meta.isBackground) { + this._ensureBackgroundSelectable(meta.fabricObj); + } this._cm.canvas.setActiveObject(meta.fabricObj); this._cm.canvas.renderAll(); } diff --git a/plugins/Image-Toolbox/core/src/LayerStore.js b/plugins/Image-Toolbox/core/src/LayerStore.js deleted file mode 100644 index ff5f975b7..000000000 --- a/plugins/Image-Toolbox/core/src/LayerStore.js +++ /dev/null @@ -1,292 +0,0 @@ -/** - * LayerStore — 纯数据图层模型 - * - * 零 DOM、零 fabric、零平台依赖。 - * 图层用 engineId 关联渲染引擎对象,不保存任何引擎对象引用。 - * - * @example - * const store = new LayerStore({ eventBus }); - * store.addLayer({ engineId: 'obj_1', type: 'brush', name: '画笔' }); - * store.getLayers(); // [{ id: 1, engineId: 'obj_1', ... }] - */ -let _globalIdCounter = 0; - -export default class LayerStore { - /** - * @param {object} options - * @param {import('./EventBus.js').EventBus} [options.eventBus] - */ - constructor({ eventBus = null } = {}) { - this._eventBus = eventBus; - this._layers = []; - this._idCounter = 0; - } - - // ── 查询 ── - - /** @returns {LayerModel[]} */ - getLayers() { return this._layers.slice(); } - - /** @returns {number} */ - getCount() { return this._layers.length; } - - /** - * @param {number} layerId - * @returns {LayerModel|null} - */ - getById(layerId) { - return this._layers.find(l => l.id === layerId) || null; - } - - /** - * @param {string} engineId - * @returns {LayerModel|null} - */ - getByEngineId(engineId) { - return this._layers.find(l => l.engineId === engineId) || null; - } - - /** - * 获取非背景图层列表(面板用,顶层在前)。 - * @returns {LayerModel[]} - */ - getOverlayLayers() { - return this._layers.filter(l => !l.isBackground); - } - - /** - * 获取背景图层。 - * @returns {LayerModel|null} - */ - getBackground() { - return this._layers.find(l => l.isBackground) || null; - } - - // ── 增删 ── - - /** - * 添加图层。 - * @param {object} input - { engineId, type, name, visible, locked, opacity, metadata } - * @returns {LayerModel} - */ - addLayer(input) { - const id = ++this._idCounter; - const layer = { - id, - engineId: input.engineId || '', - name: input.name || this._defaultName(input.type), - type: input.type || 'image', - visible: input.visible !== false, - locked: !!input.locked, - opacity: typeof input.opacity === 'number' ? input.opacity : 1, - isBackground: !!input.isBackground, - zIndex: 0, - metadata: input.metadata ? { ...input.metadata } : {}, - }; - - this._layers.push(layer); - this._renumberZIndex(); - this._notify('layers:added', layer); - return { ...layer }; - } - - /** - * 删除图层。 - * @param {number} layerId - * @returns {boolean} - */ - removeLayer(layerId) { - const idx = this._layers.findIndex(l => l.id === layerId); - if (idx === -1) return false; - - const [removed] = this._layers.splice(idx, 1); - this._renumberZIndex(); - this._notify('layers:removed', removed); - return true; - } - - // ── 修改 ── - - /** - * 更新图层属性。 - * @param {number} layerId - * @param {object} patch - { name?, visible?, locked?, opacity?, metadata? } - * @returns {LayerModel|null} - */ - updateLayer(layerId, patch) { - const layer = this.getById(layerId); - if (!layer) return null; - - if (patch.name !== undefined) layer.name = patch.name; - if (patch.visible !== undefined) layer.visible = !!patch.visible; - if (patch.locked !== undefined) layer.locked = !!patch.locked; - if (patch.opacity !== undefined) layer.opacity = patch.opacity; - if (patch.engineId !== undefined) layer.engineId = patch.engineId; - if (patch.metadata !== undefined) { - layer.metadata = { ...layer.metadata, ...patch.metadata }; - } - - this._notify('layers:updated', layer); - return { ...layer }; - } - - /** - * 切换可见性。 - * @param {number} layerId - * @returns {LayerModel|null} - */ - toggleVisibility(layerId) { - const layer = this.getById(layerId); - if (!layer) return null; - return this.updateLayer(layerId, { visible: !layer.visible }); - } - - /** - * 切换锁定。 - * @param {number} layerId - * @returns {LayerModel|null} - */ - toggleLock(layerId) { - const layer = this.getById(layerId); - if (!layer || layer.isBackground) return null; - return this.updateLayer(layerId, { locked: !layer.locked }); - } - - // ── 排序 ── - - /** - * 将图层移到目标面板位置(0 = 顶部,背景图层固定在底部)。 - * @param {number} layerId - * @param {number} targetPanelIndex - * @returns {boolean} - */ - reorder(layerId, targetPanelIndex) { - const layer = this.getById(layerId); - if (!layer || layer.isBackground) return false; - - const overlays = this.getOverlayLayers(); - const fromIndex = overlays.findIndex(l => l.id === layerId); - if (fromIndex === -1 || overlays.length < 2) return false; - - let insertIndex = Math.max(0, Math.min(targetPanelIndex, overlays.length)); - if (fromIndex < insertIndex) insertIndex -= 1; - if (insertIndex === fromIndex) return false; - - // 从 _layers 中取出并插入 - const globalFrom = this._layers.indexOf(layer); - this._layers.splice(globalFrom, 1); - - // 找到插入位置的全局索引 - const targetOverlay = overlays[insertIndex]; - const globalTo = targetOverlay - ? this._layers.indexOf(targetOverlay) - : this._layers.length; - this._layers.splice(globalTo, 0, layer); - - this._renumberZIndex(); - this._notify('layers:reordered', { layer, fromIndex, toIndex: insertIndex }); - return true; - } - - /** - * 图层上移(向顶部方向)。 - * @param {number} layerId - */ - moveUp(layerId) { - const overlays = this.getOverlayLayers(); - const idx = overlays.findIndex(l => l.id === layerId); - if (idx <= 0) return; - this.reorder(layerId, idx - 1); - } - - /** - * 图层下移(向底部方向)。 - * @param {number} layerId - */ - moveDown(layerId) { - const overlays = this.getOverlayLayers(); - const idx = overlays.findIndex(l => l.id === layerId); - if (idx === -1 || idx >= overlays.length - 1) return; - this.reorder(layerId, idx + 2); - } - - /** - * 置顶。 - * @param {number} layerId - */ - bringToFront(layerId) { this.reorder(layerId, 0); } - - /** - * 置底(在背景之上)。 - * @param {number} layerId - */ - sendToBack(layerId) { - const overlays = this.getOverlayLayers(); - this.reorder(layerId, overlays.length - 1); - } - - // ── 重命名 ── - - /** - * @param {number} layerId - * @param {string} newName - * @returns {LayerModel|null} - */ - rename(layerId, newName) { - return this.updateLayer(layerId, { name: newName }); - } - - // ── 序列化 ── - - /** - * 导出为纯数据。 - * @returns {LayerModel[]} - */ - toJSON() { - return this._layers.map(l => ({ ...l, metadata: { ...l.metadata } })); - } - - /** - * 从纯数据恢复。 - * @param {LayerModel[]} data - */ - fromJSON(data) { - if (!Array.isArray(data)) return; - this._layers = data.map(l => ({ - ...l, - metadata: l.metadata ? { ...l.metadata } : {}, - })); - this._idCounter = this._layers.reduce((max, l) => Math.max(max, l.id || 0), 0); - this._renumberZIndex(); - this._notify('layers:restored'); - } - - // ── 内部 ── - - _renumberZIndex() { - let z = this._layers.length; - for (const layer of this._layers) { - layer.zIndex = z--; - } - } - - _defaultName(type) { - const names = { - background: '背景', - image: '图片', - text: '文字', - brush: '画笔', - mosaic: '马赛克', - shape: '形状', - group: '组合', - }; - return names[type] || '图层'; - } - - _notify(event, ...args) { - if (this._eventBus) { - this._eventBus.emit(event, ...args); - this._eventBus.emit('layers:changed', this._layers); - } - } -} diff --git a/plugins/Image-Toolbox/core/src/ToolManager.js b/plugins/Image-Toolbox/core/src/ToolManager.js index 3f23ce27e..b4a916a3b 100644 --- a/plugins/Image-Toolbox/core/src/ToolManager.js +++ b/plugins/Image-Toolbox/core/src/ToolManager.js @@ -1,7 +1,8 @@ -import eventBus from './EventBus.js'; +import eventBus from './EventBus.js'; import SelectModule from './modules/SelectModule.js'; import MosaicModule from './modules/MosaicModule.js'; import CropModule from './modules/CropModule.js'; +import ColorModule from './modules/ColorModule.js'; import BrushModule from './modules/BrushModule.js'; import EraserModule from './modules/EraserModule.js'; import TextModule from './modules/TextModule.js'; @@ -21,7 +22,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 +46,7 @@ class ToolManager { /** * 注入 host adapter。 - * @param {import('./interfaces/HostAdapter.js').default} host + * @param {object} host */ setHost(host) { this._host = host; @@ -69,7 +70,7 @@ class ToolManager { name: 'mosaic', label: '马赛克', icon: 'mosaic', - group: 'edit', + group: 'redact', shortcut: 'M', module: MosaicModule, defaultOptions: { mode: 'mosaic', drawMode: 'rect', mosaicSize: 12, blurRadius: 8, brushSize: 20 }, @@ -84,6 +85,16 @@ class ToolManager { module: CropModule, }); + this.registerTool({ + name: 'color', + label: '调色', + icon: 'color', + group: 'adjust', + shortcut: 'A', + module: ColorModule, + defaultOptions: { filterScope: 'all' }, + }); + this.registerTool({ name: 'brush', label: '画笔', @@ -221,6 +232,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/ToolRegistry.js b/plugins/Image-Toolbox/core/src/ToolRegistry.js deleted file mode 100644 index b09c50cd7..000000000 --- a/plugins/Image-Toolbox/core/src/ToolRegistry.js +++ /dev/null @@ -1,268 +0,0 @@ -/** - * ToolRegistry — 纯工具注册 / 切换 / 状态管理 - * - * 零 DOM、零 fabric、零平台依赖。 - * 只管理工具定义和当前激活状态,实际工具逻辑由外部 handler 实现。 - * - * @example - * const registry = new ToolRegistry({ eventBus }); - * registry.register({ name: 'brush', label: '画笔', shortcut: 'B', handler: myBrushHandler }); - * registry.activate('brush'); - * registry.getActive(); // 'brush' - */ -export default class ToolRegistry { - /** - * @param {object} options - * @param {import('./EventBus.js').EventBus} [options.eventBus] - */ - constructor({ eventBus = null } = {}) { - this._eventBus = eventBus; - this._tools = new Map(); // name -> ToolDefinition - this._activeTool = null; - this._toolOptions = {}; // name -> options - } - - // ── 注册 ── - - /** - * 注册工具。 - * @param {ToolDefinition} def - { name, label, group?, shortcut?, icon?, defaultOptions?, controls?, presets?, handler? } - */ - register(def) { - if (!def || !def.name) { - throw new Error('[ToolRegistry] 工具定义必须包含 name'); - } - - this._tools.set(def.name, { - name: def.name, - label: def.label || def.name, - group: def.group || 'edit', - shortcut: def.shortcut || null, - icon: def.icon || def.name, - defaultOptions: def.defaultOptions ? { ...def.defaultOptions } : {}, - controls: def.controls || [], - presets: def.presets || [], - handler: def.handler || null, - }); - - if (!this._toolOptions[def.name]) { - this._toolOptions[def.name] = { ...(def.defaultOptions || {}) }; - } - } - - /** - * 批量注册。 - * @param {ToolDefinition[]} defs - */ - registerAll(defs) { - defs.forEach(d => this.register(d)); - } - - /** - * 注销工具。 - * @param {string} name - */ - unregister(name) { - this._tools.delete(name); - delete this._toolOptions[name]; - if (this._activeTool === name) { - this._activeTool = null; - this._emit('tool:changed', { toolName: null }); - } - } - - // ── 查询 ── - - /** - * 获取所有已注册工具。 - * @returns {ToolDefinition[]} - */ - getAll() { - return Array.from(this._tools.values()); - } - - /** - * 按组获取工具。 - * @param {string} group - * @returns {ToolDefinition[]} - */ - getByGroup(group) { - return this.getAll().filter(t => t.group === group); - } - - /** - * 获取单个工具定义。 - * @param {string} name - * @returns {ToolDefinition|null} - */ - get(name) { - return this._tools.get(name) || null; - } - - /** - * 根据快捷键查找工具。 - * @param {string} key - 按键值(如 'B', 'V', 'M') - * @returns {ToolDefinition|null} - */ - getByShortcut(key) { - const upper = key.toUpperCase(); - return this.getAll().find(t => t.shortcut === upper) || null; - } - - /** - * 工具是否已注册。 - * @param {string} name - * @returns {boolean} - */ - has(name) { - return this._tools.has(name); - } - - // ── 激活 ── - - /** - * 激活工具。 - * @param {string} name - * @param {object} [options] - 运行时选项(合并到默认选项上) - * @returns {boolean} 是否成功激活 - */ - activate(name, options = {}) { - const def = this._tools.get(name); - if (!def) { - console.warn(`[ToolRegistry] 未知工具: ${name}`); - return false; - } - - // 通知当前工具停用 - const prevName = this._activeTool; - if (prevName && prevName !== name) { - const prev = this._tools.get(prevName); - if (prev?.handler?.deactivate) { - try { prev.handler.deactivate(); } catch (e) { console.error('[ToolRegistry]', e); } - } - this._emit('tool:deactivated', { toolName: prevName }); - } - - this._activeTool = name; - - // 合并选项 - this._toolOptions[name] = { - ...def.defaultOptions, - ...options, - }; - - // 通知新工具激活 - if (def.handler?.activate) { - try { def.handler.activate(this._toolOptions[name]); } catch (e) { console.error('[ToolRegistry]', e); } - } - - this._emit('tool:changed', { - toolName: name, - options: { ...this._toolOptions[name] }, - }); - - return true; - } - - /** - * 获取当前激活工具名。 - * @returns {string|null} - */ - getActive() { - return this._activeTool; - } - - /** - * 获取当前工具定义。 - * @returns {ToolDefinition|null} - */ - getActiveDef() { - return this._activeTool ? this._tools.get(this._activeTool) || null : null; - } - - // ── 选项 ── - - /** - * 获取工具当前选项。 - * @param {string} name - * @returns {object} - */ - getOptions(name) { - return { ...(this._toolOptions[name] || {}) }; - } - - /** - * 获取当前激活工具的选项。 - * @returns {object} - */ - getActiveOptions() { - return this._activeTool ? this.getOptions(this._activeTool) : {}; - } - - /** - * 更新工具选项。 - * @param {string} name - * @param {object} patch - */ - updateOptions(name, patch) { - if (!this._toolOptions[name]) { - this._toolOptions[name] = {}; - } - Object.assign(this._toolOptions[name], patch); - this._emit('tool:optionsChanged', { - toolName: name, - options: { ...this._toolOptions[name] }, - }); - } - - /** - * 更新当前工具选项。 - * @param {object} patch - */ - updateActiveOptions(patch) { - if (this._activeTool) { - this.updateOptions(this._activeTool, patch); - } - } - - // ── 控件 schema 查询 ── - - /** - * 获取工具的预设列表。 - * @param {string} name - * @returns {ToolPreset[]} - */ - getPresets(name) { - return this._tools.get(name)?.presets || []; - } - - /** - * 获取工具的控件 schema。 - * @param {string} name - * @returns {ToolControlSchema[]} - */ - getControls(name) { - return this._tools.get(name)?.controls || []; - } - - // ── 销毁 ── - - destroy() { - for (const [name, def] of this._tools) { - if (def.handler?.deactivate) { - try { def.handler.deactivate(); } catch (e) { /* ignore */ } - } - } - this._tools.clear(); - this._toolOptions = {}; - this._activeTool = null; - } - - // ── 内部 ── - - _emit(event, ...args) { - if (this._eventBus) { - this._eventBus.emit(event, ...args); - } - } -} diff --git a/plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js b/plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js new file mode 100644 index 000000000..9641464d2 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/adapters/BaseHostAdapter.js @@ -0,0 +1,470 @@ +/** + * BaseHostAdapter + * 宿主适配器基类,提取两个平台 adapter 的公共逻辑。 + * + * 子类需要覆盖以下方法: + * - platformId: 平台标识(如 'utools'、'ztools') + * - getDefaultHostName(): 默认宿主名称 + * - getHostApiPriority(): API 查找优先级数组 + * - getAppVersionPriority(): 版本获取方法优先级数组 + * - getHostDisplayName(api): 获取宿主显示名称 + * - normalizeUser(user): 用户数据标准化 + * - getRawUser(api): 获取原始用户数据 + * - getContactUrl(): 联系链接 + */ + +const _isUserValid = (user) => { + return user && (user.nickname || user.name || user.userName || user.username || user.avatar || user.avatarUrl || user.photo); +}; + +const _normalizeUser = (user) => { + if (!user) return null; + + const rawType = user.type || ''; + + return { + nickname: user.nickname || user.name || user.userName || user.username || '', + avatar: user.avatar || user.avatarUrl || user.photo || '', + type: rawType, + raw: user, + }; +}; + +class BaseHostAdapter { + constructor() { + this._api = this._getHostApi(); + this._isInitialized = false; + + this.platform = { + id: this.platformId, + name: this.getHostDisplayName(), + version: this.getHostAppVersion(), + runtime: 'electron', + }; + + this.user = { + getCurrentUser: () => this.getHostUser(), + fetchServerTemporaryToken: () => this.fetchUserServerTemporaryToken(), + }; + + this.storage = { + get: (key) => this.getStorageItem(key), + set: (key, value) => this.setStorageItem(key, value), + remove: (key) => this.removeStorageItem(key), + }; + + this.file = { + pickImage: () => this.pickImage(), + readImageFile: (filePath) => this.readImageFile(filePath), + saveImage: (data, suggestedName) => this.saveImage(data, suggestedName), + }; + + this.clipboard = { + writeImage: (data) => this.copyImage(data), + readText: () => this.readClipboard(), + writeText: (text) => this.writeClipboard(text), + }; + + this.window = { + setHeight: (height) => this.setWindowHeight(height), + setWidth: (width) => this.setWindowWidth(width), + setTitle: (title) => this.setWindowTitle(title), + }; + + this.system = { + openExternal: (url) => this.openHostExternal(url), + getSystemFonts: () => this.getSystemFonts(), + showNotification: (message, type) => this.showNotification(message, type), + }; + + this.lifecycle = { + onEnter: (callback) => this.onPluginEnter(callback), + onExit: (callback) => this.onPluginOut(callback), + }; + } + + // ═══ 平台特定覆盖点 ═══ + + get platformId() { + return 'unknown'; + } + + getDefaultHostName() { + return 'Unknown'; + } + + getHostApiPriority() { + return []; + } + + getAppVersionPriority() { + return []; + } + + getHostDisplayName(api) { + const target = api || this._api; + if (!target) return this.getDefaultHostName(); + + try { + if (typeof target.getAppName === 'function') { + const name = target.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 获取宿主名称失败:`, e); + } + + return this.getDefaultHostName(); + } + + normalizeUser(user) { + return _normalizeUser(user); + } + + getRawUser(api) { + const target = api || this._api; + try { + if (target && typeof target.getUser === 'function') return target.getUser(); + if (target && typeof target.getUserInfo === 'function') return target.getUserInfo(); + if (typeof window !== 'undefined' && typeof window.getHostUser === 'function') return window.getHostUser(); + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 获取宿主用户失败:`, e); + } + return null; + } + + getContactUrl() { + return ''; + } + + // ═══ 内部方法 ═══ + + _getHostApi() { + const priorities = this.getHostApiPriority(); + + if (typeof window !== 'undefined') { + for (const key of priorities) { + if (window[key]) return window[key]; + } + } + + if (typeof globalThis !== 'undefined') { + for (const key of priorities) { + if (globalThis[key]) return globalThis[key]; + } + } + + return null; + } + + // ═══ 公共方法 ═══ + + get isInitialized() { + return this._isInitialized; + } + + get name() { + return this.platform.id; + } + + /** + * 设置宿主窗口高度 + */ + setWindowHeight(height) { + if (this._api && typeof this._api.setExpendHeight === 'function') { + this._api.setExpendHeight(height); + } + } + + /** + * 设置宿主窗口宽度 + */ + setWindowWidth(width) { + if (this._api && typeof this._api.setExpendWidth === 'function') { + this._api.setExpendWidth(width); + } + } + + /** + * 设置主窗口标题 + */ + setWindowTitle(title) { + if (this._api && typeof this._api.setMainWindowTitle === 'function') { + this._api.setMainWindowTitle(title); + } + } + + /** + * 插件进入回调 + */ + onPluginEnter(callback) { + if (this._api && typeof this._api.onPluginEnter === 'function') { + this._api.onPluginEnter(callback); + } + return () => {}; + } + + /** + * 插件退出回调 + */ + onPluginOut(callback) { + if (this._api && typeof this._api.onPluginOut === 'function') { + this._api.onPluginOut(callback); + } + return () => {}; + } + + /** + * 显示文件选择对话框 + */ + showOpenDialog(options) { + if (this._api && typeof this._api.showOpenDialog === 'function') { + return this._api.showOpenDialog(options); + } + return null; + } + + /** + * 显示保存对话框 + */ + showSaveDialog(options) { + if (this._api && typeof this._api.showSaveDialog === 'function') { + return this._api.showSaveDialog(options); + } + return null; + } + + /** + * 选择图片文件并返回 dataURL + */ + pickImage() { + if (typeof window !== 'undefined' && typeof window.showOpenImageDialog === 'function') { + // showOpenImageDialog 返回文件路径字符串或 null + const filePath = window.showOpenImageDialog(); + return filePath ? this.readImageFile(filePath) : null; + } + return null; + } + + /** + * 读取图片文件为 dataURL + */ + readImageFile(filePath) { + if (typeof window !== 'undefined' && typeof window.readImageFile === 'function') { + return window.readImageFile(filePath); + } + return null; + } + + /** + * 读取文件(别名) + */ + readFile(filePath) { + return this.readImageFile(filePath); + } + + /** + * 保存图片到文件 + */ + saveImage(data, suggestedName = 'edited.png') { + if (typeof window === 'undefined') return false; + if (typeof window.showSaveImageDialog !== 'function' || typeof window.writeImageFile !== 'function') return false; + + const filePath = window.showSaveImageDialog(suggestedName); + if (!filePath) return false; + + return !!window.writeImageFile(filePath, data); + } + + /** + * 显示保存对话框(仅返回路径) + */ + showSaveImageDialog(suggestedName = 'edited.png') { + if (typeof window !== 'undefined' && typeof window.showSaveImageDialog === 'function') { + return window.showSaveImageDialog(suggestedName); + } + return null; + } + + /** + * 写入图片文件 + */ + writeImageFile(filePath, data) { + if (typeof window !== 'undefined' && typeof window.writeImageFile === 'function') { + return !!window.writeImageFile(filePath, data); + } + return false; + } + + /** + * 复制图片到剪贴板 + */ + copyImage(data) { + if (typeof window !== 'undefined' && typeof window.copyImageToClipboard === 'function') { + window.copyImageToClipboard(data); + return true; + } + return false; + } + + /** + * 写入文本到剪贴板 + */ + writeClipboard(text) { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text).then(() => true); + } + return Promise.resolve(false); + } + + /** + * 从剪贴板读取文本 + */ + readClipboard() { + if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) { + return navigator.clipboard.readText(); + } + return Promise.resolve(null); + } + + /** + * 显示通知 + */ + showNotification(message, type) { + if (this._api && typeof this._api.showNotification === 'function') { + this._api.showNotification(message, type); + } + } + + /** + * 获取本地文件 + */ + fetchLocalFile(filePath) { + if (this._api && typeof this._api.fetchLocalFile === 'function') { + return this._api.fetchLocalFile(filePath); + } + return null; + } + + /** + * 获取宿主应用版本 + */ + getHostAppVersion() { + const priorities = this.getAppVersionPriority(); + for (const methodName of priorities) { + if (this._api && typeof this._api[methodName] === 'function') { + try { + const version = this._api[methodName](); + if (version) return version; + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 获取版本失败 (${methodName}):`, e); + } + } + } + return 'unknown'; + } + + /** + * 获取宿主名称 + */ + getHostName() { + return this.platform?.name || this.getHostDisplayName(this._api); + } + + /** + * 获取宿主用户信息 + */ + getHostUser() { + const rawUser = this.getRawUser(this._api); + return this.normalizeUser(rawUser); + } + + /** + * 获取用户服务器临时 token + */ + fetchUserServerTemporaryToken() { + if (this._api && typeof this._api.fetchUserServerTemporaryToken === 'function') { + return this._api.fetchUserServerTemporaryToken(); + } + return Promise.resolve(null); + } + + /** + * 获取存储项 + */ + getStorageItem(key) { + const storage = this._api?.dbStorage; + if (storage && typeof storage.getItem === 'function') return storage.getItem(key); + if (typeof localStorage !== 'undefined') return localStorage.getItem(key); + return null; + } + + /** + * 设置存储项 + */ + setStorageItem(key, value) { + const storage = this._api?.dbStorage; + if (storage && typeof storage.setItem === 'function') { + storage.setItem(key, value); + return; + } + if (typeof localStorage !== 'undefined') localStorage.setItem(key, value); + } + + /** + * 删除存储项 + */ + removeStorageItem(key) { + const storage = this._api?.dbStorage; + if (storage && typeof storage.removeItem === 'function') { + storage.removeItem(key); + return; + } + if (typeof localStorage !== 'undefined') localStorage.removeItem(key); + } + + /** + * 获取系统字体 + */ + getSystemFonts() { + if (typeof window !== 'undefined' && typeof window.getSystemFonts === 'function') { + return window.getSystemFonts(); + } + return []; + } + + /** + * 异步获取系统字体 + */ + getSystemFontsAsync() { + if (typeof window !== 'undefined' && typeof window.getSystemFontsAsync === 'function') { + return window.getSystemFontsAsync(); + } + return Promise.resolve([]); + } + + /** + * 打开外部链接 + */ + openHostExternal(url) { + if (!url) return false; + + try { + if (this._api && typeof this._api.shellOpenExternal === 'function') { + this._api.shellOpenExternal(url); + return true; + } + } catch (e) { + console.warn(`[${this.platformId}HostAdapter] 使用宿主打开外部链接失败:`, e); + } + + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + } + + return false; + } +} + +export default BaseHostAdapter; diff --git a/plugins/Image-Toolbox/core/src/app/App.js b/plugins/Image-Toolbox/core/src/app/App.js new file mode 100644 index 000000000..6e62ec597 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/app/App.js @@ -0,0 +1,595 @@ +/** + * App — 图片工具箱主入口 + * 跨平台共享的应用初始化逻辑。 + * + * 各平台 index.js 只需传入 HostAdapter 构造函数即可复用。 + */ + +import { + eventBus, + CanvasManager, + LayerManager, + HistoryManager, + ToolManager, +} from '../runtime/fabric.js'; + +import Toolbar from '../ui/Toolbar.js'; +import OptionsBar from '../ui/OptionsBar.js'; +import SidePanelTabs from '../ui/SidePanelTabs.js'; +import PropertyPanel from '../ui/PropertyPanel.js'; +import LayerPanel from '../ui/LayerPanel.js'; +import StatusBar from '../ui/StatusBar.js'; +import AccountPage, { + EDITOR_BARS_LAYOUT_KEY, + EDITOR_BARS_LAYOUTS, + EDITOR_SIDE_PANEL_POSITION_KEY, + EDITOR_SIDE_PANEL_POSITIONS, + TOOLBAR_COLLAPSED_KEY, + TOOLBAR_COLLAPSED, +} from '../ui/AccountPage.js'; +import { initTheme } from '../utils/theme.js'; + +// ═══════════════════════════════════════ +// 应用入口 +// ═══════════════════════════════════════ + +class App { + constructor(HostAdapter) { + this.HostAdapter = HostAdapter; + this.canvasManager = null; + this.layerManager = null; + this.historyManager = null; + this.toolManager = null; + + this.toolbar = null; + this.optionsBar = null; + this.sidePanelTabs = null; + this.propertyPanel = null; + this.layerPanel = null; + this.statusBar = null; + this.accountPage = null; + this.hostAdapter = null; + this._destroyed = false; + this._boundGlobalListeners = null; + this._externalSourceTimer = null; + this._onPluginEnterCallback = null; + + this._init(); + } + + _init() { + // 确保 Fabric.js 已加载 + if (typeof fabric === 'undefined') { + console.error('[App] Fabric.js 未加载,请检查 CDN'); + document.body.innerHTML = '
Fabric.js 加载失败,请检查网络连接
'; + return; + } + + try { + initTheme(); + this._applyEditorBarsLayout(this._getEditorBarsLayout()); + this._applyEditorSidePanelPosition(this._getEditorSidePanelPosition()); + this._applyToolbarCollapsed(this._getToolbarCollapsed()); + + // 1. 初始化画布管理器 + this.canvasManager = new CanvasManager('fabric-canvas'); + this.canvasManager.init({ + width: 800, + height: 600, + backgroundColor: 'transparent', + preserveObjectStacking: true, + selection: true, + stopContextMenu: true, + fireRightClick: true, + }); + + // 2. 初始化图层管理器 + this.layerManager = new LayerManager(this.canvasManager); + + // 3. 初始化历史记录 + this.historyManager = new HistoryManager(this.canvasManager, 30); + + // 4. 初始化工具管理器(注入 host adapter) + this.hostAdapter = new this.HostAdapter(); + this.toolManager = new ToolManager(this.canvasManager, this.historyManager, { + host: this.hostAdapter, + }); + + // 5. 初始化 UI 组件 + this.toolbar = new Toolbar( + document.getElementById('toolbar'), + this.toolManager, + this.hostAdapter + ); + + this.optionsBar = new OptionsBar( + document.getElementById('optionsbar'), + this.toolManager + ); + + this.sidePanelTabs = new SidePanelTabs( + document.getElementById('panel-area'), + this.layerManager + ); + + this.propertyPanel = new PropertyPanel( + document.getElementById('property-panel'), + this.toolManager, + this.canvasManager, + this.layerManager + ); + + this.layerPanel = new LayerPanel( + document.getElementById('layer-panel'), + this.layerManager + ); + + this.statusBar = new StatusBar( + document.getElementById('statusbar'), + this.canvasManager, + this.layerManager + ); + + this.accountPage = new AccountPage( + document.getElementById('account-page'), + document.getElementById('app'), + this.sidePanelTabs, + this.hostAdapter + ); + + // 6. 绑定全局事件 + this._bindGlobalEvents(); + + // 7. 默认激活选择工具 + this.toolManager.activateTool('select'); + + // 8. 检查是否有外部传入的图片源 + this._checkExternalSource(); + + console.log('[App] 图片工具箱初始化完成'); + } catch (err) { + console.error('[App] 初始化失败:', err); + } + } + + _bindGlobalEvents() { + // ═══ 图片导入 ═══ + + // 拖拽导入 + const onDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const onDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + this._loadImage(file); + } + } + }; + + // 粘贴导入 + const onPaste = (e) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + this._loadImage(file); + } + break; + } + } + }; + + // 文件选择对话框(宿主 API) + document.getElementById('welcome-btn')?.addEventListener('click', () => { + if (typeof window.showOpenImageDialog === 'function') { + // showOpenImageDialog 返回文件路径字符串或 null + const filePath = window.showOpenImageDialog(); + if (filePath) { + const dataURL = window.readImageFile(filePath); + if (dataURL) { + this._loadImage(dataURL); + } + } + } else { + // 降级方案:浏览器 file input + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/png,image/jpeg,image/webp,image/bmp,image/gif,image/svg+xml'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (file) this._loadImage(file); + }; + input.click(); + } + }); + + // 点击欢迎图标导入 + document.getElementById('welcome-drop')?.addEventListener('click', () => { + document.getElementById('welcome-btn')?.click(); + }); + + // ═══ 缩放控制 ═══ + document.getElementById('zoom-in')?.addEventListener('click', () => { + this.canvasManager?.zoomIn(); + this._updateZoomLabel(); + }); + + document.getElementById('zoom-out')?.addEventListener('click', () => { + this.canvasManager?.zoomOut(); + this._updateZoomLabel(); + }); + + document.getElementById('zoom-value')?.addEventListener('click', () => { + this.canvasManager?.resetZoom(); + this._updateZoomLabel(); + }); + + eventBus.on('canvas:zoomIn', () => { + this.canvasManager?.zoomIn(); + this._updateZoomLabel(); + }); + eventBus.on('canvas:zoomOut', () => { + this.canvasManager?.zoomOut(); + this._updateZoomLabel(); + }); + + // ═══ 导出 ═══ + eventBus.on('export:requested', async (format) => { + if (format === 'clipboard') { + await this.toolManager?.export('clipboard'); + } else { + const exportModule = this.toolManager?.getModule('export'); + if (exportModule) { + await exportModule.exportToFile(); + } + } + }); + + // ═══ 撤销 / 重做 ═══ + eventBus.on('history:undo', () => { + this.historyManager?.undo(); + }); + eventBus.on('history:redo', () => { + this.historyManager?.redo(); + }); + + // ═══ 编辑器布局偏好 ═══ + eventBus.on('sidePanel:layoutChanged', (layout) => { + this.sidePanelTabs?.applyLayout(layout, false); + }); + + eventBus.on('editorBars:layoutChanged', (layout) => { + this._applyEditorBarsLayout(layout); + }); + + eventBus.on('editorSidePanel:positionChanged', (position) => { + this._applyEditorSidePanelPosition(position); + }); + + eventBus.on('toolbar:collapsedChanged', (value) => { + this._applyToolbarCollapsed(value); + }); + + // ═══ 快捷键 ═══ + const onKeyDown = (e) => { + // Ctrl+Z 撤销 + if (e.ctrlKey && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + this.historyManager?.undo(); + return; + } + + // Ctrl+Shift+Z 或 Ctrl+Y 重做 + if ((e.ctrlKey && e.shiftKey && e.key === 'z') || (e.ctrlKey && !e.shiftKey && e.key === 'y')) { + e.preventDefault(); + this.historyManager?.redo(); + return; + } + + // Delete 删除选中物件 + if (e.key === 'Delete' || e.key === 'Backspace') { + const active = this.canvasManager?.getActiveObject(); + if (active && active.isEditing) return; + if (active && active.excludeFromHistory) return; + this.historyManager?.saveState(); + this.canvasManager?.removeActiveObject(); + return; + } + + // 工具快捷键 + if (!e.ctrlKey && !e.metaKey) { + const activeElement = document.activeElement; + const tagName = activeElement?.tagName?.toUpperCase(); + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return; + if (activeElement?.isContentEditable) return; + + 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) { + e.preventDefault(); + this.toolManager?.activateTool(tool.name); + } + } + }; + + // ═══ 滚轮缩放 ═══ + const onWheel = (e) => { + if (!this.canvasManager?.canvas) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + const pointer = this.canvasManager.canvas.getPointer(e); + this.canvasManager.zoomIn(delta, new fabric.Point(pointer.x, pointer.y)); + this._updateZoomLabel(); + }; + + // 注册所有全局监听器并保存引用以便销毁时移除 + document.addEventListener('dragover', onDragOver); + document.addEventListener('drop', onDrop); + document.addEventListener('paste', onPaste); + document.addEventListener('keydown', onKeyDown); + document.getElementById('canvas-area')?.addEventListener('wheel', onWheel, { passive: false }); + + this._boundGlobalListeners = { onDragOver, onDrop, onPaste, onKeyDown, onWheel }; + + // ═══ 画布操作后自动保存历史 ═══ + eventBus.on('canvas:objectModified', (target) => { + if (target?.excludeFromHistory) return; + + if (this._saveTimer) clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this.historyManager?.saveState(); + }, 300); + }); + + eventBus.on('layer:reorderWillChange', () => { + this.historyManager?.saveState(); + }); + + // ═══ 工具自动切换 ═══ + eventBus.on('tool:requestChange', (toolName) => { + this.toolManager?.activateTool(toolName); + }); + + // ═══ Toast ═══ + eventBus.on('toast:show', ({ message, type }) => { + this._showToast(message, type); + }); + + // ═══ 插件重复进入 ═══ + // preload.js 已在插件加载时注册了 onPluginEnter,将首次进入的图片 + // payload 暂存到 window.__imageSource,由 _checkExternalSource() 拾取。 + // 此处重新注册 onPluginEnter 处理后续进入(覆盖 preload 中的回调)。 + this._onPluginEnterCallback = ({ code, type, payload, from }) => { + console.log('[App] onPluginEnter:', { code, type, from, payload }); + if (code === 'image-edit') { + const source = this._getExternalImageSource(type, payload); + console.log('[App] 外部图片源:', source ? 'ok' : 'empty', { type, from }); + if (source) { + if (window.__imageSource === source) { + window.__imageSource = null; + } + this._loadImage(source); + } else if (type === 'img' && window.__imageSource) { + const fallbackSource = window.__imageSource; + if (fallbackSource) { + window.__imageSource = null; + this._loadImage(fallbackSource); + } + } + + this.hostAdapter?.setWindowHeight(560); + } + }; + this.hostAdapter?.onPluginEnter(this._onPluginEnterCallback); + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + + if (this._saveTimer) { + clearTimeout(this._saveTimer); + this._saveTimer = null; + } + + if (this._externalSourceTimer) { + clearTimeout(this._externalSourceTimer); + this._externalSourceTimer = null; + } + + // 移除全局事件监听器 + if (this._boundGlobalListeners) { + const { onDragOver, onDrop, onPaste, onKeyDown, onWheel } = this._boundGlobalListeners; + document.removeEventListener('dragover', onDragOver); + document.removeEventListener('drop', onDrop); + document.removeEventListener('paste', onPaste); + document.removeEventListener('keydown', onKeyDown); + document.getElementById('canvas-area')?.removeEventListener('wheel', onWheel); + this._boundGlobalListeners = null; + } + + [ + this.accountPage, + this.toolbar, + this.optionsBar, + this.sidePanelTabs, + this.propertyPanel, + this.layerPanel, + this.statusBar, + ].forEach(component => component?.destroy?.()); + + this.toolManager?.destroy?.(); + this.canvasManager?.destroy?.(); + } + + _showToast(message, type = 'success') { + const existing = document.querySelector('.toast'); + if (existing) existing.remove(); + + const icons = { + success: '', + error: '', + }; + + const toast = document.createElement('div'); + toast.className = `toast toast--${type}`; + toast.innerHTML = icons[type] || ''; + const textEl = document.createElement('span'); + textEl.textContent = message; + toast.appendChild(textEl); + document.body.appendChild(toast); + + toast.addEventListener('animationend', () => { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }); + } + + async _loadImage(source) { + try { + document.getElementById('welcome')?.classList.add('hidden'); + document.getElementById('canvas-container')?.classList.remove('hidden'); + document.getElementById('zoom-control')?.classList.remove('hidden'); + + await this.canvasManager.loadImage(source); + this.canvasManager.fitToCanvas(40); + + this.layerManager.syncLayers(); + + this.historyManager.clear(); + this.historyManager.saveState(); + + this.hostAdapter?.setWindowHeight(560); + } catch (err) { + console.error('[App] 图片加载失败:', err); + document.getElementById('welcome')?.classList.remove('hidden'); + document.getElementById('canvas-container')?.classList.add('hidden'); + document.getElementById('zoom-control')?.classList.add('hidden'); + eventBus.emit('toast:show', { message: '图片加载失败,请重试', type: 'error' }); + } + } + + _getExternalImageSource(type, payload) { + if (typeof window.getImageSourceFromPluginPayload === 'function') { + try { + const source = window.getImageSourceFromPluginPayload(type, payload); + if (source) return source; + } catch (e) { + console.error('[App] 解析外部图片 payload 失败:', e); + } + } + + if (type !== 'file' && type !== 'files') return null; + + const files = Array.isArray(payload) ? payload : [payload]; + const fileInfo = files.find(item => item && item.path); + if (!fileInfo || typeof window.readImageFile !== 'function') return null; + + try { + return window.readImageFile(fileInfo.path); + } catch (e) { + console.error('[App] 文件匹配读取失败:', e); + return null; + } + } + + _checkExternalSource() { + // 使用事件驱动 + 轮询降级:先检查是否已有图片源, + // 如果没有则设置一个更长的轮询窗口(10s),等待 preload 回调写入。 + const check = () => { + if (window.__imageSource) { + const source = window.__imageSource; + window.__imageSource = null; + console.log('[App] _checkExternalSource 发现图片源,开始加载'); + if (source) { + this._loadImage(source); + } + return; + } + // 继续等待,直到超时 + this._externalSourceTimer = setTimeout(check, 200); + }; + + // 先等待 100ms 再开始检查,给 preload 回调留出时间 + this._externalSourceTimer = setTimeout(check, 100); + + // 安全兜底:10 秒后清理定时器 + setTimeout(() => { + if (this._externalSourceTimer) { + clearTimeout(this._externalSourceTimer); + this._externalSourceTimer = null; + console.log('[App] _checkExternalSource 超时(10s),未发现外部图片源'); + } + }, 10000); + } + + _updateZoomLabel() { + const label = document.getElementById('zoom-value'); + if (label && this.canvasManager) { + label.textContent = Math.round(this.canvasManager.zoomLevel * 100) + '%'; + } + } + + _getEditorBarsLayout() { + const saved = localStorage.getItem(EDITOR_BARS_LAYOUT_KEY); + return Object.values(EDITOR_BARS_LAYOUTS).includes(saved) ? saved : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + } + + _applyEditorBarsLayout(layout) { + const normalized = Object.values(EDITOR_BARS_LAYOUTS).includes(layout) + ? layout + : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + + document.getElementById('app')?.classList.toggle( + 'app--bars-swapped', + normalized === EDITOR_BARS_LAYOUTS.STATUS_TOP + ); + } + + _getEditorSidePanelPosition() { + const saved = localStorage.getItem(EDITOR_SIDE_PANEL_POSITION_KEY); + return Object.values(EDITOR_SIDE_PANEL_POSITIONS).includes(saved) ? saved : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + } + + _applyEditorSidePanelPosition(position) { + const normalized = Object.values(EDITOR_SIDE_PANEL_POSITIONS).includes(position) + ? position + : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + + document.getElementById('app')?.classList.toggle( + 'app--panel-left', + normalized === EDITOR_SIDE_PANEL_POSITIONS.LEFT + ); + } + + _getToolbarCollapsed() { + const saved = localStorage.getItem(TOOLBAR_COLLAPSED_KEY); + return Object.values(TOOLBAR_COLLAPSED).includes(saved) ? saved : TOOLBAR_COLLAPSED.COLLAPSED; + } + + _applyToolbarCollapsed(value) { + const normalized = Object.values(TOOLBAR_COLLAPSED).includes(value) + ? value + : TOOLBAR_COLLAPSED.COLLAPSED; + + document.getElementById('app')?.classList.toggle( + 'app--toolbar-expanded', + normalized === TOOLBAR_COLLAPSED.EXPANDED + ); + } +} + +export default App; diff --git a/plugins/Image-Toolbox/core/src/identity/IdentityClient.js b/plugins/Image-Toolbox/core/src/identity/IdentityClient.js new file mode 100644 index 000000000..2af9e76c8 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/identity/IdentityClient.js @@ -0,0 +1,326 @@ +/** + * IdentityClient — 轻量认证客户端 + * + * 负责: + * - Token 存储 / 检查 / 刷新(与 Teaven Identity 交互) + * - 用户档案获取 / 修改(与业务后端交互) + * + * 登录方式: + * - 邮箱验证码(requestEmailCode + loginWithEmailCode) + * - uTools signed-plugin(loginWithUTools) + */ + +const DEFAULT_IDENTITY_BASE = 'https://identity.moruteaven.com'; +const DEFAULT_API_BASE = 'https://api.image-toolbox.moruteaven.com'; +const DEFAULT_CLIENT_ID = 'image-toolbox'; +const TOKEN_KEY = 'image_toolbox_tokens'; + +// 简单的 Base64 编码/解码,用于降低 localStorage 中 token 的明文可见性 +// 注意:这不是加密,仅做轻量混淆以防 XSS 直接读取明文 token +const _encode = (str) => { + try { return btoa(unescape(encodeURIComponent(str))); } catch { return str; } +}; +const _decode = (str) => { + try { return decodeURIComponent(escape(atob(str))); } catch { return str; } +}; + +class IdentityClient { + constructor(options = {}) { + this.identityBaseUrl = (options.identityBaseUrl || DEFAULT_IDENTITY_BASE).replace(/\/+$/, ''); + this.apiBaseUrl = (options.apiBaseUrl || DEFAULT_API_BASE).replace(/\/+$/, ''); + this.clientId = options.clientId || DEFAULT_CLIENT_ID; + this.tokenKey = options.tokenKey || TOKEN_KEY; + } + + // ═══════════════════════════════════════ + // Token 管理 + // ═══════════════════════════════════════ + + _getStoredTokens() { + try { + const raw = localStorage.getItem(this.tokenKey); + if (!raw) return null; + const decoded = _decode(raw); + const parsed = JSON.parse(decoded); + if (parsed && typeof parsed.accessToken === 'string' && typeof parsed.refreshToken === 'string') { + return parsed; + } + } catch {} + return null; + } + + _setTokens(tokens) { + const encoded = _encode(JSON.stringify(tokens)); + localStorage.setItem(this.tokenKey, encoded); + } + + _clearTokens() { + localStorage.removeItem(this.tokenKey); + } + + isAuthenticated() { + const tokens = this._getStoredTokens(); + return !!(tokens && tokens.accessToken && tokens.accessTokenExpiresAt > Date.now()); + } + + _getAuthHeader() { + const tokens = this._getStoredTokens(); + if (!tokens?.accessToken) return null; + return `Bearer ${tokens.accessToken}`; + } + + // ═══════════════════════════════════════ + // Identity API(登录 / Token 刷新) + // ═══════════════════════════════════════ + + async _identityRequest(path, options = {}) { + const url = new URL(path, this.identityBaseUrl + '/'); + if (options.query) { + for (const [k, v] of Object.entries(options.query)) { + if (v !== undefined) url.searchParams.set(k, v); + } + } + + const headers = { Accept: 'application/json', ...options.headers }; + let body; + if (options.body) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + + const res = await fetch(url.toString(), { + method: options.method || 'GET', + headers, + body, + }); + + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + + if (!res.ok) { + throw { code: data?.code || 'HTTP_ERROR', message: data?.message || `HTTP ${res.status}`, status: res.status }; + } + + // 统一响应壳 { code, message, data, timestamp } + if (data && typeof data.code === 'string') { + if (data.code !== 'OK') { + throw data; + } + return data.data; + } + return data; + } + + /** 请求邮箱验证码 */ + requestEmailCode(email, purpose = 'login') { + return this._identityRequest('/auth/email/redirect', { + query: { email, purpose }, + }); + } + + /** 邮箱验证码登录 */ + async loginWithEmailCode(email, code, purpose = 'login') { + const result = await this._identityRequest('/auth/login', { + method: 'POST', + body: { + provider: 'email', + payload: { email, code, purpose }, + clientId: this.clientId, + }, + }); + this._setTokens(result); + return result; + } + + /** uTools signed-plugin 登录 */ + async loginWithUTools(accessToken, deviceId) { + const result = await this._identityRequest('/auth/login', { + method: 'POST', + body: { + provider: 'utools', + payload: { accessToken }, + clientId: this.clientId, + deviceId, + }, + }); + this._setTokens(result); + return result; + } + + /** 刷新 Token */ + async refresh() { + const tokens = this._getStoredTokens(); + if (!tokens?.refreshToken) { + throw { code: 'REFRESH_TOKEN_MISSING', message: 'Refresh token is missing' }; + } + + try { + const result = await this._identityRequest('/auth/refresh', { + method: 'POST', + body: { refreshToken: tokens.refreshToken }, + }); + this._setTokens(result); + return result; + } catch (e) { + this._clearTokens(); + throw e; + } + } + + /** 注销 */ + async logout() { + try { + await this._identityRequest('/auth/logout', { + method: 'POST', + headers: { Authorization: this._getAuthHeader() }, + }); + } catch {} + this._clearTokens(); + } + + // ═══════════════════════════════════════ + // 业务后端 API(用户档案) + // ═══════════════════════════════════════ + + async _apiRequest(path, options = {}) { + const url = new URL(path, this.apiBaseUrl + '/'); + const headers = { Accept: 'application/json' }; + let body; + if (options.body) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + + const authHeader = this._getAuthHeader(); + if (authHeader) { + headers['Authorization'] = authHeader; + } + + const res = await fetch(url.toString(), { + method: options.method || 'GET', + headers, + body, + }); + + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + + // 401 → 尝试刷新 + if (res.status === 401) { + try { + await this.refresh(); + // 重试一次 + const newAuth = this._getAuthHeader(); + if (newAuth) headers['Authorization'] = newAuth; + const retryRes = await fetch(url.toString(), { method: options.method || 'GET', headers, body }); + const retryText = await retryRes.text(); + let retryData = null; + if (retryText) { + try { retryData = JSON.parse(retryText); } catch { retryData = retryText; } + } + if (retryData && typeof retryData.code === 'string' && retryData.code === 'OK') { + return retryData.data; + } + throw retryData || { code: 'HTTP_ERROR', message: `HTTP ${retryRes.status}` }; + } catch { + this._clearTokens(); + throw { code: 'UNAUTHORIZED', message: 'Token expired, please login again' }; + } + } + + if (!res.ok) { + throw data || { code: 'HTTP_ERROR', message: `HTTP ${res.status}`, status: res.status }; + } + + if (data && typeof data.code === 'string') { + if (data.code !== 'OK') throw data; + return data.data; + } + return data; + } + + /** 将头像相对路径转为完整 URL */ + _resolveAvatarUrl(profile) { + if (!profile) return profile; + if (profile.avatar && profile.avatar.startsWith('/api/avatars/')) { + return { ...profile, avatar: this.apiBaseUrl + profile.avatar }; + } + return profile; + } + + /** 获取用户档案(首次访问自动创建) */ + async getProfile() { + const profile = await this._apiRequest('/api/me'); + return this._resolveAvatarUrl(profile); + } + + /** 更新昵称 */ + updateProfile(patch) { + return this._apiRequest('/api/me', { method: 'PATCH', body: patch }); + } + + /** 上传头像文件(multipart/form-data) */ + async uploadAvatar(file) { + const url = new URL('/api/me/avatar', this.apiBaseUrl + '/'); + const formData = new FormData(); + formData.append('file', file); + + const headers = {}; + const authHeader = this._getAuthHeader(); + if (authHeader) { + headers['Authorization'] = authHeader; + } + + const res = await fetch(url.toString(), { + method: 'POST', + headers, + body: formData, + }); + + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + + // 401 → 尝试刷新 + if (res.status === 401) { + try { + await this.refresh(); + const newAuth = this._getAuthHeader(); + if (newAuth) headers['Authorization'] = newAuth; + const retryRes = await fetch(url.toString(), { method: 'POST', headers, body: formData }); + const retryText = await retryRes.text(); + let retryData = null; + if (retryText) { + try { retryData = JSON.parse(retryText); } catch { retryData = retryText; } + } + if (retryData && typeof retryData.code === 'string' && retryData.code === 'OK') { + return this._resolveAvatarUrl(retryData.data); + } + throw retryData || { code: 'HTTP_ERROR', message: `HTTP ${retryRes.status}` }; + } catch { + this._clearTokens(); + throw { code: 'UNAUTHORIZED', message: 'Token expired, please login again' }; + } + } + + if (!res.ok) { + throw data || { code: 'HTTP_ERROR', message: `HTTP ${res.status}`, status: res.status }; + } + + if (data && typeof data.code === 'string') { + if (data.code !== 'OK') throw data; + return this._resolveAvatarUrl(data.data); + } + return data; + } +} + +export default IdentityClient; diff --git a/plugins/Image-Toolbox/core/src/index.js b/plugins/Image-Toolbox/core/src/index.js index 6d041dfee..049c03117 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/interfaces/EditorEngineAdapter.js b/plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js deleted file mode 100644 index 2a9329e76..000000000 --- a/plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * EditorEngineAdapter 接口定义(JSDoc) - * - * 渲染引擎适配器抽象。Fabric.js 是默认实现,后续可替换为 CanvasKit / WebGL / 原生等。 - * - * @interface EditorEngineAdapter - */ -export default class EditorEngineAdapter { - /** - * 初始化引擎。 - * @param {HTMLCanvasElement|object} target - canvas 元素或平台等价物 - * @param {object} [options] - 引擎配置 - * @returns {Promise} - */ - async init(target, options = {}) {} - - /** 销毁引擎,释放资源。 */ - destroy() {} - - /** - * 加载图片。 - * @param {string|File|Blob} source - * @returns {Promise} engineId - */ - async loadImage(source) { throw new Error('Not implemented'); } - - /** - * 替换当前背景图片。 - * @param {string} source - * @returns {Promise} engineId - */ - async replaceImage(source) { throw new Error('Not implemented'); } - - /** - * 导出画布为 dataURL。 - * @param {object} [options] - { format, quality, multiplier } - * @returns {string|null} - */ - exportToDataURL(options = {}) { return null; } - - // ── 物件操作 ── - - /** @returns {{ engineId: string, type: string, name?: string }[]} */ - getObjects() { return []; } - - /** - * @param {string} engineId - * @returns {object|null} - */ - getObject(engineId) { return null; } - - /** - * @param {object} input - { type, options } - * @returns {Promise} engineId - */ - async addObject(input) { throw new Error('Not implemented'); } - - /** - * @param {string} engineId - * @param {object} patch - */ - updateObject(engineId, patch) {} - - /** @param {string} engineId */ - removeObject(engineId) {} - - /** - * @param {string} engineId - * @param {number} targetIndex - */ - reorderObject(engineId, targetIndex) {} - - // ── 选择 ── - - /** @returns {string[]} 选中的 engineId 列表 */ - getSelection() { return []; } - - /** @param {string[]} engineIds */ - setSelection(engineIds) {} - - /** - * @param {string} engineId - * @param {boolean} interactive - */ - setInteractivity(engineId, interactive) {} - - // ── 视口 ── - - /** @returns {{ zoom: number, offsetX: number, offsetY: number }} */ - getViewport() { return { zoom: 1, offsetX: 0, offsetY: 0 }; } - - /** - * @param {{ zoom?: number, offsetX?: number, offsetY?: number }} viewport - */ - setViewport(viewport) {} - - /** - * 图片自适应视口。 - * @param {number} [padding=40] - */ - fitToViewport(padding = 40) {} - - // ── 序列化 ── - - /** - * 将当前引擎状态序列化为可存储的 JSON。 - * @returns {object} - */ - serialize() { return {}; } - - /** - * 从序列化数据恢复引擎状态。 - * @param {object} state - * @returns {Promise} - */ - async restore(state) {} - - // ── 事件 ── - - /** - * 订阅引擎事件。 - * @param {string} event - * @param {Function} callback - * @returns {Function} 取消订阅函数 - */ - on(event, callback) { return () => {}; } - - /** 取消订阅。 */ - off(event, callback) {} -} diff --git a/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js b/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js deleted file mode 100644 index 01c9aacb5..000000000 --- a/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * HostAdapter 接口定义(JSDoc) - * - * 端侧能力统一注入接口。 - * 各端根据自身环境实现:UtoolsHostAdapter、WebHostAdapter、ElectronHostAdapter 等。 - * - * @interface HostAdapter - */ -export default class HostAdapter { - /** - * 弹出图片选择对话框,返回图片 source(dataURL / URL / File / Blob)。 - * 不支持的环境返回 null。 - * @returns {Promise} - */ - async pickImage() { return null; } - - /** - * 读取本地文件路径,返回 dataURL。 - * @param {string} filePath - * @returns {Promise} - */ - async readImageFile(filePath) { throw new Error('Not implemented'); } - - /** - * 弹出保存对话框,写入图片。 - * @param {Blob|string} data - Blob 对象或 dataURL 字符串 - * @param {string} [suggestedName='edited'] - * @returns {Promise} - */ - async saveImage(data, suggestedName = 'edited') { return false; } - - /** - * 复制图片到系统剪贴板。 - * @param {Blob|string} data - Blob 对象或 dataURL 字符串 - * @returns {Promise} - */ - async copyImage(data) { return false; } - - /** - * 获取系统字体列表。 - * @returns {Promise} - */ - async getSystemFonts() { return []; } - - /** - * 异步获取系统字体列表(不阻塞UI线程)。 - * @returns {Promise} - */ - async getSystemFontsAsync() { return []; } - - /** - * 获取存储值。 - * @param {string} key - * @returns {Promise} - */ - async getStorageItem(key) { return null; } - - /** - * 设置存储值。 - * @param {string} key - * @param {string} value - * @returns {Promise} - */ - async setStorageItem(key, value) {} - - /** - * 用系统浏览器打开外部链接。 - * @param {string} url - * @returns {Promise} - */ - async openExternal(url) { return false; } - - /** - * 调整宿主窗口高度。 - * @param {number} height - */ - setWindowHeight(height) {} - - /** - * 注册宿主进入事件(文件匹配 / 超级面板等)。 - * @param {function} callback - * @returns {function} 取消订阅函数 - */ - onPluginEnter(callback) { return () => {}; } - - /** - * 获取当前宿主名称。 - * @returns {string} - */ - getHostName() { return 'browser'; } - - /** - * 获取当前宿主用户信息。 - * @returns {Promise} - */ - async getHostUser() { return null; } -} diff --git a/plugins/Image-Toolbox/core/src/modules/BaseModule.js b/plugins/Image-Toolbox/core/src/modules/BaseModule.js index 5c79e6256..0b04f2e41 100644 --- a/plugins/Image-Toolbox/core/src/modules/BaseModule.js +++ b/plugins/Image-Toolbox/core/src/modules/BaseModule.js @@ -1,4 +1,4 @@ -/** +/** * 模块基类 — 所有功能模块的抽象基类 */ class BaseModule { @@ -75,13 +75,36 @@ class BaseModule { const saved = this._savedInteractivity.get(obj); if (saved) { obj.set({ selectable: saved.selectable, evented: saved.evented }); - } else { - obj.set({ selectable: true, evented: true }); } + // 不在保存映射中的对象(如工具激活期间新增的图层)保持原样, + // 不做任何修改,避免错误地解锁被用户主动锁定的图层 }); this._savedInteractivity.clear(); } + /** + * 启用可编辑图层的交互性,保留用户主动锁定的图层和临时辅助对象状态。 + */ + _enableEditableLayerInteractivity() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const layerManager = this.canvasManager.layerManager; + const objects = canvas.getObjects(); + objects.forEach(obj => { + if (obj.excludeFromLayer || obj.excludeFromHistory) return; + + // 优先走 LayerManager 公开接口。getLayerByObject 仅在已同步的 _layers + // 列表中查找;当对象尚未被 syncLayers() 收录(如工具激活期间新增的临时 + // 对象)时返回 null,此时回退到对象自身的 _layerLocked 标记判断锁定状态。 + const meta = layerManager?.getLayerByObject?.(obj) || null; + const locked = meta ? meta.locked : obj._layerLocked === true; + if (locked && !meta?.isBackground && obj !== this.canvasManager.originalImage && !obj._originalImage) return; + + obj.set({ selectable: true, evented: true }); + }); + } + /** * 获取属性面板 HTML(子类可选实现) * @returns {string} diff --git a/plugins/Image-Toolbox/core/src/modules/BrushModule.js b/plugins/Image-Toolbox/core/src/modules/BrushModule.js index 7b81fe730..6fb5fa2b2 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 ` `; + }).join(''); + + const scopeHint = this._getFilterScope() === 'all' + ? `全部图片图层 (${targets.length})` + : '当前图层'; + + return `
${scopeHint}${presets}
`; + } + + // ── 右侧属性面板:调色滑块 ── + getPropertyPanelHTML() { + const reference = this._getReferenceImage(); + const scopeControl = this._getScopeControlHTML(); + if (!reference) { + const hint = this._getAllImages().length > 0 + ? '选中图片图层,或将作用范围切换为全部图片图层' + : '当前画布没有可调色的图片图层'; + return `${scopeControl}
${hint}
`; + } + + 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(reference, type); + return ` +
+ + + ${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; + + // 拖拽开始时保存一次历史(仅首个 input 事件触发),保存调整前状态以支持撤销 + if (eventType === 'input' && !this._filterDragSaving) { + this._filterDragSaving = true; + this.history?.saveState?.(); + } + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + + if (eventType === 'change') { + this._filterDragSaving = false; + this.history?.saveState?.(); + 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/CropModule.js b/plugins/Image-Toolbox/core/src/modules/CropModule.js index 9cc1383c4..01231ad59 100644 --- a/plugins/Image-Toolbox/core/src/modules/CropModule.js +++ b/plugins/Image-Toolbox/core/src/modules/CropModule.js @@ -1,5 +1,13 @@ -import BaseModule from './BaseModule.js'; +import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, requestRender as _requestRender, createClipPathFromSource } from '../utils/helpers.js'; +import { CROP_RATIOS } from '../utils/constants.js'; + +// 将 CROP_RATIOS 映射为 id → ratio 的快速查找表 +const CROP_RATIO_MAP = {}; +for (const ratio of CROP_RATIOS) { + CROP_RATIO_MAP[ratio.id] = ratio.w === null ? null : { w: ratio.w, h: ratio.h }; +} /** * 剪切模块 — 图片裁剪 @@ -101,19 +109,8 @@ class CropModule extends BaseModule { applyPreset(presetName) { if (this.applyShapePreset(presetName)) return; - const ratioMap = { - 'crop-ratio-free': null, - 'crop-ratio-1-1': { w: 1, h: 1 }, - 'crop-ratio-3-2': { w: 3, h: 2 }, - 'crop-ratio-2-3': { w: 2, h: 3 }, - 'crop-ratio-3-4': { w: 3, h: 4 }, - 'crop-ratio-4-3': { w: 4, h: 3 }, - 'crop-ratio-16-9': { w: 16, h: 9 }, - 'crop-ratio-9-16': { w: 9, h: 16 }, - }; - - if (!Object.prototype.hasOwnProperty.call(ratioMap, presetName)) return; - this.setAspectRatio(ratioMap[presetName]); + if (!(presetName in CROP_RATIO_MAP)) return; + this.setAspectRatio(CROP_RATIO_MAP[presetName]); } applyShapePreset(presetName) { @@ -540,8 +537,7 @@ class CropModule extends BaseModule { } _clamp(value, min, max) { - if (max < min) return min; - return Math.max(min, Math.min(max, value)); + return clamp(value, min, max); } _getCropShape() { @@ -557,48 +553,7 @@ class CropModule extends BaseModule { } _createClipPathFromSource(source) { - const width = Math.max(1, source.width || (source.rx || 0) * 2 || 0); - const height = Math.max(1, source.height || (source.ry || 0) * 2 || 0); - const commonOptions = { - left: source.left || 0, - top: source.top || 0, - scaleX: source.scaleX == null ? 1 : source.scaleX, - scaleY: source.scaleY == null ? 1 : source.scaleY, - angle: source.angle || 0, - skewX: source.skewX || 0, - skewY: source.skewY || 0, - flipX: !!source.flipX, - flipY: !!source.flipY, - originX: source.originX || 'left', - originY: source.originY || 'top', - fill: '#000', - stroke: null, - strokeWidth: 0, - absolutePositioned: true, - objectCaching: false, - }; - - const clipPath = this._isEllipseObject(source) - ? new fabric.Ellipse({ - ...commonOptions, - width, - height, - rx: width / 2, - ry: height / 2, - }) - : new fabric.Rect({ - ...commonOptions, - width, - height, - rx: source.rx || 0, - ry: source.ry || 0, - }); - - if (source.clipPath) { - clipPath.clipPath = this._createClipPathFromSource(source.clipPath); - } - clipPath.setCoords(); - return clipPath; + return createClipPathFromSource(source); } _detachCanvasClipPath() { @@ -676,13 +631,7 @@ class CropModule 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); } _removeCropOverlay() { @@ -714,20 +663,17 @@ class CropModule extends BaseModule { getOptionsBarHTML() { const ratio = this.options.aspectRatio; const shape = this._getCropShape(); + const ratioButtons = CROP_RATIOS.map(r => { + const isActive = r.w === null ? !ratio : ratio && ratio.w === r.w && ratio.h === r.h; + return ``; + }).join(''); return `
- - - - - - - - + ${ratioButtons}
`; } @@ -768,14 +714,11 @@ class CropModule extends BaseModule {
diff --git a/plugins/Image-Toolbox/core/src/modules/EraserModule.js b/plugins/Image-Toolbox/core/src/modules/EraserModule.js index 37ad2217a..11919d315 100644 --- a/plugins/Image-Toolbox/core/src/modules/EraserModule.js +++ b/plugins/Image-Toolbox/core/src/modules/EraserModule.js @@ -1,5 +1,6 @@ import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp } from '../utils/helpers.js'; /** * 橡皮擦模块 - 默认擦除当前图层,并把擦除结果固化为位图。 @@ -227,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; @@ -417,7 +422,7 @@ class EraserModule extends BaseModule { } _clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); + return clamp(value, min, max); } } diff --git a/plugins/Image-Toolbox/core/src/modules/ExportModule.js b/plugins/Image-Toolbox/core/src/modules/ExportModule.js index dc4b8a053..2705ee330 100644 --- a/plugins/Image-Toolbox/core/src/modules/ExportModule.js +++ b/plugins/Image-Toolbox/core/src/modules/ExportModule.js @@ -11,7 +11,7 @@ class ExportModule extends BaseModule { * @param {import('../CanvasManager.js').default} canvasManager * @param {import('../HistoryManager.js').default} historyManager * @param {object} [defaultOptions] - * @param {import('../interfaces/HostAdapter.js').default} [host] + * @param {object} [host] */ constructor(canvasManager, historyManager, defaultOptions = {}, host = null) { super(canvasManager, historyManager, { @@ -25,7 +25,7 @@ class ExportModule extends BaseModule { /** * 注入 host adapter(可在运行时设置)。 - * @param {import('../interfaces/HostAdapter.js').default} host + * @param {object} host */ setHost(host) { this._host = host; @@ -35,10 +35,23 @@ class ExportModule extends BaseModule { * 导出为文件 — 先弹保存对话框,用户选择格式后自动匹配导出 */ async exportToFile() { + // 优先使用 host adapter + if (this._host?.showSaveImageDialog && this._host?.writeImageFile) { + const filePath = this._host.showSaveImageDialog('edited.png'); + if (!filePath) return; + + const format = this._getFormatFromFilePath(filePath); + const dataURL = this.exportToDataURL(format); + if (!dataURL) return; + + const saved = this._host.writeImageFile(filePath, dataURL); + this._notifyToast(saved ? '图片已保存' : '保存失败', saved ? 'success' : 'error'); + return; + } + const dataURL = this.exportToDataURL('png'); if (!dataURL) return; - // 优先使用 host adapter if (this._host?.saveImage) { const saved = await this._host.saveImage(dataURL, 'edited.png'); if (saved) { @@ -123,6 +136,13 @@ class ExportModule extends BaseModule { return this.exportToDataURL(opts.format, opts.quality); } + _getFormatFromFilePath(filePath) { + const ext = String(filePath || '').split('.').pop()?.toLowerCase(); + if (ext === 'jpg' || ext === 'jpeg') return 'jpeg'; + if (ext === 'webp') return 'webp'; + return 'png'; + } + _toDataURL(dataURLOptions, options = {}) { const canvas = this.canvasManager.canvas; const viewportTransform = canvas.viewportTransform?.slice(); diff --git a/plugins/Image-Toolbox/core/src/modules/MosaicModule.js b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js index 752238056..a6547e5f6 100644 --- a/plugins/Image-Toolbox/core/src/modules/MosaicModule.js +++ b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js @@ -1,5 +1,6 @@ -import BaseModule from './BaseModule.js'; +import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, requestRender as _requestRender, createClipPathFromSource, normalizeBounds, intersectBounds, getPointsBounds } from '../utils/helpers.js'; const SELECTION_FILL = 'rgba(47,127,134,0.16)'; const SELECTION_STROKE = '#2f7f86'; @@ -41,6 +42,8 @@ class MosaicModule extends BaseModule { this._boundMouseOut = this._onMouseOut.bind(this); this._boundObjectMoving = this._onObjectMoving.bind(this); this._refreshingDynamicMosaic = false; + this._eventBusUnsubscribers = []; + this._refreshDynamicRafId = null; this._bindDynamicMosaicEvents(); } @@ -53,31 +56,33 @@ class MosaicModule extends BaseModule { canvas.on('object:rotating', this._boundObjectMoving); } - eventBus.on('canvas:objectModified', (target) => { - if (this._refreshingDynamicMosaic) return; + this._eventBusUnsubscribers.push( + eventBus.on('canvas:objectModified', (target) => { + if (this._refreshingDynamicMosaic) return; - const targets = this._getDynamicMosaicTargets(target); - if (targets.length > 0) { - targets.forEach(obj => this._refreshDynamicMosaicOverlay(obj, { render: false })); - this._requestRender(); - return; - } - - this.refreshDynamicMosaics(); - }); + const targets = this._getDynamicMosaicTargets(target); + if (targets.length > 0) { + targets.forEach(obj => this._refreshDynamicMosaicOverlay(obj, { render: false })); + this._requestRender(); + return; + } - eventBus.on('canvas:restored', () => this.refreshDynamicMosaics()); - eventBus.on('canvas:objectAdded', (target) => { - if (target === this._selectionRect || target === this._brushPreview || target === this._lassoPreview) return; - if (target && this._isDynamicMosaic(target)) return; - this.refreshDynamicMosaics(); - }); - eventBus.on('canvas:objectRemoved', () => this.refreshDynamicMosaics()); - eventBus.on('layer:visibilityChanged', () => this.refreshDynamicMosaics()); - eventBus.on('layer:reordered', () => this.refreshDynamicMosaics()); - eventBus.on('mosaic:refreshDynamic', () => this.refreshDynamicMosaics({ render: true })); + this.refreshDynamicMosaics(); + }), + eventBus.on('canvas:restored', () => this.refreshDynamicMosaics()), + eventBus.on('canvas:objectAdded', (target) => { + if (target === this._selectionRect || target === this._brushPreview || target === this._lassoPreview) return; + if (target && this._isDynamicMosaic(target)) return; + this.refreshDynamicMosaics(); + }), + eventBus.on('canvas:objectRemoved', () => this.refreshDynamicMosaics()), + eventBus.on('layer:visibilityChanged', () => this.refreshDynamicMosaics()), + eventBus.on('layer:reordered', () => this.refreshDynamicMosaics()), + eventBus.on('mosaic:refreshDynamic', () => this.refreshDynamicMosaics({ render: true })) + ); - this.canvasManager.refreshDynamicMosaics = (options = {}) => this.refreshDynamicMosaics(options); + // 通过正式方法注册回调,而非动态注入 + this.canvasManager.setRefreshDynamicMosaics((options) => this.refreshDynamicMosaics(options)); } // ── 生命周期 ── @@ -104,7 +109,6 @@ class MosaicModule extends BaseModule { canvas.off('mouse:move', this._boundMouseMove); canvas.off('mouse:up', this._boundMouseUp); canvas.off('mouse:out', this._boundMouseOut); - this._cleanupRect(); this._cleanupLasso(); this._cleanupLiveBrushOverlay(); @@ -115,6 +119,19 @@ class MosaicModule extends BaseModule { super.deactivate(); } + destroy() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('object:moving', this._boundObjectMoving); + canvas.off('object:scaling', this._boundObjectMoving); + canvas.off('object:rotating', this._boundObjectMoving); + } + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + // 清除动态马赛克回调 + this.canvasManager.setRefreshDynamicMosaics(null); + } + // ── 参数设置 ── setMode(mode) { @@ -229,6 +246,10 @@ class MosaicModule extends BaseModule { strokeDashArray: [4, 3], selectable: false, evented: false, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, }); this.canvasManager.canvas.add(this._selectionRect); } @@ -293,6 +314,10 @@ class MosaicModule extends BaseModule { selectable: false, evented: false, objectCaching: false, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, }); this.canvasManager.canvas.add(this._lassoPreview); this.canvasManager.canvas.renderAll(); @@ -327,6 +352,7 @@ class MosaicModule extends BaseModule { if (!rect || rect.width < 5 || rect.height < 5) return; + this._saveStateWithCanvasClipPath(); this._createDynamicMosaicOverlay({ rect, maskType: 'lasso', @@ -335,7 +361,6 @@ class MosaicModule extends BaseModule { y: Math.round(p.y - rect.top), })), }); - this._saveStateWithCanvasClipPath(); } _appendLassoPoint(pointer) { @@ -384,20 +409,7 @@ class MosaicModule extends BaseModule { } _getPointsBounds(points) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const p of points) { - if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; - if (p.x < minX) minX = p.x; - if (p.y < minY) minY = p.y; - if (p.x > maxX) maxX = p.x; - if (p.y > maxY) maxY = p.y; - } - - if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { - return null; - } - - return { left: minX, top: minY, right: maxX, bottom: maxY }; + return getPointsBounds(points); } _getPolygonArea(points) { @@ -419,6 +431,7 @@ class MosaicModule extends BaseModule { this._isDrawing = true; this._brushPoints = [{ x: pointer.x, y: pointer.y }]; + this._saveStateWithCanvasClipPath(); this._updateLiveBrushOverlay(); this._updateBrushPreview(pointer); } @@ -440,6 +453,10 @@ class MosaicModule extends BaseModule { selectable: false, evented: false, objectCaching: false, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, }); this.canvasManager.canvas.add(this._brushPreview); } else { @@ -493,7 +510,7 @@ class MosaicModule extends BaseModule { } } - _finishBrush(e) { + _finishBrush(e) { this._isDrawing = false; this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e)); this._updateLiveBrushOverlay(); @@ -502,9 +519,8 @@ class MosaicModule extends BaseModule { this._brushPoints = []; if (!this._liveBrushOverlay) return; - + // 将实时预览转为持久化图层:保留在画布上,但解除引用 this._liveBrushOverlay = null; - this._saveStateWithCanvasClipPath(); } _updateLiveBrushOverlay() { @@ -687,8 +703,8 @@ class MosaicModule extends BaseModule { rect = this._clipRectToEditableImage(rect); if (!rect || rect.width < 1 || rect.height < 1) return; - this._createDynamicMosaicOverlay({ rect, maskType: 'rect' }); this._saveStateWithCanvasClipPath(); + this._createDynamicMosaicOverlay({ rect, maskType: 'rect' }); } _createDynamicMosaicOverlay({ rect, maskType, brushPoints = null, brushSize = null, lassoPoints = null }) { @@ -756,15 +772,26 @@ class MosaicModule extends BaseModule { } _onObjectMoving(e) { + if (this._refreshingDynamicMosaic) return; + const targets = this._getDynamicMosaicTargets(e.target); if (targets.length === 0) return; - this._refreshingDynamicMosaic = true; - try { - targets.forEach(obj => this._refreshDynamicMosaicOverlay(obj, { render: false })); - } finally { - this._refreshingDynamicMosaic = false; + // 使用 RAF 防抖,避免在拖动过程中频繁重算 + if (this._refreshDynamicRafId) { + cancelAnimationFrame(this._refreshDynamicRafId); } + + this._refreshDynamicRafId = requestAnimationFrame(() => { + this._refreshDynamicRafId = null; + this._refreshingDynamicMosaic = true; + try { + targets.forEach(obj => this._refreshDynamicMosaicOverlay(obj, { render: false })); + this._requestRender(); + } finally { + this._refreshingDynamicMosaic = false; + } + }); } _getDynamicMosaicTargets(target) { @@ -1153,32 +1180,15 @@ class MosaicModule extends BaseModule { } _normalizeBounds(bounds) { - const left = bounds.left; - const top = bounds.top; - const width = Math.max(0, bounds.width || 0); - const height = Math.max(0, bounds.height || 0); - return { - left, - top, - right: left + width, - bottom: top + height, - width, - height, - }; + return normalizeBounds(bounds); } _intersectBounds(a, b) { - const left = Math.max(a.left, b.left); - const top = Math.max(a.top, b.top); - const right = Math.min(a.right, b.right); - const bottom = Math.min(a.bottom, b.bottom); - if (right <= left || bottom <= top) return null; - return { left, top, right, bottom, width: right - left, height: bottom - top }; + return intersectBounds(a, b); } _clamp(value, min, max) { - if (max < min) return min; - return Math.max(min, Math.min(max, value)); + return clamp(value, min, max); } _getActiveCropClipPath() { @@ -1273,13 +1283,7 @@ class MosaicModule 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); } _attachCurrentCropClipPath(obj) { @@ -1291,48 +1295,7 @@ class MosaicModule extends BaseModule { } _createClipPathFromSource(source) { - const width = Math.max(1, source.width || (source.rx || 0) * 2 || 0); - const height = Math.max(1, source.height || (source.ry || 0) * 2 || 0); - const commonOptions = { - left: source.left || 0, - top: source.top || 0, - scaleX: source.scaleX == null ? 1 : source.scaleX, - scaleY: source.scaleY == null ? 1 : source.scaleY, - angle: source.angle || 0, - skewX: source.skewX || 0, - skewY: source.skewY || 0, - flipX: !!source.flipX, - flipY: !!source.flipY, - originX: source.originX || 'left', - originY: source.originY || 'top', - fill: '#000', - stroke: null, - strokeWidth: 0, - absolutePositioned: true, - objectCaching: false, - }; - - const clipPath = source.type === 'ellipse' - ? new fabric.Ellipse({ - ...commonOptions, - width, - height, - rx: width / 2, - ry: height / 2, - }) - : new fabric.Rect({ - ...commonOptions, - width, - height, - rx: source.rx || 0, - ry: source.ry || 0, - }); - - if (source.clipPath) { - clipPath.clipPath = this._createClipPathFromSource(source.clipPath); - } - clipPath.setCoords(); - return clipPath; + return createClipPathFromSource(source); } /** @@ -1343,9 +1306,12 @@ class MosaicModule extends BaseModule { const overlays = canvas.getObjects().filter( o => o.id && o.id.startsWith('mosaic_') ); + if (overlays.length === 0) return; + + // 先保存当前状态(含马赛克覆盖层),以便用户撤销清除操作 + this._saveStateWithCanvasClipPath(); overlays.forEach(o => canvas.remove(o)); canvas.renderAll(); - this._saveStateWithCanvasClipPath(); } applyPreset(presetName) { diff --git a/plugins/Image-Toolbox/core/src/modules/SelectModule.js b/plugins/Image-Toolbox/core/src/modules/SelectModule.js index d0844fcad..451876afc 100644 --- a/plugins/Image-Toolbox/core/src/modules/SelectModule.js +++ b/plugins/Image-Toolbox/core/src/modules/SelectModule.js @@ -1,4 +1,6 @@ -import BaseModule from './BaseModule.js'; +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { requestRender as _requestRender } from '../utils/helpers.js'; /** * 移动/框选模块 - 保持 Fabric 默认选择行为,并提供常用变换预设。 @@ -13,6 +15,9 @@ class SelectModule extends BaseModule { canvas.selection = true; canvas.defaultCursor = 'default'; + + // 启用未锁定图层的交互性,确保新建图层切回移动/框选后可选中。 + this._enableEditableLayerInteractivity(); } deactivate() { @@ -117,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) { @@ -138,13 +146,7 @@ class SelectModule 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); } } diff --git a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js index c3d7550b2..6386c4836 100644 --- a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js +++ b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js @@ -1,8 +1,9 @@ import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, escapeAttr, normalizeColor } from '../utils/helpers.js'; /** - * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、直线、箭头等多种图形 + * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、平行四边形、菱形、直线、箭头等多种图形 */ class ShapeModule extends BaseModule { static SHAPE_OPTIONS = [ @@ -12,6 +13,8 @@ class ShapeModule extends BaseModule { { type: 'star', preset: 'shape-type-star', label: '星星', icon: '' }, { type: 'heart', preset: 'shape-type-heart', label: '心形', icon: '' }, { type: 'trapezoid', preset: 'shape-type-trapezoid', label: '梯形', icon: '' }, + { type: 'parallelogram', preset: 'shape-type-parallelogram', label: '平行四边形', icon: '' }, + { type: 'diamond', preset: 'shape-type-diamond', label: '菱形', icon: '' }, { type: 'line', preset: 'shape-type-line', label: '直线', icon: '' }, { type: 'arrow', preset: 'shape-type-arrow', label: '箭头', icon: '' }, { type: 'double-arrow', preset: 'shape-type-double-arrow', label: '双箭头', icon: '' }, @@ -58,10 +61,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'); } @@ -69,10 +78,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; @@ -84,20 +95,20 @@ class ShapeModule extends BaseModule { } setShapeType(type) { - if (['rect', 'triangle', 'circle', 'star', 'heart', 'trapezoid', 'line', 'arrow', 'double-arrow'].includes(type)) { + if (['rect', 'triangle', 'circle', 'star', 'heart', 'trapezoid', 'parallelogram', 'diamond', 'line', 'arrow', 'double-arrow'].includes(type)) { this.options.shapeType = type; } } setFill(fill) { - const normalized = this._normalizeColor(fill, this.options.fill, true); + const normalized = normalizeColor(fill, this.options.fill, true); this.options.fill = this._hasExplicitOpacity(normalized) ? normalized : this._withColorOpacity(normalized, this._getColorOpacity(this.options.fill)); } setStroke(stroke) { - const normalized = this._normalizeColor(stroke, this.options.stroke, false); + const normalized = normalizeColor(stroke, this.options.stroke, false); this.options.stroke = this._hasExplicitOpacity(normalized) ? normalized : this._withColorOpacity(normalized, this._getColorOpacity(this.options.stroke)); @@ -131,6 +142,8 @@ class ShapeModule extends BaseModule { 'shape-type-star': { shapeType: 'star' }, 'shape-type-heart': { shapeType: 'heart' }, 'shape-type-trapezoid': { shapeType: 'trapezoid' }, + 'shape-type-parallelogram': { shapeType: 'parallelogram' }, + 'shape-type-diamond': { shapeType: 'diamond' }, 'shape-type-line': { shapeType: 'line' }, 'shape-type-arrow': { shapeType: 'arrow' }, 'shape-type-double-arrow': { shapeType: 'double-arrow' }, @@ -172,8 +185,10 @@ class ShapeModule extends BaseModule {
-
- ${colorPresets} +
+
+ ${colorPresets} +
@@ -253,7 +268,7 @@ class ShapeModule extends BaseModule { ${this.options.strokeWidth}px
-
拖拽鼠标绘制图形,支持矩形、三角形、椭圆、星星、心形等。
+
拖拽鼠标绘制图形,支持矩形、三角形、椭圆、星星、心形、菱形等。
`; } @@ -280,12 +295,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(); @@ -298,7 +315,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 }; // 移除旧的预览形状 @@ -319,7 +336,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 }; // 移除预览形状 @@ -415,6 +432,12 @@ class ShapeModule extends BaseModule { case 'trapezoid': return this._createTrapezoid(left, top, width, height, commonProps); + case 'parallelogram': + return this._createParallelogram(left, top, width, height, commonProps); + + case 'diamond': + return this._createDiamond(left, top, width, height, commonProps); + case 'line': return this._createLine(startPoint, endPoint, commonProps); @@ -498,6 +521,27 @@ class ShapeModule extends BaseModule { }); } + _createParallelogram(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const skewX = width * 0.22; + const baseWidth = width - 2 * skewX; + const points = [ + { x: -baseWidth / 2 + skewX, y: -height / 2 }, + { x: baseWidth / 2 + skewX, y: -height / 2 }, + { x: baseWidth / 2 - skewX, y: height / 2 }, + { x: -baseWidth / 2 - skewX, y: height / 2 }, + ]; + + return new fabric.Polygon(points, { + ...props, + left: centerX, + top: centerY, + originX: 'center', + originY: 'center', + }); + } + _createTriangle(left, top, width, height, props) { const centerX = left + width / 2; const centerY = top + height / 2; @@ -516,6 +560,25 @@ class ShapeModule extends BaseModule { }); } + _createDiamond(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const points = [ + { x: 0, y: -height / 2 }, + { x: width / 2, y: 0 }, + { x: 0, y: height / 2 }, + { x: -width / 2, y: 0 }, + ]; + + return new fabric.Polygon(points, { + ...props, + left: centerX, + top: centerY, + originX: 'center', + originY: 'center', + }); + } + _createLine(startPoint, endPoint, props) { return new fabric.Line([startPoint.x, startPoint.y, endPoint.x, endPoint.y], { ...props, @@ -611,22 +674,7 @@ class ShapeModule extends BaseModule { } _normalizeColor(color, fallback = '#000000', allowTransparent = false) { - if (allowTransparent && color === 'transparent') return 'transparent'; - - if (typeof color !== 'string') return fallback; - - const value = color.trim().toLowerCase(); - - // 处理 rgba 格式 - if (value.startsWith('rgba')) return value; - - // 处理 hex 格式 - if (/^#[0-9a-f]{6}$/i.test(value)) return value; - if (/^#[0-9a-f]{3}$/i.test(value)) { - return '#' + value.slice(1).split('').map(ch => ch + ch).join(''); - } - - return fallback; + return normalizeColor(color, fallback, allowTransparent); } _normalizeComparableColor(color) { @@ -730,16 +778,11 @@ class ShapeModule extends BaseModule { } _clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); + return clamp(value, min, max); } _escapeAttr(value) { - return String(value ?? '').replace(/[&<>"]/g, ch => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - }[ch])); + return escapeAttr(value); } } diff --git a/plugins/Image-Toolbox/core/src/modules/TextModule.js b/plugins/Image-Toolbox/core/src/modules/TextModule.js index d0b882867..a358a2924 100644 --- a/plugins/Image-Toolbox/core/src/modules/TextModule.js +++ b/plugins/Image-Toolbox/core/src/modules/TextModule.js @@ -1,4 +1,4 @@ -import BaseModule from './BaseModule.js'; +import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../utils/fonts.js'; @@ -18,6 +18,7 @@ class TextModule extends BaseModule { fontWeight: 'normal', fontStyle: 'normal', underline: false, + linethrough: false, textAlign: 'left', ...defaultOptions, }); @@ -86,11 +87,14 @@ class TextModule extends BaseModule { fontWeight: opts.fontWeight, fontStyle: opts.fontStyle, underline: opts.underline, + linethrough: opts.linethrough, textAlign: opts.textAlign, editable: true, id: 'text_' + Date.now(), }); + this.history.saveState(); + canvas.add(textObj); canvas.setActiveObject(textObj); canvas.renderAll(); @@ -101,8 +105,6 @@ class TextModule extends BaseModule { textObj.enterEditing(); textObj.selectAll(); }, 50); - - this.history.saveState(); return textObj; } @@ -151,6 +153,11 @@ class TextModule extends BaseModule { this._updateActiveTextStyle('underline', underline); } + setLinethrough(linethrough) { + this.options.linethrough = linethrough; + this._updateActiveTextStyle('linethrough', linethrough); + } + setBackgroundColor(color) { this._updateActiveTextStyle('backgroundColor', color); } @@ -264,9 +271,9 @@ class TextModule extends BaseModule { return `
- - - + + + @@ -325,6 +332,10 @@ class TextModule extends BaseModule {
+
+ + +
+ + +
+ `; + } else { + nicknameRow = ` + + `; + } + + identityCard = ` + + `; + } + + return identityCard; + } + + _renderSettings() { + const theme = getThemeChoice(); + const sidePanelLayout = this._getSidePanelLayout(); + const editorBarsLayout = this._getEditorBarsLayout(); + const editorSidePanelPosition = this._getEditorSidePanelPosition(); + const toolbarLabels = this._getToolbarLabelsVisible(); + const toolbarCollapsed = this._getToolbarCollapsed(); + const toolbarToggleVisible = this._getToolbarToggleVisible(); + return ` + + + + + + + + + + + + + + `; + } + + _renderAbout() { + const appVersion = this._getCurrentVersion(); + const hostName = this._getHostName(); + const hostVersion = this._getHostVersion(); + const qqUrl = this._getContactUrl(); + + return ` + + `; + } + + _renderUpdates() { + return ` +
+ ${updateRecords.map(record => this._renderUpdateRecord(record)).join('')} +
+ `; + } + + _renderUpdateRecord(record) { + return ` +
+
+

版本 ${this._escapeHTML(record.version)}

+ +
+
+ ${updateCategories.map(category => this._renderChangeGroup(record, category)).join('')} +
+
+ `; + } + + _renderChangeGroup(record, category) { + const items = record.changes?.[category.key] || []; + if (items.length === 0) return ''; + + // 过滤出当前平台应显示的项目 + const visibleItems = items.filter(item => { + // 兼容旧格式(字符串) + if (typeof item === 'string') return true; + // 新格式(对象)- 检查平台限制 + return shouldShowForCurrentPlatform(item.platforms); + }); + + if (visibleItems.length === 0) return ''; + + return ` +
+
${this._escapeHTML(category.title)}
+
    + ${visibleItems.map(item => this._renderChangeItem(item)).join('')} +
+
+ `; + } + + /** + * 渲染单个更新项(仅展示文本内容,不展示平台标签) + */ + _renderChangeItem(item) { + // 兼容旧格式(字符串) + if (typeof item === 'string') { + return `
  • ${this._escapeHTML(item)}
  • `; + } + + // 新格式(对象)— 仅展示文本,平台过滤已在 _renderChangeGroup 中完成 + const text = item.text || ''; + return `
  • ${this._escapeHTML(text)}
  • `; + } + + _renderAvatar(className) { + const user = this._getUserView(); + const title = this._escapeAttr(user.name); + const initial = this._escapeAttr(user.initial); + + if (user.avatar) { + return ``; + } + + return ``; + } + + _getUserView() { + const user = this._user || {}; + const hostName = this._getHostName(); + const name = user.nickname || user.name || user.userName || user.username || `${hostName} 用户`; + const avatar = user.avatar || user.avatarUrl || user.photo || ''; + return { + name, + avatar, + initial: this._getInitial(name), + status: this._user ? `已连接 ${hostName} 用户信息` : `未获取到 ${hostName} 用户信息`, + }; + } + + _getSectionTitle(section) { + const titles = { + mine: '我的', + settings: '设置', + updates: '更新记录', + about: '关于', + }; + return titles[section] || titles.mine; + } + + _setTheme(theme) { + applyThemeChoice(theme); + } + + _getCurrentVersion() { + const version = updateRecords?.[0]?.version; + return this._formatVersion(version); + } + + _getHostVersion() { + try { + return this._formatVersion(this._host?.platform?.version || this._host?.getHostAppVersion?.()); + } catch (e) { + console.warn('[AccountPage] 获取宿主版本失败:', e); + } + + return '未知'; + } + + _formatVersion(version) { + const text = String(version || '').trim(); + if (!text) return '未知'; + return /^v/i.test(text) ? text : `v${text}`; + } + + _openExternalUrl(url) { + if (!url) return; + + try { + if (this._host?.system?.openExternal?.(url) || this._host?.openHostExternal?.(url)) { + return; + } + } catch (e) { + console.warn('[AccountPage] 使用宿主打开外部链接失败:', e); + } + + window.open(url, '_blank', 'noopener,noreferrer'); + } + + _setSidePanelLayout(layout) { + if (!Object.values(SIDE_PANEL_LAYOUTS).includes(layout)) return; + + localStorage.setItem(SIDE_PANEL_LAYOUT_KEY, layout); + this._sidePanelTabs?.applyLayout(layout, false); + eventBus.emit('sidePanel:layoutChanged', layout); + } + + _setEditorBarsLayout(layout) { + if (!VALID_EDITOR_BARS_LAYOUTS.has(layout)) return; + + localStorage.setItem(EDITOR_BARS_LAYOUT_KEY, layout); + eventBus.emit('editorBars:layoutChanged', layout); + } + + _setEditorSidePanelPosition(position) { + if (!VALID_EDITOR_SIDE_PANEL_POSITIONS.has(position)) return; + + localStorage.setItem(EDITOR_SIDE_PANEL_POSITION_KEY, position); + eventBus.emit('editorSidePanel:positionChanged', position); + } + + _setToolbarLabelsVisible(value) { + if (!VALID_TOOLBAR_LABELS_VISIBLE.has(value)) return; + + localStorage.setItem(TOOLBAR_LABELS_VISIBLE_KEY, value); + eventBus.emit('toolbar:labelsVisibilityChanged', value); + } + + _setToolbarCollapsed(value) { + if (!VALID_TOOLBAR_COLLAPSED.has(value)) return; + + localStorage.setItem(TOOLBAR_COLLAPSED_KEY, value); + eventBus.emit('toolbar:collapsedChanged', value); + } + + _setToolbarToggleVisible(value) { + if (!VALID_TOOLBAR_TOGGLE_VISIBLE.has(value)) return; + + localStorage.setItem(TOOLBAR_TOGGLE_VISIBLE_KEY, value); + eventBus.emit('toolbar:toggleVisibleChanged', value); + } + + _getSidePanelLayout() { + const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY); + return Object.values(SIDE_PANEL_LAYOUTS).includes(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS; + } + + _getEditorBarsLayout() { + const saved = localStorage.getItem(EDITOR_BARS_LAYOUT_KEY); + return VALID_EDITOR_BARS_LAYOUTS.has(saved) ? saved : EDITOR_BARS_LAYOUTS.PRESETS_TOP; + } + + _getEditorSidePanelPosition() { + const saved = localStorage.getItem(EDITOR_SIDE_PANEL_POSITION_KEY); + return VALID_EDITOR_SIDE_PANEL_POSITIONS.has(saved) ? saved : EDITOR_SIDE_PANEL_POSITIONS.RIGHT; + } + + _getToolbarLabelsVisible() { + const saved = localStorage.getItem(TOOLBAR_LABELS_VISIBLE_KEY); + return VALID_TOOLBAR_LABELS_VISIBLE.has(saved) ? saved : TOOLBAR_LABELS_VISIBLE.ON; + } + + _getToolbarCollapsed() { + const saved = localStorage.getItem(TOOLBAR_COLLAPSED_KEY); + return VALID_TOOLBAR_COLLAPSED.has(saved) ? saved : TOOLBAR_COLLAPSED.COLLAPSED; + } + + _getToolbarToggleVisible() { + const saved = localStorage.getItem(TOOLBAR_TOGGLE_VISIBLE_KEY); + return VALID_TOOLBAR_TOGGLE_VISIBLE.has(saved) ? saved : TOOLBAR_TOGGLE_VISIBLE.ON; + } + + _getHostUser() { + try { + const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null; + if (result && typeof result.then === 'function') { + result.then((user) => { + this._user = user; + this._render(); + }).catch((e) => console.warn('[AccountPage] 获取宿主用户信息失败:', e)); + return null; + } + return result; + } catch (e) { + console.warn('[AccountPage] 获取宿主用户信息失败:', e); + } + return null; + } + + _getHostName() { + return this._host?.platform?.name || this._host?.getHostName?.() || 'uTools'; + } + + _getContactUrl() { + try { + const url = this._host?.getContactUrl?.(); + if (url) return url; + } catch (e) { + console.warn('[AccountPage] 获取联系方式失败:', e); + } + return 'https://qm.qq.com/q/Nzn12S22e6'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _formatTime(ts) { + if (!ts) return '—'; + const d = new Date(typeof ts === 'number' ? ts : Date.parse(ts)); + if (isNaN(d.getTime())) return '—'; + return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + + // ═══════════════════════════════════════ + // 账户异步操作 + // ═══════════════════════════════════════ + + async _loadProfile() { + this._profileLoading = true; + this._render(); + try { + this._profile = await this._identity.getProfile(); + } catch (e) { + console.warn('[AccountPage] 加载用户档案失败:', e); + this._profile = null; + } + this._profileLoading = false; + this._render(); + // 通知侧栏等外部组件同步刷新头像 + eventBus.emit('account:profileChanged', this._profile); + } + + async _handleNicknameSave(nickname) { + const trimmed = String(nickname || '').trim(); + if (!trimmed) { + eventBus.emit('toast:show', { message: '昵称不能为空', type: 'error' }); + return; + } + if (trimmed.length > 32) { + eventBus.emit('toast:show', { message: '昵称最多 32 字符', type: 'error' }); + return; + } + try { + this._profile = await this._identity.updateProfile({ nickname: trimmed }); + this._nicknameEditing = false; + this._render(); + eventBus.emit('toast:show', { message: '昵称已更新', type: 'success' }); + eventBus.emit('account:profileChanged', this._profile); + } catch (e) { + eventBus.emit('toast:show', { message: e?.message || '保存失败', type: 'error' }); + } + } + + async _handleAvatarUpload(file) { + try { + this._profile = await this._identity.uploadAvatar(file); + this._render(); + eventBus.emit('toast:show', { message: '头像已更新', type: 'success' }); + eventBus.emit('account:profileChanged', this._profile); + } catch (e) { + eventBus.emit('toast:show', { message: e?.message || '头像上传失败', type: 'error' }); + } + } + + async _handleLogout() { + try { + await this._identity.logout(); + } catch {} + this._profile = null; + this._nicknameEditing = false; + this._render(); + eventBus.emit('toast:show', { message: '已退出登录', type: 'success' }); + eventBus.emit('account:profileChanged', null); + } + + // ═══════════════════════════════════════ + // 登录弹窗 + // ═══════════════════════════════════════ + + _openLoginModal() { + let modal = document.getElementById('login-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'login-modal'; + modal.className = 'login-modal'; + document.body.appendChild(modal); + } + const isUTools = !!window.utools; + modal.innerHTML = ` + + + `; + modal.classList.add('login-modal--active'); + + // 弹窗挂载在 document.body 上,不在 this._el 内, + // 因此需要单独绑定点击事件 + modal.onclick = (e) => { + const modalAction = e.target.closest('[data-modal-action]')?.getAttribute('data-modal-action'); + if (modalAction === 'close-login') { + this._closeLoginModal(); + return; + } + if (modalAction === 'utools-login') { + this._handleUToolsLogin(); + return; + } + if (modalAction === 'send-code') { + const emailInput = document.getElementById('login-email-input'); + if (emailInput) this._handleSendCode(emailInput.value); + return; + } + if (modalAction === 'email-login') { + const emailInput = document.getElementById('login-email-input'); + const codeInput = document.getElementById('login-code-input'); + if (emailInput && codeInput) this._handleEmailLogin(emailInput.value, codeInput.value); + return; + } + }; + } + + _closeLoginModal() { + const modal = document.getElementById('login-modal'); + if (modal) { + modal.classList.remove('login-modal--active'); + setTimeout(() => modal.remove(), 200); + } + } + + async _handleSendCode(email) { + const btn = document.getElementById('send-code-btn'); + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + eventBus.emit('toast:show', { message: '请输入有效的邮箱地址', type: 'error' }); + return; + } + try { + if (btn) { btn.disabled = true; btn.textContent = '发送中…'; } + await this._identity.requestEmailCode(email, 'login'); + eventBus.emit('toast:show', { message: '验证码已发送', type: 'success' }); + this._startCountdown(btn, 60); + } catch (e) { + eventBus.emit('toast:show', { message: e?.message || '发送失败', type: 'error' }); + if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; } + } + } + + _startCountdown(btn, seconds) { + if (!btn) return; + let remaining = seconds; + btn.disabled = true; + btn.textContent = `${remaining}s`; + const timer = setInterval(() => { + remaining--; + if (remaining <= 0) { + clearInterval(timer); + btn.disabled = false; + btn.textContent = '发送验证码'; + } else { + btn.textContent = `${remaining}s`; + } + }, 1000); + } + + async _handleEmailLogin(email, code) { + if (!email || !code) { + eventBus.emit('toast:show', { message: '请填写邮箱和验证码', type: 'error' }); + return; + } + try { + await this._identity.loginWithEmailCode(email, code, 'login'); + this._closeLoginModal(); + eventBus.emit('toast:show', { message: '登录成功', type: 'success' }); + await this._loadProfile(); + } catch (e) { + eventBus.emit('toast:show', { message: e?.message || '登录失败', type: 'error' }); + } + } + + async _handleUToolsLogin() { + try { + const api = window.utools; + if (!api?.fetchUserServerTemporaryToken) { + eventBus.emit('toast:show', { message: '当前环境不支持一键登录', type: 'error' }); + return; + } + const { token: accessToken } = await api.fetchUserServerTemporaryToken(); + const deviceId = api.getDeviceId?.() || 'utools-device'; + await this._identity.loginWithUTools(accessToken, deviceId); + this._closeLoginModal(); + eventBus.emit('toast:show', { message: '登录成功', type: 'success' }); + await this._loadProfile(); + } catch (e) { + eventBus.emit('toast:show', { message: e?.message || '登录失败', type: 'error' }); + } + } + + _escapeAttr(value) { + return escapeAttr(value); + } + + _escapeHTML(value) { + return escapeHTML(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default AccountPage; diff --git a/plugins/Image-Toolbox/core/src/ui/ColorPanel.js b/plugins/Image-Toolbox/core/src/ui/ColorPanel.js new file mode 100644 index 000000000..1c8a670c8 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ui/ColorPanel.js @@ -0,0 +1,275 @@ +import { eventBus } from '../index.js'; +import { FILTER_RANGES, FILTER_PRESETS, getFilterUiValue, setFilter, clearFilters, applyFilterPreset, isPresetActive } from '../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._domHandlers = { + input: (e) => this._handleEvent(e), + change: (e) => this._handleEvent(e), + click: (e) => this._handleEvent(e), + }; + this._el.addEventListener('input', this._domHandlers.input); + this._el.addEventListener('change', this._domHandlers.change); + this._el.addEventListener('click', this._domHandlers.click); + } + + _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._hm?.saveState?.(); + 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 = []; + + if (this._domHandlers) { + this._el.removeEventListener('input', this._domHandlers.input); + this._el.removeEventListener('change', this._domHandlers.change); + this._el.removeEventListener('click', this._domHandlers.click); + this._domHandlers = null; + } + } +} + +export default ColorPanel; diff --git a/plugins/Image-Toolbox/src/ui/LayerPanel.js b/plugins/Image-Toolbox/core/src/ui/LayerPanel.js similarity index 86% rename from plugins/Image-Toolbox/src/ui/LayerPanel.js rename to plugins/Image-Toolbox/core/src/ui/LayerPanel.js index 8f96c51f4..62ec9bbcc 100644 --- a/plugins/Image-Toolbox/src/ui/LayerPanel.js +++ b/plugins/Image-Toolbox/core/src/ui/LayerPanel.js @@ -1,4 +1,5 @@ -import { eventBus } from '../../core/src/index.js'; +import { eventBus } from '../index.js'; +import { escapeHTML, escapeAttr } from '../utils/helpers.js'; /** * 图层面板 UI 组件 @@ -12,6 +13,7 @@ class LayerPanel { this._dropPanelIndex = null; this._selectedLayerId = null; this._activeLayerIds = []; + this._eventBusUnsubscribers = []; this._bindEvents(); this._render(); @@ -39,47 +41,45 @@ class LayerPanel { _bindEvents() { // Refresh the list when layers change. - eventBus.on('layers:updated', () => { - this._refreshLayerList(); - }); - - // Sync layers after canvas operations. - eventBus.on('canvas:objectAdded', (obj) => { - this._lm.syncLayers(); - this._selectLayerByObject(obj); - }); - eventBus.on('canvas:objectRemoved', () => { - this._lm.syncLayers(); - }); - eventBus.on('canvas:objectModified', () => { - this._lm.syncLayers(); - }); - eventBus.on('canvas:objectMetadataChanged', () => { - this._lm.syncLayers(); - }); - - // Highlight layers matching the current selection. - eventBus.on('canvas:selectionCreated', () => this._selectLayerFromActiveObject()); - eventBus.on('canvas:selectionUpdated', () => this._selectLayerFromActiveObject()); - eventBus.on('canvas:selectionCleared', () => { - this._activeLayerIds = []; - this._refreshLayerList(); - }); - eventBus.on('layer:selected', (meta) => { - this._selectedLayerId = meta?.id ?? null; - this._activeLayerIds = meta ? [meta.id] : []; - this._refreshLayerList(); - }); - eventBus.on('image:loaded', () => { - this._selectedLayerId = null; - this._activeLayerIds = []; - this._refreshLayerList(); - }); - eventBus.on('canvas:restored', () => { - this._selectedLayerId = null; - this._activeLayerIds = []; - this._refreshLayerList(); - }); + this._eventBusUnsubscribers.push( + eventBus.on('layers:updated', () => { + this._refreshLayerList(); + }), + eventBus.on('canvas:objectAdded', (obj) => { + this._lm.syncLayers(); + this._selectLayerByObject(obj); + }), + eventBus.on('canvas:objectRemoved', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:objectModified', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:objectMetadataChanged', () => { + this._lm.syncLayers(); + }), + eventBus.on('canvas:selectionCreated', () => this._selectLayerFromActiveObject()), + eventBus.on('canvas:selectionUpdated', () => this._selectLayerFromActiveObject()), + eventBus.on('canvas:selectionCleared', () => { + this._activeLayerIds = []; + this._refreshLayerList(); + }), + eventBus.on('layer:selected', (meta) => { + this._selectedLayerId = meta?.id ?? null; + this._activeLayerIds = meta ? [meta.id] : []; + this._refreshLayerList(); + }), + eventBus.on('image:loaded', () => { + this._selectedLayerId = null; + this._activeLayerIds = []; + this._refreshLayerList(); + }), + eventBus.on('canvas:restored', () => { + this._selectedLayerId = null; + this._activeLayerIds = []; + this._refreshLayerList(); + }) + ); // 事件委托 this._el.addEventListener('click', (e) => { @@ -362,7 +362,7 @@ class LayerPanel { ${eyeIcon} ${icon} ${layerName} - ${lockIcon} + ${lockIcon} `; } @@ -376,16 +376,16 @@ class LayerPanel { } _escapeHTML(value) { - return String(value ?? '').replace(/[&<>"]/g, ch => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - }[ch])); + return escapeHTML(value); } _escapeAttr(value) { - return this._escapeHTML(value).replace(/'/g, '''); + return escapeAttr(value); + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; } } diff --git a/plugins/Image-Toolbox/src/ui/OptionsBar.js b/plugins/Image-Toolbox/core/src/ui/OptionsBar.js similarity index 72% rename from plugins/Image-Toolbox/src/ui/OptionsBar.js rename to plugins/Image-Toolbox/core/src/ui/OptionsBar.js index 4e45afad7..29d6b7a91 100644 --- a/plugins/Image-Toolbox/src/ui/OptionsBar.js +++ b/plugins/Image-Toolbox/core/src/ui/OptionsBar.js @@ -1,4 +1,4 @@ -import { eventBus } from '../../core/src/index.js'; +import { eventBus } from '../index.js'; /** * Top options bar UI component. @@ -14,6 +14,7 @@ class OptionsBar { this._boundDocumentClick = this._handleDocumentClick.bind(this); this._boundKeyDown = this._handleKeyDown.bind(this); this._boundRepositionShapePicker = this._positionShapePicker.bind(this); + this._eventBusUnsubscribers = []; this._render(); this._bindEvents(); @@ -27,11 +28,13 @@ class OptionsBar { _bindEvents() { // Update options when the active tool changes. - eventBus.on('tool:changed', (toolName) => { - this._currentTool = toolName; - this._closeShapePicker(); - this._updateControls(); - }); + this._eventBusUnsubscribers.push( + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._closeShapePicker(); + this._updateControls(); + }) + ); [ 'canvas:selectionCreated', @@ -42,15 +45,27 @@ class OptionsBar { 'image:loaded', 'tool:propertiesChanged', ].forEach(eventName => { - eventBus.on(eventName, () => { - if (this._currentTool) this._updateControls(); - }); + this._eventBusUnsubscribers.push( + eventBus.on(eventName, () => { + if (this._currentTool) this._updateControls(); + }) + ); }); // Only handle one-click presets here; detailed controls live in the property panel. this._el.addEventListener('click', (e) => { this._handleControlEvent(e); }); + + // 鼠标悬停在配色预设滑动区时,滚轮转为横向滚动 + this._el.addEventListener('wheel', (e) => { + const scrollEl = e.target.closest('.shape-style-scroll'); + if (!scrollEl) return; + // 仅在纵向滚轮占主导时接管(触控板原生横向滚动不拦截) + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + e.preventDefault(); + scrollEl.scrollLeft += e.deltaY; + }, { passive: false }); } _updateControls() { @@ -62,11 +77,33 @@ class OptionsBar { const module = this._tm.getCurrentModule(); if (module && typeof module.getOptionsBarHTML === 'function') { controlsEl.innerHTML = module.getOptionsBarHTML(); + this._scrollActiveShapePresetIntoView(controlsEl); } else { controlsEl.innerHTML = ''; } } + /** + * 图形工具配色预设可横向滑动,重渲染后把当前选中的预设滚到可视区, + * 避免点击后滚动位置被重置导致激活项不可见。 + */ + _scrollActiveShapePresetIntoView(container) { + const scrollEl = container.querySelector('.shape-style-scroll'); + if (!scrollEl) return; + const activeBtn = scrollEl.querySelector('.shape-style-btn.active'); + if (!activeBtn) return; + + const scrollRect = scrollEl.getBoundingClientRect(); + const btnRect = activeBtn.getBoundingClientRect(); + const margin = 8; + + if (btnRect.left < scrollRect.left + margin) { + scrollEl.scrollLeft -= (scrollRect.left + margin - btnRect.left); + } else if (btnRect.right > scrollRect.right - margin) { + scrollEl.scrollLeft += (btnRect.right - (scrollRect.right - margin)); + } + } + _handleControlEvent(e) { const pickerToggle = e.target.closest('[data-shape-picker-toggle]'); if (pickerToggle) { @@ -169,7 +206,8 @@ class OptionsBar { */ destroy() { this._closeShapePicker(); - // EventBus subscriptions are currently app-lifetime listeners. + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; } } diff --git a/plugins/Image-Toolbox/src/ui/PropertyPanel.js b/plugins/Image-Toolbox/core/src/ui/PropertyPanel.js similarity index 87% rename from plugins/Image-Toolbox/src/ui/PropertyPanel.js rename to plugins/Image-Toolbox/core/src/ui/PropertyPanel.js index adf3322c2..c156eda8a 100644 --- a/plugins/Image-Toolbox/src/ui/PropertyPanel.js +++ b/plugins/Image-Toolbox/core/src/ui/PropertyPanel.js @@ -1,5 +1,6 @@ -import { eventBus } from '../../core/src/index.js'; -import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../../core/src/utils/fonts.js'; +import { eventBus } from '../index.js'; +import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../utils/fonts.js'; +import { clamp, escapeHTML, escapeAttr } from '../utils/helpers.js'; /** * Property panel UI component. @@ -11,6 +12,7 @@ class PropertyPanel { this._tm = toolManager; this._cm = canvasManager || toolManager?._cm || null; this._lm = layerManager; + this._eventBusUnsubscribers = []; this._bindEvents(); this._render(); @@ -31,23 +33,21 @@ class PropertyPanel { _bindEvents() { // Update the property panel when selection changes. - eventBus.on('canvas:selectionCreated', () => this._updateProperties()); - eventBus.on('canvas:selectionUpdated', () => this._updateProperties()); - eventBus.on('canvas:selectionCleared', () => this._updateProperties()); - eventBus.on('layer:selected', () => this._updateProperties()); - eventBus.on('layers:updated', () => this._updateProperties()); - eventBus.on('canvas:objectAdded', () => this._updateProperties()); - eventBus.on('canvas:objectRemoved', () => this._updateProperties()); - eventBus.on('canvas:restored', () => this._updateProperties()); - eventBus.on('image:loaded', () => this._clearProperties()); - eventBus.on('tool:changed', () => this._updateProperties()); - eventBus.on('crop:updated', () => this._updateProperties()); - eventBus.on('tool:propertiesChanged', () => this._updateProperties()); - - // Refresh properties after object changes. - eventBus.on('canvas:objectModified', () => { - this._updateProperties(); - }); + this._eventBusUnsubscribers.push( + eventBus.on('canvas:selectionCreated', () => this._updateProperties()), + eventBus.on('canvas:selectionUpdated', () => this._updateProperties()), + eventBus.on('canvas:selectionCleared', () => this._updateProperties()), + eventBus.on('layer:selected', () => this._updateProperties()), + eventBus.on('layers:updated', () => this._updateProperties()), + eventBus.on('canvas:objectAdded', () => this._updateProperties()), + eventBus.on('canvas:objectRemoved', () => this._updateProperties()), + eventBus.on('canvas:restored', () => this._updateProperties()), + eventBus.on('image:loaded', () => this._clearProperties()), + eventBus.on('tool:changed', () => this._updateProperties()), + eventBus.on('crop:updated', () => this._updateProperties()), + eventBus.on('tool:propertiesChanged', () => this._updateProperties()), + eventBus.on('canvas:objectModified', () => this._updateProperties()) + ); // Delegate property input handling. this._el.addEventListener('input', (e) => { @@ -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)}%
    @@ -205,6 +215,13 @@ class PropertyPanel {
    +
    + + +
    @@ -217,6 +234,10 @@ class PropertyPanel {
    +
    + + +