@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.
Files changed (27) hide show
  1. package/dist/core/createEditable.js +4 -1
  2. package/dist/plugins/CodeBlockPlugin/styles_module.css +1 -0
  3. package/dist/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.js +121 -0
  4. package/dist/plugins/CollapsibleBlocksPlugin/index.js +2 -0
  5. package/dist/plugins/CollapsibleBlocksPlugin/styles.module.js +8 -0
  6. package/dist/plugins/CollapsibleBlocksPlugin/styles_module.css +38 -0
  7. package/dist/plugins/DnDPlugin/DnDPlugin.js +60 -19
  8. package/dist/plugins/index.js +2 -1
  9. package/dist/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.d.ts +13 -0
  10. package/dist/src/plugins/CollapsibleBlocksPlugin/index.d.ts +1 -0
  11. package/dist/src/plugins/DnDPlugin/DnDPlugin.d.ts +2 -1
  12. package/dist/src/plugins/DnDPlugin/types.d.ts +1 -0
  13. package/dist/src/plugins/index.d.ts +1 -0
  14. package/dist/src/types.d.ts +5 -0
  15. package/dist/types.d.ts +1 -0
  16. package/package.json +1 -1
  17. package/src/core/createEditable.tsx +5 -1
  18. package/src/plugins/CodeBlockPlugin/styles.module.css +1 -0
  19. package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.spec.tsx +216 -0
  20. package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.tsx +191 -0
  21. package/src/plugins/CollapsibleBlocksPlugin/index.ts +1 -0
  22. package/src/plugins/CollapsibleBlocksPlugin/styles.module.css +37 -0
  23. package/src/plugins/DnDPlugin/DnDPlugin.spec.ts +346 -0
  24. package/src/plugins/DnDPlugin/DnDPlugin.tsx +117 -31
  25. package/src/plugins/DnDPlugin/types.ts +1 -0
  26. package/src/plugins/index.ts +1 -0
  27. 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
+ }