@apollohg/react-native-prose-editor 0.3.0 → 0.4.1

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 (37) hide show
  1. package/README.md +18 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +515 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
  7. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  8. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  9. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  10. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  11. package/dist/EditorToolbar.d.ts +26 -6
  12. package/dist/EditorToolbar.js +299 -65
  13. package/dist/NativeEditorBridge.d.ts +40 -1
  14. package/dist/NativeEditorBridge.js +184 -90
  15. package/dist/NativeRichTextEditor.d.ts +5 -1
  16. package/dist/NativeRichTextEditor.js +201 -78
  17. package/dist/YjsCollaboration.d.ts +2 -0
  18. package/dist/YjsCollaboration.js +142 -20
  19. package/dist/index.d.ts +1 -1
  20. package/dist/schemas.js +12 -0
  21. package/dist/useNativeEditor.d.ts +2 -0
  22. package/dist/useNativeEditor.js +7 -0
  23. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  24. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  25. package/ios/EditorLayoutManager.swift +3 -3
  26. package/ios/Generated_editor_core.swift +87 -0
  27. package/ios/NativeEditorExpoView.swift +488 -178
  28. package/ios/NativeEditorModule.swift +25 -0
  29. package/ios/PositionBridge.swift +310 -75
  30. package/ios/RenderBridge.swift +362 -27
  31. package/ios/RichTextEditorView.swift +2001 -189
  32. package/ios/editor_coreFFI/editor_coreFFI.h +55 -0
  33. package/package.json +11 -2
  34. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  35. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  36. package/rust/android/x86_64/libeditor_core.so +0 -0
  37. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +128 -0
@@ -1,8 +1,10 @@
1
1
  import type { ActiveState, HistoryState } from './NativeEditorBridge';
2
2
  import type { EditorToolbarTheme } from './EditorTheme';
3
3
  export type EditorToolbarListType = 'bulletList' | 'orderedList';
4
+ export type EditorToolbarHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
4
5
  export type EditorToolbarCommand = 'indentList' | 'outdentList' | 'undo' | 'redo';
