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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user