JEPA4Japan · 教程

第 10 章:让编辑器用起来顺手

5,337字 15分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

实现多选、缩放、旋转、吸附、连接线、剪贴板、拖动复制和边缘滚动,并在每个缩放级别保持稳定阈值。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

本章唯一的真理:专业 Canvas 的价值不在于对象可以被拖动,而在于每一条交互规则始终保持一致。

在桌上铺开方格纸。放上三个剪纸、一根两端各有插头的绳子和几块小磁铁。当孩子把剪纸拖到网格线附近时,磁铁会轻轻地将它吸附到位。当孩子捏住右下角时,一枚图钉会固定住左上角。拿着一张“保持形状”卡片时,剪纸只会按照原始比例变大。把剪纸推到桌边,桌布便会缓缓展开,让孩子能够继续移动。

先预测会发生什么。如果玩具城市放大到 400%,磁铁应该在相同的地图距离处吸附,还是在孩子视野中同样相距六像素时吸附?把右下角拖过左上角之后,哪个角会成为新的“固定图钉”?如果删除一个与绳子相连的剪纸,松开的那一端是否应该悄悄连接到别处?

  1. 固定对角拉动另一侧
  2. 仅在接近时吸附可见距离保持不变
  3. 插头各有名称连接不会自行猜测
  4. 在桌边展开不丢失手势,继续拖动
先做出选择:磁铁的阈值属于孩子的眼睛,还是玩具城市的地图?它属于眼睛,然后由 Camera 进行转换。

“手感”来自许多细小规则在每一种 Zoom、Rotation、Group 和输入设备下都保持一致。用户也许不知道 Pivot、Binding 或 Cursor Semantics 这些术语,但当控制柄忽大忽小、吸附时强时弱、选择框跳动,或复制的连接线错误连接时,他们会立即察觉。

将玩具转化为 Canvas 概念

玩具操作Canvas 实验室不变量
圈住一个或多个剪纸单选/多选/框选选择项是由稳定 ID 组成的集合
剪纸周围的透明边框选择边界从所选 Geometry 的 World Bounds 派生
八个小夹子缩放控制柄屏幕尺寸固定,命中规则独立
边框上方的圆环旋转控制柄围绕 Pivot 改变角度
固定图钉锚点/对角Resize 期间世界位置不会漂移
“保持形状”卡片宽高比约束由 Guard 决定约束是否生效
角度刻度盘角度约束相对于起始角度量化,而不是反复累加
小磁铁和亮线吸附/对齐参考线/均匀分布/网格阈值以屏幕像素定义
绳子插头连接线/绑定/端口引用稳定 ID 和明确端口
复印机剪贴板 Schema/复制/粘贴验证、重新分配 ID 并保留内部引用
在边缘展开桌布边缘滚动Camera 移动后重新计算 World Point

这个类比有其局限。磁铁不会自动解决候选项之间的优先级问题;实现需要确定性的规则。翻转剪纸很直观,但负 Scale 对文本而言可能不可接受。真实的绳子可以放在任何地方,而 Connector Binding 必须处理权限、删除和端口兼容性。剪贴板内容是不受信任的输入,并非你自己复印机的输出;用户和其他应用程序都可能更改它。

先消除错误直觉

  • “只要能拖动,编辑器就完成了。” 多选、跨越 Resize 边界、Rotation、吸附、键盘替代操作和 Undo 决定了可靠性。
  • “在世界空间中绘制 Handles 最简单。” Zoom 会让它们变成房屋或尘埃。应在 Screen Overlay 中保持其视觉尺寸和命中尺寸固定。
  • “为 Snap 使用固定的 10 个世界单位。” 磁力会随 Zoom 彻底改变。应从屏幕阈值进行转换。
  • “Resize 只会改变宽度和高度。” 旋转对象和多选要求将指针转换到 Selection Local Space,并围绕 Opposite Corner 更新矩阵。
  • “复制就是 JSON.stringify(selection)。” 外部 Bindings、未知 Shapes、恶意的超大载荷和重复 ID 都会破坏 Document。
  • “让连接线在每一帧查找最近的 Shape。” 它会在重叠的目标之间跳动。一旦提交,就应存储经过验证的 Binding。
  • “边缘滚动只会改变 Camera。” Camera 移动后,同一个 Screen Pointer 会映射到新的 World Point,因此必须重新计算 Preview。

