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.x).toBe('none');
|
||||||
expect(ob.y).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,
|
lines,
|
||||||
locale,
|
locale,
|
||||||
focus,
|
focus,
|
||||||
|
recenter,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
oncell,
|
oncell,
|
||||||
ontogglezoom,
|
ontogglezoom,
|
||||||
@@ -39,6 +40,9 @@
|
|||||||
lines: boolean;
|
lines: boolean;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
focus: { row: number; col: number } | null;
|
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. */
|
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
|
||||||
dropTarget: { row: number; col: number } | null;
|
dropTarget: { row: number; col: number } | null;
|
||||||
oncell: (row: number, col: number) => void;
|
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
|
// 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
|
// 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.
|
// 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
|
// It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked,
|
||||||
// not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board.
|
// 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(() => {
|
$effect(() => {
|
||||||
const on = zoomed;
|
const on = zoomed;
|
||||||
|
void recenter; // re-run on an explicit recenter request even when the zoom state is unchanged
|
||||||
const vp = viewport;
|
const vp = viewport;
|
||||||
if (!vp) return;
|
if (!vp) return;
|
||||||
const f = untrack(() => focus);
|
const f = untrack(() => focus);
|
||||||
@@ -72,8 +83,6 @@
|
|||||||
if (clientW === 0) return;
|
if (clientW === 0) return;
|
||||||
const fitW = clientW; // board width at scale 1 (fills the viewport)
|
const fitW = clientW; // board width at scale 1 (fills the viewport)
|
||||||
const fullW = clientW * Z; // board width at full zoom
|
const fullW = clientW * Z; // board width at full zoom
|
||||||
const startSL = vp.scrollLeft;
|
|
||||||
const startST = vp.scrollTop;
|
|
||||||
let finalSL = 0;
|
let finalSL = 0;
|
||||||
let finalST = 0;
|
let finalST = 0;
|
||||||
if (on && f) {
|
if (on && f) {
|
||||||
@@ -81,6 +90,19 @@
|
|||||||
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW));
|
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));
|
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 fromW = on ? fitW : fullW; // board width when this transition begins
|
||||||
const toW = on ? fullW : fitW;
|
const toW = on ? fullW : fitW;
|
||||||
let raf = requestAnimationFrame(function tick() {
|
let raf = requestAnimationFrame(function tick() {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
let zoomed = $state(false);
|
let zoomed = $state(false);
|
||||||
let selected = $state<number | null>(null);
|
let selected = $state<number | null>(null);
|
||||||
let focus = $state<{ row: number; col: 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
|
// 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.
|
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
|
||||||
let rackIds = $state<number[]>([]);
|
let rackIds = $state<number[]>([]);
|
||||||
@@ -629,6 +633,10 @@
|
|||||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
||||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 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;
|
if (isCoarse()) zoomed = true;
|
||||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||||
@@ -929,6 +937,7 @@
|
|||||||
lines={app.boardLines}
|
lines={app.boardLines}
|
||||||
locale={app.locale}
|
locale={app.locale}
|
||||||
{focus}
|
{focus}
|
||||||
|
{recenter}
|
||||||
{dropTarget}
|
{dropTarget}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||||
|
|||||||
Reference in New Issue
Block a user