@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
@@ -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,9 +324,10 @@ 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;
290
333
  const groupStyle = {
@@ -296,11 +339,12 @@ export const ReactSlate = forwardRef((props, ref) => {
296
339
  return (_jsx("div", { style: groupStyle, children: group.display === "buttons"
297
340
  ? (() => {
298
341
  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)
342
+ return subGroups.map((subGroup, subGroupIndex) => (_jsx("div", { className: "toolbar-button-group", children: subGroup.map((option) => {
343
+ const optionObj = getOption(option);
344
+ const handler = buttonHandlers[`${option.type}-${option.id}`];
345
+ if (!optionObj || !handler)
302
346
  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}`));
347
+ return (_jsx(ToolbarButtonWrapper, { option: option, icon: optionObj.icon, editor: editor, onMouseDown: handler }, `${option.type}-${option.id}`));
304
348
  }) }, `subgroup-${subGroupIndex}`)));
305
349
  })()
306
350
  : (() => {
@@ -309,27 +353,39 @@ export const ReactSlate = forwardRef((props, ref) => {
309
353
  // If there's only one block option, render it as a disabled-style button
310
354
  if (blockOptions.length === 1) {
311
355
  const singleOption = dropdownOptions.find((opt) => blockOptions.some((blockOpt) => blockOpt.type === "block" &&
312
- getOption(blockOpt.type, blockOpt.id) === opt.value));
356
+ getOption(blockOpt) === opt.value));
313
357
  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
358
  }
315
359
  // Multiple options - render normally wrapped in grey container
316
360
  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
- } }) }));
361
+ const buttonStyle = {
362
+ padding: "5px 10px",
363
+ margin: "0",
364
+ background: isActive ? "#ffffff" : "transparent",
365
+ border: "none",
366
+ borderRadius: "3px",
367
+ cursor: "pointer",
368
+ boxShadow: isActive
369
+ ? "0 1px 2px rgba(0,0,0,0.1)"
370
+ : "none",
371
+ };
372
+ return (_jsx("div", { className: "toolbar-dropdown-container", children: _jsx(MemoizedEditorDropdown, { options: dropdownOptions, editor: editor, label: group.label, buttonStyle: buttonStyle }) }));
328
373
  })() }, `group-${group.id || index}`));
329
- };
330
- const [showLinkDialog, setShowLinkDialog] = useState(false);
331
- const [selectedLink, setSelectedLink] = useState(null);
332
- const [linkDialogCallback, setLinkDialogCallback] = useState(null);
374
+ }, [getOption, splitOptionsByDividers, createDropdownOptions, optionHandlers, buttonHandlers, editor]);
375
+ // Memoize toolbar structure (expensive grouping operation)
376
+ const toolbarStructure = useMemo(() => {
377
+ // Group toolbar items by row
378
+ const groupsByRow = editorProfile.toolbar.groups.reduce((acc, group, index) => {
379
+ const row = group.row !== undefined ? group.row : index;
380
+ if (!acc[row])
381
+ acc[row] = [];
382
+ acc[row].push(group);
383
+ return acc;
384
+ }, {});
385
+ // Return sorted entries for consistent rendering
386
+ return Object.entries(groupsByRow)
387
+ .sort(([a], [b]) => parseInt(a) - parseInt(b));
388
+ }, [editorProfile.toolbar.groups]);
333
389
  const editLink = useCallback((element) => {
334
390
  const linkType = element.link?.type || "external";
335
391
  let linkData;
@@ -402,93 +458,9 @@ export const ReactSlate = forwardRef((props, ref) => {
402
458
  setShowLinkDialog(false);
403
459
  setSelectedLink(null);
404
460
  }, [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 }) => {
461
+ // Use the keyboard handler from plugins
462
+ const handleKeyDown = useMemo(() => createKeyboardHandler(editor), [editor]);
463
+ 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
464
  const style = {
493
465
  textAlign: element.align || "left",
494
466
  };
@@ -508,44 +480,27 @@ export const ReactSlate = forwardRef((props, ref) => {
508
480
  const indent = element.indent || 0;
509
481
  const listType = element.listType || "unordered";
510
482
  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
483
  const listStyle = {
537
484
  ...style,
538
485
  position: "relative",
539
486
  listStyleType: "none",
540
487
  };
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 })] }));
488
+ 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 })] }));
489
+ }
490
+ if (element.type === "horizontal-rule") {
491
+ return (_jsxs("div", { ...attributes, contentEditable: false, style: { ...style, userSelect: "none" }, children: [_jsx("hr", { style: {
492
+ border: "none",
493
+ borderTop: "1px solid #ccc",
494
+ margin: "1em 0",
495
+ width: "100%"
496
+ } }), children] }));
543
497
  }
544
498
  // Handle different block types using built-in SLATE_BLOCKS configuration
545
- const blockConfig = SLATE_BLOCKS[element.type];
499
+ const isValidBlockId = element.type in SLATE_BLOCKS;
500
+ const blockConfig = isValidBlockId ? SLATE_BLOCKS[element.type] : undefined;
546
501
  if (blockConfig && element.type === "no-tag") {
547
502
  // Special handling for no-tag blocks (plain text without wrapper)
548
- return (_jsx("span", { ...attributes, style: style, children: children }));
503
+ return (_jsx("div", { ...attributes, style: style, children: children }));
549
504
  }
550
505
  // For standard blocks, use the appropriate HTML tag
551
506
  if (blockConfig) {
@@ -574,7 +529,7 @@ export const ReactSlate = forwardRef((props, ref) => {
574
529
  }
575
530
  });
576
531
  return el;
577
- } })] }, remountKey), showLinkDialog && selectedLink && (_jsx(LinkEditorDialog, { linkValue: selectedLink, onOk: handleLinkUpdate, onCancel: () => {
532
+ } })] }), showLinkDialog && selectedLink && (_jsx(LinkEditorDialog, { linkValue: selectedLink, onOk: handleLinkUpdate, onCancel: () => {
578
533
  setShowLinkDialog(false);
579
534
  setSelectedLink(null);
580
535
  setLinkDialogCallback(null);