生产级工具包

前置契约

第 8 章的 Geometry Provider 提供 Bounds、Outline、Nearest Point 和 Snap Points。第 9 章为 Tool State 提供明确的 Pointing、Dragging、Resizing 和 Rotating 状态,以及一个 Transaction。Document 拥有 Stable IDs、BindingRecords 和 Group Transforms。本章绝不会绕过 Command Boundary。

形式化知识

Single Selection 是一个 ID;Multi-Selection 是多个具有稳定顺序或集合语义的 ID。Marquee Select 将屏幕矩形转换为 World Query,然后应用“完全包含”或“相交”的产品规则。Selection Bounds 是派生状态,通常是所有选中对象变换后边界的并集。旋转后的多选可以使用与世界坐标轴对齐的边界框,也可以使用固定朝向的选择框。将这个决定记录在 ADR 中;绝不要在 Pointer Move 期间切换算法。

Resize Handles 和 Rotation Handle 通常绘制在 DOM 或 Canvas Overlay 的 Screen Space 中。在 Resize 开始时,冻结 initialSelectionSnapshot、Selection Matrix、Pivot、Anchor 和 Opposite Corner。每一帧都根据初始快照进行计算,而不是在上一帧 Preview 的基础上累加,从而避免浮点漂移。当 Pointer 越过 Opposite Handle 时,缩放符号会改变,控制柄的语义也会翻转。如果产品禁止镜像文本,则将尺寸限制在最小值,并显示正确的光标。Aspect-Ratio Constraint 根据初始宽高比求解。Angle Constraint 对 atan2(pointer-pivot)-startAngle 进行量化,例如按 15° 的增量量化。

吸附包含五项独立职责:生成候选项、距离评分、优先级、阈值和视觉反馈。Grid Snap 提供点。Alignment Guides 比较左/中/右和上/中/下。Distribution 比较间距。以屏幕 CSS px 定义阈值,然后使用 Camera 的逆变换获得世界容差。分数相同时,依次按类型优先级、Z-Order 和稳定 ID 决胜;绝不要依赖偶然的数组顺序。修饰键可以在操作中途启用或禁用吸附,而 Preview 每一帧都从原始指针位置重新计算。

Connector 是一种 Shape。Binding 将 Connector 的端点关联到目标 Shape 和 Port。Port Provider 提供可连接的点、方向和权限。Smart Connector 首先使用 Broad Phase 查找附近的端口,然后根据屏幕距离、方向和兼容性对它们评分。删除目标时,应遵循明确的产品规则:要么“解除绑定,并将端点固定在其最后的世界坐标点”,要么级联删除。绝不要留下悬空 ID。对于穿过 Shapes 的 Connector,可以稍后再实现正交路由或避障路由;本章首先保证绑定行为一致。

复制/粘贴会写入包含 mime/version/records/bindings/bounds 的 Clipboard Schema。粘贴时,首先限制字节数,然后解析、验证类型和字段、隔离 Unknown Shapes、为每个内部 Record 重新生成 ID,最后通过旧 ID 到新 ID 的映射重写 Parent 和 Binding 引用。指向未被复制的外部目标的 Binding 应当移除,或转换为自由端点。Drag/Drop 同样将 DataTransfer 视为不受信任的输入。Duplicate Drag 在越过阈值后创建一个 Preview 克隆,并提交一个 Batch;绝不要在每次 Move 时都进行克隆。

自由手绘使用第 7 章中的合并点。首先按最小屏幕距离进行重采样,然后应用移动平均、Catmull–Rom 或感知压力的笔画平滑。根据产品需求,可保留原始点以便重新编辑,同时由渲染器缓存平滑后的路径。光标语义应说明“现在可以做什么”:控制柄根据对象旋转角度选择正确的调整大小光标,而连接线端点、拖动复制、禁止的操作和文本编辑则分别提供相应反馈。此外,还要提供不只依赖光标的视觉和无障碍提示。

边缘滚动根据指针到视口边缘的距离计算相机速度,并依据 rAF 时钟进行积分,而不是依赖事件频率。捕获仍由 InputController 负责。每一帧先移动相机,再将固定的屏幕指针转换为新的世界坐标点,并更新预览。指针结束/取消、离开激活区域或切换工具时,都必须停止自动滚动。

