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
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:
+78
-21
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user