机电图纸工作台的批注在单点操作时很容易成立:点一下给水阀门,写入一个稳定的页锚点。真正把页面交给现场人员后,问题会出现在手势重叠的一瞬间。用户正用笔描出一段管线,另一只手指按到屏幕准备缩放;如果笔迹模块仍把后续位移当成绘制坐标,图纸上就会出现一条长线。反过来,双指已经开始调整视口,某根手指先抬起时,又可能被误认为一次新的批注点击。

前三篇已经把坐标和数据职责拆开:首层机电平面图的原始逻辑尺寸为4200×2970,内容视口按960×640讨论,给水阀门位于页锚点(2600,1850);第二篇有从屏幕到图纸的逆仿射矩阵,第三篇将给排水、强电和文字批注分成独立图层。现在要让触摸、笔输入与视口操作共用一套状态仲裁入口,最后仍把点位保存到原始页坐标,而不是某一次缩放后的屏幕位置。

图纸笔触与多指手势的操作边界

一、别让每个手势回调自行写批注

我把输入处理划分为三层:界面采集层负责接收平台事件,归一层把事件整理成固定字段,操作仲裁层才决定要不要创建批注。归一事件含pointerId、phase、kind、x、y、timestamp和当前接触点集合;phase用down、move、up、cancel表示。kind在业务上取pen、touch或unknown,只有实际事件来源能够可靠区分时才填具体值。设备没有笔类型标志时,让用户选择“批注工具”同样可以进入绘制模式,避免依据触点面积推断输入工具。

一个关键规则是同一时刻只有一个操作所有者。ink拥有尚未提交的笔迹缓存,viewport拥有缩放、旋转和平移的临时视角,idle没有活跃动作。两者不应该同时修改图纸数据。双指到达时,当前未提交笔迹必须明确作废或按照产品约定封存;这里选择作废,防止将缩放动作混进管线描画。取消操作只清空临时缓存,不删除第三篇已经持久保存的批注。

触点的坐标单位需要特别注意。ArkUI触摸对象中的局部位置采用vp坐标描述,业务几何模块应接收统一的视口坐标;若使用像素阈值,需要转换到同一单位后再判断。多端适配时把这一转换集中在输入适配层,不在每个批注工具里分别写比例系数。

二、使用状态机协调单指与双指

指针集合一旦从一根变成两根,就应退出单指批注候选态。两根指头缩放后即使一根先离开,也不能马上自动回到绘制态,而要等全部触点抬起后接受下一轮down。否则视口平移尾段会变成下一条笔迹的起点。手势的开始、取消和结束都应携带一个sessionId,异步处理返回前先检查会话是否仍然有效。

以下Node.js业务状态机只接受整理后的标准事件,不负责推断底层硬件种类。它显式记录活动触点,确保多点事件发生时撤销临时笔迹,并阻止迟到的move重新写入。函数返回新状态,方便在界面层采用不可变更新方式。

function routePointer(state, e) {
  const next = { ...state, active: new Set(state.active), draft: [...state.draft] };
  if (e.phase === 'cancel') return { mode: 'idle', active: new Set(), draft: [], blocked: false };
  if (e.phase === 'down') next.active.add(e.pointerId);
  if (e.phase === 'up') next.active.delete(e.pointerId);
  if (next.active.size > 1) {
    next.mode = 'viewport'; next.draft = []; next.blocked = true;
    return next;
  }
  if (next.blocked) {
    if (next.active.size === 0) return { mode: 'idle', active: new Set(), draft: [], blocked: false };
    return next;
  }
  if (e.phase === 'down' && e.tool === 'draw') {
    next.mode = 'ink'; next.draft = [{ x: e.x, y: e.y }];
  } else if (e.phase === 'move' && next.mode === 'ink' && next.active.has(e.pointerId)) {
    next.draft.push({ x: e.x, y: e.y });
  }
  if (e.phase === 'up' && next.active.size === 0) next.mode = 'pendingCommit';
  return next;
}
let s = { mode: 'idle', active: new Set(), draft: [], blocked: false };
s = routePointer(s, { phase: 'down', pointerId: 1, tool: 'draw', x: 10, y: 20 });
s = routePointer(s, { phase: 'down', pointerId: 2, tool: 'pan', x: 30, y: 40 });
console.assert(s.mode === 'viewport' && s.draft.length === 0);

这段代码表达的是“冲突时保守处理”,而不是完整绘图控件。pendingCommit表示本次指针序列结束、但数据尚待业务校验。真正提交前还要核验当前图层是否可写、绘制路径点数和视图矩阵版本。若用户在绘制过程中切换工具或折叠画布,同样发出cancel;不要单纯依赖最后一次up事件来完成资源清理。

三、笔迹点进入数据层前先回到页坐标

当页面旋转15度、缩放到1.5倍时,笔迹的屏幕轨迹不具备跨设备稳定性。我们沿用第二篇的六参数仿射矩阵,将每个采集点用逆矩阵映射回原始图纸页坐标,再写入第三篇的批注实体。记录里保留pageId、layerId、geometry、revision和稳定的annotationId;图层可见性只影响显示,不改变采样后的锚点。

这里必须避免一个容易忽略的竞态:触点开始时的相机矩阵与提交时的相机矩阵不同。即使从头到尾是一根笔,如果绘制期间页面被其他控件重新居中,按提交时矩阵反算也会让整条线偏移。做法是笔迹开始时冻结cameraRevision和逆矩阵,只有该会话结束后才将视口操作加入队列;或者当矩阵改变时取消当前笔迹。两种策略都可以,不能混合使用。

