@mideind/netskrafl-react 2.0.0 → 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,6 +31258,31 @@ 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
+ }
31269
+ function handleKeydown(game, ev) {
31270
+ // Escape key cancels the dialog
31271
+ let { key } = ev;
31272
+ if (key === 'Escape') {
31273
+ ev.preventDefault();
31274
+ cancel(game);
31275
+ return;
31276
+ }
31277
+ if (key.length === 1) {
31278
+ // Check if the pressed key matches a valid letter
31279
+ key = key.toLowerCase();
31280
+ if (game.alphabet.includes(key)) {
31281
+ ev.preventDefault();
31282
+ place(game, key);
31283
+ }
31284
+ }
31285
+ }
31231
31286
  function blankLetters(game) {
31232
31287
  const legalLetters = game.alphabet;
31233
31288
  let len = legalLetters.length;
@@ -31241,8 +31296,8 @@ const BlankDialog = () => {
31241
31296
  const letter = legalLetters[ix++];
31242
31297
  c.push(m('td', {
31243
31298
  onclick: (ev) => {
31244
- game.placeBlank(letter);
31245
31299
  ev.preventDefault();
31300
+ place(game, letter);
31246
31301
  },
31247
31302
  onmouseover: buttonOver,
31248
31303
  onmouseout: buttonOut,
@@ -31261,6 +31316,9 @@ const BlankDialog = () => {
31261
31316
  return m('.modal-dialog', {
31262
31317
  id: 'blank-dialog',
31263
31318
  style: { visibility: 'visible' },
31319
+ tabindex: -1,
31320
+ onkeydown: (ev) => handleKeydown(game, ev),
31321
+ oncreate: (vnode) => vnode.dom.focus(),
31264
31322
  }, m('.ui-widget.ui-widget-content.ui-corner-all', { id: 'blank-form' }, [
31265
31323
  mt('p', 'Hvaða staf táknar auða flísin?'),
31266
31324
  m('.rack.blank-rack', m('table.board', { id: 'blank-meaning' }, blankLetters(game))),
@@ -31269,7 +31327,7 @@ const BlankDialog = () => {
31269
31327
  title: ts('Hætta við'),
31270
31328
  onclick: (ev) => {
31271
31329
  ev.preventDefault();
31272
- game.cancelBlankDialog();
31330
+ cancel(game);
31273
31331
  },
31274
31332
  }, glyph('remove')),
31275
31333
  ]));
@@ -31544,6 +31602,8 @@ const Buttons = {
31544
31602
  drag-and-drop does not offer enough flexibility for our requirements.
31545
31603
 
31546
31604
  */
31605
+ // Minimum movement in pixels before a mousedown becomes a drag
31606
+ const DRAG_THRESHOLD = 10;
31547
31607
  class DragManager {
31548
31608
  getEventCoordinates(e) {
31549
31609
  if (e instanceof MouseEvent) {
@@ -31563,28 +31623,58 @@ class DragManager {
31563
31623
  p.y >= rect.top &&
31564
31624
  p.y < rect.bottom);
31565
31625
  }
31566
- constructor(e, dropHandler) {
31626
+ constructor(e, dropHandler, clickHandler) {
31567
31627
  this.parentElement = null;
31568
31628
  this.parentRect = null;
31569
31629
  this.offsetX = 0;
31570
31630
  this.offsetY = 0;
31571
31631
  this.centerX = 0;
31572
31632
  this.centerY = 0;
31633
+ this.startX = 0;
31634
+ this.startY = 0;
31573
31635
  this.lastX = 0;
31574
31636
  this.lastY = 0;
31575
31637
  this.currentDropTarget = null;
31576
- e.preventDefault();
31638
+ this.dragStarted = false;
31577
31639
  // Note: We use e.currentTarget here, not e.target, as we may be
31578
31640
  // handling events that were originally targeted at a child element
31579
31641
  // (such as the letter or score elements within a tile).
31580
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
+ });
31581
31648
  const coords = this.getEventCoordinates(e);
31649
+ this.startX = coords.x;
31650
+ this.startY = coords.y;
31582
31651
  this.lastX = coords.x;
31583
31652
  this.lastY = coords.y;
31584
31653
  this.draggedElement = dragged;
31585
31654
  this.dropHandler = dropHandler;
31655
+ this.clickHandler = clickHandler;
31586
31656
  this.parentElement = dragged.parentElement;
31587
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;
31588
31678
  // Find out the bounding rectangle of the element
31589
31679
  // before starting to apply modifications
31590
31680
  const rect = dragged.getBoundingClientRect();
@@ -31599,28 +31689,16 @@ class DragManager {
31599
31689
  // Find out the dimensions of the element in its dragged state
31600
31690
  const { offsetWidth, offsetHeight } = dragged;
31601
31691
  // Offset of the click or touch within the dragged element
31602
- 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;
31603
31695
  this.offsetY =
31604
- coords.y - rect.top + (offsetHeight - originalHeight) / 2;
31696
+ this.startY - rect.top + (offsetHeight - originalHeight) / 2;
31605
31697
  this.centerX = offsetWidth / 2;
31606
31698
  this.centerY = offsetHeight / 2;
31607
- // Create bound event handlers that properly assign 'this'
31608
- this.boundEventHandlers = {
31609
- drag: this.drag.bind(this),
31610
- endDrag: this.endDrag.bind(this),
31611
- };
31612
- const { drag, endDrag } = this.boundEventHandlers;
31613
- if (e instanceof MouseEvent) {
31614
- document.addEventListener('mousemove', drag);
31615
- document.addEventListener('mouseup', endDrag);
31616
- }
31617
- else if (e instanceof TouchEvent) {
31618
- document.addEventListener('touchmove', drag, { passive: false });
31619
- document.addEventListener('touchend', endDrag);
31620
- }
31621
31699
  // Do an initial position update, as the size of the dragged element
31622
31700
  // may have changed, and it should remain centered
31623
- this.updatePosition(coords.x, coords.y);
31701
+ this.updatePosition(this.lastX, this.lastY);
31624
31702
  }
31625
31703
  removeDragListeners() {
31626
31704
  const { drag, endDrag } = this.boundEventHandlers;
@@ -31675,8 +31753,19 @@ class DragManager {
31675
31753
  this.currentDropTarget = dropTarget;
31676
31754
  }
31677
31755
  drag(e) {
31678
- e.preventDefault();
31756
+ e.stopPropagation();
31679
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();
31680
31769
  // Update position for both mouse and touch events
31681
31770
  this.updatePosition(coords.x, coords.y);
31682
31771
  this.updateDropTarget(coords.x, coords.y);
@@ -31699,13 +31788,21 @@ class DragManager {
31699
31788
  }
31700
31789
  }
31701
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
+ }
31702
31800
  e.preventDefault();
31703
31801
  const coords = this.getEventCoordinates(e);
31704
31802
  const dropTarget = this.findDropTargetAtPoint(coords.x, coords.y);
31705
31803
  if (this.currentDropTarget) {
31706
31804
  this.currentDropTarget.classList.remove('over');
31707
31805
  }
31708
- this.removeDragListeners();
31709
31806
  // Avoid flicker by hiding the element while we are manipulating its
31710
31807
  // position in the DOM tree and completing the drop operation
31711
31808
  this.draggedElement.style.visibility = 'hidden';
@@ -31719,8 +31816,9 @@ class DragManager {
31719
31816
  });
31720
31817
  }
31721
31818
  }
31722
- const startDrag = (e, dropHandler) => {
31723
- new DragManager(e, dropHandler);
31819
+ const startDrag = (e, dropHandler, clickHandler) => {
31820
+ e.stopPropagation();
31821
+ new DragManager(e, dropHandler, clickHandler);
31724
31822
  };
31725
31823
 
31726
31824
  /*
@@ -31737,11 +31835,10 @@ const startDrag = (e, dropHandler) => {
31737
31835
  For further information, see https://github.com/mideind/Netskrafl
31738
31836
 
31739
31837
  */
31740
- const Tile = (initialVnode) => {
31741
- // Display a tile on the board or in the rack
31742
- const { view, game, coord } = initialVnode.attrs;
31743
- const dragHandler = (ev) => {
31744
- // 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.
31745
31842
  startDrag(ev, (_, target) => {
31746
31843
  // Drop handler
31747
31844
  if (!game)
@@ -31770,15 +31867,28 @@ const Tile = (initialVnode) => {
31770
31867
  console.error(e);
31771
31868
  }
31772
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();
31773
31880
  });
31774
31881
  ev.redraw = false;
31775
31882
  return false;
31776
31883
  };
31884
+ };
31885
+ const Tile = () => {
31886
+ // Display a tile on the board or in the rack
31777
31887
  return {
31778
31888
  view: (vnode) => {
31889
+ const { view, game, coord, opponent } = vnode.attrs;
31779
31890
  if (!game)
31780
31891
  return undefined;
31781
- const { opponent } = vnode.attrs;
31782
31892
  const isRackTile = coord[0] === 'R';
31783
31893
  // A single tile, on the board or in the rack
31784
31894
  const t = game.tiles[coord];
@@ -31834,20 +31944,9 @@ const Tile = (initialVnode) => {
31834
31944
  */
31835
31945
  }
31836
31946
  if (t.draggable && game.allowDragDrop()) {
31947
+ const dragHandler = createDragHandler(view, game, coord);
31837
31948
  attrs.onmousedown = dragHandler;
31838
31949
  attrs.ontouchstart = dragHandler;
31839
- /*
31840
- attrs.onclick = (ev: MouseEvent) => {
31841
- // When clicking a tile, make it selected (blinking)
31842
- if (coord === game.selectedSq)
31843
- // Clicking again: deselect
31844
- game.selectedSq = null;
31845
- else
31846
- game.selectedSq = coord;
31847
- ev.stopPropagation();
31848
- return false;
31849
- };
31850
- */
31851
31950
  }
31852
31951
  return m(classes.join('.'), attrs, [
31853
31952
  t.letter === ' ' ? nbsp() : t.letter,
@@ -31904,7 +32003,7 @@ const ReviewTileSquare = {
31904
32003
  const DropTargetSquare = {
31905
32004
  // Return a td element that is a target for dropping tiles
31906
32005
  view: (vnode) => {
31907
- const { view, game, coord } = vnode.attrs;
32006
+ const { view, game, coord, isKeyboardTarget, keyboardDirection } = vnode.attrs;
31908
32007
  if (!game)
31909
32008
  return undefined;
31910
32009
  let cls = game.squareClass(coord) || '';
@@ -31917,6 +32016,12 @@ const DropTargetSquare = {
31917
32016
  if (coord === game.startSquare && game.localturn)
31918
32017
  // Unoccupied start square, first move
31919
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
+ }
31920
32025
  return m(`td.drop-target${cls}`, {
31921
32026
  id: `sq_${coord}`,
31922
32027
  onclick: (ev) => {
@@ -32103,6 +32208,8 @@ const Board = {
32103
32208
  if (scale !== 1.0)
32104
32209
  attrs.style = `transform: scale(${scale})`;
32105
32210
  */
32211
+ // Get the next keyboard target square for visual feedback
32212
+ const nextKeyboardSquare = !review ? game.getNextKeyboardSquare() : null;
32106
32213
  function colid() {
32107
32214
  // The column identifier row
32108
32215
  const r = [];
@@ -32142,6 +32249,8 @@ const Board = {
32142
32249
  game,
32143
32250
  key: coord,
32144
32251
  coord: coord,
32252
+ isKeyboardTarget: coord === nextKeyboardSquare,
32253
+ keyboardDirection: game?.getEffectiveKeyboardDirection(),
32145
32254
  }));
32146
32255
  }
32147
32256
  return m('tr', r);
@@ -33475,6 +33584,80 @@ const currentMoveState = (riddle) => {
33475
33584
  return { selectedMoves, bestMove };
33476
33585
  };
33477
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
+
33478
33661
  /*
33479
33662
 
33480
33663
  Localstorage.ts
@@ -33800,8 +33983,11 @@ class BaseGame {
33800
33983
  // UI state
33801
33984
  this.showingDialog = null;
33802
33985
  this.selectedSq = null;
33803
- this.sel = 'movelist';
33986
+ this.sel = 'movelist'; // Selected tab
33804
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
33805
33991
  // Local storage
33806
33992
  this.localStorage = null;
33807
33993
  this.uuid = uuid;
@@ -33915,6 +34101,13 @@ class BaseGame {
33915
34101
  this._moveTile(from, to);
33916
34102
  // Clear error message, if any
33917
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
+ }
33918
34111
  // Update the current word score
33919
34112
  this.updateScore();
33920
34113
  // Update the local storage
@@ -33939,12 +34132,12 @@ class BaseGame {
33939
34132
  // Return a list of coordinates of tiles that the user has
33940
34133
  // placed on the board by dragging from the rack
33941
34134
  const r = [];
33942
- for (const sq in this.tiles)
33943
- if (Object.hasOwn(this.tiles, sq) &&
33944
- sq[0] !== 'R' &&
33945
- this.tiles[sq].draggable)
34135
+ for (const sq of Object.keys(this.tiles)) {
34136
+ if (sq[0] !== 'R' && this.tiles[sq].draggable) {
33946
34137
  // Found a non-rack tile that is not glued to the board
33947
34138
  r.push(sq);
34139
+ }
34140
+ }
33948
34141
  return r;
33949
34142
  }
33950
34143
  resetRack() {
@@ -33974,6 +34167,8 @@ class BaseGame {
33974
34167
  }
33975
34168
  // Reset current error message, if any
33976
34169
  this.currentError = null;
34170
+ // Reset keyboard placement state
34171
+ this.resetKeyboardState();
33977
34172
  }
33978
34173
  rescrambleRack() {
33979
34174
  // Reorder the rack randomly. Bound to the Backspace key.
@@ -34247,36 +34442,39 @@ class BaseGame {
34247
34442
  // The saved destination square is empty:
34248
34443
  // find the tile in the saved rack and move it there
34249
34444
  const tile = savedTiles[i].tile;
34250
- for (const sq in rackTiles)
34251
- if (Object.hasOwn(rackTiles, sq) &&
34252
- rackTiles[sq].tile === tile.charAt(0)) {
34445
+ for (const sq of Object.keys(rackTiles)) {
34446
+ if (rackTiles[sq].tile === tile.charAt(0)) {
34253
34447
  // Found the tile (or its equivalent) in the rack: move it
34254
- if (tile.charAt(0) === '?')
34255
- if (saved_sq.charAt(0) === 'R')
34448
+ if (tile.charAt(0) === '?') {
34449
+ if (saved_sq.charAt(0) === 'R') {
34256
34450
  // Going to the rack: no associated letter
34257
34451
  rackTiles[sq].letter = ' ';
34258
- // Going to a board square: associate the originally
34259
- // chosen and saved letter
34260
- else
34452
+ }
34453
+ else {
34454
+ // Going to a board square: associate the originally
34455
+ // chosen and saved letter
34261
34456
  rackTiles[sq].letter = tile.charAt(1);
34457
+ }
34458
+ }
34262
34459
  // ...and assign it
34263
34460
  this.tiles[saved_sq] = rackTiles[sq];
34264
34461
  delete rackTiles[sq];
34265
34462
  break;
34266
34463
  }
34464
+ }
34267
34465
  }
34268
34466
  }
34269
34467
  // Allocate any remaining tiles to free slots in the rack
34270
34468
  let j = 1;
34271
- for (const sq in rackTiles)
34272
- if (Object.hasOwn(rackTiles, sq)) {
34273
- // Look for a free slot in the rack
34274
- while (`R${j}` in this.tiles)
34275
- j++;
34276
- if (j <= RACK_SIZE)
34277
- // Should always be true unless something is very wrong
34278
- 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];
34279
34476
  }
34477
+ }
34280
34478
  // The local storage may have been cleared before calling
34281
34479
  // restoreTiles() so we must ensure that it is updated
34282
34480
  this.saveTiles();
@@ -34286,6 +34484,192 @@ class BaseGame {
34286
34484
  cleanup() {
34287
34485
  // Base cleanup - can be overridden by subclasses
34288
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
+ }
34289
34673
  }
34290
34674
 
34291
34675
  /*
@@ -34663,9 +35047,9 @@ class Game extends BaseGame {
34663
35047
  // Remember if the game was already won before this update
34664
35048
  const wasWon = this.congratulate;
34665
35049
  // Stop highlighting the previous opponent move, if any
34666
- for (const sq in this.tiles)
34667
- if (Object.hasOwn(this.tiles, sq))
34668
- this.tiles[sq].freshtile = false;
35050
+ for (const sq of Object.keys(this.tiles)) {
35051
+ this.tiles[sq].freshtile = false;
35052
+ }
34669
35053
  this.init(srvGame);
34670
35054
  if (this.currentError === null) {
34671
35055
  if (this.succ_chall) {
@@ -37065,7 +37449,7 @@ const MoveListItem = () => {
37065
37449
  if (tile === '?')
37066
37450
  continue;
37067
37451
  const sq = coord(row, col);
37068
- if (sq && Object.hasOwn(game.tiles, sq))
37452
+ if (sq && sq in game.tiles)
37069
37453
  game.tiles[sq].highlight = show ? playerColor : undefined;
37070
37454
  col += vec.dx;
37071
37455
  row += vec.dy;
@@ -40510,6 +40894,11 @@ class View {
40510
40894
  if (this.selectedTab === sel)
40511
40895
  return false;
40512
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
+ }
40513
40902
  return true;
40514
40903
  }
40515
40904
  // Globally available view functions
@@ -40666,6 +41055,13 @@ async function main$1(state, container) {
40666
41055
  m.mount(container, {
40667
41056
  view: () => m(GataDagsins$1, { view, date: validDate, locale }),
40668
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
+ });
40669
41065
  }
40670
41066
  catch (e) {
40671
41067
  console.error('Exception during initialization: ', e);
@@ -40791,6 +41187,13 @@ async function main(state, container) {
40791
41187
  const model = new Model(settings, state);
40792
41188
  const actions = new Actions(model);
40793
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
+ });
40794
41197
  // Run the Mithril router
40795
41198
  const routeResolver = createRouteResolver(actions, view);
40796
41199
  m.route(container, settings.defaultRoute, routeResolver);
@@ -40801,60 +41204,34 @@ async function main(state, container) {
40801
41204
  }
40802
41205
  return 'success';
40803
41206
  }
41207
+ function unmount(container) {
41208
+ // Unmount the Mithril UI completely
41209
+ // First unmount via m.mount to clean up Mithril's internal state
41210
+ m.mount(container, null);
41211
+ // Then clear the container to ensure a clean slate for next mount
41212
+ container.innerHTML = '';
41213
+ }
40804
41214
 
40805
- const mountForUser = async (state) => {
40806
- // Return a DOM tree containing a mounted Netskrafl UI
40807
- // for the user specified in the state object
41215
+ const mountForUser = (state, container) => {
41216
+ // Mount a Netskrafl UI directly into the container
40808
41217
  const { userEmail } = state;
40809
41218
  if (!userEmail) {
40810
- // console.error("No user specified for Netskrafl UI");
40811
- throw new Error('No user specified for Netskrafl UI');
41219
+ return Promise.reject(new Error('No user specified for Netskrafl UI'));
40812
41220
  }
40813
- // Check whether we already have a mounted UI for this user
40814
- const elemId = `netskrafl-user-${userEmail}`;
40815
- const existing = document.getElementById(elemId);
40816
- if (existing) {
40817
- // console.log("Netskrafl UI already mounted for user", userEmail);
40818
- return existing;
40819
- }
40820
- // Create a new div element to hold the UI
40821
- const root = document.createElement('div');
40822
- root.id = elemId;
40823
- root.className = 'netskrafl-loading';
40824
- // Attach the partially-mounted div to the document body
40825
- // as a placeholder while the UI is being mounted
40826
- document.body.appendChild(root);
40827
- const loginResult = await main(state, root);
40828
- if (loginResult === 'success') {
40829
- // The UI was successfully mounted
40830
- root.className = 'netskrafl-user';
40831
- return root;
40832
- }
40833
- // console.error("Failed to mount Netskrafl UI for user", userEmail);
40834
- throw new Error('Failed to mount Netskrafl UI');
41221
+ return main(state, container).then((loginResult) => {
41222
+ if (loginResult !== 'success') {
41223
+ throw new Error('Failed to mount Netskrafl UI');
41224
+ }
41225
+ });
40835
41226
  };
40836
41227
  const NetskraflImpl = ({ state, tokenExpired }) => {
40837
- const ref = React.createRef();
41228
+ const ref = React.useRef(null);
41229
+ const mountedRef = React.useRef(false);
40838
41230
  const completeState = makeGlobalState({ ...state, tokenExpired });
40839
41231
  const { userEmail } = completeState;
40840
- /*
40841
- useEffect(() => {
40842
- // Check whether the stylesheet is already present
40843
- if (document.getElementById(CSS_LINK_ID)) return;
40844
- // Load and link the stylesheet into the document head
40845
- const link = document.createElement("link");
40846
- const styleUrl = `${window.location.origin}/static/css/netskrafl.css`;
40847
- link.id = CSS_LINK_ID;
40848
- link.rel = "stylesheet";
40849
- link.type = "text/css";
40850
- link.href = styleUrl;
40851
- document.head.appendChild(link);
40852
- // We don't bother to remove the stylesheet when the component is unmounted
40853
- }, []);
40854
- */
40855
41232
  // biome-ignore lint/correctness/useExhaustiveDependencies: The dependency is only on userEmail
40856
41233
  React.useEffect(() => {
40857
- // Load the Netskrafl (Mithril) UI for a new user
41234
+ // Mount the Netskrafl (Mithril) UI for the current user
40858
41235
  if (!userEmail)
40859
41236
  return;
40860
41237
  const container = ref.current;
@@ -40862,36 +41239,19 @@ const NetskraflImpl = ({ state, tokenExpired }) => {
40862
41239
  console.error('No container for Netskrafl UI');
40863
41240
  return;
40864
41241
  }
40865
- const elemId = `netskrafl-user-${userEmail}`;
40866
- if (container.firstElementChild?.id === elemId) {
40867
- // The Netskrafl UI is already correctly mounted
40868
- return;
40869
- }
40870
- try {
40871
- mountForUser(completeState).then((div) => {
40872
- // Attach the div as a child of the container
40873
- // instead of any previous children
40874
- const container = ref.current;
40875
- if (container) {
40876
- container.innerHTML = '';
40877
- container.appendChild(div);
40878
- }
40879
- });
40880
- }
40881
- catch (_) {
40882
- console.error('Failed to mount Netskrafl UI for user', userEmail);
40883
- const container = document.getElementById('netskrafl-container');
40884
- if (container)
40885
- container.innerHTML = '';
40886
- }
41242
+ // Mount Mithril directly into the container
41243
+ mountForUser(completeState, container)
41244
+ .then(() => {
41245
+ mountedRef.current = true;
41246
+ })
41247
+ .catch((error) => {
41248
+ console.error('Failed to mount Netskrafl UI:', error);
41249
+ });
40887
41250
  return () => {
40888
- // Move the Netskrafl UI to a hidden div under the body element
40889
- // when the component is unmounted
40890
- // console.log("Dismounting Netskrafl UI for user", userEmail);
40891
- const container = document.getElementById('netskrafl-container');
40892
- const div = container?.firstElementChild;
40893
- if (div?.id === elemId) {
40894
- document.body.appendChild(div);
41251
+ // Only unmount if mounting actually succeeded
41252
+ if (mountedRef.current) {
41253
+ unmount(container);
41254
+ mountedRef.current = false;
40895
41255
  }
40896
41256
  };
40897
41257
  }, [userEmail]);