diff --git a/packages/client/src/lib/stores/phantom-drag.svelte.ts b/packages/client/src/lib/stores/phantom-drag.svelte.ts new file mode 100644 index 0000000..d2f9756 --- /dev/null +++ b/packages/client/src/lib/stores/phantom-drag.svelte.ts @@ -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();