mock: PHY Editorを作り直す(T-29)
provisional/phy-editor-feedback.md の合意事項と phy-editor-keybindings.md の割り当て案に沿って、PHY 画面を一から書き直した。 App.vue は共通のシェル(入出力・履歴・タブ)だけにし、PHY は src/ui/phy/ に、コンポーネント・composable・純粋モジュール(logic.ts, viewport.ts)を分けて置いた。 - 画面確認で見つかった不具合(読み込み時の全体表示、Shift+クリック、削除の確認の大きさ、Alt+↑、フォーカス中の二重描画、長押し中の選択など)を直し、キャンバス上の実際の操作で確かめる e2e を足した - 階層パネルでクラスターの子を余分な折りたたみで包まず、件数を配下のキー・部品の数にそろえた - 実装は Codex(Luna)。検証: vue-tsc 0件、Vitest 64件、Playwright 30件、prettier --check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
This commit is contained in:
@@ -69,6 +69,56 @@ export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
/** 指定コンテナへキーを追加する。 */
|
||||
export function addKeyToParent(
|
||||
f: PhyFile,
|
||||
parentId: string | null,
|
||||
at: Transform,
|
||||
size: { w: number; h: number },
|
||||
profile = f.profiles[0]!.id,
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f),
|
||||
target = parentNodes(o, parentId);
|
||||
target.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
||||
return o;
|
||||
}
|
||||
|
||||
/** 指定コンテナへ部品を追加する。 */
|
||||
export function addComponentToParent(
|
||||
f: PhyFile,
|
||||
parentId: string | null,
|
||||
at: Transform,
|
||||
partGroup?: string,
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f),
|
||||
target = parentNodes(o, parentId);
|
||||
target.push({
|
||||
type: "component",
|
||||
id: nodeId("component", gen),
|
||||
transform: { ...at },
|
||||
...(partGroup ? { partGroup } : {}),
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
/** root / cluster / variant の指定先にノード群を挿入する。 */
|
||||
export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[], index?: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
target = parentNodes(o, parentId);
|
||||
target.splice(index ?? target.length, 0, ...structuredClone(nodes));
|
||||
return o;
|
||||
}
|
||||
|
||||
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
|
||||
if (!parentId) return f.root;
|
||||
const n = find(f, 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;
|
||||
}
|
||||
/** ローカル座標の差分でノードを移動する。 */
|
||||
export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
@@ -407,20 +457,38 @@ export function deleteNode(f: PhyFile, id: string): PhyFile {
|
||||
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id),
|
||||
copy = structuredClone(n);
|
||||
const renew = (x: Node) => {
|
||||
x.id = nodeId(x.type, gen);
|
||||
if (x.type === "cluster") x.children.forEach(renew);
|
||||
if (x.type === "variantGroup")
|
||||
for (const v of x.children) {
|
||||
v.id = nodeId("variant", gen);
|
||||
v.children.forEach(renew);
|
||||
}
|
||||
};
|
||||
renew(copy);
|
||||
copy = cloneNodesWithNewIds([n], gen)[0]!;
|
||||
copy.transform.x += 0.25;
|
||||
copy.transform.y += 0.25;
|
||||
const c = containers(o).get(id)!;
|
||||
c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy);
|
||||
return o;
|
||||
}
|
||||
/** ノード群を複製し、配下のIDと閉じたdefault参照を振り直す。T-11。 */
|
||||
export function cloneNodesWithNewIds(nodes: Node[], gen?: () => string): Node[] {
|
||||
const copies = structuredClone(nodes);
|
||||
const remap = new Map<string, string>();
|
||||
const renew = (x: Node) => {
|
||||
const oldId = x.id;
|
||||
x.id = nodeId(x.type, gen);
|
||||
remap.set(oldId, x.id);
|
||||
if (x.type === "cluster") x.children.forEach(renew);
|
||||
if (x.type === "variantGroup")
|
||||
for (const v of x.children) {
|
||||
const oldVariantId = v.id;
|
||||
v.id = nodeId("variant", gen);
|
||||
remap.set(oldVariantId, v.id);
|
||||
v.children.forEach(renew);
|
||||
}
|
||||
};
|
||||
copies.forEach(renew);
|
||||
const remapDefaults = (x: Node) => {
|
||||
if (x.type === "cluster") x.children.forEach(remapDefaults);
|
||||
if (x.type === "variantGroup") {
|
||||
x.default = remap.get(x.default) ?? x.default;
|
||||
x.children.forEach((v) => v.children.forEach(remapDefaults));
|
||||
}
|
||||
};
|
||||
copies.forEach(remapDefaults);
|
||||
return copies;
|
||||
}
|
||||
|
||||
+91
-690
@@ -2,198 +2,111 @@
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
|
||||
import { createProjectFromKle } from "../domain/project";
|
||||
import type { ProjectFile } from "../domain/project";
|
||||
import type { Node, KeyNode, Transform, VariantGroupNode } from "../domain/phy";
|
||||
import { flattenKeys } from "../domain/geometry";
|
||||
import {
|
||||
addKey,
|
||||
addComponent,
|
||||
moveNode,
|
||||
resizeKey,
|
||||
rotateNode,
|
||||
clusterNodes,
|
||||
uncluster,
|
||||
reparent,
|
||||
reparentToVariant,
|
||||
duplicateNode,
|
||||
createVariantGroup,
|
||||
addVariant,
|
||||
setDefaultVariant,
|
||||
reframe,
|
||||
} from "../domain/phyCommands";
|
||||
import { ProjectHistory } from "../domain/history";
|
||||
import { validateProject } from "../domain/validate";
|
||||
import { listDeleteReferences, deleteNodeCascade } from "../domain/deleteCascade";
|
||||
import { saveProject, loadProject } from "../io/projectFile";
|
||||
import { exportKle } from "../io/kleExport";
|
||||
import { defaultLayout } from "./defaultLayout";
|
||||
import type { Dispatch } from "./types";
|
||||
import PhyTree from "./phy/PhyTree.vue";
|
||||
import PhyCanvas from "./phy/PhyCanvas.vue";
|
||||
import { parentPoint, snappedDelta } from "./phy/dragMath";
|
||||
import PhyEditor from "./phy/PhyEditor.vue";
|
||||
import IdxPanel from "./panels/IdxPanel.vue";
|
||||
import ElcPanel from "./panels/ElcPanel.vue";
|
||||
import MapPanel from "./panels/MapPanel.vue";
|
||||
import GeneratorPanel from "./panels/GeneratorPanel.vue";
|
||||
|
||||
const project = shallowRef<ProjectFile | null>(null);
|
||||
const activeTab = ref("PHY"),
|
||||
selection = ref<string[]>([]),
|
||||
variants = ref<Record<string, string>>({}),
|
||||
zoom = ref(1);
|
||||
const errorText = ref(""),
|
||||
pendingDelete = ref<{
|
||||
id: string;
|
||||
refs: ReturnType<typeof listDeleteReferences>;
|
||||
} | null>(null);
|
||||
const project = shallowRef<ProjectFile | null>(null),
|
||||
activeTab = ref("PHY"),
|
||||
errorText = ref("");
|
||||
const importInput = ref<HTMLInputElement>(),
|
||||
openInput = ref<HTMLInputElement>();
|
||||
let history: ProjectHistory | null = null;
|
||||
const historyEpoch = ref(0);
|
||||
let drag: {
|
||||
id: string;
|
||||
parent: SVGGElement;
|
||||
start: { x: number; y: number };
|
||||
origin: Transform;
|
||||
base: ProjectFile;
|
||||
} | null = null;
|
||||
const epoch = ref(0);
|
||||
const mismatch = computed(
|
||||
() =>
|
||||
!!project.value &&
|
||||
new Set(project.value.phy.profiles.map((p) => `${p.unitSize.x},${p.unitSize.y}`)).size > 1,
|
||||
);
|
||||
const issues = computed(() => (project.value ? validateProject(project.value) : []));
|
||||
const issues = computed(() => (project.value ? validateProject(project.value) : [])),
|
||||
severity = ["blocksSave", "blocksOutput", "warning"] as const;
|
||||
const canUndo = computed(() => {
|
||||
historyEpoch.value;
|
||||
epoch.value;
|
||||
return !!history?.canUndo;
|
||||
}),
|
||||
canRedo = computed(() => {
|
||||
historyEpoch.value;
|
||||
epoch.value;
|
||||
return !!history?.canRedo;
|
||||
});
|
||||
const severity = ["blocksSave", "blocksOutput", "warning"] as const;
|
||||
const selectedNode = computed(() =>
|
||||
project.value && selection.value.length === 1
|
||||
? findNode(project.value.phy.root, selection.value[0]!)
|
||||
: null,
|
||||
);
|
||||
const keyCount = computed(() => (project.value ? flattenKeys(project.value.phy, variants.value).length : 0));
|
||||
const variantGroups = computed(() => {
|
||||
const groups: VariantGroupNode[] = [];
|
||||
if (project.value)
|
||||
visit(project.value.phy.root, (n) => {
|
||||
if (n.type === "variantGroup") groups.push(n);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
const dispatch: Dispatch = (label, update) => {
|
||||
if (!project.value) return;
|
||||
if (mismatch.value) return;
|
||||
if (!project.value || mismatch.value) return;
|
||||
const next = update(project.value);
|
||||
project.value = next;
|
||||
if (history) history.push(next);
|
||||
historyEpoch.value++;
|
||||
history?.push(next);
|
||||
epoch.value++;
|
||||
errorText.value = "";
|
||||
};
|
||||
function findNode(nodes: Node[], id: string): Node | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (n.type === "cluster") {
|
||||
const x = findNode(n.children, id);
|
||||
if (x) return x;
|
||||
}
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return n;
|
||||
const x = findNode(v.children, id);
|
||||
if (x) return x;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function visit(nodes: Node[], fn: (n: Node) => void) {
|
||||
for (const n of nodes) {
|
||||
fn(n);
|
||||
if (n.type === "cluster") visit(n.children, fn);
|
||||
if (n.type === "variantGroup") for (const v of n.children) visit(v.children, fn);
|
||||
function beginHistory() {
|
||||
if (history) {
|
||||
history.begin();
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function parents() {
|
||||
const out: { id: string; label: string }[] = [{ id: "", label: "root" }];
|
||||
if (project.value)
|
||||
visit(project.value.phy.root, (n) => {
|
||||
if (n.type === "cluster") out.push({ id: n.id, label: `クラスター ${n.name || n.id}` });
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) out.push({ id: v.id, label: `${n.name} / ${v.name}` });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function setSelected(id: string, additive: boolean) {
|
||||
selection.value = additive
|
||||
? selection.value.includes(id)
|
||||
? selection.value.filter((x) => x !== id)
|
||||
: [...selection.value, id]
|
||||
: [id];
|
||||
const n = project.value ? findNode(project.value.phy.root, id) : null;
|
||||
if (n?.type === "cluster")
|
||||
promptValue.value = { x: n.transform.x, y: n.transform.y, r: n.transform.rotation };
|
||||
}
|
||||
async function initialize() {
|
||||
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
historyEpoch.value++;
|
||||
variants.value = { ...p.output.variantSelection };
|
||||
}
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
window.addEventListener("keydown", keyHandler);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", keyHandler));
|
||||
function keyHandler(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
e.shiftKey ? redo() : undo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
function commitHistory() {
|
||||
if (history && project.value) {
|
||||
history.commit(project.value);
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function undo() {
|
||||
if (history?.canUndo) {
|
||||
project.value = history.undo();
|
||||
historyEpoch.value++;
|
||||
variants.value = { ...project.value.output.variantSelection };
|
||||
selection.value = selection.value.filter(
|
||||
(id) => !!project.value && !!findNode(project.value.phy.root, id),
|
||||
);
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function redo() {
|
||||
if (history?.canRedo) {
|
||||
project.value = history.redo();
|
||||
historyEpoch.value++;
|
||||
variants.value = { ...project.value.output.variantSelection };
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
async function readFile(file: File, mode: "project" | "kle") {
|
||||
function globalKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
e.shiftKey ? redo() : undo();
|
||||
} else if (mod && e.key.toLowerCase() === "y") {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
} else if (mod && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
}
|
||||
async function initialize() {
|
||||
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
epoch.value++;
|
||||
}
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
window.addEventListener("keydown", globalKey);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", globalKey));
|
||||
async function readFile(file: File, mode: "kle" | "project") {
|
||||
try {
|
||||
const json = JSON.parse(await file.text()) as unknown;
|
||||
if (mode === "kle") {
|
||||
const p = await createProjectFromKle(json, { name: file.name });
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
variants.value = { ...p.output.variantSelection };
|
||||
selection.value = [];
|
||||
} else {
|
||||
const result = await loadProject(JSON.stringify(json));
|
||||
if (!result.project) throw Error(result.errors.map((x) => x.message).join("; "));
|
||||
project.value = result.project;
|
||||
history = new ProjectHistory(result.project);
|
||||
variants.value = { ...result.project.output.variantSelection };
|
||||
selection.value = [];
|
||||
errorText.value = result.errors.map((x) => x.message).join("; ");
|
||||
const raw = JSON.parse(await file.text()) as unknown;
|
||||
let p: ProjectFile;
|
||||
if (mode === "kle") p = await createProjectFromKle(raw, { name: file.name });
|
||||
else {
|
||||
const r = await loadProject(JSON.stringify(raw));
|
||||
if (!r.project) throw Error(r.errors.map((x) => x.message).join("; "));
|
||||
p = r.project;
|
||||
errorText.value = r.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
historyEpoch.value++;
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
epoch.value++;
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
@@ -206,215 +119,21 @@ function download(name: string, text: string) {
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
async function save() {
|
||||
if (project.value) {
|
||||
const result = await saveProject(project.value, true);
|
||||
if (result.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, result.json);
|
||||
else errorText.value = result.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
if (!project.value) return;
|
||||
const r = await saveProject(project.value, true);
|
||||
if (r.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, r.json);
|
||||
else errorText.value = r.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
function exportSelected() {
|
||||
if (project.value)
|
||||
download("layout.kle.json", JSON.stringify(exportKle(project.value.phy, variants.value), null, 2));
|
||||
}
|
||||
function nodeDown(event: PointerEvent, id: string, parent: SVGGElement) {
|
||||
if (mismatch.value || !project.value) return;
|
||||
const node = findNode(project.value.phy.root, id);
|
||||
if (!node) return;
|
||||
event.preventDefault();
|
||||
const p = parentPoint(parent, event.clientX, event.clientY);
|
||||
history?.begin();
|
||||
historyEpoch.value++;
|
||||
drag = { id, parent, start: p, origin: { ...node.transform }, base: project.value };
|
||||
window.addEventListener("pointermove", movePointer);
|
||||
window.addEventListener("pointerup", endPointer, { once: true });
|
||||
}
|
||||
function movePointer(e: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const p = parentPoint(drag.parent, e.clientX, e.clientY),
|
||||
delta = snappedDelta(drag.start, p);
|
||||
const next = moveNode(drag.base.phy, drag.id, delta.x, delta.y);
|
||||
project.value = { ...drag.base, phy: next };
|
||||
history?.replaceTop(project.value);
|
||||
}
|
||||
function endPointer() {
|
||||
window.removeEventListener("pointermove", movePointer);
|
||||
if (project.value) history?.commit(project.value);
|
||||
historyEpoch.value++;
|
||||
drag = null;
|
||||
}
|
||||
function keyAdd() {
|
||||
if (!project.value) return;
|
||||
dispatch("キー追加", (p) => ({ ...p, phy: addKey(p.phy, { x: 0, y: 0, rotation: 0 }, { w: 1, h: 1 }) }));
|
||||
const n = project.value.phy.root.at(-1);
|
||||
if (n) selection.value = [n.id];
|
||||
}
|
||||
function componentAdd(group: string) {
|
||||
if (!project.value) return;
|
||||
dispatch("部品追加", (p) => ({
|
||||
...p,
|
||||
phy: addComponent(p.phy, { x: 0, y: 0, rotation: 0 }, group || undefined),
|
||||
}));
|
||||
const n = project.value.phy.root.at(-1);
|
||||
if (n) selection.value = [n.id];
|
||||
}
|
||||
function updateTransform(field: keyof Transform, value: number) {
|
||||
if (mismatch.value || !project.value || selection.value.length !== 1 || !selectedNode.value) return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("変換変更", (p) => {
|
||||
const phy = structuredClone(p.phy),
|
||||
n = findNode(phy.root, id);
|
||||
if (n) n.transform[field] = value;
|
||||
return { ...p, phy };
|
||||
});
|
||||
}
|
||||
function increment(field: keyof Transform, step: number) {
|
||||
if (!selectedNode.value) return;
|
||||
const n = selectedNode.value;
|
||||
updateTransform(field, n.transform[field] + step);
|
||||
}
|
||||
function updateSize(field: "w" | "h", value: number) {
|
||||
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("キーサイズ変更", (p) => ({
|
||||
...p,
|
||||
phy: resizeKey(p.phy, id, { ...(findNode(p.phy.root, id) as KeyNode).size, [field]: value }),
|
||||
}));
|
||||
}
|
||||
function updateLabel(value: string) {
|
||||
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("ラベル変更", (p) => {
|
||||
const phy = structuredClone(p.phy),
|
||||
n = findNode(phy.root, id);
|
||||
if (n && n.type === "key") n.labels = [value];
|
||||
return { ...p, phy };
|
||||
});
|
||||
}
|
||||
function rotateSelected() {
|
||||
if (mismatch.value || !project.value) return;
|
||||
const ids = [...selection.value].filter((id) => findNode(project.value!.phy.root, id));
|
||||
if (!ids.length) return;
|
||||
dispatch("回転", (p) => ({ ...p, phy: ids.reduce((phy, id) => rotateNode(phy, id, 15), p.phy) }));
|
||||
}
|
||||
function makeCluster() {
|
||||
if (!project.value || selection.value.length < 1) return;
|
||||
try {
|
||||
dispatch("クラスター化", (p) => ({ ...p, phy: clusterNodes(p.phy, selection.value) }));
|
||||
const n = project.value.phy.root.find((n) => n.type === "cluster");
|
||||
if (n) selection.value = [n.id];
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function ungroup() {
|
||||
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("クラスター解除", (p) => ({ ...p, phy: uncluster(p.phy, id) }));
|
||||
selection.value = [];
|
||||
}
|
||||
function duplicate() {
|
||||
if (!project.value || selection.value.length !== 1) return;
|
||||
const id = selection.value[0]!;
|
||||
dispatch("複製", (p) => ({ ...p, phy: duplicateNode(p.phy, id) }));
|
||||
}
|
||||
function reparentTo(target: string) {
|
||||
if (mismatch.value || !project.value || selection.value.length !== 1) return;
|
||||
const id = selection.value[0]!;
|
||||
try {
|
||||
dispatch("reparent", (p) => ({
|
||||
...p,
|
||||
phy: target.startsWith("v-")
|
||||
? reparentToVariant(p.phy, id, target)
|
||||
: reparent(p.phy, id, target || null),
|
||||
}));
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function createVariants() {
|
||||
if (!project.value || selection.value.length === 0) return;
|
||||
const first = findNode(project.value.phy.root, selection.value[0]!);
|
||||
if (!first) return;
|
||||
try {
|
||||
dispatch("VariantGroup作成", (p) => {
|
||||
let phy = createVariantGroup(p.phy, "Layout", { ...first.transform }, [
|
||||
{ name: "Option A" },
|
||||
{ name: "Option B" },
|
||||
]);
|
||||
const vg = phy.root.at(-1);
|
||||
if (vg?.type === "variantGroup") {
|
||||
for (const id of selection.value) phy = reparentToVariant(phy, id, vg.children[0]!.id);
|
||||
return {
|
||||
...p,
|
||||
phy,
|
||||
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [vg.id]: vg.default } },
|
||||
};
|
||||
}
|
||||
return { ...p, phy };
|
||||
});
|
||||
selection.value = [];
|
||||
if (project.value) variants.value = { ...project.value.output.variantSelection };
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function addVariantUi(groupId: string) {
|
||||
if (!project.value) return;
|
||||
dispatch("Variant追加", (p) => ({
|
||||
...p,
|
||||
phy: addVariant(
|
||||
p.phy,
|
||||
groupId,
|
||||
`Option ${String.fromCharCode(65 + (findNode(p.phy.root, groupId) as Node & { children: Node[] }).children.length)}`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
function removeVariantUi(variantId: string) {
|
||||
if (!project.value) return;
|
||||
pendingDelete.value = { id: variantId, refs: listDeleteReferences(project.value, variantId) };
|
||||
}
|
||||
function changeDefault(groupId: string, id: string) {
|
||||
if (!project.value) return;
|
||||
dispatch("既定Variant変更", (p) => ({ ...p, phy: setDefaultVariant(p.phy, groupId, id) }));
|
||||
}
|
||||
function confirmDelete() {
|
||||
if (!pendingDelete.value || !project.value) return;
|
||||
const { id } = pendingDelete.value;
|
||||
try {
|
||||
dispatch("ノード削除", (p) => deleteNodeCascade(p, id));
|
||||
if (project.value) variants.value = { ...project.value.output.variantSelection };
|
||||
selection.value = [];
|
||||
pendingDelete.value = null;
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function requestDelete() {
|
||||
if (mismatch.value || !project.value || selection.value.length !== 1) return;
|
||||
const id = selection.value[0]!;
|
||||
pendingDelete.value = { id, refs: listDeleteReferences(project.value, id) };
|
||||
}
|
||||
function changeFrame() {
|
||||
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
|
||||
const id = selectedNode.value.id;
|
||||
const x = Number(promptValue.value.x),
|
||||
y = Number(promptValue.value.y),
|
||||
rotation = Number(promptValue.value.r);
|
||||
dispatch("座標枠変更", (p) => ({ ...p, phy: reframe(p.phy, id, { x, y, rotation }) }));
|
||||
}
|
||||
const promptValue = ref({ x: 0, y: 0, r: 0 });
|
||||
function changeVariant(groupId: string, id: string) {
|
||||
variants.value = { ...variants.value, [groupId]: id };
|
||||
if (!mismatch.value)
|
||||
dispatch("表示構成変更", (p) => ({
|
||||
...p,
|
||||
output: { ...p.output, variantSelection: { ...variants.value } },
|
||||
}));
|
||||
download(
|
||||
"layout.kle.json",
|
||||
JSON.stringify(exportKle(project.value.phy, project.value.output.variantSelection), null, 2),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell" :class="{ locked: mismatch }" v-if="project">
|
||||
<main v-if="project" class="app-shell" :class="{ locked: mismatch }">
|
||||
<header class="toolbar">
|
||||
<strong>Beyond KLE</strong><button @click="importInput?.click()">KLE JSONから新規</button
|
||||
><input
|
||||
@@ -434,7 +153,7 @@ function changeVariant(groupId: string, id: string) {
|
||||
ref="openInput"
|
||||
hidden
|
||||
type="file"
|
||||
accept=".bkle.json,.json,application/json"
|
||||
accept=".json,application/json"
|
||||
@change="
|
||||
(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
@@ -450,157 +169,23 @@ function changeVariant(groupId: string, id: string) {
|
||||
</header>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
|
||||
:key="tab"
|
||||
:class="{ active: activeTab === tab }"
|
||||
@click="activeTab = tab"
|
||||
v-for="t in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
|
||||
:key="t"
|
||||
:class="{ active: activeTab === t }"
|
||||
@click="activeTab = t"
|
||||
>
|
||||
{{ tab }}
|
||||
{{ t }}
|
||||
</button>
|
||||
</nav>
|
||||
<section v-if="activeTab === 'PHY'" class="workspace">
|
||||
<aside class="tree pane">
|
||||
<h2>PHYの木</h2>
|
||||
<div class="commands">
|
||||
<button :disabled="mismatch" @click="keyAdd">キー追加</button
|
||||
><select
|
||||
id="part-group"
|
||||
:disabled="mismatch"
|
||||
@change="componentAdd(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">部品追加…</option>
|
||||
<option>@led</option>
|
||||
<option>@encoder</option>
|
||||
<option>@screw</option>
|
||||
<option>@oled</option>
|
||||
<option>custom</option></select
|
||||
><button :disabled="mismatch || selection.length === 0" @click="makeCluster">クラスター化</button
|
||||
><button :disabled="mismatch || selectedNode?.type !== 'cluster'" @click="ungroup">解除</button
|
||||
><button :disabled="mismatch || selection.length !== 1" @click="duplicate">複製</button
|
||||
><button :disabled="mismatch || selection.length !== 1" @click="requestDelete">削除</button
|
||||
><select
|
||||
:disabled="mismatch || selection.length !== 1"
|
||||
@change="reparentTo(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">reparent先…</option>
|
||||
<option v-for="p in parents()" :key="p.id" :value="p.id">{{ p.label }}</option></select
|
||||
><button :disabled="mismatch || selection.length === 0" @click="createVariants">
|
||||
選択からVariantGroup
|
||||
</button>
|
||||
</div>
|
||||
<PhyTree
|
||||
:nodes="project.phy.root"
|
||||
:selected="selection"
|
||||
@select="(id, add) => setSelected(id, add)"
|
||||
/>
|
||||
<div v-if="pendingDelete" class="confirm-box">
|
||||
<strong>削除の確認: {{ pendingDelete.id }}</strong>
|
||||
<p>削除される参照 {{ pendingDelete.refs.length }} 件 (T-15)</p>
|
||||
<ul>
|
||||
<li v-for="(r, i) in pendingDelete.refs" :key="i">
|
||||
{{ r.layer }} · {{ r.id }} · {{ r.location }}
|
||||
</li>
|
||||
</ul>
|
||||
<button :disabled="mismatch" @click="confirmDelete">参照と一緒に削除</button
|
||||
><button @click="pendingDelete = null">キャンセル</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="canvas-pane pane">
|
||||
<div class="canvas-head">
|
||||
<strong>レイアウト · {{ keyCount }} keys</strong
|
||||
><label>Zoom <input type="range" min="0.5" max="2" step="0.1" v-model.number="zoom" /></label
|
||||
><label v-for="n in variantGroups" :key="n.id"
|
||||
>{{ n.name }}
|
||||
<select
|
||||
:value="variants[n.id] ?? n.default"
|
||||
@change="changeVariant(n.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="v in n.children" :key="v.id" :value="v.id">{{ v.name }}</option></select
|
||||
><button :disabled="mismatch" @click="addVariantUi(n.id)">+</button
|
||||
><button :disabled="mismatch" @click="changeDefault(n.id, variants[n.id] ?? n.default)">
|
||||
default</button
|
||||
><button :disabled="mismatch" @click="removeVariantUi(variants[n.id] ?? n.default)">
|
||||
−
|
||||
</button></label
|
||||
>
|
||||
</div>
|
||||
<p v-if="mismatch" class="warning">unitSize が揃っていないPHYは編集できません。</p>
|
||||
<PhyCanvas
|
||||
:nodes="project.phy.root"
|
||||
:selected="selection"
|
||||
:variants="variants"
|
||||
:zoom="zoom"
|
||||
@select="(id, add) => setSelected(id, add)"
|
||||
@drag-start="nodeDown"
|
||||
/>
|
||||
</section>
|
||||
<aside class="properties pane">
|
||||
<h2>プロパティ</h2>
|
||||
<template v-if="selectedNode"
|
||||
><p>
|
||||
<b>{{ selectedNode.type }}</b
|
||||
><br /><small>{{ selectedNode.id }}</small>
|
||||
</p>
|
||||
<label
|
||||
>X
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.transform.x"
|
||||
@change="updateTransform('x', Number(($event.target as HTMLInputElement).value))"
|
||||
/><button @click="increment('x', 0.25)">±.25U</button></label
|
||||
><label
|
||||
>Y
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.transform.y"
|
||||
@change="updateTransform('y', Number(($event.target as HTMLInputElement).value))"
|
||||
/><button @click="increment('y', 0.25)">±.25U</button></label
|
||||
><label
|
||||
>Rotation
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.transform.rotation"
|
||||
@change="updateTransform('rotation', Number(($event.target as HTMLInputElement).value))"
|
||||
/><button @click="increment('rotation', 15)">±15°</button></label
|
||||
><button @click="rotateSelected">+15°回転</button
|
||||
><template v-if="selectedNode.type === 'key'"
|
||||
><label
|
||||
>Width
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.size.w"
|
||||
@change="updateSize('w', Number(($event.target as HTMLInputElement).value))" /></label
|
||||
><label
|
||||
>Height
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.size.h"
|
||||
@change="updateSize('h', Number(($event.target as HTMLInputElement).value))" /></label
|
||||
><label
|
||||
>Label
|
||||
<input
|
||||
:value="selectedNode.labels?.filter(Boolean)[0]"
|
||||
@change="updateLabel(($event.target as HTMLInputElement).value)" /></label></template
|
||||
><template v-if="selectedNode.type === 'cluster'"
|
||||
><h3>座標枠変更</h3>
|
||||
<label>原点 X <input type="number" v-model.number="promptValue.x" /></label
|
||||
><label>原点 Y <input type="number" v-model.number="promptValue.y" /></label
|
||||
><label>角度 <input type="number" v-model.number="promptValue.r" /></label
|
||||
><button @click="changeFrame">適用</button></template
|
||||
></template
|
||||
>
|
||||
<template v-else-if="selection.length > 1">
|
||||
<p>{{ selection.length }}件を選択中</p>
|
||||
<button :disabled="mismatch" @click="rotateSelected">選択を+15°回転</button>
|
||||
</template>
|
||||
<p v-else>ノードを選択してください。Shift+クリックで複数選択。</p>
|
||||
</aside>
|
||||
</section>
|
||||
<PhyEditor
|
||||
v-if="activeTab === 'PHY'"
|
||||
:project="project"
|
||||
:dispatch="dispatch"
|
||||
:mismatch="mismatch"
|
||||
:begin-history="beginHistory"
|
||||
:commit-history="commitHistory"
|
||||
@error="errorText = $event"
|
||||
/>
|
||||
<section v-else class="other-panel">
|
||||
<IdxPanel v-if="activeTab === 'IDX'" :project="project" :dispatch="dispatch" /><ElcPanel
|
||||
v-else-if="activeTab === 'ELC'"
|
||||
@@ -613,14 +198,6 @@ function changeVariant(groupId: string, id: string) {
|
||||
:editable="!mismatch"
|
||||
/><GeneratorPanel v-else :project="project" :dispatch="dispatch" />
|
||||
</section>
|
||||
<div v-if="activeTab === 'PHY' && selectedNode" class="nudge-controls">
|
||||
<button :disabled="mismatch" @click="increment('x', -0.25)">X −.25U</button
|
||||
><button :disabled="mismatch" @click="increment('x', 0.25)">X +.25U</button
|
||||
><button :disabled="mismatch" @click="increment('y', -0.25)">Y −.25U</button
|
||||
><button :disabled="mismatch" @click="increment('y', 0.25)">Y +.25U</button
|
||||
><button :disabled="mismatch" @click="increment('rotation', -15)">回転 −15°</button
|
||||
><button :disabled="mismatch" @click="increment('rotation', 15)">回転 +15°</button>
|
||||
</div>
|
||||
<aside class="validation pane">
|
||||
<h2>検証結果</h2>
|
||||
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
|
||||
@@ -631,21 +208,15 @@ function changeVariant(groupId: string, id: string) {
|
||||
<h3>
|
||||
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
|
||||
</h3>
|
||||
<button
|
||||
v-for="(issue, i) in issues.filter((x) => x.severity === s)"
|
||||
:key="i"
|
||||
class="issue"
|
||||
@click="issue.ids?.[0] && setSelected(issue.ids[0], false)"
|
||||
>
|
||||
<div v-for="(issue, i) in issues.filter((x) => x.severity === s)" :key="i" class="issue">
|
||||
<b>{{ issue.code }}</b> {{ issue.message }} <small>{{ issue.path }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -665,15 +236,10 @@ body {
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.app-shell.locked .properties {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.toolbar,
|
||||
.tabs,
|
||||
.pane,
|
||||
.placeholder {
|
||||
background: white;
|
||||
.pane {
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -694,158 +260,17 @@ body {
|
||||
.tabs button.active {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.workspace {
|
||||
.other-panel {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(300px, 1fr) 250px;
|
||||
gap: 8px;
|
||||
}
|
||||
.pane {
|
||||
.validation {
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.tree h2,
|
||||
.properties h2,
|
||||
.validation h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.commands {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.commands button,
|
||||
.commands select {
|
||||
font-size: 12px;
|
||||
}
|
||||
.tree-list {
|
||||
padding-left: 14px;
|
||||
list-style: none;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.tree-node {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
.tree-node small {
|
||||
color: #778;
|
||||
}
|
||||
.tree-node.chosen {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.canvas-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.canvas-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.canvas-head label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.canvas {
|
||||
width: 1152px;
|
||||
height: 896px;
|
||||
min-height: 240px;
|
||||
flex: none;
|
||||
background-color: #fbfcfe;
|
||||
background-image:
|
||||
linear-gradient(#e7ebf1 1px, transparent 1px), linear-gradient(90deg, #e7ebf1 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
touch-action: none;
|
||||
}
|
||||
.phy-node {
|
||||
cursor: grab;
|
||||
}
|
||||
.phy-node:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.key-face {
|
||||
fill: #f8cc78;
|
||||
stroke: #5b4a2b;
|
||||
stroke-width: 0.025;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.secondary {
|
||||
fill: #ffe7ad;
|
||||
}
|
||||
.key-label {
|
||||
font-size: 0.13px;
|
||||
fill: #29251e;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.decal .key-face {
|
||||
fill: #fff;
|
||||
stroke-dasharray: 0.08 0.06;
|
||||
}
|
||||
.chosen > .key-face,
|
||||
.chosen > .part {
|
||||
stroke: #1769d2;
|
||||
stroke-width: 0.055;
|
||||
}
|
||||
.part {
|
||||
fill: #b4d5ef;
|
||||
stroke: #315d7a;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.part-label {
|
||||
font-size: 0.12px;
|
||||
fill: #333;
|
||||
}
|
||||
.selection-box {
|
||||
fill: none;
|
||||
stroke: #1769d2;
|
||||
stroke-width: 0.025;
|
||||
stroke-dasharray: 0.04;
|
||||
}
|
||||
.group-origin {
|
||||
fill: #d33;
|
||||
pointer-events: none;
|
||||
}
|
||||
.properties label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.properties input {
|
||||
width: 105px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nudge-controls {
|
||||
position: fixed;
|
||||
right: 270px;
|
||||
bottom: 194px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100vw - 540px);
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #adb8c8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.validation {
|
||||
grid-row: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.issues {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
@@ -858,11 +283,7 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
.issue {
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
margin: 3px 0;
|
||||
border: 0;
|
||||
background: #f4f6f9;
|
||||
padding: 5px;
|
||||
}
|
||||
@@ -881,24 +302,4 @@ body {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.confirm-box {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: #fff2dc;
|
||||
border: 1px solid #db9d39;
|
||||
}
|
||||
.confirm-box ul {
|
||||
max-height: 90px;
|
||||
overflow: auto;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.other-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
.placeholder {
|
||||
padding: 20px;
|
||||
}
|
||||
.canvas-pane .warning {
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from "../../domain/phy";
|
||||
import { localBounds } from "./logic";
|
||||
defineOptions({ name: "NodeSvg" });
|
||||
const props = defineProps<{
|
||||
nodes: Node[];
|
||||
selected: string[];
|
||||
variants: Record<string, string>;
|
||||
cut: string[];
|
||||
dim: boolean;
|
||||
zoom: number;
|
||||
angle: number;
|
||||
longCandidate?: string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
||||
badge: [g: Node, e: PointerEvent];
|
||||
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>
|
||||
<template>
|
||||
<g
|
||||
v-for="n in nodes"
|
||||
:key="n.id"
|
||||
:data-node-type="n.type"
|
||||
:data-node-id="n.id"
|
||||
:transform="`translate(${n.transform.x} ${n.transform.y}) rotate(${n.transform.rotation})`"
|
||||
:class="{ dim: dim, cut: cut.includes(n.id) }"
|
||||
@dblclick.stop="emit('focus', n.id)"
|
||||
>
|
||||
<g
|
||||
v-if="n.type === 'key'"
|
||||
class="node"
|
||||
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||
><rect
|
||||
:width="n.size.w"
|
||||
:height="n.size.h"
|
||||
rx=".06"
|
||||
class="key-face"
|
||||
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" /><text
|
||||
class="key-label"
|
||||
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
|
||||
v-else-if="n.type === 'component'"
|
||||
class="node"
|
||||
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||
><rect
|
||||
v-if="n.partGroup === '@led'"
|
||||
x="-.14"
|
||||
y="-.14"
|
||||
width=".28"
|
||||
height=".28"
|
||||
transform="rotate(45)"
|
||||
class="part led"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
/><g v-else-if="n.partGroup === '@encoder'" class="part" :class="{ chosen: selected.includes(n.id) }"
|
||||
><circle r=".16" /><path d="M -.1 0 H .1 M 0 -.1 V .1" /></g
|
||||
><path
|
||||
v-else-if="n.partGroup === '@screw'"
|
||||
d="M -.14 -.14 L .14 .14 M .14 -.14 L -.14 .14"
|
||||
class="part screw"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
/><rect
|
||||
v-else-if="n.partGroup === '@oled'"
|
||||
x="-.2"
|
||||
y="-.12"
|
||||
width=".4"
|
||||
height=".24"
|
||||
rx=".025"
|
||||
class="part oled"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
/><circle v-else r=".14" class="part" :class="{ chosen: selected.includes(n.id) }" /><text
|
||||
x=".2"
|
||||
y=".04"
|
||||
class="part-label"
|
||||
>{{ n.partGroup || "part" }}</text
|
||||
></g
|
||||
>
|
||||
<g
|
||||
v-else-if="n.type === 'cluster'"
|
||||
class="group"
|
||||
@pointerdown.self="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||
><NodeSvg
|
||||
:nodes="n.children"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="dim"
|
||||
:zoom="zoom"
|
||||
:angle="angle + n.transform.rotation"
|
||||
:long-candidate="longCandidate"
|
||||
@down="(e, c, el) => emit('down', e, c, el)"
|
||||
@badge="(g, e) => emit('badge', g, e)"
|
||||
@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
|
||||
>
|
||||
<g v-else class="group"
|
||||
><NodeSvg
|
||||
v-for="v in n.children.filter((v) => v.id === (variants[n.id] || n.default))"
|
||||
:key="v.id"
|
||||
:nodes="v.children"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="dim"
|
||||
:zoom="zoom"
|
||||
:angle="angle + n.transform.rotation"
|
||||
:long-candidate="longCandidate"
|
||||
@down="(e, c, el) => emit('down', e, c, el)"
|
||||
@badge="(g, e) => emit('badge', g, e)"
|
||||
@focus="(id) => emit('focus', id)"
|
||||
/><rect
|
||||
v-if="selected.includes(n.id) || longCandidate === n.id"
|
||||
:x="bounds(n).x"
|
||||
:y="bounds(n).y"
|
||||
:width="bounds(n).width"
|
||||
:height="bounds(n).height"
|
||||
class="group-sel"
|
||||
:class="{ candidate: longCandidate === n.id }"
|
||||
/><g
|
||||
: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>
|
||||
</template>
|
||||
<style scoped>
|
||||
.node {
|
||||
cursor: grab;
|
||||
}
|
||||
.key-face {
|
||||
fill: #f8cc78;
|
||||
stroke: #5b4a2b;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.key-face.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.key-face.decal {
|
||||
fill: #fff;
|
||||
stroke-dasharray: 0.08 0.06;
|
||||
}
|
||||
.key-label,
|
||||
.part-label,
|
||||
.group-label {
|
||||
font-size: 0.14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.part {
|
||||
fill: #b4d5ef;
|
||||
stroke: #315d7a;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.part.chosen {
|
||||
stroke: #1769d2;
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.part.screw {
|
||||
fill: none;
|
||||
}
|
||||
.part path {
|
||||
fill: none;
|
||||
stroke: #315d7a;
|
||||
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 {
|
||||
cursor: default;
|
||||
}
|
||||
.dim {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.cut {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.group-label {
|
||||
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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from "../../domain/phy";
|
||||
import PhyNodeView from "./PhyNodeView.vue";
|
||||
defineProps<{ nodes: Node[]; selected: string[]; variants: Record<string, string>; zoom: number }>();
|
||||
const emit = defineEmits<{
|
||||
select: [id: string, additive: boolean];
|
||||
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
|
||||
}>();
|
||||
function visible(n: Node, selection: Record<string, string>) {
|
||||
return n.type !== "variantGroup" || !!n.children.find((v) => v.id === (selection[n.id] ?? n.default));
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<svg
|
||||
class="canvas"
|
||||
data-testid="phy-canvas"
|
||||
:viewBox="`-64 -64 ${1152 / zoom} ${896 / zoom}`"
|
||||
width="1152"
|
||||
height="896"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
>
|
||||
<g class="layout" transform="scale(64)">
|
||||
<g v-for="n in nodes" :key="n.id">
|
||||
<PhyNodeView
|
||||
:node="n"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:visible="visible(n, variants)"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,587 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { Node } from "../../domain/phy";
|
||||
import type { Dispatch } from "../types";
|
||||
import { reframe } from "../../domain/phyCommands";
|
||||
import { listDeleteReferences } from "../../domain/deleteCascade";
|
||||
import PhyTree from "./PhyTree.vue";
|
||||
import NodeSvg from "./NodeSvg.vue";
|
||||
import PhyToolbar from "./PhyToolbar.vue";
|
||||
import PhyProperties from "./PhyProperties.vue";
|
||||
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
|
||||
import { usePhyClipboard } from "./usePhyClipboard";
|
||||
import { usePhyViewport } from "./usePhyViewport";
|
||||
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
|
||||
import { usePhyKeyboard } from "./usePhyKeyboard";
|
||||
import { usePhyCommands } from "./usePhyCommands";
|
||||
import { usePhyCanvasView } from "./usePhyCanvasView";
|
||||
import { usePhyTreeModel } from "./usePhyTreeModel";
|
||||
const props = defineProps<{
|
||||
project: ProjectFile;
|
||||
dispatch: Dispatch;
|
||||
mismatch: boolean;
|
||||
beginHistory: () => void;
|
||||
commitHistory: () => void;
|
||||
}>();
|
||||
const emit = defineEmits<{ error: [text: string] }>();
|
||||
const selected = ref<string[]>([]),
|
||||
variants = ref<Record<string, string>>({}),
|
||||
focus = ref<string | null>(null),
|
||||
grid = ref<number>(0.25),
|
||||
zoom = ref(52),
|
||||
pan = ref({ x: 50, y: 50 }),
|
||||
pane = ref<HTMLElement>(),
|
||||
svg = ref<SVGSVGElement>(),
|
||||
treeOpen = ref(true),
|
||||
showHelp = ref(false),
|
||||
popover = ref<string | null>(null),
|
||||
cut = ref<string[]>([]),
|
||||
confirm = ref<string | null>(null),
|
||||
confirmIds = ref<string[]>([]),
|
||||
confirmRefs = ref<ReturnType<typeof listDeleteReferences>>([]),
|
||||
confirmPos = ref({ left: 12, top: 44 }),
|
||||
popoverPos = ref({ left: 12, top: 44 }),
|
||||
size = ref({ w: 900, h: 600 }),
|
||||
longHint = ref("");
|
||||
watch(
|
||||
() => props.project,
|
||||
(p) => {
|
||||
for (const g of structuralNodes(p.phy.root))
|
||||
if (g.type === "variantGroup" && !variants.value[g.id]) variants.value[g.id] = g.default;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.project.projectId,
|
||||
async () => {
|
||||
selected.value = [];
|
||||
focus.value = null;
|
||||
variants.value = {};
|
||||
await nextTick();
|
||||
fit();
|
||||
},
|
||||
);
|
||||
const {
|
||||
allNodes,
|
||||
node,
|
||||
parentId,
|
||||
isAncestor,
|
||||
selectedContainer,
|
||||
groupForVariant,
|
||||
parentLabel,
|
||||
pressTargets,
|
||||
} = usePhyTreeModel(() => props.project, focus);
|
||||
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
|
||||
project: () => props.project,
|
||||
pane,
|
||||
svg,
|
||||
size,
|
||||
zoom,
|
||||
pan,
|
||||
selected,
|
||||
variants,
|
||||
node,
|
||||
});
|
||||
function setSel(id: string, add = false) {
|
||||
selected.value = normalizeSelection(
|
||||
props.project.phy,
|
||||
add
|
||||
? selected.value.includes(id)
|
||||
? selected.value.filter((x) => x !== id)
|
||||
: [...selected.value, id]
|
||||
: [id],
|
||||
);
|
||||
pane.value?.focus();
|
||||
}
|
||||
const canvasView = usePhyCanvasView({
|
||||
project: () => props.project,
|
||||
size,
|
||||
pan,
|
||||
zoom,
|
||||
focus,
|
||||
variants,
|
||||
grid,
|
||||
selected,
|
||||
node,
|
||||
parentId,
|
||||
parentLabel,
|
||||
selectedContainer,
|
||||
});
|
||||
const {
|
||||
keys,
|
||||
groupsAll,
|
||||
outsideNodes,
|
||||
gridLines,
|
||||
variantGroups,
|
||||
focusGridLines,
|
||||
moveTargets,
|
||||
parentMatrix: focusParentMatrix,
|
||||
focusAngle,
|
||||
affineText,
|
||||
focusCrumbs,
|
||||
propertyNode: prop,
|
||||
} = canvasView;
|
||||
function focusIn(id: string) {
|
||||
focus.value = id;
|
||||
selected.value = [id];
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", keyHandler);
|
||||
window.addEventListener("keyup", spaceUp);
|
||||
window.addEventListener("paste", pasteEvent);
|
||||
window.addEventListener("copy", copyEvent);
|
||||
window.addEventListener("cut", copyEvent);
|
||||
fit();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", keyHandler);
|
||||
window.removeEventListener("keyup", spaceUp);
|
||||
window.removeEventListener("paste", pasteEvent);
|
||||
window.removeEventListener("copy", copyEvent);
|
||||
window.removeEventListener("cut", copyEvent);
|
||||
});
|
||||
function dispatch(label: string, fn: (p: ProjectFile) => ProjectFile) {
|
||||
try {
|
||||
props.dispatch(label, fn);
|
||||
} catch (e) {
|
||||
emit("error", String(e));
|
||||
}
|
||||
}
|
||||
function update(id: string, fn: (n: Node) => void, label = "プロパティ変更") {
|
||||
dispatch(label, (p) => {
|
||||
const q = structuredClone(p);
|
||||
const n = allNodes(q.phy.root).find((x) => x.id === id);
|
||||
if (n) fn(n);
|
||||
return q;
|
||||
});
|
||||
}
|
||||
const commands = usePhyCommands({
|
||||
project: () => props.project,
|
||||
selected,
|
||||
variants,
|
||||
focus,
|
||||
grid,
|
||||
zoom,
|
||||
pan,
|
||||
size,
|
||||
svg,
|
||||
pane,
|
||||
popover,
|
||||
confirm,
|
||||
confirmIds,
|
||||
confirmRefs,
|
||||
confirmPos,
|
||||
node,
|
||||
allNodes,
|
||||
parentId,
|
||||
selectedContainer,
|
||||
groupForVariant,
|
||||
variantGroups: () => variantGroups.value,
|
||||
dispatch,
|
||||
emitError: (message) => emit("error", message),
|
||||
});
|
||||
const {
|
||||
parentMatrix,
|
||||
nodesInContainer,
|
||||
directChildren,
|
||||
tabItem,
|
||||
chooseVariant,
|
||||
keyAdd,
|
||||
partAdd,
|
||||
makeCluster,
|
||||
ungroup,
|
||||
variantGroup,
|
||||
duplicate,
|
||||
moveSelectionTo,
|
||||
askDelete,
|
||||
deleteConfirmed,
|
||||
setDefault,
|
||||
addVariant: addVariantToGroup,
|
||||
} = commands;
|
||||
const { copyEvent, pasteEvent } = usePhyClipboard({
|
||||
project: () => props.project,
|
||||
mismatch: () => props.mismatch,
|
||||
selected,
|
||||
cut,
|
||||
grid,
|
||||
node,
|
||||
selectedContainer,
|
||||
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
|
||||
parentMatrix,
|
||||
dispatch,
|
||||
setSelected: (ids) => (selected.value = ids),
|
||||
});
|
||||
const priorChild = new Map<string, string>();
|
||||
function visibleIds(): Set<string> {
|
||||
if (!focus.value) return new Set(allNodes().map((n) => n.id));
|
||||
return visibleNodeIds(node(focus.value), variants.value);
|
||||
}
|
||||
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const pointer = usePhyCanvasPointer({
|
||||
project: () => props.project,
|
||||
svg,
|
||||
pane,
|
||||
pan,
|
||||
zoom,
|
||||
grid,
|
||||
selected,
|
||||
focus,
|
||||
rangeBox,
|
||||
allNodes,
|
||||
keys: () => keys.value,
|
||||
localPoint,
|
||||
visibleIds,
|
||||
mismatch: () => props.mismatch,
|
||||
isAncestor,
|
||||
parentLabel,
|
||||
pressTargets,
|
||||
setSelection: setSel,
|
||||
beginHistory: props.beginHistory,
|
||||
commitHistory: props.commitHistory,
|
||||
dispatch,
|
||||
});
|
||||
const { canvasDown, down, spaceUp, longLabel, longLabelPos, spaceHeld, holdCandidates, holdIndex } = pointer;
|
||||
const keyHandler = usePhyKeyboard({
|
||||
project: () => props.project,
|
||||
mismatch: () => props.mismatch,
|
||||
pane,
|
||||
selected,
|
||||
focus,
|
||||
grid,
|
||||
zoom,
|
||||
showHelp,
|
||||
popover,
|
||||
confirm,
|
||||
spaceHeld,
|
||||
variants,
|
||||
priorChild,
|
||||
node,
|
||||
allNodes,
|
||||
parentId,
|
||||
selectedContainer,
|
||||
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
|
||||
nodesInContainer,
|
||||
directChildren,
|
||||
tabItem,
|
||||
dispatch,
|
||||
fit,
|
||||
fitSelected,
|
||||
focusIn,
|
||||
makeCluster,
|
||||
ungroup,
|
||||
duplicate,
|
||||
keyAdd,
|
||||
askDelete,
|
||||
deleteConfirmed,
|
||||
chooseVariant,
|
||||
});
|
||||
function overlayPoint(clientX: number, clientY: number, width = 380, height = 240) {
|
||||
const r = pane.value?.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.max(4, Math.min(clientX - (r?.left ?? 0), (r?.width ?? width) - width - 4)),
|
||||
top: Math.max(4, Math.min(clientY - (r?.top ?? 0), (r?.height ?? height) - height - 4)),
|
||||
};
|
||||
}
|
||||
function openBadge(g: Node, e: PointerEvent | MouseEvent) {
|
||||
if (focus.value && !visibleIds().has(g.id)) return;
|
||||
popover.value = g.id;
|
||||
const r = (e.currentTarget as Element).getBoundingClientRect();
|
||||
const paneRect = pane.value?.getBoundingClientRect();
|
||||
const left = r.right + 210 < (paneRect?.right ?? Infinity) ? r.left : r.left - 210;
|
||||
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
|
||||
popoverPos.value = overlayPoint(left, top, 210, 150);
|
||||
}
|
||||
function onFocus(_id: string) {
|
||||
if (
|
||||
pointer.priorSelection().length === 1 &&
|
||||
["cluster", "variantGroup"].includes(node(pointer.priorSelection()[0]!)?.type || "")
|
||||
)
|
||||
focusIn(pointer.priorSelection()[0]!);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="workspace" :class="{ locked: mismatch }">
|
||||
<aside v-if="treeOpen" class="tree pane">
|
||||
<PhyTree :nodes="project.phy.root" :selected="selected" @select="setSel" />
|
||||
</aside>
|
||||
<section ref="pane" class="canvas-pane pane" tabindex="0">
|
||||
<PhyToolbar
|
||||
:mismatch="mismatch"
|
||||
:selected-count="selected.length"
|
||||
:grid="grid"
|
||||
:move-targets="moveTargets"
|
||||
:focus="focus"
|
||||
:crumbs="focus ? focusCrumbs(focus) : []"
|
||||
@key-add="keyAdd"
|
||||
@part-add="partAdd"
|
||||
@cluster="makeCluster"
|
||||
@ungroup="ungroup"
|
||||
@variant-group="variantGroup"
|
||||
@move-to="moveSelectionTo"
|
||||
@duplicate="duplicate"
|
||||
@delete="(event) => askDelete(undefined, event)"
|
||||
@grid="grid = nextGrid(grid, 1)"
|
||||
@fit="fit"
|
||||
@fit-selected="fitSelected"
|
||||
@zoom="(direction) => (zoom *= direction > 0 ? 1.15 : 1 / 1.15)"
|
||||
@tree="treeOpen = !treeOpen"
|
||||
@help="showHelp = true"
|
||||
@focus="focus = $event"
|
||||
/>
|
||||
<div
|
||||
v-if="longLabel"
|
||||
class="long-label"
|
||||
:style="{ left: longLabelPos.x + 'px', top: longLabelPos.y + 'px' }"
|
||||
>
|
||||
{{ longLabel }}
|
||||
</div>
|
||||
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
|
||||
<svg
|
||||
ref="svg"
|
||||
class="canvas"
|
||||
:viewBox="`0 0 ${size.w} ${size.h}`"
|
||||
@wheel.prevent="wheel"
|
||||
@pointerdown="canvasDown"
|
||||
>
|
||||
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
|
||||
<path
|
||||
v-if="!focus"
|
||||
v-for="(line, i) in gridLines"
|
||||
:key="i"
|
||||
:d="line.d"
|
||||
:class="{ major: line.major }"
|
||||
/>
|
||||
<NodeSvg
|
||||
v-if="focus && outsideNodes.length"
|
||||
:nodes="outsideNodes"
|
||||
:selected="[]"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="true"
|
||||
:zoom="zoom"
|
||||
:angle="0"
|
||||
/>
|
||||
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
|
||||
<g
|
||||
:transform="`translate(${node(focus)!.transform.x} ${node(focus)!.transform.y}) rotate(${node(focus)!.transform.rotation})`"
|
||||
>
|
||||
<path v-for="(line, i) in focusGridLines" :key="i" :d="line.d" :class="{ major: line.major }" />
|
||||
</g>
|
||||
<NodeSvg
|
||||
:nodes="[node(focus)!]"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="false"
|
||||
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
||||
:zoom="zoom"
|
||||
:angle="focusAngle(focus)"
|
||||
@down="down"
|
||||
@badge="openBadge"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</g>
|
||||
<NodeSvg
|
||||
v-else
|
||||
:nodes="project.phy.root"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="false"
|
||||
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
||||
:zoom="zoom"
|
||||
:angle="0"
|
||||
@down="down"
|
||||
@badge="openBadge"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
v-if="rangeBox"
|
||||
class="range-box"
|
||||
:x="rangeBox.x"
|
||||
:y="rangeBox.y"
|
||||
:width="rangeBox.width"
|
||||
:height="rangeBox.height"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
v-if="confirm"
|
||||
class="confirm-float"
|
||||
:style="{ left: confirmPos.left + 'px', top: confirmPos.top + 'px' }"
|
||||
>
|
||||
<b>削除の確認</b>
|
||||
<p>参照 {{ confirmRefs.length }}件</p>
|
||||
<ul>
|
||||
<li v-for="(r, i) in confirmRefs" :key="i">{{ r.layer }} · {{ r.id }} · {{ r.location }}</li>
|
||||
</ul>
|
||||
<button @click="deleteConfirmed">削除</button><button @click="confirm = null">取消</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="popover"
|
||||
class="popover"
|
||||
:style="{ left: popoverPos.left + 'px', top: popoverPos.top + 'px' }"
|
||||
>
|
||||
<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)">
|
||||
{{ v.name }} {{ g.default === v.id ? "✓" : "" }}
|
||||
</button></template
|
||||
>
|
||||
</div>
|
||||
<div v-if="showHelp" class="overlay" @click.self="showHelp = false">
|
||||
<article>
|
||||
<button @click="showHelp = false">閉じる</button>
|
||||
<h2>ショートカット</h2>
|
||||
<p>Esc 選択解除・階層を戻る · Alt+↑/↓ 親・子 · Tab/Shift+Tab 兄弟 · Ctrl+A 同じ親を選択</p>
|
||||
<p>矢印 移動 · Shift+矢印 サイズ · PgUp/PgDn 回転 · R/Shift+R キーの向き</p>
|
||||
<p>
|
||||
Ctrl+G クラスター · Ctrl+Shift+G 解除 · Ctrl+C/X/V コピー・カット・貼り付け · Ctrl+D 複製 · Ins
|
||||
追加 · Delete 削除
|
||||
</p>
|
||||
<p>
|
||||
Shift+1 全体表示 · Shift+2 選択表示 · +/- ズーム · G/Shift+G 刻み · V/Shift+V バリアント · Enter
|
||||
フォーカス · F2 プロパティ
|
||||
</p>
|
||||
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
|
||||
<p>長押し: キー上で500ms待つと選択先候補が450msごとに進み、離すと確定します。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<PhyProperties
|
||||
:selected-count="selected.length"
|
||||
:selected-id="selected[0] ?? null"
|
||||
:item="prop ?? null"
|
||||
:mismatch="mismatch"
|
||||
@update="update"
|
||||
@apply-frame="(id, frame) => dispatch('座標枠変更', (p) => ({ ...p, phy: reframe(p.phy, id, frame) }))"
|
||||
@set-default="(groupId, variantId) => setDefault(groupId, variantId)"
|
||||
@change-variant="chooseVariant"
|
||||
@delete-node="(id) => askDelete(id)"
|
||||
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.workspace {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(300px, 1fr) 250px;
|
||||
gap: 8px;
|
||||
}
|
||||
.pane {
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.canvas-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
background: #fbfcfe;
|
||||
touch-action: none;
|
||||
}
|
||||
.canvas path {
|
||||
stroke: #e6eaf0;
|
||||
stroke-width: 0.015;
|
||||
fill: none;
|
||||
}
|
||||
.canvas path.major {
|
||||
stroke: #ccd3de;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.range-box {
|
||||
fill: #4b8de822;
|
||||
stroke: #1769d2;
|
||||
stroke-width: 1px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
.properties fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.properties label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.properties input {
|
||||
width: 110px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.popover,
|
||||
.confirm-float,
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border: 1px solid #99a3b2;
|
||||
box-shadow: 0 4px 15px #0003;
|
||||
}
|
||||
.confirm-float {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
width: min(380px, calc(100% - 16px));
|
||||
max-height: min(240px, calc(100% - 16px));
|
||||
overflow: auto;
|
||||
font-size: 14px;
|
||||
color: #20242a;
|
||||
}
|
||||
.confirm-float ul {
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
padding-left: 22px;
|
||||
}
|
||||
.overlay {
|
||||
inset: 20%;
|
||||
}
|
||||
.tree-list {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.confirm-float rect {
|
||||
fill: #fff2dc;
|
||||
stroke: #db9d39;
|
||||
}
|
||||
.confirm-float foreignObject div {
|
||||
font: 1.5px sans-serif;
|
||||
}
|
||||
.confirm-float ul {
|
||||
max-height: 1px;
|
||||
overflow: auto;
|
||||
}
|
||||
.workspace.locked .properties {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.warning {
|
||||
color: #9a4c00;
|
||||
}
|
||||
.long-label {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: translate(10px, 10px);
|
||||
pointer-events: none;
|
||||
background: #fff3d8;
|
||||
border: 1px solid #c87d13;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from "../../domain/phy";
|
||||
const props = defineProps<{
|
||||
node: Node;
|
||||
selected: string[];
|
||||
variants: Record<string, string>;
|
||||
visible: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
select: [id: string, additive: boolean];
|
||||
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
|
||||
}>();
|
||||
function children(n: Node): Node[] {
|
||||
if (n.type === "cluster") return n.children;
|
||||
if (n.type === "variantGroup")
|
||||
return n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [];
|
||||
return [];
|
||||
}
|
||||
function partShape(part?: string) {
|
||||
return part === "@led"
|
||||
? "led"
|
||||
: part === "@encoder"
|
||||
? "encoder"
|
||||
: part === "@screw"
|
||||
? "screw"
|
||||
: part === "@oled"
|
||||
? "oled"
|
||||
: "other";
|
||||
}
|
||||
function pointer(event: PointerEvent) {
|
||||
const parent = (event.currentTarget as SVGGElement).parentElement as unknown as SVGGElement;
|
||||
emit("dragStart", event, props.node.id, parent);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<g
|
||||
v-if="visible"
|
||||
class="phy-node"
|
||||
:class="[
|
||||
{
|
||||
chosen: selected.includes(node.id),
|
||||
ghost: node.type === 'key' && node.ghost,
|
||||
decal: node.type === 'key' && node.decal,
|
||||
},
|
||||
`node-${node.type}`,
|
||||
]"
|
||||
:data-node-id="node.id"
|
||||
:data-node-type="node.type"
|
||||
:transform="`translate(${node.transform.x},${node.transform.y}) rotate(${node.transform.rotation})`"
|
||||
@pointerdown.stop="pointer"
|
||||
@click.stop="emit('select', node.id, $event.shiftKey)"
|
||||
>
|
||||
<template v-if="node.type === 'key'"
|
||||
><rect
|
||||
class="key-face"
|
||||
x="0.04"
|
||||
y="0.04"
|
||||
:width="Math.max(0.1, node.size.w - 0.08)"
|
||||
:height="Math.max(0.1, node.size.h - 0.08)"
|
||||
rx="0.08"
|
||||
/><rect
|
||||
v-if="node.size.w2 || node.size.h2 || node.size.x2 || node.size.y2"
|
||||
class="key-face secondary"
|
||||
:x="node.size.x2 || 0"
|
||||
:y="node.size.y2 || 0"
|
||||
:width="node.size.w2 || node.size.w"
|
||||
:height="node.size.h2 || node.size.h"
|
||||
rx="0.06"
|
||||
/><text class="key-label" x="0.12" y="0.3">{{
|
||||
node.labels?.filter(Boolean).slice(0, 2).join(" / ")
|
||||
}}</text></template
|
||||
>
|
||||
<template v-else-if="node.type === 'component'"
|
||||
><circle v-if="partShape(node.partGroup) === 'led'" class="part led" r="0.25" /><circle
|
||||
v-else-if="partShape(node.partGroup) === 'screw'"
|
||||
class="part screw"
|
||||
r="0.18"
|
||||
/><rect
|
||||
v-else-if="partShape(node.partGroup) === 'oled'"
|
||||
class="part oled"
|
||||
x="-0.35"
|
||||
y="-0.22"
|
||||
width="0.7"
|
||||
height="0.44"
|
||||
/><g v-else-if="partShape(node.partGroup) === 'encoder'"
|
||||
><circle class="part encoder" r="0.27" /><path d="M-.1 -.18 L.12 0 -.1 .18" /></g
|
||||
><path v-else class="part" d="M0 -.22 L.22 0 0 .22 -.22 0 Z" /><text
|
||||
class="part-label"
|
||||
x="0.28"
|
||||
y="0.05"
|
||||
>{{ node.partGroup || "part" }}</text
|
||||
></template
|
||||
>
|
||||
<g v-else-if="node.type === 'cluster'" class="cluster-content"
|
||||
><PhyNodeView
|
||||
v-for="c in node.children"
|
||||
:key="c.id"
|
||||
:node="c"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:visible="true"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
|
||||
/></g>
|
||||
<g v-else-if="node.type === 'variantGroup'" class="variant-content"
|
||||
><PhyNodeView
|
||||
v-for="c in children(node)"
|
||||
:key="c.id"
|
||||
:node="c"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:visible="true"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
|
||||
/></g>
|
||||
<circle v-if="node.type === 'cluster' || node.type === 'variantGroup'" class="group-origin" r="0.07" />
|
||||
<rect
|
||||
v-if="selected.includes(node.id) && node.type !== 'key'"
|
||||
class="selection-box"
|
||||
x="-0.12"
|
||||
y="-0.12"
|
||||
width="0.24"
|
||||
height="0.24"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { Node, Transform } from "../../domain/phy";
|
||||
const props = defineProps<{
|
||||
selectedCount: number;
|
||||
selectedId: string | null;
|
||||
item: Node | null;
|
||||
mismatch: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
update: [id: string, change: (node: Node) => void, label?: string];
|
||||
applyFrame: [id: string, frame: Transform];
|
||||
setDefault: [groupId: string, variantId: string];
|
||||
changeVariant: [group: Node, variantId: string];
|
||||
deleteNode: [id: string];
|
||||
addVariant: [groupId: string, name: string];
|
||||
}>();
|
||||
const group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
|
||||
const frameX = ref(0);
|
||||
const frameY = ref(0);
|
||||
const frameR = ref(0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="properties pane">
|
||||
<fieldset :disabled="mismatch">
|
||||
<p v-if="selectedCount > 1">{{ selectedCount }}件を選択中</p>
|
||||
<template v-else-if="item">
|
||||
<h2>{{ item.type }}</h2>
|
||||
<label
|
||||
>X
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="item.transform.x"
|
||||
@change="
|
||||
emit(
|
||||
'update',
|
||||
item.id,
|
||||
(n) => (n.transform.x = Number(($event.target as HTMLInputElement).value)),
|
||||
)
|
||||
"
|
||||
/></label>
|
||||
<label
|
||||
>Y
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="item.transform.y"
|
||||
@change="
|
||||
emit(
|
||||
'update',
|
||||
item.id,
|
||||
(n) => (n.transform.y = Number(($event.target as HTMLInputElement).value)),
|
||||
)
|
||||
"
|
||||
/></label>
|
||||
<label
|
||||
>Rotation
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="item.transform.rotation"
|
||||
@change="
|
||||
emit(
|
||||
'update',
|
||||
item.id,
|
||||
(n) => (n.transform.rotation = Number(($event.target as HTMLInputElement).value)),
|
||||
)
|
||||
"
|
||||
/></label>
|
||||
<template v-if="item.type === 'key'">
|
||||
<label
|
||||
>Width
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="item.size.w"
|
||||
@change="
|
||||
emit('update', item.id, (n) => {
|
||||
if (n.type === 'key') n.size.w = Number(($event.target as HTMLInputElement).value);
|
||||
})
|
||||
"
|
||||
/></label>
|
||||
<label
|
||||
>Height
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="item.size.h"
|
||||
@change="
|
||||
emit('update', item.id, (n) => {
|
||||
if (n.type === 'key') n.size.h = Number(($event.target as HTMLInputElement).value);
|
||||
})
|
||||
"
|
||||
/></label>
|
||||
<label
|
||||
>Label
|
||||
<input
|
||||
:value="item.labels?.[0]"
|
||||
@change="
|
||||
emit('update', item.id, (n) => {
|
||||
if (n.type === 'key') n.labels = [($event.target as HTMLInputElement).value];
|
||||
})
|
||||
"
|
||||
/></label>
|
||||
</template>
|
||||
<template v-if="item.type === 'cluster'">
|
||||
<label
|
||||
>名前
|
||||
<input
|
||||
:value="item.name"
|
||||
@change="
|
||||
emit('update', item.id, (n) => {
|
||||
if (n.type === 'cluster') n.name = ($event.target as HTMLInputElement).value;
|
||||
})
|
||||
"
|
||||
/></label>
|
||||
<h3>座標枠変更</h3>
|
||||
<label>原点X <input type="number" v-model.number="frameX" /></label>
|
||||
<label>原点Y <input type="number" v-model.number="frameY" /></label>
|
||||
<label>角度 <input type="number" v-model.number="frameR" /></label>
|
||||
<button @click="emit('applyFrame', item.id, { x: frameX, y: frameY, rotation: frameR })">
|
||||
適用
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="group">
|
||||
<label
|
||||
>名前
|
||||
<input
|
||||
:value="group.name"
|
||||
@change="
|
||||
emit('update', group.id, (n) => {
|
||||
if (n.type === 'variantGroup') n.name = ($event.target as HTMLInputElement).value;
|
||||
})
|
||||
"
|
||||
/></label>
|
||||
<div v-for="v in group.children" :key="v.id">
|
||||
{{ v.name }} {{ v.id === group.default ? "✓" : "" }}
|
||||
<button @click="emit('setDefault', group.id, v.id)">既定</button>
|
||||
<button @click="emit('changeVariant', group, v.id)">表示</button>
|
||||
<button :disabled="group.children.length <= 2" @click="emit('deleteNode', v.id)">削除</button>
|
||||
</div>
|
||||
<button @click="emit('addVariant', group.id, 'Option ' + (group.children.length + 1))">追加</button>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else-if="selectedCount === 0">ノードを選択してください</p>
|
||||
</fieldset>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane {
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.properties fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.properties label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.properties input {
|
||||
width: 110px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
mismatch: boolean;
|
||||
selectedCount: number;
|
||||
grid: number;
|
||||
moveTargets: { id: string; label: string }[];
|
||||
focus: string | null;
|
||||
crumbs: { id: string | null; label: string }[];
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
keyAdd: [];
|
||||
partAdd: [group: string];
|
||||
cluster: [];
|
||||
ungroup: [];
|
||||
variantGroup: [];
|
||||
moveTo: [id: string];
|
||||
duplicate: [];
|
||||
delete: [event: MouseEvent];
|
||||
grid: [];
|
||||
fit: [];
|
||||
fitSelected: [];
|
||||
zoom: [direction: 1 | -1];
|
||||
tree: [];
|
||||
help: [];
|
||||
focus: [id: string | null];
|
||||
}>();
|
||||
function addPart(e: Event) {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
emit("partAdd", select.value);
|
||||
select.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tools">
|
||||
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
|
||||
+⌨
|
||||
</button>
|
||||
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
|
||||
<option value="">部品を足す</option>
|
||||
<option>@led</option>
|
||||
<option>@encoder</option>
|
||||
<option>@screw</option>
|
||||
<option>@oled</option>
|
||||
<option>その他</option>
|
||||
</select>
|
||||
<button
|
||||
title="クラスターにする"
|
||||
aria-label="クラスターにする"
|
||||
:disabled="mismatch || !selectedCount"
|
||||
@click="emit('cluster')"
|
||||
>
|
||||
▱
|
||||
</button>
|
||||
<button
|
||||
title="クラスターを解除"
|
||||
aria-label="クラスターを解除"
|
||||
:disabled="mismatch"
|
||||
@click="emit('ungroup')"
|
||||
>
|
||||
⊟
|
||||
</button>
|
||||
<button
|
||||
title="選択からバリアントグループ"
|
||||
aria-label="選択からバリアントグループ"
|
||||
:disabled="mismatch || !selectedCount"
|
||||
@click="emit('variantGroup')"
|
||||
>
|
||||
◇
|
||||
</button>
|
||||
<select
|
||||
aria-label="移し先…"
|
||||
:disabled="mismatch || !selectedCount"
|
||||
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">移し先…</option>
|
||||
<option v-for="target in moveTargets" :key="target.id" :value="target.id">{{ target.label }}</option>
|
||||
</select>
|
||||
<button title="複製" aria-label="複製" :disabled="mismatch || !selectedCount" @click="emit('duplicate')">
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
title="削除"
|
||||
aria-label="削除"
|
||||
:disabled="mismatch || !selectedCount"
|
||||
@click="emit('delete', $event)"
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
<button @click="emit('grid')">Grid {{ grid }}U</button>
|
||||
<button title="全体表示" aria-label="全体表示" @click="emit('fit')">⛶</button>
|
||||
<button title="選択範囲にズーム" aria-label="選択範囲にズーム" @click="emit('fitSelected')">⌕</button>
|
||||
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
|
||||
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
|
||||
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
|
||||
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
|
||||
<span v-if="focus">
|
||||
<template v-for="(crumb, i) in crumbs" :key="i">
|
||||
<button @click="emit('focus', crumb.id)">{{ i ? " › " : "" }}{{ crumb.label }}</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.tools button,
|
||||
.tools select {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
+99
-39
@@ -1,54 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node, Variant } from "../../domain/phy";
|
||||
type Item = Node | Variant;
|
||||
defineProps<{ nodes: Item[]; selected: string[]; expanded?: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: string, additive: boolean] }>();
|
||||
function kind(n: Item) {
|
||||
switch (n.type) {
|
||||
case "variantGroup":
|
||||
return "VariantGroup";
|
||||
case "cluster":
|
||||
return "Cluster";
|
||||
case "component":
|
||||
return "部品";
|
||||
case "key":
|
||||
return "キー";
|
||||
case "variant":
|
||||
return "Variant";
|
||||
}
|
||||
}
|
||||
function title(n: Item) {
|
||||
switch (n.type) {
|
||||
case "key":
|
||||
return n.labels?.filter(Boolean)[0] || "無題キー";
|
||||
case "component":
|
||||
return n.partGroup || "部品";
|
||||
case "variantGroup":
|
||||
case "cluster":
|
||||
case "variant":
|
||||
return n.name || n.type;
|
||||
}
|
||||
}
|
||||
function children(n: Item): Item[] {
|
||||
return n.type === "cluster" || n.type === "variantGroup" || n.type === "variant" ? n.children : [];
|
||||
import { computed } from "vue";
|
||||
import type { Node } from "../../domain/phy";
|
||||
defineOptions({ name: "PhyTree" });
|
||||
const props = defineProps<{ nodes: Node[]; selected: string[] }>();
|
||||
const emit = defineEmits<{ select: [id: string, add: boolean] }>();
|
||||
const structures = computed(() =>
|
||||
props.nodes.filter((n) => n.type === "cluster" || n.type === "variantGroup"),
|
||||
);
|
||||
const leaves = computed(() => props.nodes.filter((n) => n.type === "key" || n.type === "component"));
|
||||
function countLeaves(nodes: Node[]): number {
|
||||
return nodes.reduce(
|
||||
(count, n) =>
|
||||
count +
|
||||
(n.type === "key" || n.type === "component"
|
||||
? 1
|
||||
: n.type === "cluster"
|
||||
? countLeaves(n.children)
|
||||
: n.type === "variantGroup"
|
||||
? n.children.reduce((sum, v) => sum + countLeaves(v.children), 0)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ul class="tree-list">
|
||||
<li v-for="n in nodes" :key="n.id">
|
||||
<li v-for="n in structures" :key="n.id">
|
||||
<button
|
||||
class="tree-node"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
:data-tree-id="n.id"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
@click="emit('select', n.id, $event.shiftKey)"
|
||||
>
|
||||
{{ kind(n) }} · {{ title(n) }} <small>{{ n.id }}</small></button
|
||||
><PhyTree
|
||||
v-if="children(n).length"
|
||||
:nodes="children(n)"
|
||||
{{ n.type === "cluster" ? "クラスター" : n.name }}
|
||||
<small>{{ n.type === "cluster" ? `${countLeaves(n.children)}件` : `${n.children.length}件` }}</small>
|
||||
</button>
|
||||
<PhyTree
|
||||
v-if="n.type === 'cluster'"
|
||||
:nodes="n.children"
|
||||
:selected="selected"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@select="(id, add) => emit('select', id, add)"
|
||||
/>
|
||||
<div v-else class="variants">
|
||||
<div v-for="v in n.children" :key="v.id">
|
||||
<button
|
||||
class="tree-node variant"
|
||||
:data-tree-id="n.id"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
@click="emit('select', n.id, $event.shiftKey)"
|
||||
>
|
||||
{{ v.name }}
|
||||
<small>{{ n.default === v.id ? "既定 · " : "" }}{{ countLeaves(v.children) }}件</small>
|
||||
</button>
|
||||
<details>
|
||||
<summary>キー・部品 {{ countLeaves(v.children) }}件</summary>
|
||||
<PhyTree
|
||||
:nodes="v.children"
|
||||
:selected="selected"
|
||||
@select="(id, add) => emit('select', id, add)"
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="leaves.length">
|
||||
<details>
|
||||
<summary>キー・部品 {{ leaves.length }}件</summary>
|
||||
<ul class="tree-list">
|
||||
<li v-for="n in leaves" :key="n.id">
|
||||
<button
|
||||
class="tree-node leaf"
|
||||
:data-tree-id="n.id"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
@click="emit('select', n.id, $event.shiftKey)"
|
||||
>
|
||||
{{ n.type === "key" ? "キー" : "部品" }}
|
||||
<small>{{ n.type === "key" ? n.labels?.find(Boolean) : n.partGroup }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<style scoped>
|
||||
.tree-list {
|
||||
list-style: none;
|
||||
padding-left: 12px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.tree-node {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.leaf {
|
||||
font-size: 12px;
|
||||
}
|
||||
.chosen {
|
||||
background: #dce9ff;
|
||||
}
|
||||
summary {
|
||||
font-size: 11px;
|
||||
color: #667;
|
||||
cursor: pointer;
|
||||
}
|
||||
.variants {
|
||||
padding-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export type Point = { x: number; y: number };
|
||||
export function snapFromStart(_start: number, delta: number, step = 0.25): number {
|
||||
return Math.round(delta / step) * step;
|
||||
}
|
||||
export function parentPoint(parent: SVGGraphicsElement, clientX: number, clientY: number): Point {
|
||||
const matrix = parent.getScreenCTM();
|
||||
if (!matrix) throw new Error("SVG parent has no screen transform");
|
||||
const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
export function snappedDelta(start: Point, current: Point): Point {
|
||||
return { x: snapFromStart(0, current.x - start.x), y: snapFromStart(0, current.y - start.y) };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { Node, PhyFile, Transform } from "../../domain/phy";
|
||||
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
|
||||
|
||||
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
|
||||
const parent = new Map<string, string | null>(),
|
||||
nodes = new Map<string, Node>();
|
||||
const walk = (items: Node[], p: string | null) =>
|
||||
items.forEach((n) => {
|
||||
parent.set(n.id, p);
|
||||
nodes.set(n.id, n);
|
||||
if (n.type === "cluster") walk(n.children, n.id);
|
||||
if (n.type === "variantGroup")
|
||||
n.children.forEach((v) => {
|
||||
parent.set(v.id, n.id);
|
||||
walk(v.children, v.id);
|
||||
});
|
||||
});
|
||||
walk(file.root, null);
|
||||
const out: string[] = [];
|
||||
for (const id of ids) {
|
||||
if (!nodes.has(id) || out.includes(id)) continue;
|
||||
const ancestors = new Set<string>();
|
||||
let p = parent.get(id) ?? null;
|
||||
while (p) {
|
||||
ancestors.add(p);
|
||||
p = parent.get(p) ?? null;
|
||||
}
|
||||
const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id));
|
||||
out.splice(0, out.length, ...keep, id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function isDescendant(parent: Map<string, string | null>, candidate: string, ancestor: string): boolean {
|
||||
let p = parent.get(candidate) ?? null;
|
||||
while (p) {
|
||||
if (p === ancestor) return true;
|
||||
p = parent.get(p) ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] {
|
||||
const list = [keyId, ...ancestorsNearestFirst];
|
||||
if (focusId) {
|
||||
const i = list.indexOf(focusId);
|
||||
if (i >= 0) list.splice(i);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
export function tabOrder<T extends { id: string; x: number; y: number }>(items: T[]): T[] {
|
||||
const sorted = [...items].sort((a, b) => a.y - b.y),
|
||||
rows: T[][] = [];
|
||||
for (const item of sorted) {
|
||||
const row = rows.at(-1);
|
||||
if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]);
|
||||
else row.push(item);
|
||||
}
|
||||
return rows.flatMap((row) => row.sort((a, b) => a.x - b.x));
|
||||
}
|
||||
|
||||
/** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */
|
||||
export function treeIndex(roots: Node[]) {
|
||||
const nodes = new Map<string, Node>();
|
||||
const parents = new Map<string, string | null>();
|
||||
const containers = new Map<string, string | null>();
|
||||
const variants = new Map<string, string>();
|
||||
const visit = (items: Node[], parent: string | null, container: string | null) => {
|
||||
for (const n of items) {
|
||||
nodes.set(n.id, n);
|
||||
parents.set(n.id, parent);
|
||||
containers.set(n.id, container);
|
||||
if (n.type === "cluster") visit(n.children, n.id, n.id);
|
||||
if (n.type === "variantGroup") {
|
||||
for (const v of n.children) {
|
||||
variants.set(v.id, n.id);
|
||||
visit(v.children, n.id, v.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(roots, null, null);
|
||||
return {
|
||||
nodes,
|
||||
parents,
|
||||
containers,
|
||||
variants,
|
||||
selectableParent(id: string) {
|
||||
return parents.get(id) ?? null;
|
||||
},
|
||||
insertionContainer(id: string) {
|
||||
return containers.get(id) ?? null;
|
||||
},
|
||||
groupForVariant(id: string) {
|
||||
return variants.get(id) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function treeNodes(roots: Node[]): Node[] {
|
||||
return [...treeIndex(roots).nodes.values()];
|
||||
}
|
||||
|
||||
export function structuralNodes(roots: Node[]): Extract<Node, { type: "cluster" | "variantGroup" }>[] {
|
||||
return treeNodes(roots).filter(
|
||||
(node): node is Extract<Node, { type: "cluster" | "variantGroup" }> =>
|
||||
node.type === "cluster" || node.type === "variantGroup",
|
||||
);
|
||||
}
|
||||
|
||||
export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] {
|
||||
const result: Node[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.id === focusedId) continue;
|
||||
if (node.type === "cluster") {
|
||||
const children = removeFocusedSubtree(node.children, focusedId);
|
||||
result.push({ ...node, children });
|
||||
} else if (node.type === "variantGroup") {
|
||||
result.push({
|
||||
...node,
|
||||
children: node.children.map((variant) => ({
|
||||
...variant,
|
||||
children: removeFocusedSubtree(variant.children, focusedId),
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function visibleNodeIds(root: Node | undefined, shownVariants: Record<string, string>): Set<string> {
|
||||
const result = new Set<string>();
|
||||
const visit = (node: Node) => {
|
||||
result.add(node.id);
|
||||
if (node.type === "cluster") node.children.forEach(visit);
|
||||
if (node.type === "variantGroup") {
|
||||
const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default));
|
||||
variant?.children.forEach(visit);
|
||||
}
|
||||
};
|
||||
if (root) visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function selectableParent(roots: Node[], id: string): string | null {
|
||||
return treeIndex(roots).selectableParent(id);
|
||||
}
|
||||
|
||||
export function insertionContainer(
|
||||
roots: Node[],
|
||||
parentId: string | null,
|
||||
shownVariants: Record<string, string> = {},
|
||||
): string | null {
|
||||
if (parentId === null) return null;
|
||||
const index = treeIndex(roots);
|
||||
const node = index.nodes.get(parentId);
|
||||
if (node?.type === "cluster") return node.id;
|
||||
if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default;
|
||||
return index.variants.get(parentId) === undefined ? null : parentId;
|
||||
}
|
||||
export function snap(value: number, step: number): number {
|
||||
return Math.round(value / step) * step;
|
||||
}
|
||||
export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const;
|
||||
export function nextGrid(step: number, direction: 1 | -1): number {
|
||||
const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9);
|
||||
return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!;
|
||||
}
|
||||
export function resolveKey(
|
||||
e: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
|
||||
): string | null {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (e.code === "Digit1" && e.shiftKey) return "fit";
|
||||
if (e.code === "Digit2" && e.shiftKey) return "selection-fit";
|
||||
if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo";
|
||||
if (mod && e.key.toLowerCase() === "y") return "redo";
|
||||
if (mod && e.key.toLowerCase() === "s") return "save";
|
||||
if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group";
|
||||
if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase();
|
||||
return e.key;
|
||||
}
|
||||
export type Box = { x: number; y: number; width: number; height: number };
|
||||
export function obbAabb(points: { x: number; y: number }[]): Box {
|
||||
if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
|
||||
const xs = points.map((p) => p.x),
|
||||
ys = points.map((p) => p.y),
|
||||
x = Math.min(...xs),
|
||||
y = Math.min(...ys);
|
||||
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
||||
}
|
||||
export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null {
|
||||
return selectedParents.length ? selectedParents[0]! : focus;
|
||||
}
|
||||
/** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */
|
||||
export function localBounds(nodes: Node[], variants: Record<string, string> = {}): Box {
|
||||
const points: { x: number; y: number }[] = [];
|
||||
const visit = (items: Node[], parent: Affine) =>
|
||||
items.forEach((n) => {
|
||||
const w = compose(parent, matrix(n.transform));
|
||||
if (n.type === "key") {
|
||||
const corners = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: n.size.w, y: 0 },
|
||||
{ x: n.size.w, y: n.size.h },
|
||||
{ x: 0, y: n.size.h },
|
||||
];
|
||||
if (
|
||||
n.size.x2 !== undefined ||
|
||||
n.size.y2 !== undefined ||
|
||||
n.size.w2 !== undefined ||
|
||||
n.size.h2 !== undefined
|
||||
) {
|
||||
const x = n.size.x2 ?? 0,
|
||||
y = n.size.y2 ?? 0,
|
||||
ww = n.size.w2 ?? n.size.w,
|
||||
hh = n.size.h2 ?? n.size.h;
|
||||
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
|
||||
}
|
||||
points.push(...corners.map((p) => apply(w, p)));
|
||||
} else if (n.type === "component") {
|
||||
const c = apply(w, { x: 0, y: 0 });
|
||||
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
|
||||
} else if (n.type === "cluster") visit(n.children, w);
|
||||
else {
|
||||
const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default));
|
||||
if (v) visit(v.children, w);
|
||||
}
|
||||
});
|
||||
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
||||
return obbAabb(points);
|
||||
}
|
||||
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
|
||||
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
|
||||
const local = compose(inverse(parentWorld), sourceWorld);
|
||||
return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI };
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { ref, type Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { KeyNode, Node } from "../../domain/phy";
|
||||
import { apply, worldTransform } from "../../domain/geometry";
|
||||
import { moveNode } from "../../domain/phyCommands";
|
||||
import { normalizeSelection, snap } from "./logic";
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Box = Point & { width: number; height: number };
|
||||
type Options = {
|
||||
project: () => ProjectFile;
|
||||
svg: Ref<SVGSVGElement | undefined>;
|
||||
pane: Ref<HTMLElement | undefined>;
|
||||
pan: Ref<Point>;
|
||||
zoom: Ref<number>;
|
||||
grid: Ref<number>;
|
||||
selected: Ref<string[]>;
|
||||
focus: Ref<string | null>;
|
||||
rangeBox: Ref<Box | null>;
|
||||
allNodes: () => Node[];
|
||||
keys: () => { key: KeyNode }[];
|
||||
localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point;
|
||||
visibleIds: () => Set<string>;
|
||||
mismatch: () => boolean;
|
||||
isAncestor: (candidate: string, id: string) => boolean;
|
||||
parentLabel: (id: string | null) => string;
|
||||
pressTargets: (id: string) => string[];
|
||||
setSelection: (id: string, add?: boolean) => void;
|
||||
beginHistory: () => void;
|
||||
commitHistory: () => void;
|
||||
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||
};
|
||||
|
||||
export function usePhyCanvasPointer(options: Options) {
|
||||
const longLabel = ref("");
|
||||
const longLabelPos = ref({ x: 0, y: 0 });
|
||||
const spaceHeld = ref(false);
|
||||
const holdCandidates = ref<string[]>([]);
|
||||
const holdIndex = ref(0);
|
||||
let preClickSelection: string[] = [];
|
||||
let preClickTarget: string | null = null;
|
||||
let preClickAt = 0;
|
||||
let holdTimer: number | undefined;
|
||||
let holdCycle: number | undefined;
|
||||
let holdShift = false;
|
||||
let holdActivated = false;
|
||||
let panDrag: { start: Point; origin: Point } | null = null;
|
||||
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
|
||||
let dragState: {
|
||||
id: string;
|
||||
start: Point;
|
||||
base: ProjectFile;
|
||||
ids: string[];
|
||||
shift: boolean;
|
||||
parents: Map<string, SVGGElement>;
|
||||
starts: Map<string, Point>;
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
|
||||
function priorSelection() {
|
||||
return preClickSelection;
|
||||
}
|
||||
|
||||
function parentElementFor(id: string, fallback?: SVGGElement) {
|
||||
const elements = options.svg.value?.querySelectorAll(`[data-node-id="${id}"]`);
|
||||
return (
|
||||
(elements?.item((elements.length || 1) - 1)?.parentElement as SVGGElement | undefined) ||
|
||||
(fallback?.parentElement?.parentElement as SVGGElement | undefined) ||
|
||||
options.svg.value!
|
||||
);
|
||||
}
|
||||
|
||||
function beginEmptyPointer(event: PointerEvent, point?: Point) {
|
||||
const svg = options.svg.value;
|
||||
const start = point ?? (svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 });
|
||||
rangeDrag = { start, shift: event.shiftKey, moved: false };
|
||||
options.rangeBox.value = { ...start, width: 0, height: 0 };
|
||||
window.addEventListener("pointermove", move, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
svg?.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function canvasDown(event: PointerEvent) {
|
||||
const svg = options.svg.value;
|
||||
if (event.target !== svg || !svg) return;
|
||||
const point = options.localPoint(svg, event.clientX, event.clientY);
|
||||
if (event.button === 1 || spaceHeld.value) {
|
||||
panDrag = { start: point, origin: { ...options.pan.value } };
|
||||
window.addEventListener("pointermove", move, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
beginEmptyPointer(event, point);
|
||||
}
|
||||
|
||||
function updateRange(event: PointerEvent) {
|
||||
if (!rangeDrag || !options.svg.value) return;
|
||||
const point = options.localPoint(options.svg.value, event.clientX, event.clientY);
|
||||
const start = rangeDrag.start;
|
||||
rangeDrag.moved = Math.hypot(point.x - start.x, point.y - start.y) >= 4;
|
||||
options.rangeBox.value = {
|
||||
x: Math.min(start.x, point.x),
|
||||
y: Math.min(start.y, point.y),
|
||||
width: Math.abs(point.x - start.x),
|
||||
height: Math.abs(point.y - start.y),
|
||||
};
|
||||
}
|
||||
|
||||
function down(event: PointerEvent, node: Node, _element: SVGGElement) {
|
||||
const svg = options.svg.value;
|
||||
if (event.button === 1 || spaceHeld.value) {
|
||||
const point = svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 };
|
||||
panDrag = { start: point, origin: { ...options.pan.value } };
|
||||
window.addEventListener("pointermove", move, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
svg?.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.altKey) return;
|
||||
options.pane.value?.focus();
|
||||
if (options.focus.value && !options.visibleIds().has(node.id)) {
|
||||
beginEmptyPointer(event);
|
||||
return;
|
||||
}
|
||||
if (options.mismatch()) {
|
||||
options.setSelection(node.id, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (preClickTarget !== node.id || Date.now() - preClickAt > 600) {
|
||||
preClickSelection = [...options.selected.value];
|
||||
}
|
||||
preClickTarget = node.id;
|
||||
preClickAt = Date.now();
|
||||
holdActivated = false;
|
||||
dragState = {
|
||||
id: node.id,
|
||||
start: { x: event.clientX, y: event.clientY },
|
||||
base: options.project(),
|
||||
ids: [...options.selected.value],
|
||||
shift: event.shiftKey,
|
||||
parents: new Map(),
|
||||
starts: new Map(),
|
||||
moved: false,
|
||||
};
|
||||
window.addEventListener("pointermove", move, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
if (node.type === "key") {
|
||||
holdCandidates.value = options.pressTargets(node.id);
|
||||
holdIndex.value = 0;
|
||||
holdShift = event.shiftKey;
|
||||
holdTimer = window.setTimeout(() => {
|
||||
if (holdCandidates.value.length <= 1) return;
|
||||
holdActivated = true;
|
||||
holdIndex.value = 1;
|
||||
longLabelPos.value = { x: event.clientX, y: event.clientY };
|
||||
longLabel.value = `${options.parentLabel(holdCandidates.value[1]!)} 2/${holdCandidates.value.length}`;
|
||||
holdCycle = window.setInterval(() => {
|
||||
holdIndex.value = (holdIndex.value + 1) % holdCandidates.value.length;
|
||||
const id = holdCandidates.value[holdIndex.value]!;
|
||||
const label = holdIndex.value === 0 ? "キー" : options.parentLabel(id);
|
||||
longLabel.value = `${label} ${holdIndex.value + 1}/${holdCandidates.value.length}`;
|
||||
}, 450);
|
||||
}, 500);
|
||||
}
|
||||
(event.currentTarget as Element).setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function move(event: PointerEvent) {
|
||||
const svg = options.svg.value;
|
||||
if (!svg) return;
|
||||
const point = options.localPoint(svg, event.clientX, event.clientY);
|
||||
if (panDrag) {
|
||||
options.pan.value = {
|
||||
x: panDrag.origin.x + point.x - panDrag.start.x,
|
||||
y: panDrag.origin.y + point.y - panDrag.start.y,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (rangeDrag) {
|
||||
updateRange(event);
|
||||
return;
|
||||
}
|
||||
if (!dragState) return;
|
||||
const state = dragState;
|
||||
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
||||
if (holdTimer && !longLabel.value) {
|
||||
window.clearTimeout(holdTimer);
|
||||
holdTimer = undefined;
|
||||
}
|
||||
if (longLabel.value) {
|
||||
const id = holdCandidates.value[holdIndex.value]!;
|
||||
options.selected.value = normalizeSelection(
|
||||
options.project().phy,
|
||||
holdShift ? [...preClickSelection, id] : [id],
|
||||
);
|
||||
state.ids = [id];
|
||||
state.base = options.project();
|
||||
state.parents = new Map([[id, parentElementFor(id)]]);
|
||||
state.starts = new Map([
|
||||
[id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
|
||||
]);
|
||||
options.beginHistory();
|
||||
if (holdCycle) window.clearInterval(holdCycle);
|
||||
longLabel.value = "";
|
||||
} else if (!state.moved) {
|
||||
const includesAncestor = preClickSelection.some(
|
||||
(id) => id === state.id || options.isAncestor(id, state.id),
|
||||
);
|
||||
const ids = includesAncestor ? preClickSelection : [state.id];
|
||||
if (!includesAncestor) options.selected.value = [state.id];
|
||||
state.ids = ids;
|
||||
state.base = options.project();
|
||||
state.parents = new Map(ids.map((id) => [id, parentElementFor(id)]));
|
||||
state.starts = new Map(
|
||||
[...state.parents].map(([id, parent]) => [
|
||||
id,
|
||||
options.localPoint(parent, state.start.x, state.start.y),
|
||||
]),
|
||||
);
|
||||
options.beginHistory();
|
||||
}
|
||||
state.moved = true;
|
||||
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, 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() {
|
||||
window.removeEventListener("pointermove", move, true);
|
||||
window.removeEventListener("pointerup", up, true);
|
||||
if (panDrag) {
|
||||
panDrag = null;
|
||||
return;
|
||||
}
|
||||
if (rangeDrag) {
|
||||
if (rangeDrag.moved && options.rangeBox.value) {
|
||||
const box = options.rangeBox.value;
|
||||
const worldBox = {
|
||||
x: (box.x - options.pan.value.x) / options.zoom.value,
|
||||
y: (box.y - options.pan.value.y) / options.zoom.value,
|
||||
width: box.width / options.zoom.value,
|
||||
height: box.height / options.zoom.value,
|
||||
};
|
||||
const scope = options.visibleIds();
|
||||
const hits: string[] = [];
|
||||
for (const item of options.keys()) {
|
||||
if (!scope.has(item.key.id)) continue;
|
||||
const transform = worldTransform(options.project().phy, item.key.id);
|
||||
const corners = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: item.key.size.w, y: 0 },
|
||||
{ x: item.key.size.w, y: item.key.size.h },
|
||||
{ x: 0, y: item.key.size.h },
|
||||
].map((point) => apply(transform, point));
|
||||
const xs = corners.map((point) => point.x);
|
||||
const ys = corners.map((point) => point.y);
|
||||
if (
|
||||
intersects(worldBox, {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
})
|
||||
) {
|
||||
hits.push(item.key.id);
|
||||
}
|
||||
}
|
||||
for (const node of options
|
||||
.allNodes()
|
||||
.filter((item) => item.type === "component" && scope.has(item.id))) {
|
||||
const transform = worldTransform(options.project().phy, node.id);
|
||||
if (
|
||||
intersects(worldBox, { x: transform.e - 0.16, y: transform.f - 0.16, width: 0.32, height: 0.32 })
|
||||
) {
|
||||
hits.push(node.id);
|
||||
}
|
||||
}
|
||||
options.selected.value = normalizeSelection(
|
||||
options.project().phy,
|
||||
rangeDrag.shift ? [...options.selected.value, ...hits] : hits,
|
||||
);
|
||||
} else {
|
||||
options.selected.value = [];
|
||||
}
|
||||
rangeDrag = null;
|
||||
options.rangeBox.value = null;
|
||||
return;
|
||||
}
|
||||
if (holdTimer) window.clearTimeout(holdTimer);
|
||||
if (holdCycle) window.clearInterval(holdCycle);
|
||||
if (longLabel.value && holdCandidates.value[holdIndex.value]) {
|
||||
const id = holdCandidates.value[holdIndex.value]!;
|
||||
options.selected.value = normalizeSelection(
|
||||
options.project().phy,
|
||||
holdShift ? [...options.selected.value, id] : [id],
|
||||
);
|
||||
}
|
||||
longLabel.value = "";
|
||||
const moved = dragState?.moved ?? false;
|
||||
if (dragState?.moved) options.commitHistory();
|
||||
else if (dragState && !holdActivated) {
|
||||
const id = dragState.id;
|
||||
const next = dragState.shift
|
||||
? options.selected.value.includes(id)
|
||||
? options.selected.value.filter((selectedId) => selectedId !== id)
|
||||
: [...options.selected.value, id]
|
||||
: [id];
|
||||
options.selected.value = normalizeSelection(options.project().phy, next);
|
||||
}
|
||||
if (holdActivated || moved) preClickTarget = null;
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
function spaceUp(event: KeyboardEvent) {
|
||||
if (event.code === "Space") spaceHeld.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
canvasDown,
|
||||
down,
|
||||
move,
|
||||
up,
|
||||
spaceUp,
|
||||
longLabel,
|
||||
longLabelPos,
|
||||
spaceHeld,
|
||||
holdCandidates,
|
||||
holdIndex,
|
||||
priorSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function intersects(a: Box, b: Box) {
|
||||
return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { computed, type Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||
import {
|
||||
apply,
|
||||
compose,
|
||||
flattenKeys,
|
||||
inverse,
|
||||
matrix,
|
||||
worldTransform,
|
||||
type Affine,
|
||||
} from "../../domain/geometry";
|
||||
import { removeFocusedSubtree, structuralNodes } from "./logic";
|
||||
|
||||
type Options = {
|
||||
project: () => ProjectFile;
|
||||
size: Ref<{ w: number; h: number }>;
|
||||
pan: Ref<{ x: number; y: number }>;
|
||||
zoom: Ref<number>;
|
||||
focus: Ref<string | null>;
|
||||
variants: Ref<Record<string, string>>;
|
||||
grid: Ref<number>;
|
||||
selected: Ref<string[]>;
|
||||
node: (id: string) => Node | undefined;
|
||||
parentId: (id: string) => string | null;
|
||||
parentLabel: (id: string | null) => string;
|
||||
selectedContainer: (id: string) => string | null;
|
||||
};
|
||||
|
||||
export function usePhyCanvasView(options: Options) {
|
||||
const keys = computed(() => flattenKeys(options.project().phy, options.variants.value));
|
||||
const groupsAll = computed(() => structuralNodes(options.project().phy.root));
|
||||
const outsideNodes = computed(() =>
|
||||
options.focus.value ? removeFocusedSubtree(options.project().phy.root, options.focus.value) : [],
|
||||
);
|
||||
const gridLines = computed(() => {
|
||||
const step = options.grid.value;
|
||||
const xMin = Math.floor(-options.pan.value.x / options.zoom.value / step);
|
||||
const xMax = Math.ceil((options.size.value.w - options.pan.value.x) / options.zoom.value / step);
|
||||
const yMin = Math.floor(-options.pan.value.y / options.zoom.value / step);
|
||||
const yMax = Math.ceil((options.size.value.h - options.pan.value.y) / options.zoom.value / step);
|
||||
const lines: { d: string; major: boolean }[] = [];
|
||||
for (let index = xMin; index <= xMax; index++) {
|
||||
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||
}
|
||||
for (let index = yMin; index <= yMax; index++) {
|
||||
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
const variantGroups = computed(() =>
|
||||
groupsAll.value.filter((node): node is VariantGroupNode => node.type === "variantGroup"),
|
||||
);
|
||||
const focusGridLines = computed(() => {
|
||||
const focusId = options.focus.value;
|
||||
if (!focusId || !options.node(focusId)) return [];
|
||||
const screen = compose(
|
||||
{
|
||||
a: options.zoom.value,
|
||||
b: 0,
|
||||
c: 0,
|
||||
d: options.zoom.value,
|
||||
e: options.pan.value.x,
|
||||
f: options.pan.value.y,
|
||||
},
|
||||
worldTransform(options.project().phy, focusId),
|
||||
);
|
||||
const toLocal = inverse(screen);
|
||||
const corners = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: options.size.value.w, y: 0 },
|
||||
{ x: options.size.value.w, y: options.size.value.h },
|
||||
{ x: 0, y: options.size.value.h },
|
||||
].map((point) => apply(toLocal, point));
|
||||
const xs = corners.map((point) => point.x);
|
||||
const ys = corners.map((point) => point.y);
|
||||
const step = options.grid.value;
|
||||
const lines: { d: string; major: boolean }[] = [];
|
||||
for (
|
||||
let index = Math.floor(Math.min(...xs) / step);
|
||||
index <= Math.ceil(Math.max(...xs) / step);
|
||||
index++
|
||||
) {
|
||||
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||
}
|
||||
for (
|
||||
let index = Math.floor(Math.min(...ys) / step);
|
||||
index <= Math.ceil(Math.max(...ys) / step);
|
||||
index++
|
||||
) {
|
||||
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
const moveTargets = computed(() => {
|
||||
const excluded = new Set<string>();
|
||||
const add = (node: Node) => {
|
||||
excluded.add(node.id);
|
||||
if (node.type === "cluster") node.children.forEach(add);
|
||||
if (node.type === "variantGroup") {
|
||||
node.children.forEach((variant) => {
|
||||
excluded.add(variant.id);
|
||||
variant.children.forEach(add);
|
||||
});
|
||||
}
|
||||
};
|
||||
options.selected.value.forEach((id) => {
|
||||
const node = options.node(id);
|
||||
if (node) add(node);
|
||||
});
|
||||
return [
|
||||
{ id: "__root__", label: "root" },
|
||||
...groupsAll.value
|
||||
.filter((node) => node.type === "cluster" && !excluded.has(node.id))
|
||||
.map((node) => ({ id: node.id, label: node.name || "クラスター" })),
|
||||
...variantGroups.value.flatMap((group) =>
|
||||
group.children
|
||||
.filter((variant) => !excluded.has(variant.id))
|
||||
.map((variant) => ({ id: variant.id, label: `${group.name} / ${variant.name}` })),
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
function parentMatrix(id: string): Affine {
|
||||
const node = options.node(id);
|
||||
return node
|
||||
? compose(worldTransform(options.project().phy, id), inverse(matrix(node.transform)))
|
||||
: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||
}
|
||||
|
||||
function focusAngle(id: string) {
|
||||
const transform = parentMatrix(id);
|
||||
return (Math.atan2(transform.b, transform.a) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function affineText(transform: Affine) {
|
||||
return `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`;
|
||||
}
|
||||
|
||||
function focusCrumbs(id: string): { id: string | null; label: string }[] {
|
||||
const ids = [id];
|
||||
let parent = options.parentId(id);
|
||||
while (parent) {
|
||||
ids.unshift(parent);
|
||||
parent = options.parentId(parent);
|
||||
}
|
||||
return [
|
||||
{ id: null, label: "root" },
|
||||
...ids.map((item) => ({
|
||||
id:
|
||||
groupsAll.value.find(
|
||||
(node) => node.type === "variantGroup" && node.children.some((variant) => variant.id === item),
|
||||
)?.id || item,
|
||||
label: options.parentLabel(item),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const propertyNode = computed(() =>
|
||||
options.selected.value.length === 1 ? options.node(options.selected.value[0]!) : null,
|
||||
);
|
||||
|
||||
return {
|
||||
keys,
|
||||
groupsAll,
|
||||
outsideNodes,
|
||||
gridLines,
|
||||
variantGroups,
|
||||
focusGridLines,
|
||||
moveTargets,
|
||||
parentMatrix,
|
||||
focusAngle,
|
||||
affineText,
|
||||
focusCrumbs,
|
||||
propertyNode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { KeyNode, Node } from "../../domain/phy";
|
||||
import { cloneNodesWithNewIds, insertNodes, reparent, reparentToVariant } from "../../domain/phyCommands";
|
||||
import { worldTransform, type Affine } from "../../domain/geometry";
|
||||
import { importKle } from "../../io/kleImport";
|
||||
import { treeIndex, pasteLocalTransform } from "./logic";
|
||||
|
||||
type ClipboardNode = { node: Node; world: Affine };
|
||||
type Options = {
|
||||
project: () => ProjectFile;
|
||||
mismatch: () => boolean;
|
||||
selected: Ref<string[]>;
|
||||
cut: Ref<string[]>;
|
||||
grid: Ref<number>;
|
||||
node: (id: string) => Node | undefined;
|
||||
selectedContainer: (id: string) => string | null;
|
||||
insertionParent: () => string | null;
|
||||
parentMatrix: (project: ProjectFile, parent: string | null) => Affine;
|
||||
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||
setSelected: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export function usePhyClipboard(options: Options) {
|
||||
let clipboard: ClipboardNode[] = [];
|
||||
let marker = "";
|
||||
|
||||
function copyEvent(event: ClipboardEvent) {
|
||||
if (!options.selected.value.length || !event.clipboardData) return;
|
||||
const project = options.project();
|
||||
clipboard = options.selected.value.map((id) => ({
|
||||
node: structuredClone(options.node(id)!),
|
||||
world: worldTransform(project.phy, id),
|
||||
}));
|
||||
marker = `beyond-kle-clipboard:${crypto.randomUUID()}`;
|
||||
event.clipboardData.setData("text/plain", marker);
|
||||
if (event.type === "cut") options.cut.value = [...options.selected.value];
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function pasteEvent(event: ClipboardEvent) {
|
||||
if (options.mismatch()) return;
|
||||
const text = event.clipboardData?.getData("text/plain") || "";
|
||||
if (text === marker && clipboard.length) {
|
||||
event.preventDefault();
|
||||
const parent = options.selected.value.length
|
||||
? options.selectedContainer(options.selected.value[0]!)
|
||||
: options.insertionParent();
|
||||
if (options.cut.value.length) {
|
||||
const ids = [...options.cut.value];
|
||||
options.dispatch("カット貼り付け", (project) => ({
|
||||
...ids.reduce((next, id) => {
|
||||
const isVariant = parent !== null && treeIndex(next.phy.root).groupForVariant(parent) !== null;
|
||||
return {
|
||||
...next,
|
||||
phy: isVariant ? reparentToVariant(next.phy, id, parent!) : reparent(next.phy, id, parent),
|
||||
};
|
||||
}, project),
|
||||
}));
|
||||
options.setSelected(ids);
|
||||
options.cut.value = [];
|
||||
} else {
|
||||
let ids: string[] = [];
|
||||
options.dispatch("貼り付け", (project) => {
|
||||
const copies = cloneNodesWithNewIds(clipboard.map((item) => item.node));
|
||||
const parentWorld = options.parentMatrix(project, parent);
|
||||
copies.forEach((node, index) => {
|
||||
node.transform = pasteLocalTransform(clipboard[index]!.world, parentWorld, options.grid.value);
|
||||
ids.push(node.id);
|
||||
});
|
||||
return { ...project, phy: insertNodes(project.phy, parent, copies) };
|
||||
});
|
||||
options.setSelected(ids);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { phy, keyOrder } = importKle(JSON.parse(text));
|
||||
if (!event.clipboardData) return;
|
||||
event.preventDefault();
|
||||
let ids: string[] = [];
|
||||
options.dispatch("KLE貼り付け", (project) => {
|
||||
const nodes = phy.root
|
||||
.filter((node): node is KeyNode => node.type === "key")
|
||||
.map((key) => ({ ...key, profile: project.phy.profiles[0]?.id || key.profile }));
|
||||
ids = nodes.map((node) => node.id);
|
||||
const parent = options.selected.value.length
|
||||
? options.selectedContainer(options.selected.value[0]!)
|
||||
: options.insertionParent();
|
||||
return {
|
||||
...project,
|
||||
phy: insertNodes(project.phy, parent, nodes),
|
||||
idx: { ...project.idx, order: [...project.idx.order, ...keyOrder] },
|
||||
};
|
||||
});
|
||||
options.setSelected(ids);
|
||||
} catch {
|
||||
// 外部クリップボードがKLE JSONでなければ無視する。
|
||||
}
|
||||
}
|
||||
|
||||
return { copyEvent, pasteEvent };
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy";
|
||||
import {
|
||||
addComponentToParent,
|
||||
addVariant as addVariantCommand,
|
||||
addKeyToParent,
|
||||
clusterNodes,
|
||||
cloneNodesWithNewIds,
|
||||
createVariantGroup,
|
||||
insertNodes,
|
||||
reparent,
|
||||
reparentToVariant,
|
||||
setDefaultVariant,
|
||||
uncluster,
|
||||
} from "../../domain/phyCommands";
|
||||
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
|
||||
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
|
||||
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
|
||||
|
||||
type Options = {
|
||||
project: () => ProjectFile;
|
||||
selected: Ref<string[]>;
|
||||
variants: Ref<Record<string, string>>;
|
||||
focus: Ref<string | null>;
|
||||
grid: Ref<number>;
|
||||
zoom: Ref<number>;
|
||||
pan: Ref<{ x: number; y: number }>;
|
||||
size: Ref<{ w: number; h: number }>;
|
||||
svg: Ref<SVGSVGElement | undefined>;
|
||||
pane: Ref<HTMLElement | undefined>;
|
||||
popover: Ref<string | null>;
|
||||
confirm: Ref<string | null>;
|
||||
confirmIds: Ref<string[]>;
|
||||
confirmRefs: Ref<ReturnType<typeof listDeleteReferences>>;
|
||||
confirmPos: Ref<{ left: number; top: number }>;
|
||||
node: (id: string) => Node | undefined;
|
||||
allNodes: (roots?: Node[]) => Node[];
|
||||
parentId: (id: string) => string | null;
|
||||
selectedContainer: (id: string) => string | null;
|
||||
groupForVariant: (id: string) => string | null;
|
||||
variantGroups: () => VariantGroupNode[];
|
||||
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||
emitError: (message: string) => void;
|
||||
};
|
||||
|
||||
export function usePhyCommands(options: Options) {
|
||||
function parentMatrix(project: ProjectFile, parent: string | null): Affine {
|
||||
if (!parent) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||
if (treeIndex(project.phy.root).nodes.get(parent)?.type === "cluster")
|
||||
return worldTransform(project.phy, parent);
|
||||
const groupId = treeIndex(project.phy.root).groupForVariant(parent);
|
||||
if (groupId) return worldTransform(project.phy, groupId);
|
||||
throw new Error(`Unknown parent: ${parent}`);
|
||||
}
|
||||
|
||||
function nodesInContainer(container: string | null): Node[] {
|
||||
if (container === null) return options.project().phy.root;
|
||||
const node = options.node(container);
|
||||
if (node?.type === "cluster") return node.children;
|
||||
if (node?.type === "variantGroup") {
|
||||
const variant = node.children.find(
|
||||
(item) => item.id === (options.variants.value[node.id] || node.default),
|
||||
);
|
||||
return variant?.children ?? [];
|
||||
}
|
||||
const groupId = options.groupForVariant(container);
|
||||
const group = groupId ? options.node(groupId) : null;
|
||||
if (group?.type === "variantGroup")
|
||||
return group.children.find((variant) => variant.id === container)?.children ?? [];
|
||||
return [];
|
||||
}
|
||||
|
||||
function directChildren(id: string): Node[] {
|
||||
const node = options.node(id);
|
||||
if (node?.type === "cluster") return node.children;
|
||||
if (node?.type === "variantGroup") {
|
||||
const variant = node.children.find(
|
||||
(item) => item.id === (options.variants.value[node.id] || node.default),
|
||||
);
|
||||
return variant?.children ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function tabItem(node: Node) {
|
||||
if (node.type === "key") {
|
||||
const center = apply(matrix(node.transform), { x: node.size.w / 2, y: node.size.h / 2 });
|
||||
return { id: node.id, x: center.x, y: center.y };
|
||||
}
|
||||
if (node.type === "component") return { id: node.id, x: node.transform.x, y: node.transform.y };
|
||||
const bounds =
|
||||
node.type === "cluster"
|
||||
? localBounds(node.children, options.variants.value)
|
||||
: localBounds(directChildren(node.id), options.variants.value);
|
||||
const center = apply(matrix(node.transform), {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height / 2,
|
||||
});
|
||||
return { id: node.id, x: center.x, y: center.y };
|
||||
}
|
||||
|
||||
function centerInParent(parent: string | null) {
|
||||
const world = {
|
||||
x: (options.size.value.w / 2 - options.pan.value.x) / options.zoom.value,
|
||||
y: (options.size.value.h / 2 - options.pan.value.y) / options.zoom.value,
|
||||
};
|
||||
return apply(inverse(parentMatrix(options.project(), parent)), world);
|
||||
}
|
||||
|
||||
function chooseVariant(group: Node, id: string) {
|
||||
if (group.type !== "variantGroup") return;
|
||||
options.variants.value = { ...options.variants.value, [group.id]: id };
|
||||
const valid = new Set(
|
||||
group.children.find((variant) => variant.id === id)?.children.map((item) => item.id) || [],
|
||||
);
|
||||
if (options.selected.value.some((selectedId) => !valid.has(selectedId)))
|
||||
options.selected.value = [group.id];
|
||||
options.popover.value = null;
|
||||
}
|
||||
|
||||
function keyAdd() {
|
||||
const chosen = options.node(options.selected.value.at(-1) || "");
|
||||
const parent = chosen ? options.selectedContainer(chosen.id) : insertionParent();
|
||||
const center = centerInParent(parent);
|
||||
const x =
|
||||
Math.round(
|
||||
(chosen ? chosen.transform.x + (chosen.type === "key" ? chosen.size.w : 1) : center.x) /
|
||||
options.grid.value,
|
||||
) * options.grid.value;
|
||||
const y = Math.round((chosen ? chosen.transform.y : center.y) / options.grid.value) * options.grid.value;
|
||||
let id = "";
|
||||
options.dispatch("キー追加", (project) => {
|
||||
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
|
||||
id = options.allNodes(phy.root).at(-1)?.id || "";
|
||||
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
|
||||
});
|
||||
if (id) options.selected.value = [id];
|
||||
}
|
||||
|
||||
function insertionParent() {
|
||||
const focusId = options.focus.value;
|
||||
if (!focusId) return null;
|
||||
const focused = options.node(focusId);
|
||||
if (focused?.type === "cluster") return focused.id;
|
||||
if (focused?.type === "variantGroup") return options.variants.value[focused.id] ?? focused.default;
|
||||
return null;
|
||||
}
|
||||
|
||||
function partAdd(group: string) {
|
||||
const selected = options.node(options.selected.value.at(-1) || "");
|
||||
const parent = selected ? options.selectedContainer(selected.id) : insertionParent();
|
||||
const center = centerInParent(parent);
|
||||
const x =
|
||||
Math.round(
|
||||
(selected ? selected.transform.x + (selected.type === "key" ? selected.size.w : 1) : center.x) /
|
||||
options.grid.value,
|
||||
) * options.grid.value;
|
||||
const y =
|
||||
Math.round((selected ? selected.transform.y : center.y) / options.grid.value) * options.grid.value;
|
||||
let id = "";
|
||||
options.dispatch("部品追加", (project) => {
|
||||
const phy = addComponentToParent(
|
||||
project.phy,
|
||||
parent,
|
||||
{ x, y, rotation: 0 },
|
||||
group === "その他" ? undefined : group,
|
||||
);
|
||||
id = options.allNodes(phy.root).at(-1)?.id || "";
|
||||
return { ...project, phy };
|
||||
});
|
||||
if (id) options.selected.value = [id];
|
||||
}
|
||||
|
||||
function makeCluster() {
|
||||
try {
|
||||
let id = "";
|
||||
options.dispatch("クラスター化", (project) => {
|
||||
const phy = clusterNodes(project.phy, options.selected.value);
|
||||
id =
|
||||
options
|
||||
.allNodes(phy.root)
|
||||
.find(
|
||||
(node) =>
|
||||
node.type === "cluster" &&
|
||||
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
|
||||
)?.id || "";
|
||||
return { ...project, phy };
|
||||
});
|
||||
if (id) options.selected.value = [id];
|
||||
} catch (error) {
|
||||
options.emitError(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function ungroup() {
|
||||
const ids = options.selected.value.filter((id) => options.node(id)?.type === "cluster");
|
||||
if (ids.length)
|
||||
options.dispatch("クラスター解除", (project) => ({
|
||||
...project,
|
||||
phy: ids.reduce((phy, id) => uncluster(phy, id), project.phy),
|
||||
}));
|
||||
options.selected.value = [];
|
||||
}
|
||||
|
||||
function variantGroup() {
|
||||
if (!options.selected.value.length) return;
|
||||
let id = "";
|
||||
options.dispatch("バリアントグループ作成", (project) => {
|
||||
let phy = createVariantGroup(project.phy, "Variant Group", { x: 0, y: 0, rotation: 0 }, [
|
||||
{ name: "Option A" },
|
||||
{ name: "Option B" },
|
||||
]);
|
||||
const group = options
|
||||
.allNodes(phy.root)
|
||||
.find(
|
||||
(node) =>
|
||||
node.type === "variantGroup" &&
|
||||
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
|
||||
);
|
||||
if (group?.type !== "variantGroup") return { ...project, phy };
|
||||
id = group.id;
|
||||
for (const child of options.selected.value) phy = reparentToVariant(phy, child, group.children[0]!.id);
|
||||
return {
|
||||
...project,
|
||||
phy,
|
||||
output: {
|
||||
...project.output,
|
||||
variantSelection: { ...project.output.variantSelection, [group.id]: group.default },
|
||||
},
|
||||
};
|
||||
});
|
||||
if (id) options.selected.value = [id];
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
if (!options.selected.value.length) return;
|
||||
const originals = options.selected.value
|
||||
.map((id) => options.node(id))
|
||||
.filter((node): node is Node => !!node);
|
||||
const parent = options.selectedContainer(options.selected.value[0]!);
|
||||
const worlds = originals.map((node) => worldTransform(options.project().phy, node.id));
|
||||
let ids: string[] = [];
|
||||
options.dispatch("複製", (project) => {
|
||||
const copies = cloneNodesWithNewIds(originals);
|
||||
const parentWorld = parentMatrix(project, parent);
|
||||
copies.forEach((node, index) => {
|
||||
node.transform = pasteLocalTransform(worlds[index]!, parentWorld, options.grid.value);
|
||||
ids.push(node.id);
|
||||
});
|
||||
return { ...project, phy: insertNodes(project.phy, parent, copies) };
|
||||
});
|
||||
options.selected.value = ids;
|
||||
}
|
||||
|
||||
function moveSelectionTo(target: string) {
|
||||
const destination = target === "__root__" ? null : target;
|
||||
try {
|
||||
options.dispatch("reparent", (project) => ({
|
||||
...project,
|
||||
phy: options.selected.value.reduce(
|
||||
(phy, id) =>
|
||||
destination && options.groupForVariant(destination)
|
||||
? reparentToVariant(phy, id, destination)
|
||||
: reparent(phy, id, destination),
|
||||
project.phy,
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
options.emitError(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete(id = options.selected.value[0], event?: MouseEvent) {
|
||||
const ids = id ? (options.selected.value.includes(id) ? options.selected.value : [id]) : [];
|
||||
if (!ids.length) return;
|
||||
options.confirmIds.value = ids;
|
||||
options.confirm.value = id!;
|
||||
options.confirmRefs.value = ids.flatMap((selectedId) =>
|
||||
listDeleteReferences(options.project(), selectedId),
|
||||
);
|
||||
if (event) {
|
||||
const rect = (event.currentTarget as Element).getBoundingClientRect();
|
||||
const pane = options.pane.value?.getBoundingClientRect();
|
||||
const left = rect.right + 380 < (pane?.right ?? Infinity) ? rect.left : rect.left - 380;
|
||||
const top = rect.bottom + 240 < (pane?.bottom ?? Infinity) ? rect.bottom + 4 : rect.top - 240;
|
||||
options.confirmPos.value = overlayPoint(left, top, 380, 240);
|
||||
} else {
|
||||
const rects = ids.flatMap((selectedId) =>
|
||||
[...(options.svg.value?.querySelectorAll(`[data-node-id="${selectedId}"]`) ?? [])].map((element) =>
|
||||
element.getBoundingClientRect(),
|
||||
),
|
||||
);
|
||||
if (rects.length) {
|
||||
const right = Math.max(...rects.map((rect) => rect.right));
|
||||
const bottom = Math.max(...rects.map((rect) => rect.bottom));
|
||||
const pane = options.pane.value?.getBoundingClientRect();
|
||||
const left = right + 388 < (pane?.right ?? Infinity) ? right + 8 : right - 388;
|
||||
const top = bottom + 248 < (pane?.bottom ?? Infinity) ? bottom + 8 : bottom - 248;
|
||||
options.confirmPos.value = overlayPoint(left, top, 380, 240);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function overlayPoint(clientX: number, clientY: number, width: number, height: number) {
|
||||
const rect = options.pane.value?.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.max(4, Math.min(clientX - (rect?.left ?? 0), (rect?.width ?? width) - width - 4)),
|
||||
top: Math.max(4, Math.min(clientY - (rect?.top ?? 0), (rect?.height ?? height) - height - 4)),
|
||||
};
|
||||
}
|
||||
|
||||
function deleteConfirmed() {
|
||||
if (!options.confirm.value) return;
|
||||
const ids = [...options.confirmIds.value];
|
||||
const shownGroups = ids
|
||||
.map(
|
||||
(id) =>
|
||||
options
|
||||
.variantGroups()
|
||||
.find(
|
||||
(group) =>
|
||||
group.children.some((variant) => variant.id === id) &&
|
||||
options.variants.value[group.id] === id,
|
||||
)?.id,
|
||||
)
|
||||
.filter((id): id is string => !!id);
|
||||
options.dispatch("削除", (project) => ids.reduce((next, id) => deleteNodeCascade(next, id), project));
|
||||
const next = { ...options.variants.value };
|
||||
for (const id of shownGroups) {
|
||||
const group = options.variantGroups().find((item) => item.id === id);
|
||||
if (group) next[id] = group.default;
|
||||
else delete next[id];
|
||||
}
|
||||
options.variants.value = next;
|
||||
options.selected.value = [];
|
||||
options.confirm.value = null;
|
||||
options.confirmIds.value = [];
|
||||
}
|
||||
|
||||
function setDefault(groupId: string, variantId: string) {
|
||||
options.dispatch("既定変更", (project) => ({
|
||||
...project,
|
||||
phy: setDefaultVariant(project.phy, groupId, variantId),
|
||||
}));
|
||||
}
|
||||
|
||||
function addVariant(groupId: string, name: string) {
|
||||
options.dispatch("バリアント追加", (project) => ({
|
||||
...project,
|
||||
phy: addVariantCommand(project.phy, groupId, name),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
parentMatrix,
|
||||
nodesInContainer,
|
||||
directChildren,
|
||||
tabItem,
|
||||
chooseVariant,
|
||||
keyAdd,
|
||||
partAdd,
|
||||
makeCluster,
|
||||
ungroup,
|
||||
variantGroup,
|
||||
duplicate,
|
||||
moveSelectionTo,
|
||||
askDelete,
|
||||
deleteConfirmed,
|
||||
setDefault,
|
||||
addVariant,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { KeyNode, Node } from "../../domain/phy";
|
||||
import { moveNode, resizeKey, rotateNode } from "../../domain/phyCommands";
|
||||
import { nextGrid, resolveKey, tabOrder } from "./logic";
|
||||
|
||||
type Options = {
|
||||
project: () => ProjectFile;
|
||||
mismatch: () => boolean;
|
||||
pane: Ref<HTMLElement | undefined>;
|
||||
selected: Ref<string[]>;
|
||||
focus: Ref<string | null>;
|
||||
grid: Ref<number>;
|
||||
zoom: Ref<number>;
|
||||
showHelp: Ref<boolean>;
|
||||
popover: Ref<string | null>;
|
||||
confirm: Ref<string | null>;
|
||||
spaceHeld: Ref<boolean>;
|
||||
variants: Ref<Record<string, string>>;
|
||||
priorChild: Map<string, string>;
|
||||
node: (id: string) => Node | undefined;
|
||||
allNodes: (roots?: Node[]) => Node[];
|
||||
parentId: (id: string) => string | null;
|
||||
selectedContainer: (id: string) => string | null;
|
||||
insertionParent: () => string | null;
|
||||
nodesInContainer: (container: string | null) => Node[];
|
||||
directChildren: (id: string) => Node[];
|
||||
tabItem: (node: Node) => { id: string; x: number; y: number };
|
||||
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||
fit: () => void;
|
||||
fitSelected: () => void;
|
||||
focusIn: (id: string) => void;
|
||||
makeCluster: () => void;
|
||||
ungroup: () => void;
|
||||
duplicate: () => void;
|
||||
keyAdd: () => void;
|
||||
askDelete: () => void;
|
||||
deleteConfirmed: () => void;
|
||||
chooseVariant: (group: Node, id: string) => void;
|
||||
};
|
||||
|
||||
export function usePhyKeyboard(options: Options) {
|
||||
return function keyHandler(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
!options.pane.value?.contains(document.activeElement) ||
|
||||
target?.closest("input,select,textarea,[contenteditable=true]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.code === "Space") {
|
||||
options.spaceHeld.value = true;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const action = resolveKey(event);
|
||||
const control = event.ctrlKey || event.metaKey;
|
||||
if (["undo", "redo", "save"].includes(action || "")) return;
|
||||
if (action === "?" || event.key === "?") {
|
||||
options.showHelp.value = true;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (options.showHelp.value || options.popover.value || options.confirm.value) {
|
||||
options.showHelp.value = false;
|
||||
options.popover.value = null;
|
||||
options.confirm.value = null;
|
||||
} else if (options.selected.value.length) {
|
||||
options.selected.value = [];
|
||||
} else if (options.focus.value) {
|
||||
options.focus.value = options.parentId(options.focus.value);
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && options.confirm.value) {
|
||||
options.deleteConfirmed();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
options.selected.value.length === 1 &&
|
||||
["cluster", "variantGroup"].includes(options.node(options.selected.value[0]!)?.type || "")
|
||||
) {
|
||||
options.focusIn(options.selected.value[0]!);
|
||||
return;
|
||||
}
|
||||
if (action === "fit") {
|
||||
options.fit();
|
||||
return;
|
||||
}
|
||||
if (action === "selection-fit") {
|
||||
options.fitSelected();
|
||||
return;
|
||||
}
|
||||
if (options.mismatch()) return;
|
||||
if (action === "group" && control) {
|
||||
event.preventDefault();
|
||||
options.makeCluster();
|
||||
return;
|
||||
}
|
||||
if (action === "ungroup" && control) {
|
||||
event.preventDefault();
|
||||
options.ungroup();
|
||||
return;
|
||||
}
|
||||
if (action === "d" && control) {
|
||||
event.preventDefault();
|
||||
options.duplicate();
|
||||
return;
|
||||
}
|
||||
if (action === "a" && control) {
|
||||
event.preventDefault();
|
||||
const container = options.selected.value.length
|
||||
? options.selectedContainer(options.selected.value[0]!)
|
||||
: options.insertionParent();
|
||||
options.selected.value = options.nodesInContainer(container).map((node) => node.id);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Insert") {
|
||||
event.preventDefault();
|
||||
options.keyAdd();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
options.askDelete();
|
||||
return;
|
||||
}
|
||||
if (event.key === "g" || event.key === "G") {
|
||||
options.grid.value = nextGrid(options.grid.value, event.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "+" || event.key === "=") {
|
||||
options.zoom.value *= 1.15;
|
||||
return;
|
||||
}
|
||||
if (event.key === "-") {
|
||||
options.zoom.value /= 1.15;
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const container = options.selected.value.length
|
||||
? options.selectedContainer(options.selected.value[0]!)
|
||||
: options.insertionParent();
|
||||
const siblings = options.nodesInContainer(container);
|
||||
const ordered = tabOrder(siblings.map(options.tabItem));
|
||||
const index = ordered.findIndex((node) => node.id === options.selected.value[0]);
|
||||
const next = (index + (event.shiftKey ? -1 : 1) + ordered.length) % ordered.length;
|
||||
if (ordered[next]) options.selected.value = [ordered[next]!.id];
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.altKey && event.key === "ArrowUp") {
|
||||
const id = options.selected.value[0];
|
||||
const parent = id ? options.parentId(id) : null;
|
||||
if (id && parent) {
|
||||
options.priorChild.set(parent, id);
|
||||
options.selected.value = [parent];
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.altKey && event.key === "ArrowDown") {
|
||||
const id = options.selected.value[0];
|
||||
const children = id ? options.directChildren(id) : [];
|
||||
const back = id ? options.priorChild.get(id) : undefined;
|
||||
const next = children.some((child) => child.id === back)
|
||||
? back
|
||||
: tabOrder(children.map(options.tabItem))[0]?.id;
|
||||
if (next) options.selected.value = [next];
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key.startsWith("Arrow")) {
|
||||
const ids = options.selected.value;
|
||||
if (!ids.length) return;
|
||||
event.preventDefault();
|
||||
let phy = options.project().phy;
|
||||
for (const id of ids) {
|
||||
const node = options.node(id);
|
||||
if (!node) continue;
|
||||
if (event.shiftKey) {
|
||||
if (node.type !== "key") continue;
|
||||
const size = { ...node.size };
|
||||
if (event.key === "ArrowRight") size.w = Math.max(0.25, size.w + 0.25);
|
||||
if (event.key === "ArrowDown") size.h = Math.max(0.25, size.h + 0.25);
|
||||
if (event.key === "ArrowLeft") size.w = Math.max(0.25, size.w - 0.25);
|
||||
if (event.key === "ArrowUp") size.h = Math.max(0.25, size.h - 0.25);
|
||||
phy = resizeKey(phy, id, size);
|
||||
} else {
|
||||
const delta =
|
||||
event.key === "ArrowLeft"
|
||||
? [-options.grid.value, 0]
|
||||
: event.key === "ArrowRight"
|
||||
? [options.grid.value, 0]
|
||||
: event.key === "ArrowUp"
|
||||
? [0, -options.grid.value]
|
||||
: [0, options.grid.value];
|
||||
phy = moveNode(phy, id, delta[0]!, delta[1]!);
|
||||
}
|
||||
}
|
||||
options.dispatch(event.shiftKey ? "サイズ" : "移動", (project) => ({ ...project, phy }));
|
||||
return;
|
||||
}
|
||||
if (event.key === "PageUp" || event.key === "PageDown") {
|
||||
const ids = options.selected.value;
|
||||
if (ids.length) {
|
||||
const delta = event.key === "PageUp" ? 15 : -15;
|
||||
options.dispatch("回転", (project) => ({
|
||||
...project,
|
||||
phy: ids.reduce((phy, id) => rotateNode(phy, id, delta), project.phy),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key.toLowerCase() === "r") {
|
||||
const ids = options.selected.value.filter((id) => options.node(id)?.type === "key");
|
||||
if (ids.length) {
|
||||
const delta = event.shiftKey ? -90 : 90;
|
||||
options.dispatch("向き変更", (project) => {
|
||||
const copy = structuredClone(project);
|
||||
for (const id of ids) {
|
||||
const node = options.allNodes(copy.phy.root).find((item) => item.id === id);
|
||||
if (node?.type === "key") {
|
||||
node.orientation = (((node.orientation || 0) + delta + 360) % 360) as KeyNode["orientation"];
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key.toLowerCase() === "v" && options.selected.value.length === 1) {
|
||||
const group = options.node(options.selected.value[0]!);
|
||||
if (group?.type === "variantGroup") {
|
||||
const index = group.children.findIndex(
|
||||
(variant) => variant.id === (options.variants.value[group.id] || group.default),
|
||||
);
|
||||
const variant =
|
||||
group.children[(index + (event.shiftKey ? -1 : 1) + group.children.length) % group.children.length];
|
||||
if (variant) options.chooseVariant(group, variant.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "F2") {
|
||||
(document.querySelector(".properties input") as HTMLInputElement | null)?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { Node } from "../../domain/phy";
|
||||
import { longPressColumn, treeIndex, treeNodes } from "./logic";
|
||||
|
||||
export function usePhyTreeModel(project: () => ProjectFile, focus: Ref<string | null>) {
|
||||
function allNodes(nodes = project().phy.root): Node[] {
|
||||
return treeNodes(nodes);
|
||||
}
|
||||
|
||||
function node(id: string): Node | undefined {
|
||||
return treeIndex(project().phy.root).nodes.get(id);
|
||||
}
|
||||
|
||||
function parentId(id: string): string | null {
|
||||
return treeIndex(project().phy.root).selectableParent(id);
|
||||
}
|
||||
|
||||
function isAncestor(candidate: string, id: string): boolean {
|
||||
let parent = parentId(id);
|
||||
while (parent) {
|
||||
if (parent === candidate) return true;
|
||||
parent = parentId(parent);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectedContainer(id: string): string | null {
|
||||
return treeIndex(project().phy.root).insertionContainer(id);
|
||||
}
|
||||
|
||||
function groupForVariant(id: string): string | null {
|
||||
return treeIndex(project().phy.root).groupForVariant(id);
|
||||
}
|
||||
|
||||
function parentLabel(id: string | null): string {
|
||||
if (!id) return "root";
|
||||
const item = node(id);
|
||||
if (item?.type === "variantGroup") return item.name;
|
||||
if (item?.type === "cluster") return item.name || "クラスター";
|
||||
const variant = allNodes()
|
||||
.flatMap((item) => (item.type === "variantGroup" ? item.children : []))
|
||||
.find((item) => item.id === id);
|
||||
return variant?.name || id;
|
||||
}
|
||||
|
||||
function pressTargets(id: string): string[] {
|
||||
const ancestors: string[] = [];
|
||||
let parent = parentId(id);
|
||||
while (parent) {
|
||||
const group = allNodes().find(
|
||||
(item) => item.type === "variantGroup" && item.children.some((variant) => variant.id === parent),
|
||||
);
|
||||
if (group) {
|
||||
ancestors.push(group.id);
|
||||
parent = parentId(group.id);
|
||||
} else {
|
||||
ancestors.push(parent);
|
||||
parent = parentId(parent);
|
||||
}
|
||||
if (ancestors.at(-1) === focus.value) break;
|
||||
}
|
||||
return longPressColumn(id, ancestors, focus.value || undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
allNodes,
|
||||
node,
|
||||
parentId,
|
||||
isAncestor,
|
||||
selectedContainer,
|
||||
groupForVariant,
|
||||
parentLabel,
|
||||
pressTargets,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { onMounted, onUnmounted, type Ref } from "vue";
|
||||
import type { ProjectFile } from "../../domain/project";
|
||||
import type { Node } from "../../domain/phy";
|
||||
import { fitViewport, zoomAround } from "./viewport";
|
||||
import { localBounds } from "./logic";
|
||||
|
||||
type Options = {
|
||||
project: () => ProjectFile;
|
||||
pane: Ref<HTMLElement | undefined>;
|
||||
svg: Ref<SVGSVGElement | undefined>;
|
||||
size: Ref<{ w: number; h: number }>;
|
||||
zoom: Ref<number>;
|
||||
pan: Ref<{ x: number; y: number }>;
|
||||
selected: Ref<string[]>;
|
||||
variants: Ref<Record<string, string>>;
|
||||
node: (id: string) => Node | undefined;
|
||||
};
|
||||
|
||||
export function usePhyViewport(options: Options) {
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
function fit() {
|
||||
const bounds = localBounds(options.project().phy.root, options.variants.value);
|
||||
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
|
||||
options.zoom.value = viewport.zoom;
|
||||
options.pan.value = viewport.pan;
|
||||
}
|
||||
|
||||
function fitSelected() {
|
||||
const nodes = options.selected.value
|
||||
.map((id) => options.node(id))
|
||||
.filter((node): node is Node => node !== undefined);
|
||||
if (!nodes.length) return;
|
||||
const bounds = localBounds(nodes, options.variants.value);
|
||||
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
|
||||
options.zoom.value = viewport.zoom;
|
||||
options.pan.value = viewport.pan;
|
||||
}
|
||||
|
||||
function localPoint(element: SVGSVGElement | SVGGElement, x: number, y: number) {
|
||||
const transform = element.getScreenCTM();
|
||||
if (!transform) return { x: 0, y: 0 };
|
||||
const point = new DOMPoint(x, y).matrixTransform(transform.inverse());
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
function wheel(event: WheelEvent) {
|
||||
const svg = options.svg.value;
|
||||
if (!svg) return;
|
||||
const point = localPoint(svg, event.clientX, event.clientY);
|
||||
const viewport = zoomAround(
|
||||
point,
|
||||
{ zoom: options.zoom.value, pan: options.pan.value },
|
||||
Math.exp(-event.deltaY * 0.001),
|
||||
);
|
||||
options.zoom.value = viewport.zoom;
|
||||
options.pan.value = viewport.pan;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!options.pane.value) return;
|
||||
observer = new ResizeObserver((entries) => {
|
||||
const rect = entries[0]!.contentRect;
|
||||
options.size.value = { w: rect.width, h: rect.height };
|
||||
fit();
|
||||
});
|
||||
observer.observe(options.pane.value);
|
||||
fit();
|
||||
});
|
||||
onUnmounted(() => observer?.disconnect());
|
||||
|
||||
return { fit, fitSelected, localPoint, wheel };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export type Viewport = { zoom: number; pan: { x: number; y: number } };
|
||||
export type ViewBounds = { x: number; y: number; width: number; height: number };
|
||||
|
||||
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
|
||||
export function fitViewport(
|
||||
bounds: ViewBounds,
|
||||
width: number,
|
||||
height: number,
|
||||
padding = 40,
|
||||
minZoom = 8,
|
||||
maxZoom = 160,
|
||||
): Viewport {
|
||||
const zoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(
|
||||
maxZoom,
|
||||
Math.min(
|
||||
(width - 2 * padding) / Math.max(bounds.width, 1),
|
||||
(height - 2 * padding) / Math.max(bounds.height, 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
zoom,
|
||||
pan: {
|
||||
x: (width - (2 * bounds.x + bounds.width) * zoom) / 2,
|
||||
y: (height - (2 * bounds.y + bounds.height) * zoom) / 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 指定した画面座標の下にある点を保ったまま倍率を変える。 */
|
||||
export function zoomAround(
|
||||
point: { x: number; y: number },
|
||||
viewport: Viewport,
|
||||
factor: number,
|
||||
minZoom = 5,
|
||||
maxZoom = 200,
|
||||
): Viewport {
|
||||
const zoom = Math.max(minZoom, Math.min(maxZoom, viewport.zoom * factor));
|
||||
const world = {
|
||||
x: (point.x - viewport.pan.x) / viewport.zoom,
|
||||
y: (point.y - viewport.pan.y) / viewport.zoom,
|
||||
};
|
||||
return { zoom, pan: { x: point.x - world.x * zoom, y: point.y - world.y * zoom } };
|
||||
}
|
||||
Reference in New Issue
Block a user