@alpaca-editor/core 1.0.4045 → 1.0.4048

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 (41) hide show
  1. package/dist/editor/field-types/RichTextEditorComponent.js +3 -10
  2. package/dist/editor/field-types/RichTextEditorComponent.js.map +1 -1
  3. package/dist/editor/field-types/richtext/components/ReactSlate.js +300 -342
  4. package/dist/editor/field-types/richtext/components/ReactSlate.js.map +1 -1
  5. package/dist/editor/field-types/richtext/components/SimpleRichTextEditor.js.map +1 -1
  6. package/dist/editor/field-types/richtext/components/SimpleToolbar.js +9 -9
  7. package/dist/editor/field-types/richtext/components/SimpleToolbar.js.map +1 -1
  8. package/dist/editor/field-types/richtext/config/pluginFactory.d.ts +7 -6
  9. package/dist/editor/field-types/richtext/config/pluginFactory.js +2 -1
  10. package/dist/editor/field-types/richtext/config/pluginFactory.js.map +1 -1
  11. package/dist/editor/field-types/richtext/hooks/useProfileCache.js +24 -18
  12. package/dist/editor/field-types/richtext/hooks/useProfileCache.js.map +1 -1
  13. package/dist/editor/field-types/richtext/hooks/useRichTextProfile.js +1 -1
  14. package/dist/editor/field-types/richtext/hooks/useRichTextProfile.js.map +1 -1
  15. package/dist/editor/field-types/richtext/types.d.ts +236 -90
  16. package/dist/editor/field-types/richtext/types.js +3 -3
  17. package/dist/editor/field-types/richtext/types.js.map +1 -1
  18. package/dist/editor/field-types/richtext/utils/conversion.d.ts +4 -2
  19. package/dist/editor/field-types/richtext/utils/conversion.js +79 -12
  20. package/dist/editor/field-types/richtext/utils/conversion.js.map +1 -1
  21. package/dist/editor/field-types/richtext/utils/plugins.d.ts +66 -39
  22. package/dist/editor/field-types/richtext/utils/plugins.js +377 -233
  23. package/dist/editor/field-types/richtext/utils/plugins.js.map +1 -1
  24. package/dist/editor/field-types/richtext/utils/profileMapper.js +22 -2
  25. package/dist/editor/field-types/richtext/utils/profileMapper.js.map +1 -1
  26. package/dist/revision.d.ts +2 -2
  27. package/dist/revision.js +2 -2
  28. package/package.json +1 -1
  29. package/src/editor/field-types/RichTextEditorComponent.tsx +4 -10
  30. package/src/editor/field-types/richtext/components/ReactSlate.css +85 -24
  31. package/src/editor/field-types/richtext/components/ReactSlate.tsx +375 -428
  32. package/src/editor/field-types/richtext/components/SimpleRichTextEditor.tsx +4 -2
  33. package/src/editor/field-types/richtext/components/SimpleToolbar.tsx +3 -3
  34. package/src/editor/field-types/richtext/config/pluginFactory.tsx +2 -1
  35. package/src/editor/field-types/richtext/hooks/useProfileCache.ts +25 -19
  36. package/src/editor/field-types/richtext/hooks/useRichTextProfile.ts +1 -1
  37. package/src/editor/field-types/richtext/types.ts +150 -112
  38. package/src/editor/field-types/richtext/utils/conversion.ts +100 -27
  39. package/src/editor/field-types/richtext/utils/plugins.ts +469 -268
  40. package/src/editor/field-types/richtext/utils/profileMapper.ts +26 -3
  41. package/src/revision.ts +2 -2
