JEPA4Japan · チュートリアル

第10章:触って気持ちよいエディターにする

5,814文字 17分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

複数選択、Resize、Rotate、Snap、Connector、Clipboard、複製Drag、画面端Scrollを実装し、どのZoomでも閾値を一定に保ちます。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 現在のレッスン
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

まずは5歳児にもわかるゲームから

この章で覚える真実は一つだけです。プロフェッショナルな Canvas の価値は「ドラッグできること」ではなく、すべての操作ルールが一貫していることにあります。

テーブルに方眼紙を広げ、3枚の紙片、両端にプラグが付いたひも、小さな磁石をいくつか置きます。子どもが紙片を格子線の近くまでドラッグすると、磁石がそっと引き寄せます。右下をつまむと、左上のピンは動きません。「形を保つ」カードを押さえている間、紙片は元の比率のまま大きくなります。紙片をテーブルの端まで押すとテーブルクロスが少しずつ広がり、子どもはそのまま進めます。

先に予想してみてください。おもちゃの街を 400% に拡大したとき、磁石は地図上の同じ距離で働くべきでしょうか、それとも子どもの目に見える同じ 6 ピクセルで働くべきでしょうか。右下角を左上角の向こうまでドラッグしたら、どの角が新しい「動かないピン」になるでしょうか。ひもでつないだ紙片が削除されたら、ひもの端は黙って別の場所につながるべきでしょうか。

  1. 反対の角を固定もう一方を引く
  2. 近いときだけ引く見える距離は一定
  3. プラグには名前がある接続を推測しない
  4. テーブル端で広げる手を離さずドラッグを続ける
まず選んでください。磁石の閾値は子どもの目と、おもちゃの街の地図のどちらに属するでしょうか。目に属し、そのあと Camera で換算します。

「操作感」は、数多くの小さな規則があらゆる Zoom、Rotation、Group、入力デバイスで一貫することから生まれます。ユーザーは Pivot、Binding、Cursor Semantics という言葉を知らなくても、ハンドルの大きさが変わる、吸着の強さが変わる、選択枠が跳ねる、コピーしたコネクターが誤接続するとすぐに気づきます。

おもちゃを Canvas に置き換える

おもちゃの動作Canvas Lab不変条件
1枚または複数の紙片を囲むSingle/Multi/Marquee SelectionSelection は Stable ID の集合
紙片を囲む透明な枠Selection Bounds選択 Geometry の World Bounds から導出
8個の小さなクリップResize Handles画面サイズを固定し、命中規則は独立
枠の上の輪Rotation HandlePivot を中心に角度変更
動かないピンAnchor/Opposite CornerResize 中にワールド位置がずれない
「形を保つ」カードAspect-Ratio ConstraintGuard が制約の有効化を決める
角度ダイヤルAngle Constraint反復加算せず開始角からの差を量子化
小さな磁石と明るい線Snapping/Alignment Guides/Distribution/Grid閾値を画面ピクセルで定義
ひものプラグConnector/Binding/PortsStable ID と明示的 port を参照
コピー機Clipboard Schema/Copy/Paste検証、ID 再割り当て、内部参照の保持
テーブル端でクロスを広げるEdge ScrollingCamera 移動後に World Point を再計算

この比喩には限界があります。磁石は複数候補の優先順位を自動解決しません。実装には決定的な規則が必要です。紙片の裏返しは直感的でも、テキストに負の Scale を許せない場合があります。本物のひもはどこへでも置けますが、Connector Binding は権限、削除、port 互換性を扱います。Clipboard は自分のコピー機からだけ届くのではなく、ユーザーや別アプリが変更できる信頼できない入力です。

