@use-kona/editor 0.1.15 → 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 (31) hide show
  1. package/LICENSE +21 -0
  2. package/dist/plugins/CommandsPlugin/CommandsPlugin.d.ts +2 -3
  3. package/dist/plugins/CommandsPlugin/CommandsPlugin.js +5 -15
  4. package/dist/plugins/CommandsPlugin/Menu.d.ts +3 -3
  5. package/dist/plugins/CommandsPlugin/Menu.js +240 -69
  6. package/dist/plugins/CommandsPlugin/index.d.ts +1 -1
  7. package/dist/plugins/CommandsPlugin/resolveCommands.d.ts +34 -0
  8. package/dist/plugins/CommandsPlugin/resolveCommands.js +150 -0
  9. package/dist/plugins/CommandsPlugin/resolveCommands.spec.d.ts +1 -0
  10. package/dist/plugins/CommandsPlugin/resolveCommands.spec.js +277 -0
  11. package/dist/plugins/CommandsPlugin/styles.module.js +5 -0
  12. package/dist/plugins/CommandsPlugin/styles_module.css +47 -7
  13. package/dist/plugins/CommandsPlugin/types.d.ts +14 -2
  14. package/dist/plugins/CommandsPlugin/useResolvedCommands.d.ts +12 -0
  15. package/dist/plugins/CommandsPlugin/useResolvedCommands.js +46 -0
  16. package/dist/plugins/DnDPlugin/DnDPlugin.d.ts +8 -0
  17. package/dist/plugins/DnDPlugin/DnDPlugin.js +35 -7
  18. package/dist/plugins/NodeIdPlugin/NodeIdPlugin.js +6 -1
  19. package/dist/plugins/index.d.ts +1 -1
  20. package/package.json +11 -11
  21. package/src/plugins/CommandsPlugin/CommandsPlugin.tsx +5 -25
  22. package/src/plugins/CommandsPlugin/Menu.tsx +260 -86
  23. package/src/plugins/CommandsPlugin/index.ts +1 -1
  24. package/src/plugins/CommandsPlugin/resolveCommands.spec.ts +261 -0
  25. package/src/plugins/CommandsPlugin/resolveCommands.ts +275 -0
  26. package/src/plugins/CommandsPlugin/styles.module.css +49 -7
  27. package/src/plugins/CommandsPlugin/types.ts +17 -3
  28. package/src/plugins/CommandsPlugin/useResolvedCommands.ts +67 -0
  29. package/src/plugins/DnDPlugin/DnDPlugin.tsx +66 -9
  30. package/src/plugins/NodeIdPlugin/NodeIdPlugin.ts +10 -2
  31. package/src/plugins/index.ts +6 -1
@@ -0,0 +1,150 @@
1
+ const toPathEntry = (command)=>({
2
+ name: command.name,
3
+ title: command.title,
4
+ commandName: command.commandName
5
+ });
6
+ const pathToKey = (path)=>path.map((item)=>item.name).join('/');
7
+ const normalizeCommands = (commands)=>Array.isArray(commands) ? commands : [];
8
+ const isCommandMatchesQuery = (command, query)=>{
9
+ const normalized = query.toLocaleLowerCase();
10
+ return command.commandName.toLocaleLowerCase().includes(normalized) || command.title.toLocaleLowerCase().includes(normalized);
11
+ };
12
+ const toResolvedCommand = (command, parentPath)=>{
13
+ const path = [
14
+ ...parentPath,
15
+ toPathEntry(command)
16
+ ];
17
+ return {
18
+ command,
19
+ path,
20
+ key: pathToKey(path),
21
+ breadcrumb: parentPath.map((item)=>item.title).join(' / '),
22
+ isSubmenu: Boolean(command.getCommands)
23
+ };
24
+ };
25
+ const resolveBrowseCommands = async (params, resolveChildCommands)=>{
26
+ const { rootCommands, path, editor } = params;
27
+ let currentCommands = rootCommands;
28
+ let currentPath = [];
29
+ for (const item of path){
30
+ const command = currentCommands.find((entry)=>entry.name === item.name);
31
+ if (!command?.getCommands) return [];
32
+ currentPath = [
33
+ ...currentPath,
34
+ toPathEntry(command)
35
+ ];
36
+ currentCommands = await resolveChildCommands({
37
+ command,
38
+ path: currentPath,
39
+ query: '',
40
+ editor
41
+ });
42
+ }
43
+ return currentCommands.map((command)=>toResolvedCommand(command, currentPath));
44
+ };
45
+ 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;
70
+ };
71
+ class CommandsResolver {
72
+ cache = new Map();
73
+ requestId = 0;
74
+ state = {
75
+ commands: [],
76
+ isLoading: false,
77
+ isError: false
78
+ };
79
+ getState() {
80
+ return this.state;
81
+ }
82
+ resolve(params) {
83
+ const currentRequestId = ++this.requestId;
84
+ this.state = {
85
+ commands: [],
86
+ isLoading: true,
87
+ isError: false
88
+ };
89
+ const promise = this.resolveInternal(params).then((commands)=>{
90
+ if (currentRequestId !== this.requestId) return this.state;
91
+ this.state = {
92
+ commands,
93
+ isLoading: false,
94
+ isError: false
95
+ };
96
+ return this.state;
97
+ }).catch(()=>{
98
+ if (currentRequestId !== this.requestId) return this.state;
99
+ this.state = {
100
+ ...this.state,
101
+ isLoading: false,
102
+ isError: true
103
+ };
104
+ return this.state;
105
+ });
106
+ return {
107
+ state: this.state,
108
+ promise
109
+ };
110
+ }
111
+ resolveChildCommands = async (params)=>{
112
+ const { command, path, query, editor } = params;
113
+ if (!command.getCommands) return [];
114
+ const cacheKey = `${query}::${pathToKey(path)}`;
115
+ const cached = this.cache.get(cacheKey);
116
+ if (cached?.status === 'resolved') return cached.value;
117
+ if (cached?.status === 'rejected') throw cached.error;
118
+ if (cached?.status === 'pending') return cached.promise;
119
+ const promise = Promise.resolve(command.getCommands({
120
+ query,
121
+ editor,
122
+ path,
123
+ parent: command
124
+ })).then((commands)=>normalizeCommands(commands));
125
+ this.cache.set(cacheKey, {
126
+ status: 'pending',
127
+ promise
128
+ });
129
+ try {
130
+ const commands = await promise;
131
+ this.cache.set(cacheKey, {
132
+ status: 'resolved',
133
+ value: commands
134
+ });
135
+ return commands;
136
+ } catch (error) {
137
+ this.cache.set(cacheKey, {
138
+ status: 'rejected',
139
+ error
140
+ });
141
+ throw error;
142
+ }
143
+ };
144
+ async resolveInternal(params) {
145
+ const { filter } = params;
146
+ if (!filter) return resolveBrowseCommands(params, this.resolveChildCommands);
147
+ return resolveSearchCommands(params, this.resolveChildCommands);
148
+ }
149
+ }
150
+ export { CommandsResolver };
@@ -0,0 +1,277 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CommandsResolver } from "./resolveCommands.js";
3
+ const editor = {};
4
+ const leaf = (params)=>({
5
+ name: params.name,
6
+ title: params.title,
7
+ commandName: params.commandName ?? params.title.toLocaleLowerCase(),
8
+ icon: null,
9
+ action: ()=>{}
10
+ });
11
+ const deferred = ()=>{
12
+ let resolve = ()=>{};
13
+ let reject = ()=>{};
14
+ const promise = new Promise((res, rej)=>{
15
+ resolve = res;
16
+ reject = rej;
17
+ });
18
+ return {
19
+ promise,
20
+ resolve,
21
+ reject
22
+ };
23
+ };
24
+ describe('CommandsResolver', ()=>{
25
+ it('keeps leaf-only legacy behavior', async ()=>{
26
+ const resolver = new CommandsResolver();
27
+ const rootCommands = [
28
+ leaf({
29
+ name: 'paragraph',
30
+ title: 'Paragraph'
31
+ }),
32
+ leaf({
33
+ name: 'code',
34
+ title: 'Code'
35
+ })
36
+ ];
37
+ const request = resolver.resolve({
38
+ rootCommands,
39
+ filter: '',
40
+ path: [],
41
+ editor
42
+ });
43
+ const result = await request.promise;
44
+ expect(result.commands.map((item)=>item.command.name)).toEqual([
45
+ 'paragraph',
46
+ 'code'
47
+ ]);
48
+ });
49
+ it('resolves static submenu with sync getCommands', async ()=>{
50
+ const resolver = new CommandsResolver();
51
+ const rootCommands = [
52
+ {
53
+ name: 'insert',
54
+ title: 'Insert',
55
+ commandName: 'insert',
56
+ icon: null,
57
+ getCommands: ()=>[
58
+ leaf({
59
+ name: 'heading-1',
60
+ title: 'Heading 1'
61
+ })
62
+ ]
63
+ }
64
+ ];
65
+ const request = resolver.resolve({
66
+ rootCommands,
67
+ filter: '',
68
+ path: [
69
+ {
70
+ name: 'insert',
71
+ title: 'Insert',
72
+ commandName: 'insert'
73
+ }
74
+ ],
75
+ editor
76
+ });
77
+ const result = await request.promise;
78
+ expect(result.commands).toHaveLength(1);
79
+ expect(result.commands[0]?.command.name).toBe('heading-1');
80
+ });
81
+ it('resolves async submenu', async ()=>{
82
+ const resolver = new CommandsResolver();
83
+ const rootCommands = [
84
+ {
85
+ name: 'remote',
86
+ title: 'Remote',
87
+ commandName: 'remote',
88
+ icon: null,
89
+ getCommands: async ()=>[
90
+ leaf({
91
+ name: 'code',
92
+ title: 'Code'
93
+ })
94
+ ]
95
+ }
96
+ ];
97
+ const request = resolver.resolve({
98
+ rootCommands,
99
+ filter: '',
100
+ path: [
101
+ {
102
+ name: 'remote',
103
+ title: 'Remote',
104
+ commandName: 'remote'
105
+ }
106
+ ],
107
+ editor
108
+ });
109
+ const result = await request.promise;
110
+ expect(result.commands).toHaveLength(1);
111
+ expect(result.commands[0]?.command.name).toBe('code');
112
+ });
113
+ it('returns global leaf query matches with breadcrumbs', async ()=>{
114
+ const resolver = new CommandsResolver();
115
+ const rootCommands = [
116
+ {
117
+ name: 'insert',
118
+ title: 'Insert',
119
+ commandName: 'insert',
120
+ icon: null,
121
+ getCommands: ()=>[
122
+ leaf({
123
+ name: 'heading-1',
124
+ title: 'Heading 1',
125
+ commandName: 'heading1'
126
+ }),
127
+ leaf({
128
+ name: 'code',
129
+ title: 'Code',
130
+ commandName: 'code'
131
+ })
132
+ ]
133
+ }
134
+ ];
135
+ const request = resolver.resolve({
136
+ rootCommands,
137
+ filter: 'code',
138
+ path: [],
139
+ editor
140
+ });
141
+ const result = await request.promise;
142
+ expect(result.commands).toHaveLength(1);
143
+ expect(result.commands[0]?.command.name).toBe('code');
144
+ expect(result.commands[0]?.breadcrumb).toBe('Insert');
145
+ });
146
+ it('returns matching submenu commands in query mode', async ()=>{
147
+ const resolver = new CommandsResolver();
148
+ const rootCommands = [
149
+ {
150
+ name: 'advanced',
151
+ title: 'Advanced',
152
+ commandName: 'advanced',
153
+ icon: null,
154
+ getCommands: ()=>[
155
+ leaf({
156
+ name: 'code',
157
+ title: 'Code'
158
+ })
159
+ ]
160
+ }
161
+ ];
162
+ const request = resolver.resolve({
163
+ rootCommands,
164
+ filter: 'adv',
165
+ path: [],
166
+ editor
167
+ });
168
+ const result = await request.promise;
169
+ expect(result.commands).toHaveLength(1);
170
+ expect(result.commands[0]?.command.name).toBe('advanced');
171
+ expect(result.commands[0]?.isSubmenu).toBe(true);
172
+ });
173
+ it('ignores stale out-of-order async responses', async ()=>{
174
+ const resolver = new CommandsResolver();
175
+ const searchA = deferred();
176
+ const searchB = deferred();
177
+ const rootCommands = [
178
+ {
179
+ name: 'remote',
180
+ title: 'Remote',
181
+ commandName: 'remote',
182
+ icon: null,
183
+ getCommands: ({ query })=>{
184
+ if ('a' === query) return searchA.promise;
185
+ return searchB.promise;
186
+ }
187
+ }
188
+ ];
189
+ const firstRequest = resolver.resolve({
190
+ rootCommands,
191
+ filter: 'a',
192
+ path: [],
193
+ editor
194
+ });
195
+ const secondRequest = resolver.resolve({
196
+ rootCommands,
197
+ filter: 'b',
198
+ path: [],
199
+ editor
200
+ });
201
+ searchB.resolve([
202
+ leaf({
203
+ name: 'b',
204
+ title: 'Result B',
205
+ commandName: 'b'
206
+ })
207
+ ]);
208
+ const secondResult = await secondRequest.promise;
209
+ expect(secondResult.commands.map((item)=>item.command.name)).toEqual([
210
+ 'b'
211
+ ]);
212
+ searchA.resolve([
213
+ leaf({
214
+ name: 'a',
215
+ title: 'Result A',
216
+ commandName: 'a'
217
+ })
218
+ ]);
219
+ const firstResult = await firstRequest.promise;
220
+ expect(firstResult.commands.map((item)=>item.command.name)).toEqual([
221
+ 'b'
222
+ ]);
223
+ expect(resolver.getState().commands.map((item)=>item.command.name)).toEqual([
224
+ 'b'
225
+ ]);
226
+ });
227
+ it('clears visible commands while loading new results', async ()=>{
228
+ const resolver = new CommandsResolver();
229
+ const remoteDeferred = deferred();
230
+ const rootCommands = [
231
+ leaf({
232
+ name: 'paragraph',
233
+ title: 'Paragraph'
234
+ }),
235
+ {
236
+ name: 'remote',
237
+ title: 'Remote',
238
+ commandName: 'remote',
239
+ icon: null,
240
+ getCommands: ({ query })=>{
241
+ if (query) return remoteDeferred.promise;
242
+ return [
243
+ leaf({
244
+ name: 'code',
245
+ title: 'Code'
246
+ })
247
+ ];
248
+ }
249
+ }
250
+ ];
251
+ const initial = await resolver.resolve({
252
+ rootCommands,
253
+ filter: '',
254
+ path: [],
255
+ editor
256
+ }).promise;
257
+ const loadingRequest = resolver.resolve({
258
+ rootCommands,
259
+ filter: 'code',
260
+ path: [],
261
+ editor
262
+ });
263
+ expect(loadingRequest.state.isLoading).toBe(true);
264
+ expect(initial.commands.length).toBeGreaterThan(0);
265
+ expect(loadingRequest.state.commands).toEqual([]);
266
+ remoteDeferred.resolve([
267
+ leaf({
268
+ name: 'code',
269
+ title: 'Code'
270
+ })
271
+ ]);
272
+ const settled = await loadingRequest.promise;
273
+ expect(settled.isLoading).toBe(false);
274
+ expect(settled.commands).toHaveLength(1);
275
+ expect(settled.commands[0]?.command.name).toBe('code');
276
+ });
277
+ });
@@ -4,6 +4,11 @@ const styles_module = {
4
4
  button: "button-yrhMLR",
5
5
  active: "active-Is5D6b",
6
6
  icon: "icon-Xca6dM",
7
+ content: "content-VZW7lK",
8
+ breadcrumb: "breadcrumb-Jr0wXh",
9
+ submenu: "submenu-zNsqOg",
10
+ path: "path-fYY_14",
11
+ systemRow: "systemRow-ln5twO",
7
12
  backdrop: "backdrop-lw7AjW"
8
13
  };
9
14
  export { styles_module as default };
@@ -2,20 +2,24 @@
2
2
  color: var(--kona-editor-text-color);
3
3
  z-index: 31;
4
4
  opacity: 0;
5
+ transform-origin: 0 0;
5
6
  background-color: var(--kona-editor-background-color, #fff);
6
7
  border: 1px solid var(--kona-editor-border-color, #ddd);
8
+ will-change: transform, opacity;
9
+ contain: layout paint style;
10
+ backface-visibility: hidden;
7
11
  border-radius: 4px;
8
12
  flex-direction: column;
9
13
  max-height: 200px;
10
14
  margin-top: -6px;
11
15
  font-size: 12px;
12
- transition: transform .25s;
16
+ transition: transform .12s, opacity .12s;
13
17
  display: flex;
14
- position: absolute;
15
- top: -100000px;
16
- left: -100000px;
18
+ position: fixed;
19
+ top: 0;
20
+ left: 0;
17
21
  overflow-y: auto;
18
- transform: scale(.9);
22
+ transform: translate3d(-100000px, -100000px, 0)scale(.95);
19
23
  box-shadow: 0 1px 3px #00000006;
20
24
 
21
25
  &::-webkit-scrollbar {
@@ -35,14 +39,16 @@
35
39
  .button-yrhMLR {
36
40
  box-sizing: border-box;
37
41
  cursor: pointer;
38
- height: 40px;
42
+ min-height: 40px;
39
43
  color: var(--kona-editor-text-color, #444);
44
+ text-align: left;
40
45
  background: none;
41
46
  border: none;
42
47
  align-items: center;
43
48
  column-gap: 8px;
49
+ width: 100%;
44
50
  padding: 8px;
45
- display: inline-flex;
51
+ display: flex;
46
52
  }
47
53
 
48
54
  .button-yrhMLR:hover, .active-Is5D6b {
@@ -63,6 +69,40 @@
63
69
  display: inline-flex;
64
70
  }
65
71
 
72
+ .content-VZW7lK {
73
+ flex-direction: column;
74
+ flex: 1;
75
+ row-gap: 2px;
76
+ min-width: 0;
77
+ display: flex;
78
+ }
79
+
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
+ .submenu-zNsqOg {
89
+ color: var(--kona-editor-secondary-text-color, #777);
90
+ }
91
+
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
+ .systemRow-ln5twO {
100
+ min-height: 32px;
101
+ color: var(--kona-editor-secondary-text-color, #777);
102
+ padding: 8px;
103
+ font-size: 11px;
104
+ }
105
+
66
106
  .backdrop-lw7AjW {
67
107
  z-index: 30;
68
108
  background-color: #0000;
@@ -9,14 +9,26 @@ export type Options = {
9
9
  export type CommandsStore = {
10
10
  isOpen: boolean;
11
11
  filter: boolean | string;
12
- commands: Command[];
12
+ openId: number;
13
+ };
14
+ export type CommandPathEntry = {
15
+ name: string;
16
+ title: string;
17
+ commandName: string;
18
+ };
19
+ export type GetCommandsContext = {
20
+ query: string;
21
+ editor: Editor;
22
+ path: CommandPathEntry[];
23
+ parent: Command;
13
24
  };
14
25
  export type Command = {
15
26
  name: string;
16
27
  title: string;
17
28
  commandName: string;
18
29
  icon: ReactNode;
19
- action: (actions: Actions, editor: Editor) => void;
30
+ action?: (actions: Actions, editor: Editor) => void;
31
+ getCommands?: (context: GetCommandsContext) => Command[] | Promise<Command[]>;
20
32
  };
21
33
  export type Actions = {
22
34
  removeCommand: () => void;
@@ -0,0 +1,12 @@
1
+ import type { Editor } from 'slate';
2
+ import { type ResolvedCommandsState } from './resolveCommands';
3
+ import type { Command, CommandPathEntry } from './types';
4
+ type Params = {
5
+ rootCommands: Command[];
6
+ filter: boolean | string;
7
+ path: CommandPathEntry[];
8
+ editor: Editor;
9
+ isOpen: boolean;
10
+ };
11
+ export declare const useResolvedCommands: (params: Params) => ResolvedCommandsState;
12
+ export {};
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { CommandsResolver } from "./resolveCommands.js";
3
+ const DEBOUNCE_TIMEOUT = 150;
4
+ const EMPTY_STATE = {
5
+ commands: [],
6
+ isLoading: false,
7
+ isError: false
8
+ };
9
+ const useResolvedCommands = (params)=>{
10
+ const { rootCommands, filter, path, editor, isOpen } = params;
11
+ const resolverRef = useRef(new CommandsResolver());
12
+ const [state, setState] = useState(EMPTY_STATE);
13
+ const query = 'string' == typeof filter ? filter : '';
14
+ useEffect(()=>{
15
+ if (!isOpen || false === filter) return void setState(EMPTY_STATE);
16
+ setState({
17
+ commands: [],
18
+ isLoading: true,
19
+ isError: false
20
+ });
21
+ const timeout = window.setTimeout(()=>{
22
+ const request = resolverRef.current.resolve({
23
+ rootCommands,
24
+ filter: query,
25
+ path,
26
+ editor
27
+ });
28
+ setState(request.state);
29
+ request.promise.then((resolved)=>{
30
+ setState(resolved);
31
+ });
32
+ }, query ? DEBOUNCE_TIMEOUT : 0);
33
+ return ()=>{
34
+ window.clearTimeout(timeout);
35
+ };
36
+ }, [
37
+ editor,
38
+ filter,
39
+ isOpen,
40
+ path,
41
+ query,
42
+ rootCommands
43
+ ]);
44
+ return state;
45
+ };
46
+ export { useResolvedCommands };
@@ -1,3 +1,4 @@
1
+ import { type MapStore } from 'nanostores';
1
2
  import type React from 'react';
2
3
  import { type ConnectDragPreview, type ConnectDragSource, type ConnectDropTarget } from 'react-dnd';
3
4
  import { Editor, Path } from 'slate';
@@ -12,18 +13,25 @@ type Options = {
12
13
  dropRef: ConnectDropTarget;
13
14
  previewRef: ConnectDragPreview;
14
15
  position: 'top' | 'bottom' | null;
16
+ selected?: boolean;
17
+ onToggleSelected: () => void;
15
18
  }) => React.ReactNode;
16
19
  ignoreNodes?: string[];
17
20
  customTypes?: {
18
21
  [type: string]: {
19
22
  type: string | symbol;
20
23
  getData?: (element: CustomElement) => Record<string, unknown>;
24
+ getDndItem?: (element: CustomElement) => Record<string, unknown>;
21
25
  };
22
26
  };
23
27
  };
24
28
  export declare class DnDPlugin implements IPlugin {
25
29
  private options;
30
+ store: MapStore<{
31
+ selected: Set<string>;
32
+ }>;
26
33
  constructor(options: Options);
34
+ handleToggleSelected: (nodeId: string) => void;
27
35
  static DND_BLOCK_ELEMENT: string;
28
36
  handlers: {
29
37
  onDrop: (event: DragEvent) => void;