Files
Beyond-KLE/mock/src/ui/phy/usePhyCanvasPointer.ts
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

395 lines
14 KiB
TypeScript

import { nextTick, onUnmounted, ref, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { apply, worldTransform } from "../../domain/geometry";
import { keyCorners } from "../../domain/keyShape";
import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic";
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
import { autoPanVelocity } from "./viewport";
type Point = { x: number; y: number };
type Box = Point & { width: number; height: number };
type Options = {
project: () => ProjectFile;
svg: Ref<SVGSVGElement | undefined>;
pane: Ref<HTMLElement | undefined>;
pan: Ref<Point>;
zoom: Ref<number>;
grid: Ref<number>;
selected: Ref<string[]>;
focus: Ref<string | null>;
rangeBox: Ref<Box | null>;
allNodes: () => Node[];
keys: () => { key: KeyNode }[];
localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point;
visibleIds: () => Set<string>;
mismatch: () => boolean;
isAncestor: (candidate: string, id: string) => boolean;
parentLabel: (id: string | null) => string;
pressTargets: (id: string) => string[];
holdMode: Ref<HoldMode>;
setSelection: (id: string, add?: boolean) => void;
beginHistory: () => void;
commitHistory: () => void;
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
};
export function usePhyCanvasPointer(options: Options) {
const longPress = usePhyLongPress({ ...options, mode: () => options.holdMode.value });
const spaceHeld = ref(false);
let preClickSelection: string[] = [];
let preClickTarget: string | null = null;
let preClickAt = 0;
let lastPointer: Point | null = null;
let autoPanFrame: number | undefined;
let lastAutoPanAt = 0;
let panDrag: { start: Point; origin: Point } | null = null;
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
let dragState: {
id: string;
start: Point;
base: ProjectFile;
ids: string[];
shift: boolean;
parents: Map<string, SVGGElement>;
starts: Map<string, Point>;
moved: boolean;
} | null = null;
function stopAutoPan() {
if (autoPanFrame !== undefined) window.cancelAnimationFrame(autoPanFrame);
autoPanFrame = undefined;
lastAutoPanAt = 0;
}
function updateDraggedPosition(clientX: number, clientY: number) {
const state = dragState;
if (!state?.moved) return;
let phy = state.base.phy;
for (const id of state.ids) {
const parent = state.parents.get(id);
const start = state.starts.get(id);
if (!parent || !start) continue;
const current = options.localPoint(parent, clientX, clientY);
phy = moveNode(
phy,
id,
snap(current.x - start.x, options.grid.value),
snap(current.y - start.y, options.grid.value),
);
}
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
}
function autoPanTick(time: number) {
autoPanFrame = undefined;
const svg = options.svg.value;
const pointer = lastPointer;
if (!svg || !pointer || !dragState?.moved || longPress.cycling()) return;
const rect = svg.getBoundingClientRect();
const velocity = autoPanVelocity(
{ x: pointer.x, y: pointer.y },
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
);
if (!velocity.x && !velocity.y) {
lastAutoPanAt = 0;
return;
}
const elapsed = lastAutoPanAt ? Math.min((time - lastAutoPanAt) / 1000, 0.05) : 0;
lastAutoPanAt = time;
const transform = svg.getScreenCTM();
if (elapsed && transform) {
options.pan.value = {
x: options.pan.value.x - (velocity.x * elapsed) / transform.a,
y: options.pan.value.y - (velocity.y * elapsed) / transform.d,
};
void nextTick(() => {
if (dragState?.moved && lastPointer) updateDraggedPosition(lastPointer.x, lastPointer.y);
});
}
autoPanFrame = window.requestAnimationFrame(autoPanTick);
}
function scheduleAutoPan() {
const svg = options.svg.value;
if (!svg || !lastPointer || !dragState?.moved || longPress.cycling()) {
stopAutoPan();
return;
}
const rect = svg.getBoundingClientRect();
const velocity = autoPanVelocity(
{ x: lastPointer.x, y: lastPointer.y },
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
);
if (!velocity.x && !velocity.y) {
stopAutoPan();
} else if (autoPanFrame === undefined) {
autoPanFrame = window.requestAnimationFrame(autoPanTick);
}
}
function priorSelection() {
return preClickSelection;
}
function parentElementFor(id: string, fallback?: SVGGElement) {
const elements = options.svg.value?.querySelectorAll(`[data-node-id="${id}"]`);
return (
(elements?.item((elements.length || 1) - 1)?.parentElement as SVGGElement | undefined) ||
(fallback?.parentElement?.parentElement as SVGGElement | undefined) ||
options.svg.value!
);
}
function beginEmptyPointer(event: PointerEvent, point?: Point) {
const svg = options.svg.value;
const start = point ?? (svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 });
rangeDrag = { start, shift: event.shiftKey, moved: false };
options.rangeBox.value = { ...start, width: 0, height: 0 };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg?.setPointerCapture(event.pointerId);
}
function canvasDown(event: PointerEvent) {
const svg = options.svg.value;
if (event.target !== svg || !svg) return;
const point = options.localPoint(svg, event.clientX, event.clientY);
if (event.button === 1 || spaceHeld.value) {
panDrag = { start: point, origin: { ...options.pan.value } };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
beginEmptyPointer(event, point);
}
function updateRange(event: PointerEvent) {
if (!rangeDrag || !options.svg.value) return;
const point = options.localPoint(options.svg.value, event.clientX, event.clientY);
const start = rangeDrag.start;
rangeDrag.moved = Math.hypot(point.x - start.x, point.y - start.y) >= 4;
options.rangeBox.value = {
x: Math.min(start.x, point.x),
y: Math.min(start.y, point.y),
width: Math.abs(point.x - start.x),
height: Math.abs(point.y - start.y),
};
}
function down(event: PointerEvent, node: Node, _element: SVGGElement) {
const svg = options.svg.value;
if (event.button === 1 || spaceHeld.value) {
const point = svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 };
panDrag = { start: point, origin: { ...options.pan.value } };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg?.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
if (event.altKey) return;
options.pane.value?.focus();
if (options.focus.value && !options.visibleIds().has(node.id)) {
beginEmptyPointer(event);
return;
}
if (options.mismatch()) {
options.setSelection(node.id, event.shiftKey);
return;
}
if (preClickTarget !== node.id || Date.now() - preClickAt > 600) {
preClickSelection = [...options.selected.value];
}
preClickTarget = node.id;
preClickAt = Date.now();
longPress.reset();
dragState = {
id: node.id,
start: { x: event.clientX, y: event.clientY },
base: options.project(),
ids: [...options.selected.value],
shift: event.shiftKey,
parents: new Map(),
starts: new Map(),
moved: false,
};
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
if (node.type === "key") longPress.start(event, node.id);
(event.currentTarget as Element).setPointerCapture?.(event.pointerId);
}
function move(event: PointerEvent) {
const svg = options.svg.value;
if (!svg) return;
const point = options.localPoint(svg, event.clientX, event.clientY);
if (panDrag) {
options.pan.value = {
x: panDrag.origin.x + point.x - panDrag.start.x,
y: panDrag.origin.y + point.y - panDrag.start.y,
};
return;
}
if (rangeDrag) {
updateRange(event);
return;
}
if (!dragState) return;
lastPointer = { x: event.clientX, y: event.clientY };
if (longPress.picking()) {
longPress.point(event.clientX, event.clientY);
return;
}
const state = dragState;
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
longPress.cancelPending();
if (longPress.cycling()) {
const { id, shift } = longPress.take();
options.selected.value = normalizeSelection(
options.project().phy,
shift ? [...preClickSelection, id] : [id],
);
state.ids = [id];
state.base = options.project();
state.parents = new Map([[id, parentElementFor(id)]]);
state.starts = new Map([
[id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
]);
options.beginHistory();
} else if (!state.moved) {
const includesAncestor = preClickSelection.some(
(id) => id === state.id || options.isAncestor(id, state.id),
);
const ids = includesAncestor ? preClickSelection : [state.id];
if (!includesAncestor) options.selected.value = [state.id];
state.ids = ids;
state.base = options.project();
state.parents = new Map(ids.map((id) => [id, parentElementFor(id)]));
state.starts = new Map(
[...state.parents].map(([id, parent]) => [
id,
options.localPoint(parent, state.start.x, state.start.y),
]),
);
options.beginHistory();
}
state.moved = true;
updateDraggedPosition(event.clientX, event.clientY);
scheduleAutoPan();
}
function up() {
stopAutoPan();
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", up, true);
if (panDrag) {
panDrag = null;
return;
}
if (rangeDrag) {
if (rangeDrag.moved && options.rangeBox.value) {
const box = options.rangeBox.value;
const worldBox = {
x: (box.x - options.pan.value.x) / options.zoom.value,
y: (box.y - options.pan.value.y) / options.zoom.value,
width: box.width / options.zoom.value,
height: box.height / options.zoom.value,
};
const scope = options.visibleIds();
const hits: string[] = [];
for (const item of options.keys()) {
if (!scope.has(item.key.id)) continue;
const transform = worldTransform(options.project().phy, item.key.id);
const corners = keyCorners(item.key.size).map((point) => apply(transform, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
if (
intersects(worldBox, {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
})
) {
hits.push(item.key.id);
}
}
for (const node of options
.allNodes()
.filter((item) => item.type === "component" && scope.has(item.id))) {
const transform = worldTransform(options.project().phy, node.id);
if (
intersects(worldBox, { x: transform.e - 0.16, y: transform.f - 0.16, width: 0.32, height: 0.32 })
) {
hits.push(node.id);
}
}
options.selected.value = normalizeSelection(
options.project().phy,
rangeDrag.shift ? [...options.selected.value, ...hits] : hits,
);
} else {
options.selected.value = [];
}
rangeDrag = null;
options.rangeBox.value = null;
return;
}
const pick = longPress.finish();
if (pick) {
options.selected.value = normalizeSelection(
options.project().phy,
pick.shift ? [...options.selected.value, pick.id] : [pick.id],
);
}
const moved = dragState?.moved ?? false;
if (dragState?.moved) options.commitHistory();
else if (dragState && !longPress.wasActivated()) {
const id = dragState.id;
const next = dragState.shift
? options.selected.value.includes(id)
? options.selected.value.filter((selectedId) => selectedId !== id)
: [...options.selected.value, id]
: [id];
options.selected.value = normalizeSelection(options.project().phy, next);
}
if (longPress.wasActivated() || moved) preClickTarget = null;
dragState = null;
lastPointer = null;
}
function spaceUp(event: KeyboardEvent) {
if (event.code === "Space") spaceHeld.value = false;
}
onUnmounted(stopAutoPan);
return {
canvasDown,
down,
move,
up,
spaceUp,
hold: {
open: longPress.open,
mode: longPress.mode,
pos: longPress.pos,
candidates: longPress.candidates,
names: longPress.names,
index: longPress.index,
step: longPress.step,
pointing: longPress.pointing,
},
spaceHeld,
priorSelection,
};
}
function intersects(a: Box, b: Box) {
return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
}