证据与兼容性(已于 2026-08-29 验证)

  • MDN Clipboard API 说明异步剪贴板需要安全上下文、权限和用户激活。ClipboardEvent.clipboardData 是复制/粘贴事件的 DataTransfer 入口点。请保留事件内的 text/plain 后备方案。
  • MDN HTML Drag and Drop API 和 DataTransfer 定义了外部文件与数据拖放的边界;这些数据仍需验证。
  • 边缘滚动使用 MDN Pointer Capture 保持输入连续性,但 pointercancel/lostpointercapture 仍必须停止动画。
  • 有关可用的光标值和平台差异,请参阅 MDN cursor。光标不能是唯一的状态提示。

本章的工程增量

起点: 单对象平移、随缩放改变大小的控制柄,以及仅由两个点组成的折线连接线。终点: 多选、框选、调整大小、旋转、参考线、网格、智能连接线、复制/粘贴、修饰键复制和边缘滚动全部通过第 9 章的事务运行。

添加以下文件:

  • src/engine/interactions/SelectionTransform.ts:选择、枢轴点、调整大小和旋转;
  • src/engine/snapping/SnapEngine.ts:网格、对齐、分布候选项和参考线;
  • src/engine/bindings/ConnectorBinding.ts:端口和断开连接规则;
  • src/engine/clipboard/CanvasClipboard.ts:模式、ID 重映射和后备方案;
  • src/engine/freehand/resample.ts:合并点重采样和平滑;
  • src/engine/viewport/EdgeScroller.ts:基于 rAF 的边缘速度;
  • src/engine/interactions/__tests__/selection-transform.test.ts 和 tests/browser/editor-feel.spec.ts。

下面的完整核心代码以纯函数表达“固定对角、越界与翻转、宽高比约束以及基于屏幕阈值的吸附”。每次预览都根据冻结的 initial 值计算:

export type Vec = Readonly<{ x: number; y: number }>;
export type Bounds = Readonly<{ x: number; y: number; width: number; height: number }>;
export type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
export type Guide = Readonly<{
  axis: 'x' | 'y';
  value: number;
  sourceId: string;
  priority: number;
}>;
export type ResizeResult = Readonly<{
  bounds: Bounds;
  scaleX: number;
  scaleY: number;
  nextHandle: Handle;
}>;

const components: Record<Handle, readonly [-1 | 0 | 1, -1 | 0 | 1]> = {
  nw: [-1, -1],
  n: [0, -1],
  ne: [1, -1],
  e: [1, 0],
  se: [1, 1],
  s: [0, 1],
  sw: [-1, 1],
  w: [-1, 0],
};
const byComponents = new Map<string, Handle>(
  Object.entries(components).map(([name, pair]) => [pair.join(','), name as Handle]),
);

export function resizeFromHandle(
  initial: Bounds,
  handle: Handle,
  pointer: Vec,
  keepAspect: boolean,
  minimum = 1,
): ResizeResult {
  if (!(initial.width > 0) || !(initial.height > 0) || !(minimum > 0))
    throw new Error('RESIZE_BOUNDS_MUST_BE_POSITIVE');
  const [hx, hy] = components[handle];
  const opposite: Vec = {
    x: hx < 0 ? initial.x + initial.width : hx > 0 ? initial.x : initial.x + initial.width / 2,
    y: hy < 0 ? initial.y + initial.height : hy > 0 ? initial.y : initial.y + initial.height / 2,
  };
  let dx = hx === 0 ? initial.width : pointer.x - opposite.x;
  let dy = hy === 0 ? initial.height : pointer.y - opposite.y;

  if (keepAspect && hx !== 0 && hy !== 0) {
    const ratio = Math.max(minimum, initial.width) / Math.max(minimum, initial.height);
    if (Math.abs(dx / ratio) > Math.abs(dy)) dy = Math.sign(dy || hy) * Math.abs(dx / ratio);
    else dx = Math.sign(dx || hx) * Math.abs(dy * ratio);
  }
  const width = hx === 0 ? initial.width : Math.max(minimum, Math.abs(dx));
  const height = hy === 0 ? initial.height : Math.max(minimum, Math.abs(dy));
  const nextX = hx === 0 ? 0 : (Math.sign(dx || hx) as -1 | 1);
  const nextY = hy === 0 ? 0 : (Math.sign(dy || hy) as -1 | 1);
  const endpointX = opposite.x + nextX * width;
  const endpointY = opposite.y + nextY * height;
  const x = hx === 0 ? initial.x : Math.min(opposite.x, endpointX);
  const y = hy === 0 ? initial.y : Math.min(opposite.y, endpointY);
  const sx = hx === 0 ? 1 : (nextX * width) / (hx * initial.width);
  const sy = hy === 0 ? 1 : (nextY * height) / (hy * initial.height);
  return {
    bounds: { x, y, width, height },
    scaleX: sx,
    scaleY: sy,
    nextHandle: byComponents.get(`${nextX},${nextY}`)!,
  };
}

