Files
scrabble-game/ui/src/game/Board.svelte
T
Ilia Denisov b720907db2
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Review fixes #2: bigger flag star, TG header below nav, board-tile relocation
Addressing the review on #23:
- Flag star scaled up ~25% (the hammer&sickle emblem unchanged, kept clear of it).
- TG fullscreen header: drop the WHOLE header below the content-safe-area top
  inset (the hamburger stays to the right of the title), instead of pinning the
  hamburger to the physical top edge.
- DnD: a placed (pending) tile can now be relocated by dragging it to another
  board cell (board->board); it lifts off its source cell while dragged; and it
  can be grabbed even on the zoomed board (touch-action:none on the pending
  cell, so the drag wins over the board pan). The manual-selection blue frame
  now clears on recall.
2026-06-08 18:23:10 +02:00

389 lines
13 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
let {
board,
premium,
pending,
highlight,
flash,
centre,
zoomed,
variant,
labelMode,
lines,
locale,
focus,
dropTarget,
oncell,
ontogglezoom,
onrecall,
onpenddown,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
highlight: Set<string>;
flash: boolean;
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
labelMode: BoardLabelMode;
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
lines: boolean;
locale: Locale;
focus: { row: number; col: number } | null;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: (row: number, col: number) => void;
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
onrecall: (row: number, col: number) => void;
/** Pointer-down on a pending cell, to start dragging that tile back to the rack. */
onpenddown: (e: PointerEvent, row: number, col: number) => void;
} = $props();
const Z = 1.85;
const z = $derived(zoomed ? Z : 1);
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
let viewport = $state<HTMLElement>();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
// works in every browser. The pan is interpolated toward a PRE-CLAMPED final scroll as
// the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
// from A to B in one motion instead of chasing a per-frame target that the scroll bounds
// clamp — which made the board lurch one way and then snap back near the edges/centre.
// It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does
// not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board.
$effect(() => {
const on = zoomed;
const vp = viewport;
if (!vp) return;
const f = untrack(() => focus);
const clientW = vp.clientWidth;
const clientH = vp.clientHeight;
if (clientW === 0) return;
const fitW = clientW; // board width at scale 1 (fills the viewport)
const fullW = clientW * Z; // board width at full zoom
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
let finalSL = 0;
let finalST = 0;
if (on && f) {
const cell = fullW / 15;
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW));
finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH));
}
const fromW = on ? fitW : fullW; // board width when this transition begins
const toW = on ? fullW : fitW;
let raf = requestAnimationFrame(function tick() {
const curW = vp.scrollWidth || fromW;
let prog = (curW - fromW) / (toW - fromW);
prog = prog < 0 ? 0 : prog > 1 ? 1 : prog;
vp.scrollLeft = startSL + (finalSL - startSL) * prog;
vp.scrollTop = startST + (finalST - startST) * prog;
if (prog < 1) raf = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(raf);
});
// Pinch zoom (Stage 17): a two-finger spread zooms in toward the pinch midpoint, a pinch
// close zooms out. preventDefault fires only for two touches, so the one-finger native
// scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
// double-tap, toggling toward the midpoint cell.
$effect(() => {
const vp = viewport;
if (!vp) return;
let startDist = 0;
let acted = false;
const span = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
const midCell = (t: TouchList) => {
const x = (t[0].clientX + t[1].clientX) / 2;
const y = (t[0].clientY + t[1].clientY) / 2;
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) };
};
const onStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
startDist = span(e.touches);
acted = false;
}
};
const onMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || startDist === 0) return;
e.preventDefault(); // claim the two-finger gesture; one finger still scrolls natively
if (acted) return;
const ratio = span(e.touches) / startDist;
if (ratio > 1.25 && !zoomed) {
const c = midCell(e.touches);
if (c) {
acted = true;
ontogglezoom(c.row, c.col);
}
} else if (ratio < 0.8 && zoomed) {
const c = midCell(e.touches) ?? centre;
acted = true;
ontogglezoom(c.row, c.col);
}
};
const onEnd = (e: TouchEvent) => {
if (e.touches.length < 2) {
startDist = 0;
acted = false;
}
};
vp.addEventListener('touchstart', onStart, { passive: true });
vp.addEventListener('touchmove', onMove, { passive: false });
vp.addEventListener('touchend', onEnd);
vp.addEventListener('touchcancel', onEnd);
return () => {
vp.removeEventListener('touchstart', onStart);
vp.removeEventListener('touchmove', onMove);
vp.removeEventListener('touchend', onEnd);
vp.removeEventListener('touchcancel', onEnd);
};
});
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
// scroll of the zoomed board is never hijacked.
let lastTap = 0;
let lastCell = '';
function onTap(row: number, col: number) {
const now = Date.now();
const k = key(row, col);
// A double-tap counts only when it lands twice on the same cell, so quick taps across
// different cells don't coalesce into a stray recall/zoom.
if (k === lastCell && now - lastTap < 300) {
lastTap = 0;
lastCell = '';
if (pending.has(k)) onrecall(row, col);
else ontogglezoom(row, col);
return;
}
lastTap = now;
lastCell = k;
oncell(row, col);
}
const key = (r: number, c: number) => `${r},${c}`;
</script>
<div class="viewport" class:zoomed bind:this={viewport}>
<div class="scaler" style="--z: {z};">
<div class="grid" class:gridless={!lines}>
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
{@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
class:flash={!!cell && flash && highlight.has(key(r, c))}
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
class:droptarget={dropTarget?.row === r && dropTarget?.col === c}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
onpointerdown={(e) => { if (!!p && !cell) onpenddown(e, r, c); }}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
<span class="b1">{bl.text}</span>
{:else if bl?.kind === 'split'}
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
</div>
<style>
.viewport {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: var(--board-bg);
border-radius: var(--radius-sm);
}
.viewport.zoomed {
overflow: auto;
}
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
— a magnifying-glass zoom. */
.scaler {
width: calc(100% * var(--z));
transition: width 0.25s ease;
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 1px;
background: var(--cell-line);
padding: 1px;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 1px;
background: var(--cell-bg);
color: var(--prem-text);
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
cell animation is the last-word .flash highlight). */
-webkit-tap-highlight-color: transparent;
padding: 0;
overflow: hidden;
font-size: 0;
}
.cell.tw {
background: var(--prem-tw);
}
.cell.dw {
background: var(--prem-dw);
}
.cell.tl {
background: var(--prem-tl);
}
.cell.dl {
background: var(--prem-dl);
}
.cell.filled,
.cell.pending {
background: var(--tile-bg);
color: var(--tile-text);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
.cell.pending {
background: var(--tile-pending);
/* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
board) instead of the touch starting a board pan (Stage 17). */
touch-action: none;
}
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
get rounded corners and a soft right-side shadow so adjacent gapless tiles still read
as separate pieces. */
.grid.gridless {
gap: 0;
padding: 0;
background: var(--board-bg);
}
.grid.gridless .cell {
border-radius: 0;
}
.grid.gridless .cell.dark {
background: color-mix(in srgb, var(--cell-bg) 88%, #000);
}
.grid.gridless .cell.filled,
.grid.gridless .cell.pending {
border-radius: 4px;
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
}
.cell.droptarget {
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
target reads clearly while dragging without obscuring the bonus label underneath. */
box-shadow: inset 0 0 0 2px var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--cell-bg));
}
.cell.hl {
background: var(--tile-recent);
/* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
--tile-edge would), so the tile still reads as raised. */
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
}
.cell.flash {
/* Two flashes to draw the eye, then settle back to normal so it does not distract. */
animation: tileflash 1s ease-in-out 2;
}
@keyframes tileflash {
0%,
100% {
background: var(--tile-bg);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
50% {
background: var(--tile-recent);
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
}
}
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
the board grows on zoom (relatively smaller, never overflowing). */
.letter {
position: absolute;
top: 5%;
left: 8%;
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 5%;
bottom: 3%;
font-size: 2.4cqw;
font-weight: 600;
}
.star {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3.6cqw;
opacity: 0.7;
}
.b1 {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2.7cqw;
font-weight: 600;
opacity: 0.9;
}
.bsplit {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.05;
opacity: 0.92;
overflow: hidden;
padding: 0 1px;
}
.bt {
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
}
</style>