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; pane: Ref; pan: Ref; zoom: Ref; grid: Ref; selected: Ref; focus: Ref; rangeBox: Ref; allNodes: () => Node[]; keys: () => { key: KeyNode }[]; localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point; visibleIds: () => Set; mismatch: () => boolean; isAncestor: (candidate: string, id: string) => boolean; parentLabel: (id: string | null) => string; pressTargets: (id: string) => string[]; holdMode: Ref; 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; starts: Map; 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; }