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
+10 -13
View File
@@ -5,14 +5,11 @@ test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "IDX", exact: true }).click();
});
test("register unindexed key, reorder its output number, and undo", async ({ page }) => {
test("new PHY key is registered at the end, reorder its output number, and undo", async ({ page }) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
await page.getByRole("button", { name: "キー追加" }).click();
await page.getByRole("button", { name: "キーを足す" }).click();
const keyId = await page.locator('[data-node-type="key"]').last().getAttribute("data-node-id");
await page.getByRole("button", { name: "IDX", exact: true }).click();
const unregisteredRow = page.locator(".idx-all-keys li.unregistered").first();
const keyId = (await unregisteredRow.locator("b").innerText()).trim();
await unregisteredRow.getByRole("button", { name: "登録" }).click();
const row = page.getByTestId(`idx-row-${keyId}`);
await expect(row).toBeVisible();
const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`);
@@ -28,15 +25,15 @@ test("output numbers recalculate after switching to a variant that excludes orde
page,
}) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
const treeKeys = page.locator(".tree-node").filter({ hasText: "キー ·" });
const firstId = await treeKeys.nth(0).getAttribute("data-tree-id");
const secondId = await treeKeys.nth(1).getAttribute("data-tree-id");
await treeKeys.nth(0).click();
await treeKeys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
const keys = page.locator('[data-node-type="key"]');
const firstId = await keys.nth(0).getAttribute("data-node-id");
const secondId = await keys.nth(1).getAttribute("data-node-id");
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0");
await page.getByLabel("Layout 出力バリアント").selectOption({ label: "Option B" });
await page.getByLabel("Variant Group 出力バリアント").selectOption({ label: "Option B" });
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外");
await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外");
const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" });
+421 -177
View File
@@ -7,225 +7,469 @@ test.beforeEach(async ({ page }) => {
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
});
test("drag snaps to quarter units and undo/redo", async ({ page }) => {
async function makeCluster(page: import("@playwright/test").Page) {
const keys = page.locator('[data-node-type="key"]');
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスターにする" }).click();
return page.locator('[data-node-type="cluster"]').first();
}
test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.reload();
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
const key = page.locator('[data-node-type="key"]').first();
const before = await key.getAttribute("transform");
const box = await key.boundingBox();
if (!box) throw new Error("Key has no bounds");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2 + 2, { steps: 3 });
await page.mouse.move(box!.x + box!.width / 2 + 24, box!.y + box!.height / 2, { steps: 3 });
await page.mouse.up();
const moved = await key.getAttribute("transform");
expect(moved).not.toBe(before);
const x = Number(moved?.match(/translate\(([-\d.]+)/)?.[1]);
expect(x / 0.25).toBeCloseTo(Math.round(x / 0.25), 6);
await expect.poll(() => key.getAttribute("transform")).not.toBe(before);
const after = await key.getAttribute("transform");
await page.getByRole("button", { name: "Undo" }).click();
await expect(key).toHaveAttribute("transform", before!);
await page.getByRole("button", { name: "Redo" }).click();
await expect(key).toHaveAttribute("transform", moved!);
await expect(key).toHaveAttribute("transform", after!);
expect(errors).toEqual([]);
});
test("dragging inside a rotated cluster tracks pointer movement", async ({ page }) => {
const treeNodes = page.locator("[data-tree-id]");
await treeNodes.nth(0).click();
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスター化" }).click();
const cluster = page.locator('[data-node-type="cluster"]').first();
test("クラスター化で画面位置を保つ", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const centers = async () =>
keys.evaluateAll((es) =>
es.slice(0, 2).map((e) => {
const r = e.getBoundingClientRect();
return [r.x + r.width / 2, r.y + r.height / 2];
}),
);
const before = await centers();
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスターにする" }).click();
const after = await centers();
for (let i = 0; i < 2; i++) {
expect(after[i]![0]).toBeCloseTo(before[i]![0]!, 0);
expect(after[i]![1]).toBeCloseTo(before[i]![1]!, 0);
}
});
test("複数選択のドラッグが全件に適用される", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const before = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
const box = await keys.nth(0).boundingBox();
if (!box) throw new Error("key missing");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 27, box.y + box.height / 2 + 10, { steps: 3 });
await page.mouse.up();
const after = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
expect(after[0]).not.toBe(before[0]);
expect(after[1]).not.toBe(before[1]);
});
test("回転クラスターの矢印移動は親軸で動き、ドラッグもポインタに追従する", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${clusterId}"]`).click();
const rotation = page.getByLabel("Rotation");
await rotation.fill("30");
await rotation.dispatchEvent("change");
await page.getByLabel("Rotation").fill("90");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = page.locator('[data-node-type="key"]').first();
const box = await key.boundingBox();
if (!box) throw new Error("Key bounds missing");
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const before = { ...start };
const localBefore = await key.getAttribute("transform");
const matrix = await key.evaluate((el) => {
const m = (el.parentElement as unknown as SVGGElement).getScreenCTM()!;
return [m.a, m.b, m.c, m.d] as const;
});
await page.mouse.move(start.x, start.y);
const beforeBox = await key.boundingBox();
const beforeTransform = await key.getAttribute("transform");
await key.click();
const pane = page.locator(".canvas-pane");
await pane.focus();
await page.keyboard.press("ArrowRight");
const afterBox = await key.boundingBox();
expect(afterBox!.y).toBeGreaterThan(beforeBox!.y + 4);
const dragBox = await key.boundingBox();
await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + dragBox!.height / 2);
await page.mouse.down();
await page.mouse.move(start.x + 32, start.y, { steps: 4 });
await page.mouse.move(dragBox!.x + dragBox!.width / 2 + 28, dragBox!.y + dragBox!.height / 2, { steps: 3 });
await page.mouse.up();
const after = await key.boundingBox();
if (!after) throw new Error("Key bounds missing");
const screenDx = after.x + after.width / 2 - before.x;
const localAfter = await key.getAttribute("transform");
const parse = (value: string | null) => {
const match = value?.match(/translate\(([-\d.]+),([-\d.]+)\)/);
return { x: Number(match?.[1]), y: Number(match?.[2]) };
};
const delta = {
x: parse(localAfter).x - parse(localBefore).x,
y: parse(localAfter).y - parse(localBefore).y,
};
expect(Math.abs(screenDx - (matrix[0] * delta.x + matrix[2] * delta.y))).toBeLessThan(4);
expect(
Math.abs(after.y + after.height / 2 - before.y - (matrix[1] * delta.x + matrix[3] * delta.y)),
).toBeLessThan(4);
expect(Math.abs(screenDx - 32)).toBeLessThan(15);
const dragAfterBox = await key.boundingBox();
expect(await key.getAttribute("transform")).not.toBe(beforeTransform);
expect(dragAfterBox!.x).toBeGreaterThan(dragBox!.x + 10);
expect(dragAfterBox!.y).toBeCloseTo(dragBox!.y, 0);
});
test("cluster and drag leave every other key at the same screen coordinates", async ({ page }) => {
const positions = async (): Promise<Record<string, [number, number]>> =>
page.locator('[data-node-type="key"]').evaluateAll((els) =>
Object.fromEntries(
els.map((el) => {
const r = el.getBoundingClientRect();
return [el.getAttribute("data-node-id"), [r.x + r.width / 2, r.y + r.height / 2]];
}),
),
) as Promise<Record<string, [number, number]>>;
const before = await positions();
const treeNodes = page.locator("[data-tree-id]");
const targetId = await page.locator('[data-node-type="key"]').first().getAttribute("data-node-id");
await treeNodes.nth(0).click();
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスター化" }).click();
const clustered = await positions();
for (const [id, point] of Object.entries(before)) {
expect(clustered[id]?.[0]).toBeCloseTo(point[0], 0);
expect(clustered[id]?.[1]).toBeCloseTo(point[1], 0);
}
const target = page.locator(`[data-node-id="${targetId}"]`).first();
const box = await target.boundingBox();
if (!box) throw new Error("Target key bounds missing");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
test("長押しでクラスターを選び、ダブルクリックでフォーカスしてEscで戻る", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
const key = page.locator('[data-node-type="key"]').first();
const b = await key.boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 });
await expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 });
await page.mouse.up();
const after = await positions();
for (const id of Object.keys(before)) {
if (id === targetId) continue;
expect(after[id]?.[0]).toBeCloseTo(before[id]![0]!, 0);
expect(after[id]?.[1]).toBeCloseTo(before[id]![1]!, 0);
}
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
const keyBox = await key.boundingBox();
await page.mouse.dblclick(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2, { delay: 80 });
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
const pane = page.locator(".canvas-pane");
await pane.focus();
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "root", exact: true })).toHaveCount(0);
});
test("variant deletion uses the reference review and removes IDX references", async ({ page }) => {
const initialKey = page.locator('[data-node-type="key"]').first();
const keyId = await initialKey.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${keyId}"]`).click();
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
const controls = page.locator(".canvas-head label").last();
await controls.locator("button").first().click(); // Add a third Variant so deletion can proceed.
await controls.locator("button").last().click(); // Request deletion of the selected/default Variant.
await expect(page.locator(".confirm-box")).toContainText("削除される参照");
await expect(page.locator(".confirm-box")).toContainText("idx");
await page.getByRole("button", { name: "参照と一緒に削除" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${keyId}`)).toHaveCount(0);
test("入れ子の回転クラスターをフォーカスできる", async ({ page }) => {
const outer = await makeCluster(page);
const outerId = await outer.getAttribute("data-node-id");
const childIds = await outer
.locator('[data-node-type="key"]')
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
await page.getByText("キー・部品 2件", { exact: true }).click();
await page.locator(`[data-tree-id="${childIds[0]}"]`).first().click();
await page
.locator(`[data-tree-id="${childIds[1]}"]`)
.first()
.click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスターにする" }).click();
const inner = outer.locator('[data-node-type="cluster"]').first();
const innerId = await inner.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = inner.locator('[data-node-type="key"]').first();
await expect(key).toBeVisible();
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
await expect(page.locator(`[data-node-id="${childIds[0]}"]`).last()).toBeVisible();
const focusedKey = page.locator(`[data-node-id="${childIds[0]}"]`).last();
const focusedBox = await focusedKey.boundingBox();
await page.mouse.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2);
await page.mouse.down();
await page.waitForTimeout(700);
await expect(page.locator(`[data-node-id="${innerId}"] .group-sel.candidate`)).toBeVisible({
timeout: 1800,
});
await page.mouse.up();
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-long-press-in-focus.png") });
const clickBox = await focusedKey.boundingBox();
await page.mouse.dblclick(clickBox!.x + clickBox!.width / 2, clickBox!.y + clickBox!.height / 2, {
delay: 80,
});
await expect(page.locator(`[data-node-id="${innerId}"]`)).toHaveCount(1);
});
test("multi-key rotation is one undo step", async ({ page }) => {
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const ids = [
await keys.nth(0).getAttribute("data-node-id"),
await keys.nth(1).getAttribute("data-node-id"),
];
const before = await Promise.all(
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
);
const tree = page.locator("[data-tree-id]");
await tree.nth(0).click();
await tree.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択を+15°回転" }).click();
const after = await Promise.all(
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
);
expect(after).not.toEqual(before);
await page.getByRole("button", { name: "Undo" }).click();
const undone = await Promise.all(
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
);
expect(undone).toEqual(before);
await keys.first().click();
const outline = page.locator('[data-node-type="key"] .sel').first();
await expect(outline).toBeVisible();
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("2px");
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
const group = page.locator('[data-node-type="variantGroup"]').first();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const badge = group.locator(".badge");
await expect(badge).toBeVisible();
await badge.locator("rect").click();
await page
.locator(".popover")
.getByRole("button", { name: /Option B/ })
.click();
await expect(keys).toHaveCount(14);
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
await badge.locator("rect").click();
const focusedBadgeBox = await badge.boundingBox();
const focusedPopoverBox = await page.locator(".popover").boundingBox();
expect(
Math.hypot(focusedPopoverBox!.x - focusedBadgeBox!.x, focusedPopoverBox!.y - focusedBadgeBox!.y),
).toBeLessThanOrEqual(80);
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-badge-in-focus.png") });
await page
.locator(".popover")
.getByRole("button", { name: /Option A/ })
.click();
await expect(keys).toHaveCount(16);
});
test("unsupported versions are rejected; save-blocked projects stay open and mismatches block panel edits", async ({
page,
}) => {
const first = page.locator('[data-node-type="key"]').first();
const firstId = await first.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${firstId}"]`).click();
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
await key.click();
await page.getByRole("button", { name: "削除", exact: true }).click();
const confirm = page.locator(".confirm-float");
await expect(confirm).toContainText("参照");
await expect(confirm).toContainText(id!);
await confirm.getByRole("button", { name: "削除", exact: true }).click();
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${id}`)).toHaveCount(0);
});
test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
const original = await key.getAttribute("transform");
await key.click();
const marker = await page.evaluate(() => {
const data = new DataTransfer();
window.dispatchEvent(
new ClipboardEvent("copy", { bubbles: true, cancelable: true, clipboardData: data }),
);
return data.getData("text/plain");
});
await page.evaluate((marker) => {
const data = new DataTransfer();
data.setData("text/plain", marker);
window.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
);
}, marker);
await expect(page.locator('[data-node-type="key"]')).toHaveCount(17);
const pasted = page.locator('[data-node-type="key"]').last();
expect(await pasted.getAttribute("data-node-id")).not.toBe(id);
expect(await pasted.getAttribute("transform")).not.toBe(original);
await page.evaluate(() => {
const data = new DataTransfer();
data.setData("text/plain", JSON.stringify([["Pasted"]]));
window.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
);
});
await expect(page.locator('[data-node-type="key"]')).toHaveCount(18);
});
test("カット貼り付けはIDとワールド位置を保つ", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
const transform = await key.getAttribute("transform");
await key.click();
const marker = await page.evaluate(() => {
const data = new DataTransfer();
window.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true, clipboardData: data }));
return data.getData("text/plain");
});
await expect(key).toHaveClass(/cut/);
await page.evaluate((value) => {
const data = new DataTransfer();
data.setData("text/plain", value);
window.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
);
}, marker);
const cutKey = page.locator(`[data-node-id="${id}"]`);
await expect(cutKey).toHaveAttribute("transform", transform!);
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
});
test("プロジェクト保存読み込みとKLEから60キー新規を往復する", async ({ page }) => {
await page.locator('[data-node-type="key"]').first().click();
await page.getByLabel("Width").fill("1.5");
await page.getByLabel("Width").dispatchEvent("change");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const filepath = path.join(test.info().outputDir, download.suggestedFilename());
await download.saveAs(filepath);
const fs = await import("node:fs/promises");
const original = JSON.parse(await fs.readFile(filepath, "utf8"));
const load = async (value: unknown, name: string) => {
const chooserPromise = page.waitForEvent("filechooser");
const savePath = path.join(test.info().outputDir, "project.json");
await download.saveAs(savePath);
const saved = await readFile(savePath);
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooserPromise
).setFiles({
name,
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(value)),
});
};
await load({ ...original, version: "0.2.0" }, "unsupported.bkle.json");
await chooser
).setFiles({ name: "roundtrip.bkle.json", mimeType: "application/json", buffer: saved });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
await expect(page.locator(".error")).toContainText("Unsupported ProjectFile version");
const duplicate = structuredClone(original);
duplicate.idx.order[0] = "missing-key-reference";
await load(duplicate, "missing-reference.bkle.json");
await expect(page.getByTestId("save-blocked")).toBeVisible();
const mismatch = structuredClone(original);
mismatch.phy.profiles.push({ ...mismatch.phy.profiles[0], id: "other", unitSize: { x: 2, y: 2 } });
await load(mismatch, "unit-mismatch.bkle.json");
await expect(page.getByText("unitSize が揃っていないPHYは編集できません。", { exact: true })).toBeVisible();
await page.getByLabel("Layout").selectOption({ label: "Option B" });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(15);
await page.getByRole("button", { name: "MAP", exact: true }).click();
await expect(page.getByLabel("追加するレイヤー名")).toBeDisabled();
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
await expect(page.getByRole("tab", { name: "Blocked" })).toHaveCount(0);
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", "1.5");
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
const kleChooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
await (await kleChooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
});
test("project download can be loaded back", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const box = await key.boundingBox();
if (!box) throw new Error("Key bounds missing");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 16, box.y + box.height / 2, { steps: 2 });
await page.mouse.up();
const savedTransform = await key.getAttribute("transform");
test("unitSize不一致では追加と数値編集が無効", async ({ page }) => {
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const filepath = path.join(test.info().outputDir, download.suggestedFilename());
await download.saveAs(filepath);
const chooserPromise = page.waitForEvent("filechooser");
const savePath = path.join(test.info().outputDir, "project.json");
await download.saveAs(savePath);
const source = JSON.parse(await readFile(savePath, "utf8"));
source.phy.profiles.push({
...source.phy.profiles[0],
id: "alternate",
name: "Alternate",
unitSize: { x: 18, y: 18 },
});
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
const chooser = await chooserPromise;
await chooser.setFiles(filepath);
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
await expect(page.locator('[data-node-type="key"]').first()).toHaveAttribute("transform", savedTransform!);
await expect(page.locator('[data-testid="phy-canvas"]')).toBeVisible();
await (
await chooser
).setFiles({
name: "unit-mismatch.bkle.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(source)),
});
await expect(page.getByText("unitSize が一致しないため編集できません。")).toBeVisible();
await expect(page.getByRole("button", { name: "キーを足す" })).toBeDisabled();
await page.locator('[data-node-type="key"]').first().click();
await expect(page.getByLabel("Width")).toBeDisabled();
const before = await page.locator('[data-node-type="key"] .key-face').first().getAttribute("width");
await page.getByLabel("Width").evaluate((e: HTMLInputElement) => {
e.value = "2";
e.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", before!);
});
test("KLE import creates sixty visible keys", async ({ page }) => {
const kle = JSON.parse(await readFile(path.resolve(process.cwd(), "../samples/kle/cr0wn60.json"), "utf8"));
const chooserPromise = page.waitForEvent("filechooser");
test("KLE読込直後に全体表示し、cr0wn60の全キーがキャンバス内に収まる", async ({ page }) => {
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: "cr0wn60.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(kle)),
});
await (await chooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
const canvas = await page.locator("svg.canvas").boundingBox();
const boxes = await page.locator('[data-node-type="key"]').evaluateAll((es) =>
es.map((e) => {
const r = e.getBoundingClientRect();
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
}),
);
expect(canvas).not.toBeNull();
for (const b of boxes) {
expect(b.left).toBeGreaterThanOrEqual(canvas!.x);
expect(b.top).toBeGreaterThanOrEqual(canvas!.y);
expect(b.right).toBeLessThanOrEqual(canvas!.x + canvas!.width);
expect(b.bottom).toBeLessThanOrEqual(canvas!.y + canvas!.height);
}
await page.screenshot({ path: path.join(test.info().outputDir, "cr0wn60-fit.png") });
});
test("キャンバス上のShift選択で2件をクラスター化し、選択解除と親ドラッグを行う", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await expect(page.locator(".sel")).toHaveCount(2);
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Control+g");
const cluster = page.locator('[data-node-type="cluster"]').first();
await expect(cluster.locator('[data-node-type="key"]')).toHaveCount(2);
const clusterBefore = await cluster.getAttribute("transform");
const childrenBefore = await cluster
.locator('[data-node-type="key"]')
.evaluateAll((es) => es.map((e) => e.getAttribute("transform")));
const key = cluster.locator('[data-node-type="key"]').first();
const b = await key.boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await page.mouse.move(b!.x + b!.width / 2 + 28, b!.y + b!.height / 2 + 5, { steps: 3 });
await page.mouse.up();
await expect(cluster).not.toHaveAttribute("transform", clusterBefore!);
expect(
await cluster
.locator('[data-node-type="key"]')
.evaluateAll((es) => es.map((e) => e.getAttribute("transform"))),
).toEqual(childrenBefore);
const third = keys.nth(3);
await third.click();
await third.click({ modifiers: ["Shift"] });
await expect(page.locator(".sel")).toHaveCount(0);
});
test("削除のキーボード確認とバリアントポップオーバーを対象の近くに置く", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
await key.click();
const keyBox = await key.boundingBox();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Delete");
const confirm = page.locator(".confirm-float");
const confirmBox = await confirm.boundingBox();
const pane = await page.locator(".canvas-pane").boundingBox();
expect(confirmBox!.width).toBeLessThanOrEqual(400);
expect(confirmBox!.x).toBeGreaterThanOrEqual(pane!.x);
expect(confirmBox!.y).toBeGreaterThanOrEqual(pane!.y);
expect(confirmBox!.x + confirmBox!.width).toBeLessThanOrEqual(pane!.x + pane!.width);
expect(confirmBox!.y + confirmBox!.height).toBeLessThanOrEqual(pane!.y + pane!.height);
const gapX = Math.max(
0,
confirmBox!.x - (keyBox!.x + keyBox!.width),
keyBox!.x - (confirmBox!.x + confirmBox!.width),
);
const gapY = Math.max(
0,
confirmBox!.y - (keyBox!.y + keyBox!.height),
keyBox!.y - (confirmBox!.y + confirmBox!.height),
);
expect(Math.hypot(gapX, gapY)).toBeLessThanOrEqual(150);
await page.screenshot({ path: path.join(test.info().outputDir, "delete-confirm.png") });
await page.keyboard.press("Escape");
await keysSelectVariantGroup(page);
const badge = page.locator('[data-node-type="variantGroup"] .badge').first();
await badge.locator("rect").click();
const badgeBox = await badge.boundingBox();
const popoverBox = await page.locator(".popover").boundingBox();
expect(Math.hypot(popoverBox!.x - badgeBox!.x, popoverBox!.y - badgeBox!.y)).toBeLessThanOrEqual(80);
await page.screenshot({ path: path.join(test.info().outputDir, "variant-popover.png") });
});
async function keysSelectVariantGroup(page: import("@playwright/test").Page) {
const keys = page.locator('[data-node-type="key"]');
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
}
test("Alt+上矢印は選択を親へ上げ、フォーカス描画にID重複を作らない", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${clusterId}"]`).click();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = cluster.locator('[data-node-type="key"]').first();
const transform = await key.getAttribute("transform");
await key.click();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
await expect(key).toHaveAttribute("transform", transform!);
await page.keyboard.press("Enter");
const ids = await page
.locator("svg.canvas [data-node-id]")
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
expect(new Set(ids).size).toBe(ids.length);
await page.screenshot({ path: path.join(test.info().outputDir, "focus-mode.png") });
const pane = await page.locator("svg.canvas").boundingBox();
await page.mouse.click(pane!.x + 8, pane!.y + 8);
await expect(page.locator(".sel")).toHaveCount(0);
});
test("長押し候補は離すまで選択を変えず、離すとクラスターを選ぶ", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
const keys = page.locator('[data-node-type="key"]');
await keys.nth(4).click();
await expect(keys.nth(4).locator(".sel")).toBeVisible();
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await expect(page.locator(".long-label")).toContainText("2/", { timeout: 1800 });
await expect(keys.nth(4).locator(".sel")).toBeVisible();
await page.screenshot({ path: path.join(test.info().outputDir, "long-press-candidate.png") });
await page.mouse.up();
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
});
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
const select = page.getByRole("combobox", { name: "部品を足す" });
await select.selectOption("その他");
await expect(select).toHaveValue("");
await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part");
});
+79 -11
View File
@@ -69,6 +69,56 @@ export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?
});
return o;
}
/** 指定コンテナへキーを追加する。 */
export function addKeyToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
size: { w: number; h: number },
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
const o = mutable(f),
target = parentNodes(o, parentId);
target.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
return o;
}
/** 指定コンテナへ部品を追加する。 */
export function addComponentToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
partGroup?: string,
gen?: () => string,
): PhyFile {
const o = mutable(f),
target = parentNodes(o, parentId);
target.push({
type: "component",
id: nodeId("component", gen),
transform: { ...at },
...(partGroup ? { partGroup } : {}),
});
return o;
}
/** root / cluster / variant の指定先にノード群を挿入する。 */
export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[], index?: number): PhyFile {
const o = mutable(f),
target = parentNodes(o, parentId);
target.splice(index ?? target.length, 0, ...structuredClone(nodes));
return o;
}
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
if (!parentId) return f.root;
const n = find(f, parentId);
if (n.type === "cluster") return n.children;
if (n.type === "variantGroup") throw new Error("Choose a variant as the parent");
return findVariant(f, parentId).children;
}
/** ローカル座標の差分でノードを移動する。 */
export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile {
const o = mutable(f),
@@ -407,20 +457,38 @@ export function deleteNode(f: PhyFile, id: string): PhyFile {
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
const o = mutable(f),
n = find(o, id),
copy = structuredClone(n);
const renew = (x: Node) => {
x.id = nodeId(x.type, gen);
if (x.type === "cluster") x.children.forEach(renew);
if (x.type === "variantGroup")
for (const v of x.children) {
v.id = nodeId("variant", gen);
v.children.forEach(renew);
}
};
renew(copy);
copy = cloneNodesWithNewIds([n], gen)[0]!;
copy.transform.x += 0.25;
copy.transform.y += 0.25;
const c = containers(o).get(id)!;
c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy);
return o;
}
/** ノード群を複製し、配下のIDと閉じたdefault参照を振り直す。T-11。 */
export function cloneNodesWithNewIds(nodes: Node[], gen?: () => string): Node[] {
const copies = structuredClone(nodes);
const remap = new Map<string, string>();
const renew = (x: Node) => {
const oldId = x.id;
x.id = nodeId(x.type, gen);
remap.set(oldId, x.id);
if (x.type === "cluster") x.children.forEach(renew);
if (x.type === "variantGroup")
for (const v of x.children) {
const oldVariantId = v.id;
v.id = nodeId("variant", gen);
remap.set(oldVariantId, v.id);
v.children.forEach(renew);
}
};
copies.forEach(renew);
const remapDefaults = (x: Node) => {
if (x.type === "cluster") x.children.forEach(remapDefaults);
if (x.type === "variantGroup") {
x.default = remap.get(x.default) ?? x.default;
x.children.forEach((v) => v.children.forEach(remapDefaults));
}
};
copies.forEach(remapDefaults);
return copies;
}
+89 -688
View File
@@ -2,198 +2,111 @@
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
import { createProjectFromKle } from "../domain/project";
import type { ProjectFile } from "../domain/project";
import type { Node, KeyNode, Transform, VariantGroupNode } from "../domain/phy";
import { flattenKeys } from "../domain/geometry";
import {
addKey,
addComponent,
moveNode,
resizeKey,
rotateNode,
clusterNodes,
uncluster,
reparent,
reparentToVariant,
duplicateNode,
createVariantGroup,
addVariant,
setDefaultVariant,
reframe,
} from "../domain/phyCommands";
import { ProjectHistory } from "../domain/history";
import { validateProject } from "../domain/validate";
import { listDeleteReferences, deleteNodeCascade } from "../domain/deleteCascade";
import { saveProject, loadProject } from "../io/projectFile";
import { exportKle } from "../io/kleExport";
import { defaultLayout } from "./defaultLayout";
import type { Dispatch } from "./types";
import PhyTree from "./phy/PhyTree.vue";
import PhyCanvas from "./phy/PhyCanvas.vue";
import { parentPoint, snappedDelta } from "./phy/dragMath";
import PhyEditor from "./phy/PhyEditor.vue";
import IdxPanel from "./panels/IdxPanel.vue";
import ElcPanel from "./panels/ElcPanel.vue";
import MapPanel from "./panels/MapPanel.vue";
import GeneratorPanel from "./panels/GeneratorPanel.vue";
const project = shallowRef<ProjectFile | null>(null);
const activeTab = ref("PHY"),
selection = ref<string[]>([]),
variants = ref<Record<string, string>>({}),
zoom = ref(1);
const errorText = ref(""),
pendingDelete = ref<{
id: string;
refs: ReturnType<typeof listDeleteReferences>;
} | null>(null);
const project = shallowRef<ProjectFile | null>(null),
activeTab = ref("PHY"),
errorText = ref("");
const importInput = ref<HTMLInputElement>(),
openInput = ref<HTMLInputElement>();
let history: ProjectHistory | null = null;
const historyEpoch = ref(0);
let drag: {
id: string;
parent: SVGGElement;
start: { x: number; y: number };
origin: Transform;
base: ProjectFile;
} | null = null;
const epoch = ref(0);
const mismatch = computed(
() =>
!!project.value &&
new Set(project.value.phy.profiles.map((p) => `${p.unitSize.x},${p.unitSize.y}`)).size > 1,
);
const issues = computed(() => (project.value ? validateProject(project.value) : []));
const issues = computed(() => (project.value ? validateProject(project.value) : [])),
severity = ["blocksSave", "blocksOutput", "warning"] as const;
const canUndo = computed(() => {
historyEpoch.value;
epoch.value;
return !!history?.canUndo;
}),
canRedo = computed(() => {
historyEpoch.value;
epoch.value;
return !!history?.canRedo;
});
const severity = ["blocksSave", "blocksOutput", "warning"] as const;
const selectedNode = computed(() =>
project.value && selection.value.length === 1
? findNode(project.value.phy.root, selection.value[0]!)
: null,
);
const keyCount = computed(() => (project.value ? flattenKeys(project.value.phy, variants.value).length : 0));
const variantGroups = computed(() => {
const groups: VariantGroupNode[] = [];
if (project.value)
visit(project.value.phy.root, (n) => {
if (n.type === "variantGroup") groups.push(n);
});
return groups;
});
const dispatch: Dispatch = (label, update) => {
if (!project.value) return;
if (mismatch.value) return;
if (!project.value || mismatch.value) return;
const next = update(project.value);
project.value = next;
if (history) history.push(next);
historyEpoch.value++;
history?.push(next);
epoch.value++;
errorText.value = "";
};
function findNode(nodes: Node[], id: string): Node | null {
for (const n of nodes) {
if (n.id === id) return n;
if (n.type === "cluster") {
const x = findNode(n.children, id);
if (x) return x;
}
if (n.type === "variantGroup")
for (const v of n.children) {
if (v.id === id) return n;
const x = findNode(v.children, id);
if (x) return x;
function beginHistory() {
if (history) {
history.begin();
epoch.value++;
}
}
return null;
}
function visit(nodes: Node[], fn: (n: Node) => void) {
for (const n of nodes) {
fn(n);
if (n.type === "cluster") visit(n.children, fn);
if (n.type === "variantGroup") for (const v of n.children) visit(v.children, fn);
}
}
function parents() {
const out: { id: string; label: string }[] = [{ id: "", label: "root" }];
if (project.value)
visit(project.value.phy.root, (n) => {
if (n.type === "cluster") out.push({ id: n.id, label: `クラスター ${n.name || n.id}` });
if (n.type === "variantGroup")
for (const v of n.children) out.push({ id: v.id, label: `${n.name} / ${v.name}` });
});
return out;
}
function setSelected(id: string, additive: boolean) {
selection.value = additive
? selection.value.includes(id)
? selection.value.filter((x) => x !== id)
: [...selection.value, id]
: [id];
const n = project.value ? findNode(project.value.phy.root, id) : null;
if (n?.type === "cluster")
promptValue.value = { x: n.transform.x, y: n.transform.y, r: n.transform.rotation };
}
async function initialize() {
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
project.value = p;
history = new ProjectHistory(p);
historyEpoch.value++;
variants.value = { ...p.output.variantSelection };
}
onMounted(() => {
void initialize();
window.addEventListener("keydown", keyHandler);
});
onUnmounted(() => window.removeEventListener("keydown", keyHandler));
function keyHandler(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
e.shiftKey ? redo() : undo();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
function commitHistory() {
if (history && project.value) {
history.commit(project.value);
epoch.value++;
}
}
function undo() {
if (history?.canUndo) {
project.value = history.undo();
historyEpoch.value++;
variants.value = { ...project.value.output.variantSelection };
selection.value = selection.value.filter(
(id) => !!project.value && !!findNode(project.value.phy.root, id),
);
epoch.value++;
}
}
function redo() {
if (history?.canRedo) {
project.value = history.redo();
historyEpoch.value++;
variants.value = { ...project.value.output.variantSelection };
epoch.value++;
}
}
async function readFile(file: File, mode: "project" | "kle") {
try {
const json = JSON.parse(await file.text()) as unknown;
if (mode === "kle") {
const p = await createProjectFromKle(json, { name: file.name });
function globalKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === "z") {
e.preventDefault();
e.shiftKey ? redo() : undo();
} else if (mod && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
} else if (mod && e.key.toLowerCase() === "s") {
e.preventDefault();
void save();
}
}
async function initialize() {
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
project.value = p;
history = new ProjectHistory(p);
variants.value = { ...p.output.variantSelection };
selection.value = [];
} else {
const result = await loadProject(JSON.stringify(json));
if (!result.project) throw Error(result.errors.map((x) => x.message).join("; "));
project.value = result.project;
history = new ProjectHistory(result.project);
variants.value = { ...result.project.output.variantSelection };
selection.value = [];
errorText.value = result.errors.map((x) => x.message).join("; ");
epoch.value++;
}
historyEpoch.value++;
onMounted(() => {
void initialize();
window.addEventListener("keydown", globalKey);
});
onUnmounted(() => window.removeEventListener("keydown", globalKey));
async function readFile(file: File, mode: "kle" | "project") {
try {
const raw = JSON.parse(await file.text()) as unknown;
let p: ProjectFile;
if (mode === "kle") p = await createProjectFromKle(raw, { name: file.name });
else {
const r = await loadProject(JSON.stringify(raw));
if (!r.project) throw Error(r.errors.map((x) => x.message).join("; "));
p = r.project;
errorText.value = r.errors.map((x) => x.message).join("; ");
}
project.value = p;
history = new ProjectHistory(p);
epoch.value++;
} catch (e) {
errorText.value = String(e);
}
@@ -206,215 +119,21 @@ function download(name: string, text: string) {
URL.revokeObjectURL(a.href);
}
async function save() {
if (project.value) {
const result = await saveProject(project.value, true);
if (result.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, result.json);
else errorText.value = result.errors.map((x) => x.message).join("; ");
}
if (!project.value) return;
const r = await saveProject(project.value, true);
if (r.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, r.json);
else errorText.value = r.errors.map((x) => x.message).join("; ");
}
function exportSelected() {
if (project.value)
download("layout.kle.json", JSON.stringify(exportKle(project.value.phy, variants.value), null, 2));
}
function nodeDown(event: PointerEvent, id: string, parent: SVGGElement) {
if (mismatch.value || !project.value) return;
const node = findNode(project.value.phy.root, id);
if (!node) return;
event.preventDefault();
const p = parentPoint(parent, event.clientX, event.clientY);
history?.begin();
historyEpoch.value++;
drag = { id, parent, start: p, origin: { ...node.transform }, base: project.value };
window.addEventListener("pointermove", movePointer);
window.addEventListener("pointerup", endPointer, { once: true });
}
function movePointer(e: PointerEvent) {
if (!drag) return;
const p = parentPoint(drag.parent, e.clientX, e.clientY),
delta = snappedDelta(drag.start, p);
const next = moveNode(drag.base.phy, drag.id, delta.x, delta.y);
project.value = { ...drag.base, phy: next };
history?.replaceTop(project.value);
}
function endPointer() {
window.removeEventListener("pointermove", movePointer);
if (project.value) history?.commit(project.value);
historyEpoch.value++;
drag = null;
}
function keyAdd() {
if (!project.value) return;
dispatch("キー追加", (p) => ({ ...p, phy: addKey(p.phy, { x: 0, y: 0, rotation: 0 }, { w: 1, h: 1 }) }));
const n = project.value.phy.root.at(-1);
if (n) selection.value = [n.id];
}
function componentAdd(group: string) {
if (!project.value) return;
dispatch("部品追加", (p) => ({
...p,
phy: addComponent(p.phy, { x: 0, y: 0, rotation: 0 }, group || undefined),
}));
const n = project.value.phy.root.at(-1);
if (n) selection.value = [n.id];
}
function updateTransform(field: keyof Transform, value: number) {
if (mismatch.value || !project.value || selection.value.length !== 1 || !selectedNode.value) return;
const id = selectedNode.value.id;
dispatch("変換変更", (p) => {
const phy = structuredClone(p.phy),
n = findNode(phy.root, id);
if (n) n.transform[field] = value;
return { ...p, phy };
});
}
function increment(field: keyof Transform, step: number) {
if (!selectedNode.value) return;
const n = selectedNode.value;
updateTransform(field, n.transform[field] + step);
}
function updateSize(field: "w" | "h", value: number) {
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
const id = selectedNode.value.id;
dispatch("キーサイズ変更", (p) => ({
...p,
phy: resizeKey(p.phy, id, { ...(findNode(p.phy.root, id) as KeyNode).size, [field]: value }),
}));
}
function updateLabel(value: string) {
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
const id = selectedNode.value.id;
dispatch("ラベル変更", (p) => {
const phy = structuredClone(p.phy),
n = findNode(phy.root, id);
if (n && n.type === "key") n.labels = [value];
return { ...p, phy };
});
}
function rotateSelected() {
if (mismatch.value || !project.value) return;
const ids = [...selection.value].filter((id) => findNode(project.value!.phy.root, id));
if (!ids.length) return;
dispatch("回転", (p) => ({ ...p, phy: ids.reduce((phy, id) => rotateNode(phy, id, 15), p.phy) }));
}
function makeCluster() {
if (!project.value || selection.value.length < 1) return;
try {
dispatch("クラスター化", (p) => ({ ...p, phy: clusterNodes(p.phy, selection.value) }));
const n = project.value.phy.root.find((n) => n.type === "cluster");
if (n) selection.value = [n.id];
} catch (e) {
errorText.value = String(e);
}
}
function ungroup() {
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
const id = selectedNode.value.id;
dispatch("クラスター解除", (p) => ({ ...p, phy: uncluster(p.phy, id) }));
selection.value = [];
}
function duplicate() {
if (!project.value || selection.value.length !== 1) return;
const id = selection.value[0]!;
dispatch("複製", (p) => ({ ...p, phy: duplicateNode(p.phy, id) }));
}
function reparentTo(target: string) {
if (mismatch.value || !project.value || selection.value.length !== 1) return;
const id = selection.value[0]!;
try {
dispatch("reparent", (p) => ({
...p,
phy: target.startsWith("v-")
? reparentToVariant(p.phy, id, target)
: reparent(p.phy, id, target || null),
}));
} catch (e) {
errorText.value = String(e);
}
}
function createVariants() {
if (!project.value || selection.value.length === 0) return;
const first = findNode(project.value.phy.root, selection.value[0]!);
if (!first) return;
try {
dispatch("VariantGroup作成", (p) => {
let phy = createVariantGroup(p.phy, "Layout", { ...first.transform }, [
{ name: "Option A" },
{ name: "Option B" },
]);
const vg = phy.root.at(-1);
if (vg?.type === "variantGroup") {
for (const id of selection.value) phy = reparentToVariant(phy, id, vg.children[0]!.id);
return {
...p,
phy,
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [vg.id]: vg.default } },
};
}
return { ...p, phy };
});
selection.value = [];
if (project.value) variants.value = { ...project.value.output.variantSelection };
} catch (e) {
errorText.value = String(e);
}
}
function addVariantUi(groupId: string) {
if (!project.value) return;
dispatch("Variant追加", (p) => ({
...p,
phy: addVariant(
p.phy,
groupId,
`Option ${String.fromCharCode(65 + (findNode(p.phy.root, groupId) as Node & { children: Node[] }).children.length)}`,
),
}));
}
function removeVariantUi(variantId: string) {
if (!project.value) return;
pendingDelete.value = { id: variantId, refs: listDeleteReferences(project.value, variantId) };
}
function changeDefault(groupId: string, id: string) {
if (!project.value) return;
dispatch("既定Variant変更", (p) => ({ ...p, phy: setDefaultVariant(p.phy, groupId, id) }));
}
function confirmDelete() {
if (!pendingDelete.value || !project.value) return;
const { id } = pendingDelete.value;
try {
dispatch("ノード削除", (p) => deleteNodeCascade(p, id));
if (project.value) variants.value = { ...project.value.output.variantSelection };
selection.value = [];
pendingDelete.value = null;
} catch (e) {
errorText.value = String(e);
}
}
function requestDelete() {
if (mismatch.value || !project.value || selection.value.length !== 1) return;
const id = selection.value[0]!;
pendingDelete.value = { id, refs: listDeleteReferences(project.value, id) };
}
function changeFrame() {
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
const id = selectedNode.value.id;
const x = Number(promptValue.value.x),
y = Number(promptValue.value.y),
rotation = Number(promptValue.value.r);
dispatch("座標枠変更", (p) => ({ ...p, phy: reframe(p.phy, id, { x, y, rotation }) }));
}
const promptValue = ref({ x: 0, y: 0, r: 0 });
function changeVariant(groupId: string, id: string) {
variants.value = { ...variants.value, [groupId]: id };
if (!mismatch.value)
dispatch("表示構成変更", (p) => ({
...p,
output: { ...p.output, variantSelection: { ...variants.value } },
}));
download(
"layout.kle.json",
JSON.stringify(exportKle(project.value.phy, project.value.output.variantSelection), null, 2),
);
}
</script>
<template>
<main class="app-shell" :class="{ locked: mismatch }" v-if="project">
<main v-if="project" class="app-shell" :class="{ locked: mismatch }">
<header class="toolbar">
<strong>Beyond KLE</strong><button @click="importInput?.click()">KLE JSONから新規</button
><input
@@ -434,7 +153,7 @@ function changeVariant(groupId: string, id: string) {
ref="openInput"
hidden
type="file"
accept=".bkle.json,.json,application/json"
accept=".json,application/json"
@change="
(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
@@ -450,157 +169,23 @@ function changeVariant(groupId: string, id: string) {
</header>
<nav class="tabs">
<button
v-for="tab in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
:key="tab"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
v-for="t in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
:key="t"
:class="{ active: activeTab === t }"
@click="activeTab = t"
>
{{ tab }}
{{ t }}
</button>
</nav>
<section v-if="activeTab === 'PHY'" class="workspace">
<aside class="tree pane">
<h2>PHYの木</h2>
<div class="commands">
<button :disabled="mismatch" @click="keyAdd">キー追加</button
><select
id="part-group"
:disabled="mismatch"
@change="componentAdd(($event.target as HTMLSelectElement).value)"
>
<option value="">部品追加…</option>
<option>@led</option>
<option>@encoder</option>
<option>@screw</option>
<option>@oled</option>
<option>custom</option></select
><button :disabled="mismatch || selection.length === 0" @click="makeCluster">クラスター化</button
><button :disabled="mismatch || selectedNode?.type !== 'cluster'" @click="ungroup">解除</button
><button :disabled="mismatch || selection.length !== 1" @click="duplicate">複製</button
><button :disabled="mismatch || selection.length !== 1" @click="requestDelete">削除</button
><select
:disabled="mismatch || selection.length !== 1"
@change="reparentTo(($event.target as HTMLSelectElement).value)"
>
<option value="">reparent先…</option>
<option v-for="p in parents()" :key="p.id" :value="p.id">{{ p.label }}</option></select
><button :disabled="mismatch || selection.length === 0" @click="createVariants">
選択からVariantGroup
</button>
</div>
<PhyTree
:nodes="project.phy.root"
:selected="selection"
@select="(id, add) => setSelected(id, add)"
<PhyEditor
v-if="activeTab === 'PHY'"
:project="project"
:dispatch="dispatch"
:mismatch="mismatch"
:begin-history="beginHistory"
:commit-history="commitHistory"
@error="errorText = $event"
/>
<div v-if="pendingDelete" class="confirm-box">
<strong>削除の確認: {{ pendingDelete.id }}</strong>
<p>削除される参照 {{ pendingDelete.refs.length }} 件 (T-15)</p>
<ul>
<li v-for="(r, i) in pendingDelete.refs" :key="i">
{{ r.layer }} · {{ r.id }} · {{ r.location }}
</li>
</ul>
<button :disabled="mismatch" @click="confirmDelete">参照と一緒に削除</button
><button @click="pendingDelete = null">キャンセル</button>
</div>
</aside>
<section class="canvas-pane pane">
<div class="canvas-head">
<strong>レイアウト · {{ keyCount }} keys</strong
><label>Zoom <input type="range" min="0.5" max="2" step="0.1" v-model.number="zoom" /></label
><label v-for="n in variantGroups" :key="n.id"
>{{ n.name }}
<select
:value="variants[n.id] ?? n.default"
@change="changeVariant(n.id, ($event.target as HTMLSelectElement).value)"
>
<option v-for="v in n.children" :key="v.id" :value="v.id">{{ v.name }}</option></select
><button :disabled="mismatch" @click="addVariantUi(n.id)">+</button
><button :disabled="mismatch" @click="changeDefault(n.id, variants[n.id] ?? n.default)">
default</button
><button :disabled="mismatch" @click="removeVariantUi(variants[n.id] ?? n.default)">
−
</button></label
>
</div>
<p v-if="mismatch" class="warning">unitSize が揃っていないPHYは編集できません。</p>
<PhyCanvas
:nodes="project.phy.root"
:selected="selection"
:variants="variants"
:zoom="zoom"
@select="(id, add) => setSelected(id, add)"
@drag-start="nodeDown"
/>
</section>
<aside class="properties pane">
<h2>プロパティ</h2>
<template v-if="selectedNode"
><p>
<b>{{ selectedNode.type }}</b
><br /><small>{{ selectedNode.id }}</small>
</p>
<label
>X
<input
type="number"
step="any"
:value="selectedNode.transform.x"
@change="updateTransform('x', Number(($event.target as HTMLInputElement).value))"
/><button @click="increment('x', 0.25)">±.25U</button></label
><label
>Y
<input
type="number"
step="any"
:value="selectedNode.transform.y"
@change="updateTransform('y', Number(($event.target as HTMLInputElement).value))"
/><button @click="increment('y', 0.25)">±.25U</button></label
><label
>Rotation
<input
type="number"
step="any"
:value="selectedNode.transform.rotation"
@change="updateTransform('rotation', Number(($event.target as HTMLInputElement).value))"
/><button @click="increment('rotation', 15)">±15°</button></label
><button @click="rotateSelected">+15°回転</button
><template v-if="selectedNode.type === 'key'"
><label
>Width
<input
type="number"
step="any"
:value="selectedNode.size.w"
@change="updateSize('w', Number(($event.target as HTMLInputElement).value))" /></label
><label
>Height
<input
type="number"
step="any"
:value="selectedNode.size.h"
@change="updateSize('h', Number(($event.target as HTMLInputElement).value))" /></label
><label
>Label
<input
:value="selectedNode.labels?.filter(Boolean)[0]"
@change="updateLabel(($event.target as HTMLInputElement).value)" /></label></template
><template v-if="selectedNode.type === 'cluster'"
><h3>座標枠変更</h3>
<label>原点 X <input type="number" v-model.number="promptValue.x" /></label
><label>原点 Y <input type="number" v-model.number="promptValue.y" /></label
><label>角度 <input type="number" v-model.number="promptValue.r" /></label
><button @click="changeFrame">適用</button></template
></template
>
<template v-else-if="selection.length > 1">
<p>{{ selection.length }}件を選択中</p>
<button :disabled="mismatch" @click="rotateSelected">選択を+15°回転</button>
</template>
<p v-else>ノードを選択してください。Shift+クリックで複数選択。</p>
</aside>
</section>
<section v-else class="other-panel">
<IdxPanel v-if="activeTab === 'IDX'" :project="project" :dispatch="dispatch" /><ElcPanel
v-else-if="activeTab === 'ELC'"
@@ -613,14 +198,6 @@ function changeVariant(groupId: string, id: string) {
:editable="!mismatch"
/><GeneratorPanel v-else :project="project" :dispatch="dispatch" />
</section>
<div v-if="activeTab === 'PHY' && selectedNode" class="nudge-controls">
<button :disabled="mismatch" @click="increment('x', -0.25)">X −.25U</button
><button :disabled="mismatch" @click="increment('x', 0.25)">X +.25U</button
><button :disabled="mismatch" @click="increment('y', -0.25)">Y −.25U</button
><button :disabled="mismatch" @click="increment('y', 0.25)">Y +.25U</button
><button :disabled="mismatch" @click="increment('rotation', -15)">回転 −15°</button
><button :disabled="mismatch" @click="increment('rotation', 15)">回転 +15°</button>
</div>
<aside class="validation pane">
<h2>検証結果</h2>
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
@@ -631,21 +208,15 @@ function changeVariant(groupId: string, id: string) {
<h3>
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
</h3>
<button
v-for="(issue, i) in issues.filter((x) => x.severity === s)"
:key="i"
class="issue"
@click="issue.ids?.[0] && setSelected(issue.ids[0], false)"
>
<div v-for="(issue, i) in issues.filter((x) => x.severity === s)" :key="i" class="issue">
<b>{{ issue.code }}</b> {{ issue.message }} <small>{{ issue.path }}</small>
</button>
</div>
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
</section>
</div>
</aside>
</main>
</template>
<style>
* {
box-sizing: border-box;
@@ -665,15 +236,10 @@ body {
gap: 8px;
padding: 8px;
}
.app-shell.locked .properties {
pointer-events: none;
opacity: 0.55;
}
.toolbar,
.tabs,
.pane,
.placeholder {
background: white;
.pane {
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
@@ -694,158 +260,17 @@ body {
.tabs button.active {
background: #dce9ff;
}
.workspace {
.other-panel {
min-height: 0;
display: grid;
grid-template-columns: 260px minmax(300px, 1fr) 250px;
gap: 8px;
}
.pane {
.validation {
overflow: hidden;
padding: 10px;
min-height: 0;
overflow: auto;
}
.tree h2,
.properties h2,
.validation h2 {
font-size: 16px;
margin: 0 0 8px;
}
.commands {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.commands button,
.commands select {
font-size: 12px;
}
.tree-list {
padding-left: 14px;
list-style: none;
margin: 3px 0;
}
.tree-node {
width: 100%;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: transparent;
border: 0;
padding: 4px;
}
.tree-node small {
color: #778;
}
.tree-node.chosen {
background: #dce9ff;
}
.canvas-pane {
display: flex;
flex-direction: column;
}
.canvas-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.canvas-head label {
display: flex;
align-items: center;
gap: 4px;
}
.canvas {
width: 1152px;
height: 896px;
min-height: 240px;
flex: none;
background-color: #fbfcfe;
background-image:
linear-gradient(#e7ebf1 1px, transparent 1px), linear-gradient(90deg, #e7ebf1 1px, transparent 1px);
background-size: 64px 64px;
touch-action: none;
}
.phy-node {
cursor: grab;
}
.phy-node:active {
cursor: grabbing;
}
.key-face {
fill: #f8cc78;
stroke: #5b4a2b;
stroke-width: 0.025;
vector-effect: non-scaling-stroke;
}
.secondary {
fill: #ffe7ad;
}
.key-label {
font-size: 0.13px;
fill: #29251e;
pointer-events: none;
}
.ghost {
opacity: 0.4;
}
.decal .key-face {
fill: #fff;
stroke-dasharray: 0.08 0.06;
}
.chosen > .key-face,
.chosen > .part {
stroke: #1769d2;
stroke-width: 0.055;
}
.part {
fill: #b4d5ef;
stroke: #315d7a;
stroke-width: 0.03;
}
.part-label {
font-size: 0.12px;
fill: #333;
}
.selection-box {
fill: none;
stroke: #1769d2;
stroke-width: 0.025;
stroke-dasharray: 0.04;
}
.group-origin {
fill: #d33;
pointer-events: none;
}
.properties label {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
}
.properties input {
width: 105px;
margin-left: auto;
}
.nudge-controls {
position: fixed;
right: 270px;
bottom: 194px;
z-index: 4;
display: flex;
gap: 4px;
flex-wrap: wrap;
max-width: calc(100vw - 540px);
padding: 6px;
background: #fff;
border: 1px solid #adb8c8;
border-radius: 6px;
}
.validation {
grid-row: 4;
overflow: hidden;
}
.issues {
display: flex;
gap: 20px;
@@ -858,11 +283,7 @@ body {
font-size: 13px;
}
.issue {
display: block;
text-align: left;
width: 100%;
margin: 3px 0;
border: 0;
background: #f4f6f9;
padding: 5px;
}
@@ -881,24 +302,4 @@ body {
max-width: 240px;
overflow: hidden;
}
.confirm-box {
margin-top: 12px;
padding: 8px;
background: #fff2dc;
border: 1px solid #db9d39;
}
.confirm-box ul {
max-height: 90px;
overflow: auto;
padding-left: 18px;
}
.other-panel {
min-height: 0;
}
.placeholder {
padding: 20px;
}
.canvas-pane .warning {
margin: 4px 0;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
import { localBounds } from "./logic";
defineOptions({ name: "NodeSvg" });
const props = defineProps<{
nodes: Node[];
selected: string[];
variants: Record<string, string>;
cut: string[];
dim: boolean;
zoom: number;
angle: number;
longCandidate?: string | null;
}>();
const emit = defineEmits<{
down: [e: PointerEvent, n: Node, el: SVGGElement];
badge: [g: Node, e: PointerEvent];
focus: [id: string];
}>();
function bounds(n: Node) {
return n.type === "cluster"
? localBounds(n.children, props.variants)
: n.type === "variantGroup"
? localBounds(
n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [],
props.variants,
)
: { x: 0, y: 0, width: 0, height: 0 };
}
function badgeBox(n: Node) {
const b = bounds(n);
const size = 26 / props.zoom;
return {
x: b.x + b.width + 4 / props.zoom,
y: b.y - size - 4 / props.zoom,
size,
angle: -(props.angle + n.transform.rotation),
};
}
</script>
<template>
<g
v-for="n in nodes"
:key="n.id"
:data-node-type="n.type"
:data-node-id="n.id"
:transform="`translate(${n.transform.x} ${n.transform.y}) rotate(${n.transform.rotation})`"
:class="{ dim: dim, cut: cut.includes(n.id) }"
@dblclick.stop="emit('focus', n.id)"
>
<g
v-if="n.type === 'key'"
class="node"
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><rect
:width="n.size.w"
:height="n.size.h"
rx=".06"
class="key-face"
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" /><text
class="key-label"
x=".08"
y=".24"
>{{ n.labels?.find(Boolean) }}</text
><rect
v-if="selected.includes(n.id)"
:width="n.size.w"
:height="n.size.h"
class="sel"
:class="{ candidate: longCandidate === n.id }"
/></g>
<g
v-else-if="n.type === 'component'"
class="node"
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><rect
v-if="n.partGroup === '@led'"
x="-.14"
y="-.14"
width=".28"
height=".28"
transform="rotate(45)"
class="part led"
:class="{ chosen: selected.includes(n.id) }"
/><g v-else-if="n.partGroup === '@encoder'" class="part" :class="{ chosen: selected.includes(n.id) }"
><circle r=".16" /><path d="M -.1 0 H .1 M 0 -.1 V .1" /></g
><path
v-else-if="n.partGroup === '@screw'"
d="M -.14 -.14 L .14 .14 M .14 -.14 L -.14 .14"
class="part screw"
:class="{ chosen: selected.includes(n.id) }"
/><rect
v-else-if="n.partGroup === '@oled'"
x="-.2"
y="-.12"
width=".4"
height=".24"
rx=".025"
class="part oled"
:class="{ chosen: selected.includes(n.id) }"
/><circle v-else r=".14" class="part" :class="{ chosen: selected.includes(n.id) }" /><text
x=".2"
y=".04"
class="part-label"
>{{ n.partGroup || "part" }}</text
></g
>
<g
v-else-if="n.type === 'cluster'"
class="group"
@pointerdown.self="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><NodeSvg
:nodes="n.children"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="dim"
:zoom="zoom"
:angle="angle + n.transform.rotation"
:long-candidate="longCandidate"
@down="(e, c, el) => emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/><rect
v-if="selected.includes(n.id) || longCandidate === n.id"
:x="bounds(n).x"
:y="bounds(n).y"
:width="bounds(n).width"
:height="bounds(n).height"
class="group-sel"
:class="{ candidate: longCandidate === n.id }"
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
>
<g v-else class="group"
><NodeSvg
v-for="v in n.children.filter((v) => v.id === (variants[n.id] || n.default))"
:key="v.id"
:nodes="v.children"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="dim"
:zoom="zoom"
:angle="angle + n.transform.rotation"
:long-candidate="longCandidate"
@down="(e, c, el) => emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/><rect
v-if="selected.includes(n.id) || longCandidate === n.id"
:x="bounds(n).x"
:y="bounds(n).y"
:width="bounds(n).width"
:height="bounds(n).height"
class="group-sel"
:class="{ candidate: longCandidate === n.id }"
/><g
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
class="badge"
@click.stop="emit('badge', n, $event)"
><rect
:x="badgeBox(n).x"
:y="badgeBox(n).y"
:width="badgeBox(n).size"
:height="badgeBox(n).size"
rx="3"
@click.stop="emit('badge', n, $event)"
/><text
:x="badgeBox(n).x + 3 / zoom"
:y="badgeBox(n).y + 16 / zoom"
:style="{ fontSize: `${12 / zoom}px` }"
>{{ n.children.find((v) => v.id === (variants[n.id] || n.default))?.name
}}{{ n.default === (variants[n.id] || n.default) ? " ✓" : "" }}</text
></g
></g
>
</g>
</template>
<style scoped>
.node {
cursor: grab;
}
.key-face {
fill: #f8cc78;
stroke: #5b4a2b;
stroke-width: 0.03;
}
.key-face.ghost {
opacity: 0.4;
}
.key-face.decal {
fill: #fff;
stroke-dasharray: 0.08 0.06;
}
.key-label,
.part-label,
.group-label {
font-size: 0.14px;
pointer-events: none;
}
.part {
fill: #b4d5ef;
stroke: #315d7a;
stroke-width: 0.03;
}
.part.chosen {
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.part.screw {
fill: none;
}
.part path {
fill: none;
stroke: #315d7a;
stroke-width: 0.03;
}
.sel,
.group-sel {
fill: none;
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.group-sel {
stroke-dasharray: 5 3;
}
.candidate {
stroke: #e87912;
stroke-width: 2.5px;
}
.group {
cursor: default;
}
.dim {
opacity: 0.25;
}
.cut {
opacity: 0.3;
}
.group-label {
fill: #222;
}
.badge {
cursor: pointer;
pointer-events: all;
}
.badge rect {
fill: #fff3c2;
stroke: #8a691b;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
}
.badge text {
font-size: 12px;
fill: #302709;
pointer-events: none;
}
</style>
-35
View File
@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
import PhyNodeView from "./PhyNodeView.vue";
defineProps<{ nodes: Node[]; selected: string[]; variants: Record<string, string>; zoom: number }>();
const emit = defineEmits<{
select: [id: string, additive: boolean];
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
}>();
function visible(n: Node, selection: Record<string, string>) {
return n.type !== "variantGroup" || !!n.children.find((v) => v.id === (selection[n.id] ?? n.default));
}
</script>
<template>
<svg
class="canvas"
data-testid="phy-canvas"
:viewBox="`-64 -64 ${1152 / zoom} ${896 / zoom}`"
width="1152"
height="896"
preserveAspectRatio="xMinYMin meet"
>
<g class="layout" transform="scale(64)">
<g v-for="n in nodes" :key="n.id">
<PhyNodeView
:node="n"
:selected="selected"
:variants="variants"
:visible="visible(n, variants)"
@select="(id, additive) => emit('select', id, additive)"
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
/>
</g>
</g>
</svg>
</template>
+587
View File
@@ -0,0 +1,587 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import type { Dispatch } from "../types";
import { reframe } from "../../domain/phyCommands";
import { listDeleteReferences } from "../../domain/deleteCascade";
import PhyTree from "./PhyTree.vue";
import NodeSvg from "./NodeSvg.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
import { usePhyClipboard } from "./usePhyClipboard";
import { usePhyViewport } from "./usePhyViewport";
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
import { usePhyKeyboard } from "./usePhyKeyboard";
import { usePhyCommands } from "./usePhyCommands";
import { usePhyCanvasView } from "./usePhyCanvasView";
import { usePhyTreeModel } from "./usePhyTreeModel";
const props = defineProps<{
project: ProjectFile;
dispatch: Dispatch;
mismatch: boolean;
beginHistory: () => void;
commitHistory: () => void;
}>();
const emit = defineEmits<{ error: [text: string] }>();
const selected = ref<string[]>([]),
variants = ref<Record<string, string>>({}),
focus = ref<string | null>(null),
grid = ref<number>(0.25),
zoom = ref(52),
pan = ref({ x: 50, y: 50 }),
pane = ref<HTMLElement>(),
svg = ref<SVGSVGElement>(),
treeOpen = ref(true),
showHelp = ref(false),
popover = ref<string | null>(null),
cut = ref<string[]>([]),
confirm = ref<string | null>(null),
confirmIds = ref<string[]>([]),
confirmRefs = ref<ReturnType<typeof listDeleteReferences>>([]),
confirmPos = ref({ left: 12, top: 44 }),
popoverPos = ref({ left: 12, top: 44 }),
size = ref({ w: 900, h: 600 }),
longHint = ref("");
watch(
() => props.project,
(p) => {
for (const g of structuralNodes(p.phy.root))
if (g.type === "variantGroup" && !variants.value[g.id]) variants.value[g.id] = g.default;
},
{ immediate: true },
);
watch(
() => props.project.projectId,
async () => {
selected.value = [];
focus.value = null;
variants.value = {};
await nextTick();
fit();
},
);
const {
allNodes,
node,
parentId,
isAncestor,
selectedContainer,
groupForVariant,
parentLabel,
pressTargets,
} = usePhyTreeModel(() => props.project, focus);
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
project: () => props.project,
pane,
svg,
size,
zoom,
pan,
selected,
variants,
node,
});
function setSel(id: string, add = false) {
selected.value = normalizeSelection(
props.project.phy,
add
? selected.value.includes(id)
? selected.value.filter((x) => x !== id)
: [...selected.value, id]
: [id],
);
pane.value?.focus();
}
const canvasView = usePhyCanvasView({
project: () => props.project,
size,
pan,
zoom,
focus,
variants,
grid,
selected,
node,
parentId,
parentLabel,
selectedContainer,
});
const {
keys,
groupsAll,
outsideNodes,
gridLines,
variantGroups,
focusGridLines,
moveTargets,
parentMatrix: focusParentMatrix,
focusAngle,
affineText,
focusCrumbs,
propertyNode: prop,
} = canvasView;
function focusIn(id: string) {
focus.value = id;
selected.value = [id];
}
onMounted(() => {
window.addEventListener("keydown", keyHandler);
window.addEventListener("keyup", spaceUp);
window.addEventListener("paste", pasteEvent);
window.addEventListener("copy", copyEvent);
window.addEventListener("cut", copyEvent);
fit();
});
onUnmounted(() => {
window.removeEventListener("keydown", keyHandler);
window.removeEventListener("keyup", spaceUp);
window.removeEventListener("paste", pasteEvent);
window.removeEventListener("copy", copyEvent);
window.removeEventListener("cut", copyEvent);
});
function dispatch(label: string, fn: (p: ProjectFile) => ProjectFile) {
try {
props.dispatch(label, fn);
} catch (e) {
emit("error", String(e));
}
}
function update(id: string, fn: (n: Node) => void, label = "プロパティ変更") {
dispatch(label, (p) => {
const q = structuredClone(p);
const n = allNodes(q.phy.root).find((x) => x.id === id);
if (n) fn(n);
return q;
});
}
const commands = usePhyCommands({
project: () => props.project,
selected,
variants,
focus,
grid,
zoom,
pan,
size,
svg,
pane,
popover,
confirm,
confirmIds,
confirmRefs,
confirmPos,
node,
allNodes,
parentId,
selectedContainer,
groupForVariant,
variantGroups: () => variantGroups.value,
dispatch,
emitError: (message) => emit("error", message),
});
const {
parentMatrix,
nodesInContainer,
directChildren,
tabItem,
chooseVariant,
keyAdd,
partAdd,
makeCluster,
ungroup,
variantGroup,
duplicate,
moveSelectionTo,
askDelete,
deleteConfirmed,
setDefault,
addVariant: addVariantToGroup,
} = commands;
const { copyEvent, pasteEvent } = usePhyClipboard({
project: () => props.project,
mismatch: () => props.mismatch,
selected,
cut,
grid,
node,
selectedContainer,
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
parentMatrix,
dispatch,
setSelected: (ids) => (selected.value = ids),
});
const priorChild = new Map<string, string>();
function visibleIds(): Set<string> {
if (!focus.value) return new Set(allNodes().map((n) => n.id));
return visibleNodeIds(node(focus.value), variants.value);
}
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
const pointer = usePhyCanvasPointer({
project: () => props.project,
svg,
pane,
pan,
zoom,
grid,
selected,
focus,
rangeBox,
allNodes,
keys: () => keys.value,
localPoint,
visibleIds,
mismatch: () => props.mismatch,
isAncestor,
parentLabel,
pressTargets,
setSelection: setSel,
beginHistory: props.beginHistory,
commitHistory: props.commitHistory,
dispatch,
});
const { canvasDown, down, spaceUp, longLabel, longLabelPos, spaceHeld, holdCandidates, holdIndex } = pointer;
const keyHandler = usePhyKeyboard({
project: () => props.project,
mismatch: () => props.mismatch,
pane,
selected,
focus,
grid,
zoom,
showHelp,
popover,
confirm,
spaceHeld,
variants,
priorChild,
node,
allNodes,
parentId,
selectedContainer,
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
nodesInContainer,
directChildren,
tabItem,
dispatch,
fit,
fitSelected,
focusIn,
makeCluster,
ungroup,
duplicate,
keyAdd,
askDelete,
deleteConfirmed,
chooseVariant,
});
function overlayPoint(clientX: number, clientY: number, width = 380, height = 240) {
const r = pane.value?.getBoundingClientRect();
return {
left: Math.max(4, Math.min(clientX - (r?.left ?? 0), (r?.width ?? width) - width - 4)),
top: Math.max(4, Math.min(clientY - (r?.top ?? 0), (r?.height ?? height) - height - 4)),
};
}
function openBadge(g: Node, e: PointerEvent | MouseEvent) {
if (focus.value && !visibleIds().has(g.id)) return;
popover.value = g.id;
const r = (e.currentTarget as Element).getBoundingClientRect();
const paneRect = pane.value?.getBoundingClientRect();
const left = r.right + 210 < (paneRect?.right ?? Infinity) ? r.left : r.left - 210;
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
popoverPos.value = overlayPoint(left, top, 210, 150);
}
function onFocus(_id: string) {
if (
pointer.priorSelection().length === 1 &&
["cluster", "variantGroup"].includes(node(pointer.priorSelection()[0]!)?.type || "")
)
focusIn(pointer.priorSelection()[0]!);
}
</script>
<template>
<section class="workspace" :class="{ locked: mismatch }">
<aside v-if="treeOpen" class="tree pane">
<PhyTree :nodes="project.phy.root" :selected="selected" @select="setSel" />
</aside>
<section ref="pane" class="canvas-pane pane" tabindex="0">
<PhyToolbar
:mismatch="mismatch"
:selected-count="selected.length"
:grid="grid"
:move-targets="moveTargets"
:focus="focus"
:crumbs="focus ? focusCrumbs(focus) : []"
@key-add="keyAdd"
@part-add="partAdd"
@cluster="makeCluster"
@ungroup="ungroup"
@variant-group="variantGroup"
@move-to="moveSelectionTo"
@duplicate="duplicate"
@delete="(event) => askDelete(undefined, event)"
@grid="grid = nextGrid(grid, 1)"
@fit="fit"
@fit-selected="fitSelected"
@zoom="(direction) => (zoom *= direction > 0 ? 1.15 : 1 / 1.15)"
@tree="treeOpen = !treeOpen"
@help="showHelp = true"
@focus="focus = $event"
/>
<div
v-if="longLabel"
class="long-label"
:style="{ left: longLabelPos.x + 'px', top: longLabelPos.y + 'px' }"
>
{{ longLabel }}
</div>
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
<svg
ref="svg"
class="canvas"
:viewBox="`0 0 ${size.w} ${size.h}`"
@wheel.prevent="wheel"
@pointerdown="canvasDown"
>
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
<path
v-if="!focus"
v-for="(line, i) in gridLines"
:key="i"
:d="line.d"
:class="{ major: line.major }"
/>
<NodeSvg
v-if="focus && outsideNodes.length"
:nodes="outsideNodes"
:selected="[]"
:variants="variants"
:cut="cut"
:dim="true"
:zoom="zoom"
:angle="0"
/>
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
<g
:transform="`translate(${node(focus)!.transform.x} ${node(focus)!.transform.y}) rotate(${node(focus)!.transform.rotation})`"
>
<path v-for="(line, i) in focusGridLines" :key="i" :d="line.d" :class="{ major: line.major }" />
</g>
<NodeSvg
:nodes="[node(focus)!]"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="false"
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:zoom="zoom"
:angle="focusAngle(focus)"
@down="down"
@badge="openBadge"
@focus="onFocus"
/>
</g>
<NodeSvg
v-else
:nodes="project.phy.root"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="false"
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:zoom="zoom"
:angle="0"
@down="down"
@badge="openBadge"
@focus="onFocus"
/>
</g>
<rect
v-if="rangeBox"
class="range-box"
:x="rangeBox.x"
:y="rangeBox.y"
:width="rangeBox.width"
:height="rangeBox.height"
/>
</svg>
<div
v-if="confirm"
class="confirm-float"
:style="{ left: confirmPos.left + 'px', top: confirmPos.top + 'px' }"
>
<b>削除の確認</b>
<p>参照 {{ confirmRefs.length }}件</p>
<ul>
<li v-for="(r, i) in confirmRefs" :key="i">{{ r.layer }} · {{ r.id }} · {{ r.location }}</li>
</ul>
<button @click="deleteConfirmed">削除</button><button @click="confirm = null">取消</button>
</div>
<div
v-if="popover"
class="popover"
:style="{ left: popoverPos.left + 'px', top: popoverPos.top + 'px' }"
>
<template v-for="g in variantGroups.filter((n) => n.id === popover)" :key="g.id"
><button v-for="v in g.children" :key="v.id" @click="chooseVariant(g, v.id)">
{{ v.name }} {{ g.default === v.id ? "✓" : "" }}
</button></template
>
</div>
<div v-if="showHelp" class="overlay" @click.self="showHelp = false">
<article>
<button @click="showHelp = false">閉じる</button>
<h2>ショートカット</h2>
<p>Esc 選択解除・階層を戻る · Alt+↑/↓ 親・子 · Tab/Shift+Tab 兄弟 · Ctrl+A 同じ親を選択</p>
<p>矢印 移動 · Shift+矢印 サイズ · PgUp/PgDn 回転 · R/Shift+R キーの向き</p>
<p>
Ctrl+G クラスター · Ctrl+Shift+G 解除 · Ctrl+C/X/V コピー・カット・貼り付け · Ctrl+D 複製 · Ins
追加 · Delete 削除
</p>
<p>
Shift+1 全体表示 · Shift+2 選択表示 · +/- ズーム · G/Shift+G 刻み · V/Shift+V バリアント · Enter
フォーカス · F2 プロパティ
</p>
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
<p>長押し: キー上で500ms待つと選択先候補が450msごとに進み、離すと確定します。</p>
</article>
</div>
</section>
<PhyProperties
:selected-count="selected.length"
:selected-id="selected[0] ?? null"
:item="prop ?? null"
:mismatch="mismatch"
@update="update"
@apply-frame="(id, frame) => dispatch('座標枠変更', (p) => ({ ...p, phy: reframe(p.phy, id, frame) }))"
@set-default="(groupId, variantId) => setDefault(groupId, variantId)"
@change-variant="chooseVariant"
@delete-node="(id) => askDelete(id)"
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
/>
</section>
</template>
<style scoped>
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 260px minmax(300px, 1fr) 250px;
gap: 8px;
}
.pane {
padding: 10px;
min-height: 0;
overflow: auto;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.canvas-pane {
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
background: #fbfcfe;
touch-action: none;
}
.canvas path {
stroke: #e6eaf0;
stroke-width: 0.015;
fill: none;
}
.canvas path.major {
stroke: #ccd3de;
stroke-width: 0.03;
}
.range-box {
fill: #4b8de822;
stroke: #1769d2;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.properties fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.properties label {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
}
.properties input {
width: 110px;
margin-left: auto;
}
.popover,
.confirm-float,
.overlay {
position: absolute;
z-index: 5;
background: #fff;
padding: 12px;
border: 1px solid #99a3b2;
box-shadow: 0 4px 15px #0003;
}
.confirm-float {
position: absolute;
z-index: 6;
width: min(380px, calc(100% - 16px));
max-height: min(240px, calc(100% - 16px));
overflow: auto;
font-size: 14px;
color: #20242a;
}
.confirm-float ul {
max-height: 120px;
overflow: auto;
padding-left: 22px;
}
.overlay {
inset: 20%;
}
.tree-list {
list-style: none;
padding-left: 10px;
}
.confirm-float rect {
fill: #fff2dc;
stroke: #db9d39;
}
.confirm-float foreignObject div {
font: 1.5px sans-serif;
}
.confirm-float ul {
max-height: 1px;
overflow: auto;
}
.workspace.locked .properties {
pointer-events: none;
opacity: 0.55;
}
.warning {
color: #9a4c00;
}
.long-label {
position: fixed;
z-index: 3;
left: 0;
top: 0;
transform: translate(10px, 10px);
pointer-events: none;
background: #fff3d8;
border: 1px solid #c87d13;
padding: 3px 7px;
border-radius: 4px;
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
const props = defineProps<{
node: Node;
selected: string[];
variants: Record<string, string>;
visible: boolean;
}>();
const emit = defineEmits<{
select: [id: string, additive: boolean];
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
}>();
function children(n: Node): Node[] {
if (n.type === "cluster") return n.children;
if (n.type === "variantGroup")
return n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [];
return [];
}
function partShape(part?: string) {
return part === "@led"
? "led"
: part === "@encoder"
? "encoder"
: part === "@screw"
? "screw"
: part === "@oled"
? "oled"
: "other";
}
function pointer(event: PointerEvent) {
const parent = (event.currentTarget as SVGGElement).parentElement as unknown as SVGGElement;
emit("dragStart", event, props.node.id, parent);
}
</script>
<template>
<g
v-if="visible"
class="phy-node"
:class="[
{
chosen: selected.includes(node.id),
ghost: node.type === 'key' && node.ghost,
decal: node.type === 'key' && node.decal,
},
`node-${node.type}`,
]"
:data-node-id="node.id"
:data-node-type="node.type"
:transform="`translate(${node.transform.x},${node.transform.y}) rotate(${node.transform.rotation})`"
@pointerdown.stop="pointer"
@click.stop="emit('select', node.id, $event.shiftKey)"
>
<template v-if="node.type === 'key'"
><rect
class="key-face"
x="0.04"
y="0.04"
:width="Math.max(0.1, node.size.w - 0.08)"
:height="Math.max(0.1, node.size.h - 0.08)"
rx="0.08"
/><rect
v-if="node.size.w2 || node.size.h2 || node.size.x2 || node.size.y2"
class="key-face secondary"
:x="node.size.x2 || 0"
:y="node.size.y2 || 0"
:width="node.size.w2 || node.size.w"
:height="node.size.h2 || node.size.h"
rx="0.06"
/><text class="key-label" x="0.12" y="0.3">{{
node.labels?.filter(Boolean).slice(0, 2).join(" / ")
}}</text></template
>
<template v-else-if="node.type === 'component'"
><circle v-if="partShape(node.partGroup) === 'led'" class="part led" r="0.25" /><circle
v-else-if="partShape(node.partGroup) === 'screw'"
class="part screw"
r="0.18"
/><rect
v-else-if="partShape(node.partGroup) === 'oled'"
class="part oled"
x="-0.35"
y="-0.22"
width="0.7"
height="0.44"
/><g v-else-if="partShape(node.partGroup) === 'encoder'"
><circle class="part encoder" r="0.27" /><path d="M-.1 -.18 L.12 0 -.1 .18" /></g
><path v-else class="part" d="M0 -.22 L.22 0 0 .22 -.22 0 Z" /><text
class="part-label"
x="0.28"
y="0.05"
>{{ node.partGroup || "part" }}</text
></template
>
<g v-else-if="node.type === 'cluster'" class="cluster-content"
><PhyNodeView
v-for="c in node.children"
:key="c.id"
:node="c"
:selected="selected"
:variants="variants"
:visible="true"
@select="(id, additive) => emit('select', id, additive)"
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
/></g>
<g v-else-if="node.type === 'variantGroup'" class="variant-content"
><PhyNodeView
v-for="c in children(node)"
:key="c.id"
:node="c"
:selected="selected"
:variants="variants"
:visible="true"
@select="(id, additive) => emit('select', id, additive)"
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
/></g>
<circle v-if="node.type === 'cluster' || node.type === 'variantGroup'" class="group-origin" r="0.07" />
<rect
v-if="selected.includes(node.id) && node.type !== 'key'"
class="selection-box"
x="-0.12"
y="-0.12"
width="0.24"
height="0.24"
/>
</g>
</template>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy";
const props = defineProps<{
selectedCount: number;
selectedId: string | null;
item: Node | null;
mismatch: boolean;
}>();
const emit = defineEmits<{
update: [id: string, change: (node: Node) => void, label?: string];
applyFrame: [id: string, frame: Transform];
setDefault: [groupId: string, variantId: string];
changeVariant: [group: Node, variantId: string];
deleteNode: [id: string];
addVariant: [groupId: string, name: string];
}>();
const group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
const frameX = ref(0);
const frameY = ref(0);
const frameR = ref(0);
</script>
<template>
<aside class="properties pane">
<fieldset :disabled="mismatch">
<p v-if="selectedCount > 1">{{ selectedCount }}件を選択中</p>
<template v-else-if="item">
<h2>{{ item.type }}</h2>
<label
>X
<input
type="number"
step="any"
:value="item.transform.x"
@change="
emit(
'update',
item.id,
(n) => (n.transform.x = Number(($event.target as HTMLInputElement).value)),
)
"
/></label>
<label
>Y
<input
type="number"
step="any"
:value="item.transform.y"
@change="
emit(
'update',
item.id,
(n) => (n.transform.y = Number(($event.target as HTMLInputElement).value)),
)
"
/></label>
<label
>Rotation
<input
type="number"
step="any"
:value="item.transform.rotation"
@change="
emit(
'update',
item.id,
(n) => (n.transform.rotation = Number(($event.target as HTMLInputElement).value)),
)
"
/></label>
<template v-if="item.type === 'key'">
<label
>Width
<input
type="number"
step="any"
:value="item.size.w"
@change="
emit('update', item.id, (n) => {
if (n.type === 'key') n.size.w = Number(($event.target as HTMLInputElement).value);
})
"
/></label>
<label
>Height
<input
type="number"
step="any"
:value="item.size.h"
@change="
emit('update', item.id, (n) => {
if (n.type === 'key') n.size.h = Number(($event.target as HTMLInputElement).value);
})
"
/></label>
<label
>Label
<input
:value="item.labels?.[0]"
@change="
emit('update', item.id, (n) => {
if (n.type === 'key') n.labels = [($event.target as HTMLInputElement).value];
})
"
/></label>
</template>
<template v-if="item.type === 'cluster'">
<label
>名前
<input
:value="item.name"
@change="
emit('update', item.id, (n) => {
if (n.type === 'cluster') n.name = ($event.target as HTMLInputElement).value;
})
"
/></label>
<h3>座標枠変更</h3>
<label>原点X <input type="number" v-model.number="frameX" /></label>
<label>原点Y <input type="number" v-model.number="frameY" /></label>
<label>角度 <input type="number" v-model.number="frameR" /></label>
<button @click="emit('applyFrame', item.id, { x: frameX, y: frameY, rotation: frameR })">
適用
</button>
</template>
<template v-if="group">
<label
>名前
<input
:value="group.name"
@change="
emit('update', group.id, (n) => {
if (n.type === 'variantGroup') n.name = ($event.target as HTMLInputElement).value;
})
"
/></label>
<div v-for="v in group.children" :key="v.id">
{{ v.name }} {{ v.id === group.default ? "✓" : "" }}
<button @click="emit('setDefault', group.id, v.id)">既定</button>
<button @click="emit('changeVariant', group, v.id)">表示</button>
<button :disabled="group.children.length <= 2" @click="emit('deleteNode', v.id)">削除</button>
</div>
<button @click="emit('addVariant', group.id, 'Option ' + (group.children.length + 1))">追加</button>
</template>
</template>
<p v-else-if="selectedCount === 0">ノードを選択してください</p>
</fieldset>
</aside>
</template>
<style scoped>
.pane {
padding: 10px;
min-height: 0;
overflow: auto;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.properties fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.properties label {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
}
.properties input {
width: 110px;
margin-left: auto;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
defineProps<{
mismatch: boolean;
selectedCount: number;
grid: number;
moveTargets: { id: string; label: string }[];
focus: string | null;
crumbs: { id: string | null; label: string }[];
}>();
const emit = defineEmits<{
keyAdd: [];
partAdd: [group: string];
cluster: [];
ungroup: [];
variantGroup: [];
moveTo: [id: string];
duplicate: [];
delete: [event: MouseEvent];
grid: [];
fit: [];
fitSelected: [];
zoom: [direction: 1 | -1];
tree: [];
help: [];
focus: [id: string | null];
}>();
function addPart(e: Event) {
const select = e.target as HTMLSelectElement;
emit("partAdd", select.value);
select.value = "";
}
</script>
<template>
<div class="tools">
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
+⌨
</button>
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
<option value="">部品を足す</option>
<option>@led</option>
<option>@encoder</option>
<option>@screw</option>
<option>@oled</option>
<option>その他</option>
</select>
<button
title="クラスターにする"
aria-label="クラスターにする"
:disabled="mismatch || !selectedCount"
@click="emit('cluster')"
>
▱
</button>
<button
title="クラスターを解除"
aria-label="クラスターを解除"
:disabled="mismatch"
@click="emit('ungroup')"
>
⊟
</button>
<button
title="選択からバリアントグループ"
aria-label="選択からバリアントグループ"
:disabled="mismatch || !selectedCount"
@click="emit('variantGroup')"
>
◇
</button>
<select
aria-label="移し先…"
:disabled="mismatch || !selectedCount"
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
>
<option value="">移し先…</option>
<option v-for="target in moveTargets" :key="target.id" :value="target.id">{{ target.label }}</option>
</select>
<button title="複製" aria-label="複製" :disabled="mismatch || !selectedCount" @click="emit('duplicate')">
⧉
</button>
<button
title="削除"
aria-label="削除"
:disabled="mismatch || !selectedCount"
@click="emit('delete', $event)"
>
⌫
</button>
<button @click="emit('grid')">Grid {{ grid }}U</button>
<button title="全体表示" aria-label="全体表示" @click="emit('fit')">⛶</button>
<button title="選択範囲にズーム" aria-label="選択範囲にズーム" @click="emit('fitSelected')">⌕</button>
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
<span v-if="focus">
<template v-for="(crumb, i) in crumbs" :key="i">
<button @click="emit('focus', crumb.id)">{{ i ? " › " : "" }}{{ crumb.label }}</button>
</template>
</span>
</div>
</template>
<style scoped>
.tools {
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.tools button,
.tools select {
font-size: 12px;
}
</style>
+99 -39
View File
@@ -1,54 +1,114 @@
<script setup lang="ts">
import type { Node, Variant } from "../../domain/phy";
type Item = Node | Variant;
defineProps<{ nodes: Item[]; selected: string[]; expanded?: boolean }>();
const emit = defineEmits<{ select: [id: string, additive: boolean] }>();
function kind(n: Item) {
switch (n.type) {
case "variantGroup":
return "VariantGroup";
case "cluster":
return "Cluster";
case "component":
return "部品";
case "key":
return "キー";
case "variant":
return "Variant";
}
}
function title(n: Item) {
switch (n.type) {
case "key":
return n.labels?.filter(Boolean)[0] || "無題キー";
case "component":
return n.partGroup || "部品";
case "variantGroup":
case "cluster":
case "variant":
return n.name || n.type;
}
}
function children(n: Item): Item[] {
return n.type === "cluster" || n.type === "variantGroup" || n.type === "variant" ? n.children : [];
import { computed } from "vue";
import type { Node } from "../../domain/phy";
defineOptions({ name: "PhyTree" });
const props = defineProps<{ nodes: Node[]; selected: string[] }>();
const emit = defineEmits<{ select: [id: string, add: boolean] }>();
const structures = computed(() =>
props.nodes.filter((n) => n.type === "cluster" || n.type === "variantGroup"),
);
const leaves = computed(() => props.nodes.filter((n) => n.type === "key" || n.type === "component"));
function countLeaves(nodes: Node[]): number {
return nodes.reduce(
(count, n) =>
count +
(n.type === "key" || n.type === "component"
? 1
: n.type === "cluster"
? countLeaves(n.children)
: n.type === "variantGroup"
? n.children.reduce((sum, v) => sum + countLeaves(v.children), 0)
: 0),
0,
);
}
</script>
<template>
<ul class="tree-list">
<li v-for="n in nodes" :key="n.id">
<li v-for="n in structures" :key="n.id">
<button
class="tree-node"
:class="{ chosen: selected.includes(n.id) }"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ kind(n) }} · {{ title(n) }} <small>{{ n.id }}</small></button
><PhyTree
v-if="children(n).length"
:nodes="children(n)"
{{ n.type === "cluster" ? "クラスター" : n.name }}
<small>{{ n.type === "cluster" ? `${countLeaves(n.children)}件` : `${n.children.length}件` }}</small>
</button>
<PhyTree
v-if="n.type === 'cluster'"
:nodes="n.children"
:selected="selected"
@select="(id, additive) => emit('select', id, additive)"
@select="(id, add) => emit('select', id, add)"
/>
<div v-else class="variants">
<div v-for="v in n.children" :key="v.id">
<button
class="tree-node variant"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ v.name }}
<small>{{ n.default === v.id ? "既定 · " : "" }}{{ countLeaves(v.children) }}件</small>
</button>
<details>
<summary>キー・部品 {{ countLeaves(v.children) }}件</summary>
<PhyTree
:nodes="v.children"
:selected="selected"
@select="(id, add) => emit('select', id, add)"
/>
</details>
</div>
</div>
</li>
<li v-if="leaves.length">
<details>
<summary>キー・部品 {{ leaves.length }}件</summary>
<ul class="tree-list">
<li v-for="n in leaves" :key="n.id">
<button
class="tree-node leaf"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ n.type === "key" ? "キー" : "部品" }}
<small>{{ n.type === "key" ? n.labels?.find(Boolean) : n.partGroup }}</small>
</button>
</li>
</ul>
</details>
</li>
</ul>
</template>
<style scoped>
.tree-list {
list-style: none;
padding-left: 12px;
margin: 3px 0;
}
.tree-node {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 4px;
white-space: nowrap;
}
.leaf {
font-size: 12px;
}
.chosen {
background: #dce9ff;
}
summary {
font-size: 11px;
color: #667;
cursor: pointer;
}
.variants {
padding-left: 4px;
}
</style>
-13
View File
@@ -1,13 +0,0 @@
export type Point = { x: number; y: number };
export function snapFromStart(_start: number, delta: number, step = 0.25): number {
return Math.round(delta / step) * step;
}
export function parentPoint(parent: SVGGraphicsElement, clientX: number, clientY: number): Point {
const matrix = parent.getScreenCTM();
if (!matrix) throw new Error("SVG parent has no screen transform");
const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
return { x: point.x, y: point.y };
}
export function snappedDelta(start: Point, current: Point): Point {
return { x: snapFromStart(0, current.x - start.x), y: snapFromStart(0, current.y - start.y) };
}
+236
View File
@@ -0,0 +1,236 @@
import type { Node, PhyFile, Transform } from "../../domain/phy";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
const parent = new Map<string, string | null>(),
nodes = new Map<string, Node>();
const walk = (items: Node[], p: string | null) =>
items.forEach((n) => {
parent.set(n.id, p);
nodes.set(n.id, n);
if (n.type === "cluster") walk(n.children, n.id);
if (n.type === "variantGroup")
n.children.forEach((v) => {
parent.set(v.id, n.id);
walk(v.children, v.id);
});
});
walk(file.root, null);
const out: string[] = [];
for (const id of ids) {
if (!nodes.has(id) || out.includes(id)) continue;
const ancestors = new Set<string>();
let p = parent.get(id) ?? null;
while (p) {
ancestors.add(p);
p = parent.get(p) ?? null;
}
const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id));
out.splice(0, out.length, ...keep, id);
}
return out;
}
function isDescendant(parent: Map<string, string | null>, candidate: string, ancestor: string): boolean {
let p = parent.get(candidate) ?? null;
while (p) {
if (p === ancestor) return true;
p = parent.get(p) ?? null;
}
return false;
}
export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] {
const list = [keyId, ...ancestorsNearestFirst];
if (focusId) {
const i = list.indexOf(focusId);
if (i >= 0) list.splice(i);
}
return list;
}
export function tabOrder<T extends { id: string; x: number; y: number }>(items: T[]): T[] {
const sorted = [...items].sort((a, b) => a.y - b.y),
rows: T[][] = [];
for (const item of sorted) {
const row = rows.at(-1);
if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]);
else row.push(item);
}
return rows.flatMap((row) => row.sort((a, b) => a.x - b.x));
}
/** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */
export function treeIndex(roots: Node[]) {
const nodes = new Map<string, Node>();
const parents = new Map<string, string | null>();
const containers = new Map<string, string | null>();
const variants = new Map<string, string>();
const visit = (items: Node[], parent: string | null, container: string | null) => {
for (const n of items) {
nodes.set(n.id, n);
parents.set(n.id, parent);
containers.set(n.id, container);
if (n.type === "cluster") visit(n.children, n.id, n.id);
if (n.type === "variantGroup") {
for (const v of n.children) {
variants.set(v.id, n.id);
visit(v.children, n.id, v.id);
}
}
}
};
visit(roots, null, null);
return {
nodes,
parents,
containers,
variants,
selectableParent(id: string) {
return parents.get(id) ?? null;
},
insertionContainer(id: string) {
return containers.get(id) ?? null;
},
groupForVariant(id: string) {
return variants.get(id) ?? null;
},
};
}
export function treeNodes(roots: Node[]): Node[] {
return [...treeIndex(roots).nodes.values()];
}
export function structuralNodes(roots: Node[]): Extract<Node, { type: "cluster" | "variantGroup" }>[] {
return treeNodes(roots).filter(
(node): node is Extract<Node, { type: "cluster" | "variantGroup" }> =>
node.type === "cluster" || node.type === "variantGroup",
);
}
export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] {
const result: Node[] = [];
for (const node of nodes) {
if (node.id === focusedId) continue;
if (node.type === "cluster") {
const children = removeFocusedSubtree(node.children, focusedId);
result.push({ ...node, children });
} else if (node.type === "variantGroup") {
result.push({
...node,
children: node.children.map((variant) => ({
...variant,
children: removeFocusedSubtree(variant.children, focusedId),
})),
});
} else {
result.push(node);
}
}
return result;
}
export function visibleNodeIds(root: Node | undefined, shownVariants: Record<string, string>): Set<string> {
const result = new Set<string>();
const visit = (node: Node) => {
result.add(node.id);
if (node.type === "cluster") node.children.forEach(visit);
if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default));
variant?.children.forEach(visit);
}
};
if (root) visit(root);
return result;
}
export function selectableParent(roots: Node[], id: string): string | null {
return treeIndex(roots).selectableParent(id);
}
export function insertionContainer(
roots: Node[],
parentId: string | null,
shownVariants: Record<string, string> = {},
): string | null {
if (parentId === null) return null;
const index = treeIndex(roots);
const node = index.nodes.get(parentId);
if (node?.type === "cluster") return node.id;
if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default;
return index.variants.get(parentId) === undefined ? null : parentId;
}
export function snap(value: number, step: number): number {
return Math.round(value / step) * step;
}
export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const;
export function nextGrid(step: number, direction: 1 | -1): number {
const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9);
return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!;
}
export function resolveKey(
e: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
): string | null {
const mod = e.ctrlKey || e.metaKey;
if (e.code === "Digit1" && e.shiftKey) return "fit";
if (e.code === "Digit2" && e.shiftKey) return "selection-fit";
if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo";
if (mod && e.key.toLowerCase() === "y") return "redo";
if (mod && e.key.toLowerCase() === "s") return "save";
if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group";
if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase();
return e.key;
}
export type Box = { x: number; y: number; width: number; height: number };
export function obbAabb(points: { x: number; y: number }[]): Box {
if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
const xs = points.map((p) => p.x),
ys = points.map((p) => p.y),
x = Math.min(...xs),
y = Math.min(...ys);
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
}
export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null {
return selectedParents.length ? selectedParents[0]! : focus;
}
/** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */
export function localBounds(nodes: Node[], variants: Record<string, string> = {}): Box {
const points: { x: number; y: number }[] = [];
const visit = (items: Node[], parent: Affine) =>
items.forEach((n) => {
const w = compose(parent, matrix(n.transform));
if (n.type === "key") {
const corners = [
{ x: 0, y: 0 },
{ x: n.size.w, y: 0 },
{ x: n.size.w, y: n.size.h },
{ x: 0, y: n.size.h },
];
if (
n.size.x2 !== undefined ||
n.size.y2 !== undefined ||
n.size.w2 !== undefined ||
n.size.h2 !== undefined
) {
const x = n.size.x2 ?? 0,
y = n.size.y2 ?? 0,
ww = n.size.w2 ?? n.size.w,
hh = n.size.h2 ?? n.size.h;
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
}
points.push(...corners.map((p) => apply(w, p)));
} else if (n.type === "component") {
const c = apply(w, { x: 0, y: 0 });
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
} else if (n.type === "cluster") visit(n.children, w);
else {
const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default));
if (v) visit(v.children, w);
}
});
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
return obbAabb(points);
}
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
const local = compose(inverse(parentWorld), sourceWorld);
return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI };
}
+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;
}
+177
View File
@@ -0,0 +1,177 @@
import { computed, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node, VariantGroupNode } from "../../domain/phy";
import {
apply,
compose,
flattenKeys,
inverse,
matrix,
worldTransform,
type Affine,
} from "../../domain/geometry";
import { removeFocusedSubtree, structuralNodes } from "./logic";
type Options = {
project: () => ProjectFile;
size: Ref<{ w: number; h: number }>;
pan: Ref<{ x: number; y: number }>;
zoom: Ref<number>;
focus: Ref<string | null>;
variants: Ref<Record<string, string>>;
grid: Ref<number>;
selected: Ref<string[]>;
node: (id: string) => Node | undefined;
parentId: (id: string) => string | null;
parentLabel: (id: string | null) => string;
selectedContainer: (id: string) => string | null;
};
export function usePhyCanvasView(options: Options) {
const keys = computed(() => flattenKeys(options.project().phy, options.variants.value));
const groupsAll = computed(() => structuralNodes(options.project().phy.root));
const outsideNodes = computed(() =>
options.focus.value ? removeFocusedSubtree(options.project().phy.root, options.focus.value) : [],
);
const gridLines = computed(() => {
const step = options.grid.value;
const xMin = Math.floor(-options.pan.value.x / options.zoom.value / step);
const xMax = Math.ceil((options.size.value.w - options.pan.value.x) / options.zoom.value / step);
const yMin = Math.floor(-options.pan.value.y / options.zoom.value / step);
const yMax = Math.ceil((options.size.value.h - options.pan.value.y) / options.zoom.value / step);
const lines: { d: string; major: boolean }[] = [];
for (let index = xMin; index <= xMax; index++) {
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
for (let index = yMin; index <= yMax; index++) {
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
return lines;
});
const variantGroups = computed(() =>
groupsAll.value.filter((node): node is VariantGroupNode => node.type === "variantGroup"),
);
const focusGridLines = computed(() => {
const focusId = options.focus.value;
if (!focusId || !options.node(focusId)) return [];
const screen = compose(
{
a: options.zoom.value,
b: 0,
c: 0,
d: options.zoom.value,
e: options.pan.value.x,
f: options.pan.value.y,
},
worldTransform(options.project().phy, focusId),
);
const toLocal = inverse(screen);
const corners = [
{ x: 0, y: 0 },
{ x: options.size.value.w, y: 0 },
{ x: options.size.value.w, y: options.size.value.h },
{ x: 0, y: options.size.value.h },
].map((point) => apply(toLocal, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
const step = options.grid.value;
const lines: { d: string; major: boolean }[] = [];
for (
let index = Math.floor(Math.min(...xs) / step);
index <= Math.ceil(Math.max(...xs) / step);
index++
) {
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
for (
let index = Math.floor(Math.min(...ys) / step);
index <= Math.ceil(Math.max(...ys) / step);
index++
) {
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
return lines;
});
const moveTargets = computed(() => {
const excluded = new Set<string>();
const add = (node: Node) => {
excluded.add(node.id);
if (node.type === "cluster") node.children.forEach(add);
if (node.type === "variantGroup") {
node.children.forEach((variant) => {
excluded.add(variant.id);
variant.children.forEach(add);
});
}
};
options.selected.value.forEach((id) => {
const node = options.node(id);
if (node) add(node);
});
return [
{ id: "__root__", label: "root" },
...groupsAll.value
.filter((node) => node.type === "cluster" && !excluded.has(node.id))
.map((node) => ({ id: node.id, label: node.name || "クラスター" })),
...variantGroups.value.flatMap((group) =>
group.children
.filter((variant) => !excluded.has(variant.id))
.map((variant) => ({ id: variant.id, label: `${group.name} / ${variant.name}` })),
),
];
});
function parentMatrix(id: string): Affine {
const node = options.node(id);
return node
? compose(worldTransform(options.project().phy, id), inverse(matrix(node.transform)))
: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
}
function focusAngle(id: string) {
const transform = parentMatrix(id);
return (Math.atan2(transform.b, transform.a) * 180) / Math.PI;
}
function affineText(transform: Affine) {
return `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`;
}
function focusCrumbs(id: string): { id: string | null; label: string }[] {
const ids = [id];
let parent = options.parentId(id);
while (parent) {
ids.unshift(parent);
parent = options.parentId(parent);
}
return [
{ id: null, label: "root" },
...ids.map((item) => ({
id:
groupsAll.value.find(
(node) => node.type === "variantGroup" && node.children.some((variant) => variant.id === item),
)?.id || item,
label: options.parentLabel(item),
})),
];
}
const propertyNode = computed(() =>
options.selected.value.length === 1 ? options.node(options.selected.value[0]!) : null,
);
return {
keys,
groupsAll,
outsideNodes,
gridLines,
variantGroups,
focusGridLines,
moveTargets,
parentMatrix,
focusAngle,
affineText,
focusCrumbs,
propertyNode,
};
}
+103
View File
@@ -0,0 +1,103 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { cloneNodesWithNewIds, insertNodes, reparent, reparentToVariant } from "../../domain/phyCommands";
import { worldTransform, type Affine } from "../../domain/geometry";
import { importKle } from "../../io/kleImport";
import { treeIndex, pasteLocalTransform } from "./logic";
type ClipboardNode = { node: Node; world: Affine };
type Options = {
project: () => ProjectFile;
mismatch: () => boolean;
selected: Ref<string[]>;
cut: Ref<string[]>;
grid: Ref<number>;
node: (id: string) => Node | undefined;
selectedContainer: (id: string) => string | null;
insertionParent: () => string | null;
parentMatrix: (project: ProjectFile, parent: string | null) => Affine;
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
setSelected: (ids: string[]) => void;
};
export function usePhyClipboard(options: Options) {
let clipboard: ClipboardNode[] = [];
let marker = "";
function copyEvent(event: ClipboardEvent) {
if (!options.selected.value.length || !event.clipboardData) return;
const project = options.project();
clipboard = options.selected.value.map((id) => ({
node: structuredClone(options.node(id)!),
world: worldTransform(project.phy, id),
}));
marker = `beyond-kle-clipboard:${crypto.randomUUID()}`;
event.clipboardData.setData("text/plain", marker);
if (event.type === "cut") options.cut.value = [...options.selected.value];
event.preventDefault();
}
function pasteEvent(event: ClipboardEvent) {
if (options.mismatch()) return;
const text = event.clipboardData?.getData("text/plain") || "";
if (text === marker && clipboard.length) {
event.preventDefault();
const parent = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
if (options.cut.value.length) {
const ids = [...options.cut.value];
options.dispatch("カット貼り付け", (project) => ({
...ids.reduce((next, id) => {
const isVariant = parent !== null && treeIndex(next.phy.root).groupForVariant(parent) !== null;
return {
...next,
phy: isVariant ? reparentToVariant(next.phy, id, parent!) : reparent(next.phy, id, parent),
};
}, project),
}));
options.setSelected(ids);
options.cut.value = [];
} else {
let ids: string[] = [];
options.dispatch("貼り付け", (project) => {
const copies = cloneNodesWithNewIds(clipboard.map((item) => item.node));
const parentWorld = options.parentMatrix(project, parent);
copies.forEach((node, index) => {
node.transform = pasteLocalTransform(clipboard[index]!.world, parentWorld, options.grid.value);
ids.push(node.id);
});
return { ...project, phy: insertNodes(project.phy, parent, copies) };
});
options.setSelected(ids);
}
return;
}
try {
const { phy, keyOrder } = importKle(JSON.parse(text));
if (!event.clipboardData) return;
event.preventDefault();
let ids: string[] = [];
options.dispatch("KLE貼り付け", (project) => {
const nodes = phy.root
.filter((node): node is KeyNode => node.type === "key")
.map((key) => ({ ...key, profile: project.phy.profiles[0]?.id || key.profile }));
ids = nodes.map((node) => node.id);
const parent = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
return {
...project,
phy: insertNodes(project.phy, parent, nodes),
idx: { ...project.idx, order: [...project.idx.order, ...keyOrder] },
};
});
options.setSelected(ids);
} catch {
// 外部クリップボードがKLE JSONでなければ無視する。
}
}
return { copyEvent, pasteEvent };
}
+373
View File
@@ -0,0 +1,373 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy";
import {
addComponentToParent,
addVariant as addVariantCommand,
addKeyToParent,
clusterNodes,
cloneNodesWithNewIds,
createVariantGroup,
insertNodes,
reparent,
reparentToVariant,
setDefaultVariant,
uncluster,
} from "../../domain/phyCommands";
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
type Options = {
project: () => ProjectFile;
selected: Ref<string[]>;
variants: Ref<Record<string, string>>;
focus: Ref<string | null>;
grid: Ref<number>;
zoom: Ref<number>;
pan: Ref<{ x: number; y: number }>;
size: Ref<{ w: number; h: number }>;
svg: Ref<SVGSVGElement | undefined>;
pane: Ref<HTMLElement | undefined>;
popover: Ref<string | null>;
confirm: Ref<string | null>;
confirmIds: Ref<string[]>;
confirmRefs: Ref<ReturnType<typeof listDeleteReferences>>;
confirmPos: Ref<{ left: number; top: number }>;
node: (id: string) => Node | undefined;
allNodes: (roots?: Node[]) => Node[];
parentId: (id: string) => string | null;
selectedContainer: (id: string) => string | null;
groupForVariant: (id: string) => string | null;
variantGroups: () => VariantGroupNode[];
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
emitError: (message: string) => void;
};
export function usePhyCommands(options: Options) {
function parentMatrix(project: ProjectFile, parent: string | null): Affine {
if (!parent) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
if (treeIndex(project.phy.root).nodes.get(parent)?.type === "cluster")
return worldTransform(project.phy, parent);
const groupId = treeIndex(project.phy.root).groupForVariant(parent);
if (groupId) return worldTransform(project.phy, groupId);
throw new Error(`Unknown parent: ${parent}`);
}
function nodesInContainer(container: string | null): Node[] {
if (container === null) return options.project().phy.root;
const node = options.node(container);
if (node?.type === "cluster") return node.children;
if (node?.type === "variantGroup") {
const variant = node.children.find(
(item) => item.id === (options.variants.value[node.id] || node.default),
);
return variant?.children ?? [];
}
const groupId = options.groupForVariant(container);
const group = groupId ? options.node(groupId) : null;
if (group?.type === "variantGroup")
return group.children.find((variant) => variant.id === container)?.children ?? [];
return [];
}
function directChildren(id: string): Node[] {
const node = options.node(id);
if (node?.type === "cluster") return node.children;
if (node?.type === "variantGroup") {
const variant = node.children.find(
(item) => item.id === (options.variants.value[node.id] || node.default),
);
return variant?.children ?? [];
}
return [];
}
function tabItem(node: Node) {
if (node.type === "key") {
const center = apply(matrix(node.transform), { x: node.size.w / 2, y: node.size.h / 2 });
return { id: node.id, x: center.x, y: center.y };
}
if (node.type === "component") return { id: node.id, x: node.transform.x, y: node.transform.y };
const bounds =
node.type === "cluster"
? localBounds(node.children, options.variants.value)
: localBounds(directChildren(node.id), options.variants.value);
const center = apply(matrix(node.transform), {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
});
return { id: node.id, x: center.x, y: center.y };
}
function centerInParent(parent: string | null) {
const world = {
x: (options.size.value.w / 2 - options.pan.value.x) / options.zoom.value,
y: (options.size.value.h / 2 - options.pan.value.y) / options.zoom.value,
};
return apply(inverse(parentMatrix(options.project(), parent)), world);
}
function chooseVariant(group: Node, id: string) {
if (group.type !== "variantGroup") return;
options.variants.value = { ...options.variants.value, [group.id]: id };
const valid = new Set(
group.children.find((variant) => variant.id === id)?.children.map((item) => item.id) || [],
);
if (options.selected.value.some((selectedId) => !valid.has(selectedId)))
options.selected.value = [group.id];
options.popover.value = null;
}
function keyAdd() {
const chosen = options.node(options.selected.value.at(-1) || "");
const parent = chosen ? options.selectedContainer(chosen.id) : insertionParent();
const center = centerInParent(parent);
const x =
Math.round(
(chosen ? chosen.transform.x + (chosen.type === "key" ? chosen.size.w : 1) : center.x) /
options.grid.value,
) * options.grid.value;
const y = Math.round((chosen ? chosen.transform.y : center.y) / options.grid.value) * options.grid.value;
let id = "";
options.dispatch("キー追加", (project) => {
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
id = options.allNodes(phy.root).at(-1)?.id || "";
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
});
if (id) options.selected.value = [id];
}
function insertionParent() {
const focusId = options.focus.value;
if (!focusId) return null;
const focused = options.node(focusId);
if (focused?.type === "cluster") return focused.id;
if (focused?.type === "variantGroup") return options.variants.value[focused.id] ?? focused.default;
return null;
}
function partAdd(group: string) {
const selected = options.node(options.selected.value.at(-1) || "");
const parent = selected ? options.selectedContainer(selected.id) : insertionParent();
const center = centerInParent(parent);
const x =
Math.round(
(selected ? selected.transform.x + (selected.type === "key" ? selected.size.w : 1) : center.x) /
options.grid.value,
) * options.grid.value;
const y =
Math.round((selected ? selected.transform.y : center.y) / options.grid.value) * options.grid.value;
let id = "";
options.dispatch("部品追加", (project) => {
const phy = addComponentToParent(
project.phy,
parent,
{ x, y, rotation: 0 },
group === "その他" ? undefined : group,
);
id = options.allNodes(phy.root).at(-1)?.id || "";
return { ...project, phy };
});
if (id) options.selected.value = [id];
}
function makeCluster() {
try {
let id = "";
options.dispatch("クラスター化", (project) => {
const phy = clusterNodes(project.phy, options.selected.value);
id =
options
.allNodes(phy.root)
.find(
(node) =>
node.type === "cluster" &&
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
)?.id || "";
return { ...project, phy };
});
if (id) options.selected.value = [id];
} catch (error) {
options.emitError(String(error));
}
}
function ungroup() {
const ids = options.selected.value.filter((id) => options.node(id)?.type === "cluster");
if (ids.length)
options.dispatch("クラスター解除", (project) => ({
...project,
phy: ids.reduce((phy, id) => uncluster(phy, id), project.phy),
}));
options.selected.value = [];
}
function variantGroup() {
if (!options.selected.value.length) return;
let id = "";
options.dispatch("バリアントグループ作成", (project) => {
let phy = createVariantGroup(project.phy, "Variant Group", { x: 0, y: 0, rotation: 0 }, [
{ name: "Option A" },
{ name: "Option B" },
]);
const group = options
.allNodes(phy.root)
.find(
(node) =>
node.type === "variantGroup" &&
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
);
if (group?.type !== "variantGroup") return { ...project, phy };
id = group.id;
for (const child of options.selected.value) phy = reparentToVariant(phy, child, group.children[0]!.id);
return {
...project,
phy,
output: {
...project.output,
variantSelection: { ...project.output.variantSelection, [group.id]: group.default },
},
};
});
if (id) options.selected.value = [id];
}
function duplicate() {
if (!options.selected.value.length) return;
const originals = options.selected.value
.map((id) => options.node(id))
.filter((node): node is Node => !!node);
const parent = options.selectedContainer(options.selected.value[0]!);
const worlds = originals.map((node) => worldTransform(options.project().phy, node.id));
let ids: string[] = [];
options.dispatch("複製", (project) => {
const copies = cloneNodesWithNewIds(originals);
const parentWorld = parentMatrix(project, parent);
copies.forEach((node, index) => {
node.transform = pasteLocalTransform(worlds[index]!, parentWorld, options.grid.value);
ids.push(node.id);
});
return { ...project, phy: insertNodes(project.phy, parent, copies) };
});
options.selected.value = ids;
}
function moveSelectionTo(target: string) {
const destination = target === "__root__" ? null : target;
try {
options.dispatch("reparent", (project) => ({
...project,
phy: options.selected.value.reduce(
(phy, id) =>
destination && options.groupForVariant(destination)
? reparentToVariant(phy, id, destination)
: reparent(phy, id, destination),
project.phy,
),
}));
} catch (error) {
options.emitError(String(error));
}
}
function askDelete(id = options.selected.value[0], event?: MouseEvent) {
const ids = id ? (options.selected.value.includes(id) ? options.selected.value : [id]) : [];
if (!ids.length) return;
options.confirmIds.value = ids;
options.confirm.value = id!;
options.confirmRefs.value = ids.flatMap((selectedId) =>
listDeleteReferences(options.project(), selectedId),
);
if (event) {
const rect = (event.currentTarget as Element).getBoundingClientRect();
const pane = options.pane.value?.getBoundingClientRect();
const left = rect.right + 380 < (pane?.right ?? Infinity) ? rect.left : rect.left - 380;
const top = rect.bottom + 240 < (pane?.bottom ?? Infinity) ? rect.bottom + 4 : rect.top - 240;
options.confirmPos.value = overlayPoint(left, top, 380, 240);
} else {
const rects = ids.flatMap((selectedId) =>
[...(options.svg.value?.querySelectorAll(`[data-node-id="${selectedId}"]`) ?? [])].map((element) =>
element.getBoundingClientRect(),
),
);
if (rects.length) {
const right = Math.max(...rects.map((rect) => rect.right));
const bottom = Math.max(...rects.map((rect) => rect.bottom));
const pane = options.pane.value?.getBoundingClientRect();
const left = right + 388 < (pane?.right ?? Infinity) ? right + 8 : right - 388;
const top = bottom + 248 < (pane?.bottom ?? Infinity) ? bottom + 8 : bottom - 248;
options.confirmPos.value = overlayPoint(left, top, 380, 240);
}
}
}
function overlayPoint(clientX: number, clientY: number, width: number, height: number) {
const rect = options.pane.value?.getBoundingClientRect();
return {
left: Math.max(4, Math.min(clientX - (rect?.left ?? 0), (rect?.width ?? width) - width - 4)),
top: Math.max(4, Math.min(clientY - (rect?.top ?? 0), (rect?.height ?? height) - height - 4)),
};
}
function deleteConfirmed() {
if (!options.confirm.value) return;
const ids = [...options.confirmIds.value];
const shownGroups = ids
.map(
(id) =>
options
.variantGroups()
.find(
(group) =>
group.children.some((variant) => variant.id === id) &&
options.variants.value[group.id] === id,
)?.id,
)
.filter((id): id is string => !!id);
options.dispatch("削除", (project) => ids.reduce((next, id) => deleteNodeCascade(next, id), project));
const next = { ...options.variants.value };
for (const id of shownGroups) {
const group = options.variantGroups().find((item) => item.id === id);
if (group) next[id] = group.default;
else delete next[id];
}
options.variants.value = next;
options.selected.value = [];
options.confirm.value = null;
options.confirmIds.value = [];
}
function setDefault(groupId: string, variantId: string) {
options.dispatch("既定変更", (project) => ({
...project,
phy: setDefaultVariant(project.phy, groupId, variantId),
}));
}
function addVariant(groupId: string, name: string) {
options.dispatch("バリアント追加", (project) => ({
...project,
phy: addVariantCommand(project.phy, groupId, name),
}));
}
return {
parentMatrix,
nodesInContainer,
directChildren,
tabItem,
chooseVariant,
keyAdd,
partAdd,
makeCluster,
ungroup,
variantGroup,
duplicate,
moveSelectionTo,
askDelete,
deleteConfirmed,
setDefault,
addVariant,
};
}
+253
View File
@@ -0,0 +1,253 @@
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();
}
};
}
+76
View File
@@ -0,0 +1,76 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import { longPressColumn, treeIndex, treeNodes } from "./logic";
export function usePhyTreeModel(project: () => ProjectFile, focus: Ref<string | null>) {
function allNodes(nodes = project().phy.root): Node[] {
return treeNodes(nodes);
}
function node(id: string): Node | undefined {
return treeIndex(project().phy.root).nodes.get(id);
}
function parentId(id: string): string | null {
return treeIndex(project().phy.root).selectableParent(id);
}
function isAncestor(candidate: string, id: string): boolean {
let parent = parentId(id);
while (parent) {
if (parent === candidate) return true;
parent = parentId(parent);
}
return false;
}
function selectedContainer(id: string): string | null {
return treeIndex(project().phy.root).insertionContainer(id);
}
function groupForVariant(id: string): string | null {
return treeIndex(project().phy.root).groupForVariant(id);
}
function parentLabel(id: string | null): string {
if (!id) return "root";
const item = node(id);
if (item?.type === "variantGroup") return item.name;
if (item?.type === "cluster") return item.name || "クラスター";
const variant = allNodes()
.flatMap((item) => (item.type === "variantGroup" ? item.children : []))
.find((item) => item.id === id);
return variant?.name || id;
}
function pressTargets(id: string): string[] {
const ancestors: string[] = [];
let parent = parentId(id);
while (parent) {
const group = allNodes().find(
(item) => item.type === "variantGroup" && item.children.some((variant) => variant.id === parent),
);
if (group) {
ancestors.push(group.id);
parent = parentId(group.id);
} else {
ancestors.push(parent);
parent = parentId(parent);
}
if (ancestors.at(-1) === focus.value) break;
}
return longPressColumn(id, ancestors, focus.value || undefined);
}
return {
allNodes,
node,
parentId,
isAncestor,
selectedContainer,
groupForVariant,
parentLabel,
pressTargets,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { onMounted, onUnmounted, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import { fitViewport, zoomAround } from "./viewport";
import { localBounds } from "./logic";
type Options = {
project: () => ProjectFile;
pane: Ref<HTMLElement | undefined>;
svg: Ref<SVGSVGElement | undefined>;
size: Ref<{ w: number; h: number }>;
zoom: Ref<number>;
pan: Ref<{ x: number; y: number }>;
selected: Ref<string[]>;
variants: Ref<Record<string, string>>;
node: (id: string) => Node | undefined;
};
export function usePhyViewport(options: Options) {
let observer: ResizeObserver | null = null;
function fit() {
const bounds = localBounds(options.project().phy.root, options.variants.value);
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
options.zoom.value = viewport.zoom;
options.pan.value = viewport.pan;
}
function fitSelected() {
const nodes = options.selected.value
.map((id) => options.node(id))
.filter((node): node is Node => node !== undefined);
if (!nodes.length) return;
const bounds = localBounds(nodes, options.variants.value);
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
options.zoom.value = viewport.zoom;
options.pan.value = viewport.pan;
}
function localPoint(element: SVGSVGElement | SVGGElement, x: number, y: number) {
const transform = element.getScreenCTM();
if (!transform) return { x: 0, y: 0 };
const point = new DOMPoint(x, y).matrixTransform(transform.inverse());
return { x: point.x, y: point.y };
}
function wheel(event: WheelEvent) {
const svg = options.svg.value;
if (!svg) return;
const point = localPoint(svg, event.clientX, event.clientY);
const viewport = zoomAround(
point,
{ zoom: options.zoom.value, pan: options.pan.value },
Math.exp(-event.deltaY * 0.001),
);
options.zoom.value = viewport.zoom;
options.pan.value = viewport.pan;
}
onMounted(() => {
if (!options.pane.value) return;
observer = new ResizeObserver((entries) => {
const rect = entries[0]!.contentRect;
options.size.value = { w: rect.width, h: rect.height };
fit();
});
observer.observe(options.pane.value);
fit();
});
onUnmounted(() => observer?.disconnect());
return { fit, fitSelected, localPoint, wheel };
}
+46
View File
@@ -0,0 +1,46 @@
export type Viewport = { zoom: number; pan: { x: number; y: number } };
export type ViewBounds = { x: number; y: number; width: number; height: number };
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
export function fitViewport(
bounds: ViewBounds,
width: number,
height: number,
padding = 40,
minZoom = 8,
maxZoom = 160,
): Viewport {
const zoom = Math.max(
minZoom,
Math.min(
maxZoom,
Math.min(
(width - 2 * padding) / Math.max(bounds.width, 1),
(height - 2 * padding) / Math.max(bounds.height, 1),
),
),
);
return {
zoom,
pan: {
x: (width - (2 * bounds.x + bounds.width) * zoom) / 2,
y: (height - (2 * bounds.y + bounds.height) * zoom) / 2,
},
};
}
/** 指定した画面座標の下にある点を保ったまま倍率を変える。 */
export function zoomAround(
point: { x: number; y: number },
viewport: Viewport,
factor: number,
minZoom = 5,
maxZoom = 200,
): Viewport {
const zoom = Math.max(minZoom, Math.min(maxZoom, viewport.zoom * factor));
const world = {
x: (point.x - viewport.pan.x) / viewport.zoom,
y: (point.y - viewport.pan.y) / viewport.zoom,
};
return { zoom, pan: { x: point.x - world.x * zoom, y: point.y - world.y * zoom } };
}
-12
View File
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { snapFromStart, snappedDelta } from "../src/ui/phy/dragMath";
describe("PHY drag math", () => {
it("snaps from the operation start in quarter-unit increments", () => {
expect(snapFromStart(0.1, 0.28)).toBeCloseTo(0.25);
expect(snapFromStart(0.1, 0.38)).toBeCloseTo(0.5);
});
it("returns snapped local deltas", () => {
expect(snappedDelta({ x: 1, y: 1 }, { x: 1.37, y: 0.61 })).toEqual({ x: 0.25, y: -0.5 });
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { PhyFile } from "../src/domain/phy";
import {
addComponentToParent,
addKeyToParent,
cloneNodesWithNewIds,
duplicateNode,
insertNodes,
} from "../src/domain/phyCommands";
const ids = (() => {
let n = 0;
return () => `0000000${++n}-0000-4000-8000-000000000000`;
})();
const file: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [{ id: "p", name: "P", unitSize: { x: 1, y: 1 } }],
root: [
{ type: "cluster", id: "cl-parent", name: "P", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
],
};
describe("PHY parent insertion and duplication", () => {
it("adds keys and components to root, clusters, or variants", () => {
let p = addKeyToParent(file, "cl-parent", { x: 2, y: 3, rotation: 0 }, { w: 1, h: 1 }, "p", ids);
expect(
p.root[0]!.type === "cluster" &&
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[0]?.type,
).toBe("key");
p = addComponentToParent(p, "cl-parent", { x: 0, y: 0, rotation: 0 }, "@led", ids);
expect(
p.root[0]!.type === "cluster" &&
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[1]?.type,
).toBe("component");
});
it("inserts a copied node batch into a selected container", () => {
const node = {
type: "key" as const,
id: "key-source",
profile: "p",
transform: { x: 1, y: 1, rotation: 0 },
size: { w: 1, h: 1 },
};
const p = insertNodes(file, "cl-parent", [node]);
expect(
p.root[0]!.type === "cluster" && (p.root[0]! as import("../src/domain/phy").ClusterNode).children,
).toHaveLength(1);
});
it("renews IDs and remaps a duplicated group's internal default", () => {
const base: PhyFile = {
...file,
root: [
{
type: "variantGroup",
id: "vg-source",
name: "VG",
transform: { x: 0, y: 0, rotation: 0 },
default: "v-one",
children: [
{ type: "variant", id: "v-one", name: "One", children: [] },
{ type: "variant", id: "v-two", name: "Two", children: [] },
],
},
],
};
const q = duplicateNode(base, "vg-source", ids);
const copy = q.root[1];
expect(copy?.type).toBe("variantGroup");
if (copy?.type === "variantGroup") {
expect(copy.id).not.toBe("vg-source");
expect(copy.default).not.toBe("v-one");
expect(copy.children.map((v) => v.id)).not.toContain("v-one");
expect(copy.default).toBe(copy.children[0]!.id);
}
});
it("remaps defaults for variant groups nested inside a copied cluster", () => {
const nested: PhyFile["root"][number] = {
type: "cluster",
id: "outer",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{
type: "variantGroup",
id: "vg-inner",
name: "Inner",
transform: { x: 1, y: 2, rotation: 0 },
default: "v-inner-a",
children: [
{ type: "variant", id: "v-inner-a", name: "A", children: [] },
{ type: "variant", id: "v-inner-b", name: "B", children: [] },
],
},
],
};
const copy = cloneNodesWithNewIds([nested], ids)[0];
expect(copy?.type).toBe("cluster");
if (copy?.type === "cluster") {
const group = copy.children[0];
expect(group?.type).toBe("variantGroup");
if (group?.type === "variantGroup") {
expect(group.default).toBe(group.children[0]!.id);
expect(group.default).not.toBe("v-inner-a");
}
}
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
GRID_STEPS,
localBounds,
insertionContainer,
longPressColumn,
nextGrid,
normalizeSelection,
obbAabb,
removeFocusedSubtree,
pasteLocalTransform,
pasteParent,
resolveKey,
snap,
tabOrder,
treeIndex,
visibleNodeIds,
} from "../src/ui/phy/logic";
import { fitViewport, zoomAround } from "../src/ui/phy/viewport";
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
const phy: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [],
root: [
{
type: "cluster",
id: "c",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{ type: "key", id: "k", profile: "p", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 } },
],
},
],
};
describe("PHY pure interaction rules", () => {
it("removes ancestors and descendants from mixed selections", () => {
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);
const outside = removeFocusedSubtree(phy.root, "k");
expect(treeIndex(outside).nodes.has("c")).toBe(true);
expect(treeIndex(outside).nodes.has("k")).toBe(false);
expect(outside[0]?.type === "cluster" ? outside[0].children : []).toEqual([]);
});
it("cycles long press candidates and stops before the focus parent", () => {
expect(longPressColumn("k", ["c", "root"], "c")).toEqual(["k"]);
expect(longPressColumn("k", ["c", "root"])).toEqual(["k", "c", "root"]);
});
it("orders rows with a half unit tolerance", () => {
expect(
tabOrder([
{ id: "b", x: 2, y: 0.4 },
{ id: "a", x: 1, y: 0 },
{ id: "c", x: 0, y: 1 },
]).map((x) => x.id),
).toEqual(["a", "b", "c"]);
});
it("computes bounds and paste target", () => {
expect(
obbAabb([
{ x: 2, y: 3 },
{ x: -1, y: 5 },
]),
).toEqual({ x: -1, y: 3, width: 3, height: 2 });
expect(pasteParent([], "c")).toBe("c");
expect(pasteParent(["p"], null)).toBe("p");
expect(pasteParent([null], "c")).toBeNull();
});
it("computes child bounds in parent axes and converts paste world position", () => {
const group: PhyNode = {
type: "cluster",
id: "g",
transform: { x: 10, y: 20, rotation: 90 },
children: [
{
type: "key",
id: "key2",
profile: "p",
transform: { x: 2, y: 3, rotation: 0 },
size: { w: 1, h: 2 },
},
],
};
expect(localBounds(group.type === "cluster" ? group.children : [])).toEqual({
x: 2,
y: 3,
width: 1,
height: 2,
});
expect(
pasteLocalTransform(
{ a: 0, b: 1, c: -1, d: 0, e: 10, f: 20 },
{ a: 0, b: 1, c: -1, d: 0, e: 8, f: 18 },
0.25,
),
).toEqual({ x: 2.25, y: -1.75, rotation: 0 });
});
it("distinguishes selectable parents from variant insertion containers without ID prefixes", () => {
const roots: PhyNode[] = [
{
type: "variantGroup",
id: "group-any-id",
name: "Layout",
transform: { x: 0, y: 0, rotation: 0 },
default: "option-any-id",
children: [
{
type: "variant",
id: "option-any-id",
name: "Main",
children: [
{
type: "key",
id: "key-any-id",
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
},
],
},
],
},
];
const index = treeIndex(roots);
expect(index.selectableParent("key-any-id")).toBe("group-any-id");
expect(index.insertionContainer("key-any-id")).toBe("option-any-id");
expect(index.groupForVariant("option-any-id")).toBe("group-any-id");
expect(insertionContainer(roots, "group-any-id", { "group-any-id": "option-any-id" })).toBe(
"option-any-id",
);
expect(visibleNodeIds(roots[0], {}).has("key-any-id")).toBe(true);
const outside = removeFocusedSubtree(roots, "key-any-id");
expect(treeIndex(outside).nodes.has("key-any-id")).toBe(false);
expect(treeIndex(outside).nodes.has("group-any-id")).toBe(true);
});
it("snaps without changing off-grid direct numeric values and cycles steps", () => {
expect(snap(0.37, 0.25)).toBe(0.25);
expect(nextGrid(0.25, 1)).toBe(0.125);
expect(GRID_STEPS).toHaveLength(4);
});
it("resolves keyboard layout independent digit shortcuts and modifiers", () => {
expect(
resolveKey({ key: "@", code: "Digit1", shiftKey: true, ctrlKey: false, metaKey: false, altKey: false }),
).toBe("fit");
expect(
resolveKey({ key: "z", code: "KeyZ", shiftKey: true, ctrlKey: true, metaKey: false, altKey: false }),
).toBe("redo");
});
it("fits bounds centrally with padding and zooms around the cursor", () => {
const fitted = fitViewport({ x: 10, y: 20, width: 4, height: 2 }, 400, 300, 20);
expect(fitted.zoom).toBe(90);
expect(fitted.pan.x + (10 + 2) * fitted.zoom).toBeCloseTo(200);
expect(fitted.pan.y + (20 + 1) * fitted.zoom).toBeCloseTo(150);
const cursor = { x: 140, y: 160 },
world = { x: (cursor.x - fitted.pan.x) / fitted.zoom, y: (cursor.y - fitted.pan.y) / fitted.zoom },
zoomed = zoomAround(cursor, fitted, 1.5);
expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
});
});