まず誤った直感を捨てる

  • 「ドラッグできればエディターは完成だ。」 Multi-selection、境界を越える Resize、Rotation、吸着、キーボード代替操作、Undo が信頼性を決めます。
  • 「Handle はワールド空間に描くのが簡単だ。」 Zoom 後に家ほど大きくなったり、ほこりほど小さくなったりします。Screen Overlay で見た目と命中サイズを固定します。
  • 「Snap は固定 10 world units にする。」 Zoom ごとに磁力がまるで変わります。screen threshold から換算します。
  • 「Resize は width/height だけ変える。」 回転オブジェクトと multi-selection では pointer を Selection Local Space に変換し、Opposite Corner を中心に行列を更新します。
  • 「コピーは JSON.stringify(selection) だ。」 外部 Binding、未知の Shape、悪意ある巨大 payload、重複 ID が Document を壊します。
  • 「コネクターは毎フレーム一番近い Shape を探せばよい。」 重なった対象の間で跳ねます。Commit 後は検証済み Binding を保存します。
  • 「Edge scrolling は Camera だけ変える。」 Camera 移動後、同じ Screen Pointer が新しい World Point を指すので Preview の再計算が必要です。

Production backpack

前提となる契約

第8章の Geometry Provider は Bounds、Outline、Nearest Point、Snap Points を提供します。第9章の Tool State には明示的な Pointing/Dragging/Resizing/Rotating と Transaction があります。Document には Stable ID、BindingRecord、Group Transform があります。この章は Command Boundary を迂回しません。

正式な知識

Single Selection は一つの ID、Multi-Selection は安定した順序または集合の意味を持つ複数 ID です。Marquee Select は画面矩形を World Query に変換し、「完全に含む」か「交差する」かというプロダクト規則を適用します。Selection Bounds は派生状態で、通常は選択オブジェクトの transformed bounds の和集合です。回転した multi-selection には world-axis-aligned box または固定の selection-oriented box を使えます。決定を ADR に書き、Pointer Move の途中でアルゴリズムを切り替えてはいけません。

Resize Handles と Rotation Handle は通常 DOM または Canvas Overlay の Screen Space に描きます。Resize 開始時に initialSelectionSnapshot、Selection Matrix、Pivot、Anchor、Opposite Corner を固定します。前フレームの Preview から累積せず、毎フレーム開始 snapshot から計算して浮動小数点ドリフトを避けます。Pointer が Opposite Handle を越えると scale の符号と handle の意味が反転します。テキストの鏡像を禁止するプロダクトでは minimum size に clamp し、正しい cursor を表示します。Aspect-Ratio Constraint は初期の幅高さ比で解きます。Angle Constraint は atan2(pointer-pivot)-startAngle を、たとえば 15° 刻みに量子化します。

Snapping は候補生成、距離スコア、優先順位、閾値、視覚フィードバックという5個の責務です。Grid Snap は点を提供し、Alignment Guides は left/center/right と top/middle/bottom を比較し、Distribution は間隔を比較します。閾値は screen CSS px で定義し、Camera inverse で world tolerance へ変換します。同点は種類の優先度、Z-Order、Stable ID で決め、配列の偶然の順序に依存しません。修飾キーで途中から snapping を有効・無効にでき、Preview は毎フレーム生 pointer から再計算します。

Connector は Shape です。Binding は Connector 端点と対象 Shape/Port の関係です。Port Provider は接続可能点、方向、権限を提供します。Smart Connector はまず Broad Phase で近くの port を探し、画面距離、方向、互換性でスコアリングします。対象削除時は「端点を解除し最後の world point に固定する」か、カスケード削除するかを明示します。dangling ID を残してはいけません。Connector が Shape を横切る場合の直交・障害物回避ルーティングは別に実装できます。この章ではまず Binding の一貫性を保証します。

Copy/Paste は mime/version/records/bindings/bounds を持つ Clipboard Schema を書きます。Paste は byte 制限、parse、型とフィールドの検証、Unknown Shape の隔離、すべての内部 Record の ID 再発行を行い、旧→新 ID map で Parent/Binding を書き換えます。コピーされなかった外部対象への Binding は削除するか自由端点にします。Drag/Drop も DataTransfer を信頼できない入力として扱います。Duplicate Drag は閾値を越えたあと一度だけ Preview clone を作り、一つの Batch を Commit します。Move ごとにコピーしてはいけません。

Freehand は第7章の Coalesced Points を使います。最小画面距離でリサンプリングしてから moving average、Catmull–Rom、または筆圧対応 stroke smoothing を適用します。再編集のため原点列を保存してもよいですが、Renderer は平滑化済み path をキャッシュします。Cursor Semantics は「いま何ができるか」を示します。handle はオブジェクト回転に応じた resize cursor を選び、接続端、複製ドラッグ、禁止操作、テキスト編集にも別々のフィードバックを与えます。Cursor だけに依存しない視覚・アクセシビリティの手がかりも必要です。

