Files
scrabble-game/ui/src/game/Rack.svelte
T
Ilia Denisov 2b0b1c0035
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Stage 17 round 6 (#3): drag-reorder rack tiles with a visual gap
Dragging a rack tile and dropping it back on the rack reorders it: the dragged tile is
lifted out (the drag ghost stands in) and the tiles at/after the pointer's drop slot slide
right to open a gap there, so the drop position is visible. On drop the rack and its stable
ids are permuted (reorderIndices, unit-tested). Reorder applies only with no pending tiles,
so it stays a clean permutation; dropping on a board cell still places as before. Server
persistence of the order follows (#4).
2026-06-07 12:21:09 +02:00

120 lines
4.1 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';
let {
slots,
variant,
selected,
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;
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 (Stage 17).
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} 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)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<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;
}
.tile.selected {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* 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;
}
</style>