diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts index e935bac..f151337 100644 --- a/mock/e2e/phy.spec.ts +++ b/mock/e2e/phy.spec.ts @@ -269,7 +269,7 @@ test("選択枠は画面上で見え、バリアントバッジで表示を切 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"); + const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`); await expect(badge).toBeVisible(); await badge.locator("rect").click(); await page @@ -303,9 +303,20 @@ test("クラスター内のキーから作ったバリアントグループは await page.getByRole("button", { name: "選択からバリアントグループ" }).click(); const group = cluster.locator('[data-node-type="variantGroup"]'); await expect(group).toHaveCount(1); - const badge = group.locator(".badge"); + const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`); await expect(badge.locator("text")).toHaveText("⇄"); await expect(badge.locator("title")).toHaveText("Variant Group: Option A(既定)"); + // バッジはキーより後に描き、隣のキーの下に隠れない + const onTop = await badge.evaluate((element) => { + const keys = [...document.querySelectorAll('[data-node-type="key"]')]; + const box = element.getBoundingClientRect(); + const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return [ + keys.every((key) => key.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING), + element.contains(hit), + ]; + }); + expect(onTop).toEqual([true, true]); await badge.locator("rect").click(); const popover = page.locator(".popover"); await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定"); @@ -527,7 +538,7 @@ test("削除のキーボード確認とバリアントポップオーバーを await page.keyboard.press("Escape"); await keysSelectVariantGroup(page); - const badge = page.locator('[data-node-type="variantGroup"] .badge').first(); + const badge = page.locator(".badge-layer:not(.dim) .badge").first(); await badge.locator("rect").click(); const badgeBox = await badge.boundingBox(); const popoverBox = await page.locator(".popover").boundingBox(); diff --git a/mock/src/ui/phy/NodeSvg.vue b/mock/src/ui/phy/NodeSvg.vue index 5be01e7..0b2fd75 100644 --- a/mock/src/ui/phy/NodeSvg.vue +++ b/mock/src/ui/phy/NodeSvg.vue @@ -1,47 +1,17 @@ diff --git a/mock/src/ui/phy/PhyBadgeLayer.vue b/mock/src/ui/phy/PhyBadgeLayer.vue new file mode 100644 index 0000000..f267ef8 --- /dev/null +++ b/mock/src/ui/phy/PhyBadgeLayer.vue @@ -0,0 +1,66 @@ + + + diff --git a/mock/src/ui/phy/PhyEditor.vue b/mock/src/ui/phy/PhyEditor.vue index 7d1e7de..65ef71a 100644 --- a/mock/src/ui/phy/PhyEditor.vue +++ b/mock/src/ui/phy/PhyEditor.vue @@ -8,6 +8,7 @@ import { listDeleteReferences } from "../../domain/deleteCascade"; import PhyTree from "./PhyTree.vue"; import NodeSvg from "./NodeSvg.vue"; import PhySelectionLayer from "./PhySelectionLayer.vue"; +import PhyBadgeLayer from "./PhyBadgeLayer.vue"; import PhyToolbar from "./PhyToolbar.vue"; import PhyProperties from "./PhyProperties.vue"; import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic"; @@ -118,7 +119,6 @@ const { focusGridLines, moveTargets, parentMatrix: focusParentMatrix, - focusAngle, affineText, focusCrumbs, propertyNode: prop, @@ -360,8 +360,6 @@ function onFocus(_id: string) { :variants="variants" :cut="cut" :dim="true" - :zoom="zoom" - :angle="0" /> @@ -389,10 +384,7 @@ function onFocus(_id: string) { :variants="variants" :cut="cut" :dim="false" - :zoom="zoom" - :angle="0" @down="down" - @badge="openBadge" @focus="onFocus" /> + + ; + x: number; + y: number; + size: number; +}; +/** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */ +export function variantBadges( + roots: Node[], + variants: Record, + zoom: number, + parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, +): VariantBadge[] { + const badges: VariantBadge[] = []; + const size = 20 / zoom; + const visit = (nodes: Node[], ancestor: Affine) => { + for (const node of nodes) { + const transform = compose(ancestor, matrix(node.transform)); + if (node.type === "cluster") visit(node.children, transform); + else if (node.type === "variantGroup") { + const variant = node.children.find((item) => item.id === (variants[node.id] || node.default)); + const box = localBounds(variant?.children ?? [], variants); + const corner = apply(transform, { x: box.x + box.width + 4 / zoom, y: box.y - size - 4 / zoom }); + badges.push({ group: node, x: corner.x, y: corner.y, size }); + if (variant) visit(variant.children, transform); + } + } + }; + visit(roots, parent); + return badges; +} /** ワールド位置を貼り先の親ローカルへ戻し、親軸で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 b4c24bb..ca4f140 100644 --- a/mock/tests/phyLogic.test.ts +++ b/mock/tests/phyLogic.test.ts @@ -13,6 +13,7 @@ import { resolveKey, snap, selectionFrames, + variantBadges, tabOrder, treeIndex, visibleNodeIds, @@ -69,6 +70,34 @@ describe("PHY pure interaction rules", () => { const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!; expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]); }); + it("places variant badges at the rotated group's top-right corner, upright", () => { + const key = (id: string): PhyNode => ({ + type: "key", + id, + profile: "p", + transform: { x: 0, y: 0, rotation: 0 }, + size: { w: 2, h: 1 }, + }); + const roots: PhyNode[] = [ + { + type: "variantGroup", + id: "g", + name: "G", + transform: { x: 1, y: 1, rotation: 90 }, + default: "a", + children: [ + { type: "variant", id: "a", name: "A", children: [key("ka")] }, + { type: "variant", id: "b", name: "B", children: [] }, + ], + }, + ]; + const [badge] = variantBadges(roots, {}, 10); + // ローカル (2 + 0.4, -2 - 0.4) を90度回して (1, 1) だけ動かす + expect(badge!.group.id).toBe("g"); + expect(badge!.x).toBeCloseTo(1 + 2.4); + expect(badge!.y).toBeCloseTo(1 + 2.4); + expect(badge!.size).toBeCloseTo(2); + }); it("removes ancestors and descendants from mixed selections", () => { expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]); expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);