@@ -1,346 +1,547 @@
1
- import { Editor, Element, Transforms, Text } from 'slate';
2
- import { Alignment, LinkElement } from '../types';
3
-
4
- export const withMark = (editor: Editor) => {
5
- if (!editor.isMarkActive) {
6
- editor.isMarkActive = (format: string) => {
7
- const marks = Editor.marks(editor);
8
- return marks ? Boolean((marks as any)[format]) : false;
9
- };
1
+ import { Editor, Element, Transforms, Text, Path, Node, Range, Point } from "slate";
2
+ import React from "react";
3
+ import {
4
+ CustomElement,
5
+ LinkElement,
6
+ Alignment,
7
+ MarkId,
8
+ BlockId,
9
+ SLATE_MARKS,
10
+ SLATE_BLOCKS,
11
+ } from "../types";
12
+
13
+ // Hot key utility function
14
+ export const isHotkey = (hotkey: string, event: React.KeyboardEvent): boolean => {
15
+ const keys = hotkey.split('+');
16
+ const modKeys = keys.slice(0, -1);
17
+ const key = keys[keys.length - 1]?.toLowerCase();
18
+
19
+ let hasCtrlOrCmd = false;
20
+ let hasShift = false;
21
+ let hasAlt = false;
22
+
23
+ for (const modKey of modKeys) {
24
+ switch (modKey) {
25
+ case 'mod':
26
+ hasCtrlOrCmd = event.ctrlKey || event.metaKey;
27
+ break;
28
+ case 'ctrl':
29
+ hasCtrlOrCmd = event.ctrlKey;
30
+ break;
31
+ case 'cmd':
32
+ hasCtrlOrCmd = event.metaKey;
33
+ break;
34
+ case 'shift':
35
+ hasShift = event.shiftKey;
36
+ break;
37
+ case 'alt':
38
+ hasAlt = event.altKey;
39
+ break;
40
+ }
10
41
  }
42
+
43
+ return (
44
+ hasCtrlOrCmd === (modKeys.includes('mod') || modKeys.includes('ctrl') || modKeys.includes('cmd')) &&
45
+ hasShift === modKeys.includes('shift') &&
46
+ hasAlt === modKeys.includes('alt') &&
47
+ event.key.toLowerCase() === key
48
+ );
49
+ };
11
50
 
12
- if (!editor.toggleMark) {
13
- editor.toggleMark = (format: string) => {
14
- const isActive = editor.isMarkActive(format);
51
+ // Mark plugin with strict typing
52
+ export const withMark = (editor: Editor) => {
53
+ editor.isMarkActive = (format: MarkId): boolean => {
54
+ const marks = Editor.marks(editor);
55
+ return marks ? marks[format] === true : false;
56
+ };
15
57
 
16
- if (isActive) {
17
- Editor.removeMark(editor, format);
18
- } else {
19
- Editor.addMark(editor, format, true);
20
- }
21
- };
22
- }
58
+ editor.toggleMark = (format: MarkId): void => {
59
+ const isActive = editor.isMarkActive(format);
60
+
61
+ if (isActive) {
62
+ Editor.removeMark(editor, format);
63
+ } else {
64
+ Editor.addMark(editor, format, true);
65
+ }
66
+ };
23
67
 
24
68
  return editor;
25
69
  };
26
70
 
71
+ // Block plugin with strict typing
27
72
  export const withBlock = (editor: Editor) => {
28
- if (!editor.isBlockActive) {
29
- editor.isBlockActive = (format: string) => {
30
- const { selection } = editor;
31
- if (!selection) return false;
73
+ editor.isBlockActive = (format: BlockId): boolean => {
74
+ const { selection } = editor;
75
+ if (!selection) return false;
32
76
 
33
- const [match] = Editor.nodes(editor, {
34
- at: selection,
77
+ const [match] = Array.from(
78
+ Editor.nodes(editor, {
79
+ at: Editor.unhangRange(editor, selection),
35
80
  match: n =>
36
- !Editor.isEditor(n) &&
37
- Element.isElement(n) &&
38
- n.type === format
39
- });
40
-
41
- return !!match;
42
- };
43
- }
81
+ !Editor.isEditor(n) && Element.isElement(n) && n.type === format,
82
+ })
83
+ );
44
84
 
45
- if (!editor.toggleBlock) {
46
- editor.toggleBlock = (format: string) => {
47
- const isActive = editor.isBlockActive(format);
85
+ return !!match;
86
+ };
48
87
 
49
- Transforms.setNodes(
50
- editor,
51
- { type: isActive ? 'paragraph' : format },
52
- { match: n => !Editor.isEditor(n) && Element.isElement(n) }
53
- );
54
- };
55
- }
88
+ editor.toggleBlock = (format: BlockId): void => {
89
+ const isActive = editor.isBlockActive(format);
90
+
91
+ Transforms.setNodes(
92
+ editor,
93
+ { type: isActive ? 'paragraph' : format },
94
+ { match: n => !Editor.isEditor(n) && Element.isElement(n) }
95
+ );
96
+ };
56
97
 
57
98
  return editor;
58
99
  };
59
100
 
101
+ // Alignment plugin with strict typing
60
102
  export const withAlignment = (editor: Editor) => {
61
- if (!editor.isAlignActive) {
62
- editor.isAlignActive = (align: string) => {
63
- const { selection } = editor;
64
- if (!selection) return false;
65
-
66
- const [match] = Array.from(
67
- Editor.nodes(editor, {
68
- at: Editor.unhangRange(editor, selection),
69
- match: n =>
70
- !Editor.isEditor(n) &&
71
- Element.isElement(n) &&
72
- n.align === align,
73
- })
74
- );
103
+ editor.isAlignActive = (align: Alignment): boolean => {
104
+ const { selection } = editor;
105
+ if (!selection) return false;
75
106
 
76
- return !!match;
77
- };
78
- }
79
-
80
- if (!editor.toggleAlign) {
81
- editor.toggleAlign = (align: string) => {
82
- const { selection } = editor;
83
- if (!selection) return;
107
+ const [match] = Array.from(
108
+ Editor.nodes(editor, {
109
+ at: Editor.unhangRange(editor, selection),
110
+ match: n =>
111
+ !Editor.isEditor(n) && Element.isElement(n) && (n as CustomElement).align === align,
112
+ })
113
+ );
84
114
 
85
- const isActive = editor.isAlignActive(align);
115
+ return !!match;
116
+ };
86
117
 
87
- Transforms.setNodes(
88
- editor,
89
- { align: isActive ? undefined : align as Alignment },
90
- { match: n => !Editor.isEditor(n) && Element.isElement(n) }
91
- );
92
- };
93
- }
118
+ editor.toggleAlign = (align: Alignment): void => {
119
+ const isActive = editor.isAlignActive(align);
120
+
121
+ Transforms.setNodes(
122
+ editor,
123
+ { align: isActive ? undefined : align },
124
+ { match: n => !Editor.isEditor(n) && Element.isElement(n) }
125
+ );
126
+ };
94
127
 
95
128
  return editor;
96
129
  };
97
130
 
98
- export const withList = (editor: Editor) => {
99
- if (!editor.isListActive) {
100
- editor.isListActive = (listType?: 'unordered' | 'ordered') => {
101
- const { selection } = editor;
102
- if (!selection) return false;
103
-
104
- const [match] = Editor.nodes(editor, {
105
- at: selection,
106
- match: n =>
107
- !Editor.isEditor(n) &&
108
- Element.isElement(n) &&
109
- n.type === 'list-item' &&
110
- (listType ? n.listType === listType : true)
111
- });
131
+ // Link plugin
132
+ export const withLink = (editor: Editor) => {
133
+ const { insertData, insertText, isInline, isElementReadOnly, isSelectable } = editor;
112
134
 
113
- return !!match;
114
- };
115
- }
135
+ editor.isInline = (element: Element) => {
136
+ return element.type === 'link' ? true : isInline(element);
137
+ };
116
138
 
117
- if (!editor.toggleList) {
118
- editor.toggleList = (listType: 'unordered' | 'ordered') => {
119
- const { selection } = editor;
120
- if (!selection) return;
139
+ editor.isElementReadOnly = (element: Element) => {
140
+ return element.type === 'link' ? false : isElementReadOnly(element);
141
+ };
121
142
 
122
- const isActive = editor.isListActive(listType);
123
- const isAnyListActive = editor.isListActive();
143
+ editor.isSelectable = (element: Element) => {
144
+ return element.type === 'link' ? true : isSelectable(element);
145
+ };
124
146
 
125
- if (isActive) {
126
- // Convert list items back to paragraphs
127
- Transforms.setNodes(
128
- editor,
129
- { type: 'paragraph', listType: undefined, indent: undefined },
130
- {
131
- match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item',
132
- split: true
133
- }
134
- );
135
- } else {
136
- // Convert to list items
137
- Transforms.setNodes(
138
- editor,
139
- {
140
- type: 'list-item',
141
- listType: listType,
142
- indent: isAnyListActive ? 1 : 0 // If already in a list, indent by 1
143
- },
144
- {
145
- match: n => !Editor.isEditor(n) && Element.isElement(n) &&
146
- (n.type === 'paragraph' || n.type === 'list-item'),
147
- split: true
148
- }
149
- );
150
- }
151
- };
152
- }
147
+ editor.insertText = (text: string) => {
148
+ if (text && isUrl(text)) {
149
+ wrapLink(editor, text);
150
+ } else {
151
+ insertText(text);
152
+ }
153
+ };
153
154
 
154
- if (!editor.indentList) {
155
- editor.indentList = () => {
156
- const { selection } = editor;
157
- if (!selection) return;
155
+ editor.insertData = (data: DataTransfer) => {
156
+ const text = data.getData('text/plain');
158
157
 
159
- const listItem = Editor.above(editor, {
160
- at: selection,
161
- match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item'
162
- });
158
+ if (text && isUrl(text)) {
159
+ wrapLink(editor, text);
160
+ } else {
161
+ insertData(data);
162
+ }
163
+ };
163
164
 
164
- if (listItem) {
165
- const [node, path] = listItem;
166
- const currentIndent = (node as any).indent || 0;
167
- const newIndent = Math.min(currentIndent + 1, 5); // Max 5 levels
165
+ editor.isLinkActive = (): boolean => {
166
+ const [link] = Editor.nodes(editor, {
167
+ match: n =>
168
+ !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link',
169
+ });
170
+ return !!link;
171
+ };
168
172
 
169
- Transforms.setNodes(
170
- editor,
171
- { indent: newIndent },
172
- { at: path }
173
- );
173
+ editor.insertLink = (options?: {
174
+ onOpenLinkDialog?: (callback: (link: any) => void) => void;
175
+ }): void => {
176
+ if (editor.isLinkActive()) {
177
+ unwrapLink(editor);
178
+ } else {
179
+ if (options?.onOpenLinkDialog) {
180
+ options.onOpenLinkDialog((linkData: any) => {
181
+ wrapLink(editor, linkData.url || linkData.link?.url || '', linkData);
182
+ });
174
183
  }
175
- };
176
- }
184
+ }
185
+ };
177
186
 
178
- if (!editor.outdentList) {
179
- editor.outdentList = () => {
180
- const { selection } = editor;
181
- if (!selection) return;
187
+ return editor;
188
+ };
182
189
 
183
- const listItem = Editor.above(editor, {
184
- at: selection,
185
- match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item'
186
- });
190
+ // List plugin with strict typing
191
+ export const withList = (editor: Editor) => {
192
+ editor.isListActive = (listType?: "unordered" | "ordered"): boolean => {
193
+ const { selection } = editor;
194
+ if (!selection) return false;
195
+
196
+ const [match] = Array.from(
197
+ Editor.nodes(editor, {
198
+ at: Editor.unhangRange(editor, selection),
199
+ match: n =>
200
+ !Editor.isEditor(n) &&
201
+ Element.isElement(n) &&
202
+ n.type === 'list-item' &&
203
+ (!listType || (n as CustomElement).listType === listType),
204
+ })
205
+ );
187
206
 
188
- if (listItem) {
189
- const [node, path] = listItem;
190
- const currentIndent = (node as any).indent || 0;
207
+ return !!match;
208
+ };
209
+
210
+ editor.toggleList = (listType: "unordered" | "ordered"): void => {
211
+ const isActive = editor.isListActive(listType);
212
+
213
+ if (isActive) {
214
+ // Convert list items back to paragraphs
215
+ Transforms.setNodes(
216
+ editor,
217
+ { type: 'paragraph', listType: undefined, indent: undefined },
218
+ {
219
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item',
220
+ }
221
+ );
222
+ } else {
223
+ // Check if we're switching between list types (preserve indentation)
224
+ // or converting from non-list elements (reset to indent 0)
225
+ const selectedNodes = Array.from(
226
+ Editor.nodes(editor, {
227
+ match: n => !Editor.isEditor(n) && Element.isElement(n),
228
+ })
229
+ );
230
+
231
+ selectedNodes.forEach(([node, path]) => {
232
+ const element = node as CustomElement;
191
233
 
192
- if (currentIndent === 0) {
193
- // Convert to paragraph when already at the highest level
234
+ if (element.type === 'list-item') {
235
+ // Switching between list types - preserve existing indentation
194
236
  Transforms.setNodes(
195
237
  editor,
196
- { type: 'paragraph', listType: undefined, indent: undefined },
238
+ { type: 'list-item', listType },
197
239
  { at: path }
198
240
  );
199
241
  } else {
200
- // Reduce indent level
201
- const newIndent = Math.max(currentIndent - 1, 0);
242
+ // Converting from non-list element - start at indent 0
202
243
  Transforms.setNodes(
203
244
  editor,
204
- { indent: newIndent },
245
+ { type: 'list-item', listType, indent: 0 },
205
246
  { at: path }
206
247
  );
207
248
  }
208
- }
209
- };
210
- }
211
-
212
- return editor;
213
- };
214
-
215
- export const withLink = (editor: Editor) => {
216
- const { isInline } = editor;
217
-
218
- // Override isInline to mark link elements as inline
219
- editor.isInline = element => {
220
- return element.type === 'link' ? true : isInline(element);
249
+ });
250
+ }
221
251
  };
222
252
 
223
- if (!editor.isLinkActive) {
224
- editor.isLinkActive = () => {
225
- const { selection } = editor;
226
- if (!selection) return false;
227
-
228
- const [match] = Array.from(
253
+ editor.indentList = (): void => {
254
+ const { selection } = editor;
255
+ if (!selection) return;
256
+
257
+ const [match] = Array.from(
258
+ Editor.nodes(editor, {
259
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item',
260
+ })
261
+ );
262
+
263
+ if (match) {
264
+ const [node, path] = match;
265
+ const currentIndent = (node as CustomElement).indent || 0;
266
+
267
+ // Check if this is the first list item in the document
268
+ const allListItems = Array.from(
229
269
  Editor.nodes(editor, {
230
- at: selection,
231
- match: n =>
232
- !Editor.isEditor(n) &&
233
- Element.isElement(n) &&
234
- n.type === 'link'
270
+ at: [],
271
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item',
235
272
  })
236
273
  );
237
-
238
- return !!match;
239
- };
240
- }
241
-
242
- if (!editor.insertLink) {
243
- editor.insertLink = (options: { onOpenLinkDialog?: (callback: (link: any) => void) => void } = {}) => {
244
- const { selection } = editor;
245
- const { onOpenLinkDialog } = options;
246
-
247
- const createLinkElement = (link: any, defaultText: string = "Link"): LinkElement => {
248
- return {
249
- type: 'link',
250
- url: link.url || '',
251
- link: {
252
- type: link.type || 'external',
253
- url: link.url || '',
254
- target: link.target || '_blank',
255
- targetItemLongId: link.targetItemLongId,
256
- itemId: link.itemId,
257
- queryString: link.queryString
258
- },
259
- children: link.text ? [{ text: link.text }] : [{ text: defaultText }]
260
- };
261
- };
262
-
263
- // If there's a link active, remove it
264
- if (editor.isLinkActive()) {
265
- Transforms.unwrapNodes(editor, {
266
- match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link'
267
- });
274
+
275
+ const currentIndex = allListItems.findIndex(([, itemPath]) => Path.equals(itemPath, path));
276
+
277
+ // Don't allow indenting the first list item
278
+ if (currentIndex === 0) {
268
279
  return;
269
280
  }
270
-
271
- // Handle link insertion
272
- if (onOpenLinkDialog) {
273
- onOpenLinkDialog((link) => {
274
- if (selection && Editor.string(editor, selection)) {
275
- // Wrap selected text
276
- const linkElement = createLinkElement(link);
277
- Transforms.wrapNodes(editor, linkElement, { split: true });
278
- } else {
279
- // Insert new link
280
- const linkElement = createLinkElement(link, link.text || "Link");
281
- Transforms.insertNodes(editor, linkElement);
281
+
282
+ // Find the previous list item to check maximum allowed indent
283
+ if (currentIndex > 0) {
284
+ const prevEntry = allListItems[currentIndex - 1];
285
+ if (prevEntry) {
286
+ const [prevNode] = prevEntry;
287
+ const prevIndent = (prevNode as CustomElement).indent || 0;
288
+ const maxAllowedIndent = prevIndent + 1;
289
+
290
+ // Only allow indenting if it doesn't exceed previous item's indent + 1
291
+ if (currentIndent < maxAllowedIndent) {
292
+ Transforms.setNodes(
293
+ editor,
294
+ { indent: Math.min(currentIndent + 1, Math.min(5, maxAllowedIndent)) },
295
+ { at: path }
296
+ );
282
297
  }
283
-
284
- // Add space after link
285
- Transforms.insertText(editor, ' ');
286
- });
287
- } else {
288
- // Default link creation
289
- const defaultLink = {
290
- type: 'external',
291
- url: 'https://example.com',
292
- target: '_blank'
293
- };
294
-
295
- if (selection && Editor.string(editor, selection)) {
296
- const linkElement = createLinkElement(defaultLink);
297
- Transforms.wrapNodes(editor, linkElement, { split: true });
298
- } else {
299
- const linkElement = createLinkElement(defaultLink, "Link");
300
- Transforms.insertNodes(editor, linkElement);
301
298
  }
302
-
303
- // Add space after link
304
- Transforms.insertText(editor, ' ');
305
299
  }
306
- };
307
- }
300
+ }
301
+ };
302
+
303
+ editor.outdentList = (): void => {
304
+ const { selection } = editor;
305
+ if (!selection) return;
306
+
307
+ const [match] = Array.from(
308
+ Editor.nodes(editor, {
309
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item',
310
+ })
311
+ );
312
+
313
+ if (match) {
314
+ const [node, path] = match;
315
+ const currentIndent = (node as CustomElement).indent || 0;
316
+
317
+ if (currentIndent > 0) {
318
+ Transforms.setNodes(
319
+ editor,
320
+ { indent: currentIndent - 1 },
321
+ { at: path }
322
+ );
323
+ } else {
324
+ // Convert back to paragraph if at root level
325
+ Transforms.setNodes(
326
+ editor,
327
+ { type: 'paragraph', listType: undefined, indent: undefined },
328
+ { at: path }
329
+ );
330
+ }
331
+ }
332
+ };
308
333
 
309
334
  return editor;
310
335
  };
311
336
 
312
- // Simplified normalization plugin
337
+ // Normalization plugin
313
338
  export const withNormalization = (editor: Editor) => {
314
339
  const { normalizeNode } = editor;
315
340
 
316
- editor.normalizeNode = (entry) => {
341
+ editor.normalizeNode = (entry: [Node, Path]) => {
317
342
  const [node, path] = entry;
318
343
 
319
- // Use Slate's built-in normalizations first
320
- normalizeNode(entry);
344
+ // Ensure all text nodes have proper mark structure
345
+ if (Text.isText(node)) {
346
+ const validMarks = Object.keys(SLATE_MARKS) as MarkId[];
347
+ const nodeMarks = Object.keys(node).filter(key => key !== 'text');
348
+
349
+ for (const mark of nodeMarks) {
350
+ if (!validMarks.includes(mark as MarkId)) {
351
+ // Remove invalid marks
352
+ Transforms.unsetNodes(editor, mark, { at: path });
353
+ return;
354
+ }
355
+ }
356
+ }
321
357
 
322
- // Only essential custom normalization rules
358
+ // Ensure all elements have valid types
323
359
  if (Element.isElement(node)) {
324
- // Ensure list items have valid properties
325
- if (node.type === 'list-item') {
326
- if (!node.listType) {
327
- Transforms.setNodes(editor, { listType: 'unordered' }, { at: path });
328
- return;
360
+ const validBlockTypes = Object.keys(SLATE_BLOCKS) as BlockId[];
361
+ const specialTypes = ['link', 'list-item', 'horizontal-rule'];
362
+ const allValidTypes = [...validBlockTypes, ...specialTypes];
363
+
364
+ if (!allValidTypes.includes(node.type)) {
365
+ // Convert invalid element types to paragraphs
366
+ Transforms.setNodes(editor, { type: 'paragraph' }, { at: path });
367
+ return;
368
+ }
369
+ }
370
+
371
+ normalizeNode(entry);
372
+ };
373
+
374
+ return editor;
375
+ };
376
+
377
+ // Insertion plugin for content insertion features
378
+ export const withInsertion = (editor: Editor) => {
379
+ editor.insertHorizontalRule = (): void => {
380
+ const hrElement: CustomElement = {
381
+ type: 'horizontal-rule',
382
+ children: [{ text: '' }],
383
+ };
384
+
385
+ Transforms.insertNodes(editor, hrElement);
386
+
387
+ // Move cursor to after the hr element by inserting a new paragraph
388
+ const newParagraph: CustomElement = {
389
+ type: 'paragraph',
390
+ children: [{ text: '' }],
391
+ };
392
+ Transforms.insertNodes(editor, newParagraph);
393
+ };
394
+
395
+ return editor;
396
+ };
397
+
398
+ // Keyboard handler function
399
+ export const createKeyboardHandler = (editor: Editor) => {
400
+ return (event: React.KeyboardEvent) => {
401
+ // Handle hotkeys for marks
402
+ Object.entries(SLATE_MARKS).forEach(([markId, config]) => {
403
+ if ('hotkey' in config && config.hotkey && isHotkey(config.hotkey, event)) {
404
+ event.preventDefault();
405
+ editor.toggleMark(markId as MarkId);
406
+ }
407
+ });
408
+
409
+ // Handle Shift+Enter for line break
410
+ if (isHotkey('shift+enter', event)) {
411
+ event.preventDefault();
412
+ editor.insertText('<br>');
413
+ return;
414
+ }
415
+
416
+ // Handle Enter to escape from links
417
+ if (event.key === 'Enter') {
418
+ const { selection } = editor;
419
+ if (selection && Range.isCollapsed(selection)) {
420
+ const [linkNode] = Editor.nodes(editor, {
421
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link',
422
+ });
423
+
424
+ if (linkNode) {
425
+ const [, linkPath] = linkNode;
426
+ const linkEnd = Editor.end(editor, linkPath);
427
+
428
+ // Check if cursor is at the end of the link
429
+ if (Point.equals(selection.anchor, linkEnd)) {
430
+ event.preventDefault();
431
+
432
+ // Move cursor after the link
433
+ const after = Editor.after(editor, linkPath);
434
+ if (after) {
435
+ Transforms.select(editor, after);
436
+ editor.insertBreak();
437
+ }
438
+ return;
439
+ }
329
440
  }
441
+ }
442
+ }
330
443
 
331
- if (typeof node.indent !== 'number' || node.indent < 0 || node.indent > 5) {
332
- Transforms.setNodes(editor, { indent: 0 }, { at: path });
333
- return;
444
+ // Handle Right arrow to escape from links
445
+ if (event.key === 'ArrowRight') {
446
+ const { selection } = editor;
447
+ if (selection && Range.isCollapsed(selection)) {
448
+ const [linkNode] = Editor.nodes(editor, {
449
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link',
450
+ });
451
+
452
+ if (linkNode) {
453
+ const [, linkPath] = linkNode;
454
+ const linkEnd = Editor.end(editor, linkPath);
455
+
456
+ // Check if cursor is at the end of the link
457
+ if (Point.equals(selection.anchor, linkEnd)) {
458
+ event.preventDefault();
459
+
460
+ // Move cursor after the link
461
+ const after = Editor.after(editor, linkPath);
462
+ if (after) {
463
+ Transforms.select(editor, after);
464
+ }
465
+ return;
466
+ }
334
467
  }
335
468
  }
469
+ }
336
470
 
337
- // Ensure link elements have valid structure
338
- if (node.type === 'link' && (!node.link || !node.link.url)) {
339
- Transforms.unwrapNodes(editor, { at: path });
340
- return;
471
+ // Handle Tab for list indentation
472
+ if (event.key === 'Tab') {
473
+ event.preventDefault();
474
+ if (event.shiftKey) {
475
+ editor.outdentList();
476
+ } else {
477
+ editor.indentList();
478
+ }
479
+ return;
480
+ }
481
+
482
+ // Handle Backspace/Delete for empty list items
483
+ if (event.key === 'Backspace' || event.key === 'Delete') {
484
+ const { selection } = editor;
485
+ if (selection && Range.isCollapsed(selection)) {
486
+ const [match] = Array.from(
487
+ Editor.nodes(editor, {
488
+ match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'list-item',
489
+ })
490
+ );
491
+
492
+ if (match) {
493
+ const [node] = match;
494
+ const text = Node.string(node);
495
+
496
+ if (text === '') {
497
+ event.preventDefault();
498
+ editor.outdentList();
499
+ }
500
+ }
341
501
  }
342
502
  }
343
503
  };
504
+ };
344
505
 
345
- return editor;
506
+ // Helper functions
507
+ const isUrl = (text: string): boolean => {
508
+ try {
509
+ new URL(text);
510
+ return true;
511
+ } catch {
512
+ return false;
513
+ }
514
+ };
515
+
516
+ const wrapLink = (editor: Editor, url: string, linkData?: any): void => {
517
+ if (editor.isLinkActive()) {
518
+ unwrapLink(editor);
519
+ }
520
+
521
+ const { selection } = editor;
522
+ const isCollapsed = selection && Range.isCollapsed(selection);
523
+ const link: LinkElement = {
524
+ type: 'link',
525
+ url,
526
+ link: linkData || {
527
+ type: 'external',
528
+ url,
529
+ target: '_blank',
530
+ },
531
+ children: isCollapsed ? [{ text: url }] : [],
532
+ };
533
+
534
+ if (isCollapsed) {
535
+ Transforms.insertNodes(editor, link);
536
+ } else {
537
+ Transforms.wrapNodes(editor, link, { split: true });
538
+ Transforms.collapse(editor, { edge: 'end' });
539
+ }
540
+ };
541
+
542
+ const unwrapLink = (editor: Editor): void => {
543
+ Transforms.unwrapNodes(editor, {
544
+ match: n =>
545
+ !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link',
546
+ });
346
547
  };