5
- export type EditorToolbarDefaultIconId = 'bold' | 'italic' | 'underline' | 'strike' | 'link' | 'image' | 'blockquote' | 'bulletList' | 'orderedList' | 'indentList' | 'outdentList' | 'lineBreak' | 'horizontalRule' | 'undo' | 'redo';
6
+ export type EditorToolbarGroupPresentation = 'expand' | 'menu';
7
+ export type EditorToolbarDefaultIconId = 'bold' | 'italic' | 'underline' | 'strike' | 'link' | 'image' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote' | 'bulletList' | 'orderedList' | 'indentList' | 'outdentList' | 'lineBreak' | 'horizontalRule' | 'undo' | 'redo';
6
8
  export interface EditorToolbarSFSymbolIcon {
7
9
  type: 'sfSymbol';
8
10
  name: string;
@@ -23,7 +25,7 @@ export type EditorToolbarIcon = {
23
25
  android?: EditorToolbarMaterialIcon;
24
26
  fallbackText?: string;
25
27
  };
26
- export type EditorToolbarItem = {
28
+ export type EditorToolbarLeafItem = {
27
29
  type: 'mark';
28
30
  mark: string;
29
31
  label: string;
@@ -39,6 +41,12 @@ export type EditorToolbarItem = {
39
41
  label: string;
40
42
  icon: EditorToolbarIcon;
41
43
  key?: string;
44
+ } | {
45
+ type: 'heading';
46
+ level: EditorToolbarHeadingLevel;
47
+ label: string;
48
+ icon: EditorToolbarIcon;
49
+ key?: string;
42
50
  } | {
43
51
  type: 'blockquote';
44
52
  label: string;
@@ -62,9 +70,6 @@ export type EditorToolbarItem = {
62
70
  label: string;
63
71
  icon: EditorToolbarIcon;
64
72
  key?: string;
65
- } | {
66
- type: 'separator';
67
- key?: string;
68
73
  } | {
69
74
  type: 'action';
70
75
  key: string;
@@ -73,6 +78,19 @@ export type EditorToolbarItem = {
73
78
  isActive?: boolean;
74
79
  isDisabled?: boolean;
75
80
  };
81
+ export type EditorToolbarGroupChildItem = EditorToolbarLeafItem;
82
+ export interface EditorToolbarGroupItem {
83
+ type: 'group';
84
+ key: string;
85
+ label: string;
86
+ icon: EditorToolbarIcon;
87
+ presentation?: EditorToolbarGroupPresentation;
88
+ items: readonly EditorToolbarGroupChildItem[];
89
+ }
90
+ export type EditorToolbarItem = EditorToolbarLeafItem | EditorToolbarGroupItem | {
91
+ type: 'separator';
92
+ key?: string;
93
+ };
76
94
  export declare const DEFAULT_EDITOR_TOOLBAR_ITEMS: readonly EditorToolbarItem[];
77
95
  export interface EditorToolbarProps {
78
96
  /** Currently active marks and nodes from the Rust engine. */
@@ -109,6 +127,8 @@ export interface EditorToolbarProps {
109
127
  onToggleMark?: (mark: string) => void;
110
128
  /** Generic list toggle handler used by configurable list buttons. */
111
129
  onToggleListType?: (listType: EditorToolbarListType) => void;
130
+ /** Generic heading toggle handler used by configurable heading buttons. */
131
+ onToggleHeading?: (level: EditorToolbarHeadingLevel) => void;
112
132
  /** Generic node insertion handler used by configurable node buttons. */
113
133
  onInsertNodeType?: (nodeType: string) => void;
114
134
  /** Generic command handler used by configurable command buttons. */
@@ -126,4 +146,4 @@ export interface EditorToolbarProps {
126
146
  /** Whether to render the built-in top separator line. */
127
147
  showTopBorder?: boolean;
128
148
  }
129
- export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
149
+ export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
@@ -50,6 +50,8 @@ const BUTTON_HIT = 44;
50
50
  const BUTTON_VISIBLE = 32;
51
51
  const TOOLBAR_PADDING_H = 12;
52
52
  const TOOLBAR_PADDING_V = 4;
53
+ const MENU_MARGIN = 8;
54
+ const MENU_WIDTH = 192;
53
55
  const ACTIVE_BG = 'rgba(0, 122, 255, 0.12)';
54
56
  const ACTIVE_COLOR = '#007AFF';
55
57
  const DEFAULT_COLOR = '#666666';
@@ -59,6 +61,8 @@ const TOOLBAR_BG = '#FFFFFF';
59
61
  const TOOLBAR_BORDER = '#E5E5EA';
60
62
  const TOOLBAR_RADIUS = 0;
61
63
  const BUTTON_RADIUS = 6;
64
+ const MENU_BORDER = '#D1D1D6';
65
+ const MENU_SHADOW = '#000000';
62
66
  const DEFAULT_GLYPH_ICONS = {
63
67
  bold: 'B',
64
68
  italic: 'I',
@@ -67,6 +71,12 @@ const DEFAULT_GLYPH_ICONS = {
67
71
  link: '🔗',
68
72
  image: '🖼',
69
73
  blockquote: '❝',
74
+ h1: 'H1',
75
+ h2: 'H2',
76
+ h3: 'H3',
77
+ h4: 'H4',
78
+ h5: 'H5',
79
+ h6: 'H6',
70
80
  bulletList: '•≡',
71
81
  orderedList: '1.',
72
82
  indentList: '→',
@@ -83,6 +93,12 @@ const DEFAULT_MATERIAL_ICONS = {
83
93
  strike: 'strikethrough-s',
84
94
  link: 'link',
85
95
  image: 'image',
96
+ h1: 'title',
97
+ h2: 'title',
98
+ h3: 'title',
99
+ h4: 'title',
100
+ h5: 'title',
101
+ h6: 'title',
86
102
  blockquote: 'format-quote',
87
103
  bulletList: 'format-list-bulleted',
88
104
  orderedList: 'format-list-numbered',
@@ -93,20 +109,22 @@ const DEFAULT_MATERIAL_ICONS = {
93
109
  undo: 'undo',
94
110
  redo: 'redo',
95
111
  };
96
- function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS, theme, showTopBorder = true, }) {
112
+ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS, theme, showTopBorder = true, }) {
97
113
  const marks = activeState.marks ?? {};
98
114
  const nodes = activeState.nodes ?? {};
99
115
  const commands = activeState.commands ?? {};
100
116
  const allowedMarks = activeState.allowedMarks ?? [];
101
117
  const insertableNodes = activeState.insertableNodes ?? [];
118
+ const groupButtonRefs = (0, react_1.useRef)(new Map());
119
+ const { width: windowWidth, height: windowHeight } = (0, react_native_1.useWindowDimensions)();
120
+ const [expandedGroupKey, setExpandedGroupKey] = (0, react_1.useState)(null);
121
+ const [menuState, setMenuState] = (0, react_1.useState)(null);
102
122
  const isMarkActive = (0, react_1.useCallback)((mark) => !!marks[mark], [marks]);
103
123
  const isInList = !!nodes['bulletList'] || !!nodes['orderedList'];
104
124
  const canIndentList = isInList && !!commands['indentList'];
105
125
  const canOutdentList = isInList && !!commands['outdentList'];
106
126
  const getActionForItem = (0, react_1.useCallback)((item) => {
107
127
  switch (item.type) {
108
- case 'separator':
109
- return null;
110
128
  case 'mark':
111
129
  if (onToggleMark) {
112
130
  return () => onToggleMark(item.mark);
@@ -134,6 +152,8 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
134
152
  return onRequestLink ?? null;
135
153
  case 'image':
136
154
  return onRequestImage ?? null;
155
+ case 'heading':
156
+ return onToggleHeading ? () => onToggleHeading(item.level) : null;
137
157
  case 'blockquote':
138
158
  return onToggleBlockquote ?? null;
139
159
  case 'node':
@@ -173,49 +193,44 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
173
193
  onOutdentList,
174
194
  onRedo,
175
195
  onRunCommand,
176
- onRequestLink,
177
196
  onRequestImage,
178
- onToolbarAction,
179
- onToggleBold,
197
+ onRequestLink,
180
198
  onToggleBlockquote,
199
+ onToggleBold,
181
200
  onToggleBulletList,
201
+ onToggleHeading,
182
202
  onToggleItalic,
183
203
  onToggleListType,
184
204
  onToggleMark,
185
205
  onToggleOrderedList,
186
206
  onToggleStrike,
187
207
  onToggleUnderline,
208
+ onToolbarAction,
188
209
  onUndo,
189
210
  ]);
190
- const makeButtonKey = (0, react_1.useCallback)((item, index) => item.key ??
191
- (item.type === 'mark'
192
- ? `mark:${item.mark}:${index}`
211
+ const makeButtonKey = (0, react_1.useCallback)((item, index, prefix = '') => item.key != null
212
+ ? `${prefix}${item.key}`
213
+ : item.type === 'mark'
214
+ ? `${prefix}mark:${item.mark}:${index}`
193
215
  : item.type === 'link'
194
- ? `link:${index}`
216
+ ? `${prefix}link:${index}`
195
217
  : item.type === 'image'
196
- ? `image:${index}`
197
- : item.type === 'blockquote'
198
- ? `blockquote:${index}`
199
- : item.type === 'list'
200
- ? `list:${item.listType}:${index}`
201
- : item.type === 'command'
202
- ? `command:${item.command}:${index}`
203
- : item.type === 'node'
204
- ? `node:${item.nodeType}:${index}`
205
- : `action:${item.key}:${index}`), []);
206
- const renderedItems = [];
207
- for (let index = 0; index < toolbarItems.length; index += 1) {
208
- const item = toolbarItems[index];
209
- if (item.type === 'separator') {
210
- renderedItems.push({
211
- type: 'separator',
212
- key: item.key ?? `separator:${index}`,
213
- });
214
- continue;
215
- }
218
+ ? `${prefix}image:${index}`
219
+ : item.type === 'heading'
220
+ ? `${prefix}heading:${item.level}:${index}`
221
+ : item.type === 'blockquote'
222
+ ? `${prefix}blockquote:${index}`
223
+ : item.type === 'list'
224
+ ? `${prefix}list:${item.listType}:${index}`
225
+ : item.type === 'command'
226
+ ? `${prefix}command:${item.command}:${index}`
227
+ : item.type === 'node'
228
+ ? `${prefix}node:${item.nodeType}:${index}`
229
+ : `${prefix}action:${item.key}:${index}`, []);
230
+ const resolveButton = (0, react_1.useCallback)((item, index, prefix = '', groupKey) => {
216
231
  const action = getActionForItem(item);
217
232
  if (!action) {
218
- continue;
233
+ return null;
219
234
  }
220
235
  let isActive = false;
221
236
  let isDisabled = false;
@@ -231,6 +246,12 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
231
246
  case 'image':
232
247
  isDisabled = !insertableNodes.includes('image') || !onRequestImage;
233
248
  break;
249
+ case 'heading': {
250
+ const headingNodeType = `h${item.level}`;
251
+ isActive = !!nodes[headingNodeType];
252
+ isDisabled = !commands[`toggleHeading${item.level}`];
253
+ break;
254
+ }
234
255
  case 'blockquote':
235
256
  isActive = !!nodes['blockquote'];
236
257
  isDisabled = !commands['toggleBlockquote'];
@@ -265,46 +286,176 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
265
286
  isDisabled = !insertableNodes.includes(item.nodeType);
266
287
  break;
267
288
  }
268
- renderedItems.push({
269
- type: 'button',
270
- button: {
271
- key: makeButtonKey(item, index),
272
- label: item.label,
273
- icon: item.icon,
274
- action,
275
- isActive,
276
- isDisabled,
277
- },
278
- });
279
- }
280
- const compactItems = renderedItems.filter((entry, index, list) => {
281
- if (entry.type !== 'separator') {
282
- return true;
289
+ return {
290
+ key: makeButtonKey(item, index, prefix),
291
+ label: item.label,
292
+ icon: item.icon,
293
+ action,
294
+ isActive,
295
+ isDisabled,
296
+ groupKey,
297
+ };
298
+ }, [
299
+ allowedMarks,
300
+ canIndentList,
301
+ canOutdentList,
302
+ commands,
303
+ getActionForItem,
304
+ historyState.canRedo,
305
+ historyState.canUndo,
306
+ insertableNodes,
307
+ isMarkActive,
308
+ makeButtonKey,
309
+ nodes,
310
+ onRequestImage,
311
+ onRequestLink,
312
+ onToolbarAction,
313
+ ]);
314
+ const { renderedItems, groupsByKey } = (0, react_1.useMemo)(() => {
315
+ const entries = [];
316
+ const nextGroups = new Map();
317
+ for (let index = 0; index < toolbarItems.length; index += 1) {
318
+ const item = toolbarItems[index];
319
+ if (item.type === 'separator') {
320
+ entries.push({
321
+ type: 'separator',
322
+ key: item.key ?? `separator:${index}`,
323
+ });
324
+ continue;
325
+ }
326
+ if (item.type === 'group') {
327
+ const children = item.items
328
+ .map((child, childIndex) => resolveButton(child, childIndex, `${item.key}:`, item.key))
329
+ .filter((child) => child != null);
330
+ if (children.length === 0) {
331
+ continue;
332
+ }
333
+ const presentation = item.presentation ?? 'expand';
334
+ const isExpanded = presentation === 'expand' && expandedGroupKey === item.key;
335
+ const isMenuOpen = presentation === 'menu' && menuState?.groupKey === item.key;
336
+ const group = {
337
+ key: item.key,
338
+ label: item.label,
339
+ icon: item.icon,
340
+ presentation,
341
+ children,
342
+ isActive: children.some((child) => child.isActive) || isExpanded || isMenuOpen,
343
+ isDisabled: children.every((child) => child.isDisabled),
344
+ isExpanded,
345
+ isOpen: isExpanded || isMenuOpen,
346
+ };
347
+ nextGroups.set(group.key, group);
348
+ entries.push({ type: 'group', group });
349
+ if (group.isExpanded) {
350
+ entries.push(...children.map((child) => ({ type: 'button', button: child })));
351
+ }
352
+ continue;
353
+ }
354
+ const button = resolveButton(item, index);
355
+ if (button) {
356
+ entries.push({ type: 'button', button });
357
+ }
358
+ }
359
+ return {
360
+ renderedItems: entries.filter((entry, index, list) => {
361
+ if (entry.type !== 'separator') {
362
+ return true;
363
+ }
364
+ const previous = list[index - 1];
365
+ const next = list[index + 1];
366
+ return (previous != null &&
367
+ previous.type !== 'separator' &&
368
+ next != null &&
369
+ next.type !== 'separator');
370
+ }),
371
+ groupsByKey: nextGroups,
372
+ };
373
+ }, [expandedGroupKey, menuState?.groupKey, resolveButton, toolbarItems]);
374
+ (0, react_1.useEffect)(() => {
375
+ if (expandedGroupKey != null && !groupsByKey.has(expandedGroupKey)) {
376
+ setExpandedGroupKey(null);
377
+ }
378
+ }, [expandedGroupKey, groupsByKey]);
379
+ (0, react_1.useEffect)(() => {
380
+ if (menuState != null && !groupsByKey.has(menuState.groupKey)) {
381
+ setMenuState(null);
382
+ }
383
+ }, [groupsByKey, menuState]);
384
+ const handleButtonPress = (0, react_1.useCallback)((button) => {
385
+ button.action();
386
+ if (button.groupKey) {
387
+ setExpandedGroupKey((current) => (current === button.groupKey ? null : current));
283
388
  }
284
- const previous = list[index - 1];
285
- const next = list[index + 1];
286
- return previous?.type === 'button' && next?.type === 'button';
287
- });
288
- const renderButton = ({ key, label, icon, action, isActive, isDisabled }) => {
389
+ setMenuState(null);
390
+ }, []);
391
+ const handleGroupPress = (0, react_1.useCallback)((group) => {
392
+ if (group.isDisabled) {
393
+ return;
394
+ }
395
+ if (group.presentation === 'expand') {
396
+ setMenuState(null);
397
+ setExpandedGroupKey((current) => (current === group.key ? null : group.key));
398
+ return;
399
+ }
400
+ const anchor = groupButtonRefs.current.get(group.key);
401
+ if (!anchor) {
402
+ return;
403
+ }
404
+ anchor.measureInWindow((x, y, width, height) => {
405
+ setExpandedGroupKey(null);
406
+ setMenuState((current) => current?.groupKey === group.key
407
+ ? null
408
+ : {
409
+ groupKey: group.key,
410
+ x,
411
+ y,
412
+ width,
413
+ height,
414
+ });
415
+ });
416
+ }, []);
417
+ const menuGroup = menuState != null ? groupsByKey.get(menuState.groupKey) ?? null : null;
418
+ const menuHeight = menuGroup ? menuGroup.children.length * 40 + 16 : 0;
419
+ const menuTop = menuState == null
420
+ ? 0
421
+ : Math.max(MENU_MARGIN, Math.min(menuState.y + menuState.height + 8, windowHeight - menuHeight - MENU_MARGIN));
422
+ const menuLeft = menuState == null
423
+ ? 0
424
+ : Math.max(MENU_MARGIN, Math.min(menuState.x + menuState.width - MENU_WIDTH, windowWidth - MENU_WIDTH - MENU_MARGIN));
425
+ const renderButton = (button, onPress, options) => {
289
426
  const activeColor = theme?.buttonActiveColor ?? ACTIVE_COLOR;
290
427
  const defaultColor = theme?.buttonColor ?? DEFAULT_COLOR;
291
428
  const disabledColor = theme?.buttonDisabledColor ?? DISABLED_COLOR;
292
- const color = isActive ? activeColor : isDisabled ? disabledColor : defaultColor;
293
- return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: action, disabled: isDisabled, style: [
294
- styles.button,
295
- {
296
- borderRadius: theme?.buttonBorderRadius ?? BUTTON_RADIUS,
297
- },
298
- isActive && {
299
- backgroundColor: theme?.buttonActiveBackgroundColor ?? ACTIVE_BG,
300
- },
301
- ], activeOpacity: 0.5, accessibilityRole: 'button', accessibilityLabel: label, accessibilityState: { selected: isActive, disabled: isDisabled }, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { children: (0, jsx_runtime_1.jsx)(ToolbarIcon, { icon: icon, color: color }) }) }, key));
429
+ const color = button.isActive ? activeColor : button.isDisabled ? disabledColor : defaultColor;
430
+ const anchorGroupKey = options?.anchorGroupKey;
431
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: anchorGroupKey == null
432
+ ? undefined
433
+ : (node) => {
434
+ if (node) {
435
+ groupButtonRefs.current.set(anchorGroupKey, node);
436
+ }
437
+ else {
438
+ groupButtonRefs.current.delete(anchorGroupKey);
439
+ }
440
+ }, collapsable: false, style: styles.buttonAnchor, children: [(0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: onPress, disabled: button.isDisabled, style: [
441
+ styles.button,
442
+ {
443
+ borderRadius: theme?.buttonBorderRadius ?? BUTTON_RADIUS,
444
+ },
445
+ button.isActive && {
446
+ backgroundColor: theme?.buttonActiveBackgroundColor ?? ACTIVE_BG,
447
+ },
448
+ ], activeOpacity: 0.5, accessibilityRole: 'button', accessibilityLabel: button.label, accessibilityState: {
449
+ selected: button.isActive,
450
+ disabled: button.isDisabled,
451
+ expanded: options?.showsDisclosure ? options.expanded : undefined,
452
+ }, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { children: (0, jsx_runtime_1.jsx)(ToolbarIcon, { icon: button.icon, color: color }) }) }), options?.showsDisclosure ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.groupDisclosure, { color }], children: '\u25BE' })) : null] }, button.key));
302
453
  };
