@scrabble-solver/scrabble-solver 2.8.9 → 2.8.11

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.
Files changed (74) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +11 -11
  3. package/.next/cache/.tsbuildinfo +1 -1
  4. package/.next/cache/eslint/.cache_8dgz12 +1 -1
  5. package/.next/cache/next-server.js.nft.json +1 -1
  6. package/.next/cache/webpack/client-production/0.pack +0 -0
  7. package/.next/cache/webpack/client-production/index.pack +0 -0
  8. package/.next/cache/webpack/server-production/0.pack +0 -0
  9. package/.next/cache/webpack/server-production/index.pack +0 -0
  10. package/.next/next-server.js.nft.json +1 -1
  11. package/.next/prerender-manifest.json +1 -1
  12. package/.next/routes-manifest.json +1 -1
  13. package/.next/server/chunks/413.js +189 -43
  14. package/.next/server/chunks/429.js +2 -13
  15. package/.next/server/chunks/515.js +469 -254
  16. package/.next/server/chunks/911.js +25 -3
  17. package/.next/server/middleware-build-manifest.js +1 -1
  18. package/.next/server/pages/404.html +2 -2
  19. package/.next/server/pages/404.js.nft.json +1 -1
  20. package/.next/server/pages/500.html +2 -2
  21. package/.next/server/pages/_app.js.nft.json +1 -1
  22. package/.next/server/pages/api/solve.js +77 -20
  23. package/.next/server/pages/index.html +3 -3
  24. package/.next/server/pages/index.js +1 -1
  25. package/.next/server/pages/index.js.nft.json +1 -1
  26. package/.next/server/pages/index.json +1 -1
  27. package/.next/static/VJkrGviICslA_8zNVJ-g-/_buildManifest.js +1 -0
  28. package/.next/static/{yCxjzzYpw5JjJE53PO_s6 → VJkrGviICslA_8zNVJ-g-}/_ssgManifest.js +0 -0
  29. package/.next/static/chunks/317-8e8909dd2f587b64.js +1 -0
  30. package/.next/static/chunks/546-447e243fc9de2c59.js +1 -0
  31. package/.next/static/chunks/pages/{404-90c624da3c83fd17.js → 404-7082923654d5996f.js} +1 -1
  32. package/.next/static/chunks/pages/_app-57c77cad0f197d93.js +1 -0
  33. package/.next/static/chunks/pages/index-d3360e075ca3c222.js +1 -0
  34. package/.next/static/css/9ac903004135f4b1.css +1 -0
  35. package/.next/trace +42 -42
  36. package/package.json +12 -12
  37. package/src/components/Badge/Badge.module.scss +1 -1
  38. package/src/components/Board/Board.tsx +4 -2
  39. package/src/components/Board/BoardPure.tsx +25 -5
  40. package/src/components/Board/components/Cell/CellPure.tsx +33 -31
  41. package/src/components/Board/hooks/useGrid.ts +217 -91
  42. package/src/components/Dictionary/Dictionary.tsx +8 -1
  43. package/src/components/Rack/Rack.tsx +51 -11
  44. package/src/components/Rack/RackTile.tsx +33 -16
  45. package/src/components/Results/Results.tsx +19 -3
  46. package/src/components/Sidebar/Sidebar.tsx +20 -1
  47. package/src/components/SquareButton/Link.tsx +1 -1
  48. package/src/components/Tile/Tile.module.scss +4 -0
  49. package/src/components/Tile/Tile.tsx +13 -4
  50. package/src/components/Tile/TilePure.tsx +3 -4
  51. package/src/lib/extractCharacters.ts +26 -0
  52. package/src/lib/extractInputValue.ts +17 -0
  53. package/src/lib/index.ts +2 -0
  54. package/src/lib/isCtrl.ts +1 -1
  55. package/src/lib/memoize.ts +15 -1
  56. package/src/pages/api/solve.ts +1 -1
  57. package/src/sdk/fetchJson.ts +36 -0
  58. package/src/sdk/findWordDefinitions.ts +4 -3
  59. package/src/sdk/solve.ts +8 -7
  60. package/src/sdk/verify.ts +5 -6
  61. package/src/state/sagas.ts +9 -3
  62. package/src/state/selectors.ts +9 -1
  63. package/src/state/slices/dictionaryInitialState.ts +10 -2
  64. package/src/state/slices/dictionarySlice.ts +10 -16
  65. package/src/state/slices/rackSlice.ts +7 -5
  66. package/src/state/slices/solveInitialState.ts +14 -2
  67. package/src/state/slices/solveSlice.ts +7 -4
  68. package/src/types/index.ts +2 -0
  69. package/.next/static/chunks/317-a33dd38e9b9a17ed.js +0 -1
  70. package/.next/static/chunks/758-f333b1dcdb941547.js +0 -1
  71. package/.next/static/chunks/pages/_app-f8f360878e1c2aff.js +0 -1
  72. package/.next/static/chunks/pages/index-ecea697d3e5d8a6f.js +0 -1
  73. package/.next/static/css/64dc2ce1811912f1.css +0 -1
  74. package/.next/static/yCxjzzYpw5JjJE53PO_s6/_buildManifest.js +0 -1
