mock: 長押しの処理を usePhyLongPress に切り出す(T-30 単位7a)

候補・巡回のタイマー・表示ラベルを usePhyLongPress に移し、
usePhyCanvasPointer はドラッグ・クリックとの接続だけを持つ。挙動は変えない。
7b で2方式を入れる土台にする。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKxNtqjjPyttUfT1TgC7Qt
This commit is contained in:
2026-10-23 08:36:09 +09:00
co-authored by Claude Opus 5.5
parent 50e672768b
commit b314309e8c
2 changed files with 107 additions and 50 deletions
+19 -50
View File
@@ -4,6 +4,7 @@ import type { KeyNode, Node } from "../../domain/phy";
import { apply, worldTransform } from "../../domain/geometry"; import { apply, worldTransform } from "../../domain/geometry";
import { moveNode } from "../../domain/phyCommands"; import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic"; import { normalizeSelection, snap } from "./logic";
import { usePhyLongPress } from "./usePhyLongPress";
import { autoPanVelocity } from "./viewport"; import { autoPanVelocity } from "./viewport";
type Point = { x: number; y: number }; type Point = { x: number; y: number };
@@ -33,18 +34,11 @@ type Options = {
}; };
export function usePhyCanvasPointer(options: Options) { export function usePhyCanvasPointer(options: Options) {
const longLabel = ref(""); const longPress = usePhyLongPress(options);
const longLabelPos = ref({ x: 0, y: 0 });
const spaceHeld = ref(false); const spaceHeld = ref(false);
const holdCandidates = ref<string[]>([]);
const holdIndex = ref(0);
let preClickSelection: string[] = []; let preClickSelection: string[] = [];
let preClickTarget: string | null = null; let preClickTarget: string | null = null;
let preClickAt = 0; let preClickAt = 0;
let holdTimer: number | undefined;
let holdCycle: number | undefined;
let holdShift = false;
let holdActivated = false;
let lastPointer: Point | null = null; let lastPointer: Point | null = null;
let autoPanFrame: number | undefined; let autoPanFrame: number | undefined;
let lastAutoPanAt = 0; let lastAutoPanAt = 0;
@@ -90,7 +84,7 @@ export function usePhyCanvasPointer(options: Options) {
autoPanFrame = undefined; autoPanFrame = undefined;
const svg = options.svg.value; const svg = options.svg.value;
const pointer = lastPointer; const pointer = lastPointer;
if (!svg || !pointer || !dragState?.moved || longLabel.value) return; if (!svg || !pointer || !dragState?.moved || longPress.cycling()) return;
const rect = svg.getBoundingClientRect(); const rect = svg.getBoundingClientRect();
const velocity = autoPanVelocity( const velocity = autoPanVelocity(
{ x: pointer.x, y: pointer.y }, { x: pointer.x, y: pointer.y },
@@ -117,7 +111,7 @@ export function usePhyCanvasPointer(options: Options) {
function scheduleAutoPan() { function scheduleAutoPan() {
const svg = options.svg.value; const svg = options.svg.value;
if (!svg || !lastPointer || !dragState?.moved || longLabel.value) { if (!svg || !lastPointer || !dragState?.moved || longPress.cycling()) {
stopAutoPan(); stopAutoPan();
return; return;
} }
@@ -210,7 +204,7 @@ export function usePhyCanvasPointer(options: Options) {
} }
preClickTarget = node.id; preClickTarget = node.id;
preClickAt = Date.now(); preClickAt = Date.now();
holdActivated = false; longPress.reset();
dragState = { dragState = {
id: node.id, id: node.id,
start: { x: event.clientX, y: event.clientY }, start: { x: event.clientX, y: event.clientY },
@@ -223,24 +217,7 @@ export function usePhyCanvasPointer(options: Options) {
}; };
window.addEventListener("pointermove", move, true); window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true); window.addEventListener("pointerup", up, true);
if (node.type === "key") { if (node.type === "key") longPress.start(event, node.id);
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);
}
(event.currentTarget as Element).setPointerCapture?.(event.pointerId); (event.currentTarget as Element).setPointerCapture?.(event.pointerId);
} }
@@ -263,15 +240,12 @@ export function usePhyCanvasPointer(options: Options) {
lastPointer = { x: event.clientX, y: event.clientY }; lastPointer = { x: event.clientX, y: event.clientY };
const state = dragState; const state = dragState;
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return; if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
if (holdTimer && !longLabel.value) { longPress.cancelPending();
window.clearTimeout(holdTimer); if (longPress.cycling()) {
holdTimer = undefined; const { id, shift } = longPress.take();
}
if (longLabel.value) {
const id = holdCandidates.value[holdIndex.value]!;
options.selected.value = normalizeSelection( options.selected.value = normalizeSelection(
options.project().phy, options.project().phy,
holdShift ? [...preClickSelection, id] : [id], shift ? [...preClickSelection, id] : [id],
); );
state.ids = [id]; state.ids = [id];
state.base = options.project(); 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)], [id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
]); ]);
options.beginHistory(); options.beginHistory();
if (holdCycle) window.clearInterval(holdCycle);
longLabel.value = "";
} else if (!state.moved) { } else if (!state.moved) {
const includesAncestor = preClickSelection.some( const includesAncestor = preClickSelection.some(
(id) => id === state.id || options.isAncestor(id, state.id), (id) => id === state.id || options.isAncestor(id, state.id),
@@ -366,19 +338,16 @@ export function usePhyCanvasPointer(options: Options) {
options.rangeBox.value = null; options.rangeBox.value = null;
return; return;
} }
if (holdTimer) window.clearTimeout(holdTimer); const pick = longPress.finish();
if (holdCycle) window.clearInterval(holdCycle); if (pick) {
if (longLabel.value && holdCandidates.value[holdIndex.value]) {
const id = holdCandidates.value[holdIndex.value]!;
options.selected.value = normalizeSelection( options.selected.value = normalizeSelection(
options.project().phy, 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; const moved = dragState?.moved ?? false;
if (dragState?.moved) options.commitHistory(); if (dragState?.moved) options.commitHistory();
else if (dragState && !holdActivated) { else if (dragState && !longPress.wasActivated()) {
const id = dragState.id; const id = dragState.id;
const next = dragState.shift const next = dragState.shift
? options.selected.value.includes(id) ? options.selected.value.includes(id)
@@ -387,7 +356,7 @@ export function usePhyCanvasPointer(options: Options) {
: [id]; : [id];
options.selected.value = normalizeSelection(options.project().phy, next); options.selected.value = normalizeSelection(options.project().phy, next);
} }
if (holdActivated || moved) preClickTarget = null; if (longPress.wasActivated() || moved) preClickTarget = null;
dragState = null; dragState = null;
lastPointer = null; lastPointer = null;
} }
@@ -404,11 +373,11 @@ export function usePhyCanvasPointer(options: Options) {
move, move,
up, up,
spaceUp, spaceUp,
longLabel, longLabel: longPress.label,
longLabelPos, longLabelPos: longPress.labelPos,
spaceHeld, spaceHeld,
holdCandidates, holdCandidates: longPress.candidates,
holdIndex, holdIndex: longPress.index,
priorSelection, priorSelection,
}; };
} }
+88
View File
@@ -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<string[]>([]);
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,
};
}