303
454
  const renderSeparator = (key) => ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
304
455
  styles.separator,
305
456
  theme?.separatorColor != null ? { backgroundColor: theme.separatorColor } : null,
306
457
  ] }, key));
307
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
458
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
308
459
  styles.container,
309
460
  !showTopBorder && styles.containerWithoutTopBorder,
310
461
  theme?.backgroundColor != null ? { backgroundColor: theme.backgroundColor } : null,
@@ -321,9 +472,55 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
321
472
  {
322
473
  borderRadius: theme?.borderRadius ?? TOOLBAR_RADIUS,
323
474
  },
324
- ], children: (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, contentContainerStyle: styles.scrollContent, keyboardShouldPersistTaps: 'always', children: compactItems.map((item) => item.type === 'separator'
325
- ? renderSeparator(item.key)
326
- : renderButton(item.button)) }) }));
475
+ ], children: [(0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, contentContainerStyle: styles.scrollContent, keyboardShouldPersistTaps: 'always', onScrollBeginDrag: () => setMenuState(null), children: renderedItems.map((item) => {
476
+ if (item.type === 'separator') {
477
+ return renderSeparator(item.key);
478
+ }
479
+ if (item.type === 'group') {
480
+ return renderButton({
481
+ key: item.group.key,
482
+ label: item.group.label,
483
+ icon: item.group.icon,
484
+ isActive: item.group.isActive,
485
+ isDisabled: item.group.isDisabled,
486
+ }, () => handleGroupPress(item.group), {
487
+ anchorGroupKey: item.group.key,
488
+ showsDisclosure: true,
489
+ expanded: item.group.isOpen,
490
+ });
491
+ }
492
+ return renderButton(item.button, () => handleButtonPress(item.button));
493
+ }) }), menuState != null && menuGroup != null ? ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { transparent: true, visible: true, animationType: 'fade', onRequestClose: () => setMenuState(null), children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.menuBackdrop, onPress: () => setMenuState(null), children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
494
+ styles.menuCard,
495
+ {
496
+ top: menuTop,
497
+ left: menuLeft,
498
+ backgroundColor: theme?.backgroundColor ?? TOOLBAR_BG,
499
+ borderColor: theme?.borderColor ?? MENU_BORDER,
500
+ },
501
+ ], children: menuGroup.children.map((button) => {
502
+ const activeColor = theme?.buttonActiveColor ?? ACTIVE_COLOR;
503
+ const defaultColor = theme?.buttonColor ?? DEFAULT_COLOR;
504
+ const disabledColor = theme?.buttonDisabledColor ?? DISABLED_COLOR;
505
+ const color = button.isActive
506
+ ? activeColor
507
+ : button.isDisabled
508
+ ? disabledColor
509
+ : defaultColor;
510
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => handleButtonPress(button), disabled: button.isDisabled, style: ({ pressed }) => [
511
+ styles.menuItem,
512
+ button.isActive && {
513
+ backgroundColor: theme?.buttonActiveBackgroundColor ?? ACTIVE_BG,
514
+ },
515
+ pressed &&
516
+ !button.isDisabled && {
517
+ opacity: 0.75,
518
+ },
519
+ ], accessibilityRole: 'button', accessibilityLabel: button.label, accessibilityState: {
520
+ selected: button.isActive,
521
+ disabled: button.isDisabled,
522
+ }, children: [(0, jsx_runtime_1.jsx)(ToolbarIcon, { icon: button.icon, color: color }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.menuLabel, { color }], children: button.label })] }, button.key));
523
+ }) }) }) })) : null] }));
327
524
  }
