コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
まずは5歳児にもわかるゲームから
この章で覚える真実は一つだけです。プロフェッショナルな Canvas の価値は「ドラッグできること」ではなく、すべての操作ルールが一貫していることにあります。
テーブルに方眼紙を広げ、3枚の紙片、両端にプラグが付いたひも、小さな磁石をいくつか置きます。子どもが紙片を格子線の近くまでドラッグすると、磁石がそっと引き寄せます。右下をつまむと、左上のピンは動きません。「形を保つ」カードを押さえている間、紙片は元の比率のまま大きくなります。紙片をテーブルの端まで押すとテーブルクロスが少しずつ広がり、子どもはそのまま進めます。
先に予想してみてください。おもちゃの街を 400% に拡大したとき、磁石は地図上の同じ距離で働くべきでしょうか、それとも子どもの目に見える同じ 6 ピクセルで働くべきでしょうか。右下角を左上角の向こうまでドラッグしたら、どの角が新しい「動かないピン」になるでしょうか。ひもでつないだ紙片が削除されたら、ひもの端は黙って別の場所につながるべきでしょうか。
- 反対の角を固定もう一方を引く
- 近いときだけ引く見える距離は一定
- プラグには名前がある接続を推測しない
- テーブル端で広げる手を離さずドラッグを続ける
「操作感」は、数多くの小さな規則があらゆる Zoom、Rotation、Group、入力デバイスで一貫することから生まれます。ユーザーは Pivot、Binding、Cursor Semantics という言葉を知らなくても、ハンドルの大きさが変わる、吸着の強さが変わる、選択枠が跳ねる、コピーしたコネクターが誤接続するとすぐに気づきます。
おもちゃを Canvas に置き換える
| おもちゃの動作 | Canvas Lab | 不変条件 |
|---|---|---|
| 1枚または複数の紙片を囲む | Single/Multi/Marquee Selection | Selection は Stable ID の集合 |
| 紙片を囲む透明な枠 | Selection Bounds | 選択 Geometry の World Bounds から導出 |
| 8個の小さなクリップ | Resize Handles | 画面サイズを固定し、命中規則は独立 |
| 枠の上の輪 | Rotation Handle | Pivot を中心に角度変更 |
| 動かないピン | Anchor/Opposite Corner | Resize 中にワールド位置がずれない |
| 「形を保つ」カード | Aspect-Ratio Constraint | Guard が制約の有効化を決める |
| 角度ダイヤル | Angle Constraint | 反復加算せず開始角からの差を量子化 |
| 小さな磁石と明るい線 | Snapping/Alignment Guides/Distribution/Grid | 閾値を画面ピクセルで定義 |
| ひものプラグ | Connector/Binding/Ports | Stable ID と明示的 port を参照 |
| コピー機 | Clipboard Schema/Copy/Paste | 検証、ID 再割り当て、内部参照の保持 |
| テーブル端でクロスを広げる | Edge Scrolling | Camera 移動後に 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 trace | handle の意味を反転し bounds を正規化 | 四隅の越境をパラメータ化 | initial snapshot から再計算 |
| Rotated Multi-Selection | オブジェクトが誤中心を回る | Selection matrix/pivot を可視化 | selection coordinate frame を固定 | 90°/37° multi-selection | 累積 transform を破棄 |
| Group Resize | 子要素がずれる | local/world round trip | group local で transform を合成 | 入れ子 group fixture | 元の local transform を復元 |
| Zoom 変更後に Snap | 磁力が強弱する | 画面距離・ワールド閾値ログ | screen threshold を逆変換 | 3段階 Zoom | candidates を再計算 |
| Connector Target を削除 | dangling Binding または誤接続 | dangling ID validator | 最後の world point で unlink、または明示的 cascade | 対象削除テスト | 不正 binding を隔離 |
| Unknown Shape を Paste | crash または空オブジェクト | schema rejection code | Unknown 隔離または version migration | 未知 type fixture | Document を書かない |
| Clipboard を改ざん | 重複 ID、過大メモリ | byte limit/validator ログ | 制限、検証、remap | fuzz JSON | paste 全体を原子的に戻す |
| Edge Scroll + Capture Lost | Camera が流れ続ける | 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 Scrolling | fake 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歳児に説明する
「吸着」「拡縮」「バインディング」「クリップボード」という言葉を使わずに答えてください。
- なぜ小さな磁石の距離は地図のマス目ではなく、子どもの目に見える大きさで決めるのでしょうか。
- 紙片の右下を左上のピンの向こうまで引いたあと、自然に大きくし続けるにはどうしますか。
- ひもをコピーするとき、新しい紙片とプラグに新しい名前が必要なのはなぜでしょうか。
- 新しい問題です。紙片をテーブル端へ押してクロスが動き始めたあと、手の下の地図位置をもう一度測るのはなぜでしょうか。