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