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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user