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
254 lines
8.8 KiB
TypeScript
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();
|
|
}
|
|
};
|
|
}
|