From ce20965e2d014a186602a1e937e527a2773684b0 Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sun, 8 Mar 2026 12:42:01 -0400 Subject: [PATCH] Allow for rearranging the rack while claiming a work This is implemented with both of DND and touch drag, (so should work either a mouse or a touchscreen). --- anagrams/anagrams.css | 19 +++++ anagrams/anagrams.jsx | 172 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 182 insertions(+), 9 deletions(-) diff --git a/anagrams/anagrams.css b/anagrams/anagrams.css index 620465b..d525582 100644 --- a/anagrams/anagrams.css +++ b/anagrams/anagrams.css @@ -216,6 +216,25 @@ z-index: 1; } +/* Rack drag reordering */ +.rack-tile-wrapper.ghost .tile { + background: var(--accent-color-muted); + color: #aaa; +} + +.rack-tile-wrapper.ghost .remove-tile-btn { + display: none; +} + +.rack-drag-ghost { + position: fixed; + z-index: 200; + pointer-events: none; + opacity: 0.9; + transform: scale(1.1); + box-shadow: 2px 2px 8px rgba(0,0,0,0.3); +} + .separator { font-size: 18px; font-weight: bold; diff --git a/anagrams/anagrams.jsx b/anagrams/anagrams.jsx index 712e21c..20cd373 100644 --- a/anagrams/anagrams.jsx +++ b/anagrams/anagrams.jsx @@ -121,6 +121,8 @@ class Game extends React.Component { claim_error: null, claim_warning: false, claim_remaining_ms: 0, + rack_dragging: null, + rack_drop_target: null, /* Voting */ vote_pending: null, my_vote: null, @@ -1045,6 +1047,146 @@ class Game extends React.Component { ); } + /***************************************************** + * Rack tile reordering (mouse + touch drag) * + *****************************************************/ + + _rack_drag_begin(index, clientX, clientY, e) { + const el = e.target.closest(".rack-tile-wrapper"); + if (!el) return; + this._rack_drag = { + index, + startX: clientX, + startY: clientY, + dragging: false, + el, + offsetX: clientX - el.getBoundingClientRect().left, + offsetY: clientY - el.getBoundingClientRect().top + }; + } + + _rack_drag_move(clientX, clientY) { + const rd = this._rack_drag; + if (!rd) return; + + if (!rd.dragging) { + const dx = clientX - rd.startX; + const dy = clientY - rd.startY; + if (dx * dx + dy * dy < 25) return; + rd.dragging = true; + + const clone = rd.el.cloneNode(true); + clone.classList.add("rack-drag-ghost"); + clone.style.width = rd.el.offsetWidth + "px"; + clone.style.height = rd.el.offsetHeight + "px"; + document.body.appendChild(clone); + rd.ghost = clone; + + this.setState({ rack_dragging: rd.index }); + } + + rd.ghost.style.left = (clientX - rd.offsetX) + "px"; + rd.ghost.style.top = (clientY - rd.offsetY) + "px"; + + /* Use the center of the floating ghost to determine target. + * Compare only against non-ghost tiles to avoid feedback loops. */ + const ghostCX = clientX - rd.offsetX + rd.el.offsetWidth / 2; + + const rack_el = rd.el.closest(".claim-rack"); + if (!rack_el) return; + const wrappers = rack_el.querySelectorAll( + ".rack-tile-wrapper:not(.ghost)"); + let target = this.state.claim_rack.length; + /* Find insertion point among the non-dragged tiles. */ + let slot = wrappers.length; + for (let i = 0; i < wrappers.length; i++) { + const r = wrappers[i].getBoundingClientRect(); + if (ghostCX < r.left + r.width / 2) { + slot = i; + break; + } + } + /* Map slot back to original rack index. The non-ghost tiles are + * the original rack with the dragged entry removed. */ + const from = rd.index; + target = slot >= from ? slot + 1 : slot; + rd.targetIndex = target; + this.setState({ rack_drop_target: target }); + } + + _rack_drag_end() { + const rd = this._rack_drag; + if (!rd) return; + + if (rd.dragging && rd.ghost) { + rd.ghost.remove(); + const from = rd.index; + const to = rd.targetIndex; + if (from !== undefined && to !== undefined && from !== to) { + this.setState(prev => { + const rack = [...prev.claim_rack]; + const [entry] = rack.splice(from, 1); + const insert = to > from ? to - 1 : to; + rack.splice(insert, 0, entry); + return { claim_rack: rack }; + }); + } + } + + this._rack_drag = null; + this.setState({ rack_dragging: null, rack_drop_target: null }); + } + + on_rack_mouse_down(e, index) { + if (e.button !== 0) return; + this._rack_drag_begin(index, e.clientX, e.clientY, e); + + const onMove = (me) => { + this._rack_drag_move(me.clientX, me.clientY); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + this._rack_drag_end(); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + e.preventDefault(); + } + + on_rack_touch_start(e, index) { + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + this._rack_drag_begin(index, touch.clientX, touch.clientY, e); + e.preventDefault(); + } + + on_rack_touch_move(e) { + if (!this._rack_drag) return; + const touch = e.touches[0]; + this._rack_drag_move(touch.clientX, touch.clientY); + e.preventDefault(); + } + + on_rack_touch_end(e) { + this._rack_drag_end(); + } + + /* Return the rack in preview order during a drag, or as-is otherwise. + * The dragged entry is marked with _ghost and placed at target. */ + _rack_preview() { + const { claim_rack, rack_dragging, rack_drop_target } = this.state; + const entries = claim_rack.map((e, i) => ({ ...e, _orig_index: i })); + if (rack_dragging === null || rack_drop_target === null) return entries; + + const [dragged] = entries.splice(rack_dragging, 1); + dragged._ghost = true; + const insert = rack_drop_target > rack_dragging + ? rack_drop_target - 1 : rack_drop_target; + entries.splice(insert, 0, dragged); + return entries; + } + /* Check if a specific letter position in a stolen word is used. */ _is_word_letter_used(word_id, idx) { return !!this.state.claim_rack.find(e => @@ -1113,16 +1255,28 @@ class Game extends React.Component { ) : null} -
- {claim_rack.map((entry, i) => ( - - - this.return_rack_entry(entry, i)}> - ✕ +
this.on_rack_touch_move(e)} + onTouchEnd={(e) => this.on_rack_touch_end(e)}> + {this._rack_preview().map((entry, i) => { + const is_ghost = entry._ghost; + return ( + this.on_rack_touch_start(e, i)} + onMouseDown={(e) => this.on_rack_mouse_down(e, i)}> + + {!is_ghost ? ( + e.stopPropagation()} + onClick={() => this.return_rack_entry(entry, entry._orig_index)}> + ✕ + + ) : null} - - ))} + ); + })}
-- 2.45.2