Merge pull request 'fix(ui): landscape board zoom/pan + rack tile rendering + mobile block label' (#141) from fix/landscape-board-ui into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m31s

This commit was merged in pull request #141.
This commit is contained in:
2026-06-29 19:11:05 +00:00
4 changed files with 118 additions and 15 deletions
+86 -11
View File
@@ -105,18 +105,27 @@
prevZoom = on; prevZoom = on;
prevRecenter = rc; prevRecenter = rc;
if (landscape) { if (landscape) {
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board // A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target // HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each // moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like // until the board is wider than the viewport): the board positioning "in two steps". Drive
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it // BOTH axes by one progress q — how far the board has grown past the viewport width, the point
// stops overflowing.) // at which a horizontal pan first becomes possible. Until then q is 0 and the board simply
// zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus
// target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so
// q running 1→0 unwinds the scroll before the board shrinks back below the viewport.
if (toggled || (recentered && on && f)) { if (toggled || (recentered && on && f)) {
const t0 = performance.now(); const scaler = vp.firstElementChild as HTMLElement | null;
let raf = requestAnimationFrame(function tick(now: number) { const targetSL = on ? finalSL : vp.scrollLeft;
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth)); const targetST = on ? finalST : vp.scrollTop;
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight)); const span = Math.max(1, fullSide - clientW);
if (now - t0 < 320) raf = requestAnimationFrame(tick); let raf = requestAnimationFrame(function tick() {
const side = scaler ? scaler.getBoundingClientRect().width : fullSide;
const q = Math.max(0, Math.min(1, (side - clientW) / span));
vp.scrollLeft = targetSL * q;
vp.scrollTop = targetST * q;
const settled = on ? side >= fullSide - 1 : side <= fitSide + 1;
if (!settled) raf = requestAnimationFrame(tick);
}); });
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
} }
@@ -204,6 +213,69 @@
}; };
}); });
// Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a
// mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not
// be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a
// pending tile (those own their pointer for relocation), and only once the pointer passes a small
// threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows
// the trailing click so it does not also act on the cell under the release.
$effect(() => {
const vp = viewport;
if (!vp) return;
let armed = false;
let panning = false;
let sx = 0;
let sy = 0;
let sl = 0;
let st = 0;
const onDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
armed = true;
panning = false;
sx = e.clientX;
sy = e.clientY;
sl = vp.scrollLeft;
st = vp.scrollTop;
};
const onMove = (e: PointerEvent) => {
if (!armed) return;
const dx = e.clientX - sx;
const dy = e.clientY - sy;
if (!panning) {
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
panning = true;
vp.setPointerCapture(e.pointerId);
}
vp.scrollLeft = sl - dx;
vp.scrollTop = st - dy;
};
const onUp = (e: PointerEvent) => {
armed = false;
if (!panning) return;
panning = false;
if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
// cell); self-remove on that click, and clean up on the next tick if none fired.
const swallow = (ev: Event) => {
ev.stopPropagation();
vp.removeEventListener('click', swallow, true);
};
vp.addEventListener('click', swallow, true);
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
};
vp.addEventListener('pointerdown', onDown);
vp.addEventListener('pointermove', onMove);
vp.addEventListener('pointerup', onUp);
vp.addEventListener('pointercancel', onUp);
return () => {
vp.removeEventListener('pointerdown', onDown);
vp.removeEventListener('pointermove', onMove);
vp.removeEventListener('pointerup', onUp);
vp.removeEventListener('pointercancel', onUp);
};
});
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward // 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 // 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 // toward a cell the held tile hovers over (handled in Game), so the one-finger native
@@ -287,6 +359,9 @@
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed /* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
board cannot be dragged past its content into empty space — it just stops at the edge. */ board cannot be dragged past its content into empty space — it just stops at the edge. */
overscroll-behavior: none; overscroll-behavior: none;
/* The zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the
browser's scroll-anchoring so it does not fight those writes mid-transition. */
overflow-anchor: none;
} }
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board /* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
— a magnifying-glass zoom. */ — a magnifying-glass zoom. */
+8
View File
@@ -1544,6 +1544,14 @@
.make:disabled { .make:disabled {
opacity: 0.4; opacity: 0.4;
} }
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
so it sits inside the freed slot with its right edge level with the rack's right edge — the
mirror of the first tile's left edge. */
.game-land .make {
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
}
/* The move-history header: leave (active) / export (finished) on the left, comms on the /* The move-history header: leave (active) / export (finished) on the left, comms on the
right, icon-only. Sticky so it stays atop the scrolling move list. */ right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead { .hhead {
+23 -3
View File
@@ -112,12 +112,32 @@
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width /* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the (capped at the portrait size) and shrink below it when the panel is narrow, instead of the
viewport-width measure that would overflow seven tiles in a column. */ viewport-width measure that would overflow seven tiles in a column. */
.rack.landscape {
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
resolves unreliably, so the rack — which has a definite width — is the query container. A
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
container-type: inline-size;
}
.rack.landscape .tile { .rack.landscape .tile {
flex: 1 1 0; /* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
width: auto; so placing a tile leaves the remaining ones put instead of growing them to refill the row —
min-width: 0; matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
tiles leave. */
flex: 0 0 auto;
width: calc((100% - 6 * 5px) / 7);
max-width: 46px; max-width: 46px;
} }
.rack.landscape .letter {
font-size: 6cqw;
}
.rack.landscape .val {
font-size: 3.1cqw;
}
.rack.landscape .star {
font-size: 7.6cqw;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one /* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */ tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile { .rack.reordering .tile {
+1 -1
View File
@@ -301,7 +301,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG', 'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.addFriendShort': 'В друзья?', 'game.addFriendShort': 'В друзья?',
'game.blockShort': 'Блокируем?', 'game.blockShort': 'В бан?',
'time.minutes': '{n} мин', 'time.minutes': '{n} мин',
'time.hours': '{n} ч', 'time.hours': '{n} ч',