@apollohg/react-native-prose-editor 0.5.3 → 0.5.4

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.
@@ -55,6 +55,7 @@ data class EditorTextStyle(
55
55
 
56
56
  data class EditorListTheme(
57
57
  val indent: Float? = null,
58
+ val baseIndentMultiplier: Float? = null,
58
59
  val itemSpacing: Float? = null,
59
60
  val markerColor: Int? = null,
60
61
  val markerScale: Float? = null
@@ -64,6 +65,7 @@ data class EditorListTheme(
64
65
  json ?: return null
65
66
  return EditorListTheme(
66
67
  indent = json.optNullableFloat("indent"),
68
+ baseIndentMultiplier = json.optNullableFloat("baseIndentMultiplier"),
67
69
  itemSpacing = json.optNullableFloat("itemSpacing"),
68
70
  markerColor = parseColor(json.optNullableString("markerColor")),
69
71
  markerScale = json.optNullableFloat("markerScale")
@@ -194,6 +196,8 @@ data class EditorToolbarTheme(
194
196
  val borderColor: Int? = null,
195
197
  val borderWidth: Float? = null,
196
198
  val borderRadius: Float? = null,
199
+ val marginTop: Float? = null,
200
+ val showTopBorder: Boolean? = null,
197
201
  val keyboardOffset: Float? = null,
198
202
  val horizontalInset: Float? = null,
199
203
  val separatorColor: Int? = null,
@@ -222,6 +226,8 @@ data class EditorToolbarTheme(
222
226
  borderColor = parseColor(json.optNullableString("borderColor")),
223
227
  borderWidth = json.optNullableFloat("borderWidth"),
224
228
  borderRadius = json.optNullableFloat("borderRadius"),
229
+ marginTop = json.optNullableFloat("marginTop"),
230
+ showTopBorder = if (json.has("showTopBorder")) json.optBoolean("showTopBorder") else null,
225
231
  keyboardOffset = json.optNullableFloat("keyboardOffset"),
226
232
  horizontalInset = json.optNullableFloat("horizontalInset"),
227
233
  separatorColor = parseColor(json.optNullableString("separatorColor")),
@@ -386,6 +386,11 @@ class NativeEditorModule : Module() {
386
386
  Prop("themeJson") { view: NativeProseViewerExpoView, themeJson: String? ->
387
387
  view.setThemeJson(themeJson)
388
388
  }
389
+ Prop("collapsesWhenEmpty") {
390
+ view: NativeProseViewerExpoView,
391
+ collapsesWhenEmpty: Boolean? ->
392
+ view.setCollapsesWhenEmpty(collapsesWhenEmpty)
393
+ }
389
394
  Prop("enableLinkTaps") { view: NativeProseViewerExpoView, enableLinkTaps: Boolean? ->
390
395
  view.setEnableLinkTaps(enableLinkTaps)
391
396
  }
@@ -10,6 +10,7 @@ import android.view.ViewGroup
10
10
  import expo.modules.kotlin.AppContext
11
11
  import expo.modules.kotlin.viewevent.EventDispatcher
12
12
  import expo.modules.kotlin.views.ExpoView
13
+ import org.json.JSONArray
13
14
 
14
15
  class NativeProseViewerExpoView(
15
16
  context: Context,
@@ -26,6 +27,8 @@ class NativeProseViewerExpoView(
26
27
  private var lastRenderJson: String? = null
27
28
  private var lastThemeJson: String? = null
28
29
  private var lastEmittedContentHeight = 0
30
+ private var collapsesWhenEmpty = true
31
+ private var isCollapsedEmptyContent = false
29
32
  private var enableLinkTaps = true
30
33
  private var interceptLinkTaps = false
31
34
 
@@ -84,7 +87,7 @@ class NativeProseViewerExpoView(
84
87
  fun setRenderJson(renderJson: String?) {
85
88
  if (lastRenderJson == renderJson) return
86
89
  lastRenderJson = renderJson
87
- proseView.applyRenderJSON(renderJson ?: "[]")
90
+ applyRenderJson()
88
91
  post {
89
92
  requestLayout()
90
93
  emitContentHeightIfNeeded(force = true)
@@ -95,13 +98,22 @@ class NativeProseViewerExpoView(
95
98
  if (lastThemeJson == themeJson) return
96
99
  lastThemeJson = themeJson
97
100
  proseView.applyTheme(EditorTheme.fromJson(themeJson))
98
- proseView.applyRenderJSON(lastRenderJson ?: "[]")
101
+ applyRenderJson()
99
102
  post {
100
103
  requestLayout()
101
104
  emitContentHeightIfNeeded(force = true)
102
105
  }
103
106
  }
104
107
 
108
+ fun setCollapsesWhenEmpty(collapsesWhenEmpty: Boolean?) {
109
+ val nextValue = collapsesWhenEmpty ?: true
110
+ if (this.collapsesWhenEmpty == nextValue) return
111
+ this.collapsesWhenEmpty = nextValue
112
+ updateCollapsedEmptyState()
113
+ requestLayout()
114
+ emitContentHeightIfNeeded(force = true)
115
+ }
116
+
105
117
  fun setEnableLinkTaps(enableLinkTaps: Boolean?) {
106
118
  this.enableLinkTaps = enableLinkTaps ?: true
107
119
  }
@@ -111,6 +123,11 @@ class NativeProseViewerExpoView(
111
123
  }
112
124
 
113
125
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
126
+ if (isCollapsedEmptyContent) {
127
+ setMeasuredDimension(resolveSize(0, widthMeasureSpec), 0)
128
+ return
129
+ }
130
+
114
131
  val childWidthSpec = getChildMeasureSpec(
115
132
  widthMeasureSpec,
116
133
  paddingLeft + paddingRight,
@@ -131,6 +148,12 @@ class NativeProseViewerExpoView(
131
148
  }
132
149
 
133
150
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
151
+ if (isCollapsedEmptyContent) {
152
+ proseView.layout(paddingLeft, paddingTop, right - left - paddingRight, paddingTop)
153
+ emitContentHeightIfNeeded()
154
+ return
155
+ }
156
+
134
157
  val childLeft = paddingLeft
135
158
  val childTop = paddingTop
136
159
  proseView.layout(
@@ -142,10 +165,25 @@ class NativeProseViewerExpoView(
142
165
  emitContentHeightIfNeeded()
143
166
  }
144
167
 
168
+ private fun applyRenderJson() {
169
+ updateCollapsedEmptyState()
170
+ proseView.applyRenderJSON(lastRenderJson ?: "[]")
171
+ proseView.visibility = if (isCollapsedEmptyContent) View.GONE else View.VISIBLE
172
+ }
173
+
174
+ private fun updateCollapsedEmptyState() {
175
+ isCollapsedEmptyContent = collapsesWhenEmpty &&
176
+ renderJsonContainsOnlyEmptyParagraphs(lastRenderJson ?: "[]")
177
+ proseView.visibility = if (isCollapsedEmptyContent) View.GONE else View.VISIBLE
178
+ }
179
+
145
180
  private fun emitContentHeightIfNeeded(force: Boolean = false) {
146
- val contentHeight = (measureContentHeightPx() + paddingTop + paddingBottom)
147
- .coerceAtLeast(0)
148
- if (contentHeight <= 0) {
181
+ val contentHeight = if (isCollapsedEmptyContent) {
182
+ 0
183
+ } else {
184
+ (measureContentHeightPx() + paddingTop + paddingBottom).coerceAtLeast(0)
185
+ }
186
+ if (contentHeight <= 0 && !isCollapsedEmptyContent) {
149
187
  return
150
188
  }
151
189
  if (!force && contentHeight == lastEmittedContentHeight) {
@@ -156,6 +194,10 @@ class NativeProseViewerExpoView(
156
194
  }
157
195
 
158
196
  private fun measureContentHeightPx(): Int {
197
+ if (isCollapsedEmptyContent) {
198
+ return 0
199
+ }
200
+
159
201
  val currentMeasuredHeight = proseView.measuredHeight
160
202
  if (currentMeasuredHeight > 0 && proseView.layout != null) {
161
203
  return currentMeasuredHeight
@@ -191,4 +233,61 @@ class NativeProseViewerExpoView(
191
233
  true
192
234
  }.getOrDefault(false)
193
235
  }
236
+
237
+ companion object {
238
+ private const val EMPTY_TEXT_BLOCK_PLACEHOLDER = '\u200B'
239
+
240
+ internal fun renderJsonContainsOnlyEmptyParagraphs(renderJson: String): Boolean {
241
+ val elements = try {
242
+ JSONArray(renderJson)
243
+ } catch (_: Exception) {
244
+ return false
245
+ }
246
+
247
+ if (elements.length() == 0) {
248
+ return true
249
+ }
250
+
251
+ var hasParagraph = false
252
+ var paragraphIsOpen = false
253
+
254
+ for (index in 0 until elements.length()) {
255
+ val element = elements.optJSONObject(index) ?: return false
256
+ when (element.optString("type", "")) {
257
+ "blockStart" -> {
258
+ if (
259
+ paragraphIsOpen ||
260
+ element.optString("nodeType", "") != "paragraph" ||
261
+ element.optInt("depth", 0) != 0
262
+ ) {
263
+ return false
264
+ }
265
+ paragraphIsOpen = true
266
+ hasParagraph = true
267
+ }
268
+
269
+ "textRun" -> {
270
+ val text = element.optString("text", "")
271
+ if (
272
+ !paragraphIsOpen ||
273
+ !text.all { it == EMPTY_TEXT_BLOCK_PLACEHOLDER }
274
+ ) {
275
+ return false
276
+ }
277
+ }
278
+
279
+ "blockEnd" -> {
280
+ if (!paragraphIsOpen) {
281
+ return false
282
+ }
283
+ paragraphIsOpen = false
284
+ }
285
+
286
+ else -> return false
287
+ }
288
+ }
289
+
290
+ return hasParagraph && !paragraphIsOpen
291
+ }
292
+ }
194
293
  }
@@ -1465,6 +1465,9 @@ object RenderBridge {
1465
1465
  val indent = calculateIndent(currentBlock, blockStack, theme, density)
1466
1466
  val markerWidth = calculateMarkerWidth(density)
1467
1467
  val quoteDepth = blockquoteDepth(blockStack)
1468
+ val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
1469
+ val listBaseIndentAdjustment =
1470
+ calculateListBaseIndentAdjustment(currentBlock, theme, density)
1468
1471
  val quoteStripeColor = if (quoteDepth > 0) {
1469
1472
  theme?.blockquote?.borderColor ?: Color.argb(
1470
1473
  (Color.alpha(resolveInlineTextColor(blockStack, Color.BLACK, theme)) * 0.3f).toInt(),
@@ -1486,8 +1489,9 @@ object RenderBridge {
1486
1489
  ) * density
1487
1490
  val blockquoteIndentPx = (quoteDepth * quoteIndent).toInt()
1488
1491
  val quoteBaseIndent = if (quoteDepth > 0) {
1489
- ((currentBlock.depth * ((theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density))
1490
- - (quoteDepth * ((theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density))
1492
+ ((currentBlock.depth * indentPerDepth)
1493
+ - (quoteDepth * indentPerDepth)
1494
+ + listBaseIndentAdjustment
1491
1495
  + ((quoteDepth - 1f) * quoteIndent)).toInt()
1492
1496
  } else {
1493
1497
  0
@@ -1666,7 +1670,25 @@ object RenderBridge {
1666
1670
  (theme?.blockquote?.markerGap ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) +
1667
1671
  (theme?.blockquote?.borderWidth ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH)
1668
1672
  ) * density
1669
- return (context.depth * indentPerDepth) - (quoteDepth * indentPerDepth) + (quoteDepth * quoteIndent)
1673
+ val listBaseIndentAdjustment = calculateListBaseIndentAdjustment(context, theme, density)
1674
+ return (context.depth * indentPerDepth) -
1675
+ (quoteDepth * indentPerDepth) +
1676
+ listBaseIndentAdjustment +
1677
+ (quoteDepth * quoteIndent)
1678
+ }
1679
+
1680
+ private fun calculateListBaseIndentAdjustment(
1681
+ context: BlockContext,
1682
+ theme: EditorTheme?,
1683
+ density: Float
1684
+ ): Float {
1685
+ if (context.listContext == null) {
1686
+ return 0f
1687
+ }
1688
+
1689
+ val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
1690
+ val listBaseIndentMultiplier = maxOf(theme?.list?.baseIndentMultiplier ?: 1f, 0f)
1691
+ return (listBaseIndentMultiplier - 1f) * indentPerDepth
1670
1692
  }
1671
1693
 
1672
1694
  private fun effectiveBlockContext(blockStack: List<BlockContext>): BlockContext? {
@@ -36,6 +36,7 @@ export interface EditorHeadingTheme {
36
36
  }
37
37
  export interface EditorListTheme {
38
38
  indent?: number;
39
+ baseIndentMultiplier?: number;
39
40
  itemSpacing?: number;
40
41
  markerColor?: string;
41
42
  markerScale?: number;
@@ -59,6 +60,8 @@ export interface EditorToolbarTheme {
59
60
  borderColor?: string;
60
61
  borderWidth?: number;
61
62
  borderRadius?: number;
63
+ marginTop?: number;
64
+ showTopBorder?: boolean;
62
65
  keyboardOffset?: number;
63
66
  horizontalInset?: number;
64
67
  separatorColor?: string;
@@ -109,7 +109,7 @@ const DEFAULT_MATERIAL_ICONS = {
109
109
  undo: 'undo',
110
110
  redo: 'redo',
111
111
  };
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, }) {
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, }) {
113
113
  const marks = activeState.marks ?? {};
114
114
  const nodes = activeState.nodes ?? {};
115
115
  const commands = activeState.commands ?? {};
@@ -371,6 +371,7 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
371
371
  groupsByKey: nextGroups,
372
372
  };
373
373
  }, [expandedGroupKey, menuState?.groupKey, resolveButton, toolbarItems]);
374
+ const resolvedShowTopBorder = showTopBorder ?? theme?.showTopBorder ?? true;
374
375
  (0, react_1.useEffect)(() => {
375
376
  if (expandedGroupKey != null && !groupsByKey.has(expandedGroupKey)) {
376
377
  setExpandedGroupKey(null);
@@ -414,7 +415,7 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
414
415
  });
415
416
  });
416
417
  }, []);
417
- const menuGroup = menuState != null ? groupsByKey.get(menuState.groupKey) ?? null : null;
418
+ const menuGroup = menuState != null ? (groupsByKey.get(menuState.groupKey) ?? null) : null;
418
419
  const menuHeight = menuGroup ? menuGroup.children.length * 40 + 16 : 0;
419
420
  const menuTop = menuState == null
420
421
  ? 0
@@ -426,7 +427,11 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
426
427
  const activeColor = theme?.buttonActiveColor ?? ACTIVE_COLOR;
427
428
  const defaultColor = theme?.buttonColor ?? DEFAULT_COLOR;
428
429
  const disabledColor = theme?.buttonDisabledColor ?? DISABLED_COLOR;
429
- const color = button.isActive ? activeColor : button.isDisabled ? disabledColor : defaultColor;
430
+ const color = button.isActive
431
+ ? activeColor
432
+ : button.isDisabled
433
+ ? disabledColor
434
+ : defaultColor;
430
435
  const anchorGroupKey = options?.anchorGroupKey;
431
436
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: anchorGroupKey == null
432
437
  ? undefined
@@ -457,15 +462,15 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
457
462
  ] }, key));
458
463
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
459
464
  styles.container,
460
- !showTopBorder && styles.containerWithoutTopBorder,
465
+ !resolvedShowTopBorder && styles.containerWithoutTopBorder,
461
466
  theme?.backgroundColor != null ? { backgroundColor: theme.backgroundColor } : null,
462
467
  theme?.borderColor != null
463
- ? showTopBorder
468
+ ? resolvedShowTopBorder
464
469
  ? { borderTopColor: theme.borderColor }
465
470
  : null
466
471
  : null,
467
472
  theme?.borderWidth != null
468
- ? showTopBorder
473
+ ? resolvedShowTopBorder
469
474
  ? { borderTopWidth: theme.borderWidth }
470
475
  : null
471
476
  : null,
@@ -168,6 +168,59 @@ function isEmptyParagraphPlaceholderText(text) {
168
168
  }
169
169
  return Array.from(text).every((char) => char === EMPTY_TEXT_BLOCK_PLACEHOLDER);
170
170
  }
171
+ function isCollapsibleEmptyParagraphText(text) {
172
+ return Array.from(text).every((char) => char === EMPTY_TEXT_BLOCK_PLACEHOLDER);
173
+ }
174
+ function renderElementsJsonContainsOnlyEmptyParagraphs(renderJson) {
175
+ let parsedElements;
176
+ try {
177
+ parsedElements = JSON.parse(renderJson);
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ if (!Array.isArray(parsedElements)) {
183
+ return false;
184
+ }
185
+ if (parsedElements.length === 0) {
186
+ return true;
187
+ }
188
+ let hasParagraph = false;
189
+ let paragraphIsOpen = false;
190
+ for (const element of parsedElements) {
191
+ if (element == null || typeof element !== 'object' || Array.isArray(element)) {
192
+ return false;
193
+ }
194
+ const renderElement = element;
195
+ switch (renderElement.type) {
196
+ case 'blockStart':
197
+ if (paragraphIsOpen ||
198
+ renderElement.nodeType !== 'paragraph' ||
199
+ renderElement.depth !== 0) {
200
+ return false;
201
+ }
202
+ paragraphIsOpen = true;
203
+ hasParagraph = true;
204
+ break;
205
+ case 'textRun':
206
+ if (!paragraphIsOpen ||
207
+ typeof renderElement.text !== 'string' ||
208
+ !isCollapsibleEmptyParagraphText(renderElement.text)) {
209
+ return false;
210
+ }
211
+ break;
212
+ case 'blockEnd':
213
+ if (!paragraphIsOpen) {
214
+ return false;
215
+ }
216
+ paragraphIsOpen = false;
217
+ break;
218
+ default:
219
+ return false;
220
+ }
221
+ }
222
+ return hasParagraph && !paragraphIsOpen;
223
+ }
171
224
  function isTrailingEmptyParagraphRange(elements, start, endExclusive) {
172
225
  const startElement = elements[start];
173
226
  const endElement = elements[endExclusive - 1];
@@ -325,6 +378,8 @@ function NativeProseViewer({ ...props }) {
325
378
  mentionPayloadsByDocPos,
326
379
  serializedContentJson,
327
380
  ]);
381
+ const renderJsonIsCollapsedEmpty = (0, react_1.useMemo)(() => collapseTrailingEmptyParagraphs &&
382
+ renderElementsJsonContainsOnlyEmptyParagraphs(renderJson), [collapseTrailingEmptyParagraphs, renderJson]);
328
383
  const [contentHeight, setContentHeight] = (0, react_1.useState)(null);
329
384
  const allowContentHeightShrinkRef = (0, react_1.useRef)(true);
330
385
  (0, react_1.useEffect)(() => {
@@ -332,8 +387,14 @@ function NativeProseViewer({ ...props }) {
332
387
  }, [resolvedContentRevision, renderJson, themeJson]);
333
388
  const handleContentHeightChange = (0, react_1.useCallback)((event) => {
334
389
  const nextHeight = event.nativeEvent.contentHeight;
335
- if (nextHeight <= 0)
390
+ if (nextHeight < 0)
391
+ return;
392
+ if (nextHeight === 0 && !renderJsonIsCollapsedEmpty)
393
+ return;
394
+ if (nextHeight === 0) {
395
+ setContentHeight((currentHeight) => currentHeight === 0 ? currentHeight : 0);
336
396
  return;
397
+ }
337
398
  setContentHeight((currentHeight) => currentHeight == null ||
338
399
  nextHeight >= currentHeight ||
339
400
  allowContentHeightShrinkRef.current
@@ -344,7 +405,7 @@ function NativeProseViewer({ ...props }) {
344
405
  : nextHeight;
345
406
  })()
346
407
  : currentHeight);
347
- }, []);
408
+ }, [renderJsonIsCollapsedEmpty]);
348
409
  const handlePressMention = (0, react_1.useCallback)((event) => {
349
410
  if (!onPressMention)
350
411
  return;
@@ -364,10 +425,19 @@ function NativeProseViewer({ ...props }) {
364
425
  text: event.nativeEvent.text,
365
426
  });
366
427
  }, [onPressLink]);
367
- const nativeStyle = (0, react_1.useMemo)(() => [
368
- { minHeight: 1 },
369
- style,
370
- contentHeight != null ? { minHeight: contentHeight } : null,
371
- ], [contentHeight, style]);
372
- return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, enableLinkTaps: enableLinkTaps, interceptLinkTaps: typeof onPressLink === 'function', onContentHeightChange: handleContentHeightChange, onPressLink: typeof onPressLink === 'function' ? handlePressLink : undefined, onPressMention: typeof onPressMention === 'function' ? handlePressMention : undefined }));
428
+ const nativeStyle = (0, react_1.useMemo)(() => {
429
+ let measuredStyle = null;
430
+ if (renderJsonIsCollapsedEmpty) {
431
+ measuredStyle = { height: 0, minHeight: 0 };
432
+ }
433
+ else if (contentHeight != null && contentHeight > 0) {
434
+ measuredStyle = { minHeight: contentHeight };
435
+ }
436
+ return [
437
+ { minHeight: renderJsonIsCollapsedEmpty ? 0 : 1 },
438
+ style,
439
+ measuredStyle,
440
+ ];
441
+ }, [contentHeight, renderJsonIsCollapsedEmpty, style]);
442
+ return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, collapsesWhenEmpty: collapseTrailingEmptyParagraphs, enableLinkTaps: enableLinkTaps, interceptLinkTaps: typeof onPressLink === 'function', onContentHeightChange: handleContentHeightChange, onPressLink: typeof onPressLink === 'function' ? handlePressLink : undefined, onPressMention: typeof onPressMention === 'function' ? handlePressMention : undefined }));
373
443
  }
@@ -16,6 +16,9 @@ const DEV_NATIVE_VIEW_KEY = __DEV__
16
16
  : 'native-editor';
17
17
  const LINK_TOOLBAR_ACTION_KEY = '__native-editor-link__';
18
18
  const IMAGE_TOOLBAR_ACTION_KEY = '__native-editor-image__';
19
+ const DEFAULT_MENTION_TRIGGER = '@';
20
+ const MAX_INLINE_MENTION_SUGGESTIONS = 8;
21
+ const INLINE_TOOLBAR_BORDER_COLOR = '#E5E5EA';
19
22
  function mapToolbarChildForNative(item, activeState, editable, onRequestLink, onRequestImage) {
20
23
  if (item.type === 'link') {
21
24
  return {
@@ -34,7 +37,9 @@ function mapToolbarChildForNative(item, activeState, editable, onRequestLink, on
34
37
  label: item.label,
35
38
  icon: item.icon,
36
39
  isActive: false,
37
- isDisabled: !editable || !onRequestImage || !activeState.insertableNodes.includes(schemas_1.IMAGE_NODE_NAME),
40
+ isDisabled: !editable ||
41
+ !onRequestImage ||
42
+ !activeState.insertableNodes.includes(schemas_1.IMAGE_NODE_NAME),
38
43
  };
39
44
  }
40
45
  return item;
@@ -65,6 +70,34 @@ function isPromiseLike(value) {
65
70
  function isRecord(value) {
66
71
  return value != null && typeof value === 'object' && !Array.isArray(value);
67
72
  }
73
+ function resolveMentionTrigger(addons) {
74
+ return addons?.mentions?.trigger?.trim() || DEFAULT_MENTION_TRIGGER;
75
+ }
76
+ function resolveMentionSuggestionLabel(suggestion, trigger) {
77
+ return suggestion.label?.trim() || `${trigger}${suggestion.title}`;
78
+ }
79
+ function filterMentionSuggestions(suggestions, query, trigger) {
80
+ const normalizedQuery = query.trim().toLowerCase();
81
+ const filtered = normalizedQuery.length === 0
82
+ ? suggestions
83
+ : suggestions.filter((suggestion) => {
84
+ const label = resolveMentionSuggestionLabel(suggestion, trigger);
85
+ return (suggestion.title.toLowerCase().includes(normalizedQuery) ||
86
+ label.toLowerCase().includes(normalizedQuery) ||
87
+ suggestion.subtitle?.toLowerCase().includes(normalizedQuery) === true);
88
+ });
89
+ return filtered.slice(0, MAX_INLINE_MENTION_SUGGESTIONS);
90
+ }
91
+ function resolveMentionSuggestionAttrs(suggestion, trigger) {
92
+ const attrs = { ...(suggestion.attrs ?? {}) };
93
+ if (!('label' in attrs)) {
94
+ attrs.label = resolveMentionSuggestionLabel(suggestion, trigger);
95
+ }
96
+ if (!('mentionSuggestionChar' in attrs)) {
97
+ attrs.mentionSuggestionChar = trigger;
98
+ }
99
+ return attrs;
100
+ }
68
101
  const AUTO_LINK_URL_REGEX = /(?:https?:\/\/|www\.)\S+/giu;
69
102
  const AUTO_LINK_INLINE_PLACEHOLDER = '\uFFFC';
70
103
  const AUTO_LINK_LEADING_BOUNDARY_CHARS = new Set(['(', '[', '{', '<', '"', "'"]);
@@ -142,7 +175,8 @@ function trimAutoLinkTrailingPunctuation(value) {
142
175
  result = chars.join('');
143
176
  continue;
144
177
  }
145
- if ((lastChar === '"' || lastChar === "'") && countOccurrences(result, lastChar) % 2 !== 0) {
178
+ if ((lastChar === '"' || lastChar === "'") &&
179
+ countOccurrences(result, lastChar) % 2 !== 0) {
146
180
  chars.pop();
147
181
  result = chars.join('');
148
182
  continue;
@@ -169,7 +203,9 @@ function isAutoLinkBoundaryChar(char) {
169
203
  if (!char) {
170
204
  return true;
171
205
  }
172
- return /\s/u.test(char) || char === AUTO_LINK_INLINE_PLACEHOLDER || AUTO_LINK_LEADING_BOUNDARY_CHARS.has(char);
206
+ return (/\s/u.test(char) ||
207
+ char === AUTO_LINK_INLINE_PLACEHOLDER ||
208
+ AUTO_LINK_LEADING_BOUNDARY_CHARS.has(char));
173
209
  }
174
210
  function isAutoLinkTrailingDelimiterChar(char) {
175
211
  if (!char) {
@@ -236,13 +272,15 @@ function findAutoLinkCandidateInInlineBlock(block, cursorDocPos) {
236
272
  return null;
237
273
  }
238
274
  let localIndex = 0;
239
- while (localIndex < block.docPositions.length && block.docPositions[localIndex] < cursorDocPos) {
275
+ while (localIndex < block.docPositions.length &&
276
+ block.docPositions[localIndex] < cursorDocPos) {
240
277
  localIndex += 1;
241
278
  }
242
279
  if (localIndex === 0) {
243
280
  return null;
244
281
  }
245
- if (cursorDocPos < block.contentEnd && !isAutoLinkTrailingDelimiterChar(block.chars[localIndex - 1])) {
282
+ if (cursorDocPos < block.contentEnd &&
283
+ !isAutoLinkTrailingDelimiterChar(block.chars[localIndex - 1])) {
246
284
  return null;
247
285
  }
248
286
  const prefixChars = block.chars.slice(0, localIndex);
@@ -444,11 +482,14 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
444
482
  canUndo: false,
445
483
  canRedo: false,
446
484
  });
485
+ const [mentionQueryEvent, setMentionQueryEvent] = (0, react_1.useState)(null);
447
486
  // Selection and rendered text length refs (non-rendering state)
448
487
  const selectionRef = (0, react_1.useRef)({ type: 'text', anchor: 0, head: 0 });
449
488
  const renderedTextLengthRef = (0, react_1.useRef)(0);
450
489
  const documentVersionRef = (0, react_1.useRef)(null);
451
490
  const toolbarRef = (0, react_1.useRef)(null);
491
+ const mentionQueryEventRef = (0, react_1.useRef)(null);
492
+ mentionQueryEventRef.current = mentionQueryEvent;
452
493
  const toolbarItemsSerializationCacheRef = (0, react_1.useRef)(null);
453
494
  // Stable callback refs to avoid re-renders
454
495
  const onContentChangeRef = (0, react_1.useRef)(onContentChange);
@@ -672,12 +713,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
672
713
  setIsReady(false);
673
714
  };
674
715
  // eslint-disable-next-line react-hooks/exhaustive-deps
675
- }, [
676
- maxLength,
677
- syncStateFromUpdate,
678
- allowBase64Images,
679
- serializedSchemaJson,
680
- ]);
716
+ }, [maxLength, syncStateFromUpdate, allowBase64Images, serializedSchemaJson]);
681
717
  (0, react_1.useEffect)(() => {
682
718
  if (value == null)
683
719
  return;
@@ -803,6 +839,9 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
803
839
  const handleFocusChange = (0, react_1.useCallback)((event) => {
804
840
  const { isFocused: focused } = event.nativeEvent;
805
841
  setIsFocused(focused);
842
+ if (!focused) {
843
+ setMentionQueryEvent(null);
844
+ }
806
845
  if (focused) {
807
846
  onFocusRef.current?.();
808
847
  }
@@ -810,6 +849,12 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
810
849
  onBlurRef.current?.();
811
850
  }
812
851
  }, []);
852
+ (0, react_1.useEffect)(() => {
853
+ if (addons?.mentions != null) {
854
+ return;
855
+ }
856
+ setMentionQueryEvent(null);
857
+ }, [addons?.mentions]);
813
858
  const handleContentHeightChange = (0, react_1.useCallback)((event) => {
814
859
  if (heightBehavior !== 'autoGrow')
815
860
  return;
@@ -897,6 +942,46 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
897
942
  }
898
943
  onToolbarAction?.(event.nativeEvent.key);
899
944
  }, [onToolbarAction, openImageRequest, openLinkRequest]);
945
+ const resolveMentionSelectionAttrs = (0, react_1.useCallback)((selectionEvent) => {
946
+ let resolvedAttrs;
947
+ try {
948
+ resolvedAttrs =
949
+ addonsRef.current?.mentions?.resolveSelectionAttrs?.(selectionEvent);
950
+ }
951
+ catch (error) {
952
+ if (__DEV__) {
953
+ console.error('NativeRichTextEditor: mentions.resolveSelectionAttrs threw', error);
954
+ }
955
+ }
956
+ return isRecord(resolvedAttrs)
957
+ ? { ...selectionEvent.attrs, ...resolvedAttrs }
958
+ : selectionEvent.attrs;
959
+ }, []);
960
+ const handleInlineMentionSuggestionPress = (0, react_1.useCallback)((suggestion) => {
961
+ const mentionQuery = mentionQueryEventRef.current;
962
+ const mentions = addonsRef.current?.mentions;
963
+ if (!mentionQuery ||
964
+ !mentions ||
965
+ !bridgeRef.current ||
966
+ bridgeRef.current.isDestroyed) {
967
+ return;
968
+ }
969
+ const attrs = resolveMentionSelectionAttrs({
970
+ trigger: mentionQuery.trigger,
971
+ suggestion,
972
+ attrs: resolveMentionSuggestionAttrs(suggestion, mentionQuery.trigger),
973
+ range: mentionQuery.range,
974
+ });
975
+ const update = runAndApply(() => bridgeRef.current?.insertContentJsonAtSelectionScalar(mentionQuery.range.anchor, mentionQuery.range.head, (0, addons_1.buildMentionFragmentJson)(attrs)) ?? null);
976
+ if (update) {
977
+ setMentionQueryEvent(null);
978
+ mentions.onSelect?.({
979
+ trigger: mentionQuery.trigger,
980
+ suggestion,
981
+ attrs,
982
+ });
983
+ }
984
+ }, [resolveMentionSelectionAttrs, runAndApply]);
900
985
  const handleAddonEvent = (0, react_1.useCallback)((event) => {
901
986
  let parsed = null;
902
987
  try {
@@ -908,11 +993,18 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
908
993
  if (!parsed)
909
994
  return;
910
995
  if (parsed.type === 'mentionsQueryChange') {
911
- addonsRef.current?.mentions?.onQueryChange?.({
996
+ const nextEvent = {
912
997
  query: parsed.query,
913
998
  trigger: parsed.trigger,
914
999
  range: parsed.range,
915
1000
  isActive: parsed.isActive,
1001
+ };
1002
+ setMentionQueryEvent(parsed.isActive ? nextEvent : null);
1003
+ addonsRef.current?.mentions?.onQueryChange?.({
1004
+ query: nextEvent.query,
1005
+ trigger: nextEvent.trigger,
1006
+ range: nextEvent.range,
1007
+ isActive: nextEvent.isActive,
916
1008
  });
917
1009
  return;
918
1010
  }
@@ -926,19 +1018,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
926
1018
  attrs: parsed.attrs,
927
1019
  range: parsed.range,
928
1020
  };
929
- let resolvedAttrs;
930
- try {
931
- resolvedAttrs =
932
- addonsRef.current?.mentions?.resolveSelectionAttrs?.(selectionEvent);
933
- }
934
- catch (error) {
935
- if (__DEV__) {
936
- console.error('NativeRichTextEditor: mentions.resolveSelectionAttrs threw', error);
937
- }
938
- }
939
- const finalAttrs = isRecord(resolvedAttrs)
940
- ? { ...parsed.attrs, ...resolvedAttrs }
941
- : parsed.attrs;
1021
+ const finalAttrs = resolveMentionSelectionAttrs(selectionEvent);
942
1022
  const update = runAndApply(() => bridgeRef.current?.insertContentJsonAtSelectionScalar(parsed.range.anchor, parsed.range.head, (0, addons_1.buildMentionFragmentJson)(finalAttrs)) ?? null);
943
1023
  if (update) {
944
1024
  addonsRef.current?.mentions?.onSelect?.({
@@ -959,7 +1039,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
959
1039
  attrs: parsed.attrs,
960
1040
  });
961
1041
  }
962
- }, [runAndApply]);
1042
+ }, [resolveMentionSelectionAttrs, runAndApply]);
963
1043
  (0, react_1.useImperativeHandle)(ref, () => ({
964
1044
  focus() {
965
1045
  nativeViewRef.current?.focus?.();
@@ -1092,6 +1172,25 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1092
1172
  borderWidth: theme?.toolbar?.borderWidth,
1093
1173
  borderRadius: theme?.toolbar?.borderRadius,
1094
1174
  };
1175
+ const inlineToolbarMarginTop = theme?.toolbar?.marginTop ?? 8;
1176
+ const inlineToolbarShowTopBorder = theme?.toolbar?.showTopBorder ?? false;
1177
+ const inlineToolbarMentionTheme = theme?.mentions ?? addons?.mentions?.theme;
1178
+ const inlineToolbarContentTopBorderStyle = inlineToolbarShowTopBorder
1179
+ ? {
1180
+ borderTopWidth: theme?.toolbar?.borderWidth ?? react_native_1.StyleSheet.hairlineWidth,
1181
+ borderTopColor: theme?.toolbar?.borderColor ?? INLINE_TOOLBAR_BORDER_COLOR,
1182
+ }
1183
+ : null;
1184
+ const inlineMentionSuggestions = toolbarPlacement === 'inline' &&
1185
+ isFocused &&
1186
+ mentionQueryEvent != null &&
1187
+ addons?.mentions != null
1188
+ ? filterMentionSuggestions(addons.mentions.suggestions ?? [], mentionQueryEvent.query, mentionQueryEvent.trigger || resolveMentionTrigger(addons))
1189
+ : [];
1190
+ const shouldShowInlineMentionSuggestions = shouldRenderJsToolbar &&
1191
+ toolbarPlacement === 'inline' &&
1192
+ isFocused &&
1193
+ inlineMentionSuggestions.length > 0;
1095
1194
  const containerMinHeight = react_native_1.StyleSheet.flatten(containerStyle)?.minHeight;
1096
1195
  const nativeViewStyleParts = [];
1097
1196
  if (containerMinHeight != null) {
@@ -1106,6 +1205,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1106
1205
  const nativeViewStyle = nativeViewStyleParts.length <= 1 ? nativeViewStyleParts[0] : nativeViewStyleParts;
1107
1206
  const jsToolbar = ((0, jsx_runtime_1.jsx)(react_native_1.View, { ref: toolbarRef, testID: 'native-editor-js-toolbar', style: [
1108
1207
  styles.inlineToolbar,
1208
+ { marginTop: inlineToolbarMarginTop },
1109
1209
  inlineToolbarChrome.backgroundColor != null
1110
1210
  ? { backgroundColor: inlineToolbarChrome.backgroundColor }
1111
1211
  : null,
@@ -1118,7 +1218,43 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1118
1218
  inlineToolbarChrome.borderRadius != null
1119
1219
  ? { borderRadius: inlineToolbarChrome.borderRadius }
1120
1220
  : null,
1121
- ], onLayout: updateToolbarFrame, children: (0, jsx_runtime_1.jsx)(EditorToolbar_1.EditorToolbar, { activeState: activeState, historyState: historyState, toolbarItems: toolbarItems, theme: theme?.toolbar, showTopBorder: false, onToggleMark: (mark) => runAndApply(() => bridgeRef.current?.toggleMark(mark) ?? null, {
1221
+ ], onLayout: updateToolbarFrame, children: shouldShowInlineMentionSuggestions ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: 'native-editor-inline-mention-suggestions', style: [
1222
+ styles.inlineMentionSuggestionsContainer,
1223
+ inlineToolbarContentTopBorderStyle,
1224
+ ], children: (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, contentContainerStyle: styles.inlineMentionSuggestionsContent, keyboardShouldPersistTaps: 'always', children: inlineMentionSuggestions.map((suggestion) => {
1225
+ const label = resolveMentionSuggestionLabel(suggestion, mentionQueryEvent?.trigger ?? resolveMentionTrigger(addons));
1226
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: `native-editor-inline-mention-suggestion-${suggestion.key}`, onPress: () => handleInlineMentionSuggestionPress(suggestion), accessibilityRole: 'button', accessibilityLabel: label, style: ({ pressed }) => [
1227
+ styles.inlineMentionSuggestion,
1228
+ {
1229
+ backgroundColor: pressed
1230
+ ? (inlineToolbarMentionTheme?.optionHighlightedBackgroundColor ??
1231
+ 'rgba(0, 122, 255, 0.12)')
1232
+ : (inlineToolbarMentionTheme?.backgroundColor ??
1233
+ '#F2F2F7'),
1234
+ borderColor: inlineToolbarMentionTheme?.borderColor ??
1235
+ 'transparent',
1236
+ borderWidth: inlineToolbarMentionTheme?.borderWidth ?? 0,
1237
+ borderRadius: inlineToolbarMentionTheme?.borderRadius ?? 12,
1238
+ },
1239
+ ], children: ({ pressed }) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { numberOfLines: 1, style: [
1240
+ styles.inlineMentionSuggestionTitle,
1241
+ {
1242
+ color: pressed
1243
+ ? (inlineToolbarMentionTheme?.optionHighlightedTextColor ??
1244
+ inlineToolbarMentionTheme?.optionTextColor ??
1245
+ '#000000')
1246
+ : (inlineToolbarMentionTheme?.optionTextColor ??
1247
+ inlineToolbarMentionTheme?.textColor ??
1248
+ '#000000'),
1249
+ },
1250
+ ], children: label }), suggestion.subtitle ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { numberOfLines: 1, style: [
1251
+ styles.inlineMentionSuggestionSubtitle,
1252
+ {
1253
+ color: inlineToolbarMentionTheme?.optionSecondaryTextColor ??
1254
+ '#8E8E93',
1255
+ },
1256
+ ], children: suggestion.subtitle })) : null] })) }, suggestion.key));
1257
+ }) }) })) : ((0, jsx_runtime_1.jsx)(EditorToolbar_1.EditorToolbar, { activeState: activeState, historyState: historyState, toolbarItems: toolbarItems, theme: theme?.toolbar, showTopBorder: inlineToolbarShowTopBorder, onToggleMark: (mark) => runAndApply(() => bridgeRef.current?.toggleMark(mark) ?? null, {
1122
1258
  skipNativeApplyIfContentUnchanged: true,
1123
1259
  }), onToggleListType: (listType) => runAndApply(() => bridgeRef.current?.toggleList(listType) ?? null), onToggleHeading: (level) => runAndApply(() => bridgeRef.current?.toggleHeading(level) ?? null), onToggleBlockquote: () => runAndApply(() => bridgeRef.current?.toggleBlockquote() ?? null), onInsertNodeType: (nodeType) => runAndApply(() => bridgeRef.current?.insertNode(nodeType) ?? null), onRunCommand: (command) => {
1124
1260
  switch (command) {
@@ -1143,7 +1279,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1143
1279
  skipNativeApplyIfContentUnchanged: true,
1144
1280
  }), onToggleStrike: () => runAndApply(() => bridgeRef.current?.toggleMark('strike') ?? null, {
1145
1281
  skipNativeApplyIfContentUnchanged: true,
1146
- }), onToggleBulletList: () => runAndApply(() => bridgeRef.current?.toggleList('bulletList') ?? null), onToggleOrderedList: () => runAndApply(() => bridgeRef.current?.toggleList('orderedList') ?? null), onIndentList: () => runAndApply(() => bridgeRef.current?.indentListItem() ?? null), onOutdentList: () => runAndApply(() => bridgeRef.current?.outdentListItem() ?? null), onInsertHorizontalRule: () => runAndApply(() => bridgeRef.current?.insertNode('horizontalRule') ?? null), onInsertLineBreak: () => runAndApply(() => bridgeRef.current?.insertNode('hardBreak') ?? null), onUndo: () => runAndApply(() => bridgeRef.current?.undo() ?? null), onRedo: () => runAndApply(() => bridgeRef.current?.redo() ?? null) }) }));
1282
+ }), onToggleBulletList: () => runAndApply(() => bridgeRef.current?.toggleList('bulletList') ?? null), onToggleOrderedList: () => runAndApply(() => bridgeRef.current?.toggleList('orderedList') ?? null), onIndentList: () => runAndApply(() => bridgeRef.current?.indentListItem() ?? null), onOutdentList: () => runAndApply(() => bridgeRef.current?.outdentListItem() ?? null), onInsertHorizontalRule: () => runAndApply(() => bridgeRef.current?.insertNode('horizontalRule') ?? null), onInsertLineBreak: () => runAndApply(() => bridgeRef.current?.insertNode('hardBreak') ?? null), onUndo: () => runAndApply(() => bridgeRef.current?.undo() ?? null), onRedo: () => runAndApply(() => bridgeRef.current?.redo() ?? null) })) }));
1147
1283
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, containerStyle], children: [(0, jsx_runtime_1.jsx)(NativeEditorView, { ref: nativeViewRef, style: nativeViewStyle, editorId: editorInstanceId, placeholder: placeholder, editable: editable, autoFocus: autoFocus, showToolbar: showToolbar, toolbarPlacement: toolbarPlacement, heightBehavior: heightBehavior, allowImageResizing: allowImageResizing, themeJson: themeJson, addonsJson: addonsJson, toolbarItemsJson: toolbarItemsJson, remoteSelectionsJson: remoteSelectionsJson, toolbarFrameJson: toolbarPlacement === 'inline' && isFocused ? toolbarFrameJson : undefined, editorUpdateJson: pendingNativeUpdate.json, editorUpdateRevision: pendingNativeUpdate.revision, onEditorUpdate: handleUpdate, onSelectionChange: handleSelectionChange, onFocusChange: handleFocusChange, onContentHeightChange: handleContentHeightChange, onToolbarAction: handleToolbarAction, onAddonEvent: handleAddonEvent }, DEV_NATIVE_VIEW_KEY), shouldRenderJsToolbar && jsToolbar] }));
1148
1284
  });
