@use-kona/editor 0.1.24 → 0.1.26
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.
- package/dist/core/createEditable.js +146 -15
- package/dist/core/createEditor.js +1 -1
- package/dist/core/styles.module.js +3 -1
- package/dist/core/styles_module.css +13 -0
- package/dist/elements/Block.js +25 -0
- package/dist/index.js +2 -1
- package/dist/plugins/CodeBlockPlugin/CodeBlockPlugin.js +1 -1
- package/dist/plugins/DnDPlugin/DnDPlugin.js +93 -45
- package/dist/plugins/DnDPlugin/types.js +2 -0
- package/dist/plugins/HeadingsPlugin/HeadingsPlugin.js +1 -1
- package/dist/plugins/ListsPlugin/ListsPlugin.js +5 -3
- package/dist/plugins/NodeIdPlugin/NodeIdPlugin.js +6 -4
- package/dist/provider.js +15 -0
- package/dist/src/core/createEditable.d.ts +1 -1
- package/dist/src/elements/Block.d.ts +2 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/plugins/DnDPlugin/DnDPlugin.d.ts +0 -7
- package/dist/src/plugins/DnDPlugin/types.d.ts +29 -15
- package/dist/src/plugins/ListsPlugin/ListsPlugin.d.ts +1 -0
- package/dist/src/plugins/NodeIdPlugin/NodeIdPlugin.d.ts +1 -0
- package/dist/src/provider.d.ts +8 -0
- package/dist/src/types.d.ts +4 -0
- package/package.json +9 -8
- package/src/core/createEditable.tsx +170 -18
- package/src/core/createEditor.ts +1 -1
- package/src/core/styles.module.css +12 -0
- package/src/editor.tsx +4 -3
- package/src/elements/Block.tsx +31 -0
- package/src/index.ts +1 -0
- package/src/plugins/CodeBlockPlugin/CodeBlockPlugin.tsx +1 -3
- package/src/plugins/DnDPlugin/DnDPlugin.tsx +147 -62
- package/src/plugins/DnDPlugin/types.ts +35 -20
- package/src/plugins/HeadingsPlugin/HeadingsPlugin.tsx +1 -2
- package/src/plugins/ListsPlugin/ListsPlugin.spec.tsx +110 -14
- package/src/plugins/ListsPlugin/ListsPlugin.tsx +17 -6
- package/src/plugins/NodeIdPlugin/NodeIdPlugin.ts +9 -7
- package/src/provider.tsx +28 -0
- 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
|
-
|
|
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
|
|
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 =
|
|
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 &&
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
(
|
|
179
|
+
(data as unknown as { files: File[] }).files,
|
|
182
180
|
insertAt,
|
|
183
181
|
);
|
|
184
182
|
break;
|
|
185
183
|
default: {
|
|
186
|
-
const dragItem =
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
export type NodeWithId = Omit<CustomElement, 'children'> & {
|
|
13
|
+
nodeId: string;
|
|
14
|
+
children: (NodeWithId | CustomText)[];
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
export
|
|
18
|
-
nodeId
|
|
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
|
-
|
|
32
|
-
onToggleSelected:
|
|
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<
|
|
51
|
+
export type CustomDropContext<TItem = unknown> = {
|
|
50
52
|
editor: Editor;
|
|
51
|
-
|
|
53
|
+
data: EditorOuterDragItem<TItem>;
|
|
52
54
|
insertAt: Path;
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
type DropResult = 'handled' | 'ignored';
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*/
|
|
60
|
-
export type EditorDragItem = {
|
|
59
|
+
export type EditorInnerDragItem<TItem = unknown> = {
|
|
60
|
+
kind: 'inner';
|
|
61
61
|
nodes: NodeWithId[];
|
|
62
|
-
|
|
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
|
|
|
@@ -1,35 +1,44 @@
|
|
|
1
1
|
/** @jsxRuntime classic */
|
|
2
2
|
/** @jsx jsx */
|
|
3
3
|
|
|
4
|
+
import type { KeyboardEvent, ReactNode } from 'react';
|
|
5
|
+
import { Editor } from 'slate';
|
|
4
6
|
import {
|
|
5
7
|
createHyperscript,
|
|
6
8
|
createText,
|
|
7
9
|
type HyperscriptShorthands,
|
|
8
10
|
} from 'slate-hyperscript';
|
|
9
|
-
import { describe, expect, it } from 'vitest';
|
|
10
|
-
import { Editor } from 'slate';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
12
|
import { createEditor } from '../../core/createEditor';
|
|
12
13
|
import { ListsPlugin } from './ListsPlugin';
|
|
13
14
|
|
|
14
|
-
|
|
15
15
|
const elements: HyperscriptShorthands = {
|
|
16
16
|
paragraph: { type: 'paragraph' },
|
|
17
17
|
numberedList: { type: ListsPlugin.NUMBERED_LIST_ELEMENT },
|
|
18
18
|
bulletedList: { type: ListsPlugin.BULLETED_LIST_ELEMENT },
|
|
19
19
|
listItem: { type: ListsPlugin.LIST_ITEM_ELEMENT },
|
|
20
|
+
taskItem: { type: 'task-item' },
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
const jsx = createHyperscript({ elements, creators: { text: createText } });
|
|
23
24
|
|
|
24
25
|
jsx;
|
|
25
26
|
|
|
26
|
-
const
|
|
27
|
+
const createEditorAndPlugin = (
|
|
28
|
+
children: ReactNode,
|
|
29
|
+
options: ConstructorParameters<typeof ListsPlugin>[0] = {},
|
|
30
|
+
) => {
|
|
27
31
|
const editorState = <editor>{children}</editor>;
|
|
28
|
-
const
|
|
32
|
+
const plugin = new ListsPlugin(options);
|
|
33
|
+
const editor = createEditor([plugin])();
|
|
29
34
|
editor.children = editorState.children;
|
|
30
35
|
editor.selection = editorState.selection;
|
|
31
36
|
|
|
32
|
-
return editor;
|
|
37
|
+
return { editor, plugin };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const createEditorWithPlugin = (children: ReactNode) => {
|
|
41
|
+
return createEditorAndPlugin(children).editor;
|
|
33
42
|
};
|
|
34
43
|
|
|
35
44
|
describe('ListsPlugin', () => {
|
|
@@ -128,13 +137,19 @@ describe('ListsPlugin', () => {
|
|
|
128
137
|
{
|
|
129
138
|
type: ListsPlugin.NUMBERED_LIST_ELEMENT,
|
|
130
139
|
children: [
|
|
131
|
-
{
|
|
140
|
+
{
|
|
141
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
142
|
+
children: [{ text: 'first' }],
|
|
143
|
+
},
|
|
132
144
|
],
|
|
133
145
|
},
|
|
134
146
|
{
|
|
135
147
|
type: ListsPlugin.NUMBERED_LIST_ELEMENT,
|
|
136
148
|
children: [
|
|
137
|
-
{
|
|
149
|
+
{
|
|
150
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
151
|
+
children: [{ text: 'second' }],
|
|
152
|
+
},
|
|
138
153
|
],
|
|
139
154
|
},
|
|
140
155
|
];
|
|
@@ -146,8 +161,14 @@ describe('ListsPlugin', () => {
|
|
|
146
161
|
{
|
|
147
162
|
type: ListsPlugin.NUMBERED_LIST_ELEMENT,
|
|
148
163
|
children: [
|
|
149
|
-
{
|
|
150
|
-
|
|
164
|
+
{
|
|
165
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
166
|
+
children: [{ text: 'first' }],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
170
|
+
children: [{ text: 'second' }],
|
|
171
|
+
},
|
|
151
172
|
],
|
|
152
173
|
},
|
|
153
174
|
];
|
|
@@ -166,13 +187,19 @@ describe('ListsPlugin', () => {
|
|
|
166
187
|
{
|
|
167
188
|
type: ListsPlugin.BULLETED_LIST_ELEMENT,
|
|
168
189
|
children: [
|
|
169
|
-
{
|
|
190
|
+
{
|
|
191
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
192
|
+
children: [{ text: 'bullet' }],
|
|
193
|
+
},
|
|
170
194
|
],
|
|
171
195
|
},
|
|
172
196
|
{
|
|
173
197
|
type: ListsPlugin.NUMBERED_LIST_ELEMENT,
|
|
174
198
|
children: [
|
|
175
|
-
{
|
|
199
|
+
{
|
|
200
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
201
|
+
children: [{ text: 'number' }],
|
|
202
|
+
},
|
|
176
203
|
],
|
|
177
204
|
},
|
|
178
205
|
];
|
|
@@ -184,13 +211,19 @@ describe('ListsPlugin', () => {
|
|
|
184
211
|
{
|
|
185
212
|
type: ListsPlugin.BULLETED_LIST_ELEMENT,
|
|
186
213
|
children: [
|
|
187
|
-
{
|
|
214
|
+
{
|
|
215
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
216
|
+
children: [{ text: 'bullet' }],
|
|
217
|
+
},
|
|
188
218
|
],
|
|
189
219
|
},
|
|
190
220
|
{
|
|
191
221
|
type: ListsPlugin.NUMBERED_LIST_ELEMENT,
|
|
192
222
|
children: [
|
|
193
|
-
{
|
|
223
|
+
{
|
|
224
|
+
type: ListsPlugin.LIST_ITEM_ELEMENT,
|
|
225
|
+
children: [{ text: 'number' }],
|
|
226
|
+
},
|
|
194
227
|
],
|
|
195
228
|
},
|
|
196
229
|
];
|
|
@@ -242,4 +275,67 @@ describe('ListsPlugin', () => {
|
|
|
242
275
|
|
|
243
276
|
expect(editor.children).toEqual(output);
|
|
244
277
|
});
|
|
278
|
+
|
|
279
|
+
it('should turn first-level list item into paragraph on Backspace at start', () => {
|
|
280
|
+
const { editor, plugin } = createEditorAndPlugin(
|
|
281
|
+
<bulletedList>
|
|
282
|
+
<listItem>
|
|
283
|
+
<cursor />
|
|
284
|
+
<text>Hello world</text>
|
|
285
|
+
</listItem>
|
|
286
|
+
</bulletedList>,
|
|
287
|
+
);
|
|
288
|
+
const event = {
|
|
289
|
+
key: 'Backspace',
|
|
290
|
+
preventDefault: vi.fn(),
|
|
291
|
+
} as unknown as KeyboardEvent;
|
|
292
|
+
|
|
293
|
+
plugin.handlers.onKeyDown(event, editor);
|
|
294
|
+
|
|
295
|
+
const output = (
|
|
296
|
+
<editor>
|
|
297
|
+
<paragraph>
|
|
298
|
+
<text>Hello world</text>
|
|
299
|
+
</paragraph>
|
|
300
|
+
</editor>
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(event.preventDefault).toHaveBeenCalledOnce();
|
|
304
|
+
expect(editor.children).toEqual(output.children);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should ignore configured list item types for Backspace conversion', () => {
|
|
308
|
+
const { editor, plugin } = createEditorAndPlugin(
|
|
309
|
+
<bulletedList>
|
|
310
|
+
<taskItem>
|
|
311
|
+
<cursor />
|
|
312
|
+
<text>Hello world</text>
|
|
313
|
+
</taskItem>
|
|
314
|
+
</bulletedList>,
|
|
315
|
+
{
|
|
316
|
+
ignoreBackspaceTypes: ['task-item'],
|
|
317
|
+
listItemTypes: ['task-item'],
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
const event = {
|
|
321
|
+
key: 'Backspace',
|
|
322
|
+
preventDefault: vi.fn(),
|
|
323
|
+
} as unknown as KeyboardEvent;
|
|
324
|
+
|
|
325
|
+
plugin.handlers.onKeyDown(event, editor);
|
|
326
|
+
|
|
327
|
+
const output = (
|
|
328
|
+
<editor>
|
|
329
|
+
<bulletedList>
|
|
330
|
+
<taskItem>
|
|
331
|
+
<text />
|
|
332
|
+
<text>Hello world</text>
|
|
333
|
+
</taskItem>
|
|
334
|
+
</bulletedList>
|
|
335
|
+
</editor>
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
339
|
+
expect(editor.children).toEqual(output.children);
|
|
340
|
+
});
|
|
245
341
|
});
|
|
@@ -17,6 +17,7 @@ import styles from './styles.module.css';
|
|
|
17
17
|
type Options = {
|
|
18
18
|
listTypes?: string[];
|
|
19
19
|
listItemTypes?: string[];
|
|
20
|
+
ignoreBackspaceTypes?: string[];
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export class ListsPlugin implements IPlugin {
|
|
@@ -27,9 +28,14 @@ export class ListsPlugin implements IPlugin {
|
|
|
27
28
|
static LIST_ITEM_ELEMENT = 'li';
|
|
28
29
|
|
|
29
30
|
constructor(options: Options = {}) {
|
|
30
|
-
const {
|
|
31
|
+
const {
|
|
32
|
+
ignoreBackspaceTypes = [],
|
|
33
|
+
listTypes = [],
|
|
34
|
+
listItemTypes = [],
|
|
35
|
+
} = options;
|
|
31
36
|
|
|
32
37
|
this.options = {
|
|
38
|
+
ignoreBackspaceTypes,
|
|
33
39
|
listTypes: [
|
|
34
40
|
ListsPlugin.BULLETED_LIST_ELEMENT,
|
|
35
41
|
ListsPlugin.NUMBERED_LIST_ELEMENT,
|
|
@@ -96,7 +102,10 @@ export class ListsPlugin implements IPlugin {
|
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
/* Merge adjacent lists */
|
|
99
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
this.isList(editor, node as CustomElement) &&
|
|
107
|
+
Path.hasPrevious(path)
|
|
108
|
+
) {
|
|
100
109
|
const prevPath = Path.previous(path);
|
|
101
110
|
const prevNode = Node.get(editor, prevPath) as CustomElement;
|
|
102
111
|
|
|
@@ -236,9 +245,14 @@ export class ListsPlugin implements IPlugin {
|
|
|
236
245
|
if (
|
|
237
246
|
selection &&
|
|
238
247
|
match &&
|
|
248
|
+
!this.options.ignoreBackspaceTypes?.includes(
|
|
249
|
+
(match[0] as CustomElement).type,
|
|
250
|
+
) &&
|
|
239
251
|
this.getListItemDepth(editor, match[1]) === 1 &&
|
|
240
252
|
Editor.isStart(editor, selection.anchor, match[1])
|
|
241
253
|
) {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
|
|
242
256
|
Transforms.unwrapNodes(editor, {
|
|
243
257
|
match: (n) => this.isList(editor, n as CustomElement),
|
|
244
258
|
split: true,
|
|
@@ -331,10 +345,7 @@ export class ListsPlugin implements IPlugin {
|
|
|
331
345
|
const [match] = Array.from(
|
|
332
346
|
Editor.nodes(editor, {
|
|
333
347
|
at: Editor.unhangRange(editor, selection),
|
|
334
|
-
match: (node) =>
|
|
335
|
-
!Editor.isEditor(node) &&
|
|
336
|
-
Element.isElement(node) &&
|
|
337
|
-
node.type === type,
|
|
348
|
+
match: (node) => Element.isElement(node) && node.type === type,
|
|
338
349
|
}),
|
|
339
350
|
);
|
|
340
351
|
|