@openeditor/react-native-prose-editor 0.0.10 → 0.0.12

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.
@@ -42,10 +42,12 @@ import uniffi.editor_core.editorSetNodeSelection
42
42
  import uniffi.editor_core.editorSetSelection
43
43
  import uniffi.editor_core.editorSplitBlock
44
44
  import uniffi.editor_core.editorToggleTaskItemCheckedAtSelectionScalar
45
+ import uniffi.editor_core.editorUpdateNodeAttrs
45
46
 
46
47
  internal data class SelectedImageGeometry(val docPos: Int, val rect: RectF)
47
48
 
48
49
  class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
50
+ var onPageOpen: ((String, String, String?, String?) -> Unit)? = null
49
51
  var isScrollingEnabled: Boolean = true
50
52
 
51
53
  internal var geometryRevision: Long = 0
@@ -616,6 +618,9 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
616
618
  internal fun tableFillColorsForTesting(): List<Int> = tableCells.map { it.baseFillColor }
617
619
 
618
620
  private fun viewForNode(node: JSONObject, depth: Int, path: List<Int>): View {
621
+ if (node.optString("nodeType") == "page") {
622
+ return pageView(node, depth).also { nodeViews[path] = it }
623
+ }
619
624
  val rendered = when (node.optString("kind")) {
620
625
  "list" -> listView(node, depth, path)
621
626
  "listItem" -> listItemView(node, 0, null, depth, path)
@@ -696,10 +701,6 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
696
701
  orientation = LinearLayout.HORIZONTAL
697
702
  isBaselineAligned = false
698
703
  }
699
- row.addView(
700
- markerView(node, parentListType, index),
701
- LinearLayout.LayoutParams((24 * density).toInt(), (24 * density).toInt())
702
- )
703
704
  val content = LinearLayout(context).apply {
704
705
  orientation = LinearLayout.VERTICAL
705
706
  setPadding((8 * density).toInt(), 0, 0, 0)
@@ -711,12 +712,26 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
711
712
  if (content.childCount == 0) {
712
713
  content.addView(textBlockView(node, depth + 1))
713
714
  }
715
+ val marker = if (parentListType == "toggleList" || parentListType == "toggle_list") {
716
+ val open = node.optJSONObject("attrs")?.optBoolean("open", true) ?: true
717
+ setToggleContent(content, open)
718
+ toggleMarkerView(node, content, open)
719
+ } else {
720
+ markerView(node, parentListType, index)
721
+ }
722
+ row.addView(
723
+ marker,
724
+ LinearLayout.LayoutParams((24 * density).toInt(), (24 * density).toInt())
725
+ )
714
726
  row.addView(content, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
715
727
  return row
716
728
  }
717
729
 
718
730
  private fun blockContainerView(node: JSONObject, depth: Int, path: List<Int>): View {
719
731
  val density = resources.displayMetrics.density
732
+ if (node.optString("nodeType") == "callout") {
733
+ return calloutView(node, depth, path)
734
+ }
720
735
  if (node.optString("nodeType") == "blockquote") {
721
736
  val row = LinearLayout(context).apply {
722
737
  orientation = LinearLayout.HORIZONTAL
@@ -755,6 +770,86 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
755
770
  return container
756
771
  }
757
772
 
773
+ private fun calloutView(node: JSONObject, depth: Int, path: List<Int>): View {
774
+ val density = resources.displayMetrics.density
775
+ val tone = node.optJSONObject("attrs")?.optString("tone", "info") ?: "info"
776
+ val accent = when (tone) {
777
+ "success" -> 0xFF16A34A.toInt()
778
+ "warning" -> 0xFFD97706.toInt()
779
+ "danger" -> 0xFFDC2626.toInt()
780
+ else -> 0xFF2563EB.toInt()
781
+ }
782
+ val container = LinearLayout(context).apply {
783
+ orientation = LinearLayout.VERTICAL
784
+ val padding = (12 * density).toInt()
785
+ setPadding(padding, padding, padding, padding)
786
+ background = roundedDrawable(
787
+ Color.argb(24, Color.red(accent), Color.green(accent), Color.blue(accent)),
788
+ Color.argb(140, Color.red(accent), Color.green(accent), Color.blue(accent)),
789
+ 1f,
790
+ 10f
791
+ )
792
+ }
793
+ container.addView(TextView(context).apply {
794
+ text = tone.replaceFirstChar { it.uppercase() }
795
+ setTextColor(accent)
796
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
797
+ setTypeface(typeface, Typeface.BOLD)
798
+ isClickable = this@NativeBlockEditorSurface.isEditable
799
+ setOnClickListener { cycleCalloutTone(node) }
800
+ })
801
+ val children = node.optJSONArray("children")
802
+ for (index in 0 until (children?.length() ?: 0)) {
803
+ children?.optJSONObject(index)?.let { container.addView(viewForNode(it, depth + 1, path + index)) }
804
+ }
805
+ return container
806
+ }
807
+
808
+ private fun toggleMarkerView(node: JSONObject, content: LinearLayout, initiallyOpen: Boolean): View =
809
+ TextView(context).apply {
810
+ var open = initiallyOpen
811
+ text = if (open) "⌄" else "›"
812
+ contentDescription = if (open) "Collapse toggle item" else "Expand toggle item"
813
+ gravity = android.view.Gravity.CENTER
814
+ setTextColor(currentTheme?.list?.markerColor ?: baseTextColor)
815
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
816
+ setTypeface(typeface, Typeface.BOLD)
817
+ setOnClickListener {
818
+ open = !open
819
+ text = if (open) "⌄" else "›"
820
+ contentDescription = if (open) "Collapse toggle item" else "Expand toggle item"
821
+ setToggleContent(content, open)
822
+ if (isEditable) updateNodeAttrs(node, JSONObject().put("open", open))
823
+ }
824
+ }
825
+
826
+ private fun setToggleContent(content: LinearLayout, open: Boolean) {
827
+ for (index in 1 until content.childCount) {
828
+ content.getChildAt(index).visibility = if (open) View.VISIBLE else View.GONE
829
+ }
830
+ }
831
+
832
+ private fun cycleCalloutTone(node: JSONObject) {
833
+ if (!isEditable) return
834
+ val tones = listOf("info", "success", "warning", "danger")
835
+ val current = node.optJSONObject("attrs")?.optString("tone", "info") ?: "info"
836
+ val next = tones[(tones.indexOf(current).coerceAtLeast(0) + 1) % tones.size]
837
+ updateNodeAttrs(node, JSONObject().put("tone", next))
838
+ }
839
+
840
+ private fun updateNodeAttrs(node: JSONObject, patch: JSONObject) {
841
+ if (currentEditorId == 0L) return
842
+ val attrs = JSONObject(node.optJSONObject("attrs")?.toString() ?: "{}")
843
+ patch.keys().forEach { key -> attrs.put(key, patch.get(key)) }
844
+ val updateJSON = editorUpdateNodeAttrs(
845
+ currentEditorId.toULong(),
846
+ node.optInt("docPos", 0).toUInt(),
847
+ attrs.toString()
848
+ )
849
+ applyUpdateJSON(updateJSON, currentEditorId)
850
+ onAppliedUpdate?.invoke(updateJSON)
851
+ }
852
+
758
853
  private fun tableView(node: JSONObject, path: List<Int>): View {
759
854
  val tableId = nextTableId++
760
855
  val table = LinearLayout(context).apply {
@@ -829,6 +924,42 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
829
924
  return TextLeaf(context).also { configureTextLeaf(it, node) }
830
925
  }
831
926
 
927
+ private fun pageView(node: JSONObject, depth: Int): View {
928
+ val density = resources.displayMetrics.density
929
+ val attrs = node.optJSONObject("attrs")
930
+ val row = LinearLayout(context).apply {
931
+ orientation = LinearLayout.HORIZONTAL
932
+ gravity = android.view.Gravity.CENTER_VERTICAL
933
+ val horizontal = (6 * density).toInt()
934
+ val vertical = (5 * density).toInt()
935
+ setPadding(horizontal + (depth * 8 * density).toInt(), vertical, horizontal, vertical)
936
+ }
937
+ row.addView(TextView(context).apply {
938
+ text = attrs?.optString("icon", "📄") ?: "📄"
939
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
940
+ }, LinearLayout.LayoutParams((28 * density).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT))
941
+ val title = textBlockView(node, depth) as TextLeaf
942
+ title.setTypeface(title.typeface, Typeface.BOLD)
943
+ row.addView(title, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
944
+ row.addView(TextView(context).apply {
945
+ text = "→"
946
+ contentDescription = "Open page"
947
+ gravity = android.view.Gravity.CENTER
948
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
949
+ setTypeface(typeface, Typeface.BOLD)
950
+ setOnClickListener {
951
+ val pageId = attrs?.optString("pageId").orEmpty()
952
+ if (pageId.isNotEmpty()) onPageOpen?.invoke(
953
+ pageId,
954
+ title.text?.toString().orEmpty().ifEmpty { "Untitled" },
955
+ attrs?.optString("icon")?.takeIf(String::isNotEmpty),
956
+ attrs?.optString("href")?.takeIf(String::isNotEmpty),
957
+ )
958
+ }
959
+ }, LinearLayout.LayoutParams((40 * density).toInt(), (40 * density).toInt()))
960
+ return row
961
+ }
962
+
832
963
  private fun configureTextLeaf(leaf: TextLeaf, node: JSONObject) {
833
964
  val rendered = attributedInlineContent(node)
834
965
  val hadFocus = leaf.hasFocus()
@@ -727,6 +727,12 @@ class NativeEditorExpoView(
727
727
  addView(richTextView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
728
728
  richTextView.onSelectionChange = ::onSelectionChanged
729
729
  richTextView.onEditorUpdate = ::onEditorUpdate
730
+ richTextView.onPageOpen = { pageId, title, icon, href ->
731
+ val event = mutableMapOf<String, Any>("type" to "pageOpen", "pageId" to pageId, "title" to title)
732
+ icon?.let { event["icon"] = it }
733
+ href?.let { event["href"] = it }
734
+ emitAddonEvent(mapOf("eventJson" to JSONObject(event).toString()))
735
+ }
730
736
  richTextView.onBeforeDetachedFromWindow = {
731
737
  prepareForDetachFromWindow()
732
738
  }
@@ -38,6 +38,7 @@ class RichTextEditorView @JvmOverloads constructor(
38
38
  var onSelectionChange: ((Int, Int) -> Unit)? = null
39
39
  var onEditorUpdate: ((String) -> Unit)? = null
40
40
  var onFocusChange: ((Boolean) -> Unit)? = null
41
+ var onPageOpen: ((String, String, String?, String?) -> Unit)? = null
41
42
 
42
43
  var editorId: Long
43
44
  get() = currentEditorId
@@ -60,6 +61,7 @@ class RichTextEditorView @JvmOverloads constructor(
60
61
  blockSurface.onSelectionChange = { anchor, head -> onSelectionChange?.invoke(anchor, head) }
61
62
  blockSurface.onEditorUpdate = { update -> onEditorUpdate?.invoke(update) }
62
63
  blockSurface.onFocusChange = { focused -> onFocusChange?.invoke(focused) }
64
+ blockSurface.onPageOpen = { pageId, title, icon, href -> onPageOpen?.invoke(pageId, title, icon, href) }
63
65
  updateAppearance()
64
66
  }
65
67
 
@@ -81,6 +81,13 @@ export interface NativeRichTextEditorProps {
81
81
  onRequestLink?: (context: LinkRequestContext) => void;
82
82
  /** Called when a toolbar image item is pressed so the host can choose an image source. */
83
83
  onRequestImage?: (context: ImageRequestContext) => void;
84
+ /** Called when the disclosure control on a Page block is pressed. */
85
+ onOpenPage?: (page: {
86
+ pageId: string;
87
+ title: string;
88
+ icon?: string | null;
89
+ href?: string | null;
90
+ }) => void;
84
91
  /** Whether plain URLs typed or pasted into the editor should be converted into link marks automatically. */
85
92
  autoDetectLinks?: boolean;
86
93
  /** Whether `data:image/...` sources are accepted for image insertion and HTML parsing. */
@@ -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, 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, 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 === 'pageOpen') {
2058
+ onOpenPage?.({
2059
+ pageId: parsed.pageId,
2060
+ title: parsed.title,
2061
+ icon: parsed.icon,
2062
+ href: parsed.href,
2063
+ });
2064
+ return;
2065
+ }
2057
2066
  let parsedDocumentVersion = typeof parsed.documentVersion === 'number' ? parsed.documentVersion : undefined;
2058
2067
  if (typeof parsedDocumentVersion !== 'number' &&
2059
2068
  parsed.type === 'mentionsSelectRequest' &&
@@ -2169,6 +2178,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
2169
2178
  resolveMentionInsertionAttrs,
2170
2179
  runAndApplyWithCommandRetry,
2171
2180
  setMentionQueryEventState,
2181
+ onOpenPage,
2172
2182
  syncPreflightUpdateFromNativeEvent,
2173
2183
  ]);
2174
2184
  (0, react_1.useImperativeHandle)(ref, () => ({
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: 'pageOpen';
69
+ pageId: string;
70
+ title: string;
71
+ icon?: string | null;
72
+ href?: string | null;
73
+ } | {
68
74
  type: 'mentionsQueryChange';
69
75
  query: string;
70
76
  trigger: string;
@@ -1323,6 +1323,18 @@ public func editorUnwrapFromListAtSelectionScalar(id: UInt64, scalarAnchor: UInt
1323
1323
  )
1324
1324
  })
1325
1325
  }
1326
+ /**
1327
+ * Replace a node's attributes at its document position.
1328
+ */
1329
+ public func editorUpdateNodeAttrs(id: UInt64, pos: UInt32, attrsJson: String) -> String {
1330
+ return try! FfiConverterString.lift(try! rustCall() {
1331
+ uniffi_editor_core_fn_func_editor_update_node_attrs(
1332
+ FfiConverterUInt64.lower(id),
1333
+ FfiConverterUInt32.lower(pos),
1334
+ FfiConverterString.lower(attrsJson),$0
1335
+ )
1336
+ })
1337
+ }
1326
1338
  /**
1327
1339
  * Wrap the current selection in a list. Returns an update JSON string.
1328
1340
  */
@@ -1582,6 +1594,9 @@ private let initializationResult: InitializationResult = {
1582
1594
  if (uniffi_editor_core_checksum_func_editor_unwrap_from_list_at_selection_scalar() != 57899) {
1583
1595
  return InitializationResult.apiChecksumMismatch
1584
1596
  }
1597
+ if (uniffi_editor_core_checksum_func_editor_update_node_attrs() != 14684) {
1598
+ return InitializationResult.apiChecksumMismatch
1599
+ }
1585
1600
  if (uniffi_editor_core_checksum_func_editor_wrap_in_list() != 32846) {
1586
1601
  return InitializationResult.apiChecksumMismatch
1587
1602
  }
@@ -1,6 +1,7 @@
1
1
  import UIKit
2
2
 
3
3
  final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestureRecognizerDelegate {
4
+ var onPageOpen: ((String, String, String?, String?) -> Void)?
4
5
  private final class TextLeafView: UITextView {
5
6
  struct PositionSegment {
6
7
  let range: NSRange
@@ -696,6 +697,11 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
696
697
  }
697
698
 
698
699
  private func view(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
700
+ if node["nodeType"] as? String == "page" {
701
+ let rendered = pageView(for: node, depth: depth)
702
+ nodeViews[path] = rendered
703
+ return rendered
704
+ }
699
705
  let rendered: UIView = switch node["kind"] as? String {
700
706
  case "list":
701
707
  listView(for: node, depth: depth, path: path)
@@ -785,8 +791,6 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
785
791
  row.alignment = .top
786
792
  row.spacing = 8
787
793
 
788
- row.addArrangedSubview(markerView(for: node, parentListType: parentListType, index: index))
789
-
790
794
  let content = UIStackView()
791
795
  content.axis = .vertical
792
796
  content.spacing = 4
@@ -796,11 +800,20 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
796
800
  if content.arrangedSubviews.isEmpty {
797
801
  content.addArrangedSubview(textBlockView(for: node, depth: depth + 1))
798
802
  }
803
+ if parentListType == "toggleList" || parentListType == "toggle_list" {
804
+ row.addArrangedSubview(toggleMarkerView(for: node, content: content))
805
+ setToggleContent(content, open: boolAttr(node, "open") ?? true)
806
+ } else {
807
+ row.addArrangedSubview(markerView(for: node, parentListType: parentListType, index: index))
808
+ }
799
809
  row.addArrangedSubview(content)
800
810
  return row
801
811
  }
802
812
 
803
813
  private func blockContainerView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
814
+ if (node["nodeType"] as? String) == "callout" {
815
+ return calloutView(for: node, depth: depth, path: path)
816
+ }
804
817
  let container = UIStackView()
805
818
  container.axis = .vertical
806
819
  container.spacing = 4
@@ -834,6 +847,96 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
834
847
  return container
835
848
  }
836
849
 
850
+ private func calloutView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
851
+ let tone = stringAttr(node, "tone") ?? "info"
852
+ let accent: UIColor = switch tone {
853
+ case "success": .systemGreen
854
+ case "warning": .systemOrange
855
+ case "danger": .systemRed
856
+ default: .systemBlue
857
+ }
858
+ let container = UIStackView()
859
+ container.axis = .vertical
860
+ container.spacing = 8
861
+ container.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
862
+ container.isLayoutMarginsRelativeArrangement = true
863
+ container.backgroundColor = accent.withAlphaComponent(0.1)
864
+ container.layer.borderColor = accent.withAlphaComponent(0.55).cgColor
865
+ container.layer.borderWidth = 1
866
+ container.layer.cornerRadius = 10
867
+
868
+ let toneButton = UIButton(type: .system)
869
+ toneButton.setTitle(tone.capitalized, for: .normal)
870
+ toneButton.setTitleColor(accent, for: .normal)
871
+ toneButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold)
872
+ toneButton.contentHorizontalAlignment = .leading
873
+ toneButton.isEnabled = isEditable
874
+ toneButton.addAction(UIAction { [weak self] _ in
875
+ self?.cycleCalloutTone(node)
876
+ }, for: .touchUpInside)
877
+ container.addArrangedSubview(toneButton)
878
+
879
+ for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
880
+ container.addArrangedSubview(view(for: child, depth: depth + 1, path: path + [index]))
881
+ }
882
+ return container
883
+ }
884
+
885
+ private func toggleMarkerView(for node: [String: Any], content: UIStackView) -> UIView {
886
+ let open = boolAttr(node, "open") ?? true
887
+ let button = UIButton(type: .system)
888
+ button.setTitle(open ? "⌄" : "›", for: .normal)
889
+ button.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
890
+ button.accessibilityLabel = open ? "Collapse toggle item" : "Expand toggle item"
891
+ button.addAction(UIAction { [weak self, weak button, weak content] _ in
892
+ guard let self, let button, let content else { return }
893
+ let nextOpen = button.title(for: .normal) != "⌄"
894
+ button.setTitle(nextOpen ? "⌄" : "›", for: .normal)
895
+ button.accessibilityLabel = nextOpen ? "Collapse toggle item" : "Expand toggle item"
896
+ self.setToggleContent(content, open: nextOpen)
897
+ if self.isEditable {
898
+ self.updateNodeAttrs(node, patch: ["open": nextOpen])
899
+ }
900
+ }, for: .touchUpInside)
901
+ let wrapper = UIView()
902
+ wrapper.widthAnchor.constraint(equalToConstant: 24).isActive = true
903
+ wrapper.heightAnchor.constraint(equalToConstant: 24).isActive = true
904
+ button.translatesAutoresizingMaskIntoConstraints = false
905
+ wrapper.addSubview(button)
906
+ NSLayoutConstraint.activate([
907
+ button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
908
+ button.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor),
909
+ ])
910
+ return wrapper
911
+ }
912
+
913
+ private func setToggleContent(_ content: UIStackView, open: Bool) {
914
+ for (index, child) in content.arrangedSubviews.enumerated() where index > 0 {
915
+ child.isHidden = !open
916
+ }
917
+ }
918
+
919
+ private func cycleCalloutTone(_ node: [String: Any]) {
920
+ guard isEditable else { return }
921
+ let tones = ["info", "success", "warning", "danger"]
922
+ let current = stringAttr(node, "tone") ?? "info"
923
+ let next = tones[((tones.firstIndex(of: current) ?? 0) + 1) % tones.count]
924
+ updateNodeAttrs(node, patch: ["tone": next])
925
+ }
926
+
927
+ private func updateNodeAttrs(_ node: [String: Any], patch: [String: Any]) {
928
+ guard currentEditorId != 0 else { return }
929
+ var attrs = node["attrs"] as? [String: Any] ?? [:]
930
+ attrs.merge(patch) { _, next in next }
931
+ guard let data = try? JSONSerialization.data(withJSONObject: attrs),
932
+ let json = String(data: data, encoding: .utf8)
933
+ else { return }
934
+ let pos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
935
+ let updateJSON = editorUpdateNodeAttrs(id: currentEditorId, pos: pos, attrsJson: json)
936
+ applyUpdateJSON(updateJSON, editorId: currentEditorId)
937
+ onAppliedUpdate?(updateJSON)
938
+ }
939
+
837
940
  func blockquoteStripeRectsForTesting() -> [CGRect] {
838
941
  layoutIfNeeded()
839
942
  return descendants(of: self)
@@ -949,6 +1052,42 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
949
1052
  return leaf
950
1053
  }
951
1054
 
1055
+ private func pageView(for node: [String: Any], depth: Int) -> UIView {
1056
+ let row = UIStackView()
1057
+ row.axis = .horizontal
1058
+ row.alignment = .center
1059
+ row.spacing = 8
1060
+ row.layoutMargins = UIEdgeInsets(top: 5, left: CGFloat(depth) * 8 + 6, bottom: 5, right: 6)
1061
+ row.isLayoutMarginsRelativeArrangement = true
1062
+ row.layer.cornerRadius = 8
1063
+
1064
+ let icon = UILabel()
1065
+ icon.text = stringAttr(node, "icon") ?? "📄"
1066
+ icon.font = .systemFont(ofSize: 18)
1067
+ icon.setContentHuggingPriority(.required, for: .horizontal)
1068
+ row.addArrangedSubview(icon)
1069
+
1070
+ let title = textBlockView(for: node, depth: depth)
1071
+ if let leaf = title as? UITextView {
1072
+ leaf.font = .systemFont(ofSize: 16, weight: .semibold)
1073
+ leaf.textColor = baseTextColor
1074
+ }
1075
+ row.addArrangedSubview(title)
1076
+
1077
+ let open = UIButton(type: .system)
1078
+ open.setTitle("→", for: .normal)
1079
+ open.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
1080
+ open.accessibilityLabel = "Open page"
1081
+ open.setContentHuggingPriority(.required, for: .horizontal)
1082
+ open.addAction(UIAction { [weak self, weak title] _ in
1083
+ guard let pageId = self?.stringAttr(node, "pageId"), !pageId.isEmpty else { return }
1084
+ let pageTitle = (title as? UITextView)?.text ?? "Untitled"
1085
+ self?.onPageOpen?(pageId, pageTitle, self?.stringAttr(node, "icon"), self?.stringAttr(node, "href"))
1086
+ }, for: .touchUpInside)
1087
+ row.addArrangedSubview(open)
1088
+ return row
1089
+ }
1090
+
952
1091
  private func configureTextLeaf(_ leaf: TextLeafView, for node: [String: Any]) {
953
1092
  let wasFirstResponder = leaf.isFirstResponder
954
1093
  let previousSelection = leaf.selectedRange
@@ -1549,6 +1688,10 @@ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestur
1549
1688
  return attrs[key] as? Bool
1550
1689
  }
1551
1690
 
1691
+ private func stringAttr(_ node: [String: Any], _ key: String) -> String? {
1692
+ (node["attrs"] as? [String: Any])?[key] as? String
1693
+ }
1694
+
1552
1695
  private func isCodeBlock(_ node: [String: Any]) -> Bool {
1553
1696
  let nodeType = node["nodeType"] as? String
1554
1697
  return nodeType == "codeBlock" || nodeType == "code_block"
@@ -2101,6 +2101,14 @@ class NativeEditorExpoView: ExpoView, UIGestureRecognizerDelegate {
2101
2101
  if focused { self?.handleTextDidBeginEditing() }
2102
2102
  else { self?.handleTextDidEndEditing() }
2103
2103
  }
2104
+ richTextView.onPageOpen = { [weak self] pageId, title, icon, href in
2105
+ var event: [String: Any] = ["type": "pageOpen", "pageId": pageId, "title": title]
2106
+ if let icon { event["icon"] = icon }
2107
+ if let href { event["href"] = href }
2108
+ guard let data = try? JSONSerialization.data(withJSONObject: event),
2109
+ let json = String(data: data, encoding: .utf8) else { return }
2110
+ self?.onAddonEvent(["eventJson": json])
2111
+ }
2104
2112
  configureAccessoryToolbar()
2105
2113
 
2106
2114
  addSubview(richTextView)
@@ -646,6 +646,7 @@ final class RichTextEditorView: UIView {
646
646
  var onSelectionChange: ((UInt32, UInt32) -> Void)?
647
647
  var onEditorUpdate: ((String) -> Void)?
648
648
  var onFocusChange: ((Bool) -> Void)?
649
+ var onPageOpen: ((String, String, String?, String?) -> Void)?
649
650
  private var lastAutoGrowWidth: CGFloat = 0
650
651
  private var cachedAutoGrowMeasuredHeight: CGFloat = 0
651
652
  private var remoteSelections: [RemoteSelectionDecoration] = []
@@ -760,6 +761,9 @@ final class RichTextEditorView: UIView {
760
761
  blockSurface.onFocusChange = { [weak self] focused in
761
762
  self?.onFocusChange?(focused)
762
763
  }
764
+ blockSurface.onPageOpen = { [weak self] pageId, title, icon, href in
765
+ self?.onPageOpen?(pageId, title, icon, href)
766
+ }
763
767
  blockSurface.onContentHeightMayChange = { [weak self] measuredHeight in
764
768
  guard let self, self.heightBehavior == .autoGrow else { return }
765
769
  self.cachedAutoGrowMeasuredHeight = measuredHeight
@@ -617,6 +617,11 @@ RustBuffer uniffi_editor_core_fn_func_editor_unwrap_from_list(uint64_t id, RustC
617
617
  RustBuffer uniffi_editor_core_fn_func_editor_unwrap_from_list_at_selection_scalar(uint64_t id, uint32_t scalar_anchor, uint32_t scalar_head, RustCallStatus *_Nonnull out_status
618
618
  );
619
619
  #endif
620
+ #ifndef UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_FN_FUNC_EDITOR_UPDATE_NODE_ATTRS
621
+ #define UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_FN_FUNC_EDITOR_UPDATE_NODE_ATTRS
622
+ RustBuffer uniffi_editor_core_fn_func_editor_update_node_attrs(uint64_t id, uint32_t pos, RustBuffer attrs_json, RustCallStatus *_Nonnull out_status
623
+ );
624
+ #endif
620
625
  #ifndef UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_FN_FUNC_EDITOR_WRAP_IN_LIST
621
626
  #define UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_FN_FUNC_EDITOR_WRAP_IN_LIST
622
627
  RustBuffer uniffi_editor_core_fn_func_editor_wrap_in_list(uint64_t id, RustBuffer list_type, RustCallStatus *_Nonnull out_status
@@ -1343,6 +1348,12 @@ uint16_t uniffi_editor_core_checksum_func_editor_unwrap_from_list(void
1343
1348
  #define UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_CHECKSUM_FUNC_EDITOR_UNWRAP_FROM_LIST_AT_SELECTION_SCALAR
1344
1349
  uint16_t uniffi_editor_core_checksum_func_editor_unwrap_from_list_at_selection_scalar(void
1345
1350
 
1351
+ );
1352
+ #endif
1353
+ #ifndef UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_CHECKSUM_FUNC_EDITOR_UPDATE_NODE_ATTRS
1354
+ #define UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_CHECKSUM_FUNC_EDITOR_UPDATE_NODE_ATTRS
1355
+ uint16_t uniffi_editor_core_checksum_func_editor_update_node_attrs(void
1356
+
1346
1357
  );
1347
1358
  #endif
1348
1359
  #ifndef UNIFFI_FFIDEF_UNIFFI_EDITOR_CORE_CHECKSUM_FUNC_EDITOR_WRAP_IN_LIST
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openeditor/react-native-prose-editor",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
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",
@@ -911,6 +911,8 @@ internal interface IntegrityCheckingUniffiLib : Library {
911
911
 
912
912
  fun uniffi_editor_core_checksum_func_editor_unwrap_from_list_at_selection_scalar(): Short
913
913
 
914
+ fun uniffi_editor_core_checksum_func_editor_update_node_attrs(): Short
915
+
914
916
  fun uniffi_editor_core_checksum_func_editor_wrap_in_list(): Short
915
917
 
916
918
  fun uniffi_editor_core_checksum_func_editor_wrap_in_list_at_selection_scalar(): Short
@@ -1398,6 +1400,13 @@ internal interface UniffiLib : Library {
1398
1400
  uniffi_out_err: UniffiRustCallStatus,
1399
1401
  ): RustBuffer.ByValue
1400
1402
 
1403
+ fun uniffi_editor_core_fn_func_editor_update_node_attrs(
1404
+ `id`: Long,
1405
+ `pos`: Int,
1406
+ `attrsJson`: RustBuffer.ByValue,
1407
+ uniffi_out_err: UniffiRustCallStatus,
1408
+ ): RustBuffer.ByValue
1409
+
1401
1410
  fun uniffi_editor_core_fn_func_editor_wrap_in_list(
1402
1411
  `id`: Long,
1403
1412
  `listType`: RustBuffer.ByValue,
@@ -1860,6 +1869,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
1860
1869
  if (lib.uniffi_editor_core_checksum_func_editor_unwrap_from_list_at_selection_scalar() != 57899.toShort()) {
1861
1870
  throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
1862
1871
  }
1872
+ if (lib.uniffi_editor_core_checksum_func_editor_update_node_attrs() != 14684.toShort()) {
1873
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
1874
+ }
1863
1875
  if (lib.uniffi_editor_core_checksum_func_editor_wrap_in_list() != 32846.toShort()) {
1864
1876
  throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
1865
1877
  }
@@ -3212,6 +3224,25 @@ fun `editorUnwrapFromListAtSelectionScalar`(
3212
3224
  },
3213
3225
  )
3214
3226
 
3227
+ /**
3228
+ * Replace a node's attributes at its document position.
3229
+ */
3230
+ fun `editorUpdateNodeAttrs`(
3231
+ `id`: kotlin.ULong,
3232
+ `pos`: kotlin.UInt,
3233
+ `attrsJson`: kotlin.String,
3234
+ ): kotlin.String =
3235
+ FfiConverterString.lift(
3236
+ uniffiRustCall { _status ->
3237
+ UniffiLib.INSTANCE.uniffi_editor_core_fn_func_editor_update_node_attrs(
3238
+ FfiConverterULong.lower(`id`),
3239
+ FfiConverterUInt.lower(`pos`),
3240
+ FfiConverterString.lower(`attrsJson`),
3241
+ _status,
3242
+ )
3243
+ },
3244
+ )
3245
+
3215
3246
  /**
3216
3247
  * Wrap the current selection in a list. Returns an update JSON string.
3217
3248
  */