@use-kona/editor 0.1.29 → 0.1.30
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 +4 -1
- package/dist/plugins/CodeBlockPlugin/styles_module.css +1 -0
- package/dist/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.js +121 -0
- package/dist/plugins/CollapsibleBlocksPlugin/index.js +2 -0
- package/dist/plugins/CollapsibleBlocksPlugin/styles.module.js +8 -0
- package/dist/plugins/CollapsibleBlocksPlugin/styles_module.css +38 -0
- package/dist/plugins/DnDPlugin/DnDPlugin.js +60 -19
- package/dist/plugins/index.js +2 -1
- package/dist/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.d.ts +13 -0
- package/dist/src/plugins/CollapsibleBlocksPlugin/index.d.ts +1 -0
- package/dist/src/plugins/DnDPlugin/DnDPlugin.d.ts +2 -1
- package/dist/src/plugins/DnDPlugin/types.d.ts +1 -0
- package/dist/src/plugins/index.d.ts +1 -0
- package/dist/src/types.d.ts +5 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/core/createEditable.tsx +5 -1
- package/src/plugins/CodeBlockPlugin/styles.module.css +1 -0
- package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.spec.tsx +216 -0
- package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.tsx +191 -0
- package/src/plugins/CollapsibleBlocksPlugin/index.ts +1 -0
- package/src/plugins/CollapsibleBlocksPlugin/styles.module.css +37 -0
- package/src/plugins/DnDPlugin/DnDPlugin.spec.ts +346 -0
- package/src/plugins/DnDPlugin/DnDPlugin.tsx +117 -31
- package/src/plugins/DnDPlugin/types.ts +1 -0
- package/src/plugins/index.ts +1 -0
- package/src/types.ts +5 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
cleanup,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { type Editor, Transforms } from 'slate';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { KonaEditor } from '../../editor';
|
|
15
|
+
import type { IPlugin } from '../../types';
|
|
16
|
+
import { BasicFormattingPlugin } from '../BasicFormattingPlugin';
|
|
17
|
+
import { HeadingsPlugin } from '../HeadingsPlugin';
|
|
18
|
+
import { CollapsibleBlocksPlugin } from './CollapsibleBlocksPlugin';
|
|
19
|
+
|
|
20
|
+
Object.assign(globalThis, { React });
|
|
21
|
+
|
|
22
|
+
const initialValue = [
|
|
23
|
+
{ type: HeadingsPlugin.HeadingLevel1, children: [{ text: 'Heading 1' }] },
|
|
24
|
+
{ type: 'paragraph', children: [{ text: 'Hidden text' }] },
|
|
25
|
+
{ type: HeadingsPlugin.HeadingLevel2, children: [{ text: 'Heading 2' }] },
|
|
26
|
+
{ type: 'paragraph', children: [{ text: 'Nested text' }] },
|
|
27
|
+
{ type: HeadingsPlugin.HeadingLevel1, children: [{ text: 'Next heading' }] },
|
|
28
|
+
{ type: 'paragraph', children: [{ text: 'Visible text' }] },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const plugins = () => [
|
|
32
|
+
new BasicFormattingPlugin(),
|
|
33
|
+
new HeadingsPlugin(),
|
|
34
|
+
new CollapsibleBlocksPlugin([
|
|
35
|
+
HeadingsPlugin.HeadingLevel1,
|
|
36
|
+
HeadingsPlugin.HeadingLevel2,
|
|
37
|
+
HeadingsPlugin.HeadingLevel3,
|
|
38
|
+
]),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
afterEach(cleanup);
|
|
42
|
+
|
|
43
|
+
describe('CollapsibleBlocksPlugin', () => {
|
|
44
|
+
it('hides nested configured blocks until the next equal-or-higher boundary and persists the collapsed state', async () => {
|
|
45
|
+
const onChange = vi.fn();
|
|
46
|
+
|
|
47
|
+
render(
|
|
48
|
+
<KonaEditor
|
|
49
|
+
initialValue={initialValue}
|
|
50
|
+
plugins={plugins()}
|
|
51
|
+
onChange={onChange}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const [firstChevron] = screen.getAllByRole('button', {
|
|
56
|
+
name: 'Collapse section',
|
|
57
|
+
});
|
|
58
|
+
const hiddenText = screen.getByText('Hidden text');
|
|
59
|
+
|
|
60
|
+
fireEvent.click(firstChevron);
|
|
61
|
+
|
|
62
|
+
await waitFor(() => expect(hiddenText).not.toBeVisible());
|
|
63
|
+
expect(screen.getByText('Heading 2')).not.toBeVisible();
|
|
64
|
+
expect(screen.getByText('Nested text')).not.toBeVisible();
|
|
65
|
+
expect(screen.getByText('Next heading')).toBeVisible();
|
|
66
|
+
expect(screen.getByText('Visible text')).toBeVisible();
|
|
67
|
+
expect(
|
|
68
|
+
screen.getByRole('button', { name: 'Expand section' }),
|
|
69
|
+
).toHaveAttribute('aria-expanded', 'false');
|
|
70
|
+
expect(onChange).toHaveBeenLastCalledWith([
|
|
71
|
+
{
|
|
72
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
73
|
+
collapsed: true,
|
|
74
|
+
children: [{ text: 'Heading 1' }],
|
|
75
|
+
},
|
|
76
|
+
...initialValue.slice(1),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand section' }));
|
|
80
|
+
await waitFor(() => expect(screen.getByText('Hidden text')).toBeVisible());
|
|
81
|
+
expect(
|
|
82
|
+
screen.getAllByRole('button', { name: 'Collapse section' })[0],
|
|
83
|
+
).toHaveAttribute('aria-expanded', 'true');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders chevrons for adjacent and empty sections', () => {
|
|
87
|
+
render(
|
|
88
|
+
<KonaEditor
|
|
89
|
+
initialValue={[
|
|
90
|
+
{ type: HeadingsPlugin.HeadingLevel1, children: [{ text: 'One' }] },
|
|
91
|
+
{ type: HeadingsPlugin.HeadingLevel2, children: [{ text: 'Two' }] },
|
|
92
|
+
]}
|
|
93
|
+
plugins={plugins()}
|
|
94
|
+
onChange={() => {}}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(
|
|
99
|
+
screen.getAllByRole('button', { name: 'Collapse section' }),
|
|
100
|
+
).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('renders inside later block wrappers regardless of plugin array order', () => {
|
|
104
|
+
const wrapperPlugin: IPlugin = {
|
|
105
|
+
renderBlock: (props) => (
|
|
106
|
+
<div data-testid="block-wrapper">{props.children}</div>
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
render(
|
|
111
|
+
<KonaEditor
|
|
112
|
+
initialValue={[
|
|
113
|
+
{ type: HeadingsPlugin.HeadingLevel1, children: [{ text: 'One' }] },
|
|
114
|
+
]}
|
|
115
|
+
plugins={[wrapperPlugin, ...plugins()]}
|
|
116
|
+
onChange={() => {}}
|
|
117
|
+
/>,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(
|
|
121
|
+
screen
|
|
122
|
+
.getByRole('button', { name: 'Collapse section' })
|
|
123
|
+
.closest('[data-testid="block-wrapper"]'),
|
|
124
|
+
).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('keeps chevrons interactive in read-only editors', async () => {
|
|
128
|
+
const onChange = vi.fn();
|
|
129
|
+
|
|
130
|
+
render(
|
|
131
|
+
<KonaEditor
|
|
132
|
+
initialValue={initialValue}
|
|
133
|
+
plugins={plugins()}
|
|
134
|
+
readOnly
|
|
135
|
+
onChange={onChange}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
fireEvent.click(
|
|
140
|
+
screen.getAllByRole('button', { name: 'Collapse section' })[0],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
await waitFor(() =>
|
|
144
|
+
expect(screen.getByText('Hidden text')).not.toBeVisible(),
|
|
145
|
+
);
|
|
146
|
+
expect(onChange).toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('moves a hidden section selection back to its boundary', () => {
|
|
150
|
+
let editor: Editor | undefined;
|
|
151
|
+
const capturePlugin: IPlugin = {
|
|
152
|
+
init(value) {
|
|
153
|
+
editor = value;
|
|
154
|
+
return value;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
render(
|
|
159
|
+
<KonaEditor
|
|
160
|
+
initialValue={initialValue}
|
|
161
|
+
plugins={[...plugins(), capturePlugin]}
|
|
162
|
+
onChange={() => {}}
|
|
163
|
+
/>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!editor) {
|
|
167
|
+
throw new Error('Expected editor to be initialized');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
act(() => {
|
|
171
|
+
Transforms.select(editor, { path: [1, 0], offset: 2 });
|
|
172
|
+
});
|
|
173
|
+
fireEvent.click(
|
|
174
|
+
screen.getAllByRole('button', { name: 'Collapse section' })[0],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(editor.selection).toEqual({
|
|
178
|
+
anchor: { path: [0, 0], offset: 0 },
|
|
179
|
+
focus: { path: [0, 0], offset: 0 },
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps a selection in the next equal-level section in place', () => {
|
|
184
|
+
let editor: Editor | undefined;
|
|
185
|
+
const capturePlugin: IPlugin = {
|
|
186
|
+
init(value) {
|
|
187
|
+
editor = value;
|
|
188
|
+
return value;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
render(
|
|
193
|
+
<KonaEditor
|
|
194
|
+
initialValue={initialValue}
|
|
195
|
+
plugins={[...plugins(), capturePlugin]}
|
|
196
|
+
onChange={() => {}}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (!editor) {
|
|
201
|
+
throw new Error('Expected editor to be initialized');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
act(() => {
|
|
205
|
+
Transforms.select(editor, { path: [4, 0], offset: 2 });
|
|
206
|
+
});
|
|
207
|
+
fireEvent.click(
|
|
208
|
+
screen.getAllByRole('button', { name: 'Collapse section' })[0],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(editor.selection).toEqual({
|
|
212
|
+
anchor: { path: [4, 0], offset: 2 },
|
|
213
|
+
focus: { path: [4, 0], offset: 2 },
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Editor, Element, type Path, Range, Transforms } from 'slate';
|
|
3
|
+
import { ReactEditor, type RenderElementProps, useSlate } from 'slate-react';
|
|
4
|
+
import type { CustomElement } from '../../../types';
|
|
5
|
+
import type { IPlugin } from '../../types';
|
|
6
|
+
import styles from './styles.module.css';
|
|
7
|
+
|
|
8
|
+
type CollapsibleElement = CustomElement & {
|
|
9
|
+
collapsed?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sectionTypesByEditor = new WeakMap<Editor, string[]>();
|
|
13
|
+
|
|
14
|
+
export class CollapsibleBlocksPlugin implements IPlugin {
|
|
15
|
+
renderBlockPriority = -1;
|
|
16
|
+
|
|
17
|
+
constructor(private types: string[]) {}
|
|
18
|
+
|
|
19
|
+
init(editor: Editor) {
|
|
20
|
+
sectionTypesByEditor.set(editor, this.types);
|
|
21
|
+
return editor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
renderBlock = (props: RenderElementProps) => {
|
|
25
|
+
const editor = useSlate();
|
|
26
|
+
const path = ReactEditor.findPath(editor, props.element);
|
|
27
|
+
|
|
28
|
+
if (path.length !== 1) {
|
|
29
|
+
return props.children;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isHidden = isHiddenByCollapsedBoundary(editor, path, this.types);
|
|
33
|
+
|
|
34
|
+
if (isHidden) {
|
|
35
|
+
return (
|
|
36
|
+
<div className={styles.hidden} data-collapsible-block-hidden hidden>
|
|
37
|
+
{props.children}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (this.types.includes(props.element.type)) {
|
|
43
|
+
return (
|
|
44
|
+
<CollapsibleBoundary
|
|
45
|
+
editor={editor}
|
|
46
|
+
element={props.element as CollapsibleElement}
|
|
47
|
+
path={path}
|
|
48
|
+
types={this.types}
|
|
49
|
+
>
|
|
50
|
+
{props.children}
|
|
51
|
+
</CollapsibleBoundary>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return props.children;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CollapsibleBoundary = ({
|
|
60
|
+
children,
|
|
61
|
+
editor,
|
|
62
|
+
element,
|
|
63
|
+
path,
|
|
64
|
+
types,
|
|
65
|
+
}: {
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
editor: Editor;
|
|
68
|
+
element: CollapsibleElement;
|
|
69
|
+
path: Path;
|
|
70
|
+
types: string[];
|
|
71
|
+
}) => {
|
|
72
|
+
const isCollapsed = element.collapsed === true;
|
|
73
|
+
|
|
74
|
+
const toggle = () => {
|
|
75
|
+
const willCollapse = !isCollapsed;
|
|
76
|
+
|
|
77
|
+
Transforms.setNodes<CollapsibleElement>(
|
|
78
|
+
editor,
|
|
79
|
+
{ collapsed: willCollapse },
|
|
80
|
+
{ at: path },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (willCollapse && isSelectionInsideSection(editor, path, types)) {
|
|
84
|
+
Transforms.select(editor, Editor.start(editor, path));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={styles.boundary}>
|
|
90
|
+
<button
|
|
91
|
+
aria-expanded={!isCollapsed}
|
|
92
|
+
aria-label={isCollapsed ? 'Expand section' : 'Collapse section'}
|
|
93
|
+
className={
|
|
94
|
+
isCollapsed ? styles.chevron : `${styles.chevron} ${styles.expanded}`
|
|
95
|
+
}
|
|
96
|
+
contentEditable={false}
|
|
97
|
+
onClick={toggle}
|
|
98
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
99
|
+
type="button"
|
|
100
|
+
>
|
|
101
|
+
<svg
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
fill="none"
|
|
104
|
+
height="16"
|
|
105
|
+
stroke="currentColor"
|
|
106
|
+
strokeLinecap="round"
|
|
107
|
+
strokeLinejoin="round"
|
|
108
|
+
strokeWidth="1.5"
|
|
109
|
+
viewBox="0 0 24 24"
|
|
110
|
+
width="16"
|
|
111
|
+
>
|
|
112
|
+
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
|
|
113
|
+
<path d="M9 6l6 6l-6 6" />
|
|
114
|
+
</svg>
|
|
115
|
+
</button>
|
|
116
|
+
{children}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const getCollapsibleSectionPaths = (editor: Editor, path: Path) => {
|
|
122
|
+
const types = sectionTypesByEditor.get(editor);
|
|
123
|
+
if (!types || path.length !== 1) return null;
|
|
124
|
+
|
|
125
|
+
const boundary = editor.children[path[0]] as CollapsibleElement | undefined;
|
|
126
|
+
if (!boundary || !types.includes(boundary.type)) return null;
|
|
127
|
+
|
|
128
|
+
const end = getSectionEnd(editor, path[0], types);
|
|
129
|
+
return Array.from({ length: end - path[0] }, (_, index) => [path[0] + index]);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const isHiddenByCollapsedBoundary = (
|
|
133
|
+
editor: Editor,
|
|
134
|
+
path: Path,
|
|
135
|
+
types: string[],
|
|
136
|
+
) => {
|
|
137
|
+
for (let index = 0; index < path[0]; index++) {
|
|
138
|
+
const node = editor.children[index] as CollapsibleElement;
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
Element.isElement(node) &&
|
|
142
|
+
node.collapsed === true &&
|
|
143
|
+
types.includes(node.type) &&
|
|
144
|
+
path[0] < getSectionEnd(editor, index, types)
|
|
145
|
+
) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const isSelectionInsideSection = (
|
|
154
|
+
editor: Editor,
|
|
155
|
+
boundaryPath: Path,
|
|
156
|
+
types: string[],
|
|
157
|
+
) => {
|
|
158
|
+
const { selection } = editor;
|
|
159
|
+
|
|
160
|
+
if (!selection || !Range.isCollapsed(selection)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const selectionIndex = selection.focus.path[0];
|
|
165
|
+
|
|
166
|
+
if (selectionIndex <= boundaryPath[0]) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return selectionIndex < getSectionEnd(editor, boundaryPath[0], types);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const getSectionEnd = (
|
|
174
|
+
editor: Editor,
|
|
175
|
+
boundaryIndex: number,
|
|
176
|
+
types: string[],
|
|
177
|
+
) => {
|
|
178
|
+
const boundary = editor.children[boundaryIndex] as CollapsibleElement;
|
|
179
|
+
const boundaryLevel = types.indexOf(boundary.type);
|
|
180
|
+
|
|
181
|
+
for (let index = boundaryIndex + 1; index < editor.children.length; index++) {
|
|
182
|
+
const node = editor.children[index];
|
|
183
|
+
const nodeLevel = Element.isElement(node) ? types.indexOf(node.type) : -1;
|
|
184
|
+
|
|
185
|
+
if (nodeLevel !== -1 && nodeLevel <= boundaryLevel) {
|
|
186
|
+
return index;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return editor.children.length;
|
|
191
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CollapsibleBlocksPlugin } from './CollapsibleBlocksPlugin';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
.boundary {
|
|
2
|
+
position: relative;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.chevron {
|
|
6
|
+
align-items: center;
|
|
7
|
+
appearance: none;
|
|
8
|
+
border: 0;
|
|
9
|
+
background: transparent;
|
|
10
|
+
color: inherit;
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
display: flex;
|
|
13
|
+
height: 16px;
|
|
14
|
+
inset-inline-start: -20px;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
position: absolute;
|
|
17
|
+
top: 50%;
|
|
18
|
+
transform: translateY(-50%);
|
|
19
|
+
width: 16px;
|
|
20
|
+
font: inherit;
|
|
21
|
+
line-height: 1;
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
transition: transform 0.15s ease;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.expanded {
|
|
28
|
+
transform: translateY(-50%) rotate(90deg);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.chevron svg {
|
|
32
|
+
display: block;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.hidden {
|
|
36
|
+
display: none;
|
|
37
|
+
}
|