diff --git a/plugins/Image-Toolbox/core/src/CanvasManager.js b/plugins/Image-Toolbox/core/src/CanvasManager.js new file mode 100644 index 00000000..e2e5bc5c --- /dev/null +++ b/plugins/Image-Toolbox/core/src/CanvasManager.js @@ -0,0 +1,459 @@ +import eventBus from './EventBus.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 画布的创建、配置和基础操作 + */ +class CanvasManager { + constructor(canvasElId) { + this._canvasElId = canvasElId; + this.canvas = null; + this.originalImage = null; // 原始 fabric.Image + this.zoomLevel = 1; + this._historySaveTimer = null; + this._isCropMode = false; + } + + // ── 生命周期 ── + + /** + * 初始化画布 + * @param {object} [options] - Fabric.Canvas 配置 + */ + init(options = {}) { + const el = document.getElementById(this._canvasElId); + if (!el) { + throw new Error(`[CanvasManager] 找不到画布元素 #${this._canvasElId}`); + } + + const config = { ...DEFAULT_CANVAS_OPTIONS, ...options }; + this.canvas = new fabric.Canvas(this._canvasElId, config); + this._bindEvents(); + this._updateCanvasSize(); + eventBus.emit('canvas:initialized', this.canvas); + } + + /** + * 销毁画布,释放内存 + */ + destroy() { + if (this._historySaveTimer) { + clearTimeout(this._historySaveTimer); + } + if (this.canvas) { + this.canvas.dispose(); + this.canvas = null; + } + this.originalImage = null; + } + + // ── 图片操作 ── + + /** + * 加载图片到画布 + * @param {string|File} source - URL / DataURL / File 对象 + * @returns {Promise} + */ + loadImage(source) { + return new Promise((resolve, reject) => { + if (!this.canvas) { + reject(new Error('画布未初始化')); + return; + } + + // 超时保护:防止 fabric.Image.fromURL 永远不回调 + const timeout = setTimeout(() => { + reject(new Error('图片加载超时(30s),可能是格式不支持')); + }, 30000); + + /** + * fabric.Image.fromURL 回调签名: function(fabricImage, isError) + * crossOrigin 是第 4 个参数,不是 imgOptions 的一部分 + */ + const onLoad = (fabricImg, isError) => { + clearTimeout(timeout); + + if (isError || !fabricImg) { + const err = new Error('图片加载失败:格式不支持或文件已损坏'); + console.error('[CanvasManager]', err.message); + eventBus.emit('image:loadError', err); + reject(err); + return; + } + + console.log('[CanvasManager] 图片加载成功: %d×%d (type=%s)', + fabricImg.width, fabricImg.height, fabricImg.type); + + this.originalImage = fabricImg; + fabricImg._originalImage = true; + this.canvas.clear(); + this.canvas.add(fabricImg); + this.canvas.renderAll(); + this._updateCanvasSize(); + eventBus.emit('image:loaded', fabricImg); + resolve(fabricImg); + }; + + if (source instanceof File) { + const reader = new FileReader(); + reader.onload = (e) => { + const dataURL = e.target.result; + // fromURL(url, callback, imgOptions, crossOrigin) + fabric.Image.fromURL(dataURL, onLoad, undefined, 'anonymous'); + }; + reader.onerror = (err) => { + clearTimeout(timeout); + console.error('[CanvasManager] FileReader 读取失败:', err); + eventBus.emit('image:loadError', err); + reject(err); + }; + reader.readAsDataURL(source); + } else if (typeof source === 'string') { + // 数据URL 不需要 crossOrigin + const needCrossOrigin = !source.startsWith('data:'); + fabric.Image.fromURL( + source, + onLoad, + undefined, // imgOptions + needCrossOrigin ? 'anonymous' : undefined // crossOrigin + ); + } else { + reject(new Error('不支持的图片源类型')); + } + }); + } + + /** + * 替换当前图片(不清除覆盖层) + * @param {string} source + */ + replaceImage(source) { + return new Promise((resolve, reject) => { + fabric.Image.fromURL(source, (fabricImg, isError) => { + if (isError || !fabricImg) { + reject(new Error('替换图片失败')); + return; + } + if (this.originalImage) { + const index = this.canvas.getObjects().indexOf(this.originalImage); + this.canvas.remove(this.originalImage); + this.originalImage = fabricImg; + fabricImg._originalImage = true; + this.canvas.insertAt(fabricImg, index >= 0 ? index : 0); + } else { + this.originalImage = fabricImg; + fabricImg._originalImage = true; + this.canvas.insertAt(fabricImg, 0); + } + this.canvas.renderAll(); + resolve(fabricImg); + }, undefined, 'anonymous'); + }); + } + + /** + * 获取画布当前图片 DataURL + * @param {object} [options] - 导出选项 + * @returns {string} + */ + getImageData(options = {}) { + const opts = { + format: 'png', + multiplier: 1, + ...options, + }; + this.refreshDynamicMosaics?.({ render: true }); + return this.canvas.toDataURL(opts); + } + + /** + * 图片自适应画布大小 + * @param {number} [padding=40] - 边距 + */ + fitToCanvas(padding = 40) { + if (!this.originalImage) return; + + const img = this.originalImage; + const cw = this.canvas.width; + const ch = this.canvas.height; + const availableW = cw - padding * 2; + const availableH = ch - padding * 2; + + const scale = Math.min(availableW / img.width, availableH / img.height, 1); + img.set({ + scaleX: scale, + scaleY: scale, + left: (cw - img.width * scale) / 2, + top: (ch - img.height * scale) / 2, + }); + this.canvas.renderAll(); + } + + // ── 画布控制 ── + + /** + * 设置缩放 + * @param {number} level - 缩放级别(0.1~5) + * @param {{x:number,y:number}} [point] - 缩放中心点 + */ + setZoom(level, point) { + this.zoomLevel = Math.max(0.1, Math.min(5, level)); + const zoomPoint = point || new fabric.Point( + this.canvas.width / 2, + this.canvas.height / 2 + ); + this.canvas.zoomToPoint(zoomPoint, this.zoomLevel); + eventBus.emit('canvas:zoomChanged', this.zoomLevel); + } + + zoomIn(step = 0.1) { + this.setZoom(this.zoomLevel + step); + } + + zoomOut(step = 0.1) { + this.setZoom(this.zoomLevel - step); + } + + resetZoom() { + this.setZoom(1); + } + + // ── 物件管理 ── + + addObject(obj) { + if (!this.canvas) return; + this.canvas.add(obj); + this.canvas.setActiveObject(obj); + this.canvas.renderAll(); + eventBus.emit('canvas:objectAdded', obj); + } + + removeObject(obj) { + if (!this.canvas) return; + this.canvas.remove(obj); + this.canvas.discardActiveObject(); + this.canvas.renderAll(); + eventBus.emit('canvas:objectRemoved', obj); + } + + removeActiveObject() { + const active = this.canvas.getActiveObject(); + if (active) { + this.removeObject(active); + } + } + + getActiveObject() { + return this.canvas ? this.canvas.getActiveObject() : null; + } + + /** + * 清除所有覆盖层(保留原图) + */ + clearOverlays() { + if (!this.canvas) return; + const objects = this.canvas.getObjects().filter(obj => obj !== this.originalImage); + objects.forEach(obj => this.canvas.remove(obj)); + this.canvas.renderAll(); + eventBus.emit('canvas:overlaysCleared'); + } + + /** + * 获取画布上的所有物件(不含原图) + * @returns {fabric.Object[]} + */ + getOverlays() { + if (!this.canvas) return []; + return this.canvas.getObjects().filter(obj => obj !== this.originalImage); + } + + // ── 序列化 ── + + toJSON() { + if (!this.canvas) return null; + const json = this.canvas.toJSON([ + 'clipPath', + 'filters', + 'id', + 'selectable', + 'evented', + 'absolutePositioned', + 'inverted', + 'objectCaching', + 'strokeLineCap', + 'strokeLineJoin', + '_layerName', + '_layerNameAuto', + '_layerBaseName', + '_layerKind', + '_layerColorPresetName', + '_layerWidthPresetName', + '_layerPresetName', + '_mosaicDynamic', + '_mosaicMode', + '_mosaicSize', + '_mosaicBlurRadius', + '_mosaicWidth', + '_mosaicHeight', + '_mosaicMaskType', + '_mosaicBrushPoints', + '_mosaicBrushSize', + '_mosaicLassoPoints', + '_originalImage', + ]); + // 手动序列化 canvas.clipPath(Fabric.js canvas.toJSON 不包含此属性) + if (this.canvas.clipPath) { + json._canvasClipPath = this.canvas.clipPath.toJSON(CLIP_PATH_SERIALIZED_PROPS); + } + return json; + } + + fromJSON(json) { + return new Promise((resolve, reject) => { + if (!this.canvas) { + reject(new Error('画布未初始化')); + return; + } + + // 提取 canvas 级别的 clipPath + const canvasClipPathData = json._canvasClipPath; + delete json._canvasClipPath; + + 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(); + }); + } else { + // 快照中没有 clipPath → 清除画布上已有的(撤消裁切的关键) + this.canvas.clipPath = null; + } + + // 恢复 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(); + }); + }); + } + + // ── 辅助 ── + + /** + * 判断点是否在画布内 + * @param {{x:number,y:number}} point + * @returns {boolean} + */ + isPointInCanvas(point) { + return point.x >= 0 && point.x <= this.canvas.width + && point.y >= 0 && point.y <= this.canvas.height; + } + + // ── 内部方法 ── + + _updateCanvasSize() { + const container = this.canvas.wrapperEl?.parentElement; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const newWidth = rect.width; + const newHeight = rect.height; + + // 容器不可见时跳过(避免读到 0×0 导致图片缩没) + 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(); + } + + eventBus.emit('canvas:resized', { width: newWidth, height: newHeight }); + } + } + + _bindEvents() { + if (!this.canvas) return; + + // 选择变化 + this.canvas.on('selection:created', (e) => { + eventBus.emit('canvas:selectionCreated', e.selected); + }); + this.canvas.on('selection:updated', (e) => { + eventBus.emit('canvas:selectionUpdated', e.selected); + }); + this.canvas.on('selection:cleared', () => { + eventBus.emit('canvas:selectionCleared'); + }); + + // 物件修改 + this.canvas.on('object:modified', (e) => { + eventBus.emit('canvas:objectModified', e.target); + }); + this.canvas.on('text:changed', (e) => { + eventBus.emit('canvas:objectMetadataChanged', e.target); + }); + this.canvas.on('text:editing:exited', (e) => { + eventBus.emit('canvas:objectModified', e.target); + }); + + // 物件添加/删除 + this.canvas.on('object:added', (e) => { + eventBus.emit('canvas:objectAdded', e.target); + }); + + // 鼠标事件 + this.canvas.on('mouse:down', (e) => { + eventBus.emit('canvas:mouseDown', e); + }); + this.canvas.on('mouse:move', (e) => { + eventBus.emit('canvas:mouseMove', e); + }); + this.canvas.on('mouse:up', (e) => { + eventBus.emit('canvas:mouseUp', e); + }); + + // 渲染完成 + this.canvas.on('after:render', () => { + eventBus.emit('canvas:rendered'); + }); + + // 窗口大小变化 + window.addEventListener('resize', () => { + this._updateCanvasSize(); + }); + + // 使用 ResizeObserver 监听容器变化 + const container = this.canvas.wrapperEl?.parentElement; + if (container && window.ResizeObserver) { + const observer = new ResizeObserver(() => { + this._updateCanvasSize(); + }); + observer.observe(container); + } + } +} + +export default CanvasManager; diff --git a/plugins/Image-Toolbox/core/src/EditorContext.js b/plugins/Image-Toolbox/core/src/EditorContext.js new file mode 100644 index 00000000..b2de36cb --- /dev/null +++ b/plugins/Image-Toolbox/core/src/EditorContext.js @@ -0,0 +1,201 @@ +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 new file mode 100644 index 00000000..e9ac67ab --- /dev/null +++ b/plugins/Image-Toolbox/core/src/EventBus.js @@ -0,0 +1,86 @@ +/** + * 事件总线 — 发布-订阅模式,解耦模块间通信 + * 全局单例 + */ +class EventBus { + constructor() { + this._events = {}; + this._idCounter = 0; + } + + /** + * 订阅事件 + * @param {string} event - 事件名 + * @param {Function} callback - 回调函数 + * @param {object} [context] - 回调 this 上下文 + * @returns {Function} 取消订阅函数 + */ + on(event, callback, context) { + if (!this._events[event]) { + this._events[event] = []; + } + const listener = { id: ++this._idCounter, callback, context }; + this._events[event].push(listener); + return () => this.off(event, listener.id); + } + + /** + * 一次性订阅 + * @param {string} event + * @param {Function} callback + * @param {object} [context] + */ + once(event, callback, context) { + const off = this.on(event, (...args) => { + off(); + callback.apply(context, args); + }, context); + } + + /** + * 取消订阅 + * @param {string} event + * @param {number|Function} [target] - listener id 或 callback 函数 + */ + off(event, target) { + if (!this._events[event]) return; + if (target === undefined) { + delete this._events[event]; + return; + } + this._events[event] = this._events[event].filter(listener => { + if (typeof target === 'number') return listener.id !== target; + return listener.callback !== target; + }); + } + + /** + * 发布事件 + * @param {string} event - 事件名 + * @param {...*} args - 参数 + */ + emit(event, ...args) { + if (!this._events[event]) return; + const listeners = [...this._events[event]]; // 拷贝,避免遍历时修改 + for (const listener of listeners) { + try { + listener.callback.apply(listener.context, args); + } catch (err) { + console.error(`[EventBus] "${event}" 回调执行错误:`, err); + } + } + } + + /** + * 清空所有事件 + */ + clear() { + this._events = {}; + } +} + +// 全局单例(向后兼容) +const eventBus = new EventBus(); + +export { EventBus }; +export default eventBus; diff --git a/plugins/Image-Toolbox/core/src/HistoryManager.js b/plugins/Image-Toolbox/core/src/HistoryManager.js new file mode 100644 index 00000000..1cc6471a --- /dev/null +++ b/plugins/Image-Toolbox/core/src/HistoryManager.js @@ -0,0 +1,142 @@ +import eventBus from './EventBus.js'; + +/** + * 历史记录管理器 — 实现撤销/重做 + * 使用 Fabric.js toJSON/loadFromJSON 序列化快照 + */ +class HistoryManager { + constructor(canvasManager, maxSteps = 30) { + this._cm = canvasManager; + this.undoStack = []; + this.redoStack = []; + this.maxSteps = maxSteps; + this._enabled = true; + this._isRestoring = false; // 防止恢复时触发保存 + } + + /** + * 保存当前画布状态 + */ + saveState() { + if (!this._enabled || this._isRestoring) return; + if (!this._cm.canvas) return; + + const json = this._cm.toJSON(); + if (!json) return; + + this.undoStack.push(json); + + // 限制栈大小 + if (this.undoStack.length > this.maxSteps) { + this.undoStack.shift(); + } + + // 新操作清空重做栈 + this.redoStack = []; + + this._notify(); + } + + /** + * 撤销 + */ + async undo() { + if (!this.canUndo()) return; + if (this._isRestoring) return; // 防止恢复期间重复触发 + + this._isRestoring = true; + + // 保存当前状态到重做栈 + const currentJson = this._cm.toJSON(); + if (currentJson) { + this.redoStack.push(currentJson); + } + + // 恢复上一个状态 + const prevJson = this.undoStack.pop(); + try { + await this._restoreState(prevJson); + } catch (err) { + console.error('[HistoryManager] undo 失败:', err); + } finally { + this._isRestoring = false; + this._notify(); + } + } + + /** + * 重做 + */ + async redo() { + if (!this.canRedo()) return; + if (this._isRestoring) return; // 防止恢复期间重复触发 + + this._isRestoring = true; + + // 保存当前状态到撤销栈 + const currentJson = this._cm.toJSON(); + if (currentJson) { + this.undoStack.push(currentJson); + } + + // 恢复下一个状态 + const nextJson = this.redoStack.pop(); + try { + await this._restoreState(nextJson); + } catch (err) { + console.error('[HistoryManager] redo 失败:', err); + } finally { + this._isRestoring = false; + this._notify(); + } + } + + /** + * 是否可撤销 + * @returns {boolean} + */ + canUndo() { + return this.undoStack.length > 0; + } + + /** + * 是否可重做 + * @returns {boolean} + */ + canRedo() { + return this.redoStack.length > 0; + } + + /** + * 清空历史 + */ + clear() { + this.undoStack = []; + this.redoStack = []; + this._notify(); + } + + /** + * 启用/禁用历史记录 + * @param {boolean} enabled + */ + setEnabled(enabled) { + this._enabled = enabled; + } + + // ── 内部方法 ── + + async _restoreState(json) { + return this._cm.fromJSON(json); + } + + _notify() { + eventBus.emit('history:changed', { + canUndo: this.canUndo(), + canRedo: this.canRedo(), + undoCount: this.undoStack.length, + }); + } +} + +export default HistoryManager; diff --git a/plugins/Image-Toolbox/core/src/HistoryStore.js b/plugins/Image-Toolbox/core/src/HistoryStore.js new file mode 100644 index 00000000..8c4a30fe --- /dev/null +++ b/plugins/Image-Toolbox/core/src/HistoryStore.js @@ -0,0 +1,156 @@ +/** + * 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 new file mode 100644 index 00000000..4fe3092a --- /dev/null +++ b/plugins/Image-Toolbox/core/src/LayerManager.js @@ -0,0 +1,552 @@ +import eventBus from './EventBus.js'; + +/** + * 图层管理器 — 管理 Fabric.js 物件的 z-order、显隐、锁定 + * 每个 Fabric Object 即为一个"图层" + */ +class LayerManager { + constructor(canvasManager) { + this._cm = canvasManager; + this._layers = []; // 图层元数据 [{ id, name, visible, locked, fabricObj }] + this._idCounter = 0; + } + + /** + * 同步图层列表(从画布物件重建) + */ + syncLayers() { + const canvas = this._cm.canvas; + if (!canvas) return; + + const objects = canvas.getObjects(); + const oldLayers = this._layers; // 保留旧列表用于查找已有元数据 + const currentObjects = new Set(objects); + const newLayers = []; + + // 先处理非背景图层:从后往前(画布中后面的是上层 → 放在面板顶部) + for (let i = objects.length - 1; i >= 0; i--) { + const obj = objects[i]; + // 背景图层单独处理 + if (obj === this._cm.originalImage) continue; + + // 跳过标记为不显示在图层面板的对象(如裁剪遮罩/裁剪框) + if (obj.excludeFromLayer) continue; + + // 在旧列表中查找已有元数据(优先按对象引用,替换对象时再按稳定 id 匹配) + let meta = oldLayers.find(l => l.fabricObj === obj) || null; + if (!meta && obj.id) { + meta = oldLayers.find(l => !newLayers.includes(l) && l.fabricObj?.id === obj.id) || null; + } + if (!meta) { + meta = this._createMeta(obj, false, newLayers, currentObjects); + } else { + meta.fabricObj = obj; + this._refreshMetaName(meta, obj, false, newLayers, currentObjects); + } + meta.zIndex = objects.length - 1 - i; + newLayers.push(meta); + } + + // 背景图层始终在列表末尾(面板最底部) + if (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); + } else { + bgMeta.fabricObj = this._cm.originalImage; + this._refreshMetaName(bgMeta, this._cm.originalImage, true, newLayers, currentObjects); + } + bgMeta.zIndex = 0; + newLayers.push(bgMeta); + } + + this._layers = newLayers; + eventBus.emit('layers:updated', this._layers); + } + + /** + * 获取图层列表 + * @returns {Array} + */ + getLayers() { + return this._layers; + } + + /** + * 根据 ID 查找图层元数据 + * @param {number} layerId + * @returns {object|null} + */ + getLayerById(layerId) { + return this._layers.find(l => l.id === layerId) || null; + } + + /** + * 根据 fabricObj 查找图层元数据 + * @param {fabric.Object} obj + * @returns {object|null} + */ + _findMeta(obj) { + return this._layers.find(l => l.fabricObj === obj) || null; + } + + /** + * 根据 Fabric 对象获取图层元数据 + * @param {fabric.Object} obj + * @returns {object|null} + */ + getLayerByObject(obj) { + return this._findMeta(obj); + } + + _createMeta(obj, isBackground = false, newLayers = null, currentObjects = null) { + const id = ++this._idCounter; + const nameInfo = this._resolveLayerName(obj, isBackground, newLayers, null, currentObjects); + + const meta = { + id, + name: nameInfo.name, + visible: obj.visible !== false, + locked: isBackground ? true : (!obj.selectable && !obj.evented), + fabricObj: obj, + zIndex: 0, + isBackground, + }; + + if (!isBackground) { + this._setObjectLayerName(obj, meta.name, nameInfo.auto, nameInfo.baseName); + } + + return meta; + } + + _refreshMetaName(meta, obj, isBackground = false, newLayers = null, currentObjects = null) { + const nameInfo = this._resolveLayerName(obj, isBackground, newLayers, meta, currentObjects); + meta.name = nameInfo.name; + + if (!isBackground) { + this._setObjectLayerName(obj, meta.name, nameInfo.auto, nameInfo.baseName); + } + } + + _resolveLayerName(obj, isBackground = false, newLayers = null, currentMeta = null, currentObjects = null) { + if (isBackground) { + return { name: '背景', auto: false, baseName: '背景' }; + } + + const savedName = this._getObjectLayerName(obj); + const isAutoName = obj?._layerNameAuto === true + || !savedName + || (obj?._layerNameAuto !== false && this._isLegacyDefaultLayerName(savedName, obj)); + if (!isAutoName && savedName) { + return { name: savedName, auto: false, baseName: '' }; + } + + const baseName = this._getDefaultLayerBaseName(obj); + if (savedName + && obj?._layerBaseName === baseName + && !this._isLayerNameUsed(savedName, newLayers, currentMeta, currentObjects)) { + return { name: savedName, auto: true, baseName }; + } + + return { + name: this._getUniqueDefaultLayerName(baseName, newLayers, currentMeta, currentObjects), + auto: true, + baseName, + }; + } + + _getDefaultLayerBaseName(obj) { + if (this._isTextLayerObject(obj)) { + const text = this._normalizeLayerText(obj.text); + return text ? `文字 - ${text}` : '文字'; + } + + if (this._isBrushLayerObject(obj)) { + return this._joinLayerNameParts( + '画笔', + obj._layerColorPresetName || this._getBrushColorPresetName(obj.stroke), + obj._layerWidthPresetName || this._getBrushWidthPresetName(obj.strokeWidth) + ); + } + + if (this._isMosaicLayerObject(obj)) { + return this._joinLayerNameParts('马赛克', obj._layerPresetName || this._getMosaicPresetName(obj)); + } + + // 按对象功能命名(不是按 Fabric type 字面翻译) + const funcLabelMap = { + 'image': '马赛克', // 非背景图片 = 马赛克覆盖层 + 'i-text': '文字', + 'textbox': '文字', + 'text': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '涂鸦', + 'group': '组合', + }; + + return funcLabelMap[obj?.type] || '图层'; + } + + _getUniqueDefaultLayerName(baseName, newLayers = null, currentMeta = null, currentObjects = null) { + let name = baseName; + let index = 2; + + while (this._isLayerNameUsed(name, newLayers, currentMeta, currentObjects)) { + name = `${baseName} - ${index}`; + index++; + } + + return name; + } + + _isLayerNameUsed(name, newLayers = null, currentMeta = null, currentObjects = null) { + const layers = [...(newLayers || []), ...this._layers]; + return layers.some(layer => { + if (!layer || layer === currentMeta || layer.name !== name) return false; + if (!currentObjects || !layer.fabricObj) return true; + return currentObjects.has(layer.fabricObj); + }); + } + + _isTextLayerObject(obj) { + return obj?.type === 'i-text' || obj?.type === 'text' || obj?.type === 'textbox'; + } + + _isBrushLayerObject(obj) { + return obj?._layerKind === 'brush' + || (typeof obj?.id === 'string' && obj.id.startsWith('brush_')); + } + + _isMosaicLayerObject(obj) { + return obj?._layerKind === 'mosaic' + || obj?._mosaicDynamic === true + || (typeof obj?.id === 'string' && obj.id.startsWith('mosaic_')); + } + + _isLegacyDefaultLayerName(name, obj) { + const label = this._getLegacyTypeLabel(obj); + if (!label) return false; + + return new RegExp(`^${this._escapeRegExp(label)}-\\d+$`).test(name); + } + + _getLegacyTypeLabel(obj) { + const legacyLabelMap = { + 'image': '马赛克', + 'i-text': '文字', + 'textbox': '文字', + 'text': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '涂鸦', + 'group': '组合', + }; + + return legacyLabelMap[obj?.type] || '图层'; + } + + _getBrushColorPresetName(color) { + const colorMap = { + '#d83b31': '红', + '#1677ff': '蓝', + '#ffd700': '黄', + '#2ead4a': '绿', + '#ffffff': '白', + '#111111': '黑', + }; + + return colorMap[this._normalizeColor(color)] || ''; + } + + _getBrushWidthPresetName(width) { + const widthMap = { + 3: '细', + 6: '中', + 12: '粗', + 24: '特粗', + }; + + return widthMap[Math.round(Number(width))] || ''; + } + + _getMosaicPresetName(obj) { + if ((obj?._mosaicMode || 'mosaic') === 'blur') { + const blurMap = { + 6: '轻模糊', + 12: '中模糊', + 18: '强模糊', + }; + return blurMap[Math.round(Number(obj._mosaicBlurRadius))] || ''; + } + + const mosaicMap = { + 6: '轻马赛克', + 12: '中马赛克', + 24: '重马赛克', + }; + return mosaicMap[Math.round(Number(obj?._mosaicSize))] || ''; + } + + _normalizeColor(color) { + if (typeof color !== 'string') return ''; + + const value = color.trim().toLowerCase(); + 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 ''; + } + + _escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + _joinLayerNameParts(baseName, ...parts) { + return [baseName, ...parts] + .map(part => String(part || '').trim()) + .filter(Boolean) + .join(' - '); + } + + _normalizeLayerText(text) { + return String(text || '').replace(/\s+/g, ' ').trim(); + } + + _getObjectLayerName(obj) { + if (typeof obj?._layerName !== 'string') return ''; + + return obj._layerName.trim() ? obj._layerName : ''; + } + + _setObjectLayerName(obj, name, auto = false, baseName = '') { + if (!obj || typeof name !== 'string' || !name.trim()) return; + + obj._layerName = name; + obj._layerNameAuto = !!auto; + obj._layerBaseName = auto ? (baseName || name) : ''; + } + + /** + * 切换图层可见性 + * @param {number} layerId + */ + toggleVisibility(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + + this.setVisibility(layerId, !meta.visible); + } + + /** + * 设置图层可见性 + * @param {number} layerId + * @param {boolean} visible + */ + setVisibility(layerId, visible) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + + meta.visible = !!visible; + meta.fabricObj.set({ visible: meta.visible }); + this._cm.canvas.renderAll(); + eventBus.emit('layers:updated', this._layers); + eventBus.emit('layer:visibilityChanged', meta); + } + + /** + * 切换图层锁定 + * @param {number} layerId + */ + toggleLock(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this.setLock(layerId, !meta.locked); + } + + /** + * 设置图层锁定状态 + * @param {number} layerId + * @param {boolean} locked + */ + setLock(layerId, locked) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + meta.locked = !!locked; + meta.fabricObj.set({ + selectable: !meta.locked, + evented: !meta.locked, + }); + if (meta.locked && this._cm.canvas.getActiveObject() === meta.fabricObj) { + this._cm.canvas.discardActiveObject(); + } + this._cm.canvas.renderAll(); + eventBus.emit('layers:updated', this._layers); + eventBus.emit('layer:lockChanged', meta); + } + + /** + * 选中图层 + * @param {number} layerId + */ + selectLayer(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + + // 即使图层被锁定也触发事件(让橡皮擦等工具能响应图层切换), + // 但不调用 setActiveObject(避免误操作锁定图层)。 + if (!meta.locked) { + this._cm.canvas.setActiveObject(meta.fabricObj); + this._cm.canvas.renderAll(); + } + eventBus.emit('layer:selected', meta); + } + + /** + * 删除图层 + * @param {number} layerId + */ + deleteLayer(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.locked || meta.isBackground) return; + + this._cm.canvas.remove(meta.fabricObj); + this._layers = this._layers.filter(l => l.id !== layerId); + this._cm.canvas.renderAll(); + eventBus.emit('layers:updated', this._layers); + } + + /** + * 图层上移 + * @param {number} layerId + */ + moveUp(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this._cm.canvas.bringForward(meta.fabricObj); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 图层下移 + * @param {number} layerId + */ + moveDown(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this._cm.canvas.sendBackwards(meta.fabricObj); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 置顶 + * @param {number} layerId + */ + bringToFront(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + this._cm.canvas.bringToFront(meta.fabricObj); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 置底(在原图之上) + * @param {number} layerId + */ + sendToBack(layerId) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground) return; + + const objects = this._cm.canvas.getObjects(); + const imgIndex = objects.indexOf(this._cm.originalImage); + this._cm.canvas.moveTo(meta.fabricObj, imgIndex + 1); + this._cm.canvas.renderAll(); + this.syncLayers(); + } + + /** + * 按图层面板位置重排图层(0 = 顶部,背景图层固定在底部) + * @param {number} layerId + * @param {number} targetPanelIndex + * @returns {boolean} 是否发生了重排 + */ + reorderLayer(layerId, targetPanelIndex) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta || meta.isBackground || !this._cm.canvas) return false; + + const overlayLayers = this._layers.filter(l => !l.isBackground); + const fromIndex = overlayLayers.findIndex(l => l.id === layerId); + if (fromIndex === -1 || overlayLayers.length < 2) return false; + + let insertIndex = Math.max(0, Math.min(targetPanelIndex, overlayLayers.length)); + if (fromIndex < insertIndex) insertIndex -= 1; + if (insertIndex === fromIndex) return false; + + const [movedLayer] = overlayLayers.splice(fromIndex, 1); + overlayLayers.splice(insertIndex, 0, movedLayer); + + eventBus.emit('layer:reorderWillChange', { + layer: movedLayer, + fromIndex, + toIndex: insertIndex, + }); + + this._applyPanelOrder(overlayLayers); + this._cm.canvas.renderAll(); + this.syncLayers(); + + eventBus.emit('layer:reordered', { + layer: movedLayer, + fromIndex, + toIndex: insertIndex, + }); + + return true; + } + + _applyPanelOrder(overlayLayers) { + const canvas = this._cm.canvas; + const objects = canvas.getObjects(); + const backgroundIndex = objects.indexOf(this._cm.originalImage); + const startIndex = backgroundIndex >= 0 ? backgroundIndex + 1 : 0; + const bottomToTopLayers = overlayLayers.slice().reverse(); + + bottomToTopLayers.forEach((layer, index) => { + canvas.moveTo(layer.fabricObj, startIndex + index); + }); + } + + /** + * 图层重命名 + * @param {number} layerId + * @param {string} newName + */ + renameLayer(layerId, newName) { + const meta = this._layers.find(l => l.id === layerId); + if (!meta) return; + meta.name = newName; + this._setObjectLayerName(meta.fabricObj, newName, false); + eventBus.emit('layers:updated', this._layers); + } + + /** + * 获取图层数量 + * @returns {number} + */ + getCount() { + return this._layers.length; + } +} + +export default LayerManager; diff --git a/plugins/Image-Toolbox/core/src/LayerStore.js b/plugins/Image-Toolbox/core/src/LayerStore.js new file mode 100644 index 00000000..ff5f975b --- /dev/null +++ b/plugins/Image-Toolbox/core/src/LayerStore.js @@ -0,0 +1,292 @@ +/** + * 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 new file mode 100644 index 00000000..3f23ce27 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ToolManager.js @@ -0,0 +1,230 @@ +import eventBus from './EventBus.js'; +import SelectModule from './modules/SelectModule.js'; +import MosaicModule from './modules/MosaicModule.js'; +import CropModule from './modules/CropModule.js'; +import BrushModule from './modules/BrushModule.js'; +import EraserModule from './modules/EraserModule.js'; +import TextModule from './modules/TextModule.js'; +import ShapeModule from './modules/ShapeModule.js'; +import ExportModule from './modules/ExportModule.js'; + +/** + * 工具管理器 — 管理工具栏状态和工具切换 + * + * 支持两种使用方式: + * 1. 默认:自动注册内置工具(向后兼容) + * 2. 注入:通过 tools 数组外部传入工具定义列表 + */ +class ToolManager { + /** + * @param {import('./CanvasManager.js').default} canvasManager + * @param {import('./HistoryManager.js').default} historyManager + * @param {object} [options] + * @param {Array} [options.tools] - 外部注入的工具定义列表 + * @param {import('./interfaces/HostAdapter.js').default} [options.host] + */ + constructor(canvasManager, historyManager, options = {}) { + this._cm = canvasManager; + this._hm = historyManager; + this._modules = {}; + this._currentTool = null; + this._tools = []; + this._host = options.host || null; + + if (options.tools) { + // 外部注入模式:注册传入的工具,不再硬编码内置模块 + options.tools.forEach(t => this.registerTool(t)); + } else { + // 默认模式:注册内置模块(向后兼容) + this._registerBuiltinModules(); + } + + // ExportModule 始终单独实例化,不显示在工具栏 + this._modules['export'] = new ExportModule(this._cm, this._hm, {}, this._host); + } + + /** + * 注入 host adapter。 + * @param {import('./interfaces/HostAdapter.js').default} host + */ + setHost(host) { + this._host = host; + this._modules['export']?.setHost(host); + } + + /** + * 注册内置模块 + */ + _registerBuiltinModules() { + this.registerTool({ + name: 'select', + label: '移动/框选', + icon: 'select', + group: 'edit', + shortcut: 'V', + module: SelectModule, + }); + + this.registerTool({ + name: 'mosaic', + label: '马赛克', + icon: 'mosaic', + group: 'edit', + shortcut: 'M', + module: MosaicModule, + defaultOptions: { mode: 'mosaic', drawMode: 'rect', mosaicSize: 12, blurRadius: 8, brushSize: 20 }, + }); + + this.registerTool({ + name: 'crop', + label: '剪切', + icon: 'crop', + group: 'edit', + shortcut: 'C', + module: CropModule, + }); + + this.registerTool({ + name: 'brush', + label: '画笔', + icon: 'brush', + group: 'annotate', + shortcut: 'B', + module: BrushModule, + defaultOptions: { color: '#d83b31', width: 6 }, + }); + + this.registerTool({ + name: 'eraser', + label: '橡皮擦', + icon: 'eraser', + group: 'annotate', + shortcut: 'E', + module: EraserModule, + defaultOptions: { width: 20 }, + }); + + this.registerTool({ + name: 'text', + label: '文字标注', + icon: 'text', + group: 'annotate', + shortcut: 'T', + module: TextModule, + }); + + this.registerTool({ + name: 'shape', + label: '图形', + icon: 'shape', + group: 'annotate', + shortcut: 'S', + module: ShapeModule, + defaultOptions: { shapeType: 'rect', fill: 'transparent', stroke: 'rgba(216, 59, 49, 1)', strokeWidth: 2 }, + }); + } + + /** + * 注册工具 + * @param {object} toolDef - { name, label, icon, group, shortcut, module, defaultOptions } + */ + registerTool(toolDef) { + this._tools.push(toolDef); + + // 如果有模块类,实例化 + if (toolDef.module) { + this._modules[toolDef.name] = new toolDef.module(this._cm, this._hm, toolDef.defaultOptions); + } + } + + /** + * 获取所有工具定义 + * @returns {Array} + */ + getTools() { + return this._tools; + } + + /** + * 激活工具 + * @param {string} toolName + * @param {object} [options] + */ + activateTool(toolName, options = {}) { + // 停用当前工具 + if (this._currentTool && this._modules[this._currentTool]) { + this._modules[this._currentTool].deactivate(); + } + + const tool = this._tools.find(t => t.name === toolName); + if (!tool) { + console.warn(`[ToolManager] 未知工具: ${toolName}`); + this._currentTool = null; + eventBus.emit('tool:changed', null); + return; + } + + this._currentTool = toolName; + + // 激活新工具的模块 + const module = this._modules[toolName]; + if (module) { + module.activate(options); + } + + eventBus.emit('tool:changed', toolName); + } + + /** + * 获取当前工具名 + * @returns {string|null} + */ + getCurrentTool() { + return this._currentTool; + } + + /** + * 获取当前模块实例 + * @returns {BaseModule|null} + */ + getCurrentModule() { + return this._currentTool ? this._modules[this._currentTool] : null; + } + + /** + * 获取模块实例 + * @param {string} name + * @returns {BaseModule|null} + */ + getModule(name) { + return this._modules[name] || null; + } + + /** + * 执行导出 + * @param {string} action - 'file' | 'clipboard' + */ + async export(action = 'file') { + const exportModule = this._modules['export']; + if (!exportModule) return; + + if (action === 'clipboard') { + await exportModule.exportToClipboard(); + } else { + await exportModule.exportToFile(); + } + } + + /** + * 销毁所有模块 + */ + destroy() { + Object.values(this._modules).forEach(m => { + if (m.deactivate) m.deactivate(); + }); + this._modules = {}; + this._currentTool = null; + } +} + +export default ToolManager; diff --git a/plugins/Image-Toolbox/core/src/ToolRegistry.js b/plugins/Image-Toolbox/core/src/ToolRegistry.js new file mode 100644 index 00000000..b09c50cd --- /dev/null +++ b/plugins/Image-Toolbox/core/src/ToolRegistry.js @@ -0,0 +1,268 @@ +/** + * 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/index.js b/plugins/Image-Toolbox/core/src/index.js new file mode 100644 index 00000000..6d041dfe --- /dev/null +++ b/plugins/Image-Toolbox/core/src/index.js @@ -0,0 +1,17 @@ +// ═══════════════════════════════════════════════════════ +// @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 new file mode 100644 index 00000000..2a9329e7 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/interfaces/EditorEngineAdapter.js @@ -0,0 +1,130 @@ +/** + * 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 new file mode 100644 index 00000000..01c9aacb --- /dev/null +++ b/plugins/Image-Toolbox/core/src/interfaces/HostAdapter.js @@ -0,0 +1,97 @@ +/** + * 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/lib/fabric.min.js b/plugins/Image-Toolbox/core/src/lib/fabric.min.js new file mode 100644 index 00000000..18ec2418 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/lib/fabric.min.js @@ -0,0 +1 @@ +var jsdom,virtualWindow,fabric=fabric||{version:"5.3.0"};function resizeCanvasIfNeeded(t){var e=t.targetCanvas,i=e.width,r=e.height,n=t.destinationWidth,t=t.destinationHeight;i===n&&r===t||(e.width=n,e.height=t)}function copyGLTo2DDrawImage(t,e){var t=t.canvas,e=e.targetCanvas,i=e.getContext("2d"),r=(i.translate(0,e.height),i.scale(1,-1),t.height-e.height);i.drawImage(t,0,r,e.width,e.height,0,0,e.width,e.height)}function copyGLTo2DPutImageData(t,e){var i=e.targetCanvas.getContext("2d"),r=e.destinationWidth,e=e.destinationHeight,n=r*e*4,s=new Uint8Array(this.imageBuffer,0,n),n=new Uint8ClampedArray(this.imageBuffer,0,n),t=(t.readPixels(0,0,r,e,t.RGBA,t.UNSIGNED_BYTE,s),new ImageData(n,r,e));i.putImageData(t,0,0)}"undefined"!=typeof exports?exports.fabric=fabric:"function"==typeof define&&define.amd&&define([],function(){return fabric}),"undefined"!=typeof document&&"undefined"!=typeof window?(document instanceof("undefined"!=typeof HTMLDocument?HTMLDocument:Document)?fabric.document=document:fabric.document=document.implementation.createHTMLDocument(""),fabric.window=window):(jsdom=require("jsdom"),virtualWindow=new jsdom.JSDOM(decodeURIComponent("%3C!DOCTYPE%20html%3E%3Chtml%3E%3Chead%3E%3C%2Fhead%3E%3Cbody%3E%3C%2Fbody%3E%3C%2Fhtml%3E"),{features:{FetchExternalResources:["img"]},resources:"usable"}).window,fabric.document=virtualWindow.document,fabric.jsdomImplForWrapper=require("jsdom/lib/jsdom/living/generated/utils").implForWrapper,fabric.nodeCanvas=require("jsdom/lib/jsdom/utils").Canvas,fabric.window=virtualWindow,DOMParser=fabric.window.DOMParser),fabric.isTouchSupported="ontouchstart"in fabric.window||"ontouchstart"in fabric.document||fabric.window&&fabric.window.navigator&&0_)for(var C=1,S=v.length;Ct[i-2].x?1:s.x===t[i-2].x?0:-1,h=s.y>t[i-2].y?1:s.y===t[i-2].y?0:-1),n.push(["L",s.x+c*e,s.y+h*e]),n},fabric.util.getPathSegmentsInfo=l,fabric.util.getBoundsOfCurve=function(t,e,i,r,n,s,o,a){var c;if(fabric.cachesBoundsOfCurve&&(c=w.call(arguments),fabric.boundsOfCurveCache[c]))return fabric.boundsOfCurveCache[c];for(var h,l,u,f=Math.sqrt,d=Math.min,g=Math.max,p=Math.abs,m=[],v=[[],[]],b=6*t-12*i+6*n,y=-3*t+9*i-9*n+3*o,_=3*i-3*t,x=0;x<2;++x)0/g,">")},graphemeSplit:function(t){for(var e,i=0,r=[],i=0;it.x&&this.y>t.y},gte:function(t){return this.x>=t.x&&this.y>=t.y},lerp:function(t,e){return void 0===e&&(e=.5),e=Math.max(Math.min(1,e),0),new i(this.x+(t.x-this.x)*e,this.y+(t.y-this.y)*e)},distanceFrom:function(t){var e=this.x-t.x,t=this.y-t.y;return Math.sqrt(e*e+t*t)},midPointFrom:function(t){return this.lerp(t)},min:function(t){return new i(Math.min(this.x,t.x),Math.min(this.y,t.y))},max:function(t){return new i(Math.max(this.x,t.x),Math.max(this.y,t.y))},toString:function(){return this.x+","+this.y},setXY:function(t,e){return this.x=t,this.y=e,this},setX:function(t){return this.x=t,this},setY:function(t){return this.y=t,this},setFromPoint:function(t){return this.x=t.x,this.y=t.y,this},swap:function(t){var e=this.x,i=this.y;this.x=t.x,this.y=t.y,t.x=e,t.y=i},clone:function(){return new i(this.x,this.y)}}}("undefined"!=typeof exports?exports:this),function(t){"use strict";var a=t.fabric||(t.fabric={});function c(t){this.status=t,this.points=[]}a.Intersection?a.warn("fabric.Intersection is already defined"):(a.Intersection=c,a.Intersection.prototype={constructor:c,appendPoint:function(t){return this.points.push(t),this},appendPoints:function(t){return this.points=this.points.concat(t),this}},a.Intersection.intersectLineLine=function(t,e,i,r){var n,s=(r.x-i.x)*(t.y-i.y)-(r.y-i.y)*(t.x-i.x),o=(e.x-t.x)*(t.y-i.y)-(e.y-t.y)*(t.x-i.x),r=(r.y-i.y)*(e.x-t.x)-(r.x-i.x)*(e.y-t.y);return 0!=r?(i=o/r,0<=(r=s/r)&&r<=1&&0<=i&&i<=1?(n=new c("Intersection")).appendPoint(new a.Point(t.x+r*(e.x-t.x),t.y+r*(e.y-t.y))):n=new c):n=new c(0==s||0==o?"Coincident":"Parallel"),n},a.Intersection.intersectLinePolygon=function(t,e,i){for(var r,n,s=new c,o=i.length,a=0;a=o&&(s.x-=o),s.x<=-o&&(s.x+=o),s.y>=o&&(s.y-=o),s.y<=o&&(s.y+=o),s.x-=t.offsetX,s.y-=t.offsetY,s}function w(t){return t.flipX!==t.flipY}function O(t,e,i,r,n){0!==t[e]&&(e=n/t._getTransformedDimensions()[r]*t[i],t.set(i,e))}function P(t,e,i,r){var n,s=e.target,o=s._getTransformedDimensions(0,s.skewY),i=T(e,e.originX,e.originY,i,r),r=Math.abs(2*i.x)-o.x,i=s.skewX,r=(r<2?n=0:(n=g(Math.atan2(r/s.scaleX,o.y/s.scaleY)),e.originX===c&&e.originY===u&&(n=-n),e.originX===l&&e.originY===h&&(n=-n),w(s)&&(n=-n)),i!==n);return r&&(o=s._getTransformedDimensions().y,s.set("skewX",n),O(s,"skewY","scaleY","y",o)),r}function k(t,e,i,r){var n,s=e.target,o=s._getTransformedDimensions(s.skewX,0),i=T(e,e.originX,e.originY,i,r),r=Math.abs(2*i.y)-o.y,i=s.skewY,r=(r<2?n=0:(n=g(Math.atan2(r/s.scaleY,o.x/s.scaleX)),e.originX===c&&e.originY===u&&(n=-n),e.originX===l&&e.originY===h&&(n=-n),w(s)&&(n=-n)),i!==n);return r&&(o=s._getTransformedDimensions().x,s.set("skewY",n),O(s,"skewX","scaleX","x",o)),r}function E(t,e,i,r,n){var s=e.target,o=s.lockScalingX,a=s.lockScalingY,n=(n=n||{}).by,t=b(t,s),c=_(s,n,t),h=e.gestureScale;if(c)return!1;if(h)l=e.scaleX*h,u=e.scaleY*h;else{if(c=T(e,e.originX,e.originY,i,r),h="y"!==n?p(c.x):1,i="x"!==n?p(c.y):1,e.signX||(e.signX=h),e.signY||(e.signY=i),s.lockScalingFlip&&(e.signX!==h||e.signY!==i))return!1;var l,u,r=s._getTransformedDimensions();u=t&&!n?(t=Math.abs(c.x)+Math.abs(c.y),f=e.original,t=t/(Math.abs(r.x*f.scaleX/s.scaleX)+Math.abs(r.y*f.scaleY/s.scaleY)),l=f.scaleX*t,f.scaleY*t):(l=Math.abs(c.x*s.scaleX/r.x),Math.abs(c.y*s.scaleY/r.y)),y(e)&&(l*=2,u*=2),e.signX!==h&&"y"!==n&&(e.originX=d[e.originX],l*=-1,e.signX=h),e.signY!==i&&"x"!==n&&(e.originY=d[e.originY],u*=-1,e.signY=i)}var f=s.scaleX,t=s.scaleY;return n?("x"===n&&s.set("scaleX",l),"y"===n&&s.set("scaleY",u)):(o||s.set("scaleX",l),a||s.set("scaleY",u)),f!==s.scaleX||t!==s.scaleY}o.scaleCursorStyleHandler=function(t,e,i){var t=b(t,i),r="";return 0!==e.x&&0===e.y?r="x":0===e.x&&0!==e.y&&(r="y"),_(i,r,t)?"not-allowed":(r=m(i,e),n[r]+"-resize")},o.skewCursorStyleHandler=function(t,e,i){var r="not-allowed";return 0!==e.x&&i.lockSkewingY||0!==e.y&&i.lockSkewingX?r:(r=m(i,e)%4,s[r]+"-resize")},o.scaleSkewCursorStyleHandler=function(t,e,i){return t[i.canvas.altActionKey]?o.skewCursorStyleHandler(t,e,i):o.scaleCursorStyleHandler(t,e,i)},o.rotationWithSnapping=S("rotating",C(function(t,e,i,r){var n=e.target,s=n.translateToOriginPoint(n.getCenterPoint(),e.originX,e.originY);if(n.lockRotation)return!1;var o=Math.atan2(e.ey-s.y,e.ex-s.x),r=Math.atan2(r-s.y,i-s.x),i=g(r-o+e.theta);return 0r.r2,o=(this.gradientTransform||fabric.iMatrix).concat(),a=-this.offsetX,c=-this.offsetY,h=!!e.additionalTransform,l="pixels"===this.gradientUnits?"userSpaceOnUse":"objectBoundingBox";if(n.sort(function(t,e){return t.offset-e.offset}),"objectBoundingBox"==l?(a/=t.width,c/=t.height):(a+=t.width/2,c+=t.height/2),"path"===t.type&&"percentage"!==this.gradientUnits&&(a-=t.pathOffset.x,c-=t.pathOffset.y),o[4]-=a,o[5]-=c,t='id="SVGID_'+this.id+'" gradientUnits="'+l+'"',t+=' gradientTransform="'+(h?e.additionalTransform+" ":"")+fabric.util.matrixToSVG(o)+'" ',"linear"===this.type?i=["\n']:"radial"===this.type&&(i=["\n']),"radial"===this.type){if(s)for((n=n.concat()).reverse(),f=0,d=n.length;f\n')}return i.push("linear"===this.type?"\n":"\n"),i.join("")},toLive:function(t){var e,i,r,n=fabric.util.object.clone(this.coords);if(this.type){for("linear"===this.type?e=t.createLinearGradient(n.x1,n.y1,n.x2,n.y2):"radial"===this.type&&(e=t.createRadialGradient(n.x1,n.y1,n.r1,n.x2,n.y2,n.r2)),i=0,r=this.colorStops.length;i\n\n\n'},setOptions:function(t){for(var e in t)this[e]=t[e]},toLive:function(t){var e=this.source;if(!e)return"";if(void 0!==e.src){if(!e.complete)return"";if(0===e.naturalWidth||0===e.naturalHeight)return""}return t.createPattern(e,this.repeat)}})}(),function(t){"use strict";var o=t.fabric||(t.fabric={}),a=o.util.toFixed;o.Shadow?o.warn("fabric.Shadow is already defined."):(o.Shadow=o.util.createClass({color:"rgb(0,0,0)",blur:0,offsetX:0,offsetY:0,affectStroke:!1,includeDefaultValues:!0,nonScaling:!1,initialize:function(t){for(var e in t="string"==typeof t?this._parseShadow(t):t)this[e]=t[e];this.id=o.Object.__uid++},_parseShadow:function(t){var t=t.trim(),e=o.Shadow.reOffsetsAndBlur.exec(t)||[];return{color:(t.replace(o.Shadow.reOffsetsAndBlur,"")||"rgb(0,0,0)").trim(),offsetX:parseFloat(e[1],10)||0,offsetY:parseFloat(e[2],10)||0,blur:parseFloat(e[3],10)||0}},toString:function(){return[this.offsetX,this.offsetY,this.blur,this.color].join("px ")},toSVG:function(t){var e=40,i=40,r=o.Object.NUM_FRACTION_DIGITS,n=o.util.rotateVector({x:this.offsetX,y:this.offsetY},o.util.degreesToRadians(-t.angle)),s=new o.Color(this.color);return t.width&&t.height&&(e=100*a((Math.abs(n.x)+this.blur)/t.width,r)+20,i=100*a((Math.abs(n.y)+this.blur)/t.height,r)+20),t.flipX&&(n.x*=-1),t.flipY&&(n.y*=-1),'\n\t\n\t\n\t\n\t\n\t\n\t\t\n\t\t\n\t\n\n'},toObject:function(){if(this.includeDefaultValues)return{color:this.color,blur:this.blur,offsetX:this.offsetX,offsetY:this.offsetY,affectStroke:this.affectStroke,nonScaling:this.nonScaling};var e={},i=o.Shadow.prototype;return["color","blur","offsetX","offsetY","affectStroke","nonScaling"].forEach(function(t){this[t]!==i[t]&&(e[t]=this[t])},this),e}}),o.Shadow.reOffsetsAndBlur=/(?:\s|^)(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?(\d+(?:\.\d*)?(?:px)?)?(?:\s?|$)(?:$|\s)/)}("undefined"!=typeof exports?exports:this),function(){"use strict";var n,t,h,a,s,o,i,r,e;fabric.StaticCanvas?fabric.warn("fabric.StaticCanvas is already defined."):(n=fabric.util.object.extend,t=fabric.util.getElementOffset,h=fabric.util.removeFromArray,a=fabric.util.toFixed,s=fabric.util.transformPoint,o=fabric.util.invertTransform,i=fabric.util.getNodeCanvas,r=fabric.util.createCanvasElement,e=new Error("Could not initialize `canvas` element"),fabric.StaticCanvas=fabric.util.createClass(fabric.CommonMethods,{initialize:function(t,e){e=e||{},this.renderAndResetBound=this.renderAndReset.bind(this),this.requestRenderAllBound=this.requestRenderAll.bind(this),this._initStatic(t,e)},backgroundColor:"",backgroundImage:null,overlayColor:"",overlayImage:null,includeDefaultValues:!0,stateful:!1,renderOnAddRemove:!0,controlsAboveOverlay:!1,allowTouchScrolling:!1,imageSmoothingEnabled:!0,viewportTransform:fabric.iMatrix.concat(),backgroundVpt:!0,overlayVpt:!0,enableRetinaScaling:!0,vptCoords:{},skipOffscreen:!0,clipPath:void 0,_initStatic:function(t,e){var i=this.requestRenderAllBound;this._objects=[],this._createLowerCanvas(t),this._initOptions(e),this.interactive||this._initRetinaScaling(),e.overlayImage&&this.setOverlayImage(e.overlayImage,i),e.backgroundImage&&this.setBackgroundImage(e.backgroundImage,i),e.backgroundColor&&this.setBackgroundColor(e.backgroundColor,i),e.overlayColor&&this.setOverlayColor(e.overlayColor,i),this.calcOffset()},_isRetinaScaling:function(){return 1\n'),this._setSVGBgOverlayColor(i,"background"),this._setSVGBgOverlayImage(i,"backgroundImage",e),this._setSVGObjects(i,e),this.clipPath&&i.push("\n"),this._setSVGBgOverlayColor(i,"overlay"),this._setSVGBgOverlayImage(i,"overlayImage",e),i.push(""),i.join("")},_setSVGPreamble:function(t,e){e.suppressPreamble||t.push('\n','\n')},_setSVGHeader:function(t,e){var i,r=e.width||this.width,n=e.height||this.height,s='viewBox="0 0 '+this.width+" "+this.height+'" ',o=fabric.Object.NUM_FRACTION_DIGITS;e.viewBox?s='viewBox="'+e.viewBox.x+" "+e.viewBox.y+" "+e.viewBox.width+" "+e.viewBox.height+'" ':this.svgViewportTransformation&&(i=this.viewportTransform,s='viewBox="'+a(-i[4]/i[0],o)+" "+a(-i[5]/i[3],o)+" "+a(this.width/i[0],o)+" "+a(this.height/i[3],o)+'" '),t.push("\n',"Created with Fabric.js ",fabric.version,"\n","\n",this.createSVGFontFacesMarkup(),this.createSVGRefElementsMarkup(),this.createSVGClipPathMarkup(e),"\n")},createSVGClipPathMarkup:function(t){var e=this.clipPath;return e?(e.clipPathId="CLIPPATH_"+fabric.Object.__uid++,'\n'+this.clipPath.toClipPathSVG(t.reviver)+"\n"):""},createSVGRefElementsMarkup:function(){var n=this;return["background","overlay"].map(function(t){var e,i,r=n[t+"Color"];if(r&&r.toLive)return t=n[t+"Vpt"],e=n.viewportTransform,i={width:n.width/(t?e[0]:1),height:n.height/(t?e[3]:1)},r.toSVG(i,{additionalTransform:t?fabric.util.matrixToSVG(e):""})}).join("")},createSVGFontFacesMarkup:function(){var t,e,i,r,n,s,o,a,c,h="",l={},u=fabric.fontPaths,f=[];for(this._objects.forEach(function t(e){f.push(e),e._objects&&e._objects.forEach(t)}),o=0,a=f.length;o',"\n",h,"","\n"].join("")},_setSVGObjects:function(t,e){for(var i,r=this._objects,n=0,s=r.length;n\n")):t.push('\n"))},sendToBack:function(t){if(!t)return this;var e,i,r,n=this._activeObject;if(t===n&&"activeSelection"===t.type)for(e=(r=n._objects).length;e--;)i=r[e],h(this._objects,i),this._objects.unshift(i);else h(this._objects,t),this._objects.unshift(t);return this.renderOnAddRemove&&this.requestRenderAll(),this},bringToFront:function(t){if(!t)return this;var e,i,r,n=this._activeObject;if(t===n&&"activeSelection"===t.type)for(r=n._objects,e=0;e"}}),n(fabric.StaticCanvas.prototype,fabric.Observable),n(fabric.StaticCanvas.prototype,fabric.Collection),n(fabric.StaticCanvas.prototype,fabric.DataURLExporter),n(fabric.StaticCanvas,{EMPTY_JSON:'{"objects": [], "background": "white"}',supports:function(t){var e=r();if(!e||!e.getContext)return null;e=e.getContext("2d");return!e||"setLineDash"!==t?null:void 0!==e.setLineDash}}),fabric.StaticCanvas.prototype.toJSON=fabric.StaticCanvas.prototype.toObject,fabric.isLikelyNode&&(fabric.StaticCanvas.prototype.createPNGStream=function(){var t=i(this.lowerCanvasEl);return t&&t.createPNGStream()},fabric.StaticCanvas.prototype.createJPEGStream=function(t){var e=i(this.lowerCanvasEl);return e&&e.createJPEGStream(t)}))}(),fabric.BaseBrush=fabric.util.createClass({color:"rgb(0, 0, 0)",width:1,shadow:null,strokeLineCap:"round",strokeLineJoin:"round",strokeMiterLimit:10,strokeDashArray:null,limitedToCanvasSize:!1,_setBrushStyles:function(t){t.strokeStyle=this.color,t.lineWidth=this.width,t.lineCap=this.strokeLineCap,t.miterLimit=this.strokeMiterLimit,t.lineJoin=this.strokeLineJoin,t.setLineDash(this.strokeDashArray||[])},_saveAndTransform:function(t){var e=this.canvas.viewportTransform;t.save(),t.transform(e[0],e[1],e[2],e[3],e[4],e[5])},_setShadow:function(){var t,e,i,r;this.shadow&&(t=this.canvas,e=this.shadow,i=t.contextTop,r=t.getZoom(),t&&t._isRetinaScaling()&&(r*=fabric.devicePixelRatio),i.shadowColor=e.color,i.shadowBlur=e.blur*r,i.shadowOffsetX=e.offsetX*r,i.shadowOffsetY=e.offsetY*r)},needsFullRender:function(){return new fabric.Color(this.color).getAlpha()<1||!!this.shadow},_resetShadow:function(){var t=this.canvas.contextTop;t.shadowColor="",t.shadowBlur=t.shadowOffsetX=t.shadowOffsetY=0},_isOutSideCanvas:function(t){return t.x<0||t.x>this.canvas.getWidth()||t.y<0||t.y>this.canvas.getHeight()}}),fabric.PencilBrush=fabric.util.createClass(fabric.BaseBrush,{decimate:.4,drawStraightLine:!1,straightLineKey:"shiftKey",initialize:function(t){this.canvas=t,this._points=[]},needsFullRender:function(){return this.callSuper("needsFullRender")||this._hasStraightLine},_drawSegment:function(t,e,i){i=e.midPointFrom(i);return t.quadraticCurveTo(e.x,e.y,i.x,i.y),i},onMouseDown:function(t,e){this.canvas._isMainEvent(e.e)&&(this.drawStraightLine=e.e[this.straightLineKey],this._prepareForDrawing(t),this._captureDrawingPath(t),this._render())},onMouseMove:function(t,e){var i;this.canvas._isMainEvent(e.e)&&(this.drawStraightLine=e.e[this.straightLineKey],!0===this.limitedToCanvasSize&&this._isOutSideCanvas(t)||this._captureDrawingPath(t)&&1"},getObjectScaling:function(){if(!this.group)return{scaleX:this.scaleX,scaleY:this.scaleY};var t=g.util.qrDecompose(this.calcTransformMatrix());return{scaleX:Math.abs(t.scaleX),scaleY:Math.abs(t.scaleY)}},getTotalObjectScaling:function(){var t,e,i=this.getObjectScaling(),r=i.scaleX,i=i.scaleY;return this.canvas&&(r*=(t=this.canvas.getZoom())*(e=this.canvas.getRetinaScaling()),i*=t*e),{scaleX:r,scaleY:i}},getObjectOpacity:function(){var t=this.opacity;return this.group&&(t*=this.group.getObjectOpacity()),t},_set:function(t,e){var i=this[t]!==e;return("scaleX"===t||"scaleY"===t)&&(e=this._constrainScale(e)),"scaleX"===t&&e<0?(this.flipX=!this.flipX,e*=-1):"scaleY"===t&&e<0?(this.flipY=!this.flipY,e*=-1):"shadow"!==t||!e||e instanceof g.Shadow?"dirty"===t&&this.group&&this.group.set("dirty",e):e=new g.Shadow(e),this[t]=e,i&&(e=this.group&&this.group.isOnACache(),-1=t.x&&i.left+i.width<=e.x&&i.top>=t.y&&i.top+i.height<=e.y},containsPoint:function(t,e,i,r){i=this._getCoords(i,r),e=e||this._getImageLines(i),r=this._findCrossPoints(t,e);return 0!==r&&r%2==1},isOnScreen:function(t){if(!this.canvas)return!1;var e=this.canvas.vptCoords.tl,i=this.canvas.vptCoords.br;return!!this.getCoords(!0,t).some(function(t){return t.x<=i.x&&t.x>=e.x&&t.y<=i.y&&t.y>=e.y})||(!!this.intersectsWithRect(e,i,!0,t)||this._containsCenterOfCanvas(e,i,t))},_containsCenterOfCanvas:function(t,e,i){t={x:(t.x+e.x)/2,y:(t.y+e.y)/2};return!!this.containsPoint(t,null,!0,i)},isPartiallyOnScreen:function(t){if(!this.canvas)return!1;var e=this.canvas.vptCoords.tl,i=this.canvas.vptCoords.br;return!!this.intersectsWithRect(e,i,!0,t)||this.getCoords(!0,t).every(function(t){return(t.x>=i.x||t.x<=e.x)&&(t.y>=i.y||t.y<=e.y)})&&this._containsCenterOfCanvas(e,i,t)},_getImageLines:function(t){return{topline:{o:t.tl,d:t.tr},rightline:{o:t.tr,d:t.br},bottomline:{o:t.br,d:t.bl},leftline:{o:t.bl,d:t.tl}}},_findCrossPoints:function(t,e){var i,r,n,s=0;for(n in e)if(!((r=e[n]).o.y=t.y&&r.d.y>=t.y||((r.o.x===r.d.x&&r.o.x>=t.x?r.o.x:(i=(r.d.y-r.o.y)/(r.d.x-r.o.x),-(t.y-0*t.x-(r.o.y-i*r.o.x))/(0-i)))>=t.x&&(s+=1),2!==s)))break;return s},getBoundingRect:function(t,e){t=this.getCoords(t,e);return s.makeBoundingBoxFromPoints(t)},getScaledWidth:function(){return this._getTransformedDimensions().x},getScaledHeight:function(){return this._getTransformedDimensions().y},_constrainScale:function(t){return Math.abs(t)\n'))},toSVG:function(t){return this._createBaseSVGMarkup(this._toSVG(t),{reviver:t})},toClipPathSVG:function(t){return"\t"+this._createBaseClipPathSVGMarkup(this._toSVG(t),{reviver:t})},_createBaseClipPathSVGMarkup:function(t,e){var i=(e=e||{}).reviver,e=e.additionalTransform||"",e=[this.getSvgTransform(!0,e),this.getSvgCommons()].join(""),r=t.indexOf("COMMON_PARTS");return t[r]=e,i?i(t.join("")):t.join("")},_createBaseSVGMarkup:function(t,e){var i,r=(e=e||{}).noStyle,n=e.reviver,s=r?"":'style="'+this.getSvgStyles()+'" ',o=e.withShadow?'style="'+this.getSvgFilter()+'" ':"",a=this.clipPath,c=this.strokeUniform?'vector-effect="non-scaling-stroke" ':"",h=a&&a.absolutePositioned,l=this.stroke,u=this.fill,f=this.shadow,d=[],g=t.indexOf("COMMON_PARTS"),e=e.additionalTransform;return a&&(a.clipPathId="CLIPPATH_"+fabric.Object.__uid++,i='\n'+a.toClipPathSVG(n)+"\n"),h&&d.push("\n"),d.push("\n"),o=[s,c,r?"":this.addPaintOrder()," ",e?'transform="'+e+'" ':""].join(""),t[g]=o,u&&u.toLive&&d.push(u.toSVG(this)),l&&l.toLive&&d.push(l.toSVG(this)),f&&d.push(f.toSVG(this)),a&&d.push(i),d.push(t.join("")),d.push("\n"),h&&d.push("\n"),n?n(d.join("")):d.join("")},addPaintOrder:function(){return"fill"!==this.paintFirst?' paint-order="'+this.paintFirst+'" ':""}})}(),function(){var n=fabric.util.object.extend,r="stateProperties";function s(e,t,i){var r={};i.forEach(function(t){r[t]=e[t]}),n(e[t],r,!0)}fabric.util.object.extend(fabric.Object.prototype,{hasStateChanged:function(t){var e="_"+(t=t||r);return Object.keys(this[e]).length\n']}}),n.Line.ATTRIBUTE_NAMES=n.SHARED_ATTRIBUTES.concat("x1 y1 x2 y2".split(" ")),n.Line.fromElement=function(t,e,i){i=i||{};var t=n.parseAttributes(t,n.Line.ATTRIBUTE_NAMES),r=[t.x1||0,t.y1||0,t.x2||0,t.y2||0];e(new n.Line(r,s(t,i)))},n.Line.fromObject=function(t,e){var i=r(t,!0);i.points=[t.x1,t.y1,t.x2,t.y2],n.Object._fromObject("Line",i,function(t){delete t.points,e&&e(t)},"points")})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var n=t.fabric||(t.fabric={}),s=n.util.degreesToRadians;n.Circle?n.warn("fabric.Circle is already defined."):(n.Circle=n.util.createClass(n.Object,{type:"circle",radius:0,startAngle:0,endAngle:360,cacheProperties:n.Object.prototype.cacheProperties.concat("radius","startAngle","endAngle"),_set:function(t,e){return this.callSuper("_set",t,e),"radius"===t&&this.setRadius(e),this},toObject:function(t){return this.callSuper("toObject",["radius","startAngle","endAngle"].concat(t))},_toSVG:function(){var t,e,i,r=(this.endAngle-this.startAngle)%360;return 0==r?["\n']:(t=s(this.startAngle),e=s(this.endAngle),i=this.radius,['\n"])},_render:function(t){t.beginPath(),t.arc(0,0,this.radius,s(this.startAngle),s(this.endAngle),!1),this._renderPaintInOrder(t)},getRadiusX:function(){return this.get("radius")*this.get("scaleX")},getRadiusY:function(){return this.get("radius")*this.get("scaleY")},setRadius:function(t){return this.radius=t,this.set("width",2*t).set("height",2*t)}}),n.Circle.ATTRIBUTE_NAMES=n.SHARED_ATTRIBUTES.concat("cx cy r".split(" ")),n.Circle.fromElement=function(t,e){var i,t=n.parseAttributes(t,n.Circle.ATTRIBUTE_NAMES);if(!("radius"in(i=t)&&0<=i.radius))throw new Error("value of `r` attribute is required and can not be negative");t.left=(t.left||0)-t.radius,t.top=(t.top||0)-t.radius,e(new n.Circle(t))},n.Circle.fromObject=function(t,e){n.Object._fromObject("Circle",t,e)})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var i=t.fabric||(t.fabric={});i.Triangle?i.warn("fabric.Triangle is already defined"):(i.Triangle=i.util.createClass(i.Object,{type:"triangle",width:100,height:100,_render:function(t){var e=this.width/2,i=this.height/2;t.beginPath(),t.moveTo(-e,i),t.lineTo(0,-i),t.lineTo(e,i),t.closePath(),this._renderPaintInOrder(t)},_toSVG:function(){var t=this.width/2,e=this.height/2;return["']}}),i.Triangle.fromObject=function(t,e){return i.Object._fromObject("Triangle",t,e)})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var i=t.fabric||(t.fabric={}),e=2*Math.PI;i.Ellipse?i.warn("fabric.Ellipse is already defined."):(i.Ellipse=i.util.createClass(i.Object,{type:"ellipse",rx:0,ry:0,cacheProperties:i.Object.prototype.cacheProperties.concat("rx","ry"),initialize:function(t){this.callSuper("initialize",t),this.set("rx",t&&t.rx||0),this.set("ry",t&&t.ry||0)},_set:function(t,e){switch(this.callSuper("_set",t,e),t){case"rx":this.rx=e,this.set("width",2*e);break;case"ry":this.ry=e,this.set("height",2*e)}return this},getRx:function(){return this.get("rx")*this.get("scaleX")},getRy:function(){return this.get("ry")*this.get("scaleY")},toObject:function(t){return this.callSuper("toObject",["rx","ry"].concat(t))},_toSVG:function(){return["\n']},_render:function(t){t.beginPath(),t.save(),t.transform(1,0,0,this.ry/this.rx,0,0),t.arc(0,0,this.rx,0,e,!1),t.restore(),this._renderPaintInOrder(t)}}),i.Ellipse.ATTRIBUTE_NAMES=i.SHARED_ATTRIBUTES.concat("cx cy rx ry".split(" ")),i.Ellipse.fromElement=function(t,e){t=i.parseAttributes(t,i.Ellipse.ATTRIBUTE_NAMES);t.left=(t.left||0)-t.rx,t.top=(t.top||0)-t.ry,e(new i.Ellipse(t))},i.Ellipse.fromObject=function(t,e){i.Object._fromObject("Ellipse",t,e)})}("undefined"!=typeof exports?exports:this),function(t){"use strict";var r=t.fabric||(t.fabric={}),n=r.util.object.extend;r.Rect?r.warn("fabric.Rect is already defined"):(r.Rect=r.util.createClass(r.Object,{stateProperties:r.Object.prototype.stateProperties.concat("rx","ry"),type:"rect",rx:0,ry:0,cacheProperties:r.Object.prototype.cacheProperties.concat("rx","ry"),initialize:function(t){this.callSuper("initialize",t),this._initRxRy()},_initRxRy:function(){this.rx&&!this.ry?this.ry=this.rx:this.ry&&!this.rx&&(this.rx=this.ry)},_render:function(t){var e=this.rx?Math.min(this.rx,this.width/2):0,i=this.ry?Math.min(this.ry,this.height/2):0,r=this.width,n=this.height,s=-this.width/2,o=-this.height/2,a=0!==e||0!==i,c=.4477152502;t.beginPath(),t.moveTo(s+e,o),t.lineTo(s+r-e,o),a&&t.bezierCurveTo(s+r-c*e,o,s+r,o+c*i,s+r,o+i),t.lineTo(s+r,o+n-i),a&&t.bezierCurveTo(s+r,o+n-c*i,s+r-c*e,o+n,s+r-e,o+n),t.lineTo(s+e,o+n),a&&t.bezierCurveTo(s+c*e,o+n,s,o+n-c*i,s,o+n-i),t.lineTo(s,o+i),a&&t.bezierCurveTo(s,o+c*i,s+c*e,o,s+e,o),t.closePath(),this._renderPaintInOrder(t)},toObject:function(t){return this.callSuper("toObject",["rx","ry"].concat(t))},_toSVG:function(){return["\n']}}),r.Rect.ATTRIBUTE_NAMES=r.SHARED_ATTRIBUTES.concat("x y rx ry width height".split(" ")),r.Rect.fromElement=function(t,e,i){if(!t)return e(null);i=i||{};t=r.parseAttributes(t,r.Rect.ATTRIBUTE_NAMES),t.left=t.left||0,t.top=t.top||0,t.height=t.height||0,t.width=t.width||0,i=new r.Rect(n(i?r.util.object.clone(i):{},t));i.visible=i.visible&&0\n']},commonRender:function(t){var e,i=this.points.length,r=this.pathOffset.x,n=this.pathOffset.y;if(!i||isNaN(this.points[i-1].y))return!1;t.beginPath(),t.moveTo(this.points[0].x-r,this.points[0].y-n);for(var s=0;s"},toObject:function(t){return r(this.callSuper("toObject",t),{path:this.path.map(function(t){return t.slice()})})},toDatalessObject:function(t){t=this.toObject(["sourcePath"].concat(t));return t.sourcePath&&delete t.path,t},_toSVG:function(){return["\n"]},_getOffsetTransform:function(){var t=f.Object.NUM_FRACTION_DIGITS;return" translate("+e(-this.pathOffset.x,t)+", "+e(-this.pathOffset.y,t)+")"},toClipPathSVG:function(t){var e=this._getOffsetTransform();return"\t"+this._createBaseClipPathSVGMarkup(this._toSVG(),{reviver:t,additionalTransform:e})},toSVG:function(t){var e=this._getOffsetTransform();return this._createBaseSVGMarkup(this._toSVG(),{reviver:t,additionalTransform:e})},complexity:function(){return this.path.length},_calcDimensions:function(){for(var t,e,i=[],r=[],n=0,s=0,o=0,a=0,c=0,h=this.path.length;c"},addWithUpdate:function(t){var e=!!this.group;return this._restoreObjectsState(),o.util.resetObjectTransform(this),t&&(e&&o.util.removeTransformFromObject(t,this.group.calcTransformMatrix()),this._objects.push(t),t.group=this,t._set("canvas",this.canvas)),this._calcBounds(),this._updateObjectsCoords(),this.dirty=!0,e?this.group.addWithUpdate():this.setCoords(),this},removeWithUpdate:function(t){return this._restoreObjectsState(),o.util.resetObjectTransform(this),this.remove(t),this._calcBounds(),this._updateObjectsCoords(),this.setCoords(),this.dirty=!0,this},_onObjectAdded:function(t){this.dirty=!0,t.group=this,t._set("canvas",this.canvas)},_onObjectRemoved:function(t){this.dirty=!0,delete t.group},_set:function(t,e){var i=this._objects.length;if(this.useSetOnGroup)for(;i--;)this._objects[i].setOnGroup(t,e);if("canvas"===t)for(;i--;)this._objects[i]._set(t,e);o.Object.prototype._set.call(this,t,e)},toObject:function(r){var n=this.includeDefaultValues,t=this._objects.filter(function(t){return!t.excludeFromExport}).map(function(t){var e=t.includeDefaultValues,i=(t.includeDefaultValues=n,t.toObject(r));return t.includeDefaultValues=e,i}),e=o.Object.prototype.toObject.call(this,r);return e.objects=t,e},toDatalessObject:function(r){var n,t=this.sourcePath,e=(t=t||(n=this.includeDefaultValues,this._objects.map(function(t){var e=t.includeDefaultValues,i=(t.includeDefaultValues=n,t.toDatalessObject(r));return t.includeDefaultValues=e,i})),o.Object.prototype.toDatalessObject.call(this,r));return e.objects=t,e},render:function(t){this._transformDone=!0,this.callSuper("render",t),this._transformDone=!1},shouldCache:function(){var t=o.Object.prototype.shouldCache.call(this);if(t)for(var e=0,i=this._objects.length;e\n"],i=0,r=this._objects.length;i\n"),e},getSvgStyles:function(){var t=void 0!==this.opacity&&1!==this.opacity?"opacity: "+this.opacity+";":"",e=this.visible?"":" visibility: hidden;";return[t,this.getSvgFilter(),e].join("")},toClipPathSVG:function(t){for(var e=[],i=0,r=this._objects.length;i"},shouldCache:function(){return!1},isOnACache:function(){return!1},_renderControls:function(t,e,i){t.save(),t.globalAlpha=this.isMoving?this.borderOpacityWhenMoving:1,this.callSuper("_renderControls",t,e),void 0===(i=i||{}).hasControls&&(i.hasControls=!1),i.forActiveSelection=!0;for(var r=0,n=this._objects.length;r\n','\t\n',"\n"),a=' clip-path="url(#imageCrop_'+e+')" '),this.imageSmoothing||(c='" image-rendering="optimizeSpeed'),r.push("\t\n"),(this.stroke||this.strokeDashArray)&&(e=this.fill,this.fill=null,t=["\t\n'],this.fill=e),"fill"!==this.paintFirst?i.concat(t,r):i.concat(r,t)):[]},getSrc:function(t){t=t?this._element:this._originalElement;return t?t.toDataURL?t.toDataURL():this.srcFromAttribute?t.getAttribute("src"):t.src:this.src||""},setSrc:function(t,i,r){return fabric.util.loadImage(t,function(t,e){this.setElement(t,r),this._setWidthHeight(),i&&i(this,e)},this,r&&r.crossOrigin),this},toString:function(){return'#'},applyResizeFilters:function(){var t=this.resizeFilter,e=this.minimumScaleTrigger,i=this.getTotalObjectScaling(),r=i.scaleX,i=i.scaleY,n=this._filteredEl||this._originalElement;if(this.group&&this.set("dirty",!0),!t||e=t,o=["highp","mediump","lowp"],a=0;a<3;a++)if(r=void 0,i="precision "+(i=o[a])+" float;\nvoid main(){}",r=(e=s).createShader(e.FRAGMENT_SHADER),e.shaderSource(r,i),e.compileShader(r),!!e.getShaderParameter(r,e.COMPILE_STATUS)){fabric.webGlPrecision=o[a];break}}return this.isSupported=n},(fabric.WebglFilterBackend=t).prototype={tileSize:2048,resources:{},setupGLContext:function(t,e){this.dispose(),this.createWebGLCanvas(t,e),this.aPosition=new Float32Array([0,0,0,1,1,0,1,1]),this.chooseFastestCopyGLTo2DMethod(t,e)},chooseFastestCopyGLTo2DMethod:function(t,e){var i=void 0!==window.performance;try{new ImageData(1,1),s=!0}catch(t){s=!1}var r="undefined"!=typeof ArrayBuffer,n="undefined"!=typeof Uint8ClampedArray;if(i&&s&&r&&n){var i=fabric.util.createCanvasElement(),s=new ArrayBuffer(t*e*4);if(fabric.forceGLPutImageData)return this.imageBuffer=s,void(this.copyGLTo2D=copyGLTo2DPutImageData);r={imageBuffer:s,destinationWidth:t,destinationHeight:e,targetCanvas:i};i.width=t,i.height=e,n=window.performance.now(),copyGLTo2DDrawImage.call(r,this.gl,r),t=window.performance.now()-n,n=window.performance.now(),copyGLTo2DPutImageData.call(r,this.gl,r),window.performance.now()-n 0.0) {\n"+this.fragmentSource[t]+"}\n}"},retrieveShader:function(t){var e,i=this.type+"_"+this.mode;return t.programCache.hasOwnProperty(i)||(e=this.buildSource(this.mode),t.programCache[i]=this.createProgram(t.context,e)),t.programCache[i]},applyTo2d:function(t){for(var e,i,r,n=t.imageData.data,s=n.length,o=1-this.alpha,t=new u.Color(this.color).getSource(),a=t[0]*this.alpha,c=t[1]*this.alpha,h=t[2]*this.alpha,l=0;l'},_getCacheCanvasDimensions:function(){var t=this.callSuper("_getCacheCanvasDimensions"),e=this.fontSize;return t.width+=e*t.zoomX,t.height+=e*t.zoomY,t},_render:function(t){var e=this.path;e&&!e.isNotVisible()&&e._render(t),this._setTextStyles(t),this._renderTextLinesBackground(t),this._renderTextDecoration(t,"underline"),this._renderText(t),this._renderTextDecoration(t,"overline"),this._renderTextDecoration(t,"linethrough")},_renderText:function(t){"stroke"===this.paintFirst?(this._renderTextStroke(t),this._renderTextFill(t)):(this._renderTextFill(t),this._renderTextStroke(t))},_setTextStyles:function(t,e,i){if(t.textBaseline="alphabetical",this.path)switch(this.pathAlign){case"center":t.textBaseline="middle";break;case"ascender":t.textBaseline="top";break;case"descender":t.textBaseline="bottom"}t.font=this._getFontDeclaration(e,i)},calcTextWidth:function(){for(var t=this.getLineWidth(0),e=1,i=this._textLines.length;ethis.__selectionStartOnMouseDown?(this.selectionStart=this.__selectionStartOnMouseDown,this.selectionEnd=t):(this.selectionStart=t,this.selectionEnd=this.__selectionStartOnMouseDown),this.selectionStart===e&&this.selectionEnd===i||(this.restartCursorIfNeeded(),this._fireSelectionChanged(),this._updateTextarea(),this.renderCursorOrSelection())))},_setEditingProps:function(){this.hoverCursor="text",this.canvas&&(this.canvas.defaultCursor=this.canvas.moveCursor="text"),this.borderColor=this.editingBorderColor,this.hasControls=this.selectable=!1,this.lockMovementX=this.lockMovementY=!0},fromStringToGraphemeSelection:function(t,e,i){var r=i.slice(0,t),r=fabric.util.string.graphemeSplit(r).length;if(t===e)return{selectionStart:r,selectionEnd:r};i=i.slice(t,e);return{selectionStart:r,selectionEnd:r+fabric.util.string.graphemeSplit(i).length}},fromGraphemeToStringSelection:function(t,e,i){var r=i.slice(0,t).join("").length;return t===e?{selectionStart:r,selectionEnd:r}:{selectionStart:r,selectionEnd:r+i.slice(t,e).join("").length}},_updateTextarea:function(){var t;this.cursorOffsetCache={},this.hiddenTextarea&&(this.inCompositionMode||(t=this.fromGraphemeToStringSelection(this.selectionStart,this.selectionEnd,this._text),this.hiddenTextarea.selectionStart=t.selectionStart,this.hiddenTextarea.selectionEnd=t.selectionEnd),this.updateTextareaPosition())},updateFromTextArea:function(){var t;this.hiddenTextarea&&(this.cursorOffsetCache={},this.text=this.hiddenTextarea.value,this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),t=this.fromStringToGraphemeSelection(this.hiddenTextarea.selectionStart,this.hiddenTextarea.selectionEnd,this.hiddenTextarea.value),this.selectionEnd=this.selectionStart=t.selectionEnd,this.inCompositionMode||(this.selectionStart=t.selectionStart),this.updateTextareaPosition())},updateTextareaPosition:function(){var t;this.selectionStart===this.selectionEnd&&(t=this._calcTextareaPosition(),this.hiddenTextarea.style.left=t.left,this.hiddenTextarea.style.top=t.top)},_calcTextareaPosition:function(){if(!this.canvas)return{x:1,y:1};var t=this.inCompositionMode?this.compositionStart:this.selectionStart,e=this._getCursorBoundaries(t),t=this.get2DCursorLocation(t),i=t.lineIndex,t=t.charIndex,i=this.getValueOfPropertyAt(i,t,"fontSize")*this.lineHeight,t=e.leftOffset,r=this.calcTransformMatrix(),t={x:e.left+t,y:e.top+e.topOffset+i},e=this.canvas.getRetinaScaling(),n=this.canvas.upperCanvasEl,s=n.width/e,e=n.height/e,o=s-i,a=e-i,s=n.clientWidth/s,n=n.clientHeight/e,t=fabric.util.transformPoint(t,r);return(t=fabric.util.transformPoint(t,this.canvas.viewportTransform)).x*=s,t.y*=n,t.x<0&&(t.x=0),t.x>o&&(t.x=o),t.y<0&&(t.y=0),t.y>a&&(t.y=a),t.x+=this.canvas._offset.left,t.y+=this.canvas._offset.top,{left:t.x+"px",top:t.y+"px",fontSize:i+"px",charHeight:i}},_saveEditingProps:function(){this._savedProps={hasControls:this.hasControls,borderColor:this.borderColor,lockMovementX:this.lockMovementX,lockMovementY:this.lockMovementY,hoverCursor:this.hoverCursor,selectable:this.selectable,defaultCursor:this.canvas&&this.canvas.defaultCursor,moveCursor:this.canvas&&this.canvas.moveCursor}},_restoreEditingProps:function(){this._savedProps&&(this.hoverCursor=this._savedProps.hoverCursor,this.hasControls=this._savedProps.hasControls,this.borderColor=this._savedProps.borderColor,this.selectable=this._savedProps.selectable,this.lockMovementX=this._savedProps.lockMovementX,this.lockMovementY=this._savedProps.lockMovementY,this.canvas&&(this.canvas.defaultCursor=this._savedProps.defaultCursor,this.canvas.moveCursor=this._savedProps.moveCursor))},exitEditing:function(){var t=this._textBeforeEdit!==this.text,e=this.hiddenTextarea;return this.selected=!1,this.isEditing=!1,this.selectionEnd=this.selectionStart,e&&(e.blur&&e.blur(),e.parentNode&&e.parentNode.removeChild(e)),this.hiddenTextarea=null,this.abortCursorAnimation(),this._restoreEditingProps(),this._currentCursorOpacity=0,this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),this.fire("editing:exited"),t&&this.fire("modified"),this.canvas&&(this.canvas.off("mouse:move",this.mouseMoveHandler),this.canvas.fire("text:editing:exited",{target:this}),t&&this.canvas.fire("object:modified",{target:this})),this},_removeExtraneousStyles:function(){for(var t in this.styles)this._textLines[t]||delete this.styles[t]},removeStyleFromTo:function(t,e){var t=this.get2DCursorLocation(t,!0),e=this.get2DCursorLocation(e,!0),i=t.lineIndex,r=t.charIndex,n=e.lineIndex,s=e.charIndex;if(i!==n){if(this.styles[i])for(l=r;lt?this.selectionStart=t:this.selectionStart<0&&(this.selectionStart=0),this.selectionEnd>t?this.selectionEnd=t:this.selectionEnd<0&&(this.selectionEnd=0)}})}(),fabric.util.object.extend(fabric.IText.prototype,{initDoubleClickSimulation:function(){this.__lastClickTime=+new Date,this.__lastLastClickTime=+new Date,this.__lastPointer={},this.on("mousedown",this.onMouseDown)},onMouseDown:function(t){var e;this.canvas&&(this.__newClickTime=+new Date,e=t.pointer,this.isTripleClick(e)&&(this.fire("tripleclick",t),this._stopEvent(t.e)),this.__lastLastClickTime=this.__lastClickTime,this.__lastClickTime=this.__newClickTime,this.__lastPointer=e,this.__lastIsEditing=this.isEditing,this.__lastSelected=this.selected)},isTripleClick:function(t){return this.__newClickTime-this.__lastClickTime<500&&this.__lastClickTime-this.__lastLastClickTime<500&&this.__lastPointer.x===t.x&&this.__lastPointer.y===t.y},_stopEvent:function(t){t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation()},initCursorSelectionHandlers:function(){this.initMousedownHandler(),this.initMouseupHandler(),this.initClicks()},doubleClickHandler:function(t){this.isEditing&&this.selectWord(this.getSelectionStartFromPointer(t.e))},tripleClickHandler:function(t){this.isEditing&&this.selectLine(this.getSelectionStartFromPointer(t.e))},initClicks:function(){this.on("mousedblclick",this.doubleClickHandler),this.on("tripleclick",this.tripleClickHandler)},_mouseDownHandler:function(t){!this.canvas||!this.editable||t.e.button&&1!==t.e.button||(this.__isMousedown=!0,this.selected&&(this.inCompositionMode=!1,this.setCursorByClick(t.e)),this.isEditing&&(this.__selectionStartOnMouseDown=this.selectionStart,this.selectionStart===this.selectionEnd&&this.abortCursorAnimation(),this.renderCursorOrSelection()))},_mouseDownHandlerBefore:function(t){!this.canvas||!this.editable||t.e.button&&1!==t.e.button||(this.selected=this===this.canvas._activeObject)},initMousedownHandler:function(){this.on("mousedown",this._mouseDownHandler),this.on("mousedown:before",this._mouseDownHandlerBefore)},initMouseupHandler:function(){this.on("mouseup",this.mouseUpHandler)},mouseUpHandler:function(t){if(this.__isMousedown=!1,!(!this.editable||this.group||t.transform&&t.transform.actionPerformed||t.e.button&&1!==t.e.button)){if(this.canvas){var e=this.canvas._activeObject;if(e&&e!==this)return}this.__lastSelected&&!this.__corner?(this.selected=!1,this.__lastSelected=!1,this.enterEditing(t.e),this.selectionStart===this.selectionEnd?this.initDelayedCursor(!0):this.renderCursorOrSelection()):this.selected=!0}},setCursorByClick:function(t){var e=this.getSelectionStartFromPointer(t),i=this.selectionStart,r=this.selectionEnd;t.shiftKey?this.setSelectionStartEndWithShift(i,r,e):(this.selectionStart=e,this.selectionEnd=e),this.isEditing&&(this._fireSelectionChanged(),this._updateTextarea())},getSelectionStartFromPointer:function(t){for(var e=this.getLocalPointer(t),i=0,r=0,n=0,s=0,o=0,a=0,c=this._textLines.length;athis._text.length?this._text.length:t}}),fabric.util.object.extend(fabric.IText.prototype,{initHiddenTextarea:function(){this.hiddenTextarea=fabric.document.createElement("textarea"),this.hiddenTextarea.setAttribute("autocapitalize","off"),this.hiddenTextarea.setAttribute("autocorrect","off"),this.hiddenTextarea.setAttribute("autocomplete","off"),this.hiddenTextarea.setAttribute("spellcheck","false"),this.hiddenTextarea.setAttribute("data-fabric-hiddentextarea",""),this.hiddenTextarea.setAttribute("wrap","off");var t=this._calcTextareaPosition();this.hiddenTextarea.style.cssText="position: absolute; top: "+t.top+"; left: "+t.left+"; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: "+t.fontSize+";",(this.hiddenTextareaContainer||fabric.document.body).appendChild(this.hiddenTextarea),fabric.util.addListener(this.hiddenTextarea,"keydown",this.onKeyDown.bind(this)),fabric.util.addListener(this.hiddenTextarea,"keyup",this.onKeyUp.bind(this)),fabric.util.addListener(this.hiddenTextarea,"input",this.onInput.bind(this)),fabric.util.addListener(this.hiddenTextarea,"copy",this.copy.bind(this)),fabric.util.addListener(this.hiddenTextarea,"cut",this.copy.bind(this)),fabric.util.addListener(this.hiddenTextarea,"paste",this.paste.bind(this)),fabric.util.addListener(this.hiddenTextarea,"compositionstart",this.onCompositionStart.bind(this)),fabric.util.addListener(this.hiddenTextarea,"compositionupdate",this.onCompositionUpdate.bind(this)),fabric.util.addListener(this.hiddenTextarea,"compositionend",this.onCompositionEnd.bind(this)),!this._clickHandlerInitialized&&this.canvas&&(fabric.util.addListener(this.canvas.upperCanvasEl,"click",this.onClick.bind(this)),this._clickHandlerInitialized=!0)},keysMap:{9:"exitEditing",27:"exitEditing",33:"moveCursorUp",34:"moveCursorDown",35:"moveCursorRight",36:"moveCursorLeft",37:"moveCursorLeft",38:"moveCursorUp",39:"moveCursorRight",40:"moveCursorDown"},keysMapRtl:{9:"exitEditing",27:"exitEditing",33:"moveCursorUp",34:"moveCursorDown",35:"moveCursorLeft",36:"moveCursorRight",37:"moveCursorRight",38:"moveCursorUp",39:"moveCursorLeft",40:"moveCursorDown"},ctrlKeysMapUp:{67:"copy",88:"cut"},ctrlKeysMapDown:{65:"selectAll"},onClick:function(){this.hiddenTextarea&&this.hiddenTextarea.focus()},onKeyDown:function(t){if(this.isEditing){var e="rtl"===this.direction?this.keysMapRtl:this.keysMap;if(t.keyCode in e)this[e[t.keyCode]](t);else{if(!(t.keyCode in this.ctrlKeysMapDown&&(t.ctrlKey||t.metaKey)))return;this[this.ctrlKeysMapDown[t.keyCode]](t)}t.stopImmediatePropagation(),t.preventDefault(),33<=t.keyCode&&t.keyCode<=40?(this.inCompositionMode=!1,this.clearContextTop(),this.renderCursorOrSelection()):this.canvas&&this.canvas.requestRenderAll()}},onKeyUp:function(t){!this.isEditing||this._copyDone||this.inCompositionMode?this._copyDone=!1:t.keyCode in this.ctrlKeysMapUp&&(t.ctrlKey||t.metaKey)&&(this[this.ctrlKeysMapUp[t.keyCode]](t),t.stopImmediatePropagation(),t.preventDefault(),this.canvas&&this.canvas.requestRenderAll())},onInput:function(t){var e=this.fromPaste;if(this.fromPaste=!1,t&&t.stopPropagation(),this.isEditing){var i,r,n,t=this._splitTextIntoLines(this.hiddenTextarea.value).graphemeText,s=this._text.length,o=t.length,a=o-s,c=this.selectionStart,h=this.selectionEnd,l=c!==h;if(""===this.hiddenTextarea.value)return this.styles={},this.updateFromTextArea(),this.fire("changed"),void(this.canvas&&(this.canvas.fire("text:changed",{target:this}),this.canvas.requestRenderAll()));var u=this.fromStringToGraphemeSelection(this.hiddenTextarea.selectionStart,this.hiddenTextarea.selectionEnd,this.hiddenTextarea.value),f=c>u.selectionStart;l?(i=this._text.slice(c,h),a+=h-c):o=this._text.length&&this.selectionEnd>=this._text.length||this._moveCursorUpOrDown("Down",t)},moveCursorUp:function(t){0===this.selectionStart&&0===this.selectionEnd||this._moveCursorUpOrDown("Up",t)},_moveCursorUpOrDown:function(t,e){t=this["get"+t+"CursorOffset"](e,"right"===this._selectionDirection);e.shiftKey?this.moveCursorWithShift(t):this.moveCursorWithoutShift(t),0!==t&&(this.setSelectionInBoundaries(),this.abortCursorAnimation(),this._currentCursorOpacity=1,this.initDelayedCursor(),this._fireSelectionChanged(),this._updateTextarea())},moveCursorWithShift:function(t){var e="left"===this._selectionDirection?this.selectionStart+t:this.selectionEnd+t;return this.setSelectionStartEndWithShift(this.selectionStart,this.selectionEnd,e),0!==t},moveCursorWithoutShift:function(t){return t<0?(this.selectionStart+=t,this.selectionEnd=this.selectionStart):(this.selectionEnd+=t,this.selectionStart=this.selectionEnd),0!==t},moveCursorLeft:function(t){0===this.selectionStart&&0===this.selectionEnd||this._moveCursorLeftOrRight("Left",t)},_move:function(t,e,i){var r;if(t.altKey)r=this["findWordBoundary"+i](this[e]);else{if(!t.metaKey&&35!==t.keyCode&&36!==t.keyCode)return this[e]+="Left"===i?-1:1,!0;r=this["findLineBoundary"+i](this[e])}if(void 0!==r&&this[e]!==r)return this[e]=r,!0},_moveLeft:function(t,e){return this._move(t,e,"Left")},_moveRight:function(t,e){return this._move(t,e,"Right")},moveCursorLeftWithoutShift:function(t){var e=!0;return this._selectionDirection="left",this.selectionEnd===this.selectionStart&&0!==this.selectionStart&&(e=this._moveLeft(t,"selectionStart")),this.selectionEnd=this.selectionStart,e},moveCursorLeftWithShift:function(t){return"right"===this._selectionDirection&&this.selectionStart!==this.selectionEnd?this._moveLeft(t,"selectionEnd"):0!==this.selectionStart?(this._selectionDirection="left",this._moveLeft(t,"selectionStart")):void 0},moveCursorRight:function(t){this.selectionStart>=this._text.length&&this.selectionEnd>=this._text.length||this._moveCursorLeftOrRight("Right",t)},_moveCursorLeftOrRight:function(t,e){t="moveCursor"+t+"With";this._currentCursorOpacity=1,e.shiftKey?t+="Shift":t+="outShift",this[t](e)&&(this.abortCursorAnimation(),this.initDelayedCursor(),this._fireSelectionChanged(),this._updateTextarea())},moveCursorRightWithShift:function(t){return"left"===this._selectionDirection&&this.selectionStart!==this.selectionEnd?this._moveRight(t,"selectionStart"):this.selectionEnd!==this._text.length?(this._selectionDirection="right",this._moveRight(t,"selectionEnd")):void 0},moveCursorRightWithoutShift:function(t){var e=!0;return this._selectionDirection="right",this.selectionStart===this.selectionEnd?(e=this._moveRight(t,"selectionStart"),this.selectionEnd=this.selectionStart):this.selectionStart=this.selectionEnd,e},removeChars:function(t,e){this.removeStyleFromTo(t,e=void 0===e?t+1:e),this._text.splice(t,e-t),this.text=this._text.join(""),this.set("dirty",!0),this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),this._removeExtraneousStyles()},insertChars:function(t,e,i,r){i<(r=void 0===r?i:r)&&this.removeStyleFromTo(i,r);t=fabric.util.string.graphemeSplit(t);this.insertNewStyleBlock(t,i,e),this._text=[].concat(this._text.slice(0,i),t,this._text.slice(r)),this.text=this._text.join(""),this.set("dirty",!0),this._shouldClearDimensionCache()&&(this.initDimensions(),this.setCoords()),this._removeExtraneousStyles()}}),function(){var a=fabric.util.toFixed,c=/ +/g;fabric.util.object.extend(fabric.Text.prototype,{_toSVG:function(){var t=this._getSVGLeftTopOffsets(),t=this._getSVGTextAndBg(t.textTop,t.textLeft);return this._wrapSVGTextAndBg(t)},toSVG:function(t){return this._createBaseSVGMarkup(this._toSVG(),{reviver:t,noStyle:!0,withShadow:!0})},_getSVGLeftTopOffsets:function(){return{textLeft:-this.width/2,textTop:-this.height/2,lineTop:this.getHeightOfLine(0)}},_wrapSVGTextAndBg:function(t){var e=this.getSvgTextDecoration(this);return[t.textBgRects.join(""),'\t\t",t.textSpans.join(""),"\n"]},_getSVGTextAndBg:function(t,e){var i,r=[],n=[],s=t;this._setSVGBg(n);for(var o=0,a=this._textLines.length;o",fabric.util.string.escapeXml(t),""].join("")},_setSVGTextLineText:function(t,e,i,r){var n,s,o,a,c=this.getHeightOfLine(e),h=-1!==this.textAlign.indexOf("justify"),l="",u=0,f=this._textLines[e];r+=c*(1-this._fontSizeFraction)/this.lineHeight;for(var d=0,g=f.length-1;d<=g;d++)a=d===g||this.charSpacing,l+=f[d],o=this.__charBounds[e][d],0===u?(i+=o.kernedWidth-o.width,u+=o.width):u+=o.kernedWidth,(a=h&&!a&&this._reSpaceAndTab.test(f[d])?!0:a)||(n=n||this.getCompleteStyleDeclaration(e,d),s=this.getCompleteStyleDeclaration(e,d+1),a=fabric.util.hasStyleChanged(n,s,!0)),a&&(o=this._getStyleDeclaration(e,d)||{},t.push(this._createTextCharSpan(l,o,i,r)),l="",n=s,i+=u,u=0)},_pushTextBgRect:function(t,e,i,r,n,s){var o=fabric.Object.NUM_FRACTION_DIGITS;t.push("\t\t\n')},_setSVGTextLineBg:function(t,e,i,r){for(var n,s,o=this._textLines[e],a=this.getHeightOfLine(e)/this.lineHeight,c=0,h=0,l=this.getValueOfPropertyAt(e,0,"textBackgroundColor"),u=0,f=o.length;uthis.width&&this._set("width",this.dynamicMinWidth),-1!==this.textAlign.indexOf("justify")&&this.enlargeSpaces(),this.height=this.calcTextHeight(),this.saveState({propertySet:"_dimensionAffectingProps"}))},_generateStyleMap:function(t){for(var e=0,i=0,r=0,n={},s=0;sthis.dynamicMinWidth&&(this.dynamicMinWidth=g-m+r),c},isEndOfWrapping:function(t){return!this._styleMap[t+1]||this._styleMap[t+1].line!==this._styleMap[t].line},missingNewlineOffset:function(t){return!this.splitByGrapheme||this.isEndOfWrapping(t)?1:0},_splitTextIntoLines:function(t){for(var t=b.Text.prototype._splitTextIntoLines.call(this,t),e=this._wrapText(t.lines,this.width),i=new Array(e.length),r=0;r { + this._savedInteractivity.set(obj, { + selectable: obj.selectable, + evented: obj.evented, + }); + obj.set({ selectable: false, evented: false }); + }); + } + + /** + * 恢复所有对象到激活前的交互状态(保留锁定/背景图层的原始状态) + */ + _restoreObjectsInteractivity() { + const objects = this.canvasManager.canvas.getObjects(); + objects.forEach(obj => { + 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(); + } + + /** + * 获取属性面板 HTML(子类可选实现) + * @returns {string} + */ + getPropertyPanelHTML() { + return ''; + } + + /** + * 属性变更回调(子类可选实现) + * @param {string} key + * @param {*} value + */ + onPropertyChange(key, value) {} + + /** + * 获取选项栏 HTML(子类可选实现) + * @returns {string} + */ + getOptionsBarHTML() { + return ''; + } + + /** + * 键盘事件(子类可选实现) + * @param {KeyboardEvent} e + */ + onKeyDown(e) {} + + /** + * 鼠标事件(子类可选实现) + * @param {Event} e + */ + onMouseDown(e) {} + onMouseMove(e) {} + onMouseUp(e) {} +} + +export default BaseModule; diff --git a/plugins/Image-Toolbox/core/src/modules/BrushModule.js b/plugins/Image-Toolbox/core/src/modules/BrushModule.js new file mode 100644 index 00000000..7b81fe73 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/BrushModule.js @@ -0,0 +1,346 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +/** + * 画笔模块 - 使用 Fabric 自由绘制生成可编辑的 path 图层。 + */ +class BrushModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + color: '#d83b31', + width: 6, + ...defaultOptions, + }); + + this._cursorPreview = null; + this._savedBeforeStroke = false; + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseOut = this._hideCursorPreview.bind(this); + this._boundPathCreated = this._onPathCreated.bind(this); + } + + activate(options = {}) { + super.activate(options); + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + canvas.discardActiveObject(); + canvas.isDrawingMode = true; + canvas.defaultCursor = 'none'; + canvas.hoverCursor = 'none'; + canvas.freeDrawingCursor = 'none'; + this._ensureBrush(); + this._applyBrushOptions(); + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:out', this._boundMouseOut); + canvas.on('path:created', this._boundPathCreated); + canvas.upperCanvasEl?.addEventListener('mousemove', this._boundMouseMove); + canvas.upperCanvasEl?.addEventListener('mouseleave', this._boundMouseOut); + + eventBus.emit('module:activated', 'brush'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:out', this._boundMouseOut); + canvas.off('path:created', this._boundPathCreated); + canvas.upperCanvasEl?.removeEventListener('mousemove', this._boundMouseMove); + canvas.upperCanvasEl?.removeEventListener('mouseleave', this._boundMouseOut); + this._removeCursorPreview(); + canvas.isDrawingMode = false; + canvas.freeDrawingCursor = 'crosshair'; + canvas.hoverCursor = 'move'; + this._savedBeforeStroke = false; + } + + super.deactivate(); + } + + setColor(color) { + this.options.color = this._normalizeColor(color, this.options.color); + this._applyBrushOptions(); + this._updateCursorPreviewStyle(); + } + + setWidth(width) { + const parsed = parseInt(width, 10); + this.options.width = this._clamp(Number.isFinite(parsed) ? parsed : this.options.width, 1, 80); + this._applyBrushOptions(); + this._updateCursorPreviewStyle(); + } + + applyPreset(presetName) { + const presets = { + 'brush-red': { color: '#d83b31' }, + 'brush-blue': { color: '#1677ff' }, + 'brush-yellow': { color: '#ffd700' }, + 'brush-green': { color: '#2ead4a' }, + 'brush-white': { color: '#ffffff' }, + 'brush-black': { color: '#111111' }, + 'brush-thin': { width: 3 }, + 'brush-medium': { width: 6 }, + 'brush-thick': { width: 12 }, + 'brush-heavy': { width: 24 }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + if (preset.color) this.setColor(preset.color); + if (preset.width) this.setWidth(preset.width); + } + + getOptionsBarHTML() { + const color = this._normalizeColor(this.options.color); + const width = this.options.width; + + return ` +
+ ${this._getColorPresetButton('brush-red', '红', '#d83b31', color)} + ${this._getColorPresetButton('brush-blue', '蓝', '#1677ff', color)} + ${this._getColorPresetButton('brush-yellow', '黄', '#ffd700', color)} + ${this._getColorPresetButton('brush-green', '绿', '#2ead4a', color)} + ${this._getColorPresetButton('brush-white', '白', '#ffffff', color)} + ${this._getColorPresetButton('brush-black', '黑', '#111111', color)} +
+
+ + + + +
+ `; + } + + getPropertyPanelHTML() { + return ` +
画笔工具
+
+ + +
+
+ + + ${this.options.width}px +
+
按住鼠标拖拽即可绘制,生成的画笔会作为独立图层。
+ `; + } + + onToolPropertyChange(key, value) { + switch (key) { + case 'color': + this.setColor(value); + return true; + case 'width': + this.setWidth(value); + return true; + default: + return false; + } + } + + _onMouseDown(e) { + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; + + this.history.saveState(); + this._savedBeforeStroke = true; + } + + _onMouseMove(e) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const nativeEvent = e?.e || e; + if (!nativeEvent) return; + + const pointer = canvas.getPointer(nativeEvent); + const preview = this._ensureCursorPreview(); + preview.set({ + left: pointer.x, + top: pointer.y, + visible: true, + }); + canvas.bringToFront(preview); + this._requestRender(); + } + + _onPathCreated(e) { + const path = e.path; + if (!path) return; + + path.set({ + id: 'brush_' + Date.now(), + fill: null, + stroke: this.options.color, + strokeWidth: this.options.width, + strokeLineCap: 'round', + strokeLineJoin: 'round', + selectable: false, + evented: false, + _layerKind: 'brush', + _layerColorPresetName: this._getColorPresetName(this.options.color), + _layerWidthPresetName: this._getWidthPresetName(this.options.width), + }); + path.setCoords(); + this.canvasManager.canvas.discardActiveObject(); + if (this._cursorPreview) { + this.canvasManager.canvas.bringToFront(this._cursorPreview); + } + this.canvasManager.canvas.renderAll(); + eventBus.emit('canvas:objectMetadataChanged', path); + this._savedBeforeStroke = false; + } + + _ensureCursorPreview() { + if (this._cursorPreview) return this._cursorPreview; + + const canvas = this.canvasManager.canvas; + const preview = new fabric.Circle({ + left: 0, + top: 0, + originX: 'center', + originY: 'center', + radius: this.options.width / 2, + fill: 'rgba(255,255,255,0.08)', + stroke: this.options.color, + strokeWidth: 1, + strokeUniform: true, + selectable: false, + evented: false, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, + excludeFromExport: true, + objectCaching: false, + visible: false, + }); + + this._cursorPreview = preview; + canvas.add(preview); + canvas.bringToFront(preview); + return preview; + } + + _updateCursorPreviewStyle() { + if (!this._cursorPreview) return; + + this._cursorPreview.set({ + radius: this.options.width / 2, + stroke: this.options.color, + }); + this._cursorPreview.setCoords(); + this._requestRender(); + } + + _hideCursorPreview() { + if (!this._cursorPreview) return; + + this._cursorPreview.set('visible', false); + this._requestRender(); + } + + _removeCursorPreview() { + if (!this._cursorPreview) return; + + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.remove(this._cursorPreview); + } + this._cursorPreview = null; + } + + _requestRender() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + _ensureBrush() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + if (!canvas.freeDrawingBrush || !(canvas.freeDrawingBrush instanceof fabric.PencilBrush)) { + canvas.freeDrawingBrush = new fabric.PencilBrush(canvas); + } + } + + _applyBrushOptions() { + const canvas = this.canvasManager.canvas; + if (!canvas?.freeDrawingBrush) return; + + canvas.freeDrawingBrush.color = this.options.color; + canvas.freeDrawingBrush.width = this.options.width; + } + + _getColorPresetButton(preset, label, color, currentColor) { + const normalized = this._normalizeColor(color); + const active = currentColor === normalized ? ' active' : ''; + return ` + + `; + } + + _normalizeColor(color, fallback = '#000000') { + if (typeof color !== 'string') return fallback; + + const value = color.trim().toLowerCase(); + 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; + } + + _getColorPresetName(color) { + const colorMap = { + '#d83b31': '红', + '#1677ff': '蓝', + '#ffd700': '黄', + '#2ead4a': '绿', + '#ffffff': '白', + '#111111': '黑', + }; + + return colorMap[this._normalizeColor(color, '')] || ''; + } + + _getWidthPresetName(width) { + const widthMap = { + 3: '细', + 6: '中', + 12: '粗', + 24: '特粗', + }; + + return widthMap[Math.round(Number(width))] || ''; + } + + _clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + _escapeAttr(value) { + return String(value ?? '').replace(/[&<>"]/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + }[ch])); + } +} + +export default BrushModule; diff --git a/plugins/Image-Toolbox/core/src/modules/CropModule.js b/plugins/Image-Toolbox/core/src/modules/CropModule.js new file mode 100644 index 00000000..9cc1383c --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/CropModule.js @@ -0,0 +1,842 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +/** + * 剪切模块 — 图片裁剪 + * 使用 canvas.clipPath 实现非破坏性裁剪 + */ +class CropModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + aspectRatio: null, // null = 自由比例,或 {w, h} 如 {w:1, h:1} + cropShape: 'rect', // rect = 矩形,ellipse = 椭圆/圆形 + ...defaultOptions, + }); + + this._cropRect = null; + this._maskRect = null; + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + this._objectClipPathBackups = null; + this._applyingCrop = false; + this._isAdjusting = false; + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + } + + activate(options = {}) { + super.activate(options); // 自动禁用所有对象交互 + + const canvas = this.canvasManager.canvas; + canvas.defaultCursor = 'crosshair'; + this._applyingCrop = false; + this._detachCanvasClipPath(); + + // 创建遮罩和裁剪框(这两个对象在 super.activate() 之后创建, + // 因此不受 BaseModule 的禁用影响,各自显式设置了 selectable/evented) + this._createMask(); + this._createCropRect(); + + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + + eventBus.emit('module:activated', 'crop'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + + this._removeCropOverlay(); + if (this._applyingCrop) { + this._discardDetachedCanvasClipPath(false); + } else { + this._restoreDetachedCanvasClipPath(false); + } + this._applyingCrop = false; + canvas.renderAll(); + + super.deactivate(); // 恢复所有对象交互 + } + + setAspectRatio(ratio) { + this.options.aspectRatio = ratio; + if (!this._cropRect) return; + + this._cropRect.set({ lockUniScaling: !!ratio }); + if (ratio) { + this._applyAspectRatioToCropRect('width'); + } else { + this._cropRect.setCoords(); + this._updateMask(); + } + eventBus.emit('crop:updated', this._getCropBounds()); + } + + setCropShape(shape) { + const nextShape = this._normalizeCropShape(shape); + if (this.options.cropShape === nextShape) return; + + this.options.cropShape = nextShape; + if (!this._cropRect) return; + + const canvas = this.canvasManager.canvas; + const oldCropRect = this._cropRect; + const nextCropRect = this._createCropFrameFromSource(oldCropRect, nextShape); + + canvas.remove(oldCropRect); + this._cropRect = nextCropRect; + this._bindCropFrameEvents(nextCropRect); + canvas.add(nextCropRect); + canvas.setActiveObject(nextCropRect); + this._updateMask(); + eventBus.emit('crop:updated', this._getCropBounds()); + } + + 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]); + } + + applyShapePreset(presetName) { + const shapeMap = { + 'crop-shape-rect': 'rect', + 'crop-shape-ellipse': 'ellipse', + }; + + if (!Object.prototype.hasOwnProperty.call(shapeMap, presetName)) return false; + this.setCropShape(shapeMap[presetName]); + return true; + } + + /** + * 执行裁剪 + */ + applyCrop() { + if (!this._cropRect) return; + const canvas = this.canvasManager.canvas; + const clipRect = this._createClipPathFromSource(this._cropRect); + if (this._detachedCanvasClipPath) { + clipRect.clipPath = this._createClipPathFromSource(this._detachedCanvasClipPath); + } + + this._saveStateBeforeCrop(canvas); + this._clearTemporaryObjectClipPaths(false); + + canvas.clipPath = clipRect; + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + this._applyingCrop = true; + this._removeCropOverlay(); + canvas.renderAll(); + + eventBus.emit('crop:applied'); + // 自动退出裁剪模式 + eventBus.emit('tool:requestChange', 'select'); + } + + /** + * 取消裁剪 + */ + cancelCrop() { + this._removeCropOverlay(); + this._restoreDetachedCanvasClipPath(false); + this.canvasManager.canvas.renderAll(); + eventBus.emit('tool:requestChange', 'select'); + } + + // ── 内部方法 ── + + /** + * 获取当前可视区域(canvas 坐标) + * 解决放大/平移后裁剪框错位的问题 + */ + _getVisibleBounds() { + const canvas = this.canvasManager.canvas; + const vpt = canvas.viewportTransform; + const zoom = vpt[0]; // 缩放比 + const panX = vpt[4]; // 水平平移 + const panY = vpt[5]; // 垂直平移 + + // 屏幕坐标 (0,0) → canvas 坐标 + const left = -panX / zoom; + const top = -panY / zoom; + return { + left, + top, + width: canvas.width / zoom, + height: canvas.height / zoom, + }; + } + + /** + * 获取可视区域中心点(canvas 坐标) + */ + _getVisibleCenter() { + const vb = this._getVisibleBounds(); + return { + x: vb.left + vb.width / 2, + y: vb.top + vb.height / 2, + }; + } + + _createMask() { + const vb = this._getVisibleBounds(); + this._maskRect = new fabric.Rect({ + left: vb.left, + top: vb.top, + width: vb.width, + height: vb.height, + fill: 'rgba(0, 0, 0, 0.5)', + selectable: false, + evented: false, + excludeFromExport: true, + excludeFromLayer: true, + absolutePositioned: true, + objectCaching: false, + }); + this.canvasManager.canvas.add(this._maskRect); + } + + _createCropRect() { + const canvas = this.canvasManager.canvas; + const ratio = this.options.aspectRatio; + const vb = this._getVisibleBounds(); + const center = this._getVisibleCenter(); + + const defaultSize = Math.min(vb.width, vb.height) * 0.7; + let w = defaultSize; + let h = defaultSize; + + if (ratio) { + // 按比例计算 + const ratioVal = ratio.w / ratio.h; + if (w / h > ratioVal) { + w = h * ratioVal; + } else { + h = w / ratioVal; + } + } + + this._cropRect = this._createCropFrame({ + left: center.x - w / 2, + top: center.y - h / 2, + width: w, + height: h, + }); + + this._bindCropFrameEvents(this._cropRect); + + canvas.add(this._cropRect); + canvas.setActiveObject(this._cropRect); + this._updateMask(); + } + + _createCropFrame(source, shape = this._getCropShape()) { + const width = Math.max(1, source.width || 0); + const height = Math.max(1, source.height || 0); + const commonOptions = { + left: source.left || 0, + top: source.top || 0, + fill: 'transparent', + stroke: '#FFFFFF', + strokeWidth: 1.5, + strokeDashArray: [4, 4], + selectable: true, + evented: true, + hasControls: true, + hasBorders: true, + cornerColor: '#FFFFFF', + cornerSize: 8, + cornerStyle: 'circle', + transparentCorners: false, + lockUniScaling: !!this.options.aspectRatio, + lockScalingFlip: true, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, + absolutePositioned: true, + 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', + }; + + let cropFrame; + + if (shape === 'ellipse') { + cropFrame = new fabric.Ellipse({ + ...commonOptions, + width, + height, + rx: width / 2, + ry: height / 2, + }); + } else { + cropFrame = new fabric.Rect({ + ...commonOptions, + width, + height, + }); + } + + this._applyCropFrameControls(cropFrame); + return cropFrame; + } + + _createCropFrameFromSource(source, shape = this._getCropShape()) { + return this._createCropFrame({ + left: source.left || 0, + top: source.top || 0, + width: Math.max(1, source.width || (source.rx || 0) * 2), + height: Math.max(1, source.height || (source.ry || 0) * 2), + 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', + }, shape); + } + + _bindCropFrameEvents(cropFrame) { + cropFrame.on('moving', () => this._updateMask()); + cropFrame.on('scaling', () => this._updateMask()); + cropFrame.on('rotating', () => this._updateMask()); + cropFrame.on('resizing', () => this._updateMask()); + } + + _applyCropFrameControls(cropFrame) { + const rotateControl = cropFrame?.controls?.mtr; + if (!rotateControl || typeof fabric.Control !== 'function') return; + + cropFrame.controls = { ...cropFrame.controls }; + const rotateControlOptions = { + x: rotateControl.x, + y: rotateControl.y, + actionName: rotateControl.actionName || 'rotate', + sizeX: 22, + sizeY: 22, + render: this._renderCropRotateControl.bind(this), + }; + + [ + 'offsetX', + 'offsetY', + 'cursorStyleHandler', + 'mouseDownHandler', + 'actionHandler', + 'mouseUpHandler', + 'getActionName', + 'withConnection', + 'touchSizeX', + 'touchSizeY', + ].forEach((key) => { + if (rotateControl[key] !== undefined) { + rotateControlOptions[key] = rotateControl[key]; + } + }); + + cropFrame.controls.mtr = new fabric.Control(rotateControlOptions); + } + + _renderCropRotateControl(ctx, left, top) { + ctx.save(); + ctx.translate(left, top); + + ctx.beginPath(); + ctx.arc(0, 0, 11, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'; + ctx.shadowColor = 'rgba(0, 0, 0, 0.25)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetY = 1; + ctx.fill(); + + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = '#2563EB'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(0, 0, 5.5, -Math.PI * 0.85, Math.PI * 0.55); + ctx.strokeStyle = '#2563EB'; + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(5.3, 4.5); + ctx.lineTo(8.1, 3.9); + ctx.lineTo(6.5, 1.4); + ctx.lineJoin = 'round'; + ctx.stroke(); + + ctx.restore(); + } + + _updateMask() { + if (!this._maskRect || !this._cropRect) return; + + const canvas = this.canvasManager.canvas; + const vb = this._getVisibleBounds(); + + // 遮罩跟随可视区域 + this._maskRect.set({ + left: vb.left, + top: vb.top, + width: vb.width, + height: vb.height, + clipPath: this._createMaskClipPath(), + }); + this._maskRect.dirty = true; + + canvas.renderAll(); + } + + _createMaskClipPath() { + if (!this._cropRect) return null; + + const clipPath = this._createClipPathFromSource(this._cropRect); + clipPath.inverted = true; + return clipPath; + } + + _getCropBounds() { + if (!this._cropRect) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + const cr = this._cropRect; + return { + left: cr.left || 0, + top: cr.top || 0, + width: Math.max(1, Math.abs((cr.width || 0) * (cr.scaleX || 1))), + height: Math.max(1, Math.abs((cr.height || 0) * (cr.scaleY || 1))), + }; + } + + _setCropBounds(bounds) { + if (!this._cropRect) return; + + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + + if (this._isEllipseObject(this._cropRect)) { + this._cropRect.set({ + left: bounds.left, + top: bounds.top, + width, + height, + rx: width / 2, + ry: height / 2, + scaleX: 1, + scaleY: 1, + }); + this._cropRect.setCoords(); + return; + } + + this._cropRect.set({ + left: bounds.left, + top: bounds.top, + width, + height, + scaleX: 1, + scaleY: 1, + }); + this._cropRect.setCoords(); + } + + _getAspectRatioValue() { + const ratio = this.options.aspectRatio; + if (!ratio || !ratio.w || !ratio.h) return null; + return ratio.w / ratio.h; + } + + _applyAspectRatioToCropRect(changedProp = 'width') { + const ratioVal = this._getAspectRatioValue(); + if (!this._cropRect || !ratioVal) return; + + const current = this._getCropBounds(); + const center = { + x: current.left + current.width / 2, + y: current.top + current.height / 2, + }; + + let width = current.width; + let height = current.height; + if (changedProp === 'height') { + width = height * ratioVal; + } else { + height = width / ratioVal; + } + + const bounds = this._fitCropBoundsToVisible({ left: current.left, top: current.top, width, height }, center, ratioVal); + this._setCropBounds(bounds); + this._updateMask(); + } + + _fitCropBoundsToVisible(bounds, center, ratioVal = null) { + const vb = this._getVisibleBounds(); + const maxWidth = Math.max(1, vb.width); + const maxHeight = Math.max(1, vb.height); + let width = Math.max(1, bounds.width); + let height = Math.max(1, bounds.height); + + if (ratioVal) { + if (width > maxWidth) { + width = maxWidth; + height = width / ratioVal; + } + if (height > maxHeight) { + height = maxHeight; + width = height * ratioVal; + } + } else { + width = Math.min(width, maxWidth); + height = Math.min(height, maxHeight); + } + + const nextCenter = center || { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }; + let left = nextCenter.x - width / 2; + let top = nextCenter.y - height / 2; + + left = this._clamp(left, vb.left, vb.left + maxWidth - width); + top = this._clamp(top, vb.top, vb.top + maxHeight - height); + + return { left, top, width, height }; + } + + _clamp(value, min, max) { + if (max < min) return min; + return Math.max(min, Math.min(max, value)); + } + + _getCropShape() { + return this._normalizeCropShape(this.options.cropShape); + } + + _normalizeCropShape(shape) { + return shape === 'ellipse' ? 'ellipse' : 'rect'; + } + + _isEllipseObject(obj) { + return obj?.type === 'ellipse'; + } + + _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; + } + + _detachCanvasClipPath() { + const canvas = this.canvasManager.canvas; + if (!canvas?.clipPath) return; + + this._detachedCanvasClipPath = canvas.clipPath; + this._clipPathDetached = true; + canvas.clipPath = null; + this._applyTemporaryObjectClipPaths(this._detachedCanvasClipPath, false); + this._requestRender(); + } + + _applyTemporaryObjectClipPaths(clipPath, render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas || !clipPath) return; + + this._clearTemporaryObjectClipPaths(false); + this._objectClipPathBackups = canvas.getObjects() + .filter(obj => obj !== this._cropRect && obj !== this._maskRect) + .map(obj => ({ obj, clipPath: obj.clipPath || null })); + + this._objectClipPathBackups.forEach(({ obj }) => { + obj.set('clipPath', this._createClipPathFromSource(clipPath)); + obj.dirty = true; + }); + + if (render) this._requestRender(); + } + + _clearTemporaryObjectClipPaths(render = true) { + if (!this._objectClipPathBackups) return; + + this._objectClipPathBackups.forEach(({ obj, clipPath }) => { + obj.set('clipPath', clipPath || null); + obj.dirty = true; + }); + this._objectClipPathBackups = null; + + if (render) this._requestRender(); + } + + _restoreDetachedCanvasClipPath(render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + this._clearTemporaryObjectClipPaths(false); + if (this._clipPathDetached) { + canvas.clipPath = this._detachedCanvasClipPath; + } + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + + if (render) this._requestRender(); + } + + _discardDetachedCanvasClipPath(render = true) { + this._clearTemporaryObjectClipPaths(false); + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + + if (render) this._requestRender(); + } + + _saveStateBeforeCrop(canvas) { + const currentClipPath = canvas.clipPath; + this._clearTemporaryObjectClipPaths(false); + + if (this._clipPathDetached) { + canvas.clipPath = this._detachedCanvasClipPath; + } + + this.history.saveState(); + canvas.clipPath = currentClipPath; + } + + _requestRender() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + _removeCropOverlay() { + const canvas = this.canvasManager.canvas; + if (this._maskRect) { + canvas.remove(this._maskRect); + this._maskRect = null; + } + if (this._cropRect) { + canvas.remove(this._cropRect); + this._cropRect = null; + } + } + + // ── 鼠标事件(重置裁剪框) ── + + _onMouseDown(e) { + // 由 Fabric.js 处理裁剪框的拖拽/缩放 + } + + _onMouseMove(e) { + // 由 Fabric.js 处理 + } + + _onMouseUp(e) { + // 由 Fabric.js 处理 + } + + getOptionsBarHTML() { + const ratio = this.options.aspectRatio; + const shape = this._getCropShape(); + return ` +
+ + +
+
+ + + + + + + + +
+ `; + } + + getPropertyPanelHTML() { + if (!this._cropRect) { + return '
启用剪切后可编辑裁剪区域
'; + } + + const bounds = this._getCropBounds(); + const ratio = this.options.aspectRatio; + const shape = this._getCropShape(); + + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
拖动裁剪框,或直接输入区域数值
+ `; + } + + onToolPropertyChange(key, value) { + if (key === 'cropShape') { + this.setCropShape(value); + return true; + } + + if (key !== 'aspectRatio') return false; + + this.setAspectRatio(this._parseRatio(value)); + return true; + } + + onToolPropertyAction(action) { + if (action === 'applyCrop') { + this.applyCrop(); + return; + } + if (action === 'cancelCrop') { + this.cancelCrop(); + } + } + + onPropertyChange(key, value, context = {}) { + if (!this._cropRect || !['left', 'top', 'width', 'height'].includes(key)) return; + + if (this.options.aspectRatio && (key === 'width' || key === 'height')) { + this._applyAspectRatioToCropRect(key); + if (context.eventType === 'change') { + eventBus.emit('crop:updated', this._getCropBounds()); + } + return; + } + + this._cropRect.setCoords(); + this._updateMask(); + if (context.eventType === 'change') { + eventBus.emit('crop:updated', this._getCropBounds()); + } + } + + _formatNumber(value) { + const number = parseFloat(value); + if (!Number.isFinite(number)) return 0; + return Math.round(number * 100) / 100; + } + + _parseRatio(value) { + if (value === 'free') return null; + const [w, h] = String(value).split(':').map(Number); + return Number.isFinite(w) && Number.isFinite(h) ? { w, h } : null; + } +} + +export default CropModule; diff --git a/plugins/Image-Toolbox/core/src/modules/EraserModule.js b/plugins/Image-Toolbox/core/src/modules/EraserModule.js new file mode 100644 index 00000000..37ad2217 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/EraserModule.js @@ -0,0 +1,424 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +/** + * 橡皮擦模块 - 默认擦除当前图层,并把擦除结果固化为位图。 + */ +class EraserModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + width: 20, + ...defaultOptions, + }); + + this._targetObject = null; + this._strokeTarget = null; + this._isDrawing = false; + this._lastPointer = null; + this._previewImage = null; + this._previewCtx = null; + this._previewBounds = null; + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + this._boundLayerSelected = this._onLayerSelected.bind(this); + } + + activate(options = {}) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const initialTarget = this._getErasableObject(canvas.getActiveObject()); + super.activate(options); + + this._targetObject = initialTarget; + canvas.discardActiveObject(); + canvas.isDrawingMode = false; + canvas.defaultCursor = 'crosshair'; + canvas.freeDrawingCursor = 'crosshair'; + canvas.on('mouse:down', this._boundMouseDown); + canvas.on('mouse:move', this._boundMouseMove); + canvas.on('mouse:up', this._boundMouseUp); + eventBus.on('layer:selected', this._boundLayerSelected); + + eventBus.emit('module:activated', 'eraser'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + this._commitLivePreview(); + canvas.isDrawingMode = false; + canvas.freeDrawingCursor = 'crosshair'; + this._targetObject = null; + this._strokeTarget = null; + this._isDrawing = false; + this._lastPointer = null; + } + eventBus.off('layer:selected', this._boundLayerSelected); + + super.deactivate(); + } + + setWidth(width) { + const parsed = parseInt(width, 10); + this.options.width = this._clamp(Number.isFinite(parsed) ? parsed : this.options.width, 1, 120); + this._applyBrushOptions(); + } + + applyPreset(presetName) { + const presets = { + 'eraser-thin': 8, + 'eraser-medium': 20, + 'eraser-thick': 40, + 'eraser-heavy': 64, + }; + + const width = presets[presetName]; + if (!width) return; + + this.setWidth(width); + } + + getOptionsBarHTML() { + const width = this.options.width; + + return ` +
+ + + + +
+ `; + } + + getPropertyPanelHTML() { + return ` +
橡皮擦工具
+
+ + + ${this.options.width}px +
+
默认擦除进入工具前选中的当前图层;未选中图层时,会擦除鼠标下方最上层可编辑图层。
+ `; + } + + onToolPropertyChange(key, value) { + if (key !== 'width') return false; + + this.setWidth(value); + return true; + } + + _onMouseDown(e) { + const nativeEvent = e?.e; + if (nativeEvent && typeof nativeEvent.button === 'number' && nativeEvent.button !== 0) return; + if (this._isDrawing) return; + + const target = this._getStrokeTarget(e); + this._strokeTarget = target; + if (!target) return; + + this.history.saveState(); + + const canvas = this.canvasManager.canvas; + const pointer = canvas.getPointer(e.e); + if (!this._beginLivePreview(target)) { + this._resetStrokeState(); + return; + } + + this._isDrawing = true; + this._lastPointer = pointer; + this._drawErasePoint(pointer); + this._refreshPreview(); + } + + _onMouseMove(e) { + if (!this._isDrawing || !this._lastPointer) return; + if (typeof e?.e?.buttons === 'number' && e.e.buttons === 0) { + this._onMouseUp(); + return; + } + + const canvas = this.canvasManager.canvas; + const pointer = canvas.getPointer(e.e); + this._drawEraseSegment(this._lastPointer, pointer); + this._lastPointer = pointer; + this._refreshPreview(); + } + + _onMouseUp() { + if (!this._isDrawing) return; + + this._commitLivePreview(); + this._resetStrokeState(); + } + + _onLayerSelected(meta) { + if (!this.active) return; + if (this._isDrawing) return; + + this._targetObject = this._getErasableObject(meta?.fabricObj || null); + const canvas = this.canvasManager.canvas; + if (canvas) canvas.discardActiveObject(); + } + + _beginLivePreview(target) { + const canvas = this.canvasManager.canvas; + if (!canvas || !this._isErasableObject(target)) return false; + + try { + target.setCoords(); + const bounds = target.getBoundingRect(true, true); + const cropLeft = Math.floor(bounds.left); + const cropTop = Math.floor(bounds.top); + const cropRight = Math.ceil(bounds.left + bounds.width); + const cropBottom = Math.ceil(bounds.top + bounds.height); + const cropWidth = Math.max(1, cropRight - cropLeft); + const cropHeight = Math.max(1, cropBottom - cropTop); + const rasterCanvas = this._renderTargetRegion(target, cropLeft, cropTop, cropWidth, cropHeight); + const rasterCtx = rasterCanvas.getContext('2d'); + const previewImage = this._createRasterImage(target, rasterCanvas, cropLeft, cropTop); + const targetIndex = canvas.getObjects().indexOf(target); + + canvas.remove(target); + if (targetIndex >= 0) { + canvas.insertAt(previewImage, targetIndex); + } else { + canvas.add(previewImage); + } + + this._previewImage = previewImage; + this._previewCtx = rasterCtx; + this._previewBounds = { left: cropLeft, top: cropTop }; + this._targetObject = previewImage; + this._strokeTarget = previewImage; + canvas.discardActiveObject(); + canvas.requestRenderAll?.(); + return true; + } catch (err) { + console.error('[EraserModule] 创建实时擦除预览失败:', err); + return false; + } + } + + _createRasterImage(target, rasterCanvas, left, top) { + const layerName = typeof target._layerName === 'string' ? target._layerName : ''; + const image = new fabric.Image(rasterCanvas, { + left, + top, + width: rasterCanvas.width, + height: rasterCanvas.height, + scaleX: 1, + scaleY: 1, + angle: 0, + originX: 'left', + originY: 'top', + opacity: 1, + id: target.id, + selectable: false, + evented: false, + objectCaching: false, + }); + + if (layerName) { + image._layerName = layerName; + image._layerNameAuto = false; + image._layerBaseName = ''; + } + + image.setCoords(); + return image; + } + + _drawErasePoint(point) { + const ctx = this._previewCtx; + const bounds = this._previewBounds; + if (!ctx || !bounds) return; + + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(point.x - bounds.left, point.y - bounds.top, this.options.width / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + _drawEraseSegment(from, to) { + const ctx = this._previewCtx; + const bounds = this._previewBounds; + if (!ctx || !bounds) return; + + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.lineWidth = this.options.width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = '#000000'; + ctx.beginPath(); + ctx.moveTo(from.x - bounds.left, from.y - bounds.top); + ctx.lineTo(to.x - bounds.left, to.y - bounds.top); + ctx.stroke(); + ctx.restore(); + } + + _refreshPreview() { + const canvas = this.canvasManager.canvas; + if (!canvas || !this._previewImage) return; + + this._previewImage.dirty = true; + canvas.requestRenderAll?.(); + } + + _commitLivePreview() { + if (!this._previewImage) return; + + const canvas = this.canvasManager.canvas; + this._previewImage.setCoords(); + this._targetObject = this._previewImage; + this._strokeTarget = this._previewImage; + this._previewCtx = null; + this._previewBounds = null; + this._previewImage = null; + this._isDrawing = false; + this._lastPointer = null; + + if (canvas) { + canvas.discardActiveObject(); + canvas.renderAll(); + } + } + + _renderTargetRegion(target, left, top, width, height) { + const canvas = this.canvasManager.canvas; + const objects = canvas.getObjects(); + const visibilityBackups = objects.map(obj => ({ obj, visible: obj.visible })); + const viewportTransform = canvas.viewportTransform?.slice(); + const backgroundColor = canvas.backgroundColor; + + try { + canvas.discardActiveObject(); + objects.forEach(obj => { + obj.visible = obj === target; + }); + canvas.backgroundColor = null; + canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; + canvas.calcViewportBoundaries?.(); + + return canvas.toCanvasElement(1, { + left, + top, + width, + height, + enableRetinaScaling: false, + }); + } finally { + visibilityBackups.forEach(({ obj, visible }) => { + obj.visible = visible; + }); + if (viewportTransform) { + canvas.viewportTransform = viewportTransform; + } + canvas.backgroundColor = backgroundColor; + canvas.calcViewportBoundaries?.(); + canvas.requestRenderAll?.(); + } + } + + _getStrokeTarget(e) { + if (this._isErasableObject(this._targetObject)) return this._targetObject; + + const canvas = this.canvasManager.canvas; + const active = this._getErasableObject(canvas?.getActiveObject?.()); + if (active) { + this._targetObject = active; + return active; + } + + const pointer = e ? canvas.getPointer(e.e) : null; + const hit = pointer ? this._findTopmostObjectAt(pointer) : null; + this._targetObject = hit; + return hit; + } + + _getErasableObject(obj) { + if (!obj) return null; + + if (obj.type === 'activeSelection' && typeof obj.getObjects === 'function') { + return this._pickTopmostObject(obj.getObjects()); + } + + return this._isErasableObject(obj) ? obj : null; + } + + _isErasableObject(obj) { + const canvas = this.canvasManager.canvas; + if (!obj || !canvas?.getObjects().includes(obj)) return false; + + // 排除背景原图 + if (obj === this.canvasManager.originalImage) return false; + + // 排除工具临时对象 + if (obj.excludeFromHistory || obj.excludeFromLayer) return false; + + // 排除旧版/误残留的橡皮擦路径 + if (typeof obj.id === 'string' && obj.id.startsWith('eraser_')) return false; + if (obj.globalCompositeOperation === 'destination-out') return false; + + return true; + } + + _findTopmostObjectAt(pointer) { + const canvas = this.canvasManager.canvas; + if (!canvas) return null; + + const objects = canvas.getObjects(); + for (let i = objects.length - 1; i >= 0; i--) { + const obj = objects[i]; + if (!this._isErasableObject(obj) || obj.visible === false) continue; + if (obj.containsPoint?.(pointer)) return obj; + } + + return null; + } + + _pickTopmostObject(objects) { + const canvasObjects = this.canvasManager.canvas?.getObjects() || []; + let result = null; + let resultIndex = -1; + + objects.forEach(obj => { + if (!this._isErasableObject(obj)) return; + const index = canvasObjects.indexOf(obj); + if (index > resultIndex) { + result = obj; + resultIndex = index; + } + }); + + return result; + } + + _resetStrokeState() { + this._strokeTarget = null; + this._isDrawing = false; + this._lastPointer = null; + } + + _applyBrushOptions() { + // 实时橡皮擦直接写入预览 canvas,宽度在下一段轨迹生效。 + } + + _clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } +} + +export default EraserModule; diff --git a/plugins/Image-Toolbox/core/src/modules/ExportModule.js b/plugins/Image-Toolbox/core/src/modules/ExportModule.js new file mode 100644 index 00000000..dc4b8a05 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/ExportModule.js @@ -0,0 +1,242 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +/** + * 导出模块 — 将编辑结果导出为图片文件或复制到剪贴板 + * + * 接受可选 host adapter 注入。未注入时降级到浏览器原生行为。 + */ +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] + */ + constructor(canvasManager, historyManager, defaultOptions = {}, host = null) { + super(canvasManager, historyManager, { + format: 'png', + quality: 1, + multiplier: 1, + ...defaultOptions, + }); + this._host = host; + } + + /** + * 注入 host adapter(可在运行时设置)。 + * @param {import('../interfaces/HostAdapter.js').default} host + */ + setHost(host) { + this._host = host; + } + + /** + * 导出为文件 — 先弹保存对话框,用户选择格式后自动匹配导出 + */ + async exportToFile() { + 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) { + this._notifyToast('图片已保存', 'success'); + } + return; + } + + // 降级:浏览器下载 + this._browserDownload(dataURL, 'edited.png'); + } + + /** + * 导出到剪贴板 + */ + async exportToClipboard() { + const dataURL = this.exportToDataURL('png', 1, { trimToImage: true }); + if (!dataURL) return; + + // 优先使用 host adapter + if (this._host?.copyImage) { + const ok = await this._host.copyImage(dataURL); + this._notifyToast(ok ? '已复制到剪贴板' : '复制失败', ok ? 'success' : 'error'); + return; + } + + // 降级:Clipboard API + try { + const blob = await (await fetch(dataURL)).blob(); + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }), + ]); + this._notifyToast('已复制到剪贴板', 'success'); + } catch (err) { + console.error('[ExportModule] 剪贴板操作失败:', err); + this._notifyToast('复制失败', 'error'); + } + } + + /** + * 获取 DataURL + * @param {string} [format] - 'png' | 'jpeg' | 'webp' + * @param {number} [quality] - 0~1 + * @param {object} [options] + * @returns {string|null} + */ + exportToDataURL(format, quality, options = {}) { + const canvas = this.canvasManager.canvas; + if (!canvas) return null; + + const fmt = format || 'png'; + const q = quality ?? 1; + const dataURLOptions = { + format: fmt, + quality: q, + multiplier: this.options.multiplier || 1, + }; + + if (options.trimToImage) { + const bounds = this._getImageExportBounds(); + if (bounds) { + Object.assign(dataURLOptions, bounds); + } + } + + return this._toDataURL(dataURLOptions, { + resetViewport: !!options.trimToImage, + transparentBackground: !!options.trimToImage, + }); + } + + /** + * 带选项导出 + */ + exportWithOptions(options = {}) { + const opts = { + format: 'png', + multiplier: 1, + quality: 1, + ...options, + }; + return this.exportToDataURL(opts.format, opts.quality); + } + + _toDataURL(dataURLOptions, options = {}) { + const canvas = this.canvasManager.canvas; + const viewportTransform = canvas.viewportTransform?.slice(); + const backgroundColor = canvas.backgroundColor; + + try { + this.canvasManager.refreshDynamicMosaics?.({ render: true }); + if (options.resetViewport) { + canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; + } + if (options.transparentBackground) { + canvas.backgroundColor = null; + } + return canvas.toDataURL(dataURLOptions); + } finally { + if (viewportTransform) { + canvas.viewportTransform = viewportTransform; + } + canvas.backgroundColor = backgroundColor; + canvas.requestRenderAll(); + } + } + + _getImageExportBounds() { + const canvas = this.canvasManager.canvas; + const clipBounds = this._getObjectBounds(canvas.clipPath); + if (clipBounds) { + return this._normalizeBounds(clipBounds); + } + + const imageBounds = this._getObjectBounds(this.canvasManager.originalImage); + if (imageBounds) { + return this._normalizeBounds(imageBounds); + } + + return null; + } + + _getObjectBounds(obj) { + if (!obj) return null; + + try { + obj.setCoords?.(); + const rect = obj.getBoundingRect?.(true, true); + if (rect && this._isValidBounds(rect)) { + return rect; + } + } catch (err) { + console.warn('[ExportModule] 获取导出边界失败,使用备用计算:', err); + } + + const scaleX = obj.scaleX ?? 1; + const scaleY = obj.scaleY ?? 1; + return { + left: obj.left ?? 0, + top: obj.top ?? 0, + width: (obj.width ?? 0) * scaleX, + height: (obj.height ?? 0) * scaleY, + }; + } + + _normalizeBounds(bounds) { + if (!this._isValidBounds(bounds)) return null; + + const left = Math.floor(bounds.left); + const top = Math.floor(bounds.top); + const right = Math.ceil(bounds.left + bounds.width); + const bottom = Math.ceil(bounds.top + bounds.height); + + return { + left, + top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; + } + + _isValidBounds(bounds) { + return Number.isFinite(bounds.left) + && Number.isFinite(bounds.top) + && Number.isFinite(bounds.width) + && Number.isFinite(bounds.height) + && bounds.width > 0 + && bounds.height > 0; + } + + /** + * 浏览器下载(降级方案) + */ + _browserDownload(dataURL, filename) { + const link = document.createElement('a'); + link.href = dataURL; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + /** + * 通过 eventBus 发送 Toast 事件,由 UI 层渲染。 + * @param {string} message + * @param {'success'|'error'} type + */ + _notifyToast(message, type = 'success') { + eventBus.emit('toast:show', { message, type }); + } + + activate() { + super.activate(); + } + + deactivate() { + super.deactivate(); + } +} + +export default ExportModule; diff --git a/plugins/Image-Toolbox/core/src/modules/MosaicModule.js b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js new file mode 100644 index 00000000..75223805 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js @@ -0,0 +1,1497 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +const SELECTION_FILL = 'rgba(47,127,134,0.16)'; +const SELECTION_STROKE = '#2f7f86'; +const SELECTION_STROKE_SOFT = 'rgba(47,127,134,0.52)'; + +/** + * 马赛克模块 — 矩形/自由选区/画笔三种交互方式,马赛克 + 模糊两种效果 + * + * 框选模式 (rect):拖拽矩形选区应用马赛克/模糊 + * 自由选区 (lasso):拖拽闭合非矩形选区应用马赛克/模糊 + * 画笔模式 (brush):自由涂抹马赛克/模糊,释放时对涂抹覆盖区域(包围盒+画笔半径)应用效果 + */ +class MosaicModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + mode: 'mosaic', // 效果类型: 'mosaic' | 'blur' + drawMode: 'rect', // 交互方式: 'rect' | 'lasso' | 'brush' + mosaicSize: 12, // 马赛克块大小,默认对应“中马赛克”预设 + blurRadius: 8, // 模糊半径 + brushSize: 20, // 画笔直径 + ...defaultOptions, + }); + + this._isDrawing = false; + this._startPoint = null; + this._selectionRect = null; // 框选模式的虚线矩形 + this._lassoPoints = []; // 自由选区的轮廓点 + this._lassoPreview = null; // 自由选区预览轮廓 + this._brushPoints = []; // 画笔模式的轨迹点 + this._brushPreview = null; // 画笔预览圆圈 + this._liveBrushOverlay = null; // 画笔模式拖动中的实时马赛克层 + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + this._objectClipPathBackups = null; + + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + this._boundMouseOut = this._onMouseOut.bind(this); + this._boundObjectMoving = this._onObjectMoving.bind(this); + this._refreshingDynamicMosaic = false; + + this._bindDynamicMosaicEvents(); + } + + _bindDynamicMosaicEvents() { + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.on('object:moving', this._boundObjectMoving); + canvas.on('object:scaling', this._boundObjectMoving); + canvas.on('object:rotating', this._boundObjectMoving); + } + + 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(); + }); + + 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); + } + + // ── 生命周期 ── + + activate(options = {}) { + super.activate(options); + + const canvas = this.canvasManager.canvas; + canvas.defaultCursor = this._getCursorForDrawMode(); + this._detachCanvasClipPath(); + + 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', 'mosaic'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + + canvas.off('mouse:down', this._boundMouseDown); + canvas.off('mouse:move', this._boundMouseMove); + canvas.off('mouse:up', this._boundMouseUp); + canvas.off('mouse:out', this._boundMouseOut); + + this._cleanupRect(); + this._cleanupLasso(); + this._cleanupLiveBrushOverlay(); + this._cleanupBrush(); + this._restoreDetachedCanvasClipPath(false); + canvas.renderAll(); + + super.deactivate(); + } + + // ── 参数设置 ── + + setMode(mode) { + this.options.mode = mode; + } + + setDrawMode(mode) { + if (!['rect', 'lasso', 'brush'].includes(mode)) return; + if (this.options.drawMode !== mode) { + this._isDrawing = false; + this._startPoint = null; + this._brushPoints = []; + this._cleanupRect(); + this._cleanupLasso(); + this._cleanupLiveBrushOverlay(); + this._cleanupBrush(); + } + + this.options.drawMode = mode; + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.defaultCursor = this._getCursorForDrawMode(); + } + } + + setMosaicSize(size) { + this.options.mosaicSize = Math.max(2, Math.min(50, parseInt(size))); + } + + setBlurRadius(radius) { + this.options.blurRadius = Math.max(1, Math.min(30, parseInt(radius))); + } + + setBrushSize(size) { + this.options.brushSize = Math.max(4, Math.min(100, parseInt(size))); + if (this._brushPreview) { + const center = this._getBrushPreviewCenter(); + if (center) this._updateBrushPreview(center); + } + } + + // ── 鼠标事件分发 ── + + _onMouseDown(e) { + if (this.options.drawMode === 'brush') { + this._startBrush(e); + } else if (this.options.drawMode === 'lasso') { + this._startLasso(e); + } else { + this._startRect(e); + } + } + + _onMouseMove(e) { + if (this.options.drawMode === 'brush') { + if (this._isDrawing) { + this._continueBrush(e); + } else { + this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e)); + } + return; + } + + if (!this._isDrawing) return; + + if (this.options.drawMode === 'lasso') { + this._continueLasso(e); + } else { + this._updateRect(e); + } + } + + _onMouseOut() { + if (!this._isDrawing && this.options.drawMode === 'brush') { + this._cleanupBrush(); + } + } + + _onMouseUp(e) { + if (!this._isDrawing) return; + + if (this.options.drawMode === 'brush') { + this._finishBrush(e); + } else if (this.options.drawMode === 'lasso') { + this._finishLasso(e); + } else { + this._finishRect(e); + } + } + + _getCursorForDrawMode() { + return this.options.drawMode === 'brush' ? 'none' : 'crosshair'; + } + + // ═══════════════════════════════════════ + // 框选模式 (rect) — 拖拽矩形选区 + // ═══════════════════════════════════════ + + _startRect(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._isDrawing = true; + this._startPoint = pointer; + + this._selectionRect = new fabric.Rect({ + left: pointer.x, + top: pointer.y, + width: 0, + height: 0, + fill: SELECTION_FILL, + stroke: SELECTION_STROKE, + strokeWidth: 1.5, + strokeDashArray: [4, 3], + selectable: false, + evented: false, + }); + this.canvasManager.canvas.add(this._selectionRect); + } + + _updateRect(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + const left = Math.min(this._startPoint.x, pointer.x); + const top = Math.min(this._startPoint.y, pointer.y); + const width = Math.abs(pointer.x - this._startPoint.x); + const height = Math.abs(pointer.y - this._startPoint.y); + + this._selectionRect.set({ left, top, width, height }); + this.canvasManager.canvas.renderAll(); + } + + _finishRect(e) { + this._isDrawing = false; + + if (!this._selectionRect) return; + + const rect = this._selectionRect; + const width = rect.width * rect.scaleX; + const height = rect.height * rect.scaleY; + + const mosaicRect = { + left: rect.left, + top: rect.top, + width: Math.round(width), + height: Math.round(height), + }; + + // 先移除选区框,否则 getImageData 会把提示色读进去 + this._cleanupRect(); + + if (mosaicRect.width < 5 || mosaicRect.height < 5) return; + + this.applyMosaic(mosaicRect); + } + + _cleanupRect() { + if (this._selectionRect) { + this.canvasManager.canvas.remove(this._selectionRect); + this._selectionRect = null; + } + this.canvasManager.canvas.renderAll(); + } + + // ═══════════════════════════════════════ + // 自由选区 (lasso) — 拖拽闭合非矩形选区 + // ═══════════════════════════════════════ + + _startLasso(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._isDrawing = true; + this._lassoPoints = [{ x: pointer.x, y: pointer.y }]; + + this._lassoPreview = new fabric.Polygon(this._getLassoPreviewPoints(), { + fill: SELECTION_FILL, + stroke: SELECTION_STROKE, + strokeWidth: 1.5, + strokeDashArray: [4, 3], + selectable: false, + evented: false, + objectCaching: false, + }); + this.canvasManager.canvas.add(this._lassoPreview); + this.canvasManager.canvas.renderAll(); + } + + _continueLasso(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + if (this._appendLassoPoint(pointer)) { + this._updateLassoPreview(); + } + } + + _finishLasso(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._appendLassoPoint(pointer); + this._isDrawing = false; + + const points = this._lassoPoints.slice(); + this._cleanupLasso(); + + if (points.length < 3 || this._getPolygonArea(points) < 25) return; + + const bounds = this._getPointsBounds(points); + if (!bounds) return; + + const rect = this._clipRectToEditableImage({ + left: Math.floor(bounds.left), + top: Math.floor(bounds.top), + width: Math.ceil(bounds.right - bounds.left), + height: Math.ceil(bounds.bottom - bounds.top), + }); + + if (!rect || rect.width < 5 || rect.height < 5) return; + + this._createDynamicMosaicOverlay({ + rect, + maskType: 'lasso', + lassoPoints: points.map(p => ({ + x: Math.round(p.x - rect.left), + y: Math.round(p.y - rect.top), + })), + }); + this._saveStateWithCanvasClipPath(); + } + + _appendLassoPoint(pointer) { + const last = this._lassoPoints[this._lassoPoints.length - 1]; + if (!last) { + this._lassoPoints.push({ x: pointer.x, y: pointer.y }); + return true; + } + + const dx = pointer.x - last.x; + const dy = pointer.y - last.y; + if (Math.sqrt(dx * dx + dy * dy) <= 2) return false; + + this._lassoPoints.push({ x: pointer.x, y: pointer.y }); + return true; + } + + _getLassoPreviewPoints() { + if (this._lassoPoints.length > 1) { + return this._lassoPoints.map(p => ({ x: p.x, y: p.y })); + } + + const p = this._lassoPoints[0] || { x: 0, y: 0 }; + return [{ x: p.x, y: p.y }, { x: p.x, y: p.y }]; + } + + _updateLassoPreview() { + if (!this._lassoPreview) return; + + this._lassoPreview.set({ points: this._getLassoPreviewPoints() }); + if (typeof this._lassoPreview._setPositionDimensions === 'function') { + this._lassoPreview._setPositionDimensions({}); + } + this._lassoPreview.setCoords(); + this._lassoPreview.dirty = true; + this._requestRender(); + } + + _cleanupLasso() { + if (this._lassoPreview) { + this.canvasManager.canvas.remove(this._lassoPreview); + this._lassoPreview = null; + } + this._lassoPoints = []; + this.canvasManager.canvas.renderAll(); + } + + _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 }; + } + + _getPolygonArea(points) { + let area = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + area += a.x * b.y - b.x * a.y; + } + return Math.abs(area) / 2; + } + + // ═══════════════════════════════════════ + // 画笔模式 (brush) — 自由涂抹 + // ═══════════════════════════════════════ + + _startBrush(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._isDrawing = true; + this._brushPoints = [{ x: pointer.x, y: pointer.y }]; + + this._updateLiveBrushOverlay(); + this._updateBrushPreview(pointer); + } + + _updateBrushPreview(pointer) { + if (!pointer) return; + + const strokeWidth = 1; + const r = Math.max(1, (this.options.brushSize - strokeWidth) / 2); + + if (!this._brushPreview) { + this._brushPreview = new fabric.Circle({ + left: pointer.x - r, + top: pointer.y - r, + radius: r, + fill: 'transparent', + stroke: SELECTION_STROKE, + strokeWidth, + selectable: false, + evented: false, + objectCaching: false, + }); + this.canvasManager.canvas.add(this._brushPreview); + } else { + this._brushPreview.set({ + left: pointer.x - r, + top: pointer.y - r, + radius: r, + strokeWidth, + }); + } + + this._brushPreview.setCoords(); + this._bringBrushPreviewToFront(); + this._requestRender(); + } + + _getBrushPreviewCenter() { + if (!this._brushPreview) return null; + + const r = this._brushPreview.radius || 0; + return { + x: (this._brushPreview.left || 0) + r, + y: (this._brushPreview.top || 0) + r, + }; + } + + _bringBrushPreviewToFront() { + if (!this._brushPreview) return; + + const canvas = this.canvasManager.canvas; + if (typeof canvas?.bringToFront === 'function') { + canvas.bringToFront(this._brushPreview); + } else if (typeof this._brushPreview.bringToFront === 'function') { + this._brushPreview.bringToFront(); + } + } + + _continueBrush(e) { + const pointer = this.canvasManager.canvas.getPointer(e.e); + this._updateBrushPreview(pointer); + + // 采样优化:距离上个点超过一定距离才记录,避免点过密 + const last = this._brushPoints[this._brushPoints.length - 1]; + const dx = pointer.x - last.x; + const dy = pointer.y - last.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > 2) { + this._brushPoints.push({ x: pointer.x, y: pointer.y }); + this._updateLiveBrushOverlay(); + } + } + + _finishBrush(e) { + this._isDrawing = false; + this._updateBrushPreview(this.canvasManager.canvas.getPointer(e.e)); + this._updateLiveBrushOverlay(); + + if (this._brushPoints.length === 0) return; + this._brushPoints = []; + + if (!this._liveBrushOverlay) return; + + this._liveBrushOverlay = null; + this._saveStateWithCanvasClipPath(); + } + + _updateLiveBrushOverlay() { + if (this._brushPoints.length === 0) return; + + const rect = this._getBrushStrokeRect(this._brushPoints); + if (!rect || rect.width < 1 || rect.height < 1) { + this._cleanupLiveBrushOverlay(); + return; + } + + const brushPoints = this._brushPoints.map(p => ({ + x: Math.round(p.x - rect.left), + y: Math.round(p.y - rect.top), + })); + + if (!this._liveBrushOverlay) { + this._liveBrushOverlay = this._createDynamicMosaicOverlay({ + rect, + maskType: 'brush', + brushPoints, + brushSize: this.options.brushSize, + }); + this._bringBrushPreviewToFront(); + this._requestRender(); + return; + } + + this._liveBrushOverlay.set({ + left: rect.left, + top: rect.top, + scaleX: 1, + scaleY: 1, + }); + this._liveBrushOverlay._mosaicMode = this.options.mode; + this._liveBrushOverlay._mosaicSize = this.options.mosaicSize; + this._liveBrushOverlay._mosaicBlurRadius = this.options.blurRadius; + this._liveBrushOverlay._mosaicWidth = Math.max(1, Math.round(rect.width)); + this._liveBrushOverlay._mosaicHeight = Math.max(1, Math.round(rect.height)); + this._liveBrushOverlay._mosaicMaskType = 'brush'; + this._liveBrushOverlay._mosaicBrushPoints = brushPoints; + this._liveBrushOverlay._mosaicBrushSize = this.options.brushSize; + this._liveBrushOverlay._mosaicMaskCanvas = null; + this._liveBrushOverlay._layerKind = 'mosaic'; + this._liveBrushOverlay._layerPresetName = this._getCurrentLayerPresetName(); + this._liveBrushOverlay.setCoords(); + + this._refreshDynamicMosaicOverlay(this._liveBrushOverlay, { render: false }); + this._bringBrushPreviewToFront(); + this._requestRender(); + } + + _getBrushStrokeRect(points) { + const bounds = this._getPointsBounds(points); + if (!bounds) return null; + + const padding = this.options.brushSize / 2; + return this._clipRectToEditableImage({ + left: Math.round(bounds.left - padding), + top: Math.round(bounds.top - padding), + width: Math.round(bounds.right - bounds.left + padding * 2), + height: Math.round(bounds.bottom - bounds.top + padding * 2), + }); + } + + _cleanupLiveBrushOverlay() { + if (this._liveBrushOverlay) { + this.canvasManager.canvas.remove(this._liveBrushOverlay); + this._liveBrushOverlay = null; + } + } + + _cleanupBrush() { + if (this._brushPreview) { + this.canvasManager.canvas.remove(this._brushPreview); + this._brushPreview = null; + } + this.canvasManager.canvas.renderAll(); + } + + /** + * 马赛克效果 — 对阵列像素进行块化处理 + * 只处理蒙版覆盖区域内的像素 + */ + _mosaicPixels(data, w, h, mask = null, mosaicSize = this.options.mosaicSize) { + const defaultSize = this.options.mosaicSize || 12; + const rawSizeX = typeof mosaicSize === 'object' ? mosaicSize?.width : mosaicSize; + const rawSizeY = typeof mosaicSize === 'object' ? mosaicSize?.height : mosaicSize; + const sizeX = Math.max(1, Math.round(Number(rawSizeX) || defaultSize)); + const sizeY = Math.max(1, Math.round(Number(rawSizeY) || defaultSize)); + + for (let y = 0; y < h; y += sizeY) { + for (let x = 0; x < w; x += sizeX) { + // 检查该块是否有蒙版区域;矩形模式没有蒙版时整块处理。 + let hasMask = !mask; + if (mask) { + for (let dy = 0; dy < sizeY && y + dy < h && !hasMask; dy++) { + for (let dx = 0; dx < sizeX && x + dx < w && !hasMask; dx++) { + const mi = ((y + dy) * w + (x + dx)) * 4; + if (mask[mi + 3] > 0) hasMask = true; + } + } + } + if (!hasMask) continue; + + let r = 0, g = 0, b = 0, a = 0, count = 0; + for (let dy = 0; dy < sizeY && y + dy < h; dy++) { + for (let dx = 0; dx < sizeX && x + dx < w; dx++) { + const idx = ((y + dy) * w + (x + dx)) * 4; + r += data[idx]; + g += data[idx + 1]; + b += data[idx + 2]; + a += data[idx + 3]; + count++; + } + } + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + a = Math.round(a / count); + + for (let dy = 0; dy < sizeY && y + dy < h; dy++) { + for (let dx = 0; dx < sizeX && x + dx < w; dx++) { + const idx = ((y + dy) * w + (x + dx)) * 4; + if (!mask || mask[idx + 3] > 0) { + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = a; + } + } + } + } + } + } + + /** + * 模糊效果 — 使用 Canvas2D filter + */ + _blurPixels(data, originalImgData, w, h, mask = null, blurRadius = this.options.blurRadius) { + const radius = blurRadius || this.options.blurRadius || 8; + + // 源 canvas:放原始像素 + const srcCanvas = document.createElement('canvas'); + srcCanvas.width = w; + srcCanvas.height = h; + const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); + srcCtx.putImageData(new ImageData(data, w, h), 0, 0); + + // 目标 canvas:避免自绘制预乘 alpha 色偏 + const dstCanvas = document.createElement('canvas'); + dstCanvas.width = w; + dstCanvas.height = h; + const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true }); + dstCtx.filter = `blur(${radius}px)`; + dstCtx.drawImage(srcCanvas, 0, 0); + dstCtx.filter = 'none'; + + const blurred = dstCtx.getImageData(0, 0, w, h); + + // 只拷贝蒙版区域的模糊像素;矩形模式没有蒙版时整块处理。 + for (let i = 0; i < data.length; i += 4) { + if (!mask || mask[i + 3] > 0) { + data[i] = blurred.data[i]; + data[i + 1] = blurred.data[i + 1]; + data[i + 2] = blurred.data[i + 2]; + data[i + 3] = blurred.data[i + 3]; + } + } + } + + // ═══════════════════════════════════════ + // 核心马赛克方法 + // ═══════════════════════════════════════ + + /** + * 对指定矩形区域应用马赛克/模糊(框选模式使用) + */ + applyMosaic(rect) { + rect = this._clipRectToEditableImage(rect); + if (!rect || rect.width < 1 || rect.height < 1) return; + + this._createDynamicMosaicOverlay({ rect, maskType: 'rect' }); + this._saveStateWithCanvasClipPath(); + } + + _createDynamicMosaicOverlay({ rect, maskType, brushPoints = null, brushSize = null, lassoPoints = null }) { + const canvas = this.canvasManager.canvas; + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = Math.max(1, Math.round(rect.width)); + tempCanvas.height = Math.max(1, Math.round(rect.height)); + + const img = new fabric.Image(tempCanvas, { + left: rect.left, + top: rect.top, + selectable: false, + evented: false, + id: 'mosaic_' + Date.now(), + objectCaching: false, + }); + + img._mosaicDynamic = true; + img._mosaicMode = this.options.mode; + img._mosaicSize = this.options.mosaicSize; + img._mosaicBlurRadius = this.options.blurRadius; + img._mosaicWidth = tempCanvas.width; + img._mosaicHeight = tempCanvas.height; + img._mosaicMaskType = maskType; + img._mosaicBrushPoints = brushPoints; + img._mosaicBrushSize = brushSize; + img._mosaicLassoPoints = lassoPoints; + img._layerKind = 'mosaic'; + img._layerPresetName = this._getCurrentLayerPresetName(); + + this._attachCurrentCropClipPath(img); + this._refreshDynamicMosaicOverlay(img, { render: false }); + + if (this._selectionRect) { + canvas.remove(this._selectionRect); + this._selectionRect = null; + } + + canvas.add(img); + canvas.renderAll(); + return img; + } + + refreshDynamicMosaics(options = {}) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const objects = canvas.getObjects(); + let changed = false; + + this._refreshingDynamicMosaic = true; + try { + for (const obj of objects) { + if (this._isDynamicMosaic(obj)) { + changed = this._refreshDynamicMosaicOverlay(obj, { render: false }) || changed; + } + } + } finally { + this._refreshingDynamicMosaic = false; + } + + if (changed && options.render !== false) { + canvas.renderAll(); + } + } + + _onObjectMoving(e) { + 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; + } + } + + _getDynamicMosaicTargets(target) { + if (!target) return []; + const canvas = this.canvasManager.canvas; + if (!canvas) return []; + + let explicitTargets = []; + if (this._isDynamicMosaic(target)) { + explicitTargets = [target]; + } + + if (target.type === 'activeSelection' && typeof target.getObjects === 'function') { + explicitTargets = target.getObjects().filter(obj => this._isDynamicMosaic(obj)); + } + + if (explicitTargets.length === 0) return []; + + const objects = canvas.getObjects(); + const firstIndex = explicitTargets.reduce((min, obj) => { + const index = objects.indexOf(obj); + return index >= 0 ? Math.min(min, index) : min; + }, objects.length); + + return objects.filter((obj, index) => index >= firstIndex && this._isDynamicMosaic(obj)); + } + + _isDynamicMosaic(obj) { + return !!obj && obj._mosaicDynamic === true; + } + + _refreshDynamicMosaicOverlay(obj, options = {}) { + if (!this._isDynamicMosaic(obj)) return false; + + const dimensions = this._getDynamicMosaicDimensions(obj); + if (!dimensions) return false; + + const { width, height } = dimensions; + const element = this._ensureDynamicMosaicElement(obj, width, height); + const ctx = element.getContext('2d', { willReadFrequently: true }); + const imgData = this._captureDynamicMosaicSource(obj, width, height); + const maskData = this._getDynamicMosaicMaskData(obj, width, height); + + if ((obj._mosaicMode || 'mosaic') === 'mosaic') { + this._mosaicPixels(imgData.data, width, height, maskData, this._getLocalMosaicBlockSize(obj)); + } else { + this._blurPixels(imgData.data, imgData, width, height, maskData, this._getLocalBlurRadius(obj)); + } + + if (maskData) { + this._applyAlphaMask(imgData.data, maskData); + } + + ctx.clearRect(0, 0, width, height); + ctx.putImageData(imgData, 0, 0); + obj.dirty = true; + + if (options.render !== false) { + this._requestRender(); + } + + return true; + } + + _getDynamicMosaicDimensions(obj) { + const width = Math.max(1, Math.round(obj._mosaicWidth || obj.width || 0)); + const height = Math.max(1, Math.round(obj._mosaicHeight || obj.height || 0)); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + return { width, height }; + } + + _ensureDynamicMosaicElement(obj, width, height) { + let element = typeof obj.getElement === 'function' ? obj.getElement() : obj._element; + const needsCanvas = !element || element.nodeName?.toLowerCase() !== 'canvas'; + const needsResize = !needsCanvas && (element.width !== width || element.height !== height); + + if (needsCanvas) { + element = document.createElement('canvas'); + if (typeof obj.setElement === 'function') { + obj.setElement(element); + } else { + obj._element = element; + obj._originalElement = element; + } + } + + if (needsCanvas || needsResize) { + element.width = width; + element.height = height; + obj.set({ width, height }); + obj._mosaicWidth = width; + obj._mosaicHeight = height; + } + + return element; + } + + _captureDynamicMosaicSource(obj, width, height) { + const sourceCanvas = this._renderObjectsBelow(obj); + const sampleCanvas = document.createElement('canvas'); + sampleCanvas.width = width; + sampleCanvas.height = height; + const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true }); + + const matrix = this._getDynamicMosaicSamplingMatrix(obj, width, height); + const inverse = matrix ? this._invertTransform(matrix) : null; + + if (inverse) { + sampleCtx.save(); + sampleCtx.setTransform(inverse[0], inverse[1], inverse[2], inverse[3], inverse[4], inverse[5]); + sampleCtx.drawImage(sourceCanvas, 0, 0); + sampleCtx.restore(); + } else { + this._captureDynamicMosaicSourceFallback(sampleCtx, sourceCanvas, obj, width, height); + } + + return sampleCtx.getImageData(0, 0, width, height); + } + + _captureDynamicMosaicSourceFallback(sampleCtx, sourceCanvas, obj, width, height) { + const left = Math.round(obj.left || 0); + const top = Math.round(obj.top || 0); + const sampleWidth = Math.max(1, Math.round(width * Math.abs(obj.scaleX || 1))); + const sampleHeight = Math.max(1, Math.round(height * Math.abs(obj.scaleY || 1))); + const sx = Math.max(0, left); + const sy = Math.max(0, top); + const ex = Math.min(sourceCanvas.width, left + sampleWidth); + const ey = Math.min(sourceCanvas.height, top + sampleHeight); + const sw = Math.max(0, ex - sx); + const sh = Math.max(0, ey - sy); + + if (sw <= 0 || sh <= 0) return; + + sampleCtx.drawImage( + sourceCanvas, + sx, + sy, + sw, + sh, + (sx - left) * width / sampleWidth, + (sy - top) * height / sampleHeight, + sw * width / sampleWidth, + sh * height / sampleHeight + ); + } + + _getDynamicMosaicSamplingMatrix(obj, width, height) { + const matrix = this._getObjectTransformMatrix(obj); + if (!matrix) return null; + + return this._multiplyTransformMatrices(matrix, [1, 0, 0, 1, -width / 2, -height / 2]); + } + + _getObjectTransformMatrix(obj) { + if (typeof obj?.calcTransformMatrix === 'function') { + const matrix = obj.calcTransformMatrix(); + if (this._isValidTransformMatrix(matrix)) return matrix; + } + + return null; + } + + _getLocalMosaicBlockSize(obj) { + const baseSize = Math.max(1, Math.round(obj._mosaicSize || this.options.mosaicSize || 12)); + const scale = this._getDynamicMosaicTransformScale(obj); + + return { + width: Math.max(1, Math.round(baseSize / scale.x)), + height: Math.max(1, Math.round(baseSize / scale.y)), + }; + } + + _getLocalBlurRadius(obj) { + const radius = Math.max(1, Math.round(obj._mosaicBlurRadius || this.options.blurRadius || 8)); + const scale = this._getDynamicMosaicTransformScale(obj); + const averageScale = Math.max(0.0001, Math.sqrt(scale.x * scale.y)); + return Math.max(1, radius / averageScale); + } + + _getDynamicMosaicTransformScale(obj) { + const matrix = this._getObjectTransformMatrix(obj); + if (!matrix) { + return { + x: Math.max(0.0001, Math.abs(obj?.scaleX || 1)), + y: Math.max(0.0001, Math.abs(obj?.scaleY || 1)), + }; + } + + return { + x: Math.max(0.0001, Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1])), + y: Math.max(0.0001, Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3])), + }; + } + + _isValidTransformMatrix(matrix) { + return Array.isArray(matrix) + && matrix.length >= 6 + && matrix.slice(0, 6).every(value => Number.isFinite(value)); + } + + _multiplyTransformMatrices(a, b) { + return [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5], + ]; + } + + _invertTransform(matrix) { + const determinant = matrix[0] * matrix[3] - matrix[1] * matrix[2]; + if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-8) return null; + + return [ + matrix[3] / determinant, + -matrix[1] / determinant, + -matrix[2] / determinant, + matrix[0] / determinant, + (matrix[2] * matrix[5] - matrix[3] * matrix[4]) / determinant, + (matrix[1] * matrix[4] - matrix[0] * matrix[5]) / determinant, + ]; + } + + _renderObjectsBelow(obj) { + const canvas = this.canvasManager.canvas; + const width = Math.max(1, Math.ceil(canvas.width || 1)); + const height = Math.max(1, Math.ceil(canvas.height || 1)); + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = width; + sourceCanvas.height = height; + const ctx = sourceCanvas.getContext('2d', { willReadFrequently: true }); + + this._renderSourceBackground(ctx, width, height); + + const objects = canvas.getObjects(); + const objectIndex = objects.indexOf(obj); + const endIndex = objectIndex === -1 ? objects.length : objectIndex; + + for (let i = 0; i < endIndex; i++) { + const candidate = objects[i]; + if (!candidate || candidate === obj || candidate.visible === false) continue; + if (candidate === this._selectionRect || candidate === this._brushPreview || candidate === this._lassoPreview) continue; + + try { + ctx.save(); + candidate.render(ctx); + ctx.restore(); + } catch (err) { + ctx.restore(); + console.warn('[MosaicModule] 渲染动态马赛克底层失败:', err); + } + } + + return sourceCanvas; + } + + _renderSourceBackground(ctx, width, height) { + const backgroundColor = this.canvasManager.canvas?.backgroundColor; + if (!backgroundColor || backgroundColor === 'transparent') return; + if (typeof backgroundColor !== 'string') return; + + ctx.save(); + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, width, height); + ctx.restore(); + } + + _getDynamicMosaicMaskData(obj, width, height) { + if (obj._mosaicMaskType !== 'brush' && obj._mosaicMaskType !== 'lasso') return null; + + if (!obj._mosaicMaskCanvas + || obj._mosaicMaskCanvas.width !== width + || obj._mosaicMaskCanvas.height !== height) { + obj._mosaicMaskCanvas = obj._mosaicMaskType === 'lasso' + ? this._createLassoMaskCanvas(width, height, obj._mosaicLassoPoints || []) + : this._createBrushMaskCanvas( + width, + height, + obj._mosaicBrushPoints || [], + obj._mosaicBrushSize || this.options.brushSize + ); + } + + const maskCtx = obj._mosaicMaskCanvas.getContext('2d', { willReadFrequently: true }); + return maskCtx.getImageData(0, 0, width, height).data; + } + + _createBrushMaskCanvas(width, height, points, brushSize) { + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + const brushR = Math.max(1, (brushSize || this.options.brushSize) / 2); + + maskCtx.fillStyle = '#ffffff'; + for (const p of points) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + maskCtx.beginPath(); + maskCtx.arc(p.x, p.y, brushR, 0, Math.PI * 2); + maskCtx.fill(); + } + + return maskCanvas; + } + + _createLassoMaskCanvas(width, height, points) { + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + const validPoints = points.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)); + + if (validPoints.length < 3) return maskCanvas; + + maskCtx.fillStyle = '#ffffff'; + maskCtx.beginPath(); + maskCtx.moveTo(validPoints[0].x, validPoints[0].y); + for (let i = 1; i < validPoints.length; i++) { + maskCtx.lineTo(validPoints[i].x, validPoints[i].y); + } + maskCtx.closePath(); + maskCtx.fill(); + + return maskCanvas; + } + + _applyAlphaMask(data, maskData) { + for (let i = 0; i < data.length; i += 4) { + const alpha = maskData[i + 3]; + if (alpha <= 0) { + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + data[i + 3] = 0; + } else if (alpha < 255) { + data[i + 3] = Math.round(data[i + 3] * alpha / 255); + } + } + } + + _clipRectToEditableImage(rect) { + const bounds = this._getEditableImageBounds(); + if (!bounds) return rect; + + const left = this._clamp(rect.left, bounds.left, bounds.right); + const top = this._clamp(rect.top, bounds.top, bounds.bottom); + const right = this._clamp(rect.left + rect.width, bounds.left, bounds.right); + const bottom = this._clamp(rect.top + rect.height, bounds.top, bounds.bottom); + + if (right <= left || bottom <= top) return null; + return { + left: Math.round(left), + top: Math.round(top), + width: Math.round(right - left), + height: Math.round(bottom - top), + }; + } + + _getEditableImageBounds() { + const imageBounds = this._getImageBounds(); + if (!imageBounds) return null; + + const cropBounds = this._getCurrentCropBounds(); + return cropBounds ? this._intersectBounds(imageBounds, cropBounds) : imageBounds; + } + + _getImageBounds() { + const image = this.canvasManager.originalImage; + if (!image) return null; + + return this._normalizeBounds(image.getBoundingRect(true, true)); + } + + _getCurrentCropBounds() { + return this._getClipPathBounds(this._getActiveCropClipPath()); + } + + _getClipPathBounds(clipPath) { + if (!clipPath) return null; + + const bounds = this._normalizeBounds(clipPath.getBoundingRect(true, true)); + const nested = this._getClipPathBounds(clipPath.clipPath); + return nested ? this._intersectBounds(bounds, nested) : bounds; + } + + _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, + }; + } + + _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 }; + } + + _clamp(value, min, max) { + if (max < min) return min; + return Math.max(min, Math.min(max, value)); + } + + _getActiveCropClipPath() { + return this._detachedCanvasClipPath || this.canvasManager.canvas?.clipPath || null; + } + + _detachCanvasClipPath() { + const canvas = this.canvasManager.canvas; + if (!canvas?.clipPath) return; + + this._detachedCanvasClipPath = canvas.clipPath; + this._clipPathDetached = true; + canvas.clipPath = null; + this._applyTemporaryObjectClipPaths(this._detachedCanvasClipPath, false); + this._requestRender(); + } + + _applyTemporaryObjectClipPaths(clipPath, render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas || !clipPath) return; + + this._clearTemporaryObjectClipPaths(false); + this._objectClipPathBackups = canvas.getObjects() + .filter(obj => obj !== this._selectionRect && obj !== this._brushPreview && obj !== this._lassoPreview) + .map(obj => ({ obj, clipPath: obj.clipPath || null })); + + this._objectClipPathBackups.forEach(({ obj }) => { + obj.set('clipPath', this._createClipPathFromSource(clipPath)); + obj.dirty = true; + }); + + if (render) this._requestRender(); + } + + _clearTemporaryObjectClipPaths(render = true) { + if (!this._objectClipPathBackups) return; + + this._objectClipPathBackups.forEach(({ obj, clipPath }) => { + obj.set('clipPath', clipPath || null); + obj.dirty = true; + }); + this._objectClipPathBackups = null; + + if (render) this._requestRender(); + } + + _restoreDetachedCanvasClipPath(render = true) { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + this._clearTemporaryObjectClipPaths(false); + if (this._clipPathDetached) { + canvas.clipPath = this._detachedCanvasClipPath; + } + this._detachedCanvasClipPath = null; + this._clipPathDetached = false; + + if (render) this._requestRender(); + } + + _saveStateWithCanvasClipPath() { + const canvas = this.canvasManager.canvas; + if (!canvas) { + this.history.saveState(); + return; + } + + const transientObjects = [this._selectionRect, this._brushPreview, this._lassoPreview] + .filter(obj => obj && obj.canvas === canvas); + transientObjects.forEach(obj => canvas.remove(obj)); + + try { + if (!this._clipPathDetached) { + this.history.saveState(); + return; + } + + const currentClipPath = canvas.clipPath; + this._clearTemporaryObjectClipPaths(false); + canvas.clipPath = this._detachedCanvasClipPath; + try { + this.history.saveState(); + } finally { + canvas.clipPath = currentClipPath; + this._applyTemporaryObjectClipPaths(this._detachedCanvasClipPath, false); + } + } finally { + transientObjects.forEach(obj => canvas.add(obj)); + this._bringBrushPreviewToFront(); + if (transientObjects.length > 0) this._requestRender(); + } + } + + _requestRender() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } + + _attachCurrentCropClipPath(obj) { + const clipPath = this._getActiveCropClipPath(); + if (!obj || !clipPath) return; + + obj.set('clipPath', this._createClipPathFromSource(clipPath)); + obj.dirty = true; + } + + _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; + } + + /** + * 清除所有马赛克覆盖层 + */ + clearAllMosaics() { + const canvas = this.canvasManager.canvas; + const overlays = canvas.getObjects().filter( + o => o.id && o.id.startsWith('mosaic_') + ); + overlays.forEach(o => canvas.remove(o)); + canvas.renderAll(); + this._saveStateWithCanvasClipPath(); + } + + applyPreset(presetName) { + const presets = { + 'mosaic-light': { mode: 'mosaic', mosaicSize: 6 }, + 'mosaic-standard': { mode: 'mosaic', mosaicSize: 12 }, + 'mosaic-heavy': { mode: 'mosaic', mosaicSize: 24 }, + 'blur-light': { mode: 'blur', blurRadius: 6 }, + 'blur-standard': { mode: 'blur', blurRadius: 12 }, + 'blur-strong': { mode: 'blur', blurRadius: 18 }, + 'mosaic-draw-rect': { drawMode: 'rect' }, + 'mosaic-draw-lasso': { drawMode: 'lasso' }, + 'mosaic-draw-brush': { drawMode: 'brush' }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + if (preset.mode) this.setMode(preset.mode); + if (preset.mosaicSize) this.setMosaicSize(preset.mosaicSize); + if (preset.blurRadius) this.setBlurRadius(preset.blurRadius); + if (preset.drawMode) this.setDrawMode(preset.drawMode); + } + + _getCurrentLayerPresetName() { + if (this.options.mode === 'blur') { + const blurMap = { + 6: '轻模糊', + 12: '中模糊', + 18: '强模糊', + }; + return blurMap[Math.round(Number(this.options.blurRadius))] || ''; + } + + const mosaicMap = { + 6: '轻马赛克', + 12: '中马赛克', + 24: '重马赛克', + }; + return mosaicMap[Math.round(Number(this.options.mosaicSize))] || ''; + } + + // ═══════════════════════════════════════ + // 预设栏 HTML + // ═══════════════════════════════════════ + + getOptionsBarHTML() { + const isLight = this.options.mode === 'mosaic' && this.options.mosaicSize === 6; + const isStandard = this.options.mode === 'mosaic' && this.options.mosaicSize === 12; + const isHeavy = this.options.mode === 'mosaic' && this.options.mosaicSize === 24; + const isLightBlur = this.options.mode === 'blur' && this.options.blurRadius === 6; + const isStandardBlur = this.options.mode === 'blur' && this.options.blurRadius === 12; + const isStrongBlur = this.options.mode === 'blur' && this.options.blurRadius === 18; + const drawMode = this.options.drawMode || 'rect'; + return ` +
+ 选区 + + + +
+
+ + + + + + +
+ `; + } + + getPropertyPanelHTML() { + const effectMode = this.options.mode; + const drawMode = this.options.drawMode || 'rect'; + let html = ` +
马赛克工具
+
+ + +
+
+ + +
+ `; + + if (effectMode === 'mosaic') { + html += ` +
+ + + ${this.options.mosaicSize}px +
+ `; + } else { + html += ` +
+ + + ${this.options.blurRadius}px +
+ `; + } + + if (drawMode === 'brush') { + html += ` +
+ + + ${this.options.brushSize}px +
+ `; + } + + return html; + } + + onToolPropertyChange(key, value) { + switch (key) { + case 'mode': + this.setMode(value); + return true; + case 'drawMode': + this.setDrawMode(value); + return true; + case 'mosaicSize': + this.setMosaicSize(value); + return true; + case 'blurRadius': + this.setBlurRadius(value); + return true; + case 'brushSize': + this.setBrushSize(value); + return true; + default: + return false; + } + } +} + +export default MosaicModule; diff --git a/plugins/Image-Toolbox/core/src/modules/SelectModule.js b/plugins/Image-Toolbox/core/src/modules/SelectModule.js new file mode 100644 index 00000000..d0844fca --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/SelectModule.js @@ -0,0 +1,151 @@ +import BaseModule from './BaseModule.js'; + +/** + * 移动/框选模块 - 保持 Fabric 默认选择行为,并提供常用变换预设。 + */ +class SelectModule extends BaseModule { + activate(options = {}) { + this.active = true; + this.options = { ...this.options, ...options }; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + canvas.selection = true; + canvas.defaultCursor = 'default'; + } + + deactivate() { + this.active = false; + } + + getOptionsBarHTML() { + const targets = this._getTransformTargets(); + const disabled = targets.length === 0 ? ' disabled title="先选中一个图层"' : ''; + const angle = this._getCommonAngle(targets); + const flipX = targets.length > 0 && targets.every(obj => !!obj.flipX); + const flipY = targets.length > 0 && targets.every(obj => !!obj.flipY); + + return ` +
+ + + + +
+
+ + +
+ `; + } + + applyPreset(presetName) { + const targets = this._getTransformTargets(); + if (targets.length === 0) return; + + const rotateMap = { + 'select-rotate-0': 0, + 'select-rotate-90': 90, + 'select-rotate-180': 180, + 'select-rotate-270': 270, + }; + + if (Object.prototype.hasOwnProperty.call(rotateMap, presetName)) { + this._rotateTargets(targets, rotateMap[presetName]); + return; + } + + if (presetName === 'select-flip-x') { + this._flipTargets(targets, 'flipX'); + return; + } + + if (presetName === 'select-flip-y') { + this._flipTargets(targets, 'flipY'); + } + } + + _rotateTargets(targets, angle) { + const nextAngle = this._normalizeAngle(angle); + const changed = targets.some(obj => this._normalizeAngle(obj.angle || 0) !== nextAngle); + if (!changed) return; + + this.history.saveState(); + targets.forEach(obj => this._setObjectTransform(obj, { angle: nextAngle })); + this._refreshActiveSelection(targets); + this._refreshDynamicMosaics(); + this._requestRender(); + } + + _flipTargets(targets, prop) { + this.history.saveState(); + targets.forEach(obj => this._setObjectTransform(obj, { [prop]: !obj[prop] })); + this._refreshActiveSelection(targets); + this._refreshDynamicMosaics(); + this._requestRender(); + } + + _setObjectTransform(obj, props) { + const center = typeof obj.getCenterPoint === 'function' ? obj.getCenterPoint() : null; + obj.set(props); + + if (center && typeof obj.setPositionByOrigin === 'function') { + obj.setPositionByOrigin(center, 'center', 'center'); + } + + obj.dirty = true; + obj.setCoords(); + } + + _refreshActiveSelection(targets) { + const canvas = this.canvasManager.canvas; + const active = canvas?.getActiveObject(); + if (!canvas || active?.type !== 'activeSelection' || targets.length < 2) return; + if (typeof fabric === 'undefined' || !fabric.ActiveSelection) return; + + canvas.discardActiveObject(); + const selection = new fabric.ActiveSelection(targets, { canvas }); + canvas.setActiveObject(selection); + } + + _refreshDynamicMosaics() { + this.canvasManager.refreshDynamicMosaics?.({ render: false }); + } + + _getTransformTargets() { + const canvas = this.canvasManager.canvas; + const active = canvas?.getActiveObject(); + if (!active) return []; + + if (active.type === 'activeSelection' && typeof active.getObjects === 'function') { + return active.getObjects().filter(obj => !obj.excludeFromHistory); + } + + return active.excludeFromHistory ? [] : [active]; + } + + _getCommonAngle(targets) { + if (targets.length === 0) return null; + + const first = this._normalizeAngle(targets[0].angle || 0); + return targets.every(obj => this._normalizeAngle(obj.angle || 0) === first) ? first : null; + } + + _normalizeAngle(angle) { + const number = parseFloat(angle) || 0; + return ((Math.round(number) % 360) + 360) % 360; + } + + _requestRender() { + const canvas = this.canvasManager.canvas; + if (!canvas) return; + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } + } +} + +export default SelectModule; diff --git a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js new file mode 100644 index 00000000..c3d7550b --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js @@ -0,0 +1,746 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; + +/** + * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、直线、箭头等多种图形 + */ +class ShapeModule extends BaseModule { + static SHAPE_OPTIONS = [ + { type: 'rect', preset: 'shape-type-rect', label: '矩形', icon: '' }, + { type: 'triangle', preset: 'shape-type-triangle', label: '三角形', icon: '' }, + { type: 'circle', preset: 'shape-type-circle', label: '椭圆', icon: '' }, + { 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: '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: '' }, + ]; + + static SHAPE_STYLE_PRESETS = [ + { preset: 'shape-style-outline', label: '仅描边', fill: 'transparent', fillOpacity: 0, stroke: '#d83b31', strokeOpacity: 100 }, + { preset: 'shape-style-red', label: '标注红', fill: '#d83b31', fillOpacity: 22, stroke: '#d83b31', strokeOpacity: 100 }, + { preset: 'shape-style-blue', label: '标注蓝', fill: '#1677ff', fillOpacity: 20, stroke: '#1677ff', strokeOpacity: 100 }, + { preset: 'shape-style-orange', label: '警示橙', fill: '#ff7a00', fillOpacity: 22, stroke: '#ff7a00', strokeOpacity: 100 }, + { preset: 'shape-style-yellow', label: '标题黄', fill: '#ffd700', fillOpacity: 28, stroke: '#b7791f', strokeOpacity: 100 }, + { preset: 'shape-style-green', label: '强调绿', fill: '#2ead4a', fillOpacity: 20, stroke: '#2ead4a', strokeOpacity: 100 }, + { preset: 'shape-style-purple', label: '重点紫', fill: '#8b5cf6', fillOpacity: 20, stroke: '#8b5cf6', strokeOpacity: 100 }, + { preset: 'shape-style-black', label: '黑白框', fill: '#111111', fillOpacity: 12, stroke: '#111111', strokeOpacity: 100 }, + { preset: 'shape-style-white', label: '白描边', fill: '#ffffff', fillOpacity: 36, stroke: '#ffffff', strokeOpacity: 100 }, + ]; + + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + shapeType: 'rect', + fill: 'transparent', + stroke: 'rgba(216, 59, 49, 1)', + strokeWidth: 2, + ...defaultOptions, + }); + + this._isDrawing = false; + this._startPoint = null; + this._currentShape = null; + this._previewShape = null; + this._savedBeforeShape = false; + + this._boundMouseDown = this._onMouseDown.bind(this); + this._boundMouseMove = this._onMouseMove.bind(this); + this._boundMouseUp = this._onMouseUp.bind(this); + this._boundMouseOut = this._onMouseOut.bind(this); + } + + activate(options = {}) { + super.activate(options); + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + 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); + + eventBus.emit('module:activated', 'shape'); + } + + 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); + this._removePreviewShape(); + this._isDrawing = false; + this._startPoint = null; + this._currentShape = null; + this._savedBeforeShape = false; + } + + super.deactivate(); + } + + setShapeType(type) { + if (['rect', 'triangle', 'circle', 'star', 'heart', 'trapezoid', 'line', 'arrow', 'double-arrow'].includes(type)) { + this.options.shapeType = type; + } + } + + setFill(fill) { + const normalized = this._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); + this.options.stroke = this._hasExplicitOpacity(normalized) + ? normalized + : this._withColorOpacity(normalized, this._getColorOpacity(this.options.stroke)); + } + + setStrokeWidth(width) { + const parsed = parseInt(width, 10); + this.options.strokeWidth = this._clamp(Number.isFinite(parsed) ? parsed : this.options.strokeWidth, 1, 20); + } + + setFillOpacity(opacity) { + this.options.fill = this._withColorOpacity(this.options.fill, this._parseOpacity(opacity)); + } + + setStrokeOpacity(opacity) { + this.options.stroke = this._withColorOpacity(this.options.stroke, this._parseOpacity(opacity)); + } + + applyPreset(presetName) { + const stylePreset = ShapeModule.SHAPE_STYLE_PRESETS.find(item => item.preset === presetName); + if (stylePreset) { + this.options.fill = this._getPresetColor(stylePreset, 'fill'); + this.options.stroke = this._getPresetColor(stylePreset, 'stroke'); + return; + } + + const presets = { + 'shape-type-rect': { shapeType: 'rect' }, + 'shape-type-triangle': { shapeType: 'triangle' }, + 'shape-type-circle': { shapeType: 'circle' }, + 'shape-type-star': { shapeType: 'star' }, + 'shape-type-heart': { shapeType: 'heart' }, + 'shape-type-trapezoid': { shapeType: 'trapezoid' }, + 'shape-type-line': { shapeType: 'line' }, + 'shape-type-arrow': { shapeType: 'arrow' }, + 'shape-type-double-arrow': { shapeType: 'double-arrow' }, + 'shape-width-thin': { strokeWidth: 1 }, + 'shape-width-medium': { strokeWidth: 2 }, + 'shape-width-thick': { strokeWidth: 4 }, + 'shape-width-heavy': { strokeWidth: 6 }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + if (preset.fill !== undefined) this.setFill(preset.fill); + if (preset.stroke !== undefined) this.setStroke(preset.stroke); + if (preset.strokeWidth !== undefined) this.setStrokeWidth(preset.strokeWidth); + if (preset.shapeType !== undefined) this.setShapeType(preset.shapeType); + } + + getOptionsBarHTML() { + const shapeType = this.options.shapeType; + const currentShape = ShapeModule.SHAPE_OPTIONS.find(item => item.type === shapeType) || ShapeModule.SHAPE_OPTIONS[0]; + const strokeWidth = this.options.strokeWidth; + const colorPresets = ShapeModule.SHAPE_STYLE_PRESETS.map(item => { + const fill = this._getPresetColor(item, 'fill'); + const stroke = this._getPresetColor(item, 'stroke'); + return ` + + `; + }).join(''); + + return ` +
+ +
+
+ ${colorPresets} +
+
+ + + + +
+ `; + } + + _isStylePresetActive(preset) { + return this._normalizeComparableColor(this.options.fill) === this._normalizeComparableColor(this._getPresetColor(preset, 'fill')) + && this._normalizeComparableColor(this.options.stroke) === this._normalizeComparableColor(this._getPresetColor(preset, 'stroke')); + } + + _getPresetColor(preset, prop) { + const opacity = this._parseOpacity(preset[`${prop}Opacity`]); + return this._withColorOpacity(preset[prop], opacity); + } + + getShapePickerHTML() { + const shapeType = this.options.shapeType; + const items = ShapeModule.SHAPE_OPTIONS.map(item => ` + + `).join(''); + + return ` + + `; + } + + getPropertyShapePickerHTML() { + const shapeType = this.options.shapeType; + return ShapeModule.SHAPE_OPTIONS.map(item => ` + + `).join(''); + } + + getPropertyPanelHTML() { + return ` +
图形工具
+
+ +
+ ${this.getPropertyShapePickerHTML()} +
+
+
+ + +
+
+ + + ${this._getColorOpacityPercent(this.options.fill)}% +
+
+ + +
+
+ + + ${this._getColorOpacityPercent(this.options.stroke)}% +
+
+ + + ${this.options.strokeWidth}px +
+
拖拽鼠标绘制图形,支持矩形、三角形、椭圆、星星、心形等。
+ `; + } + + onToolPropertyChange(key, value) { + switch (key) { + case 'fill': + this.setFill(value); + return true; + case 'stroke': + this.setStroke(value); + return true; + case 'fillOpacity': + this.setFillOpacity(value); + return true; + case 'strokeOpacity': + this.setStrokeOpacity(value); + return true; + case 'strokeWidth': + this.setStrokeWidth(value); + return true; + default: + return false; + } + } + + _onMouseDown(e) { + if (e.button !== 0) return; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const pointer = canvas.getPointer(e); + this._isDrawing = true; + this._startPoint = { x: pointer.x, y: pointer.y }; + this.history.saveState(); + this._savedBeforeShape = true; + } + + _onMouseMove(e) { + if (!this._isDrawing || !this._startPoint) return; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const pointer = canvas.getPointer(e); + const endPoint = { x: pointer.x, y: pointer.y }; + + // 移除旧的预览形状 + this._removePreviewShape(); + + // 创建新的预览形状 + const shape = this._createShape(this._startPoint, endPoint); + if (shape) { + this._previewShape = shape; + canvas.add(shape); + canvas.renderAll(); + } + } + + _onMouseUp(e) { + if (!this._isDrawing || !this._startPoint) return; + + const canvas = this.canvasManager.canvas; + if (!canvas) return; + + const pointer = canvas.getPointer(e); + const endPoint = { x: pointer.x, y: pointer.y }; + + // 移除预览形状 + this._removePreviewShape(); + + // 创建最终形状 + const shape = this._createShape(this._startPoint, endPoint); + if (shape) { + // 检查形状是否足够大 + if (this._isShapeLargeEnough(this._startPoint, endPoint)) { + shape.set({ + id: 'shape_' + Date.now(), + selectable: false, + evented: false, + _layerKind: 'shape', + _layerShapeType: this.options.shapeType, + }); + canvas.add(shape); + canvas.renderAll(); + eventBus.emit('canvas:objectMetadataChanged', shape); + } + } + + this._isDrawing = false; + this._startPoint = null; + this._currentShape = null; + this._savedBeforeShape = false; + } + + _onMouseOut(e) { + if (this._isDrawing) { + this._removePreviewShape(); + this.canvasManager.canvas?.renderAll(); + } + } + + _isShapeLargeEnough(startPoint, endPoint) { + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + if (['line', 'arrow', 'double-arrow'].includes(this.options.shapeType)) { + return Math.sqrt(width * width + height * height) > 5; + } + + return width > 5 && height > 5; + } + + _createShape(startPoint, endPoint) { + const type = this.options.shapeType; + const left = Math.min(startPoint.x, endPoint.x); + const top = Math.min(startPoint.y, endPoint.y); + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + const commonProps = { + fill: this.options.fill, + stroke: this.options.stroke, + strokeWidth: this.options.strokeWidth, + selectable: false, + evented: false, + }; + + switch (type) { + case 'rect': + return new fabric.Rect({ + left, + top, + width, + height, + ...commonProps, + }); + + case 'triangle': + return this._createTriangle(left, top, width, height, commonProps); + + case 'circle': + return new fabric.Ellipse({ + left: left + width / 2, + top: top + height / 2, + rx: width / 2, + ry: height / 2, + originX: 'center', + originY: 'center', + ...commonProps, + }); + + case 'star': + return this._createStar(left + width / 2, top + height / 2, width, height, commonProps); + + case 'heart': + return this._createHeart(left + width / 2, top + height / 2, width, height, commonProps); + + case 'trapezoid': + return this._createTrapezoid(left, top, width, height, commonProps); + + case 'line': + return this._createLine(startPoint, endPoint, commonProps); + + case 'arrow': + return this._createArrow(startPoint, endPoint, commonProps); + + case 'double-arrow': + return this._createDoubleArrow(startPoint, endPoint, commonProps); + + default: + return null; + } + } + + _createStar(cx, cy, width, height, props) { + const points = []; + const spikes = 5; + const outerX = width / 2; + const outerY = height / 2; + const innerX = outerX * 0.42; + const innerY = outerY * 0.42; + + for (let i = 0; i < spikes * 2; i++) { + const angle = (i * Math.PI) / spikes - Math.PI / 2; + const radiusX = i % 2 === 0 ? outerX : innerX; + const radiusY = i % 2 === 0 ? outerY : innerY; + points.push({ x: radiusX * Math.cos(angle), y: radiusY * Math.sin(angle) }); + } + + const path = new fabric.Polygon(points, { + ...props, + left: cx, + top: cy, + originX: 'center', + originY: 'center', + strokeLineJoin: 'round', + }); + + return path; + } + + _createHeart(cx, cy, width, height, props) { + const pathData = `M 50 96 + C 17 68 4 55 4 34 + C 4 17 17 5 32 5 + C 41 5 47 10 50 18 + C 53 10 59 5 68 5 + C 83 5 96 17 96 34 + C 96 55 83 68 50 96 Z`; + + const heart = new fabric.Path(pathData, { + ...props, + left: cx, + top: cy, + originX: 'center', + originY: 'center', + scaleX: width / 100, + scaleY: height / 100, + }); + + return heart; + } + + _createTrapezoid(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const topInset = width * 0.22; + const points = [ + { x: -width / 2 + topInset, y: -height / 2 }, + { x: width / 2 - topInset, y: -height / 2 }, + { x: width / 2, y: height / 2 }, + { x: -width / 2, 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; + const points = [ + { x: 0, y: -height / 2 }, + { x: width / 2, y: height / 2 }, + { x: -width / 2, y: height / 2 }, + ]; + + 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, + stroke: props.stroke, + strokeWidth: props.strokeWidth, + fill: null, + strokeLineCap: 'round', + }); + } + + _createArrow(startPoint, endPoint, props) { + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 10) return null; + + const angle = Math.atan2(dy, dx); + const headLength = Math.min(Math.max(distance * 0.25, 10), 28); + const headAngle = Math.PI / 7; + const headPointA = { + x: endPoint.x - headLength * Math.cos(angle - headAngle), + y: endPoint.y - headLength * Math.sin(angle - headAngle), + }; + const headPointB = { + x: endPoint.x - headLength * Math.cos(angle + headAngle), + y: endPoint.y - headLength * Math.sin(angle + headAngle), + }; + const pathData = `M ${startPoint.x} ${startPoint.y} L ${endPoint.x} ${endPoint.y} + M ${headPointA.x} ${headPointA.y} L ${endPoint.x} ${endPoint.y} L ${headPointB.x} ${headPointB.y}`; + + return new fabric.Path(pathData, { + ...props, + fill: null, + stroke: props.stroke, + strokeWidth: props.strokeWidth, + strokeLineCap: 'round', + strokeLineJoin: 'round', + }); + } + + _createDoubleArrow(startPoint, endPoint, props) { + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 10) return null; + + const angle = Math.atan2(dy, dx); + const headLength = Math.min(Math.max(distance * 0.25, 10), 28); + const headAngle = Math.PI / 7; + + const headPointA1 = { + x: endPoint.x - headLength * Math.cos(angle - headAngle), + y: endPoint.y - headLength * Math.sin(angle - headAngle), + }; + const headPointA2 = { + x: endPoint.x - headLength * Math.cos(angle + headAngle), + y: endPoint.y - headLength * Math.sin(angle + headAngle), + }; + const reverseAngle = angle + Math.PI; + const headPointB1 = { + x: startPoint.x - headLength * Math.cos(reverseAngle - headAngle), + y: startPoint.y - headLength * Math.sin(reverseAngle - headAngle), + }; + const headPointB2 = { + x: startPoint.x - headLength * Math.cos(reverseAngle + headAngle), + y: startPoint.y - headLength * Math.sin(reverseAngle + headAngle), + }; + + const pathData = `M ${startPoint.x} ${startPoint.y} L ${endPoint.x} ${endPoint.y} + M ${headPointA1.x} ${headPointA1.y} L ${endPoint.x} ${endPoint.y} L ${headPointA2.x} ${headPointA2.y} + M ${headPointB1.x} ${headPointB1.y} L ${startPoint.x} ${startPoint.y} L ${headPointB2.x} ${headPointB2.y}`; + + return new fabric.Path(pathData, { + ...props, + fill: null, + stroke: props.stroke, + strokeWidth: props.strokeWidth, + strokeLineCap: 'round', + strokeLineJoin: 'round', + }); + } + + _removePreviewShape() { + if (!this._previewShape) return; + + const canvas = this.canvasManager.canvas; + if (canvas) { + canvas.remove(this._previewShape); + } + this._previewShape = null; + } + + _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; + } + + _normalizeComparableColor(color) { + return String(color ?? '').replace(/\s+/g, '').toLowerCase(); + } + + _hasExplicitOpacity(color) { + const value = String(color ?? '').trim().toLowerCase(); + return value === 'transparent' || value.startsWith('rgba'); + } + + _parseOpacity(value) { + const parsed = parseInt(value, 10); + return this._clamp(Number.isFinite(parsed) ? parsed : 100, 0, 100) / 100; + } + + _getColorOpacityPercent(color) { + return Math.round(this._getColorOpacity(color) * 100); + } + + _getColorOpacity(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return 0; + + const rgba = value.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)$/i); + if (rgba) { + const alpha = parseFloat(rgba[1]); + return this._clamp(Number.isFinite(alpha) ? alpha : 1, 0, 1); + } + + return 1; + } + + _withColorOpacity(color, opacity) { + const alpha = this._clamp(Number.isFinite(opacity) ? opacity : 1, 0, 1); + const rgb = this._extractRgb(color); + + if (!rgb) { + return alpha === 0 ? 'transparent' : color; + } + + if (alpha === 0 && String(color ?? '').trim().toLowerCase() === 'transparent') { + return 'transparent'; + } + + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this._formatAlpha(alpha)})`; + } + + _extractRgb(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return { r: 0, g: 0, b: 0 }; + + if (/^#[0-9a-f]{6}$/i.test(value)) { + return { + r: parseInt(value.slice(1, 3), 16), + g: parseInt(value.slice(3, 5), 16), + b: parseInt(value.slice(5, 7), 16), + }; + } + + if (/^#[0-9a-f]{3}$/i.test(value)) { + return { + r: parseInt(value[1] + value[1], 16), + g: parseInt(value[2] + value[2], 16), + b: parseInt(value[3] + value[3], 16), + }; + } + + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return { + r: this._clamp(parseInt(rgb[1], 10), 0, 255), + g: this._clamp(parseInt(rgb[2], 10), 0, 255), + b: this._clamp(parseInt(rgb[3], 10), 0, 255), + }; + } + + return null; + } + + _formatAlpha(alpha) { + return String(Math.round(alpha * 100) / 100); + } + + _extractHexColor(color) { + if (typeof color !== 'string') return '#000000'; + + // 如果已经是 hex 格式,直接返回 + if (/^#[0-9a-f]{6}$/i.test(color)) return color; + + // 从 rgba 中提取 + const rgbaMatch = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/); + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1], 10).toString(16).padStart(2, '0'); + const g = parseInt(rgbaMatch[2], 10).toString(16).padStart(2, '0'); + const b = parseInt(rgbaMatch[3], 10).toString(16).padStart(2, '0'); + return `#${r}${g}${b}`; + } + + return '#000000'; + } + + _clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + _escapeAttr(value) { + return String(value ?? '').replace(/[&<>"]/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + }[ch])); + } +} + +export default ShapeModule; diff --git a/plugins/Image-Toolbox/core/src/modules/TextModule.js b/plugins/Image-Toolbox/core/src/modules/TextModule.js new file mode 100644 index 00000000..d0b88286 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/modules/TextModule.js @@ -0,0 +1,451 @@ +import BaseModule from './BaseModule.js'; +import eventBus from '../EventBus.js'; +import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../utils/fonts.js'; + +/** + * 加字模块 — 在图片上添加文字标注 + * 使用 fabric.IText 支持双击编辑 + */ +class TextModule extends BaseModule { + constructor(canvasManager, historyManager, defaultOptions = {}) { + super(canvasManager, historyManager, { + fontFamily: 'Microsoft YaHei, PingFang SC, sans-serif', + fontSize: 24, + fill: '#d83b31', + stroke: null, + strokeWidth: 0, + strokePosition: 'outside', + fontWeight: 'normal', + fontStyle: 'normal', + underline: false, + textAlign: 'left', + ...defaultOptions, + }); + + this._boundMouseDown = this._onMouseDown.bind(this); + } + + activate(options = {}) { + super.activate(options); // 禁用所有对象交互 + const canvas = this.canvasManager.canvas; + + // 恢复文字对象的交互性(允许点击已有文字进行编辑) + canvas.getObjects().forEach(obj => { + if (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox') { + obj.set({ selectable: true, evented: true }); + } + }); + + canvas.defaultCursor = 'text'; + canvas.on('mouse:down', this._boundMouseDown); + eventBus.emit('module:activated', 'text'); + } + + deactivate() { + const canvas = this.canvasManager.canvas; + canvas.off('mouse:down', this._boundMouseDown); + + // 提交所有正在编辑的文字 + canvas.getObjects().forEach(obj => { + if (obj.isEditing) { + obj.exitEditing(); + } + }); + + super.deactivate(); // 恢复所有对象交互 + } + + /** + * 点击位置添加文字 + */ + addTextOnClick(x, y) { + this.addText('双击编辑', x, y); + } + + /** + * 在指定位置添加文字 + * @param {string} text + * @param {number} x + * @param {number} y + * @returns {fabric.IText} + */ + addText(text, x, y) { + const canvas = this.canvasManager.canvas; + const opts = this.options; + + const textObj = new fabric.IText(text, { + left: x, + top: y, + fontFamily: opts.fontFamily, + fontSize: opts.fontSize, + fill: opts.fill, + stroke: opts.stroke, + strokeWidth: opts.strokeWidth, + paintFirst: opts.strokePosition === 'inside' ? 'fill' : 'stroke', + _strokePosition: opts.strokePosition || 'outside', + fontWeight: opts.fontWeight, + fontStyle: opts.fontStyle, + underline: opts.underline, + textAlign: opts.textAlign, + editable: true, + id: 'text_' + Date.now(), + }); + + canvas.add(textObj); + canvas.setActiveObject(textObj); + canvas.renderAll(); + recordFontUsage(opts.fontFamily); + + // 进入编辑模式 + setTimeout(() => { + textObj.enterEditing(); + textObj.selectAll(); + }, 50); + + this.history.saveState(); + return textObj; + } + + // ── 样式设置 ── + + setFontFamily(family) { + this.options.fontFamily = family; + this._updateActiveTextStyle('fontFamily', family); + recordFontUsage(family); + } + + setFontSize(size) { + this.options.fontSize = parseInt(size); + this._updateActiveTextStyle('fontSize', parseInt(size)); + } + + setFontWeight(weight) { + this.options.fontWeight = weight; + this._updateActiveTextStyle('fontWeight', weight); + } + + setFontStyle(style) { + this.options.fontStyle = style; + this._updateActiveTextStyle('fontStyle', style); + } + + setTextColor(color) { + this.options.fill = color; + this._updateActiveTextStyle('fill', color); + } + + setStroke(color, width) { + this.options.stroke = color; + this.options.strokeWidth = width; + this._updateActiveTextStyle('stroke', color); + this._updateActiveTextStyle('strokeWidth', width); + } + + setStrokePosition(position) { + this.options.strokePosition = position; + this._updateActiveTextStyle('paintFirst', position === 'inside' ? 'fill' : 'stroke'); + } + + setUnderline(underline) { + this.options.underline = underline; + this._updateActiveTextStyle('underline', underline); + } + + setBackgroundColor(color) { + this._updateActiveTextStyle('backgroundColor', color); + } + + /** + * 应用文字预设样式 + * @param {string} presetName + */ + applyPreset(presetName) { + const presets = { + red: { + fill: '#d83b31', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + blue: { + fill: '#1677FF', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + pink: { + fill: '#FF4FA3', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + purple: { + fill: '#8B5CF6', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + white: { + fill: '#FFFFFF', + fontWeight: 'normal', + stroke: null, + strokeWidth: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + yellow: { + fill: '#FFD700', + fontWeight: 'bold', + stroke: '#000000', + strokeWidth: 2, + }, + black: { + fill: '#111111', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + outline: { + fill: '#FFFFFF', + fontWeight: 'bold', + stroke: '#000000', + strokeWidth: 3, + }, + orange: { + fill: '#FF7A00', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + green: { + fill: '#2EAD4A', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + }; + + const preset = presets[presetName]; + if (!preset) return; + + Object.entries(preset).forEach(([key, value]) => { + this.options[key] = value; + this._updateActiveTextStyle(key, value); + }); + } + + // ── 内部 ── + + _updateActiveTextStyle(prop, value) { + const active = this.canvasManager.getActiveObject(); + if (active && (active.type === 'i-text' || active.type === 'text' || active.type === 'textbox')) { + active.set(prop, value); + this.canvasManager.canvas.renderAll(); + } + } + + _onMouseDown(e) { + const canvas = this.canvasManager.canvas; + const pointer = canvas.getPointer(e.e); + + // 检查是否点到了已有的文字 + const target = e.target; + if (target && (target.type === 'i-text' || target.type === 'text')) { + // 允许选择和编辑已有文字 + return; + } + + // 添加新文字 + this.addTextOnClick(pointer.x, pointer.y); + } + + // ── 选项栏 ── + + getOptionsBarHTML() { + return ` +
+ + + + + + + + + +
+ `; + } + + // ── 属性面板 ── + + getPropertyPanelHTML() { + const active = this.canvasManager.getActiveObject(); + if (!active || (active.type !== 'i-text' && active.type !== 'text' && active.type !== 'textbox')) { + const opts = this.options; + return ` +
文字工具默认值
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
这些设置会用于接下来新增的文字。
+ `; + } + + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + + ${Math.round(active.opacity * 100)}% +
+ `; + } + + onPropertyChange(key, value) { + const active = this.canvasManager.getActiveObject(); + if (!active) return; + + switch (key) { + case 'fontSize': + active.set('fontSize', parseInt(value)); + break; + case 'fill': + active.set('fill', value); + break; + case 'stroke': + active.set('stroke', value); + break; + case 'strokePosition': + active.set('paintFirst', value === 'inside' ? 'fill' : 'stroke'); + active.set('_strokePosition', value); + break; + case 'opacity': + // value 已被 _handleInput 转换为 0-1 区间,直接使用 + active.set('opacity', value); + break; + } + + this.canvasManager.canvas.renderAll(); + } + + onToolPropertyChange(key, value, context = {}) { + switch (key) { + case 'fontFamily': + this.options.fontFamily = value; + if (context.eventType === 'change') recordFontUsage(value); + break; + case 'fontSize': + this.options.fontSize = Math.max(8, parseInt(value, 10) || this.options.fontSize); + break; + case 'fill': + this.options.fill = value; + break; + case 'stroke': + this.options.stroke = value; + break; + case 'strokeWidth': + this.options.strokeWidth = Math.max(0, parseInt(value, 10) || 0); + break; + case 'strokePosition': + this.options.strokePosition = value; + break; + case 'fontWeight': + this.options.fontWeight = value ? 'bold' : 'normal'; + break; + case 'fontStyle': + this.options.fontStyle = value ? 'italic' : 'normal'; + break; + case 'underline': + this.options.underline = !!value; + break; + case 'textAlign': + this.options.textAlign = value; + break; + default: + return false; + } + + return true; + } + + _getFontOptionsHTML(current) { + if (isSystemFontsLoaded()) { + return getFontOptionsHTML(current); + } + + // 异步加载字体,完成后触发属性面板更新 + onSystemFontsLoaded(() => { + eventBus.emit('tool:propertiesChanged'); + }); + + return ''; + } + + _getSelectOption(value, label, current) { + const selected = this._normalizeValue(value) === this._normalizeValue(current) ? ' selected' : ''; + return ``; + } + + _normalizeValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + } +} + +export default TextModule; diff --git a/plugins/Image-Toolbox/core/src/runtime/fabric.js b/plugins/Image-Toolbox/core/src/runtime/fabric.js new file mode 100644 index 00000000..1421fdde --- /dev/null +++ b/plugins/Image-Toolbox/core/src/runtime/fabric.js @@ -0,0 +1,8 @@ +// Fabric/browser runtime entry. +// This entry intentionally contains DOM and Fabric.js dependent managers/modules. + +export { default as eventBus, EventBus } from '../EventBus.js'; +export { default as CanvasManager } from '../CanvasManager.js'; +export { default as LayerManager } from '../LayerManager.js'; +export { default as HistoryManager } from '../HistoryManager.js'; +export { default as ToolManager } from '../ToolManager.js'; diff --git a/plugins/Image-Toolbox/core/src/updateRecords.js b/plugins/Image-Toolbox/core/src/updateRecords.js new file mode 100644 index 00000000..f14155ea --- /dev/null +++ b/plugins/Image-Toolbox/core/src/updateRecords.js @@ -0,0 +1,153 @@ +export const updateRecords = [ + { + version: '2.2.1', + date: '2026-06-26', + changes: { + added: [ + { text: '图形工具新增三角形和双箭头图形', platforms: null }, + { text: '文字描边新增位置参数,支持外部和内部两种描边位置', platforms: null } + ], + fixed: [ + { text: '修复橡皮擦工具激活时切换图层后,橡皮擦仍作用在原图层的问题', platforms: null }, + { text: '修复首次进入文字工具时界面卡顿数秒的问题,改为异步加载系统字体列表', platforms: null }, + { text: '修复撤销/重做时历史记录损坏导致画布状态异常的问题', platforms: null }, + { text: '修复切换/停用工具时图层被意外解锁的问题', platforms: null } + ], + improved: [], + adjusted: [], + removed: [] + } + }, + { + version: '2.2', + date: '2026-06-23', + changes: { + added: [ + { text: '新增 ZTools 客户端,可按 ZTools 插件规范独立加载图片工具箱', platforms: ['ztools'] }, + { text: '新增图形工具,支持绘制矩形、圆形、星星、心形、梯形、直线、箭头等多种图形', platforms: null }, + { text: '图形工具支持自定义填充色、边框色、边框宽度,拖拽绘制即可创建图形', platforms: null }, + { text: '图形工具组新增属性面板入口,支持在预设栏和属性面板同步切换图形', platforms: null }, + { text: '图形属性栏支持分别设置填充不透明度和描边不透明度', platforms: null } + ], + fixed: [ + { text: '修复马赛克图层旋转后马赛克范围不准确的问题', platforms: null }, + { text: '修复马赛克图层拉伸后马赛克块大小被错误拉伸或压缩的问题', platforms: null }, + { text: '修复星形、心形、梯形、箭头等图形绘制偏移、畸形或显示不准确的问题', platforms: null }, + { text: '修复直线和箭头在水平或垂直拖拽时可能无法创建的问题', platforms: null } + ], + improved: [ + { text: '优化图形工具组选型窗口,改用 SVG 缩略图显示真实图形效果', platforms: null }, + { text: '优化图形预设栏颜色,合并填充色和边框色为一套样式预设并改为上图案下文字布局', platforms: null }, + { text: '图形颜色预设会同时应用填充不透明度和描边不透明度', platforms: null }, + { text: '优化橡皮擦工具,拖动过程中实时显示擦除效果', platforms: null } + ], + adjusted: [ + { text: '图形工具将仅描边预设调整为首位并作为默认样式', platforms: null } + ], + removed: [] + } + }, + { + version: '2.1.1', + date: '2026-06-15', + changes: { + added: [ + { text: '马赛克工具新增自由选区模式,支持非矩形区域马赛克/模糊', platforms: null } + ], + fixed: [ + { text: '修复橡皮擦擦除画笔图层后,图层名称被错误重置为马赛克的问题', platforms: null } + ], + improved: [ + { text: '优化马赛克画笔模式,鼠标悬停/涂抹时显示画笔位置,涂抹过程中实时显示马赛克效果', platforms: null }, + { text: '优化图层默认名称展示,文字/画笔/马赛克图层会按内容和预设生成更清晰的名称', platforms: null } + ], + adjusted: [ + { text: '马赛克图层改为动态重算,移动图层时会根据新的下方内容重新生成效果', platforms: null }, + { text: '马赛克工具默认预设调整为矩形 + 中马赛克', platforms: null } + ], + removed: [] + } + }, + { + version: '2.1', + date: '2026-06-13', + changes: { + added: [ + { text: '新增画笔工具,支持自由涂鸦、颜色预设和粗细调整', platforms: null }, + { text: '新增橡皮擦工具,支持大小预设和撤销/重做', platforms: null }, + { text: '新增非矩形裁剪工具', platforms: null }, + { text: '文字字体列表支持读取并显示用户系统中已安装的字体', platforms: null }, + { text: '移动/框选预设栏新增旋转0°/90/180/270与左右/前后翻转快捷操作', platforms: null } + ], + fixed: [], + improved: [ + { text: '优化了裁剪工具的使用体验', platforms: null }, + { text: '字体列表按用户实际使用频率自动排序', platforms: null } + ], + adjusted: [], + removed: [] + } + }, + { + version: '2.0', + date: '2026-06-12', + changes: { + added: [ + { text: '右侧面板新增切换状态,可切换tab布局或上下布局', platforms: null }, + { text: '新增"预设栏"和"状态栏"位置切换功能', platforms: null }, + { text: '新增属性/图层面板位置切换,可将侧栏移到左侧', platforms: null } + ], + fixed: [ + { text: '修复首次裁剪后再次剪切时,裁剪框被上一轮裁剪范围裁掉的问题', platforms: null }, + { text: '修复第二次裁剪被错误重置为原图范围、未基于首次裁剪继续裁剪的问题', platforms: null }, + { text: '修复旋转裁剪框后应用剪切仍按未旋转矩形生效的问题', platforms: null }, + { text: '修复裁剪后马赛克拖选框被错误裁掉、不能显示到图像外的问题', platforms: null } + ], + improved: [], + adjusted: [ + { text: '将深色浅色切换调整到了"设置"页面', platforms: null }, + { text: '将"选项栏"与"属性栏"合并', platforms: null }, + { text: '原"选项栏"调整为"预设栏"', platforms: null } + ], + removed: [ + { text: '移除了"剪切"工具属性的异常参数', platforms: null } + ] + } + }, + { + version: '1.0', + date: '2026-06-10', + changes: { + added: [ + { text: '发布了第一个可用版本,支持图片导入、马赛克、裁切、文字标注和导出', platforms: null }, + { text: '搭建五区编辑器布局:工具栏、选项栏、画布区、属性/图层面板和状态栏', platforms: null } + ], + fixed: [], + improved: [], + adjusted: [], + removed: [] + } + } +]; + +export const updateCategories = [ + { key: 'added', title: '新增' }, + { key: 'fixed', title: '修复' }, + { key: 'improved', title: '优化' }, + { key: 'adjusted', title: '调整' }, + { key: 'removed', title: '去除' } +]; + +/** + * 平台常量 + * null: 所有平台通用 + * ['utools']: 仅 uTools + * ['ztools']: 仅 ZTools + * ['utools', 'ztools']: uTools 和 ZTools + */ +export const PLATFORMS = { + ALL: null, // 所有平台 + UTOOLS: 'utools', // uTools 专用 + ZTOOLS: 'ztools', // ZTools 专用 + LOCAL: 'local', // 本地环境 +}; diff --git a/plugins/Image-Toolbox/core/src/utils/constants.js b/plugins/Image-Toolbox/core/src/utils/constants.js new file mode 100644 index 00000000..4db14735 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/constants.js @@ -0,0 +1,99 @@ +/** + * 常量定义 + */ + +// 画布默认配置 +export const CANVAS_DEFAULTS = { + WIDTH: 800, + HEIGHT: 600, + BACKGROUND_COLOR: '#d2d6d9', + PRESERVE_OBJECT_STACKING: true, + SELECTION: true, + STOP_CONTEXT_MENU: true, + FIRE_RIGHT_CLICK: true, +}; + +// 马赛克默认值 +export const MOSAIC_DEFAULTS = { + MODE: 'mosaic', + DRAW_MODE: 'rect', + MOSAIC_SIZE: 12, + BLUR_RADIUS: 8, + BRUSH_SIZE: 20, + MIN_MOSAIC_SIZE: 2, + MAX_MOSAIC_SIZE: 50, + MIN_BLUR_RADIUS: 1, + MAX_BLUR_RADIUS: 30, + MIN_BRUSH_SIZE: 4, + MAX_BRUSH_SIZE: 100, +}; + +// 文字默认样式 +export const TEXT_DEFAULTS = { + FONT_FAMILY: 'Microsoft YaHei, PingFang SC, sans-serif', + FONT_SIZE: 24, + FILL: '#d83b31', + STROKE: null, + STROKE_WIDTH: 0, + FONT_WEIGHT: 'normal', + FONT_STYLE: 'normal', + TEXT_ALIGN: 'left', +}; + +// 历史记录 +export const HISTORY_MAX_STEPS = 30; + +// 缩放范围 +export const ZOOM = { + MIN: 0.1, + MAX: 5.0, + STEP: 0.1, + DEFAULT: 1, +}; + +// 裁剪 +export const CROP_DEFAULTS = { + ASPECT_RATIO: null, + SHAPE: 'rect', +}; + +// 文字预设样式 +export const TEXT_PRESETS = { + red: { + fill: '#d83b31', + fontWeight: 'bold', + stroke: '#FFFFFF', + strokeWidth: 2, + }, + white: { + fill: '#FFFFFF', + fontWeight: 'normal', + stroke: null, + strokeWidth: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + yellow: { + fill: '#FFD700', + fontWeight: 'bold', + stroke: '#000000', + strokeWidth: 2, + }, +}; + +// 支持的图片格式 +export const SUPPORTED_FORMATS = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'svg']; + +// 导出格式 +export const EXPORT_FORMATS = { + png: { label: 'PNG', mime: 'image/png', extension: 'png' }, + jpeg: { label: 'JPEG', mime: 'image/jpeg', extension: 'jpg', quality: true }, + webp: { label: 'WebP', mime: 'image/webp', extension: 'webp', quality: true }, +}; + +// 工具分组 +export const TOOL_GROUPS = { + edit: '编辑工具', + annotate: '标注工具', + view: '视图工具', + action: '操作', +}; diff --git a/plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js b/plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js new file mode 100644 index 00000000..1fb44b25 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/dynamicMosaic.js @@ -0,0 +1,355 @@ +export const DYNAMIC_MOSAIC_PROPS = [ + 'dynamicMosaic', + 'mosaicVersion', + 'mosaicEffect', + 'mosaicSize', + 'mosaicBlurRadius', + 'mosaicMaskType', + 'mosaicBrushPoints', + 'mosaicBrushSize', +]; + +const IDENTITY_VIEWPORT = [1, 0, 0, 1, 0, 0]; + +export function isDynamicMosaicObject(obj) { + return !!obj?.dynamicMosaic; +} + +export function createDynamicMosaicObject(rect, options = {}, maskOptions = {}) { + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + + const img = new fabric.Image(tempCanvas, { + left: Math.round(rect.left), + top: Math.round(rect.top), + selectable: false, + evented: false, + objectCaching: false, + id: 'mosaic_' + Date.now(), + }); + + img.set({ + dynamicMosaic: true, + mosaicVersion: 1, + mosaicEffect: options.mode === 'blur' ? 'blur' : 'mosaic', + mosaicSize: Math.max(2, parseInt(options.mosaicSize, 10) || 12), + mosaicBlurRadius: Math.max(1, parseInt(options.blurRadius, 10) || 8), + mosaicMaskType: maskOptions.type || 'rect', + mosaicBrushPoints: maskOptions.brushPoints || null, + mosaicBrushSize: maskOptions.brushSize || null, + }); + + img._dynamicMosaicCanvas = tempCanvas; + return img; +} + +export function hydrateDynamicMosaicObjects(canvas) { + if (!canvas) return; + canvas.getObjects().forEach(obj => { + if (!isDynamicMosaicObject(obj)) return; + ensureDynamicMosaicCanvas(obj); + obj.set({ objectCaching: false }); + obj.dirty = true; + }); +} + +export function queueDynamicMosaicUpdate(canvas, obj) { + if (!canvas || !obj) return; + + const targets = getDynamicTargets(canvas, obj); + if (targets.length === 0) return; + + const schedule = typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : (fn) => setTimeout(fn, 16); + + targets.forEach(target => { + if (target._dynamicMosaicFrame) return; + target._dynamicMosaicFrame = schedule(() => { + target._dynamicMosaicFrame = null; + updateDynamicMosaicObject(canvas, target, { render: true }); + }); + }); +} + +export function updateDynamicMosaics(canvas, options = {}) { + if (!canvas) return; + + const objects = canvas.getObjects(); + const fromIndex = Number.isFinite(options.fromIndex) ? options.fromIndex : 0; + let changed = false; + + objects.forEach((obj, index) => { + if (index < fromIndex || !isDynamicMosaicObject(obj)) return; + changed = updateDynamicMosaicObject(canvas, obj, { render: false }) || changed; + }); + + if (changed && options.render !== false) { + requestCanvasRender(canvas); + } +} + +export function updateDynamicMosaicObject(canvas, obj, options = {}) { + if (!canvas || !isDynamicMosaicObject(obj) || obj._dynamicMosaicUpdating) return false; + + const width = Math.max(1, Math.round(obj.width || obj._element?.width || 1)); + const height = Math.max(1, Math.round(obj.height || obj._element?.height || 1)); + const tempCanvas = ensureDynamicMosaicCanvas(obj, width, height); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + const sample = captureUnderlyingPixels(canvas, obj, width, height); + + if (!sample) return false; + + const maskData = createMaskData(obj, width, height); + const effectData = new Uint8ClampedArray(sample.data.data); + + if (obj.mosaicEffect === 'blur') { + blurPixels(effectData, width, height, obj.mosaicBlurRadius || 8, maskData); + } else { + mosaicPixels(effectData, width, height, obj.mosaicSize || 12, maskData); + } + + const output = sample.data; + const src = output.data; + for (let i = 0; i < src.length; i += 4) { + if (!maskData || maskData[i + 3] > 0) { + src[i] = effectData[i]; + src[i + 1] = effectData[i + 1]; + src[i + 2] = effectData[i + 2]; + src[i + 3] = effectData[i + 3]; + } else { + src[i] = 0; + src[i + 1] = 0; + src[i + 2] = 0; + src[i + 3] = 0; + } + } + + tempCtx.clearRect(0, 0, width, height); + tempCtx.putImageData(output, 0, 0); + setImageElement(obj, tempCanvas); + obj.dirty = true; + + if (options.render !== false) { + requestCanvasRender(canvas); + } + + return true; +} + +function getDynamicTargets(canvas, obj) { + const explicitTargets = obj.type === 'activeSelection' && typeof obj.getObjects === 'function' + ? obj.getObjects().filter(isDynamicMosaicObject) + : (isDynamicMosaicObject(obj) ? [obj] : []); + + if (explicitTargets.length === 0) return []; + + const objects = canvas.getObjects(); + const firstIndex = explicitTargets.reduce((min, target) => { + const index = objects.indexOf(target); + return index >= 0 ? Math.min(min, index) : min; + }, objects.length); + + return objects.filter((candidate, index) => index >= firstIndex && isDynamicMosaicObject(candidate)); +} + +function ensureDynamicMosaicCanvas(obj, width, height) { + const targetWidth = Math.max(1, Math.round(width || obj.width || obj._element?.width || 1)); + const targetHeight = Math.max(1, Math.round(height || obj.height || obj._element?.height || 1)); + let tempCanvas = obj._dynamicMosaicCanvas; + + if (!tempCanvas) { + tempCanvas = document.createElement('canvas'); + obj._dynamicMosaicCanvas = tempCanvas; + } + + if (tempCanvas.width !== targetWidth) tempCanvas.width = targetWidth; + if (tempCanvas.height !== targetHeight) tempCanvas.height = targetHeight; + setImageElement(obj, tempCanvas); + return tempCanvas; +} + +function setImageElement(obj, element) { + if (obj._element === element) return; + + if (typeof obj.setElement === 'function') { + obj.setElement(element); + } else { + obj._element = element; + obj._originalElement = element; + } +} + +function captureUnderlyingPixels(canvas, obj, width, height) { + const objects = canvas.getObjects(); + const objectIndex = objects.indexOf(obj); + const hiddenObjects = objectIndex >= 0 ? objects.slice(objectIndex) : []; + const visibilityBackups = []; + const viewportTransform = canvas.viewportTransform?.slice(); + const activeObject = canvas._activeObject; + const backgroundColor = canvas.backgroundColor; + + obj._dynamicMosaicUpdating = true; + + try { + hiddenObjects.forEach(item => { + visibilityBackups.push({ item, visible: item.visible }); + item.visible = false; + }); + + canvas._activeObject = null; + canvas.viewportTransform = IDENTITY_VIEWPORT.slice(); + canvas.renderAll(); + + const sampleWidth = Math.max(1, Math.round(width * Math.abs(obj.scaleX || 1))); + const sampleHeight = Math.max(1, Math.round(height * Math.abs(obj.scaleY || 1))); + const left = Math.round(obj.left || 0); + const top = Math.round(obj.top || 0); + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = width; + sourceCanvas.height = height; + const sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true }); + const canvasEl = canvas.lowerCanvasEl || canvas.getElement?.(); + + if (canvasEl) { + sourceCtx.imageSmoothingEnabled = false; + sourceCtx.drawImage( + canvasEl, + left, + top, + sampleWidth, + sampleHeight, + 0, + 0, + width, + height + ); + } else { + const ctx = canvas.getContext(); + const imageData = ctx.getImageData(left, top, width, height); + sourceCtx.putImageData(imageData, 0, 0); + } + + if (backgroundColor) { + const imageData = sourceCtx.getImageData(0, 0, width, height); + return { data: imageData }; + } + + return { data: sourceCtx.getImageData(0, 0, width, height) }; + } finally { + visibilityBackups.forEach(({ item, visible }) => { + item.visible = visible; + }); + if (viewportTransform) canvas.viewportTransform = viewportTransform; + canvas._activeObject = activeObject; + canvas.backgroundColor = backgroundColor; + obj._dynamicMosaicUpdating = false; + } +} + +function createMaskData(obj, width, height) { + if (obj.mosaicMaskType !== 'brush') return null; + + const points = Array.isArray(obj.mosaicBrushPoints) ? obj.mosaicBrushPoints : []; + const brushSize = Math.max(1, parseFloat(obj.mosaicBrushSize) || 1); + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + const radius = brushSize / 2; + + maskCtx.fillStyle = '#fff'; + points.forEach(point => { + maskCtx.beginPath(); + maskCtx.arc(point.x, point.y, radius, 0, Math.PI * 2); + maskCtx.fill(); + }); + + return maskCtx.getImageData(0, 0, width, height).data; +} + +function mosaicPixels(data, width, height, blockSize, mask) { + const size = Math.max(2, parseInt(blockSize, 10) || 12); + + for (let y = 0; y < height; y += size) { + for (let x = 0; x < width; x += size) { + if (mask && !blockHasMask(mask, width, height, x, y, size)) continue; + + let r = 0, g = 0, b = 0, a = 0, count = 0; + for (let dy = 0; dy < size && y + dy < height; dy++) { + for (let dx = 0; dx < size && x + dx < width; dx++) { + const idx = ((y + dy) * width + (x + dx)) * 4; + r += data[idx]; + g += data[idx + 1]; + b += data[idx + 2]; + a += data[idx + 3]; + count++; + } + } + + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + a = Math.round(a / count); + + for (let dy = 0; dy < size && y + dy < height; dy++) { + for (let dx = 0; dx < size && x + dx < width; dx++) { + const idx = ((y + dy) * width + (x + dx)) * 4; + if (!mask || mask[idx + 3] > 0) { + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = a; + } + } + } + } + } +} + +function blockHasMask(mask, width, height, x, y, size) { + for (let dy = 0; dy < size && y + dy < height; dy++) { + for (let dx = 0; dx < size && x + dx < width; dx++) { + const idx = ((y + dy) * width + (x + dx)) * 4; + if (mask[idx + 3] > 0) return true; + } + } + return false; +} + +function blurPixels(data, width, height, radius, mask) { + const srcCanvas = document.createElement('canvas'); + srcCanvas.width = width; + srcCanvas.height = height; + const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); + srcCtx.putImageData(new ImageData(data, width, height), 0, 0); + + const dstCanvas = document.createElement('canvas'); + dstCanvas.width = width; + dstCanvas.height = height; + const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true }); + dstCtx.filter = `blur(${Math.max(1, parseInt(radius, 10) || 8)}px)`; + dstCtx.drawImage(srcCanvas, 0, 0); + dstCtx.filter = 'none'; + + const blurred = dstCtx.getImageData(0, 0, width, height).data; + for (let i = 0; i < data.length; i += 4) { + if (!mask || mask[i + 3] > 0) { + data[i] = blurred[i]; + data[i + 1] = blurred[i + 1]; + data[i + 2] = blurred[i + 2]; + data[i + 3] = blurred[i + 3]; + } + } +} + +function requestCanvasRender(canvas) { + if (typeof canvas.requestRenderAll === 'function') { + canvas.requestRenderAll(); + } else { + canvas.renderAll(); + } +} diff --git a/plugins/Image-Toolbox/core/src/utils/fonts.js b/plugins/Image-Toolbox/core/src/utils/fonts.js new file mode 100644 index 00000000..45179114 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/fonts.js @@ -0,0 +1,206 @@ +const FALLBACK_FONT_OPTIONS = [ + { value: 'Microsoft YaHei, PingFang SC, sans-serif', label: '微软雅黑' }, + { value: 'SimSun, STSong, serif', label: '宋体' }, + { value: 'SimHei, STHeiti, sans-serif', label: '黑体' }, + { value: 'KaiTi, STKaiti, serif', label: '楷体' }, + { value: 'Arial, sans-serif', label: 'Arial' }, +]; + +const FONT_USAGE_STORAGE_KEY = 'image-toolbox.font-usage'; +const FONT_ALIAS_KEYS = new Map([ + ['microsoft yahei', '微软雅黑'], + ['microsoft yahei ui', '微软雅黑'], + ['simsun', '宋体'], + ['nsimsun', '新宋体'], + ['simhei', '黑体'], + ['kaiti', '楷体'], + ['fangsong', '仿宋'], + ['stkaiti', '华文楷体'], + ['stfangsong', '华文仿宋'], +]); + +let systemFontOptionsCache = null; +let systemFontLoading = false; +let systemFontLoadCallbacks = []; + +export function getFontOptionsHTML(current) { + const currentKey = _getFontKey(current); + + return _getFontOptions(current).map((option) => { + const selected = currentKey && _getFontKey(option.value) === currentKey ? ' selected' : ''; + return ``; + }).join(''); +} + +export function getFontOptionsHTMLAsync(current) { + if (systemFontOptionsCache) { + return Promise.resolve(getFontOptionsHTML(current)); + } + + return _ensureSystemFontsLoaded().then(() => getFontOptionsHTML(current)); +} + +export function isSystemFontsLoaded() { + return systemFontOptionsCache !== null; +} + +export function isSystemFontsLoading() { + return systemFontLoading; +} + +export function onSystemFontsLoaded(callback) { + if (systemFontOptionsCache) { + callback(); + } else { + systemFontLoadCallbacks.push(callback); + if (!systemFontLoading) { + _loadSystemFontsAsync(); + } + } +} + +function _loadSystemFontsAsync() { + if (systemFontLoading) return; + systemFontLoading = true; + + const asyncLoader = typeof window !== 'undefined' && typeof window.getSystemFontsAsync === 'function' + ? window.getSystemFontsAsync() + : Promise.resolve().then(() => { + return typeof window !== 'undefined' && typeof window.getSystemFonts === 'function' + ? window.getSystemFonts() + : []; + }); + + asyncLoader.then((fonts) => { + systemFontOptionsCache = Array.isArray(fonts) + ? fonts.map((font) => String(font || '').trim()).filter(Boolean).map((font) => ({ value: font, label: font })) + : []; + systemFontLoading = false; + systemFontLoadCallbacks.forEach((cb) => cb()); + systemFontLoadCallbacks = []; + }).catch((e) => { + console.error('[fonts] 异步加载系统字体失败:', e); + systemFontOptionsCache = []; + systemFontLoading = false; + systemFontLoadCallbacks.forEach((cb) => cb()); + systemFontLoadCallbacks = []; + }); +} + +function _ensureSystemFontsLoaded() { + if (systemFontOptionsCache) return Promise.resolve(); + return new Promise((resolve) => { + onSystemFontsLoaded(resolve); + }); +} + +export function recordFontUsage(fontFamily) { + const key = _getFontKey(fontFamily); + if (!key) return; + + const usage = _readFontUsage(); + const current = usage[key] || { count: 0, lastUsed: 0 }; + usage[key] = { + count: Math.min((parseInt(current.count, 10) || 0) + 1, Number.MAX_SAFE_INTEGER), + lastUsed: Date.now(), + }; + _writeFontUsage(usage); +} + +function _getFontOptions(current) { + const systemOptions = _getSystemFontOptions(); + const sourceOptions = systemOptions.length > 0 ? systemOptions : FALLBACK_FONT_OPTIONS; + const options = []; + const seen = new Set(); + + _sortFontOptions(sourceOptions).forEach((option) => { + const key = _getFontKey(option.value); + if (!key || seen.has(key)) return; + + seen.add(key); + options.push(option); + }); + + const currentValue = String(current || '').trim(); + const currentKey = _getFontKey(currentValue); + if (currentValue && currentKey && !seen.has(currentKey)) { + options.unshift({ value: currentValue, label: currentValue }); + } + + return options; +} + +function _getSystemFontOptions() { + if (systemFontOptionsCache) return systemFontOptionsCache; + + try { + const fonts = typeof window.getSystemFonts === 'function' ? window.getSystemFonts() : []; + systemFontOptionsCache = Array.isArray(fonts) + ? fonts.map((font) => String(font || '').trim()).filter(Boolean).map((font) => ({ value: font, label: font })) + : []; + } catch (e) { + console.error('[fonts] 获取系统字体失败:', e); + systemFontOptionsCache = []; + } + + return systemFontOptionsCache; +} + +function _sortFontOptions(options) { + const usage = _readFontUsage(); + + return options.slice().sort((a, b) => { + const usageA = usage[_getFontKey(a.value)] || {}; + const usageB = usage[_getFontKey(b.value)] || {}; + const countA = parseInt(usageA.count, 10) || 0; + const countB = parseInt(usageB.count, 10) || 0; + if (countA !== countB) return countB - countA; + + const lastUsedA = parseInt(usageA.lastUsed, 10) || 0; + const lastUsedB = parseInt(usageB.lastUsed, 10) || 0; + if (lastUsedA !== lastUsedB) return lastUsedB - lastUsedA; + + return a.label.localeCompare(b.label, 'zh-CN', { numeric: true, sensitivity: 'base' }); + }); +} + +function _readFontUsage() { + try { + const value = localStorage.getItem(FONT_USAGE_STORAGE_KEY); + const usage = value ? JSON.parse(value) : {}; + return usage && typeof usage === 'object' ? usage : {}; + } catch (e) { + return {}; + } +} + +function _writeFontUsage(usage) { + try { + localStorage.setItem(FONT_USAGE_STORAGE_KEY, JSON.stringify(usage)); + } catch (e) { + // localStorage 不可用时仅失去排序记忆,不影响字体选择。 + } +} + +function _getFontKey(value) { + const primary = _normalizeFontValue(_getPrimaryFontName(value)); + return FONT_ALIAS_KEYS.get(primary) || primary; +} + +function _getPrimaryFontName(value) { + return String(value || '').split(',')[0].replace(/^['"]|['"]$/g, '').trim(); +} + +function _normalizeFontValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +function _escapeHTML(value) { + return String(value ?? '').replace(/[&<>"']/g, (ch) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch])); +} diff --git a/plugins/Image-Toolbox/core/src/utils/host.js b/plugins/Image-Toolbox/core/src/utils/host.js new file mode 100644 index 00000000..d1d7e54e --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/host.js @@ -0,0 +1,118 @@ +export function getHostApi() { + if (typeof window === 'undefined') return null; + + const api = window.hostTools || window.utools || window.ztools || null; + if (api && window.hostTools !== api) { + window.hostTools = api; + } + return api; +} + +export function getHostName() { + const api = getHostApi(); + + try { + if (api && typeof api.getAppName === 'function') { + const name = api.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn('[Host] 获取宿主名称失败:', e); + } + + if (typeof window !== 'undefined') { + if (window.ztools) return 'ZTools'; + if (window.utools) return 'uTools'; + } + + return '本地环境'; +} + +export function getHostUser() { + if (typeof window !== 'undefined') { + try { + if (typeof window.getHostUser === 'function') return window.getHostUser(); + if (typeof window.getUtoolsUser === 'function') return window.getUtoolsUser(); + } catch (e) { + console.warn('[Host] 获取宿主用户信息失败:', e); + } + } + + const api = getHostApi(); + try { + if (api && typeof api.getUser === 'function') return api.getUser(); + } catch (e) { + console.warn('[Host] 获取宿主用户信息失败:', e); + } + + return null; +} + +export function getHostAppVersion() { + const api = getHostApi(); + + try { + if (api && typeof api.getAppVersion === 'function') return api.getAppVersion(); + } catch (e) { + console.warn('[Host] 获取宿主版本失败:', e); + } + + return null; +} + +export function isHostDarkColors() { + const api = getHostApi(); + + try { + if (api && typeof api.isDarkColors === 'function') return !!api.isDarkColors(); + } catch (e) { + console.warn('[Host] 读取宿主系统颜色失败:', e); + } + + return null; +} + +export function setHostExpendHeight(height) { + const api = getHostApi(); + + try { + if (api && typeof api.setExpendHeight === 'function') { + api.setExpendHeight(height); + return true; + } + } catch (e) { + console.warn('[Host] 设置宿主窗口高度失败:', e); + } + + return false; +} + +export function onHostPluginEnter(callback) { + const api = getHostApi(); + + try { + if (api && typeof api.onPluginEnter === 'function') { + api.onPluginEnter(callback); + return true; + } + } catch (e) { + console.warn('[Host] 注册插件进入事件失败:', e); + } + + return false; +} + +export function openHostExternal(url) { + const api = getHostApi(); + + try { + if (api && typeof api.shellOpenExternal === 'function') { + api.shellOpenExternal(url); + return true; + } + } catch (e) { + console.warn('[Host] 使用宿主打开外部链接失败:', e); + } + + return false; +} diff --git a/plugins/Image-Toolbox/core/src/utils/image.js b/plugins/Image-Toolbox/core/src/utils/image.js new file mode 100644 index 00000000..c9a0470f --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/image.js @@ -0,0 +1,104 @@ +/** + * 图片工具函数 + */ + +/** + * 从 DataURL 加载图片 + * @param {string} dataURL + * @returns {Promise} + */ +export function loadImageFromDataURL(dataURL) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = dataURL; + }); +} + +/** + * 从 File 对象读取为 DataURL + * @param {File} file + * @returns {Promise} + */ +export function fileToDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + }); +} + +/** + * 从 URL 获取 Blob + * @param {string} url + * @returns {Promise} + */ +export async function urlToBlob(url) { + const response = await fetch(url); + return response.blob(); +} + +/** + * Canvas 转 Blob + * @param {HTMLCanvasElement} canvas + * @param {string} [type='image/png'] + * @param {number} [quality=1] + * @returns {Promise} + */ +export function canvasToBlob(canvas, type = 'image/png', quality = 1) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Canvas toBlob 失败')); + }, type, quality); + }); +} + +/** + * 检测是否为支持的图片格式 + * @param {string} filename + * @returns {boolean} + */ +export function isSupportedImage(filename) { + const ext = filename.split('.').pop().toLowerCase(); + const supported = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'svg']; + return supported.includes(ext); +} + +/** + * 获取图片文件的 MIME 类型 + * @param {string} extension + * @returns {string} + */ +export function getImageMimeType(extension) { + const mimeMap = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + bmp: 'image/bmp', + gif: 'image/gif', + svg: 'image/svg+xml', + }; + return mimeMap[extension.toLowerCase()] || 'image/png'; +} + +/** + * 在画布上绘制棋盘格背景 + * @param {CanvasRenderingContext2D} ctx + * @param {number} width + * @param {number} height + * @param {number} [size=8] + * @param {string} [color1='#c8d0d4'] + * @param {string} [color2='#d2d6d9'] + */ +export function drawCheckerboard(ctx, width, height, size = 8, color1 = '#c8d0d4', color2 = '#d2d6d9') { + for (let y = 0; y < height; y += size) { + for (let x = 0; x < width; x += size) { + ctx.fillStyle = (Math.floor(x / size) + Math.floor(y / size)) % 2 === 0 ? color1 : color2; + ctx.fillRect(x, y, size, size); + } + } +} diff --git a/plugins/Image-Toolbox/core/src/utils/theme.js b/plugins/Image-Toolbox/core/src/utils/theme.js new file mode 100644 index 00000000..e86fc865 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/utils/theme.js @@ -0,0 +1,88 @@ +import { isHostDarkColors } from './host.js'; + +export const THEME_STORAGE_KEY = 'image-toolbox-theme'; +export const THEME_VERSION_KEY = 'image-toolbox-theme-version'; +export const THEME_VERSION = 'neutral-teal-light-default-v1'; + +export const THEME_CHOICES = { + SYSTEM: 'system', + LIGHT: 'light', + DARK: 'dark', +}; + +const VALID_THEME_CHOICES = new Set(Object.values(THEME_CHOICES)); +let systemThemeListenerBound = false; + +export function initTheme() { + applyThemeChoice(getThemeChoice(), false); + bindSystemThemeListener(); +} + +export function getThemeChoice() { + const savedVersion = localStorage.getItem(THEME_VERSION_KEY); + let savedTheme = localStorage.getItem(THEME_STORAGE_KEY); + + if (savedVersion !== THEME_VERSION) { + savedTheme = THEME_CHOICES.LIGHT; + localStorage.setItem(THEME_STORAGE_KEY, savedTheme); + localStorage.setItem(THEME_VERSION_KEY, THEME_VERSION); + } else if (!VALID_THEME_CHOICES.has(savedTheme)) { + savedTheme = THEME_CHOICES.LIGHT; + } + + return savedTheme; +} + +export function applyThemeChoice(choice, persist = true) { + const themeChoice = VALID_THEME_CHOICES.has(choice) ? choice : THEME_CHOICES.LIGHT; + + if (persist) { + localStorage.setItem(THEME_STORAGE_KEY, themeChoice); + localStorage.setItem(THEME_VERSION_KEY, THEME_VERSION); + } + + document.documentElement.setAttribute('data-theme-preference', themeChoice); + document.documentElement.setAttribute('data-theme', resolveTheme(themeChoice)); +} + +function resolveTheme(choice) { + if (choice === THEME_CHOICES.SYSTEM) { + return getSystemTheme(); + } + + return choice; +} + +function getSystemTheme() { + const hostDark = isHostDarkColors(); + if (hostDark !== null) { + return hostDark ? THEME_CHOICES.DARK : THEME_CHOICES.LIGHT; + } + + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? THEME_CHOICES.DARK + : THEME_CHOICES.LIGHT; + } + + return THEME_CHOICES.LIGHT; +} + +function bindSystemThemeListener() { + if (systemThemeListenerBound || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + if (getThemeChoice() === THEME_CHOICES.SYSTEM) { + applyThemeChoice(THEME_CHOICES.SYSTEM, false); + } + }; + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', handleChange); + } else if (typeof mediaQuery.addListener === 'function') { + mediaQuery.addListener(handleChange); + } + + systemThemeListenerBound = true; +} diff --git a/plugins/Image-Toolbox/logo.png b/plugins/Image-Toolbox/logo.png new file mode 100644 index 00000000..b22d4c3b Binary files /dev/null and b/plugins/Image-Toolbox/logo.png differ diff --git a/plugins/Image-Toolbox/plugin.json b/plugins/Image-Toolbox/plugin.json new file mode 100644 index 00000000..b93ac900 --- /dev/null +++ b/plugins/Image-Toolbox/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "image-toolbox-ztools", + "title": "图片工具箱", + "description": "马赛克、剪切、加字等图片编辑功能", + "version": "2.2.1", + "main": "src/index.html", + "logo": "logo.png", + "preload": "preload.js", + "features": [ + { + "code": "image-edit", + "explain": "图片工具箱 - 马赛克、剪切、加字等图片编辑功能", + "cmds": [ + "图片工具箱", + "图片编辑", + "编辑图片", + "P图", + { + "type": "img", + "label": "图片编辑" + }, + { + "type": "files", + "label": "编辑图片", + "fileType": "file", + "extensions": ["png", "jpg", "jpeg", "webp", "bmp", "gif", "svg"], + "minLength": 1, + "maxLength": 1 + } + ] + } + ] +} diff --git a/plugins/Image-Toolbox/preload.js b/plugins/Image-Toolbox/preload.js new file mode 100644 index 00000000..41cb62c5 --- /dev/null +++ b/plugins/Image-Toolbox/preload.js @@ -0,0 +1,701 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); +const { fileURLToPath } = require('url'); +const { clipboard, nativeImage } = require('electron'); + +const _getHostTools = () => { + if (typeof window === 'undefined') return null; + + const api = window.hostTools + || window.ztools + || window.utools + || (typeof globalThis !== 'undefined' ? (globalThis.ztools || globalThis.utools) : null) + || null; + + if (api) { + window.hostTools = api; + } + + return api; +}; + +const _getHostName = () => { + const hostTools = _getHostTools(); + + try { + if (hostTools && typeof hostTools.getAppName === 'function') { + const name = hostTools.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn('[preload] 获取宿主名称失败:', e); + } + + if (typeof window !== 'undefined') { + if (window.ztools) return 'ZTools'; + if (window.utools) return 'uTools'; + } + + return '宿主'; +}; + +const _getHostPath = (name) => { + const hostTools = _getHostTools(); + try { + if (hostTools && typeof hostTools.getPath === 'function') return hostTools.getPath(name); + } catch (e) { + console.warn('[preload] 获取宿主路径失败:', e); + } + + return null; +}; + +const _setHostExpendHeight = (height) => { + const hostTools = _getHostTools(); + try { + if (hostTools && typeof hostTools.setExpendHeight === 'function') { + hostTools.setExpendHeight(height); + } + } catch (e) { + console.warn('[preload] 设置宿主窗口高度失败:', e); + } +}; + +window.hostTools = _getHostTools(); +window.getHostName = _getHostName; + +const MIME_MAP = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + bmp: 'image/bmp', + gif: 'image/gif', + svg: 'image/svg+xml', +}; + +const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.ttc', '.otc']); +let _systemFontsCache = null; +let _systemFontsPromise = null; + +// ── 文件操作 ── +window.readImageFile = (filePath) => { + const buffer = fs.readFileSync(filePath); + const ext = path.extname(filePath).toLowerCase().replace('.', ''); + const mime = MIME_MAP[ext] || 'image/png'; + return 'data:' + mime + ';base64,' + buffer.toString('base64'); +}; + +// ── 系统字体(同步版本,向后兼容)── +window.getSystemFonts = () => { + if (_systemFontsCache) return _systemFontsCache.slice(); + + try { + _systemFontsCache = _loadSystemFonts(); + } catch (e) { + console.error('[preload] 获取系统字体失败:', e); + _systemFontsCache = []; + } + + return _systemFontsCache.slice(); +}; + +// ── 系统字体(异步版本,不阻塞UI)── +window.getSystemFontsAsync = () => { + if (_systemFontsCache) return Promise.resolve(_systemFontsCache.slice()); + if (_systemFontsPromise) return _systemFontsPromise; + + _systemFontsPromise = new Promise((resolve) => { + setTimeout(() => { + try { + _systemFontsCache = _loadSystemFonts(); + } catch (e) { + console.error('[preload] 异步获取系统字体失败:', e); + _systemFontsCache = []; + } + resolve(_systemFontsCache.slice()); + }, 0); + }); + + return _systemFontsPromise; +}; + +const _loadSystemFonts = () => { + const families = new Map(); + + _getSystemFontFiles().forEach((filePath) => { + try { + _readFontFamilies(filePath).forEach((family) => { + const key = _normalizeFontName(family); + if (key && !families.has(key)) families.set(key, family); + }); + } catch (e) { + // 个别系统字体可能是旧格式或权限受限,跳过不影响其它字体。 + } + }); + + return Array.from(families.values()).sort((a, b) => ( + a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' }) + )); +}; + +const _getSystemFontFiles = () => { + const files = new Set(); + const fontDirs = _getSystemFontDirs(); + + fontDirs.forEach((dir) => _collectFontFiles(dir, files)); + _getRegisteredFontFiles(fontDirs).forEach((filePath) => { + if (_isFontFile(filePath) && fs.existsSync(filePath)) files.add(filePath); + }); + + return Array.from(files); +}; + +const _getSystemFontDirs = () => { + const dirs = []; + const homeDir = os.homedir(); + + if (process.platform === 'win32') { + const windowsDir = process.env.WINDIR || process.env.SystemRoot || 'C:\\Windows'; + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + dirs.push( + path.join(windowsDir, 'Fonts'), + path.join(localAppData, 'Microsoft', 'Windows', 'Fonts') + ); + } else if (process.platform === 'darwin') { + dirs.push( + '/System/Library/Fonts', + '/Library/Fonts', + path.join(homeDir, 'Library', 'Fonts') + ); + } else { + dirs.push( + '/usr/share/fonts', + '/usr/local/share/fonts', + path.join(homeDir, '.fonts'), + path.join(homeDir, '.local', 'share', 'fonts') + ); + } + + const seen = new Set(); + return dirs.filter((dir) => { + const key = path.resolve(dir).toLowerCase(); + if (seen.has(key) || !fs.existsSync(dir)) return false; + seen.add(key); + return true; + }); +}; + +const _collectFontFiles = (dir, files, depth = 0) => { + if (depth > 8) return; + + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + return; + } + + entries.forEach((entry) => { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + _collectFontFiles(filePath, files, depth + 1); + } else if (entry.isFile() && _isFontFile(entry.name)) { + files.add(filePath); + } + }); +}; + +const _getRegisteredFontFiles = (fontDirs) => { + if (process.platform !== 'win32') return []; + + const files = new Set(); + const registryKeys = [ + 'HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts', + 'HKCU\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts', + ]; + + registryKeys.forEach((key) => { + let output = ''; + try { + output = execFileSync('reg', ['query', key], { encoding: 'utf8', windowsHide: true }); + } catch (e) { + return; + } + + output.split(/\r?\n/).forEach((line) => { + const match = line.match(/^\s+.+?\s+REG_\w+\s+(.+?)\s*$/); + if (!match) return; + + const rawPath = _expandEnvPath(match[1].trim()); + if (!_isFontFile(rawPath)) return; + + if (path.isAbsolute(rawPath)) { + files.add(rawPath); + return; + } + + fontDirs.forEach((dir) => files.add(path.join(dir, rawPath))); + }); + }); + + return Array.from(files); +}; + +const _expandEnvPath = (value) => { + return String(value || '').replace(/%([^%]+)%/g, (_, name) => ( + process.env[name] || process.env[name.toUpperCase()] || '' + )); +}; + +const _readFontFamilies = (filePath) => { + const buffer = fs.readFileSync(filePath); + const families = []; + + _getFontOffsets(buffer).forEach((offset) => { + const family = _readSfntFamily(buffer, offset); + if (family) families.push(family); + }); + + return families; +}; + +const _getFontOffsets = (buffer) => { + if (buffer.length < 12) return []; + + if (buffer.toString('ascii', 0, 4) === 'ttcf') { + const count = _readUInt32(buffer, 8); + const offsets = []; + for (let i = 0; i < count; i++) { + const offset = _readUInt32(buffer, 12 + i * 4); + if (offset > 0 && offset < buffer.length) offsets.push(offset); + } + return offsets; + } + + return [0]; +}; + +const _readSfntFamily = (buffer, sfntOffset) => { + if (!_isSfnt(buffer, sfntOffset)) return null; + + const numTables = _readUInt16(buffer, sfntOffset + 4); + const tableDir = sfntOffset + 12; + let nameOffset = 0; + let nameLength = 0; + + for (let i = 0; i < numTables; i++) { + const recordOffset = tableDir + i * 16; + if (recordOffset + 16 > buffer.length) break; + if (buffer.toString('ascii', recordOffset, recordOffset + 4) !== 'name') continue; + + nameOffset = _readUInt32(buffer, recordOffset + 8); + nameLength = _readUInt32(buffer, recordOffset + 12); + break; + } + + if (!nameOffset || nameOffset + 6 > buffer.length) return null; + return _pickFontFamilyName(_readNameRecords(buffer, nameOffset, nameLength)); +}; + +const _isSfnt = (buffer, offset) => { + if (offset + 12 > buffer.length) return false; + const tag = buffer.toString('ascii', offset, offset + 4); + const version = _readUInt32(buffer, offset); + return version === 0x00010000 || tag === 'OTTO' || tag === 'true' || tag === 'typ1'; +}; + +const _readNameRecords = (buffer, tableOffset, tableLength) => { + const count = _readUInt16(buffer, tableOffset + 2); + const stringBase = tableOffset + _readUInt16(buffer, tableOffset + 4); + const tableEnd = tableLength ? Math.min(buffer.length, tableOffset + tableLength) : buffer.length; + const records = []; + + for (let i = 0; i < count; i++) { + const recordOffset = tableOffset + 6 + i * 12; + if (recordOffset + 12 > buffer.length) break; + + const platformID = _readUInt16(buffer, recordOffset); + const languageID = _readUInt16(buffer, recordOffset + 4); + const nameID = _readUInt16(buffer, recordOffset + 6); + if (nameID !== 16 && nameID !== 1 && nameID !== 21) continue; + + const length = _readUInt16(buffer, recordOffset + 8); + const offset = _readUInt16(buffer, recordOffset + 10); + const start = stringBase + offset; + const end = start + length; + if (start < stringBase || end > tableEnd || end > buffer.length) continue; + + const name = _cleanFontName(_decodeFontName(buffer.slice(start, end), platformID)); + if (name) records.push({ name, nameID, platformID, languageID }); + } + + return records; +}; + +const _pickFontFamilyName = (records) => { + for (const nameID of [16, 1, 21]) { + const candidates = records.filter((record) => record.nameID === nameID); + const cjk = candidates.find((record) => _isChineseLanguage(record.languageID) && _hasCjk(record.name)) + || candidates.find((record) => _hasCjk(record.name)); + if (cjk) return cjk.name; + + const english = candidates.find((record) => record.languageID === 0x0409); + if (english) return english.name; + + const windows = candidates.find((record) => record.platformID === 3); + if (windows) return windows.name; + + if (candidates[0]) return candidates[0].name; + } + + return null; +}; + +const _decodeFontName = (buffer, platformID) => { + if (platformID === 0 || platformID === 3 || _looksUtf16BE(buffer)) { + return _decodeUtf16BE(buffer); + } + + return buffer.toString('latin1'); +}; + +const _decodeUtf16BE = (buffer) => { + const length = buffer.length - (buffer.length % 2); + const swapped = Buffer.allocUnsafe(length); + for (let i = 0; i < length; i += 2) { + swapped[i] = buffer[i + 1]; + swapped[i + 1] = buffer[i]; + } + return swapped.toString('utf16le'); +}; + +const _looksUtf16BE = (buffer) => { + if (buffer.length < 4) return false; + let zeroBytes = 0; + for (let i = 0; i < buffer.length; i += 2) { + if (buffer[i] === 0) zeroBytes++; + } + return zeroBytes >= Math.ceil(buffer.length / 4); +}; + +const _cleanFontName = (value) => { + return String(value || '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +const _normalizeFontName = (value) => _cleanFontName(value).toLowerCase(); + +const _isFontFile = (filePath) => FONT_EXTENSIONS.has(path.extname(filePath).toLowerCase()); + +const _hasCjk = (value) => /[\u2e80-\u9fff]/.test(value); + +const _isChineseLanguage = (languageID) => [0x0804, 0x0404, 0x0c04, 0x1004, 0x1404].includes(languageID); + +const _readUInt16 = (buffer, offset) => { + return offset + 2 <= buffer.length ? buffer.readUInt16BE(offset) : 0; +}; + +const _readUInt32 = (buffer, offset) => { + return offset + 4 <= buffer.length ? buffer.readUInt32BE(offset) : 0; +}; + +const _detectImageMime = (buffer) => { + if (!buffer || buffer.length < 4) return 'image/png'; + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png'; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'; + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) return 'image/gif'; + if (buffer[0] === 0x42 && buffer[1] === 0x4d) return 'image/bmp'; + if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'; + const start = buffer.toString('utf8', 0, Math.min(buffer.length, 256)).trimStart().toLowerCase(); + if (start.startsWith(' { + if (!payload) return []; + return Array.isArray(payload) ? payload : [payload]; +}; + +const _toLocalPath = (value) => { + if (typeof value !== 'string') return null; + const text = value.trim(); + if (!text || text.length > 2048 || /[\r\n]/.test(text)) return null; + if (/^file:\/\//i.test(text)) { + try { + return fileURLToPath(text); + } catch (e) { + console.error('[preload] file URL 解析失败:', e); + return null; + } + } + return text; +}; + +const _getImagePath = (value) => { + const filePath = _toLocalPath(value); + if (!filePath) return null; + try { + const ext = path.extname(filePath).toLowerCase().replace('.', ''); + if (!MIME_MAP[ext]) return null; + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null; + return filePath; + } catch (e) { + return null; + } +}; + +const _dataURLFromBase64 = (value) => { + const base64 = value.replace(/\s/g, ''); + try { + const buffer = Buffer.from(base64, 'base64'); + return 'data:' + _detectImageMime(buffer) + ';base64,' + base64; + } catch (e) { + return null; + } +}; + +const _dataURLFromBytes = (value) => { + if (!value) return null; + let buffer = null; + if (Buffer.isBuffer(value)) { + buffer = value; + } else if (value instanceof ArrayBuffer) { + buffer = Buffer.from(value); + } else if (ArrayBuffer.isView(value)) { + buffer = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (!buffer || buffer.length === 0) return null; + return 'data:' + _detectImageMime(buffer) + ';base64,' + buffer.toString('base64'); +}; + +const _normalizeImageString = (value) => { + if (typeof value !== 'string') return null; + const text = value.trim(); + if (!text) return null; + + if (/^data:image\//i.test(text)) return text; + if (/^image\/[a-z0-9.+-]+;base64,/i.test(text)) return 'data:' + text; + + const imagePath = _getImagePath(text); + if (imagePath) { + try { + return window.readImageFile(imagePath); + } catch (e) { + console.error('[preload] 读取 payload 图片文件失败:', e); + return null; + } + } + + if (/^(https?:|blob:)/i.test(text)) return text; + + if (text.length > 100 && /^[A-Za-z0-9+/=\s]+$/.test(text)) { + return _dataURLFromBase64(text); + } + + return null; +}; + +const _normalizeImagePayload = (payload) => { + for (const item of _payloadItems(payload)) { + if (typeof item === 'string') { + const source = _normalizeImageString(item); + if (source) return source; + continue; + } + + if (!item || typeof item !== 'object') continue; + + if (typeof item.toDataURL === 'function') { + try { + const source = item.toDataURL(); + if (source) return source; + } catch (e) { + console.error('[preload] payload.toDataURL 失败:', e); + } + } + + const candidates = [item.path, item.filePath, item.url, item.src, item.dataURL, item.data, item.base64, item.content]; + for (const candidate of candidates) { + const source = _normalizeImageString(candidate); + if (source) return source; + } + } + + return null; +}; + +const _escapeHtmlAttr = (value) => { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +}; + +const _decodeHtmlAttr = (value) => { + return String(value) + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +}; + +const _extractImageDataURLFromHTML = (html) => { + if (typeof html !== 'string' || !html) return null; + + const quotedMatch = html.match(/\bsrc\s*=\s*(["'])(data:image\/[a-z0-9.+-]+;base64,[^"']+)\1/i); + const unquotedMatch = quotedMatch ? null : html.match(/\bsrc\s*=\s*(data:image\/[a-z0-9.+-]+;base64,[^\s>]+)/i); + const source = quotedMatch ? quotedMatch[2] : unquotedMatch?.[1]; + return source ? _normalizeImageString(_decodeHtmlAttr(source)) : null; +}; + +const _readImageDataURLFromClipboardHTML = () => { + try { + return _extractImageDataURLFromHTML(clipboard.readHTML()); + } catch (e) { + console.error('[preload] 读取剪贴板 HTML 图片失败:', e); + return null; + } +}; + +window.getImageSourceFromPluginPayload = (type, payload) => { + if (type === 'file' || type === 'files') { + return _normalizeImagePayload(payload); + } + + if (type === 'img') { + return _readImageDataURLFromClipboardHTML() + || _normalizeImagePayload(payload) + || window.readImageFromClipboard(); + } + + return null; +}; + +window.writeImageFile = (filePath, dataURL) => { + const matches = dataURL.match(/^data:image\/(png|jpeg|webp);base64,(.+)$/); + if (!matches) return false; + const buffer = Buffer.from(matches[2], 'base64'); + fs.writeFileSync(filePath, buffer); + return true; +}; + +// ── 剪贴板操作 ── +window.copyImageToClipboard = (dataURL) => { + const img = nativeImage.createFromDataURL(dataURL); + try { + clipboard.write({ + image: img, + html: 'image', + }); + } catch (e) { + console.error('[preload] 写入透明剪贴板图片失败,降级为系统图片格式:', e); + clipboard.writeImage(img); + } +}; + +window.readImageFromClipboard = () => { + const htmlImage = _readImageDataURLFromClipboardHTML(); + if (htmlImage) return htmlImage; + + const img = clipboard.readImage(); + if (img.isEmpty()) return null; + return img.toDataURL(); +}; + +// ── 导出文件对话框 ── +window.showSaveImageDialog = (defaultName) => { + const hostTools = _getHostTools(); + if (!hostTools || typeof hostTools.showSaveDialog !== 'function') return undefined; + + const baseDir = _getHostPath('pictures') || _getHostPath('desktop') || os.homedir(); + return hostTools.showSaveDialog({ + title: '保存图片', + defaultPath: path.join(baseDir, defaultName || 'edited'), + filters: [ + { name: 'PNG 图片', extensions: ['png'] }, + { name: 'JPEG 图片', extensions: ['jpg', 'jpeg'] }, + { name: 'WebP 图片', extensions: ['webp'] }, + ], + }); +}; + +// ── 打开文件对话框 ── +window.showOpenImageDialog = () => { + const hostTools = _getHostTools(); + if (!hostTools || typeof hostTools.showOpenDialog !== 'function') return undefined; + + return hostTools.showOpenDialog({ + title: '选择图片', + filters: [ + { name: '图片文件', extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'svg'] }, + ], + properties: ['openFile'], + }); +}; + +// ── 宿主用户信息 ── +window.getHostUser = () => { + const hostTools = _getHostTools(); + try { + if (hostTools && typeof hostTools.getUser === 'function') { + return hostTools.getUser(); + } + } catch (e) { + console.error('[preload] 获取宿主用户信息失败:', e); + } + return null; +}; + +window.getZtoolsUser = window.getHostUser; +window.getUtoolsUser = window.getHostUser; + +// ── 插件生命周期 ── +let _pluginLifecycleBound = false; + +const _handlePluginEnter = ({ code, type, payload }) => { + if (code === 'image-edit') { + let imageSource = null; + try { + imageSource = window.getImageSourceFromPluginPayload(type, payload); + } catch (e) { + console.error('[preload] 解析外部图片失败:', e); + } + + // 通过 window 对象将图片源传递给前端 + window.__imageSource = imageSource; + + // 动态调整窗口高度 + _setHostExpendHeight(560); + } +}; + +const _registerPluginLifecycle = () => { + if (_pluginLifecycleBound) return; + + const hostTools = _getHostTools(); + if (!hostTools || typeof hostTools.onPluginEnter !== 'function') return; + + hostTools.onPluginEnter(_handlePluginEnter); + if (typeof hostTools.onPluginOut === 'function') { + // 插件退出时清理 + hostTools.onPluginOut(() => { + window.__imageSource = null; + }); + } + + _pluginLifecycleBound = true; + console.log('[preload] 已注册插件生命周期:', _getHostName()); +}; + +_registerPluginLifecycle(); +setTimeout(_registerPluginLifecycle, 0); +setTimeout(_registerPluginLifecycle, 100); diff --git a/plugins/Image-Toolbox/src/adapters/host/ZtoolsHostAdapter.js b/plugins/Image-Toolbox/src/adapters/host/ZtoolsHostAdapter.js new file mode 100644 index 00000000..804c7bec --- /dev/null +++ b/plugins/Image-Toolbox/src/adapters/host/ZtoolsHostAdapter.js @@ -0,0 +1,346 @@ +/** + * ZtoolsHostAdapter + * ZTools 平台宿主适配器。 + * + * 对外提供分组能力,同时保留旧方法,便于逐步迁移现有 UI/模块。 + */ + +const DEFAULT_HOST_NAME = 'ZTools'; + +function getHostApi() { + if (typeof window !== 'undefined') { + return window.hostTools + || window.ztools + || window.utools + || null; + } + + if (typeof globalThis !== 'undefined') { + return globalThis.ztools || globalThis.utools || null; + } + + return null; +} + +function getHostDisplayName(api = getHostApi()) { + try { + if (api && typeof api.getAppName === 'function') { + const name = api.getAppName(); + if (name) return String(name); + } + } catch (e) { + console.warn('[ZtoolsHostAdapter] 获取宿主名称失败:', e); + } + + if (typeof window !== 'undefined') { + if (window.ztools) return 'ZTools'; + if (window.utools) return 'uTools'; + } + + return DEFAULT_HOST_NAME; +} + +function normalizeZtoolsUser(user) { + if (!user) return null; + + return { + nickname: user.nickname || user.name || user.userName || user.username || '', + avatar: user.avatar || user.avatarUrl || user.photo || '', + type: user.type || '', + raw: user, + }; +} + +function getRawUser(api = getHostApi()) { + try { + if (api && typeof api.getUser === 'function') return api.getUser(); + if (api && typeof api.getUserInfo === 'function') return api.getUserInfo(); + if (typeof window !== 'undefined' && typeof window.getHostUser === 'function') return window.getHostUser(); + } catch (e) { + console.warn('[ZtoolsHostAdapter] 获取宿主用户失败:', e); + } + + return null; +} + +function openExternal(url, api = getHostApi()) { + if (!url) return false; + + try { + if (api && typeof api.shellOpenExternal === 'function') { + api.shellOpenExternal(url); + return true; + } + } catch (e) { + console.warn('[ZtoolsHostAdapter] 使用宿主打开外部链接失败:', e); + } + + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + } + + return false; +} + +class ZtoolsHostAdapter { + constructor() { + this._api = getHostApi(); + this._isZTools = !!this._api; + + this.platform = { + id: 'ztools', + name: getHostDisplayName(this._api), + 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 isZTools() { + return this._isZTools; + } + + 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; + } + + pickImage() { + if (typeof window !== 'undefined' && typeof window.showOpenImageDialog === 'function') { + const result = window.showOpenImageDialog(); + const filePath = Array.isArray(result) ? result[0] : result?.filePaths?.[0]; + return filePath ? this.readImageFile(filePath) : null; + } + return null; + } + + 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); + } + + 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() { + if (this._api && typeof this._api.getAppVersion === 'function') { + return this._api.getAppVersion(); + } + if (this._api && typeof this._api.getVersion === 'function') { + return this._api.getVersion(); + } + return 'unknown'; + } + + getHostName() { + return this.platform?.name || getHostDisplayName(this._api); + } + + getHostUser() { + return normalizeZtoolsUser(getRawUser(this._api)); + } + + 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) { + return openExternal(url, this._api); + } +} + +const defaultAdapter = new ZtoolsHostAdapter(); + +export default ZtoolsHostAdapter; + +// 便捷导出函数(旧 UI 兼容;新代码优先注入 host adapter) +export function getHostAppVersion() { + return defaultAdapter.getHostAppVersion(); +} + +export function getHostName() { + return defaultAdapter.getHostName(); +} + +export function getHostUser() { + return defaultAdapter.getHostUser(); +} + +export function openHostExternal(url) { + return defaultAdapter.openHostExternal(url); +} diff --git a/plugins/Image-Toolbox/src/index.html b/plugins/Image-Toolbox/src/index.html new file mode 100644 index 00000000..063dc3ea --- /dev/null +++ b/plugins/Image-Toolbox/src/index.html @@ -0,0 +1,62 @@ + + + + + + 图片工具箱 + + + + + + +
+ +
+ + +
+ + +
+ +
+
+ + + + + +
+
拖入图片 / 粘贴截图 / 点击图标导入
+
支持 PNG · JPG · WebP · BMP · GIF · SVG
+ +
+ + + + + + +
+ + +
+ + +
+
+ + + + + + + + diff --git a/plugins/Image-Toolbox/src/index.js b/plugins/Image-Toolbox/src/index.js new file mode 100644 index 00000000..ef748009 --- /dev/null +++ b/plugins/Image-Toolbox/src/index.js @@ -0,0 +1,516 @@ +/** + * 图片工具箱 — 主入口 + * 初始化所有管理器、UI 组件、绑定生命周期 + */ +import { + eventBus, + CanvasManager, + LayerManager, + HistoryManager, + ToolManager, +} from '../core/src/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, +} from './ui/AccountPage.js'; +import ZtoolsHostAdapter from './adapters/host/ZtoolsHostAdapter.js'; +import { initTheme } from '../core/src/utils/theme.js'; + +// ═══════════════════════════════════════ +// 应用入口 +// ═══════════════════════════════════════ + +class App { + constructor() { + 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._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()); + + // 1. 初始化画布管理器 + this.canvasManager = new CanvasManager('fabric-canvas'); + this.canvasManager.init({ + width: 800, + height: 600, + backgroundColor: 'transparent', // 由 CSS 背景负责 + 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 ZtoolsHostAdapter(); + 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() { + // ═══ 图片导入 ═══ + + // 拖拽导入 + document.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + document.addEventListener('drop', (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); + } + } + }); + + // 粘贴导入 + document.addEventListener('paste', (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) + // ⚠️ uTools/ZTools showOpenDialog 返回 string[](文件路径数组),不是 { filePaths: [] } + // ⚠️ 该 API 是同步的,不需要 await + document.getElementById('welcome-btn')?.addEventListener('click', () => { + if (typeof window.showOpenImageDialog === 'function') { + const result = window.showOpenImageDialog(); + if (result && result.length > 0) { + const dataURL = window.readImageFile(result[0]); + 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(); + }); + + // 滚轮缩放 + document.getElementById('canvas-area')?.addEventListener('wheel', (e) => { + if (!this.canvasManager?.canvas) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + this.canvasManager.zoomIn(delta); + this._updateZoomLabel(); + }, { passive: false }); + + 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); + }); + + // ═══ 快捷键 ═══ + document.addEventListener('keydown', (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.canvasManager?.removeActiveObject(); + this.historyManager?.saveState(); + return; + } + + // 工具快捷键 + if (!e.ctrlKey && !e.metaKey) { + const tools = this.toolManager?.getTools() || []; + const tool = tools.find(t => t.shortcut === e.key.toUpperCase()); + if (tool) { + e.preventDefault(); + this.toolManager?.activateTool(tool.name); + } + } + }); + + // ═══ 画布操作后自动保存历史 ═══ + 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 提示(ExportModule 等模块通过 eventBus 触发) ═══ + eventBus.on('toast:show', ({ message, type }) => { + this._showToast(message, type); + }); + + // ═══ 插件重复进入(文件匹配/剪贴板匹配/超级面板等) ═══ + this.hostAdapter?.onPluginEnter(({ code, type, payload, from }) => { + console.log('[App] onPluginEnter:', { code, type, from, payload }); + if (code === 'image-edit') { + // 文件和超级面板图片都优先直接解析 payload,避免和 preload 执行顺序竞争。 + 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 source = window.__imageSource; + if (source) { + window.__imageSource = null; + this._loadImage(source); + } + } + + this.hostAdapter?.setWindowHeight(560); + } + }); + } + + /** + * 显示 Toast 提示 + */ + _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] || ''}${message}`; + document.body.appendChild(toast); + + toast.addEventListener('animationend', () => { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }); + } + + /** + * 加载图片到画布 + */ + async _loadImage(source) { + try { + // ⚠️ 必须先显示画布容器,否则 _updateCanvasSize 读到 0×0 + 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.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; + } + } + + /** + * 检查外部传入的图片源(从 preload.js / 宿主 payload) + */ + _checkExternalSource() { + let attempts = 0; + const maxAttempts = 30; // 最多重试 30 次 = 3 秒 + + const check = () => { + if (window.__imageSource) { + const source = window.__imageSource; + window.__imageSource = null; + console.log('[App] _checkExternalSource 发现图片源,开始加载'); + if (source) { + this._loadImage(source); + } + return; + } + attempts++; + if (attempts < maxAttempts) { + setTimeout(check, 100); + } else { + console.log('[App] _checkExternalSource 超时(3s),未发现外部图片源'); + } + }; + + // 100ms 后开始检查 + setTimeout(check, 100); + } + + _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 + ); + } +} + +// ═══ 启动应用 ═══ +window.addEventListener('DOMContentLoaded', () => { + new App(); +}); diff --git a/plugins/Image-Toolbox/src/style.css b/plugins/Image-Toolbox/src/style.css new file mode 100644 index 00000000..6bbf7f1c --- /dev/null +++ b/plugins/Image-Toolbox/src/style.css @@ -0,0 +1,2129 @@ +/* ══════════════════════════════════════════════ + 图片工具箱 — 全局样式 + 中性编辑器布局 | 灰青主题 | BEM 命名 + ══════════════════════════════════════════════ */ + +/* ── CSS 变量:中性浅色主题(默认) ── */ +:root, [data-theme="light"] { + --bg-window: #f2f4f5; + --bg-panel: #eef1f2; + --bg-card: #ffffff; + --bg-canvas: #d2d6d9; + --bg-active: #2f7f86; + --bg-hover: #e2e8ea; + --bg-statusbar: #38464c; + --color-text: #222a2d; + --color-text-secondary: #637074; + --color-border: #c8d0d4; + --color-danger: #c84f46; + --color-accent: #2f7f86; + --toolbar-width: 52px; + --optionsbar-height: 50px; + --panel-width: 220px; + --statusbar-height: 28px; + --scrollbar-bg: #e2e8ea; + --scrollbar-thumb: #b7c1c6; + --scrollbar-thumb-hover: #9facb2; +} + +/* ── CSS 变量:中性深色主题 ── */ +[data-theme="dark"] { + --bg-window: #1d2225; + --bg-panel: #252b2f; + --bg-card: #202529; + --bg-canvas: #30363a; + --bg-active: #2f7378; + --bg-hover: #323a3f; + --bg-statusbar: #2a3338; + --color-text: #d8dee2; + --color-text-secondary: #8e9aa1; + --color-border: #3b454b; + --color-danger: #e06a60; + --color-accent: #46a0a6; + --scrollbar-bg: #202529; + --scrollbar-thumb: #4a555c; + --scrollbar-thumb-hover: #5c6971; +} + +/* ── 基础重置 ── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 12px; + color: var(--color-text); + background: var(--bg-window); + user-select: none; + -webkit-user-select: none; +} + +/* ── 滚动条 ── */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* ══════════════════════════════════════════════ + 整体布局 — 五区结构 + ══════════════════════════════════════════════ */ + +.app { + display: grid; + grid-template-columns: var(--toolbar-width) 1fr var(--panel-width); + grid-template-rows: var(--optionsbar-height) 1fr var(--statusbar-height); + grid-template-areas: + "toolbar optionsbar optionsbar" + "toolbar canvas panel" + "toolbar statusbar statusbar"; + width: 100%; + height: 100%; + overflow: hidden; +} + +.app.app--bars-swapped { + grid-template-rows: var(--statusbar-height) 1fr var(--optionsbar-height); + grid-template-areas: + "toolbar statusbar statusbar" + "toolbar canvas panel" + "toolbar optionsbar optionsbar"; +} + +.app.app--panel-left { + grid-template-columns: var(--toolbar-width) var(--panel-width) 1fr; + grid-template-areas: + "toolbar optionsbar optionsbar" + "toolbar panel canvas" + "toolbar statusbar statusbar"; +} + +.app.app--panel-left.app--bars-swapped { + grid-template-rows: var(--statusbar-height) 1fr var(--optionsbar-height); + grid-template-areas: + "toolbar statusbar statusbar" + "toolbar panel canvas" + "toolbar optionsbar optionsbar"; +} + +/* ── 工具栏(左侧) ── */ +.toolbar { + grid-area: toolbar; + background: var(--bg-panel); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 0; + gap: 2px; + overflow: hidden; + z-index: 10; +} + +.toolbar__tools, +.toolbar__footer { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.toolbar__tools { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 4px; +} + +.toolbar__footer { + flex-shrink: 0; + padding-top: 4px; +} + +.toolbar__btn { + width: 44px; + height: 44px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 4px; + transition: background 0.15s, color 0.15s; + font-size: 10px; +} + +.toolbar__btn:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.toolbar__btn--active { + background: var(--bg-active) !important; + color: #ffffff !important; +} + +.toolbar__btn svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.toolbar__btn span { + font-size: 9px; + line-height: 1; + white-space: nowrap; +} + +.toolbar__separator { + width: 36px; + height: 1px; + background: var(--color-border); + margin: 4px 0; + flex-shrink: 0; +} + +.toolbar__spacer { + flex: 1; +} + +.toolbar__account { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} + +.toolbar__account:hover { + background: var(--bg-hover); +} + +.toolbar__avatar { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--color-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.16); +} + +.toolbar__avatar-img { + display: block; + object-fit: cover; +} + +.toolbar__avatar--fallback { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-active); + color: #ffffff; + font-size: 12px; + font-weight: 600; +} + +/* ── 账户页 ── */ +.account-page { + width: 100%; + height: 100%; + background: var(--bg-window); + color: var(--color-text); + overflow: hidden; +} + +.account-page__shell { + display: grid; + grid-template-columns: 180px 1fr; + width: 100%; + height: 100%; +} + +.account-page__sidebar { + display: flex; + flex-direction: column; + gap: 18px; + padding: 18px 12px; + background: var(--bg-panel); + border-right: 1px solid var(--color-border); +} + +.account-page__brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 6px 12px; + border-bottom: 1px solid var(--color-border); +} + +.account-page__brand-mark { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: var(--bg-card); + border: 1px solid var(--color-border); + overflow: hidden; +} + +.account-page__brand-logo { + width: 24px; + height: 24px; + object-fit: contain; +} + +.account-page__brand-title { + font-size: 13px; + font-weight: 700; +} + +.account-page__brand-subtitle, +.account-page__eyebrow, +.account-card__label { + color: var(--color-text-secondary); + font-size: 11px; +} + +.account-page__nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.account-page__nav-item, +.account-page__back, +.account-page__header-back, +.account-page__theme-choice { + border: 1px solid transparent; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.account-page__nav-item { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + text-align: left; + font-size: 13px; +} + +.account-page__nav-item:hover, +.account-page__back:hover, +.account-page__header-back:hover, +.account-page__theme-choice:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.account-page__nav-item--active { + background: var(--bg-active) !important; + color: #ffffff !important; +} + +.account-page__back { + margin-top: auto; + padding: 9px 10px; + border-color: var(--color-border); + border-radius: 8px; + font-size: 12px; +} + +.account-page__main { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.account-page__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 28px 18px; + border-bottom: 1px solid var(--color-border); + background: var(--bg-card); +} + +.account-page__header h1 { + margin-top: 4px; + font-size: 24px; + line-height: 1.2; +} + +.account-page__header-back { + padding: 7px 12px; + border-color: var(--color-border); + border-radius: 7px; + font-size: 12px; +} + +.account-page__content { + flex: 1; + overflow-y: auto; + padding: 28px; +} + +.account-page__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 14px; +} + +.account-card { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 14px; + padding: 18px; + box-shadow: 0 8px 24px rgba(25, 42, 46, 0.06); +} + +.account-page__content > .account-card + .account-card { + margin-top: 14px; +} + +.account-card--profile { + display: flex; + align-items: center; + gap: 18px; +} + +.account-card__avatar-wrap { + flex-shrink: 0; +} + +.account-card__body { + min-width: 0; +} + +.account-card h2 { + margin: 4px 0 6px; + font-size: 22px; +} + +.account-card p { + margin-top: 8px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.7; +} + +.account-card__value { + margin-top: 5px; + font-size: 16px; + font-weight: 700; +} + +.account-page__avatar { + border-radius: 50%; + border: 1px solid var(--color-border); + object-fit: cover; + box-shadow: 0 6px 16px rgba(25, 42, 46, 0.14); +} + +.account-page__avatar--large { + width: 72px; + height: 72px; +} + +.account-page__avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-active); + color: #ffffff; + font-size: 26px; + font-weight: 700; +} + +.account-page__theme-row { + display: flex; + gap: 8px; + margin-top: 14px; +} + +.account-page__theme-choice { + padding: 7px 14px; + border-color: var(--color-border); + border-radius: 999px; + font-size: 12px; +} + +.account-page__theme-choice--active { + background: var(--bg-active) !important; + border-color: var(--bg-active) !important; + color: #ffffff !important; +} + +.account-about { + max-width: 820px; +} + +.account-about__hero { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + gap: 22px; + align-items: center; + overflow: hidden; + min-height: 190px; + padding: 26px; + border: 1px solid rgba(47, 127, 134, 0.28); + border-radius: 22px; + background: + radial-gradient(circle at 18% 20%, rgba(70, 160, 166, 0.28), transparent 34%), + linear-gradient(135deg, #ffffff 0%, var(--bg-card) 62%, #e3f0f1 100%); + box-shadow: 0 18px 46px rgba(25, 42, 46, 0.12); +} + +.account-about__hero-glow { + position: absolute; + right: -70px; + top: -90px; + width: 220px; + height: 220px; + border-radius: 50%; + background: rgba(47, 127, 134, 0.22); + filter: blur(10px); + pointer-events: none; +} + +.account-about__logo-wrap { + position: relative; + z-index: 1; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 28px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.72); + box-shadow: 0 16px 30px rgba(25, 42, 46, 0.14); +} + +.account-about__logo { + width: 66px; + height: 66px; + object-fit: contain; +} + +.account-about__hero-body { + position: relative; + z-index: 1; +} + +.account-about__kicker, +.account-about__section-label { + color: var(--color-accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-about__hero h2 { + margin-top: 8px; + font-size: 32px; + line-height: 1.1; +} + +.account-about__hero p, +.account-about__author p { + margin-top: 10px; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.8; +} + +.account-about__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.account-about__tags span { + padding: 5px 10px; + border: 1px solid rgba(47, 127, 134, 0.24); + border-radius: 999px; + background: rgba(47, 127, 134, 0.1); + color: var(--color-accent); + font-size: 11px; + font-weight: 700; +} + +.account-about__layout { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(260px, 1.1fr); + gap: 14px; + margin-top: 14px; +} + +.account-about__panel, +.account-about__footer { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 18px; + box-shadow: 0 8px 24px rgba(25, 42, 46, 0.06); +} + +.account-about__panel { + padding: 20px; +} + +.account-about__author-name { + margin-top: 8px; + font-size: 24px; + font-weight: 800; + line-height: 1.2; +} + +.account-about__contacts { + display: grid; + gap: 10px; +} + +.account-about__contact { + display: grid; + grid-template-columns: 38px 1fr; + gap: 12px; + align-items: center; + min-width: 0; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 14px; + color: var(--color-text); + text-decoration: none; + background: rgba(47, 127, 134, 0.04); + transition: transform 0.15s, border-color 0.15s, background 0.15s; +} + +.account-about__contact:hover { + transform: translateY(-1px); + border-color: var(--color-accent); + background: var(--bg-hover); +} + +.account-about__contact-icon { + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--bg-active); + color: #ffffff; + font-size: 14px; + font-weight: 800; +} + +.account-about__contact strong, +.account-about__contact em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-about__contact strong { + font-size: 13px; + line-height: 1.4; +} + +.account-about__contact em { + margin-top: 2px; + color: var(--color-text-secondary); + font-size: 12px; + font-style: normal; +} + +.account-about__footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + padding: 14px 16px; + color: var(--color-text-secondary); + font-size: 11px; +} + +.account-about__footer span { + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-hover); +} + +[data-theme="dark"] .account-about__logo-wrap { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.1); +} + +[data-theme="dark"] .account-about__hero { + background: + radial-gradient(circle at 18% 20%, rgba(70, 160, 166, 0.22), transparent 34%), + linear-gradient(135deg, #243035 0%, var(--bg-card) 62%, #1f2b2f 100%); +} + +[data-theme="dark"] .account-about__contact { + background: rgba(70, 160, 166, 0.08); +} + +@media (max-width: 760px) { + .account-page__shell { + grid-template-columns: 1fr; + } + + .account-page__sidebar { + display: none; + } + + .account-page__header { + padding: 18px; + } + + .account-page__content { + padding: 18px; + } + + .account-page__grid, + .account-about__layout, + .account-about__hero { + grid-template-columns: 1fr; + } + + .account-about__hero { + gap: 18px; + padding: 22px; + } + + .account-about__hero h2 { + font-size: 28px; + } +} + +.updates-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.update-record { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 14px; + padding: 18px; + box-shadow: 0 8px 24px rgba(25, 42, 46, 0.06); +} + +.update-record__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.update-record__header h2 { + font-size: 16px; + line-height: 1.3; +} + +.update-record__header time { + color: var(--color-text-secondary); + font-size: 11px; + white-space: nowrap; +} + +.update-record__changes { + display: flex; + flex-direction: column; + gap: 10px; +} + +.update-record__group { + display: grid; + grid-template-columns: 44px 1fr; + gap: 10px; + align-items: start; +} + +.update-record__group-title { + font-size: 12px; + font-weight: 700; + line-height: 1.8; +} + +.update-record__group--added .update-record__group-title { + color: #2e7d32; +} + +.update-record__group--adjusted .update-record__group-title { + color: #7b4f9d; +} + +.update-record__group--fixed .update-record__group-title { + color: #2f6fba; +} + +.update-record__group--improved .update-record__group-title { + color: #b7791f; +} + +.update-record__group--removed .update-record__group-title { + color: var(--color-danger); +} + +.update-record__group ul { + margin: 0; + padding-left: 16px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.8; +} + +.update-record__group li + li { + margin-top: 3px; +} + +/* ── 更新项平台限制标记 ── */ +.update-item__text { + display: inline-block; +} + +.update-item__platform-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + font-size: 10px; + font-weight: 600; + border-radius: 3px; + white-space: nowrap; + vertical-align: middle; + letter-spacing: 0.3px; +} + +.update-item__platform-badge--current { + background: rgba(46, 125, 50, 0.15); + color: #2e7d32; + border: 1px solid rgba(46, 125, 50, 0.3); +} + +.update-item__platform-badge--other { + background: rgba(191, 144, 0, 0.12); + color: #b7791f; + border: 1px solid rgba(191, 144, 0, 0.25); +} + +/* ── 选项栏(顶部) ── */ +.optionsbar { + grid-area: optionsbar; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + padding: 0 12px; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + z-index: 9; +} + +.app.app--bars-swapped .optionsbar { + border-top: 1px solid var(--color-border); + border-bottom: none; +} + +.optionsbar__controls { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* 选项栏控件 */ +.options-group { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.options-label { + font-size: 11px; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.options-slider { + width: 80px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--color-border); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.options-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-accent); + cursor: pointer; + border: 2px solid var(--bg-panel); +} + +.options-value { + font-size: 11px; + color: var(--color-text); + min-width: 30px; + text-align: center; +} + +.options-select { + background: var(--bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 2px 20px 2px 6px; + font-size: 11px; + cursor: pointer; + outline: none; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3E%3Cpath fill='%23637074' d='M4 5.5L1 2.5h6z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 6px center; +} + +.options-select:hover { + border-color: var(--color-accent); +} + +.options-color { + width: 24px; + height: 24px; + border: 1px solid var(--color-border); + border-radius: 3px; + cursor: pointer; + padding: 0; + background: transparent; +} + +.options-color::-webkit-color-swatch-wrapper { + padding: 0; +} + +.options-color::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.options-btn-group { + display: flex; + gap: 2px; +} + +.options-btn { + background: var(--bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 2px 8px; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.options-btn:hover { + background: var(--bg-hover); + border-color: var(--color-accent); +} + +.options-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.options-btn:disabled:hover { + background: var(--bg-card); + border-color: var(--color-border); +} + +.options-btn.active { + background: var(--bg-active); + color: #ffffff; + border-color: var(--bg-active); +} + +.options-btn-primary { + background: var(--color-accent) !important; + color: #ffffff !important; + border-color: var(--color-accent) !important; +} + +.options-btn-primary:hover { + opacity: 0.85; +} + +.options-btn-sm { + padding: 4px 10px; + font-size: 12px; + line-height: 1.2; +} + +.shape-picker-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 88px; + justify-content: space-between; +} + +.shape-picker-trigger__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex: 0 0 18px; +} + +.shape-picker-trigger__arrow { + color: var(--color-text-secondary); + font-size: 10px; +} + +.shape-picker-popover { + position: fixed; + z-index: 2000; + width: 246px; + max-width: calc(100vw - 16px); + color: var(--color-text); + filter: drop-shadow(0 14px 28px rgba(0, 0, 0, 0.22)); +} + +.shape-picker { + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 8px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); +} + +.shape-picker__header { + padding: 2px 4px 8px; + color: var(--color-text-secondary); + font-size: 11px; + letter-spacing: 0.04em; +} + +.shape-picker__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.shape-picker__item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 58px; + gap: 5px; + padding: 7px 5px; + color: var(--color-text); + background: var(--bg-panel); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s; +} + +.shape-picker__item:hover { + background: var(--bg-hover); + border-color: var(--color-accent); + transform: translateY(-1px); +} + +.shape-picker__item.active { + background: var(--bg-active); + border-color: var(--bg-active); + color: #ffffff; +} + +.shape-picker__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.shape-icon-svg { + display: block; + width: 100%; + height: 100%; + fill: rgba(47, 127, 134, 0.12); + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + overflow: visible; +} + +.shape-picker__label { + font-size: 11px; + line-height: 1; +} + +.shape-style-btn { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-width: 56px; + padding: 4px 7px; + line-height: 1; +} + +.shape-style-btn__swatch { + position: relative; + width: 22px; + height: 15px; + flex: 0 0 15px; + overflow: hidden; + border: 2px solid var(--shape-style-stroke); + border-radius: 4px; + background: #ffffff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); +} + +.shape-style-btn__swatch::before, +.shape-style-btn__swatch::after { + content: ''; + position: absolute; + inset: 0; +} + +.shape-style-btn__swatch::before { + background-image: + linear-gradient(45deg, rgba(0, 0, 0, 0.14) 25%, transparent 25%), + linear-gradient(-45deg, rgba(0, 0, 0, 0.14) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.14) 75%), + linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.14) 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0; +} + +.shape-style-btn__swatch::after { + background: var(--shape-style-fill); +} + +.shape-style-btn.active .shape-style-btn__swatch { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.72), 0 0 0 3px rgba(255, 255, 255, 0.18); +} + +.text-preset-btn { + background: #a8b0b5; + color: var(--text-preset-fill, var(--color-text)); + border-color: #8a949a; + font-size: 14px; + font-weight: 700; + line-height: 1.15; + -webkit-text-stroke: 0.45px var(--text-preset-stroke, transparent); + text-shadow: + 0 1px 0 var(--text-preset-stroke, transparent), + 1px 0 0 var(--text-preset-stroke, transparent), + 0 -1px 0 var(--text-preset-stroke, transparent), + -1px 0 0 var(--text-preset-stroke, transparent); +} + +.text-preset-btn:hover { + background: #9ca5aa; + border-color: #7c878d; +} + +.brush-color-btn { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.brush-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--brush-color); + border: 1px solid rgba(0, 0, 0, 0.24); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35) inset; +} + +/* ── 画布区(中央) ── */ +.canvas-area { + grid-area: canvas; + position: relative; + background: var(--bg-canvas); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.canvas-area__container { + position: relative; + width: 100%; + height: 100%; +} + +.canvas-area__container canvas { + display: block; +} + +/* 画布网格背景 */ +.canvas-area::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(45deg, var(--color-border) 25%, transparent 25%), + linear-gradient(-45deg, var(--color-border) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--color-border) 75%), + linear-gradient(-45deg, transparent 75%, var(--color-border) 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + opacity: 0.15; + pointer-events: none; +} + +/* 缩放控制 */ +.zoom-control { + position: absolute; + bottom: 6px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-panel); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 4px 8px; + z-index: 5; +} + +.zoom-control__btn { + width: 22px; + height: 22px; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; +} + +.zoom-control__btn:hover { + background: var(--bg-hover); +} + +.zoom-control__value { + font-size: 11px; + color: var(--color-text); + min-width: 40px; + text-align: center; + cursor: pointer; +} + +.zoom-control__value:hover { + color: var(--color-accent); +} + +/* ── 右侧面板(属性 / 图层) ── */ +.panel-area { + grid-area: panel; + background: var(--bg-panel); + border-left: 1px solid var(--color-border); + overflow: hidden; +} + +.app.app--panel-left .panel-area { + border-left: none; + border-right: 1px solid var(--color-border); +} + +.side-tabs { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.side-tabs__header { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + padding: 5px; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.side-tabs__tab { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 0; + height: 26px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 11px; + font-weight: 600; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.side-tabs__tab:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +.side-tabs__tab--active { + background: var(--bg-card); + color: var(--color-text); + border-color: var(--color-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.side-tabs__badge { + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--bg-hover); + color: var(--color-text-secondary); + font-size: 10px; + line-height: 16px; + text-align: center; +} + +.side-tabs__tab--active .side-tabs__badge { + background: var(--bg-active); + color: #ffffff; +} + +.side-tabs__content { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.side-tabs__pane { + height: 100%; + min-height: 0; +} + +.side-tabs__pane:not(.side-tabs__pane--active) { + display: none; +} + +.side-tabs__pane #property-panel, +.side-tabs__pane #layer-panel { + height: 100%; + min-height: 0; +} + +.side-tabs--tabs .panel__header { + display: none; +} + +.side-tabs--tabs .panel--property { + border-bottom: 0; +} + +.side-tabs--split .side-tabs__header { + display: none; +} + +.side-tabs--split .side-tabs__content { + display: flex; + flex-direction: column; +} + +.side-tabs--split .side-tabs__pane[data-panel-pane="property"] { + flex: 2; + height: auto; +} + +.side-tabs--split .side-tabs__pane[data-panel-pane="layer"] { + flex: 3; + height: auto; +} + +.panel { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; +} + +.panel--property { + border-bottom: 1px solid var(--color-border); +} + +.panel--layer { +} + +.panel--layer .panel__body { + display: block; + flex-wrap: initial; + gap: initial; + align-content: initial; +} + +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.panel__title { + font-size: 10px; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.panel__body { + flex: 1; + overflow-y: auto; + padding: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; + align-content: flex-start; +} + +/* ── 属性面板 ── */ +.property-section-title { + width: 100%; + padding: 4px 6px 2px; + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.4px; +} + +.property-empty { + padding: 8px; + text-align: center; + color: var(--color-text-secondary); + font-size: 11px; + width: 100%; +} + +.property-item { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 4px; + background: var(--bg-card); + border: 1px solid var(--color-border); + border-radius: 4px; + flex-shrink: 0; +} + +.property-item--wide { + min-width: 132px; +} + +.property-item--stacked { + width: 100%; + align-items: stretch; + flex-direction: column; + gap: 6px; + padding: 6px; +} + +.property-item--stacked label { + text-align: left; +} + +.property-shape-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} + +.property-shape-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 48px; + gap: 3px; + padding: 5px 3px; + color: var(--color-text); + background: var(--bg-panel); + border: 1px solid var(--color-border); + border-radius: 5px; + cursor: pointer; + font-size: 10px; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.property-shape-btn:hover { + background: var(--bg-hover); + border-color: var(--color-accent); +} + +.property-shape-btn.active { + background: var(--bg-active); + border-color: var(--bg-active); + color: #ffffff; +} + +.property-shape-btn__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.property-shape-btn__label { + line-height: 1; +} + +.property-actions { + width: 100%; + display: flex; + gap: 6px; + padding: 4px; +} + +.property-btn { + flex: 1; + min-width: 0; + padding: 5px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--bg-card); + color: var(--color-text); + cursor: pointer; + font-size: 11px; + transition: background 0.15s, border-color 0.15s; +} + +.property-btn:hover { + background: var(--bg-hover); + border-color: var(--color-accent); +} + +.property-btn--primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: #ffffff; +} + +.property-btn--primary:hover { + opacity: 0.88; +} + +.property-item label { + font-size: 10px; + color: var(--color-text-secondary); + flex-shrink: 0; + text-align: right; +} + +.property-input { + width: 60px; + background: transparent; + color: var(--color-text); + border: none; + padding: 1px 2px; + font-size: 11px; + outline: none; + text-align: center; + -moz-appearance: textfield; +} + +.property-input--name, +.property-input--text { + width: 96px; + text-align: left; +} + +.property-input::-webkit-inner-spin-button, +.property-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.property-input:focus { + outline: 1px solid var(--color-accent); + border-radius: 2px; +} + +.property-color { + width: 24px; + height: 20px; + border: 1px solid var(--color-border); + border-radius: 3px; + cursor: pointer; + padding: 0; + background: transparent; +} + +.property-select { + width: 120px; + background: var(--bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 1px 4px; + font-size: 11px; + outline: none; +} + +.property-select--short { + width: 88px; +} + +.property-checkbox { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--color-accent); +} + +.property-static { + min-width: 34px; + color: var(--color-text); + font-size: 11px; +} + +.property-input:disabled, +.property-select:disabled, +.property-color:disabled, +.property-checkbox:disabled, +.property-range:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.property-range { + width: 90px; + flex: 0 0 auto; + height: 3px; + -webkit-appearance: none; + appearance: none; + background: var(--color-border); + border-radius: 2px; + outline: none; +} + +.property-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-accent); + cursor: pointer; +} + +.property-value { + font-size: 10px; + color: var(--color-text-secondary); + min-width: 28px; + text-align: center; + flex-shrink: 0; +} + +/* ── 图层面板 ── */ +.layer-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 1px; + padding: 0; + margin: 0; + width: 100%; +} + +.layer-item { + position: relative; + display: flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + border: 1px solid transparent; + border-radius: 3px; + cursor: grab; + font-size: 11px; + color: var(--color-text); + transition: background 0.1s, border-color 0.1s, box-shadow 0.1s; +} + +.layer-item:active { + cursor: grabbing; +} + +.layer-item:hover { + background: var(--bg-hover); +} + +.layer-item--selected { + background: var(--bg-active) !important; + border-color: var(--color-accent); + box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.85); + color: #ffffff !important; +} + +.layer-item--selected .layer-item__thumbnail { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.58); + color: #ffffff; +} + +.layer-item--selected .layer-item__visibility:hover, +.layer-item--selected .layer-item__lock:hover { + background: rgba(255, 255, 255, 0.16); +} + +.layer-item--dragging { + opacity: 0.45; +} + +.layer-item--drop-before::before, +.layer-item--drop-after::after { + content: ''; + position: absolute; + left: 6px; + right: 6px; + height: 2px; + background: var(--color-accent); + border-radius: 2px; + box-shadow: 0 0 0 1px var(--bg-panel); + pointer-events: none; +} + +.layer-item--drop-before::before { + top: -1px; +} + +.layer-item--drop-after::after { + bottom: -1px; +} + +.layer-item__visibility { + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.7; + border-radius: 2px; +} + +.layer-item__visibility:hover { + opacity: 1; + background: var(--bg-hover); +} + +.layer-item__visibility svg { + width: 12px; + height: 12px; +} + +.layer-item__thumbnail { + width: 20px; + height: 14px; + flex-shrink: 0; + border-radius: 2px; + background: var(--bg-card); + border: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + font-size: 8px; + color: var(--color-text-secondary); +} + +.layer-item__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.layer-item__lock { + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.4; + border-radius: 2px; +} + +.layer-item__lock:hover { + opacity: 0.8; + background: var(--bg-hover); +} + +.layer-item__lock--locked { + opacity: 0.8; +} + +.layer-item__lock svg { + width: 10px; + height: 10px; +} + +/* 背景图层特殊样式 */ +.layer-item--background { + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + cursor: default; +} + +.layer-item--background .layer-item__name { + font-style: italic; +} + +.layer-item__lock--bg { + opacity: 0.3 !important; + cursor: default !important; + pointer-events: none; +} + +.layer-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px; + border-top: 1px solid var(--color-border); +} + +.layer-actions__btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.layer-actions__btn:hover { + background: var(--bg-hover); + color: var(--color-text); +} + +/* ── 状态栏(底部) ── */ +.statusbar { + grid-area: statusbar; + background: var(--bg-statusbar); + color: #ffffff; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 11px; + gap: 12px; + z-index: 9; +} + +.app.app--bars-swapped .statusbar { + border-bottom: 1px solid rgba(255,255,255,0.12); +} + +.statusbar__left { + display: flex; + align-items: center; + gap: 12px; +} + +.statusbar__item { + display: flex; + align-items: center; + gap: 4px; + opacity: 0.85; +} + +.statusbar__separator { + width: 1px; + height: 14px; + background: rgba(255,255,255,0.3); +} + +.statusbar__right { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; +} + +.statusbar__btn { + border: 1px solid rgba(255,255,255,0.55); + background: rgba(255,255,255,0.08); + color: #ffffff; + cursor: pointer; + padding: 3px 14px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + transition: background 0.15s, box-shadow 0.15s; +} + +.statusbar__btn:hover { + background: rgba(255,255,255,0.25); + border-color: rgba(255,255,255,0.75); + box-shadow: 0 1px 4px rgba(0,0,0,0.15); +} + +.statusbar__btn--primary { + background: #ffffff; + color: var(--bg-statusbar); + border-color: #ffffff; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.statusbar__btn--primary:hover { + background: #e8f7f8; + color: var(--bg-statusbar); + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── 标签页(多图编辑预留) ── */ +.tabs { + display: flex; + align-items: center; + background: var(--bg-panel); + border-bottom: 1px solid var(--color-border); + height: 28px; + flex-shrink: 0; +} + +.tab { + padding: 3px 12px; + font-size: 11px; + color: var(--color-text-secondary); + border-right: 1px solid var(--color-border); + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tab:hover { + background: var(--bg-hover); +} + +.tab--active { + background: var(--bg-window); + color: var(--color-text); +} + +.tab__close { + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + flex-shrink: 0; +} + +.tab__close:hover { + background: var(--color-border); +} + +/* ── 欢迎/导入界面 ── */ +.welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 20px; + color: var(--color-text-secondary); +} + +.welcome__icon { + width: 80px; + height: 80px; + border: 2px dashed var(--color-border); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.welcome__icon:hover { + border-color: var(--color-accent); + background: var(--bg-hover); +} + +.welcome__icon svg { + width: 36px; + height: 36px; + opacity: 0.5; +} + +.welcome__text { + font-size: 13px; +} + +.welcome__hint { + font-size: 11px; + opacity: 0.6; +} + +.welcome__btn { + background: var(--color-accent); + color: #ffffff; + border: none; + padding: 6px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: opacity 0.15s; +} + +.welcome__btn:hover { + opacity: 0.85; +} + +/* ── 加载状态 ── */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-secondary); + font-size: 13px; +} + +/* ── 隐藏类 ── */ +.hidden { + display: none !important; +} + +/* ── Toast 提示 ── */ +.toast { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + animation: toastFadeInOut 2s ease forwards; +} + +.toast--success { + background: #2e7d32; + color: #ffffff; + box-shadow: 0 4px 12px rgba(46,125,50,0.4); +} + +.toast--error { + background: #c62828; + color: #ffffff; + box-shadow: 0 4px 12px rgba(198,40,40,0.4); +} + +.toast__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +@keyframes toastFadeInOut { + 0% { opacity: 0; transform: translateX(-50%) translateY(-10px); } + 15% { opacity: 1; transform: translateX(-50%) translateY(0); } + 75% { opacity: 1; transform: translateX(-50%) translateY(0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-10px); } +} diff --git a/plugins/Image-Toolbox/src/ui/AccountPage.js b/plugins/Image-Toolbox/src/ui/AccountPage.js new file mode 100644 index 00000000..92311f46 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/AccountPage.js @@ -0,0 +1,568 @@ +import { eventBus } from '../../core/src/index.js'; +import { SIDE_PANEL_LAYOUT_KEY, SIDE_PANEL_LAYOUTS } from './SidePanelTabs.js'; +import { THEME_CHOICES, applyThemeChoice, getThemeChoice } from '../../core/src/utils/theme.js'; +import { updateCategories, updateRecords, PLATFORMS } from '../../core/src/updateRecords.js'; + +/** + * 获取当前平台标识 + */ +function getCurrentPlatform() { + if (typeof window === 'undefined') return null; + if (window.ztools) return PLATFORMS.ZTOOLS; + if (window.utools) return PLATFORMS.UTOOLS; + return null; +} + +/** + * 检查更新项是否应在当前平台显示 + * @param {null|string[]} platforms - 平台限制 (null=所有平台, ['utools']=仅utools等) + * @returns {boolean} 是否应显示 + */ +function shouldShowForCurrentPlatform(platforms) { + if (platforms === null || platforms === undefined) return true; + if (!Array.isArray(platforms)) return true; + + const currentPlatform = getCurrentPlatform(); + return platforms.includes(currentPlatform); +} + +export const EDITOR_BARS_LAYOUT_KEY = 'image-toolbox-editor-bars-layout'; +export const EDITOR_BARS_LAYOUTS = { + PRESETS_TOP: 'presets-top', + STATUS_TOP: 'status-top', +}; + +export const EDITOR_SIDE_PANEL_POSITION_KEY = 'image-toolbox-editor-side-panel-position'; +export const EDITOR_SIDE_PANEL_POSITIONS = { + RIGHT: 'right', + LEFT: 'left', +}; + +const VALID_EDITOR_BARS_LAYOUTS = new Set(Object.values(EDITOR_BARS_LAYOUTS)); +const VALID_EDITOR_SIDE_PANEL_POSITIONS = new Set(Object.values(EDITOR_SIDE_PANEL_POSITIONS)); + +/** + * Account page UI component. + * Opens from the avatar into a standalone page with side navigation. + */ +class AccountPage { + constructor(containerEl, editorEl, sidePanelTabs, host = null) { + this._el = containerEl; + this._editorEl = editorEl; + this._sidePanelTabs = sidePanelTabs; + this._host = host; + this._activeSection = 'mine'; + this._user = this._getHostUser(); + + this._render(); + this._bindEvents(); + } + + open() { + this._user = this._getHostUser(); + this._render(); + this._editorEl?.classList.add('hidden'); + this._el?.classList.remove('hidden'); + } + + close() { + this._el?.classList.add('hidden'); + this._editorEl?.classList.remove('hidden'); + } + + _render() { + if (!this._el) return; + + const sectionTitle = this._getSectionTitle(this._activeSection); + this._el.innerHTML = ` + + `; + } + + _bindEvents() { + if (!this._el) return; + + eventBus.on('account:open', () => this.open()); + + this._el.addEventListener('click', (e) => { + const navItem = this._closest(e.target, '[data-section]'); + if (navItem) { + this._activeSection = navItem.getAttribute('data-section'); + this._render(); + return; + } + + const action = this._closest(e.target, '[data-action]')?.getAttribute('data-action'); + if (action === 'back') { + this.close(); + return; + } + + const externalUrl = this._closest(e.target, '[data-external-url]')?.getAttribute('data-external-url'); + if (externalUrl) { + e.preventDefault(); + this._openExternalUrl(externalUrl); + return; + } + + const theme = this._closest(e.target, '[data-theme-choice]')?.getAttribute('data-theme-choice'); + if (theme) { + this._setTheme(theme); + this._render(); + return; + } + + const panelLayout = this._closest(e.target, '[data-side-panel-layout]')?.getAttribute('data-side-panel-layout'); + if (panelLayout) { + this._setSidePanelLayout(panelLayout); + this._render(); + return; + } + + const editorBarsLayout = this._closest(e.target, '[data-editor-bars-layout]')?.getAttribute('data-editor-bars-layout'); + if (editorBarsLayout) { + this._setEditorBarsLayout(editorBarsLayout); + this._render(); + return; + } + + const editorSidePanelPosition = this._closest(e.target, '[data-editor-side-panel-position]')?.getAttribute('data-editor-side-panel-position'); + if (editorSidePanelPosition) { + this._setEditorSidePanelPosition(editorSidePanelPosition); + this._render(); + } + }); + + this._el.addEventListener('error', (e) => { + const avatar = e.target.closest?.('.account-page__avatar-img'); + if (!avatar) return; + const fallback = document.createElement('div'); + fallback.className = avatar.className.replace('account-page__avatar-img', 'account-page__avatar-fallback'); + fallback.textContent = avatar.dataset.initial || 'U'; + avatar.replaceWith(fallback); + }, true); + } + + _renderNavItem(section, label) { + const isActive = this._activeSection === section; + return ` + + `; + } + + _closest(target, selector) { + if (!target) return null; + const match = typeof target.closest === 'function' + ? target.closest(selector) + : target.parentElement?.closest?.(selector) || null; + + if (match && typeof this._el?.contains === 'function' && !this._el.contains(match)) return null; + return match; + } + + _renderSection() { + if (this._activeSection === 'settings') return this._renderSettings(); + if (this._activeSection === 'updates') return this._renderUpdates(); + if (this._activeSection === 'about') return this._renderAbout(); + return this._renderMine(); + } + + _renderMine() { + const user = this._getUserView(); + const hostName = this._getHostName(); + return ` + + `; + } + + _renderSettings() { + const theme = getThemeChoice(); + const sidePanelLayout = this._getSidePanelLayout(); + const editorBarsLayout = this._getEditorBarsLayout(); + const editorSidePanelPosition = this._getEditorSidePanelPosition(); + return ` + + + + + + + + `; + } + + _renderAbout() { + const appVersion = this._getCurrentVersion(); + const hostName = this._getHostName(); + const hostVersion = this._getHostVersion(); + + 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)}
  • `; + } + + // 新格式(对象) + const text = item.text || ''; + const platforms = item.platforms; + const currentPlatform = getCurrentPlatform(); + + // 如果有平台限制且当前不是所有平台,添加平台标签 + let badge = ''; + if (Array.isArray(platforms) && platforms.length > 0 && platforms.length < 3) { + const platformLabels = { + 'utools': 'uTools', + 'ztools': 'ZTools', + 'local': '本地环境' + }; + const labels = platforms.map(p => platformLabels[p] || p).join('/'); + const isCurrentPlatform = shouldShowForCurrentPlatform(platforms); + const badgeClass = isCurrentPlatform ? 'update-item__platform-badge--current' : 'update-item__platform-badge--other'; + badge = `${this._escapeHTML(labels)}`; + } + + return `
  • ${this._escapeHTML(text)}${badge}
  • `; + } + + _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); + } + + _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; + } + + _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'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _escapeAttr(value) { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + _escapeHTML(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); + } +} + +export default AccountPage; diff --git a/plugins/Image-Toolbox/src/ui/LayerPanel.js b/plugins/Image-Toolbox/src/ui/LayerPanel.js new file mode 100644 index 00000000..8f96c51f --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/LayerPanel.js @@ -0,0 +1,392 @@ +import { eventBus } from '../../core/src/index.js'; + +/** + * 图层面板 UI 组件 + * 显示所有图层列表,支持显隐/锁定/选中/排序 + */ +class LayerPanel { + constructor(containerEl, layerManager) { + this._el = containerEl; + this._lm = layerManager; + this._dragLayerId = null; + this._dropPanelIndex = null; + this._selectedLayerId = null; + this._activeLayerIds = []; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
    +
    + 图层 + 0 +
    +
    +
      +
      +
      + + +
      +
      + `; + + this._refreshLayerList(); + } + + _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._el.addEventListener('click', (e) => { + const layerItem = e.target.closest('.layer-item'); + if (!layerItem) { + // Check whether an action button was clicked. + this._handleActionClick(e); + return; + } + + const layerId = parseInt(layerItem.dataset.layerId); + if (isNaN(layerId)) return; + + // Toggle visibility. + if (e.target.closest('.layer-item__visibility')) { + this._lm.toggleVisibility(layerId); + return; + } + + // 锁定切换 + if (e.target.closest('.layer-item__lock')) { + this._lm.toggleLock(layerId); + return; + } + + // 选中图层 + this._selectedLayerId = layerId; + this._activeLayerIds = []; + this._refreshLayerList(); + this._lm.selectLayer(layerId); + }); + + this._el.addEventListener('dragstart', (e) => this._handleDragStart(e)); + this._el.addEventListener('dragover', (e) => this._handleDragOver(e)); + this._el.addEventListener('drop', (e) => this._handleDrop(e)); + this._el.addEventListener('dragend', () => this._handleDragEnd()); + this._el.addEventListener('dragleave', (e) => { + if (!this._el.contains(e.relatedTarget)) { + this._clearDropIndicators(false); + } + }); + + } + + _handleDragStart(e) { + const layerItem = e.target.closest('.layer-item'); + if (!layerItem) return; + + const layerId = parseInt(layerItem.dataset.layerId); + const layer = this._lm.getLayerById(layerId); + if (!layer || layer.isBackground) { + e.preventDefault(); + return; + } + + this._dragLayerId = layerId; + this._dropPanelIndex = null; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(layerId)); + layerItem.classList.add('layer-item--dragging'); + } + + _handleDragOver(e) { + if (this._dragLayerId === null) return; + + const dropInfo = this._getDropInfo(e); + if (!dropInfo) return; + + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + + this._clearDropIndicators(false); + this._dropPanelIndex = dropInfo.panelIndex; + + if (this._isNoopDrop(dropInfo.panelIndex)) return; + + dropInfo.item.classList.add( + dropInfo.position === 'after' ? 'layer-item--drop-after' : 'layer-item--drop-before' + ); + } + + _handleDrop(e) { + if (this._dragLayerId === null) return; + + e.preventDefault(); + e.stopPropagation(); + + const dropInfo = this._getDropInfo(e); + const targetIndex = dropInfo ? dropInfo.panelIndex : this._dropPanelIndex; + + if (targetIndex !== null && !this._isNoopDrop(targetIndex)) { + this._lm.reorderLayer(this._dragLayerId, targetIndex); + } + + this._handleDragEnd(); + } + + _handleDragEnd() { + this._dragLayerId = null; + this._dropPanelIndex = null; + this._clearDropIndicators(); + } + + _getDropInfo(e) { + const item = e.target.closest('.layer-item'); + if (!item) return null; + + const targetLayerId = parseInt(item.dataset.layerId); + const layers = this._lm.getLayers(); + const targetLayer = layers.find(l => l.id === targetLayerId); + if (!targetLayer) return null; + + const overlayLayers = layers.filter(l => !l.isBackground); + if (targetLayer.isBackground) { + return { + item, + panelIndex: overlayLayers.length, + position: 'before', + }; + } + + const targetIndex = overlayLayers.findIndex(l => l.id === targetLayerId); + if (targetIndex === -1) return null; + + const rect = item.getBoundingClientRect(); + const position = e.clientY > rect.top + rect.height / 2 ? 'after' : 'before'; + + return { + item, + panelIndex: targetIndex + (position === 'after' ? 1 : 0), + position, + }; + } + + _isNoopDrop(panelIndex) { + if (this._dragLayerId === null) return true; + + const overlayLayers = this._lm.getLayers().filter(l => !l.isBackground); + const fromIndex = overlayLayers.findIndex(l => l.id === this._dragLayerId); + if (fromIndex === -1) return true; + + let insertIndex = Math.max(0, Math.min(panelIndex, overlayLayers.length)); + if (fromIndex < insertIndex) insertIndex -= 1; + return insertIndex === fromIndex; + } + + _clearDropIndicators(includeDragging = true) { + const selector = includeDragging + ? '.layer-item--dragging, .layer-item--drop-before, .layer-item--drop-after' + : '.layer-item--drop-before, .layer-item--drop-after'; + + this._el.querySelectorAll(selector) + .forEach(item => { + item.classList.remove('layer-item--dragging', 'layer-item--drop-before', 'layer-item--drop-after'); + }); + } + + _handleActionClick(e) { + const target = e.target; + if (target.id === 'layer-delete') { + const selected = this._getSelectedLayerId(); + if (selected !== null) { + this._lm.deleteLayer(selected); + } + return; + } + + if (target.id === 'layer-add') { + eventBus.emit('tool:requestChange', 'text'); + } + } + + _getSelectedLayerId() { + return this._selectedLayerId; + } + + _selectLayerFromActiveObject() { + const active = this._lm._cm?.getActiveObject(); + const layerIds = this._getSelectedLayerIds(active); + this._activeLayerIds = layerIds; + if (layerIds.length > 0) { + this._selectedLayerId = layerIds[0]; + } + this._refreshLayerList(); + } + + _selectLayerByObject(obj) { + if (!obj || obj.excludeFromLayer) return; + + const meta = this._lm.getLayerByObject?.(obj) || this._lm._findMeta?.(obj); + if (!meta) return; + + this._selectedLayerId = meta.id; + this._activeLayerIds = [meta.id]; + this._refreshLayerList(); + } + + _getSelectedLayerIds(activeObj) { + if (!activeObj) return []; + + const objects = activeObj.type === 'activeSelection' && typeof activeObj.getObjects === 'function' + ? activeObj.getObjects() + : [activeObj]; + + return objects + .map(obj => this._lm.getLayerByObject?.(obj) || this._lm._findMeta?.(obj)) + .filter(Boolean) + .map(meta => meta.id); + } + + _getSelectedLayerIdSet() { + const ids = this._activeLayerIds; + return ids.length > 0 ? new Set(ids) : new Set(this._selectedLayerId === null ? [] : [this._selectedLayerId]); + } + + _ensureSelectedLayerExists(layers) { + const layerIds = new Set(layers.map(layer => layer.id)); + this._activeLayerIds = this._activeLayerIds.filter(id => layerIds.has(id)); + + if (this._selectedLayerId === null) return; + if (!layerIds.has(this._selectedLayerId)) { + this._selectedLayerId = null; + } + } + + _refreshLayerList() { + // Prevent refresh -> syncLayers -> emit('layers:updated') -> refresh loops. + if (this._refreshing) return; + this._refreshing = true; + + const listEl = this._el.querySelector('#layer-list'); + const countEl = this._el.querySelector('#layer-count'); + if (!listEl) { this._refreshing = false; return; } + + this._lm.syncLayers(); + const layers = this._lm.getLayers(); + this._ensureSelectedLayerExists(layers); + if (countEl) countEl.textContent = layers.length; + + const selectedLayerIds = this._getSelectedLayerIdSet(); + + // 图标映射 + const typeIcons = { + 'i-text': 'T', + 'text': 'T', + 'textbox': 'T', + 'rect': '+', + 'circle': 'O', + 'image': '🖼', + 'group': '+', + 'path': '~', + 'triangle': '^', + }; + + let html = ''; + for (const layer of layers) { + const isSelected = selectedLayerIds.has(layer.id); + const isBg = layer.isBackground; + + // Use a custom icon for the background layer. + const icon = isBg + ? `` + : (typeIcons[layer.fabricObj.type] || '□'); + + const eyeIcon = layer.visible + ? `` + : ``; + + // 背景图层锁定图标灰显 + const lockIconClass = isBg ? 'layer-item__lock--bg' : (layer.locked ? 'layer-item__lock--locked' : ''); + const lockIcon = layer.locked + ? `` + : ``; + const layerName = this._escapeHTML(layer.name); + const layerTitle = this._escapeAttr(layer.name); + + html += ` +
    • + ${eyeIcon} + ${icon} + ${layerName} + ${lockIcon} +
    • + `; + } + + if (layers.length === 0) { + html = '
      暂无图层
      '; + } + + listEl.innerHTML = html; + this._refreshing = false; + } + + _escapeHTML(value) { + return String(value ?? '').replace(/[&<>"]/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + }[ch])); + } + + _escapeAttr(value) { + return this._escapeHTML(value).replace(/'/g, '''); + } +} + +export default LayerPanel; diff --git a/plugins/Image-Toolbox/src/ui/OptionsBar.js b/plugins/Image-Toolbox/src/ui/OptionsBar.js new file mode 100644 index 00000000..4e45afad --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/OptionsBar.js @@ -0,0 +1,176 @@ +import { eventBus } from '../../core/src/index.js'; + +/** + * Top options bar UI component. + * Renders presets for the active tool. + */ +class OptionsBar { + constructor(containerEl, toolManager) { + this._el = containerEl; + this._tm = toolManager; + this._currentTool = null; + this._shapePickerEl = null; + this._shapePickerAnchor = null; + this._boundDocumentClick = this._handleDocumentClick.bind(this); + this._boundKeyDown = this._handleKeyDown.bind(this); + this._boundRepositionShapePicker = this._positionShapePicker.bind(this); + + this._render(); + this._bindEvents(); + } + + _render() { + this._el.innerHTML = ` +
      + `; + } + + _bindEvents() { + // Update options when the active tool changes. + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._closeShapePicker(); + this._updateControls(); + }); + + [ + 'canvas:selectionCreated', + 'canvas:selectionUpdated', + 'canvas:selectionCleared', + 'canvas:objectModified', + 'canvas:restored', + 'image:loaded', + 'tool:propertiesChanged', + ].forEach(eventName => { + 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); + }); + } + + _updateControls() { + this._closeShapePicker(); + + const controlsEl = this._el.querySelector('#optionsbar-controls'); + if (!controlsEl) return; + + const module = this._tm.getCurrentModule(); + if (module && typeof module.getOptionsBarHTML === 'function') { + controlsEl.innerHTML = module.getOptionsBarHTML(); + } else { + controlsEl.innerHTML = ''; + } + } + + _handleControlEvent(e) { + const pickerToggle = e.target.closest('[data-shape-picker-toggle]'); + if (pickerToggle) { + e.preventDefault(); + e.stopPropagation(); + const module = this._tm.getCurrentModule(); + if (module && typeof module.getShapePickerHTML === 'function') { + this._toggleShapePicker(pickerToggle, module); + } + return; + } + + const target = e.target.closest('[data-preset]'); + if (!target) return; + + const module = this._tm.getCurrentModule(); + if (!module) return; + + if (module.applyPreset) module.applyPreset(target.dataset.preset); + this._updateControls(); + eventBus.emit('tool:propertiesChanged'); + } + + _toggleShapePicker(anchor, module) { + if (this._shapePickerEl && this._shapePickerAnchor === anchor) { + this._closeShapePicker(); + return; + } + + this._closeShapePicker(); + + const pickerEl = document.createElement('div'); + pickerEl.className = 'shape-picker-popover'; + pickerEl.innerHTML = module.getShapePickerHTML(); + pickerEl.addEventListener('click', (e) => { + const target = e.target.closest('[data-preset]'); + if (!target) return; + + const currentModule = this._tm.getCurrentModule(); + if (currentModule && currentModule.applyPreset) currentModule.applyPreset(target.dataset.preset); + this._closeShapePicker(); + this._updateControls(); + eventBus.emit('tool:propertiesChanged'); + }); + + document.body.appendChild(pickerEl); + this._shapePickerEl = pickerEl; + this._shapePickerAnchor = anchor; + this._positionShapePicker(); + + setTimeout(() => document.addEventListener('click', this._boundDocumentClick), 0); + document.addEventListener('keydown', this._boundKeyDown); + window.addEventListener('resize', this._boundRepositionShapePicker); + window.addEventListener('scroll', this._boundRepositionShapePicker, true); + } + + _positionShapePicker() { + if (!this._shapePickerEl || !this._shapePickerAnchor) return; + + const anchorRect = this._shapePickerAnchor.getBoundingClientRect(); + const pickerRect = this._shapePickerEl.getBoundingClientRect(); + const margin = 8; + const maxLeft = Math.max(margin, window.innerWidth - pickerRect.width - margin); + const left = Math.min(Math.max(anchorRect.left, margin), maxLeft); + + let top = anchorRect.bottom + margin; + if (top + pickerRect.height > window.innerHeight - margin) { + top = anchorRect.top - pickerRect.height - margin; + } + top = Math.max(margin, top); + + this._shapePickerEl.style.left = `${left}px`; + this._shapePickerEl.style.top = `${top}px`; + } + + _handleDocumentClick(e) { + if (!this._shapePickerEl) return; + if (this._shapePickerEl.contains(e.target) || this._shapePickerAnchor?.contains(e.target)) return; + this._closeShapePicker(); + } + + _handleKeyDown(e) { + if (e.key === 'Escape') this._closeShapePicker(); + } + + _closeShapePicker() { + if (!this._shapePickerEl) return; + + this._shapePickerEl.remove(); + this._shapePickerEl = null; + this._shapePickerAnchor = null; + document.removeEventListener('click', this._boundDocumentClick); + document.removeEventListener('keydown', this._boundKeyDown); + window.removeEventListener('resize', this._boundRepositionShapePicker); + window.removeEventListener('scroll', this._boundRepositionShapePicker, true); + } + + /** + * Destroy the options bar. + */ + destroy() { + this._closeShapePicker(); + // EventBus subscriptions are currently app-lifetime listeners. + } +} + +export default OptionsBar; diff --git a/plugins/Image-Toolbox/src/ui/PropertyPanel.js b/plugins/Image-Toolbox/src/ui/PropertyPanel.js new file mode 100644 index 00000000..adf3322c --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/PropertyPanel.js @@ -0,0 +1,663 @@ +import { eventBus } from '../../core/src/index.js'; +import { getFontOptionsHTML, recordFontUsage, isSystemFontsLoaded, onSystemFontsLoaded } from '../../core/src/utils/fonts.js'; + +/** + * Property panel UI component. + * Renders property editors for the selected layer type. + */ +class PropertyPanel { + constructor(containerEl, toolManager, canvasManager = null, layerManager = null) { + this._el = containerEl; + this._tm = toolManager; + this._cm = canvasManager || toolManager?._cm || null; + this._lm = layerManager; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
      +
      + 属性 +
      +
      +
      选中物件以编辑属性
      +
      +
      + `; + } + + _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(); + }); + + // Delegate property input handling. + this._el.addEventListener('input', (e) => { + this._handleInput(e); + }); + this._el.addEventListener('change', (e) => { + this._handleInput(e); + }); + this._el.addEventListener('click', (e) => { + this._handleInput(e); + }); + } + + _updateProperties() { + const bodyEl = this._el.querySelector('#property-body'); + if (!bodyEl) return; + + const module = this._tm.getCurrentModule(); + const active = this._getActiveObject(); + + if (active?.excludeFromProperty && module && typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + if (active) { + bodyEl.innerHTML = this._getLayerPropertiesHTML(active); + return; + } + + if (!module) { + bodyEl.innerHTML = '
      选中物件以编辑属性
      '; + return; + } + + // Prefer the active module's custom property panel when available. + if (typeof module.getPropertyPanelHTML === 'function') { + const html = module.getPropertyPanelHTML(); + if (html) { + bodyEl.innerHTML = html; + return; + } + } + + bodyEl.innerHTML = '
      选中物件以编辑属性
      '; + } + + _getLayerPropertiesHTML(active) { + const meta = this._getLayerMeta(active); + 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 renameDisabled = (!meta || isBackground) ? ' disabled' : ''; + const lockDisabled = isBackground ? ' disabled' : ''; + const opacity = active.opacity == null ? 1 : active.opacity; + const typeLabel = this._getTypeLabel(active, meta); + const layerName = meta?.name || typeLabel; + const width = this._getScaledWidth(active); + const height = this._getScaledHeight(active); + + let html = ''; + + html += ` +
      + + +
      +
      + + ${this._escapeHTML(typeLabel)} +
      +
      + + +
      +
      + + +
      + `; + + // 位置 + html += ` +
      + + +
      +
      + + +
      + `; + + // 大小(如有) + if (active.width !== undefined) { + html += ` +
      + + +
      +
      + + +
      + `; + } + + // 旋转 + html += ` +
      + + +
      + `; + + // Opacity. + html += ` +
      + + + ${Math.round(opacity * 100)}% +
      + `; + + if (isText) { + html += ` +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      + `; + } else if (this._supportsPaint(active)) { + html += ` +
      + + +
      +
      + + + ${this._getColorOpacityPercent(active.fill)}% +
      +
      + + +
      +
      + + + ${this._getColorOpacityPercent(active.stroke)}% +
      +
      + + +
      + `; + } + + return html; + } + + _clearProperties() { + const bodyEl = this._el.querySelector('#property-body'); + if (bodyEl) { + bodyEl.innerHTML = '
      选中物件以编辑属性
      '; + } + } + + _handleInput(e) { + const target = e.target.closest('[data-prop], [data-module-prop], [data-module-action], [data-module-preset]'); + if (!target || !this._el.contains(target)) return; + + const prop = target.dataset.prop; + const moduleProp = target.dataset.moduleProp; + const moduleAction = target.dataset.moduleAction; + const modulePreset = target.dataset.modulePreset; + if (!prop && !moduleProp && !moduleAction && !modulePreset) return; + if (e.type === 'click' && !moduleAction && !modulePreset) return; + + const module = this._tm.getCurrentModule(); + if (moduleProp || moduleAction || modulePreset) { + this._handleModuleInput(e, module, moduleProp, moduleAction, modulePreset); + return; + } + + const active = this._getActiveObject(); + if (!active) return; + + let value = target.type === 'checkbox' ? target.checked : target.value; + let propertyValue = value; + + switch (prop) { + case 'layerName': + if (e.type === 'change') { + this._renameLayer(active, value); + } + return; + case 'visible': + if (e.type === 'change') { + this._setLayerVisibility(active, value); + this._notifyObjectChanged(active); + } + return; + case 'locked': + if (e.type === 'change') { + this._setLayerLock(active, value); + this._notifyObjectChanged(active); + } + return; + case 'left': + case 'top': + case 'angle': { + const parsed = parseFloat(value); + if (!Number.isFinite(parsed)) return; + active.set(prop, parsed); + propertyValue = parsed; + break; + } + case 'width': + this._setScaledDimension(active, 'width', value); + break; + case 'height': + this._setScaledDimension(active, 'height', value); + break; + case 'opacity': { + const percent = this._clamp(parseInt(value, 10), 0, 100); + propertyValue = percent / 100; + active.set('opacity', propertyValue); + if (target.nextElementSibling) { + target.nextElementSibling.textContent = percent + '%'; + } + break; + } + case 'fillOpacity': + case 'strokeOpacity': { + const percent = this._clamp(parseInt(value, 10), 0, 100); + const paintProp = prop === 'fillOpacity' ? 'fill' : 'stroke'; + propertyValue = percent / 100; + active.set(paintProp, this._withColorOpacity(active[paintProp], propertyValue)); + if (target.nextElementSibling) { + target.nextElementSibling.textContent = percent + '%'; + } + break; + } + case 'text': + active.set('text', value); + break; + case 'fontFamily': + active.set('fontFamily', value); + if (e.type === 'change') recordFontUsage(value); + break; + case 'fontSize': + case 'strokeWidth': { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return; + propertyValue = parsed; + active.set(prop, parsed); + break; + } + case 'fill': + case 'stroke': + active.set(prop, this._withColorOpacity(value, this._getColorOpacity(active[prop], 1))); + break; + case 'fontWeight': + propertyValue = value ? 'bold' : 'normal'; + active.set('fontWeight', propertyValue); + break; + case 'fontStyle': + propertyValue = value ? 'italic' : 'normal'; + active.set('fontStyle', propertyValue); + break; + case 'underline': + active.set('underline', value); + break; + case 'textAlign': + active.set('textAlign', value); + break; + default: + return; + } + + active.setCoords(); + this._requestRender(); + + // Notify the module so it can keep custom linked state in sync. + if (module?.onPropertyChange) { + module.onPropertyChange(prop, propertyValue, { eventType: e.type }); + } + + if (e.type === 'change' && !active.excludeFromHistory) { + this._notifyObjectChanged(active); + } + } + + _handleModuleInput(e, module, prop, action, preset) { + if (!module) return; + + const target = e.target; + if (preset) { + if (typeof module.applyPreset === 'function') { + module.applyPreset(preset); + this._updateProperties(); + eventBus.emit('tool:propertiesChanged'); + } + return; + } + + if (action) { + if (typeof module.onToolPropertyAction === 'function') { + module.onToolPropertyAction(action, { eventType: e.type }); + } + return; + } + + if (!prop || typeof module.onToolPropertyChange !== 'function') return; + + const value = target.type === 'checkbox' ? target.checked : target.value; + const handled = module.onToolPropertyChange(prop, value, { eventType: e.type }); + + if (target.type === 'range' && target.nextElementSibling) { + target.nextElementSibling.textContent = `${value}${target.dataset.valueSuffix || 'px'}`; + } + + if (handled !== false && target.dataset.refreshProperty === 'true') { + this._updateProperties(); + } + } + + _getActiveObject() { + return this._cm?.getActiveObject?.() || null; + } + + _getLayerMeta(obj) { + if (!obj || !this._lm) return null; + if (typeof this._lm.getLayerByObject === 'function') { + return this._lm.getLayerByObject(obj); + } + return typeof this._lm._findMeta === 'function' ? this._lm._findMeta(obj) : null; + } + + _renameLayer(active, name) { + const meta = this._getLayerMeta(active); + const nextName = String(name || '').trim(); + if (!meta || !nextName) return; + this._lm.renameLayer(meta.id, nextName); + } + + _setLayerVisibility(active, visible) { + const meta = this._getLayerMeta(active); + if (meta && typeof this._lm?.setVisibility === 'function') { + this._lm.setVisibility(meta.id, visible); + return; + } + + active.set('visible', visible); + this._requestRender(); + } + + _setLayerLock(active, locked) { + const meta = this._getLayerMeta(active); + if (meta && typeof this._lm?.setLock === 'function') { + this._lm.setLock(meta.id, locked); + return; + } + + active.set({ selectable: !locked, evented: !locked }); + if (locked && this._cm?.canvas?.getActiveObject() === active) { + this._cm.canvas.discardActiveObject(); + } + this._requestRender(); + } + + _setScaledDimension(active, prop, value) { + const parsed = parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) return; + + const base = prop === 'width' ? active.width : active.height; + if (!base) return; + + active.set(prop === 'width' ? 'scaleX' : 'scaleY', parsed / base); + } + + _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(); + } + } + + _isTextObject(obj) { + return obj && (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox'); + } + + _supportsPaint(obj) { + return obj && obj.type !== 'image' && obj.type !== 'group' + && (obj.fill !== undefined || obj.stroke !== undefined || obj.strokeWidth !== undefined); + } + + _getScaledWidth(obj) { + if (typeof obj.getScaledWidth === 'function') return Math.abs(obj.getScaledWidth()); + return Math.abs((obj.width || 0) * (obj.scaleX || 1)); + } + + _getScaledHeight(obj) { + if (typeof obj.getScaledHeight === 'function') return Math.abs(obj.getScaledHeight()); + return Math.abs((obj.height || 0) * (obj.scaleY || 1)); + } + + _getTypeLabel(obj, meta) { + if (meta?.isBackground) return '背景图片'; + const labels = { + 'image': '图像', + 'i-text': '文字', + 'text': '文字', + 'textbox': '文字', + 'rect': '矩形', + 'circle': '圆形', + 'path': '路径', + 'triangle': '三角形', + 'group': '组合', + 'line': '线条', + }; + return labels[obj.type] || obj.type || '图层'; + } + + _getFontOptionsHTML(current) { + if (isSystemFontsLoaded()) { + return getFontOptionsHTML(current); + } + + // 异步加载字体,完成后更新属性面板 + onSystemFontsLoaded(() => { + this._updateProperties(); + }); + + return ''; + } + + _getSelectOption(value, label, current) { + const selected = this._normalizeValue(value) === this._normalizeValue(current) ? ' selected' : ''; + return ``; + } + + _normalizeValue(value) { + return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + } + + _toColorValue(value, fallback = '#000000') { + if (typeof value !== 'string') return fallback; + const color = value.trim(); + const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hex) { + if (color.length === 4) { + return '#' + color.slice(1).split('').map(ch => ch + ch).join(''); + } + return color; + } + + const rgb = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return '#' + [rgb[1], rgb[2], rgb[3]] + .map(v => this._clamp(parseInt(v, 10), 0, 255).toString(16).padStart(2, '0')) + .join(''); + } + + return fallback; + } + + _getColorOpacityPercent(color) { + return Math.round(this._getColorOpacity(color, 0) * 100); + } + + _getColorOpacity(color, fallback = 1) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value) return fallback; + if (value === 'transparent') return 0; + + const rgba = value.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)$/i); + if (rgba) { + const alpha = parseFloat(rgba[1]); + return this._clamp(Number.isFinite(alpha) ? alpha : fallback, 0, 1); + } + + return 1; + } + + _withColorOpacity(color, opacity) { + const alpha = this._clamp(Number.isFinite(opacity) ? opacity : 1, 0, 1); + const rgb = this._extractRgb(color); + + if (!rgb) return color; + if (alpha === 0 && String(color ?? '').trim().toLowerCase() === 'transparent') return 'transparent'; + + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this._formatAlpha(alpha)})`; + } + + _extractRgb(color) { + const value = String(color ?? '').trim().toLowerCase(); + if (!value || value === 'transparent') return { r: 0, g: 0, b: 0 }; + + if (/^#[0-9a-f]{6}$/i.test(value)) { + return { + r: parseInt(value.slice(1, 3), 16), + g: parseInt(value.slice(3, 5), 16), + b: parseInt(value.slice(5, 7), 16), + }; + } + + if (/^#[0-9a-f]{3}$/i.test(value)) { + return { + r: parseInt(value[1] + value[1], 16), + g: parseInt(value[2] + value[2], 16), + b: parseInt(value[3] + value[3], 16), + }; + } + + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) { + return { + r: this._clamp(parseInt(rgb[1], 10), 0, 255), + g: this._clamp(parseInt(rgb[2], 10), 0, 255), + b: this._clamp(parseInt(rgb[3], 10), 0, 255), + }; + } + + return null; + } + + _formatAlpha(alpha) { + return String(Math.round(alpha * 100) / 100); + } + + _formatNumber(value) { + const number = parseFloat(value); + if (!Number.isFinite(number)) return 0; + return Math.round(number * 100) / 100; + } + + _clamp(value, min, max) { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); + } + + _escapeHTML(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch])); + } + + _escapeAttr(value) { + return this._escapeHTML(value); + } +} + +export default PropertyPanel; diff --git a/plugins/Image-Toolbox/src/ui/SidePanelTabs.js b/plugins/Image-Toolbox/src/ui/SidePanelTabs.js new file mode 100644 index 00000000..69e1ad15 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/SidePanelTabs.js @@ -0,0 +1,140 @@ +import { eventBus } from '../../core/src/index.js'; + +const SIDE_PANEL_TAB_KEY = 'image-toolbox-side-panel-tab'; +export const SIDE_PANEL_LAYOUT_KEY = 'image-toolbox-side-panel-layout'; +export const SIDE_PANEL_LAYOUTS = { + TABS: 'tabs', + SPLIT: 'split', +}; + +const VALID_TABS = new Set(['property', 'layer']); +const VALID_LAYOUTS = new Set(Object.values(SIDE_PANEL_LAYOUTS)); + +/** + * Side panel container. + * Supports tab and split layouts, with remembered user preference. + */ +class SidePanelTabs { + constructor(containerEl, layerManager) { + this._el = containerEl; + this._lm = layerManager; + this._activeTab = this._getInitialTab(); + this._layout = this._getInitialLayout(); + + this._render(); + this._bindEvents(); + this._applyLayout(this._layout, false); + this._activateTab(this._activeTab, false); + this._updateLayerCount(); + } + + _render() { + this._el.innerHTML = ` +
      +
      + + +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + `; + } + + _bindEvents() { + this._el.addEventListener('click', (e) => { + const tabBtn = e.target.closest('[data-panel-tab]'); + if (!tabBtn) return; + + this._activateTab(tabBtn.dataset.panelTab, true); + }); + + eventBus.on('layers:updated', () => this._updateLayerCount()); + eventBus.on('image:loaded', () => this._updateLayerCount()); + eventBus.on('canvas:restored', () => this._updateLayerCount()); + } + + applyLayout(layout, persist = true) { + this._applyLayout(layout, persist); + } + + _activateTab(tabName, persist = true) { + if (!VALID_TABS.has(tabName)) return; + + this._activeTab = tabName; + this._syncTabs(); + this._syncPanes(); + + if (persist) { + localStorage.setItem(SIDE_PANEL_TAB_KEY, tabName); + eventBus.emit('sidePanel:tabChanged', tabName); + } + } + + _applyLayout(layout, persist = true) { + if (!VALID_LAYOUTS.has(layout)) return; + + this._layout = layout; + const shell = this._el.querySelector('.side-tabs'); + if (shell) { + shell.classList.toggle('side-tabs--tabs', layout === SIDE_PANEL_LAYOUTS.TABS); + shell.classList.toggle('side-tabs--split', layout === SIDE_PANEL_LAYOUTS.SPLIT); + } + + this._syncPanes(); + + if (persist) { + localStorage.setItem(SIDE_PANEL_LAYOUT_KEY, layout); + eventBus.emit('sidePanel:layoutChanged', layout); + } + } + + _syncTabs() { + this._el.querySelectorAll('[data-panel-tab]').forEach(btn => { + const active = btn.dataset.panelTab === this._activeTab; + btn.classList.toggle('side-tabs__tab--active', active); + btn.setAttribute('aria-selected', active ? 'true' : 'false'); + btn.setAttribute('tabindex', active ? '0' : '-1'); + }); + } + + _syncPanes() { + const isSplit = this._layout === SIDE_PANEL_LAYOUTS.SPLIT; + this._el.querySelectorAll('[data-panel-pane]').forEach(pane => { + const active = isSplit || pane.dataset.panelPane === this._activeTab; + pane.classList.toggle('side-tabs__pane--active', active); + pane.hidden = !active; + pane.setAttribute('aria-hidden', active ? 'false' : 'true'); + }); + } + + _updateLayerCount() { + const countEl = this._el.querySelector('#side-layer-count'); + if (!countEl) return; + + const count = this._lm?.getLayers?.().length || 0; + countEl.textContent = count; + } + + _getInitialTab() { + const saved = localStorage.getItem(SIDE_PANEL_TAB_KEY); + return VALID_TABS.has(saved) ? saved : 'property'; + } + + _getInitialLayout() { + const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY); + return VALID_LAYOUTS.has(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS; + } +} + +export default SidePanelTabs; diff --git a/plugins/Image-Toolbox/src/ui/StatusBar.js b/plugins/Image-Toolbox/src/ui/StatusBar.js new file mode 100644 index 00000000..7e64385f --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/StatusBar.js @@ -0,0 +1,74 @@ +import { eventBus } from '../../core/src/index.js'; + +/** + * Status bar UI component. + * Shows image size, layer count, engine version, and export shortcuts. + */ +class StatusBar { + constructor(containerEl, canvasManager, layerManager) { + this._el = containerEl; + this._cm = canvasManager; + this._lm = layerManager; + + this._bindEvents(); + this._render(); + } + + _render() { + this._el.innerHTML = ` +
      + -- × -- + + 图层: 0 + + Fabric.js 5.x +
      +
      + + +
      + `; + } + + _bindEvents() { + // Update size after image load. + eventBus.on('image:loaded', (img) => { + this._updateSize(img); + }); + + // Update size after canvas changes. + eventBus.on('canvas:objectModified', () => { + if (this._cm.originalImage) { + this._updateSize(this._cm.originalImage); + } + }); + + // Update layer count when layers change. + eventBus.on('layers:updated', (layers) => { + const countEl = this._el.querySelector('#status-layers'); + if (countEl) { + countEl.textContent = `图层: ${layers ? layers.length : this._lm.getCount()}`; + } + }); + + // 导出按钮 + this._el.addEventListener('click', (e) => { + if (e.target.id === 'status-save-file') { + eventBus.emit('export:requested', 'file'); + } else if (e.target.id === 'status-clipboard') { + eventBus.emit('export:requested', 'clipboard'); + } + }); + } + + _updateSize(img) { + const sizeEl = this._el.querySelector('#status-size'); + if (sizeEl && img) { + const w = Math.round(img.width * img.scaleX); + const h = Math.round(img.height * img.scaleY); + sizeEl.textContent = `${w} × ${h} px`; + } + } +} + +export default StatusBar; diff --git a/plugins/Image-Toolbox/src/ui/Toolbar.js b/plugins/Image-Toolbox/src/ui/Toolbar.js new file mode 100644 index 00000000..d6dda046 --- /dev/null +++ b/plugins/Image-Toolbox/src/ui/Toolbar.js @@ -0,0 +1,223 @@ +import { eventBus } from '../../core/src/index.js'; + +/** + * Toolbar UI component. + * Renders tool buttons and handles tool switching. + */ +class Toolbar { + constructor(containerEl, toolManager, host = null) { + this._el = containerEl; + this._tm = toolManager; + this._host = host; + this._currentTool = 'select'; + this._user = this._getHostUser(); + + // SVG 图标模板 + this._icons = { + select: ``, + mosaic: ``, + crop: ``, + brush: ``, + eraser: ``, + text: ``, + shape: ``, + undo: ``, + redo: ``, + }; + + this._render(); + this._bindEvents(); + } + + _render() { + const tools = this._tm.getTools(); + let toolsHtml = ''; + + let currentGroup = null; + + // Render tool groups in a stable order. + const groupOrder = ['edit', 'annotate', 'view', 'action']; + for (const group of groupOrder) { + const groupTools = tools.filter(t => t.group === group); + if (groupTools.length === 0) continue; + + if (currentGroup !== null) { + toolsHtml += '
      '; + } + currentGroup = group; + + for (const tool of groupTools) { + // 导出按钮放在底部 + if (tool.group === 'action') continue; + + const icon = this._icons[tool.icon] || ''; + const isActive = this._currentTool === tool.name; + toolsHtml += ` + + `; + } + } + + // 撤销 / 重做 + const footerHtml = ` +
      + + + ${this._renderAccount()} + `; + + this._el.innerHTML = ` +
      ${toolsHtml}
      + + `; + } + + _bindEvents() { + this._el.addEventListener('click', (e) => { + const account = e.target.closest('.toolbar__account'); + if (account) { + eventBus.emit('account:open'); + return; + } + + const btn = e.target.closest('.toolbar__btn'); + if (!btn) return; + + const toolName = btn.dataset.tool; + + if (toolName === 'undo') { + eventBus.emit('history:undo'); + return; + } + if (toolName === 'redo') { + eventBus.emit('history:redo'); + return; + } + + // 切换工具 + this._currentTool = toolName; + this._updateActive(); + this._tm.activateTool(toolName); + }); + + this._el.addEventListener('error', (e) => { + const avatar = e.target.closest?.('.toolbar__avatar-img'); + if (!avatar) return; + const fallback = document.createElement('div'); + fallback.className = 'toolbar__avatar toolbar__avatar--fallback'; + fallback.textContent = avatar.dataset.initial || 'U'; + avatar.replaceWith(fallback); + }, true); + + // 监听工具切换事件(外部触发) + eventBus.on('tool:changed', (toolName) => { + this._currentTool = toolName; + this._updateActive(); + }); + + // 监听历史状态变化,更新撤销/重做按钮 + eventBus.on('history:changed', ({ canUndo, canRedo }) => { + this._updateHistoryButtons(canUndo, canRedo); + }); + } + + _updateHistoryButtons(canUndo, canRedo) { + const undoBtn = this._el.querySelector('[data-tool="undo"]'); + const redoBtn = this._el.querySelector('[data-tool="redo"]'); + if (undoBtn) { + undoBtn.disabled = !canUndo; + undoBtn.style.opacity = canUndo ? '' : '0.4'; + } + if (redoBtn) { + redoBtn.disabled = !canRedo; + redoBtn.style.opacity = canRedo ? '' : '0.4'; + } + } + + _updateActive() { + const btns = this._el.querySelectorAll('.toolbar__btn'); + btns.forEach(btn => { + const tool = btn.dataset.tool; + if (tool === this._currentTool) { + btn.classList.add('toolbar__btn--active'); + } else { + btn.classList.remove('toolbar__btn--active'); + } + }); + } + + _renderAccount() { + const user = this._user || {}; + const name = user.nickname || user.name || user.userName || user.username || `${this._getHostName()} 用户`; + const avatar = user.avatar || user.avatarUrl || user.photo || ''; + const initial = this._getInitial(name); + const title = this._escapeAttr(name); + + if (avatar) { + return ` + + `; + } + + return ` + + `; + } + + _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('[Toolbar] 获取宿主用户信息失败:', e)); + return null; + } + return result; + } catch (e) { + console.warn('[Toolbar] 获取宿主用户信息失败:', e); + } + return null; + } + + _getHostName() { + return this._host?.platform?.name || this._host?.getHostName?.() || 'uTools'; + } + + _getInitial(name) { + const text = String(name || '').trim(); + return text ? text.slice(0, 1).toUpperCase() : 'U'; + } + + _escapeAttr(value) { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + _escapeHTML(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); + } +} + +export default Toolbar;