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

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,8 @@ 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 kotlin.math.abs
14
+ import org.json.JSONArray
13
15
 
14
16
  class NativeProseViewerExpoView(
15
17
  context: Context,
@@ -26,8 +28,11 @@ class NativeProseViewerExpoView(
26
28
  private var lastRenderJson: String? = null
27
29
  private var lastThemeJson: String? = null
28
30
  private var lastEmittedContentHeight = 0
31
+ private var collapsesWhenEmpty = true
32
+ private var isCollapsedEmptyContent = false
29
33
  private var enableLinkTaps = true
30
34
  private var interceptLinkTaps = false
35
+ internal var suppressContentHeightEventsForTesting = false
31
36
 
32
37
  init {
33
38
  proseView.setBaseStyle(
@@ -36,6 +41,9 @@ class NativeProseViewerExpoView(
36
41
  Color.TRANSPARENT
37
42
  )
38
43
  proseView.isEditable = false
44
+ proseView.inputType = android.text.InputType.TYPE_CLASS_TEXT or
45
+ android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
46
+ android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
39
47
  proseView.setImageResizingEnabled(false)
40
48
  proseView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
41
49
  proseView.isFocusable = false
@@ -84,22 +92,25 @@ class NativeProseViewerExpoView(
84
92
  fun setRenderJson(renderJson: String?) {
85
93
  if (lastRenderJson == renderJson) return
86
94
  lastRenderJson = renderJson
87
- proseView.applyRenderJSON(renderJson ?: "[]")
88
- post {
89
- requestLayout()
90
- emitContentHeightIfNeeded(force = true)
91
- }
95
+ applyRenderJson()
96
+ requestLayout()
92
97
  }
93
98
 
94
99
  fun setThemeJson(themeJson: String?) {
95
100
  if (lastThemeJson == themeJson) return
96
101
  lastThemeJson = themeJson
97
102
  proseView.applyTheme(EditorTheme.fromJson(themeJson))
98
- proseView.applyRenderJSON(lastRenderJson ?: "[]")
99
- post {
100
- requestLayout()
101
- emitContentHeightIfNeeded(force = true)
102
- }
103
+ applyRenderJson()
104
+ requestLayout()
105
+ }
106
+
107
+ fun setCollapsesWhenEmpty(collapsesWhenEmpty: Boolean?) {
108
+ val nextValue = collapsesWhenEmpty ?: true
109
+ if (this.collapsesWhenEmpty == nextValue) return
110
+ this.collapsesWhenEmpty = nextValue
111
+ updateCollapsedEmptyState()
112
+ requestLayout()
113
+ emitContentHeightIfNeeded(force = true)
103
114
  }
104
115
 
105
116
  fun setEnableLinkTaps(enableLinkTaps: Boolean?) {
@@ -111,6 +122,12 @@ class NativeProseViewerExpoView(
111
122
  }
112
123
 
113
124
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
125
+ if (isCollapsedEmptyContent) {
126
+ setMeasuredDimension(resolveSize(0, widthMeasureSpec), 0)
127
+ emitContentHeightIfNeeded()
128
+ return
129
+ }
130
+
114
131
  val childWidthSpec = getChildMeasureSpec(
115
132
  widthMeasureSpec,
116
133
  paddingLeft + paddingRight,
@@ -122,15 +139,29 @@ class NativeProseViewerExpoView(
122
139
  )
123
140
  proseView.measure(childWidthSpec, childHeightSpec)
124
141
 
142
+ val resolvedContentHeight = proseView.resolveAutoGrowHeight()
125
143
  val desiredWidth = proseView.measuredWidth + paddingLeft + paddingRight
126
- val desiredHeight = proseView.measuredHeight + paddingTop + paddingBottom
144
+ val desiredHeight = resolvedContentHeight + paddingTop + paddingBottom
145
+ val measuredHeight = when (View.MeasureSpec.getMode(heightMeasureSpec)) {
146
+ View.MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(
147
+ View.MeasureSpec.getSize(heightMeasureSpec)
148
+ )
149
+ else -> desiredHeight
150
+ }
127
151
  setMeasuredDimension(
128
152
  resolveSize(desiredWidth, widthMeasureSpec),
129
- resolveSize(desiredHeight, heightMeasureSpec)
153
+ measuredHeight
130
154
  )
155
+ emitContentHeightIfNeeded(measuredContentHeight = desiredHeight)
131
156
  }
132
157
 
133
158
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
159
+ if (isCollapsedEmptyContent) {
160
+ proseView.layout(paddingLeft, paddingTop, right - left - paddingRight, paddingTop)
161
+ emitContentHeightIfNeeded()
162
+ return
163
+ }
164
+
134
165
  val childLeft = paddingLeft
135
166
  val childTop = paddingTop
136
167
  proseView.layout(
@@ -142,30 +173,63 @@ class NativeProseViewerExpoView(
142
173
  emitContentHeightIfNeeded()
143
174
  }
144
175
 
145
- private fun emitContentHeightIfNeeded(force: Boolean = false) {
146
- val contentHeight = (measureContentHeightPx() + paddingTop + paddingBottom)
147
- .coerceAtLeast(0)
148
- if (contentHeight <= 0) {
176
+ private fun applyRenderJson() {
177
+ updateCollapsedEmptyState()
178
+ proseView.applyRenderJSON(lastRenderJson ?: "[]")
179
+ proseView.visibility = if (isCollapsedEmptyContent) View.GONE else View.VISIBLE
180
+ }
181
+
182
+ private fun updateCollapsedEmptyState() {
183
+ isCollapsedEmptyContent = collapsesWhenEmpty &&
184
+ renderJsonContainsOnlyEmptyParagraphs(lastRenderJson ?: "[]")
185
+ proseView.visibility = if (isCollapsedEmptyContent) View.GONE else View.VISIBLE
186
+ }
187
+
188
+ private fun emitContentHeightIfNeeded(
189
+ force: Boolean = false,
190
+ measuredContentHeight: Int? = null
191
+ ) {
192
+ val contentHeight = if (isCollapsedEmptyContent) {
193
+ 0
194
+ } else {
195
+ (
196
+ measuredContentHeight ?: (measureContentHeightPx() + paddingTop + paddingBottom)
197
+ ).coerceAtLeast(0)
198
+ }
199
+ if (contentHeight <= 0 && !isCollapsedEmptyContent) {
149
200
  return
150
201
  }
151
202
  if (!force && contentHeight == lastEmittedContentHeight) {
152
203
  return
153
204
  }
154
205
  lastEmittedContentHeight = contentHeight
206
+ if (suppressContentHeightEventsForTesting) {
207
+ return
208
+ }
155
209
  onContentHeightChange(mapOf("contentHeight" to contentHeight))
156
210
  }
157
211
 
158
212
  private fun measureContentHeightPx(): Int {
159
- val currentMeasuredHeight = proseView.measuredHeight
160
- if (currentMeasuredHeight > 0 && proseView.layout != null) {
161
- return currentMeasuredHeight
213
+ if (isCollapsedEmptyContent) {
214
+ return 0
162
215
  }
163
216
 
164
217
  val availableWidthPx = resolveAvailableWidthPx()
165
- val childWidthSpec = View.MeasureSpec.makeMeasureSpec(availableWidthPx, View.MeasureSpec.EXACTLY)
166
- val childHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
167
- proseView.measure(childWidthSpec, childHeightSpec)
168
- return proseView.measuredHeight
218
+ if (
219
+ proseView.measuredWidth <= 0 ||
220
+ abs(proseView.measuredWidth - availableWidthPx) > 1
221
+ ) {
222
+ val childWidthSpec = View.MeasureSpec.makeMeasureSpec(
223
+ availableWidthPx,
224
+ View.MeasureSpec.EXACTLY
225
+ )
226
+ val childHeightSpec = View.MeasureSpec.makeMeasureSpec(
227
+ 0,
228
+ View.MeasureSpec.UNSPECIFIED
229
+ )
230
+ proseView.measure(childWidthSpec, childHeightSpec)
231
+ }
232
+ return proseView.resolveAutoGrowHeight()
169
233
  }
170
234
 
171
235
  private fun resolveAvailableWidthPx(): Int {
@@ -191,4 +255,61 @@ class NativeProseViewerExpoView(
191
255
  true
192
256
  }.getOrDefault(false)
193
257
  }
258
+
259
+ companion object {
260
+ private const val EMPTY_TEXT_BLOCK_PLACEHOLDER = '\u200B'
261
+
262
+ internal fun renderJsonContainsOnlyEmptyParagraphs(renderJson: String): Boolean {
263
+ val elements = try {
264
+ JSONArray(renderJson)
265
+ } catch (_: Exception) {
266
+ return false
267
+ }
268
+
269
+ if (elements.length() == 0) {
270
+ return true
271
+ }
272
+
273
+ var hasParagraph = false
274
+ var paragraphIsOpen = false
275
+
276
+ for (index in 0 until elements.length()) {
277
+ val element = elements.optJSONObject(index) ?: return false
278
+ when (element.optString("type", "")) {
279
+ "blockStart" -> {
280
+ if (
281
+ paragraphIsOpen ||
282
+ element.optString("nodeType", "") != "paragraph" ||
283
+ element.optInt("depth", 0) != 0
284
+ ) {
285
+ return false
286
+ }
287
+ paragraphIsOpen = true
288
+ hasParagraph = true
289
+ }
290
+
291
+ "textRun" -> {
292
+ val text = element.optString("text", "")
293
+ if (
294
+ !paragraphIsOpen ||
295
+ !text.all { it == EMPTY_TEXT_BLOCK_PLACEHOLDER }
296
+ ) {
297
+ return false
298
+ }
299
+ }
300
+
301
+ "blockEnd" -> {
302
+ if (!paragraphIsOpen) {
303
+ return false
304
+ }
305
+ paragraphIsOpen = false
306
+ }
307
+
308
+ else -> return false
309
+ }
310
+ }
311
+
312
+ return hasParagraph && !paragraphIsOpen
313
+ }
314
+ }
194
315
  }
@@ -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,
@@ -4,6 +4,7 @@ exports.NativeProseViewer = NativeProseViewer;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const react_1 = require("react");
6
6
  const expo_modules_core_1 = require("expo-modules-core");
7
+ const react_native_1 = require("react-native");
7
8
  const addons_1 = require("./addons");
8
9
  const EditorTheme_1 = require("./EditorTheme");
9
10
  const schemas_1 = require("./schemas");
@@ -168,6 +169,59 @@ function isEmptyParagraphPlaceholderText(text) {
168
169
  }
169
170
  return Array.from(text).every((char) => char === EMPTY_TEXT_BLOCK_PLACEHOLDER);
170
171
  }
172
+ function isCollapsibleEmptyParagraphText(text) {
173
+ return Array.from(text).every((char) => char === EMPTY_TEXT_BLOCK_PLACEHOLDER);
174
+ }
175
+ function renderElementsJsonContainsOnlyEmptyParagraphs(renderJson) {
176
+ let parsedElements;
177
+ try {
178
+ parsedElements = JSON.parse(renderJson);
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ if (!Array.isArray(parsedElements)) {
184
+ return false;
185
+ }
186
+ if (parsedElements.length === 0) {
187
+ return true;
188
+ }
189
+ let hasParagraph = false;
190
+ let paragraphIsOpen = false;
191
+ for (const element of parsedElements) {
192
+ if (element == null || typeof element !== 'object' || Array.isArray(element)) {
193
+ return false;
194
+ }
195
+ const renderElement = element;
196
+ switch (renderElement.type) {
197
+ case 'blockStart':
198
+ if (paragraphIsOpen ||
199
+ renderElement.nodeType !== 'paragraph' ||
200
+ renderElement.depth !== 0) {
201
+ return false;
202
+ }
203
+ paragraphIsOpen = true;
204
+ hasParagraph = true;
205
+ break;
206
+ case 'textRun':
207
+ if (!paragraphIsOpen ||
208
+ typeof renderElement.text !== 'string' ||
209
+ !isCollapsibleEmptyParagraphText(renderElement.text)) {
210
+ return false;
211
+ }
212
+ break;
213
+ case 'blockEnd':
214
+ if (!paragraphIsOpen) {
215
+ return false;
216
+ }
217
+ paragraphIsOpen = false;
218
+ break;
219
+ default:
220
+ return false;
221
+ }
222
+ }
223
+ return hasParagraph && !paragraphIsOpen;
224
+ }
171
225
  function isTrailingEmptyParagraphRange(elements, start, endExclusive) {
172
226
  const startElement = elements[start];
173
227
  const endElement = elements[endExclusive - 1];
@@ -325,26 +379,22 @@ function NativeProseViewer({ ...props }) {
325
379
  mentionPayloadsByDocPos,
326
380
  serializedContentJson,
327
381
  ]);
382
+ const renderJsonIsCollapsedEmpty = (0, react_1.useMemo)(() => collapseTrailingEmptyParagraphs &&
383
+ renderElementsJsonContainsOnlyEmptyParagraphs(renderJson), [collapseTrailingEmptyParagraphs, renderJson]);
328
384
  const [contentHeight, setContentHeight] = (0, react_1.useState)(null);
329
- const allowContentHeightShrinkRef = (0, react_1.useRef)(true);
330
- (0, react_1.useEffect)(() => {
331
- allowContentHeightShrinkRef.current = true;
332
- }, [resolvedContentRevision, renderJson, themeJson]);
333
385
  const handleContentHeightChange = (0, react_1.useCallback)((event) => {
334
- const nextHeight = event.nativeEvent.contentHeight;
335
- if (nextHeight <= 0)
386
+ const density = react_native_1.Platform.OS === 'android' ? react_native_1.PixelRatio.get() : 1;
387
+ const nextHeight = Math.ceil(event.nativeEvent.contentHeight / density);
388
+ if (nextHeight < 0)
389
+ return;
390
+ if (nextHeight === 0 && !renderJsonIsCollapsedEmpty)
391
+ return;
392
+ if (nextHeight === 0) {
393
+ setContentHeight((currentHeight) => currentHeight === 0 ? currentHeight : 0);
336
394
  return;
337
- setContentHeight((currentHeight) => currentHeight == null ||
338
- nextHeight >= currentHeight ||
339
- allowContentHeightShrinkRef.current
340
- ? (() => {
341
- allowContentHeightShrinkRef.current = false;
342
- return currentHeight === nextHeight
343
- ? currentHeight
344
- : nextHeight;
345
- })()
346
- : currentHeight);
347
- }, []);
395
+ }
396
+ setContentHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight);
397
+ }, [renderJsonIsCollapsedEmpty]);
348
398
  const handlePressMention = (0, react_1.useCallback)((event) => {
349
399
  if (!onPressMention)
350
400
  return;
@@ -364,10 +414,19 @@ function NativeProseViewer({ ...props }) {
364
414
  text: event.nativeEvent.text,
365
415
  });
366
416
  }, [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 }));
417
+ const nativeStyle = (0, react_1.useMemo)(() => {
418
+ let measuredStyle = null;
419
+ if (renderJsonIsCollapsedEmpty) {
420
+ measuredStyle = { height: 0, minHeight: 0 };
421
+ }
422
+ else if (contentHeight != null && contentHeight > 0) {
423
+ measuredStyle = { minHeight: contentHeight };
424
+ }
425
+ return [
426
+ { minHeight: renderJsonIsCollapsedEmpty ? 0 : 1 },
427
+ style,
428
+ measuredStyle,
429
+ ];
430
+ }, [contentHeight, renderJsonIsCollapsedEmpty, style]);
431
+ 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
432
  }
@@ -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
  });
@@ -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
  }
