选区是像素画编辑器里很容易“看起来能动、实际会坏”的功能。用户框出一块区域后,期望它能移动、清除、撤销,还希望拖到边缘时被自然限制在画布内。若每一帧都在已经移动过的像素上继续移动,选区会像被橡皮擦拖过一样留下残影;若移动时只更新边框、不同步图层像素,导出或切帧又会回到旧位置。bitArt 选择把选区当成一段有明确起止状态的编辑事务,而不是普通的覆盖层。

选区不是一条边框,而是一段可控的像素范围

最容易出现的错误是把当前 layersPixels 当成下一次移动的输入。手指每移动一格,前一次结果已经包含位移;再在它上面移动就会造成内容反复搬运,像素被覆盖或拖出残影。另一个错误是只检查鼠标是否落在边框内,却忘记检查活动图层是否锁定,最终用户看见选区能拖动但数据并不允许改。

选区的边框和像素修改怎样分工

选区移动表面像画布上的一层边框,真正的难点在于临时预览和最终像素必须共享同一个起点。快照、边界和脏区缺一个,拖动都会变得不可靠。

移动时始终从原始快照重新计算

EditorCanvasDrawingWorkflowService 在按下选区内部时,先保存 selectionMoveSnapshot、起始触点和起始边界。Move 事件只计算相对起点的 delta,再把这份 delta 应用到快照。这样无论事件频率高低,某个位置的预览都只由“原始选区 + 当前偏移”决定。松手时再比较快照和现状,确认真的变化后才记录一条名为“移动选区”的撤销快照,并同步当前动画帧。

选区的清除也遵循同一个边界:先检查当前图层是否可编辑,再让图层数据服务产生结果。选区框本身不是历史数据;真正进入历史的是被修改的图层像素。

选区的触控分支首先判断是否进入移动会话,再把普通框选交给交互服务。

if (type === TouchType.Down && canBeginSelectionMove(target, row, col, valid)) {
  beginSelectionMove(target, row, col);
  return;
}
const result = EditorCanvasInteractionService.handleSelectionTouch(target, target.strokeActive,
  type === TouchType.Up || type === TouchType.Cancel, type === TouchType.Down,
  type === TouchType.Move, row, col, valid);
target.strokeActive = result.strokeActive;

边界约束要同时更新像素和选区坐标

偏移量不能直接等于手指移动量。工作流会根据选区起始矩形和画布宽高算出 minDelta 与 maxDelta,再对 rawDeltaRow 和 rawDeltaCol 夹紧。脏区则取旧边界与新边界的并集,所以渲染层既能擦掉旧位置,也能画出新位置。这个并集很关键,只刷新新位置会让旧位置残留,只刷新旧位置又看不到移动后的内容。

const bounds = clampSelectionMoveDelta(target, rawDeltaRow, rawDeltaCol);
const activeLayerIndex = LayerCanvasService.getLayerIndex(target.editorLayers, target.activeLayer);
const nextLayersPixels = LayerCanvasService.moveLayerBounds(target.selectionMoveSnapshot, activeLayerIndex,
  target.canvasWidth, target.canvasHeight, target.selectionMoveStartMinRow, target.selectionMoveStartMinCol,
  target.selectionMoveStartMaxRow, target.selectionMoveStartMaxCol, bounds.deltaRow, bounds.deltaCol);
target.selectionStartRow = target.selectionMoveStartMinRow + bounds.deltaRow;
target.selectionStartCol = target.selectionMoveStartMinCol + bounds.deltaCol;
target.selectionEndRow = target.selectionMoveStartMaxRow + bounds.deltaRow;
target.selectionEndCol = target.selectionMoveStartMaxCol + bounds.deltaCol;
EditorCanvasStateService.setLayersPixels(target, nextLayersPixels, bounds.dirtyBounds);

移动会话结束时再同步帧与历史

