- 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
395 lines
14 KiB
TypeScript
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;
|
|
}
|