export function constrainedAngle(
  pivot: Vec,
  pointer: Vec,
  startPointerAngle: number,
  startShapeAngle: number,
  stepDegrees: number,
): number {
  const pointerAngle = Math.atan2(pointer.y - pivot.y, pointer.x - pivot.x);
  const delta = Math.atan2(
    Math.sin(pointerAngle - startPointerAngle),
    Math.cos(pointerAngle - startPointerAngle),
  );
  const step = (stepDegrees * Math.PI) / 180;
  const constrainedDelta = step > 0 ? Math.round(delta / step) * step : delta;
  return startShapeAngle + constrainedDelta;
}

export function snapPoint(
  raw: Vec,
  guides: readonly Guide[],
  thresholdScreenPx: number,
  cameraScale: number,
): Readonly<{ point: Vec; active: readonly Guide[] }> {
  if (
    !(cameraScale > 0) ||
    !Number.isFinite(cameraScale) ||
    !(thresholdScreenPx >= 0) ||
    !Number.isFinite(thresholdScreenPx)
  )
    return { point: raw, active: [] };
  const thresholdWorld = thresholdScreenPx / cameraScale;
  const choose = (axis: 'x' | 'y') =>
    guides
      .filter((g) => g.axis === axis && Math.abs(g.value - raw[axis]) <= thresholdWorld)
      .sort(
        (a, b) =>
          Math.abs(a.value - raw[axis]) - Math.abs(b.value - raw[axis]) ||
          b.priority - a.priority ||
          a.sourceId.localeCompare(b.sourceId),
      )[0];
  const xGuide = choose('x'),
    yGuide = choose('y');
  return {
    point: { x: xGuide?.value ?? raw.x, y: yGuide?.value ?? raw.y },
    active: [xGuide, yGuide].filter((g): g is Guide => Boolean(g)),
  };
}

剪贴板操作的关键是“验证并重命名”,而不是字符串化。这个可执行实现会拒绝未知版本、重复的源 ID 和过大的数据,然后重写内部连接。调用方的 makeId() 必须从文档的全局 ID 分配器中预留名称;在单个事务中写入经过完整验证的返回值:

type ClipShape = Readonly<{ id: string; type: 'rect' | 'ellipse' | 'text'; x: number; y: number }>;
type ClipBinding = Readonly<{ id: string; fromId: string; toId: string }>;
type CanvasClip = Readonly<{
  mime: 'application/x-canvas-lab+json';
  version: 1;
  shapes: readonly ClipShape[];
  bindings: readonly ClipBinding[];
}>;

