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