Files
Beyond-KLE/mock/src/ui/phy/usePhyKeyboard.ts
T
ewdernaandClaude Opus 5.5 4d1eb23cf8 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
2026-10-07 20:54:37 +09:00

254 lines
8.8 KiB
TypeScript

import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { moveNode, resizeKey, rotateNode } from "../../domain/phyCommands";
import { nextGrid, resolveKey, tabOrder } from "./logic";
type Options = {
project: () => ProjectFile;
mismatch: () => boolean;
pane: Ref<HTMLElement | undefined>;
selected: Ref<string[]>;
focus: Ref<string | null>;
grid: Ref<number>;
zoom: Ref<number>;
showHelp: Ref<boolean>;
popover: Ref<string | null>;
confirm: Ref<string | null>;
spaceHeld: Ref<boolean>;
variants: Ref<Record<string, string>>;
priorChild: Map<string, string>;
node: (id: string) => Node | undefined;
allNodes: (roots?: Node[]) => Node[];
parentId: (id: string) => string | null;
selectedContainer: (id: string) => string | null;
insertionParent: () => string | null;
nodesInContainer: (container: string | null) => Node[];
directChildren: (id: string) => Node[];
tabItem: (node: Node) => { id: string; x: number; y: number };
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
fit: () => void;
fitSelected: () => void;
focusIn: (id: string) => void;
makeCluster: () => void;
ungroup: () => void;
duplicate: () => void;
keyAdd: () => void;
askDelete: () => void;
deleteConfirmed: () => void;
chooseVariant: (group: Node, id: string) => void;
};
export function usePhyKeyboard(options: Options) {
return function keyHandler(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (
!options.pane.value?.contains(document.activeElement) ||
target?.closest("input,select,textarea,[contenteditable=true]")
) {
return;
}
if (event.code === "Space") {
options.spaceHeld.value = true;
event.preventDefault();
return;
}
const action = resolveKey(event);
const control = event.ctrlKey || event.metaKey;
if (["undo", "redo", "save"].includes(action || "")) return;
if (action === "?" || event.key === "?") {
options.showHelp.value = true;
event.preventDefault();
return;
}
if (event.key === "Escape") {
if (options.showHelp.value || options.popover.value || options.confirm.value) {
options.showHelp.value = false;
options.popover.value = null;
options.confirm.value = null;
} else if (options.selected.value.length) {
options.selected.value = [];
} else if (options.focus.value) {
options.focus.value = options.parentId(options.focus.value);
}
event.preventDefault();
return;
}
if (event.key === "Enter" && options.confirm.value) {
options.deleteConfirmed();
event.preventDefault();
return;
}
if (
event.key === "Enter" &&
options.selected.value.length === 1 &&
["cluster", "variantGroup"].includes(options.node(options.selected.value[0]!)?.type || "")
) {
options.focusIn(options.selected.value[0]!);
return;
}
if (action === "fit") {
options.fit();
return;
}
if (action === "selection-fit") {
options.fitSelected();
return;
}
if (options.mismatch()) return;
if (action === "group" && control) {
event.preventDefault();
options.makeCluster();
return;
}
if (action === "ungroup" && control) {
event.preventDefault();
options.ungroup();
return;
}
if (action === "d" && control) {
event.preventDefault();
options.duplicate();
return;
}
if (action === "a" && control) {
event.preventDefault();
const container = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
options.selected.value = options.nodesInContainer(container).map((node) => node.id);
return;
}
if (event.key === "Insert") {
event.preventDefault();
options.keyAdd();
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
options.askDelete();
return;
}
if (event.key === "g" || event.key === "G") {
options.grid.value = nextGrid(options.grid.value, event.shiftKey ? -1 : 1);
return;
}
if (event.key === "+" || event.key === "=") {
options.zoom.value *= 1.15;
return;
}
if (event.key === "-") {
options.zoom.value /= 1.15;
return;
}
if (event.key === "Tab") {
const container = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
const siblings = options.nodesInContainer(container);
const ordered = tabOrder(siblings.map(options.tabItem));
const index = ordered.findIndex((node) => node.id === options.selected.value[0]);
const next = (index + (event.shiftKey ? -1 : 1) + ordered.length) % ordered.length;
if (ordered[next]) options.selected.value = [ordered[next]!.id];
event.preventDefault();
return;
}
if (event.altKey && event.key === "ArrowUp") {
const id = options.selected.value[0];
const parent = id ? options.parentId(id) : null;
if (id && parent) {
options.priorChild.set(parent, id);
options.selected.value = [parent];
}
event.preventDefault();
return;
}
if (event.altKey && event.key === "ArrowDown") {
const id = options.selected.value[0];
const children = id ? options.directChildren(id) : [];
const back = id ? options.priorChild.get(id) : undefined;
const next = children.some((child) => child.id === back)
? back
: tabOrder(children.map(options.tabItem))[0]?.id;
if (next) options.selected.value = [next];
event.preventDefault();
return;
}
if (event.key.startsWith("Arrow")) {
const ids = options.selected.value;
if (!ids.length) return;
event.preventDefault();
let phy = options.project().phy;
for (const id of ids) {
const node = options.node(id);
if (!node) continue;
if (event.shiftKey) {
if (node.type !== "key") continue;
const size = { ...node.size };
if (event.key === "ArrowRight") size.w = Math.max(0.25, size.w + 0.25);
if (event.key === "ArrowDown") size.h = Math.max(0.25, size.h + 0.25);
if (event.key === "ArrowLeft") size.w = Math.max(0.25, size.w - 0.25);
if (event.key === "ArrowUp") size.h = Math.max(0.25, size.h - 0.25);
phy = resizeKey(phy, id, size);
} else {
const delta =
event.key === "ArrowLeft"
? [-options.grid.value, 0]
: event.key === "ArrowRight"
? [options.grid.value, 0]
: event.key === "ArrowUp"
? [0, -options.grid.value]
: [0, options.grid.value];
phy = moveNode(phy, id, delta[0]!, delta[1]!);
}
}
options.dispatch(event.shiftKey ? "サイズ" : "移動", (project) => ({ ...project, phy }));
return;
}
if (event.key === "PageUp" || event.key === "PageDown") {
const ids = options.selected.value;
if (ids.length) {
const delta = event.key === "PageUp" ? 15 : -15;
options.dispatch("回転", (project) => ({
...project,
phy: ids.reduce((phy, id) => rotateNode(phy, id, delta), project.phy),
}));
}
return;
}
if (event.key.toLowerCase() === "r") {
const ids = options.selected.value.filter((id) => options.node(id)?.type === "key");
if (ids.length) {
const delta = event.shiftKey ? -90 : 90;
options.dispatch("向き変更", (project) => {
const copy = structuredClone(project);
for (const id of ids) {
const node = options.allNodes(copy.phy.root).find((item) => item.id === id);
if (node?.type === "key") {
node.orientation = (((node.orientation || 0) + delta + 360) % 360) as KeyNode["orientation"];
}
}
return copy;
});
}
return;
}
if (event.key.toLowerCase() === "v" && options.selected.value.length === 1) {
const group = options.node(options.selected.value[0]!);
if (group?.type === "variantGroup") {
const index = group.children.findIndex(
(variant) => variant.id === (options.variants.value[group.id] || group.default),
);
const variant =
group.children[(index + (event.shiftKey ? -1 : 1) + group.children.length) % group.children.length];
if (variant) options.chooseVariant(group, variant.id);
}
return;
}
if (event.key === "F2") {
(document.querySelector(".properties input") as HTMLInputElement | null)?.focus();
event.preventDefault();
}
};
}