]> git.cworth.org Git - lmno.games/commitdiff
Allow for rearranging the rack while claiming a work
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 16:42:01 +0000 (12:42 -0400)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 16:42:01 +0000 (12:42 -0400)
This is implemented with both of DND and touch drag, (so should work
either a mouse or a touchscreen).

anagrams/anagrams.css
anagrams/anagrams.jsx

index 620465bc68c3a40ea42dfc016375e36acdb012af..d5255826b90f05bbf90f67324008ff9076b9916b 100644 (file)
   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;
index 712e21c962fd17422e1e5ce37a32ad3899c946f1..20cd3731b72f3c17dc86b4392cf3ce6e808c12ec 100644 (file)
@@ -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 {
           </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)}>
-                &#x2715;
+        <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)}>
+                    &#x2715;
+                  </span>
+                ) : null}
               </span>
-            </span>
-          ))}
+            );
+          })}
         </div>
 
         <div className="claim-actions">