Skip to content

chore(image-toolbox): release v2.3#301

Open
MoruTeaven wants to merge 3 commits into
ZToolsCenter:mainfrom
MoruTeaven:feat/image-toolbox-v2.3
Open

chore(image-toolbox): release v2.3#301
MoruTeaven wants to merge 3 commits into
ZToolsCenter:mainfrom
MoruTeaven:feat/image-toolbox-v2.3

Conversation

@MoruTeaven

Copy link
Copy Markdown
Contributor

更新内容

  • 更新图片工具箱版本至 v2.3
  • 新增 ColorModule 颜色调整模块
  • 新增 filters.js 工具模块
  • 新增 ColorPanel UI 组件
  • 重构核心模块和 UI 组件

抹露茶柒 added 2 commits June 29, 2026 18:03
1.  新增功能:
    - 图形工具新增平行四边形绘制
    - 文字属性面板新增删除线复选框支持
2.  问题修复:
    - 修复部分编辑操作首次撤销无反应的问题
    - 修复打开新图片后撤销可能恢复上一张图片的问题
    - 修复JPEG/WebP保存时实际写入PNG数据的问题
    - 修复ZTools端选中文字后缺少描边位置设置的问题
    - 修复裁剪撤销/重做时裁剪范围恢复不正确的问题
    - 修复清除所有马赛克后无法撤销恢复的问题
3.  代码优化:
    - 抽离公共工具函数到helpers模块,消除重复代码
    - 重构渲染、边界计算、颜色处理等通用逻辑
    - 完善组件销毁逻辑,正确解绑事件监听
    - 优化历史栈去重和恢复逻辑
    - 重构导出模块,支持宿主原生保存对话框
    - 统一属性面板的转义工具函数使用
    - 调整文件扩展名配置格式
