diff --git a/mock/src/ui/phy/usePhyCanvasPointer.ts b/mock/src/ui/phy/usePhyCanvasPointer.ts index 2becf5b..2a73a47 100644 --- a/mock/src/ui/phy/usePhyCanvasPointer.ts +++ b/mock/src/ui/phy/usePhyCanvasPointer.ts @@ -4,6 +4,7 @@ import type { KeyNode, Node } from "../../domain/phy"; import { apply, worldTransform } from "../../domain/geometry"; import { moveNode } from "../../domain/phyCommands"; import { normalizeSelection, snap } from "./logic"; +import { usePhyLongPress } from "./usePhyLongPress"; import { autoPanVelocity } from "./viewport"; type Point = { x: number; y: number }; @@ -33,18 +34,11 @@ type Options = { }; export function usePhyCanvasPointer(options: Options) { - const longLabel = ref(""); - const longLabelPos = ref({ x: 0, y: 0 }); + const longPress = usePhyLongPress(options); const spaceHeld = ref(false); - const holdCandidates = ref([]); - const holdIndex = ref(0); let preClickSelection: string[] = []; let preClickTarget: string | null = null; let preClickAt = 0; - let holdTimer: number | undefined; - let holdCycle: number | undefined; - let holdShift = false; - let holdActivated = false; let lastPointer: Point | null = null; let autoPanFrame: number | undefined; let lastAutoPanAt = 0; @@ -90,7 +84,7 @@ export function usePhyCanvasPointer(options: Options) { autoPanFrame = undefined; const svg = options.svg.value; const pointer = lastPointer; - if (!svg || !pointer || !dragState?.moved || longLabel.value) return; + if (!svg || !pointer || !dragState?.moved || longPress.cycling()) return; const rect = svg.getBoundingClientRect(); const velocity = autoPanVelocity( { x: pointer.x, y: pointer.y }, @@ -117,7 +111,7 @@ export function usePhyCanvasPointer(options: Options) { function scheduleAutoPan() { const svg = options.svg.value; - if (!svg || !lastPointer || !dragState?.moved || longLabel.value) { + if (!svg || !lastPointer || !dragState?.moved || longPress.cycling()) { stopAutoPan(); return; } @@ -210,7 +204,7 @@ export function usePhyCanvasPointer(options: Options) { } preClickTarget = node.id; preClickAt = Date.now(); - holdActivated = false; + longPress.reset(); dragState = { id: node.id, start: { x: event.clientX, y: event.clientY }, @@ -223,24 +217,7 @@ export function usePhyCanvasPointer(options: Options) { }; window.addEventListener("pointermove", move, true); window.addEventListener("pointerup", up, true); - if (node.type === "key") { - holdCandidates.value = options.pressTargets(node.id); - holdIndex.value = 0; - holdShift = event.shiftKey; - holdTimer = window.setTimeout(() => { - if (holdCandidates.value.length <= 1) return; - holdActivated = true; - holdIndex.value = 1; - longLabelPos.value = { x: event.clientX, y: event.clientY }; - longLabel.value = `${options.parentLabel(holdCandidates.value[1]!)} 2/${holdCandidates.value.length}`; - holdCycle = window.setInterval(() => { - holdIndex.value = (holdIndex.value + 1) % holdCandidates.value.length; - const id = holdCandidates.value[holdIndex.value]!; - const label = holdIndex.value === 0 ? "キー" : options.parentLabel(id); - longLabel.value = `${label} ${holdIndex.value + 1}/${holdCandidates.value.length}`; - }, 450); - }, 500); - } + if (node.type === "key") longPress.start(event, node.id); (event.currentTarget as Element).setPointerCapture?.(event.pointerId); } @@ -263,15 +240,12 @@ export function usePhyCanvasPointer(options: Options) { lastPointer = { x: event.clientX, y: event.clientY }; const state = dragState; if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return; - if (holdTimer && !longLabel.value) { - window.clearTimeout(holdTimer); - holdTimer = undefined; - } - if (longLabel.value) { - const id = holdCandidates.value[holdIndex.value]!; + longPress.cancelPending(); + if (longPress.cycling()) { + const { id, shift } = longPress.take(); options.selected.value = normalizeSelection( options.project().phy, - holdShift ? [...preClickSelection, id] : [id], + shift ? [...preClickSelection, id] : [id], ); state.ids = [id]; state.base = options.project(); @@ -280,8 +254,6 @@ export function usePhyCanvasPointer(options: Options) { [id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)], ]); options.beginHistory(); - if (holdCycle) window.clearInterval(holdCycle); - longLabel.value = ""; } else if (!state.moved) { const includesAncestor = preClickSelection.some( (id) => id === state.id || options.isAncestor(id, state.id), @@ -366,19 +338,16 @@ export function usePhyCanvasPointer(options: Options) { options.rangeBox.value = null; return; } - if (holdTimer) window.clearTimeout(holdTimer); - if (holdCycle) window.clearInterval(holdCycle); - if (longLabel.value && holdCandidates.value[holdIndex.value]) { - const id = holdCandidates.value[holdIndex.value]!; + const pick = longPress.finish(); + if (pick) { options.selected.value = normalizeSelection( options.project().phy, - holdShift ? [...options.selected.value, id] : [id], + pick.shift ? [...options.selected.value, pick.id] : [pick.id], ); } - longLabel.value = ""; const moved = dragState?.moved ?? false; if (dragState?.moved) options.commitHistory(); - else if (dragState && !holdActivated) { + else if (dragState && !longPress.wasActivated()) { const id = dragState.id; const next = dragState.shift ? options.selected.value.includes(id) @@ -387,7 +356,7 @@ export function usePhyCanvasPointer(options: Options) { : [id]; options.selected.value = normalizeSelection(options.project().phy, next); } - if (holdActivated || moved) preClickTarget = null; + if (longPress.wasActivated() || moved) preClickTarget = null; dragState = null; lastPointer = null; } @@ -404,11 +373,11 @@ export function usePhyCanvasPointer(options: Options) { move, up, spaceUp, - longLabel, - longLabelPos, + longLabel: longPress.label, + longLabelPos: longPress.labelPos, spaceHeld, - holdCandidates, - holdIndex, + holdCandidates: longPress.candidates, + holdIndex: longPress.index, priorSelection, }; } diff --git a/mock/src/ui/phy/usePhyLongPress.ts b/mock/src/ui/phy/usePhyLongPress.ts new file mode 100644 index 0000000..fd753b2 --- /dev/null +++ b/mock/src/ui/phy/usePhyLongPress.ts @@ -0,0 +1,88 @@ +import { ref } from "vue"; + +type Options = { + parentLabel: (id: string | null) => string; + pressTargets: (id: string) => string[]; +}; + +export type LongPressPick = { id: string; shift: boolean }; + +// キーの長押しで、キー → 親 → … の順に選択先を巡回する。 +// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。 +export function usePhyLongPress(options: Options) { + const label = ref(""); + const labelPos = ref({ x: 0, y: 0 }); + const candidates = ref([]); + const index = ref(0); + let timer: number | undefined; + let cycle: number | undefined; + let shift = false; + let activated = false; + + function reset() { + activated = false; + } + + function start(event: PointerEvent, keyId: string) { + candidates.value = options.pressTargets(keyId); + index.value = 0; + shift = event.shiftKey; + timer = window.setTimeout(() => { + if (candidates.value.length <= 1) return; + activated = true; + index.value = 1; + labelPos.value = { x: event.clientX, y: event.clientY }; + label.value = `${options.parentLabel(candidates.value[1]!)} 2/${candidates.value.length}`; + cycle = window.setInterval(() => { + index.value = (index.value + 1) % candidates.value.length; + const id = candidates.value[index.value]!; + const name = index.value === 0 ? "キー" : options.parentLabel(id); + label.value = `${name} ${index.value + 1}/${candidates.value.length}`; + }, 450); + }, 500); + } + + function cycling() { + return label.value !== ""; + } + + function cancelPending() { + if (timer && !label.value) { + window.clearTimeout(timer); + timer = undefined; + } + } + + function take(): LongPressPick { + const id = candidates.value[index.value]!; + if (cycle) window.clearInterval(cycle); + label.value = ""; + return { id, shift }; + } + + function finish(): LongPressPick | null { + if (timer) window.clearTimeout(timer); + if (cycle) window.clearInterval(cycle); + const id = label.value ? candidates.value[index.value] : undefined; + label.value = ""; + return id ? { id, shift } : null; + } + + function wasActivated() { + return activated; + } + + return { + label, + labelPos, + candidates, + index, + reset, + start, + cycling, + cancelPending, + take, + finish, + wasActivated, + }; +}