release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master) #144

Merged
developer merged 15 commits from development into master 2026-06-30 05:37:43 +00:00
3 changed files with 96 additions and 10 deletions
Showing only changes of commit 500a01cf97 - Show all commits
+78 -9
View File
@@ -105,18 +105,24 @@
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 // Height-driven board in a wide viewport: pan so the focused cell is centred. Interpolate
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target // BOTH scroll axes together by TIME over the board's grow/shrink transition; the browser
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each // clamps each to the current scrollable range as the board resizes. The earlier per-frame
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like // clamp-to-max reached the wide axis (large clientWidth) before the tall one, which the owner
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it // saw as the board positioning "in two steps" — a single time-eased motion moves the axes in
// stops overflowing.) // sync. Time-based (not a scrollWidth-progress tween like portrait's) so it also settles on
// zoom-out, where the board shrinks below the viewport and stops overflowing.
if (toggled || (recentered && on && f)) { if (toggled || (recentered && on && f)) {
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
const t0 = performance.now(); const t0 = performance.now();
const dur = 250; // ~the .scaler.land width/height transition (0.25s ease)
let raf = requestAnimationFrame(function tick(now: number) { let raf = requestAnimationFrame(function tick(now: number) {
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth)); const p = Math.min(1, (now - t0) / dur);
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight)); const e = 1 - Math.pow(1 - p, 3); // easeOutCubic, close to the CSS ease curve
if (now - t0 < 320) raf = requestAnimationFrame(tick); vp.scrollLeft = startSL + (finalSL - startSL) * e;
vp.scrollTop = startST + (finalST - startST) * e;
if (p < 1) raf = requestAnimationFrame(tick);
}); });
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
} }
@@ -204,6 +210,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
+17
View File
@@ -112,12 +112,29 @@
/* 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; flex: 1 1 0;
width: auto; width: auto;
min-width: 0; min-width: 0;
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} ч',