@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 CHANGED
@@ -56,7 +56,7 @@ Required peer dependencies:
56
56
  Install the package:
57
57
 
58
58
  ```sh
59
- npm install @apollohg/react-native-prose-editor@0.4.3
59
+ npm install @apollohg/react-native-prose-editor@0.5.0
60
60
  ```
61
61
 
62
62
  For local package development in this repo:
@@ -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; } });
@@ -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.4.3",
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",