diff --git a/plugins/Image-Toolbox/core/src/CanvasManager.js b/plugins/Image-Toolbox/core/src/CanvasManager.js index e2e5bc5c..7fb91279 100644 --- a/plugins/Image-Toolbox/core/src/CanvasManager.js +++ b/plugins/Image-Toolbox/core/src/CanvasManager.js @@ -1,4 +1,4 @@ -import eventBus from './EventBus.js'; +import eventBus from './EventBus.js'; const CLIP_PATH_SERIALIZED_PROPS = ['clipPath', 'absolutePositioned', 'inverted']; @@ -23,6 +23,8 @@ class CanvasManager { this.zoomLevel = 1; this._historySaveTimer = null; this._isCropMode = false; + this._resizeObserver = null; + this._boundResize = null; } // ── 生命周期 ── @@ -48,6 +50,14 @@ class CanvasManager { * 销毁画布,释放内存 */ destroy() { + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + if (this._boundResize) { + window.removeEventListener('resize', this._boundResize); + this._boundResize = null; + } if (this._historySaveTimer) { clearTimeout(this._historySaveTimer); } @@ -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(); } @@ -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(); }); }); } @@ -441,17 +491,16 @@ class CanvasManager { }); // 窗口大小变化 - window.addEventListener('resize', () => { - this._updateCanvasSize(); - }); + this._boundResize = () => this._updateCanvasSize(); + window.addEventListener('resize', this._boundResize); // 使用 ResizeObserver 监听容器变化 const container = this.canvas.wrapperEl?.parentElement; if (container && window.ResizeObserver) { - const observer = new ResizeObserver(() => { + this._resizeObserver = new ResizeObserver(() => { this._updateCanvasSize(); }); - observer.observe(container); + this._resizeObserver.observe(container); } } } diff --git a/plugins/Image-Toolbox/core/src/HistoryManager.js b/plugins/Image-Toolbox/core/src/HistoryManager.js index 1cc6471a..d4fb3315 100644 --- a/plugins/Image-Toolbox/core/src/HistoryManager.js +++ b/plugins/Image-Toolbox/core/src/HistoryManager.js @@ -24,6 +24,9 @@ class HistoryManager { const json = this._cm.toJSON(); if (!json) return; + const last = this.undoStack[this.undoStack.length - 1]; + if (last && this._isSameSnapshot(last, json)) return; + this.undoStack.push(json); // 限制栈大小 @@ -46,14 +49,23 @@ class HistoryManager { this._isRestoring = true; - // 保存当前状态到重做栈 const currentJson = this._cm.toJSON(); + let prevJson = this.undoStack.pop(); + + while (prevJson && currentJson && this._isSameSnapshot(prevJson, currentJson) && this.undoStack.length > 0) { + prevJson = this.undoStack.pop(); + } + + if (!prevJson || (currentJson && this._isSameSnapshot(prevJson, currentJson))) { + this._isRestoring = false; + this._notify(); + return; + } + if (currentJson) { this.redoStack.push(currentJson); } - // 恢复上一个状态 - const prevJson = this.undoStack.pop(); try { await this._restoreState(prevJson); } catch (err) { @@ -137,6 +149,17 @@ class HistoryManager { undoCount: this.undoStack.length, }); } + + _isSameSnapshot(a, b) { + if (a === b) return true; + if (!a || !b) return false; + + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch (err) { + return false; + } + } } export default HistoryManager; diff --git a/plugins/Image-Toolbox/core/src/LayerManager.js b/plugins/Image-Toolbox/core/src/LayerManager.js index 4fe3092a..276d2070 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); } @@ -106,13 +110,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 +126,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 +417,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 +438,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/ToolManager.js b/plugins/Image-Toolbox/core/src/ToolManager.js index 3f23ce27..b4a916a3 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/index.js b/plugins/Image-Toolbox/core/src/index.js index 6d041dfe..049c0311 100644 --- a/plugins/Image-Toolbox/core/src/index.js +++ b/plugins/Image-Toolbox/core/src/index.js @@ -1,17 +1,5 @@ // ═══════════════════════════════════════════════════════ // @img-toolbox/core — 公共无环境依赖入口 -// 这里只导出平台无关的状态和接口;Fabric/browser 运行时见 ./runtime/fabric.js // ═══════════════════════════════════════════════════════ -// ── 基础设施 ── export { default as eventBus, EventBus } from './EventBus.js'; -export { default as EditorContext } from './EditorContext.js'; - -// ── 状态存储 ── -export { default as HistoryStore } from './HistoryStore.js'; -export { default as LayerStore } from './LayerStore.js'; -export { default as ToolRegistry } from './ToolRegistry.js'; - -// ── 接口 ── -export { default as HostAdapter } from './interfaces/HostAdapter.js'; -export { default as EditorEngineAdapter } from './interfaces/EditorEngineAdapter.js'; diff --git a/plugins/Image-Toolbox/core/src/modules/BaseModule.js b/plugins/Image-Toolbox/core/src/modules/BaseModule.js index 5c79e625..7d554e28 100644 --- a/plugins/Image-Toolbox/core/src/modules/BaseModule.js +++ b/plugins/Image-Toolbox/core/src/modules/BaseModule.js @@ -75,13 +75,33 @@ 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; + + 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 7b81fe73..6fb5fa2b 100644 --- a/plugins/Image-Toolbox/core/src/modules/BrushModule.js +++ b/plugins/Image-Toolbox/core/src/modules/BrushModule.js @@ -1,5 +1,6 @@ import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, escapeAttr, normalizeColor, requestRender as _requestRender } from '../utils/helpers.js'; /** * 画笔模块 - 使用 Fabric 自由绘制生成可编辑的 path 图层。 @@ -61,7 +62,7 @@ class BrushModule extends BaseModule { } setColor(color) { - this.options.color = this._normalizeColor(color, this.options.color); + this.options.color = normalizeColor(color, this.options.color); this._applyBrushOptions(); this._updateCursorPreviewStyle(); } @@ -95,7 +96,7 @@ class BrushModule extends BaseModule { } getOptionsBarHTML() { - const color = this._normalizeColor(this.options.color); + const color = normalizeColor(this.options.color); const width = this.options.width; return ` @@ -121,7 +122,7 @@ class BrushModule extends BaseModule {
画笔工具
- +
@@ -257,13 +258,7 @@ class BrushModule extends BaseModule { } _requestRender() { - const canvas = this.canvasManager.canvas; - if (!canvas) return; - if (typeof canvas.requestRenderAll === 'function') { - canvas.requestRenderAll(); - } else { - canvas.renderAll(); - } + _requestRender(this.canvasManager.canvas); } _ensureBrush() { @@ -284,7 +279,7 @@ class BrushModule extends BaseModule { } _getColorPresetButton(preset, label, color, currentColor) { - const normalized = this._normalizeColor(color); + const normalized = normalizeColor(color); const active = currentColor === normalized ? ' active' : ''; return ` `; + }).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; + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + + if (eventType === 'change') { + eventBus.emit('canvas:objectModified', targets[0]); + } + return false; // 不刷新属性面板(避免滑块失焦) + } + + // ── 重置按钮(属性面板) ── + onToolPropertyAction(action, { eventType } = {}) { + if (action !== 'filter-reset') return; + if (eventType !== 'click') return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + this.history?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + _requestRender(this.canvasManager.canvas); + eventBus.emit('canvas:objectModified', targets[0]); + } + + _getScopeControlHTML() { + const scope = this._getFilterScope(); + return ` +
作用范围
+
+ + +
+ `; + } + + _getFilterScope() { + return this.options.filterScope === 'all' ? 'all' : 'current'; + } + + _getTargetImages() { + if (this._getFilterScope() === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveImage(); + return active ? [active] : []; + } + + _getReferenceImage() { + if (this._getFilterScope() === 'all') { + return this._getActiveImage() || this._getAllImages()[0] || null; + } + + return this._getActiveImage(); + } + + _getAllImages() { + const canvas = this.canvasManager?.canvas; + if (!canvas) return []; + + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + // ── 获取当前选中的图片图层 ── + _getActiveImage() { + const active = this.canvasManager?.getActiveObject?.(); + if (!active || active.type === 'activeSelection') return null; + return active.type === 'image' ? active : null; + } +} + +export default ColorModule; diff --git a/plugins/Image-Toolbox/core/src/modules/CropModule.js b/plugins/Image-Toolbox/core/src/modules/CropModule.js index 9cc1383c..3669c3d0 100644 --- a/plugins/Image-Toolbox/core/src/modules/CropModule.js +++ b/plugins/Image-Toolbox/core/src/modules/CropModule.js @@ -1,5 +1,6 @@ import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, requestRender as _requestRender, createClipPathFromSource } from '../utils/helpers.js'; /** * 剪切模块 — 图片裁剪 @@ -540,8 +541,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 +557,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 +635,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() { diff --git a/plugins/Image-Toolbox/core/src/modules/EraserModule.js b/plugins/Image-Toolbox/core/src/modules/EraserModule.js index 37ad2217..11919d31 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 dc4b8a05..2705ee33 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 75223805..8e9a4bdc 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,7 @@ class MosaicModule extends BaseModule { this._boundMouseOut = this._onMouseOut.bind(this); this._boundObjectMoving = this._onObjectMoving.bind(this); this._refreshingDynamicMosaic = false; + this._eventBusUnsubscribers = []; this._bindDynamicMosaicEvents(); } @@ -53,29 +55,30 @@ 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); } @@ -104,7 +107,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 +117,20 @@ 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 = []; + if (this.canvasManager.refreshDynamicMosaics) { + delete this.canvasManager.refreshDynamicMosaics; + } + } + // ── 参数设置 ── setMode(mode) { @@ -327,6 +343,7 @@ class MosaicModule extends BaseModule { if (!rect || rect.width < 5 || rect.height < 5) return; + this._saveStateWithCanvasClipPath(); this._createDynamicMosaicOverlay({ rect, maskType: 'lasso', @@ -335,7 +352,6 @@ class MosaicModule extends BaseModule { y: Math.round(p.y - rect.top), })), }); - this._saveStateWithCanvasClipPath(); } _appendLassoPoint(pointer) { @@ -384,20 +400,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 +422,7 @@ class MosaicModule extends BaseModule { this._isDrawing = true; this._brushPoints = [{ x: pointer.x, y: pointer.y }]; + this._saveStateWithCanvasClipPath(); this._updateLiveBrushOverlay(); this._updateBrushPreview(pointer); } @@ -504,7 +508,6 @@ class MosaicModule extends BaseModule { if (!this._liveBrushOverlay) return; this._liveBrushOverlay = null; - this._saveStateWithCanvasClipPath(); } _updateLiveBrushOverlay() { @@ -687,8 +690,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 }) { @@ -1153,32 +1156,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 +1259,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 +1271,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 +1282,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 d0844fca..451876af 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 c3d7550b..3623278b 100644 --- a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js +++ b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js @@ -1,5 +1,6 @@ import BaseModule from './BaseModule.js'; import eventBus from '../EventBus.js'; +import { clamp, escapeAttr, normalizeColor } from '../utils/helpers.js'; /** * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、直线、箭头等多种图形 @@ -12,6 +13,7 @@ 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: '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 +60,16 @@ class ShapeModule extends BaseModule { canvas.discardActiveObject(); canvas.defaultCursor = 'crosshair'; - canvas.upperCanvasEl?.addEventListener('mousedown', this._boundMouseDown); - canvas.upperCanvasEl?.addEventListener('mousemove', this._boundMouseMove); - canvas.upperCanvasEl?.addEventListener('mouseup', this._boundMouseUp); - canvas.upperCanvasEl?.addEventListener('mouseout', this._boundMouseOut); + // 禁用 Fabric.js 的目标查找,防止拖选绘制时意外移动图层 + canvas.skipTargetFind = true; + + // 使用 Fabric.js 合成事件(与其他模块一致), + // 确保在 Fabric 内部处理完事件后才接收,避免原生 DOM 事件与 + // Fabric.js 内部 __onMouseDown 同时处理同一事件导致的状态冲突 + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + canvas.on('mouse:out', this._boundMouseOut); eventBus.emit('module:activated', 'shape'); } @@ -69,10 +77,12 @@ class ShapeModule extends BaseModule { deactivate() { const canvas = this.canvasManager.canvas; if (canvas) { - canvas.upperCanvasEl?.removeEventListener('mousedown', this._boundMouseDown); - canvas.upperCanvasEl?.removeEventListener('mousemove', this._boundMouseMove); - canvas.upperCanvasEl?.removeEventListener('mouseup', this._boundMouseUp); - canvas.upperCanvasEl?.removeEventListener('mouseout', this._boundMouseOut); + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + canvas.off('mouse:out', this._boundMouseOut); + // 重置 skipTargetFind,避免影响后续工具(如 SelectModule) + canvas.skipTargetFind = false; this._removePreviewShape(); this._isDrawing = false; this._startPoint = null; @@ -84,20 +94,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', '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 +141,7 @@ 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-line': { shapeType: 'line' }, 'shape-type-arrow': { shapeType: 'arrow' }, 'shape-type-double-arrow': { shapeType: 'double-arrow' }, @@ -280,12 +291,14 @@ class ShapeModule extends BaseModule { } _onMouseDown(e) { - if (e.button !== 0) return; + // Fabric.js 合成事件:e.e 是原生 DOM 事件,e.pointer 是画布坐标 + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; const canvas = this.canvasManager.canvas; if (!canvas) return; - const pointer = canvas.getPointer(e); + const pointer = e.pointer || canvas.getPointer(nativeEvent); this._isDrawing = true; this._startPoint = { x: pointer.x, y: pointer.y }; this.history.saveState(); @@ -298,7 +311,7 @@ class ShapeModule extends BaseModule { const canvas = this.canvasManager.canvas; if (!canvas) return; - const pointer = canvas.getPointer(e); + const pointer = e.pointer || canvas.getPointer(e.e); const endPoint = { x: pointer.x, y: pointer.y }; // 移除旧的预览形状 @@ -319,7 +332,7 @@ class ShapeModule extends BaseModule { const canvas = this.canvasManager.canvas; if (!canvas) return; - const pointer = canvas.getPointer(e); + const pointer = e.pointer || canvas.getPointer(e.e); const endPoint = { x: pointer.x, y: pointer.y }; // 移除预览形状 @@ -415,6 +428,9 @@ 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 'line': return this._createLine(startPoint, endPoint, commonProps); @@ -498,6 +514,26 @@ 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 points = [ + { x: -width / 2 + skewX, y: -height / 2 }, + { x: width / 2 + skewX, y: -height / 2 }, + { x: width / 2 - skewX, y: height / 2 }, + { x: -width / 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; @@ -611,22 +647,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 +751,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 d0b88286..f542672f 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); } @@ -325,6 +332,10 @@ class TextModule extends BaseModule {
+
+ + +
+ ${value} +
+ `; + }).join(''); + + const filterPresets = FILTER_PRESETS.map(preset => { + const targets = this._getTargetImages(); + const isActive = this._filterScope === 'all' + ? targets.length > 0 && targets.every(image => isPresetActive(image, preset.preset)) + : isPresetActive(active, preset.preset); + return ``; + }).join(''); + + const scopeTitle = this._filterScope === 'all' + ? `调色 (${this._getTargetImages().length} 个图片图层)` + : '调色'; + + return ` + ${this._getScopeControlHTML()} +
滤镜
+
${filterPresets}
+
${scopeTitle}
+ ${sliders} +
+ +
+ `; + } + + _handleEvent(e) { + // 滤镜预设按钮(一键应用) + const presetTarget = e.target.closest('[data-preset]'); + if (presetTarget && this._el.contains(presetTarget)) { + const preset = presetTarget.dataset.preset; + if (preset && preset.startsWith('filter-')) { + if (e.type !== 'click') return; + this._applyFilterPreset(preset); + return; + } + } + + // 调色滑块 / 重置 + const target = e.target.closest('[data-prop]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + if (!prop) return; + + if (prop === 'filterScope') { + if (e.type !== 'change') return; + this._filterScope = target.value === 'all' ? 'all' : 'current'; + this._update(); + return; + } + + if (!prop.startsWith('filter:')) return; + + const targets = this._getTargetImages(); + if (targets.length === 0) return; + + const value = target.value; + + // 重置按钮 + if (prop === 'filter:reset') { + if (e.type !== 'click') return; + this._hm?.saveState?.(); + targets.forEach(image => clearFilters(image)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + return; + } + + // 滑块 + const type = prop.slice('filter:'.length); + const uiValue = parseInt(value, 10); + if (!Number.isFinite(uiValue)) return; + + targets.forEach(image => setFilter(image, type, uiValue)); + this._markImagesChanged(targets); + + if (target.nextElementSibling && target.nextElementSibling.classList.contains('property-value')) { + target.nextElementSibling.textContent = String(uiValue); + } + + this._requestRender(); + + if (e.type === 'change') { + this._notifyObjectChanged(targets[0]); + } + } + + _applyFilterPreset(presetName) { + const targets = this._getTargetImages(); + if (targets.length === 0) return; + this._hm?.saveState?.(); + targets.forEach(image => applyFilterPreset(image, presetName)); + this._markImagesChanged(targets); + this._requestRender(); + this._notifyObjectChanged(targets[0]); + this._update(); + } + + _getScopeControlHTML() { + return ` +
作用范围
+
+ + +
+ `; + } + + _getTargetImages() { + if (this._filterScope === 'all') { + return this._getAllImages(); + } + + const active = this._getActiveObject(); + return active && active.type === 'image' && active.type !== 'activeSelection' ? [active] : []; + } + + _getReferenceImage() { + if (this._filterScope === 'all') { + const active = this._getActiveObject(); + return active?.type === 'image' ? active : this._getAllImages()[0] || null; + } + + const active = this._getActiveObject(); + return active?.type === 'image' ? active : null; + } + + _getAllImages() { + const canvas = this._cm?.canvas; + if (!canvas) return []; + return canvas.getObjects().filter(obj => ( + obj && + obj.type === 'image' && + !obj.excludeFromLayer && + !obj.excludeFromHistory + )); + } + + _markImagesChanged(images) { + images.forEach(image => { + image.dirty = true; + image.setCoords(); + }); + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _notifyObjectChanged(active) { + eventBus.emit('canvas:objectModified', active); + } + + _requestRender() { + const canvas = this._cm?.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + destroy() { + this._eventBusUnsubscribers.forEach(unsub => unsub()); + this._eventBusUnsubscribers = []; + } +} + +export default ColorPanel; diff --git a/plugins/Image-Toolbox/src/ui/LayerPanel.js b/plugins/Image-Toolbox/src/ui/LayerPanel.js index 8f96c51f..c01da791 100644 --- a/plugins/Image-Toolbox/src/ui/LayerPanel.js +++ b/plugins/Image-Toolbox/src/ui/LayerPanel.js @@ -1,4 +1,5 @@ import { eventBus } from '../../core/src/index.js'; +import { escapeHTML, escapeAttr } from '../../core/src/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/src/ui/OptionsBar.js index 4e45afad..ee71a991 100644 --- a/plugins/Image-Toolbox/src/ui/OptionsBar.js +++ b/plugins/Image-Toolbox/src/ui/OptionsBar.js @@ -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,9 +45,11 @@ 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. @@ -169,7 +174,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/src/ui/PropertyPanel.js index adf3322c..ed4d8c1d 100644 --- a/plugins/Image-Toolbox/src/ui/PropertyPanel.js +++ b/plugins/Image-Toolbox/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 { clamp, escapeHTML, escapeAttr } from '../../core/src/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 {
+
+ + +