@use-kona/editor 0.1.33 → 0.1.34

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.
@@ -187,34 +187,41 @@ const moveNode = (editor, targetNodePath, nodeIds, position, sourcePath, section
187
187
  const dropPath = getDropPath(editor, targetNodePath, position);
188
188
  if (!dropPath) return;
189
189
  if (!isValidInsertPath(editor, dropPath)) return;
190
- if (sectionPaths?.some((path)=>Path.equals(path, targetNodePath))) return;
190
+ const explicitPaths = sectionPaths?.length ? sectionPaths : 0 === nodeIds.length && sourcePath ? [
191
+ sourcePath
192
+ ] : void 0;
193
+ const explicitNodes = explicitPaths && new Set(explicitPaths.filter((path)=>Editor.hasPath(editor, path)).map((path)=>Editor.node(editor, path)[0]));
194
+ const paths = Array.from(Editor.nodes(editor, {
195
+ at: [],
196
+ match: (n)=>{
197
+ if (explicitNodes) return explicitNodes.has(n);
198
+ return Element.isElement(n) && Editor.isBlock(editor, n) && 'string' == typeof n.nodeId && nodeIds.includes(n.nodeId);
199
+ },
200
+ mode: 'highest'
201
+ }), ([, path])=>path);
202
+ if (paths.some((path)=>Path.equals(path, targetNodePath) || Path.isAncestor(path, targetNodePath))) return;
203
+ const targetRef = Editor.pathRef(editor, targetNodePath);
204
+ const sourceRefs = paths.map((path)=>Editor.pathRef(editor, path));
191
205
  Editor.withoutNormalizing(editor, ()=>{
192
- if (sectionPaths?.length) {
193
- const sectionNodes = new Set(sectionPaths.filter((path)=>Editor.hasPath(editor, path)).map((path)=>Editor.node(editor, path)[0]));
194
- Transforms.moveNodes(editor, {
195
- at: [],
196
- match: (node)=>sectionNodes.has(node),
197
- to: dropPath,
198
- mode: 'highest'
199
- });
200
- return;
206
+ try {
207
+ const orderedRefs = 'top' === position ? sourceRefs : [
208
+ ...sourceRefs
209
+ ].reverse();
210
+ for (const sourceRef of orderedRefs){
211
+ const from = sourceRef.current;
212
+ const target = targetRef.current;
213
+ if (!from || !target) continue;
214
+ let to = 'top' === position ? target : Path.next(target);
215
+ if (Path.isSibling(from, to) && Path.isBefore(from, to)) to = Path.previous(to);
216
+ Transforms.moveNodes(editor, {
217
+ at: from,
218
+ to
219
+ });
220
+ }
221
+ } finally{
222
+ targetRef.unref();
223
+ for (const ref of sourceRefs)ref.unref();
201
224
  }
202
- if (0 === nodeIds.length && sourcePath && Editor.hasPath(editor, sourcePath)) return void Transforms.moveNodes(editor, {
203
- at: sourcePath,
204
- to: dropPath
205
- });
206
- Transforms.moveNodes(editor, {
207
- at: [],
208
- match: (n)=>{
209
- if (!Editor.isBlock(editor, n)) return false;
210
- if (Editor.isEditor(n)) return false;
211
- const node = n;
212
- const shouldMove = 'string' == typeof node.nodeId && nodeIds.includes(node.nodeId);
213
- return shouldMove;
214
- },
215
- to: dropPath,
216
- mode: 'highest'
217
- });
218
225
  });
219
226
  Editor.normalize(editor);
220
227
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-kona/editor",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -1,17 +1,24 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import '@testing-library/jest-dom/vitest';
4
- import { act, render, waitFor } from '@testing-library/react';
4
+ import {
5
+ act,
6
+ fireEvent,
7
+ render,
8
+ waitFor,
9
+ within,
10
+ } from '@testing-library/react';
5
11
  import { createDragDropManager, type DragDropManager } from 'dnd-core';
6
12
  import React from 'react';
7
13
  import { DndProvider } from 'react-dnd';
8
14
  import type { Editor } from 'slate';
9
- import { createEditor } from 'slate';
15
+ import { createEditor, Node, Editor as SlateEditor } from 'slate';
10
16
  import { describe, expect, it, vi } from 'vitest';
11
17
  import { KonaEditor } from '../../editor';
12
18
  import type { IPlugin } from '../../types';
13
19
  import { CollapsibleBlocksPlugin } from '../CollapsibleBlocksPlugin';
14
20
  import { HeadingsPlugin } from '../HeadingsPlugin';
21
+ import { ListsPlugin } from '../ListsPlugin';
15
22
  import { DnDPlugin, moveNode } from './DnDPlugin';
16
23
 
17
24
  Object.assign(globalThis, { React });
@@ -61,6 +68,181 @@ const sectionEditor = () => {
61
68
  };
62
69
 
63
70
  describe('moveNode', () => {
71
+ const paragraph = (text: string) => ({
72
+ type: 'paragraph',
73
+ nodeId: text,
74
+ children: [{ text }],
75
+ });
76
+
77
+ const directions = [
78
+ { direction: 'down', initial: ['A', 'X', 'B', 'Y'], source: 0, target: 2 },
79
+ { direction: 'up', initial: ['X', 'B', 'Y', 'A'], source: 3, target: 1 },
80
+ ];
81
+
82
+ describe.each(['top', 'bottom'] as const)('drop at %s', (position) => {
83
+ it.each(directions)(
84
+ 'moves one block $direction using IDs or a source path',
85
+ ({ initial, source, target }) => {
86
+ for (const useIds of [true, false]) {
87
+ const editor = createEditor();
88
+ editor.children = initial.map(paragraph);
89
+
90
+ moveNode(editor, [target], useIds ? ['A'] : [], position, [source]);
91
+
92
+ expect(editor.children.map(Node.string)).toEqual(
93
+ position === 'top' ? ['X', 'A', 'B', 'Y'] : ['X', 'B', 'A', 'Y'],
94
+ );
95
+ }
96
+ },
97
+ );
98
+
99
+ it.each([
100
+ { name: 'down', initial: ['A', 'C', 'X', 'B', 'Y'] },
101
+ { name: 'up', initial: ['X', 'B', 'Y', 'A', 'C'] },
102
+ { name: 'noncontiguous', initial: ['A', 'X', 'C', 'B', 'Y'] },
103
+ { name: 'across the target', initial: ['A', 'X', 'B', 'Y', 'C'] },
104
+ ])('moves a selection $name in document order', ({ initial }) => {
105
+ const editor = createEditor();
106
+ editor.children = initial.map(paragraph);
107
+
108
+ moveNode(editor, [initial.indexOf('B')], ['C', 'A'], position);
109
+
110
+ expect(editor.children.map(Node.string)).toEqual(
111
+ position === 'top'
112
+ ? ['X', 'A', 'C', 'B', 'Y']
113
+ : ['X', 'B', 'A', 'C', 'Y'],
114
+ );
115
+ });
116
+
117
+ it.each([true, false])(
118
+ 'moves a section in both directions (collapsed=%s)',
119
+ (collapsed) => {
120
+ for (const direction of ['down', 'up']) {
121
+ const editor = sectionEditor();
122
+ const section = editor.children.slice(0, 4);
123
+ Object.assign(section[0], { collapsed });
124
+ const surrounding = ['X', 'B', 'Y'].map(paragraph);
125
+ editor.children =
126
+ direction === 'down'
127
+ ? [...section, ...surrounding]
128
+ : [...surrounding, ...section];
129
+ const start = direction === 'down' ? 0 : 3;
130
+
131
+ moveNode(
132
+ editor,
133
+ [direction === 'down' ? 5 : 1],
134
+ [],
135
+ position,
136
+ undefined,
137
+ [0, 1, 2, 3].map((index) => [start + index]),
138
+ );
139
+
140
+ expect(editor.children).toEqual(
141
+ position === 'top'
142
+ ? [surrounding[0], ...section, surrounding[1], surrounding[2]]
143
+ : [surrounding[0], surrounding[1], ...section, surrounding[2]],
144
+ );
145
+ }
146
+ },
147
+ );
148
+
149
+ it.each(directions)(
150
+ 'reorders items $direction within a nested list',
151
+ ({ initial, source, target }) => {
152
+ const editor = new ListsPlugin().init(createEditor());
153
+ const items = initial.map((text) => ({
154
+ ...paragraph(text),
155
+ type: 'li',
156
+ }));
157
+ editor.children = [
158
+ {
159
+ type: 'ul',
160
+ children: [
161
+ { type: 'li', children: [{ text: 'Parent' }] },
162
+ { type: 'ul', children: items },
163
+ { type: 'li', children: [{ text: 'Tail' }] },
164
+ ],
165
+ },
166
+ ];
167
+
168
+ moveNode(editor, [0, 1, target], ['A'], position, [0, 1, source]);
169
+
170
+ expect(
171
+ Array.from(Node.children(editor, [0, 1]), ([node]) =>
172
+ Node.string(node),
173
+ ),
174
+ ).toEqual(
175
+ position === 'top' ? ['X', 'A', 'B', 'Y'] : ['X', 'B', 'A', 'Y'],
176
+ );
177
+ expect(Node.string(Node.get(editor, [0, 0]))).toBe('Parent');
178
+ expect(Node.string(Node.get(editor, [0, 2]))).toBe('Tail');
179
+ },
180
+ );
181
+
182
+ it.each(['into', 'out of'])(
183
+ 'moves an item %s a nested list',
184
+ (direction) => {
185
+ const editor = new ListsPlugin().init(createEditor());
186
+ const item = (text: string) => ({ ...paragraph(text), type: 'li' });
187
+ editor.children = [
188
+ {
189
+ type: 'ul',
190
+ children: [
191
+ item('A'),
192
+ { type: 'ul', children: [item('B'), item('C')] },
193
+ item('D'),
194
+ ],
195
+ },
196
+ ];
197
+
198
+ if (direction === 'into') {
199
+ moveNode(editor, [0, 1, 0], ['A'], position);
200
+ expect(editor.children).toEqual([
201
+ {
202
+ type: 'ul',
203
+ children: [
204
+ {
205
+ type: 'ul',
206
+ children: (position === 'top'
207
+ ? ['A', 'B', 'C']
208
+ : ['B', 'A', 'C']
209
+ ).map(item),
210
+ },
211
+ item('D'),
212
+ ],
213
+ },
214
+ ]);
215
+ } else {
216
+ moveNode(editor, [0, 2], ['B'], position);
217
+ const nested = { type: 'ul', children: [item('C')] };
218
+ expect(editor.children).toEqual([
219
+ {
220
+ type: 'ul',
221
+ children:
222
+ position === 'top'
223
+ ? [item('A'), nested, item('B'), item('D')]
224
+ : [item('A'), nested, item('D'), item('B')],
225
+ },
226
+ ]);
227
+ }
228
+ },
229
+ );
230
+ });
231
+
232
+ it.each(['top', 'bottom'] as const)(
233
+ 'does not move a selection onto itself at %s',
234
+ (position) => {
235
+ const editor = createEditor();
236
+ editor.children = ['A', 'B', 'C'].map(paragraph);
237
+ const original = structuredClone(editor.children);
238
+
239
+ moveNode(editor, [1], ['A', 'B'], position);
240
+
241
+ expect(editor.children).toEqual(original);
242
+ expect(SlateEditor.pathRefs(editor).size).toBe(0);
243
+ },
244
+ );
245
+
64
246
  it('moves a list item before another list item', () => {
65
247
  const editor = listEditor();
66
248
 
@@ -149,99 +331,181 @@ const getHandlerIds = (
149
331
  };
150
332
 
151
333
  describe('DnDPlugin', () => {
152
- it('reorders rendered paragraphs when dropped before another block', async () => {
153
- const manager = createDndManager();
154
- const onChange = vi.fn();
155
- let editor: Editor | undefined;
156
-
157
- const dndPlugin = new DnDPlugin({
158
- onDropFiles: () => {},
159
- renderBlock: ({ dragRef, dropRef, previewRef, props }) =>
160
- React.createElement(
161
- 'div',
162
- {
163
- ...props.attributes,
164
- ref: (element) => dropRef(previewRef(element)),
165
- },
334
+ it.each([
335
+ {
336
+ selected: [],
337
+ direction: 'down',
338
+ source: 0,
339
+ target: 2,
340
+ y: 0,
341
+ expected: ['Second', 'First', 'Third', 'Fourth'],
342
+ },
343
+ {
344
+ selected: [],
345
+ direction: 'up',
346
+ source: 3,
347
+ target: 1,
348
+ y: 0,
349
+ expected: ['First', 'Fourth', 'Second', 'Third'],
350
+ },
351
+ {
352
+ selected: [],
353
+ direction: 'down',
354
+ source: 0,
355
+ target: 2,
356
+ y: 100,
357
+ expected: ['Second', 'Third', 'First', 'Fourth'],
358
+ },
359
+ {
360
+ selected: [],
361
+ direction: 'up',
362
+ source: 3,
363
+ target: 1,
364
+ y: 100,
365
+ expected: ['First', 'Second', 'Fourth', 'Third'],
366
+ },
367
+ {
368
+ selected: [0, 1],
369
+ direction: 'down',
370
+ source: 0,
371
+ target: 3,
372
+ y: 0,
373
+ expected: ['Third', 'First', 'Second', 'Fourth'],
374
+ },
375
+ {
376
+ selected: [2, 3],
377
+ direction: 'up',
378
+ source: 3,
379
+ target: 0,
380
+ y: 100,
381
+ expected: ['First', 'Third', 'Fourth', 'Second'],
382
+ },
383
+ ])(
384
+ 'reorders rendered paragraphs $direction at y=$y',
385
+ async ({ selected, source, target, y, expected }) => {
386
+ const manager = createDndManager();
387
+ const onChange = vi.fn();
388
+ let editor: Editor | undefined;
389
+
390
+ const dndPlugin = new DnDPlugin({
391
+ onDropFiles: () => {},
392
+ renderBlock: ({
393
+ dragRef,
394
+ dropRef,
395
+ previewRef,
396
+ props,
397
+ onToggleSelected,
398
+ }) =>
166
399
  React.createElement(
167
- 'button',
168
- { contentEditable: false, ref: dragRef, type: 'button' },
169
- 'Drag',
170
- ),
171
- props.children,
172
- ),
173
- });
174
-
175
- render(
176
- React.createElement(
177
- DndProvider,
178
- { manager },
179
- React.createElement(KonaEditor, {
180
- initialValue: [
181
- { type: 'paragraph', children: [{ text: 'First' }] },
182
- { type: 'paragraph', children: [{ text: 'Second' }] },
183
- ],
184
- plugins: [
185
- dndPlugin,
400
+ 'div',
186
401
  {
187
- init(value) {
188
- editor = value;
189
- return value;
402
+ ...props.attributes,
403
+ ref: (element) => dropRef(previewRef(element)),
404
+ },
405
+ React.createElement(
406
+ 'button',
407
+ { contentEditable: false, ref: dragRef, type: 'button' },
408
+ 'Drag',
409
+ ),
410
+ React.createElement(
411
+ 'button',
412
+ {
413
+ type: 'button',
414
+ contentEditable: false,
415
+ onClick: onToggleSelected,
190
416
  },
191
- } satisfies IPlugin,
192
- ],
193
- onChange,
194
- }),
195
- ),
196
- );
197
-
198
- const originalRect = HTMLElement.prototype.getBoundingClientRect;
199
- Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
200
- configurable: true,
201
- value: () => ({ bottom: 100, top: 0 }),
202
- });
417
+ 'Select',
418
+ ),
419
+ props.children,
420
+ ),
421
+ });
203
422
 
204
- try {
205
- const sourceIds = getHandlerIds(manager, 'dragSources');
206
- const targetIds = getHandlerIds(manager, 'dropTargets');
423
+ const view = render(
424
+ React.createElement(
425
+ DndProvider,
426
+ { manager },
427
+ React.createElement(KonaEditor, {
428
+ initialValue: [
429
+ {
430
+ type: 'paragraph',
431
+ nodeId: 'First',
432
+ children: [{ text: 'First' }],
433
+ },
434
+ {
435
+ type: 'paragraph',
436
+ nodeId: 'Second',
437
+ children: [{ text: 'Second' }],
438
+ },
439
+ {
440
+ type: 'paragraph',
441
+ nodeId: 'Third',
442
+ children: [{ text: 'Third' }],
443
+ },
444
+ {
445
+ type: 'paragraph',
446
+ nodeId: 'Fourth',
447
+ children: [{ text: 'Fourth' }],
448
+ },
449
+ ],
450
+ plugins: [
451
+ dndPlugin,
452
+ {
453
+ init(value) {
454
+ editor = value;
455
+ return value;
456
+ },
457
+ } satisfies IPlugin,
458
+ ],
459
+ onChange,
460
+ }),
461
+ ),
462
+ );
207
463
 
208
- expect(sourceIds).toHaveLength(2);
209
- expect(targetIds).toHaveLength(2);
464
+ for (const index of selected) {
465
+ fireEvent.click(within(view.container).getAllByText('Select')[index]);
466
+ }
210
467
 
211
- act(() => {
212
- manager.getActions().beginDrag([sourceIds[1]], {
213
- clientOffset: { x: 0, y: 50 },
214
- getSourceClientOffset: () => ({ x: 0, y: 0 }),
215
- });
216
- manager.getActions().publishDragSource();
217
- manager
218
- .getActions()
219
- .hover([targetIds[0]], { clientOffset: { x: 0, y: 0 } });
220
- expect(manager.getMonitor().canDropOnTarget(targetIds[0])).toBe(true);
221
- manager.getActions().drop();
222
- manager.getActions().endDrag();
223
- });
224
- } finally {
468
+ const originalRect = HTMLElement.prototype.getBoundingClientRect;
225
469
  Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
226
470
  configurable: true,
227
- value: originalRect,
471
+ value: () => ({ bottom: 100, top: 0 }),
228
472
  });
229
- }
230
473
 
231
- expect(editor?.children).toEqual([
232
- expect.objectContaining({
233
- type: 'paragraph',
234
- children: [{ text: 'Second' }],
235
- }),
236
- expect.objectContaining({
237
- type: 'paragraph',
238
- children: [{ text: 'First' }],
239
- }),
240
- ]);
241
- await waitFor(() =>
242
- expect(onChange).toHaveBeenLastCalledWith(editor?.children),
243
- );
244
- });
474
+ try {
475
+ const sourceIds = getHandlerIds(manager, 'dragSources');
476
+ const targetIds = getHandlerIds(manager, 'dropTargets');
477
+
478
+ expect(sourceIds).toHaveLength(4);
479
+ expect(targetIds).toHaveLength(4);
480
+
481
+ await act(async () => {
482
+ manager.getActions().beginDrag([sourceIds[source]], {
483
+ clientOffset: { x: 0, y: 50 },
484
+ getSourceClientOffset: () => ({ x: 0, y: 0 }),
485
+ });
486
+ manager.getActions().publishDragSource();
487
+ manager
488
+ .getActions()
489
+ .hover([targetIds[target]], { clientOffset: { x: 0, y } });
490
+ expect(manager.getMonitor().canDropOnTarget(targetIds[target])).toBe(
491
+ true,
492
+ );
493
+ manager.getActions().drop();
494
+ manager.getActions().endDrag();
495
+ });
496
+ } finally {
497
+ Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
498
+ configurable: true,
499
+ value: originalRect,
500
+ });
501
+ }
502
+
503
+ expect(editor?.children.map(Node.string)).toEqual(expected);
504
+ await waitFor(() =>
505
+ expect(onChange).toHaveBeenLastCalledWith(editor?.children),
506
+ );
507
+ },
508
+ );
245
509
 
246
510
  it('moves every block in an expanded collapsible section', async () => {
247
511
  const manager = createDndManager();
@@ -9,7 +9,6 @@ import {
9
9
  useReadOnly,
10
10
  useSlate,
11
11
  } from 'slate-react';
12
- import type { CustomElement } from '../../../types';
13
12
  import { useEditorContext } from '../../provider';
14
13
  import type { IPlugin } from '../../types';
15
14
  import {
@@ -329,55 +328,71 @@ export const moveNode = (
329
328
  return;
330
329
  }
331
330
 
332
- if (sectionPaths?.some((path) => Path.equals(path, targetNodePath))) {
331
+ const explicitPaths = sectionPaths?.length
332
+ ? sectionPaths
333
+ : nodeIds.length === 0 && sourcePath
334
+ ? [sourcePath]
335
+ : undefined;
336
+ const explicitNodes =
337
+ explicitPaths &&
338
+ new Set(
339
+ explicitPaths
340
+ .filter((path) => Editor.hasPath(editor, path))
341
+ .map((path) => Editor.node(editor, path)[0]),
342
+ );
343
+ const paths = Array.from(
344
+ Editor.nodes(editor, {
345
+ at: [],
346
+ match: (n) => {
347
+ if (explicitNodes) return explicitNodes.has(n);
348
+ return (
349
+ Element.isElement(n) &&
350
+ Editor.isBlock(editor, n) &&
351
+ typeof (n as NodeWithId).nodeId === 'string' &&
352
+ nodeIds.includes((n as NodeWithId).nodeId)
353
+ );
354
+ },
355
+ mode: 'highest',
356
+ }),
357
+ ([, path]) => path,
358
+ );
359
+
360
+ // A selected target (or its ancestor) cannot also anchor the move.
361
+ if (
362
+ paths.some(
363
+ (path) =>
364
+ Path.equals(path, targetNodePath) ||
365
+ Path.isAncestor(path, targetNodePath),
366
+ )
367
+ ) {
333
368
  return;
334
369
  }
335
370
 
336
- Editor.withoutNormalizing(editor, () => {
337
- if (sectionPaths?.length) {
338
- const sectionNodes = new Set(
339
- sectionPaths
340
- .filter((path) => Editor.hasPath(editor, path))
341
- .map((path) => Editor.node(editor, path)[0]),
342
- );
343
-
344
- Transforms.moveNodes(editor, {
345
- at: [],
346
- match: (node) => sectionNodes.has(node),
347
- to: dropPath,
348
- mode: 'highest',
349
- });
350
- return;
351
- }
352
-
353
- if (
354
- nodeIds.length === 0 &&
355
- sourcePath &&
356
- Editor.hasPath(editor, sourcePath)
357
- ) {
358
- Transforms.moveNodes(editor, { at: sourcePath, to: dropPath });
359
- return;
360
- }
361
-
362
- Transforms.moveNodes(editor, {
363
- at: [],
364
- match: (n) => {
365
- if (!Editor.isBlock(editor, n as CustomElement)) {
366
- return false;
367
- }
371
+ const targetRef = Editor.pathRef(editor, targetNodePath);
372
+ const sourceRefs = paths.map((path) => Editor.pathRef(editor, path));
368
373
 
369
- if (Editor.isEditor(n)) {
370
- return false;
374
+ Editor.withoutNormalizing(editor, () => {
375
+ try {
376
+ // Keep every move anchored to the target as earlier moves shift paths.
377
+ // Inserting after the target runs in reverse to preserve document order.
378
+ const orderedRefs =
379
+ position === 'top' ? sourceRefs : [...sourceRefs].reverse();
380
+ for (const sourceRef of orderedRefs) {
381
+ const from = sourceRef.current;
382
+ const target = targetRef.current;
383
+ if (!from || !target) continue;
384
+
385
+ let to = position === 'top' ? target : Path.next(target);
386
+ // Slate's sibling destination is the index after removing the source.
387
+ if (Path.isSibling(from, to) && Path.isBefore(from, to)) {
388
+ to = Path.previous(to);
371
389
  }
372
-
373
- const node = n as NodeWithId;
374
- const shouldMove =
375
- typeof node.nodeId === 'string' && nodeIds.includes(node.nodeId);
376
- return shouldMove;
377
- },
378
- to: dropPath,
379
- mode: 'highest',
380
- });
390
+ Transforms.moveNodes(editor, { at: from, to });
391
+ }
392
+ } finally {
393
+ targetRef.unref();
394
+ for (const ref of sourceRefs) ref.unref();
395
+ }
381
396
  });
382
397
  Editor.normalize(editor);
383
398
  };