@@ -1,9 +1,16 @@
1
- import { BLANK } from '@scrabble-solver/constants';
2
1
  import classNames from 'classnames';
3
- import { createRef, FunctionComponent, useCallback, useMemo, useRef } from 'react';
2
+ import { ChangeEvent, ClipboardEvent, createRef, FunctionComponent, useCallback, useMemo, useRef } from 'react';
3
+ import { useDispatch } from 'react-redux';
4
4
 
5
- import { createArray, createKeyboardNavigation, zipCharactersAndTiles } from 'lib';
6
- import { selectConfig, selectRack, selectResultCandidateTiles, useTypedSelector } from 'state';
5
+ import {
6
+ createArray,
7
+ createKeyboardNavigation,
8
+ extractCharacters,
9
+ extractInputValue,
10
+ isCtrl,
11
+ zipCharactersAndTiles,
12
+ } from 'lib';
13
+ import { rackSlice, selectConfig, selectRack, selectResultCandidateTiles, useTypedSelector } from 'state';
7
14
 
8
15
  import styles from './Rack.module.scss';
9
16
  import RackTile from './RackTile';
@@ -13,6 +20,7 @@ interface Props {
13
20
  }
14
21
 
15
22
  const Rack: FunctionComponent<Props> = ({ className }) => {
23
+ const dispatch = useDispatch();
16
24
  const config = useTypedSelector(selectConfig);
17
25
  const rack = useTypedSelector(selectRack);
18
26
  const resultCandidateTiles = useTypedSelector(selectResultCandidateTiles);
@@ -35,7 +43,33 @@ const Rack: FunctionComponent<Props> = ({ className }) => {
35
43
  [activeIndexRef, tilesCount, tilesRefs],
36
44
  );
37
45
 
38
- const onKeyDown = useMemo(() => {
46
+ const handleChange = useCallback(
47
+ (event: ChangeEvent<HTMLInputElement>) => {
48
+ const value = extractInputValue(event.target);
49
+ const characters = value ? extractCharacters(config, value) : [];
50
+ changeActiveIndex(value ? characters.length : -1);
51
+ },
52
+ [changeActiveIndex, config],
53
+ );
54
+
55
+ const handlePaste = useCallback(
56
+ (event: ClipboardEvent<HTMLInputElement>) => {
57
+ const index = activeIndexRef.current;
58
+
59
+ if (typeof index === 'undefined') {
60
+ return;
61
+ }
62
+
63
+ event.preventDefault();
64
+ const value = event.clipboardData.getData('text/plain').toLocaleLowerCase();
65
+ const characters = value ? extractCharacters(config, value) : [];
66
+ changeActiveIndex(value ? characters.length : -1);
67
+ dispatch(rackSlice.actions.changeCharacters({ characters, index }));
68
+ },
69
+ [changeActiveIndex, config, dispatch],
70
+ );
71
+
72
+ const handleKeyDown = useMemo(() => {
39
73
  return createKeyboardNavigation({
40
74
  onArrowLeft: (event) => {
41
75
  event.preventDefault();
@@ -45,13 +79,18 @@ const Rack: FunctionComponent<Props> = ({ className }) => {
45
79
  event.preventDefault();
46
80
  changeActiveIndex(1);
47
81
  },
48
- onBackspace: () => {
82
+ onBackspace: (event) => {
83
+ event.preventDefault();
49
84
  changeActiveIndex(-1);
50
85
  },
51
86
  onKeyDown: (event) => {
52
- const character = event.key.toLowerCase();
53
-
54
- if (config.hasCharacter(character) || character === BLANK) {
87
+ if (isCtrl(event) && config.isTwoCharacterTilePrefix(event.key)) {
88
+ changeActiveIndex(1);
89
+ } else if (event.currentTarget.value === event.key) {
90
+ // change event did not fire because the same character was typed over the current one
91
+ // but we still want to move the caret
92
+ event.preventDefault();
93
+ event.stopPropagation();
55
94
  changeActiveIndex(1);
56
95
  }
57
96
  },
@@ -59,7 +98,7 @@ const Rack: FunctionComponent<Props> = ({ className }) => {
59
98
  }, [changeActiveIndex, config]);
60
99
 
61
100
  return (
62
- <div className={classNames(styles.rack, className)}>
101
+ <div className={classNames(styles.rack, className)} onPaste={handlePaste}>
63
102
  {tiles.map(({ character, tile }, index) => (
64
103
  <RackTile
65
104
  activeIndexRef={activeIndexRef}
@@ -68,7 +107,8 @@ const Rack: FunctionComponent<Props> = ({ className }) => {
68
107
  inputRef={tilesRefs[index]}
69
108
  key={index}
70
109
  tile={tile}
71
- onKeyDown={onKeyDown}
110
+ onChange={handleChange}
111
+ onKeyDown={handleKeyDown}
72
112
  />
73
113
  ))}
74
114
  </div>
@@ -1,6 +1,8 @@
1
1
  import { BLANK } from '@scrabble-solver/constants';
2
2
  import { Tile as TileModel } from '@scrabble-solver/types';
3
3
  import {
4
+ ChangeEvent,
5
+ ChangeEventHandler,
4
6
  FunctionComponent,
5
7
  KeyboardEventHandler,
6
8
  MutableRefObject,
@@ -10,7 +12,7 @@ import {
10
12
  } from 'react';
11
13
  import { useDispatch } from 'react-redux';
12
14
 
13
- import { createKeyboardNavigation, isCtrl } from 'lib';
15
+ import { createKeyboardNavigation, extractCharacters, extractInputValue, isCtrl } from 'lib';
14
16
  import { TILE_SIZE } from 'parameters';
15
17
  import { rackSlice, selectCharacterPoints, selectConfig, useTranslate, useTypedSelector } from 'state';
16
18
 
@@ -24,10 +26,19 @@ interface Props {
24
26
  index: number;
25
27
  inputRef: RefObject<HTMLInputElement>;
26
28
  tile: TileModel | null;
29
+ onChange: ChangeEventHandler<HTMLInputElement>;
27
30
  onKeyDown: KeyboardEventHandler<HTMLInputElement>;
28
31
  }
29
32
 
30
- const RackTile: FunctionComponent<Props> = ({ activeIndexRef, character, index, inputRef, tile, onKeyDown }) => {
33
+ const RackTile: FunctionComponent<Props> = ({
34
+ activeIndexRef,
35
+ character,
36
+ index,
37
+ inputRef,
38
+ tile,
39
+ onChange,
40
+ onKeyDown,
41
+ }) => {
31
42
  const dispatch = useDispatch();
32
43
  const translate = useTranslate();
33
44
  const config = useTypedSelector(selectConfig);
@@ -37,32 +48,37 @@ const RackTile: FunctionComponent<Props> = ({ activeIndexRef, character, index,
37
48
  activeIndexRef.current = index;
38
49
  }, [index]);
39
50
 
40
- const handleCharacterChange = useCallback(
41
- (value: string | null) => {
42
- dispatch(rackSlice.actions.changeCharacter({ character: value, index }));
51
+ const handleChange = useCallback(
52
+ (event: ChangeEvent<HTMLInputElement>) => {
53
+ event.preventDefault();
54
+ event.stopPropagation();
55
+ const value = extractInputValue(event.target);
56
+ const characters = value ? extractCharacters(config, value) : [null];
57
+ dispatch(rackSlice.actions.changeCharacters({ characters, index }));
58
+ onChange(event);
43
59
  },
44
- [index],
60
+ [config, index, onChange],
45
61
  );
46
62
 
47
63
  const handleKeyDown = useMemo(() => {
48
64
  return createKeyboardNavigation({
49
- onBackspace: () => handleCharacterChange(null),
50
- onDelete: () => handleCharacterChange(null),
65
+ onBackspace: (event) => {
66
+ event.preventDefault();
67
+ dispatch(rackSlice.actions.changeCharacter({ character: null, index }));
68
+ },
51
69
  onKeyDown: (event) => {
52
- const newCharacter = event.key.toLowerCase();
53
- const twoCharacterTile = config.getTwoCharacterTileByPrefix(newCharacter);
54
-
55
- if (isCtrl(event) && twoCharacterTile) {
70
+ if (isCtrl(event) && config.isTwoCharacterTilePrefix(event.key)) {
56
71
  event.preventDefault();
57
- handleCharacterChange(twoCharacterTile);
58
- } else if (config.hasCharacter(newCharacter) || newCharacter === BLANK) {
59
- handleCharacterChange(newCharacter);
72
+ event.stopPropagation();
73
+ const twoTilesCharacter = config.getTwoCharacterTileByPrefix(event.key);
74
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
75
+ dispatch(rackSlice.actions.changeCharacter({ character: twoTilesCharacter!, index }));
60
76
  }
61
77
 
62
78
  onKeyDown(event);
63
79
  },
64
80
  });
65
- }, [config, handleCharacterChange, onKeyDown]);
81
+ }, [index, onKeyDown]);
66
82
 
67
83
  return (
68
84
  <Tile
@@ -78,6 +94,7 @@ const RackTile: FunctionComponent<Props> = ({ activeIndexRef, character, index,
78
94
  raised
79
95
  size={TILE_SIZE}
80
96
  tabIndex={index === 0 ? undefined : -1}
97
+ onChange={handleChange}
81
98
  onFocus={handleFocus}
82
99
  onKeyDown={handleKeyDown}
83
100
  />
@@ -1,11 +1,12 @@
1
1
  import classNames from 'classnames';
2
- import { FunctionComponent } from 'react';
2
+ import { FunctionComponent, useLayoutEffect, useRef } from 'react';
3
3
  import { FixedSizeList } from 'react-window';
4
4
 
5
5
  import { RESULTS_HEADER_HEIGHT, RESULTS_INPUT_HEIGHT, RESULTS_ITEM_HEIGHT } from 'parameters';
6
6
  import {
7
7
  selectAreResultsOutdated,
8
8
  selectIsLoading,
9
+ selectSolveError,
9
10
  selectSortedFilteredResults,
10
11
  selectSortedResults,
11
12
  useTranslate,
@@ -33,6 +34,14 @@ const Results: FunctionComponent<Props> = ({ height, width }) => {
33
34
  const results = useTypedSelector(selectSortedFilteredResults);
34
35
  const isLoading = useTypedSelector(selectIsLoading);
35
36
  const isOutdated = useTypedSelector(selectAreResultsOutdated);
37
+ const error = useTypedSelector(selectSolveError);
38
+ const listRef = useRef<HTMLElement>();
39
+
40
+ useLayoutEffect(() => {
41
+ if (listRef.current) {
42
+ listRef.current.scrollTo(0, 0);
43
+ }
44
+ }, [listRef, results]);
36
45
 
37
46
  return (
38
47
  <div className={styles.results}>
@@ -42,7 +51,13 @@ const Results: FunctionComponent<Props> = ({ height, width }) => {
42
51
  ))}
43
52
  </div>
44
53
 
45
- {typeof results === 'undefined' && (
54
+ {typeof error !== 'undefined' && (
55
+ <EmptyState className={styles.emptyState} type="error">
56
+ {error.message}
57
+ </EmptyState>
58
+ )}
59
+
60
+ {typeof results === 'undefined' && typeof error === 'undefined' && (
46
61
  <EmptyState className={styles.emptyState} type="info">
47
62
  {translate('results.empty-state.uninitialized')}
48
63
 
@@ -50,7 +65,7 @@ const Results: FunctionComponent<Props> = ({ height, width }) => {
50
65
  </EmptyState>
51
66
  )}
52
67
 
53
- {typeof results !== 'undefined' && typeof allResults !== 'undefined' && (
68
+ {typeof results !== 'undefined' && typeof allResults !== 'undefined' && typeof error === 'undefined' && (
54
69
  <>
55
70
  {isOutdated && (
56
71
  <EmptyState className={styles.emptyState} type="info">
@@ -80,6 +95,7 @@ const Results: FunctionComponent<Props> = ({ height, width }) => {
80
95
  [styles.outdated]: isOutdated,
81
96
  })}
82
97
  height={height - RESULTS_HEADER_HEIGHT - RESULTS_INPUT_HEIGHT}
98
+ innerRef={listRef}
83
99
  itemCount={results.length}
84
100
  itemSize={RESULTS_ITEM_HEIGHT}
85
101
  width={width}
@@ -1,6 +1,7 @@
1
1
  import classNames from 'classnames';
2
- import { FunctionComponent, ReactNode } from 'react';
2
+ import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
3
3
  import Modal from 'react-modal';
4
+ import { useKey } from 'react-use';
4
5
 
5
6
  import { CrossFill } from 'icons';
6
7
  import { TRANSITION_DURATION_LONG } from 'parameters';
@@ -21,6 +22,23 @@ export interface Props {
21
22
 
22
23
  const Sidebar: FunctionComponent<Props> = ({ children, className, isOpen, title, onClose }) => {
23
24
  const translate = useTranslate();
25
+ const [shouldReturnFocusAfterClose, setShouldReturnFocusAfterClose] = useState(true);
26
+
27
+ useKey(
28
+ 'Escape',
29
+ () => {
30
+ setShouldReturnFocusAfterClose(false);
31
+ onClose();
32
+ },
33
+ undefined,
34
+ [onClose],
35
+ );
36
+
37
+ useEffect(() => {
38
+ if (isOpen) {
39
+ setShouldReturnFocusAfterClose(true);
40
+ }
41
+ }, [isOpen]);
24
42
 
25
43
  return (
26
44
  <Modal
@@ -33,6 +51,7 @@ const Sidebar: FunctionComponent<Props> = ({ children, className, isOpen, title,
33
51
  contentLabel={title}
34
52
  isOpen={isOpen}
35
53
  overlayClassName={styles.overlay}
54
+ shouldReturnFocusAfterClose={shouldReturnFocusAfterClose}
36
55
  onRequestClose={onClose}
37
56
  >
38
57
  <div className={classNames(styles.sidebar, className)}>
@@ -16,7 +16,7 @@ const Link: FunctionComponent<Props> = ({ className, Icon, tooltip, ...props })
16
16
  const triggerProps = useTooltip(tooltip, props);
17
17
 
18
18
  return (
19
- <a className={classNames(styles.squareButton, className)} type="button" {...props} {...triggerProps}>
19
+ <a className={classNames(styles.squareButton, className)} {...props} {...triggerProps}>
20
20
  <span className={styles.content}>
21
21
  <Icon className={styles.icon} />
22
22
  </span>
@@ -62,6 +62,10 @@
62
62
  color: var(--color--inactive);
63
63
  }
64
64
 
65
+ &::selection {
66
+ background: transparent;
67
+ }
68
+
65
69
  &:disabled {
66
70
  color: inherit;
67
71
  }
@@ -1,5 +1,6 @@
1
1
  import { EMPTY_CELL } from '@scrabble-solver/constants';
2
2
  import {
3
+ ChangeEventHandler,
3
4
  createRef,
4
5
  FocusEventHandler,
5
6
  FunctionComponent,
@@ -9,7 +10,7 @@ import {
9
10
  useMemo,
10
11
  } from 'react';
11
12
 
12
- import { getTileSizes } from 'lib';
13
+ import { getTileSizes, noop } from 'lib';
13
14
 
14
15
  import TilePure from './TilePure';
15
16
 
@@ -26,6 +27,7 @@ interface Props {
26
27
  raised?: boolean;
27
28
  size: number;
28
29
  tabIndex?: number;
30
+ onChange?: ChangeEventHandler<HTMLInputElement>;
29
31
  onFocus?: FocusEventHandler<HTMLInputElement>;
30
32
  onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
31
33
  }
@@ -43,8 +45,9 @@ const Tile: FunctionComponent<Props> = ({
43
45
  raised,
44
46
  size,
45
47
  tabIndex,
46
- onFocus,
47
- onKeyDown,
48
+ onChange,
49
+ onFocus = noop,
50
+ onKeyDown = noop,
48
51
  }) => {
49
52
  const { pointsFontSize, tileFontSize, tileSize } = getTileSizes(size);
50
53
  const style = useMemo(() => ({ height: tileSize, width: tileSize }), [tileSize]);
@@ -54,6 +57,11 @@ const Tile: FunctionComponent<Props> = ({
54
57
  const isEmpty = !character || character === EMPTY_CELL;
55
58
  const canShowPoints = (isBlank || !isEmpty) && typeof points !== 'undefined';
56
59
 
60
+ const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
61
+ inputRef.current?.select();
62
+ onKeyDown(event);
63
+ };
64
+
57
65
  useEffect(() => {
58
66
  if (autoFocus && inputRef.current) {
59
67
  inputRef.current.focus();
@@ -77,8 +85,9 @@ const Tile: FunctionComponent<Props> = ({
77
85
  raised={raised}
78
86
  style={style}
79
87
  tabIndex={tabIndex}
88
+ onChange={onChange}
80
89
  onFocus={onFocus}
81
- onKeyDown={onKeyDown}
90
+ onKeyDown={handleKeyDown}
82
91
  />
83
92
  );
84
93
  };
@@ -27,12 +27,11 @@ interface Props {
27
27
  raised?: boolean;
28
28
  style?: CSSProperties;
29
29
  tabIndex?: number;
30
+ onChange?: ChangeEventHandler<HTMLInputElement>;
30
31
  onFocus?: FocusEventHandler<HTMLInputElement>;
31
32
  onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
32
33
  }
33
34
 
34
- const handleChange: ChangeEventHandler = (event) => event.preventDefault();
35
-
36
35
  const TilePure: FunctionComponent<Props> = ({
37
36
  autoFocus,
38
37
  canShowPoints,
@@ -49,6 +48,7 @@ const TilePure: FunctionComponent<Props> = ({
49
48
  raised,
50
49
  style,
51
50
  tabIndex,
51
+ onChange,
52
52
  onFocus,
53
53
  onKeyDown,
54
54
  }) => (
@@ -72,14 +72,13 @@ const TilePure: FunctionComponent<Props> = ({
72
72
  autoFocus={autoFocus}
73
73
  className={styles.character}
74
74
  disabled={disabled}
75
- maxLength={1}
76
75
  placeholder={placeholder}
77
76
  ref={inputRef}
78
77
  spellCheck={false}
79
78
  style={inputStyle}
80
79
  tabIndex={tabIndex}
81
80
  value={character || ''}
82
- onChange={handleChange}
81
+ onChange={onChange}
83
82
  onFocus={onFocus}
84
83
  onKeyDown={onKeyDown}
85
84
  />
@@ -0,0 +1,26 @@
1
+ import { BLANK } from '@scrabble-solver/constants';
2
+ import { Config } from '@scrabble-solver/types';
3
+
4
+ const extractCharacters = (config: Config, value: string): string[] => {
5
+ let index = 0;
6
+ const characters: string[] = [];
7
+
8
+ while (index < value.length) {
9
+ const character = value[index];
10
+ const nextCharacter = value[index + 1];
11
+ const twoCharacterTileCandidate = `${character}${nextCharacter}`;
12
+
13
+ if (config.twoCharacterTiles.includes(twoCharacterTileCandidate)) {
14
+ characters.push(twoCharacterTileCandidate);
15
+ ++index;
16
+ } else if (config.hasCharacter(character) || character === BLANK) {
17
+ characters.push(character);
18
+ }
19
+
20
+ ++index;
21
+ }
22
+
23
+ return characters;
24
+ };
25
+
26
+ export default extractCharacters;
@@ -0,0 +1,17 @@
1
+ const extractInputValue = (input: HTMLInputElement) => {
2
+ const value = input.value.toLocaleLowerCase();
3
+
4
+ if (input.selectionStart === null || input.selectionEnd === null) {
5
+ return value;
6
+ }
7
+
8
+ const index = Math.min(input.selectionStart, input.selectionEnd);
9
+
10
+ if (index > 0) {
11
+ return value.substring(index - 1, index);
12
+ }
13
+
14
+ return value;
15
+ };
16
+
17
+ export default extractInputValue;
package/src/lib/index.ts CHANGED
@@ -6,6 +6,8 @@ export { default as createKeyboardNavigation } from './createKeyboardNavigation'
6
6
  export { default as createKeyComparator } from './createKeyComparator';
7
7
  export { default as createNullMovingComparator } from './createNullMovingComparator';
8
8
  export { default as detectLocale } from './detectLocale';
9
+ export { default as extractCharacters } from './extractCharacters';
10
+ export { default as extractInputValue } from './extractInputValue';
9
11
  export { default as findCell } from './findCell';
10
12
  export { default as getCellSize } from './getCellSize';
11
13
  export { default as getRemainingTiles } from './getRemainingTiles';
package/src/lib/isCtrl.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { KeyboardEvent } from 'react';
2
2
 
3
- const isCtrl = <T>(event: KeyboardEvent<T>): boolean => {
3
+ const isCtrl = <T>(event: KeyboardEvent<T> | globalThis.KeyboardEvent): boolean => {
4
4
  return event.ctrlKey || event.metaKey;
5
5
  };
6
6
 
@@ -1,5 +1,5 @@
1
1
  interface AnyFunction {
2
- (...parameters: any[]): any; // eslint-disable-line @typescript-eslint/no-explicit-any
2
+ (...parameters: any[]): any | Promise<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
3
3
  }
4
4
 
5
5
  interface AnyCachedFunction<T extends AnyFunction> extends AnyFunction {
@@ -20,6 +20,14 @@ const memoize = <T extends AnyFunction>(fn: T): AnyCachedFunction<T> => {
20
20
  return cache.find((entry) => parametersEqual(entry.parameters, parameters))?.result;
21
21
  };
22
22
 
23
+ const removeCache = (parameters: Parameters<T>): void => {
24
+ const index = cache.findIndex((entry) => parametersEqual(entry.parameters, parameters));
25
+
26
+ if (index >= 0) {
27
+ cache.splice(index, 1);
28
+ }
29
+ };
30
+
23
31
  const writeCache = (parameters: Parameters<T>, result: ReturnType<T>): void => {
24
32
  cache.push({ parameters, result });
25
33
  };
@@ -33,6 +41,12 @@ const memoize = <T extends AnyFunction>(fn: T): AnyCachedFunction<T> => {
33
41
 
34
42
  const result = fn(...parameters);
35
43
 
44
+ if (result instanceof Promise) {
45
+ result.catch(() => {
46
+ removeCache(parameters);
47
+ });
48
+ }
49
+
36
50
  writeCache(parameters, result);
37
51
 
38
52
  return result;
@@ -37,7 +37,7 @@ const solve = async (request: NextApiRequest, response: NextApiResponse): Promis
37
37
  const trie = await dictionaries.get(locale);
38
38
  const tiles = characters.map((character) => new Tile({ character, isBlank: character === BLANK }));
39
39
  const results = solveScrabble(trie, config, board, tiles);
40
- response.status(200).send(results.map((result) => result.toJson()));
40
+ response.status(200).send(results);
41
41
  } catch (error) {
42
42
  const message = error instanceof Error ? error.message : 'Unknown error';
43
43
  logger.error('solve - error', { error, meta });
@@ -0,0 +1,36 @@
1
+ import { isError } from '@scrabble-solver/types';
2
+
3
+ const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> => {
4
+ let response: Response;
5
+
6
+ try {
7
+ response = await fetch(input, {
8
+ ...init,
9
+ headers: {
10
+ 'Content-Type': 'application/json',
11
+ ...init?.headers,
12
+ },
13
+ });
14
+ } catch (error) {
15
+ const message = isError(error) ? error.message : 'Unknown error';
16
+ throw new Error(`Network error: ${message}`);
17
+ }
18
+
19
+ if (response.ok) {
20
+ return response.json();
21
+ }
22
+
23
+ try {
24
+ const json = await response.json();
25
+
26
+ if (isError(json)) {
27
+ throw new Error(json.message);
28
+ }
29
+ } finally {
30
+ // do nothing
31
+ }
32
+
33
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
34
+ };
35
+
36
+ export default fetchJson;
@@ -1,8 +1,9 @@
1
- import { Locale, WordDefinition } from '@scrabble-solver/types';
1
+ import { Locale, WordDefinition, WordDefinitionJson } from '@scrabble-solver/types';
2
+
3
+ import fetchJson from './fetchJson';
2
4
 
3
5
  const findWordDefinitions = async (locale: Locale, word: string): Promise<WordDefinition[]> => {
4
- const url = `/api/dictionary/${locale}/${word}`;
5
- const json = await fetch(url).then((response) => response.json());
6
+ const json = await fetchJson<WordDefinitionJson[]>(`/api/dictionary/${locale}/${word}`);
6
7
  return json.map(WordDefinition.fromJson);
7
8
  };
8
9
 
package/src/sdk/solve.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { BoardJson, Locale, ResultJson } from '@scrabble-solver/types';
1
+ import { BoardJson, Locale, Result, ResultJson } from '@scrabble-solver/types';
2
+
3
+ import fetchJson from './fetchJson';
2
4
 
3
5
  interface Payload {
4
6
  board: BoardJson;
@@ -7,14 +9,13 @@ interface Payload {
7
9
  locale: Locale;
8
10
  }
9
11
 
10
- const solve = ({ board, characters, configId, locale }: Payload): Promise<ResultJson[]> => {
11
- return fetch('/api/solve', {
12
+ const solve = async ({ board, characters, configId, locale }: Payload): Promise<Result[]> => {
13
+ const json = await fetchJson<ResultJson[]>('/api/solve', {
12
14
  method: 'POST',
13
- headers: {
14
- 'Content-Type': 'application/json',
15
- },
16
15
  body: JSON.stringify({ board, characters, configId, locale }),
17
- }).then((response) => response.json());
16
+ });
17
+
18
+ return json.map(Result.fromJson);
18
19
  };
19
20
 
20
21
  export default solve;
package/src/sdk/verify.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { BoardJson, Locale } from '@scrabble-solver/types';
2
2
 
3
+ import fetchJson from './fetchJson';
4
+
3
5
  interface Payload {
4
6
  board: BoardJson;
5
7
  configId: string;
@@ -11,14 +13,11 @@ interface Response {
11
13
  validWords: string[];
12
14
  }
13
15
 
14
- const verify = ({ board, configId, locale }: Payload): Promise<Response> => {
15
- return fetch('/api/verify', {
16
+ const verify = async ({ board, configId, locale }: Payload): Promise<Response> => {
17
+ return fetchJson<Response>('/api/verify', {
16
18
  method: 'POST',
17
- headers: {
18
- 'Content-Type': 'application/json',
19
- },
20
19
  body: JSON.stringify({ board, configId, locale }),
21
- }).then((response) => response.json());
20
+ });
22
21
  };
23
22
 
24
23
  export default verify;