@use-kona/editor 0.1.31 → 0.1.33
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 +3 -4
- package/dist/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.js +15 -0
- package/package.json +1 -1
- package/src/core/createEditable.spec.tsx +87 -0
- package/src/core/createEditable.tsx +10 -4
- package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.spec.tsx +139 -1
- package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.tsx +29 -0
|
@@ -3,7 +3,7 @@ import { useStore } from "@nanostores/react";
|
|
|
3
3
|
import is_hotkey from "is-hotkey";
|
|
4
4
|
import { atom } from "nanostores";
|
|
5
5
|
import react, { isValidElement, useCallback, useState } from "react";
|
|
6
|
-
import { Editor, Element, Path, Transforms } from "slate";
|
|
6
|
+
import { Editor, Element, Path, Range, Transforms } from "slate";
|
|
7
7
|
import { Editable, ReactEditor, useReadOnly } from "slate-react";
|
|
8
8
|
import { BaseElement } from "../elements/BaseElement.js";
|
|
9
9
|
import { Block } from "../elements/Block.js";
|
|
@@ -128,11 +128,10 @@ const createEditable = (editor, plugins)=>({ readOnly })=>{
|
|
|
128
128
|
at: editor.selection?.focus,
|
|
129
129
|
match: (n)=>Element.isElement(n) && Editor.isBlock(editor, n)
|
|
130
130
|
});
|
|
131
|
-
if (!currentBlock) return;
|
|
132
|
-
if (editor.selection?.focus.offset !== 0) return;
|
|
131
|
+
if (!currentBlock || !editor.selection || !Range.isCollapsed(editor.selection) || !Editor.isStart(editor, editor.selection.focus, currentBlock[1])) return;
|
|
133
132
|
const prevBlock = Editor.previous(editor, {
|
|
134
133
|
at: currentBlock[1],
|
|
135
|
-
match: (n)=>Element.isElement(n),
|
|
134
|
+
match: (n)=>Element.isElement(n) && Editor.isBlock(editor, n),
|
|
136
135
|
voids: true
|
|
137
136
|
});
|
|
138
137
|
if (prevBlock && Editor.isVoid(editor, prevBlock[0])) {
|
|
@@ -11,6 +11,21 @@ class CollapsibleBlocksPlugin {
|
|
|
11
11
|
}
|
|
12
12
|
init(editor) {
|
|
13
13
|
sectionTypesByEditor.set(editor, this.types);
|
|
14
|
+
const { insertBreak } = editor;
|
|
15
|
+
editor.insertBreak = ()=>{
|
|
16
|
+
const { selection } = editor;
|
|
17
|
+
const boundary = selection && Range.isCollapsed(selection) ? Editor.above(editor, {
|
|
18
|
+
match: (node, path)=>1 === path.length && Element.isElement(node) && this.types.includes(node.type) && true === node.collapsed
|
|
19
|
+
}) : void 0;
|
|
20
|
+
Editor.withoutNormalizing(editor, ()=>{
|
|
21
|
+
if (boundary) Transforms.setNodes(editor, {
|
|
22
|
+
collapsed: false
|
|
23
|
+
}, {
|
|
24
|
+
at: boundary[1]
|
|
25
|
+
});
|
|
26
|
+
insertBreak();
|
|
27
|
+
});
|
|
28
|
+
};
|
|
14
29
|
return editor;
|
|
15
30
|
}
|
|
16
31
|
renderBlock = (props)=>{
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { Node, Transforms } from 'slate';
|
|
4
|
+
import { ReactEditor, Slate } from 'slate-react';
|
|
5
|
+
import { afterEach, expect, it } from 'vitest';
|
|
6
|
+
import type { IPlugin } from '../types';
|
|
7
|
+
import { createEditable } from './createEditable';
|
|
8
|
+
import { createEditor } from './createEditor';
|
|
9
|
+
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
it.each([
|
|
13
|
+
{
|
|
14
|
+
name: 'after an inline void deletes only the chip',
|
|
15
|
+
point: { path: [1, 2], offset: 0 },
|
|
16
|
+
text: ['Previous link.', 'Keep this text '],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'at a paragraph start merges text rather than selecting a preceding inline void',
|
|
20
|
+
point: { path: [1, 0], offset: 0 },
|
|
21
|
+
text: ['Previous link.Keep this text delete me'],
|
|
22
|
+
},
|
|
23
|
+
])('Backspace $name', async ({ point, text }) => {
|
|
24
|
+
const plugin: IPlugin = {
|
|
25
|
+
blocks: [
|
|
26
|
+
{
|
|
27
|
+
type: 'chip',
|
|
28
|
+
isInline: true,
|
|
29
|
+
isVoid: true,
|
|
30
|
+
render: (props) => (
|
|
31
|
+
<span {...props.attributes}>
|
|
32
|
+
{props.children}
|
|
33
|
+
<span contentEditable={false}>Chip</span>
|
|
34
|
+
</span>
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
leafs: [
|
|
39
|
+
{
|
|
40
|
+
render: (props) => <span {...props.attributes}>{props.children}</span>,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const editor = createEditor([plugin])();
|
|
45
|
+
const Editable = createEditable(editor, [plugin]);
|
|
46
|
+
const value = [
|
|
47
|
+
{
|
|
48
|
+
type: 'paragraph',
|
|
49
|
+
children: [
|
|
50
|
+
{ text: 'Previous ' },
|
|
51
|
+
{ type: 'chip', children: [{ text: 'link' }] },
|
|
52
|
+
{ text: '.' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'paragraph',
|
|
57
|
+
children: [
|
|
58
|
+
{ text: 'Keep this text ' },
|
|
59
|
+
{ type: 'chip', children: [{ text: 'delete me' }] },
|
|
60
|
+
{ text: '' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<Slate editor={editor} initialValue={value}>
|
|
66
|
+
<Editable />
|
|
67
|
+
</Slate>,
|
|
68
|
+
);
|
|
69
|
+
const editable = container.querySelector<HTMLElement>('[data-slate-editor]')!;
|
|
70
|
+
// jsdom does not implement this browser property, which Slate checks for keyboard events.
|
|
71
|
+
Object.defineProperty(editable, 'isContentEditable', { value: true });
|
|
72
|
+
await act(async () => {
|
|
73
|
+
Transforms.select(editor, point);
|
|
74
|
+
ReactEditor.focus(editor);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await act(async () => {
|
|
78
|
+
const useDefault = fireEvent.keyDown(editable, {
|
|
79
|
+
key: 'Backspace',
|
|
80
|
+
keyCode: 8,
|
|
81
|
+
});
|
|
82
|
+
// jsdom does not dispatch the browser's beforeinput deletion.
|
|
83
|
+
if (useDefault) await editor.deleteBackward('character');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(editor.children.map(Node.string)).toEqual(text);
|
|
87
|
+
});
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
Element,
|
|
19
19
|
type NodeEntry,
|
|
20
20
|
Path,
|
|
21
|
+
Range,
|
|
21
22
|
Transforms,
|
|
22
23
|
} from 'slate';
|
|
23
24
|
import {
|
|
@@ -192,12 +193,17 @@ export const createEditable =
|
|
|
192
193
|
match: (n) => Element.isElement(n) && Editor.isBlock(editor, n),
|
|
193
194
|
});
|
|
194
195
|
|
|
195
|
-
if (
|
|
196
|
-
|
|
196
|
+
if (
|
|
197
|
+
!currentBlock ||
|
|
198
|
+
!editor.selection ||
|
|
199
|
+
!Range.isCollapsed(editor.selection) ||
|
|
200
|
+
!Editor.isStart(editor, editor.selection.focus, currentBlock[1])
|
|
201
|
+
)
|
|
202
|
+
return;
|
|
197
203
|
|
|
198
|
-
const prevBlock = Editor.previous(editor, {
|
|
204
|
+
const prevBlock = Editor.previous<CustomElement>(editor, {
|
|
199
205
|
at: currentBlock[1],
|
|
200
|
-
match: (n) => Element.isElement(n),
|
|
206
|
+
match: (n) => Element.isElement(n) && Editor.isBlock(editor, n),
|
|
201
207
|
voids: true,
|
|
202
208
|
});
|
|
203
209
|
|
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
waitFor,
|
|
10
10
|
} from '@testing-library/react';
|
|
11
11
|
import React from 'react';
|
|
12
|
-
import {
|
|
12
|
+
import { Editor, Transforms } from 'slate';
|
|
13
13
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { CustomElement } from '../../../types';
|
|
14
15
|
import { KonaEditor } from '../../editor';
|
|
15
16
|
import type { IPlugin } from '../../types';
|
|
16
17
|
import { BasicFormattingPlugin } from '../BasicFormattingPlugin';
|
|
18
|
+
import { BreaksPlugin } from '../BreaksPlugin';
|
|
17
19
|
import { HeadingsPlugin } from '../HeadingsPlugin';
|
|
18
20
|
import { CollapsibleBlocksPlugin } from './CollapsibleBlocksPlugin';
|
|
19
21
|
|
|
@@ -214,3 +216,139 @@ describe('CollapsibleBlocksPlugin', () => {
|
|
|
214
216
|
});
|
|
215
217
|
});
|
|
216
218
|
});
|
|
219
|
+
|
|
220
|
+
describe('Enter in collapsed sections', () => {
|
|
221
|
+
const headingTypes = [
|
|
222
|
+
HeadingsPlugin.HeadingLevel1,
|
|
223
|
+
HeadingsPlugin.HeadingLevel2,
|
|
224
|
+
HeadingsPlugin.HeadingLevel3,
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const renderEditor = (value: CustomElement[]) => {
|
|
228
|
+
let editor: Editor | undefined;
|
|
229
|
+
const capturePlugin: IPlugin = {
|
|
230
|
+
init(value) {
|
|
231
|
+
editor = value;
|
|
232
|
+
return value;
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const { container, unmount } = render(
|
|
236
|
+
<KonaEditor
|
|
237
|
+
initialValue={value}
|
|
238
|
+
plugins={[
|
|
239
|
+
...plugins(),
|
|
240
|
+
new BreaksPlugin({ breakNodes: headingTypes }),
|
|
241
|
+
capturePlugin,
|
|
242
|
+
]}
|
|
243
|
+
onChange={() => {}}
|
|
244
|
+
/>,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (!editor) throw new Error('Expected editor to be initialized');
|
|
248
|
+
|
|
249
|
+
return { editor, container, cleanup: unmount };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it.each(headingTypes)(
|
|
253
|
+
'expands collapsed %s when Enter creates a paragraph after it',
|
|
254
|
+
async (type) => {
|
|
255
|
+
const { editor, container, cleanup } = renderEditor([
|
|
256
|
+
{
|
|
257
|
+
type,
|
|
258
|
+
collapsed: true,
|
|
259
|
+
children: [{ text: 'Heading' }],
|
|
260
|
+
},
|
|
261
|
+
{ type: 'paragraph', children: [{ text: 'Existing content' }] },
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
expect(container.querySelector('[hidden]')).not.toBeNull();
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
Transforms.select(editor, Editor.end(editor, [0]));
|
|
269
|
+
editor.insertBreak();
|
|
270
|
+
editor.insertText('New content');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(editor.children[0]).toMatchObject({ collapsed: false });
|
|
274
|
+
expect(editor.children[1]).toMatchObject({
|
|
275
|
+
type: 'paragraph',
|
|
276
|
+
children: [{ text: 'New content' }],
|
|
277
|
+
});
|
|
278
|
+
expect(container.querySelector('[hidden]')).toBeNull();
|
|
279
|
+
expect(container.textContent).toContain('New content');
|
|
280
|
+
expect(
|
|
281
|
+
container
|
|
282
|
+
.querySelector('[aria-label="Collapse section"]')
|
|
283
|
+
?.getAttribute('aria-expanded'),
|
|
284
|
+
).toBe('true');
|
|
285
|
+
} finally {
|
|
286
|
+
cleanup();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
it('keeps a section collapsed when Enter inserts a paragraph before its heading', async () => {
|
|
292
|
+
const { editor, container, cleanup } = renderEditor([
|
|
293
|
+
{
|
|
294
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
295
|
+
collapsed: true,
|
|
296
|
+
children: [{ text: 'Heading' }],
|
|
297
|
+
},
|
|
298
|
+
{ type: 'paragraph', children: [{ text: 'Existing content' }] },
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await act(async () => {
|
|
303
|
+
Transforms.select(editor, Editor.start(editor, [0]));
|
|
304
|
+
editor.insertBreak();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(editor.children[0]).toMatchObject({ type: 'paragraph' });
|
|
308
|
+
expect(editor.children[1]).toMatchObject({ collapsed: true });
|
|
309
|
+
expect(container.querySelectorAll('[hidden]')).toHaveLength(1);
|
|
310
|
+
} finally {
|
|
311
|
+
cleanup();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('expands the edited section while preserving nested and adjacent collapsed sections', async () => {
|
|
316
|
+
const { editor, container, cleanup } = renderEditor([
|
|
317
|
+
{
|
|
318
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
319
|
+
collapsed: true,
|
|
320
|
+
children: [{ text: 'Heading' }],
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
type: HeadingsPlugin.HeadingLevel2,
|
|
324
|
+
collapsed: true,
|
|
325
|
+
children: [{ text: 'Nested heading' }],
|
|
326
|
+
},
|
|
327
|
+
{ type: 'paragraph', children: [{ text: 'Nested content' }] },
|
|
328
|
+
{
|
|
329
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
330
|
+
collapsed: true,
|
|
331
|
+
children: [{ text: 'Adjacent heading' }],
|
|
332
|
+
},
|
|
333
|
+
{ type: 'paragraph', children: [{ text: 'Adjacent content' }] },
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await act(async () => {
|
|
338
|
+
Transforms.select(editor, Editor.end(editor, [0]));
|
|
339
|
+
editor.insertBreak();
|
|
340
|
+
editor.insertText('New content');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(editor.children[0]).toMatchObject({ collapsed: false });
|
|
344
|
+
expect(editor.children[2]).toMatchObject({ collapsed: true });
|
|
345
|
+
expect(editor.children[4]).toMatchObject({ collapsed: true });
|
|
346
|
+
expect(container.querySelectorAll('[hidden]')).toHaveLength(2);
|
|
347
|
+
expect(
|
|
348
|
+
container.querySelectorAll('[aria-label="Expand section"]'),
|
|
349
|
+
).toHaveLength(2);
|
|
350
|
+
} finally {
|
|
351
|
+
cleanup();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -18,6 +18,35 @@ export class CollapsibleBlocksPlugin implements IPlugin {
|
|
|
18
18
|
|
|
19
19
|
init(editor: Editor) {
|
|
20
20
|
sectionTypesByEditor.set(editor, this.types);
|
|
21
|
+
|
|
22
|
+
const { insertBreak } = editor;
|
|
23
|
+
|
|
24
|
+
editor.insertBreak = () => {
|
|
25
|
+
const { selection } = editor;
|
|
26
|
+
const boundary =
|
|
27
|
+
selection && Range.isCollapsed(selection)
|
|
28
|
+
? Editor.above<CollapsibleElement>(editor, {
|
|
29
|
+
match: (node, path) =>
|
|
30
|
+
path.length === 1 &&
|
|
31
|
+
Element.isElement(node) &&
|
|
32
|
+
this.types.includes(node.type) &&
|
|
33
|
+
node.collapsed === true,
|
|
34
|
+
})
|
|
35
|
+
: undefined;
|
|
36
|
+
|
|
37
|
+
Editor.withoutNormalizing(editor, () => {
|
|
38
|
+
if (boundary) {
|
|
39
|
+
Transforms.setNodes<CollapsibleElement>(
|
|
40
|
+
editor,
|
|
41
|
+
{ collapsed: false },
|
|
42
|
+
{ at: boundary[1] },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
insertBreak();
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
return editor;
|
|
22
51
|
}
|
|
23
52
|
|