mock: バリアントのバッジをノードより後に描き、キーの下に隠れないようにする
バッジは NodeSvg の再帰の中で各バリアントグループの <g> に描いていたため、 後から描かれる兄弟のキーに隠れていた。選択の描画層の後に PhyBadgeLayer を置き、 位置は純粋関数 variantBadges でワールド座標に直して正立で描く。 フォーカス中、フォーカス外のバッジは薄く描き、クリックを受けない。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
This commit is contained in:
+14
-3
@@ -269,7 +269,7 @@ test("選択枠は画面上で見え、バリアントバッジで表示を切
|
|||||||
const group = page.locator('[data-node-type="variantGroup"]').first();
|
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||||
await page.getByLabel("Rotation").fill("35");
|
await page.getByLabel("Rotation").fill("35");
|
||||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
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 expect(badge).toBeVisible();
|
||||||
await badge.locator("rect").click();
|
await badge.locator("rect").click();
|
||||||
await page
|
await page
|
||||||
@@ -303,9 +303,20 @@ test("クラスター内のキーから作ったバリアントグループは
|
|||||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
const group = cluster.locator('[data-node-type="variantGroup"]');
|
const group = cluster.locator('[data-node-type="variantGroup"]');
|
||||||
await expect(group).toHaveCount(1);
|
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("text")).toHaveText("⇄");
|
||||||
await expect(badge.locator("title")).toHaveText("Variant Group: Option A(既定)");
|
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();
|
await badge.locator("rect").click();
|
||||||
const popover = page.locator(".popover");
|
const popover = page.locator(".popover");
|
||||||
await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定");
|
await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定");
|
||||||
@@ -527,7 +538,7 @@ test("削除のキーボード確認とバリアントポップオーバーを
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await keysSelectVariantGroup(page);
|
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();
|
await badge.locator("rect").click();
|
||||||
const badgeBox = await badge.boundingBox();
|
const badgeBox = await badge.boundingBox();
|
||||||
const popoverBox = await page.locator(".popover").boundingBox();
|
const popoverBox = await page.locator(".popover").boundingBox();
|
||||||
|
|||||||
@@ -1,47 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Node } from "../../domain/phy";
|
import type { Node } from "../../domain/phy";
|
||||||
import { localBounds } from "./logic";
|
|
||||||
defineOptions({ name: "NodeSvg" });
|
defineOptions({ name: "NodeSvg" });
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
selected: string[];
|
selected: string[];
|
||||||
variants: Record<string, string>;
|
variants: Record<string, string>;
|
||||||
cut: string[];
|
cut: string[];
|
||||||
dim: boolean;
|
dim: boolean;
|
||||||
zoom: number;
|
|
||||||
angle: number;
|
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
||||||
badge: [g: Node, e: PointerEvent];
|
|
||||||
focus: [id: string];
|
focus: [id: string];
|
||||||
}>();
|
}>();
|
||||||
function bounds(n: Node) {
|
|
||||||
return n.type === "cluster"
|
|
||||||
? localBounds(n.children, props.variants)
|
|
||||||
: n.type === "variantGroup"
|
|
||||||
? localBounds(
|
|
||||||
n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [],
|
|
||||||
props.variants,
|
|
||||||
)
|
|
||||||
: { x: 0, y: 0, width: 0, height: 0 };
|
|
||||||
}
|
|
||||||
function badgeBox(n: Node) {
|
|
||||||
const b = bounds(n);
|
|
||||||
const size = 20 / props.zoom;
|
|
||||||
return {
|
|
||||||
x: b.x + b.width + 4 / props.zoom,
|
|
||||||
y: b.y - size - 4 / props.zoom,
|
|
||||||
size,
|
|
||||||
angle: -(props.angle + n.transform.rotation),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/** バッジは名前を書かず、表示中のバリアント名はツールチップで示す。T-30。 */
|
|
||||||
function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
|
|
||||||
const shown = props.variants[n.id] || n.default;
|
|
||||||
const name = n.children.find((v) => v.id === shown)?.name ?? "";
|
|
||||||
return `${n.name}: ${name}${shown === n.default ? "(既定)" : ""}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<g
|
<g
|
||||||
@@ -111,10 +81,7 @@ function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="dim"
|
:dim="dim"
|
||||||
:zoom="zoom"
|
|
||||||
:angle="angle + n.transform.rotation"
|
|
||||||
@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)"
|
|
||||||
@focus="(id) => emit('focus', id)"
|
@focus="(id) => emit('focus', 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
|
||||||
>
|
>
|
||||||
@@ -127,30 +94,9 @@ function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="dim"
|
:dim="dim"
|
||||||
:zoom="zoom"
|
|
||||||
:angle="angle + n.transform.rotation"
|
|
||||||
@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)"
|
|
||||||
@focus="(id) => emit('focus', id)"
|
@focus="(id) => emit('focus', id)"
|
||||||
/><g
|
/></g>
|
||||||
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
|
|
||||||
class="badge"
|
|
||||||
@click.stop="emit('badge', n, $event)"
|
|
||||||
><rect
|
|
||||||
:x="badgeBox(n).x"
|
|
||||||
:y="badgeBox(n).y"
|
|
||||||
:width="badgeBox(n).size"
|
|
||||||
:height="badgeBox(n).size"
|
|
||||||
:rx="3 / zoom"
|
|
||||||
@click.stop="emit('badge', n, $event)"
|
|
||||||
/><text
|
|
||||||
:x="badgeBox(n).x + badgeBox(n).size / 2"
|
|
||||||
:y="badgeBox(n).y + badgeBox(n).size / 2"
|
|
||||||
:style="{ fontSize: `${12 / zoom}px` }"
|
|
||||||
>⇄</text
|
|
||||||
><title>{{ badgeTitle(n) }}</title></g
|
|
||||||
></g
|
|
||||||
>
|
|
||||||
</g>
|
</g>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -205,21 +151,4 @@ function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
|
|||||||
.group-label {
|
.group-label {
|
||||||
fill: #222;
|
fill: #222;
|
||||||
}
|
}
|
||||||
.badge {
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
.badge rect {
|
|
||||||
fill: #fff3c2;
|
|
||||||
stroke: #8a691b;
|
|
||||||
stroke-width: 1px;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
.badge text {
|
|
||||||
font-size: 12px;
|
|
||||||
fill: #302709;
|
|
||||||
text-anchor: middle;
|
|
||||||
dominant-baseline: central;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import type { Affine } from "../../domain/geometry";
|
||||||
|
import { variantBadges } from "./logic";
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
nodes: Node[];
|
||||||
|
variants: Record<string, string>;
|
||||||
|
zoom: number;
|
||||||
|
dim?: boolean;
|
||||||
|
parent?: Affine;
|
||||||
|
}>(),
|
||||||
|
{ dim: false, parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{ badge: [g: Node, e: MouseEvent] }>();
|
||||||
|
// ノードより後に描き、隣のキーの下に隠れないようにする
|
||||||
|
const badges = computed(() => variantBadges(props.nodes, props.variants, props.zoom, props.parent));
|
||||||
|
/** バッジは名前を書かず、表示中のバリアント名はツールチップで示す。T-30。 */
|
||||||
|
function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
|
||||||
|
const shown = props.variants[n.id] || n.default;
|
||||||
|
const name = n.children.find((v) => v.id === shown)?.name ?? "";
|
||||||
|
return `${n.name}: ${name}${shown === n.default ? "(既定)" : ""}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<g class="badge-layer" :class="{ dim }">
|
||||||
|
<g
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.group.id"
|
||||||
|
:data-badge-id="badge.group.id"
|
||||||
|
class="badge"
|
||||||
|
@click.stop="!dim && emit('badge', badge.group, $event)"
|
||||||
|
><rect :x="badge.x" :y="badge.y" :width="badge.size" :height="badge.size" :rx="3 / zoom" /><text
|
||||||
|
:x="badge.x + badge.size / 2"
|
||||||
|
:y="badge.y + badge.size / 2"
|
||||||
|
:style="{ fontSize: `${12 / zoom}px` }"
|
||||||
|
>⇄</text
|
||||||
|
><title>{{ badgeTitle(badge.group) }}</title></g
|
||||||
|
>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.dim .badge {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.badge rect {
|
||||||
|
fill: #fff3c2;
|
||||||
|
stroke: #8a691b;
|
||||||
|
stroke-width: 1px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.badge text {
|
||||||
|
fill: #302709;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: central;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ 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 PhySelectionLayer from "./PhySelectionLayer.vue";
|
||||||
|
import PhyBadgeLayer from "./PhyBadgeLayer.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";
|
||||||
@@ -118,7 +119,6 @@ const {
|
|||||||
focusGridLines,
|
focusGridLines,
|
||||||
moveTargets,
|
moveTargets,
|
||||||
parentMatrix: focusParentMatrix,
|
parentMatrix: focusParentMatrix,
|
||||||
focusAngle,
|
|
||||||
affineText,
|
affineText,
|
||||||
focusCrumbs,
|
focusCrumbs,
|
||||||
propertyNode: prop,
|
propertyNode: prop,
|
||||||
@@ -360,8 +360,6 @@ function onFocus(_id: string) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="true"
|
:dim="true"
|
||||||
:zoom="zoom"
|
|
||||||
:angle="0"
|
|
||||||
/>
|
/>
|
||||||
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
|
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
|
||||||
<g
|
<g
|
||||||
@@ -375,10 +373,7 @@ function onFocus(_id: string) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="false"
|
:dim="false"
|
||||||
:zoom="zoom"
|
|
||||||
:angle="focusAngle(focus)"
|
|
||||||
@down="down"
|
@down="down"
|
||||||
@badge="openBadge"
|
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
@@ -389,10 +384,7 @@ function onFocus(_id: string) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="false"
|
:dim="false"
|
||||||
:zoom="zoom"
|
|
||||||
:angle="0"
|
|
||||||
@down="down"
|
@down="down"
|
||||||
@badge="openBadge"
|
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
/>
|
/>
|
||||||
<PhySelectionLayer
|
<PhySelectionLayer
|
||||||
@@ -403,6 +395,20 @@ function onFocus(_id: string) {
|
|||||||
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
||||||
:parent="focus ? focusParentMatrix(focus) : undefined"
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
/>
|
/>
|
||||||
|
<PhyBadgeLayer
|
||||||
|
v-if="focus && outsideNodes.length"
|
||||||
|
:nodes="outsideNodes"
|
||||||
|
:variants="variants"
|
||||||
|
:zoom="zoom"
|
||||||
|
:dim="true"
|
||||||
|
/>
|
||||||
|
<PhyBadgeLayer
|
||||||
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
|
:variants="variants"
|
||||||
|
:zoom="zoom"
|
||||||
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
|
@badge="openBadge"
|
||||||
|
/>
|
||||||
</g>
|
</g>
|
||||||
<rect
|
<rect
|
||||||
v-if="rangeBox"
|
v-if="rangeBox"
|
||||||
|
|||||||
@@ -288,6 +288,37 @@ export function selectionFrames(
|
|||||||
visit(roots, parent);
|
visit(roots, parent);
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
export type VariantBadge = {
|
||||||
|
group: Extract<Node, { type: "variantGroup" }>;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
/** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */
|
||||||
|
export function variantBadges(
|
||||||
|
roots: Node[],
|
||||||
|
variants: Record<string, string>,
|
||||||
|
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。 */
|
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で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);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
resolveKey,
|
resolveKey,
|
||||||
snap,
|
snap,
|
||||||
selectionFrames,
|
selectionFrames,
|
||||||
|
variantBadges,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
treeIndex,
|
treeIndex,
|
||||||
visibleNodeIds,
|
visibleNodeIds,
|
||||||
@@ -69,6 +70,34 @@ describe("PHY pure interaction rules", () => {
|
|||||||
const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!;
|
const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!;
|
||||||
expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]);
|
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", () => {
|
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