@use-kona/editor 0.1.16 → 0.1.17

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 (30) hide show
  1. package/dist/plugins/CommandsPlugin/CommandsPlugin.d.ts +2 -3
  2. package/dist/plugins/CommandsPlugin/CommandsPlugin.js +5 -15
  3. package/dist/plugins/CommandsPlugin/Menu.d.ts +3 -3
  4. package/dist/plugins/CommandsPlugin/Menu.js +240 -69
  5. package/dist/plugins/CommandsPlugin/index.d.ts +1 -1
  6. package/dist/plugins/CommandsPlugin/resolveCommands.d.ts +34 -0
  7. package/dist/plugins/CommandsPlugin/resolveCommands.js +150 -0
  8. package/dist/plugins/CommandsPlugin/resolveCommands.spec.d.ts +1 -0
  9. package/dist/plugins/CommandsPlugin/resolveCommands.spec.js +277 -0
  10. package/dist/plugins/CommandsPlugin/styles.module.js +5 -0
  11. package/dist/plugins/CommandsPlugin/styles_module.css +47 -7
  12. package/dist/plugins/CommandsPlugin/types.d.ts +14 -2
  13. package/dist/plugins/CommandsPlugin/useResolvedCommands.d.ts +12 -0
  14. package/dist/plugins/CommandsPlugin/useResolvedCommands.js +46 -0
  15. package/dist/plugins/DnDPlugin/DnDPlugin.d.ts +7 -0
  16. package/dist/plugins/DnDPlugin/DnDPlugin.js +34 -7
  17. package/dist/plugins/NodeIdPlugin/NodeIdPlugin.js +6 -1
  18. package/dist/plugins/index.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/plugins/CommandsPlugin/CommandsPlugin.tsx +5 -25
  21. package/src/plugins/CommandsPlugin/Menu.tsx +260 -86
  22. package/src/plugins/CommandsPlugin/index.ts +1 -1
  23. package/src/plugins/CommandsPlugin/resolveCommands.spec.ts +261 -0
  24. package/src/plugins/CommandsPlugin/resolveCommands.ts +275 -0
  25. package/src/plugins/CommandsPlugin/styles.module.css +49 -7
  26. package/src/plugins/CommandsPlugin/types.ts +17 -3
  27. package/src/plugins/CommandsPlugin/useResolvedCommands.ts +67 -0
  28. package/src/plugins/DnDPlugin/DnDPlugin.tsx +64 -9
  29. package/src/plugins/NodeIdPlugin/NodeIdPlugin.ts +10 -2
  30. package/src/plugins/index.ts +6 -1
@@ -1,7 +1,7 @@
1
1
  import { useStore } from '@nanostores/react';
2
2
  import cn from 'clsx';
3
3
  import type { MapStore } from 'nanostores';
