diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts index 9c65a0b..40148d2 100644 --- a/mock/e2e/phy.spec.ts +++ b/mock/e2e/phy.spec.ts @@ -15,6 +15,49 @@ async function makeCluster(page: import("@playwright/test").Page) { return page.locator('[data-node-type="cluster"]').first(); } +test("選択レイヤーはキーより前面にあり、回転とフォーカスに追従する", async ({ page }) => { + test.setTimeout(60_000); + const keys = page.locator('[data-node-type="key"]'); + const leftId = await keys.first().getAttribute("data-node-id"); + await keys.first().click(); + const order = await page.locator("svg.canvas").evaluate((svg, id) => { + const face = svg.querySelector(`[data-node-id="${id}"]`)!; + const layer = svg.querySelector(`[data-sel-id="${id}"]`)!; + return [ + face.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING, + layer.parentElement?.classList.contains("selection-layer"), + ]; + }, leftId); + expect(order).toEqual([4, true]); + + const cluster = await makeCluster(page); + const clusterId = await cluster.getAttribute("data-node-id"); + await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); + await page.getByLabel("Rotation").fill("35"); + await page.getByLabel("Rotation").dispatchEvent("change"); + const key = cluster.locator('[data-node-type="key"]').first(); + const id = await key.getAttribute("data-node-id"); + await key.click(); + const aligned = async () => + page.evaluate((keyId) => { + const face = document.querySelector(`[data-node-id="${keyId}"] .key-face`)!.getBoundingClientRect(); + const outline = document.querySelector(`[data-sel-id="${keyId}"]`)!.getBoundingClientRect(); + return [ + Math.abs(face.x - outline.x), + Math.abs(face.y - outline.y), + Math.abs(face.width - outline.width), + Math.abs(face.height - outline.height), + ]; + }, id); + expect((await aligned()).every((delta) => delta < 1)).toBe(true); + await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); + await page.locator(".canvas-pane").focus(); + await page.keyboard.press("Enter"); + const focused = page.locator(`[data-node-id="${id}"]`).last(); + await focused.click(); + expect((await aligned()).every((delta) => delta < 1)).toBe(true); +}); + test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => { const errors: string[] = []; page.on("console", (message) => { @@ -151,7 +194,7 @@ test("入れ子の回転クラスターをフォーカスできる", async ({ pa 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({ + await expect(page.locator(`[data-sel-id="${innerId}"].candidate`)).toBeVisible({ timeout: 1800, }); await page.mouse.up(); @@ -164,11 +207,12 @@ test("入れ子の回転クラスターをフォーカスできる", async ({ pa }); test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => { + test.setTimeout(60_000); const keys = page.locator('[data-node-type="key"]'); await keys.first().click(); - const outline = page.locator('[data-node-type="key"] .sel').first(); + const outline = page.locator(".selection-layer .sel").first(); await expect(outline).toBeVisible(); - expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("2px"); + expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("3.5px"); await keys.nth(1).click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "選択からバリアントグループ" }).click(); const group = page.locator('[data-node-type="variantGroup"]').first(); @@ -456,12 +500,16 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ 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(); + await expect( + page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`), + ).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 expect( + page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`), + ).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/); diff --git a/mock/src/ui/phy/NodeSvg.vue b/mock/src/ui/phy/NodeSvg.vue index f639106..dbda78f 100644 --- a/mock/src/ui/phy/NodeSvg.vue +++ b/mock/src/ui/phy/NodeSvg.vue @@ -10,7 +10,6 @@ const props = defineProps<{ dim: boolean; zoom: number; angle: number; - longCandidate?: string | null; }>(); const emit = defineEmits<{ down: [e: PointerEvent, n: Node, el: SVGGElement]; @@ -57,18 +56,9 @@ function badgeBox(n: Node) { :height="n.size.h" rx=".06" class="key-face" - :class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" />{{ n.labels?.find(Boolean) }} + :class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" + />{{ n.labels?.find(Boolean) }} {{ n.name }} + +import { computed } from "vue"; +import type { Node } from "../../domain/phy"; +import type { Affine } from "../../domain/geometry"; +import { selectionFrames } from "./logic"; +const props = withDefaults( + defineProps<{ + nodes: Node[]; + selected: string[]; + variants: Record; + candidate?: string | null; + parent?: Affine; + }>(), + { candidate: null, parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) }, +); +const frames = computed(() => + selectionFrames(props.nodes, props.selected, props.variants, props.candidate, props.parent), +); +const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`; + + + diff --git a/mock/src/ui/phy/logic.ts b/mock/src/ui/phy/logic.ts index a273877..1872223 100644 --- a/mock/src/ui/phy/logic.ts +++ b/mock/src/ui/phy/logic.ts @@ -229,6 +229,61 @@ export function localBounds(nodes: Node[], variants: Record = {} visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); return obbAabb(points); } + +export type SelectionFrame = { + id: string; + type: Node["type"]; + transform: Affine; + box: Box; + candidate: boolean; +}; +/** 可視ノードの選択枠をワールド行列とローカル矩形で返す。 */ +export function selectionFrames( + roots: Node[], + selected: string[], + variants: Record, + candidate: string | null, + parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, +): SelectionFrame[] { + const frames: SelectionFrame[] = []; + const visit = (nodes: Node[], ancestor: Affine) => { + for (const node of nodes) { + const transform = compose(ancestor, matrix(node.transform)); + if (node.type === "key" && (selected.includes(node.id) || candidate === node.id)) { + frames.push({ + id: node.id, + type: node.type, + transform, + box: { x: 0, y: 0, width: node.size.w, height: node.size.h }, + candidate: candidate === node.id, + }); + } else if (node.type === "cluster") { + if (selected.includes(node.id) || candidate === node.id) + frames.push({ + id: node.id, + type: node.type, + transform, + box: localBounds(node.children, variants), + candidate: candidate === node.id, + }); + visit(node.children, transform); + } else if (node.type === "variantGroup") { + const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default)); + if (selected.includes(node.id) || candidate === node.id) + frames.push({ + id: node.id, + type: node.type, + transform, + box: localBounds(variant?.children ?? [], variants), + candidate: candidate === node.id, + }); + if (variant) visit(variant.children, transform); + } + } + }; + visit(roots, parent); + return frames; +} /** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */ export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform { const local = compose(inverse(parentWorld), sourceWorld); diff --git a/mock/tests/phyLogic.test.ts b/mock/tests/phyLogic.test.ts index b9797d3..7ea4c93 100644 --- a/mock/tests/phyLogic.test.ts +++ b/mock/tests/phyLogic.test.ts @@ -12,6 +12,7 @@ import { pasteParent, resolveKey, snap, + selectionFrames, tabOrder, treeIndex, visibleNodeIds, @@ -34,6 +35,37 @@ const phy: PhyFile = { ], }; describe("PHY pure interaction rules", () => { + it("computes selected frame world transforms through nested rotations", () => { + const roots: PhyNode[] = [ + { + type: "cluster", + id: "outer", + transform: { x: 10, y: 20, rotation: 90 }, + children: [ + { + type: "cluster", + id: "inner", + transform: { x: 2, y: 3, rotation: -90 }, + children: [ + { + type: "key", + id: "target", + profile: "p", + transform: { x: 4, y: 5, rotation: 30 }, + size: { w: 2, h: 1 }, + }, + ], + }, + ], + }, + ]; + const frame = selectionFrames(roots, ["target"], {}, null)[0]!; + expect(frame.id).toBe("target"); + expect(frame.transform.e).toBeCloseTo(11); + expect(frame.transform.f).toBeCloseTo(27); + expect(frame.transform.a).toBeCloseTo(Math.cos(Math.PI / 6)); + expect(frame.transform.b).toBeCloseTo(Math.sin(Math.PI / 6)); + }); it("removes ancestors and descendants from mixed selections", () => { expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]); expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);