@openeditor/react-native-prose-editor 0.0.24 → 0.0.25

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.
@@ -352,6 +352,10 @@ data class EditorTheme(
352
352
  val toolbar: EditorToolbarTheme? = null,
353
353
  val placeholderColor: Int? = null,
354
354
  val backgroundColor: Int? = null,
355
+ val blockSurfaceColor: Int? = null,
356
+ val structuralLineColor: Int? = null,
357
+ val accentStrongColor: Int? = null,
358
+ val accentStrongTextColor: Int? = null,
355
359
  val borderRadius: Float? = null,
356
360
  val contentInsets: EditorContentInsets? = null
357
361
  ) {
@@ -386,6 +390,10 @@ data class EditorTheme(
386
390
  toolbar = EditorToolbarTheme.fromJson(root.optJSONObject("toolbar")),
387
391
  placeholderColor = parseColor(root.optNullableString("placeholderColor")),
388
392
  backgroundColor = parseColor(root.optNullableString("backgroundColor")),
393
+ blockSurfaceColor = parseColor(root.optNullableString("blockSurfaceColor")),
394
+ structuralLineColor = parseColor(root.optNullableString("structuralLineColor")),
395
+ accentStrongColor = parseColor(root.optNullableString("accentStrongColor")),
396
+ accentStrongTextColor = parseColor(root.optNullableString("accentStrongTextColor")),
389
397
  borderRadius = root.optNullableFloat("borderRadius"),
390
398
  contentInsets = EditorContentInsets.fromJson(root.optJSONObject("contentInsets"))
391
399
  )
@@ -2,6 +2,9 @@ package com.openeditor.editor
2
2
 
3
3
  import android.content.Context
4
4
  import android.graphics.Color
5
+ import android.graphics.Canvas
6
+ import android.graphics.Paint
7
+ import android.graphics.Path
5
8
  import android.graphics.Rect
6
9
  import android.graphics.RectF
7
10
  import android.graphics.Typeface
@@ -49,6 +52,7 @@ internal data class SelectedImageGeometry(val docPos: Int, val rect: RectF)
49
52
  class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
50
53
  var onPageOpen: ((String, String, String?, String?) -> Unit)? = null
51
54
  var onAttachmentOpen: ((String?, String, String?, Long?, String?) -> Unit)? = null
55
+ var onEmojiEditRequest: ((String, Int, String, JSONObject) -> Unit)? = null
52
56
  var isScrollingEnabled: Boolean = true
53
57
 
54
58
  internal var geometryRevision: Long = 0
@@ -63,6 +67,33 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
63
67
  val isAtom: Boolean
64
68
  )
65
69
 
70
+ private class ToggleCaretView(context: Context) : View(context) {
71
+ var open: Boolean = true
72
+ set(value) {
73
+ field = value
74
+ invalidate()
75
+ }
76
+ var caretColor: Int = Color.GRAY
77
+
78
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
79
+ private val path = Path()
80
+
81
+ override fun onDraw(canvas: Canvas) {
82
+ super.onDraw(canvas)
83
+ val density = resources.displayMetrics.density
84
+ val centerX = width / 2f
85
+ val centerY = height / 2f
86
+ path.reset()
87
+ path.moveTo(centerX - 3f * density, centerY - 4f * density)
88
+ path.lineTo(centerX + 3f * density, centerY)
89
+ path.lineTo(centerX - 3f * density, centerY + 4f * density)
90
+ path.close()
91
+ paint.color = caretColor
92
+ if (open) canvas.rotate(90f, centerX, centerY)
93
+ canvas.drawPath(path, paint)
94
+ }
95
+ }
96
+
66
97
  private class TextLeaf(context: Context) : AppCompatEditText(context) {
67
98
  var positionSegments: List<PositionSegment> = emptyList()
68
99
  var editorSurface: NativeBlockEditorSurface? = null
@@ -204,11 +235,11 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
204
235
  val available = MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(1)
205
236
  val intrinsicWidth = drawable?.intrinsicWidth?.takeIf { it > 0 } ?: available
206
237
  val intrinsicHeight = drawable?.intrinsicHeight?.takeIf { it > 0 } ?: (intrinsicWidth * 0.56f).toInt()
207
- val requestedWidth = preferredWidthPx ?: intrinsicWidth
208
- val width = minOf(requestedWidth.coerceAtLeast(1), available)
209
- val requestedHeight = preferredHeightPx
210
- ?: ((width.toFloat() / intrinsicWidth.coerceAtLeast(1)) * intrinsicHeight).toInt()
211
- setMeasuredDimension(width, requestedHeight.coerceAtLeast(1))
238
+ val sourceWidth = (preferredWidthPx ?: intrinsicWidth).coerceAtLeast(1)
239
+ val sourceHeight = (preferredHeightPx ?: intrinsicHeight).coerceAtLeast(1)
240
+ val width = minOf(sourceWidth, available)
241
+ val height = (width.toFloat() / sourceWidth * sourceHeight).toInt().coerceAtLeast(1)
242
+ setMeasuredDimension(width, height)
212
243
  }
213
244
  }
214
245
 
@@ -221,6 +252,10 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
221
252
  private var baseTextColor: Int = Color.BLACK
222
253
  private var isApplyingRemoteUpdate = false
223
254
  private var currentTheme: EditorTheme? = null
255
+ private val resolvedBlockSurfaceColor: Int
256
+ get() = currentTheme?.blockSurfaceColor ?: 0xFFEEEEEE.toInt()
257
+ private val resolvedStructuralLineColor: Int
258
+ get() = currentTheme?.structuralLineColor ?: 0xFFD4D4D4.toInt()
224
259
  private var nextTableId = 1
225
260
  private val tableCells = mutableListOf<CellView>()
226
261
  private var selectionAnchorCell: CellView? = null
@@ -742,7 +777,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
742
777
  setPadding((depth * 8 * density).toInt(), (6 * density).toInt(), 0, (6 * density).toInt())
743
778
  }
