@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.
@@ -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 mentionHitAt(x: Float, y: Float): MentionHit? {
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
- 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
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
- true
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
- export interface NativeProseViewerProps {
14
- contentJSON: NativeProseViewerContent;
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
- export declare function NativeProseViewer({ contentJSON, contentJSONRevision, schema, theme, style, allowBase64Images, mentionPrefix, resolveMentionTheme, onPressMention, }: NativeProseViewerProps): import("react/jsx-runtime").JSX.Element;
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({ contentJSON, contentJSONRevision, schema, theme, style, allowBase64Images = false, mentionPrefix, resolveMentionTheme, onPressMention, }) {
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)(() => serializeDocumentInput(contentJSON, documentSchema), [contentJSON, contentJSONRevision, documentSchema]);
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 = getNativeProseViewerModule().renderDocumentJson(configJson, serializedContentJson);
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
- return applyResolvedMentionRendering(nextRenderJson, mentionPayloadsByDocPos);
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
- }, [contentJSONRevision, renderJson, themeJson]);
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';
@@ -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 mentionTapRecognizer: UITapGestureRecognizer = {
18
+ private lazy var interactiveTapRecognizer: UITapGestureRecognizer = {
16
19
  let recognizer = UITapGestureRecognizer(
17
20
  target: self,
18
- action: #selector(handleMentionTap(_:))
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(mentionTapRecognizer)
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 handleMentionTap(_ recognizer: UITapGestureRecognizer) {
105
- guard recognizer.state == .ended,
106
- let mention = mentionHit(at: recognizer.location(in: textView))
107
- else {
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 mentionHit(at location: CGPoint) -> (docPos: Int, label: String)? {
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
  }
@@ -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 markObject = mark as? [String: Any],
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.2",
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",