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";
draggable="true"
onDragStart={onDragStart}
onDragEnd={onDragEnd}
- onClick={onClick}>
+ onClick={onClick}
+ onTouchStart={onTouchStart}>
{letter}
</div>
);
};
}
+ 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) *
*****************************************************/
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 *
*****************************************************/
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 })}
&& 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>
);
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)}
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 ? (