@use-kona/editor 0.1.17 → 0.1.19

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.
@@ -1,9 +1,9 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useStore } from "@nanostores/react";
3
3
  import clsx from "clsx";
4
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
5
  import { createPortal } from "react-dom";
6
- import { Editor } from "slate";
6
+ import { Editor, Transforms } from "slate";
7
7
  import { useFocused, useSlate, useSlateSelection } from "slate-react";
8
8
  import { insert, insertText, removeCommand, set, wrap } from "./actions.js";
9
9
  import styles_module from "./styles.module.js";
@@ -23,7 +23,6 @@ const Menu = (props)=>{
23
23
  match: (n)=>Editor.isBlock(editor, n)
24
24
  });
25
25
  const isBrowseMode = 'string' == typeof store.filter && '' === store.filter;
26
- const isSearchMode = 'string' == typeof store.filter && '' !== store.filter;
27
26
  const { commands, isLoading, isError } = useResolvedCommands({
28
27
  rootCommands,
29
28
  filter: store.filter,
@@ -48,6 +47,26 @@ const Menu = (props)=>{
48
47
  isBrowseMode,
49
48
  path.length
50
49
  ]);
50
+ const enterSubmenu = useCallback((nextPath)=>{
51
+ if (!isBrowseMode && 'string' == typeof store.filter && store.filter) {
52
+ const focus = selection?.focus ?? editor.selection?.focus;
53
+ if (focus) Transforms["delete"](editor, {
54
+ at: focus,
55
+ distance: store.filter.length,
56
+ reverse: true,
57
+ unit: 'character'
58
+ });
59
+ $store.setKey('filter', '');
60
+ }
61
+ setPath(nextPath);
62
+ setActive(0);
63
+ }, [
64
+ editor,
65
+ isBrowseMode,
66
+ selection,
67
+ store.filter,
68
+ $store
69
+ ]);
51
70
  const actions = useMemo(()=>({
52
71
  removeCommand: removeCommand(editor, selection, store.filter),
53
72
  set: set(editor, selection, store.filter),
@@ -67,9 +86,8 @@ const Menu = (props)=>{
67
86
  store.openId
68
87
  ]);
69
88
  useEffect(()=>{
70
- if (false === store.filter || isSearchMode) setActive(0);
89
+ if (false === store.filter || 'string' == typeof store.filter && '' !== store.filter) setActive(0);
71
90
  }, [
72
- isSearchMode,
73
91
  store.filter
74
92
  ]);
75
93
  useEffect(()=>{
@@ -141,9 +159,7 @@ const Menu = (props)=>{
141
159
  if (!entry || 'command' !== entry.type || !entry.command.isSubmenu) return;
142
160
  event.preventDefault();
143
161
  event.stopPropagation();
144
- setPath(entry.command.path);
145
- if (!isBrowseMode) $store.setKey('filter', '');
146
- setActive(0);
162
+ enterSubmenu(entry.command.path);
147
163
  break;
148
164
  }
149
165
  case 'ArrowLeft':
@@ -166,9 +182,7 @@ const Menu = (props)=>{
166
182
  }
167
183
  if ('command' !== entry.type) break;
168
184
  if (entry.command.isSubmenu) {
169
- setPath(entry.command.path);
170
- if (!isBrowseMode) $store.setKey('filter', '');
171
- setActive(0);
185
+ enterSubmenu(entry.command.path);
172
186
  break;
173
187
  }
174
188
  entry.command.command.action?.(actions, editor);
@@ -191,15 +205,15 @@ const Menu = (props)=>{
191
205
  active,
192
206
  entries,
193
207
  editor,
208
+ enterSubmenu,
194
209
  isBrowseMode,
195
210
  path.length,
196
211
  store.isOpen,
197
212
  $store
198
213
  ]);
199
- const hasRows = entries.length > 0 || isLoading || isError;
214
+ const hasRows = entries.length > 0 || isError;
200
215
  if (false === store.filter || !hasRows) return null;
201
216
  if (entry && ignoreNodes.includes(entry[0].type)) return null;
202
- const pathLabel = path.map((item)=>item.title).join(' / ');
203
217
  return /*#__PURE__*/ createPortal(renderMenu(/*#__PURE__*/ jsxs(Fragment, {
204
218
  children: [
205
219
  store.isOpen && /*#__PURE__*/ jsx("div", {
@@ -216,10 +230,6 @@ const Menu = (props)=>{
216
230
  event.preventDefault();
217
231
  },
218
232
  children: [
219
- isBrowseMode && pathLabel && /*#__PURE__*/ jsx("div", {
220
- className: styles_module.path,
221
- children: pathLabel
222
- }),
223
233
  entries.map((entry, index)=>{
224
234
  if ('back' === entry.type) return /*#__PURE__*/ jsxs("button", {
225
235
  type: "button",
@@ -247,7 +257,7 @@ const Menu = (props)=>{
247
257
  })
248
258
  ]
249
259
  }, "back");
250
- return /*#__PURE__*/ jsxs("button", {
260
+ return /*#__PURE__*/ jsx("button", {
251
261
  type: "button",
252
262
  ref: (element)=>{
253
263
  refs.current[index] = element;
@@ -257,64 +267,55 @@ const Menu = (props)=>{
257
267
  }),
258
268
  onMouseDown: (event)=>{
259
269
  event.preventDefault();
260
- if (entry.command.isSubmenu) {
261
- setPath(entry.command.path);
262
- if (!isBrowseMode) $store.setKey('filter', '');
263
- setActive(0);
264
- return;
265
- }
270
+ if (entry.command.isSubmenu) return void enterSubmenu(entry.command.path);
266
271
  entry.command.command.action?.(actions, editor);
267
272
  $store.setKey('isOpen', false);
268
273
  },
269
- children: [
270
- /*#__PURE__*/ jsx("span", {
271
- className: styles_module.icon,
272
- children: entry.command.command.icon
273
- }),
274
- /*#__PURE__*/ jsxs("span", {
275
- className: styles_module.content,
276
- children: [
277
- /*#__PURE__*/ jsx("span", {
274
+ children: entry.command.command.render?.({
275
+ command: entry.command.command,
276
+ isSubmenu: entry.command.isSubmenu,
277
+ isActive: index === active
278
+ }) ?? /*#__PURE__*/ jsxs(Fragment, {
279
+ children: [
280
+ /*#__PURE__*/ jsx("span", {
281
+ className: styles_module.icon,
282
+ children: entry.command.command.icon
283
+ }),
284
+ /*#__PURE__*/ jsx("span", {
285
+ className: styles_module.content,
286
+ children: /*#__PURE__*/ jsx("span", {
278
287
  children: entry.command.command.title
279
- }),
280
- isSearchMode && entry.command.breadcrumb && /*#__PURE__*/ jsx("span", {
281
- className: styles_module.breadcrumb,
282
- children: entry.command.breadcrumb
283
288
  })
284
- ]
285
- }),
286
- entry.command.isSubmenu && isBrowseMode && /*#__PURE__*/ jsx("span", {
287
- className: styles_module.submenu,
288
- "aria-hidden": "true",
289
- children: /*#__PURE__*/ jsxs("svg", {
290
- xmlns: "http://www.w3.org/2000/svg",
291
- width: "12",
292
- height: "12",
293
- viewBox: "0 0 24 24",
294
- fill: "none",
295
- stroke: "currentColor",
296
- strokeWidth: "2",
297
- strokeLinecap: "round",
298
- strokeLinejoin: "round",
299
- children: [
300
- /*#__PURE__*/ jsx("path", {
301
- stroke: "none",
302
- d: "M0 0h24v24H0z",
303
- fill: "none"
304
- }),
305
- /*#__PURE__*/ jsx("path", {
306
- d: "M9 6l6 6l-6 6"
307
- })
308
- ]
289
+ }),
290
+ entry.command.isSubmenu && /*#__PURE__*/ jsx("span", {
291
+ className: styles_module.submenu,
292
+ "aria-hidden": "true",
293
+ children: /*#__PURE__*/ jsxs("svg", {
294
+ xmlns: "http://www.w3.org/2000/svg",
295
+ width: "12",
296
+ height: "12",
297
+ viewBox: "0 0 24 24",
298
+ fill: "none",
299
+ stroke: "currentColor",
300
+ strokeWidth: "2",
301
+ strokeLinecap: "round",
302
+ strokeLinejoin: "round",
303
+ children: [
304
+ /*#__PURE__*/ jsx("path", {
305
+ stroke: "none",
306
+ d: "M0 0h24v24H0z",
307
+ fill: "none"
308
+ }),
309
+ /*#__PURE__*/ jsx("path", {
310
+ d: "M9 6l6 6l-6 6"
311
+ })
312
+ ]
313
+ })
309
314
  })
310
- })
311
- ]
315
+ ]
316
+ })
312
317
  }, entry.command.key);
313
318
  }),
314
- isLoading && /*#__PURE__*/ jsx("div", {
315
- className: styles_module.systemRow,
316
- children: "Loading..."
317
- }),
318
319
  isError && /*#__PURE__*/ jsx("div", {
319
320
  className: styles_module.systemRow,
320
321
  children: "Could not load commands"
@@ -4,7 +4,6 @@ export type ResolvedCommand = {
4
4
  command: Command;
5
5
  key: string;
6
6
  path: CommandPathEntry[];
7
- breadcrumb: string;
8
7
  isSubmenu: boolean;
9
8
  };
10
9
  export type ResolveCommandsParams = {
@@ -18,17 +18,21 @@ const toResolvedCommand = (command, parentPath)=>{
18
18
  command,
19
19
  path,
20
20
  key: pathToKey(path),
21
- breadcrumb: parentPath.map((item)=>item.title).join(' / '),
22
21
  isSubmenu: Boolean(command.getCommands)
23
22
  };
24
23
  };
25
- const resolveBrowseCommands = async (params, resolveChildCommands)=>{
24
+ const resolveCurrentLevelCommands = async (params, resolveChildCommands, queryForCurrentLevel)=>{
26
25
  const { rootCommands, path, editor } = params;
27
26
  let currentCommands = rootCommands;
28
27
  let currentPath = [];
29
- for (const item of path){
28
+ for(let index = 0; index < path.length; index++){
29
+ const item = path[index];
30
+ const isLastPathItem = index === path.length - 1;
30
31
  const command = currentCommands.find((entry)=>entry.name === item.name);
31
- if (!command?.getCommands) return [];
32
+ if (!command?.getCommands) return {
33
+ commands: [],
34
+ path: currentPath
35
+ };
32
36
  currentPath = [
33
37
  ...currentPath,
34
38
  toPathEntry(command)
@@ -36,37 +40,24 @@ const resolveBrowseCommands = async (params, resolveChildCommands)=>{
36
40
  currentCommands = await resolveChildCommands({
37
41
  command,
38
42
  path: currentPath,
39
- query: '',
43
+ query: isLastPathItem ? queryForCurrentLevel : '',
40
44
  editor
41
45
  });
42
46
  }
43
- return currentCommands.map((command)=>toResolvedCommand(command, currentPath));
47
+ return {
48
+ commands: currentCommands,
49
+ path: currentPath
50
+ };
51
+ };
52
+ const resolveBrowseCommands = async (params, resolveChildCommands)=>{
53
+ const currentLevel = await resolveCurrentLevelCommands(params, resolveChildCommands, '');
54
+ return currentLevel.commands.map((command)=>toResolvedCommand(command, currentLevel.path));
44
55
  };
45
56
  const resolveSearchCommands = async (params, resolveChildCommands)=>{
46
- const { rootCommands, filter, editor } = params;
47
- const commands = [];
48
- const walk = async (levelCommands, parentPath)=>{
49
- for (const command of levelCommands){
50
- if (command.getCommands) {
51
- if (isCommandMatchesQuery(command, filter)) commands.push(toResolvedCommand(command, parentPath));
52
- const currentPath = [
53
- ...parentPath,
54
- toPathEntry(command)
55
- ];
56
- const children = await resolveChildCommands({
57
- command,
58
- path: currentPath,
59
- query: filter,
60
- editor
61
- });
62
- await walk(children, currentPath);
63
- continue;
64
- }
65
- if (command.action && isCommandMatchesQuery(command, filter)) commands.push(toResolvedCommand(command, parentPath));
66
- }
67
- };
68
- await walk(rootCommands, []);
69
- return commands;
57
+ const { filter } = params;
58
+ const currentLevel = await resolveCurrentLevelCommands(params, resolveChildCommands, filter);
59
+ const matchedCommands = currentLevel.commands.filter((command)=>isCommandMatchesQuery(command, filter));
60
+ return matchedCommands.map((command)=>toResolvedCommand(command, currentLevel.path));
70
61
  };
71
62
  class CommandsResolver {
72
63
  cache = new Map();
@@ -110,7 +110,7 @@ describe('CommandsResolver', ()=>{
110
110
  expect(result.commands).toHaveLength(1);
111
111
  expect(result.commands[0]?.command.name).toBe('code');
112
112
  });
113
- it('returns global leaf query matches with breadcrumbs', async ()=>{
113
+ it('searches only root level when path is empty', async ()=>{
114
114
  const resolver = new CommandsResolver();
115
115
  const rootCommands = [
116
116
  {
@@ -120,14 +120,37 @@ describe('CommandsResolver', ()=>{
120
120
  icon: null,
121
121
  getCommands: ()=>[
122
122
  leaf({
123
- name: 'heading-1',
124
- title: 'Heading 1',
125
- commandName: 'heading1'
126
- }),
123
+ name: 'code',
124
+ title: 'Code'
125
+ })
126
+ ]
127
+ },
128
+ leaf({
129
+ name: 'paragraph',
130
+ title: 'Paragraph'
131
+ })
132
+ ];
133
+ const request = resolver.resolve({
134
+ rootCommands,
135
+ filter: 'code',
136
+ path: [],
137
+ editor
138
+ });
139
+ const result = await request.promise;
140
+ expect(result.commands).toHaveLength(0);
141
+ });
142
+ it('searches only current nested level', async ()=>{
143
+ const resolver = new CommandsResolver();
144
+ const rootCommands = [
145
+ {
146
+ name: 'insert',
147
+ title: 'Insert',
148
+ commandName: 'insert',
149
+ icon: null,
150
+ getCommands: ()=>[
127
151
  leaf({
128
152
  name: 'code',
129
- title: 'Code',
130
- commandName: 'code'
153
+ title: 'Code'
131
154
  })
132
155
  ]
133
156
  }
@@ -135,13 +158,18 @@ describe('CommandsResolver', ()=>{
135
158
  const request = resolver.resolve({
136
159
  rootCommands,
137
160
  filter: 'code',
138
- path: [],
161
+ path: [
162
+ {
163
+ name: 'insert',
164
+ title: 'Insert',
165
+ commandName: 'insert'
166
+ }
167
+ ],
139
168
  editor
140
169
  });
141
170
  const result = await request.promise;
142
171
  expect(result.commands).toHaveLength(1);
143
172
  expect(result.commands[0]?.command.name).toBe('code');
144
- expect(result.commands[0]?.breadcrumb).toBe('Insert');
145
173
  });
146
174
  it('returns matching submenu commands in query mode', async ()=>{
147
175
  const resolver = new CommandsResolver();
@@ -189,13 +217,25 @@ describe('CommandsResolver', ()=>{
189
217
  const firstRequest = resolver.resolve({
190
218
  rootCommands,
191
219
  filter: 'a',
192
- path: [],
220
+ path: [
221
+ {
222
+ name: 'remote',
223
+ title: 'Remote',
224
+ commandName: 'remote'
225
+ }
226
+ ],
193
227
  editor
194
228
  });
195
229
  const secondRequest = resolver.resolve({
196
230
  rootCommands,
197
231
  filter: 'b',
198
- path: [],
232
+ path: [
233
+ {
234
+ name: 'remote',
235
+ title: 'Remote',
236
+ commandName: 'remote'
237
+ }
238
+ ],
199
239
  editor
200
240
  });
201
241
  searchB.resolve([
@@ -257,7 +297,13 @@ describe('CommandsResolver', ()=>{
257
297
  const loadingRequest = resolver.resolve({
258
298
  rootCommands,
259
299
  filter: 'code',
260
- path: [],
300
+ path: [
301
+ {
302
+ name: 'remote',
303
+ title: 'Remote',
304
+ commandName: 'remote'
305
+ }
306
+ ],
261
307
  editor
262
308
  });
263
309
  expect(loadingRequest.state.isLoading).toBe(true);
@@ -5,9 +5,7 @@ const styles_module = {
5
5
  active: "active-Is5D6b",
6
6
  icon: "icon-Xca6dM",
7
7
  content: "content-VZW7lK",
8
- breadcrumb: "breadcrumb-Jr0wXh",
9
8
  submenu: "submenu-zNsqOg",
10
- path: "path-fYY_14",
11
9
  systemRow: "systemRow-ln5twO",
12
10
  backdrop: "backdrop-lw7AjW"
13
11
  };
@@ -8,7 +8,7 @@
8
8
  will-change: transform, opacity;
9
9
  contain: layout paint style;
10
10
  backface-visibility: hidden;
11
- border-radius: 4px;
11
+ border-radius: 8px;
12
12
  flex-direction: column;
13
13
  max-height: 200px;
14
14
  margin-top: -6px;
@@ -70,32 +70,16 @@
70
70
  }
71
71
 
72
72
  .content-VZW7lK {
73
- flex-direction: column;
73
+ flex-direction: row;
74
74
  flex: 1;
75
- row-gap: 2px;
76
75
  min-width: 0;
77
76
  display: flex;
78
77
  }
79
78
 
80
- .breadcrumb-Jr0wXh {
81
- color: var(--kona-editor-secondary-text-color, #777);
82
- white-space: nowrap;
83
- text-overflow: ellipsis;
84
- font-size: 11px;
85
- overflow: hidden;
86
- }
87
-
88
79
  .submenu-zNsqOg {
89
80
  color: var(--kona-editor-secondary-text-color, #777);
90
81
  }
91
82
 
92
- .path-fYY_14 {
93
- color: var(--kona-editor-secondary-text-color, #777);
94
- border-bottom: 1px solid var(--kona-editor-border-color, #ddd);
95
- padding: 6px 8px;
96
- font-size: 11px;
97
- }
98
-
99
83
  .systemRow-ln5twO {
100
84
  min-height: 32px;
101
85
  color: var(--kona-editor-secondary-text-color, #777);
@@ -29,6 +29,11 @@ export type Command = {
29
29
  icon: ReactNode;
30
30
  action?: (actions: Actions, editor: Editor) => void;
31
31
  getCommands?: (context: GetCommandsContext) => Command[] | Promise<Command[]>;
32
+ render?: (params: {
33
+ command: Command;
34
+ isSubmenu: boolean;
35
+ isActive: boolean;
36
+ }) => ReactNode;
32
37
  };
33
38
  export type Actions = {
34
39
  removeCommand: () => void;
@@ -10,14 +10,35 @@ const useResolvedCommands = (params)=>{
10
10
  const { rootCommands, filter, path, editor, isOpen } = params;
11
11
  const resolverRef = useRef(new CommandsResolver());
12
12
  const [state, setState] = useState(EMPTY_STATE);
13
+ const prevIsOpenRef = useRef(false);
14
+ const prevPathKeyRef = useRef('');
15
+ const prevQueryRef = useRef('');
13
16
  const query = 'string' == typeof filter ? filter : '';
14
17
  useEffect(()=>{
15
- if (!isOpen || false === filter) return void setState(EMPTY_STATE);
16
- setState({
18
+ if (!isOpen || false === filter) {
19
+ setState(EMPTY_STATE);
20
+ prevIsOpenRef.current = false;
21
+ prevPathKeyRef.current = '';
22
+ prevQueryRef.current = '';
23
+ return;
24
+ }
25
+ const pathKey = path.map((item)=>item.name).join('/');
26
+ const isNewSession = !prevIsOpenRef.current;
27
+ const isPathChanged = !isNewSession && prevPathKeyRef.current !== pathKey;
28
+ const isQueryChanged = !isNewSession && prevQueryRef.current !== query;
29
+ if (isNewSession || isPathChanged) setState({
17
30
  commands: [],
18
31
  isLoading: true,
19
32
  isError: false
20
33
  });
34
+ else if (isQueryChanged) setState((state)=>({
35
+ ...state,
36
+ isLoading: true,
37
+ isError: false
38
+ }));
39
+ prevIsOpenRef.current = true;
40
+ prevPathKeyRef.current = pathKey;
41
+ prevQueryRef.current = query;
21
42
  const timeout = window.setTimeout(()=>{
22
43
  const request = resolverRef.current.resolve({
23
44
  rootCommands,
@@ -25,7 +46,6 @@ const useResolvedCommands = (params)=>{
25
46
  path,
26
47
  editor
27
48
  });
28
- setState(request.state);
29
49
  request.promise.then((resolved)=>{
30
50
  setState(resolved);
31
51
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-kona/editor",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -4,6 +4,7 @@ import type { MapStore } from 'nanostores';
4
4
  import {
5
5
  type CSSProperties,
6
6
  type ReactNode,
7
+ useCallback,
7
8
  useEffect,
8
9
  useLayoutEffect,
9
10
  useMemo,
@@ -11,7 +12,7 @@ import {
11
12
  useState,
12
13
  } from 'react';
13
14
  import { createPortal } from 'react-dom';
14
- import { Editor } from 'slate';
15
+ import { Editor, Transforms } from 'slate';
15
16
  import { useFocused, useSlate, useSlateSelection } from 'slate-react';
16
17
  import type { CustomElement } from '../../../types';
17
18
  import { insert, insertText, removeCommand, set, wrap } from './actions';
@@ -44,7 +45,6 @@ export const Menu = (props: Props) => {
44
45
  });
45
46
 
46
47
  const isBrowseMode = typeof store.filter === 'string' && store.filter === '';
47
- const isSearchMode = typeof store.filter === 'string' && store.filter !== '';
48
48
 
49
49
  const { commands, isLoading, isError } = useResolvedCommands({
50
50
  rootCommands,
@@ -66,6 +66,29 @@ export const Menu = (props: Props) => {
66
66
  return commandEntries;
67
67
  }, [commands, isBrowseMode, path.length]);
68
68
 
69
+ const enterSubmenu = useCallback(
70
+ (nextPath: CommandPathEntry[]) => {
71
+ if (!isBrowseMode && typeof store.filter === 'string' && store.filter) {
72
+ const focus = selection?.focus ?? editor.selection?.focus;
73
+
74
+ if (focus) {
75
+ Transforms.delete(editor, {
76
+ at: focus,
77
+ distance: store.filter.length,
78
+ reverse: true,
79
+ unit: 'character',
80
+ });
81
+ }
82
+
83
+ $store.setKey('filter', '');
84
+ }
85
+
86
+ setPath(nextPath);
87
+ setActive(0);
88
+ },
89
+ [editor, isBrowseMode, selection, store.filter, $store],
90
+ );
91
+
69
92
  // biome-ignore lint/correctness/useExhaustiveDependencies: we care only about those deps
70
93
  const actions = useMemo(() => {
71
94
  return {
@@ -87,10 +110,13 @@ export const Menu = (props: Props) => {
87
110
  }, [store.isOpen, store.openId]);
88
111
 
89
112
  useEffect(() => {
90
- if (store.filter === false || isSearchMode) {
113
+ if (
114
+ store.filter === false ||
115
+ (typeof store.filter === 'string' && store.filter !== '')
116
+ ) {
91
117
  setActive(0);
92
118
  }
93
- }, [isSearchMode, store.filter]);
119
+ }, [store.filter]);
94
120
 
95
121
  useEffect(() => {
96
122
  if (!entries.length) {
@@ -183,11 +209,7 @@ export const Menu = (props: Props) => {
183
209
 
184
210
  event.preventDefault();
185
211
  event.stopPropagation();
186
- setPath(entry.command.path);
187
- if (!isBrowseMode) {
188
- $store.setKey('filter', '');
189
- }
190
- setActive(0);
212
+ enterSubmenu(entry.command.path);
191
213
  break;
192
214
  }
193
215
  case 'ArrowLeft': {
@@ -221,11 +243,7 @@ export const Menu = (props: Props) => {
221
243
  }
222
244
 
223
245
  if (entry.command.isSubmenu) {
224
- setPath(entry.command.path);
225
- if (!isBrowseMode) {
226
- $store.setKey('filter', '');
227
- }
228
- setActive(0);
246
+ enterSubmenu(entry.command.path);
229
247
  break;
230
248
  }
231
249
 
@@ -252,13 +270,14 @@ export const Menu = (props: Props) => {
252
270
  active,
253
271
  entries,
254
272
  editor,
273
+ enterSubmenu,
255
274
  isBrowseMode,
256
275
  path.length,
257
276
  store.isOpen,
258
277
  $store,
259
278
  ]);
260
279
 
261
- const hasRows = entries.length > 0 || isLoading || isError;
280
+ const hasRows = entries.length > 0 || isError;
262
281
 
263
282
  if (store.filter === false || !hasRows) {
264
283
  return null;
@@ -268,8 +287,6 @@ export const Menu = (props: Props) => {
268
287
  return null;
269
288
  }
270
289
 
271
- const pathLabel = path.map((item) => item.title).join(' / ');
272
-
273
290
  return createPortal(
274
291
  renderMenu(
275
292
  <>
@@ -289,9 +306,6 @@ export const Menu = (props: Props) => {
289
306
  event.preventDefault();
290
307
  }}
291
308
  >
292
- {isBrowseMode && pathLabel && (
293
- <div className={styles.path}>{pathLabel}</div>
294
- )}
295
309
  {entries.map((entry, index) => {
296
310
  if (entry.type === 'back') {
297
311
  return (
@@ -331,11 +345,7 @@ export const Menu = (props: Props) => {
331
345
  onMouseDown={(event) => {
332
346
  event.preventDefault();
333
347
  if (entry.command.isSubmenu) {
334
- setPath(entry.command.path);
335
- if (!isBrowseMode) {
336
- $store.setKey('filter', '');
337
- }
338
- setActive(0);
348
+ enterSubmenu(entry.command.path);
339
349
  return;
340
350
  }
341
351
 
@@ -343,39 +353,41 @@ export const Menu = (props: Props) => {
343
353
  $store.setKey('isOpen', false);
344
354
  }}
345
355
  >
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}
356
+ {entry.command.command.render?.({
357
+ command: entry.command.command,
358
+ isSubmenu: entry.command.isSubmenu,
359
+ isActive: index === active,
360
+ }) ?? (
361
+ <>
362
+ <span className={styles.icon}>
363
+ {entry.command.command.icon}
354
364
  </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>
365
+ <span className={styles.content}>
366
+ <span>{entry.command.command.title}</span>
367
+ </span>
368
+ {entry.command.isSubmenu && (
369
+ <span className={styles.submenu} aria-hidden="true">
370
+ <svg
371
+ xmlns="http://www.w3.org/2000/svg"
372
+ width="12"
373
+ height="12"
374
+ viewBox="0 0 24 24"
375
+ fill="none"
376
+ stroke="currentColor"
377
+ strokeWidth="2"
378
+ strokeLinecap="round"
379
+ strokeLinejoin="round"
380
+ >
381
+ <path stroke="none" d="M0 0h24v24H0z" fill="none" />
382
+ <path d="M9 6l6 6l-6 6" />
383
+ </svg>
384
+ </span>
385
+ )}
386
+ </>
374
387
  )}
375
388
  </button>
376
389
  );
377
390
  })}
378
- {isLoading && <div className={styles.systemRow}>Loading...</div>}
379
391
  {isError && (
380
392
  <div className={styles.systemRow}>Could not load commands</div>
381
393
  )}
@@ -102,7 +102,7 @@ describe('CommandsResolver', () => {
102
102
  expect(result.commands[0]?.command.name).toBe('code');
103
103
  });
104
104
 
105
- it('returns global leaf query matches with breadcrumbs', async () => {
105
+ it('searches only root level when path is empty', async () => {
106
106
  const resolver = new CommandsResolver();
107
107
  const rootCommands: Command[] = [
108
108
  {
@@ -110,15 +110,9 @@ describe('CommandsResolver', () => {
110
110
  title: 'Insert',
111
111
  commandName: 'insert',
112
112
  icon: null,
113
- getCommands: () => [
114
- leaf({
115
- name: 'heading-1',
116
- title: 'Heading 1',
117
- commandName: 'heading1',
118
- }),
119
- leaf({ name: 'code', title: 'Code', commandName: 'code' }),
120
- ],
113
+ getCommands: () => [leaf({ name: 'code', title: 'Code' })],
121
114
  },
115
+ leaf({ name: 'paragraph', title: 'Paragraph' }),
122
116
  ];
123
117
 
124
118
  const request = resolver.resolve({
@@ -130,9 +124,32 @@ describe('CommandsResolver', () => {
130
124
 
131
125
  const result = await request.promise;
132
126
 
127
+ expect(result.commands).toHaveLength(0);
128
+ });
129
+
130
+ it('searches only current nested level', async () => {
131
+ const resolver = new CommandsResolver();
132
+ const rootCommands: Command[] = [
133
+ {
134
+ name: 'insert',
135
+ title: 'Insert',
136
+ commandName: 'insert',
137
+ icon: null,
138
+ getCommands: () => [leaf({ name: 'code', title: 'Code' })],
139
+ },
140
+ ];
141
+
142
+ const request = resolver.resolve({
143
+ rootCommands,
144
+ filter: 'code',
145
+ path: [{ name: 'insert', title: 'Insert', commandName: 'insert' }],
146
+ editor,
147
+ });
148
+
149
+ const result = await request.promise;
150
+
133
151
  expect(result.commands).toHaveLength(1);
134
152
  expect(result.commands[0]?.command.name).toBe('code');
135
- expect(result.commands[0]?.breadcrumb).toBe('Insert');
136
153
  });
137
154
 
138
155
  it('returns matching submenu commands in query mode', async () => {
@@ -185,13 +202,13 @@ describe('CommandsResolver', () => {
185
202
  const firstRequest = resolver.resolve({
186
203
  rootCommands,
187
204
  filter: 'a',
188
- path: [],
205
+ path: [{ name: 'remote', title: 'Remote', commandName: 'remote' }],
189
206
  editor,
190
207
  });
191
208
  const secondRequest = resolver.resolve({
192
209
  rootCommands,
193
210
  filter: 'b',
194
- path: [],
211
+ path: [{ name: 'remote', title: 'Remote', commandName: 'remote' }],
195
212
  editor,
196
213
  });
197
214
 
@@ -243,7 +260,7 @@ describe('CommandsResolver', () => {
243
260
  const loadingRequest = resolver.resolve({
244
261
  rootCommands,
245
262
  filter: 'code',
246
- path: [],
263
+ path: [{ name: 'remote', title: 'Remote', commandName: 'remote' }],
247
264
  editor,
248
265
  });
249
266
 
@@ -16,7 +16,6 @@ export type ResolvedCommand = {
16
16
  command: Command;
17
17
  key: string;
18
18
  path: CommandPathEntry[];
19
- breadcrumb: string;
20
19
  isSubmenu: boolean;
21
20
  };
22
21
 
@@ -84,36 +83,57 @@ const toResolvedCommand = (
84
83
  command,
85
84
  path,
86
85
  key: pathToKey(path),
87
- breadcrumb: parentPath.map((item) => item.title).join(' / '),
88
86
  isSubmenu: Boolean(command.getCommands),
89
87
  };
90
88
  };
91
89
 
92
- const resolveBrowseCommands = async (
90
+ const resolveCurrentLevelCommands = async (
93
91
  params: ResolveCommandsParams,
94
92
  resolveChildCommands: ResolveChildCommands,
93
+ queryForCurrentLevel: string,
95
94
  ) => {
96
95
  const { rootCommands, path, editor } = params;
97
96
  let currentCommands = rootCommands;
98
97
  let currentPath: CommandPathEntry[] = [];
99
98
 
100
- for (const item of path) {
99
+ for (let index = 0; index < path.length; index++) {
100
+ const item = path[index];
101
+ const isLastPathItem = index === path.length - 1;
101
102
  const command = currentCommands.find((entry) => entry.name === item.name);
102
103
  if (!command?.getCommands) {
103
- return [];
104
+ return {
105
+ commands: [],
106
+ path: currentPath,
107
+ };
104
108
  }
105
109
 
106
110
  currentPath = [...currentPath, toPathEntry(command)];
107
111
  currentCommands = await resolveChildCommands({
108
112
  command,
109
113
  path: currentPath,
110
- query: '',
114
+ query: isLastPathItem ? queryForCurrentLevel : '',
111
115
  editor,
112
116
  });
113
117
  }
114
118
 
115
- return currentCommands.map((command) =>
116
- toResolvedCommand(command, currentPath),
119
+ return {
120
+ commands: currentCommands,
121
+ path: currentPath,
122
+ };
123
+ };
124
+
125
+ const resolveBrowseCommands = async (
126
+ params: ResolveCommandsParams,
127
+ resolveChildCommands: ResolveChildCommands,
128
+ ) => {
129
+ const currentLevel = await resolveCurrentLevelCommands(
130
+ params,
131
+ resolveChildCommands,
132
+ '',
133
+ );
134
+
135
+ return currentLevel.commands.map((command) =>
136
+ toResolvedCommand(command, currentLevel.path),
117
137
  );
118
138
  };
119
139
 
@@ -121,38 +141,20 @@ const resolveSearchCommands = async (
121
141
  params: ResolveCommandsParams,
122
142
  resolveChildCommands: ResolveChildCommands,
123
143
  ) => {
124
- const { rootCommands, filter, editor } = params;
125
- const commands: ResolvedCommand[] = [];
126
-
127
- const walk = async (
128
- levelCommands: Command[],
129
- parentPath: CommandPathEntry[],
130
- ): Promise<void> => {
131
- for (const command of levelCommands) {
132
- if (command.getCommands) {
133
- if (isCommandMatchesQuery(command, filter)) {
134
- commands.push(toResolvedCommand(command, parentPath));
135
- }
144
+ const { filter } = params;
145
+ const currentLevel = await resolveCurrentLevelCommands(
146
+ params,
147
+ resolveChildCommands,
148
+ filter,
149
+ );
136
150
 
137
- const currentPath = [...parentPath, toPathEntry(command)];
138
- const children = await resolveChildCommands({
139
- command,
140
- path: currentPath,
141
- query: filter,
142
- editor,
143
- });
144
- await walk(children, currentPath);
145
- continue;
146
- }
147
-
148
- if (command.action && isCommandMatchesQuery(command, filter)) {
149
- commands.push(toResolvedCommand(command, parentPath));
150
- }
151
- }
152
- };
151
+ const matchedCommands = currentLevel.commands.filter((command) =>
152
+ isCommandMatchesQuery(command, filter),
153
+ );
153
154
 
154
- await walk(rootCommands, []);
155
- return commands;
155
+ return matchedCommands.map((command) =>
156
+ toResolvedCommand(command, currentLevel.path),
157
+ );
156
158
  };
157
159
 
158
160
  export class CommandsResolver {
@@ -11,7 +11,7 @@
11
11
  background-color: var(--kona-editor-background-color, #fff);
12
12
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.025);
13
13
  border: 1px solid var(--kona-editor-border-color, #ddd);
14
- border-radius: 4px;
14
+ border-radius: 8px;
15
15
  transition:
16
16
  transform 0.12s ease,
17
17
  opacity 0.12s ease;
@@ -79,29 +79,13 @@
79
79
  display: flex;
80
80
  flex: 1;
81
81
  min-width: 0;
82
- flex-direction: column;
83
- row-gap: 2px;
84
- }
85
-
86
- .breadcrumb {
87
- font-size: 11px;
88
- color: var(--kona-editor-secondary-text-color, #777);
89
- white-space: nowrap;
90
- overflow: hidden;
91
- text-overflow: ellipsis;
82
+ flex-direction: row;
92
83
  }
93
84
 
94
85
  .submenu {
95
86
  color: var(--kona-editor-secondary-text-color, #777);
96
87
  }
97
88
 
98
- .path {
99
- padding: 6px 8px;
100
- font-size: 11px;
101
- color: var(--kona-editor-secondary-text-color, #777);
102
- border-bottom: 1px solid var(--kona-editor-border-color, #ddd);
103
- }
104
-
105
89
  .systemRow {
106
90
  min-height: 32px;
107
91
  padding: 8px;
@@ -35,6 +35,11 @@ export type Command = {
35
35
  icon: ReactNode;
36
36
  action?: (actions: Actions, editor: Editor) => void;
37
37
  getCommands?: (context: GetCommandsContext) => Command[] | Promise<Command[]>;
38
+ render?: (params: {
39
+ command: Command;
40
+ isSubmenu: boolean;
41
+ isActive: boolean;
42
+ }) => ReactNode;
38
43
  };
39
44
 
40
45
  export type Actions = {
@@ -26,19 +26,42 @@ export const useResolvedCommands = (params: Params) => {
26
26
  const { rootCommands, filter, path, editor, isOpen } = params;
27
27
  const resolverRef = useRef(new CommandsResolver());
28
28
  const [state, setState] = useState<ResolvedCommandsState>(EMPTY_STATE);
29
+ const prevIsOpenRef = useRef(false);
30
+ const prevPathKeyRef = useRef('');
31
+ const prevQueryRef = useRef('');
29
32
  const query = typeof filter === 'string' ? filter : '';
30
33
 
31
34
  useEffect(() => {
32
35
  if (!isOpen || filter === false) {
33
36
  setState(EMPTY_STATE);
37
+ prevIsOpenRef.current = false;
38
+ prevPathKeyRef.current = '';
39
+ prevQueryRef.current = '';
34
40
  return;
35
41
  }
36
42
 
37
- setState({
38
- commands: [],
39
- isLoading: true,
40
- isError: false,
41
- });
43
+ const pathKey = path.map((item) => item.name).join('/');
44
+ const isNewSession = !prevIsOpenRef.current;
45
+ const isPathChanged = !isNewSession && prevPathKeyRef.current !== pathKey;
46
+ const isQueryChanged = !isNewSession && prevQueryRef.current !== query;
47
+
48
+ if (isNewSession || isPathChanged) {
49
+ setState({
50
+ commands: [],
51
+ isLoading: true,
52
+ isError: false,
53
+ });
54
+ } else if (isQueryChanged) {
55
+ setState((state) => ({
56
+ ...state,
57
+ isLoading: true,
58
+ isError: false,
59
+ }));
60
+ }
61
+
62
+ prevIsOpenRef.current = true;
63
+ prevPathKeyRef.current = pathKey;
64
+ prevQueryRef.current = query;
42
65
 
43
66
  const timeout = window.setTimeout(
44
67
  () => {
@@ -49,8 +72,6 @@ export const useResolvedCommands = (params: Params) => {
49
72
  editor,
50
73
  });
51
74
 
52
- setState(request.state);
53
-
54
75
  request.promise.then((resolved) => {
55
76
  setState(resolved);
56
77
  });