mock: PHY Editorを作り直す(T-29)

provisional/phy-editor-feedback.md の合意事項と phy-editor-keybindings.md の割り当て案に沿って、PHY 画面を一から書き直した。
App.vue は共通のシェル(入出力・履歴・タブ)だけにし、PHY は src/ui/phy/ に、コンポーネント・composable・純粋モジュール(logic.ts, viewport.ts)を分けて置いた。

- 画面確認で見つかった不具合(読み込み時の全体表示、Shift+クリック、削除の確認の大きさ、Alt+↑、フォーカス中の二重描画、長押し中の選択など)を直し、キャンバス上の実際の操作で確かめる e2e を足した
- 階層パネルでクラスターの子を余分な折りたたみで包まず、件数を配下のキー・部品の数にそろえた
- 実装は Codex(Luna)。検証: vue-tsc 0件、Vitest 64件、Playwright 30件、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
This commit is contained in:
2026-10-07 20:54:37 +09:00
co-authored by Claude Opus 5.5
parent beac52c20b
commit 4d1eb23cf8
24 changed files with 3807 additions and 1131 deletions
+349
View File
@@ -0,0 +1,349 @@
import { ref, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { apply, worldTransform } from "../../domain/geometry";
import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic";
type Point = { x: number; y: number };
type Box = Point & { width: number; height: number };
type Options = {
project: () => ProjectFile;
svg: Ref<SVGSVGElement | undefined>;
pane: Ref<HTMLElement | undefined>;
pan: Ref<Point>;
zoom: Ref<number>;
grid: Ref<number>;
selected: Ref<string[]>;
focus: Ref<string | null>;
rangeBox: Ref<Box | null>;
allNodes: () => Node[];
keys: () => { key: KeyNode }[];
localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point;
visibleIds: () => Set<string>;
mismatch: () => boolean;
isAncestor: (candidate: string, id: string) => boolean;
parentLabel: (id: string | null) => string;
pressTargets: (id: string) => string[];
setSelection: (id: string, add?: boolean) => void;
beginHistory: () => void;
commitHistory: () => void;
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
};
export function usePhyCanvasPointer(options: Options) {
const longLabel = ref("");
const longLabelPos = ref({ x: 0, y: 0 });
const spaceHeld = ref(false);
const holdCandidates = ref<string[]>([]);
const holdIndex = ref(0);
let preClickSelection: string[] = [];
let preClickTarget: string | null = null;
let preClickAt = 0;
let holdTimer: number | undefined;
let holdCycle: number | undefined;
let holdShift = false;
let holdActivated = false;
let panDrag: { start: Point; origin: Point } | null = null;
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
let dragState: {
id: string;
start: Point;
base: ProjectFile;
ids: string[];
shift: boolean;
parents: Map<string, SVGGElement>;
starts: Map<string, Point>;
moved: boolean;
} | null = null;
function priorSelection() {
return preClickSelection;
}
function parentElementFor(id: string, fallback?: SVGGElement) {
const elements = options.svg.value?.querySelectorAll(`[data-node-id="${id}"]`);
return (
(elements?.item((elements.length || 1) - 1)?.parentElement as SVGGElement | undefined) ||
(fallback?.parentElement?.parentElement as SVGGElement | undefined) ||
options.svg.value!
);
}
function beginEmptyPointer(event: PointerEvent, point?: Point) {
const svg = options.svg.value;
const start = point ?? (svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 });
rangeDrag = { start, shift: event.shiftKey, moved: false };
options.rangeBox.value = { ...start, width: 0, height: 0 };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg?.setPointerCapture(event.pointerId);
}
function canvasDown(event: PointerEvent) {
const svg = options.svg.value;
if (event.target !== svg || !svg) return;
const point = options.localPoint(svg, event.clientX, event.clientY);
if (event.button === 1 || spaceHeld.value) {
panDrag = { start: point, origin: { ...options.pan.value } };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
beginEmptyPointer(event, point);
}
function updateRange(event: PointerEvent) {
if (!rangeDrag || !options.svg.value) return;
const point = options.localPoint(options.svg.value, event.clientX, event.clientY);
const start = rangeDrag.start;
rangeDrag.moved = Math.hypot(point.x - start.x, point.y - start.y) >= 4;
options.rangeBox.value = {
x: Math.min(start.x, point.x),
y: Math.min(start.y, point.y),
width: Math.abs(point.x - start.x),
height: Math.abs(point.y - start.y),
};
}
function down(event: PointerEvent, node: Node, _element: SVGGElement) {
const svg = options.svg.value;
if (event.button === 1 || spaceHeld.value) {
const point = svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 };
panDrag = { start: point, origin: { ...options.pan.value } };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg?.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
if (event.altKey) return;
options.pane.value?.focus();
if (options.focus.value && !options.visibleIds().has(node.id)) {
beginEmptyPointer(event);
return;
}
if (options.mismatch()) {
options.setSelection(node.id, event.shiftKey);
return;
}
if (preClickTarget !== node.id || Date.now() - preClickAt > 600) {
preClickSelection = [...options.selected.value];
}
preClickTarget = node.id;
preClickAt = Date.now();
holdActivated = false;
dragState = {
id: node.id,
start: { x: event.clientX, y: event.clientY },
base: options.project(),
ids: [...options.selected.value],
shift: event.shiftKey,
parents: new Map(),
starts: new Map(),
moved: false,
};
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
if (node.type === "key") {
holdCandidates.value = options.pressTargets(node.id);
holdIndex.value = 0;
holdShift = event.shiftKey;
holdTimer = window.setTimeout(() => {
if (holdCandidates.value.length <= 1) return;
holdActivated = true;
holdIndex.value = 1;
longLabelPos.value = { x: event.clientX, y: event.clientY };
longLabel.value = `${options.parentLabel(holdCandidates.value[1]!)} 2/${holdCandidates.value.length}`;
holdCycle = window.setInterval(() => {
holdIndex.value = (holdIndex.value + 1) % holdCandidates.value.length;
const id = holdCandidates.value[holdIndex.value]!;
const label = holdIndex.value === 0 ? "キー" : options.parentLabel(id);
longLabel.value = `${label} ${holdIndex.value + 1}/${holdCandidates.value.length}`;
}, 450);
}, 500);
}
(event.currentTarget as Element).setPointerCapture?.(event.pointerId);
}
function move(event: PointerEvent) {
const svg = options.svg.value;
if (!svg) return;
const point = options.localPoint(svg, event.clientX, event.clientY);
if (panDrag) {
options.pan.value = {
x: panDrag.origin.x + point.x - panDrag.start.x,
y: panDrag.origin.y + point.y - panDrag.start.y,
};
return;
}
if (rangeDrag) {
updateRange(event);
return;
}
if (!dragState) return;
const state = dragState;
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
if (holdTimer && !longLabel.value) {
window.clearTimeout(holdTimer);
holdTimer = undefined;
}
if (longLabel.value) {
const id = holdCandidates.value[holdIndex.value]!;
options.selected.value = normalizeSelection(
options.project().phy,
holdShift ? [...preClickSelection, id] : [id],
);
state.ids = [id];
state.base = options.project();
state.parents = new Map([[id, parentElementFor(id)]]);
state.starts = new Map([
[id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
]);
options.beginHistory();
if (holdCycle) window.clearInterval(holdCycle);
longLabel.value = "";
} else if (!state.moved) {
const includesAncestor = preClickSelection.some(
(id) => id === state.id || options.isAncestor(id, state.id),
);
const ids = includesAncestor ? preClickSelection : [state.id];
if (!includesAncestor) options.selected.value = [state.id];
state.ids = ids;
state.base = options.project();
state.parents = new Map(ids.map((id) => [id, parentElementFor(id)]));
state.starts = new Map(
[...state.parents].map(([id, parent]) => [
id,
options.localPoint(parent, state.start.x, state.start.y),
]),
);
options.beginHistory();
}
state.moved = true;
let phy = state.base.phy;
for (const id of state.ids) {
const parent = state.parents.get(id);
const start = state.starts.get(id);
if (!parent || !start) continue;
const current = options.localPoint(parent, event.clientX, event.clientY);
phy = moveNode(
phy,
id,
snap(current.x - start.x, options.grid.value),
snap(current.y - start.y, options.grid.value),
);
}
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
}
function up() {
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", up, true);
if (panDrag) {
panDrag = null;
return;
}
if (rangeDrag) {
if (rangeDrag.moved && options.rangeBox.value) {
const box = options.rangeBox.value;
const worldBox = {
x: (box.x - options.pan.value.x) / options.zoom.value,
y: (box.y - options.pan.value.y) / options.zoom.value,
width: box.width / options.zoom.value,
height: box.height / options.zoom.value,
};
const scope = options.visibleIds();
const hits: string[] = [];
for (const item of options.keys()) {
if (!scope.has(item.key.id)) continue;
const transform = worldTransform(options.project().phy, item.key.id);
const corners = [
{ x: 0, y: 0 },
{ x: item.key.size.w, y: 0 },
{ x: item.key.size.w, y: item.key.size.h },
{ x: 0, y: item.key.size.h },
].map((point) => apply(transform, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
if (
intersects(worldBox, {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
})
) {
hits.push(item.key.id);
}
}
for (const node of options
.allNodes()
.filter((item) => item.type === "component" && scope.has(item.id))) {
const transform = worldTransform(options.project().phy, node.id);
if (
intersects(worldBox, { x: transform.e - 0.16, y: transform.f - 0.16, width: 0.32, height: 0.32 })
) {
hits.push(node.id);
}
}
options.selected.value = normalizeSelection(
options.project().phy,
rangeDrag.shift ? [...options.selected.value, ...hits] : hits,
);
} else {
options.selected.value = [];
}
rangeDrag = null;
options.rangeBox.value = null;
return;
}
if (holdTimer) window.clearTimeout(holdTimer);
if (holdCycle) window.clearInterval(holdCycle);
if (longLabel.value && holdCandidates.value[holdIndex.value]) {
const id = holdCandidates.value[holdIndex.value]!;
options.selected.value = normalizeSelection(
options.project().phy,
holdShift ? [...options.selected.value, id] : [id],
);
}
longLabel.value = "";
const moved = dragState?.moved ?? false;
if (dragState?.moved) options.commitHistory();
else if (dragState && !holdActivated) {
const id = dragState.id;
const next = dragState.shift
? options.selected.value.includes(id)
? options.selected.value.filter((selectedId) => selectedId !== id)
: [...options.selected.value, id]
: [id];
options.selected.value = normalizeSelection(options.project().phy, next);
}
if (holdActivated || moved) preClickTarget = null;
dragState = null;
}
function spaceUp(event: KeyboardEvent) {
if (event.code === "Space") spaceHeld.value = false;
}
return {
canvasDown,
down,
move,
up,
spaceUp,
longLabel,
longLabelPos,
spaceHeld,
holdCandidates,
holdIndex,
priorSelection,
};
}
function intersects(a: Box, b: Box) {
return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
}