claim_error: null,
claim_warning: false,
claim_remaining_ms: 0,
+ rack_dragging: null,
+ rack_drop_target: null,
/* Voting */
vote_pending: null,
my_vote: null,
);
}
+ /*****************************************************
+ * 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 =>
</div>
) : null}
- <div className="claim-rack">
- {claim_rack.map((entry, i) => (
- <span key={i} className="rack-tile-wrapper">
- <Tile letter={entry.letter} />
- <span className="remove-tile-btn"
- onClick={() => this.return_rack_entry(entry, i)}>
- ✕
+ <div className="claim-rack"
+ onTouchMove={(e) => 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 (
+ <span key={i}
+ className={"rack-tile-wrapper"
+ + (is_ghost ? " ghost" : "")}
+ onTouchStart={(e) => this.on_rack_touch_start(e, i)}
+ onMouseDown={(e) => this.on_rack_mouse_down(e, i)}>
+ <Tile letter={entry.letter} />
+ {!is_ghost ? (
+ <span className="remove-tile-btn"
+ onMouseDown={(e) => e.stopPropagation()}
+ onClick={() => this.return_rack_entry(entry, entry._orig_index)}>
+ ✕
+ </span>
+ ) : null}
</span>
- </span>
- ))}
+ );
+ })}
</div>
<div className="claim-actions">