mock: 選択中のクラスターにだけ原点の丸を出す(T-30 単位5)

単位1の選択の描画層に、選択中のクラスターとバリアントグループの原点の丸を描く。
長押しの候補になっているだけのクラスターには出さない。丸の大きさは画面上で固定する。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
This commit is contained in:
2026-10-22 09:42:32 +09:00
co-authored by Claude Opus 5.5
parent e50722948d
commit 812e2edfc2
5 changed files with 43 additions and 0 deletions
+17
View File
@@ -115,6 +115,23 @@ test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る",
await expect(key).toHaveAttribute("transform", before!);
});
test("選択中のクラスターにだけ原点の丸を出す", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const corner = (await keys.nth(0).locator(".key-face").boundingBox())!;
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await keys.nth(5).click();
await expect(page.locator(".origin")).toHaveCount(0);
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
const origin = page.locator(`[data-origin-id="${clusterId}"]`);
await expect(origin).toHaveCount(1);
const box = (await origin.boundingBox())!;
expect(Math.abs(box.x + box.width / 2 - corner.x)).toBeLessThan(2);
expect(Math.abs(box.y + box.height / 2 - corner.y)).toBeLessThan(2);
await keys.nth(5).click();
await expect(page.locator(".origin")).toHaveCount(0);
});
test("クラスター化で画面位置を保つ", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const centers = async () =>
+1
View File
@@ -399,6 +399,7 @@ function onFocus(_id: string) {
:nodes="focus ? [node(focus)!] : project.phy.root"
:selected="selected"
:variants="variants"
:zoom="zoom"
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
+18
View File
@@ -8,6 +8,7 @@ const props = withDefaults(
nodes: Node[];
selected: string[];
variants: Record<string, string>;
zoom: number;
candidate?: string | null;
parent?: Affine;
}>(),
@@ -16,6 +17,8 @@ const props = withDefaults(
const frames = computed(() =>
selectionFrames(props.nodes, props.selected, props.variants, props.candidate, props.parent),
);
// 原点の丸は選択中のクラスター(VariantGroupを含む)にだけ出す。T-30。
const origins = computed(() => frames.value.filter((frame) => frame.selected && frame.type !== "key"));
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
</script>
<template>
@@ -31,6 +34,15 @@ const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m
:width="frame.box.width"
:height="frame.box.height"
/>
<circle
v-for="frame in origins"
:key="`origin-${frame.id}`"
:data-origin-id="frame.id"
class="origin"
:cx="frame.transform.e"
:cy="frame.transform.f"
:r="5 / zoom"
/>
</g>
</template>
<style scoped>
@@ -48,4 +60,10 @@ const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m
stroke: #e87912;
stroke-width: 3.5px;
}
.origin {
fill: #fff;
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
</style>
+4
View File
@@ -235,6 +235,7 @@ export type SelectionFrame = {
type: Node["type"];
transform: Affine;
box: Box;
selected: boolean;
candidate: boolean;
};
/** 可視ノードの選択枠をワールド行列とローカル矩形で返す。 */
@@ -255,6 +256,7 @@ export function selectionFrames(
type: node.type,
transform,
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
} else if (node.type === "cluster") {
@@ -264,6 +266,7 @@ export function selectionFrames(
type: node.type,
transform,
box: localBounds(node.children, variants),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
visit(node.children, transform);
@@ -275,6 +278,7 @@ export function selectionFrames(
type: node.type,
transform,
box: localBounds(variant?.children ?? [], variants),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
if (variant) visit(variant.children, transform);
+3
View File
@@ -65,6 +65,9 @@ describe("PHY pure interaction rules", () => {
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));
expect(frame.selected).toBe(true);
const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!;
expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]);
});
it("removes ancestors and descendants from mixed selections", () => {
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);