@mideind/netskrafl-react 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/index.js CHANGED
@@ -30015,6 +30015,34 @@ function formatDate(dateStr) {
30015
30015
  const month = ts(MONTH_KEYS[date.getUTCMonth()]);
30016
30016
  return ts('date_format', { day, month });
30017
30017
  }
30018
+ function setDefaultFocus(delay = 0) {
30019
+ // Set focus to the main container element to enable keyboard input.
30020
+ // Use class selector to work with both Netskrafl and Gáta dagsins.
30021
+ // An optional delay can be specified to wait for rendering to complete.
30022
+ const doFocus = () => {
30023
+ const container = document.querySelector('.netskrafl-container');
30024
+ container?.focus();
30025
+ };
30026
+ if (delay > 0) {
30027
+ setTimeout(doFocus, delay);
30028
+ }
30029
+ else {
30030
+ setTimeout(doFocus);
30031
+ }
30032
+ }
30033
+ function setKeyboardHandler(handler, delay = 100) {
30034
+ // Set up keyboard handling on the main container element.
30035
+ // Use class selector to work with both Netskrafl and Gáta dagsins.
30036
+ // The handler is attached after a delay to ensure the DOM is ready.
30037
+ setTimeout(() => {
30038
+ const container = document.querySelector('.netskrafl-container');
30039
+ if (container) {
30040
+ container.tabIndex = 0;
30041
+ container.addEventListener('keydown', handler);
30042
+ container.focus();
30043
+ }
30044
+ }, delay);
30045
+ }
30018
30046
 
30019
30047
  /*
30020
30048
 
@@ -30064,6 +30092,8 @@ class Actions {
30064
30092
  if (!model.state?.uiFullscreen)
30065
30093
  // Mobile UI: show board tab
30066
30094
  view.setSelectedTab('board');
30095
+ // Focus the container to enable keyboard input
30096
+ setDefaultFocus(100);
30067
30097
  }, deleteZombie);
30068
30098
  if (model.game !== null && model.game !== undefined) {
30069
30099
  logEvent('game_open', {
@@ -31226,12 +31256,20 @@ const TogglerFairplay = () => {
31226
31256
  const BLANK_TILES_PER_LINE = 6;
31227
31257
  const BlankDialog = () => {
31228
31258
  // A dialog for choosing the meaning of a blank tile
31259
+ function cancel(game) {
31260
+ game.cancelBlankDialog();
31261
+ setDefaultFocus();
31262
+ }
31263
+ function place(game, letter) {
31264
+ game.placeBlank(letter);
31265
+ setDefaultFocus();
31266
+ }
31229
31267
  function handleKeydown(game, ev) {
31230
31268
  // Escape key cancels the dialog
31231
31269
  let { key } = ev;
31232
31270
  if (key === 'Escape') {
31233
31271
  ev.preventDefault();
31234
- game.cancelBlankDialog();
31272
+ cancel(game);
31235
31273
  return;
31236
31274
  }
31237
31275
  if (key.length === 1) {
@@ -31239,7 +31277,7 @@ const BlankDialog = () => {
31239
31277
  key = key.toLowerCase();
31240
31278
  if (game.alphabet.includes(key)) {
31241
31279
  ev.preventDefault();
31242
- game.placeBlank(key);
31280
+ place(game, key);
31243
31281
  }
31244
31282
  }
31245
31283
  }
@@ -31256,8 +31294,8 @@ const BlankDialog = () => {
31256
31294
  const letter = legalLetters[ix++];
31257
31295
  c.push(m('td', {
31258
31296
  onclick: (ev) => {
31259
- game.placeBlank(letter);
31260
31297
  ev.preventDefault();
31298
+ place(game, letter);
31261
31299
  },
31262
31300
  onmouseover: buttonOver,
31263
31301
  onmouseout: buttonOut,
@@ -31287,7 +31325,7 @@ const BlankDialog = () => {
31287
31325
  title: ts('Hætta við'),
31288
31326
  onclick: (ev) => {
31289
31327
  ev.preventDefault();
31290
- game.cancelBlankDialog();
31328
+ cancel(game);
31291
31329
  },
31292
31330
  }, glyph('remove')),
31293
31331
  ]));
@@ -31562,6 +31600,8 @@ const Buttons = {
31562
31600
  drag-and-drop does not offer enough flexibility for our requirements.
31563
31601
 
31564
31602
  */
