Stage 17 (#3,#5,#10): hover-hold drag zoom, always-editable profile, drag-back + double-tap recall
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s

- Board drag now auto-zooms toward a cell after holding the tile over it ~1s (#3).
- Profile is inline-editable: drop the Edit/Cancel toggle, form is always shown
  for durable accounts; hint balance stays read-only; re-populate after link/merge (#5).
- A pending tile recalls by double-tap (same cell) or by dragging it back onto the
  rack (unzoomed board); a single tap no longer recalls (#10).
- e2e: lock double-tap recall + single-tap no-op; drop the removed Edit-profile click.
This commit is contained in:
Ilia Denisov
2026-06-06 14:42:09 +02:00
parent 4fd82335db
commit 1bbf0bc654
6 changed files with 171 additions and 90 deletions
+78 -21
View File
@@ -143,61 +143,112 @@
}
// --- tile placement: pointer drag + tap ---
let downInfo: { index: number; x0: number; y0: number } | null = null;
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
// for the movement threshold that distinguishes a drag from a tap.
type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
let dragMoved = false;
let swallowClick = false;
let hoverKey = '';
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
downInfo = { index, x0: e.clientX, y0: e.clientY };
function beginDrag(src: DragSrc, e: PointerEvent) {
downInfo = { src, x0: e.clientX, y0: e.clientY };
dragMoved = false;
window.addEventListener('pointermove', onWinMove);
window.addEventListener('pointerup', onWinUp);
}
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
beginDrag({ from: 'rack', index }, e);
}
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
function onBoardDown(e: PointerEvent, row: number, col: number) {
if (!isMyTurn || busy || zoomed) return;
beginDrag({ from: 'board', row, col }, e);
}
function cellUnder(x: number, y: number): { row: number; col: number } | null {
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as
| HTMLElement
| null;
if (!el?.dataset.row || !el.dataset.col) return null;
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
}
function clearHover() {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hoverKey = '';
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
dragMoved = true;
const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
// No zoom on drag start: the player may still change their mind. The zoom
// (and centring) happens on drop, in attemptPlace.
const src = downInfo.src;
const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
// No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
}
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = c
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it.
if (drag && isCoarse()) {
focus = c;
zoomed = true;
telegramHaptic('light');
}
}, 1000)
: null;
}
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
clearHover();
const di = downInfo;
downInfo = null;
if (drag && dragMoved && di) {
const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(
'[data-cell]',
) as HTMLElement | null;
drag = null;
if (el?.dataset.row && el.dataset.col) {
attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col));
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
const cell = cellUnder(e.clientX, e.clientY);
if (di.src.from === 'rack' && cell) {
attemptPlace(di.src.index, cell.row, cell.col);
} else if (di.src.from === 'board' && onRack) {
// Dropped a pending tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col);
recompute();
}
swallowClick = true;
setTimeout(() => (swallowClick = false), 60);
} else if (di) {
selected = selected === di.index ? null : di.index;
} else if (di && di.src.from === 'rack') {
selected = selected === di.src.index ? null : di.src.index;
drag = null;
} else {
drag = null;
}
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
clearHover();
telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
if (swallowClick) return;
if (pendingMap.has(`${row},${col}`)) {
placement = recallAt(placement, row, col);
recompute();
return;
}
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
// by a single tap (which recalled too easily — Stage 17).
if (pendingMap.has(`${row},${col}`)) return;
if (selected != null) {
// A committed tile already sits here: keep the rack selection so a stray tap
// on an occupied cell doesn't cancel placement — wait for an empty cell.
@@ -206,6 +257,10 @@
selected = null;
}
}
function onRecall(row: number, col: number) {
placement = recallAt(placement, row, col);
recompute();
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return;
@@ -512,6 +567,8 @@
{focus}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
onpenddown={onBoardDown}
/>
</div>
</div>