mock: 選択枠を太くし、最前面の描画層に移す(T-30 単位1)
選択枠と長押し候補の枠を NodeSvg の再帰から外し、ノードの描画より後に描く PhySelectionLayer に移した。枠はワールド変換とローカル矩形から純粋関数 selectionFrames で求め、画面上3.5pxで描く。部品の選択表示は形状上に残す。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TDkA8qMQ3Uf1eY3Ar19vjy
This commit is contained in:
+53
-5
@@ -15,6 +15,49 @@ async function makeCluster(page: import("@playwright/test").Page) {
|
|||||||
return page.locator('[data-node-type="cluster"]').first();
|
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 }) => {
|
test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
page.on("console", (message) => {
|
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.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.waitForTimeout(700);
|
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,
|
timeout: 1800,
|
||||||
});
|
});
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
@@ -164,11 +207,12 @@ test("入れ子の回転クラスターをフォーカスできる", async ({ pa
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
|
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
const keys = page.locator('[data-node-type="key"]');
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
await keys.first().click();
|
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();
|
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 keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
const group = page.locator('[data-node-type="variantGroup"]').first();
|
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||||
@@ -456,12 +500,16 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ
|
|||||||
const clusterId = await cluster.getAttribute("data-node-id");
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
const keys = page.locator('[data-node-type="key"]');
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
await keys.nth(4).click();
|
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();
|
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.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await expect(page.locator(".long-label")).toContainText("2/", { timeout: 1800 });
|
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.screenshot({ path: path.join(test.info().outputDir, "long-press-candidate.png") });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const props = defineProps<{
|
|||||||
dim: boolean;
|
dim: boolean;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
longCandidate?: string | null;
|
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
||||||
@@ -57,18 +56,9 @@ function badgeBox(n: Node) {
|
|||||||
:height="n.size.h"
|
:height="n.size.h"
|
||||||
rx=".06"
|
rx=".06"
|
||||||
class="key-face"
|
class="key-face"
|
||||||
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" /><text
|
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }"
|
||||||
class="key-label"
|
/><text class="key-label" x=".08" y=".24">{{ n.labels?.find(Boolean) }}</text></g
|
||||||
x=".08"
|
>
|
||||||
y=".24"
|
|
||||||
>{{ n.labels?.find(Boolean) }}</text
|
|
||||||
><rect
|
|
||||||
v-if="selected.includes(n.id)"
|
|
||||||
:width="n.size.w"
|
|
||||||
:height="n.size.h"
|
|
||||||
class="sel"
|
|
||||||
:class="{ candidate: longCandidate === n.id }"
|
|
||||||
/></g>
|
|
||||||
<g
|
<g
|
||||||
v-else-if="n.type === 'component'"
|
v-else-if="n.type === 'component'"
|
||||||
class="node"
|
class="node"
|
||||||
@@ -117,18 +107,9 @@ function badgeBox(n: Node) {
|
|||||||
:dim="dim"
|
:dim="dim"
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:angle="angle + n.transform.rotation"
|
:angle="angle + n.transform.rotation"
|
||||||
:long-candidate="longCandidate"
|
|
||||||
@down="(e, c, el) => emit('down', e, c, el)"
|
@down="(e, c, el) => emit('down', e, c, el)"
|
||||||
@badge="(g, e) => emit('badge', g, e)"
|
@badge="(g, e) => emit('badge', g, e)"
|
||||||
@focus="(id) => emit('focus', id)"
|
@focus="(id) => emit('focus', id)"
|
||||||
/><rect
|
|
||||||
v-if="selected.includes(n.id) || longCandidate === n.id"
|
|
||||||
:x="bounds(n).x"
|
|
||||||
:y="bounds(n).y"
|
|
||||||
:width="bounds(n).width"
|
|
||||||
:height="bounds(n).height"
|
|
||||||
class="group-sel"
|
|
||||||
:class="{ candidate: longCandidate === n.id }"
|
|
||||||
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
|
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
|
||||||
>
|
>
|
||||||
<g v-else class="group"
|
<g v-else class="group"
|
||||||
@@ -142,18 +123,9 @@ function badgeBox(n: Node) {
|
|||||||
:dim="dim"
|
:dim="dim"
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:angle="angle + n.transform.rotation"
|
:angle="angle + n.transform.rotation"
|
||||||
:long-candidate="longCandidate"
|
|
||||||
@down="(e, c, el) => emit('down', e, c, el)"
|
@down="(e, c, el) => emit('down', e, c, el)"
|
||||||
@badge="(g, e) => emit('badge', g, e)"
|
@badge="(g, e) => emit('badge', g, e)"
|
||||||
@focus="(id) => emit('focus', id)"
|
@focus="(id) => emit('focus', id)"
|
||||||
/><rect
|
|
||||||
v-if="selected.includes(n.id) || longCandidate === n.id"
|
|
||||||
:x="bounds(n).x"
|
|
||||||
:y="bounds(n).y"
|
|
||||||
:width="bounds(n).width"
|
|
||||||
:height="bounds(n).height"
|
|
||||||
class="group-sel"
|
|
||||||
:class="{ candidate: longCandidate === n.id }"
|
|
||||||
/><g
|
/><g
|
||||||
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
|
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
|
||||||
class="badge"
|
class="badge"
|
||||||
@@ -216,21 +188,6 @@ function badgeBox(n: Node) {
|
|||||||
stroke: #315d7a;
|
stroke: #315d7a;
|
||||||
stroke-width: 0.03;
|
stroke-width: 0.03;
|
||||||
}
|
}
|
||||||
.sel,
|
|
||||||
.group-sel {
|
|
||||||
fill: none;
|
|
||||||
stroke: #1769d2;
|
|
||||||
stroke-width: 2px;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.group-sel {
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
.candidate {
|
|
||||||
stroke: #e87912;
|
|
||||||
stroke-width: 2.5px;
|
|
||||||
}
|
|
||||||
.group {
|
.group {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { reframe } from "../../domain/phyCommands";
|
|||||||
import { listDeleteReferences } from "../../domain/deleteCascade";
|
import { listDeleteReferences } from "../../domain/deleteCascade";
|
||||||
import PhyTree from "./PhyTree.vue";
|
import PhyTree from "./PhyTree.vue";
|
||||||
import NodeSvg from "./NodeSvg.vue";
|
import NodeSvg from "./NodeSvg.vue";
|
||||||
|
import PhySelectionLayer from "./PhySelectionLayer.vue";
|
||||||
import PhyToolbar from "./PhyToolbar.vue";
|
import PhyToolbar from "./PhyToolbar.vue";
|
||||||
import PhyProperties from "./PhyProperties.vue";
|
import PhyProperties from "./PhyProperties.vue";
|
||||||
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
|
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
|
||||||
@@ -374,7 +375,6 @@ function onFocus(_id: string) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="false"
|
:dim="false"
|
||||||
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:angle="focusAngle(focus)"
|
:angle="focusAngle(focus)"
|
||||||
@down="down"
|
@down="down"
|
||||||
@@ -389,13 +389,19 @@ function onFocus(_id: string) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="false"
|
:dim="false"
|
||||||
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:angle="0"
|
:angle="0"
|
||||||
@down="down"
|
@down="down"
|
||||||
@badge="openBadge"
|
@badge="openBadge"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
/>
|
/>
|
||||||
|
<PhySelectionLayer
|
||||||
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
||||||
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
|
/>
|
||||||
</g>
|
</g>
|
||||||
<rect
|
<rect
|
||||||
v-if="rangeBox"
|
v-if="rangeBox"
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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<string, string>;
|
||||||
|
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})`;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<g class="selection-layer" pointer-events="none">
|
||||||
|
<rect
|
||||||
|
v-for="frame in frames"
|
||||||
|
:key="frame.id"
|
||||||
|
:data-sel-id="frame.id"
|
||||||
|
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]"
|
||||||
|
:transform="affineText(frame.transform)"
|
||||||
|
:x="frame.box.x"
|
||||||
|
:y="frame.box.y"
|
||||||
|
:width="frame.box.width"
|
||||||
|
:height="frame.box.height"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.sel,
|
||||||
|
.group-sel {
|
||||||
|
fill: none;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 3.5px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.group-sel {
|
||||||
|
stroke-dasharray: 5 3;
|
||||||
|
}
|
||||||
|
.candidate {
|
||||||
|
stroke: #e87912;
|
||||||
|
stroke-width: 3.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -229,6 +229,61 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
|
|||||||
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
||||||
return obbAabb(points);
|
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<string, string>,
|
||||||
|
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。 */
|
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
|
||||||
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
|
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
|
||||||
const local = compose(inverse(parentWorld), sourceWorld);
|
const local = compose(inverse(parentWorld), sourceWorld);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
pasteParent,
|
pasteParent,
|
||||||
resolveKey,
|
resolveKey,
|
||||||
snap,
|
snap,
|
||||||
|
selectionFrames,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
treeIndex,
|
treeIndex,
|
||||||
visibleNodeIds,
|
visibleNodeIds,
|
||||||
@@ -34,6 +35,37 @@ const phy: PhyFile = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
describe("PHY pure interaction rules", () => {
|
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", () => {
|
it("removes ancestors and descendants from mixed selections", () => {
|
||||||
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
|
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
|
||||||
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);
|
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user