a88678c5c6
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m35s
Follow-up to the contour review of the landscape fixes: - Zoom still positioned "in two steps": a wide viewport overflows vertically as soon as the square board grows past its height, but horizontally only once it grows past the (much wider) width — so the browser pins scrollLeft to 0 while the vertical axis already pans (no time/ease tween fixes it; the horizontal scroll range physically is not there yet). Found by instrumenting the scroll trajectory. Now drive both axes by one progress = how far the board has grown past the viewport width (when a horizontal pan first becomes possible): until then the board just zooms centred, past it both axes pan together in one diagonal motion. Also disable scroll-anchoring so the browser stops fighting the programmatic scroll mid-transition. Re-verified: both axes now start and move together. - Rack tiles resized when a tile was placed: landscape used flex-grow, so removing a tile regrew the rest. Give them a fixed size (1/7 of the fixed-width rack), like the portrait rack, so placing a tile leaves the rest put.
173 lines
6.5 KiB
Svelte
173 lines
6.5 KiB
Svelte
<script lang="ts">
|
||
import type { RackSlot } from '../lib/placement';
|
||
import { BLANK } from '../lib/placement';
|
||
import { valueForLetter } from '../lib/alphabet';
|
||
import type { Variant } from '../lib/model';
|
||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||
|
||
let {
|
||
slots,
|
||
variant,
|
||
selected,
|
||
landscape = false,
|
||
shuffling = false,
|
||
draggingId = null,
|
||
dropIndex = null,
|
||
ondown,
|
||
}: {
|
||
// Each slot carries a stable id that travels with its tile through a shuffle, so the
|
||
// keyed list reorders (rather than relabelling in place) and the hop animation fires.
|
||
slots: (RackSlot & { id: number })[];
|
||
variant: Variant;
|
||
selected: number | null;
|
||
/** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the
|
||
* viewport-width measure, so seven tiles never overflow the panel. */
|
||
landscape?: boolean;
|
||
shuffling?: boolean;
|
||
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||
draggingId?: number | null;
|
||
dropIndex?: number | null;
|
||
ondown: (e: PointerEvent, index: number) => void;
|
||
} = $props();
|
||
|
||
// Used slots are hidden (the rack shifts left, freeing room on the right for the
|
||
// MakeMove control); the slot still exists in the model for per-tile recall. While
|
||
// reordering, the dragged tile is lifted out (the ghost shows it).
|
||
const visible = $derived(slots.filter((s) => !s.used));
|
||
const shown = $derived(draggingId == null ? visible : visible.filter((s) => s.id !== draggingId));
|
||
|
||
// hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile
|
||
// height). The duration scales with the horizontal distance — i.e. the arc length — so
|
||
// the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only
|
||
// while a shuffle is in progress (and motion is not reduced); ordinary reflow
|
||
// (placing/recalling a tile) is instant.
|
||
function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) {
|
||
const dx = from.left - to.left;
|
||
const dy = from.top - to.top;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (!active || dist < 2) return { duration: 0 };
|
||
const span = node.parentElement?.getBoundingClientRect().width || dist;
|
||
const lift = (to.height || from.height) * 0.5;
|
||
return {
|
||
duration: Math.max(120, Math.min(300, (dist / span) * 340)),
|
||
css: (t: number, u: number) =>
|
||
`transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`,
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
|
||
{#each shown as slot, i (slot.id)}
|
||
<button
|
||
class="tile"
|
||
class:selected={selected === slot.index}
|
||
class:shift={dropIndex != null && i >= dropIndex}
|
||
data-rack-index={slot.index}
|
||
animate:hop={shuffling}
|
||
onpointerdown={(e) => ondown(e, slot.index)}
|
||
>
|
||
{#if slot.letter === BLANK}
|
||
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
|
||
{:else}
|
||
<span class="letter">{slot.letter}</span>
|
||
<span class="val">{valueForLetter(variant, slot.letter)}</span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
|
||
<style>
|
||
.rack {
|
||
display: flex;
|
||
gap: 5px;
|
||
align-items: center;
|
||
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
|
||
footer the same size as during play — no layout jump between states. */
|
||
min-height: min(12.5vw, 46px);
|
||
}
|
||
.tile {
|
||
position: relative;
|
||
flex: 0 0 auto;
|
||
width: min(12.5vw, 46px);
|
||
aspect-ratio: 1;
|
||
background: var(--tile-bg);
|
||
color: var(--tile-text);
|
||
border: none;
|
||
border-radius: 5px;
|
||
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
||
font-weight: 700;
|
||
font-size: 1.4rem;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
|
||
dragged tile's slot; suppress it so only our own styles mark a tile. */
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.tile.selected {
|
||
outline: 3px solid var(--accent);
|
||
outline-offset: -3px;
|
||
}
|
||
/* 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
|
||
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 {
|
||
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
||
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
||
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;
|
||
}
|
||
.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
|
||
tile width plus the rack gap), so the drop position is visible. */
|
||
.rack.reordering .tile {
|
||
transition: transform 0.14s ease;
|
||
}
|
||
.tile.shift {
|
||
transform: translateX(calc(100% + 5px));
|
||
}
|
||
.letter {
|
||
position: absolute;
|
||
top: 8%;
|
||
left: 14%;
|
||
}
|
||
.val {
|
||
position: absolute;
|
||
right: 4px;
|
||
bottom: 1px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
}
|
||
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
||
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
|
||
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
|
||
.star {
|
||
position: absolute;
|
||
top: calc(0.5% - 1px);
|
||
left: 0;
|
||
right: 0;
|
||
text-align: center;
|
||
font-size: 1.7rem;
|
||
}
|
||
</style>
|