Files
Beyond-KLE/mock/src/ui/phy/PhyEditor.vue
T
ewdernaandClaude Opus 5.5 afd9af1914 mock: PHY Editorで異形キーを描き・編集し・追加できるようにする(T-32)
- 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
2026-10-26 08:55:20 +09:00

646 lines
18 KiB
Vue

<script setup lang="ts">
import { computed, 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 PhySelectionLayer from "./PhySelectionLayer.vue";
import PhyBadgeLayer from "./PhyBadgeLayer.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
import PhyMountLayer from "./PhyMountLayer.vue";
import {
normalizeSelection,
nextGrid,
insertionContainer,
structuralNodes,
visibleNodeIds,
stabilizerSelections,
selectedStabilizers,
withStabilizers,
} from "./logic";
import { stabilizerCandidates } from "../../domain/stabilizers";
import { usePhyClipboard } from "./usePhyClipboard";
import { usePhyViewport } from "./usePhyViewport";
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
import { HOLD_DELAY, HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
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,
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,
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,
presetAdd,
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 holdMode = ref<HoldMode>("loop");
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,
holdMode,
setSelection: setSel,
beginHistory: props.beginHistory,
commitHistory: props.commitHistory,
dispatch,
});
const { canvasDown, down, spaceUp, hold, spaceHeld } = 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);
}
const showMounts = ref(false);
const stabilizerChoice = computed(() => {
const item = prop.value;
if (item?.type !== "key") return null;
return {
candidates: stabilizerCandidates(item.size),
selected: selectedStabilizers(stabilizerSelections(props.project), item),
};
});
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, 'tree-closed': !treeOpen }">
<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()"
@preset-add="presetAdd"
@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"
:hold-mode="holdMode"
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
:show-mounts="showMounts"
@mounts="showMounts = !showMounts"
/>
<PhyHoldOverlay
v-if="hold.open.value"
:mode="hold.mode.value"
:pos="hold.pos.value"
:names="hold.names.value"
:index="hold.index.value"
:step="hold.step.value"
:pointing="hold.pointing.value"
/>
<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"
/>
<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"
@down="down"
@focus="onFocus"
/>
</g>
<NodeSvg
v-else
:nodes="project.phy.root"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="false"
@down="down"
@focus="onFocus"
/>
<PhyMountLayer
v-if="showMounts"
:nodes="focus ? [node(focus)!] : project.phy.root"
:variants="variants"
:selections="stabilizerSelections(project)"
:zoom="zoom"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
<PhySelectionLayer
:nodes="focus ? [node(focus)!] : project.phy.root"
:selected="selected"
:variants="variants"
:zoom="zoom"
:candidate="hold.open.value ? (hold.candidates.value[hold.index.value] ?? null) : null"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
<PhyBadgeLayer
v-if="focus && outsideNodes.length"
:nodes="outsideNodes"
:variants="variants"
:zoom="zoom"
:dim="true"
/>
<PhyBadgeLayer
:nodes="focus ? [node(focus)!] : project.phy.root"
:variants="variants"
:zoom="zoom"
:parent="focus ? focusParentMatrix(focus) : undefined"
@badge="openBadge"
/>
</g>
<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 }}<span v-if="g.default === v.id" class="default-tag">既定</span>
</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>
長押し: キー上で{{ HOLD_DELAY }}ms待つと選択先候補が{{
HOLD_INTERVAL
}}msごとに進み、離すと確定します。
ループ方式では、そのまま動かすと選んだ先をドラッグします。リスト方式では、リストに入ると巡回が止まり、指している行で離すと確定します。
</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)"
:stabilizers="stabilizerChoice"
@stabilizers="(keyId, ids) => dispatch('スタビ位置', (p) => withStabilizers(p, keyId, ids))"
/>
</section>
</template>
<style scoped>
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 260px minmax(300px, 1fr) 250px;
gap: 8px;
}
.workspace.tree-closed {
grid-template-columns: minmax(300px, 1fr) 250px;
}
.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;
}
.default-tag {
margin-left: 6px;
padding: 0 4px;
border: 1px solid #8a691b;
border-radius: 3px;
font-size: 11px;
color: #6b520f;
}
</style>