@apollohg/react-native-prose-editor 0.5.2 → 0.5.3
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/android/src/main/java/com/apollohg/editor/EditorEditText.kt +33 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +15 -1
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +44 -7
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +10 -0
- package/dist/NativeProseViewer.d.ts +19 -3
- package/dist/NativeProseViewer.js +116 -6
- package/dist/index.d.ts +1 -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 +14 -1
- package/ios/NativeProseViewerExpoView.swift +56 -8
- package/ios/RenderBridge.swift +8 -2
- 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
|
@@ -77,6 +77,11 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
77
77
|
val label: String
|
|
78
78
|
)
|
|
79
79
|
|
|
80
|
+
data class LinkHit(
|
|
81
|
+
val href: String,
|
|
82
|
+
val text: String
|
|
83
|
+
)
|
|
84
|
+
|
|
80
85
|
private data class ParsedRenderPatch(
|
|
81
86
|
val startIndex: Int,
|
|
82
87
|
val deleteCount: Int,
|
|
@@ -1583,7 +1588,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1583
1588
|
}
|
|
1584
1589
|
}
|
|
1585
1590
|
|
|
1586
|
-
fun
|
|
1591
|
+
private fun textOffsetHitAt(x: Float, y: Float): Pair<Spanned, Int>? {
|
|
1587
1592
|
val spannable = text as? Spanned ?: return null
|
|
1588
1593
|
val layout = layout ?: return null
|
|
1589
1594
|
if (spannable.isEmpty()) return null
|
|
@@ -1603,6 +1608,11 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1603
1608
|
|
|
1604
1609
|
val offset = layout.getOffsetForHorizontal(line, localX)
|
|
1605
1610
|
.coerceIn(0, maxOf(spannable.length - 1, 0))
|
|
1611
|
+
return spannable to offset
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
fun mentionHitAt(x: Float, y: Float): MentionHit? {
|
|
1615
|
+
val (spannable, offset) = textOffsetHitAt(x, y) ?: return null
|
|
1606
1616
|
val annotations = spannable.getSpans(
|
|
1607
1617
|
offset,
|
|
1608
1618
|
(offset + 1).coerceAtMost(spannable.length),
|
|
@@ -1626,6 +1636,28 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1626
1636
|
)
|
|
1627
1637
|
}
|
|
1628
1638
|
|
|
1639
|
+
fun linkHitAt(x: Float, y: Float): LinkHit? {
|
|
1640
|
+
val (spannable, offset) = textOffsetHitAt(x, y) ?: return null
|
|
1641
|
+
val annotations = spannable.getSpans(
|
|
1642
|
+
offset,
|
|
1643
|
+
(offset + 1).coerceAtMost(spannable.length),
|
|
1644
|
+
Annotation::class.java
|
|
1645
|
+
)
|
|
1646
|
+
val linkAnnotation = annotations.firstOrNull {
|
|
1647
|
+
it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION && it.value.isNotBlank()
|
|
1648
|
+
} ?: return null
|
|
1649
|
+
val start = spannable.getSpanStart(linkAnnotation)
|
|
1650
|
+
val end = spannable.getSpanEnd(linkAnnotation)
|
|
1651
|
+
if (start < 0 || end <= start) {
|
|
1652
|
+
return null
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
return LinkHit(
|
|
1656
|
+
href = linkAnnotation.value,
|
|
1657
|
+
text = spannable.subSequence(start, end).toString()
|
|
1658
|
+
)
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1629
1661
|
private fun handleImageTap(event: MotionEvent): Boolean {
|
|
1630
1662
|
if (!imageResizingEnabled) {
|
|
1631
1663
|
return false
|
|
@@ -295,6 +295,14 @@ class NativeEditorModule : Module() {
|
|
|
295
295
|
editorDestroy(editorId)
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
|
+
Function("renderDocumentHtml") { configJson: String, html: String ->
|
|
299
|
+
val editorId = editorCreate(configJson)
|
|
300
|
+
try {
|
|
301
|
+
editorSetHtml(editorId, html)
|
|
302
|
+
} finally {
|
|
303
|
+
editorDestroy(editorId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
298
306
|
|
|
299
307
|
View(NativeEditorExpoView::class) {
|
|
300
308
|
Events(
|
|
@@ -370,7 +378,7 @@ class NativeEditorModule : Module() {
|
|
|
370
378
|
|
|
371
379
|
View(NativeProseViewerExpoView::class) {
|
|
372
380
|
Name("NativeProseViewer")
|
|
373
|
-
Events("onContentHeightChange", "onPressMention")
|
|
381
|
+
Events("onContentHeightChange", "onPressLink", "onPressMention")
|
|
374
382
|
|
|
375
383
|
Prop("renderJson") { view: NativeProseViewerExpoView, renderJson: String? ->
|
|
376
384
|
view.setRenderJson(renderJson)
|
|
@@ -378,6 +386,12 @@ class NativeEditorModule : Module() {
|
|
|
378
386
|
Prop("themeJson") { view: NativeProseViewerExpoView, themeJson: String? ->
|
|
379
387
|
view.setThemeJson(themeJson)
|
|
380
388
|
}
|
|
389
|
+
Prop("enableLinkTaps") { view: NativeProseViewerExpoView, enableLinkTaps: Boolean? ->
|
|
390
|
+
view.setEnableLinkTaps(enableLinkTaps)
|
|
391
|
+
}
|
|
392
|
+
Prop("interceptLinkTaps") { view: NativeProseViewerExpoView, interceptLinkTaps: Boolean? ->
|
|
393
|
+
view.setInterceptLinkTaps(interceptLinkTaps)
|
|
394
|
+
}
|
|
381
395
|
}
|
|
382
396
|
}
|
|
383
397
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package com.apollohg.editor
|
|
2
2
|
|
|
3
|
+
import android.content.Intent
|
|
3
4
|
import android.content.Context
|
|
4
5
|
import android.graphics.Color
|
|
6
|
+
import android.net.Uri
|
|
5
7
|
import android.view.MotionEvent
|
|
6
8
|
import android.view.View
|
|
7
9
|
import android.view.ViewGroup
|
|
@@ -17,11 +19,15 @@ class NativeProseViewerExpoView(
|
|
|
17
19
|
private val proseView = EditorEditText(context)
|
|
18
20
|
private val onContentHeightChange by EventDispatcher<Map<String, Any>>()
|
|
19
21
|
@Suppress("unused")
|
|
22
|
+
private val onPressLink by EventDispatcher<Map<String, Any>>()
|
|
23
|
+
@Suppress("unused")
|
|
20
24
|
private val onPressMention by EventDispatcher<Map<String, Any>>()
|
|
21
25
|
|
|
22
26
|
private var lastRenderJson: String? = null
|
|
23
27
|
private var lastThemeJson: String? = null
|
|
24
28
|
private var lastEmittedContentHeight = 0
|
|
29
|
+
private var enableLinkTaps = true
|
|
30
|
+
private var interceptLinkTaps = false
|
|
25
31
|
|
|
26
32
|
init {
|
|
27
33
|
proseView.setBaseStyle(
|
|
@@ -43,14 +49,27 @@ class NativeProseViewerExpoView(
|
|
|
43
49
|
return@setOnTouchListener false
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
proseView.mentionHitAt(event.x, event.y)?.let { mention ->
|
|
53
|
+
onPressMention(mapOf("docPos" to mention.docPos, "label" to mention.label))
|
|
54
|
+
return@setOnTouchListener true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!enableLinkTaps) {
|
|
58
|
+
return@setOnTouchListener false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
val link = proseView.linkHitAt(event.x, event.y) ?: return@setOnTouchListener false
|
|
62
|
+
if (interceptLinkTaps) {
|
|
63
|
+
onPressLink(
|
|
64
|
+
mapOf(
|
|
65
|
+
"href" to link.href,
|
|
66
|
+
"text" to link.text
|
|
67
|
+
)
|
|
51
68
|
)
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
return@setOnTouchListener true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return@setOnTouchListener openLink(link.href)
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
addView(
|
|
@@ -83,6 +102,14 @@ class NativeProseViewerExpoView(
|
|
|
83
102
|
}
|
|
84
103
|
}
|
|
85
104
|
|
|
105
|
+
fun setEnableLinkTaps(enableLinkTaps: Boolean?) {
|
|
106
|
+
this.enableLinkTaps = enableLinkTaps ?: true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun setInterceptLinkTaps(interceptLinkTaps: Boolean?) {
|
|
110
|
+
this.interceptLinkTaps = interceptLinkTaps ?: false
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
87
114
|
val childWidthSpec = getChildMeasureSpec(
|
|
88
115
|
widthMeasureSpec,
|
|
@@ -154,4 +181,14 @@ class NativeProseViewerExpoView(
|
|
|
154
181
|
|
|
155
182
|
return (resources.displayMetrics.widthPixels - paddingLeft - paddingRight).coerceAtLeast(1)
|
|
156
183
|
}
|
|
184
|
+
|
|
185
|
+
private fun openLink(href: String): Boolean {
|
|
186
|
+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(href)).apply {
|
|
187
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
188
|
+
}
|
|
189
|
+
return runCatching {
|
|
190
|
+
context.startActivity(intent)
|
|
191
|
+
true
|
|
192
|
+
}.getOrDefault(false)
|
|
193
|
+
}
|
|
157
194
|
}
|
|
@@ -752,6 +752,7 @@ class CenteredBulletSpan(
|
|
|
752
752
|
object RenderBridge {
|
|
753
753
|
internal const val NATIVE_BLOCKQUOTE_ANNOTATION = "nativeBlockquote"
|
|
754
754
|
internal const val NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION = "nativeTopLevelChildIndex"
|
|
755
|
+
internal const val NATIVE_LINK_HREF_ANNOTATION = "nativeLinkHref"
|
|
755
756
|
private const val NATIVE_SYNTHETIC_PLACEHOLDER_ANNOTATION = "nativeSyntheticPlaceholder"
|
|
756
757
|
|
|
757
758
|
private data class RenderBuildState(
|
|
@@ -1174,6 +1175,15 @@ object RenderBridge {
|
|
|
1174
1175
|
end,
|
|
1175
1176
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1176
1177
|
)
|
|
1178
|
+
val href = mark.optString("href", "")
|
|
1179
|
+
if (href.isNotBlank()) {
|
|
1180
|
+
builder.setSpan(
|
|
1181
|
+
Annotation(NATIVE_LINK_HREF_ANNOTATION, href),
|
|
1182
|
+
start,
|
|
1183
|
+
end,
|
|
1184
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1185
|
+
)
|
|
1186
|
+
}
|
|
1177
1187
|
}
|
|
1178
1188
|
}
|
|
1179
1189
|
}
|
|
@@ -9,17 +9,33 @@ export interface NativeProseViewerMentionRenderContext {
|
|
|
9
9
|
}
|
|
10
10
|
export interface NativeProseViewerMentionPressEvent extends NativeProseViewerMentionRenderContext {
|
|
11
11
|
}
|
|
12
|
+
export interface NativeProseViewerLinkPressEvent {
|
|
13
|
+
href: string;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
12
16
|
type NativeProseViewerContent = DocumentJSON | string;
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
interface NativeProseViewerBaseProps {
|
|
18
|
+
contentRevision?: string | number;
|
|
15
19
|
contentJSONRevision?: string | number;
|
|
16
20
|
schema?: SchemaDefinition;
|
|
17
21
|
theme?: EditorTheme;
|
|
18
22
|
style?: StyleProp<ViewStyle>;
|
|
19
23
|
allowBase64Images?: boolean;
|
|
24
|
+
collapseTrailingEmptyParagraphs?: boolean;
|
|
25
|
+
enableLinkTaps?: boolean;
|
|
20
26
|
mentionPrefix?: string | ((mention: NativeProseViewerMentionRenderContext) => string | null | undefined);
|
|
21
27
|
resolveMentionTheme?: (mention: NativeProseViewerMentionRenderContext) => EditorMentionTheme | null | undefined;
|
|
28
|
+
onPressLink?: (event: NativeProseViewerLinkPressEvent) => void;
|
|
22
29
|
onPressMention?: (event: NativeProseViewerMentionPressEvent) => void;
|
|
23
30
|
}
|
|
24
|
-
|
|
31
|
+
interface NativeProseViewerJsonProps extends NativeProseViewerBaseProps {
|
|
32
|
+
contentJSON: NativeProseViewerContent;
|
|
33
|
+
contentHTML?: never;
|
|
34
|
+
}
|
|
35
|
+
interface NativeProseViewerHtmlProps extends NativeProseViewerBaseProps {
|
|
36
|
+
contentHTML: string;
|
|
37
|
+
contentJSON?: never;
|
|
38
|
+
}
|
|
39
|
+
export type NativeProseViewerProps = NativeProseViewerJsonProps | NativeProseViewerHtmlProps;
|
|
40
|
+
export declare function NativeProseViewer({ ...props }: NativeProseViewerProps): import("react/jsx-runtime").JSX.Element;
|
|
25
41
|
export {};
|
|
@@ -17,6 +17,7 @@ function getNativeProseViewerModule() {
|
|
|
17
17
|
return nativeProseViewerModule;
|
|
18
18
|
}
|
|
19
19
|
const serializedJsonCache = new WeakMap();
|
|
20
|
+
const EMPTY_TEXT_BLOCK_PLACEHOLDER = '\u200B';
|
|
20
21
|
function stringifyCachedJson(value) {
|
|
21
22
|
if (value != null && typeof value === 'object') {
|
|
22
23
|
const cached = serializedJsonCache.get(value);
|
|
@@ -158,6 +159,88 @@ function applyResolvedMentionRendering(renderJson, mentionPayloadsByDocPos) {
|
|
|
158
159
|
});
|
|
159
160
|
return didChange ? JSON.stringify(nextElements) : renderJson;
|
|
160
161
|
}
|
|
162
|
+
function isTopLevelSingleElementBlock(element) {
|
|
163
|
+
return element.type === 'voidBlock' || element.type === 'opaqueBlockAtom';
|
|
164
|
+
}
|
|
165
|
+
function isEmptyParagraphPlaceholderText(text) {
|
|
166
|
+
if (text.length === 0) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return Array.from(text).every((char) => char === EMPTY_TEXT_BLOCK_PLACEHOLDER);
|
|
170
|
+
}
|
|
171
|
+
function isTrailingEmptyParagraphRange(elements, start, endExclusive) {
|
|
172
|
+
const startElement = elements[start];
|
|
173
|
+
const endElement = elements[endExclusive - 1];
|
|
174
|
+
if (startElement?.type !== 'blockStart' ||
|
|
175
|
+
startElement.nodeType !== 'paragraph' ||
|
|
176
|
+
startElement.depth !== 0 ||
|
|
177
|
+
endElement?.type !== 'blockEnd') {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const innerElements = elements.slice(start + 1, endExclusive - 1);
|
|
181
|
+
return (innerElements.length > 0 &&
|
|
182
|
+
innerElements.every((element) => element.type === 'textRun' &&
|
|
183
|
+
typeof element.text === 'string' &&
|
|
184
|
+
isEmptyParagraphPlaceholderText(element.text)));
|
|
185
|
+
}
|
|
186
|
+
function collapseTrailingEmptyParagraphRenderElements(renderJson) {
|
|
187
|
+
let parsedElements;
|
|
188
|
+
try {
|
|
189
|
+
parsedElements = JSON.parse(renderJson);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return renderJson;
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(parsedElements)) {
|
|
195
|
+
return renderJson;
|
|
196
|
+
}
|
|
197
|
+
const elements = parsedElements;
|
|
198
|
+
const topLevelRanges = [];
|
|
199
|
+
for (let index = 0; index < elements.length; index += 1) {
|
|
200
|
+
const element = elements[index];
|
|
201
|
+
if (!element || typeof element !== 'object' || Array.isArray(element)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (element.type === 'blockStart' && element.depth === 0) {
|
|
205
|
+
let nestingDepth = 1;
|
|
206
|
+
let cursor = index + 1;
|
|
207
|
+
while (cursor < elements.length && nestingDepth > 0) {
|
|
208
|
+
const current = elements[cursor];
|
|
209
|
+
if (current?.type === 'blockStart') {
|
|
210
|
+
nestingDepth += 1;
|
|
211
|
+
}
|
|
212
|
+
else if (current?.type === 'blockEnd') {
|
|
213
|
+
nestingDepth -= 1;
|
|
214
|
+
}
|
|
215
|
+
cursor += 1;
|
|
216
|
+
}
|
|
217
|
+
if (nestingDepth !== 0) {
|
|
218
|
+
return renderJson;
|
|
219
|
+
}
|
|
220
|
+
topLevelRanges.push({ start: index, endExclusive: cursor });
|
|
221
|
+
index = cursor - 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (isTopLevelSingleElementBlock(element)) {
|
|
225
|
+
topLevelRanges.push({ start: index, endExclusive: index + 1 });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (topLevelRanges.length <= 1) {
|
|
229
|
+
return renderJson;
|
|
230
|
+
}
|
|
231
|
+
let trimStart = null;
|
|
232
|
+
for (let rangeIndex = topLevelRanges.length - 1; rangeIndex >= 1; rangeIndex -= 1) {
|
|
233
|
+
const range = topLevelRanges[rangeIndex];
|
|
234
|
+
if (!isTrailingEmptyParagraphRange(elements, range.start, range.endExclusive)) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
trimStart = range.start;
|
|
238
|
+
}
|
|
239
|
+
if (trimStart == null) {
|
|
240
|
+
return renderJson;
|
|
241
|
+
}
|
|
242
|
+
return JSON.stringify(elements.slice(0, trimStart));
|
|
243
|
+
}
|
|
161
244
|
function serializeDocumentInput(document, schema) {
|
|
162
245
|
if (typeof document === 'string') {
|
|
163
246
|
try {
|
|
@@ -194,9 +277,21 @@ function extractRenderError(json) {
|
|
|
194
277
|
return null;
|
|
195
278
|
}
|
|
196
279
|
}
|
|
197
|
-
function NativeProseViewer({
|
|
280
|
+
function NativeProseViewer({ ...props }) {
|
|
281
|
+
const { contentRevision, contentJSONRevision, schema, theme, style, allowBase64Images = false, collapseTrailingEmptyParagraphs = true, enableLinkTaps = true, mentionPrefix, resolveMentionTheme, onPressLink, onPressMention, } = props;
|
|
282
|
+
const contentJSON = 'contentJSON' in props ? props.contentJSON : undefined;
|
|
283
|
+
const contentHTML = 'contentHTML' in props ? props.contentHTML : undefined;
|
|
284
|
+
const resolvedContentRevision = contentRevision ?? contentJSONRevision;
|
|
198
285
|
const documentSchema = (0, react_1.useMemo)(() => (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema), [schema]);
|
|
199
|
-
const { normalizedDocument, serializedContentJson } = (0, react_1.useMemo)(() =>
|
|
286
|
+
const { normalizedDocument, serializedContentJson } = (0, react_1.useMemo)(() => {
|
|
287
|
+
if (contentJSON === undefined) {
|
|
288
|
+
return {
|
|
289
|
+
normalizedDocument: null,
|
|
290
|
+
serializedContentJson: null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return serializeDocumentInput(contentJSON, documentSchema);
|
|
294
|
+
}, [contentJSON, resolvedContentRevision, documentSchema]);
|
|
200
295
|
const themeJson = (0, react_1.useMemo)(() => (0, EditorTheme_1.serializeEditorTheme)(theme), [theme]);
|
|
201
296
|
const mentionPayloadsByDocPos = (0, react_1.useMemo)(() => normalizedDocument == null
|
|
202
297
|
? new Map()
|
|
@@ -206,19 +301,26 @@ function NativeProseViewer({ contentJSON, contentJSONRevision, schema, theme, st
|
|
|
206
301
|
schema: documentSchema,
|
|
207
302
|
...(allowBase64Images ? { allowBase64Images } : {}),
|
|
208
303
|
});
|
|
209
|
-
const nextRenderJson =
|
|
304
|
+
const nextRenderJson = serializedContentJson != null
|
|
305
|
+
? getNativeProseViewerModule().renderDocumentJson(configJson, serializedContentJson)
|
|
306
|
+
: getNativeProseViewerModule().renderDocumentHtml(configJson, contentHTML ?? '');
|
|
210
307
|
const renderError = extractRenderError(nextRenderJson);
|
|
211
308
|
if (renderError != null) {
|
|
212
309
|
console.error(`NativeProseViewer: ${renderError}`);
|
|
213
310
|
return '[]';
|
|
214
311
|
}
|
|
215
312
|
if (looksLikeRenderElementsJson(nextRenderJson)) {
|
|
216
|
-
|
|
313
|
+
const collapsedRenderJson = collapseTrailingEmptyParagraphs
|
|
314
|
+
? collapseTrailingEmptyParagraphRenderElements(nextRenderJson)
|
|
315
|
+
: nextRenderJson;
|
|
316
|
+
return applyResolvedMentionRendering(collapsedRenderJson, mentionPayloadsByDocPos);
|
|
217
317
|
}
|
|
218
318
|
console.error('NativeProseViewer: native renderDocumentJson returned an invalid payload.');
|
|
219
319
|
return '[]';
|
|
220
320
|
}, [
|
|
221
321
|
allowBase64Images,
|
|
322
|
+
collapseTrailingEmptyParagraphs,
|
|
323
|
+
contentHTML,
|
|
222
324
|
documentSchema,
|
|
223
325
|
mentionPayloadsByDocPos,
|
|
224
326
|
serializedContentJson,
|
|
@@ -227,7 +329,7 @@ function NativeProseViewer({ contentJSON, contentJSONRevision, schema, theme, st
|
|
|
227
329
|
const allowContentHeightShrinkRef = (0, react_1.useRef)(true);
|
|
228
330
|
(0, react_1.useEffect)(() => {
|
|
229
331
|
allowContentHeightShrinkRef.current = true;
|
|
230
|
-
}, [
|
|
332
|
+
}, [resolvedContentRevision, renderJson, themeJson]);
|
|
231
333
|
const handleContentHeightChange = (0, react_1.useCallback)((event) => {
|
|
232
334
|
const nextHeight = event.nativeEvent.contentHeight;
|
|
233
335
|
if (nextHeight <= 0)
|
|
@@ -254,10 +356,18 @@ function NativeProseViewer({ contentJSON, contentJSONRevision, schema, theme, st
|
|
|
254
356
|
attrs: resolvedMention?.attrs ?? {},
|
|
255
357
|
});
|
|
256
358
|
}, [mentionPayloadsByDocPos, onPressMention]);
|
|
359
|
+
const handlePressLink = (0, react_1.useCallback)((event) => {
|
|
360
|
+
if (!onPressLink)
|
|
361
|
+
return;
|
|
362
|
+
onPressLink({
|
|
363
|
+
href: event.nativeEvent.href,
|
|
364
|
+
text: event.nativeEvent.text,
|
|
365
|
+
});
|
|
366
|
+
}, [onPressLink]);
|
|
257
367
|
const nativeStyle = (0, react_1.useMemo)(() => [
|
|
258
368
|
{ minHeight: 1 },
|
|
259
369
|
style,
|
|
260
370
|
contentHeight != null ? { minHeight: contentHeight } : null,
|
|
261
371
|
], [contentHeight, style]);
|
|
262
|
-
return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, onContentHeightChange: handleContentHeightChange, onPressMention: typeof onPressMention === 'function' ? handlePressMention : undefined }));
|
|
372
|
+
return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, enableLinkTaps: enableLinkTaps, interceptLinkTaps: typeof onPressLink === 'function', onContentHeightChange: handleContentHeightChange, onPressLink: typeof onPressLink === 'function' ? handlePressLink : undefined, onPressMention: typeof onPressMention === 'function' ? handlePressMention : undefined }));
|
|
263
373
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +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 NativeProseViewerMentionRenderContext, type NativeProseViewerMentionPressEvent, } from './NativeProseViewer';
|
|
2
|
+
export { NativeProseViewer, type NativeProseViewerProps, type NativeProseViewerLinkPressEvent, type NativeProseViewerMentionRenderContext, type NativeProseViewerMentionPressEvent, } from './NativeProseViewer';
|
|
3
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';
|
|
4
4
|
export type { EditorContentInsets, EditorTheme, EditorTextStyle, EditorHeadingTheme, EditorListTheme, EditorHorizontalRuleTheme, EditorMentionTheme, EditorToolbarTheme, EditorToolbarAppearance, EditorFontStyle, EditorFontWeight, } from './EditorTheme';
|
|
5
5
|
export { MENTION_NODE_NAME, mentionNodeSpec, withMentionsSchema, buildMentionFragmentJson, type EditorAddons, type MentionsAddonConfig, type MentionSuggestion, type MentionQueryChangeEvent, type MentionSelectionAttrsEvent, type MentionSelectEvent, type EditorAddonEvent, } from './addons';
|
|
Binary file
|
|
Binary file
|
|
@@ -268,6 +268,13 @@ public class NativeEditorModule: Module {
|
|
|
268
268
|
}
|
|
269
269
|
return editorSetJson(id: editorId, json: json)
|
|
270
270
|
}
|
|
271
|
+
Function("renderDocumentHtml") { (configJson: String, html: String) -> String in
|
|
272
|
+
let editorId = editorCreate(configJson: configJson)
|
|
273
|
+
defer {
|
|
274
|
+
editorDestroy(id: editorId)
|
|
275
|
+
}
|
|
276
|
+
return editorSetHtml(id: editorId, html: html)
|
|
277
|
+
}
|
|
271
278
|
Function("editorReplaceHtml") { (id: Int, html: String) -> String in
|
|
272
279
|
editorReplaceHtml(id: UInt64(id), html: html)
|
|
273
280
|
}
|
|
@@ -375,7 +382,7 @@ public class NativeEditorModule: Module {
|
|
|
375
382
|
|
|
376
383
|
View(NativeProseViewerExpoView.self) {
|
|
377
384
|
ViewName("NativeProseViewer")
|
|
378
|
-
Events("onContentHeightChange", "onPressMention")
|
|
385
|
+
Events("onContentHeightChange", "onPressLink", "onPressMention")
|
|
379
386
|
|
|
380
387
|
Prop("renderJson") { (view: NativeProseViewerExpoView, renderJson: String?) in
|
|
381
388
|
view.setRenderJson(renderJson)
|
|
@@ -383,6 +390,12 @@ public class NativeEditorModule: Module {
|
|
|
383
390
|
Prop("themeJson") { (view: NativeProseViewerExpoView, themeJson: String?) in
|
|
384
391
|
view.setThemeJson(themeJson)
|
|
385
392
|
}
|
|
393
|
+
Prop("enableLinkTaps") { (view: NativeProseViewerExpoView, enableLinkTaps: Bool?) in
|
|
394
|
+
view.setEnableLinkTaps(enableLinkTaps)
|
|
395
|
+
}
|
|
396
|
+
Prop("interceptLinkTaps") { (view: NativeProseViewerExpoView, interceptLinkTaps: Bool?) in
|
|
397
|
+
view.setInterceptLinkTaps(interceptLinkTaps)
|
|
398
|
+
}
|
|
386
399
|
}
|
|
387
400
|
}
|
|
388
401
|
}
|
|
@@ -3,6 +3,7 @@ import UIKit
|
|
|
3
3
|
|
|
4
4
|
final class NativeProseViewerExpoView: ExpoView {
|
|
5
5
|
let onContentHeightChange = EventDispatcher()
|
|
6
|
+
let onPressLink = EventDispatcher()
|
|
6
7
|
let onPressMention = EventDispatcher()
|
|
7
8
|
|
|
8
9
|
private let textView = EditorTextView(frame: .zero, textContainer: nil)
|
|
@@ -11,11 +12,13 @@ final class NativeProseViewerExpoView: ExpoView {
|
|
|
11
12
|
private var lastEmittedContentHeight: CGFloat = 0
|
|
12
13
|
private var lastMeasuredWidth: CGFloat = 0
|
|
13
14
|
private var allowContentHeightShrink = true
|
|
15
|
+
private var enableLinkTaps = true
|
|
16
|
+
private var interceptLinkTaps = false
|
|
14
17
|
|
|
15
|
-
private lazy var
|
|
18
|
+
private lazy var interactiveTapRecognizer: UITapGestureRecognizer = {
|
|
16
19
|
let recognizer = UITapGestureRecognizer(
|
|
17
20
|
target: self,
|
|
18
|
-
action: #selector(
|
|
21
|
+
action: #selector(handleInteractiveTap(_:))
|
|
19
22
|
)
|
|
20
23
|
recognizer.cancelsTouchesInView = false
|
|
21
24
|
return recognizer
|
|
@@ -36,10 +39,18 @@ final class NativeProseViewerExpoView: ExpoView {
|
|
|
36
39
|
textView.onHeightMayChange = { [weak self] measuredHeight in
|
|
37
40
|
self?.emitContentHeightIfNeeded(measuredHeight: measuredHeight, force: true)
|
|
38
41
|
}
|
|
39
|
-
textView.addGestureRecognizer(
|
|
42
|
+
textView.addGestureRecognizer(interactiveTapRecognizer)
|
|
40
43
|
addSubview(textView)
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
func setEnableLinkTaps(_ enabled: Bool?) {
|
|
47
|
+
enableLinkTaps = enabled ?? true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func setInterceptLinkTaps(_ intercept: Bool?) {
|
|
51
|
+
interceptLinkTaps = intercept ?? false
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
func setRenderJson(_ renderJson: String?) {
|
|
44
55
|
guard lastRenderJSON != renderJson else { return }
|
|
45
56
|
lastRenderJSON = renderJson
|
|
@@ -101,20 +112,32 @@ final class NativeProseViewerExpoView: ExpoView {
|
|
|
101
112
|
onContentHeightChange(["contentHeight": contentHeight])
|
|
102
113
|
}
|
|
103
114
|
|
|
104
|
-
@objc private func
|
|
105
|
-
guard recognizer.state == .ended
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
@objc private func handleInteractiveTap(_ recognizer: UITapGestureRecognizer) {
|
|
116
|
+
guard recognizer.state == .ended else {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let location = recognizer.location(in: textView)
|
|
121
|
+
if enableLinkTaps, let link = linkHit(at: location) {
|
|
122
|
+
if interceptLinkTaps {
|
|
123
|
+
onPressLink([
|
|
124
|
+
"href": link.href,
|
|
125
|
+
"text": link.text,
|
|
126
|
+
])
|
|
127
|
+
} else {
|
|
128
|
+
openLink(link.href)
|
|
129
|
+
}
|
|
108
130
|
return
|
|
109
131
|
}
|
|
110
132
|
|
|
133
|
+
guard let mention = mentionHit(at: location) else { return }
|
|
111
134
|
onPressMention([
|
|
112
135
|
"docPos": mention.docPos,
|
|
113
136
|
"label": mention.label,
|
|
114
137
|
])
|
|
115
138
|
}
|
|
116
139
|
|
|
117
|
-
private func
|
|
140
|
+
private func characterIndex(at location: CGPoint) -> Int? {
|
|
118
141
|
let textStorage = textView.textStorage
|
|
119
142
|
guard textStorage.length > 0 else { return nil }
|
|
120
143
|
|
|
@@ -133,6 +156,26 @@ final class NativeProseViewerExpoView: ExpoView {
|
|
|
133
156
|
guard glyphIndex < layoutManager.numberOfGlyphs else { return nil }
|
|
134
157
|
let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex)
|
|
135
158
|
guard characterIndex < textStorage.length else { return nil }
|
|
159
|
+
return characterIndex
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private func linkHit(at location: CGPoint) -> (href: String, text: String)? {
|
|
163
|
+
let textStorage = textView.textStorage
|
|
164
|
+
guard let characterIndex = characterIndex(at: location) else { return nil }
|
|
165
|
+
|
|
166
|
+
var effectiveRange = NSRange(location: 0, length: 0)
|
|
167
|
+
let attrs = textStorage.attributes(at: characterIndex, effectiveRange: &effectiveRange)
|
|
168
|
+
guard let href = attrs[RenderBridgeAttributes.linkHref] as? String, !href.isEmpty else {
|
|
169
|
+
return nil
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let text = (textStorage.string as NSString).substring(with: effectiveRange)
|
|
173
|
+
return (href: href, text: text)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private func mentionHit(at location: CGPoint) -> (docPos: Int, label: String)? {
|
|
177
|
+
let textStorage = textView.textStorage
|
|
178
|
+
guard let characterIndex = characterIndex(at: location) else { return nil }
|
|
136
179
|
|
|
137
180
|
var effectiveRange = NSRange(location: 0, length: 0)
|
|
138
181
|
let attrs = textStorage.attributes(at: characterIndex, effectiveRange: &effectiveRange)
|
|
@@ -146,4 +189,9 @@ final class NativeProseViewerExpoView: ExpoView {
|
|
|
146
189
|
let label = (textStorage.string as NSString).substring(with: effectiveRange)
|
|
147
190
|
return (docPos: docPos, label: label)
|
|
148
191
|
}
|
|
192
|
+
|
|
193
|
+
private func openLink(_ href: String) {
|
|
194
|
+
guard let url = URL(string: href) else { return }
|
|
195
|
+
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
|
196
|
+
}
|
|
149
197
|
}
|
package/ios/RenderBridge.swift
CHANGED
|
@@ -107,6 +107,9 @@ enum RenderBridgeAttributes {
|
|
|
107
107
|
/// Marks synthetic zero-width placeholders used only for UIKit layout.
|
|
108
108
|
static let syntheticPlaceholder = NSAttributedString.Key("com.apollohg.editor.syntheticPlaceholder")
|
|
109
109
|
|
|
110
|
+
/// Stores the link href for visually styled link text without enabling UITextView's default link interaction.
|
|
111
|
+
static let linkHref = NSAttributedString.Key("com.apollohg.editor.linkHref")
|
|
112
|
+
|
|
110
113
|
/// Stores the owning top-level document child index for partial native patching.
|
|
111
114
|
static let topLevelChildIndex = NSAttributedString.Key("com.apollohg.editor.topLevelChildIndex")
|
|
112
115
|
}
|
|
@@ -564,11 +567,11 @@ final class RenderBridge {
|
|
|
564
567
|
var traits: UIFontDescriptor.SymbolicTraits = []
|
|
565
568
|
var useMonospace = false
|
|
566
569
|
for mark in marks {
|
|
570
|
+
let markObject = mark as? [String: Any]
|
|
567
571
|
let markType: String
|
|
568
572
|
if let markName = mark as? String {
|
|
569
573
|
markType = markName
|
|
570
|
-
} else if let
|
|
571
|
-
let resolvedType = markObject["type"] as? String {
|
|
574
|
+
} else if let resolvedType = markObject?["type"] as? String {
|
|
572
575
|
markType = resolvedType
|
|
573
576
|
} else {
|
|
574
577
|
continue
|
|
@@ -588,6 +591,9 @@ final class RenderBridge {
|
|
|
588
591
|
case "link":
|
|
589
592
|
attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
590
593
|
attrs[.foregroundColor] = UIColor.systemBlue
|
|
594
|
+
if let href = markObject?["href"] as? String, !href.isEmpty {
|
|
595
|
+
attrs[RenderBridgeAttributes.linkHref] = href
|
|
596
|
+
}
|
|
591
597
|
default:
|
|
592
598
|
break
|
|
593
599
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apollohg/react-native-prose-editor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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
|