From: Carl Worth Date: Fri, 6 Mar 2026 22:06:06 +0000 (-0500) Subject: Rework touch drag operation to render dragging tile as a React component X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=e240e05894a4e22b443218be8a10cec5d57bb868;p=lmno.games Rework touch drag operation to render dragging tile as a React component 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. --- diff --git a/letterrip/letterrip.jsx b/letterrip/letterrip.jsx index 2fea89a..ee76f40 100644 --- a/letterrip/letterrip.jsx +++ b/letterrip/letterrip.jsx @@ -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 { ) : null} + ) : 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 ( +
+ {letter} +
+ ); + } + render_board(analysis, invalid_cells, unconnected_cells) { const { grid, drag_over_cell, drag_source, selected } = this.state; const bounds = grid_bounds(grid);