From: Carl Worth Date: Fri, 6 Mar 2026 14:00:37 +0000 (-0500) Subject: Add a "touch drag" interface to the game X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=76a08eb0aaad955470827c7eb0ac4fb7fcaa44a3;p=lmno.games Add a "touch drag" interface to the game 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). --- diff --git a/letterrip/letterrip.css b/letterrip/letterrip.css index 36e7dd6..fc5a67c 100644 --- a/letterrip/letterrip.css +++ b/letterrip/letterrip.css @@ -112,6 +112,12 @@ 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; diff --git a/letterrip/letterrip.jsx b/letterrip/letterrip.jsx index 06d2c15..4d506c7 100644 --- a/letterrip/letterrip.jsx +++ b/letterrip/letterrip.jsx @@ -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} ); @@ -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(
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}
); @@ -811,6 +974,7 @@ class Game extends React.Component { return (
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 ? (