export function parseAndRemapClipboard(text: string, makeId: () => string): CanvasClip {
  if (new TextEncoder().encode(text).byteLength > 1_000_000) throw new Error('CLIPBOARD_TOO_LARGE');
  let value: unknown;
  try {
    value = JSON.parse(text);
  } catch {
    throw new Error('CLIPBOARD_INVALID_JSON');
  }
  if (!value || typeof value !== 'object') throw new Error('CLIPBOARD_INVALID');
  const v = value as Record<string, unknown>;
  if (
    v.mime !== 'application/x-canvas-lab+json' ||
    v.version !== 1 ||
    !Array.isArray(v.shapes) ||
    !Array.isArray(v.bindings)
  )
    throw new Error('CLIPBOARD_UNSUPPORTED');
  const isShape = (s: unknown): s is ClipShape =>
    Boolean(
      s &&
      typeof s === 'object' &&
      typeof (s as ClipShape).id === 'string' &&
      ['rect', 'ellipse', 'text'].includes((s as ClipShape).type) &&
      Number.isFinite((s as ClipShape).x) &&
      Number.isFinite((s as ClipShape).y),
    );
  if (!v.shapes.every(isShape) || v.shapes.length > 5000)
    throw new Error('CLIPBOARD_INVALID_SHAPE');
  if (new Set(v.shapes.map((shape) => shape.id)).size !== v.shapes.length)
    throw new Error('CLIPBOARD_DUPLICATE_ID');
  const issued = new Set<string>();
  const nextId = () => {
    const id = makeId();
    if (!id || issued.has(id)) throw new Error('CLIPBOARD_ID_FACTORY_COLLISION');
    issued.add(id);
    return id;
  };
  const ids = new Map(v.shapes.map((s) => [s.id, nextId()]));
  const shapes = v.shapes.map((s) => ({ ...s, id: ids.get(s.id)!, x: s.x + 16, y: s.y + 16 }));
  const isBinding = (b: unknown): b is ClipBinding =>
    Boolean(
      b &&
      typeof b === 'object' &&
      typeof (b as ClipBinding).id === 'string' &&
      typeof (b as ClipBinding).fromId === 'string' &&
      typeof (b as ClipBinding).toId === 'string',
    );
  if (!v.bindings.every(isBinding) || v.bindings.length > 10_000)
    throw new Error('CLIPBOARD_INVALID_BINDING');
  const bindings = v.bindings.flatMap((b): ClipBinding[] => {
    const fromId = ids.get(b.fromId),
      toId = ids.get(b.toId);
    return fromId && toId ? [{ id: nextId(), fromId, toId }] : [];
  });
  return { mime: 'application/x-canvas-lab+json', version: 1, shapes, bindings };
}

测试会锁定最容易被目视检查遗漏的越界与缩放规则:

import { describe, expect, it } from 'vitest';
import { constrainedAngle, resizeFromHandle, snapPoint } from '../SelectionTransform';

describe('professional interaction invariants', () => {
  it('flips handle semantics after crossing the opposite corner while keeping the anchor fixed', () => {
    const result = resizeFromHandle(
      { x: 0, y: 0, width: 100, height: 50 },
      'se',
      { x: -20, y: -10 },
      false,
    );
    expect(result.nextHandle).toBe('nw');
    expect(result.bounds).toEqual({ x: -20, y: -10, width: 20, height: 10 });
    expect({
      x: result.bounds.x + result.bounds.width,
      y: result.bounds.y + result.bounds.height,
    }).toEqual({ x: 0, y: 0 });
  });

  it('does not incorrectly flip the northwest handle to southeast before it crosses', () => {
    const result = resizeFromHandle(
      { x: 0, y: 0, width: 100, height: 50 },
      'nw',
      { x: -20, y: -10 },
      false,
    );
    expect(result.nextHandle).toBe('nw');
    expect(result.scaleX).toBeGreaterThan(0);
    expect(result.scaleY).toBeGreaterThan(0);
    expect({
      x: result.bounds.x + result.bounds.width,
      y: result.bounds.y + result.bounds.height,
    }).toEqual({ x: 100, y: 50 });
  });

  it('keeps a 6px snap threshold the same at different zoom levels', () => {
    const guides = [{ axis: 'x' as const, value: 100, sourceId: 'grid-100', priority: 1 }];
    expect(snapPoint({ x: 105, y: 0 }, guides, 6, 1).point.x).toBe(100);
    expect(snapPoint({ x: 100.5, y: 0 }, guides, 6, 10).point.x).toBe(100);
    expect(snapPoint({ x: 100.7, y: 0 }, guides, 6, 10).point.x).toBe(100.7);
  });

  it('calculates rotation delta from the frozen start angle instead of accumulating each frame', () => {
    const degrees = (value: number) => (value * Math.PI) / 180;
    const angle = constrainedAngle(
      { x: 0, y: 0 },
      { x: Math.cos(degrees(29)), y: Math.sin(degrees(29)) },
      degrees(4),
      degrees(10),
      15,
    );
    expect(angle).toBeCloseTo(degrees(40));
  });
});

运行 pnpm vitest run src/engine/interactions src/engine/clipboard。边界越界、宽高比、旋转、ID 重映射和恶意载荷测试应全部通过。运行 pnpm playwright test tests/browser/editor-feel.spec.ts。在 25%、100% 和 400% 缩放级别下,控制柄边长的差异不应超过 1 px;距离参考线 5 px 的点应吸附,而距离 7 px 的点不应吸附。

