@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,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React, { useCallback, useMemo, useState, forwardRef, useRef, useEffect, } from "react";
2
+ import React, { useCallback, useMemo, useState, forwardRef, useRef, useEffect, memo, } from "react";
3
3
  import { createEditor, Editor, Element, Transforms } from "slate";
4
4
  import { Slate, Editable, withReact, ReactEditor } from "slate-react";
5
5
  import { withHistory } from "slate-history";
6
- import isEqual from "lodash/isEqual";
7
6
  import "./ReactSlate.css";
8
7
  import { SLATE_MARKS, SLATE_BLOCKS, SLATE_ALIGNMENTS, } from "../types";
9
8
  import EditorDropdown from "./EditorDropdown";
@@ -11,63 +10,45 @@ import { ToolbarButton } from "./ToolbarButton";
11
10
  import { LinkEditorDialog } from "../../../LinkEditorDialog";
12
11
  import { htmlToSlate, slateToHtml } from "../utils/conversion";
13
12
  import { createPluginsFromConfig } from "../config/pluginFactory";
13
+ import { createKeyboardHandler } from "../utils/plugins";
14
14
  import { useCachedSimplifiedProfile } from "../hooks/useProfileCache";
15
15
  import { classNames } from "primereact/utils";
16
- // Helper function to normalize HTML for comparison
17
- const normalizeHtmlForComparison = (html) => {
18
- if (!html)
19
- return "";
20
- // Create a temporary DOM element to normalize the HTML
21
- const temp = document.createElement("div");
22
- temp.innerHTML = html;
23
- // Remove extra whitespace and normalize structure
24
- const normalizedHtml = temp.innerHTML
25
- .replace(/\s+/g, " ") // Replace multiple spaces with single space
26
- .replace(/>\s+</g, "><") // Remove spaces between tags
27
- .trim();
28
- // Handle common empty content patterns
29
- if (normalizedHtml === "<p><br></p>" ||
30
- normalizedHtml === "<br>" ||
31
- normalizedHtml === "<p></p>") {
32
- return "";
33
- }
34
- return normalizedHtml;
35
- };
36
- // Helper function to check if HTML conversion is stable
37
- const isHtmlConversionStable = (originalHtml, slateValue, profile) => {
38
- try {
39
- // Convert Slate back to HTML
40
- const convertedHtml = slateToHtml(slateValue, profile);
41
- // Normalize both HTML strings for comparison
42
- const normalizedOriginal = normalizeHtmlForComparison(originalHtml);
43
- const normalizedConverted = normalizeHtmlForComparison(convertedHtml);
44
- // Debug logging (can be enabled for troubleshooting)
45
- // console.log('HTML Stability Check:', {
46
- // original: normalizedOriginal,
47
- // converted: normalizedConverted,
48
- // stable: normalizedOriginal === normalizedConverted
49
- // });
50
- // Check if they're equivalent
51
- return normalizedOriginal === normalizedConverted;
52
- }
53
- catch (error) {
54
- console.warn("HTML conversion stability check failed:", error);
55
- // If we can't check stability, assume it's not stable to be safe
56
- return false;
16
+ // Toolbar button component - not memoized to allow reactivity to editor state changes
17
+ const ToolbarButtonWrapper = ({ option, icon, editor, onMouseDown }) => {
18
+ // Calculate active state on every render - this is lightweight and ensures reactivity
19
+ let isActive = false;
20
+ switch (option.type) {
21
+ case "mark":
22
+ isActive = editor.isMarkActive(option.id);
23
+ break;
24
+ case "block":
25
+ isActive = editor.isBlockActive(option.id);
26
+ break;
27
+ case "alignment":
28
+ isActive = editor.isAlignActive(SLATE_ALIGNMENTS[option.id].value);
29
+ break;
30
+ case "link":
31
+ isActive = editor.isLinkActive();
32
+ break;
33
+ case "list":
34
+ const listType = option.id === "unordered-list" ? "unordered" : "ordered";
35
+ isActive = editor.isListActive(listType);
36
+ break;
37
+ case "insertion":
38
+ isActive = false; // Insertion buttons are never active
39
+ break;
40
+ default:
41
+ isActive = false;
57
42
  }
43
+ return (_jsx(ToolbarButton, { icon: icon, active: isActive, onMouseDown: onMouseDown }));
58
44
  };
45
+ // Memoized dropdown component to prevent unnecessary re-renders
46
+ const MemoizedEditorDropdown = memo(({ options, editor, label, buttonStyle }) => {
47
+ return (_jsx(EditorDropdown, { options: options, editor: editor, label: label, buttonStyle: buttonStyle }));
48
+ });
49
+ MemoizedEditorDropdown.displayName = "MemoizedEditorDropdown";
59
50
  export const ReactSlate = forwardRef((props, ref) => {
60
51
  const { value = "", onChange, onFocus, onBlur, readOnly = false, placeholder = "Enter some text...", profile, } = props;
61
- // Create the Slate editor with plugins
62
- const editor = useMemo(() => {
63
- // Start with base editor
64
- let slateEditor = createEditor();
65
- // Apply core plugins
66
- slateEditor = withReact(slateEditor);
67
- slateEditor = withHistory(slateEditor);
68
- slateEditor = createPluginsFromConfig(slateEditor);
69
- return slateEditor;
70
- }, []);
71
52
  const editorProfile = profile || {
72
53
  toolbar: {
73
54
  groups: [],
@@ -75,180 +56,225 @@ export const ReactSlate = forwardRef((props, ref) => {
75
56
  };
76
57
  // Convert to simplified profile for the conversion functions (with caching)
77
58
  const simplifiedProfile = useCachedSimplifiedProfile(editorProfile);
78
- // Store the original HTML value for comparison
79
- const originalValueRef = useRef(value);
80
- const isInitialLoadRef = useRef(true);
81
- // Update original value when prop value changes
82
- useMemo(() => {
83
- if (value !== originalValueRef.current) {
84
- originalValueRef.current = value;
85
- isInitialLoadRef.current = true;
59
+ // Create the Slate editor with plugins - only once
60
+ const editor = useMemo(() => {
61
+ const baseEditor = createEditor();
62
+ // Apply plugins in correct order
63
+ const enhancedEditor = createPluginsFromConfig(withHistory(withReact(baseEditor)));
64
+ return enhancedEditor;
65
+ }, []);
66
+ // Initial value for Slate - capture initial props at mount time
67
+ const initialSlateValue = useMemo(() => {
68
+ try {
69
+ return htmlToSlate(value, simplifiedProfile);
86
70
  }
87
- }, [value]);
88
- // Convert the HTML value to Slate format
89
- const [internalValue, setInternalValue] = useState(htmlToSlate(value, simplifiedProfile));
90
- // Counter to force remount when value changes externally
91
- const [remountKey, setRemountKey] = useState(0);
92
- // Update internal value when prop value changes
93
- useEffect(() => {
94
- const newValue = htmlToSlate(value, simplifiedProfile);
95
- if (!isEqual(newValue, internalValue)) {
96
- setInternalValue(newValue);
97
- // Force remount by incrementing the key
98
- setRemountKey((prev) => prev + 1);
71
+ catch (error) {
72
+ console.warn("Failed to convert initial HTML to Slate:", error);
73
+ return [{ type: "paragraph", children: [{ text: "" }] }];
74
+ }
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, []); // Intentionally empty - we want initial value only
77
+ // Convert HTML to Slate format - memoized for performance
78
+ const slateValue = useMemo(() => {
79
+ try {
80
+ return htmlToSlate(value, simplifiedProfile);
81
+ }
82
+ catch (error) {
83
+ console.warn("Failed to convert HTML to Slate:", error);
84
+ return [{ type: "paragraph", children: [{ text: "" }] }];
99
85
  }
100
86
  }, [value, simplifiedProfile]);
101
- // Handle value changes with round-trip validation
87
+ // Track previous value to detect external changes
88
+ const previousValueRef = useRef(value);
89
+ const isInternalChangeRef = useRef(false);
90
+ // Update editor when value changes from outside
91
+ useEffect(() => {
92
+ // Skip if this is an internal change (from our own onChange)
93
+ if (isInternalChangeRef.current) {
94
+ isInternalChangeRef.current = false;
95
+ previousValueRef.current = value;
96
+ return;
97
+ }
98
+ // Only update if value actually changed from outside
99
+ if (value !== previousValueRef.current) {
100
+ previousValueRef.current = value;
101
+ // Update editor children with new value
102
+ const newChildren = slateValue;
103
+ // Prevent infinite loops by temporarily removing selection
104
+ const currentSelection = editor.selection;
105
+ // Update the editor's children
106
+ editor.children = newChildren;
107
+ // Normalize the editor to ensure consistency
108
+ Editor.normalize(editor, { force: true });
109
+ // Restore selection if it was valid, otherwise set to end
110
+ if (currentSelection && Editor.hasPath(editor, currentSelection.anchor.path)) {
111
+ try {
112
+ Transforms.select(editor, currentSelection);
113
+ }
114
+ catch {
115
+ // If selection is invalid, move to end
116
+ Transforms.select(editor, Editor.end(editor, []));
117
+ }
118
+ }
119
+ else {
120
+ // Move to end of document
121
+ Transforms.select(editor, Editor.end(editor, []));
122
+ }
123
+ }
124
+ }, [editor, slateValue, value]);
125
+ // Handle value changes with proper Slate patterns
102
126
  const handleChange = useCallback((newValue) => {
103
- setInternalValue(newValue);
104
127
  if (onChange) {
105
- const html = slateToHtml(newValue, simplifiedProfile);
106
- // Check if this is the initial load or if the conversion is stable
107
- if (isInitialLoadRef.current) {
108
- // On initial load, check if the HTML conversion is stable
109
- const isStable = isHtmlConversionStable(originalValueRef.current, newValue, simplifiedProfile);
110
- if (isStable) {
111
- // If stable, don't trigger onChange on initial load
112
- isInitialLoadRef.current = false;
113
- return;
128
+ try {
129
+ const html = slateToHtml(newValue, simplifiedProfile);
130
+ // Only trigger onChange if content actually changed
131
+ if (html !== value) {
132
+ // Mark this as an internal change
133
+ isInternalChangeRef.current = true;
134
+ onChange(html);
114
135
  }
115
- // If not stable, we still need to proceed, but mark as no longer initial load
116
- isInitialLoadRef.current = false;
117
136
  }
118
- // For subsequent changes, only trigger onChange if there's a meaningful difference
119
- const normalizedNewHtml = normalizeHtmlForComparison(html);
120
- const normalizedOriginalHtml = normalizeHtmlForComparison(originalValueRef.current);
121
- if (normalizedNewHtml !== normalizedOriginalHtml) {
122
- // Update the original value reference to the new HTML
123
- originalValueRef.current = html;
124
- onChange(html);
137
+ catch (error) {
138
+ console.warn("Failed to convert Slate to HTML:", error);
125
139
  }
126
140
  }
127
- }, [onChange, simplifiedProfile]);
128
- const getOption = (type, id) => {
129
- switch (type) {
130
- case "mark":
131
- const markConfig = SLATE_MARKS[id];
132
- return markConfig
133
- ? {
134
- id,
135
- label: markConfig.label,
136
- icon: markConfig.icon,
137
- isActive: (editor) => editor.isMarkActive(id),
138
- toggle: (editor, event) => {
139
- event.preventDefault();
140
- editor.toggleMark(id);
141
- },
141
+ }, [onChange, simplifiedProfile, value]);
142
+ // Link dialog state
143
+ const [showLinkDialog, setShowLinkDialog] = useState(false);
144
+ const [selectedLink, setSelectedLink] = useState(null);
145
+ const [linkDialogCallback, setLinkDialogCallback] = useState(null);
146
+ const handleLinkButtonClick = useCallback(() => {
147
+ editor.insertLink({
148
+ onOpenLinkDialog: (callback) => {
149
+ setSelectedLink({
150
+ type: "external",
151
+ url: "",
152
+ target: "_blank",
153
+ });
154
+ setShowLinkDialog(true);
155
+ setLinkDialogCallback(() => callback);
156
+ },
157
+ });
158
+ }, [editor]);
159
+ // Memoize option handlers to prevent creating new functions on every render
160
+ const optionHandlers = useMemo(() => {
161
+ const handlers = {};
162
+ const handleOptionSelect = (option) => (editor, event) => {
163
+ event.preventDefault();
164
+ switch (option.type) {
165
+ case "mark":
166
+ editor.toggleMark(option.id);
167
+ break;
168
+ case "block":
169
+ editor.toggleBlock(option.id);
170
+ break;
171
+ case "alignment":
172
+ const alignConfig = SLATE_ALIGNMENTS[option.id];
173
+ editor.toggleAlign(alignConfig.value);
174
+ break;
175
+ case "link":
176
+ handleLinkButtonClick();
177
+ break;
178
+ case "list":
179
+ const listType = option.id === "unordered-list" ? "unordered" : "ordered";
180
+ editor.toggleList(listType);
181
+ break;
182
+ case "insertion":
183
+ if (option.id === "horizontal-rule") {
184
+ editor.insertHorizontalRule();
142
185
  }
143
- : undefined;
186
+ break;
187
+ default:
188
+ console.warn(`Unhandled option type: ${option.type}`);
189
+ }
190
+ };
191
+ // Pre-create handlers for all possible options
192
+ editorProfile.toolbar.groups.forEach(group => {
193
+ group.options.forEach(option => {
194
+ const key = `${option.type}-${option.id}`;
195
+ handlers[key] = handleOptionSelect(option);
196
+ });
197
+ });
198
+ return handlers;
199
+ }, [editor, editorProfile.toolbar.groups, handleLinkButtonClick]);
200
+ // Create button handlers that adapt the signature for toolbar buttons
201
+ const buttonHandlers = useMemo(() => {
202
+ const handlers = {};
203
+ Object.entries(optionHandlers).forEach(([key, handler]) => {
204
+ handlers[key] = (event) => {
205
+ handler(editor, event);
206
+ };
207
+ });
208
+ return handlers;
209
+ }, [optionHandlers, editor]);
210
+ const getOption = useCallback((option) => {
211
+ const handlerKey = `${option.type}-${option.id}`;
212
+ switch (option.type) {
213
+ case "mark":
214
+ const markConfig = SLATE_MARKS[option.id];
215
+ return {
216
+ id: option.id,
217
+ label: markConfig.label,
218
+ icon: markConfig.icon,
219
+ isActive: (editor) => editor.isMarkActive(option.id),
220
+ toggle: optionHandlers[handlerKey],
221
+ };
144
222
  case "block":
145
- const blockConfig = SLATE_BLOCKS[id];
146
- return blockConfig
147
- ? {
148
- id,
149
- label: blockConfig.label,
150
- icon: blockConfig.icon,
151
- isActive: (editor) => editor.isBlockActive(id),
152
- toggle: (editor, event) => {
153
- event.preventDefault();
154
- editor.toggleBlock(id);
155
- },
156
- }
157
- : undefined;
223
+ const blockConfig = SLATE_BLOCKS[option.id];
224
+ return {
225
+ id: option.id,
226
+ label: blockConfig.label,
227
+ icon: blockConfig.icon,
228
+ isActive: (editor) => editor.isBlockActive(option.id),
229
+ toggle: optionHandlers[handlerKey],
230
+ };
158
231
  case "alignment":
159
- const alignConfig = SLATE_ALIGNMENTS[id];
160
- return alignConfig
161
- ? {
162
- id,
163
- label: alignConfig.label,
164
- icon: alignConfig.icon,
165
- isActive: (editor) => editor.isAlignActive(alignConfig.value),
166
- toggle: (editor, event) => {
167
- event.preventDefault();
168
- editor.toggleAlign(alignConfig.value);
169
- },
170
- }
171
- : undefined;
232
+ const alignConfig = SLATE_ALIGNMENTS[option.id];
233
+ return {
234
+ id: option.id,
235
+ label: alignConfig.label,
236
+ icon: alignConfig.icon,
237
+ isActive: (editor) => editor.isAlignActive(alignConfig.value),
238
+ toggle: optionHandlers[handlerKey],
239
+ };
172
240
  case "link":
173
241
  return {
174
242
  id: "link",
175
243
  label: "Link",
176
244
  icon: "🔗",
177
245
  isActive: (editor) => editor.isLinkActive(),
178
- toggle: (editor, event) => {
179
- event.preventDefault();
180
- handleLinkButtonClick();
181
- },
246
+ toggle: optionHandlers[handlerKey],
182
247
  };
183
248
  case "list":
184
249
  return {
185
- id,
186
- label: id === "unordered-list" ? "Bulleted List" : "Numbered List",
187
- icon: id === "unordered-list" ? "•" : "1.",
188
- isActive: (editor) => editor.isListActive(id === "unordered-list" ? "unordered" : "ordered"),
189
- toggle: (editor, event) => {
190
- event.preventDefault();
191
- editor.toggleList(id === "unordered-list" ? "unordered" : "ordered");
192
- },
250
+ id: option.id,
251
+ label: option.id === "unordered-list" ? "Bulleted List" : "Numbered List",
252
+ icon: option.id === "unordered-list" ? "•" : "1.",
253
+ isActive: (editor) => editor.isListActive(option.id === "unordered-list" ? "unordered" : "ordered"),
254
+ toggle: optionHandlers[handlerKey],
193
255
  };
194
256
  case "divider":
195
257
  return { id: "divider", label: "Divider" };
258
+ case "insertion":
259
+ if (option.id === "horizontal-rule") {
260
+ return {
261
+ id: option.id,
262
+ label: "Horizontal Rule",
263
+ icon: "─",
264
+ isActive: () => false, // Insertion buttons are never "active"
265
+ toggle: optionHandlers[handlerKey],
266
+ };
267
+ }
268
+ return undefined;
196
269
  default:
197
270
  return undefined;
198
271
  }
199
- };
200
- const optionHandlers = {
201
- mark: {
202
- isActive: (id) => editor.isMarkActive(id),
203
- toggle: (id) => editor.toggleMark(id),
204
- },
205
- block: {
206
- isActive: (id) => editor.isBlockActive(id),
207
- toggle: (id) => editor.toggleBlock(id),
208
- },
209
- alignment: {
210
- isActive: (id) => {
211
- const alignConfig = SLATE_ALIGNMENTS[id];
212
- return alignConfig ? editor.isAlignActive(alignConfig.value) : false;
213
- },
214
- toggle: (id) => {
215
- const alignConfig = SLATE_ALIGNMENTS[id];
216
- if (alignConfig) {
217
- editor.toggleAlign(alignConfig.value);
218
- }
219
- },
220
- },
221
- link: {
222
- isActive: () => editor.isLinkActive(),
223
- toggle: () => handleLinkButtonClick(),
224
- },
225
- list: {
226
- isActive: (id) => {
227
- const listType = id === "unordered-list" ? "unordered" : "ordered";
228
- return editor.isListActive(listType);
229
- },
230
- toggle: (id, event) => {
231
- const listType = id === "unordered-list" ? "unordered" : "ordered";
232
- editor.toggleList(listType);
233
- },
234
- },
235
- };
236
- const isOptionActive = (type, id) => {
237
- const handler = optionHandlers[type];
238
- return handler ? handler.isActive(id) : false;
239
- };
240
- const handleOptionSelect = (type, id) => (event) => {
241
- event.preventDefault();
242
- const handler = optionHandlers[type];
243
- if (handler) {
244
- return handler.toggle(id, event);
245
- }
246
- console.warn(`Unhandled option type: ${type}`);
247
- };
248
- const createDropdownOptions = (options) => {
272
+ }, [optionHandlers]);
273
+ // Memoize dropdown options creation with strict type safety
274
+ const createDropdownOptions = useCallback((options) => {
249
275
  return options
250
276
  .map((option) => {
251
- const optionObj = getOption(option.type, option.id);
277
+ const optionObj = getOption(option);
252
278
  if (!optionObj)
253
279
  return null;
254
280
  return {
@@ -256,14 +282,30 @@ export const ReactSlate = forwardRef((props, ref) => {
256
282
  label: optionObj.label || option.id,
257
283
  icon: optionObj.icon,
258
284
  style: optionObj.style,
259
- isActive: (editor) => isOptionActive(option.type, option.id),
260
- onSelect: (editor, event) => handleOptionSelect(option.type, option.id)(event),
285
+ isActive: (editor) => {
286
+ switch (option.type) {
287
+ case "mark":
288
+ return editor.isMarkActive(option.id);
289
+ case "block":
290
+ return editor.isBlockActive(option.id);
291
+ case "alignment":
292
+ return editor.isAlignActive(SLATE_ALIGNMENTS[option.id].value);
293
+ case "link":
294
+ return editor.isLinkActive();
295
+ case "list":
296
+ const listType = option.id === "unordered-list" ? "unordered" : "ordered";
297
+ return editor.isListActive(listType);
298
+ default:
299
+ return false;
300
+ }
301
+ },
302
+ onSelect: optionHandlers[`${option.type}-${option.id}`],
261
303
  };
262
304
  })
263
305
  .filter(Boolean);
264
- };
306
+ }, [getOption, optionHandlers]);
265
307
  // Helper function to split options by dividers into sub-groups
266
- const splitOptionsByDividers = (options) => {
308
+ const splitOptionsByDividers = useCallback((options) => {
267
309
  const subGroups = [];
268
310
  let currentGroup = [];
269
311
  options.forEach((option) => {
@@ -282,11 +324,15 @@ export const ReactSlate = forwardRef((props, ref) => {
282
324
  subGroups.push(currentGroup);
283
325
  }
284
326
  return subGroups;
285
- };
286
- const renderToolbarGroup = (group, index) => {
287
- const validOptions = group.options.filter((option) => getOption(option.type, option.id) || option.type === "divider");
327
+ }, []);
328
+ // Memoize toolbar group rendering
329
+ const renderToolbarGroup = useCallback((group, index) => {
330
+ const validOptions = group.options.filter((option) => getOption(option) || option.type === "divider");
288
331
  if (validOptions.length === 0)
289
332
  return null;
333
+ // Skip rendering dropdown groups with only one option
334
+ if (group.display === "dropdown" && validOptions.length === 1)
335
+ return null;
290
336
  const groupStyle = {
291
337
  display: "flex",
292
338
  alignItems: "center",
@@ -296,11 +342,12 @@ export const ReactSlate = forwardRef((props, ref) => {
296
342
  return (_jsx("div", { style: groupStyle, children: group.display === "buttons"
297
343
  ? (() => {
298
344
  const subGroups = splitOptionsByDividers(validOptions);
299
- return subGroups.map((subGroup, subGroupIndex) => (_jsx("div", { className: "toolbar-button-group", children: subGroup.map((option, optionIndex) => {
300
- const optionObj = getOption(option.type, option.id);
301
- if (!optionObj)
345
+ return subGroups.map((subGroup, subGroupIndex) => (_jsx("div", { className: "toolbar-button-group", children: subGroup.map((option) => {
346
+ const optionObj = getOption(option);
347
+ const handler = buttonHandlers[`${option.type}-${option.id}`];
348
+ if (!optionObj || !handler)
302
349
  return null;
303
- return (_jsx(ToolbarButton, { icon: optionObj.icon, active: isOptionActive(option.type, option.id), onMouseDown: handleOptionSelect(option.type, option.id) }, `${option.type}-${option.id}`));
350
+ return (_jsx(ToolbarButtonWrapper, { option: option, icon: optionObj.icon, editor: editor, onMouseDown: handler }, `${option.type}-${option.id}`));
304
351
  }) }, `subgroup-${subGroupIndex}`)));
305
352
  })()
306
353
  : (() => {
@@ -309,27 +356,39 @@ export const ReactSlate = forwardRef((props, ref) => {
309
356
  // If there's only one block option, render it as a disabled-style button
310
357
  if (blockOptions.length === 1) {
311
358
  const singleOption = dropdownOptions.find((opt) => blockOptions.some((blockOpt) => blockOpt.type === "block" &&
312
- getOption(blockOpt.type, blockOpt.id) === opt.value));
359
+ getOption(blockOpt) === opt.value));
313
360
  return (_jsx("div", { className: "toolbar-dropdown-container", children: _jsx("button", { className: "toolbar-dropdown-button", disabled: true, children: group.label ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "toolbar-dropdown-content", children: [group.label, ": ", singleOption?.label] }), _jsx("span", { className: "toolbar-dropdown-arrow", children: "\u25BC" })] })) : group.showIconsOnly ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "toolbar-dropdown-icon", children: singleOption?.icon }), _jsx("span", { className: "toolbar-dropdown-arrow", children: "\u25BC" })] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { className: "toolbar-dropdown-icon", children: [singleOption?.icon && (_jsx("span", { className: "toolbar-dropdown-icon", children: singleOption.icon })), _jsx("span", { className: "toolbar-dropdown-content", children: singleOption?.label })] }), _jsx("span", { className: "toolbar-dropdown-arrow", children: "\u25BC" })] })) }) }));
314
361
  }
315
362
  // Multiple options - render normally wrapped in grey container
316
363
  const isActive = dropdownOptions.some((option) => option.isActive(editor));
317
- return (_jsx("div", { className: "toolbar-dropdown-container", children: _jsx(EditorDropdown, { options: dropdownOptions, editor: editor, label: group.label, buttonStyle: {
318
- padding: "5px 10px",
319
- margin: "0",
320
- background: isActive ? "#ffffff" : "transparent",
321
- border: "none",
322
- borderRadius: "3px",
323
- cursor: "pointer",
324
- boxShadow: isActive
325
- ? "0 1px 2px rgba(0,0,0,0.1)"
326
- : "none",
327
- } }) }));
364
+ const buttonStyle = {
365
+ padding: "5px 10px",
366
+ margin: "0",
367
+ background: isActive ? "#ffffff" : "transparent",
368
+ border: "none",
369
+ borderRadius: "3px",
370
+ cursor: "pointer",
371
+ boxShadow: isActive
372
+ ? "0 1px 2px rgba(0,0,0,0.1)"
373
+ : "none",
374
+ };
375
+ return (_jsx("div", { className: "toolbar-dropdown-container", children: _jsx(MemoizedEditorDropdown, { options: dropdownOptions, editor: editor, label: group.label, buttonStyle: buttonStyle }) }));
328
376
  })() }, `group-${group.id || index}`));
329
- };
330
- const [showLinkDialog, setShowLinkDialog] = useState(false);
331
- const [selectedLink, setSelectedLink] = useState(null);
332
- const [linkDialogCallback, setLinkDialogCallback] = useState(null);
377
+ }, [getOption, splitOptionsByDividers, createDropdownOptions, optionHandlers, buttonHandlers, editor]);
378
+ // Memoize toolbar structure (expensive grouping operation)
379
+ const toolbarStructure = useMemo(() => {
380
+ // Group toolbar items by row
381
+ const groupsByRow = editorProfile.toolbar.groups.reduce((acc, group, index) => {
382
+ const row = group.row !== undefined ? group.row : index;
383
+ if (!acc[row])
384
+ acc[row] = [];
385
+ acc[row].push(group);
386
+ return acc;
387
+ }, {});
388
+ // Return sorted entries for consistent rendering
389
+ return Object.entries(groupsByRow)
390
+ .sort(([a], [b]) => parseInt(a) - parseInt(b));
391
+ }, [editorProfile.toolbar.groups]);
333
392
  const editLink = useCallback((element) => {
334
393
  const linkType = element.link?.type || "external";
335
394
  let linkData;
@@ -402,93 +461,9 @@ export const ReactSlate = forwardRef((props, ref) => {
402
461
  setShowLinkDialog(false);
403
462
  setSelectedLink(null);
404
463
  }, [editor, linkDialogCallback]);
405
- const handleLinkButtonClick = useCallback(() => {
406
- editor.insertLink({
407
- onOpenLinkDialog: (callback) => {
408
- setSelectedLink({
409
- type: "external",
410
- url: "",
411
- target: "_blank",
412
- });
413
- setShowLinkDialog(true);
414
- setLinkDialogCallback(() => callback);
415
- },
416
- });
417
- }, [editor]);
418
- const handleKeyDown = useCallback((event) => {
419
- if (event.key === "Enter" && event.shiftKey) {
420
- // Handle Shift+Enter for line breaks in all block types
421
- event.preventDefault();
422
- if (editor.selection) {
423
- // Insert literal <br> text
424
- editor.insertText("<br>");
425
- }
426
- }
427
- else if (event.key === "Tab") {
428
- event.preventDefault();
429
- // Check if we're in a list item more explicitly
430
- if (editor.selection) {
431
- // Try to find the closest list item ancestor using Editor.above
432
- const listItem = Editor.above(editor, {
433
- at: editor.selection,
434
- match: (n) => !Editor.isEditor(n) &&
435
- Element.isElement(n) &&
436
- n.type === "list-item",
437
- });
438
- if (listItem) {
439
- if (event.shiftKey) {
440
- // Shift+Tab: Outdent
441
- editor.outdentList();
442
- }
443
- else {
444
- // Tab: Indent
445
- editor.indentList();
446
- }
447
- }
448
- }
449
- }
450
- else if (event.key === "Backspace" || event.key === "Delete") {
451
- // Handle empty list items
452
- if (editor.selection) {
453
- const listItem = Editor.above(editor, {
454
- at: editor.selection,
455
- match: (n) => !Editor.isEditor(n) &&
456
- Element.isElement(n) &&
457
- n.type === "list-item",
458
- });
459
- if (listItem) {
460
- const selectedText = Editor.string(editor, editor.selection);
461
- const [node, path] = listItem;
462
- const nodeText = Editor.string(editor, path);
463
- // If the list item is empty or only contains whitespace, convert to paragraph
464
- if (!nodeText.trim() ||
465
- (selectedText === nodeText && nodeText.trim())) {
466
- event.preventDefault();
467
- Transforms.setNodes(editor, { type: "paragraph", listType: undefined, indent: undefined }, {
468
- match: (n) => !Editor.isEditor(n) &&
469
- Element.isElement(n) &&
470
- n.type === "list-item",
471
- split: true,
472
- });
473
- }
474
- }
475
- }
476
- }
477
- }, [editor]);
478
- return (_jsxs("div", { className: `slate-editor ${props.className}`, children: [_jsxs(Slate, { editor: editor, initialValue: internalValue, onChange: handleChange, children: [!readOnly && (_jsx("div", { className: "toolbar", children: (() => {
479
- // Group toolbar items by row
480
- const groupsByRow = editorProfile.toolbar.groups.reduce((acc, group, index) => {
481
- const row = group.row !== undefined ? group.row : index;
482
- if (!acc[row])
483
- acc[row] = [];
484
- acc[row].push(group);
485
- return acc;
486
- }, {});
487
- // Render each row
488
- return Object.entries(groupsByRow)
489
- .sort(([a], [b]) => parseInt(a) - parseInt(b))
490
- .map(([rowIndex, rowGroups]) => (_jsx("div", { className: "toolbar-row", children: rowGroups.map((group) => renderToolbarGroup(group, parseInt(rowIndex))) }, `row-${rowIndex}`)));
491
- })() })), _jsx(Editable, { className: classNames(readOnly ? "bg-gray-4" : "bg-gray-5", "focus-shadow p-2"), readOnly: readOnly, placeholder: placeholder, renderPlaceholder: ({ attributes, children }) => (_jsx("span", { ...attributes, className: "p-2 text-gray-500", children: children })), onFocus: onFocus, onBlur: onBlur, onKeyDown: handleKeyDown, renderElement: ({ attributes, children, element }) => {
464
+ // Use the keyboard handler from plugins
465
+ const handleKeyDown = useMemo(() => createKeyboardHandler(editor), [editor]);
466
+ return (_jsxs("div", { className: `slate-editor ${props.className}`, children: [_jsxs(Slate, { editor: editor, initialValue: initialSlateValue, onChange: handleChange, children: [!readOnly && (_jsx("div", { className: "toolbar", children: toolbarStructure.map(([rowIndex, rowGroups]) => (_jsx("div", { className: "toolbar-row", children: rowGroups.map((group) => renderToolbarGroup(group, parseInt(rowIndex))) }, `row-${rowIndex}`))) })), _jsx(Editable, { className: classNames(readOnly ? "bg-gray-4" : "bg-gray-5", "focus-shadow p-2"), readOnly: readOnly, placeholder: placeholder, renderPlaceholder: ({ attributes, children }) => (_jsx("span", { ...attributes, className: "p-2 text-gray-500", children: children })), onFocus: onFocus, onBlur: onBlur, onKeyDown: handleKeyDown, renderElement: ({ attributes, children, element }) => {
492
467
  const style = {
493
468
  textAlign: element.align || "left",
494
469
  };
@@ -508,44 +483,27 @@ export const ReactSlate = forwardRef((props, ref) => {
508
483
  const indent = element.indent || 0;
509
484
  const listType = element.listType || "unordered";
510
485
  const isOrdered = listType === "ordered";
511
- // Calculate proper numbering for ordered lists
512
- let listNumber = 1;
513
- if (isOrdered) {
514
- // Find the position of this item within its level
515
- const allElements = editor.children;
516
- const currentIndex = allElements.findIndex((el) => el === element);
517
- // Count preceding list items at the same indent level and list type
518
- let count = 0;
519
- for (let i = 0; i < currentIndex; i++) {
520
- const prevElement = allElements[i];
521
- if (prevElement &&
522
- prevElement.type === "list-item" &&
523
- prevElement.listType === "ordered" &&
524
- (prevElement.indent || 0) === indent) {
525
- count++;
526
- }
527
- else if (prevElement &&
528
- prevElement.type === "list-item" &&
529
- (prevElement.indent || 0) < indent) {
530
- // Reset count when we encounter a parent-level item
531
- count = 0;
532
- }
533
- }
534
- listNumber = count + 1;
535
- }
536
486
  const listStyle = {
537
487
  ...style,
538
488
  position: "relative",
539
489
  listStyleType: "none",
540
490
  };
541
- const bulletContent = isOrdered ? `${listNumber}.` : "";
542
- return (_jsxs("div", { ...attributes, style: listStyle, className: `slate-list-item slate-list-${listType}`, "data-indent": indent, children: [_jsx("span", { className: "slate-list-bullet", children: bulletContent }), _jsx("div", { className: "slate-list-content", children: children })] }));
491
+ return (_jsxs("div", { ...attributes, style: listStyle, className: `slate-list-item slate-list-${listType}`, "data-indent": indent, children: [_jsx("span", { className: "slate-list-bullet" }), _jsx("div", { className: "slate-list-content", children: children })] }));
492
+ }
493
+ if (element.type === "horizontal-rule") {
494
+ return (_jsxs("div", { ...attributes, contentEditable: false, style: { ...style, userSelect: "none" }, children: [_jsx("hr", { style: {
495
+ border: "none",
496
+ borderTop: "1px solid #ccc",
497
+ margin: "1em 0",
498
+ width: "100%"
499
+ } }), children] }));
543
500
  }
544
501
  // Handle different block types using built-in SLATE_BLOCKS configuration
545
- const blockConfig = SLATE_BLOCKS[element.type];
502
+ const isValidBlockId = element.type in SLATE_BLOCKS;
503
+ const blockConfig = isValidBlockId ? SLATE_BLOCKS[element.type] : undefined;
546
504
  if (blockConfig && element.type === "no-tag") {
547
505
  // Special handling for no-tag blocks (plain text without wrapper)
548
- return (_jsx("span", { ...attributes, style: style, children: children }));
506
+ return (_jsx("div", { ...attributes, style: style, children: children }));
549
507
  }
550
508
  // For standard blocks, use the appropriate HTML tag
551
509
  if (blockConfig) {
@@ -574,7 +532,7 @@ export const ReactSlate = forwardRef((props, ref) => {
574
532
  }
575
533
  });
576
534
  return el;
577
- } })] }, remountKey), showLinkDialog && selectedLink && (_jsx(LinkEditorDialog, { linkValue: selectedLink, onOk: handleLinkUpdate, onCancel: () => {
535
+ } })] }), showLinkDialog && selectedLink && (_jsx(LinkEditorDialog, { linkValue: selectedLink, onOk: handleLinkUpdate, onCancel: () => {
578
536
  setShowLinkDialog(false);
579
537
  setSelectedLink(null);
580
538
  setLinkDialogCallback(null);