1149
1285
  const styles = react_native_1.StyleSheet.create({
@@ -1151,9 +1287,32 @@ const styles = react_native_1.StyleSheet.create({
1151
1287
  position: 'relative',
1152
1288
  },
1153
1289
  inlineToolbar: {
1154
- marginTop: 8,
1155
1290
  borderWidth: react_native_1.StyleSheet.hairlineWidth,
1156
- borderColor: '#E5E5EA',
1291
+ borderColor: INLINE_TOOLBAR_BORDER_COLOR,
1292
+ overflow: 'hidden',
1293
+ },
1294
+ inlineMentionSuggestionsContainer: {
1157
1295
  overflow: 'hidden',
1158
1296
  },
1297
+ inlineMentionSuggestionsContent: {
1298
+ paddingHorizontal: 12,
1299
+ paddingVertical: 8,
1300
+ alignItems: 'center',
1301
+ },
1302
+ inlineMentionSuggestion: {
1303
+ minWidth: 88,
1304
+ minHeight: 40,
1305
+ marginRight: 8,
1306
+ paddingHorizontal: 12,
1307
+ paddingVertical: 8,
1308
+ justifyContent: 'center',
1309
+ },
1310
+ inlineMentionSuggestionTitle: {
1311
+ fontSize: 14,
1312
+ fontWeight: '600',
1313
+ },
1314
+ inlineMentionSuggestionSubtitle: {
1315
+ marginTop: 1,
1316
+ fontSize: 12,
1317
+ },
1159
1318
  });
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>libeditor_core.a</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64_x86_64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>libeditor_core.a</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
+ <string>x86_64</string>
17
18
  </array>
18
19
  <key>SupportedPlatform</key>
19
20
  <string>ios</string>
21
+ <key>SupportedPlatformVariant</key>
22
+ <string>simulator</string>
20
23
  </dict>
21
24
  <dict>
22
25
  <key>BinaryPath</key>
23
26
  <string>libeditor_core.a</string>
24
27
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64_x86_64-simulator</string>
28
+ <string>ios-arm64</string>
26
29
  <key>LibraryPath</key>
27
30
  <string>libeditor_core.a</string>
28
31
  <key>SupportedArchitectures</key>
29
32
  <array>
30
33
  <string>arm64</string>
31
- <string>x86_64</string>
32
34
  </array>
33
35
  <key>SupportedPlatform</key>
34
36
  <string>ios</string>
35
- <key>SupportedPlatformVariant</key>
36
- <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -80,12 +80,14 @@ struct EditorTextStyle {
80
80
 
81
81
  struct EditorListTheme {
82
82
  var indent: CGFloat?
83
+ var baseIndentMultiplier: CGFloat?
83
84
  var itemSpacing: CGFloat?
84
85
  var markerColor: UIColor?
85
86
  var markerScale: CGFloat?
86
87
 
87
88
  init(dictionary: [String: Any]) {
88
89
  indent = EditorTheme.cgFloat(dictionary["indent"])
90
+ baseIndentMultiplier = EditorTheme.cgFloat(dictionary["baseIndentMultiplier"])
89
91
  itemSpacing = EditorTheme.cgFloat(dictionary["itemSpacing"])
90
92
  markerColor = EditorTheme.color(from: dictionary["markerColor"])
91
93
  markerScale = EditorTheme.cgFloat(dictionary["markerScale"])
@@ -194,6 +196,8 @@ struct EditorToolbarTheme {
194
196
  var borderColor: UIColor?
195
197
  var borderWidth: CGFloat?
196
198
  var borderRadius: CGFloat?
199
+ var marginTop: CGFloat?
200
+ var showTopBorder: Bool?
197
201
  var keyboardOffset: CGFloat?
198
202
  var horizontalInset: CGFloat?
199
203
  var separatorColor: UIColor?
@@ -209,6 +213,8 @@ struct EditorToolbarTheme {
209
213
  borderColor = EditorTheme.color(from: dictionary["borderColor"])
210
214
  borderWidth = EditorTheme.cgFloat(dictionary["borderWidth"])
211
215
  borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
216
+ marginTop = EditorTheme.cgFloat(dictionary["marginTop"])
217
+ showTopBorder = dictionary["showTopBorder"] as? Bool
212
218
  keyboardOffset = EditorTheme.cgFloat(dictionary["keyboardOffset"])
213
219
  horizontalInset = EditorTheme.cgFloat(dictionary["horizontalInset"])
214
220
  separatorColor = EditorTheme.color(from: dictionary["separatorColor"])
@@ -390,6 +390,10 @@ public class NativeEditorModule: Module {
390
390
  Prop("themeJson") { (view: NativeProseViewerExpoView, themeJson: String?) in
391
391
  view.setThemeJson(themeJson)
392
392
  }
393
+ Prop("collapsesWhenEmpty") {
394
+ (view: NativeProseViewerExpoView, collapsesWhenEmpty: Bool?) in
395
+ view.setCollapsesWhenEmpty(collapsesWhenEmpty)
396
+ }
393
397
  Prop("enableLinkTaps") { (view: NativeProseViewerExpoView, enableLinkTaps: Bool?) in
394
398
  view.setEnableLinkTaps(enableLinkTaps)
395
399
  }
@@ -12,6 +12,8 @@ final class NativeProseViewerExpoView: ExpoView {
12
12
  private var lastEmittedContentHeight: CGFloat = 0
13
13
  private var lastMeasuredWidth: CGFloat = 0
14
14
  private var allowContentHeightShrink = true
15
+ private var collapsesWhenEmpty = true
16
+ private var isCollapsedEmptyContent = false
15
17
  private var enableLinkTaps = true
16
18
  private var interceptLinkTaps = false
17
19
 
@@ -51,6 +53,16 @@ final class NativeProseViewerExpoView: ExpoView {
51
53
  interceptLinkTaps = intercept ?? false
52
54
  }
53
55
 
56
+ func setCollapsesWhenEmpty(_ collapses: Bool?) {
57
+ let nextValue = collapses ?? true
58
+ guard collapsesWhenEmpty != nextValue else { return }
59
+ collapsesWhenEmpty = nextValue
60
+ allowContentHeightShrink = true
61
+ updateCollapsedEmptyState()
62
+ setNeedsLayout()
63
+ emitContentHeightIfNeeded(force: true)
64
+ }
65
+
54
66
  func setRenderJson(_ renderJson: String?) {
55
67
  guard lastRenderJSON != renderJson else { return }
56
68
  lastRenderJSON = renderJson
@@ -71,6 +83,9 @@ final class NativeProseViewerExpoView: ExpoView {
71
83
  }
72
84
 
73
85
  override var intrinsicContentSize: CGSize {
86
+ if isCollapsedEmptyContent {
87
+ return CGSize(width: UIView.noIntrinsicMetric, height: 0)
88
+ }
74
89
  guard lastEmittedContentHeight > 0 else {
75
90
  return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
76
91
  }
@@ -79,8 +94,13 @@ final class NativeProseViewerExpoView: ExpoView {
79
94
 
80
95
  override func layoutSubviews() {
81
96
  super.layoutSubviews()
82
- textView.frame = bounds
83
- textView.updateAutoGrowHostHeight(bounds.height)
97
+ if isCollapsedEmptyContent {
98
+ textView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 0)
99
+ textView.updateAutoGrowHostHeight(0)
100
+ } else {
101
+ textView.frame = bounds
102
+ textView.updateAutoGrowHostHeight(bounds.height)
103
+ }
84
104
 
85
105
  let currentWidth = ceil(bounds.width)
86
106
  guard abs(currentWidth - lastMeasuredWidth) > 0.5 else { return }
@@ -89,21 +109,36 @@ final class NativeProseViewerExpoView: ExpoView {
89
109
  }
90
110
 
91
111
  private func applyRenderJSON() {
112
+ updateCollapsedEmptyState()
92
113
  textView.applyRenderJSON(lastRenderJSON ?? "[]")
114
+ textView.isHidden = isCollapsedEmptyContent
115
+ invalidateIntrinsicContentSize()
116
+ setNeedsLayout()
93
117
  emitContentHeightIfNeeded(force: true)
94
118
  }
95
119
 
120
+ private func updateCollapsedEmptyState() {
121
+ isCollapsedEmptyContent = collapsesWhenEmpty
122
+ && Self.renderJsonContainsOnlyEmptyParagraphs(lastRenderJSON ?? "[]")
123
+ textView.isHidden = isCollapsedEmptyContent
124
+ }
125
+
96
126
  private func emitContentHeightIfNeeded(
97
127
  measuredHeight: CGFloat? = nil,
98
128
  force: Bool = false
99
129
  ) {
100
- let resolvedWidth = bounds.width > 0
101
- ? bounds.width
102
- : (superview?.bounds.width ?? UIScreen.main.bounds.width)
103
- let fittedHeight = measuredHeight
104
- ?? textView.measuredAutoGrowHeightForTesting(width: resolvedWidth)
105
- let contentHeight = ceil(fittedHeight)
106
- guard contentHeight > 0 else { return }
130
+ let contentHeight: CGFloat
131
+ if isCollapsedEmptyContent {
132
+ contentHeight = 0
133
+ } else {
134
+ let resolvedWidth = bounds.width > 0
135
+ ? bounds.width
136
+ : (superview?.bounds.width ?? UIScreen.main.bounds.width)
137
+ let fittedHeight = measuredHeight
138
+ ?? textView.measuredAutoGrowHeightForTesting(width: resolvedWidth)
139
+ contentHeight = ceil(fittedHeight)
140
+ guard contentHeight > 0 else { return }
141
+ }
107
142
  guard allowContentHeightShrink || contentHeight >= lastEmittedContentHeight else { return }
108
143
  allowContentHeightShrink = false
109
144
  guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
@@ -194,4 +229,56 @@ final class NativeProseViewerExpoView: ExpoView {
194
229
  guard let url = URL(string: href) else { return }
195
230
  UIApplication.shared.open(url, options: [:], completionHandler: nil)
196
231
  }
232
+
233
+ static func renderJsonContainsOnlyEmptyParagraphs(_ renderJson: String) -> Bool {
234
+ guard let data = renderJson.data(using: .utf8),
235
+ let elements = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
236
+ else {
237
+ return false
238
+ }
239
+
240
+ if elements.isEmpty {
241
+ return true
242
+ }
243
+
244
+ var hasParagraph = false
245
+ var paragraphIsOpen = false
246
+
247
+ for element in elements {
248
+ guard let type = element["type"] as? String else {
249
+ return false
250
+ }
251
+
252
+ switch type {
253
+ case "blockStart":
254
+ guard !paragraphIsOpen,
255
+ element["nodeType"] as? String == "paragraph",
256
+ (element["depth"] as? NSNumber)?.intValue == 0
257
+ else {
258
+ return false
259
+ }
260
+ paragraphIsOpen = true
261
+ hasParagraph = true
262
+
263
+ case "textRun":
264
+ guard paragraphIsOpen,
265
+ let text = element["text"] as? String,
266
+ text.allSatisfy({ $0 == "\u{200B}" })
267
+ else {
268
+ return false
269
+ }
270
+
271
+ case "blockEnd":
272
+ guard paragraphIsOpen else {
273
+ return false
274
+ }
275
+ paragraphIsOpen = false
276
+
277
+ default:
278
+ return false
279
+ }
280
+ }
281
+
282
+ return hasParagraph && !paragraphIsOpen
283
+ }
197
284
  }
@@ -847,8 +847,13 @@ final class RenderBridge {
847
847
  (theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap)
848
848
  + (theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth)
849
849
  )
850
+ let listBaseIndentMultiplier = max(theme?.list?.baseIndentMultiplier ?? 1, 0)
851
+ let listBaseIndentAdjustment = context.listContext != nil
852
+ ? ((listBaseIndentMultiplier - 1) * indentPerDepth)
853
+ : 0
850
854
  let baseIndent = (CGFloat(context.depth) * indentPerDepth)
851
855
  - (quoteDepth * indentPerDepth)
856
+ + listBaseIndentAdjustment
852
857
  + (quoteDepth * quoteIndent)
853
858
 
854
859
  if context.listContext != nil {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Native rich text editor with Rust core for React Native",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/apollohg/react-native-prose-editor",