Edge Scrolling は Pointer と Viewport 端の距離から Camera velocity を決め、イベント頻度ではなく rAF clock で積分します。Capture の所有者は引き続き InputController です。各フレームで先に Camera を動かし、固定 Screen Pointer から新しい World Point を逆算して Preview を更新します。Pointer End/Cancel、アクティブ帯からの離脱、Tool 切り替えで自動スクロールを停止します。

根拠と互換性(確認日:2026-08-29)

  • MDN Clipboard API は非同期 Clipboard が secure context、権限、ユーザー操作に制約されることを説明します。ClipboardEvent.clipboardData は copy/paste event の DataTransfer 入口です。イベント内の text/plain フォールバックを残します。
  • MDN HTML Drag and Drop API と DataTransfer は外部ファイル・データの drop 境界です。データの検証は依然として必要です。
  • Edge Scrolling は MDN Pointer Capture で入力の連続性を保ちますが、pointercancel/lostpointercapture でアニメーションを停止します。
  • Cursor の値とプラットフォーム差は MDN cursor を参照してください。Cursor を唯一の状態表示にしてはいけません。

この章で積み上げる実装

開始地点: 単一オブジェクトの移動、Zoom で大きさが変わる handle、2点 Polyline にすぎない Connector。完了地点: Multi-select、Marquee、Resize、Rotate、Guides、Grid、Smart Connector、Copy/Paste、Modifier Duplicate、Edge Scroll がすべて第9章の Transaction を通ります。

次のファイルを追加します。

  • src/engine/interactions/SelectionTransform.ts:Selection、Pivot、Resize、Rotate。
  • src/engine/snapping/SnapEngine.ts:Grid、Alignment、Distribution の候補と Guides。
  • src/engine/bindings/ConnectorBinding.ts:Ports と切断規則。
  • src/engine/clipboard/CanvasClipboard.ts:schema、ID remap、fallback。
  • src/engine/freehand/resample.ts:coalesced point のリサンプリングと平滑化。
  • src/engine/viewport/EdgeScroller.ts:rAF ベースの端速度。
  • src/engine/interactions/__tests__/selection-transform.test.ts と tests/browser/editor-feel.spec.ts。

以下の完全な中核は、「反対角の固定、通過と反転、比率制約、画面閾値の吸着」を純粋関数で表します。すべての Preview を固定した 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)),
  };
}

Clipboard の要点は stringify ではなく「検証と改名」です。以下の実行可能な実装は未知の version、重複 source ID、上限超過データを拒否し、内部接続を書き換えます。呼び出し側の makeId() は Document のグローバル ID allocator から名前を予約し、返却値全体の検証後に一つの Transaction で書き込みます。

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 };
}

テストは目視で見落としやすい境界越えと Zoom の規則を固定します。

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

