Stage 17 round 6 (#3): drag-reorder rack tiles with a visual gap
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

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).
This commit is contained in:
Ilia Denisov
2026-06-07 12:21:09 +02:00
parent 35666e1705
commit 2b0b1c0035
4 changed files with 106 additions and 7 deletions
+63 -4
View File
@@ -28,6 +28,7 @@
placementFromHint,
rackView,
recallAt,
reorderIndices,
reset,
toSubmit,
type Placement,
@@ -193,6 +194,11 @@
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
// Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -214,6 +220,7 @@
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
downInfo = null;
dragMoved = false;
drag = null;
@@ -241,6 +248,33 @@
hoverKey = '';
dropTarget = null;
}
function clearReorder() {
reorderDragId = null;
reorderTo = null;
}
// overRack reports whether y is within the rack's row (a small margin makes the target
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
function overRack(y: number): boolean {
const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
return !!r && y >= r.top - 24 && y <= r.bottom + 24;
}
function dropSlotAt(x: number): number {
const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
for (let i = 0; i < tiles.length; i++) {
const r = tiles[i].getBoundingClientRect();
if (x < r.left + r.width / 2) return i;
}
return tiles.length;
}
// reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
// its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
function reorderRack(fromIndex: number, toSlot: number) {
if (placement.pending.length > 0) return;
const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
@@ -249,15 +283,26 @@
const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
// No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
}
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
// Highlight the aimed-at cell as a drop target, but only when it is free (no committed
// or pending tile there).
dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
// Preview where the drop lands: a drop-target ring on a free board cell, or — for a
// rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
if (c) {
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
reorderTo = null;
} else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) {
reorderTo = dropSlotAt(e.clientX);
dropTarget = null;
} else {
dropTarget = null;
reorderTo = null;
}
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
@@ -287,8 +332,12 @@
drag = null;
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
const cell = cellUnder(e.clientX, e.clientY);
const to = reorderTo;
if (di.src.from === 'rack' && cell) {
attemptPlace(di.src.index, cell.row, cell.col);
} else if (di.src.from === 'rack' && onRack && to != null) {
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
reorderRack(di.src.index, to);
} else if (di.src.from === 'board' && onRack) {
// Dropped a pending tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col);
@@ -302,12 +351,14 @@
} else {
drag = null;
}
clearReorder();
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
telegramClosingConfirmation(false);
});
@@ -667,7 +718,15 @@
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack slots={rackSlots} {variant} {selected} shuffling={shuffling && !app.reduceMotion} ondown={onRackDown} />
<Rack
slots={rackSlots}
{variant}
{selected}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
+20 -3
View File
@@ -9,6 +9,8 @@
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
@@ -17,12 +19,18 @@
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.
// 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
@@ -44,11 +52,12 @@
}
</script>
<div class="rack" data-rack>
{#each visible as slot (slot.id)}
<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)}
@@ -87,6 +96,14 @@
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%;
+10
View File
@@ -10,6 +10,7 @@ import {
rackView,
recallAt,
recallIndex,
reorderIndices,
reset,
toSubmit,
} from './placement';
@@ -122,3 +123,12 @@ describe('placementFromHint', () => {
expect(p.pending.map((t) => t.letter)).toEqual(['A']);
});
});
describe('reorderIndices', () => {
it('lifts an element and drops it at the given slot among the others', () => {
expect(reorderIndices(4, 0, 2)).toEqual([1, 2, 0, 3]);
expect(reorderIndices(4, 3, 0)).toEqual([3, 0, 1, 2]);
expect(reorderIndices(4, 1, 1)).toEqual([0, 1, 2, 3]); // back to identity
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
});
});
+13
View File
@@ -106,6 +106,19 @@ export function reset(p: Placement): Placement {
return { ...p, pending: [] };
}
/**
* reorderIndices returns the permutation of [0, n) that lifts the element at `from` and
* drops it at slot `toSlot` among the remaining elements (clamped to a valid slot). It is
* applied in parallel to the rack letters and their stable ids when a tile is dragged to a
* new rack position.
*/
export function reorderIndices(n: number, from: number, toSlot: number): number[] {
const order: number[] = [];
for (let i = 0; i < n; i++) if (i !== from) order.push(i);
order.splice(Math.max(0, Math.min(toSlot, order.length)), 0, from);
return order;
}
/**
* direction infers the play orientation from the pending tiles: H if they share a row,
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).