Skip to content

拖动折线的顶点:预览、提交与撤销各归谁管 ​

一个地图标注工具允许拖动折线的顶点。第一版在每次 pointermove 时直接改模型,拖一次就产生几十条撤销记录;按 Esc 想放弃时,模型早已改完了。改成「松开时才提交」之后,又出现了协同编辑的问题:用户拖着一个顶点的时候,另一个人改了另一个顶点,松开后对方的修改不见了;撤销一次,对方的修改又被撤掉了一遍。

交互编辑器里最容易混在一起的是三样东西:正式模型(数据是什么)、编辑会话(用户正在做但还没确认的事)、输入与绘制(事件怎么变成坐标、坐标怎么变成像素)。混在一起时,取消、撤销、协同中的每一个问题都要在事件处理函数里单独处理;分开之后,它们分别是模型上的一个操作、会话上的一个状态和适配层里的一次换算。

本文用一个原生 Canvas 的折线编辑器演示这种分层,用 Playwright 驱动真实 Chromium,在桌面宽度 DPR 1 和手机宽度 DPR 2 下验证每一种情况。实验见文末。

输入适配视口坐标 → 逻辑坐标DPR 只用于像素缓冲编辑会话指针、顶点、基准版本预览松开commit核对基准版本正式模型点序列、版本Esc、pointercancel:丢弃会话别处改过 → stale,不提交撤销(别处已把 p3 改到 y=40)快照式:换回整份点序列p3 回到 100,别人的修改被撤掉命令式:只还原改过的顶点p3 保留;p1 也被改过时报告冲突顶点用稳定 ID 而不是数组下标;坐标范围由模型限制
图 1 · 实测(Chromium 153,900px DPR 1 与 390px DPR 2 结果相同):拖动期间只改会话的预览,松开时提交一次;Esc 与 pointercancel 丢弃会话。拖动期间别处更新了模型,松开时因基准版本变化而不提交。快照式撤销把别处对 p3 的修改一起撤掉;命令式撤销只还原 p1,p1 也被别处改过时报告冲突。

一、三层各自的职责 ​

层文件知道什么不知道什么
正式模型与历史polyline.js带稳定 ID 的点序列、版本号、撤销栈画布、事件、像素
编辑会话editor.js 中的 session哪个指针、哪个顶点、基准版本、预览怎样绘制
输入适配与绘制editor.js视口坐标、devicePixelRatio、指针捕获撤销规则

顶点有稳定的 ID,而不是用数组下标表示身份:选中状态、属性面板、撤销记录都指向同一个对象,插入或删除顶点也不会让引用错位。

二、编辑会话:拖动期间只改预览 ​

js
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));
});
text
拖动中:预览 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 让指针拖出画布之后仍然把事件交给画布:

text
拖到画布左侧之外再松开:committed,p1(0,140)(x 限制在 0)

捕获只保证事件能收到,不保证坐标有效,限制坐标范围是模型的规则(movePoint 把坐标夹在逻辑画布之内)。

三、坐标换算属于适配层 ​

js
function toLogical(event) {
  const box = canvas.getBoundingClientRect();
  return { x: (event.clientX - box.left) * WIDTH / box.width, y: (event.clientY - box.top) * HEIGHT / box.height };
}
text
900px.dpr1:画布显示宽度 600px,像素缓冲宽度 600
390px.dpr2:画布显示宽度 358px,像素缓冲宽度 1200

模型使用固定的 600 × 300 逻辑坐标。手机宽度下画布显示为 358px,适配层按显示尺寸换算,同样的拖动得到同样的逻辑坐标,两种配置的所有结果都一致。devicePixelRatio 只影响像素缓冲:缓冲放大 DPR 倍,绘制时用 setTransform 缩回逻辑坐标,保证高分屏上清晰。它不能乘到模型坐标上,否则同一个文档在不同屏幕上会得到不同的数据。

四、基准版本:拖动期间模型被别处改了 ​

会话开始时记下正式模型的版本,提交时核对:

js
commit(baseRevision, next) {
  if (baseRevision !== this.#revision) return 'stale';
  if (samePoints(this.#points, next)) return 'unchanged';
  ...
}
text
拖动 p2 期间别处把 p3 改到 y=40:松开时 stale,结果 p1(0,140) p2(240,160) p3(430,40)

预览是基于旧版本的整份点序列,如果直接提交,就会把别处对 p3 的修改覆盖回去,开头那个「对方的修改不见了」就是这样产生的。这里选择了最保守的做法:版本变了就放弃这次拖动,让用户重新操作。更好的做法是把会话改成「移动 p2 到某处」这样的命令,在新版本上重新应用;那需要命令能判断自己是否还适用,下一节的撤销用的就是这个思路。

五、撤销:快照还是命令 ​

最简单的撤销是保存提交前的整份点序列,撤销时换回去:

text
随后撤销一次:p1(120,110) p2(240,160) p3(430,100)(快照式撤销把别处对 p3 的修改也撤掉了)

本地的撤销栈里存的是「别人修改之前」的快照,换回去等于把别人的修改也撤销了。单人编辑时快照式撤销简单可靠,有外部更新时就会出问题。

命令式撤销只记录这次编辑改动了哪些顶点、从哪里到哪里,撤销时只把这些顶点改回去,并且先检查它们当前的位置是否仍是这次编辑留下的:

js
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;
}
text
命令式撤销:提交 p1 的拖动、别处改 p3 之后撤销:applied,p1(80,80) p2(240,160) p3(430,40)
命令式撤销:提交 p1 的拖动、别处又改了 p1 之后撤销:conflict,p1(300,100)(不覆盖别人的修改)

别处改的是 p3 时,撤销只还原 p1,p3 的修改保留;别处也改了 p1 时,撤销报告冲突,不覆盖。冲突之后怎么办(放弃这条撤销记录,还是提示用户)是产品决定,但模型至少不会悄悄丢掉别人的数据。真正的协同编辑还需要服务端的条件更新与操作合并,本文没有涉及。

两种方式还有一个共同的规则:撤销之后再提交新的编辑,重做栈清空。

text
撤销后重做栈 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 由测试主动派发,没有模拟移动系统的真实中断。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权