@apollohg/react-native-prose-editor 0.4.3 → 0.5.0
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.
- package/README.md +1 -1
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +48 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +20 -0
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +157 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +14 -0
- package/dist/NativeProseViewer.d.ts +21 -0
- package/dist/NativeProseViewer.js +174 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/NativeEditorModule.swift +19 -0
- package/ios/NativeProseViewerExpoView.swift +146 -0
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
package/README.md
CHANGED
|
@@ -72,6 +72,11 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
72
72
|
val rect: RectF
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
+
data class MentionHit(
|
|
76
|
+
val docPos: Int,
|
|
77
|
+
val label: String
|
|
78
|
+
)
|
|
79
|
+
|
|
75
80
|
private data class ParsedRenderPatch(
|
|
76
81
|
val startIndex: Int,
|
|
77
82
|
val deleteCount: Int,
|
|
@@ -1578,6 +1583,49 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1578
1583
|
}
|
|
1579
1584
|
}
|
|
1580
1585
|
|
|
1586
|
+
fun mentionHitAt(x: Float, y: Float): MentionHit? {
|
|
1587
|
+
val spannable = text as? Spanned ?: return null
|
|
1588
|
+
val layout = layout ?: return null
|
|
1589
|
+
if (spannable.isEmpty()) return null
|
|
1590
|
+
|
|
1591
|
+
val localX = x - totalPaddingLeft + scrollX
|
|
1592
|
+
val localY = y - totalPaddingTop + scrollY
|
|
1593
|
+
if (localY < 0f || localY > layout.height.toFloat()) {
|
|
1594
|
+
return null
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
val line = layout.getLineForVertical(localY.toInt())
|
|
1598
|
+
val lineLeft = layout.getLineLeft(line)
|
|
1599
|
+
val lineRight = layout.getLineRight(line)
|
|
1600
|
+
if (localX < lineLeft || localX > lineRight) {
|
|
1601
|
+
return null
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
val offset = layout.getOffsetForHorizontal(line, localX)
|
|
1605
|
+
.coerceIn(0, maxOf(spannable.length - 1, 0))
|
|
1606
|
+
val annotations = spannable.getSpans(
|
|
1607
|
+
offset,
|
|
1608
|
+
(offset + 1).coerceAtMost(spannable.length),
|
|
1609
|
+
Annotation::class.java
|
|
1610
|
+
)
|
|
1611
|
+
val mentionAnnotation = annotations.firstOrNull {
|
|
1612
|
+
it.key == "nativeVoidNodeType" && it.value == "mention"
|
|
1613
|
+
} ?: return null
|
|
1614
|
+
val docPos = annotations.firstOrNull { it.key == "nativeDocPos" }
|
|
1615
|
+
?.value
|
|
1616
|
+
?.toIntOrNull() ?: return null
|
|
1617
|
+
val start = spannable.getSpanStart(mentionAnnotation)
|
|
1618
|
+
val end = spannable.getSpanEnd(mentionAnnotation)
|
|
1619
|
+
if (start < 0 || end <= start) {
|
|
1620
|
+
return null
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
return MentionHit(
|
|
1624
|
+
docPos = docPos,
|
|
1625
|
+
label = spannable.subSequence(start, end).toString()
|
|
1626
|
+
)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1581
1629
|
private fun handleImageTap(event: MotionEvent): Boolean {
|
|
1582
1630
|
if (!imageResizingEnabled) {
|
|
1583
1631
|
return false
|
|
@@ -287,6 +287,14 @@ class NativeEditorModule : Module() {
|
|
|
287
287
|
Function("editorCanRedo") { id: Int ->
|
|
288
288
|
editorCanRedo(id.toULong())
|
|
289
289
|
}
|
|
290
|
+
Function("renderDocumentJson") { configJson: String, json: String ->
|
|
291
|
+
val editorId = editorCreate(configJson)
|
|
292
|
+
try {
|
|
293
|
+
editorSetJson(editorId, json)
|
|
294
|
+
} finally {
|
|
295
|
+
editorDestroy(editorId)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
290
298
|
|
|
291
299
|
View(NativeEditorExpoView::class) {
|
|
292
300
|
Events(
|
|
@@ -359,5 +367,17 @@ class NativeEditorModule : Module() {
|
|
|
359
367
|
}
|
|
360
368
|
|
|
361
369
|
}
|
|
370
|
+
|
|
371
|
+
View(NativeProseViewerExpoView::class) {
|
|
372
|
+
Name("NativeProseViewer")
|
|
373
|
+
Events("onContentHeightChange", "onPressMention")
|
|
374
|
+
|
|
375
|
+
Prop("renderJson") { view: NativeProseViewerExpoView, renderJson: String? ->
|
|
376
|
+
view.setRenderJson(renderJson)
|
|
377
|
+
}
|
|
378
|
+
Prop("themeJson") { view: NativeProseViewerExpoView, themeJson: String? ->
|
|
379
|
+
view.setThemeJson(themeJson)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
362
382
|
}
|
|
363
383
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
package com.apollohg.editor
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.view.MotionEvent
|
|
6
|
+
import android.view.View
|
|
7
|
+
import android.view.ViewGroup
|
|
8
|
+
import expo.modules.kotlin.AppContext
|
|
9
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
10
|
+
import expo.modules.kotlin.views.ExpoView
|
|
11
|
+
|
|
12
|
+
class NativeProseViewerExpoView(
|
|
13
|
+
context: Context,
|
|
14
|
+
appContext: AppContext
|
|
15
|
+
) : ExpoView(context, appContext) {
|
|
16
|
+
|
|
17
|
+
private val proseView = EditorEditText(context)
|
|
18
|
+
private val onContentHeightChange by EventDispatcher<Map<String, Any>>()
|
|
19
|
+
@Suppress("unused")
|
|
20
|
+
private val onPressMention by EventDispatcher<Map<String, Any>>()
|
|
21
|
+
|
|
22
|
+
private var lastRenderJson: String? = null
|
|
23
|
+
private var lastThemeJson: String? = null
|
|
24
|
+
private var lastEmittedContentHeight = 0
|
|
25
|
+
|
|
26
|
+
init {
|
|
27
|
+
proseView.setBaseStyle(
|
|
28
|
+
proseView.textSize,
|
|
29
|
+
proseView.currentTextColor,
|
|
30
|
+
Color.TRANSPARENT
|
|
31
|
+
)
|
|
32
|
+
proseView.isEditable = false
|
|
33
|
+
proseView.setImageResizingEnabled(false)
|
|
34
|
+
proseView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
|
|
35
|
+
proseView.isFocusable = false
|
|
36
|
+
proseView.isFocusableInTouchMode = false
|
|
37
|
+
proseView.isCursorVisible = false
|
|
38
|
+
proseView.isLongClickable = false
|
|
39
|
+
proseView.setTextIsSelectable(false)
|
|
40
|
+
proseView.showSoftInputOnFocus = false
|
|
41
|
+
proseView.setOnTouchListener { _, event ->
|
|
42
|
+
if (event.actionMasked != MotionEvent.ACTION_UP) {
|
|
43
|
+
return@setOnTouchListener false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
val mention = proseView.mentionHitAt(event.x, event.y) ?: return@setOnTouchListener false
|
|
47
|
+
onPressMention(
|
|
48
|
+
mapOf(
|
|
49
|
+
"docPos" to mention.docPos,
|
|
50
|
+
"label" to mention.label
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addView(
|
|
57
|
+
proseView,
|
|
58
|
+
LayoutParams(
|
|
59
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
60
|
+
ViewGroup.LayoutParams.WRAP_CONTENT
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun setRenderJson(renderJson: String?) {
|
|
66
|
+
if (lastRenderJson == renderJson) return
|
|
67
|
+
lastRenderJson = renderJson
|
|
68
|
+
proseView.applyRenderJSON(renderJson ?: "[]")
|
|
69
|
+
post {
|
|
70
|
+
requestLayout()
|
|
71
|
+
emitContentHeightIfNeeded(force = true)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun setThemeJson(themeJson: String?) {
|
|
76
|
+
if (lastThemeJson == themeJson) return
|
|
77
|
+
lastThemeJson = themeJson
|
|
78
|
+
proseView.applyTheme(EditorTheme.fromJson(themeJson))
|
|
79
|
+
proseView.applyRenderJSON(lastRenderJson ?: "[]")
|
|
80
|
+
post {
|
|
81
|
+
requestLayout()
|
|
82
|
+
emitContentHeightIfNeeded(force = true)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
87
|
+
val childWidthSpec = getChildMeasureSpec(
|
|
88
|
+
widthMeasureSpec,
|
|
89
|
+
paddingLeft + paddingRight,
|
|
90
|
+
proseView.layoutParams.width
|
|
91
|
+
)
|
|
92
|
+
val childHeightSpec = android.view.View.MeasureSpec.makeMeasureSpec(
|
|
93
|
+
0,
|
|
94
|
+
android.view.View.MeasureSpec.UNSPECIFIED
|
|
95
|
+
)
|
|
96
|
+
proseView.measure(childWidthSpec, childHeightSpec)
|
|
97
|
+
|
|
98
|
+
val desiredWidth = proseView.measuredWidth + paddingLeft + paddingRight
|
|
99
|
+
val desiredHeight = proseView.measuredHeight + paddingTop + paddingBottom
|
|
100
|
+
setMeasuredDimension(
|
|
101
|
+
resolveSize(desiredWidth, widthMeasureSpec),
|
|
102
|
+
resolveSize(desiredHeight, heightMeasureSpec)
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
107
|
+
val childLeft = paddingLeft
|
|
108
|
+
val childTop = paddingTop
|
|
109
|
+
proseView.layout(
|
|
110
|
+
childLeft,
|
|
111
|
+
childTop,
|
|
112
|
+
right - left - paddingRight,
|
|
113
|
+
childTop + proseView.measuredHeight
|
|
114
|
+
)
|
|
115
|
+
emitContentHeightIfNeeded()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun emitContentHeightIfNeeded(force: Boolean = false) {
|
|
119
|
+
val contentHeight = (measureContentHeightPx() + paddingTop + paddingBottom)
|
|
120
|
+
.coerceAtLeast(0)
|
|
121
|
+
if (contentHeight <= 0) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!force && contentHeight == lastEmittedContentHeight) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
lastEmittedContentHeight = contentHeight
|
|
128
|
+
onContentHeightChange(mapOf("contentHeight" to contentHeight))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private fun measureContentHeightPx(): Int {
|
|
132
|
+
val currentMeasuredHeight = proseView.measuredHeight
|
|
133
|
+
if (currentMeasuredHeight > 0 && proseView.layout != null) {
|
|
134
|
+
return currentMeasuredHeight
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
val availableWidthPx = resolveAvailableWidthPx()
|
|
138
|
+
val childWidthSpec = View.MeasureSpec.makeMeasureSpec(availableWidthPx, View.MeasureSpec.EXACTLY)
|
|
139
|
+
val childHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
|
140
|
+
proseView.measure(childWidthSpec, childHeightSpec)
|
|
141
|
+
return proseView.measuredHeight
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun resolveAvailableWidthPx(): Int {
|
|
145
|
+
val localWidth = width - paddingLeft - paddingRight
|
|
146
|
+
if (localWidth > 0) {
|
|
147
|
+
return localWidth
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
val parentWidth = ((parent as? View)?.width ?: 0) - paddingLeft - paddingRight
|
|
151
|
+
if (parentWidth > 0) {
|
|
152
|
+
return parentWidth
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (resources.displayMetrics.widthPixels - paddingLeft - paddingRight).coerceAtLeast(1)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -908,10 +908,12 @@ object RenderBridge {
|
|
|
908
908
|
"opaqueInlineAtom" -> {
|
|
909
909
|
val nodeType = element.optString("nodeType", "")
|
|
910
910
|
val label = element.optString("label", "?")
|
|
911
|
+
val docPos = element.optInt("docPos", 0)
|
|
911
912
|
appendOpaqueInlineAtom(
|
|
912
913
|
state.result,
|
|
913
914
|
nodeType,
|
|
914
915
|
label,
|
|
916
|
+
docPos,
|
|
915
917
|
baseFontSize,
|
|
916
918
|
textColor,
|
|
917
919
|
state.blockStack,
|
|
@@ -924,6 +926,7 @@ object RenderBridge {
|
|
|
924
926
|
"opaqueBlockAtom" -> {
|
|
925
927
|
val nodeType = element.optString("nodeType", "")
|
|
926
928
|
val label = element.optString("label", "?")
|
|
929
|
+
val docPos = element.optInt("docPos", 0)
|
|
927
930
|
val blockSpacing = theme?.effectiveTextStyle(nodeType)?.spacingAfter
|
|
928
931
|
if (!state.isFirstBlock) {
|
|
929
932
|
val spacingPx = ((state.nextBlockSpacingBefore ?: 0f) * density).toInt()
|
|
@@ -941,6 +944,7 @@ object RenderBridge {
|
|
|
941
944
|
state.result,
|
|
942
945
|
nodeType,
|
|
943
946
|
label,
|
|
947
|
+
docPos,
|
|
944
948
|
baseFontSize,
|
|
945
949
|
textColor,
|
|
946
950
|
theme,
|
|
@@ -1343,6 +1347,7 @@ object RenderBridge {
|
|
|
1343
1347
|
builder: SpannableStringBuilder,
|
|
1344
1348
|
nodeType: String,
|
|
1345
1349
|
label: String,
|
|
1350
|
+
docPos: Int,
|
|
1346
1351
|
baseFontSize: Float,
|
|
1347
1352
|
textColor: Int,
|
|
1348
1353
|
blockStack: MutableList<BlockContext>,
|
|
@@ -1378,6 +1383,10 @@ object RenderBridge {
|
|
|
1378
1383
|
Annotation("nativeVoidNodeType", nodeType),
|
|
1379
1384
|
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1380
1385
|
)
|
|
1386
|
+
builder.setSpan(
|
|
1387
|
+
Annotation("nativeDocPos", docPos.toString()),
|
|
1388
|
+
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1389
|
+
)
|
|
1381
1390
|
if (isMention && (theme?.mentions?.fontWeight == "bold" ||
|
|
1382
1391
|
theme?.mentions?.fontWeight?.toIntOrNull()?.let { it >= 600 } == true)
|
|
1383
1392
|
) {
|
|
@@ -1393,6 +1402,7 @@ object RenderBridge {
|
|
|
1393
1402
|
builder: SpannableStringBuilder,
|
|
1394
1403
|
nodeType: String,
|
|
1395
1404
|
label: String,
|
|
1405
|
+
docPos: Int,
|
|
1396
1406
|
baseFontSize: Float,
|
|
1397
1407
|
textColor: Int,
|
|
1398
1408
|
theme: EditorTheme?,
|
|
@@ -1415,6 +1425,10 @@ object RenderBridge {
|
|
|
1415
1425
|
Annotation("nativeVoidNodeType", nodeType),
|
|
1416
1426
|
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1417
1427
|
)
|
|
1428
|
+
builder.setSpan(
|
|
1429
|
+
Annotation("nativeDocPos", docPos.toString()),
|
|
1430
|
+
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1431
|
+
)
|
|
1418
1432
|
annotateTopLevelChild(builder, start, end, topLevelChildIndex)
|
|
1419
1433
|
}
|
|
1420
1434
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
import { type EditorTheme } from './EditorTheme';
|
|
3
|
+
import type { DocumentJSON } from './NativeEditorBridge';
|
|
4
|
+
import { type SchemaDefinition } from './schemas';
|
|
5
|
+
export interface NativeProseViewerMentionPressEvent {
|
|
6
|
+
docPos: number;
|
|
7
|
+
label: string;
|
|
8
|
+
attrs: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
type NativeProseViewerContent = DocumentJSON | string;
|
|
11
|
+
export interface NativeProseViewerProps {
|
|
12
|
+
contentJSON: NativeProseViewerContent;
|
|
13
|
+
contentJSONRevision?: string | number;
|
|
14
|
+
schema?: SchemaDefinition;
|
|
15
|
+
theme?: EditorTheme;
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
allowBase64Images?: boolean;
|
|
18
|
+
onPressMention?: (event: NativeProseViewerMentionPressEvent) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function NativeProseViewer({ contentJSON, contentJSONRevision, schema, theme, style, allowBase64Images, onPressMention, }: NativeProseViewerProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NativeProseViewer = NativeProseViewer;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const expo_modules_core_1 = require("expo-modules-core");
|
|
7
|
+
const addons_1 = require("./addons");
|
|
8
|
+
const EditorTheme_1 = require("./EditorTheme");
|
|
9
|
+
const schemas_1 = require("./schemas");
|
|
10
|
+
const NativeProseViewerView = (0, expo_modules_core_1.requireNativeViewManager)('NativeEditor', 'NativeProseViewer');
|
|
11
|
+
let nativeProseViewerModule = null;
|
|
12
|
+
function getNativeProseViewerModule() {
|
|
13
|
+
if (!nativeProseViewerModule) {
|
|
14
|
+
nativeProseViewerModule =
|
|
15
|
+
(0, expo_modules_core_1.requireNativeModule)('NativeEditor');
|
|
16
|
+
}
|
|
17
|
+
return nativeProseViewerModule;
|
|
18
|
+
}
|
|
19
|
+
const serializedJsonCache = new WeakMap();
|
|
20
|
+
function stringifyCachedJson(value) {
|
|
21
|
+
if (value != null && typeof value === 'object') {
|
|
22
|
+
const cached = serializedJsonCache.get(value);
|
|
23
|
+
if (cached != null) {
|
|
24
|
+
return cached;
|
|
25
|
+
}
|
|
26
|
+
const serialized = JSON.stringify(value);
|
|
27
|
+
serializedJsonCache.set(value, serialized);
|
|
28
|
+
return serialized;
|
|
29
|
+
}
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
function looksLikeRenderElementsJson(json) {
|
|
33
|
+
for (let index = 0; index < json.length; index += 1) {
|
|
34
|
+
const char = json[index];
|
|
35
|
+
if (char === ' ' || char === '\n' || char === '\r' || char === '\t') {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
return char === '[';
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function unicodeScalarLength(text) {
|
|
43
|
+
let length = 0;
|
|
44
|
+
for (const _char of text) {
|
|
45
|
+
length += 1;
|
|
46
|
+
}
|
|
47
|
+
return length;
|
|
48
|
+
}
|
|
49
|
+
function normalizeMentionAttrs(node) {
|
|
50
|
+
if (node == null || typeof node !== 'object') {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
const attrs = node.attrs;
|
|
54
|
+
if (attrs == null || typeof attrs !== 'object' || Array.isArray(attrs)) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
return attrs;
|
|
58
|
+
}
|
|
59
|
+
function collectMentionPayloadsByDocPos(document) {
|
|
60
|
+
const mentions = new Map();
|
|
61
|
+
const visit = (node, pos, isRoot = false) => {
|
|
62
|
+
if (node == null || typeof node !== 'object') {
|
|
63
|
+
return pos;
|
|
64
|
+
}
|
|
65
|
+
const nodeRecord = node;
|
|
66
|
+
const nodeType = typeof nodeRecord.type === 'string' ? nodeRecord.type : '';
|
|
67
|
+
const content = Array.isArray(nodeRecord.content) ? nodeRecord.content : [];
|
|
68
|
+
if (nodeType === 'text') {
|
|
69
|
+
const text = typeof nodeRecord.text === 'string' ? nodeRecord.text : '';
|
|
70
|
+
return pos + unicodeScalarLength(text);
|
|
71
|
+
}
|
|
72
|
+
if (nodeType === 'mention') {
|
|
73
|
+
const attrs = normalizeMentionAttrs(nodeRecord);
|
|
74
|
+
const label = typeof attrs.label === 'string' ? attrs.label : undefined;
|
|
75
|
+
mentions.set(pos, { label, attrs });
|
|
76
|
+
}
|
|
77
|
+
if (isRoot && nodeType === 'doc') {
|
|
78
|
+
let nextPos = pos;
|
|
79
|
+
for (const child of content) {
|
|
80
|
+
nextPos = visit(child, nextPos);
|
|
81
|
+
}
|
|
82
|
+
return nextPos;
|
|
83
|
+
}
|
|
84
|
+
if (content.length === 0) {
|
|
85
|
+
return pos + 1;
|
|
86
|
+
}
|
|
87
|
+
let nextPos = pos + 1;
|
|
88
|
+
for (const child of content) {
|
|
89
|
+
nextPos = visit(child, nextPos);
|
|
90
|
+
}
|
|
91
|
+
return nextPos + 1;
|
|
92
|
+
};
|
|
93
|
+
visit(document, 0, true);
|
|
94
|
+
return mentions;
|
|
95
|
+
}
|
|
96
|
+
function serializeDocumentInput(document, schema) {
|
|
97
|
+
if (typeof document === 'string') {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(document);
|
|
100
|
+
const normalizedDocument = (0, schemas_1.normalizeDocumentJson)(parsed, schema);
|
|
101
|
+
return {
|
|
102
|
+
normalizedDocument,
|
|
103
|
+
serializedContentJson: stringifyCachedJson(normalizedDocument),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return {
|
|
108
|
+
normalizedDocument: null,
|
|
109
|
+
serializedContentJson: document,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const normalizedDocument = (0, schemas_1.normalizeDocumentJson)(document, schema);
|
|
114
|
+
return {
|
|
115
|
+
normalizedDocument,
|
|
116
|
+
serializedContentJson: stringifyCachedJson(normalizedDocument),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function extractRenderError(json) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(json);
|
|
122
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const error = parsed.error;
|
|
126
|
+
return typeof error === 'string' ? error : null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function NativeProseViewer({ contentJSON, contentJSONRevision, schema, theme, style, allowBase64Images = false, onPressMention, }) {
|
|
133
|
+
const documentSchema = (0, react_1.useMemo)(() => (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema), [schema]);
|
|
134
|
+
const { normalizedDocument, serializedContentJson } = (0, react_1.useMemo)(() => serializeDocumentInput(contentJSON, documentSchema), [contentJSON, contentJSONRevision, documentSchema]);
|
|
135
|
+
const themeJson = (0, react_1.useMemo)(() => (0, EditorTheme_1.serializeEditorTheme)(theme), [theme]);
|
|
136
|
+
const mentionPayloadsByDocPos = (0, react_1.useMemo)(() => normalizedDocument == null
|
|
137
|
+
? new Map()
|
|
138
|
+
: collectMentionPayloadsByDocPos(normalizedDocument), [normalizedDocument]);
|
|
139
|
+
const renderJson = (0, react_1.useMemo)(() => {
|
|
140
|
+
const configJson = JSON.stringify({
|
|
141
|
+
schema: documentSchema,
|
|
142
|
+
...(allowBase64Images ? { allowBase64Images } : {}),
|
|
143
|
+
});
|
|
144
|
+
const nextRenderJson = getNativeProseViewerModule().renderDocumentJson(configJson, serializedContentJson);
|
|
145
|
+
const renderError = extractRenderError(nextRenderJson);
|
|
146
|
+
if (renderError != null) {
|
|
147
|
+
console.error(`NativeProseViewer: ${renderError}`);
|
|
148
|
+
return '[]';
|
|
149
|
+
}
|
|
150
|
+
if (looksLikeRenderElementsJson(nextRenderJson)) {
|
|
151
|
+
return nextRenderJson;
|
|
152
|
+
}
|
|
153
|
+
console.error('NativeProseViewer: native renderDocumentJson returned an invalid payload.');
|
|
154
|
+
return '[]';
|
|
155
|
+
}, [allowBase64Images, documentSchema, serializedContentJson]);
|
|
156
|
+
const [contentHeight, setContentHeight] = (0, react_1.useState)(null);
|
|
157
|
+
const handleContentHeightChange = (0, react_1.useCallback)((event) => {
|
|
158
|
+
const nextHeight = event.nativeEvent.contentHeight;
|
|
159
|
+
setContentHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight);
|
|
160
|
+
}, []);
|
|
161
|
+
const handlePressMention = (0, react_1.useCallback)((event) => {
|
|
162
|
+
if (!onPressMention)
|
|
163
|
+
return;
|
|
164
|
+
const { docPos, label } = event.nativeEvent;
|
|
165
|
+
const resolvedMention = mentionPayloadsByDocPos.get(docPos);
|
|
166
|
+
onPressMention({
|
|
167
|
+
docPos,
|
|
168
|
+
label: resolvedMention?.label ?? label,
|
|
169
|
+
attrs: resolvedMention?.attrs ?? {},
|
|
170
|
+
});
|
|
171
|
+
}, [mentionPayloadsByDocPos, onPressMention]);
|
|
172
|
+
const nativeStyle = (0, react_1.useMemo)(() => [{ minHeight: 1 }, style, contentHeight != null ? { height: contentHeight } : null], [contentHeight, style]);
|
|
173
|
+
return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, onContentHeightChange: handleContentHeightChange, onPressMention: typeof onPressMention === 'function' ? handlePressMention : undefined }));
|
|
174
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { NativeRichTextEditor, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, } from './NativeRichTextEditor';
|
|
2
|
+
export { NativeProseViewer, type NativeProseViewerProps, type NativeProseViewerMentionPressEvent, } from './NativeProseViewer';
|
|
2
3
|
export { EditorToolbar, DEFAULT_EDITOR_TOOLBAR_ITEMS, type EditorToolbarProps, type EditorToolbarItem, type EditorToolbarLeafItem, type EditorToolbarGroupChildItem, type EditorToolbarGroupItem, type EditorToolbarGroupPresentation, type EditorToolbarIcon, type EditorToolbarDefaultIconId, type EditorToolbarSFSymbolIcon, type EditorToolbarMaterialIcon, type EditorToolbarCommand, type EditorToolbarHeadingLevel, type EditorToolbarListType, } from './EditorToolbar';
|
|
3
4
|
export type { EditorContentInsets, EditorTheme, EditorTextStyle, EditorHeadingTheme, EditorListTheme, EditorHorizontalRuleTheme, EditorMentionTheme, EditorToolbarTheme, EditorToolbarAppearance, EditorFontStyle, EditorFontWeight, } from './EditorTheme';
|
|
4
5
|
export { MENTION_NODE_NAME, mentionNodeSpec, withMentionsSchema, buildMentionFragmentJson, type EditorAddons, type MentionsAddonConfig, type MentionSuggestion, type MentionQueryChangeEvent, type MentionSelectEvent, type EditorAddonEvent, } from './addons';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.decodeCollaborationStateBase64 = exports.encodeCollaborationStateBase64 = exports.useYjsCollaboration = exports.createYjsCollaborationController = exports.buildImageFragmentJson = exports.withImagesSchema = exports.imageNodeSpec = exports.IMAGE_NODE_NAME = exports.prosemirrorSchema = exports.tiptapSchema = exports.buildMentionFragmentJson = exports.withMentionsSchema = exports.mentionNodeSpec = exports.MENTION_NODE_NAME = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS = exports.EditorToolbar = exports.NativeRichTextEditor = void 0;
|
|
3
|
+
exports.decodeCollaborationStateBase64 = exports.encodeCollaborationStateBase64 = exports.useYjsCollaboration = exports.createYjsCollaborationController = exports.buildImageFragmentJson = exports.withImagesSchema = exports.imageNodeSpec = exports.IMAGE_NODE_NAME = exports.prosemirrorSchema = exports.tiptapSchema = exports.buildMentionFragmentJson = exports.withMentionsSchema = exports.mentionNodeSpec = exports.MENTION_NODE_NAME = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS = exports.EditorToolbar = exports.NativeProseViewer = exports.NativeRichTextEditor = void 0;
|
|
4
4
|
var NativeRichTextEditor_1 = require("./NativeRichTextEditor");
|
|
5
5
|
Object.defineProperty(exports, "NativeRichTextEditor", { enumerable: true, get: function () { return NativeRichTextEditor_1.NativeRichTextEditor; } });
|
|
6
|
+
var NativeProseViewer_1 = require("./NativeProseViewer");
|
|
7
|
+
Object.defineProperty(exports, "NativeProseViewer", { enumerable: true, get: function () { return NativeProseViewer_1.NativeProseViewer; } });
|
|
6
8
|
var EditorToolbar_1 = require("./EditorToolbar");
|
|
7
9
|
Object.defineProperty(exports, "EditorToolbar", { enumerable: true, get: function () { return EditorToolbar_1.EditorToolbar; } });
|
|
8
10
|
Object.defineProperty(exports, "DEFAULT_EDITOR_TOOLBAR_ITEMS", { enumerable: true, get: function () { return EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS; } });
|
|
Binary file
|
|
Binary file
|
|
@@ -261,6 +261,13 @@ public class NativeEditorModule: Module {
|
|
|
261
261
|
Function("editorCanRedo") { (id: Int) -> Bool in
|
|
262
262
|
editorCanRedo(id: UInt64(id))
|
|
263
263
|
}
|
|
264
|
+
Function("renderDocumentJson") { (configJson: String, json: String) -> String in
|
|
265
|
+
let editorId = editorCreate(configJson: configJson)
|
|
266
|
+
defer {
|
|
267
|
+
editorDestroy(id: editorId)
|
|
268
|
+
}
|
|
269
|
+
return editorSetJson(id: editorId, json: json)
|
|
270
|
+
}
|
|
264
271
|
Function("editorReplaceHtml") { (id: Int, html: String) -> String in
|
|
265
272
|
editorReplaceHtml(id: UInt64(id), html: html)
|
|
266
273
|
}
|
|
@@ -365,5 +372,17 @@ public class NativeEditorModule: Module {
|
|
|
365
372
|
view.blur()
|
|
366
373
|
}
|
|
367
374
|
}
|
|
375
|
+
|
|
376
|
+
View(NativeProseViewerExpoView.self) {
|
|
377
|
+
ViewName("NativeProseViewer")
|
|
378
|
+
Events("onContentHeightChange", "onPressMention")
|
|
379
|
+
|
|
380
|
+
Prop("renderJson") { (view: NativeProseViewerExpoView, renderJson: String?) in
|
|
381
|
+
view.setRenderJson(renderJson)
|
|
382
|
+
}
|
|
383
|
+
Prop("themeJson") { (view: NativeProseViewerExpoView, themeJson: String?) in
|
|
384
|
+
view.setThemeJson(themeJson)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
368
387
|
}
|
|
369
388
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
final class NativeProseViewerExpoView: ExpoView {
|
|
5
|
+
let onContentHeightChange = EventDispatcher()
|
|
6
|
+
let onPressMention = EventDispatcher()
|
|
7
|
+
|
|
8
|
+
private let textView = EditorTextView(frame: .zero, textContainer: nil)
|
|
9
|
+
private var lastRenderJSON: String?
|
|
10
|
+
private var lastThemeJSON: String?
|
|
11
|
+
private var lastEmittedContentHeight: CGFloat = 0
|
|
12
|
+
private var lastMeasuredWidth: CGFloat = 0
|
|
13
|
+
|
|
14
|
+
private lazy var mentionTapRecognizer: UITapGestureRecognizer = {
|
|
15
|
+
let recognizer = UITapGestureRecognizer(
|
|
16
|
+
target: self,
|
|
17
|
+
action: #selector(handleMentionTap(_:))
|
|
18
|
+
)
|
|
19
|
+
recognizer.cancelsTouchesInView = false
|
|
20
|
+
return recognizer
|
|
21
|
+
}()
|
|
22
|
+
|
|
23
|
+
required init(appContext: AppContext? = nil) {
|
|
24
|
+
super.init(appContext: appContext)
|
|
25
|
+
setupView()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private func setupView() {
|
|
29
|
+
textView.baseBackgroundColor = .clear
|
|
30
|
+
textView.backgroundColor = .clear
|
|
31
|
+
textView.isEditable = false
|
|
32
|
+
textView.isSelectable = false
|
|
33
|
+
textView.allowImageResizing = false
|
|
34
|
+
textView.heightBehavior = .autoGrow
|
|
35
|
+
textView.onHeightMayChange = { [weak self] measuredHeight in
|
|
36
|
+
self?.emitContentHeightIfNeeded(measuredHeight: measuredHeight, force: true)
|
|
37
|
+
}
|
|
38
|
+
textView.addGestureRecognizer(mentionTapRecognizer)
|
|
39
|
+
addSubview(textView)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func setRenderJson(_ renderJson: String?) {
|
|
43
|
+
guard lastRenderJSON != renderJson else { return }
|
|
44
|
+
lastRenderJSON = renderJson
|
|
45
|
+
applyRenderJSON()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func setThemeJson(_ themeJson: String?) {
|
|
49
|
+
guard lastThemeJSON != themeJson else { return }
|
|
50
|
+
lastThemeJSON = themeJson
|
|
51
|
+
let theme = EditorTheme.from(json: themeJson)
|
|
52
|
+
textView.applyTheme(theme)
|
|
53
|
+
let cornerRadius = theme?.borderRadius ?? 0
|
|
54
|
+
layer.cornerRadius = cornerRadius
|
|
55
|
+
clipsToBounds = cornerRadius > 0
|
|
56
|
+
applyRenderJSON()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override var intrinsicContentSize: CGSize {
|
|
60
|
+
guard lastEmittedContentHeight > 0 else {
|
|
61
|
+
return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
62
|
+
}
|
|
63
|
+
return CGSize(width: UIView.noIntrinsicMetric, height: lastEmittedContentHeight)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override func layoutSubviews() {
|
|
67
|
+
super.layoutSubviews()
|
|
68
|
+
textView.frame = bounds
|
|
69
|
+
textView.updateAutoGrowHostHeight(bounds.height)
|
|
70
|
+
|
|
71
|
+
let currentWidth = ceil(bounds.width)
|
|
72
|
+
guard abs(currentWidth - lastMeasuredWidth) > 0.5 else { return }
|
|
73
|
+
lastMeasuredWidth = currentWidth
|
|
74
|
+
emitContentHeightIfNeeded(force: true)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func applyRenderJSON() {
|
|
78
|
+
textView.applyRenderJSON(lastRenderJSON ?? "[]")
|
|
79
|
+
emitContentHeightIfNeeded(force: true)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func emitContentHeightIfNeeded(
|
|
83
|
+
measuredHeight: CGFloat? = nil,
|
|
84
|
+
force: Bool = false
|
|
85
|
+
) {
|
|
86
|
+
let resolvedWidth = bounds.width > 0
|
|
87
|
+
? bounds.width
|
|
88
|
+
: (superview?.bounds.width ?? UIScreen.main.bounds.width)
|
|
89
|
+
let fittedHeight = measuredHeight
|
|
90
|
+
?? textView.sizeThatFits(
|
|
91
|
+
CGSize(width: resolvedWidth, height: CGFloat.greatestFiniteMagnitude)
|
|
92
|
+
).height
|
|
93
|
+
let contentHeight = ceil(fittedHeight)
|
|
94
|
+
guard contentHeight > 0 else { return }
|
|
95
|
+
guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
|
|
96
|
+
lastEmittedContentHeight = contentHeight
|
|
97
|
+
invalidateIntrinsicContentSize()
|
|
98
|
+
onContentHeightChange(["contentHeight": contentHeight])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@objc private func handleMentionTap(_ recognizer: UITapGestureRecognizer) {
|
|
102
|
+
guard recognizer.state == .ended,
|
|
103
|
+
let mention = mentionHit(at: recognizer.location(in: textView))
|
|
104
|
+
else {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onPressMention([
|
|
109
|
+
"docPos": mention.docPos,
|
|
110
|
+
"label": mention.label,
|
|
111
|
+
])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func mentionHit(at location: CGPoint) -> (docPos: Int, label: String)? {
|
|
115
|
+
let textStorage = textView.textStorage
|
|
116
|
+
guard textStorage.length > 0 else { return nil }
|
|
117
|
+
|
|
118
|
+
let layoutManager = textView.layoutManager
|
|
119
|
+
let textContainer = textView.textContainer
|
|
120
|
+
var containerPoint = location
|
|
121
|
+
containerPoint.x -= textView.textContainerInset.left
|
|
122
|
+
containerPoint.y -= textView.textContainerInset.top
|
|
123
|
+
|
|
124
|
+
let usedRect = layoutManager.usedRect(for: textContainer)
|
|
125
|
+
guard usedRect.insetBy(dx: -6, dy: -6).contains(containerPoint) else {
|
|
126
|
+
return nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let glyphIndex = layoutManager.glyphIndex(for: containerPoint, in: textContainer)
|
|
130
|
+
guard glyphIndex < layoutManager.numberOfGlyphs else { return nil }
|
|
131
|
+
let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex)
|
|
132
|
+
guard characterIndex < textStorage.length else { return nil }
|
|
133
|
+
|
|
134
|
+
var effectiveRange = NSRange(location: 0, length: 0)
|
|
135
|
+
let attrs = textStorage.attributes(at: characterIndex, effectiveRange: &effectiveRange)
|
|
136
|
+
guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "mention" else {
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let docPos =
|
|
141
|
+
(attrs[RenderBridgeAttributes.docPos] as? NSNumber)?.intValue
|
|
142
|
+
?? Int((attrs[RenderBridgeAttributes.docPos] as? UInt32) ?? 0)
|
|
143
|
+
let label = (textStorage.string as NSString).substring(with: effectiveRange)
|
|
144
|
+
return (docPos: docPos, label: label)
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apollohg/react-native-prose-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|