describe('professional interaction invariants', () => {
  it('反対角を越えたら handle の意味を反転し、anchor は固定する', () => {
    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('北西 handle は越える前に誤って南東へ反転しない', () => {
    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('6px の snap threshold は Zoom が違っても同じである', () => {
    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('rotation はフレームごとに累積せず固定した開始角から delta を求める', () => {
    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 remap、悪意ある payload のテストがすべて通ることを確認します。pnpm playwright test tests/browser/editor-feel.spec.ts を実行し、25%/100%/400% Zoom で handle の画面上の一辺が 1 px 以内の差に収まり、guide から 5 px なら吸着し 7 px なら吸着しないことを確認します。

磁石のテーブルへ戻りましょう。Handle はクリップ、Opposite Corner はピン、SnapEngine は目に見える距離が近いときだけ働く磁石、Binding は名前付きプラグです。このコードは Demo に効果を足すためではなく、あらゆる拡大、回転、キャンセル経路で同じ物理ルールを守るためにあります。

わざと壊してみる

注入する障害症状証拠修正回帰テスト復旧
Handle が Opposite を越える負の幅、選択枠が消えるscale sign と anchor tracehandle の意味を反転し bounds を正規化四隅の越境をパラメータ化initial snapshot から再計算
Rotated Multi-Selectionオブジェクトが誤中心を回るSelection matrix/pivot を可視化selection coordinate frame を固定90°/37° multi-selection累積 transform を破棄
Group Resize子要素がずれるlocal/world round tripgroup local で transform を合成入れ子 group fixture元の local transform を復元
Zoom 変更後に Snap磁力が強弱する画面距離・ワールド閾値ログscreen threshold を逆変換3段階 Zoomcandidates を再計算
Connector Target を削除dangling Binding または誤接続dangling ID validator最後の world point で unlink、または明示的 cascade対象削除テスト不正 binding を隔離
Unknown Shape を Pastecrash または空オブジェクトschema rejection codeUnknown 隔離または version migration未知 type fixtureDocument を書かない
Clipboard を改ざん重複 ID、過大メモリbyte limit/validator ログ制限、検証、remapfuzz JSONpaste 全体を原子的に戻す
Edge Scroll + Capture LostCamera が流れ続けるactive rAF 数Cancel が scroller を止めるlost capture ブラウザテストrAF 停止、Preview rollback

証拠をもって合格する

要件受け入れられる証拠受け入れないもの
どの Zoom でも Handle が同じ大きさブラウザのピクセル計測で誤差 1 px 以内「だいたい同じに見える」
Snap の操作感が一定複数 Zoom で固定 5/7 screen px テスト固定 world units
Selection が跳ねないfrozen snapshot から計算した replayフレームごとの累積乗算
Connector が誤接続しない安定スコア、削除・重なり fixture「一番近いものを取る」
Clipboard が安全size/type/version/fuzz/atomic rollback テストJSON の try/catch だけ
Edge Scroll が止まるactive rAF と Cancel assertion離したあと目視で動かない
検査面自動化された証拠手動の証拠
Handle と Snap 閾値Playwright ピクセル計測と複数 Zoom fixture実マウス・タッチで操作感比較
Connector の安定性Binding validator と削除・重なりテスト密集 port 上をドラッグし跳ねを観察
Edge Scrollingfake clock、rAF、cancel 数四辺へドラッグし途中でフォーカス喪失
  • Single/Multi/Marquee Selection と Selection Bounds に決定的な規則がある。
  • Resize/Rotate の Handle、Pivot、Anchor、Opposite Corner、比率、角度制約にテストがある。
  • Alignment、Distribution、Grid Snap が画面閾値を共有し Guides を描く。
  • Connector、Binding、Ports、Target 削除プロトコルが定義されている。
  • Copy/Paste、Drag/Drop、Duplicate Drag が Schema、ID remap、Transaction を通る。
  • Freehand が coalesced points、リサンプリング、平滑化方針を使い、Cursor は意味を持つが唯一の手がかりではない。
  • Edge Scrolling と Pointer Capture の停止、再計算、キャンセル経路が検証されている。

5歳児に説明する

「吸着」「拡縮」「バインディング」「クリップボード」という言葉を使わずに答えてください。

  1. なぜ小さな磁石の距離は地図のマス目ではなく、子どもの目に見える大きさで決めるのでしょうか。
  2. 紙片の右下を左上のピンの向こうまで引いたあと、自然に大きくし続けるにはどうしますか。
  3. ひもをコピーするとき、新しい紙片とプラグに新しい名前が必要なのはなぜでしょうか。
  4. 新しい問題です。紙片をテーブル端へ押してクロスが動き始めたあと、手の下の地図位置をもう一度測るのはなぜでしょうか。
参考解答を見る 子どもが操作するのは目の前の絵なので、街を拡大しても縮小しても、磁石は目に同じくらい近いときに働くべきです。クリップがピンを越えたら、元のクリップ名を反対側へ移し、固定した点はその場に残します。コピーが古い名前を使うと、新しいひもが古い紙片へつながったり、古い記録と衝突したりするため、全体を改名してからコピー内部のひもをつなぎ直します。テーブルクロスが動くと地図が手の下を滑り、同じ手が別の通りを指すため、Camera の地図でもう一度換算します。