]> git.cworth.org Git - lmno.games/commitdiff
Rework touch drag operation to render dragging tile as a React component
authorCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 22:06:06 +0000 (17:06 -0500)
committerCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 13:19:16 +0000 (08:19 -0500)
We encountered a bug where a touch drag operation was interrupted, by
some operation, leaving a ghost tile left around half-way through the
drag operation. One option to fix this would be to implement a
tounchCancel handler to clean things up.

But an alternate approach, in this commit, is to render the tile being
dragged as a React component. This way, everything is rendered
uniformly as React components, and we have less dependence on precise
browser semantics with respect to touchCanel, etc.

letterrip/letterrip.jsx

index 2fea89a15dc5b8eea8fbf5a53f76b7b6634cbf99..ee76f40da6341dc58e4fefb35f712f511ef30d24 100644 (file)
@@ -346,7 +346,8 @@ class Game extends React.Component {
       drag_source: null,
       drag_over_cell: null,
       rack_drag_over: false,
-      selected: null
+      selected: null,
+      touch_pos: null
     };
   }
 
@@ -355,6 +356,7 @@ class Game extends React.Component {
     this._onTouchEnd = (e) => this.on_touch_end(e);
     document.addEventListener("touchmove", this._onTouchMove, { passive: false });
     document.addEventListener("touchend", this._onTouchEnd);
+    document.addEventListener("touchcancel", this._onTouchEnd);
   }
 
   componentDidUpdate(prevProps, prevState) {
@@ -370,6 +372,7 @@ class Game extends React.Component {
   componentWillUnmount() {
     document.removeEventListener("touchmove", this._onTouchMove);
     document.removeEventListener("touchend", this._onTouchEnd);
+    document.removeEventListener("touchcancel", this._onTouchEnd);
   }
 
   /* Send tile placement changes to the server. */
@@ -645,105 +648,86 @@ class Game extends React.Component {
     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 });
+    this._touch_moved = false;
+    this.setState({
+      drag_source: source,
+      selected: null,
+      touch_pos: { x: touch.clientX, y: touch.clientY }
+    });
 
     e.preventDefault();
   }
 
   on_touch_move(e) {
-    if (!this._touch) return;
+    if (!this.state.touch_pos) return;
     const touch = e.touches[0];
-    const t = this._touch;
+    this._touch_moved = true;
 
-    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";
+    /* The floating tile has pointer-events: none so elementFromPoint
+     * sees through it without needing to hide/show anything. */
     const el = document.elementFromPoint(touch.clientX, touch.clientY);
-    t.clone.style.display = "";
+
+    const updates = { touch_pos: { x: touch.clientX, y: touch.clientY } };
 
     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 });
-        }
+        updates.drag_over_cell = key;
+        updates.rack_drag_over = false;
       } else if (rack) {
-        if (!this.state.rack_drag_over) {
-          this.setState({ drag_over_cell: null, rack_drag_over: true });
-        }
+        updates.drag_over_cell = null;
+        updates.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 });
-        }
+        updates.drag_over_cell = null;
+        updates.rack_drag_over = false;
       }
     }
 
+    this.setState(updates);
     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 (!this.state.touch_pos) return;
+    const source = this.state.drag_source;
 
     /* 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 });
+    if (!this._touch_moved) {
+      this.setState({ drag_source: null, drag_over_cell: null,
+                      rack_drag_over: false, touch_pos: null });
       return;
     }
 
     /* Find drop target. */
-    const touch = e.changedTouches[0];
-    const el = document.elementFromPoint(touch.clientX, touch.clientY);
+    const touch = e.changedTouches ? e.changedTouches[0] : null;
+    const el = touch ? document.elementFromPoint(touch.clientX, touch.clientY) : null;
 
-    if (el) {
+    if (el && source) {
       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") {
+        this._drop_touch(r, c, source);
+        this.setState({ touch_pos: null });
+        return;
+      } else if (rack && 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];
+        delete new_grid[source.grid_key];
+        const new_rack = [...this.state.rack, 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;
+                        drag_source: null, drag_over_cell: null,
+                        rack_drag_over: false, touch_pos: null });
         return;
       }
     }
 
-    this._touch = null;
-    this.setState({ drag_source: null, drag_over_cell: null, rack_drag_over: false });
+    this.setState({ drag_source: null, drag_over_cell: null,
+                    rack_drag_over: false, touch_pos: null });
   }
 
   _drop_touch(r, c, source) {
@@ -770,7 +754,7 @@ class Game extends React.Component {
     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 } });
+                      touch_pos: null, blank_pending: { r, c, tileIndex: tile_index } });
       return;
     }
 
@@ -780,7 +764,8 @@ class Game extends React.Component {
 
     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 });
+                    drag_source: null, drag_over_cell: null, rack_drag_over: false,
+                    touch_pos: null });
   }
 
   /*****************************************************
@@ -995,10 +980,44 @@ class Game extends React.Component {
             </div>
           ) : null}
         </div>
+      ) : null,
+
+      state.touch_pos && state.drag_source ? (
+        this.render_touch_tile()
       ) : null
     ];
   }
 
+  render_touch_tile() {
+    const { drag_source, touch_pos, tiles } = this.state;
+    const tile_index = drag_source.tile_index;
+    const tile_char = tiles[tile_index];
+    const is_blank = (tile_char === "_" || tile_char === undefined);
+
+    /* For blanks already on the grid, show their assigned letter. */
+    let letter;
+    if (drag_source.from === "grid") {
+      const cell = this.state.grid[drag_source.grid_key];
+      letter = cell ? cell.letter : (is_blank ? "\u00A0" : tile_char);
+    } else {
+      letter = is_blank ? "\u00A0" : tile_char;
+    }
+
+    return (
+      <div key="touch-tile"
+           className="tile touch-dragging"
+           style={{
+             position: "fixed",
+             left: touch_pos.x - 22,
+             top: touch_pos.y - 22,
+             zIndex: 200,
+             pointerEvents: "none"
+           }}>
+        {letter}
+      </div>
+    );
+  }
+
   render_board(analysis, invalid_cells, unconnected_cells) {
     const { grid, drag_over_cell, drag_source, selected } = this.state;
     const bounds = grid_bounds(grid);