31603
+ // Minimum movement in pixels before a mousedown becomes a drag
31604
+ const DRAG_THRESHOLD = 10;
31565
31605
  class DragManager {
31566
31606
  getEventCoordinates(e) {
31567
31607
  if (e instanceof MouseEvent) {
@@ -31581,28 +31621,58 @@ class DragManager {
31581
31621
  p.y >= rect.top &&
31582
31622
  p.y < rect.bottom);
31583
31623
  }
31584
- constructor(e, dropHandler) {
31624
+ constructor(e, dropHandler, clickHandler) {
31585
31625
  this.parentElement = null;
31586
31626
  this.parentRect = null;
31587
31627
  this.offsetX = 0;
31588
31628
  this.offsetY = 0;
31589
31629
  this.centerX = 0;
31590
31630
  this.centerY = 0;
31631
+ this.startX = 0;
31632
+ this.startY = 0;
31591
31633
  this.lastX = 0;
31592
31634
  this.lastY = 0;
31593
31635
  this.currentDropTarget = null;
31594
- e.preventDefault();
31636
+ this.dragStarted = false;
31595
31637
  // Note: We use e.currentTarget here, not e.target, as we may be
31596
31638
  // handling events that were originally targeted at a child element
31597
31639
  // (such as the letter or score elements within a tile).
31598
31640
  const dragged = e.currentTarget;
31641
+ // Prevent the click event (which fires after mouseup) from bubbling
31642
+ // to parent elements like DropTargetSquare
31643
+ dragged.addEventListener('click', (ev) => ev.stopPropagation(), {
31644
+ once: true,
31645
+ });
31599
31646
  const coords = this.getEventCoordinates(e);
31647
+ this.startX = coords.x;
31648
+ this.startY = coords.y;
31600
31649
  this.lastX = coords.x;
31601
31650
  this.lastY = coords.y;
31602
31651
  this.draggedElement = dragged;
31603
31652
  this.dropHandler = dropHandler;
31653
+ this.clickHandler = clickHandler;
31604
31654
  this.parentElement = dragged.parentElement;
31605
31655
  this.parentRect = this.parentElement?.getBoundingClientRect() ?? null;
31656
+ // Create bound event handlers that properly assign 'this'
31657
+ this.boundEventHandlers = {
31658
+ drag: this.drag.bind(this),
31659
+ endDrag: this.endDrag.bind(this),
31660
+ };
31661
+ const { drag, endDrag } = this.boundEventHandlers;
31662
+ if (e instanceof MouseEvent) {
31663
+ document.addEventListener('mousemove', drag);
31664
+ document.addEventListener('mouseup', endDrag);
31665
+ }
31666
+ else if (e instanceof TouchEvent) {
31667
+ document.addEventListener('touchmove', drag, { passive: false });
31668
+ document.addEventListener('touchend', endDrag);
31669
+ }
31670
+ }
31671
+ initiateDrag(e) {
31672
+ // Called when movement exceeds the drag threshold
31673
+ e.preventDefault();
31674
+ this.dragStarted = true;
31675
+ const dragged = this.draggedElement;
31606
31676
  // Find out the bounding rectangle of the element
31607
31677
  // before starting to apply modifications
31608
31678
  const rect = dragged.getBoundingClientRect();
@@ -31617,28 +31687,16 @@ class DragManager {
31617
31687
  // Find out the dimensions of the element in its dragged state
31618
31688
  const { offsetWidth, offsetHeight } = dragged;
31619
31689
  // Offset of the click or touch within the dragged element
31620
- this.offsetX = coords.x - rect.left + (offsetWidth - originalWidth) / 2;
31690
+ // Use the original start position for offset calculation
31691
+ this.offsetX =
31692
+ this.startX - rect.left + (offsetWidth - originalWidth) / 2;
31621
31693
  this.offsetY =
31622
- coords.y - rect.top + (offsetHeight - originalHeight) / 2;
31694
+ this.startY - rect.top + (offsetHeight - originalHeight) / 2;
31623
31695
  this.centerX = offsetWidth / 2;
31624
31696
  this.centerY = offsetHeight / 2;
31625
- // Create bound event handlers that properly assign 'this'
31626
- this.boundEventHandlers = {
31627
- drag: this.drag.bind(this),
31628
- endDrag: this.endDrag.bind(this),
31629
- };
31630
- const { drag, endDrag } = this.boundEventHandlers;
31631
- if (e instanceof MouseEvent) {
31632
- document.addEventListener('mousemove', drag);
31633
- document.addEventListener('mouseup', endDrag);
31634
- }
31635
- else if (e instanceof TouchEvent) {
31636
- document.addEventListener('touchmove', drag, { passive: false });
31637
- document.addEventListener('touchend', endDrag);
31638
- }
31639
31697
  // Do an initial position update, as the size of the dragged element
31640
31698
  // may have changed, and it should remain centered
31641
- this.updatePosition(coords.x, coords.y);
31699
+ this.updatePosition(this.lastX, this.lastY);
31642
31700
  }
31643
31701
  removeDragListeners() {
31644
31702
  const { drag, endDrag } = this.boundEventHandlers;
@@ -31693,8 +31751,19 @@ class DragManager {
31693
31751
  this.currentDropTarget = dropTarget;
31694
31752
  }
31695
31753
  drag(e) {
31696
- e.preventDefault();
31754
+ e.stopPropagation();
31697
31755
  const coords = this.getEventCoordinates(e);
31756
+ this.lastX = coords.x;
31757
+ this.lastY = coords.y;
31758
+ if (!this.dragStarted) {
31759
+ // Check if movement exceeds threshold
31760
+ const distance = Math.sqrt((coords.x - this.startX) ** 2 + (coords.y - this.startY) ** 2);
31761
+ if (distance >= DRAG_THRESHOLD) {
31762
+ this.initiateDrag(e);
31763
+ }
31764
+ return;
31765
+ }
31766
+ e.preventDefault();
31698
31767
  // Update position for both mouse and touch events
31699
31768
  this.updatePosition(coords.x, coords.y);
31700
31769
  this.updateDropTarget(coords.x, coords.y);
@@ -31717,13 +31786,21 @@ class DragManager {
31717
31786
  }
31718
31787
  }
31719
31788
  endDrag(e) {
31789
+ e.stopPropagation();
31790
+ this.removeDragListeners();
31791
+ if (!this.dragStarted) {
31792
+ // No drag occurred - treat as a click
31793
+ if (this.clickHandler) {
31794
+ this.clickHandler(this.draggedElement);
31795
+ }
31796
+ return;
31797
+ }
31720
31798
  e.preventDefault();
31721
31799
  const coords = this.getEventCoordinates(e);
31722
31800
  const dropTarget = this.findDropTargetAtPoint(coords.x, coords.y);
31723
31801
  if (this.currentDropTarget) {
31724
31802
  this.currentDropTarget.classList.remove('over');
31725
31803
  }
31726
- this.removeDragListeners();
31727
31804
  // Avoid flicker by hiding the element while we are manipulating its
31728
31805
  // position in the DOM tree and completing the drop operation
31729
31806
  this.draggedElement.style.visibility = 'hidden';
@@ -31737,8 +31814,9 @@ class DragManager {
31737
31814
  });
31738
31815
  }
31739
31816
  }
31740
- const startDrag = (e, dropHandler) => {
31741
- new DragManager(e, dropHandler);
31817
+ const startDrag = (e, dropHandler, clickHandler) => {
31818
+ e.stopPropagation();
31819
+ new DragManager(e, dropHandler, clickHandler);
31742
31820
  };
31743
31821
 
31744
31822
  /*
@@ -31755,11 +31833,10 @@ const startDrag = (e, dropHandler) => {
31755
31833
  For further information, see https://github.com/mideind/Netskrafl
31756
31834
 
31757
31835
  */
31758
- const Tile = (initialVnode) => {
31759
- // Display a tile on the board or in the rack
31760
- const { view, game, coord } = initialVnode.attrs;
31761
- const dragHandler = (ev) => {
31762
- // Start a drag-and-drop process, for mouse or touch interaction
31836
+ const createDragHandler = (view, game, coord) => {
31837
+ return (ev) => {
31838
+ // Start a drag-and-drop process, for mouse or touch interaction.
31839
+ // If the user clicks without dragging, toggle tile selection.
31763
31840
  startDrag(ev, (_, target) => {
31764
31841
  // Drop handler
31765
31842
  if (!game)
@@ -31788,15 +31865,28 @@ const Tile = (initialVnode) => {
31788
31865
  console.error(e);
31789
31866
  }
31790
31867
  }
31868
+ }, () => {
31869
+ // Click handler: toggle tile selection
31870
+ if (!game)
31871
+ return;
31872
+ if (coord === game.selectedSq)
31873
+ // Clicking again: deselect
31874
+ game.selectedSq = null;
31875
+ else
31876
+ game.selectedSq = coord;
31877
+ m.redraw();
31791
31878
  });
31792
31879
  ev.redraw = false;
31793
31880
  return false;
31794
31881
  };
31882
+ };
31883
+ const Tile = () => {
31884
+ // Display a tile on the board or in the rack
31795
31885
  return {
31796
31886
  view: (vnode) => {
31887
+ const { view, game, coord, opponent } = vnode.attrs;
31797
31888
  if (!game)
31798
31889
  return undefined;
31799
- const { opponent } = vnode.attrs;
31800
31890
  const isRackTile = coord[0] === 'R';
31801
31891
  // A single tile, on the board or in the rack
31802
31892
  const t = game.tiles[coord];
@@ -31852,20 +31942,9 @@ const Tile = (initialVnode) => {
31852
31942
  */
31853
31943
  }
31854
31944
  if (t.draggable && game.allowDragDrop()) {
31945
+ const dragHandler = createDragHandler(view, game, coord);
31855
31946
  attrs.onmousedown = dragHandler;
31856
31947
  attrs.ontouchstart = dragHandler;
31857
- /*
31858
- attrs.onclick = (ev: MouseEvent) => {
31859
- // When clicking a tile, make it selected (blinking)
31860
- if (coord === game.selectedSq)
31861
- // Clicking again: deselect
31862
- game.selectedSq = null;
31863
- else
31864
- game.selectedSq = coord;
31865
- ev.stopPropagation();
31866
- return false;
31867
- };
31868
- */
31869
31948
  }
31870
31949
  return m(classes.join('.'), attrs, [
31871
31950
  t.letter === ' ' ? nbsp() : t.letter,
@@ -31922,7 +32001,7 @@ const ReviewTileSquare = {
31922
32001
  const DropTargetSquare = {
31923
32002
  // Return a td element that is a target for dropping tiles
31924
32003
  view: (vnode) => {
31925
- const { view, game, coord } = vnode.attrs;
32004
+ const { view, game, coord, isKeyboardTarget, keyboardDirection } = vnode.attrs;
31926
32005
  if (!game)
31927
32006
  return undefined;
31928
32007
  let cls = game.squareClass(coord) || '';
@@ -31935,6 +32014,12 @@ const DropTargetSquare = {
31935
32014
  if (coord === game.startSquare && game.localturn)
31936
32015
  // Unoccupied start square, first move
31937
32016
  cls += '.center';
32017
+ // Mark the cell as the keyboard target if applicable
32018
+ if (isKeyboardTarget) {
32019
+ cls += '.keyboard-target';
32020
+ if (keyboardDirection === 'V')
32021
+ cls += '.vertical';
32022
+ }
31938
32023
  return m(`td.drop-target${cls}`, {
31939
32024
  id: `sq_${coord}`,
31940
32025
  onclick: (ev) => {
@@ -32121,6 +32206,8 @@ const Board = {
32121
32206
  if (scale !== 1.0)
32122
32207
  attrs.style = `transform: scale(${scale})`;
32123
32208
  */
32209
+ // Get the next keyboard target square for visual feedback
32210
+ const nextKeyboardSquare = !review ? game.getNextKeyboardSquare() : null;
32124
32211
  function colid() {
32125
32212
  // The column identifier row
32126
32213
  const r = [];
@@ -32160,6 +32247,8 @@ const Board = {
32160
32247
  game,
32161
32248
  key: coord,
32162
32249
  coord: coord,
32250
+ isKeyboardTarget: coord === nextKeyboardSquare,
32251
+ keyboardDirection: game?.getEffectiveKeyboardDirection(),
32163
32252
  }));
32164
32253
  }
32165
32254
  return m('tr', r);
@@ -33493,6 +33582,80 @@ const currentMoveState = (riddle) => {
33493
33582
  return { selectedMoves, bestMove };
33494
33583
  };
33495
33584
 
33585
+ /*
33586
+
33587
+ Keyboard.ts
33588
+
33589
+ Keyboard event handling for tile placement
33590
+
33591
+ Copyright (C) 2025 Miðeind ehf.
33592
+ Author: Vilhjálmur Þorsteinsson
33593
+
33594
+ The Creative Commons Attribution-NonCommercial 4.0
33595
+ International Public License (CC-BY-NC 4.0) applies to this software.
33596
+ For further information, see https://github.com/mideind/Netskrafl
33597
+
33598
+ */
33599
+ function handleGameKeydown(game, view, ev) {
33600
+ // Skip if the target is an input element (text fields, textareas, etc.)
33601
+ const target = ev.target;
33602
+ if (target.tagName === 'INPUT' ||
33603
+ target.tagName === 'TEXTAREA' ||
33604
+ target.tagName === 'SELECT' ||
33605
+ target.isContentEditable) {
33606
+ return;
33607
+ }
33608
+ // Skip if dialog is showing (except for blank dialog which has its own handler)
33609
+ if (game.showingDialog)
33610
+ return;
33611
+ // Skip if blank tile dialog is active (it has its own keyboard handler)
33612
+ if (game.askingForBlank !== null)
33613
+ return;
33614
+ const { key } = ev;
33615
+ // Arrow keys: set direction
33616
+ if (key === 'ArrowRight') {
33617
+ ev.preventDefault();
33618
+ game.setKeyboardDirection('H');
33619
+ m.redraw();
33620
+ return;
33621
+ }
33622
+ if (key === 'ArrowDown') {
33623
+ ev.preventDefault();
33624
+ game.setKeyboardDirection('V');
33625
+ m.redraw();
33626
+ return;
33627
+ }
33628
+ // Backspace: undo last keyboard tile
33629
+ if (key === 'Backspace') {
33630
+ ev.preventDefault();
33631
+ if (game.undoLastKeyboardTile()) {
33632
+ view.updateScale();
33633
+ m.redraw();
33634
+ }
33635
+ return;
33636
+ }
33637
+ // Escape: recall all tiles to rack
33638
+ if (key === 'Escape') {
33639
+ ev.preventDefault();
33640
+ game.resetRack();
33641
+ view.updateScale();
33642
+ m.redraw();
33643
+ return;
33644
+ }
33645
+ // Letter keys: place tile
33646
+ if (key.length === 1) {
33647
+ const letter = key.toLowerCase();
33648
+ if (game.alphabet.includes(letter)) {
33649
+ ev.preventDefault();
33650
+ if (game.placeKeyboardTile(letter)) {
33651
+ view.updateScale();
33652
+ m.redraw();
33653
+ }
33654
+ return;
33655
+ }
33656
+ }
33657
+ }
33658
+
33496
33659
  /*
33497
33660
 
33498
33661
  Localstorage.ts
@@ -33818,8 +33981,11 @@ class BaseGame {
33818
33981
  // UI state
33819
33982
  this.showingDialog = null;
33820
33983
  this.selectedSq = null;
33821
- this.sel = 'movelist';
33984
+ this.sel = 'movelist'; // Selected tab
33822
33985
  this.askingForBlank = null;
33986
+ // Keyboard tile placement state
33987
+ this.keyboardDirection = 'H'; // Default to horizontal
33988
+ this.keyboardPlacedTiles = []; // Stack of tiles placed via keyboard
33823
33989
  // Local storage
33824
33990
  this.localStorage = null;
33825
33991
  this.uuid = uuid;
@@ -33933,6 +34099,13 @@ class BaseGame {
33933
34099
  this._moveTile(from, to);
33934
34100
  // Clear error message, if any
33935
34101
  this.currentError = this.currentMessage = null;
34102
+ // If placing the first tile on the board, set default direction
34103
+ if (from[0] === 'R' && to[0] !== 'R') {
34104
+ const placed = this.tilesPlaced();
34105
+ if (placed.length === 1) {
34106
+ this.setDefaultDirection(to);
34107
+ }
34108
+ }
33936
34109
  // Update the current word score
33937
34110
  this.updateScore();
33938
34111
  // Update the local storage
@@ -33957,12 +34130,12 @@ class BaseGame {
33957
34130
  // Return a list of coordinates of tiles that the user has
33958
34131
  // placed on the board by dragging from the rack
33959
34132
  const r = [];
33960
- for (const sq in this.tiles)
33961
- if (Object.hasOwn(this.tiles, sq) &&
33962
- sq[0] !== 'R' &&
33963
- this.tiles[sq].draggable)
34133
+ for (const sq of Object.keys(this.tiles)) {
34134
+ if (sq[0] !== 'R' && this.tiles[sq].draggable) {
33964
34135
  // Found a non-rack tile that is not glued to the board
33965
34136
  r.push(sq);
34137
+ }
34138
+ }
33966
34139
  return r;
33967
34140
  }
33968
34141
  resetRack() {
@@ -33992,6 +34165,8 @@ class BaseGame {
33992
34165
  }
33993
34166
  // Reset current error message, if any
33994
34167
  this.currentError = null;
34168
+ // Reset keyboard placement state
34169
+ this.resetKeyboardState();
33995
34170
  }
33996
34171
  rescrambleRack() {
33997
34172
  // Reorder the rack randomly. Bound to the Backspace key.
@@ -34265,36 +34440,39 @@ class BaseGame {
34265
34440
  // The saved destination square is empty:
34266
34441
  // find the tile in the saved rack and move it there
34267
34442
  const tile = savedTiles[i].tile;
34268
- for (const sq in rackTiles)
34269
- if (Object.hasOwn(rackTiles, sq) &&
34270
- rackTiles[sq].tile === tile.charAt(0)) {
34443
+ for (const sq of Object.keys(rackTiles)) {
34444
+ if (rackTiles[sq].tile === tile.charAt(0)) {
34271
34445
  // Found the tile (or its equivalent) in the rack: move it
34272
- if (tile.charAt(0) === '?')
34273
- if (saved_sq.charAt(0) === 'R')
34446
+ if (tile.charAt(0) === '?') {
34447
+ if (saved_sq.charAt(0) === 'R') {
34274
34448
  // Going to the rack: no associated letter
34275
34449
  rackTiles[sq].letter = ' ';
34276
- // Going to a board square: associate the originally
34277
- // chosen and saved letter
34278
- else
34450
+ }
34451
+ else {
34452
+ // Going to a board square: associate the originally
34453
+ // chosen and saved letter
34279
34454
  rackTiles[sq].letter = tile.charAt(1);
34455
+ }
34456
+ }
34280
34457
  // ...and assign it
34281
34458
  this.tiles[saved_sq] = rackTiles[sq];
34282
34459
  delete rackTiles[sq];
34283
34460
  break;
34284
34461
  }
34462
+ }
34285
34463
  }
34286
34464
  }
34287
34465
  // Allocate any remaining tiles to free slots in the rack
34288
34466
  let j = 1;
34289
- for (const sq in rackTiles)
34290
- if (Object.hasOwn(rackTiles, sq)) {
34291
- // Look for a free slot in the rack
34292
- while (`R${j}` in this.tiles)
34293
- j++;
34294
- if (j <= RACK_SIZE)
34295
- // Should always be true unless something is very wrong
34296
- this.tiles[`R${j}`] = rackTiles[sq];
34467
+ for (const sq of Object.keys(rackTiles)) {
34468
+ // Look for a free slot in the rack
34469
+ while (`R${j}` in this.tiles)
34470
+ j++;
34471
+ if (j <= RACK_SIZE) {
34472
+ // Should always be true unless something is very wrong
34473
+ this.tiles[`R${j}`] = rackTiles[sq];
34297
34474
  }
34475
+ }
34298
34476
  // The local storage may have been cleared before calling
34299
34477
  // restoreTiles() so we must ensure that it is updated
34300
34478
  this.saveTiles();
@@ -34304,6 +34482,192 @@ class BaseGame {
34304
34482
  cleanup() {
34305
34483
  // Base cleanup - can be overridden by subclasses
34306
34484
  }
34485
+ // Keyboard tile placement methods
34486
+ getLockedDirection() {
34487
+ // Check if direction is locked by 2+ tiles in a row or column.
34488
+ // Returns 'H' if locked horizontal, 'V' if locked vertical, null if not locked.
34489
+ const placed = this.tilesPlaced();
34490
+ if (placed.length < 2)
34491
+ return null;
34492
+ const rows = new Set();
34493
+ const cols = new Set();
34494
+ for (const sq of placed) {
34495
+ rows.add(sq.charAt(0));
34496
+ cols.add(parseInt(sq.slice(1), 10));
34497
+ }
34498
+ // If all tiles are in the same row, lock to horizontal
34499
+ if (rows.size === 1 && cols.size > 1) {
34500
+ return 'H';
34501
+ }
34502
+ // If all tiles are in the same column, lock to vertical
34503
+ if (cols.size === 1 && rows.size > 1) {
34504
+ return 'V';
34505
+ }
34506
+ return null;
34507
+ }
34508
+ setKeyboardDirection(direction) {
34509
+ // Only allow direction change if not locked by placed tiles
34510
+ const locked = this.getLockedDirection();
34511
+ if (locked === null) {
34512
+ this.keyboardDirection = direction;
34513
+ }
34514
+ }
34515
+ getEffectiveKeyboardDirection() {
34516
+ // Return the effective direction, considering any lock from placed tiles
34517
+ return this.getLockedDirection() ?? this.keyboardDirection;
34518
+ }
34519
+ findNextEmptySquare(startRow, startCol, direction) {
34520
+ // Find the next empty square starting from the given position,
34521
+ // moving in the specified direction. Skips over all occupied squares
34522
+ // (both fixed tiles and tiles we've placed).
34523
+ const dx = direction === 'H' ? 1 : 0;
34524
+ const dy = direction === 'V' ? 1 : 0;
34525
+ let row = startRow;
34526
+ let col = startCol;
34527
+ while (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) {
34528
+ const sq = coord(row, col);
34529
+ if (sq && !(sq in this.tiles)) {
34530
+ // Found an empty square
34531
+ return sq;
34532
+ }
34533
+ // Square is occupied - continue scanning
34534
+ row += dy;
34535
+ col += dx;
34536
+ }
34537
+ // Reached board edge with no empty square found
34538
+ return null;
34539
+ }
34540
+ canContinueInDirection(sq, direction) {
34541
+ // Check if there's any empty square in the given direction from sq
34542
+ const vec = toVector(sq);
34543
+ const dx = direction === 'H' ? 1 : 0;
34544
+ const dy = direction === 'V' ? 1 : 0;
34545
+ return (this.findNextEmptySquare(vec.row + dy, vec.col + dx, direction) !==
34546
+ null);
34547
+ }
34548
+ setDefaultDirection(sq) {
34549
+ // When placing the first tile, set direction based on available space
34550
+ // Default to horizontal if possible, otherwise vertical
34551
+ if (this.canContinueInDirection(sq, 'H')) {
34552
+ this.keyboardDirection = 'H';
34553
+ }
34554
+ else {
34555
+ this.keyboardDirection = 'V';
34556
+ }
34557
+ }
34558
+ getNextKeyboardSquare() {
34559
+ // Get the next target square for keyboard tile placement
34560
+ const placed = this.tilesPlaced();
34561
+ // Don't show target indicator if no tiles have been placed yet,
34562
+ // since we don't know where the user wants to start their move
34563
+ if (placed.length === 0) {
34564
+ return null;
34565
+ }
34566
+ // Don't show target indicator if the rack is empty (no tiles left to place)
34567
+ let rackEmpty = true;
34568
+ for (let i = 1; i <= RACK_SIZE; i++) {
34569
+ if (`R${i}` in this.tiles) {
34570
+ rackEmpty = false;
34571
+ break;
34572
+ }
34573
+ }
34574
+ if (rackEmpty) {
34575
+ return null;
34576
+ }
34577
+ // Use locked direction if set, otherwise use user's preferred direction
34578
+ const locked = this.getLockedDirection();
34579
+ const direction = locked ?? this.keyboardDirection;
34580
+ // Find the rightmost (horizontal) or bottommost (vertical) placed tile
34581
+ let maxRow = -1;
34582
+ let maxCol = -1;
34583
+ for (const sq of placed) {
34584
+ const row = ROWIDS.indexOf(sq.charAt(0));
34585
+ const col = parseInt(sq.slice(1), 10) - 1;
34586
+ if (direction === 'H') {
34587
+ if (col > maxCol || (col === maxCol && row > maxRow)) {
34588
+ maxCol = col;
34589
+ maxRow = row;
34590
+ }
34591
+ }
34592
+ else {
34593
+ if (row > maxRow || (row === maxRow && col > maxCol)) {
34594
+ maxRow = row;
34595
+ maxCol = col;
34596
+ }
34597
+ }
34598
+ }
34599
+ // Find the next empty square in the direction
34600
+ const dx = direction === 'H' ? 1 : 0;
34601
+ const dy = direction === 'V' ? 1 : 0;
34602
+ return this.findNextEmptySquare(maxRow + dy, maxCol + dx, direction);
34603
+ }
34604
+ findRackTileForLetter(letter) {
34605
+ // Find a rack tile matching the given letter
34606
+ // Prefers real tiles over blanks
34607
+ let blankSlot = null;
34608
+ for (let i = 1; i <= RACK_SIZE; i++) {
34609
+ const slot = `R${i}`;
34610
+ if (slot in this.tiles) {
34611
+ const tile = this.tiles[slot];
34612
+ if (tile.tile === letter) {
34613
+ // Found an exact match
34614
+ return slot;
34615
+ }
34616
+ if (tile.tile === '?' && blankSlot === null) {
34617
+ // Remember the first blank we find
34618
+ blankSlot = slot;
34619
+ }
34620
+ }
34621
+ }
34622
+ // Return blank if no exact match found
34623
+ return blankSlot;
34624
+ }
34625
+ placeKeyboardTile(letter) {
34626
+ // Place a tile via keyboard
34627
+ const targetSquare = this.getNextKeyboardSquare();
34628
+ if (!targetSquare)
34629
+ return false;
34630
+ const rackSlot = this.findRackTileForLetter(letter);
34631
+ if (!rackSlot)
34632
+ return false;
34633
+ const tile = this.tiles[rackSlot];
34634
+ // If using a blank tile, assign the letter
34635
+ if (tile.tile === '?') {
34636
+ tile.letter = letter;
34637
+ }
34638
+ // Move the tile to the target square
34639
+ this.moveTile(rackSlot, targetSquare);
34640
+ // Track this tile for backspace functionality
34641
+ this.keyboardPlacedTiles.push(targetSquare);
34642
+ return true;
34643
+ }
34644
+ undoLastKeyboardTile() {
34645
+ // Undo the last keyboard-placed tile
34646
+ if (this.keyboardPlacedTiles.length === 0)
34647
+ return false;
34648
+ const lastSquare = this.keyboardPlacedTiles.pop();
34649
+ if (!lastSquare || !(lastSquare in this.tiles))
34650
+ return false;
34651
+ // Find a free slot in the rack
34652
+ let freeSlot = null;
34653
+ for (let i = 1; i <= RACK_SIZE; i++) {
34654
+ const slot = `R${i}`;
34655
+ if (!(slot in this.tiles)) {
34656
+ freeSlot = slot;
34657
+ break;
34658
+ }
34659
+ }
34660
+ if (!freeSlot)
34661
+ return false;
34662
+ // Move the tile back to the rack
34663
+ this.moveTile(lastSquare, freeSlot);
34664
+ return true;
34665
+ }
34666
+ resetKeyboardState() {
34667
+ // Reset keyboard placement state
34668
+ this.keyboardDirection = 'H';
34669
+ this.keyboardPlacedTiles = [];
34670
+ }
34307
34671
  }
34308
34672
 
34309
34673
  /*
@@ -34681,9 +35045,9 @@ class Game extends BaseGame {
34681
35045
  // Remember if the game was already won before this update
34682
35046
  const wasWon = this.congratulate;
34683
35047
  // Stop highlighting the previous opponent move, if any
34684
- for (const sq in this.tiles)
34685
- if (Object.hasOwn(this.tiles, sq))
34686
- this.tiles[sq].freshtile = false;
35048
+ for (const sq of Object.keys(this.tiles)) {
35049
+ this.tiles[sq].freshtile = false;
35050
+ }
34687
35051
  this.init(srvGame);
34688
35052
  if (this.currentError === null) {
34689
35053
  if (this.succ_chall) {
@@ -37083,7 +37447,7 @@ const MoveListItem = () => {
37083
37447
  if (tile === '?')
37084
37448
  continue;
37085
37449
  const sq = coord(row, col);
37086
- if (sq && Object.hasOwn(game.tiles, sq))
37450
+ if (sq && sq in game.tiles)
37087
37451
  game.tiles[sq].highlight = show ? playerColor : undefined;
37088
37452
  col += vec.dx;
37089
37453
  row += vec.dy;
@@ -40528,6 +40892,11 @@ class View {
40528
40892
  if (this.selectedTab === sel)
40529
40893
  return false;
40530
40894
  this.selectedTab = sel;
40895
+ // When switching to a non-chat tab, focus the container
40896
+ // to restore keyboard input for tile placement
40897
+ if (sel !== 'chat') {
40898
+ setDefaultFocus();
40899
+ }
40531
40900
  return true;
40532
40901
  }
40533
40902
  // Globally available view functions
@@ -40684,6 +41053,13 @@ async function main$1(state, container) {
40684
41053
  m.mount(container, {
40685
41054
  view: () => m(GataDagsins$1, { view, date: validDate, locale }),
40686
41055
  });
41056
+ // Set up keyboard handling on the container element
41057
+ setKeyboardHandler((ev) => {
41058
+ const riddle = model.riddle;
41059
+ if (riddle) {
41060
+ handleGameKeydown(riddle, view, ev);
41061
+ }
41062
+ });
40687
41063
  }
40688
41064
  catch (e) {
40689
41065
  console.error('Exception during initialization: ', e);
@@ -40809,6 +41185,13 @@ async function main(state, container) {
40809
41185
  const model = new Model(settings, state);
40810
41186
  const actions = new Actions(model);
40811
41187
  const view = new View(actions);
41188
+ // Set up keyboard handling on the container element
41189
+ setKeyboardHandler((ev) => {
41190
+ const game = model.game;
41191
+ if (game) {
41192
+ handleGameKeydown(game, view, ev);
41193
+ }
41194
+ });
40812
41195
  // Run the Mithril router
40813
41196
  const routeResolver = createRouteResolver(actions, view);
40814
41197
  m.route(container, settings.defaultRoute, routeResolver);