选区是像素画编辑器里很容易“看起来能动、实际会坏”的功能。用户框出一块区域后期望它能移动、清除、撤销还希望拖到边缘时被自然限制在画布内。若每一帧都在已经移动过的像素上继续移动选区会像被橡皮擦拖过一样留下残影若移动时只更新边框、不同步图层像素导出或切帧又会回到旧位置。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 区域快速来回拖动确认没有残影或像素增殖。把选区拖向画布四边确认内容和选区框都会在边缘停止。移动后撤销、重做再切换动画帧并切回确认像素位置一致。锁定图层后尝试清除或移动选区操作入口应不可用或保持无副作用。矩形选区检查记录 框选后只显示一个连续的矩形范围 移动预览不产生拖影 松手后只写入一条撤销记录 越界拖动被夹紧锁定层不允许更改矩形选区没有替代的变换能力当前实现是矩形选区不包含套索、多选区、复制粘贴浮动层和缩放旋转变换。矩形已经覆盖像素画中剪裁、平移和局部清除的高频需求更复杂的变换需要引入独立的浮动像素对象而不能只扩展四个边界坐标。这一篇的重点是让一次选区移动成为可撤销、可保存的完整事务。有了更多工具以后下一步要解决的是工具之间的尺寸、透明度和状态标签如何不互相串扰。