]> git.cworth.org Git - lmno.games/commitdiff
Add a tap-to-select/tap-to-place interface
authorCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 13:30:46 +0000 (08:30 -0500)
committerCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 16:29:48 +0000 (11:29 -0500)
This sits alongside the existing drag-and-drop interace, so either
can be used.

letterrip/letterrip.css
letterrip/letterrip.jsx

index 27498cef488cf1b97d3224016789631d8e0ab409..2c4a6fecc0f3c38bf8139f2b611201d434d57916 100644 (file)
   border-style: dashed;
 }
 
+.tile.selected {
+  outline: 3px solid #3498db;
+  outline-offset: -1px;
+  transform: scale(1.1);
+  z-index: 1;
+}
+
 .tile.dragging {
   opacity: 0.4;
 }
index da78d90da13c4e67aa674a83234df1c51728bab1..f52463652389222f568100fc0f1de5c5f0f38606 100644 (file)
@@ -288,11 +288,13 @@ function BlankTileModal(props) {
 }
 
 function Tile(props) {
-  const { letter, isBlank, invalid, unconnected, onDragStart, onDragEnd, onClick } = props;
+  const { letter, isBlank, invalid, unconnected, selected,
+          onDragStart, onDragEnd, onClick } = props;
   let className = "tile";
   if (isBlank) className += " blank";
   if (invalid) className += " invalid";
   if (unconnected) className += " unconnected";
+  if (selected) className += " selected";
 
   return (
     <div className={className}
@@ -325,7 +327,8 @@ class Game extends React.Component {
       drag_source: null,
       drag_over_cell: null,
       rack_drag_over: false,
-      grid_bounds: null
+      grid_bounds: null,
+      selected: null
     };
   }
 
@@ -420,14 +423,14 @@ class Game extends React.Component {
     const tile_index = this.state.rack[rack_index];
     e.dataTransfer.effectAllowed = "move";
     e.dataTransfer.setData("text/plain", "");
-    this.setState({ drag_source: { from: "rack", rack_index, tile_index } });
+    this.setState({ drag_source: { from: "rack", rack_index, tile_index }, selected: null });
   }
 
   on_grid_drag_start(e, grid_key) {
     const cell = this.state.grid[grid_key];
     e.dataTransfer.effectAllowed = "move";
     e.dataTransfer.setData("text/plain", "");
-    this.setState({ drag_source: { from: "grid", grid_key, tile_index: cell.tileIndex } });
+    this.setState({ drag_source: { from: "grid", grid_key, tile_index: cell.tileIndex }, selected: null });
   }
 
   on_cell_drag_over(e, grid_key) {
@@ -548,13 +551,126 @@ class Game extends React.Component {
     this.setState({ grid: new_grid, blank_pending: null });
   }
 
-  /* Click a blank tile on the grid to reassign its letter. */
-  on_blank_click(grid_key) {
+  /*****************************************************
+   * Tap to select / tap to place                      *
+   *****************************************************/
+
+  on_rack_tile_tap(rack_index) {
+    const tile_index = this.state.rack[rack_index];
+    const sel = this.state.selected;
+
+    /* If tapping the already-selected rack tile, deselect. */
+    if (sel && sel.from === "rack" && sel.tile_index === tile_index) {
+      this.setState({ selected: null });
+      return;
+    }
+
+    /* If a grid tile is selected, tapping a rack tile swaps selection. */
+    this.setState({ selected: { from: "rack", rack_index, tile_index } });
+  }
+
+  on_grid_tile_tap(grid_key) {
+    const sel = this.state.selected;
     const cell = this.state.grid[grid_key];
-    if (!cell || !cell.isBlank) return;
+
+    /* If tapping the already-selected grid tile, deselect. */
+    if (sel && sel.from === "grid" && sel.grid_key === grid_key) {
+      this.setState({ selected: null });
+      return;
+    }
+
+    /* If a tile is selected and we tap a different grid tile, place
+     * the selected tile and pick up the tapped one (swap). But only
+     * if the selected tile comes from the rack — for grid-to-grid,
+     * just switch selection. */
+    if (sel && sel.from === "rack") {
+      /* Place selected rack tile here, displacing this grid tile back
+       * to rack. This is a swap. */
+      this.place_selected_on_grid(grid_key);
+      return;
+    }
+
+    /* If a blank is tapped on the grid, allow reassigning its letter. */
+    if (cell && cell.isBlank) {
+      const [r, c] = grid_key.split(",").map(Number);
+      this.setState({
+        blank_pending: { r, c, tileIndex: cell.tileIndex }
+      });
+      return;
+    }
+
+    /* Select this grid tile. */
+    this.setState({
+      selected: { from: "grid", grid_key, tile_index: cell.tileIndex }
+    });
+  }
+
+  on_cell_tap(r, c) {
+    const sel = this.state.selected;
+    if (!sel) return;
+
+    const grid_key = r + "," + c;
+
+    /* Don't place on an occupied cell. */
+    if (this.state.grid[grid_key]) return;
+
+    this.place_selected_on_grid(grid_key);
+  }
+
+  on_rack_tap() {
+    const sel = this.state.selected;
+    if (!sel || sel.from !== "grid") return;
+
+    /* Return selected grid tile to rack. */
+    const new_grid = {...this.state.grid};
+    delete new_grid[sel.grid_key];
+    const new_rack = [...this.state.rack, sel.tile_index];
+
+    this.setState({
+      grid: new_grid,
+      rack: new_rack,
+      selected: null
+    });
+  }
+
+  place_selected_on_grid(grid_key) {
+    const sel = this.state.selected;
+    const tile_index = sel.tile_index;
+    const tile_char = this.state.tiles[tile_index];
+    const is_blank = tile_char === "_";
     const [r, c] = grid_key.split(",").map(Number);
+
+    const new_grid = {...this.state.grid};
+    let new_rack = [...this.state.rack];
+
+    if (sel.from === "rack") {
+      new_rack = new_rack.filter((_, i) => i !== sel.rack_index);
+    } else if (sel.from === "grid") {
+      delete new_grid[sel.grid_key];
+    }
+
+    /* Blank tile that hasn't been assigned a letter — show modal. */
+    if (is_blank && !(sel.from === "grid" && this.state.grid[sel.grid_key] && this.state.grid[sel.grid_key].letter)) {
+      this.setState({
+        grid: new_grid,
+        rack: new_rack,
+        selected: null,
+        blank_pending: { r, c, tileIndex: tile_index }
+      });
+      return;
+    }
+
+    /* Preserve existing letter assignment for blanks being moved. */
+    const existing = sel.from === "grid" ? this.state.grid[sel.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({
-      blank_pending: { r, c, tileIndex: cell.tileIndex }
+      grid: new_grid,
+      rack: new_rack,
+      selected: null
     });
   }
 
@@ -635,7 +751,7 @@ class Game extends React.Component {
   }
 
   render_board(analysis, invalid_cells, unconnected_cells) {
-    const { grid, drag_over_cell, drag_source } = this.state;
+    const { grid, drag_over_cell, drag_source, selected } = this.state;
     const bounds = grid_bounds(grid, this.state.grid_bounds);
     /* Store bounds so the grid only grows, never shrinks. */
     if (JSON.stringify(bounds) !== JSON.stringify(this.state.grid_bounds)) {
@@ -657,15 +773,18 @@ class Game extends React.Component {
                className={className}
                onDragOver={(e) => this.on_cell_drag_over(e, key)}
                onDragLeave={() => this.setState({ drag_over_cell: null })}
-               onDrop={(e) => this.on_cell_drop(e, r, c)}>
+               onDrop={(e) => this.on_cell_drop(e, r, c)}
+               onClick={!cell && selected ? () => this.on_cell_tap(r, c) : null}>
             {cell ? (
               <Tile letter={cell.letter}
                     isBlank={cell.isBlank}
                     invalid={invalid_cells.has(key)}
                     unconnected={unconnected_cells.has(key)}
+                    selected={selected && selected.from === "grid"
+                              && selected.grid_key === key}
                     onDragStart={(e) => this.on_grid_drag_start(e, key)}
                     onDragEnd={() => this.on_drag_end()}
-                    onClick={cell.isBlank ? () => this.on_blank_click(key) : null} />
+                    onClick={() => this.on_grid_tile_tap(key)} />
             ) : null}
           </div>
         );
@@ -686,7 +805,7 @@ class Game extends React.Component {
   }
 
   render_rack(can_complete) {
-    const { rack, tiles, rack_drag_over } = this.state;
+    const { rack, tiles, rack_drag_over, selected } = this.state;
     let className = "tile-rack";
     if (rack_drag_over) className += " drag-over";
 
@@ -695,7 +814,11 @@ class Game extends React.Component {
            className={className}
            onDragOver={(e) => this.on_rack_drag_over(e)}
            onDragLeave={(e) => this.on_rack_drag_leave(e)}
-           onDrop={(e) => this.on_rack_drop(e)}>
+           onDrop={(e) => this.on_rack_drop(e)}
+           onClick={(e) => {
+             /* Clicking empty rack area returns a selected grid tile. */
+             if (e.target === e.currentTarget) this.on_rack_tap();
+           }}>
         {rack.map((tile_index, rack_index) => {
           const char = tiles[tile_index];
           const is_blank = (char === "_" || char === undefined);
@@ -703,15 +826,18 @@ class Game extends React.Component {
             console.error("Unexpected tile character:", JSON.stringify(char),
                           "at index", tile_index, "tiles:", JSON.stringify(tiles));
           }
+          const is_selected = selected && selected.from === "rack"
+                              && selected.tile_index === tile_index;
           return (
             <Tile key={tile_index}
                   letter={is_blank ? "\u00A0" : char}
                   isBlank={is_blank}
                   invalid={false}
                   unconnected={false}
+                  selected={is_selected}
                   onDragStart={(e) => this.on_rack_drag_start(e, rack_index)}
                   onDragEnd={() => this.on_drag_end()}
-                  onClick={null} />
+                  onClick={() => this.on_rack_tile_tap(rack_index)} />
           );
         })}
         {can_complete ? (