4
- import React, {
4
+ import {
5
5
  type CSSProperties,
6
6
  type ReactNode,
7
7
  useEffect,
@@ -16,31 +16,56 @@ import { useFocused, useSlate, useSlateSelection } from 'slate-react';
16
16
  import type { CustomElement } from '../../../types';
17
17
  import { insert, insertText, removeCommand, set, wrap } from './actions';
18
18
  import styles from './styles.module.css';
19
- import type { Command, CommandsStore } from './types';
19
+ import type { Command, CommandPathEntry, CommandsStore } from './types';
20
+ import { useResolvedCommands } from './useResolvedCommands';
20
21
 
21
22
  type Props = {
22
23
  $store: MapStore<CommandsStore>;
23
- commands: Command[];
24
+ rootCommands: Command[];
24
25
  ignoreNodes?: string[];
25
26
  renderMenu: (children: ReactNode) => ReactNode;
26
27
  };
27
28
 
28
29
  export const Menu = (props: Props) => {
29
- const { commands, $store, renderMenu, ignoreNodes = [] } = props;
30
+ const { rootCommands, $store, renderMenu, ignoreNodes = [] } = props;
30
31
  const store = useStore($store);
31
32
  const [style, setStyle] = useState<CSSProperties | undefined>({});
32
33
  const [active, setActive] = useState(0);
33
- const refs = useRef<Record<string, HTMLButtonElement>>({});
34
+ const [path, setPath] = useState<CommandPathEntry[]>([]);
35
+ const refs = useRef<Record<number, HTMLButtonElement | null>>({});
34
36
 
35
37
  const selection = useSlateSelection();
36
38
  const editor = useSlate() as Editor;
37
39
  const isFocused = useFocused();
38
- const ref = useRef<HTMLDivElement>(null)
40
+ const ref = useRef<HTMLDivElement>(null);
39
41
 
40
42
  const entry = Editor.above<CustomElement>(editor, {
41
43
  match: (n) => Editor.isBlock(editor, n as CustomElement),
42
44
  });
43
45
 
46
+ const isBrowseMode = typeof store.filter === 'string' && store.filter === '';
47
+ const isSearchMode = typeof store.filter === 'string' && store.filter !== '';
48
+
49
+ const { commands, isLoading, isError } = useResolvedCommands({
50
+ rootCommands,
51
+ filter: store.filter,
52
+ path,
53
+ editor,
54
+ isOpen: store.isOpen,
55
+ });
56
+ const entries = useMemo(() => {
57
+ const commandEntries = commands.map((command) => ({
58
+ type: 'command' as const,
59
+ command,
60
+ }));
61
+
62
+ if (isBrowseMode && path.length > 0) {
63
+ return [{ type: 'back' as const }, ...commandEntries];
64
+ }
65
+
66
+ return commandEntries;
67
+ }, [commands, isBrowseMode, path.length]);
68
+
44
69
  // biome-ignore lint/correctness/useExhaustiveDependencies: we care only about those deps
45
70
  const actions = useMemo(() => {
46
71
  return {
@@ -50,7 +75,33 @@ export const Menu = (props: Props) => {
50
75
  wrap: wrap(editor, selection, store.filter),
51
76
  insertText: insertText(editor),
52
77
  };
53
- }, [commands, store.filter]);
78
+ }, [selection, store.filter]);
79
+
80
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset on open session changes
81
+ useEffect(() => {
82
+ if (!store.isOpen) {
83
+ return;
84
+ }
85
+ setPath([]);
86
+ setActive(0);
87
+ }, [store.isOpen, store.openId]);
88
+
89
+ useEffect(() => {
90
+ if (store.filter === false || isSearchMode) {
91
+ setActive(0);
92
+ }
93
+ }, [isSearchMode, store.filter]);
94
+
95
+ useEffect(() => {
96
+ if (!entries.length) {
97
+ setActive(0);
98
+ return;
99
+ }
100
+
101
+ if (active > entries.length - 1) {
102
+ setActive(entries.length - 1);
103
+ }
104
+ }, [active, entries]);
54
105
 
55
106
  // biome-ignore lint/correctness/useExhaustiveDependencies: we care only about those deps
56
107
  useLayoutEffect(() => {
@@ -61,55 +112,129 @@ export const Menu = (props: Props) => {
61
112
  return;
62
113
  }
63
114
 
64
- setTimeout(() => {
115
+ const frame = window.requestAnimationFrame(() => {
65
116
  const domSelection = window.getSelection();
66
- const domRange = domSelection?.getRangeAt(0);
117
+ const domRange =
118
+ domSelection && domSelection.rangeCount > 0
119
+ ? domSelection.getRangeAt(0)
120
+ : null;
67
121
  const rect = domRange?.getBoundingClientRect();
122
+ const x = (rect?.left || 0) + (rect?.width || 0) / 2;
123
+ const menuHeight = ref.current?.offsetHeight || 0;
124
+ let y = (rect?.top || 0) + (rect?.height || 0) + 2;
68
125
 
69
- if (store.isOpen) {
70
- setStyle({
71
- opacity: 1,
72
- transform: 'scale(1)',
73
- top: `${(rect?.top || 0) + window.scrollY + (rect?.height || 0) + 2}px`,
74
- left: `${(rect?.left || 0) + window.scrollX + (rect?.width || 0) / 2}px`,
75
- });
76
- } else {
77
- setStyle({
78
- opacity: 0,
79
- transform: 'scale(0.9)',
80
- });
126
+ if (menuHeight > 0 && y + menuHeight >= window.innerHeight) {
127
+ y = Math.max(8, (rect?.top || 0) - menuHeight - 2);
81
128
  }
82
- }, 0);
83
129
 
130
+ const transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0) ${store.isOpen ? 'scale(1)' : 'scale(0.95)'}`;
131
+
132
+ setStyle({
133
+ opacity: store.isOpen ? 1 : 0,
134
+ transform,
135
+ });
136
+ });
137
+
138
+ return () => {
139
+ window.cancelAnimationFrame(frame);
140
+ };
141
+ }, [selection, commands.length, isLoading, isError, store.isOpen, isFocused]);
142
+
143
+ useEffect(() => {
84
144
  const handleKeyDown = (event: KeyboardEvent) => {
145
+ if (!store.isOpen) {
146
+ return;
147
+ }
148
+
85
149
  switch (event.key) {
86
150
  case 'ArrowDown': {
151
+ if (!entries.length) {
152
+ return;
153
+ }
154
+
87
155
  event.preventDefault();
156
+ event.stopPropagation();
88
157
  setActive((active) => {
89
- const newActive = active >= commands.length - 1 ? 0 : active + 1;
90
- refs.current[newActive]?.scrollIntoView({ block: 'nearest' });
91
-
92
- return newActive;
158
+ const nextActive = active >= entries.length - 1 ? 0 : active + 1;
159
+ refs.current[nextActive]?.scrollIntoView({ block: 'nearest' });
160
+ return nextActive;
93
161
  });
94
162
  break;
95
163
  }
96
164
  case 'ArrowUp': {
165
+ if (!entries.length) {
166
+ return;
167
+ }
168
+
97
169
  event.preventDefault();
170
+ event.stopPropagation();
98
171
  setActive((active) => {
99
- const newActive = active <= 0 ? commands.length - 1 : active - 1;
100
- refs.current[newActive]?.scrollIntoView({ block: 'nearest' });
101
-
102
- return newActive;
172
+ const nextActive = active <= 0 ? entries.length - 1 : active - 1;
173
+ refs.current[nextActive]?.scrollIntoView({ block: 'nearest' });
174
+ return nextActive;
103
175
  });
104
176
  break;
105
177
  }
178
+ case 'ArrowRight': {
179
+ const entry = entries[active];
180
+ if (!entry || entry.type !== 'command' || !entry.command.isSubmenu) {
181
+ return;
182
+ }
183
+
184
+ event.preventDefault();
185
+ event.stopPropagation();
186
+ setPath(entry.command.path);
187
+ if (!isBrowseMode) {
188
+ $store.setKey('filter', '');
189
+ }
190
+ setActive(0);
191
+ break;
192
+ }
193
+ case 'ArrowLeft': {
194
+ if (!isBrowseMode || !path.length) {
195
+ return;
196
+ }
197
+
198
+ event.preventDefault();
199
+ event.stopPropagation();
200
+ setPath((path) => path.slice(0, path.length - 1));
201
+ setActive(0);
202
+ break;
203
+ }
106
204
  case 'Enter': {
205
+ const entry = entries[active];
206
+ if (!entry) {
207
+ return;
208
+ }
209
+
107
210
  event.preventDefault();
108
- commands[active]?.action(actions, editor);
211
+ event.stopPropagation();
212
+
213
+ if (entry.type === 'back' && isBrowseMode) {
214
+ setPath((path) => path.slice(0, path.length - 1));
215
+ setActive(0);
216
+ break;
217
+ }
218
+
219
+ if (entry.type !== 'command') {
220
+ break;
221
+ }
222
+
223
+ if (entry.command.isSubmenu) {
224
+ setPath(entry.command.path);
225
+ if (!isBrowseMode) {
226
+ $store.setKey('filter', '');
227
+ }
228
+ setActive(0);
229
+ break;
230
+ }
231
+
232
+ entry.command.command.action?.(actions, editor);
109
233
  $store.setKey('isOpen', false);
110
234
  break;
111
235
  }
112
236
  case 'Escape': {
237
+ event.preventDefault();
113
238
  event.stopPropagation();
114
239
  $store.setKey('isOpen', false);
115
240
  break;
@@ -117,46 +242,25 @@ export const Menu = (props: Props) => {
117
242
  }
118
243
  };
119
244
 
120
- if (store.isOpen && commands.length > 0) {
121
- document.addEventListener('keydown', handleKeyDown);
122
- }
245
+ document.addEventListener('keydown', handleKeyDown);
123
246
 
124
247
  return () => {
125
248
  document.removeEventListener('keydown', handleKeyDown);
126
249
  };
127
- }, [selection, active, actions, store.isOpen]);
250
+ }, [
251
+ actions,
252
+ active,
253
+ entries,
254
+ editor,
255
+ isBrowseMode,
256
+ path.length,
257
+ store.isOpen,
258
+ $store,
259
+ ]);
128
260
 
129
- // biome-ignore lint/correctness/useExhaustiveDependencies: we care only about filter
130
- useEffect(() => {
131
- if (active < 0) {
132
- setActive(0);
133
- return;
134
- }
261
+ const hasRows = entries.length > 0 || isLoading || isError;
135
262
 
136
- if (active > commands.length - 1) {
137
- setActive(commands.length - 1);
138
- }
139
- }, [store.filter]);
140
-
141
- useLayoutEffect(() => {
142
- const element = ref.current;
143
- if (element) {
144
- const { height, top } = element.getBoundingClientRect();
145
-
146
- const domSelection = window.getSelection();
147
- const domRange = domSelection?.getRangeAt(0);
148
- const rect = domRange?.getBoundingClientRect();
149
-
150
- if (top + height >= window.innerHeight) {
151
- setStyle((style) => ({
152
- ...style,
153
- top: `${top - height - (rect?.height ?? 22)}px`,
154
- }));
155
- }
156
- }
157
- }, []);
158
-
159
- if (!commands.length) {
263
+ if (store.filter === false || !hasRows) {
160
264
  return null;
161
265
  }
162
266
 
@@ -164,6 +268,8 @@ export const Menu = (props: Props) => {
164
268
  return null;
165
269
  }
166
270
 
271
+ const pathLabel = path.map((item) => item.title).join(' / ');
272
+
167
273
  return createPortal(
168
274
  renderMenu(
169
275
  <>
@@ -183,28 +289,96 @@ export const Menu = (props: Props) => {
183
289
  event.preventDefault();
184
290
  }}
185
291
  >
186
- {commands.map((command, index) => (
187
- <button
188
- type="button"
189
- ref={(element) => {
190
- if (element) {
292
+ {isBrowseMode && pathLabel && (
293
+ <div className={styles.path}>{pathLabel}</div>
294
+ )}
295
+ {entries.map((entry, index) => {
296
+ if (entry.type === 'back') {
297
+ return (
298
+ <button
299
+ type="button"
300
+ ref={(element) => {
301
+ refs.current[index] = element;
302
+ }}
303
+ key="back"
304
+ className={cn(styles.button, {
305
+ [styles.active]: index === active,
306
+ })}
307
+ onMouseDown={(event) => {
308
+ event.preventDefault();
309
+ setPath((path) => path.slice(0, path.length - 1));
310
+ setActive(0);
311
+ }}
312
+ >
313
+ <span className={styles.icon}>...</span>
314
+ <span className={styles.content}>
315
+ <span>...</span>
316
+ </span>
317
+ </button>
318
+ );
319
+ }
320
+
321
+ return (
322
+ <button
323
+ type="button"
324
+ ref={(element) => {
191
325
  refs.current[index] = element;
192
- }
193
- }}
194
- key={index}
195
- className={cn(styles.button, {
196
- [styles.active]: index === active,
197
- })}
198
- onMouseDown={(event) => {
199
- event.preventDefault();
200
- command.action(actions, editor);
201
- $store.setKey('isOpen', false);
202
- }}
203
- >
204
- <span className={styles.icon}>{command.icon}</span>
205
- <span>{command.title}</span>
206
- </button>
207
- ))}
326
+ }}
327
+ key={entry.command.key}
328
+ className={cn(styles.button, {
329
+ [styles.active]: index === active,
330
+ })}
331
+ onMouseDown={(event) => {
332
+ event.preventDefault();
333
+ if (entry.command.isSubmenu) {
334
+ setPath(entry.command.path);
335
+ if (!isBrowseMode) {
336
+ $store.setKey('filter', '');
337
+ }
338
+ setActive(0);
339
+ return;
340
+ }
341
+
342
+ entry.command.command.action?.(actions, editor);
343
+ $store.setKey('isOpen', false);
344
+ }}
345
+ >
346
+ <span className={styles.icon}>
347
+ {entry.command.command.icon}
348
+ </span>
349
+ <span className={styles.content}>
350
+ <span>{entry.command.command.title}</span>
351
+ {isSearchMode && entry.command.breadcrumb && (
352
+ <span className={styles.breadcrumb}>
353
+ {entry.command.breadcrumb}
354
+ </span>
355
+ )}
356
+ </span>
357
+ {entry.command.isSubmenu && isBrowseMode && (
358
+ <span className={styles.submenu} aria-hidden="true">
359
+ <svg
360
+ xmlns="http://www.w3.org/2000/svg"
361
+ width="12"
362
+ height="12"
363
+ viewBox="0 0 24 24"
364
+ fill="none"
365
+ stroke="currentColor"
366
+ strokeWidth="2"
367
+ strokeLinecap="round"
368
+ strokeLinejoin="round"
369
+ >
370
+ <path stroke="none" d="M0 0h24v24H0z" fill="none" />
371
+ <path d="M9 6l6 6l-6 6" />
372
+ </svg>
373
+ </span>
374
+ )}
375
+ </button>
376
+ );
377
+ })}
378
+ {isLoading && <div className={styles.systemRow}>Loading...</div>}
379
+ {isError && (
380
+ <div className={styles.systemRow}>Could not load commands</div>
381
+ )}
208
382
  </div>
209
383
  </>,
210
384
  ),
@@ -1,2 +1,2 @@
1
1
  export { CommandsPlugin } from './CommandsPlugin';
2
- export type { Command } from './types';
2
+ export type { Command, CommandPathEntry, GetCommandsContext } from './types';