Update version to 2.3
Add ColorModule for color adjustment features
Add filters.js utility module
Add ColorPanel UI component
Refactor core modules and UI components

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new 'Color' adjustment tool with filter presets and sliders, adds a parallelogram shape, and implements a strikethrough option for text. It also refactors common utility functions into a shared helper file and resolves several bugs related to undo/redo, event handling, and layer selection. The review feedback highlights critical issues, including missing history saves on filter changes (breaking undo), incorrect width calculations for the parallelogram shape, a missing getLayerByObject method in LayerManager, and potential memory leaks due to uncleaned DOM event listeners in ColorPanel. Additionally, performance optimizations are suggested for batch filter updates and snapshot comparisons in HistoryManager.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +153 to +155
if (eventType === 'change') {
eventBus.emit('canvas:objectModified', targets[0]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在调整滤镜滑块时,当 eventType'change'(即用户拖拽结束释放鼠标时),代码触发了 canvas:objectModified 事件,但并未调用 this.history?.saveState?.() 来保存历史状态。这会导致用户在调整亮度、对比度等滑块后,无法通过撤销(Undo)功能恢复到调整前的状态。\n\n建议在 eventType === 'change' 时,先调用 this.history?.saveState?.() 保存当前状态,再触发修改事件。

Suggested change
if (eventType === 'change') {
eventBus.emit('canvas:objectModified', targets[0]);
}
if (eventType === 'change') {
this.history?.saveState?.();
eventBus.emit('canvas:objectModified', targets[0]);
}

Comment on lines +172 to +175
if (e.type === 'change') {
this._notifyObjectChanged(targets[0]);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在调色面板中,当滑块的 change 事件触发时,代码调用了 this._notifyObjectChanged,但同样没有调用 this._hm?.saveState?.() 保存历史状态,导致用户无法撤销滑块的调整。\n\n建议在 e.type === 'change' 时,先调用 this._hm?.saveState?.() 保存状态。

    if (e.type === 'change') {
      this._hm?.saveState?.();
      this._notifyObjectChanged(targets[0]);
    }

Comment on lines +143 to +177
/**
* 设置/替换图片上的某类滤镜
* @param {fabric.Image} image
* @param {string} type
* @param {number} uiValue
*/
export function setFilter(image, type, uiValue) {
if (!image) return;

if (!Array.isArray(image.filters)) {
image.filters = [];
}

const FilterClass = getFilterClass(type);
if (!FilterClass) return;

// 移除同类型旧滤镜
const className = FILTER_CLASS_NAME[type];
image.filters = image.filters.filter(f => !(f && f.type === className));

// 默认值不写入(避免无效滤镜堆积)
const defaultValue = FILTER_RANGES[type]
? FILTER_RANGES[type].default
: (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0);
if (uiValue === defaultValue) {
applyFilters(image);
return;
}

const filterValue = uiToFilterValue(type, uiValue);
const attr = FILTER_ATTR_NAME[type];
const filter = new FilterClass({ [attr]: filterValue });
image.filters.push(filter);
applyFilters(image);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

setFilter 增加 skipApply 参数,以便在批量设置滤镜(如应用预设)时,避免多次重复调用昂贵的 applyFilters(image) 操作,从而大幅提升性能。

export function setFilter(image, type, uiValue, skipApply = false) {
  if (!image) return;

  if (!Array.isArray(image.filters)) {
    image.filters = [];
  }

  const FilterClass = getFilterClass(type);
  if (!FilterClass) return;

  // 移除同类型旧滤镜
  const className = FILTER_CLASS_NAME[type];
  image.filters = image.filters.filter(f => !(f && f.type === className));

  // 默认值不写入(避免无效滤镜堆积)
  const defaultValue = FILTER_RANGES[type]
    ? FILTER_RANGES[type].default
    : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0);
  if (uiValue === defaultValue) {
    if (!skipApply) {
      applyFilters(image);
    }
    return;
  }

  const filterValue = uiToFilterValue(type, uiValue);
  const attr = FILTER_ATTR_NAME[type];
  const filter = new FilterClass({ [attr]: filterValue });
  image.filters.push(filter);
  if (!skipApply) {
    applyFilters(image);
  }
}

Comment on lines +263 to +276
export function applyFilterPreset(image, presetName) {
if (!image) return false;
const preset = FILTER_PRESETS.find(p => p.preset === presetName);
if (!preset) return false;

// 重置全部为默认值,再应用预设覆盖
ALL_RESETTABLE_TYPES.forEach(type => {
const value = preset.filters[type] != null
? preset.filters[type]
: (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0));
setFilter(image, type, value);
});
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

优化 applyFilterPreset,在循环设置滤镜时传入 skipApply = true,并在循环结束后统一调用一次 applyFilters(image),避免连续触发 8 次像素级的滤镜渲染,极大提升预设切换的流畅度。

Suggested change
export function applyFilterPreset(image, presetName) {
if (!image) return false;
const preset = FILTER_PRESETS.find(p => p.preset === presetName);
if (!preset) return false;
// 重置全部为默认值,再应用预设覆盖
ALL_RESETTABLE_TYPES.forEach(type => {
const value = preset.filters[type] != null
? preset.filters[type]
: (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0));
setFilter(image, type, value);
});
return true;
}
export function applyFilterPreset(image, presetName) {
if (!image) return false;
const preset = FILTER_PRESETS.find(p => p.preset === presetName);
if (!preset) return false;
// 重置全部为默认值,再应用预设覆盖
ALL_RESETTABLE_TYPES.forEach(type => {
const value = preset.filters[type] != null
? preset.filters[type]
: (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0));
setFilter(image, type, value, true);
});
applyFilters(image);
return true;
}

Comment on lines +517 to +535
_createParallelogram(left, top, width, height, props) {
const centerX = left + width / 2;
const centerY = top + height / 2;
const skewX = width * 0.22;
const points = [
{ x: -width / 2 + skewX, y: -height / 2 },
{ x: width / 2 + skewX, y: -height / 2 },
{ x: width / 2 - skewX, y: height / 2 },
{ x: -width / 2 - skewX, y: height / 2 },
];

return new fabric.Polygon(points, {
...props,
left: centerX,
top: centerY,
originX: 'center',
originY: 'center',
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在绘制平行四边形时,当前计算的顶点坐标中,左右两侧的倾斜量 skewX 是直接加在 width / 2 上的。这会导致生成的平行四边形实际总宽度为 width + 2 * skewX(即 1.44 * width),超出了用户拖拽框选的实际宽度 width。\n\n为了保证平行四边形能够完美契合用户的拖拽边界,应当缩减底边的基础宽度 baseWidth,使得 baseWidth + 2 * skewX = width

  _createParallelogram(left, top, width, height, props) {
    const centerX = left + width / 2;
    const centerY = top + height / 2;
    // 为了使平行四边形的总宽度(包含倾斜部分)正好等于拖拽宽度 width,
    // 我们需要缩减底边的宽度。设倾斜量为总宽度的 15%,则底边宽度为总宽度的 70%。
    const skewX = width * 0.15;
    const baseWidth = width * 0.7;
    const points = [
      { x: -baseWidth / 2 + skewX, y: -height / 2 },
      { x: baseWidth / 2 + skewX, y: -height / 2 },
      { x: baseWidth / 2 - skewX, y: height / 2 },
      { x: -baseWidth / 2 - skewX, y: height / 2 },
    ];

    return new fabric.Polygon(points, {
      ...props,
      left: centerX,
      top: centerY,
      originX: 'center',
      originY: 'center',
    });
  }

Comment on lines +129 to +133
_refreshMetaState(meta, obj, isBackground = false) {
meta.visible = obj.visible !== false;
meta.locked = this._resolveLockedState(obj, isBackground);
meta.isBackground = !!isBackground;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

BaseModule.js 中,代码调用了 layerManager?.getLayerByObject?.(obj)。然而,LayerManager 类中并没有定义 getLayerByObject 方法,这会导致该调用始终返回 undefined 并降级使用 obj._layerLocked。\n\n建议在 LayerManager 中显式实现 getLayerByObject 方法,以保证接口的完整性和设计的统一性。

  _refreshMetaState(meta, obj, isBackground = false) {
    meta.visible = obj.visible !== false;
    meta.locked = this._resolveLockedState(obj, isBackground);
    meta.isBackground = !!isBackground;
  }

  getLayerByObject(obj) {
    return this._layers.find(l => l.fabricObj === obj) || null;
  }

Comment on lines +256 to +259
destroy() {
this._eventBusUnsubscribers.forEach(unsub => unsub());
this._eventBusUnsubscribers = [];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ColorPanel 初始化时,代码直接向 this._el 绑定了 inputchangeclick 事件监听器。然而在 destroy() 方法中,只清理了 eventBus 的订阅,并没有移除这些 DOM 事件监听器。\n\n如果 ColorPanel 被销毁并重新创建,或者宿主容器持续存在,这些未清理的监听器会导致内存泄漏,并可能在后续操作中触发重复的事件回调。\n\n建议在 _bindEvents 中保存绑定的事件处理函数,并在 destroy() 中进行清理。

  destroy() {
    this._eventBusUnsubscribers.forEach(unsub => unsub());
    this._eventBusUnsubscribers = [];
    if (this._boundHandleEvent) {
      this._el.removeEventListener('input', this._boundHandleEvent);
      this._el.removeEventListener('change', this._boundHandleEvent);
      this._el.removeEventListener('click', this._boundHandleEvent);
    }
  }

Comment on lines +153 to +162
_isSameSnapshot(a, b) {
if (a === b) return true;
if (!a || !b) return false;

try {
return JSON.stringify(a) === JSON.stringify(b);
} catch (err) {
return false;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

_isSameSnapshot 中,代码使用 JSON.stringify(a) === JSON.stringify(b) 来对比两个画布快照是否相同。\n\n对于包含大图(如 base64 格式的背景图或高分辨率贴图)的画布,JSON.stringify 序列化会非常消耗 CPU 和内存,在频繁保存历史状态(如自由绘制、拖拽等)时会导致明显的界面卡顿。\n\n建议未来考虑引入轻量级的版本号/修改计数器(Revision Counter)机制,或者在对比时过滤掉图片数据源(src)等大体积的静态属性,以避免高频的深度序列化对比。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant