@use-kona/editor 0.1.24 → 0.1.25

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 (36) hide show
  1. package/dist/core/createEditable.js +146 -15
  2. package/dist/core/createEditor.js +1 -1
  3. package/dist/core/styles.module.js +3 -1
  4. package/dist/core/styles_module.css +13 -0
  5. package/dist/elements/Block.js +25 -0
  6. package/dist/index.js +2 -1
  7. package/dist/plugins/CodeBlockPlugin/CodeBlockPlugin.js +1 -1
  8. package/dist/plugins/DnDPlugin/DnDPlugin.js +93 -45
  9. package/dist/plugins/DnDPlugin/types.js +2 -0
  10. package/dist/plugins/HeadingsPlugin/HeadingsPlugin.js +1 -1
  11. package/dist/plugins/ListsPlugin/ListsPlugin.js +1 -1
  12. package/dist/plugins/NodeIdPlugin/NodeIdPlugin.js +6 -4
  13. package/dist/provider.js +15 -0
  14. package/dist/src/core/createEditable.d.ts +1 -1
  15. package/dist/src/elements/Block.d.ts +2 -0
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/plugins/DnDPlugin/DnDPlugin.d.ts +0 -7
  18. package/dist/src/plugins/DnDPlugin/types.d.ts +29 -15
  19. package/dist/src/plugins/NodeIdPlugin/NodeIdPlugin.d.ts +1 -0
  20. package/dist/src/provider.d.ts +8 -0
  21. package/dist/src/types.d.ts +4 -0
  22. package/package.json +9 -8
  23. package/src/core/createEditable.tsx +170 -18
  24. package/src/core/createEditor.ts +1 -1
  25. package/src/core/styles.module.css +12 -0
  26. package/src/editor.tsx +4 -3
  27. package/src/elements/Block.tsx +31 -0
  28. package/src/index.ts +1 -0
  29. package/src/plugins/CodeBlockPlugin/CodeBlockPlugin.tsx +1 -3
  30. package/src/plugins/DnDPlugin/DnDPlugin.tsx +147 -62
  31. package/src/plugins/DnDPlugin/types.ts +35 -20
  32. package/src/plugins/HeadingsPlugin/HeadingsPlugin.tsx +1 -2
  33. package/src/plugins/ListsPlugin/ListsPlugin.tsx +5 -5
  34. package/src/plugins/NodeIdPlugin/NodeIdPlugin.ts +9 -7
  35. package/src/provider.tsx +28 -0
  36. package/src/types.ts +5 -0
@@ -1,10 +1,8 @@
1
1
  import { useStore } from '@nanostores/react';
2
- import { nanoid } from 'nanoid';
3
- import { type MapStore, map } from 'nanostores';
4
2
  import { useEffect, useRef, useState } from 'react';
5
3
  import { type ConnectDropTarget, useDrag, useDrop } from 'react-dnd';
6
4
  import { NativeTypes } from 'react-dnd-html5-backend';
