拖动折线的顶点:预览、提交与撤销各归谁管
一个地图标注工具允许拖动折线的顶点。第一版在每次
pointermove时直接改模型,拖一次就产生几十条撤销记录;按 Esc 想放弃时,模型早已改完了。改成「松开时才提交」之后,又出现了协同编辑的问题:用户拖着一个顶点的时候,另一个人改了另一个顶点,松开后对方的修改不见了;撤销一次,对方的修改又被撤掉了一遍。
交互编辑器里最容易混在一起的是三样东西:正式模型(数据是什么)、编辑会话(用户正在做但还没确认的事)、输入与绘制(事件怎么变成坐标、坐标怎么变成像素)。混在一起时,取消、撤销、协同中的每一个问题都要在事件处理函数里单独处理;分开之后,它们分别是模型上的一个操作、会话上的一个状态和适配层里的一次换算。
本文用一个原生 Canvas 的折线编辑器演示这种分层,用 Playwright 驱动真实 Chromium,在桌面宽度 DPR 1 和手机宽度 DPR 2 下验证每一种情况。实验见文末。
一、三层各自的职责
| 层 | 文件 | 知道什么 | 不知道什么 |
|---|---|---|---|
| 正式模型与历史 | polyline.js | 带稳定 ID 的点序列、版本号、撤销栈 | 画布、事件、像素 |
| 编辑会话 | editor.js 中的 session | 哪个指针、哪个顶点、基准版本、预览 | 怎样绘制 |
| 输入适配与绘制 | editor.js | 视口坐标、devicePixelRatio、指针捕获 | 撤销规则 |
顶点有稳定的 ID,而不是用数组下标表示身份:选中状态、属性面板、撤销记录都指向同一个对象,插入或删除顶点也不会让引用错位。
二、编辑会话:拖动期间只改预览
canvas.addEventListener('pointerdown', event => {
...
session = { pointerId: event.pointerId, vertexId: hit.id, baseRevision: doc.revision, preview: doc.points };
canvas.setPointerCapture(event.pointerId); // 拖出画布后仍能收到 move 与 up
});
canvas.addEventListener('pointermove', event => {
if (!session || session.pointerId !== event.pointerId) return;
const at = toLogical(event);
session.preview = movePoint(session.preview, session.vertexId, at.x, at.y, WIDTH, HEIGHT);
});
canvas.addEventListener('pointerup', event => {
if (!session || session.pointerId !== event.pointerId) return;
endSession(doc.commit(session.baseRevision, session.preview));
});拖动中:预览 p1(120,110),正式模型 p1(80,80),版本 0
松开:committed,正式模型 p1(120,110),版本 1,撤销栈 1
拖动中按 Esc:cancelled,正式模型 p1(120,110),版本 3
拖动中派发 pointercancel:cancelled,正式模型 p1(120,110)几十次 pointermove 只改会话里的预览,松开时作为一次编辑提交,撤销栈只多一条。取消(Esc、pointercancel、丢失指针捕获)只需要丢掉会话,正式模型从没被改过,不需要「恢复」。
setPointerCapture 让指针拖出画布之后仍然把事件交给画布:
拖到画布左侧之外再松开:committed,p1(0,140)(x 限制在 0)捕获只保证事件能收到,不保证坐标有效,限制坐标范围是模型的规则(movePoint 把坐标夹在逻辑画布之内)。
三、坐标换算属于适配层
function toLogical(event) {
const box = canvas.getBoundingClientRect();
return { x: (event.clientX - box.left) * WIDTH / box.width, y: (event.clientY - box.top) * HEIGHT / box.height };
}900px.dpr1:画布显示宽度 600px,像素缓冲宽度 600
390px.dpr2:画布显示宽度 358px,像素缓冲宽度 1200模型使用固定的 600 × 300 逻辑坐标。手机宽度下画布显示为 358px,适配层按显示尺寸换算,同样的拖动得到同样的逻辑坐标,两种配置的所有结果都一致。devicePixelRatio 只影响像素缓冲:缓冲放大 DPR 倍,绘制时用 setTransform 缩回逻辑坐标,保证高分屏上清晰。它不能乘到模型坐标上,否则同一个文档在不同屏幕上会得到不同的数据。
四、基准版本:拖动期间模型被别处改了
会话开始时记下正式模型的版本,提交时核对:
commit(baseRevision, next) {
if (baseRevision !== this.#revision) return 'stale';
if (samePoints(this.#points, next)) return 'unchanged';
...
}拖动 p2 期间别处把 p3 改到 y=40:松开时 stale,结果 p1(0,140) p2(240,160) p3(430,40)预览是基于旧版本的整份点序列,如果直接提交,就会把别处对 p3 的修改覆盖回去,开头那个「对方的修改不见了」就是这样产生的。这里选择了最保守的做法:版本变了就放弃这次拖动,让用户重新操作。更好的做法是把会话改成「移动 p2 到某处」这样的命令,在新版本上重新应用;那需要命令能判断自己是否还适用,下一节的撤销用的就是这个思路。
五、撤销:快照还是命令
最简单的撤销是保存提交前的整份点序列,撤销时换回去:
随后撤销一次:p1(120,110) p2(240,160) p3(430,100)(快照式撤销把别处对 p3 的修改也撤掉了)本地的撤销栈里存的是「别人修改之前」的快照,换回去等于把别人的修改也撤销了。单人编辑时快照式撤销简单可靠,有外部更新时就会出问题。
命令式撤销只记录这次编辑改动了哪些顶点、从哪里到哪里,撤销时只把这些顶点改回去,并且先检查它们当前的位置是否仍是这次编辑留下的:
function applyChanges(points, changes, direction) {
const [expect, target] = direction === 'undo' ? ['to', 'from'] : ['from', 'to'];
const next = structuredClone(points);
for (const change of changes) {
const p = next.find(q => q.id === change.id);
if (p.x !== change[expect].x || p.y !== change[expect].y) return null; // 已被别处修改
Object.assign(p, { x: change[target].x, y: change[target].y });
}
return next;
}命令式撤销:提交 p1 的拖动、别处改 p3 之后撤销:applied,p1(80,80) p2(240,160) p3(430,40)
命令式撤销:提交 p1 的拖动、别处又改了 p1 之后撤销:conflict,p1(300,100)(不覆盖别人的修改)别处改的是 p3 时,撤销只还原 p1,p3 的修改保留;别处也改了 p1 时,撤销报告冲突,不覆盖。冲突之后怎么办(放弃这条撤销记录,还是提示用户)是产品决定,但模型至少不会悄悄丢掉别人的数据。真正的协同编辑还需要服务端的条件更新与操作合并,本文没有涉及。
两种方式还有一个共同的规则:撤销之后再提交新的编辑,重做栈清空。
撤销后重做栈 1 条,再提交一次新拖动:committed,重做栈 0 条;顶点 ID ['p1', 'p2', 'p3']六、检查清单
| 问题 | 做法 |
|---|---|
| 拖动产生大量历史记录 | 移动只改会话的预览,松开时提交一次 |
| 取消 | 丢弃会话;Esc、pointercancel、丢失捕获走同一条路径 |
| 拖出画布 | setPointerCapture,坐标范围由模型限制 |
| 屏幕尺寸与高分屏 | 适配层按显示尺寸换算到逻辑坐标;DPR 只用于像素缓冲 |
| 拖动期间模型被别处修改 | 会话记录基准版本,提交时核对 |
| 撤销覆盖别人的修改 | 有外部更新时用命令式撤销,并检查顶点是否已被修改 |
| 顶点身份 | 稳定 ID,不用数组下标 |
接入 React 等框架时,这三层的边界不变:模型与历史放在框架之外,会话可以是组件状态,适配层负责订阅与释放事件。组件卸载时的清理、外部状态库的更新顺序,需要在对应框架里另外验证。
配套实验
- codesphere-labs/design/polyline-edit-session:Playwright 1.63.0、Chromium 153,桌面宽度 DPR 1 与手机宽度 DPR 2:预览与提交、Esc 与
pointercancel、拖出画布、基准版本失效、快照式与命令式撤销(验证记录)。pointercancel由测试主动派发,没有模拟移动系统的真实中断。
参考资料