|
|
|
@@ -105,18 +105,24 @@
|
|
|
|
|
prevZoom = on;
|
|
|
|
|
prevRecenter = rc;
|
|
|
|
|
if (landscape) {
|
|
|
|
|
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board
|
|
|
|
|
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
|
|
|
|
|
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
|
|
|
|
|
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
|
|
|
|
|
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it
|
|
|
|
|
// stops overflowing.)
|
|
|
|
|
// Height-driven board in a wide viewport: pan so the focused cell is centred. Interpolate
|
|
|
|
|
// BOTH scroll axes together by TIME over the board's grow/shrink transition; the browser
|
|
|
|
|
// clamps each to the current scrollable range as the board resizes. The earlier per-frame
|
|
|
|
|
// clamp-to-max reached the wide axis (large clientWidth) before the tall one, which the owner
|
|
|
|
|
// saw as the board positioning "in two steps" — a single time-eased motion moves the axes in
|
|
|
|
|
// 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)) {
|
|
|
|
|
const startSL = vp.scrollLeft;
|
|
|
|
|
const startST = vp.scrollTop;
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
const dur = 250; // ~the .scaler.land width/height transition (0.25s ease)
|
|
|
|
|
let raf = requestAnimationFrame(function tick(now: number) {
|
|
|
|
|
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
|
|
|
|
|
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
|
|
|
|
|
if (now - t0 < 320) raf = requestAnimationFrame(tick);
|
|
|
|
|
const p = Math.min(1, (now - t0) / dur);
|
|
|
|
|
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic, close to the CSS ease curve
|
|
|
|
|
vp.scrollLeft = startSL + (finalSL - startSL) * e;
|
|
|
|
|
vp.scrollTop = startST + (finalST - startST) * e;
|
|
|
|
|
if (p < 1) raf = requestAnimationFrame(tick);
|
|
|
|
|
});
|
|
|
|
|
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
|
|
|
|
|
// 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
|
|
|
|
|