feat(ui): cross-screen caches + invitation delta channel + hint recenter on a zoomed board #56
@@ -52,3 +52,37 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => {
|
||||
expect(ob.x).toBe('none');
|
||||
expect(ob.y).toBe('none');
|
||||
});
|
||||
|
||||
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
|
||||
// zoom state does not change, so without an explicit recenter the board stays parked where the
|
||||
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
|
||||
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
|
||||
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
|
||||
// Zoom into the top-left cell (far from the centre where the hint lands).
|
||||
await page
|
||||
.locator('[data-cell]:not(.filled)')
|
||||
.first()
|
||||
.evaluate((el: HTMLElement) => {
|
||||
el.click();
|
||||
el.click();
|
||||
});
|
||||
const viewport = page.locator('.viewport.zoomed');
|
||||
await expect(viewport).toBeVisible();
|
||||
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
|
||||
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
||||
|
||||
// Take a hint (the control confirms on a second tap), which plays at the centre.
|
||||
const hint = page.getByRole('button', { name: 'Hint' });
|
||||
await hint.click();
|
||||
await hint.click();
|
||||
await page.waitForTimeout(300); // the board pans to the hint word
|
||||
|
||||
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
||||
// The board panned from the top-left toward the centre word: both offsets grew.
|
||||
expect(after.left).toBeGreaterThan(before.left + 20);
|
||||
expect(after.top).toBeGreaterThan(before.top + 20);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
lines,
|
||||
locale,
|
||||
focus,
|
||||
recenter,
|
||||
dropTarget,
|
||||
oncell,
|
||||
ontogglezoom,
|
||||
@@ -39,6 +40,9 @@
|
||||
lines: boolean;
|
||||
locale: Locale;
|
||||
focus: { row: number; col: number } | null;
|
||||
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
|
||||
* is unchanged — the hint recentring an already-zoomed board on the word it played. */
|
||||
recenter: number;
|
||||
/** 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;
|
||||
@@ -60,10 +64,17 @@
|
||||
// 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.
|
||||
// It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked,
|
||||
// so placing a 2nd+ tile or hovering a dragged tile never jumps the board — only a zoom
|
||||
// toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change
|
||||
// pans straight to `focus`, since the board width is stable and the width-driven tween below
|
||||
// has nothing to ride (the case the owner hit: a hint taken while already zoomed in).
|
||||
// The previous zoom state, to tell a zoom toggle from a recenter request. The board always mounts
|
||||
// zoomed-out, so it starts false and is updated to the live zoom on each effect run.
|
||||
let prevZoom = false;
|
||||
$effect(() => {
|
||||
const on = zoomed;
|
||||
void recenter; // re-run on an explicit recenter request even when the zoom state is unchanged
|
||||
const vp = viewport;
|
||||
if (!vp) return;
|
||||
const f = untrack(() => focus);
|
||||
@@ -72,8 +83,6 @@
|
||||
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) {
|
||||
@@ -81,6 +90,19 @@
|
||||
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 toggled = on !== prevZoom;
|
||||
prevZoom = on;
|
||||
if (!toggled) {
|
||||
// No zoom change: an explicit recenter (the hint) pans straight to the focused word; a stale
|
||||
// run with nothing focused leaves the scroll alone.
|
||||
if (on && f) {
|
||||
vp.scrollLeft = finalSL;
|
||||
vp.scrollTop = finalST;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const startSL = vp.scrollLeft;
|
||||
const startST = vp.scrollTop;
|
||||
const fromW = on ? fitW : fullW; // board width when this transition begins
|
||||
const toW = on ? fullW : fitW;
|
||||
let raf = requestAnimationFrame(function tick() {
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
let zoomed = $state(false);
|
||||
let selected = $state<number | null>(null);
|
||||
let focus = $state<{ row: number; col: number } | null>(null);
|
||||
// Bumped to ask the board to scroll to `focus` even when the zoom state does not change — the
|
||||
// hint recentring an already-zoomed board on the word it just played (a plain focus change is
|
||||
// deliberately ignored by the board so placing/dragging tiles never jumps it).
|
||||
let recenter = $state(0);
|
||||
// A stable id per rack slot, permuted together with the letters on shuffle, so the rack
|
||||
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
|
||||
let rackIds = $state<number[]>([]);
|
||||
@@ -629,6 +633,10 @@
|
||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
||||
};
|
||||
// Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on
|
||||
// the next line; this nonce also recentres a board that is already zoomed in, where the
|
||||
// unchanged zoom state would otherwise leave it parked where the player was looking.
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse()) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
@@ -929,6 +937,7 @@
|
||||
lines={app.boardLines}
|
||||
locale={app.locale}
|
||||
{focus}
|
||||
{recenter}
|
||||
{dropTarget}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||
|
||||
Reference in New Issue
Block a user