Compare commits
11
Commits
85f333cb24
...
50e672768b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e672768b | ||
|
|
e5678d79e6 | ||
|
|
f64d632136 | ||
|
|
9254d0649b | ||
|
|
0697e7a3b4 | ||
|
|
812e2edfc2 | ||
|
|
e50722948d | ||
|
|
38954e9b36 | ||
|
|
fe1b048ea4 | ||
|
|
e92708dc12 | ||
|
|
d110320cb8 |
+192
-7
@@ -15,6 +15,63 @@ 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 }) => {
|
||||||
|
const canvas = page.locator(".canvas-pane");
|
||||||
|
const properties = page.locator(".properties");
|
||||||
|
const before = (await canvas.boundingBox())!;
|
||||||
|
await page.getByRole("button", { name: "階層" }).click();
|
||||||
|
await expect(page.locator(".tree")).toHaveCount(0);
|
||||||
|
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeGreaterThan(before.width + 200);
|
||||||
|
const after = (await canvas.boundingBox())!;
|
||||||
|
const side = (await properties.boundingBox())!;
|
||||||
|
const workspace = (await page.locator(".workspace").boundingBox())!;
|
||||||
|
expect(Math.abs(after.x - workspace.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(side.x + side.width - (workspace.x + workspace.width))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
@@ -38,6 +95,43 @@ test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーが
|
|||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const before = await key.getAttribute("transform");
|
||||||
|
const keyBox = (await key.boundingBox())!;
|
||||||
|
const canvasBox = (await page.locator("svg.canvas").boundingBox())!;
|
||||||
|
const y = keyBox.y + keyBox.height / 2;
|
||||||
|
await page.mouse.move(keyBox.x + keyBox.width / 2, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(canvasBox.x + canvasBox.width - 8, y, { steps: 8 });
|
||||||
|
const atEdge = await key.getAttribute("transform");
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
expect(await key.getAttribute("transform")).not.toBe(atEdge);
|
||||||
|
await page.mouse.up();
|
||||||
|
const released = await key.getAttribute("transform");
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
expect(await key.getAttribute("transform")).toBe(released);
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
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 }) => {
|
test("クラスター化で画面位置を保つ", async ({ page }) => {
|
||||||
const keys = page.locator('[data-node-type="key"]');
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
const centers = async () =>
|
const centers = async () =>
|
||||||
@@ -151,7 +245,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,17 +258,18 @@ 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();
|
||||||
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
|
||||||
@@ -199,6 +294,37 @@ test("選択枠は画面上で見え、バリアントバッジで表示を切
|
|||||||
await expect(keys).toHaveCount(16);
|
await expect(keys).toHaveCount(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("クラスター内のキーから作ったバリアントグループはクラスターの中にでき、バッジは名前を書かない", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
await key.click();
|
||||||
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
|
const group = cluster.locator('[data-node-type="variantGroup"]');
|
||||||
|
await expect(group).toHaveCount(1);
|
||||||
|
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("既定");
|
||||||
|
await expect(popover.getByRole("button", { name: /Option B/ }).locator(".default-tag")).toHaveCount(0);
|
||||||
|
await popover.getByRole("button", { name: /Option B/ }).click();
|
||||||
|
await expect(badge.locator("title")).toHaveText("Variant Group: Option B");
|
||||||
|
});
|
||||||
|
|
||||||
test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => {
|
test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => {
|
||||||
const key = page.locator('[data-node-type="key"]').first();
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
const id = await key.getAttribute("data-node-id");
|
const id = await key.getAttribute("data-node-id");
|
||||||
@@ -412,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();
|
||||||
@@ -456,12 +582,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/);
|
||||||
@@ -473,3 +603,58 @@ test("「その他」の部品はpartGroupを設定せず、選択後は追加
|
|||||||
await expect(select).toHaveValue("");
|
await expect(select).toHaveValue("");
|
||||||
await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part");
|
await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("クラスター内でキーを足すと、足したキーを選ぶ", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterKeys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
await page.getByText("キー・部品 2件", { exact: true }).click();
|
||||||
|
await page.locator(`[data-tree-id="${before[0]}"]`).first().click();
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await expect(clusterKeys).toHaveCount(3);
|
||||||
|
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const added = after.find((id) => !before.includes(id));
|
||||||
|
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||||
|
await expect(page.locator("[data-sel-id]")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("クラスターを選んでキーを足すと、そのクラスターに新しい行を作る", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
|
const clusterKeys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await expect(clusterKeys).toHaveCount(3);
|
||||||
|
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const added = after.find((id) => !before.includes(id));
|
||||||
|
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||||
|
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
|
||||||
|
// The cluster holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
|
||||||
|
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
|
||||||
|
const unit = right!.x - left!.x;
|
||||||
|
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("バリアントグループを選んでキーを足すと、表示中のバリアントに新しい行を作る", async ({ page }) => {
|
||||||
|
await keysSelectVariantGroup(page);
|
||||||
|
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||||
|
const groupId = await group.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${groupId}"]`).first().click();
|
||||||
|
const groupKeys = group.locator('[data-node-type="key"]');
|
||||||
|
const before = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await expect(groupKeys).toHaveCount(before.length + 1);
|
||||||
|
const after = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const added = after.find((id) => !before.includes(id));
|
||||||
|
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||||
|
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
|
||||||
|
// The variant holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
|
||||||
|
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
|
||||||
|
const unit = right!.x - left!.x;
|
||||||
|
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[],
|
|||||||
|
|
||||||
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
|
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
|
||||||
if (!parentId) return f.root;
|
if (!parentId) return f.root;
|
||||||
const n = find(f, parentId);
|
// variantのIDは containers に子の入れ物として載るので、find ではノードとして引けない
|
||||||
if (n.type === "cluster") return n.children;
|
const n = containers(f)
|
||||||
if (n.type === "variantGroup") throw new Error("Choose a variant as the parent");
|
.get(parentId)
|
||||||
|
?.nodes.find((x) => x.id === parentId);
|
||||||
|
if (n?.type === "cluster") return n.children;
|
||||||
|
if (n?.type === "variantGroup") throw new Error("Choose a variant as the parent");
|
||||||
return findVariant(f, parentId).children;
|
return findVariant(f, parentId).children;
|
||||||
}
|
}
|
||||||
/** ローカル座標の差分でノードを移動する。 */
|
/** ローカル座標の差分でノードを移動する。 */
|
||||||
@@ -397,6 +400,17 @@ export function createVariantGroup(
|
|||||||
at: Transform,
|
at: Transform,
|
||||||
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
||||||
gen?: () => string,
|
gen?: () => string,
|
||||||
|
): PhyFile {
|
||||||
|
return createVariantGroupInParent(f, null, name, at, variants, gen);
|
||||||
|
}
|
||||||
|
/** 指定コンテナへVariantGroupを作成する。T-11。 */
|
||||||
|
export function createVariantGroupInParent(
|
||||||
|
f: PhyFile,
|
||||||
|
parentId: string | null,
|
||||||
|
name: string,
|
||||||
|
at: Transform,
|
||||||
|
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
||||||
|
gen?: () => string,
|
||||||
): PhyFile {
|
): PhyFile {
|
||||||
const o = mutable(f),
|
const o = mutable(f),
|
||||||
children = variants.map((x) => ({
|
children = variants.map((x) => ({
|
||||||
@@ -405,7 +419,7 @@ export function createVariantGroup(
|
|||||||
name: x.name,
|
name: x.name,
|
||||||
children: structuredClone(x.children ?? []),
|
children: structuredClone(x.children ?? []),
|
||||||
}));
|
}));
|
||||||
o.root.push({
|
parentNodes(o, parentId).push({
|
||||||
type: "variantGroup",
|
type: "variantGroup",
|
||||||
id: nodeId("variantGroup", gen),
|
id: nodeId("variantGroup", gen),
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/** Axis-aligned key rectangle in one coordinate frame, in U. */
|
||||||
|
export type KeyRect = { id: string; x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
const EPS = 1e-3;
|
||||||
|
/** Keys farther apart than this across adjacent lines are not linked. */
|
||||||
|
const MAX_LINK = 1;
|
||||||
|
|
||||||
|
type Point = { id: string; along: number; across: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects visual rows, top to bottom, each listing key ids left to right.
|
||||||
|
* The aligned axis is grouped by coordinate and the staggered axis is traced through
|
||||||
|
* adjacent lines (idx-editor-feedback.md). Keys are placed by their top-left corner,
|
||||||
|
* so tall keys belong to their top row and wide keys to their left column.
|
||||||
|
*/
|
||||||
|
export function detectRows(keys: KeyRect[]): string[][] {
|
||||||
|
return detectLines(keys, "row");
|
||||||
|
}
|
||||||
|
/** Detects visual columns, left to right, each listing key ids top to bottom. */
|
||||||
|
export function detectColumns(keys: KeyRect[]): string[][] {
|
||||||
|
return detectLines(keys, "column");
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLines(keys: KeyRect[], kind: "row" | "column"): string[][] {
|
||||||
|
const points: Point[] = keys.map((k) =>
|
||||||
|
kind === "row" ? { id: k.id, along: k.x, across: k.y } : { id: k.id, along: k.y, across: k.x },
|
||||||
|
);
|
||||||
|
const rowsAligned = groupBy(keys, (k) => k.y).length <= groupBy(keys, (k) => k.x).length;
|
||||||
|
return (kind === "row") === rowsAligned ? groupLines(points) : traceLines(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clusters values within EPS, ordered by value. */
|
||||||
|
function groupBy<T>(items: T[], value: (item: T) => number): T[][] {
|
||||||
|
const sorted = [...items].sort((a, b) => value(a) - value(b));
|
||||||
|
const groups: T[][] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last && value(item) - value(last.at(-1)!) <= EPS) last.push(item);
|
||||||
|
else groups.push([item]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLines(points: Point[]): string[][] {
|
||||||
|
return groupBy(points, (p) => p.across).map((line) => sortAlong(line).map((p) => p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Links each pair of adjacent cross lines one-to-one by nearest `across`, then joins the links. */
|
||||||
|
function traceLines(points: Point[]): string[][] {
|
||||||
|
const cross = groupBy(points, (p) => p.along);
|
||||||
|
const parent = new Map(points.map((p) => [p.id, p.id]));
|
||||||
|
const find = (id: string): string => {
|
||||||
|
const up = parent.get(id)!;
|
||||||
|
if (up === id) return id;
|
||||||
|
const root = find(up);
|
||||||
|
parent.set(id, root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
for (let i = 0; i + 1 < cross.length; i++) {
|
||||||
|
const pairs = cross[i]!.flatMap((a) =>
|
||||||
|
cross[i + 1]!.map((b) => ({ a, b, d: Math.abs(a.across - b.across) })),
|
||||||
|
)
|
||||||
|
.filter((pair) => pair.d < MAX_LINK)
|
||||||
|
.sort((p, q) => p.d - q.d);
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const { a, b } of pairs) {
|
||||||
|
if (used.has(a.id) || used.has(b.id)) continue;
|
||||||
|
used.add(a.id);
|
||||||
|
used.add(b.id);
|
||||||
|
parent.set(find(b.id), find(a.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lines = new Map<string, Point[]>();
|
||||||
|
for (const p of points) lines.set(find(p.id), [...(lines.get(find(p.id)) ?? []), p]);
|
||||||
|
return [...lines.values()]
|
||||||
|
.map(sortAlong)
|
||||||
|
.sort((a, b) => mean(a) - mean(b) || a[0]!.along - b[0]!.along)
|
||||||
|
.map((line) => line.map((p) => p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAlong(line: Point[]): Point[] {
|
||||||
|
return [...line].sort((a, b) => a.along - b.along);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mean(line: Point[]): number {
|
||||||
|
return line.reduce((sum, p) => sum + p.across, 0) / line.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position right after the last key of the row containing `keyId`. */
|
||||||
|
export function rowEndPosition(keys: KeyRect[], keyId: string): { x: number; y: number } | undefined {
|
||||||
|
const row = detectRows(keys).find((line) => line.includes(keyId));
|
||||||
|
if (!row) return undefined;
|
||||||
|
const byId = new Map(keys.map((k) => [k.id, k]));
|
||||||
|
const last = row.map((id) => byId.get(id)!).reduce((a, b) => (b.x + b.w > a.x + a.w ? b : a));
|
||||||
|
return { x: last.x + last.w, y: last.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leftmost position of a new row below every key. */
|
||||||
|
export function newRowPosition(keys: KeyRect[]): { x: number; y: number } | undefined {
|
||||||
|
if (!keys.length) return undefined;
|
||||||
|
return {
|
||||||
|
x: Math.min(...keys.map((k) => k.x)),
|
||||||
|
y: Math.max(...keys.map((k) => k.y + k.h)),
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-112
@@ -1,42 +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;
|
|
||||||
longCandidate?: string | null;
|
|
||||||
}>();
|
}>();
|
||||||
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 = 26 / 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<g
|
<g
|
||||||
@@ -57,18 +32,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"
|
||||||
@@ -115,20 +81,8 @@ function badgeBox(n: Node) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="dim"
|
:dim="dim"
|
||||||
:zoom="zoom"
|
|
||||||
: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)"
|
|
||||||
@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"
|
||||||
@@ -140,40 +94,9 @@ function badgeBox(n: Node) {
|
|||||||
:variants="variants"
|
:variants="variants"
|
||||||
:cut="cut"
|
:cut="cut"
|
||||||
:dim="dim"
|
:dim="dim"
|
||||||
:zoom="zoom"
|
|
||||||
: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)"
|
|
||||||
@focus="(id) => emit('focus', id)"
|
@focus="(id) => emit('focus', id)"
|
||||||
/><rect
|
/></g>
|
||||||
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
|
|
||||||
: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"
|
|
||||||
@click.stop="emit('badge', n, $event)"
|
|
||||||
/><text
|
|
||||||
:x="badgeBox(n).x + 3 / zoom"
|
|
||||||
:y="badgeBox(n).y + 16 / zoom"
|
|
||||||
:style="{ fontSize: `${12 / zoom}px` }"
|
|
||||||
>{{ n.children.find((v) => v.id === (variants[n.id] || n.default))?.name
|
|
||||||
}}{{ n.default === (variants[n.id] || n.default) ? " ✓" : "" }}</text
|
|
||||||
></g
|
|
||||||
></g
|
|
||||||
>
|
|
||||||
</g>
|
</g>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -216,21 +139,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;
|
||||||
}
|
}
|
||||||
@@ -243,19 +151,4 @@ function badgeBox(n: Node) {
|
|||||||
.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;
|
|
||||||
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>
|
||||||
@@ -7,6 +7,8 @@ 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 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";
|
||||||
@@ -117,7 +119,6 @@ const {
|
|||||||
focusGridLines,
|
focusGridLines,
|
||||||
moveTargets,
|
moveTargets,
|
||||||
parentMatrix: focusParentMatrix,
|
parentMatrix: focusParentMatrix,
|
||||||
focusAngle,
|
|
||||||
affineText,
|
affineText,
|
||||||
focusCrumbs,
|
focusCrumbs,
|
||||||
propertyNode: prop,
|
propertyNode: prop,
|
||||||
@@ -301,7 +302,7 @@ function onFocus(_id: string) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="workspace" :class="{ locked: mismatch }">
|
<section class="workspace" :class="{ locked: mismatch, 'tree-closed': !treeOpen }">
|
||||||
<aside v-if="treeOpen" class="tree pane">
|
<aside v-if="treeOpen" class="tree pane">
|
||||||
<PhyTree :nodes="project.phy.root" :selected="selected" @select="setSel" />
|
<PhyTree :nodes="project.phy.root" :selected="selected" @select="setSel" />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -359,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
|
||||||
@@ -374,11 +373,7 @@ 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"
|
|
||||||
:angle="focusAngle(focus)"
|
|
||||||
@down="down"
|
@down="down"
|
||||||
@badge="openBadge"
|
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
@@ -389,13 +384,31 @@ 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"
|
|
||||||
:angle="0"
|
|
||||||
@down="down"
|
@down="down"
|
||||||
@badge="openBadge"
|
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
/>
|
/>
|
||||||
|
<PhySelectionLayer
|
||||||
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:zoom="zoom"
|
||||||
|
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
||||||
|
: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"
|
||||||
@@ -425,7 +438,7 @@ function onFocus(_id: string) {
|
|||||||
>
|
>
|
||||||
<template v-for="g in variantGroups.filter((n) => n.id === popover)" :key="g.id"
|
<template v-for="g in variantGroups.filter((n) => n.id === popover)" :key="g.id"
|
||||||
><button v-for="v in g.children" :key="v.id" @click="chooseVariant(g, v.id)">
|
><button v-for="v in g.children" :key="v.id" @click="chooseVariant(g, v.id)">
|
||||||
{{ v.name }} {{ g.default === v.id ? "✓" : "" }}
|
{{ v.name }}<span v-if="g.default === v.id" class="default-tag">既定</span>
|
||||||
</button></template
|
</button></template
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,6 +482,9 @@ function onFocus(_id: string) {
|
|||||||
grid-template-columns: 260px minmax(300px, 1fr) 250px;
|
grid-template-columns: 260px minmax(300px, 1fr) 250px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.workspace.tree-closed {
|
||||||
|
grid-template-columns: minmax(300px, 1fr) 250px;
|
||||||
|
}
|
||||||
.pane {
|
.pane {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -572,6 +588,14 @@ function onFocus(_id: string) {
|
|||||||
.warning {
|
.warning {
|
||||||
color: #9a4c00;
|
color: #9a4c00;
|
||||||
}
|
}
|
||||||
|
.default-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid #8a691b;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b520f;
|
||||||
|
}
|
||||||
.long-label {
|
.long-label {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<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>;
|
||||||
|
zoom: number;
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
// 原点の丸は選択中のクラスター(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>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.origin {
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 2px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -229,6 +229,96 @@ 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;
|
||||||
|
selected: boolean;
|
||||||
|
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 },
|
||||||
|
selected: selected.includes(node.id),
|
||||||
|
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),
|
||||||
|
selected: selected.includes(node.id),
|
||||||
|
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),
|
||||||
|
selected: selected.includes(node.id),
|
||||||
|
candidate: candidate === node.id,
|
||||||
|
});
|
||||||
|
if (variant) visit(variant.children, transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(roots, parent);
|
||||||
|
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);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ref, type Ref } from "vue";
|
import { nextTick, onUnmounted, ref, type Ref } from "vue";
|
||||||
import type { ProjectFile } from "../../domain/project";
|
import type { ProjectFile } from "../../domain/project";
|
||||||
import type { KeyNode, Node } from "../../domain/phy";
|
import type { KeyNode, Node } from "../../domain/phy";
|
||||||
import { apply, worldTransform } from "../../domain/geometry";
|
import { apply, worldTransform } from "../../domain/geometry";
|
||||||
import { moveNode } from "../../domain/phyCommands";
|
import { moveNode } from "../../domain/phyCommands";
|
||||||
import { normalizeSelection, snap } from "./logic";
|
import { normalizeSelection, snap } from "./logic";
|
||||||
|
import { autoPanVelocity } from "./viewport";
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
type Point = { x: number; y: number };
|
||||||
type Box = Point & { width: number; height: number };
|
type Box = Point & { width: number; height: number };
|
||||||
@@ -44,6 +45,9 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
let holdCycle: number | undefined;
|
let holdCycle: number | undefined;
|
||||||
let holdShift = false;
|
let holdShift = false;
|
||||||
let holdActivated = false;
|
let holdActivated = false;
|
||||||
|
let lastPointer: Point | null = null;
|
||||||
|
let autoPanFrame: number | undefined;
|
||||||
|
let lastAutoPanAt = 0;
|
||||||
let panDrag: { start: Point; origin: Point } | null = null;
|
let panDrag: { start: Point; origin: Point } | null = null;
|
||||||
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
|
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
|
||||||
let dragState: {
|
let dragState: {
|
||||||
@@ -57,6 +61,78 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
moved: boolean;
|
moved: boolean;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
|
function stopAutoPan() {
|
||||||
|
if (autoPanFrame !== undefined) window.cancelAnimationFrame(autoPanFrame);
|
||||||
|
autoPanFrame = undefined;
|
||||||
|
lastAutoPanAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraggedPosition(clientX: number, clientY: number) {
|
||||||
|
const state = dragState;
|
||||||
|
if (!state?.moved) return;
|
||||||
|
let phy = state.base.phy;
|
||||||
|
for (const id of state.ids) {
|
||||||
|
const parent = state.parents.get(id);
|
||||||
|
const start = state.starts.get(id);
|
||||||
|
if (!parent || !start) continue;
|
||||||
|
const current = options.localPoint(parent, clientX, clientY);
|
||||||
|
phy = moveNode(
|
||||||
|
phy,
|
||||||
|
id,
|
||||||
|
snap(current.x - start.x, options.grid.value),
|
||||||
|
snap(current.y - start.y, options.grid.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoPanTick(time: number) {
|
||||||
|
autoPanFrame = undefined;
|
||||||
|
const svg = options.svg.value;
|
||||||
|
const pointer = lastPointer;
|
||||||
|
if (!svg || !pointer || !dragState?.moved || longLabel.value) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const velocity = autoPanVelocity(
|
||||||
|
{ x: pointer.x, y: pointer.y },
|
||||||
|
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
||||||
|
);
|
||||||
|
if (!velocity.x && !velocity.y) {
|
||||||
|
lastAutoPanAt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = lastAutoPanAt ? Math.min((time - lastAutoPanAt) / 1000, 0.05) : 0;
|
||||||
|
lastAutoPanAt = time;
|
||||||
|
const transform = svg.getScreenCTM();
|
||||||
|
if (elapsed && transform) {
|
||||||
|
options.pan.value = {
|
||||||
|
x: options.pan.value.x - (velocity.x * elapsed) / transform.a,
|
||||||
|
y: options.pan.value.y - (velocity.y * elapsed) / transform.d,
|
||||||
|
};
|
||||||
|
void nextTick(() => {
|
||||||
|
if (dragState?.moved && lastPointer) updateDraggedPosition(lastPointer.x, lastPointer.y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
autoPanFrame = window.requestAnimationFrame(autoPanTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutoPan() {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
if (!svg || !lastPointer || !dragState?.moved || longLabel.value) {
|
||||||
|
stopAutoPan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const velocity = autoPanVelocity(
|
||||||
|
{ x: lastPointer.x, y: lastPointer.y },
|
||||||
|
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
||||||
|
);
|
||||||
|
if (!velocity.x && !velocity.y) {
|
||||||
|
stopAutoPan();
|
||||||
|
} else if (autoPanFrame === undefined) {
|
||||||
|
autoPanFrame = window.requestAnimationFrame(autoPanTick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function priorSelection() {
|
function priorSelection() {
|
||||||
return preClickSelection;
|
return preClickSelection;
|
||||||
}
|
}
|
||||||
@@ -184,6 +260,7 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
|
lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
const state = dragState;
|
const state = dragState;
|
||||||
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
||||||
if (holdTimer && !longLabel.value) {
|
if (holdTimer && !longLabel.value) {
|
||||||
@@ -223,23 +300,12 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
options.beginHistory();
|
options.beginHistory();
|
||||||
}
|
}
|
||||||
state.moved = true;
|
state.moved = true;
|
||||||
let phy = state.base.phy;
|
updateDraggedPosition(event.clientX, event.clientY);
|
||||||
for (const id of state.ids) {
|
scheduleAutoPan();
|
||||||
const parent = state.parents.get(id);
|
|
||||||
const start = state.starts.get(id);
|
|
||||||
if (!parent || !start) continue;
|
|
||||||
const current = options.localPoint(parent, event.clientX, event.clientY);
|
|
||||||
phy = moveNode(
|
|
||||||
phy,
|
|
||||||
id,
|
|
||||||
snap(current.x - start.x, options.grid.value),
|
|
||||||
snap(current.y - start.y, options.grid.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function up() {
|
function up() {
|
||||||
|
stopAutoPan();
|
||||||
window.removeEventListener("pointermove", move, true);
|
window.removeEventListener("pointermove", move, true);
|
||||||
window.removeEventListener("pointerup", up, true);
|
window.removeEventListener("pointerup", up, true);
|
||||||
if (panDrag) {
|
if (panDrag) {
|
||||||
@@ -323,12 +389,15 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
}
|
}
|
||||||
if (holdActivated || moved) preClickTarget = null;
|
if (holdActivated || moved) preClickTarget = null;
|
||||||
dragState = null;
|
dragState = null;
|
||||||
|
lastPointer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function spaceUp(event: KeyboardEvent) {
|
function spaceUp(event: KeyboardEvent) {
|
||||||
if (event.code === "Space") spaceHeld.value = false;
|
if (event.code === "Space") spaceHeld.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopAutoPan);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvasDown,
|
canvasDown,
|
||||||
down,
|
down,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
addKeyToParent,
|
addKeyToParent,
|
||||||
clusterNodes,
|
clusterNodes,
|
||||||
cloneNodesWithNewIds,
|
cloneNodesWithNewIds,
|
||||||
createVariantGroup,
|
createVariantGroupInParent,
|
||||||
insertNodes,
|
insertNodes,
|
||||||
reparent,
|
reparent,
|
||||||
reparentToVariant,
|
reparentToVariant,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "../../domain/phyCommands";
|
} from "../../domain/phyCommands";
|
||||||
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
|
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
|
||||||
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
|
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
|
||||||
|
import { newRowPosition, rowEndPosition } from "../../domain/rows";
|
||||||
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
|
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
@@ -119,20 +120,39 @@ export function usePhyCommands(options: Options) {
|
|||||||
options.popover.value = null;
|
options.popover.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The node id present in `after` but not in `before`; the tree order does not tell where it went. */
|
||||||
|
function addedId(before: ProjectFile["phy"], after: ProjectFile["phy"]): string {
|
||||||
|
const existing = new Set(options.allNodes(before.root).map((node) => node.id));
|
||||||
|
return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || "";
|
||||||
|
}
|
||||||
|
|
||||||
function keyAdd() {
|
function keyAdd() {
|
||||||
const chosen = options.node(options.selected.value.at(-1) || "");
|
const chosen = options.node(options.selected.value.at(-1) || "");
|
||||||
const parent = chosen ? options.selectedContainer(chosen.id) : insertionParent();
|
// A selected cluster, or the shown variant of a selected group, receives a new row inside it,
|
||||||
|
// like an empty selection inside the focus.
|
||||||
|
const parent =
|
||||||
|
chosen?.type === "cluster"
|
||||||
|
? chosen.id
|
||||||
|
: chosen?.type === "variantGroup"
|
||||||
|
? options.variants.value[chosen.id] || chosen.default
|
||||||
|
: chosen
|
||||||
|
? options.selectedContainer(chosen.id)
|
||||||
|
: insertionParent();
|
||||||
|
const keys = nodesInContainer(parent).flatMap((node) =>
|
||||||
|
node.type === "key" ? [{ id: node.id, x: node.transform.x, y: node.transform.y, ...node.size }] : [],
|
||||||
|
);
|
||||||
|
const snap = (value: number) => Math.round(value / options.grid.value) * options.grid.value;
|
||||||
const center = centerInParent(parent);
|
const center = centerInParent(parent);
|
||||||
const x =
|
const { x, y } =
|
||||||
Math.round(
|
chosen?.type === "key"
|
||||||
(chosen ? chosen.transform.x + (chosen.type === "key" ? chosen.size.w : 1) : center.x) /
|
? rowEndPosition(keys, chosen.id)!
|
||||||
options.grid.value,
|
: chosen?.type === "component"
|
||||||
) * options.grid.value;
|
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) }
|
||||||
const y = Math.round((chosen ? chosen.transform.y : center.y) / options.grid.value) * options.grid.value;
|
: (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) });
|
||||||
let id = "";
|
let id = "";
|
||||||
options.dispatch("キー追加", (project) => {
|
options.dispatch("キー追加", (project) => {
|
||||||
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
|
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
|
||||||
id = options.allNodes(phy.root).at(-1)?.id || "";
|
id = addedId(project.phy, phy);
|
||||||
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
|
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
|
||||||
});
|
});
|
||||||
if (id) options.selected.value = [id];
|
if (id) options.selected.value = [id];
|
||||||
@@ -166,7 +186,7 @@ export function usePhyCommands(options: Options) {
|
|||||||
{ x, y, rotation: 0 },
|
{ x, y, rotation: 0 },
|
||||||
group === "その他" ? undefined : group,
|
group === "その他" ? undefined : group,
|
||||||
);
|
);
|
||||||
id = options.allNodes(phy.root).at(-1)?.id || "";
|
id = addedId(project.phy, phy);
|
||||||
return { ...project, phy };
|
return { ...project, phy };
|
||||||
});
|
});
|
||||||
if (id) options.selected.value = [id];
|
if (id) options.selected.value = [id];
|
||||||
@@ -205,12 +225,16 @@ export function usePhyCommands(options: Options) {
|
|||||||
|
|
||||||
function variantGroup() {
|
function variantGroup() {
|
||||||
if (!options.selected.value.length) return;
|
if (!options.selected.value.length) return;
|
||||||
|
const parent = options.selectedContainer(options.selected.value[0]!);
|
||||||
let id = "";
|
let id = "";
|
||||||
options.dispatch("バリアントグループ作成", (project) => {
|
options.dispatch("バリアントグループ作成", (project) => {
|
||||||
let phy = createVariantGroup(project.phy, "Variant Group", { x: 0, y: 0, rotation: 0 }, [
|
let phy = createVariantGroupInParent(
|
||||||
{ name: "Option A" },
|
project.phy,
|
||||||
{ name: "Option B" },
|
parent,
|
||||||
]);
|
"Variant Group",
|
||||||
|
{ x: 0, y: 0, rotation: 0 },
|
||||||
|
[{ name: "Option A" }, { name: "Option B" }],
|
||||||
|
);
|
||||||
const group = options
|
const group = options
|
||||||
.allNodes(phy.root)
|
.allNodes(phy.root)
|
||||||
.find(
|
.find(
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
export type Viewport = { zoom: number; pan: { x: number; y: number } };
|
export type Viewport = { zoom: number; pan: { x: number; y: number } };
|
||||||
export type ViewBounds = { x: number; y: number; width: number; height: number };
|
export type ViewBounds = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
/** SVG画面端に近いほど強く、端の外では上限に達する自動パン速度(px/秒)。 */
|
||||||
|
export function autoPanVelocity(
|
||||||
|
point: { x: number; y: number },
|
||||||
|
bounds: ViewBounds,
|
||||||
|
edge = 32,
|
||||||
|
maxSpeed = 480,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const axis = (position: number, start: number, length: number) => {
|
||||||
|
const fromStart = position - start;
|
||||||
|
const fromEnd = start + length - position;
|
||||||
|
if (fromStart < edge) return -maxSpeed * Math.min(1, (edge - fromStart) / edge);
|
||||||
|
if (fromEnd < edge) return maxSpeed * Math.min(1, (edge - fromEnd) / edge);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
x: axis(point.x, bounds.x, bounds.width),
|
||||||
|
y: axis(point.y, bounds.y, bounds.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
|
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
|
||||||
export function fitViewport(
|
export function fitViewport(
|
||||||
bounds: ViewBounds,
|
bounds: ViewBounds,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
addComponentToParent,
|
addComponentToParent,
|
||||||
addKeyToParent,
|
addKeyToParent,
|
||||||
cloneNodesWithNewIds,
|
cloneNodesWithNewIds,
|
||||||
|
createVariantGroupInParent,
|
||||||
duplicateNode,
|
duplicateNode,
|
||||||
insertNodes,
|
insertNodes,
|
||||||
} from "../src/domain/phyCommands";
|
} from "../src/domain/phyCommands";
|
||||||
@@ -102,4 +103,22 @@ describe("PHY parent insertion and duplication", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
it("creates a variant group inside a cluster or a variant", () => {
|
||||||
|
const variants: Parameters<typeof createVariantGroupInParent>[4] = [{ name: "A" }, { name: "B" }];
|
||||||
|
let p = createVariantGroupInParent(file, "cl-parent", "G", { x: 0, y: 0, rotation: 0 }, variants, ids);
|
||||||
|
const parent = p.root[0] as import("../src/domain/phy").ClusterNode;
|
||||||
|
expect(p.root).toHaveLength(1);
|
||||||
|
expect(parent.children.map((n) => n.type)).toEqual(["variantGroup"]);
|
||||||
|
const group = parent.children[0] as import("../src/domain/phy").VariantGroupNode;
|
||||||
|
p = createVariantGroupInParent(
|
||||||
|
p,
|
||||||
|
group.children[1]!.id,
|
||||||
|
"Inner",
|
||||||
|
{ x: 0, y: 0, rotation: 0 },
|
||||||
|
variants,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
const updated = (p.root[0] as import("../src/domain/phy").ClusterNode).children[0] as typeof group;
|
||||||
|
expect(updated.children[1]!.children.map((n) => n.type)).toEqual(["variantGroup"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
pasteParent,
|
pasteParent,
|
||||||
resolveKey,
|
resolveKey,
|
||||||
snap,
|
snap,
|
||||||
|
selectionFrames,
|
||||||
|
variantBadges,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
treeIndex,
|
treeIndex,
|
||||||
visibleNodeIds,
|
visibleNodeIds,
|
||||||
} from "../src/ui/phy/logic";
|
} from "../src/ui/phy/logic";
|
||||||
import { fitViewport, zoomAround } from "../src/ui/phy/viewport";
|
import { autoPanVelocity, fitViewport, zoomAround } from "../src/ui/phy/viewport";
|
||||||
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
|
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
|
||||||
const phy: PhyFile = {
|
const phy: PhyFile = {
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -34,6 +36,68 @@ 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));
|
||||||
|
expect(frame.selected).toBe(true);
|
||||||
|
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", () => {
|
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"]);
|
||||||
@@ -157,4 +221,12 @@ describe("PHY pure interaction rules", () => {
|
|||||||
expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
|
expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
|
||||||
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
|
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
|
||||||
});
|
});
|
||||||
|
it("scales auto-pan speed near edges, outside the canvas, and at corners", () => {
|
||||||
|
const bounds = { x: 10, y: 20, width: 200, height: 100 };
|
||||||
|
expect(autoPanVelocity({ x: 110, y: 70 }, bounds)).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 194, y: 70 }, bounds)).toEqual({ x: 240, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 210, y: 70 }, bounds)).toEqual({ x: 480, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 220, y: 70 }, bounds)).toEqual({ x: 480, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 210, y: 10 }, bounds)).toEqual({ x: 480, y: -480 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { detectColumns, detectRows, newRowPosition, rowEndPosition, type KeyRect } from "../src/domain/rows";
|
||||||
|
|
||||||
|
const key = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h });
|
||||||
|
|
||||||
|
describe("row detection", () => {
|
||||||
|
it("groups row-staggered keys by y and traces columns", () => {
|
||||||
|
const keys = [
|
||||||
|
key("tab", 0, 0, 1.5),
|
||||||
|
key("q", 1.5, 0),
|
||||||
|
key("w", 2.5, 0),
|
||||||
|
key("caps", 0, 1, 1.75),
|
||||||
|
key("a", 1.75, 1),
|
||||||
|
key("s", 2.75, 1),
|
||||||
|
key("space", 0.5, 2, 3),
|
||||||
|
];
|
||||||
|
expect(detectRows(keys)).toEqual([["tab", "q", "w"], ["caps", "a", "s"], ["space"]]);
|
||||||
|
expect(detectColumns(keys)[0]).toEqual(["tab", "caps", "space"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traces column-staggered rows through the nearest key of the next column", () => {
|
||||||
|
const offsets = [0.5, 0.25, 0, 0.25];
|
||||||
|
const keys = offsets.flatMap((dy, col) => [0, 1, 2].map((row) => key(`c${col}r${row}`, col, row + dy)));
|
||||||
|
expect(detectRows(keys)).toEqual([0, 1, 2].map((row) => offsets.map((_, col) => `c${col}r${row}`)));
|
||||||
|
expect(detectColumns(keys)[2]).toEqual(["c2r0", "c2r1", "c2r2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps rows apart when an outer column has fewer keys", () => {
|
||||||
|
const keys = [
|
||||||
|
key("p1", 0, 1.5),
|
||||||
|
key("p2", 0, 2.5),
|
||||||
|
...[0, 1, 2].map((row) => key(`r${row}`, 1, row + 1.25)),
|
||||||
|
...[0, 1, 2, 3].map((row) => key(`i${row}`, 2, row + 1)),
|
||||||
|
];
|
||||||
|
expect(detectRows(keys)).toEqual([["p1", "r0", "i0"], ["p2", "r1", "i1"], ["r2", "i2"], ["i3"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not link keys a full row apart", () => {
|
||||||
|
const keys = [key("a0", 0, 0), key("a1", 0, 1), key("b1", 1, 1.1), key("b2", 1, 2.1)];
|
||||||
|
expect(detectRows(keys)).toEqual([["a0"], ["a1", "b1"], ["b2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a tall key in its top row", () => {
|
||||||
|
const keys = [key("7", 0, 0), key("8", 1, 0), key("plus", 2, 0, 1, 2), key("4", 0, 1), key("5", 1, 1)];
|
||||||
|
expect(detectRows(keys)).toEqual([
|
||||||
|
["7", "8", "plus"],
|
||||||
|
["4", "5"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links the inner columns across the gap between halves", () => {
|
||||||
|
const columns: [string, number, number][] = [
|
||||||
|
["l", 0, 0.5],
|
||||||
|
["m", 1, 0.25],
|
||||||
|
["n", 4, 0.25],
|
||||||
|
["o", 5, 0.5],
|
||||||
|
];
|
||||||
|
const keys = columns.flatMap(([name, x, dy]) =>
|
||||||
|
[0, 1, 2].map((row) => key(`${name}${row}`, x, row + dy)),
|
||||||
|
);
|
||||||
|
expect(detectRows(keys)).toEqual([0, 1, 2].map((row) => columns.map(([name]) => `${name}${row}`)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("key insertion position", () => {
|
||||||
|
const keys = [key("a", 0, 0), key("b", 1, 0, 2), key("c", 0, 1)];
|
||||||
|
|
||||||
|
it("places a new key after the right end of the selected key's row", () => {
|
||||||
|
expect(rowEndPosition(keys, "a")).toEqual({ x: 3, y: 0 });
|
||||||
|
expect(rowEndPosition(keys, "c")).toEqual({ x: 1, y: 1 });
|
||||||
|
expect(rowEndPosition(keys, "missing")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new row at the left below every key", () => {
|
||||||
|
expect(newRowPosition([...keys, key("tall", 3, 0.5, 1, 2)])).toEqual({ x: 0, y: 2.5 });
|
||||||
|
expect(newRowPosition([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,6 +71,34 @@ KLE本家の挙動の確認は、既存の検証ハーネスで本家の `serial
|
|||||||
入力は [phy-editor-feedback.md](phy-editor-feedback.md) と [phy-editor-keybindings.md](phy-editor-keybindings.md)。
|
入力は [phy-editor-feedback.md](phy-editor-feedback.md) と [phy-editor-keybindings.md](phy-editor-keybindings.md)。
|
||||||
理由: 所感はすべて画面の操作と表示に関するもので、データモデル側に作り直す理由がない。
|
理由: 所感はすべて画面の操作と表示に関するもので、データモデル側に作り直す理由がない。
|
||||||
|
|
||||||
|
### T-30 作り直し1回目の所感への対応
|
||||||
|
|
||||||
|
T-29 のコードを作り直さず、[phy-editor-feedback.md](phy-editor-feedback.md)「作り直し1回目(T-29)を触った所感」を下の単位で順に直す。
|
||||||
|
1単位1コミットとし、各単位で既存の Vitest・Playwright を通してから次へ進む。
|
||||||
|
理由: 所感はどれも局所的な修正か機能の追加で、T-29 の構成(composable・純粋モジュールの分離)のまま入る。
|
||||||
|
|
||||||
|
| 順 | 単位 | 内容 | 主な箇所 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 選択の描画層 | 選択枠を太くし、`NodeSvg` の再帰から外して最前面の層に描く。以後のオーバーレイ(5・8)もこの層に載せる | `NodeSvg.vue`、`PhyEditor.vue` |
|
||||||
|
| 2 | パネルの幅 | 階層パネルを閉じたら、キャンバスが空いた幅を埋める | `PhyEditor.vue` の `grid-template-columns` |
|
||||||
|
| 3 | バリアント | ポップオーバーの印を「既定」と分かる見た目にする。バッジには名前を書かず、表示中のバリアント名はツールチップで示す。クラスター内のキーから作ったバリアントグループは、そのクラスターの中に作る | `NodeSvg.vue`、`usePhyCommands.ts` |
|
||||||
|
| 4 | ドラッグ中の自動スクロール | キーを画面の端へ寄せると、キャンバスがスクロールする | `usePhyCanvasPointer.ts`、`usePhyCanvasView.ts` |
|
||||||
|
| 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 |
|
||||||
|
| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。クラスターを選んでいればその中に、バリアントグループを選んでいれば表示中のバリアントの中に、同じように新しい行を作る。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` |
|
||||||
|
| 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` |
|
||||||
|
| 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、切り替えて比べる | 7aの composable |
|
||||||
|
| 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄を置く | 1の層、`PhyProperties.vue` |
|
||||||
|
|
||||||
|
順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。
|
||||||
|
7・8は、着手前に下の点を決める必要がある。7は最も大きく、試して比べる部分なので、挙動を変えない切り出しを先に入れる。
|
||||||
|
|
||||||
|
着手前に決めること:
|
||||||
|
|
||||||
|
- 7b: 長押しの間隔の値。リスト方式でどう選ぶか(離した位置で決めるのか、リストを出したあとにクリックするのか)。
|
||||||
|
- 8: スタビライザーを付けるキー幅と、幅ごとの位置。これは事実なので、出典で裏取りしてから決める。推測で実装しない。
|
||||||
|
|
||||||
|
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
||||||
|
|
||||||
## B. 明示的な未決事項
|
## B. 明示的な未決事項
|
||||||
|
|
||||||
### 未確認事項
|
### 未確認事項
|
||||||
|
|||||||
Reference in New Issue
Block a user