@@ -11,7 +11,8 @@ final class NativeProseViewerExpoView: ExpoView {
11
11
  private var lastThemeJSON: String?
12
12
  private var lastEmittedContentHeight: CGFloat = 0
13
13
  private var lastMeasuredWidth: CGFloat = 0
14
- private var allowContentHeightShrink = true
14
+ private var collapsesWhenEmpty = true
15
+ private var isCollapsedEmptyContent = false
15
16
  private var enableLinkTaps = true
16
17
  private var interceptLinkTaps = false
17
18
 
@@ -51,17 +52,24 @@ final class NativeProseViewerExpoView: ExpoView {
51
52
  interceptLinkTaps = intercept ?? false
52
53
  }
53
54
 
55
+ func setCollapsesWhenEmpty(_ collapses: Bool?) {
56
+ let nextValue = collapses ?? true
57
+ guard collapsesWhenEmpty != nextValue else { return }
58
+ collapsesWhenEmpty = nextValue
59
+ updateCollapsedEmptyState()
60
+ setNeedsLayout()
61
+ emitContentHeightIfNeeded(force: true)
62
+ }
63
+
54
64
  func setRenderJson(_ renderJson: String?) {
55
65
  guard lastRenderJSON != renderJson else { return }
56
66
  lastRenderJSON = renderJson
57
- allowContentHeightShrink = true
58
67
  applyRenderJSON()
59
68
  }
60
69
 
61
70
  func setThemeJson(_ themeJson: String?) {
62
71
  guard lastThemeJSON != themeJson else { return }
63
72
  lastThemeJSON = themeJson
64
- allowContentHeightShrink = true
65
73
  let theme = EditorTheme.from(json: themeJson)
66
74
  textView.applyTheme(theme)
67
75
  let cornerRadius = theme?.borderRadius ?? 0
@@ -71,6 +79,9 @@ final class NativeProseViewerExpoView: ExpoView {
71
79
  }
72
80
 
73
81
  override var intrinsicContentSize: CGSize {
82
+ if isCollapsedEmptyContent {
83
+ return CGSize(width: UIView.noIntrinsicMetric, height: 0)
84
+ }
74
85
  guard lastEmittedContentHeight > 0 else {
75
86
  return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
76
87
  }
@@ -79,8 +90,13 @@ final class NativeProseViewerExpoView: ExpoView {
79
90
 
80
91
  override func layoutSubviews() {
81
92
  super.layoutSubviews()
82
- textView.frame = bounds
83
- textView.updateAutoGrowHostHeight(bounds.height)
93
+ if isCollapsedEmptyContent {
94
+ textView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 0)
95
+ textView.updateAutoGrowHostHeight(0)
96
+ } else {
97
+ textView.frame = bounds
98
+ textView.updateAutoGrowHostHeight(bounds.height)
99
+ }
84
100
 
85
101
  let currentWidth = ceil(bounds.width)
86
102
  guard abs(currentWidth - lastMeasuredWidth) > 0.5 else { return }
@@ -89,23 +105,34 @@ final class NativeProseViewerExpoView: ExpoView {
89
105
  }
90
106
 
91
107
  private func applyRenderJSON() {
108
+ updateCollapsedEmptyState()
92
109
  textView.applyRenderJSON(lastRenderJSON ?? "[]")
93
- emitContentHeightIfNeeded(force: true)
110
+ textView.isHidden = isCollapsedEmptyContent
111
+ lastMeasuredWidth = 0
112
+ invalidateIntrinsicContentSize()
113
+ setNeedsLayout()
114
+ }
115
+
116
+ private func updateCollapsedEmptyState() {
117
+ isCollapsedEmptyContent = collapsesWhenEmpty
118
+ && Self.renderJsonContainsOnlyEmptyParagraphs(lastRenderJSON ?? "[]")
119
+ textView.isHidden = isCollapsedEmptyContent
94
120
  }
95
121
 
96
122
  private func emitContentHeightIfNeeded(
97
123
  measuredHeight: CGFloat? = nil,
98
124
  force: Bool = false
99
125
  ) {
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 }
107
- guard allowContentHeightShrink || contentHeight >= lastEmittedContentHeight else { return }
108
- allowContentHeightShrink = false
126
+ let contentHeight: CGFloat
127
+ if isCollapsedEmptyContent {
128
+ contentHeight = 0
129
+ } else {
130
+ guard bounds.width > 0 else { return }
131
+ let fittedHeight = measuredHeight
132
+ ?? textView.measuredAutoGrowHeightForTesting(width: bounds.width)
133
+ contentHeight = ceil(fittedHeight)
134
+ guard contentHeight > 0 else { return }
135
+ }
109
136
  guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
110
137
  lastEmittedContentHeight = contentHeight
111
138
  invalidateIntrinsicContentSize()
@@ -194,4 +221,56 @@ final class NativeProseViewerExpoView: ExpoView {
194
221
  guard let url = URL(string: href) else { return }
195
222
  UIApplication.shared.open(url, options: [:], completionHandler: nil)
196
223
  }
224
+
225
+ static func renderJsonContainsOnlyEmptyParagraphs(_ renderJson: String) -> Bool {
226
+ guard let data = renderJson.data(using: .utf8),
227
+ let elements = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
228
+ else {
229
+ return false
230
+ }
231
+
232
+ if elements.isEmpty {
233
+ return true
234
+ }
235
+
236
+ var hasParagraph = false
237
+ var paragraphIsOpen = false
238
+
239
+ for element in elements {
240
+ guard let type = element["type"] as? String else {
241
+ return false
242
+ }
243
+
244
+ switch type {
245
+ case "blockStart":
246
+ guard !paragraphIsOpen,
247
+ element["nodeType"] as? String == "paragraph",
248
+ (element["depth"] as? NSNumber)?.intValue == 0
249
+ else {
250
+ return false
251
+ }
252
+ paragraphIsOpen = true
253
+ hasParagraph = true
254
+
255
+ case "textRun":
256
+ guard paragraphIsOpen,
257
+ let text = element["text"] as? String,
258
+ text.allSatisfy({ $0 == "\u{200B}" })
259
+ else {
260
+ return false
261
+ }
262
+
263
+ case "blockEnd":
264
+ guard paragraphIsOpen else {
265
+ return false
266
+ }
267
+ paragraphIsOpen = false
268
+
269
+ default:
270
+ return false
271
+ }
272
+ }
273
+
274
+ return hasParagraph && !paragraphIsOpen
275
+ }
197
276
  }
@@ -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.5",
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",