下面的坐标转换函数延续前篇定义的a、b、c、d、tx、ty矩阵格式,输入是同一视口坐标系中的点。对于几乎不可逆的矩阵直接拒绝写入,避免生成极端偏离图纸范围的锚点。

function screenToPage(m, p, width = 4200, height = 2970) {
  const det = m.a * m.d - m.b * m.c;
  if (!Number.isFinite(det) || Math.abs(det) < 1e-9) throw new Error('BAD_CAMERA');
  const x = p.x - m.tx, y = p.y - m.ty;
  const page = { x: (m.d * x - m.c * y) / det,
    y: (-m.b * x + m.a * y) / det };
  if (!Number.isFinite(page.x) || !Number.isFinite(page.y) ||
      page.x < 0 || page.y < 0 || page.x > width || page.y > height) {
    throw new Error('OUTSIDE_PAGE');
  }
  return page;
}
const camera = { a: 1.5, b: 0, c: 0, d: 1.5, tx: 30, ty: -20 };
const original = screenToPage(camera, { x: 3930, y: 2755 });
console.assert(original.x === 2600 && original.y === 1850);

视口中显示的“命中半径12px”也不能直接当作永久数据属性。命中范围应在屏幕维度判断,再通过当前有效矩阵还原目标候选点;保存结果时记录页坐标和图层归属即可。这样切换平板宽度、旋转视口或者重新打开文件,标注仍回到给水阀门的位置。

图纸输入仲裁与坐标转换开发环境

四、从绘制候选到持久批注还要过一道门

我的提交入口只接受pendingCommit状态、未取消的会话、有效图层以及与会话起始一致的相机版本。进入入口后先检查点数及相邻距离,去掉连续重复点和明显超出页面的异常坐标,随后以原始页单位持久化。对点按型标记可以只写一个锚点;对自由笔迹要写有序点列和笔宽,笔宽的语义应区分屏幕表现尺寸与图纸几何尺寸。

应用层的提交函数如下。它故意让输入会话与已存批注隔离:即使后续保存失败,也只丢弃本次草稿,已有图层仍可正常读取。更新状态需要结合存储的版本条件,防止多个组件同时写同一页时发生覆盖。

function commitStroke(session, page, layer) {
  if (session.mode !== 'pendingCommit' || session.blocked) {
    return { ok: false, reason: 'SESSION_NOT_READY' };
  }
  if (session.cameraRevision !== page.cameraRevision) {
    return { ok: false, reason: 'CAMERA_CHANGED' };
  }
  if (!layer.visible || !layer.editable || layer.pageId !== page.id) {
    return { ok: false, reason: 'LAYER_LOCKED' };
  }
  const points = session.pagePoints.filter((p, i, all) =>
    i === 0 || Math.hypot(p.x - all[i - 1].x, p.y - all[i - 1].y) > 0.01);
  if (points.length < 2) return { ok: false, reason: 'TOO_FEW_POINTS' };
  return { ok: true, record: { pageId: page.id, layerId: layer.id,
    geometry: points, baseRevision: page.dataRevision } };
}
const page = { id: 'first-floor', cameraRevision: 8, dataRevision: 4 };
const layer = { id: 'plumbing', pageId: 'first-floor', visible: true, editable: true };
const session = { mode: 'pendingCommit', blocked: false, cameraRevision: 8,
  pagePoints: [{ x: 2600, y: 1850 }, { x: 2610, y: 1850 }] };
console.assert(commitStroke(session, page, layer).ok);

为防止内存占用一路增加,草稿点列应该设置最大采样量和时间窗口;移动事件密集时使用最小距离筛选或分段缓冲,离开编辑页后释放本次输入订阅、绘制缓存和未完成会话。不能因为提高触控流畅度就把未写入成功的点列永久留在组件状态里。业务保存完成后只保留已提交的批注记录和必要的撤销入口,临时触点历史及时清空。

五、排查手势冲突,要看事件序列而不是最终画面

定位“捏合时画出斜线”,先记录事件相位、活动触点数、mode、blocked、cameraRevision和草稿长度。检查第一根指针尚未抬起时,第二根触点是否触发了草稿清空;再检查双指转单指的尾部是否被blocked隔离。若只有特定窗口宽度出现问题,重点检查触点位置是否与视口裁剪区域、坐标换算单位发生偏差,而不是直接增大命中半径。

对于触控事件的获取与手势组合,华为ArkUI文档分别提供onTouch、TouchEvent、PanGesture以及互斥组合的规则。布局同时绑定基础触摸事件与识别手势时,要有清晰的响应优先级;具体输入来源与硬件字段应以实际适配版本的事件类型为准。这里的pointerId与tool是业务归一层字段,不把它们当作平台直接提供的统一对象。

绘制与视口模式在同一页面的交互入口

六、下一步把文本标签与笔迹分开处理

经过这一层仲裁,图纸的所有编辑入口最终只产生受控的页坐标记录。单点标记、自由笔迹与双指视角调整不再彼此抢占同一份数据,图层显隐逻辑也保持独立。后续第五篇要处理文字标签避让时,可以直接读取稳定的锚点和图层,而无须再解释触点来自哪一根手指、发生在什么缩放级别。

更重要的是,图纸编辑的异常可以在业务层被明确定位:是输入所有权发生转移、相机矩阵变更、图层拒绝写入,还是存储版本冲突。把这些条件隔开处理,比不断调整手势回调里的判断顺序更容易维护,也有利于后续增加大屏鼠标操作与可访问的工具切换入口。

相关官方文档可参阅华为开发者的ArkUI触摸事件、PanGesture、互斥组合手势处理和自定义事件分发。这些资料为输入采集与手势组织提供依据,本文的模式仲裁、坐标约束与批注提交则由应用业务逻辑承担。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