选区工具优先处理自己的触控状态。只有没有进入选区移动时,才把 Down、Move、Up 交给通用的矩形选区逻辑。移动结束后,工作流清空临时快照和 strokeActive,避免下一笔普通画笔继续沿用选区会话。这个状态收束也是撤销、自动保存和帧同步能够保持一致的原因。

选区的临时快照在松手后必须释放,活动帧则在真正改变时同步,防止下一次操作沿用旧会话。

if (target.selectionMoveSnapshot.length > 0 &&
  LayerCanvasService.hasLayersChanged(target.selectionMoveSnapshot, target.layersPixels)) {
  hooks.recordUndoSnapshot({ layers: LayerStackService.cloneLayers(target.editorLayers),
    layersPixels: LayerCanvasService.cloneLayersPixels(target.selectionMoveSnapshot),
    activeLayer: target.activeLayer, canvasSize: target.canvasSize,
    canvasWidth: target.canvasWidth, canvasHeight: target.canvasHeight }, '移动选区');
  target.redoStack = [];
  target.animationFrames = EditorFrameService.syncFrame(target.animationFrames, target.activeFrame,
    target.editorLayers, target.layersPixels, target.activeLayer);
  hooks.markProjectDirty();
}

选区拖动中需要守住的边界

  • 移动选区时不要在已经移动后的数组上继续搬运,应始终基于 selectionMoveSnapshot。
  • 拖动前需要同时检查选区是否激活、触点是否位于范围内以及活动图层是否锁定。
  • 脏区要覆盖旧边界和新边界的并集,否则移动后容易留下旧像素。

选区的视觉边框可以是临时叠加层,但数据修改必须只发生在活动图层。这样在有多层、隐藏层和动画帧时,用户不会因为看到了合成结果就误以为可以直接移动其他层的内容。选区结束后同步帧,保证时间轴缩略图、导出数据和画布显示都指向同一份修改。

用移动快照避免选区拖影

选区移动的核心测试不是慢慢拖一次,而是故意制造高频 Move 事件。可以先框选一个同时包含透明和不透明像素的区域,在 64×64 画布里快速向右、向左、再向下拖动,最后停在边缘。若实现依赖上一帧结果,透明洞会被填掉,或者原位置留下碎片;若实现依赖起始快照,任何中间位置都只是同一块内容的不同偏移。

还应把选区和动画结合起来检查。先在第一帧移动选区并松手,再切到第二帧做普通绘制,最后回到第一帧确认它已经同步。因为选区的移动不是视觉覆盖层,而是一笔真实的像素修改,所以它应与画笔、填充一样进入撤销、帧同步和自动保存路径。只有这三个结果都一致,选区才不只是一个“看起来能用”的框。

怎样确认选区移动没有破坏像素

选区检查要覆盖快速拖动和越界拖动,因为这两种路径最容易暴露残影问题。

  • 在一个 8×8 色块中框选 3×3 区域,快速来回拖动,确认没有残影或像素增殖。
  • 把选区拖向画布四边,确认内容和选区框都会在边缘停止。
  • 移动后撤销、重做,再切换动画帧并切回,确认像素位置一致。
  • 锁定图层后尝试清除或移动选区,操作入口应不可用或保持无副作用。
矩形选区检查记录
框选后只显示一个连续的矩形范围
移动预览不产生拖影
松手后只写入一条撤销记录
越界拖动被夹紧,锁定层不允许更改

矩形选区没有替代的变换能力

当前实现是矩形选区,不包含套索、多选区、复制粘贴浮动层和缩放旋转变换。矩形已经覆盖像素画中剪裁、平移和局部清除的高频需求;更复杂的变换需要引入独立的浮动像素对象,而不能只扩展四个边界坐标。

这一篇的重点是让一次选区移动成为可撤销、可保存的完整事务。有了更多工具以后,下一步要解决的是工具之间的尺寸、透明度和状态标签如何不互相串扰。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