Stage 17 round 5 — board interaction & UI polish
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 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
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 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
- Even zoom: interpolate the board scroll toward a pre-clamped target as the real width grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a 2nd+ placed tile and a hovered dragged tile no longer jump the board. - Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now fires only for the first (zoom-in) hold. - Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault only for two touches, so one-finger scroll stays native); a second finger aborts a drag. - Shuffle hop capped at 0.3s and disabled under reduce-motion. - Make-move is a borderless icon button, disabled while the pending word is known illegal. - Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit -> Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble').
This commit is contained in:
+50
-18
@@ -15,6 +15,7 @@
|
||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||
import { replay } from '../lib/board';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
@@ -180,12 +181,33 @@
|
||||
let swallowClick = false;
|
||||
let hoverKey = '';
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
|
||||
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
|
||||
let dropTarget = $state<{ row: number; col: number } | null>(null);
|
||||
|
||||
let dragPointerId = -1;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
downInfo = { src, x0: e.clientX, y0: e.clientY };
|
||||
dragMoved = false;
|
||||
dragPointerId = e.pointerId;
|
||||
window.addEventListener('pointermove', onWinMove);
|
||||
window.addEventListener('pointerup', onWinUp);
|
||||
window.addEventListener('pointerdown', onExtraPointer);
|
||||
}
|
||||
// A second finger touching down turns the gesture into a pinch (Board handles it), so any
|
||||
// drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
|
||||
// The starting pointer's own event also bubbles here, so ignore it by id.
|
||||
function onExtraPointer(e: PointerEvent) {
|
||||
if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
|
||||
}
|
||||
function cancelDrag() {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
clearHover();
|
||||
downInfo = null;
|
||||
dragMoved = false;
|
||||
drag = null;
|
||||
}
|
||||
function onRackDown(e: PointerEvent, index: number) {
|
||||
if (!isMyTurn || busy) return;
|
||||
@@ -208,6 +230,7 @@
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
hoverKey = '';
|
||||
dropTarget = null;
|
||||
}
|
||||
function onWinMove(e: PointerEvent) {
|
||||
if (!downInfo) return;
|
||||
@@ -223,25 +246,31 @@
|
||||
if (!drag) return;
|
||||
drag = { ...drag, x: e.clientX, y: e.clientY };
|
||||
const c = cellUnder(e.clientX, e.clientY);
|
||||
// Highlight the aimed-at cell as a drop target, but only when it is free (no committed
|
||||
// or pending tile there).
|
||||
dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
|
||||
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;
|
||||
hoverTimer =
|
||||
c && !zoomed
|
||||
? setTimeout(() => {
|
||||
// Still holding the tile over this cell: magnify into it. Only the first
|
||||
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
||||
if (drag && isCoarse() && !zoomed) {
|
||||
focus = c;
|
||||
zoomed = true;
|
||||
telegramHaptic('light');
|
||||
}
|
||||
}, 1000)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
function onWinUp(e: PointerEvent) {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
clearHover();
|
||||
const di = downInfo;
|
||||
downInfo = null;
|
||||
@@ -268,6 +297,7 @@
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
clearHover();
|
||||
telegramClosingConfirmation(false);
|
||||
});
|
||||
@@ -547,7 +577,7 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Screen title={t('app.title')} back="/" growNav column scroll={false}>
|
||||
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
||||
{#snippet menu()}
|
||||
<Menu items={menuItems} />
|
||||
{/snippet}
|
||||
@@ -600,6 +630,7 @@
|
||||
lines={app.boardLines}
|
||||
locale={app.locale}
|
||||
{focus}
|
||||
{dropTarget}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||
onrecall={onRecall}
|
||||
@@ -624,10 +655,10 @@
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
<div class="rack-row" class:inert={gameOver}>
|
||||
<div class="rack-wrap">
|
||||
<Rack slots={rackSlots} {variant} {selected} {shuffling} ondown={onRackDown} />
|
||||
<Rack slots={rackSlots} {variant} {selected} shuffling={shuffling && !app.reduceMotion} ondown={onRackDown} />
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<button class="make" onclick={commit} disabled={busy} aria-label={t('game.makeMove')}>✅</button>
|
||||
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -883,18 +914,19 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
||||
while the pending word is known to be illegal (Stage 17). */
|
||||
.make {
|
||||
min-width: 56px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.6rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.make:disabled {
|
||||
opacity: 0.55;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.pop {
|
||||
padding: 9px 14px;
|
||||
|
||||
Reference in New Issue
Block a user