15|HarmonyOS图纸批注变更日志与可撤销编辑【共创稿事节】
首层机电平面图的工作台已经解决了几个很容易互相牵连的问题:4200×2970的页坐标不跟随窗口缩放改变,视口960×640下的触点先经逆矩阵转换,给水阀门固定在页锚点(2600,1850),图层显隐和文字标签的位置分别保存。上一篇将底图分成512逻辑单位的块,屏幕平移时只调度可见区。这样一来,批注编辑终于不再依附于某个正在显示的图块。
接下来更难处理的是“撤销”。工程师在平板上移动一条批注,接着修改说明文字,再隐藏一个图层;随后点一次撤销,到底应该恢复文字,还是恢复视口,或者把已经隐藏的图层再打开?如果只是把整个页面状态压进一个数组,不仅每步都占用大量空间,还可能把底图缓存、临时触点、当前缩放倍率一起回滚,连前面稳定下来的坐标模型都被破坏。这篇只为批注业务建立变更日志,视口和分块缓存另行管理。

一、变更对象是批注记录,不是整张画布
我把批注实体定义为annotationId、pageId、drawingRevision、layerId、anchorX、anchorY、text、labelPosition、deleted和revision。其中anchorX与anchorY仍使用原始页空间;labelPosition延续第五篇的文字框避让结果;deleted标记业务删除,不依赖某个图块是否仍在缓存。图层visible是图层自身的界面属性,通常不与某条文字批注的编辑命令混用。用户说“撤销批注修改”时,撤销范围必须有明确语义。
事件日志单独保存eventId、annotationId、action、baseRevision、before、after、undoOf、createdAt及操作者标识。before与after只保存当前命令影响的批注业务字段,而非Canvas对象、绘制缓存或图片数据。对于需要追溯的工程审阅,删除也应留下版本与变更事件;要不要物理清理历史,需要依照业务保留期限另外管理。版本字段是乐观并发检查的依据,事件号是审计和重复写入检测的依据,不能用数组位置替代它们。
上一章的tileKey、requestGeneration和字节缓存预算仍然有效,但这些字段属于底图读取层。比如用户刚移动“给水阀门”时,相应图块可以被缓存淘汰,批注却必须留在独立记录里;再次显示图块时,先恢复原始页锚点,再应用当前矩阵绘制即可。将缓存命中当作“批注是否存在”的判断,会让撤销记录莫名丢失。
二、先检查基线版本,再写入一条完整事件
一次编辑命令应至少携带对象身份、期望修订号、修改后的字段和操作来源。进入持久层之前,必须验证目标页和图纸修订是否匹配、页坐标在0到4200及0到2970范围内,且新内容符合字段规则。用户在小窗口里拖动文字框后,应先把坐标转换回页空间,再组织命令;如果直接将屏幕像素写进事件,切到大屏时恢复出的历史位置就会失准。
下面用一个不依赖平台系统接口的Node.js状态函数整理编辑前后快照。函数不会偷偷更改调用者传入的对象。revision只在一条合法命令生效时递增;版本不匹配时拒绝本次更新,供上层呈现差异。
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
function editAnnotation(current, patch, expectedRevision) {
if (current.revision !== expectedRevision) {
throw new Error('批注已被其他编辑更新');
}
const before = structuredClone(current);
const after = { ...before, ...patch, revision: before.revision + 1 };
if (!Number.isFinite(after.anchorX) || !Number.isFinite(after.anchorY) ||
after.anchorX < 0 || after.anchorX > 4200 ||
after.anchorY < 0 || after.anchorY > 2970) {
throw new RangeError('页锚点超出图纸范围');
}
if (after.pageId !== before.pageId ||
after.drawingRevision !== before.drawingRevision) {
throw new Error('编辑不能改变图纸归属');
}
return { annotation: after, event: {
eventId: randomUUID(), annotationId: before.id, action: 'update',
baseRevision: before.revision, before, after, undoOf: null
} };
}
const valve = { id: 'valve', pageId: 'first-floor',
drawingRevision: 3, layerId: 'water', anchorX: 2600,
anchorY: 1850, text: '给水阀门', revision: 6 };
const result = editAnnotation(valve, { text: '给水总阀门' }, 6);
assert.equal(result.annotation.revision, 7);
assert.equal(valve.text, '给水阀门');
console.log(result.event.action, result.event.baseRevision);
实体修订和事件写入应放在同一存储事务中:先按annotationId及revision条件更新实体,再插入eventId唯一的变更事件,最后提交。事务一旦失败,两者都不应部分留下。对于多个入口同时保存同一个批注的情况,失败不是“再覆盖一次”就能解决的问题,界面需要展示当前版本和用户手里的差异。数据库读写返回的结果集应及时关闭,避免历史面板打开很久后累积无用句柄。
三、撤销采用新事件,不抹掉原有日志
假设给水阀门文字从“给水阀门”改为“给水总阀门”,当前实体revision为7,事件的before保存revision6的内容。此时用户要求撤销,可基于事件的before构造补偿编辑,但需要先确认当前实体仍是这次编辑产生的版本。如果另一个入口已经把文字改成第三个内容,撤销旧事件就可能覆盖别人的工作,应停下来提示冲突。
补偿撤销不是删掉原事件,也不是把实体修订号直接改回6。它生成一个新的revision8实体,并追加action=undo的事件,undoOf指向需要抵消的事件ID。这一做法保留了“先修改、后撤销”的可解释顺序,对审阅时追问某次更改是谁发起的更有帮助。后续若需要重做,可以对撤销事件再发起一条受版本约束的补偿命令,而不是依赖内存中的临时栈。
以下代码使用上一段事件结构完成补偿,当前修订不符就返回冲突。它还禁止对已经撤销过的事件重复施加同一次撤销操作。
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
function undoEvent(event, current, existingEvents) {
if (event.action !== 'update') throw new Error('当前只处理编辑撤销');
if (existingEvents.some(e => e.undoOf === event.eventId)) {
throw new Error('该事件已撤销');
}
if (current.id !== event.annotationId ||
current.revision !== event.after.revision) {
throw new Error('版本冲突,需要核对最新批注');
}
const restored = { ...event.before, revision: current.revision + 1 };
return { annotation: restored, event: {
eventId: randomUUID(), annotationId: current.id, action: 'undo',
baseRevision: current.revision, before: structuredClone(current),
after: structuredClone(restored), undoOf: event.eventId
} };
}
const prior = { id: 'valve', text: '给水阀门', revision: 6 };
const changed = { ...prior, text: '给水总阀门', revision: 7 };
const editing = { eventId: 'edit-text', annotationId: 'valve',
action: 'update', before: prior, after: changed };
const undone = undoEvent(editing, changed, []);
assert.equal(undone.annotation.text, '给水阀门');
assert.equal(undone.annotation.revision, 8);
assert.equal(undone.event.undoOf, 'edit-text');
console.log(undone.event.action, undone.annotation.revision);

