- domain/keyShape.ts に、キーの矩形・外接矩形・和の外形の計算と、異形キーのプリセット表(今はISO Enterだけ)を置いた - キャンバスと選択枠は、2つ目の矩形を持つキーを和の外形のpathで描く。範囲選択と囲み矩形も両方の矩形で取る - プロパティ欄に「異形キー」のチェックと、2つ目の矩形の欄(X2・Y2・Width2・Height2)を置いた - ツールバーに「異形キーを足す」selectを置いた。1Uと同じ規則の位置に、外接矩形の左上を合わせて足す - 検証: vue-tsc 0件、Vitest 106件(うち新規5件)、Playwright PHY 31件(うち新規1件)、prettier --check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM
291 lines
9.4 KiB
Vue
291 lines
9.4 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from "vue";
|
|
import type { Node, Transform } from "../../domain/phy";
|
|
import type { StabilizerCandidate } from "../../domain/stabilizers";
|
|
import { hasSecondRect } from "../../domain/keyShape";
|
|
const props = defineProps<{
|
|
selectedCount: number;
|
|
selectedId: string | null;
|
|
item: Node | null;
|
|
mismatch: boolean;
|
|
stabilizers: { candidates: StabilizerCandidate[]; selected: string[] } | null;
|
|
}>();
|
|
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];
|
|
stabilizers: [keyId: string, ids: string[]];
|
|
}>();
|
|
function setSwitchOffset(axis: "x" | "y", value: number) {
|
|
emit("update", props.item!.id, (n) => {
|
|
if (n.type !== "key") return;
|
|
const offset = { x: n.switchOffset?.x ?? 0, y: n.switchOffset?.y ?? 0, [axis]: value };
|
|
if (offset.x === 0 && offset.y === 0) delete n.switchOffset;
|
|
else n.switchOffset = offset;
|
|
});
|
|
}
|
|
// 異形キーは2つ目の矩形(x2, y2, w2, h2)を持つ。オンにしたときはKLEの既定(0, 0, w, h)から始める。T-32。
|
|
const irregular = computed(() => props.item?.type === "key" && hasSecondRect(props.item.size));
|
|
function setIrregular(on: boolean) {
|
|
emit("update", props.item!.id, (n) => {
|
|
if (n.type !== "key") return;
|
|
if (on) n.size = { ...n.size, x2: 0, y2: 0, w2: n.size.w, h2: n.size.h };
|
|
else n.size = { w: n.size.w, h: n.size.h };
|
|
});
|
|
}
|
|
function setSecondRect(field: "x2" | "y2" | "w2" | "h2", value: number) {
|
|
emit("update", props.item!.id, (n) => {
|
|
if (n.type === "key") n.size[field] = value;
|
|
});
|
|
}
|
|
function toggleStabilizer(id: string, on: boolean) {
|
|
const current = props.stabilizers?.selected ?? [];
|
|
emit(
|
|
"stabilizers",
|
|
props.item!.id,
|
|
on ? [...current.filter((x) => x !== id), id] : current.filter((x) => x !== id),
|
|
);
|
|
}
|
|
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 class="check"
|
|
><input
|
|
type="checkbox"
|
|
:checked="irregular"
|
|
@change="setIrregular(($event.target as HTMLInputElement).checked)"
|
|
/>異形キー</label
|
|
>
|
|
<template v-if="irregular && item.type === 'key'">
|
|
<label
|
|
>X2
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
:value="item.size.x2 ?? 0"
|
|
@change="setSecondRect('x2', Number(($event.target as HTMLInputElement).value))"
|
|
/></label>
|
|
<label
|
|
>Y2
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
:value="item.size.y2 ?? 0"
|
|
@change="setSecondRect('y2', Number(($event.target as HTMLInputElement).value))"
|
|
/></label>
|
|
<label
|
|
>Width2
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
:value="item.size.w2 ?? item.size.w"
|
|
@change="setSecondRect('w2', Number(($event.target as HTMLInputElement).value))"
|
|
/></label>
|
|
<label
|
|
>Height2
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
:value="item.size.h2 ?? item.size.h"
|
|
@change="setSecondRect('h2', Number(($event.target as HTMLInputElement).value))"
|
|
/></label>
|
|
</template>
|
|
<label
|
|
>switchOffset X
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
:value="item.switchOffset?.x ?? 0"
|
|
@change="setSwitchOffset('x', Number(($event.target as HTMLInputElement).value))"
|
|
/></label>
|
|
<label
|
|
>switchOffset Y
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
:value="item.switchOffset?.y ?? 0"
|
|
@change="setSwitchOffset('y', Number(($event.target as HTMLInputElement).value))"
|
|
/></label>
|
|
<div class="stabilizers">
|
|
スタビ
|
|
<span v-if="!stabilizers?.candidates.length" class="none">候補なし</span>
|
|
<label v-for="c in stabilizers?.candidates ?? []" :key="c.id" class="check"
|
|
><input
|
|
type="checkbox"
|
|
:checked="stabilizers!.selected.includes(c.id)"
|
|
@change="toggleStabilizer(c.id, ($event.target as HTMLInputElement).checked)"
|
|
/>{{ c.label }}</label
|
|
>
|
|
</div>
|
|
<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;
|
|
}
|
|
.properties label.check {
|
|
margin: 4px 0 4px 12px;
|
|
}
|
|
.properties label.check input {
|
|
width: auto;
|
|
margin-left: 0;
|
|
}
|
|
.stabilizers .none {
|
|
margin-left: 8px;
|
|
color: #6b7380;
|
|
}
|
|
</style>
|