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
588 lines
16 KiB
Vue
588 lines
16 KiB
Vue
<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>
|