回到磁铁表格。控制柄是夹子,对角是固定销,SnapEngine 是只有在可见距离足够近时才起作用的磁铁,而 Binding 是有名称的插头。这些代码并非为演示添加特效,而是在每一种缩放、旋转和取消路径中保持同一套物理规则。

故意破坏它

故障注入症状证据修复回归测试恢复
控制柄越过对角宽度为负;选择框消失缩放符号和锚点轨迹翻转控制柄语义并规范化边界参数化测试四个角的全部越界情况根据初始快照重新计算
旋转后的多选对象绕错误的中心旋转可视化选择矩阵和枢轴点修正选择坐标系90°/37° 多选测试丢弃累积变换
调整群组大小子对象发生漂移局部/世界坐标往返转换在群组局部空间中组合变换嵌套群组夹具恢复原始局部变换
更改缩放后吸附磁力变强或变弱屏幕距离和世界坐标阈值日志对屏幕阈值求逆三个缩放级别重新计算候选项
删除连接线目标Binding 悬空或错误地重新连接悬空 ID 验证器在最后的世界坐标点解除连接,或显式级联删除目标删除测试隔离无效 Binding
粘贴未知形状崩溃或出现空对象模式拒绝代码隔离未知类型或迁移版本未知类型夹具不写入文档
篡改剪贴板ID 重复或内存占用过大字节限制和验证器日志限制、验证并重映射对 JSON 进行模糊测试以原子方式回滚整个粘贴操作
边缘滚动 + 捕获丢失相机持续漂移活跃 rAF 数量取消操作停止滚动器捕获丢失浏览器测试停止 rAF 并回滚预览

用证据验收

要求可接受的证据不可接受
控制柄在每个缩放级别下大小相同浏览器像素测量结果在 1 px 以内“看起来差不多”
吸附体验一致在多个缩放级别下进行固定的 5/7 屏幕像素测试固定的世界坐标单位
选择内容不跳动根据冻结的快照重放计算一帧接一帧地累乘
连接线不会错误连接稳定的评分机制以及删除/重叠夹具“取最近的那个”
剪贴板安全大小/类型/版本/模糊测试/原子回滚测试仅在 JSON 外包一层 try/catch
边缘滚动会停止活跃 rAF 和取消断言释放后看不到移动
测试面自动化证据手动证据
控制柄和吸附阈值Playwright 像素测量和多缩放夹具使用真实鼠标和触摸屏比较操作感受
连接线稳定性Binding 验证器和删除/重叠测试拖过密集的端口并观察是否跳动
边缘滚动模拟时钟、rAF 和取消次数拖向四个边缘,并在中途让窗口失去焦点
  • 单选/多选/框选和选择边界具有确定性规则。
  • 调整大小/旋转控制柄、枢轴点、锚点、对角、宽高比和角度约束都有测试。
  • 对齐、分布和网格吸附共享屏幕阈值并绘制参考线。
  • 已定义连接线、Binding、端口和目标删除协议。
  • 复制/粘贴、拖放和拖动复制全部通过模式、ID 重映射和事务。
  • 自由手绘使用合并点、重采样和平滑策略;光标具有明确语义,但不是唯一提示。
  • 边缘滚动具有经过验证的停止、重新计算和取消路径,并使用指针捕获。

给五岁小孩讲明白

回答时不要使用“吸附”“缩放”“绑定”或“剪贴板”:

  1. 为什么小磁铁的距离要以孩子眼睛看到的距离为准,而不是以地图方格为准?
  2. 将剪纸的右下角拉过左上角的固定销后,它怎样才能继续自然地变大?
  3. 复制一根连线时,为什么新的剪纸和插头必须获得新名字?
  4. 新问题:孩子把剪纸推到桌边,桌布开始移动后,为什么必须重新测量手下方的地图位置?
显示参考答案 孩子操作的是眼前可见的图画,所以无论城市被放大还是缩小,磁铁都应在看起来同样近的时候起作用。夹子越过固定销后,它原来的名称会移到另一侧,而固定点仍留在原位。如果副本沿用旧名字,它的新连线就可能插到旧剪纸上,或与旧记录发生冲突,因此应先重命名整个副本,再重新连接它内部的连线。移动桌布会使地图在手下滑动,所以同一只手现在指向了另一条街道,相机必须再次将其转换为地图坐标。