744
779
  row.addView(
745
- View(context).apply { setBackgroundColor(currentTheme?.blockquote?.borderColor ?: Color.LTGRAY) },
780
+ View(context).apply { setBackgroundColor(currentTheme?.blockquote?.borderColor ?: resolvedStructuralLineColor) },
746
781
  LinearLayout.LayoutParams(
747
782
  (((currentTheme?.blockquote?.borderWidth ?: 3f) * density).toInt()).coerceAtLeast(1),
748
783
  ViewGroup.LayoutParams.MATCH_PARENT
@@ -779,18 +814,24 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
779
814
  val container = LinearLayout(context).apply {
780
815
  orientation = LinearLayout.HORIZONTAL
781
816
  gravity = android.view.Gravity.TOP
782
- val padding = (12 * density).toInt()
783
- setPadding(padding, padding, padding, padding)
817
+ val horizontalPadding = ((currentTheme?.codeBlock?.paddingHorizontal ?: 14f) * density).toInt()
818
+ val verticalPadding = ((currentTheme?.codeBlock?.paddingVertical ?: 12f) * density).toInt()
819
+ setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
784
820
  background = roundedDrawable(
785
- currentTheme?.codeBlock?.backgroundColor ?: 0xFFF5F5F5.toInt(),
821
+ resolvedBlockSurfaceColor,
786
822
  Color.TRANSPARENT,
787
823
  0f,
788
- 10f
824
+ 12f
789
825
  )
790
826
  }
791
827
  container.addView(TextView(context).apply {
792
- text = node.optJSONObject("attrs")?.optString("emoji", "💡") ?: "💡"
828
+ val emoji = node.optJSONObject("attrs")?.optString("emoji", "💡") ?: "💡"
829
+ text = emoji
793
830
  setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
831
+ contentDescription = "Change callout emoji"
832
+ isClickable = isEditable
833
+ isFocusable = isEditable
834
+ if (isEditable) setOnClickListener { requestEmojiEdit(node, "callout", emoji) }
794
835
  })
795
836
  val content = LinearLayout(context).apply {
796
837
  orientation = LinearLayout.VERTICAL
@@ -805,17 +846,14 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
805
846
  }
806
847
 
807
848
  private fun toggleMarkerView(node: JSONObject, content: LinearLayout, initiallyOpen: Boolean): View =
808
- TextView(context).apply {
809
- var open = initiallyOpen
810
- text = if (open) "⌄" else "›"
849
+ ToggleCaretView(context).apply {
850
+ open = initiallyOpen
851
+ caretColor = currentTheme?.list?.markerColor ?: baseTextColor
811
852
  contentDescription = if (open) "Collapse toggle item" else "Expand toggle item"
812
- gravity = android.view.Gravity.CENTER
813
- setTextColor(currentTheme?.list?.markerColor ?: baseTextColor)
814
- setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
815
- setTypeface(typeface, Typeface.BOLD)
853
+ isClickable = true
854
+ isFocusable = true
816
855
  setOnClickListener {
817
856
  open = !open
818
- text = if (open) "⌄" else "›"
819
857
  contentDescription = if (open) "Collapse toggle item" else "Expand toggle item"
820
858
  setToggleContent(content, open)
821
859
  if (isEditable) updateNodeAttrs(node, JSONObject().put("open", open))
@@ -841,13 +879,23 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
841
879
  onAppliedUpdate?.invoke(updateJSON)
842
880
  }
843
881
 
882
+ private fun requestEmojiEdit(node: JSONObject, nodeType: String, emoji: String) {
883
+ if (!isEditable) return
884
+ onEmojiEditRequest?.invoke(
885
+ nodeType,
886
+ node.optInt("docPos", 0),
887
+ emoji,
888
+ JSONObject(node.optJSONObject("attrs")?.toString() ?: "{}"),
889
+ )
890
+ }
891
+
844
892
  private fun tableView(node: JSONObject, path: List<Int>): View {
845
893
  val tableId = nextTableId++
846
894
  val table = LinearLayout(context).apply {
847
895
  orientation = LinearLayout.VERTICAL
848
896
  background = borderDrawable(
849
897
  Color.TRANSPARENT,
850
- currentTheme?.table?.borderColor ?: baseTextColor,
898
+ currentTheme?.table?.borderColor ?: resolvedStructuralLineColor,
851
899
  1f
852
900
  )
853
901
  }
@@ -877,12 +925,10 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
877
925
  val density = resources.displayMetrics.density
878
926
  val fillColor = if (node.optString("nodeType") == "tableHeader") {
879
927
  currentTheme?.table?.headerBackgroundColor
880
- ?: currentTheme?.codeBlock?.backgroundColor
881
- ?: Color.TRANSPARENT
928
+ ?: resolvedBlockSurfaceColor
882
929
  } else {
883
930
  currentTheme?.table?.cellBackgroundColor
884
- ?: currentTheme?.backgroundColor
885
- ?: Color.TRANSPARENT
931
+ ?: resolvedBlockSurfaceColor
886
932
  }
887
933
  val cell = CellView(context).apply {
888
934
  docPos = node.optInt("docPos", 0)
@@ -894,7 +940,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
894
940
  setPadding((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt())
895
941
  background = borderDrawable(
896
942
  fillColor,
897
- currentTheme?.table?.borderColor ?: baseTextColor,
943
+ currentTheme?.table?.borderColor ?: resolvedStructuralLineColor,
898
944
  0.5f
899
945
  )
900
946
  isClickable = true
@@ -926,28 +972,42 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
926
972
  setPadding(horizontal + (depth * 8 * density).toInt(), vertical, horizontal, vertical)
927
973
  }
928
974
  row.addView(TextView(context).apply {
929
- text = attrs?.optString("icon", "📄") ?: "📄"
975
+ val icon = attrs?.optString("icon", "📄") ?: "📄"
976
+ text = icon
977
+ gravity = android.view.Gravity.CENTER
978
+ includeFontPadding = false
930
979
  setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
931
- }, LinearLayout.LayoutParams((28 * density).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT))
980
+ contentDescription = "Change page emoji"
981
+ isClickable = isEditable
982
+ isFocusable = isEditable
983
+ if (isEditable) setOnClickListener { requestEmojiEdit(node, "page", icon) }
984
+ }, LinearLayout.LayoutParams((24 * density).toInt(), (36 * density).toInt()))
932
985
  val title = textBlockView(node, depth) as TextLeaf
933
- title.setTypeface(title.typeface, Typeface.BOLD)
986
+ title.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
987
+ title.paintFlags = title.paintFlags or Paint.UNDERLINE_TEXT_FLAG
988
+ title.gravity = android.view.Gravity.CENTER_VERTICAL
989
+ title.includeFontPadding = false
990
+ title.keyListener = null
991
+ title.isCursorVisible = false
992
+ title.isFocusable = false
993
+ title.isFocusableInTouchMode = false
994
+ title.isClickable = false
995
+ title.setTextIsSelectable(false)
934
996
  row.addView(title, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
935
- row.addView(TextView(context).apply {
936
- text = "→"
937
- contentDescription = "Open page"
938
- gravity = android.view.Gravity.CENTER
939
- setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
940
- setTypeface(typeface, Typeface.BOLD)
941
- setOnClickListener {
942
- val pageId = attrs?.optString("pageId").orEmpty()
943
- if (pageId.isNotEmpty()) onPageOpen?.invoke(
997
+ val pageId = attrs?.optString("pageId").orEmpty()
998
+ if (pageId.isNotEmpty()) {
999
+ row.isClickable = true
1000
+ row.isFocusable = true
1001
+ row.contentDescription = "Open page"
1002
+ row.setOnClickListener {
1003
+ onPageOpen?.invoke(
944
1004
  pageId,
945
1005
  title.text?.toString().orEmpty().ifEmpty { "Untitled" },
946
1006
  attrs?.optString("icon")?.takeIf(String::isNotEmpty),
947
1007
  attrs?.optString("href")?.takeIf(String::isNotEmpty),
948
1008
  )
949
1009
  }
950
- }, LinearLayout.LayoutParams((40 * density).toInt(), (40 * density).toInt()))
1010
+ }
951
1011
  return row
952
1012
  }
953
1013
 
@@ -959,12 +1019,22 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
959
1019
  gravity = android.view.Gravity.CENTER_VERTICAL
960
1020
  val padding = (10 * density).toInt()
961
1021
  setPadding(padding + (depth * 8 * density).toInt(), padding, padding, padding)
962
- background = borderDrawable(Color.TRANSPARENT, currentTheme?.table?.borderColor ?: baseTextColor, 1f)
963
- addView(TextView(context).apply { text = "📎"; setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) }, LinearLayout.LayoutParams((38 * density).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT))
1022
+ background = roundedDrawable(Color.TRANSPARENT, currentTheme?.table?.borderColor ?: baseTextColor, 1f, 12f)
1023
+ val name = attrs?.optString("name").orEmpty().ifEmpty { "Attachment" }
1024
+ val type = attachmentTypeLabel(attrs?.optString("mimeType")?.takeIf(String::isNotEmpty), name)
1025
+ addView(TextView(context).apply {
1026
+ text = type
1027
+ gravity = android.view.Gravity.CENTER
1028
+ setTextColor(currentTheme?.placeholderColor ?: baseTextColor)
1029
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 9f)
1030
+ setTypeface(typeface, Typeface.BOLD)
1031
+ background = roundedDrawable(resolvedBlockSurfaceColor, Color.TRANSPARENT, 0f, 9f)
1032
+ }, LinearLayout.LayoutParams((42 * density).toInt(), (48 * density).toInt()).apply { marginEnd = (12 * density).toInt() })
964
1033
  addView(LinearLayout(context).apply {
965
1034
  orientation = LinearLayout.VERTICAL
966
- addView(TextView(context).apply { text = attrs?.optString("name").orEmpty().ifEmpty { "Attachment" }; setTextColor(baseTextColor); setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f); setTypeface(typeface, Typeface.BOLD) })
967
- addView(TextView(context).apply { text = attrs?.optString("mimeType").orEmpty().ifEmpty { "File" }; setTextColor(currentTheme?.placeholderColor ?: baseTextColor); setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) })
1035
+ addView(TextView(context).apply { text = name; setTextColor(baseTextColor); setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f); setTypeface(typeface, Typeface.BOLD) })
1036
+ val size = attrs?.optLong("size")?.takeIf { attrs.has("size") && !attrs.isNull("size") }?.let(::formatAttachmentSize)
1037
+ addView(TextView(context).apply { text = listOfNotNull(size, type).joinToString(" · "); setTextColor(currentTheme?.placeholderColor ?: baseTextColor); setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) })
968
1038
  }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
969
1039
  isClickable = true
970
1040
  isFocusable = true
@@ -981,6 +1051,18 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
981
1051
  }
