From 4d1eb23cf8099d0b7527703e407f24c7445a7d56 Mon Sep 17 00:00:00 2001 From: EwdErna Date: Wed, 7 Oct 2026 20:54:37 +0900 Subject: [PATCH] =?UTF-8?q?mock:=20PHY=20Editor=E3=82=92=E4=BD=9C=E3=82=8A?= =?UTF-8?q?=E7=9B=B4=E3=81=99(T-29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8 --- mock/e2e/idx.spec.ts | 23 +- mock/e2e/phy.spec.ts | 628 ++++++++++++++------ mock/src/domain/phyCommands.ts | 90 ++- mock/src/ui/App.vue | 781 +++---------------------- mock/src/ui/phy/NodeSvg.vue | 261 +++++++++ mock/src/ui/phy/PhyCanvas.vue | 35 -- mock/src/ui/phy/PhyEditor.vue | 587 +++++++++++++++++++ mock/src/ui/phy/PhyNodeView.vue | 126 ---- mock/src/ui/phy/PhyProperties.vue | 177 ++++++ mock/src/ui/phy/PhyToolbar.vue | 116 ++++ mock/src/ui/phy/PhyTree.vue | 138 +++-- mock/src/ui/phy/dragMath.ts | 13 - mock/src/ui/phy/logic.ts | 236 ++++++++ mock/src/ui/phy/usePhyCanvasPointer.ts | 349 +++++++++++ mock/src/ui/phy/usePhyCanvasView.ts | 177 ++++++ mock/src/ui/phy/usePhyClipboard.ts | 103 ++++ mock/src/ui/phy/usePhyCommands.ts | 373 ++++++++++++ mock/src/ui/phy/usePhyKeyboard.ts | 253 ++++++++ mock/src/ui/phy/usePhyTreeModel.ts | 76 +++ mock/src/ui/phy/usePhyViewport.ts | 73 +++ mock/src/ui/phy/viewport.ts | 46 ++ mock/tests/dragMath.test.ts | 12 - mock/tests/phyDomain.test.ts | 105 ++++ mock/tests/phyLogic.test.ts | 160 +++++ 24 files changed, 3807 insertions(+), 1131 deletions(-) create mode 100644 mock/src/ui/phy/NodeSvg.vue delete mode 100644 mock/src/ui/phy/PhyCanvas.vue create mode 100644 mock/src/ui/phy/PhyEditor.vue delete mode 100644 mock/src/ui/phy/PhyNodeView.vue create mode 100644 mock/src/ui/phy/PhyProperties.vue create mode 100644 mock/src/ui/phy/PhyToolbar.vue delete mode 100644 mock/src/ui/phy/dragMath.ts create mode 100644 mock/src/ui/phy/logic.ts create mode 100644 mock/src/ui/phy/usePhyCanvasPointer.ts create mode 100644 mock/src/ui/phy/usePhyCanvasView.ts create mode 100644 mock/src/ui/phy/usePhyClipboard.ts create mode 100644 mock/src/ui/phy/usePhyCommands.ts create mode 100644 mock/src/ui/phy/usePhyKeyboard.ts create mode 100644 mock/src/ui/phy/usePhyTreeModel.ts create mode 100644 mock/src/ui/phy/usePhyViewport.ts create mode 100644 mock/src/ui/phy/viewport.ts delete mode 100644 mock/tests/dragMath.test.ts create mode 100644 mock/tests/phyDomain.test.ts create mode 100644 mock/tests/phyLogic.test.ts diff --git a/mock/e2e/idx.spec.ts b/mock/e2e/idx.spec.ts index 81488ee..23565f9 100644 --- a/mock/e2e/idx.spec.ts +++ b/mock/e2e/idx.spec.ts @@ -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" }); diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts index 0f46ef3..9c65a0b 100644 --- a/mock/e2e/phy.spec.ts +++ b/mock/e2e/phy.spec.ts @@ -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; + 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(dragBox!.x + dragBox!.width / 2 + 28, dragBox!.y + dragBox!.height / 2, { steps: 3 }); + await page.mouse.up(); + 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("長押しでクラスターを選び、ダブルクリックでフォーカスして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 expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 }); + await page.mouse.up(); + 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("入れ子の回転クラスターをフォーカスできる", 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.move(start.x, start.y); - await page.mouse.down(); - await page.mouse.move(start.x + 32, start.y, { steps: 4 }); 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); + 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("cluster and drag leave every other key at the same screen coordinates", async ({ page }) => { - const positions = async (): Promise> => - 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>; - 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); - await page.mouse.down(); - await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 }); - 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); - } -}); - -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("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(); - 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"); - 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 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); -}); - -test("project download can be loaded back", async ({ page }) => { +test("削除確認は参照を列挙し、実行後はIDXからも消える", 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"); + 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 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(); - const chooser = await chooserPromise; - await chooser.setFiles(filepath); + await ( + 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('[data-node-type="key"]').first()).toHaveAttribute("transform", savedTransform!); - await expect(page.locator('[data-testid="phy-canvas"]')).toBeVisible(); -}); - -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"); + 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(); - const chooser = await chooserPromise; - await chooser.setFiles({ - name: "cr0wn60.json", - mimeType: "application/json", - buffer: Buffer.from(JSON.stringify(kle)), - }); + await (await kleChooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle }); await expect(page.locator('[data-node-type="key"]')).toHaveCount(60); }); + +test("unitSize不一致では追加と数値編集が無効", async ({ page }) => { + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + 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(); + 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読込直後に全体表示し、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(); + 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"); +}); diff --git a/mock/src/domain/phyCommands.ts b/mock/src/domain/phyCommands.ts index 8d1354c..7e4aab3 100644 --- a/mock/src/domain/phyCommands.ts +++ b/mock/src/domain/phyCommands.ts @@ -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(); + 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; +} diff --git a/mock/src/ui/App.vue b/mock/src/ui/App.vue index fada593..c7460dd 100644 --- a/mock/src/ui/App.vue +++ b/mock/src/ui/App.vue @@ -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(null); -const activeTab = ref("PHY"), - selection = ref([]), - variants = ref>({}), - zoom = ref(1); -const errorText = ref(""), - pendingDelete = ref<{ - id: string; - refs: ReturnType; - } | null>(null); +const project = shallowRef(null), + activeTab = ref("PHY"), + errorText = ref(""); const importInput = ref(), openInput = ref(); 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; - } - } - 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 beginHistory() { + if (history) { + history.begin(); + epoch.value++; } } -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") { +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); + epoch.value++; +} +onMounted(() => { + void initialize(); + window.addEventListener("keydown", globalKey); +}); +onUnmounted(() => window.removeEventListener("keydown", globalKey)); +async function readFile(file: File, mode: "kle" | "project") { try { - const json = JSON.parse(await file.text()) as unknown; - if (mode === "kle") { - const p = await createProjectFromKle(json, { name: file.name }); - 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("; "); + 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("; "); } - historyEpoch.value++; + 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), + ); } - - diff --git a/mock/src/ui/phy/NodeSvg.vue b/mock/src/ui/phy/NodeSvg.vue new file mode 100644 index 0000000..f639106 --- /dev/null +++ b/mock/src/ui/phy/NodeSvg.vue @@ -0,0 +1,261 @@ + + + diff --git a/mock/src/ui/phy/PhyCanvas.vue b/mock/src/ui/phy/PhyCanvas.vue deleted file mode 100644 index 5be05c8..0000000 --- a/mock/src/ui/phy/PhyCanvas.vue +++ /dev/null @@ -1,35 +0,0 @@ - - diff --git a/mock/src/ui/phy/PhyEditor.vue b/mock/src/ui/phy/PhyEditor.vue new file mode 100644 index 0000000..b3e0c54 --- /dev/null +++ b/mock/src/ui/phy/PhyEditor.vue @@ -0,0 +1,587 @@ + + + diff --git a/mock/src/ui/phy/PhyNodeView.vue b/mock/src/ui/phy/PhyNodeView.vue deleted file mode 100644 index b4f4fec..0000000 --- a/mock/src/ui/phy/PhyNodeView.vue +++ /dev/null @@ -1,126 +0,0 @@ - - diff --git a/mock/src/ui/phy/PhyProperties.vue b/mock/src/ui/phy/PhyProperties.vue new file mode 100644 index 0000000..fadf553 --- /dev/null +++ b/mock/src/ui/phy/PhyProperties.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/mock/src/ui/phy/PhyToolbar.vue b/mock/src/ui/phy/PhyToolbar.vue new file mode 100644 index 0000000..f4eebbc --- /dev/null +++ b/mock/src/ui/phy/PhyToolbar.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/mock/src/ui/phy/PhyTree.vue b/mock/src/ui/phy/PhyTree.vue index 3d76fe6..26580e9 100644 --- a/mock/src/ui/phy/PhyTree.vue +++ b/mock/src/ui/phy/PhyTree.vue @@ -1,54 +1,114 @@ + diff --git a/mock/src/ui/phy/dragMath.ts b/mock/src/ui/phy/dragMath.ts deleted file mode 100644 index 23478ac..0000000 --- a/mock/src/ui/phy/dragMath.ts +++ /dev/null @@ -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) }; -} diff --git a/mock/src/ui/phy/logic.ts b/mock/src/ui/phy/logic.ts new file mode 100644 index 0000000..a273877 --- /dev/null +++ b/mock/src/ui/phy/logic.ts @@ -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(), + nodes = new Map(); + 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(); + 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, 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(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(); + const parents = new Map(); + const containers = new Map(); + const variants = new Map(); + 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[] { + return treeNodes(roots).filter( + (node): node is Extract => + 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): Set { + const result = new Set(); + 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 | 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, +): 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 = {}): 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 }; +} diff --git a/mock/src/ui/phy/usePhyCanvasPointer.ts b/mock/src/ui/phy/usePhyCanvasPointer.ts new file mode 100644 index 0000000..ea12163 --- /dev/null +++ b/mock/src/ui/phy/usePhyCanvasPointer.ts @@ -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; + pane: Ref; + pan: Ref; + zoom: Ref; + grid: Ref; + selected: Ref; + focus: Ref; + rangeBox: Ref; + allNodes: () => Node[]; + keys: () => { key: KeyNode }[]; + localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point; + visibleIds: () => Set; + 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([]); + 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; + starts: Map; + 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; +} diff --git a/mock/src/ui/phy/usePhyCanvasView.ts b/mock/src/ui/phy/usePhyCanvasView.ts new file mode 100644 index 0000000..f0b2cf0 --- /dev/null +++ b/mock/src/ui/phy/usePhyCanvasView.ts @@ -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; + focus: Ref; + variants: Ref>; + grid: Ref; + selected: Ref; + 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(); + 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, + }; +} diff --git a/mock/src/ui/phy/usePhyClipboard.ts b/mock/src/ui/phy/usePhyClipboard.ts new file mode 100644 index 0000000..e4d10ec --- /dev/null +++ b/mock/src/ui/phy/usePhyClipboard.ts @@ -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; + cut: Ref; + grid: Ref; + 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 }; +} diff --git a/mock/src/ui/phy/usePhyCommands.ts b/mock/src/ui/phy/usePhyCommands.ts new file mode 100644 index 0000000..aa7a7a0 --- /dev/null +++ b/mock/src/ui/phy/usePhyCommands.ts @@ -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; + variants: Ref>; + focus: Ref; + grid: Ref; + zoom: Ref; + pan: Ref<{ x: number; y: number }>; + size: Ref<{ w: number; h: number }>; + svg: Ref; + pane: Ref; + popover: Ref; + confirm: Ref; + confirmIds: Ref; + confirmRefs: Ref>; + 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, + }; +} diff --git a/mock/src/ui/phy/usePhyKeyboard.ts b/mock/src/ui/phy/usePhyKeyboard.ts new file mode 100644 index 0000000..d083a5d --- /dev/null +++ b/mock/src/ui/phy/usePhyKeyboard.ts @@ -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; + selected: Ref; + focus: Ref; + grid: Ref; + zoom: Ref; + showHelp: Ref; + popover: Ref; + confirm: Ref; + spaceHeld: Ref; + variants: Ref>; + priorChild: Map; + 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(); + } + }; +} diff --git a/mock/src/ui/phy/usePhyTreeModel.ts b/mock/src/ui/phy/usePhyTreeModel.ts new file mode 100644 index 0000000..6338d24 --- /dev/null +++ b/mock/src/ui/phy/usePhyTreeModel.ts @@ -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) { + 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, + }; +} diff --git a/mock/src/ui/phy/usePhyViewport.ts b/mock/src/ui/phy/usePhyViewport.ts new file mode 100644 index 0000000..1253e78 --- /dev/null +++ b/mock/src/ui/phy/usePhyViewport.ts @@ -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; + svg: Ref; + size: Ref<{ w: number; h: number }>; + zoom: Ref; + pan: Ref<{ x: number; y: number }>; + selected: Ref; + variants: Ref>; + 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 }; +} diff --git a/mock/src/ui/phy/viewport.ts b/mock/src/ui/phy/viewport.ts new file mode 100644 index 0000000..51bb6e3 --- /dev/null +++ b/mock/src/ui/phy/viewport.ts @@ -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 } }; +} diff --git a/mock/tests/dragMath.test.ts b/mock/tests/dragMath.test.ts deleted file mode 100644 index 23c16b7..0000000 --- a/mock/tests/dragMath.test.ts +++ /dev/null @@ -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 }); - }); -}); diff --git a/mock/tests/phyDomain.test.ts b/mock/tests/phyDomain.test.ts new file mode 100644 index 0000000..044a491 --- /dev/null +++ b/mock/tests/phyDomain.test.ts @@ -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"); + } + } + }); +}); diff --git a/mock/tests/phyLogic.test.ts b/mock/tests/phyLogic.test.ts new file mode 100644 index 0000000..b9797d3 --- /dev/null +++ b/mock/tests/phyLogic.test.ts @@ -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); + }); +});