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:
@@ -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>
|
||||
Reference in New Issue
Block a user