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) }}
emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
- />{{ n.name }}
emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
- />
+
+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"]);