Merge pull request 'fix(ui): landscape board zoom/pan + rack tile rendering + mobile block label' (#141) from fix/landscape-board-ui into development
This commit was merged in pull request #141.
This commit is contained in:
+86
-11
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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} ч',
|
||||||
|
|||||||
Reference in New Issue
Block a user