]> git.cworth.org Git - lmno.games/commitdiff
Add a "touch drag" interface to the game
authorCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 14:00:37 +0000 (09:00 -0500)
committerCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 16:29:48 +0000 (11:29 -0500)
This sits alongside the existing HTML5 DND interface.

The new touch interface is _much_ more friendly on a phone than the
previous HTMl DND interface, (which required long pressing before
it would do anything).

letterrip/letterrip.css
letterrip/letterrip.jsx

index 36e7dd6d01432ff36b37bfe5960dbfef1c475736..fc5a67c48c89933d2ad49763a63bc5f8286d3888 100644 (file)
   opacity: 0.4;
 }
 
+.tile.touch-dragging {
+  opacity: 0.9;
+  box-shadow: 2px 2px 8px rgba(0,0,0,0.3);
+  transform: scale(1.1);
+}
+
 .tile.invalid {
   color: #c0392b;
   border-color: #c0392b;
index 06d2c1544efafb9278ac767f92c88d17114b56d9..4d506c73ab2ff69c7ad9afef370134915a3f10d8 100644 (file)
@@ -289,7 +289,7 @@ function BlankTileModal(props) {
 
 function Tile(props) {
   const { letter, isBlank, invalid, unconnected, selected,
-          onDragStart, onDragEnd, onClick } = props;
+          onDragStart, onDragEnd, onClick, onTouchStart } = props;
   let className = "tile";
   if (isBlank) className += " blank";
   if (invalid) className += " invalid";
@@ -301,7 +301,8 @@ function Tile(props) {
          draggable="true"
          onDragStart={onDragStart}
          onDragEnd={onDragEnd}
-         onClick={onClick}>
+         onClick={onClick}
+         onTouchStart={onTouchStart}>
       {letter}
     </div>
   );
@@ -332,6 +333,18 @@ class Game extends React.Component {
     };
   }
 
+  componentDidMount() {
+    this._onTouchMove = (e) => this.on_touch_move(e);
+    this._onTouchEnd = (e) => this.on_touch_end(e);
+    document.addEventListener("touchmove", this._onTouchMove, { passive: false });
+    document.addEventListener("touchend", this._onTouchEnd);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("touchmove", this._onTouchMove);
+    document.removeEventListener("touchend", this._onTouchEnd);
+  }
+
   /*****************************************************
    * SSE event handlers (called from window.game)      *
    *****************************************************/
@@ -540,6 +553,153 @@ class Game extends React.Component {
     this.setState({ drag_source: null, drag_over_cell: null, rack_drag_over: false });
   }
 
+  /*****************************************************
+   * Touch drag (mobile)                               *
+   *****************************************************/
+
+  on_touch_start(e, source) {
+    /* Only handle single-finger touch. */
+    if (e.touches.length !== 1) return;
+
+    const touch = e.touches[0];
+    const target = e.currentTarget;
+    const rect = target.getBoundingClientRect();
+
+    /* Create a floating clone to follow the finger. */
+    const clone = target.cloneNode(true);
+    clone.className = "tile touch-dragging";
+    clone.style.position = "fixed";
+    clone.style.zIndex = "200";
+    clone.style.pointerEvents = "none";
+    clone.style.width = rect.width + "px";
+    clone.style.height = rect.height + "px";
+    clone.style.left = (touch.clientX - rect.width / 2) + "px";
+    clone.style.top = (touch.clientY - rect.height / 2) + "px";
+    document.body.appendChild(clone);
+
+    this._touch = { source, clone, startX: touch.clientX, startY: touch.clientY, moved: false };
+    this.setState({ drag_source: source, selected: null });
+
+    e.preventDefault();
+  }
+
+  on_touch_move(e) {
+    if (!this._touch) return;
+    const touch = e.touches[0];
+    const t = this._touch;
+
+    t.clone.style.left = (touch.clientX - 22) + "px";
+    t.clone.style.top = (touch.clientY - 22) + "px";
+    t.moved = true;
+
+    /* Find the element under the finger (hide clone so elementFromPoint
+     * sees through it). */
+    t.clone.style.display = "none";
+    const el = document.elementFromPoint(touch.clientX, touch.clientY);
+    t.clone.style.display = "";
+
+    if (el) {
+      const cell = el.closest("[data-key]");
+      const rack = el.closest("[data-rack]");
+      if (cell) {
+        const key = cell.getAttribute("data-key");
+        if (this.state.drag_over_cell !== key) {
+          this.setState({ drag_over_cell: key, rack_drag_over: false });
+        }
+      } else if (rack) {
+        if (!this.state.rack_drag_over) {
+          this.setState({ drag_over_cell: null, rack_drag_over: true });
+        }
+      } else {
+        if (this.state.drag_over_cell || this.state.rack_drag_over) {
+          this.setState({ drag_over_cell: null, rack_drag_over: false });
+        }
+      }
+    }
+
+    e.preventDefault();
+  }
+
+  on_touch_end(e) {
+    if (!this._touch) return;
+    const t = this._touch;
+
+    /* Clean up the floating clone. */
+    if (t.clone.parentNode) t.clone.parentNode.removeChild(t.clone);
+
+    /* If the finger barely moved, treat as a tap (let onClick handle it). */
+    if (!t.moved) {
+      this._touch = null;
+      this.setState({ drag_source: null, drag_over_cell: null, rack_drag_over: false });
+      return;
+    }
+
+    /* Find drop target. */
+    const touch = e.changedTouches[0];
+    const el = document.elementFromPoint(touch.clientX, touch.clientY);
+
+    if (el) {
+      const cell = el.closest("[data-key]");
+      const rack = el.closest("[data-rack]");
+
+      if (cell) {
+        const key = cell.getAttribute("data-key");
+        const [r, c] = key.split(",").map(Number);
+        /* Reuse existing drop logic. */
+        this._drop_touch(r, c, t.source);
+      } else if (rack && t.source.from === "grid") {
+        /* Drop grid tile back to rack. */
+        const new_grid = {...this.state.grid};
+        delete new_grid[t.source.grid_key];
+        const new_rack = [...this.state.rack, t.source.tile_index];
+        this.setState({ grid: new_grid, rack: new_rack,
+                        drag_source: null, drag_over_cell: null, rack_drag_over: false });
+        this._touch = null;
+        return;
+      }
+    }
+
+    this._touch = null;
+    this.setState({ drag_source: null, drag_over_cell: null, rack_drag_over: false });
+  }
+
+  _drop_touch(r, c, source) {
+    const grid_key = r + "," + c;
+
+    /* Don't drop on an occupied cell (unless it's the source). */
+    if (this.state.grid[grid_key] && grid_key !== source.grid_key) {
+      return;
+    }
+
+    const tile_index = source.tile_index;
+    const tile_char = this.state.tiles[tile_index];
+    const is_blank = tile_char === "_";
+
+    const new_grid = {...this.state.grid};
+    let new_rack = [...this.state.rack];
+
+    if (source.from === "rack") {
+      new_rack = new_rack.filter((_, i) => i !== source.rack_index);
+    } else if (source.from === "grid") {
+      delete new_grid[source.grid_key];
+    }
+
+    if (is_blank && (!this.state.grid[source.grid_key] || !this.state.grid[source.grid_key].letter)) {
+      this.setState({ grid: new_grid, rack: new_rack,
+                      drag_source: null, drag_over_cell: null, rack_drag_over: false,
+                      blank_pending: { r, c, tileIndex: tile_index } });
+      return;
+    }
+
+    const existing = source.from === "grid" ? this.state.grid[source.grid_key] : null;
+    const letter = existing ? existing.letter : tile_char;
+    const isBlank = existing ? existing.isBlank : is_blank;
+
+    new_grid[grid_key] = { letter, tileIndex: tile_index, isBlank };
+    this.setState({ grid: new_grid, rack: new_rack,
+                    drag_source: null, drag_over_cell: null, rack_drag_over: false });
+  }
+
   /*****************************************************
    * Blank tile handling                               *
    *****************************************************/
@@ -770,6 +930,7 @@ class Game extends React.Component {
 
         cells.push(
           <div key={key}
+               data-key={key}
                className={className}
                onDragOver={(e) => this.on_cell_drag_over(e, key)}
                onDragLeave={() => this.setState({ drag_over_cell: null })}
@@ -784,7 +945,9 @@ class Game extends React.Component {
                               && selected.grid_key === key}
                     onDragStart={(e) => this.on_grid_drag_start(e, key)}
                     onDragEnd={() => this.on_drag_end()}
-                    onClick={() => this.on_grid_tile_tap(key)} />
+                    onClick={() => this.on_grid_tile_tap(key)}
+                    onTouchStart={(e) => this.on_touch_start(e,
+                      { from: "grid", grid_key: key, tile_index: cell.tileIndex })} />
             ) : null}
           </div>
         );
@@ -811,6 +974,7 @@ class Game extends React.Component {
 
     return (
       <div key="rack"
+           data-rack="true"
            className={className}
            onDragOver={(e) => this.on_rack_drag_over(e)}
            onDragLeave={(e) => this.on_rack_drag_leave(e)}
@@ -837,7 +1001,9 @@ class Game extends React.Component {
                   selected={is_selected}
                   onDragStart={(e) => this.on_rack_drag_start(e, rack_index)}
                   onDragEnd={() => this.on_drag_end()}
-                  onClick={() => this.on_rack_tile_tap(rack_index)} />
+                  onClick={() => this.on_rack_tile_tap(rack_index)}
+                  onTouchStart={(e) => this.on_touch_start(e,
+                    { from: "rack", rack_index, tile_index })} />
           );
         })}
         {can_complete ? (