@alpaca-editor/core 1.0.4045 → 1.0.4047

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 +297 -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 +372 -427
  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
@@ -5,11 +5,11 @@ import React, {
5
5
  forwardRef,
6
6
  useRef,
7
7
  useEffect,
8
+ memo,
8
9
  } from "react";
9
10
  import { createEditor, Descendant, Editor, Element, Transforms } from "slate";
10
11
  import { Slate, Editable, withReact, ReactEditor } from "slate-react";
11
12
  import { withHistory } from "slate-history";
12
- import isEqual from "lodash/isEqual";
13
13
  import "./ReactSlate.css";
14
14
 
15
15
  import {
@@ -23,6 +23,9 @@ import {
23
23
  SLATE_MARKS,
24
24
  SLATE_BLOCKS,
25
25
  SLATE_ALIGNMENTS,
26
+ MarkId,
27
+ BlockId,
28
+ AlignmentId,
26
29
  } from "../types";
27
30
 
28
31
  import EditorDropdown from "./EditorDropdown";
@@ -31,64 +34,70 @@ import { LinkEditorDialog } from "../../../LinkEditorDialog";
31
34
 
32
35
  import { htmlToSlate, slateToHtml } from "../utils/conversion";
33
36
  import { createPluginsFromConfig } from "../config/pluginFactory";
37
+ import { createKeyboardHandler } from "../utils/plugins";
34
38
  import { useCachedSimplifiedProfile } from "../hooks/useProfileCache";
35
39
  import { classNames } from "primereact/utils";
36
40
 
37
- // Helper function to normalize HTML for comparison
38
- const normalizeHtmlForComparison = (html: string): string => {
39
- if (!html) return "";
40
-
41
- // Create a temporary DOM element to normalize the HTML
42
- const temp = document.createElement("div");
43
- temp.innerHTML = html;
44
-
45
- // Remove extra whitespace and normalize structure
46
- const normalizedHtml = temp.innerHTML
47
- .replace(/\s+/g, " ") // Replace multiple spaces with single space
48
- .replace(/>\s+</g, "><") // Remove spaces between tags
49
- .trim();
50
-
51
- // Handle common empty content patterns
52
- if (
53
- normalizedHtml === "<p><br></p>" ||
54
- normalizedHtml === "<br>" ||
55
- normalizedHtml === "<p></p>"
56
- ) {
57
- return "";
41
+ // Toolbar button component - not memoized to allow reactivity to editor state changes
42
+ const ToolbarButtonWrapper: React.FC<{
43
+ option: ToolbarOptionConfig;
44
+ icon: React.ReactNode;
45
+ editor: Editor;
46
+ onMouseDown: (event: React.MouseEvent<HTMLButtonElement>) => void;
47
+ }> = ({ option, icon, editor, onMouseDown }) => {
48
+ // Calculate active state on every render - this is lightweight and ensures reactivity
49
+ let isActive = false;
50
+ switch (option.type) {
51
+ case "mark":
52
+ isActive = editor.isMarkActive(option.id);
53
+ break;
54
+ case "block":
55
+ isActive = editor.isBlockActive(option.id);
56
+ break;
57
+ case "alignment":
58
+ isActive = editor.isAlignActive(SLATE_ALIGNMENTS[option.id].value);
59
+ break;
60
+ case "link":
61
+ isActive = editor.isLinkActive();
62
+ break;
63
+ case "list":
64
+ const listType = option.id === "unordered-list" ? "unordered" : "ordered";
65
+ isActive = editor.isListActive(listType);
66
+ break;
67
+ case "insertion":
68
+ isActive = false; // Insertion buttons are never active
69
+ break;
70
+ default:
71
+ isActive = false;
58
72
  }
59
73
 
60
- return normalizedHtml;
74
+ return (
75
+ <ToolbarButton
76
+ icon={icon}
77
+ active={isActive}
78
+ onMouseDown={onMouseDown}
79
+ />
80
+ );
61
81
  };
62
82
 
63
- // Helper function to check if HTML conversion is stable
64
- const isHtmlConversionStable = (
65
- originalHtml: string,
66
- slateValue: Descendant[],
67
- profile: SimplifiedProfile,
68
- ): boolean => {
69
- try {
70
- // Convert Slate back to HTML
71
- const convertedHtml = slateToHtml(slateValue, profile);
72
-
73
- // Normalize both HTML strings for comparison
74
- const normalizedOriginal = normalizeHtmlForComparison(originalHtml);
75
- const normalizedConverted = normalizeHtmlForComparison(convertedHtml);
76
-
77
- // Debug logging (can be enabled for troubleshooting)
78
- // console.log('HTML Stability Check:', {
79
- // original: normalizedOriginal,
80
- // converted: normalizedConverted,
81
- // stable: normalizedOriginal === normalizedConverted
82
- // });
83
-
84
- // Check if they're equivalent
85
- return normalizedOriginal === normalizedConverted;
86
- } catch (error) {
87
- console.warn("HTML conversion stability check failed:", error);
88
- // If we can't check stability, assume it's not stable to be safe
89
- return false;
90
- }
91
- };
83
+ // Memoized dropdown component to prevent unnecessary re-renders
84
+ const MemoizedEditorDropdown = memo<{
85
+ options: DropdownOption<any>[];
86
+ editor: Editor;
87
+ label?: string;
88
+ buttonStyle: React.CSSProperties;
89
+ }>(({ options, editor, label, buttonStyle }) => {
90
+ return (
91
+ <EditorDropdown
92
+ options={options}
93
+ editor={editor}
94
+ label={label}
95
+ buttonStyle={buttonStyle}
96
+ />
97
+ );
98
+ });
99
+
100
+ MemoizedEditorDropdown.displayName = "MemoizedEditorDropdown";
92
101
 
93
102
  export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
94
103
  const {
@@ -101,20 +110,6 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
101
110
  profile,
102
111
  } = props;
103
112
 
104
- // Create the Slate editor with plugins
105
- const editor = useMemo(() => {
106
- // Start with base editor
107
- let slateEditor = createEditor();
108
-
109
- // Apply core plugins
110
- slateEditor = withReact(slateEditor);
111
- slateEditor = withHistory(slateEditor);
112
-
113
- slateEditor = createPluginsFromConfig(slateEditor);
114
-
115
- return slateEditor;
116
- }, []);
117
-
118
113
  const editorProfile = profile || {
119
114
  toolbar: {
120
115
  groups: [],
@@ -124,217 +119,260 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
124
119
  // Convert to simplified profile for the conversion functions (with caching)
125
120
  const simplifiedProfile = useCachedSimplifiedProfile(editorProfile);
126
121
 
127
- // Store the original HTML value for comparison
128
- const originalValueRef = useRef<string>(value);
129
- const isInitialLoadRef = useRef<boolean>(true);
122
+ // Create the Slate editor with plugins - only once
123
+ const editor = useMemo(() => {
124
+ const baseEditor = createEditor();
125
+
126
+ // Apply plugins in correct order
127
+ const enhancedEditor = createPluginsFromConfig(
128
+ withHistory(withReact(baseEditor))
129
+ );
130
+
131
+ return enhancedEditor;
132
+ }, []);
130
133
 
131
- // Update original value when prop value changes
132
- useMemo(() => {
133
- if (value !== originalValueRef.current) {
134
- originalValueRef.current = value;
135
- isInitialLoadRef.current = true;
134
+ // Initial value for Slate - capture initial props at mount time
135
+ const initialSlateValue = useMemo(() => {
136
+ try {
137
+ return htmlToSlate(value, simplifiedProfile);
138
+ } catch (error) {
139
+ console.warn("Failed to convert initial HTML to Slate:", error);
140
+ return [{ type: "paragraph", children: [{ text: "" }] }];
136
141
  }
137
- }, [value]);
138
-
139
- // Convert the HTML value to Slate format
140
- const [internalValue, setInternalValue] = useState<Descendant[]>(
141
- htmlToSlate(value, simplifiedProfile),
142
- );
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, []); // Intentionally empty - we want initial value only
144
+
145
+ // Convert HTML to Slate format - memoized for performance
146
+ const slateValue = useMemo(() => {
147
+ try {
148
+ return htmlToSlate(value, simplifiedProfile);
149
+ } catch (error) {
150
+ console.warn("Failed to convert HTML to Slate:", error);
151
+ return [{ type: "paragraph", children: [{ text: "" }] }];
152
+ }
153
+ }, [value, simplifiedProfile]);
143
154
 
144
- // Counter to force remount when value changes externally
145
- const [remountKey, setRemountKey] = useState(0);
155
+ // Track previous value to detect external changes
156
+ const previousValueRef = useRef(value);
157
+ const isInternalChangeRef = useRef(false);
146
158
 
147
- // Update internal value when prop value changes
159
+ // Update editor when value changes from outside
148
160
  useEffect(() => {
149
- const newValue = htmlToSlate(value, simplifiedProfile);
150
- if (!isEqual(newValue, internalValue)) {
151
- setInternalValue(newValue);
152
- // Force remount by incrementing the key
153
- setRemountKey((prev) => prev + 1);
161
+ // Skip if this is an internal change (from our own onChange)
162
+ if (isInternalChangeRef.current) {
163
+ isInternalChangeRef.current = false;
164
+ previousValueRef.current = value;
165
+ return;
154
166
  }
155
- }, [value, simplifiedProfile]);
156
167
 
157
- // Handle value changes with round-trip validation
168
+ // Only update if value actually changed from outside
169
+ if (value !== previousValueRef.current) {
170
+ previousValueRef.current = value;
171
+
172
+ // Update editor children with new value
173
+ const newChildren = slateValue;
174
+
175
+ // Prevent infinite loops by temporarily removing selection
176
+ const currentSelection = editor.selection;
177
+
178
+ // Update the editor's children
179
+ editor.children = newChildren;
180
+
181
+ // Normalize the editor to ensure consistency
182
+ Editor.normalize(editor, { force: true });
183
+
184
+ // Restore selection if it was valid, otherwise set to end
185
+ if (currentSelection && Editor.hasPath(editor, currentSelection.anchor.path)) {
186
+ try {
187
+ Transforms.select(editor, currentSelection);
188
+ } catch {
189
+ // If selection is invalid, move to end
190
+ Transforms.select(editor, Editor.end(editor, []));
191
+ }
192
+ } else {
193
+ // Move to end of document
194
+ Transforms.select(editor, Editor.end(editor, []));
195
+ }
196
+ }
197
+ }, [editor, slateValue, value]);
198
+
199
+ // Handle value changes with proper Slate patterns
158
200
  const handleChange = useCallback(
159
201
  (newValue: Descendant[]) => {
160
- setInternalValue(newValue);
161
-
162
202
  if (onChange) {
163
- const html = slateToHtml(newValue, simplifiedProfile);
164
-
165
- // Check if this is the initial load or if the conversion is stable
166
- if (isInitialLoadRef.current) {
167
- // On initial load, check if the HTML conversion is stable
168
- const isStable = isHtmlConversionStable(
169
- originalValueRef.current,
170
- newValue,
171
- simplifiedProfile,
172
- );
173
-
174
- if (isStable) {
175
- // If stable, don't trigger onChange on initial load
176
- isInitialLoadRef.current = false;
177
- return;
203
+ try {
204
+ const html = slateToHtml(newValue, simplifiedProfile);
205
+
206
+ // Only trigger onChange if content actually changed
207
+ if (html !== value) {
208
+ // Mark this as an internal change
209
+ isInternalChangeRef.current = true;
210
+ onChange(html);
178
211
  }
179
-
180
- // If not stable, we still need to proceed, but mark as no longer initial load
181
- isInitialLoadRef.current = false;
182
- }
183
-
184
- // For subsequent changes, only trigger onChange if there's a meaningful difference
185
- const normalizedNewHtml = normalizeHtmlForComparison(html);
186
- const normalizedOriginalHtml = normalizeHtmlForComparison(
187
- originalValueRef.current,
188
- );
189
-
190
- if (normalizedNewHtml !== normalizedOriginalHtml) {
191
- // Update the original value reference to the new HTML
192
- originalValueRef.current = html;
193
- onChange(html);
212
+ } catch (error) {
213
+ console.warn("Failed to convert Slate to HTML:", error);
194
214
  }
195
215
  }
196
216
  },
197
- [onChange, simplifiedProfile],
217
+ [onChange, simplifiedProfile, value],
198
218
  );
199
219
 
200
- const getOption = (type: string, id: string): CustomOption | undefined => {
201
- switch (type) {
202
- case "mark":
203
- const markConfig = SLATE_MARKS[id];
204
- return markConfig
205
- ? {
206
- id,
207
- label: markConfig.label,
208
- icon: markConfig.icon,
209
- isActive: (editor: Editor) => editor.isMarkActive(id),
210
- toggle: (editor: Editor, event: React.MouseEvent) => {
211
- event.preventDefault();
212
- editor.toggleMark(id);
213
- },
220
+ // Link dialog state
221
+ const [showLinkDialog, setShowLinkDialog] = useState(false);
222
+ const [selectedLink, setSelectedLink] = useState<any>(null);
223
+ const [linkDialogCallback, setLinkDialogCallback] = useState<
224
+ ((link: any) => void) | null
225
+ >(null);
226
+
227
+ const handleLinkButtonClick = useCallback(() => {
228
+ editor.insertLink({
229
+ onOpenLinkDialog: (callback: (link: any) => void) => {
230
+ setSelectedLink({
231
+ type: "external",
232
+ url: "",
233
+ target: "_blank",
234
+ });
235
+ setShowLinkDialog(true);
236
+
237
+ setLinkDialogCallback(() => callback);
238
+ },
239
+ });
240
+ }, [editor]);
241
+
242
+ // Memoize option handlers to prevent creating new functions on every render
243
+ const optionHandlers = useMemo(() => {
244
+ const handlers: Record<string, (editor: Editor, event: React.MouseEvent) => void> = {};
245
+
246
+ const handleOptionSelect = (option: ToolbarOptionConfig) =>
247
+ (editor: Editor, event: React.MouseEvent) => {
248
+ event.preventDefault();
249
+
250
+ switch (option.type) {
251
+ case "mark":
252
+ editor.toggleMark(option.id);
253
+ break;
254
+ case "block":
255
+ editor.toggleBlock(option.id);
256
+ break;
257
+ case "alignment":
258
+ const alignConfig = SLATE_ALIGNMENTS[option.id];
259
+ editor.toggleAlign(alignConfig.value);
260
+ break;
261
+ case "link":
262
+ handleLinkButtonClick();
263
+ break;
264
+ case "list":
265
+ const listType = option.id === "unordered-list" ? "unordered" : "ordered";
266
+ editor.toggleList(listType);
267
+ break;
268
+ case "insertion":
269
+ if (option.id === "horizontal-rule") {
270
+ editor.insertHorizontalRule();
214
271
  }
215
- : undefined;
272
+ break;
273
+ default:
274
+ console.warn(`Unhandled option type: ${option.type}`);
275
+ }
276
+ };
277
+
278
+ // Pre-create handlers for all possible options
279
+ editorProfile.toolbar.groups.forEach(group => {
280
+ group.options.forEach(option => {
281
+ const key = `${option.type}-${option.id}`;
282
+ handlers[key] = handleOptionSelect(option);
283
+ });
284
+ });
285
+
286
+ return handlers;
287
+ }, [editor, editorProfile.toolbar.groups, handleLinkButtonClick]);
288
+
289
+ // Create button handlers that adapt the signature for toolbar buttons
290
+ const buttonHandlers = useMemo(() => {
291
+ const handlers: Record<string, (event: React.MouseEvent<HTMLButtonElement>) => void> = {};
292
+
293
+ Object.entries(optionHandlers).forEach(([key, handler]) => {
294
+ handlers[key] = (event: React.MouseEvent<HTMLButtonElement>) => {
295
+ handler(editor, event);
296
+ };
297
+ });
298
+
299
+ return handlers;
300
+ }, [optionHandlers, editor]);
301
+
302
+ const getOption = useCallback((option: ToolbarOptionConfig): CustomOption | undefined => {
303
+ const handlerKey = `${option.type}-${option.id}`;
304
+
305
+ switch (option.type) {
306
+ case "mark":
307
+ const markConfig = SLATE_MARKS[option.id];
308
+ return {
309
+ id: option.id,
310
+ label: markConfig.label,
311
+ icon: markConfig.icon,
312
+ isActive: (editor: Editor) => editor.isMarkActive(option.id),
313
+ toggle: optionHandlers[handlerKey],
314
+ };
216
315
  case "block":
217
- const blockConfig = SLATE_BLOCKS[id];
218
- return blockConfig
219
- ? {
220
- id,
221
- label: blockConfig.label,
222
- icon: blockConfig.icon,
223
- isActive: (editor: Editor) => editor.isBlockActive(id),
224
- toggle: (editor: Editor, event: React.MouseEvent) => {
225
- event.preventDefault();
226
- editor.toggleBlock(id);
227
- },
228
- }
229
- : undefined;
316
+ const blockConfig = SLATE_BLOCKS[option.id];
317
+ return {
318
+ id: option.id,
319
+ label: blockConfig.label,
320
+ icon: blockConfig.icon,
321
+ isActive: (editor: Editor) => editor.isBlockActive(option.id),
322
+ toggle: optionHandlers[handlerKey],
323
+ };
230
324
  case "alignment":
231
- const alignConfig = SLATE_ALIGNMENTS[id];
232
- return alignConfig
233
- ? {
234
- id,
235
- label: alignConfig.label,
236
- icon: alignConfig.icon,
237
- isActive: (editor: Editor) =>
238
- editor.isAlignActive(alignConfig.value),
239
- toggle: (editor: Editor, event: React.MouseEvent) => {
240
- event.preventDefault();
241
- editor.toggleAlign(alignConfig.value);
242
- },
243
- }
244
- : undefined;
325
+ const alignConfig = SLATE_ALIGNMENTS[option.id];
326
+ return {
327
+ id: option.id,
328
+ label: alignConfig.label,
329
+ icon: alignConfig.icon,
330
+ isActive: (editor: Editor) => editor.isAlignActive(alignConfig.value),
331
+ toggle: optionHandlers[handlerKey],
332
+ };
245
333
  case "link":
246
334
  return {
247
335
  id: "link",
248
336
  label: "Link",
249
337
  icon: "🔗",
250
338
  isActive: (editor: Editor) => editor.isLinkActive(),
251
- toggle: (editor: Editor, event: React.MouseEvent) => {
252
- event.preventDefault();
253
- handleLinkButtonClick();
254
- },
339
+ toggle: optionHandlers[handlerKey],
255
340
  };
256
341
  case "list":
257
342
  return {
258
- id,
259
- label: id === "unordered-list" ? "Bulleted List" : "Numbered List",
260
- icon: id === "unordered-list" ? "•" : "1.",
261
- isActive: (editor: Editor) =>
262
- editor.isListActive(
263
- id === "unordered-list" ? "unordered" : "ordered",
264
- ),
265
- toggle: (editor: Editor, event: React.MouseEvent) => {
266
- event.preventDefault();
267
- editor.toggleList(
268
- id === "unordered-list" ? "unordered" : "ordered",
269
- );
270
- },
343
+ id: option.id,
344
+ label: option.id === "unordered-list" ? "Bulleted List" : "Numbered List",
345
+ icon: option.id === "unordered-list" ? "•" : "1.",
346
+ isActive: (editor: Editor) => editor.isListActive(
347
+ option.id === "unordered-list" ? "unordered" : "ordered"
348
+ ),
349
+ toggle: optionHandlers[handlerKey],
271
350
  };
272
351
  case "divider":
273
352
  return { id: "divider", label: "Divider" };
353
+ case "insertion":
354
+ if (option.id === "horizontal-rule") {
355
+ return {
356
+ id: option.id,
357
+ label: "Horizontal Rule",
358
+ icon: "─",
359
+ isActive: () => false, // Insertion buttons are never "active"
360
+ toggle: optionHandlers[handlerKey],
361
+ };
362
+ }
363
+ return undefined;
274
364
  default:
275
365
  return undefined;
276
366
  }
277
- };
367
+ }, [optionHandlers]);
278
368
 
279
- const optionHandlers = {
280
- mark: {
281
- isActive: (id: string) => editor.isMarkActive(id),
282
- toggle: (id: string) => editor.toggleMark(id),
283
- },
284
- block: {
285
- isActive: (id: string) => editor.isBlockActive(id),
286
- toggle: (id: string) => editor.toggleBlock(id),
287
- },
288
- alignment: {
289
- isActive: (id: string) => {
290
- const alignConfig = SLATE_ALIGNMENTS[id];
291
- return alignConfig ? editor.isAlignActive(alignConfig.value) : false;
292
- },
293
- toggle: (id: string) => {
294
- const alignConfig = SLATE_ALIGNMENTS[id];
295
- if (alignConfig) {
296
- editor.toggleAlign(alignConfig.value);
297
- }
298
- },
299
- },
300
- link: {
301
- isActive: () => editor.isLinkActive(),
302
- toggle: () => handleLinkButtonClick(),
303
- },
304
- list: {
305
- isActive: (id: string) => {
306
- const listType = id === "unordered-list" ? "unordered" : "ordered";
307
- return editor.isListActive(listType);
308
- },
309
- toggle: (id: string, event: React.MouseEvent<HTMLButtonElement>) => {
310
- const listType = id === "unordered-list" ? "unordered" : "ordered";
311
- editor.toggleList(listType);
312
- },
313
- },
314
- };
315
-
316
- const isOptionActive = (type: string, id: string): boolean => {
317
- const handler = optionHandlers[type as keyof typeof optionHandlers];
318
- return handler ? handler.isActive(id) : false;
319
- };
320
-
321
- const handleOptionSelect =
322
- (type: string, id: string) =>
323
- (event: React.MouseEvent<HTMLButtonElement>) => {
324
- event.preventDefault();
325
- const handler = optionHandlers[type as keyof typeof optionHandlers];
326
- if (handler) {
327
- return handler.toggle(id, event);
328
- }
329
- console.warn(`Unhandled option type: ${type}`);
330
- };
331
-
332
- const createDropdownOptions = (
333
- options: ToolbarOptionConfig[],
369
+ // Memoize dropdown options creation with strict type safety
370
+ const createDropdownOptions = useCallback((
371
+ options: readonly ToolbarOptionConfig[],
334
372
  ): DropdownOption<any>[] => {
335
373
  return options
336
374
  .map((option) => {
337
- const optionObj = getOption(option.type, option.id);
375
+ const optionObj = getOption(option);
338
376
  if (!optionObj) return null;
339
377
 
340
378
  return {
@@ -342,19 +380,31 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
342
380
  label: optionObj.label || option.id,
343
381
  icon: optionObj.icon,
344
382
  style: (optionObj as any).style,
345
- isActive: (editor: Editor) => isOptionActive(option.type, option.id),
346
- onSelect: (editor: Editor, event: React.MouseEvent) =>
347
- handleOptionSelect(
348
- option.type,
349
- option.id,
350
- )(event as React.MouseEvent<HTMLButtonElement>),
383
+ isActive: (editor: Editor) => {
384
+ switch (option.type) {
385
+ case "mark":
386
+ return editor.isMarkActive(option.id);
387
+ case "block":
388
+ return editor.isBlockActive(option.id);
389
+ case "alignment":
390
+ return editor.isAlignActive(SLATE_ALIGNMENTS[option.id].value);
391
+ case "link":
392
+ return editor.isLinkActive();
393
+ case "list":
394
+ const listType = option.id === "unordered-list" ? "unordered" : "ordered";
395
+ return editor.isListActive(listType);
396
+ default:
397
+ return false;
398
+ }
399
+ },
400
+ onSelect: optionHandlers[`${option.type}-${option.id}`],
351
401
  };
352
402
  })
353
403
  .filter(Boolean) as DropdownOption<any>[];
354
- };
404
+ }, [getOption, optionHandlers]);
355
405
 
356
406
  // Helper function to split options by dividers into sub-groups
357
- const splitOptionsByDividers = (
407
+ const splitOptionsByDividers = useCallback((
358
408
  options: ToolbarOptionConfig[],
359
409
  ): ToolbarOptionConfig[][] => {
360
410
  const subGroups: ToolbarOptionConfig[][] = [];
@@ -377,12 +427,13 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
377
427
  }
378
428
 
379
429
  return subGroups;
380
- };
430
+ }, []);
381
431
 
382
- const renderToolbarGroup = (group: ToolbarGroupConfig, index: number) => {
432
+ // Memoize toolbar group rendering
433
+ const renderToolbarGroup = useCallback((group: ToolbarGroupConfig, index: number) => {
383
434
  const validOptions = group.options.filter(
384
435
  (option) =>
385
- getOption(option.type, option.id) || option.type === "divider",
436
+ getOption(option) || option.type === "divider",
386
437
  );
387
438
 
388
439
  if (validOptions.length === 0) return null;
@@ -404,16 +455,18 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
404
455
  key={`subgroup-${subGroupIndex}`}
405
456
  className="toolbar-button-group"
406
457
  >
407
- {subGroup.map((option, optionIndex) => {
408
- const optionObj = getOption(option.type, option.id);
409
- if (!optionObj) return null;
458
+ {subGroup.map((option) => {
459
+ const optionObj = getOption(option);
460
+ const handler = buttonHandlers[`${option.type}-${option.id}`];
461
+ if (!optionObj || !handler) return null;
410
462
 
411
463
  return (
412
- <ToolbarButton
464
+ <ToolbarButtonWrapper
413
465
  key={`${option.type}-${option.id}`}
466
+ option={option}
414
467
  icon={optionObj.icon}
415
- active={isOptionActive(option.type, option.id)}
416
- onMouseDown={handleOptionSelect(option.type, option.id)}
468
+ editor={editor}
469
+ onMouseDown={handler}
417
470
  />
418
471
  );
419
472
  })}
@@ -432,7 +485,7 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
432
485
  blockOptions.some(
433
486
  (blockOpt) =>
434
487
  blockOpt.type === "block" &&
435
- getOption(blockOpt.type, blockOpt.id) === opt.value,
488
+ getOption(blockOpt) === opt.value,
436
489
  ),
437
490
  );
438
491
 
@@ -477,36 +530,51 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
477
530
  const isActive = dropdownOptions.some((option) =>
478
531
  option.isActive(editor),
479
532
  );
533
+
534
+ const buttonStyle = {
535
+ padding: "5px 10px",
536
+ margin: "0",
537
+ background: isActive ? "#ffffff" : "transparent",
538
+ border: "none",
539
+ borderRadius: "3px",
540
+ cursor: "pointer",
541
+ boxShadow: isActive
542
+ ? "0 1px 2px rgba(0,0,0,0.1)"
543
+ : "none",
544
+ };
545
+
480
546
  return (
481
547
  <div className="toolbar-dropdown-container">
482
- <EditorDropdown
548
+ <MemoizedEditorDropdown
483
549
  options={dropdownOptions}
484
550
  editor={editor}
485
551
  label={group.label}
486
- buttonStyle={{
487
- padding: "5px 10px",
488
- margin: "0",
489
- background: isActive ? "#ffffff" : "transparent",
490
- border: "none",
491
- borderRadius: "3px",
492
- cursor: "pointer",
493
- boxShadow: isActive
494
- ? "0 1px 2px rgba(0,0,0,0.1)"
495
- : "none",
496
- }}
552
+ buttonStyle={buttonStyle}
497
553
  />
498
554
  </div>
499
555
  );
500
556
  })()}
501
557
  </div>
502
558
  );
503
- };
559
+ }, [getOption, splitOptionsByDividers, createDropdownOptions, optionHandlers, buttonHandlers, editor]);
560
+
561
+ // Memoize toolbar structure (expensive grouping operation)
562
+ const toolbarStructure = useMemo(() => {
563
+ // Group toolbar items by row
564
+ const groupsByRow = editorProfile.toolbar.groups.reduce(
565
+ (acc, group, index) => {
566
+ const row = group.row !== undefined ? group.row : index;
567
+ if (!acc[row]) acc[row] = [];
568
+ acc[row].push(group);
569
+ return acc;
570
+ },
571
+ {} as Record<number, ToolbarGroupConfig[]>,
572
+ );
504
573
 
505
- const [showLinkDialog, setShowLinkDialog] = useState(false);
506
- const [selectedLink, setSelectedLink] = useState<any>(null);
507
- const [linkDialogCallback, setLinkDialogCallback] = useState<
508
- ((link: any) => void) | null
509
- >(null);
574
+ // Return sorted entries for consistent rendering
575
+ return Object.entries(groupsByRow)
576
+ .sort(([a], [b]) => parseInt(a) - parseInt(b));
577
+ }, [editorProfile.toolbar.groups]);
510
578
 
511
579
  const editLink = useCallback((element: any) => {
512
580
  const linkType = element.link?.type || "external";
@@ -592,129 +660,25 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
592
660
  [editor, linkDialogCallback],
593
661
  );
594
662
 
595
- const handleLinkButtonClick = useCallback(() => {
596
- editor.insertLink({
597
- onOpenLinkDialog: (callback: (link: any) => void) => {
598
- setSelectedLink({
599
- type: "external",
600
- url: "",
601
- target: "_blank",
602
- });
603
- setShowLinkDialog(true);
604
-
605
- setLinkDialogCallback(() => callback);
606
- },
607
- });
608
- }, [editor]);
609
-
610
- const handleKeyDown = useCallback(
611
- (event: React.KeyboardEvent) => {
612
- if (event.key === "Enter" && event.shiftKey) {
613
- // Handle Shift+Enter for line breaks in all block types
614
- event.preventDefault();
615
-
616
- if (editor.selection) {
617
- // Insert literal <br> text
618
- editor.insertText("<br>");
619
- }
620
- } else if (event.key === "Tab") {
621
- event.preventDefault();
622
-
623
- // Check if we're in a list item more explicitly
624
- if (editor.selection) {
625
- // Try to find the closest list item ancestor using Editor.above
626
- const listItem = Editor.above(editor, {
627
- at: editor.selection,
628
- match: (n) =>
629
- !Editor.isEditor(n) &&
630
- Element.isElement(n) &&
631
- n.type === "list-item",
632
- });
633
-
634
- if (listItem) {
635
- if (event.shiftKey) {
636
- // Shift+Tab: Outdent
637
- editor.outdentList();
638
- } else {
639
- // Tab: Indent
640
- editor.indentList();
641
- }
642
- }
643
- }
644
- } else if (event.key === "Backspace" || event.key === "Delete") {
645
- // Handle empty list items
646
- if (editor.selection) {
647
- const listItem = Editor.above(editor, {
648
- at: editor.selection,
649
- match: (n) =>
650
- !Editor.isEditor(n) &&
651
- Element.isElement(n) &&
652
- n.type === "list-item",
653
- });
654
-
655
- if (listItem) {
656
- const selectedText = Editor.string(editor, editor.selection);
657
- const [node, path] = listItem;
658
- const nodeText = Editor.string(editor, path);
659
-
660
- // If the list item is empty or only contains whitespace, convert to paragraph
661
- if (
662
- !nodeText.trim() ||
663
- (selectedText === nodeText && nodeText.trim())
664
- ) {
665
- event.preventDefault();
666
- Transforms.setNodes(
667
- editor,
668
- { type: "paragraph", listType: undefined, indent: undefined },
669
- {
670
- match: (n) =>
671
- !Editor.isEditor(n) &&
672
- Element.isElement(n) &&
673
- n.type === "list-item",
674
- split: true,
675
- },
676
- );
677
- }
678
- }
679
- }
680
- }
681
- },
682
- [editor],
683
- );
663
+ // Use the keyboard handler from plugins
664
+ const handleKeyDown = useMemo(() => createKeyboardHandler(editor), [editor]);
684
665
 
685
666
  return (
686
667
  <div className={`slate-editor ${props.className}`}>
687
668
  <Slate
688
- key={remountKey}
689
669
  editor={editor}
690
- initialValue={internalValue}
670
+ initialValue={initialSlateValue}
691
671
  onChange={handleChange}
692
672
  >
693
673
  {!readOnly && (
694
674
  <div className="toolbar">
695
- {(() => {
696
- // Group toolbar items by row
697
- const groupsByRow = editorProfile.toolbar.groups.reduce(
698
- (acc, group, index) => {
699
- const row = group.row !== undefined ? group.row : index;
700
- if (!acc[row]) acc[row] = [];
701
- acc[row].push(group);
702
- return acc;
703
- },
704
- {} as Record<number, typeof editorProfile.toolbar.groups>,
705
- );
706
-
707
- // Render each row
708
- return Object.entries(groupsByRow)
709
- .sort(([a], [b]) => parseInt(a) - parseInt(b))
710
- .map(([rowIndex, rowGroups]) => (
711
- <div key={`row-${rowIndex}`} className="toolbar-row">
712
- {rowGroups.map((group) =>
713
- renderToolbarGroup(group, parseInt(rowIndex)),
714
- )}
715
- </div>
716
- ));
717
- })()}
675
+ {toolbarStructure.map(([rowIndex, rowGroups]) => (
676
+ <div key={`row-${rowIndex}`} className="toolbar-row">
677
+ {rowGroups.map((group) =>
678
+ renderToolbarGroup(group, parseInt(rowIndex)),
679
+ )}
680
+ </div>
681
+ ))}
718
682
  </div>
719
683
  )}
720
684
  <Editable
@@ -766,46 +730,12 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
766
730
  const listType = element.listType || "unordered";
767
731
  const isOrdered = listType === "ordered";
768
732
 
769
- // Calculate proper numbering for ordered lists
770
- let listNumber = 1;
771
- if (isOrdered) {
772
- // Find the position of this item within its level
773
- const allElements = editor.children as CustomElement[];
774
- const currentIndex = allElements.findIndex(
775
- (el) => el === element,
776
- );
777
-
778
- // Count preceding list items at the same indent level and list type
779
- let count = 0;
780
- for (let i = 0; i < currentIndex; i++) {
781
- const prevElement = allElements[i];
782
- if (
783
- prevElement &&
784
- prevElement.type === "list-item" &&
785
- prevElement.listType === "ordered" &&
786
- (prevElement.indent || 0) === indent
787
- ) {
788
- count++;
789
- } else if (
790
- prevElement &&
791
- prevElement.type === "list-item" &&
792
- (prevElement.indent || 0) < indent
793
- ) {
794
- // Reset count when we encounter a parent-level item
795
- count = 0;
796
- }
797
- }
798
- listNumber = count + 1;
799
- }
800
-
801
733
  const listStyle: React.CSSProperties = {
802
734
  ...style,
803
735
  position: "relative",
804
736
  listStyleType: "none",
805
737
  };
806
738
 
807
- const bulletContent = isOrdered ? `${listNumber}.` : "";
808
-
809
739
  return (
810
740
  <div
811
741
  {...attributes}
@@ -813,20 +743,35 @@ export const ReactSlate = forwardRef<any, ReactSlateProps>((props, ref) => {
813
743
  className={`slate-list-item slate-list-${listType}`}
814
744
  data-indent={indent}
815
745
  >
816
- <span className="slate-list-bullet">{bulletContent}</span>
746
+ <span className="slate-list-bullet"></span>
817
747
  <div className="slate-list-content">{children}</div>
818
748
  </div>
819
749
  );
820
750
  }
821
751
 
752
+ if (element.type === "horizontal-rule") {
753
+ return (
754
+ <div {...attributes} contentEditable={false} style={{ ...style, userSelect: "none" }}>
755
+ <hr style={{
756
+ border: "none",
757
+ borderTop: "1px solid #ccc",
758
+ margin: "1em 0",
759
+ width: "100%"
760
+ }} />
761
+ {children}
762
+ </div>
763
+ );
764
+ }
765
+
822
766
  // Handle different block types using built-in SLATE_BLOCKS configuration
823
- const blockConfig = SLATE_BLOCKS[element.type];
767
+ const isValidBlockId = element.type in SLATE_BLOCKS;
768
+ const blockConfig = isValidBlockId ? SLATE_BLOCKS[element.type as BlockId] : undefined;
824
769
  if (blockConfig && element.type === "no-tag") {
825
770
  // Special handling for no-tag blocks (plain text without wrapper)
826
771
  return (
827
- <span {...attributes} style={style}>
772
+ <div {...attributes} style={style}>
828
773
  {children}
829
- </span>
774
+ </div>
830
775
  );
831
776
  }
832
777