どちらも押した位置に円形インジケータを、少し離して選択先のリストを出す。 間隔は600msで始まり1000msごとに進む(500ms/450msは早いという所感への対応)。 リスト方式は、ポインタがリストに入ると巡回を止め、離したときに指している行で確定する。 リストへ向かう動きをドラッグと取り違えないよう、リスト方式では長押し後のドラッグをしない。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShoEXYqi8PWTjB9E86Ltot
134 lines
3.8 KiB
TypeScript
134 lines
3.8 KiB
TypeScript
import { ref } from "vue";
|
|
|
|
export type HoldMode = "loop" | "list";
|
|
|
|
// feedback「長押しでの選択の切り替え」で 500ms / 450ms は早いとされたため延ばす。
|
|
export const HOLD_DELAY = 600;
|
|
export const HOLD_INTERVAL = 1000;
|
|
|
|
type Options = {
|
|
parentLabel: (id: string | null) => string;
|
|
pressTargets: (id: string) => string[];
|
|
mode: () => HoldMode;
|
|
};
|
|
|
|
export type LongPressPick = { id: string; shift: boolean };
|
|
|
|
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
|
|
// loop: 巡回だけで選び、長押し後に動かすと選んだ先をドラッグする。
|
|
// list: 巡回は同じだが、ポインタがリストに入ると巡回をやめ、指している行を離した時点で選ぶ。ドラッグはしない。
|
|
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
|
|
export function usePhyLongPress(options: Options) {
|
|
const open = ref(false);
|
|
const mode = ref<HoldMode>("loop");
|
|
const pos = ref({ x: 0, y: 0 });
|
|
const candidates = ref<string[]>([]);
|
|
const names = ref<string[]>([]);
|
|
const index = ref(0);
|
|
// 巡回が進むたびに増やし、インジケータのアニメーションをやり直させる
|
|
const step = ref(0);
|
|
// list 方式で、ポインタがリストに入ったあと
|
|
const pointing = ref(false);
|
|
let timer: number | undefined;
|
|
let cycle: number | undefined;
|
|
let shift = false;
|
|
let activated = false;
|
|
|
|
function reset() {
|
|
activated = false;
|
|
}
|
|
|
|
function stopCycle() {
|
|
if (cycle) window.clearInterval(cycle);
|
|
cycle = undefined;
|
|
}
|
|
|
|
function start(event: PointerEvent, keyId: string) {
|
|
candidates.value = options.pressTargets(keyId);
|
|
index.value = 0;
|
|
shift = event.shiftKey;
|
|
timer = window.setTimeout(() => {
|
|
timer = undefined;
|
|
if (candidates.value.length <= 1) return;
|
|
activated = true;
|
|
mode.value = options.mode();
|
|
names.value = candidates.value.map((id, i) => (i === 0 ? "キー" : options.parentLabel(id)));
|
|
index.value = 1;
|
|
step.value++;
|
|
pointing.value = false;
|
|
pos.value = { x: event.clientX, y: event.clientY };
|
|
open.value = true;
|
|
cycle = window.setInterval(() => {
|
|
index.value = (index.value + 1) % candidates.value.length;
|
|
step.value++;
|
|
}, HOLD_INTERVAL);
|
|
}, HOLD_DELAY);
|
|
}
|
|
|
|
function cycling() {
|
|
return open.value && mode.value === "loop";
|
|
}
|
|
|
|
function picking() {
|
|
return open.value && mode.value === "list";
|
|
}
|
|
|
|
// list 方式: ポインタの下にあるリストの行を指す。リストの外では直前の状態を保つ。
|
|
function point(clientX: number, clientY: number) {
|
|
const row = document
|
|
.elementsFromPoint(clientX, clientY)
|
|
.find((element) => element instanceof HTMLElement && element.dataset.holdIndex !== undefined);
|
|
if (!(row instanceof HTMLElement)) return;
|
|
stopCycle();
|
|
pointing.value = true;
|
|
index.value = Number(row.dataset.holdIndex);
|
|
}
|
|
|
|
function cancelPending() {
|
|
if (timer && !open.value) {
|
|
window.clearTimeout(timer);
|
|
timer = undefined;
|
|
}
|
|
}
|
|
|
|
function take(): LongPressPick {
|
|
const id = candidates.value[index.value]!;
|
|
stopCycle();
|
|
open.value = false;
|
|
return { id, shift };
|
|
}
|
|
|
|
function finish(): LongPressPick | null {
|
|
if (timer) window.clearTimeout(timer);
|
|
timer = undefined;
|
|
stopCycle();
|
|
const id = open.value ? candidates.value[index.value] : undefined;
|
|
open.value = false;
|
|
return id ? { id, shift } : null;
|
|
}
|
|
|
|
function wasActivated() {
|
|
return activated;
|
|
}
|
|
|
|
return {
|
|
open,
|
|
mode,
|
|
pos,
|
|
candidates,
|
|
names,
|
|
index,
|
|
step,
|
|
pointing,
|
|
reset,
|
|
start,
|
|
cycling,
|
|
picking,
|
|
point,
|
|
cancelPending,
|
|
take,
|
|
finish,
|
|
wasActivated,
|
|
};
|
|
}
|