}
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}
drag_source: null,
drag_over_cell: null,
rack_drag_over: false,
- grid_bounds: null
+ grid_bounds: null,
+ selected: null
};
}
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) {
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
});
}
}
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)) {
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>
);
}
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";
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);
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 ? (