feat(client): pointer-event drag controller for the phantom layer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-05-18 20:28:37 -04:00
parent bd98315fe3
commit f52f7dbb8f
@@ -0,0 +1,82 @@
import type { PieceType, Square } from '@blind-chess/shared';
import { phantoms } from './phantoms.svelte.js';
import { game } from './game.svelte.js';
export type DragSource =
| { kind: 'palette'; type: PieceType }
| { kind: 'board'; from: Square; type: PieceType };
/**
* Pointer-event drag controller for the phantom layer. A drag past THRESHOLD
* px places/moves/removes a phantom; a sub-threshold press is a tap and is
* left for the board's normal click handler. Real moves are unaffected.
*/
function makeDrag() {
const state = $state<{
active: DragSource | null;
x: number;
y: number;
moved: boolean;
}>({ active: null, x: 0, y: 0, moved: false });
let startX = 0;
let startY = 0;
// Set only when a board-phantom drag ends back on its origin square — the
// browser then fires a spurious `click` on that square's button which must
// be swallowed so it doesn't trigger a real move.
let suppressClickOn: Square | null = null;
const THRESHOLD = 6;
function onMove(e: PointerEvent) {
if (!state.active) return;
state.x = e.clientX;
state.y = e.clientY;
if (!state.moved && Math.hypot(e.clientX - startX, e.clientY - startY) > THRESHOLD) {
state.moved = true;
}
}
function onUp(e: PointerEvent) {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const src = state.active;
const wasDrag = state.moved;
state.active = null;
state.moved = false;
if (!src || !wasDrag) return; // a tap — the board click handler deals with it
const el = document.elementFromPoint(e.clientX, e.clientY);
const sqEl = el?.closest('[data-square]') as HTMLElement | null;
const target = sqEl?.dataset.square as Square | undefined;
if (src.kind === 'board') {
if (target === src.from) { suppressClickOn = src.from; return; }
if (!target) { phantoms.remove(src.from); return; } // dropped off the board
if (game.state.view?.pieces[target]) return; // your own real piece — reject
phantoms.move(src.from, target);
return;
}
// palette → board
if (target && !game.state.view?.pieces[target]) phantoms.place(target, src.type);
}
function start(src: DragSource, e: PointerEvent) {
suppressClickOn = null;
state.active = src;
state.x = startX = e.clientX;
state.y = startY = e.clientY;
state.moved = false;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
/** The board calls this first in its square-click handler. */
function shouldSuppressClick(sq: Square): boolean {
if (suppressClickOn === sq) { suppressClickOn = null; return true; }
return false;
}
return { state, start, shouldSuppressClick };
}
export const phantomDrag = makeDrag();