四、历史回放要考虑重复事件与确定顺序
历史面板常需要把“新增、修改、撤销”的链路按时间展示,但时间戳不能单独承担排序。设备时间可能变化,两个事件也可能具有相同毫秒时间。为同一批注的事件分配由持久层控制的递增序号,可以更可靠地构建历史。事件ID保持唯一,用于处理恢复或重试写入时的重复请求。用户查看某一版本时,可读取最近的实体检查点,再顺序应用检查点之后的合法事件,减少全量回放的开销。
回放函数需要核对每条事件的baseRevision与上一状态,拒绝中间出现空洞的链。删除事件也应存一份可解释的after状态,比如设置deleted=true,并保留原有锚点,而不是直接把该实体从回放映射中移除。重放只计算逻辑数据,当前选择了哪张图块、哪一级缩放或是否展开侧边栏,不应改变回放结论。
const assert = require('node:assert/strict');
function replay(checkpoint, events) {
let value = structuredClone(checkpoint);
const seen = new Set();
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
if (seen.has(event.eventId)) continue;
seen.add(event.eventId);
if (event.annotationId !== value.id ||
event.baseRevision !== value.revision ||
event.after.revision !== value.revision + 1) {
throw new Error('历史事件出现断档或对象不匹配');
}
value = structuredClone(event.after);
}
return value;
}
const start = { id: 'valve', revision: 4, text: '阀门',
anchorX: 2600, anchorY: 1850 };
const one = { eventId: 'rename', seq: 5, annotationId: 'valve',
baseRevision: 4, after: { ...start, revision: 5, text: '给水阀门' } };
assert.equal(replay(start, [one, one]).text, '给水阀门');
console.log(replay(start, [one]).revision);
五、图层、缓存和撤销历史分别处理
本系列的图层管理包括给排水层、强电层和文字批注层。批注被隐藏时,逻辑实体仍然存在;在历史面板里撤销文字内容,应该依据对象修订决定,而不是依据当前图层是否可见。对于需要让“切换图层可见性”也参与撤销的产品,可以另建图层命令域和事件流,让用户看到当前操作对象是图层还是批注。不要为了统一快捷键就把两种生命周期硬塞进一张表。
底图块在第06篇使用512逻辑单位索引,页面移动时会淘汰离开可见区的块。用户点击撤销后,应更新批注层缓存并触发当前视口重绘;只有批注所在页和图纸修订匹配,才需要读取相应底图块。图块缓存缺失时可以先显示批注选择框和局部占位背景,随后补齐图元,不能将“图块暂时未载入”解释成“历史版本找不到”。为防止旧异步回放覆盖新选择,历史面板每次切换版本都分配独立generation标记。
若撤销后给水阀门跳到错误位置,优先核对事件快照里的anchorX与anchorY是否是页空间数值,再核对第二篇的仿射矩阵应用顺序。若文字恢复正确但框位置意外改变,检查第五篇排布配置是否也属于这次事件的修改集合,以及布局缓存是否在重绘时采用了旧修订。若报版本冲突,按事件ID、baseRevision、当前实体revision和undoOf逐项追踪,比仅记录“撤销失败”更利于定位。

六、历史可保留,页面资源应及时结束
退出页面时应清理的是悬停高亮、临时笔迹缓冲、操作菜单监听、未完成的视口渲染任务以及不再可见的图块缓存。编辑已写入的事件不应随着页面销毁而清空。对于正在提交的编辑,界面可暂时显示待提交状态;只有存储事务确认完成后,才把最新revision记为已保存。若发生错误,则保留原实体和本地编辑意图,提醒用户处理,而不制造一条看似成功的日志。
这章把撤销从“把页面往回放一帧”改成了“提交一条可解释的补偿变更”。原始锚点、图层可见性、分块缓存与批注版本终于各在合适的位置承担责任。下一篇再处理导出时的坐标一致性和资源回收:导出应取业务层当前修订后的批注,并应用清楚的页空间到输出空间变换,不能只截取屏幕上恰好加载的那几块图。
相关华为开发者资料可以参阅ArkGraphics 2D画布与绘制结果、画布矩阵与状态操作以及ArkData数据管理总览。图纸几何、事件日志、修订检查和补偿撤销都是应用领域数据规则,画布接口承担最终绘制和状态管理。
更多推荐



所有评论(0)