982
1052
  }
983
1053
 
1054
+ private fun attachmentTypeLabel(mimeType: String?, name: String): String {
1055
+ val subtype = mimeType?.substringAfterLast('/')?.substringBefore('+')?.takeIf(String::isNotBlank)
1056
+ val extension = name.substringAfterLast('.', "").takeIf(String::isNotBlank)
1057
+ return (subtype ?: extension ?: "FILE").take(4).uppercase()
1058
+ }
1059
+
1060
+ private fun formatAttachmentSize(bytes: Long): String = when {
1061
+ bytes < 1_024 -> "$bytes B"
1062
+ bytes < 1_048_576 -> if (bytes < 10_240) "%.1f KB".format(bytes / 1_024.0) else "%.0f KB".format(bytes / 1_024.0)
1063
+ else -> if (bytes < 10_485_760) "%.1f MB".format(bytes / 1_048_576.0) else "%.0f MB".format(bytes / 1_048_576.0)
1064
+ }
1065
+
984
1066
  private fun configureTextLeaf(leaf: TextLeaf, node: JSONObject) {
985
1067
  val rendered = attributedInlineContent(node)
986
1068
  val hadFocus = leaf.hasFocus()
@@ -1004,7 +1086,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
1004
1086
  minLines = 1
1005
1087
  background = if (node.optString("nodeType") == "codeBlock" || node.optString("nodeType") == "code_block") {
1006
1088
  roundedDrawable(
1007
- currentTheme?.codeBlock?.backgroundColor ?: 0xFFF4F5F7.toInt(),
1089
+ currentTheme?.codeBlock?.backgroundColor ?: resolvedBlockSurfaceColor,
1008
1090
  Color.TRANSPARENT,
1009
1091
  0f,
1010
1092
  currentTheme?.codeBlock?.borderRadius ?: 8f
@@ -1163,7 +1245,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
1163
1245
  setPadding(0, margin, 0, margin)
1164
1246
  addView(
1165
1247
  View(context).apply {
1166
- setBackgroundColor(currentTheme?.horizontalRule?.color ?: Color.LTGRAY)
1248
+ setBackgroundColor(currentTheme?.horizontalRule?.color ?: resolvedStructuralLineColor)
1167
1249
  },
1168
1250
  LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, thickness)
1169
1251
  )
@@ -1174,11 +1256,11 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
1174
1256
  }
1175
1257
  return TextView(context).apply {
1176
1258
  text = node.optString("label", node.optString("nodeType", "Block"))
1177
- setTextColor(currentTheme?.text?.color ?: baseTextColor)
1259
+ setTextColor(currentTheme?.placeholderColor ?: baseTextColor)
1178
1260
  textSize = 14f
1179
1261
  setPadding(12, 8, 12, 8)
1180
1262
  background = borderDrawable(
1181
- currentTheme?.codeBlock?.backgroundColor ?: Color.TRANSPARENT,
1263
+ resolvedBlockSurfaceColor,
1182
1264
  Color.TRANSPARENT,
1183
1265
  0f
1184
1266
  )
@@ -1200,7 +1282,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
1200
1282
  scaleType = ImageView.ScaleType.FIT_CENTER
1201
1283
  adjustViewBounds = true
1202
1284
  background = roundedDrawable(
1203
- currentTheme?.codeBlock?.backgroundColor ?: Color.TRANSPARENT,
1285
+ resolvedBlockSurfaceColor,
1204
1286
  Color.TRANSPARENT,
1205
1287
  0f,
1206
1288
  8f
@@ -1230,7 +1312,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
1230
1312
  for (imageView in imageViews) {
1231
1313
  val selected = isEditable && imageView.docPos == selectedNodePos
1232
1314
  imageView.background = roundedDrawable(
1233
- if (imageView.drawable == null) 0xFFF2F3F5.toInt() else Color.TRANSPARENT,
1315
+ if (imageView.drawable == null) resolvedBlockSurfaceColor else Color.TRANSPARENT,
1234
1316
  if (selected) 0xFF2F80ED.toInt() else Color.TRANSPARENT,
1235
1317
  if (selected) 2f else 0f,
1236
1318
  8f
@@ -1525,14 +1607,14 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
1525
1607
  val checked = node.optJSONObject("attrs")?.optBoolean("checked", false) ?: false
1526
1608
  return TextView(context).apply {
1527
1609
  text = if (checked) "✓" else ""
1528
- setTextColor(Color.WHITE)
1610
+ setTextColor(currentTheme?.accentStrongTextColor ?: Color.WHITE)
1529
1611
  textSize = 12f
1530
1612
  gravity = android.view.Gravity.CENTER
1531
1613
  includeFontPadding = false
1532
1614
  background = roundedDrawable(
1533
- if (checked) (currentTheme?.list?.markerColor ?: baseTextColor) else Color.TRANSPARENT,
1534
- currentTheme?.list?.markerColor ?: Color.GRAY,
1535
- 1.5f,
1615
+ if (checked) (currentTheme?.accentStrongColor ?: baseTextColor) else resolvedBlockSurfaceColor,
1616
+ Color.TRANSPARENT,
1617
+ 0f,
1536
1618
  4f
1537
1619
  )
1538
1620
  setOnClickListener { toggleTaskItem(node) }
@@ -741,6 +741,21 @@ class NativeEditorExpoView(
741
741
  url?.let { event["url"] = it }
742
742
  emitAddonEvent(mapOf("eventJson" to JSONObject(event).toString()))
743
743
  }
744
+ richTextView.onEmojiEditRequest = { nodeType, docPos, emoji, attrs ->
745
+ emitAddonEvent(
746
+ mapOf(
747
+ "eventJson" to JSONObject(
748
+ mapOf(
749
+ "type" to "emojiEditRequest",
750
+ "nodeType" to nodeType,
751
+ "docPos" to docPos,
752
+ "emoji" to emoji,
753
+ "attrs" to attrs,
754
+ )
755
+ ).toString()
756
+ )
757
+ )
758
+ }
744
759
  richTextView.onBeforeDetachedFromWindow = {
745
760
  prepareForDetachFromWindow()
746
761
  }
@@ -357,6 +357,11 @@ class NativeEditorModule : Module() {
357
357
  val editorId = nativeULong(id) ?: return@Function nativeArgumentError("editor id")
358
358
  editorSetMark(editorId, markName, attrsJson)
359
359
  }
360
+ Function("editorUpdateNodeAttrs") { id: Int, pos: Int, attrsJson: String ->
361
+ val editorId = nativeULong(id) ?: return@Function nativeArgumentError("editor id")
362
+ val position = nativeUInt(pos) ?: return@Function nativeArgumentError("position")
363
+ editorUpdateNodeAttrs(editorId, position, attrsJson)
364
+ }
360
365
  Function("editorUnsetMark") { id: Int, markName: String ->
361
366
  val editorId = nativeULong(id) ?: return@Function nativeArgumentError("editor id")
362
367
  editorUnsetMark(editorId, markName)
@@ -8,6 +8,7 @@ import android.util.AttributeSet
8
8
  import android.view.ViewGroup
9
9
  import android.widget.FrameLayout
10
10
  import android.widget.LinearLayout
11
+ import org.json.JSONObject
11
12
  import uniffi.editor_core.*
12
13
 
13
14
  /**
@@ -40,6 +41,7 @@ class RichTextEditorView @JvmOverloads constructor(
40
41
  var onFocusChange: ((Boolean) -> Unit)? = null
41
42
  var onPageOpen: ((String, String, String?, String?) -> Unit)? = null
42
43
  var onAttachmentOpen: ((String?, String, String?, Long?, String?) -> Unit)? = null
44
+ var onEmojiEditRequest: ((String, Int, String, JSONObject) -> Unit)? = null
43
45
 
44
46
  var editorId: Long
45
47
  get() = currentEditorId
@@ -64,6 +66,7 @@ class RichTextEditorView @JvmOverloads constructor(
64
66
  blockSurface.onFocusChange = { focused -> onFocusChange?.invoke(focused) }
65
67
  blockSurface.onPageOpen = { pageId, title, icon, href -> onPageOpen?.invoke(pageId, title, icon, href) }
66
68
  blockSurface.onAttachmentOpen = { attachmentId, name, mimeType, size, url -> onAttachmentOpen?.invoke(attachmentId, name, mimeType, size, url) }
69
+ blockSurface.onEmojiEditRequest = { nodeType, docPos, emoji, attrs -> onEmojiEditRequest?.invoke(nodeType, docPos, emoji, attrs) }
67
70
  updateAppearance()
68
71
  }
69
72
 
@@ -115,6 +115,10 @@ export interface EditorTheme {
115
115
  toolbar?: EditorToolbarTheme;
116
116
  placeholderColor?: string;
117
117
  backgroundColor?: string;
118
+ blockSurfaceColor?: string;
119
+ structuralLineColor?: string;
120
+ accentStrongColor?: string;
121
+ accentStrongTextColor?: string;
118
122
  borderRadius?: number;
119
123
  contentInsets?: EditorContentInsets;
120
124
  }
@@ -32,6 +32,7 @@ export interface NativeEditorModule {
32
32
  editorInsertContentJsonAtSelectionScalar(editorId: number, scalarAnchor: number, scalarHead: number, json: string): string;
33
33
  editorToggleMark(editorId: number, markName: string): string;
34
34
  editorSetMark(editorId: number, markName: string, attrsJson: string): string;
35
+ editorUpdateNodeAttrs(editorId: number, pos: number, attrsJson: string): string;
35
36
  editorUnsetMark(editorId: number, markName: string): string;
36
37
  editorToggleBlockquote(editorId: number): string;
37
38
  editorToggleHeading(editorId: number, level: number): string;
@@ -241,6 +242,8 @@ export declare class NativeEditorBridge {
241
242
  deleteRange(from: number, to: number): EditorUpdate | null;
242
243
  /** Replace the current selection with text atomically. */
243
244
  replaceSelectionText(text: string): EditorUpdate | null;
245
+ /** Replace a node's attributes at its document position. */
246
+ updateNodeAttrs(pos: number, attrs: Record<string, unknown>): EditorUpdate | null;
244
247
  /** Toggle a mark (bold, italic, etc.) on the current selection. */
245
248
  toggleMark(markType: string): EditorUpdate | null;
246
249
  /** Set a mark with attrs on the current selection. */
@@ -519,6 +519,11 @@ class NativeEditorBridge {
519
519
  this.assertNotDestroyed();
520
520
  return this.runPreparedCommand(() => getNativeModule().editorReplaceSelectionText(this._editorId, text));
521
521
  }
522
+ /** Replace a node's attributes at its document position. */
523
+ updateNodeAttrs(pos, attrs) {
524
+ this.assertNotDestroyed();
525
+ return this.runPreparedCommand(() => getNativeModule().editorUpdateNodeAttrs(this._editorId, pos, JSON.stringify(attrs)));
526
+ }
522
527
  /** Toggle a mark (bold, italic, etc.) on the current selection. */
523
528
  toggleMark(markType) {
524
529
  this.assertNotDestroyed();
@@ -96,6 +96,13 @@ export interface NativeRichTextEditorProps {
96
96
  size: number | null;
97
97
  url: string | null;
98
98
  }) => void;
99
+ /** Called when an editable callout or page emoji is pressed. */
100
+ onRequestEmoji?: (request: {
101
+ nodeType: 'callout' | 'page';
102
+ docPos: number;
103
+ emoji: string;
104
+ attrs: Record<string, unknown>;
105
+ }) => void;
99
106
  /** Whether plain URLs typed or pasted into the editor should be converted into link marks automatically. */
100
107
  autoDetectLinks?: boolean;
101
108
  /** Whether `data:image/...` sources are accepted for image insertion and HTML parsing. */
@@ -163,6 +170,8 @@ export interface NativeRichTextEditorRef {
163
170
  moveTableCell(forward?: boolean): void;
164
171
  /** Insert text at the current cursor position. */
165
172
  insertText(text: string): void;
173
+ /** Replace a node's attributes at its document position. */
174
+ updateNodeAttrs(pos: number, attrs: Record<string, unknown>): void;
166
175
  /** Insert HTML content at the current selection. */
167
176
  insertContentHtml(html: string): void;
168
177
  /** Insert JSON content at the current selection. */
@@ -559,7 +559,7 @@ function doesLiveMentionQueryConflictWithNativeSelectRequest(request, currentQue
559
559
  currentQuery.range.anchor !== request.range.anchor ||
560
560
  currentQuery.range.head !== request.range.head);
561
561
  }
562
- exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEditor({ initialContent, initialJSON, value, valueJSON, valueJSONRevision, valueJSONUpdateMode = 'replace', preserveSelectionOnValueJSONReset = false, selectionOnValueJSONReset, schema, placeholder, editable = true, maxLength, autoFocus = false, autoCapitalize, autoCorrect, keyboardType, keyboardAppearance, heightBehavior = 'autoGrow', showToolbar = true, toolbarPlacement = 'keyboard', toolbarItems = EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS, onToolbarAction, onRequestLink, onRequestImage, onOpenPage, onOpenAttachment, autoDetectLinks = false, onContentChange, onContentChangeJSON, onSelectionChange, onActiveStateChange, onHistoryStateChange, onFocus, onBlur, style, containerStyle, theme, addons, remoteSelections, allowBase64Images = false, allowImageResizing = true, }, ref) {
562
+ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEditor({ initialContent, initialJSON, value, valueJSON, valueJSONRevision, valueJSONUpdateMode = 'replace', preserveSelectionOnValueJSONReset = false, selectionOnValueJSONReset, schema, placeholder, editable = true, maxLength, autoFocus = false, autoCapitalize, autoCorrect, keyboardType, keyboardAppearance, heightBehavior = 'autoGrow', showToolbar = true, toolbarPlacement = 'keyboard', toolbarItems = EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS, onToolbarAction, onRequestLink, onRequestImage, onOpenPage, onOpenAttachment, onRequestEmoji, autoDetectLinks = false, onContentChange, onContentChangeJSON, onSelectionChange, onActiveStateChange, onHistoryStateChange, onFocus, onBlur, style, containerStyle, theme, addons, remoteSelections, allowBase64Images = false, allowImageResizing = true, }, ref) {
563
563
  const bridgeRef = (0, react_1.useRef)(null);
564
564
  const nativeViewRef = (0, react_1.useRef)(null);
565
565
  const [isReady, setIsReady] = (0, react_1.useState)(false);
@@ -2054,6 +2054,15 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
2054
2054
  }
2055
2055
  if (!parsed)
2056
2056
  return;
2057
+ if (parsed.type === 'emojiEditRequest') {
2058
+ onRequestEmoji?.({
2059
+ nodeType: parsed.nodeType,
2060
+ docPos: parsed.docPos,
2061
+ emoji: parsed.emoji,
2062
+ attrs: parsed.attrs,
2063
+ });
2064
+ return;
2065
+ }
2057
2066
  if (parsed.type === 'attachmentOpen') {
2058
2067
  onOpenAttachment?.({
2059
2068
  attachmentId: parsed.attachmentId ?? null,
@@ -2188,7 +2197,9 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
2188
2197
  resolveMentionInsertionAttrs,
2189
2198
  runAndApplyWithCommandRetry,
2190
2199
  setMentionQueryEventState,
2200
+ onOpenAttachment,
2191
2201
  onOpenPage,
2202
+ onRequestEmoji,
2192
2203
  syncPreflightUpdateFromNativeEvent,
2193
2204
  ]);
2194
2205
  (0, react_1.useImperativeHandle)(ref, () => ({
@@ -2259,6 +2270,9 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
2259
2270
  insertText(text) {
2260
2271
  runAndApply(() => bridgeRef.current?.replaceSelectionText(text) ?? null);
2261
2272
  },
2273
+ updateNodeAttrs(pos, attrs) {
2274
+ runAndApply(() => bridgeRef.current?.updateNodeAttrs(pos, attrs) ?? null);
2275
+ },
2262
2276
  insertContentHtml(html) {
2263
2277
  runAndApply(() => bridgeRef.current?.insertContentHtml(html) ?? null);
2264
2278
  },
package/dist/addons.d.ts CHANGED
@@ -65,6 +65,12 @@ export interface SerializedEditorAddons {
65
65
  mentions?: SerializedMentionsAddonConfig;
66
66
  }
67
67
  export type EditorAddonEvent = {
68
+ type: 'emojiEditRequest';
69
+ nodeType: 'callout' | 'page';
70
+ docPos: number;
71
+ emoji: string;
72
+ attrs: Record<string, unknown>;
73
+ } | {
68
74
  type: 'attachmentOpen';
69
75
  attachmentId?: string | null;
70
76
  name: string;
@@ -106,6 +106,22 @@ struct EditorHorizontalRuleTheme {
106
106
  }
107
107
  }
108
108
 
109
+ struct EditorTableTheme {
110
+ var cellBackgroundColor: UIColor?
111
+ var headerBackgroundColor: UIColor?
112
+ var borderColor: UIColor?
113
+ var selectionBackgroundColor: UIColor?
114
+ var selectionBorderColor: UIColor?
115
+
116
+ init(dictionary: [String: Any]) {
117
+ cellBackgroundColor = EditorTheme.color(from: dictionary["cellBackgroundColor"])
118
+ headerBackgroundColor = EditorTheme.color(from: dictionary["headerBackgroundColor"])
119
+ borderColor = EditorTheme.color(from: dictionary["borderColor"])
120
+ selectionBackgroundColor = EditorTheme.color(from: dictionary["selectionBackgroundColor"])
121
+ selectionBorderColor = EditorTheme.color(from: dictionary["selectionBorderColor"])
122
+ }
123
+ }
124
+
109
125
  struct EditorBlockquoteTheme {
110
126
  var text: EditorTextStyle?
111
127
  var indent: CGFloat?
@@ -316,12 +332,17 @@ struct EditorTheme {
316
332
  var codeBlock: EditorCodeBlockTheme?
317
333
  var headings: [String: EditorTextStyle] = [:]
318
334
  var list: EditorListTheme?
335
+ var table: EditorTableTheme?
319
336
  var horizontalRule: EditorHorizontalRuleTheme?
320
337
  var mentions: EditorMentionTheme?
321
338
  var links: EditorLinkTheme?
322
339
  var toolbar: EditorToolbarTheme?
323
340
  var placeholderColor: UIColor?
324
341
  var backgroundColor: UIColor?
342
+ var blockSurfaceColor: UIColor?
343
+ var structuralLineColor: UIColor?
344
+ var accentStrongColor: UIColor?
345
+ var accentStrongTextColor: UIColor?
325
346
  var borderRadius: CGFloat?
326
347
  var contentInsets: EditorContentInsets?
327
348
 
@@ -358,6 +379,9 @@ struct EditorTheme {
358
379
  if let list = dictionary["list"] as? [String: Any] {
359
380
  self.list = EditorListTheme(dictionary: list)
360
381
  }
382
+ if let table = dictionary["table"] as? [String: Any] {
383
+ self.table = EditorTableTheme(dictionary: table)
384
+ }
361
385
  if let horizontalRule = dictionary["horizontalRule"] as? [String: Any] {
362
386
  self.horizontalRule = EditorHorizontalRuleTheme(dictionary: horizontalRule)
363
387
  }
@@ -372,6 +396,10 @@ struct EditorTheme {
372
396
  }
373
397
  placeholderColor = EditorTheme.color(from: dictionary["placeholderColor"])
374
398
  backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])
399
+ blockSurfaceColor = EditorTheme.color(from: dictionary["blockSurfaceColor"])
400
+ structuralLineColor = EditorTheme.color(from: dictionary["structuralLineColor"])
401
+ accentStrongColor = EditorTheme.color(from: dictionary["accentStrongColor"])
402
+ accentStrongTextColor = EditorTheme.color(from: dictionary["accentStrongTextColor"])
375
403
  borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
376
404
  if let contentInsets = dictionary["contentInsets"] as? [String: Any] {
377
405
  self.contentInsets = EditorContentInsets(dictionary: contentInsets)
@@ -3,6 +3,7 @@ import UIKit
3
3
  final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestureRecognizerDelegate {
4
4
  var onPageOpen: ((String, String, String?, String?) -> Void)?
5
5
  var onAttachmentOpen: ((String?, String, String?, NSNumber?, String?) -> Void)?
6
+ var onEmojiEditRequest: ((String, UInt32, String, [String: Any]) -> Void)?
6
7
  private final class AttachmentRow: UIStackView {
7
8
  var open: (() -> Void)?
8
9
  override init(frame: CGRect) {
@@ -12,6 +13,29 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
12
13
  }
13
14
  required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
14
15
  @objc private func handleOpen() { open?() }
16
+ override func accessibilityActivate() -> Bool {
17
+ open?()
18
+ return true
19
+ }
20
+ }
21
+ private final class PageRow: UIStackView, UIGestureRecognizerDelegate {
22
+ var open: (() -> Void)?
23
+ override init(frame: CGRect) {
24
+ super.init(frame: frame)
25
+ isUserInteractionEnabled = true
26
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleOpen))
27
+ tap.delegate = self
28
+ addGestureRecognizer(tap)
29
+ }
30
+ required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
31
+ @objc private func handleOpen() { open?() }
32
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
33
+ !(touch.view is UIControl)
34
+ }
35
+ override func accessibilityActivate() -> Bool {
36
+ open?()
37
+ return true
38
+ }
15
39
  }
16
40
  private final class TextLeafView: UITextView {
17
41
  struct PositionSegment {
@@ -192,7 +216,7 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
192
216
  var preferredWidth: CGFloat?
193
217
  var preferredHeight: CGFloat?
194
218
  weak var preferredWidthConstraint: NSLayoutConstraint?
195
- weak var preferredHeightConstraint: NSLayoutConstraint?
219
+ private var aspectRatioConstraint: NSLayoutConstraint?
196
220
 
197
221
  override var intrinsicContentSize: CGSize {
198
222
  let ratio = image.flatMap { image -> CGFloat? in
@@ -206,7 +230,12 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
206
230
  func refreshPreferredSizeConstraints() {
207
231
  let size = intrinsicContentSize
208
232
  preferredWidthConstraint?.constant = size.width
209
- preferredHeightConstraint?.constant = size.height
233
+ let ratio = size.height / max(1, size.width)
234
+ aspectRatioConstraint?.isActive = false
235
+ let constraint = heightAnchor.constraint(equalTo: widthAnchor, multiplier: ratio)
236
+ constraint.priority = .required
237
+ constraint.isActive = true
238
+ aspectRatioConstraint = constraint
210
239
  }
211
240
  }
212
241
 
@@ -215,6 +244,12 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
215
244
  private var currentEditorId: UInt64 = 0
216
245
  private var isApplyingRemoteUpdate = false
217
246
  private var currentTheme: EditorTheme?
247
+ private var resolvedBlockSurfaceColor: UIColor {
248
+ currentTheme?.blockSurfaceColor ?? UIColor(white: 238 / 255, alpha: 1)
249
+ }
250
+ private var resolvedStructuralLineColor: UIColor {
251
+ currentTheme?.structuralLineColor ?? UIColor(white: 212 / 255, alpha: 1)
252
+ }
218
253
  private var nextTableId = 1
219
254
  private var tableCells: [CellView] = []
220
255
  private weak var selectionAnchorCell: CellView?
@@ -836,10 +871,10 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
836
871
  container.layoutMargins = UIEdgeInsets(top: 6, left: 12 + CGFloat(depth) * 8, bottom: 6, right: 0)
837
872
  container.isLayoutMarginsRelativeArrangement = true
838
873
  if (node["nodeType"] as? String) == "blockquote" {
839
- container.layer.borderColor = UIColor.separator.cgColor
874
+ container.layer.borderColor = resolvedStructuralLineColor.cgColor
840
875
  container.layer.borderWidth = 0
841
876
  let border = BlockquoteStripeView()
842
- border.backgroundColor = currentTheme?.blockquote?.borderColor ?? UIColor.separator
877
+ border.backgroundColor = currentTheme?.blockquote?.borderColor ?? resolvedStructuralLineColor
843
878
  let row = UIStackView()
844
879
  row.axis = .horizontal
845
880
  row.spacing = currentTheme?.blockquote?.markerGap ?? 8
@@ -868,14 +903,22 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
868
903
  container.axis = .horizontal
869
904
  container.alignment = .top
870
905
  container.spacing = 10
871
- container.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
906
+ let horizontalPadding = currentTheme?.codeBlock?.paddingHorizontal ?? 14
907
+ let verticalPadding = currentTheme?.codeBlock?.paddingVertical ?? 12
908
+ container.layoutMargins = UIEdgeInsets(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding)
872
909
  container.isLayoutMarginsRelativeArrangement = true
873
- container.backgroundColor = UIColor.secondarySystemBackground
874
- container.layer.cornerRadius = 10
875
-
876
- let emoji = UILabel()
877
- emoji.text = stringAttr(node, "emoji") ?? "💡"
878
- emoji.font = .systemFont(ofSize: 20)
910
+ container.backgroundColor = resolvedBlockSurfaceColor
911
+ container.layer.cornerRadius = 12
912
+
913
+ let emoji = UIButton(type: .system)
914
+ let emojiValue = stringAttr(node, "emoji") ?? "💡"
915
+ emoji.setTitle(emojiValue, for: .normal)
916
+ emoji.titleLabel?.font = .systemFont(ofSize: 20)
917
+ emoji.accessibilityLabel = "Change callout emoji"
918
+ emoji.isEnabled = isEditable
919
+ emoji.addAction(UIAction { [weak self] _ in
920
+ self?.requestEmojiEdit(for: node, nodeType: "callout", emoji: emojiValue)
921
+ }, for: .touchUpInside)
879
922
  container.addArrangedSubview(emoji)
880
923
 
881
924
  let content = UIStackView()
@@ -891,13 +934,14 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
891
934
  private func toggleMarkerView(for node: [String: Any], content: UIStackView) -> UIView {
892
935
  let open = boolAttr(node, "open") ?? true
893
936
  let button = UIButton(type: .system)
894
- button.setTitle(open ? "⌄" : "›", for: .normal)
895
- button.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
937
+ button.setImage(toggleCaretImage(), for: .normal)
938
+ button.tintColor = currentTheme?.list?.markerColor ?? .secondaryLabel
939
+ button.imageView?.transform = open ? CGAffineTransform(rotationAngle: .pi / 2) : .identity
896
940
  button.accessibilityLabel = open ? "Collapse toggle item" : "Expand toggle item"
897
941
  button.addAction(UIAction { [weak self, weak button, weak content] _ in
898
942
  guard let self, let button, let content else { return }
899
- let nextOpen = button.title(for: .normal) != "⌄"
900
- button.setTitle(nextOpen ? "⌄" : "›", for: .normal)
943
+ let nextOpen = button.accessibilityLabel == "Expand toggle item"
944
+ button.imageView?.transform = nextOpen ? CGAffineTransform(rotationAngle: .pi / 2) : .identity
901
945
  button.accessibilityLabel = nextOpen ? "Collapse toggle item" : "Expand toggle item"
902
946
  self.setToggleContent(content, open: nextOpen)
903
947
  if self.isEditable {
@@ -916,6 +960,19 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
916
960
  return wrapper
917
961
  }
918
962
 
963
+ private func toggleCaretImage() -> UIImage? {
964
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 10))
965
+ return renderer.image { _ in
966
+ let path = UIBezierPath()
967
+ path.move(to: CGPoint(x: 1, y: 1))
968
+ path.addLine(to: CGPoint(x: 7, y: 5))
969
+ path.addLine(to: CGPoint(x: 1, y: 9))
970
+ path.close()
971
+ UIColor.white.setFill()
972
+ path.fill()
973
+ }.withRenderingMode(.alwaysTemplate)
974
+ }
975
+
919
976
  private func setToggleContent(_ content: UIStackView, open: Bool) {
920
977
  for (index, child) in content.arrangedSubviews.enumerated() where index > 0 {
921
978
  child.isHidden = !open
@@ -935,6 +992,13 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
935
992
  onAppliedUpdate?(updateJSON)
936
993
  }
937
994
 
995
+ private func requestEmojiEdit(for node: [String: Any], nodeType: String, emoji: String) {
996
+ guard isEditable else { return }
997
+ let attrs = node["attrs"] as? [String: Any] ?? [:]
998
+ let pos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
999
+ onEmojiEditRequest?(nodeType, pos, emoji, attrs)
1000
+ }
1001
+
938
1002
  func blockquoteStripeRectsForTesting() -> [CGRect] {
939
1003
  layoutIfNeeded()
940
1004
  return descendants(of: self)
@@ -980,7 +1044,7 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
980
1044
  let table = UIStackView()
981
1045
  table.axis = .vertical
982
1046
  table.spacing = 0
983
- table.layer.borderColor = UIColor.separator.cgColor
1047
+ table.layer.borderColor = (currentTheme?.table?.borderColor ?? resolvedStructuralLineColor).cgColor
984
1048
  table.layer.borderWidth = 1
985
1049
  table.layer.cornerRadius = 6
986
1050
  table.clipsToBounds = true
@@ -1014,11 +1078,11 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1014
1078
  cell.spacing = 4
1015
1079
  cell.layoutMargins = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
1016
1080
  cell.isLayoutMarginsRelativeArrangement = true
1017
- cell.layer.borderColor = UIColor.separator.cgColor
1081
+ cell.layer.borderColor = (currentTheme?.table?.borderColor ?? resolvedStructuralLineColor).cgColor
1018
1082
  cell.layer.borderWidth = 0.5
1019
1083
  cell.baseFillColor = (node["nodeType"] as? String) == "tableHeader"
1020
- ? UIColor.secondarySystemBackground
1021
- : (currentTheme?.backgroundColor ?? baseBackgroundColor)
1084
+ ? (currentTheme?.table?.headerBackgroundColor ?? resolvedBlockSurfaceColor)
1085
+ : (currentTheme?.table?.cellBackgroundColor ?? resolvedBlockSurfaceColor)
1022
1086
  cell.backgroundColor = cell.baseFillColor
1023
1087
  cell.isUserInteractionEnabled = true
1024
1088
  let tap = UITapGestureRecognizer(target: self, action: #selector(handleCellTap(_:)))
@@ -1051,7 +1115,7 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1051
1115
  }
1052
1116
 
1053
1117
  private func pageView(for node: [String: Any], depth: Int) -> UIView {
1054
- let row = UIStackView()
1118
+ let row = PageRow()
1055
1119
  row.axis = .horizontal
1056
1120
  row.alignment = .center
1057
1121
  row.spacing = 8
@@ -1059,30 +1123,47 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1059
1123
  row.isLayoutMarginsRelativeArrangement = true
1060
1124
  row.layer.cornerRadius = 8
1061
1125
 
1062
- let icon = UILabel()
1063
- icon.text = stringAttr(node, "icon") ?? "📄"
1064
- icon.font = .systemFont(ofSize: 18)
1126
+ let icon = UIButton(type: .system)
1127
+ let iconValue = stringAttr(node, "icon") ?? "📄"
1128
+ icon.setTitle(iconValue, for: .normal)
1129
+ icon.titleLabel?.font = .systemFont(ofSize: 18)
1130
+ icon.accessibilityLabel = "Change page emoji"
1131
+ icon.isEnabled = isEditable
1132
+ icon.addAction(UIAction { [weak self] _ in
1133
+ self?.requestEmojiEdit(for: node, nodeType: "page", emoji: iconValue)
1134
+ }, for: .touchUpInside)
1065
1135
  icon.setContentHuggingPriority(.required, for: .horizontal)
1136
+ icon.widthAnchor.constraint(equalToConstant: 24).isActive = true
1066
1137
  row.addArrangedSubview(icon)
1067
1138
 
1068
1139
  let title = textBlockView(for: node, depth: depth)
1069
1140
  if let leaf = title as? UITextView {
1070
- leaf.font = .systemFont(ofSize: 16, weight: .semibold)
1141
+ let titleText = NSMutableAttributedString(attributedString: leaf.attributedText)
1142
+ if titleText.length > 0 {
1143
+ titleText.addAttributes([
1144
+ .font: UIFont.systemFont(ofSize: 16, weight: .medium),
1145
+ .foregroundColor: baseTextColor,
1146
+ .underlineStyle: NSUnderlineStyle.single.rawValue,
1147
+ .underlineColor: baseTextColor.withAlphaComponent(0.28),
1148
+ ], range: NSRange(location: 0, length: titleText.length))
1149
+ }
1150
+ leaf.attributedText = titleText
1151
+ leaf.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
1071
1152
  leaf.textColor = baseTextColor
1153
+ leaf.isEditable = false
1154
+ leaf.isSelectable = false
1155
+ leaf.isUserInteractionEnabled = false
1072
1156
  }
1073
1157
  row.addArrangedSubview(title)
1074
1158
 
1075
- let open = UIButton(type: .system)
1076
- open.setTitle("→", for: .normal)
1077
- open.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
1078
- open.accessibilityLabel = "Open page"
1079
- open.setContentHuggingPriority(.required, for: .horizontal)
1080
- open.addAction(UIAction { [weak self, weak title] _ in
1159
+ row.isAccessibilityElement = true
1160
+ row.accessibilityTraits = .button
1161
+ row.accessibilityLabel = "Open page"
1162
+ row.open = { [weak self, weak title] in
1081
1163
  guard let pageId = self?.stringAttr(node, "pageId"), !pageId.isEmpty else { return }
1082
1164
  let pageTitle = (title as? UITextView)?.text ?? "Untitled"
1083
1165
  self?.onPageOpen?(pageId, pageTitle, self?.stringAttr(node, "icon"), self?.stringAttr(node, "href"))
1084
- }, for: .touchUpInside)
1085
- row.addArrangedSubview(open)
1166
+ }
1086
1167
  return row
1087
1168
  }
1088
1169
 
@@ -1095,10 +1176,23 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1095
1176
  row.isLayoutMarginsRelativeArrangement = true
1096
1177
  row.layer.cornerRadius = 10
1097
1178
  row.layer.borderWidth = 1
1098
- row.layer.borderColor = UIColor.separator.cgColor
1099
- let icon = UILabel()
1100
- icon.text = "📎"
1101
- icon.font = .systemFont(ofSize: 22)
1179
+ row.layer.borderColor = resolvedStructuralLineColor.cgColor
1180
+ let type = attachmentTypeLabel(mimeType: stringAttr(node, "mimeType"), name: stringAttr(node, "name") ?? "Attachment")
1181
+ let icon = UIView()
1182
+ icon.backgroundColor = resolvedBlockSurfaceColor
1183
+ icon.layer.cornerRadius = 9
1184
+ icon.widthAnchor.constraint(equalToConstant: 42).isActive = true
1185
+ icon.heightAnchor.constraint(equalToConstant: 48).isActive = true
1186
+ let typeLabel = UILabel()
1187
+ typeLabel.text = type
1188
+ typeLabel.font = .systemFont(ofSize: 9, weight: .bold)
1189
+ typeLabel.textColor = .secondaryLabel
1190
+ typeLabel.translatesAutoresizingMaskIntoConstraints = false
1191
+ icon.addSubview(typeLabel)
1192
+ NSLayoutConstraint.activate([
1193
+ typeLabel.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
1194
+ typeLabel.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
1195
+ ])
1102
1196
  row.addArrangedSubview(icon)
1103
1197
  let details = UIStackView()
1104
1198
  details.axis = .vertical
@@ -1109,7 +1203,8 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1109
1203
  name.textColor = baseTextColor
1110
1204
  name.lineBreakMode = .byTruncatingMiddle
1111
1205
  let meta = UILabel()
1112
- meta.text = stringAttr(node, "mimeType") ?? "File"
1206
+ let size = formatAttachmentSize((node["attrs"] as? [String: Any])?["size"] as? NSNumber)
1207
+ meta.text = [size, type].compactMap { $0 }.joined(separator: " · ")
1113
1208
  meta.font = .systemFont(ofSize: 12)
1114
1209
  meta.textColor = .secondaryLabel
1115
1210
  details.addArrangedSubview(name)
@@ -1129,6 +1224,20 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1129
1224
  return row
1130
1225
  }
1131
1226
 
1227
+ private func attachmentTypeLabel(mimeType: String?, name: String) -> String {
1228
+ let subtype = mimeType?.split(separator: "/").last?.split(separator: "+").first.map(String.init)
1229
+ let parts = name.split(separator: ".")
1230
+ let fileExtension = parts.count > 1 ? parts.last.map(String.init) : nil
1231
+ return String((subtype ?? fileExtension ?? "FILE").prefix(4)).uppercased()
1232
+ }
1233
+
1234
+ private func formatAttachmentSize(_ size: NSNumber?) -> String? {
1235
+ guard let bytes = size?.doubleValue, bytes >= 0 else { return nil }
1236
+ if bytes < 1_024 { return "\(Int(bytes.rounded())) B" }
1237
+ if bytes < 1_048_576 { return String(format: bytes < 10_240 ? "%.1f KB" : "%.0f KB", bytes / 1_024) }
1238
+ return String(format: bytes < 10_485_760 ? "%.1f MB" : "%.0f MB", bytes / 1_048_576)
1239
+ }
1240
+
1132
1241
  private func configureTextLeaf(_ leaf: TextLeafView, for node: [String: Any]) {
1133
1242
  let wasFirstResponder = leaf.isFirstResponder
1134
1243
  let previousSelection = leaf.selectedRange
@@ -1174,7 +1283,7 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1174
1283
  ]
1175
1284
  }
1176
1285
  if isCodeBlock(node) {
1177
- leaf.backgroundColor = currentTheme?.codeBlock?.backgroundColor ?? UIColor.secondarySystemBackground
1286
+ leaf.backgroundColor = currentTheme?.codeBlock?.backgroundColor ?? resolvedBlockSurfaceColor
1178
1287
  leaf.textContainerInset = UIEdgeInsets(
1179
1288
  top: currentTheme?.codeBlock?.paddingVertical ?? 8,
1180
1289
  left: currentTheme?.codeBlock?.paddingHorizontal ?? 12,
@@ -1301,7 +1410,7 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1301
1410
  let margin = currentTheme?.horizontalRule?.verticalMargin ?? 16
1302
1411
  let wrapper = UIView()
1303
1412
  let line = UIView()
1304
- line.backgroundColor = currentTheme?.horizontalRule?.color ?? UIColor.separator
1413
+ line.backgroundColor = currentTheme?.horizontalRule?.color ?? resolvedStructuralLineColor
1305
1414
  wrapper.addSubview(line)
1306
1415
  let thickness = currentTheme?.horizontalRule?.thickness ?? 1
1307
1416
  wrapper.heightAnchor.constraint(equalToConstant: margin * 2 + thickness).isActive = true
@@ -1320,8 +1429,8 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1320
1429
  let label = UILabel()
1321
1430
  label.text = node["label"] as? String ?? node["nodeType"] as? String ?? "Block"
1322
1431
  label.font = .systemFont(ofSize: 14, weight: .medium)
1323
- label.textColor = .secondaryLabel
1324
- label.backgroundColor = .secondarySystemBackground
1432
+ label.textColor = currentTheme?.placeholderColor ?? .secondaryLabel
1433
+ label.backgroundColor = resolvedBlockSurfaceColor
1325
1434
  label.layer.cornerRadius = 6
1326
1435
  label.clipsToBounds = true
1327
1436
  label.numberOfLines = 0
@@ -1338,7 +1447,7 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1338
1447
  imageView.contentMode = .scaleAspectFit
1339
1448
  imageView.clipsToBounds = true
1340
1449
  imageView.layer.cornerRadius = 8
1341
- imageView.backgroundColor = .secondarySystemFill
1450
+ imageView.backgroundColor = resolvedBlockSurfaceColor
1342
1451
  imageView.isUserInteractionEnabled = true
1343
1452
  imageView.addGestureRecognizer(
1344
1453
  UITapGestureRecognizer(target: self, action: #selector(handleImageTap(_:)))
@@ -1357,19 +1466,15 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1357
1466
  equalToConstant: imageView.intrinsicContentSize.width
1358
1467
  )
1359
1468
  preferredWidth.priority = .defaultHigh
1360
- let preferredHeight = imageView.heightAnchor.constraint(
1361
- equalToConstant: imageView.intrinsicContentSize.height
1362
- )
1363
1469
  imageView.preferredWidthConstraint = preferredWidth
1364
- imageView.preferredHeightConstraint = preferredHeight
1365
1470
  NSLayoutConstraint.activate([
1366
1471
  imageView.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
1367
1472
  imageView.topAnchor.constraint(equalTo: wrapper.topAnchor),
1368
1473
  imageView.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
1369
1474
  imageView.widthAnchor.constraint(lessThanOrEqualTo: wrapper.widthAnchor),
1370
- preferredWidth,
1371
- preferredHeight
1475
+ preferredWidth
1372
1476
  ])
1477
+ imageView.refreshPreferredSizeConstraints()
1373
1478
  return wrapper
1374
1479
  }
1375
1480
 
@@ -1687,13 +1792,14 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1687
1792
  let button = UIButton(type: .custom)
1688
1793
  button.setTitle(checked ? "✓" : "", for: .normal)
1689
1794
  button.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
1690
- button.setTitleColor(.white, for: .normal)
1691
- button.backgroundColor = checked ? (currentTheme?.list?.markerColor ?? tintColor) : .clear
1692
- button.layer.borderColor = (currentTheme?.list?.markerColor ?? UIColor.secondaryLabel).cgColor
1693
- button.layer.borderWidth = 1.5
1795
+ button.setTitleColor(currentTheme?.accentStrongTextColor ?? .white, for: .normal)
1796
+ button.backgroundColor = checked
1797
+ ? (currentTheme?.accentStrongColor ?? baseTextColor)
1798
+ : resolvedBlockSurfaceColor
1799
+ button.layer.borderWidth = 0
1694
1800
  button.layer.cornerRadius = 4
1695
- button.widthAnchor.constraint(equalToConstant: 18).isActive = true
1696
- button.heightAnchor.constraint(equalToConstant: 18).isActive = true
1801
+ button.widthAnchor.constraint(equalToConstant: 20).isActive = true
1802
+ button.heightAnchor.constraint(equalToConstant: 20).isActive = true
1697
1803
  button.addAction(UIAction { [weak self] _ in
1698
1804
  self?.toggleTaskItem(node)
1699
1805
  }, for: .touchUpInside)
@@ -1831,8 +1937,12 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1831
1937
  } else {
1832
1938
  selected = cell === anchor || cell === head
1833
1939
  }
1834
- cell.backgroundColor = selected ? UIColor.systemBlue.withAlphaComponent(0.12) : cell.baseFillColor
1835
- cell.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.separator.cgColor
1940
+ cell.backgroundColor = selected
1941
+ ? (currentTheme?.table?.selectionBackgroundColor ?? cell.baseFillColor)
1942
+ : cell.baseFillColor
1943
+ cell.layer.borderColor = selected
1944
+ ? (currentTheme?.table?.selectionBorderColor ?? baseTextColor).cgColor
1945
+ : (currentTheme?.table?.borderColor ?? resolvedStructuralLineColor).cgColor
1836
1946
  cell.layer.borderWidth = selected ? 1.5 : 0.5
1837
1947
  }
1838
1948
  }
@@ -2119,6 +2119,18 @@ class NativeEditorExpoView: ExpoView, UIGestureRecognizerDelegate {
2119
2119
  let json = String(data: data, encoding: .utf8) else { return }
2120
2120
  self?.onAddonEvent(["eventJson": json])
2121
2121
  }
2122
+ richTextView.onEmojiEditRequest = { [weak self] nodeType, docPos, emoji, attrs in
2123
+ let event: [String: Any] = [
2124
+ "type": "emojiEditRequest",
2125
+ "nodeType": nodeType,
2126
+ "docPos": docPos,
2127
+ "emoji": emoji,
2128
+ "attrs": attrs,
2129
+ ]
2130
+ guard let data = try? JSONSerialization.data(withJSONObject: event),
2131
+ let json = String(data: data, encoding: .utf8) else { return }
2132
+ self?.onAddonEvent(["eventJson": json])
2133
+ }
2122
2134
  configureAccessoryToolbar()
2123
2135
 
2124
2136
  addSubview(richTextView)
@@ -200,6 +200,12 @@ public class NativeEditorModule: Module {
200
200
  guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
201
201
  return editorSetMark(id: editorId, markName: markName, attrsJson: attrsJson)
202
202
  }
203
+ Function("editorUpdateNodeAttrs") { (id: Int, pos: Int, attrsJson: String) -> String in
204
+ guard let editorId = nativeUInt64(id), let position = nativeUInt32(pos) else {
205
+ return nativeArgumentError("position")
206
+ }
207
+ return editorUpdateNodeAttrs(id: editorId, pos: position, attrsJson: attrsJson)
208
+ }
203
209
  Function("editorUnsetMark") { (id: Int, markName: String) -> String in
204
210
  guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
205
211
  return editorUnsetMark(id: editorId, markName: markName)
@@ -648,6 +648,7 @@ final class RichTextEditorView: UIView {
648
648
  var onFocusChange: ((Bool) -> Void)?
649
649
  var onPageOpen: ((String, String, String?, String?) -> Void)?
650
650
  var onAttachmentOpen: ((String?, String, String?, NSNumber?, String?) -> Void)?
651
+ var onEmojiEditRequest: ((String, UInt32, String, [String: Any]) -> Void)?
651
652
  private var lastAutoGrowWidth: CGFloat = 0
652
653
  private var cachedAutoGrowMeasuredHeight: CGFloat = 0
653
654
  private var remoteSelections: [RemoteSelectionDecoration] = []
@@ -768,6 +769,9 @@ final class RichTextEditorView: UIView {
768
769
  blockSurface.onAttachmentOpen = { [weak self] attachmentId, name, mimeType, size, url in
769
770
  self?.onAttachmentOpen?(attachmentId, name, mimeType, size, url)
770
771
  }
772
+ blockSurface.onEmojiEditRequest = { [weak self] nodeType, docPos, emoji, attrs in
773
+ self?.onEmojiEditRequest?(nodeType, docPos, emoji, attrs)
774
+ }
771
775
  blockSurface.onContentHeightMayChange = { [weak self] measuredHeight in
772
776
  guard let self, self.heightBehavior == .autoGrow else { return }
773
777
  self.cachedAutoGrowMeasuredHeight = measuredHeight
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openeditor/react-native-prose-editor",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "description": "OpenEditor-owned native rich text editor engine for React Native",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/EasyLink-HQ/openeditor",