7
- import { Editor, Path, Transforms } from 'slate';
5
+ import { Editor, Element, Path, Transforms } from 'slate';
8
6
  import {
9
7
  ReactEditor,
10
8
  type RenderElementProps,
@@ -12,44 +10,18 @@ import {
12
10
  useSlate,
13
11
  } from 'slate-react';
14
12
  import type { CustomElement } from '../../../types';
13
+ import { useEditorContext } from '../../provider';
15
14
  import type { IPlugin } from '../../types';
16
15
  import type { EditorDragItem, NodeWithId, Options } from './types';
17
16
  import { getNodesByNodeIds } from './utils';
18
17
 
19
18
  export class DnDPlugin implements IPlugin {
20
- store: MapStore<{ selected: Set<string> }>;
21
- editors: WeakMap<Editor, string>;
22
-
23
- constructor(private options: Options) {
24
- this.store = map({
25
- selected: new Set(),
26
- });
27
- this.editors = new WeakMap();
28
- }
19
+ constructor(private options: Options) {}
29
20
 
30
21
  init(editor: Editor) {
31
- this.editors.set(editor, nanoid());
32
-
33
22
  return editor;
34
23
  }
35
24
 
36
- getEditorId(editor: Editor) {
37
- return this.editors.get(editor);
38
- }
39
-
40
- handleToggleSelected = (nodeId: string) => {
41
- const selected = new Set(this.store.get().selected);
42
- if (selected.has(nodeId)) {
43
- selected.delete(nodeId);
44
- } else {
45
- selected.add(nodeId);
46
- }
47
-
48
- this.store.set({
49
- selected,
50
- });
51
- };
52
-
53
25
  static DND_BLOCK_ELEMENT = 'block';
54
26
 
55
27
  handlers = {
@@ -61,9 +33,12 @@ export class DnDPlugin implements IPlugin {
61
33
  renderBlock = (props: RenderElementProps) => {
62
34
  const editor = useSlate();
63
35
 
64
- const $store = useStore(this.store);
36
+ const { selectedNodes } = useEditorContext();
37
+ const $selectedNodes = useStore(selectedNodes);
65
38
 
39
+ const isCopying = useRef(false);
66
40
  const options = this.options;
41
+ const generateId = this.options?.nodeIdPlugin?.generateId;
67
42
  const isReadOnly = useReadOnly();
68
43
 
69
44
  const [dropPosition, setDropPosition] = useState<'top' | 'bottom' | null>(
@@ -76,9 +51,9 @@ export class DnDPlugin implements IPlugin {
76
51
  const currentElement = props.element as NodeWithId;
77
52
 
78
53
  const [, drag, preview] = useDrag({
79
- type: DnDPlugin.DND_BLOCK_ELEMENT,
54
+ type: customType?.type || DnDPlugin.DND_BLOCK_ELEMENT,
80
55
  item: () => {
81
- const selected = Array.from($store.selected.values());
56
+ const selected: Array<NodeWithId> = $selectedNodes;
82
57
 
83
58
  /**
84
59
  * We copy nodes that the user is dragging.
@@ -86,10 +61,19 @@ export class DnDPlugin implements IPlugin {
86
61
  * we copy selected nodes. Otherwise, we copy only the current node.
87
62
  */
88
63
  const nodes =
89
- selected.length > 0 && selected.includes(currentElement.nodeId)
90
- ? getNodesByNodeIds(editor, selected)
64
+ selected.length > 0 &&
65
+ selected.find((node) => node.nodeId === currentElement.nodeId)
66
+ ? getNodesByNodeIds(
67
+ editor,
68
+ selected.map((node) => node.nodeId),
69
+ )
91
70
  : [structuredClone(props.element as NodeWithId)];
92
71
 
72
+ const item =
73
+ this.options.customTypes?.[currentElement.type]?.getData?.(
74
+ currentElement,
75
+ );
76
+
93
77
  const items = nodes
94
78
  .map((node) => {
95
79
  if (this.options.customTypes?.[node.type]) {
@@ -100,10 +84,12 @@ export class DnDPlugin implements IPlugin {
100
84
  .filter(Boolean);
101
85
 
102
86
  const dragItem: EditorDragItem = {
87
+ kind: 'inner',
103
88
  nodes,
89
+ item,
104
90
  items,
105
91
  source: {
106
- editorId: this.getEditorId(editor),
92
+ editor,
107
93
  documentId: options.documentId,
108
94
  },
109
95
  };
@@ -112,7 +98,7 @@ export class DnDPlugin implements IPlugin {
112
98
  },
113
99
  canDrag: !isReadOnly,
114
100
  end: () => {
115
- $store.selected.clear();
101
+ selectedNodes.set([]);
116
102
  },
117
103
  });
118
104
 
@@ -120,7 +106,11 @@ export class DnDPlugin implements IPlugin {
120
106
  (customType) => customType.type,
121
107
  );
122
108
 
123
- const [{ isOver }, drop] = useDrop({
109
+ const [{ isOver }, drop] = useDrop<
110
+ EditorDragItem,
111
+ void,
112
+ { isOver: boolean }
113
+ >({
124
114
  accept: [
125
115
  DnDPlugin.DND_BLOCK_ELEMENT,
126
116
  NativeTypes.FILE,
@@ -151,8 +141,16 @@ export class DnDPlugin implements IPlugin {
151
141
  setDropPosition('bottom');
152
142
  }
153
143
  },
154
- drop(item, monitor) {
144
+ drop: (data, monitor) => {
155
145
  const itemType = monitor.getItemType();
146
+ const sourceEditor = data.kind === 'inner' ? data.source?.editor : null;
147
+
148
+ const isSameDocument =
149
+ data.kind === 'inner' &&
150
+ data.source?.documentId === this.options.documentId &&
151
+ this.options.documentId !== undefined;
152
+ const isSameEditor =
153
+ data.kind === 'inner' && data.source?.editor === editor;
156
154
 
157
155
  const sourceTo = ReactEditor.findPath(editor, props.element);
158
156
 
@@ -169,8 +167,8 @@ export class DnDPlugin implements IPlugin {
169
167
  const customDropHandler =
170
168
  itemType && options.customDropHandlers?.[itemType];
171
169
 
172
- if (customDropHandler) {
173
- customDropHandler.onDrop({ editor, item, insertAt });
170
+ if (customDropHandler && data.kind === 'outer') {
171
+ customDropHandler.onDrop({ editor, data, insertAt });
174
172
  return;
175
173
  }
176
174
 
@@ -178,12 +176,17 @@ export class DnDPlugin implements IPlugin {
178
176
  case NativeTypes.FILE:
179
177
  options.onDropFiles(
180
178
  editor,
181
- (item as { files: File[] }).files,
179
+ (data as unknown as { files: File[] }).files,
182
180
  insertAt,
183
181
  );
184
182
  break;
185
183
  default: {
186
- const dragItem = item as EditorDragItem;
184
+ const dragItem = data as EditorDragItem;
185
+
186
+ // We don't know what "default" behavior for the outer items is
187
+ if (dragItem.kind === 'outer') {
188
+ return;
189
+ }
187
190
 
188
191
  if (!dropPosition || !dragItem.nodes.length) {
189
192
  return;
@@ -191,12 +194,35 @@ export class DnDPlugin implements IPlugin {
191
194
 
192
195
  const nodeIds = dragItem.nodes.map((node) => node.nodeId);
193
196
 
194
- moveNode(editor, sourceTo, nodeIds, dropPosition);
195
- $store.selected.clear();
197
+ if (isSameDocument || isSameEditor) {
198
+ if (!isCopying.current) {
199
+ moveNode(editor, sourceTo, nodeIds, dropPosition);
200
+ } else {
201
+ if (!generateId) {
202
+ return;
203
+ }
204
+
205
+ const clonedNodes = cloneWithNewIds(dragItem.nodes, generateId);
206
+ insertNodes(editor, insertAt, clonedNodes);
207
+ }
208
+ } else {
209
+ if (!generateId) {
210
+ return;
211
+ }
212
+
213
+ const clonedNodes = cloneWithNewIds(dragItem.nodes, generateId);
214
+
215
+ insertNodes(editor, insertAt, clonedNodes);
216
+ if (!isCopying.current && sourceEditor) {
217
+ removeNodes(sourceEditor, nodeIds);
218
+ }
219
+ }
220
+ selectedNodes.set([]);
196
221
  break;
197
222
  }
198
223
  }
199
224
 
225
+ isCopying.current = false;
200
226
  setDropPosition(null);
201
227
  },
202
228
  });
@@ -219,22 +245,32 @@ export class DnDPlugin implements IPlugin {
219
245
  return props.children;
220
246
  }
221
247
 
222
- return options.renderBlock?.({
223
- props,
224
- dragRef: drag,
225
- dropRef: connectDropRef,
226
- previewRef: preview,
227
- position: dropPosition,
228
- selected:
229
- typeof currentElement.nodeId === 'string'
230
- ? $store.selected.has(currentElement.nodeId)
231
- : false,
232
- onToggleSelected: () => {
233
- if (typeof currentElement.nodeId === 'string') {
234
- this.handleToggleSelected(currentElement.nodeId);
235
- }
236
- },
237
- });
248
+ return (
249
+ <div>
250
+ {options.renderBlock?.({
251
+ props,
252
+ dragRef: drag,
253
+ dropRef: connectDropRef,
254
+ previewRef: preview,
255
+ position: dropPosition,
256
+ onNativeDrop: (event) => {
257
+ isCopying.current = event.altKey;
258
+ },
259
+ onToggleSelected: (event) => {
260
+ event.stopPropagation();
261
+ if ($selectedNodes.includes(props.element)) {
262
+ selectedNodes.set(
263
+ [...$selectedNodes].filter(
264
+ (element) => element !== props.element,
265
+ ),
266
+ );
267
+ } else {
268
+ selectedNodes.set([...$selectedNodes, props.element]);
269
+ }
270
+ },
271
+ })}
272
+ </div>
273
+ );
238
274
  };
239
275
  }
240
276
 
@@ -289,3 +325,52 @@ const moveNode = (
289
325
  });
290
326
  Editor.normalize(editor);
291
327
  };
328
+
329
+ const insertNodes = (editor: Editor, path: Path, nodes: NodeWithId[]) => {
330
+ Editor.withoutNormalizing(editor, () => {
331
+ Transforms.insertNodes(editor, nodes, { at: path });
332
+ });
333
+ };
334
+
335
+ const removeNodes = (editor: Editor, nodeIds: string[]) => {
336
+ try {
337
+ Transforms.removeNodes(editor, {
338
+ at: [],
339
+ match: (n) => {
340
+ if (!Element.isElement(n)) {
341
+ return false;
342
+ }
343
+
344
+ if (!Editor.isBlock(editor, n)) {
345
+ return false;
346
+ }
347
+
348
+ const node = n as NodeWithId;
349
+ return typeof node.nodeId === 'string' && nodeIds.includes(node.nodeId);
350
+ },
351
+ mode: 'highest',
352
+ });
353
+ } catch {
354
+ return;
355
+ }
356
+ };
357
+
358
+ const cloneWithNewIds = (
359
+ nodes: NodeWithId[],
360
+ generateId: () => string,
361
+ ): NodeWithId[] => {
362
+ const copy = structuredClone(nodes);
363
+
364
+ const assignId = (node: NodeWithId): void => {
365
+ node.nodeId = generateId();
366
+ node.children.forEach((node) => {
367
+ if ('children' in node) {
368
+ assignId(node);
369
+ }
370
+ });
371
+ };
372
+
373
+ copy.forEach(assignId);
374
+
375
+ return copy;
376
+ };
@@ -1,4 +1,3 @@
1
- import { type MapStore, map } from 'nanostores';
2
1
  import type React from 'react';
3
2
  import type {
4
3
  ConnectDragPreview,
@@ -7,15 +6,20 @@ import type {
7
6
  } from 'react-dnd';
8
7
  import type { Editor, Path } from 'slate';
9
8
  import type { RenderElementProps } from 'slate-react';
10
- import type { CustomElement } from '../../../types';
9
+ import type { CustomElement, CustomText } from '../../../types';
10
+ import type { NodeIdPlugin } from '../NodeIdPlugin';
11
11
 
12
- type DropParams = {
13
- editor: Editor;
14
- item: unknown;
12
+ export type NodeWithId = Omit<CustomElement, 'children'> & {
13
+ nodeId: string;
14
+ children: (NodeWithId | CustomText)[];
15
15
  };
16
16
 
17
- export type NodeWithId = CustomElement & {
18
- nodeId: string;
17
+ export const isNodeWithId = (node: unknown): node is NodeWithId =>
18
+ typeof node === 'object' && node !== null && 'nodeId' in node;
19
+
20
+ export type CustomDropHandler<T = unknown> = {
21
+ type: string | symbol;
22
+ onDrop: (context: CustomDropContext<T>) => DropResult;
19
23
  };
20
24
 
21
25
  export type Options = {
@@ -28,8 +32,8 @@ export type Options = {
28
32
  dropRef: ConnectDropTarget;
29
33
  previewRef: ConnectDragPreview;
30
34
  position: 'top' | 'bottom' | null;
31
- selected?: boolean;
32
- onToggleSelected: () => void;
35
+ onNativeDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
36
+ onToggleSelected?: (event: React.MouseEvent<HTMLDivElement>) => void;
33
37
  }) => React.ReactNode;
34
38
  ignoreNodes?: string[];
35
39
  customTypes?: {
@@ -39,28 +43,26 @@ export type Options = {
39
43
  };
40
44
  };
41
45
  customDropHandlers?: {
42
- [type: string | symbol]: {
43
- type: string | symbol;
44
- onDrop: <T = unknown>(context: CustomDropContext<T>) => DropResult;
45
- };
46
+ [type: string | symbol]: CustomDropHandler;
46
47
  };
48
+ nodeIdPlugin?: NodeIdPlugin;
47
49
  };
48
50
 
49
- type CustomDropContext<T = unknown> = {
51
+ export type CustomDropContext<TItem = unknown> = {
50
52
  editor: Editor;
51
- item: T;
53
+ data: EditorOuterDragItem<TItem>;
52
54
  insertAt: Path;
53
55
  };
54
56
 
55
57
  type DropResult = 'handled' | 'ignored';
56
58
 
57
- /**
58
- * We're using this type to represent a drag item that is being dragged from the editor.
59
- */
60
- export type EditorDragItem = {
59
+ export type EditorInnerDragItem<TItem = unknown> = {
60
+ kind: 'inner';
61
61
  nodes: NodeWithId[];
62
- items?: unknown[];
62
+ item?: TItem;
63
+ items?: TItem[];
63
64
  source: {
65
+ editor: Editor;
64
66
  /**
65
67
  * Different editors have different unique identifiers.
66
68
  */
@@ -74,3 +76,16 @@ export type EditorDragItem = {
74
76
  };
75
77
  data?: Record<string, unknown>;
76
78
  };
79
+
80
+ export type EditorOuterDragItem<TItem = unknown> = {
81
+ kind: 'outer';
82
+ item: TItem;
83
+ items: TItem[];
84
+ };
85
+
86
+ /**
87
+ * We're using this type to represent a drag item that is being dragged from the editor.
88
+ */
89
+ export type EditorDragItem<TItem = unknown> =
90
+ | EditorInnerDragItem<TItem>
91
+ | EditorOuterDragItem<TItem>;
@@ -124,8 +124,7 @@ const isBlockActive = (editor, type) => {
124
124
  const [match] = Array.from(
125
125
  Editor.nodes(editor, {
126
126
  at: Editor.unhangRange(editor, selection),
127
- match: (node) =>
128
- !Editor.isEditor(node) && Element.isElement(node) && node.type === type,
127
+ match: (node) => Element.isElement(node) && node.type === type,
129
128
  }),
130
129
  );
131
130
 
@@ -96,7 +96,10 @@ export class ListsPlugin implements IPlugin {
96
96
  }
97
97
 
98
98
  /* Merge adjacent lists */
99
- if (this.isList(editor, node as CustomElement) && Path.hasPrevious(path)) {
99
+ if (
100
+ this.isList(editor, node as CustomElement) &&
101
+ Path.hasPrevious(path)
102
+ ) {
100
103
  const prevPath = Path.previous(path);
101
104
  const prevNode = Node.get(editor, prevPath) as CustomElement;
102
105
 
@@ -331,10 +334,7 @@ export class ListsPlugin implements IPlugin {
331
334
  const [match] = Array.from(
332
335
  Editor.nodes(editor, {
333
336
  at: Editor.unhangRange(editor, selection),
334
- match: (node) =>
335
- !Editor.isEditor(node) &&
336
- Element.isElement(node) &&
337
- node.type === type,
337
+ match: (node) => Element.isElement(node) && node.type === type,
338
338
  }),
339
339
  );
340
340
 
@@ -26,7 +26,11 @@ type Options = {
26
26
  };
27
27
 
28
28
  export class NodeIdPlugin implements IPlugin<Editor, NodeIdBlock> {
29
- constructor(private options: Options) {}
29
+ generateId: () => string;
30
+
31
+ constructor(private options: Options) {
32
+ this.generateId = options.generateId;
33
+ }
30
34
 
31
35
  init(editor: Editor) {
32
36
  const { apply, normalizeNode } = editor;
@@ -39,14 +43,13 @@ export class NodeIdPlugin implements IPlugin<Editor, NodeIdBlock> {
39
43
  const newType = (operation.newProperties as CustomElement).type;
40
44
 
41
45
  if (!node || (!Text.isText(node) && oldType !== newType)) {
42
- (operation.newProperties as NodeIdBlock).nodeId =
43
- this.options.generateId();
46
+ (operation.newProperties as NodeIdBlock).nodeId = this.generateId();
44
47
  }
45
48
  return apply(operation);
46
49
  }
47
50
 
48
51
  if (operation.type === 'insert_node') {
49
- assignId(operation.node, this.options.generateId);
52
+ assignId(operation.node, this.generateId);
50
53
  return apply(operation);
51
54
  }
52
55
 
@@ -54,8 +57,7 @@ export class NodeIdPlugin implements IPlugin<Editor, NodeIdBlock> {
54
57
  const node = Node.get(editor, operation.path);
55
58
 
56
59
  if (!node || !Text.isText(node)) {
57
- (operation.properties as NodeIdBlock).nodeId =
58
- this.options.generateId();
60
+ (operation.properties as NodeIdBlock).nodeId = this.generateId();
59
61
  }
60
62
  return apply(operation);
61
63
  }
@@ -66,7 +68,7 @@ export class NodeIdPlugin implements IPlugin<Editor, NodeIdBlock> {
66
68
  editor.normalizeNode = (entry: NodeEntry) => {
67
69
  const [node] = entry;
68
70
 
69
- assignId(node, this.options.generateId);
71
+ assignId(node, this.generateId);
70
72
 
71
73
  normalizeNode(entry);
72
74
  };
@@ -0,0 +1,28 @@
1
+ import type { StoreValue } from 'nanostores';
2
+ import type React from 'react';
3
+ import { createContext, useContext } from 'react';
4
+ import type { EditorContext } from './types';
5
+
6
+ const Context = createContext<EditorContext | null>(null);
7
+
8
+ export const EditorProvider = ({
9
+ children,
10
+ selectedNodes,
11
+ }: {
12
+ children: React.ReactNode;
13
+ selectedNodes: StoreValue<string[]>;
14
+ }) => {
15
+ return (
16
+ <Context.Provider value={{ selectedNodes }}>{children}</Context.Provider>
17
+ );
18
+ };
19
+
20
+ export const useEditorContext = () => {
21
+ const context = useContext(Context);
22
+
23
+ if (!context) {
24
+ throw new Error('useEditorContext must be used within an EditorProvider');
25
+ }
26
+
27
+ return context;
28
+ };
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { StoreValue } from 'nanostores';
1
2
  import type { KeyboardEvent, ReactElement, ReactNode } from 'react';
2
3
  import type {
3
4
  DecoratedRange,
@@ -98,3 +99,7 @@ export type Deserialize = (
98
99
  element: HTMLElement,
99
100
  children?: (string | Descendant)[],
100
101
  ) => CustomElement | CustomText[] | undefined;
102
+
103
+ export type EditorContext = {
104
+ selectedNodes: StoreValue<string[]>;
105
+ };