328
525
  function ToolbarIcon({ icon, color }) {
329
526
  const materialIconName = resolveMaterialIconName(icon);
@@ -370,6 +567,9 @@ const styles = react_native_1.StyleSheet.create({
370
567
  paddingHorizontal: TOOLBAR_PADDING_H,
371
568
  minWidth: '100%',
372
569
  },
570
+ buttonAnchor: {
571
+ position: 'relative',
572
+ },
373
573
  button: {
374
574
  width: BUTTON_HIT,
375
575
  height: BUTTON_VISIBLE,
@@ -377,6 +577,13 @@ const styles = react_native_1.StyleSheet.create({
377
577
  alignItems: 'center',
378
578
  borderRadius: BUTTON_RADIUS,
379
579
  },
580
+ groupDisclosure: {
581
+ position: 'absolute',
582
+ right: 5,
583
+ bottom: 2,
584
+ fontSize: 9,
585
+ fontWeight: '700',
586
+ },
380
587
  separator: {
381
588
  width: react_native_1.StyleSheet.hairlineWidth,
382
589
  height: 20,
@@ -391,4 +598,31 @@ const styles = react_native_1.StyleSheet.create({
391
598
  fontSize: 16,
392
599
  fontWeight: '600',
393
600
  },
601
+ menuBackdrop: {
602
+ flex: 1,
603
+ },
604
+ menuCard: {
605
+ position: 'absolute',
606
+ width: MENU_WIDTH,
607
+ borderRadius: 14,
608
+ borderWidth: react_native_1.StyleSheet.hairlineWidth,
609
+ paddingVertical: 8,
610
+ shadowColor: MENU_SHADOW,
611
+ shadowOpacity: 0.16,
612
+ shadowRadius: 18,
613
+ shadowOffset: { width: 0, height: 8 },
614
+ elevation: 10,
615
+ },
616
+ menuItem: {
617
+ minHeight: 40,
618
+ paddingHorizontal: 12,
619
+ flexDirection: 'row',
620
+ alignItems: 'center',
621
+ },
622
+ menuLabel: {
623
+ flex: 1,
624
+ marginLeft: 10,
625
+ fontSize: 14,
626
+ fontWeight: '500',
627
+ },
394
628
  });