@apollohg/react-native-prose-editor 0.5.1 → 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.
Files changed (31) hide show
  1. package/README.md +18 -15
  2. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +4 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +33 -1
  4. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +23 -0
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +39 -6
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +15 -1
  7. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +44 -7
  8. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +24 -4
  9. package/dist/NativeEditorBridge.d.ts +8 -0
  10. package/dist/NativeEditorBridge.js +16 -0
  11. package/dist/NativeProseViewer.d.ts +25 -5
  12. package/dist/NativeProseViewer.js +212 -13
  13. package/dist/NativeRichTextEditor.d.ts +2 -0
  14. package/dist/NativeRichTextEditor.js +417 -31
  15. package/dist/addons.d.ts +20 -0
  16. package/dist/addons.js +4 -0
  17. package/dist/index.d.ts +2 -2
  18. package/ios/EditorAddons.swift +2 -0
  19. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  20. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  21. package/ios/EditorLayoutManager.swift +10 -1
  22. package/ios/EditorTheme.swift +25 -0
  23. package/ios/NativeEditorExpoView.swift +56 -6
  24. package/ios/NativeEditorModule.swift +14 -1
  25. package/ios/NativeProseViewerExpoView.swift +62 -11
  26. package/ios/RenderBridge.swift +40 -16
  27. package/ios/RichTextEditorView.swift +4 -0
  28. package/package.json +1 -1
  29. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  30. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  31. package/rust/android/x86_64/libeditor_core.so +0 -0
@@ -62,6 +62,291 @@ function isPromiseLike(value) {
62
62
  'then' in value &&
63
63
  typeof value.then === 'function');
64
64
  }
65
+ function isRecord(value) {
66
+ return value != null && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+ const AUTO_LINK_URL_REGEX = /(?:https?:\/\/|www\.)\S+/giu;
69
+ const AUTO_LINK_INLINE_PLACEHOLDER = '\uFFFC';
70
+ const AUTO_LINK_LEADING_BOUNDARY_CHARS = new Set(['(', '[', '{', '<', '"', "'"]);
71
+ const AUTO_LINK_TRAILING_DELIMITER_CHARS = new Set([
72
+ '.',
73
+ ',',
74
+ '!',
75
+ '?',
76
+ ';',
77
+ ':',
78
+ ')',
79
+ ']',
80
+ '}',
81
+ '>',
82
+ '"',
83
+ "'",
84
+ ]);
85
+ const AUTO_LINK_ALWAYS_TRIM_CHARS = new Set(['.', ',', '!', '?', ';', ':']);
86
+ const AUTO_LINK_MATCHED_CLOSERS = {
87
+ ')': '(',
88
+ ']': '[',
89
+ '}': '{',
90
+ };
91
+ function unicodeScalars(text) {
92
+ return Array.from(text);
93
+ }
94
+ function unicodeScalarCount(text) {
95
+ return unicodeScalars(text).length;
96
+ }
97
+ function hasDocumentLinkMark(marks) {
98
+ if (!Array.isArray(marks)) {
99
+ return false;
100
+ }
101
+ return marks.some((mark) => isRecord(mark) && typeof mark.type === 'string' && mark.type === 'link');
102
+ }
103
+ function isInlineDocumentNode(node) {
104
+ if (!isRecord(node)) {
105
+ return false;
106
+ }
107
+ if (node.type === 'text') {
108
+ return true;
109
+ }
110
+ return !Array.isArray(node.content);
111
+ }
112
+ function isInlineTextBlockNode(node) {
113
+ const content = Array.isArray(node.content) ? node.content : [];
114
+ return content.length > 0 && content.every((child) => isInlineDocumentNode(child));
115
+ }
116
+ function countOccurrences(text, target) {
117
+ let count = 0;
118
+ for (const char of text) {
119
+ if (char === target) {
120
+ count += 1;
121
+ }
122
+ }
123
+ return count;
124
+ }
125
+ function trimAutoLinkTrailingPunctuation(value) {
126
+ let result = value;
127
+ while (result.length > 0) {
128
+ const chars = unicodeScalars(result);
129
+ const lastChar = chars[chars.length - 1];
130
+ if (!lastChar) {
131
+ break;
132
+ }
133
+ if (AUTO_LINK_ALWAYS_TRIM_CHARS.has(lastChar)) {
134
+ chars.pop();
135
+ result = chars.join('');
136
+ continue;
137
+ }
138
+ const matchingOpener = AUTO_LINK_MATCHED_CLOSERS[lastChar];
139
+ if (matchingOpener &&
140
+ countOccurrences(result, lastChar) > countOccurrences(result, matchingOpener)) {
141
+ chars.pop();
142
+ result = chars.join('');
143
+ continue;
144
+ }
145
+ if ((lastChar === '"' || lastChar === "'") && countOccurrences(result, lastChar) % 2 !== 0) {
146
+ chars.pop();
147
+ result = chars.join('');
148
+ continue;
149
+ }
150
+ break;
151
+ }
152
+ return result;
153
+ }
154
+ function normalizeAutoDetectedHref(value) {
155
+ const trimmed = trimAutoLinkTrailingPunctuation(value);
156
+ if (!trimmed) {
157
+ return null;
158
+ }
159
+ const normalized = /^www\./iu.test(trimmed) ? `https://${trimmed}` : trimmed;
160
+ try {
161
+ new URL(normalized);
162
+ return normalized;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ function isAutoLinkBoundaryChar(char) {
169
+ if (!char) {
170
+ return true;
171
+ }
172
+ return /\s/u.test(char) || char === AUTO_LINK_INLINE_PLACEHOLDER || AUTO_LINK_LEADING_BOUNDARY_CHARS.has(char);
173
+ }
174
+ function isAutoLinkTrailingDelimiterChar(char) {
175
+ if (!char) {
176
+ return true;
177
+ }
178
+ return (/\s/u.test(char) ||
179
+ char === AUTO_LINK_INLINE_PLACEHOLDER ||
180
+ AUTO_LINK_TRAILING_DELIMITER_CHARS.has(char));
181
+ }
182
+ function codeUnitBoundariesForScalars(chars) {
183
+ const boundaries = [0];
184
+ let offset = 0;
185
+ for (const char of chars) {
186
+ offset += char.length;
187
+ boundaries.push(offset);
188
+ }
189
+ return boundaries;
190
+ }
191
+ function codeUnitOffsetToScalarIndex(boundaries, offset) {
192
+ let scalarIndex = 0;
193
+ while (scalarIndex + 1 < boundaries.length && boundaries[scalarIndex + 1] <= offset) {
194
+ scalarIndex += 1;
195
+ }
196
+ return scalarIndex;
197
+ }
198
+ function buildAutoLinkInlineBlock(node, pos) {
199
+ const chars = [];
200
+ const docPositions = [];
201
+ const linked = [];
202
+ const content = Array.isArray(node.content) ? node.content : [];
203
+ let nextPos = pos + 1;
204
+ for (const child of content) {
205
+ if (!isRecord(child)) {
206
+ continue;
207
+ }
208
+ if (child.type === 'text') {
209
+ const childText = typeof child.text === 'string' ? child.text : '';
210
+ const childChars = unicodeScalars(childText);
211
+ const hasLink = hasDocumentLinkMark(child.marks);
212
+ for (const char of childChars) {
213
+ chars.push(char);
214
+ docPositions.push(nextPos);
215
+ linked.push(hasLink);
216
+ nextPos += 1;
217
+ }
218
+ continue;
219
+ }
220
+ chars.push(child.type === 'hardBreak' ? '\n' : AUTO_LINK_INLINE_PLACEHOLDER);
221
+ docPositions.push(nextPos);
222
+ linked.push(false);
223
+ nextPos += 1;
224
+ }
225
+ return {
226
+ chars,
227
+ docPositions,
228
+ linked,
229
+ contentStart: pos + 1,
230
+ contentEnd: nextPos,
231
+ nextPos: nextPos + 1,
232
+ };
233
+ }
234
+ function findAutoLinkCandidateInInlineBlock(block, cursorDocPos) {
235
+ if (cursorDocPos < block.contentStart || cursorDocPos > block.contentEnd) {
236
+ return null;
237
+ }
238
+ let localIndex = 0;
239
+ while (localIndex < block.docPositions.length && block.docPositions[localIndex] < cursorDocPos) {
240
+ localIndex += 1;
241
+ }
242
+ if (localIndex === 0) {
243
+ return null;
244
+ }
245
+ if (cursorDocPos < block.contentEnd && !isAutoLinkTrailingDelimiterChar(block.chars[localIndex - 1])) {
246
+ return null;
247
+ }
248
+ const prefixChars = block.chars.slice(0, localIndex);
249
+ const prefixText = prefixChars.join('');
250
+ if (!prefixText) {
251
+ return null;
252
+ }
253
+ const boundaries = codeUnitBoundariesForScalars(prefixChars);
254
+ AUTO_LINK_URL_REGEX.lastIndex = 0;
255
+ let lastMatch = null;
256
+ for (const match of prefixText.matchAll(AUTO_LINK_URL_REGEX)) {
257
+ lastMatch = match;
258
+ }
259
+ if (!lastMatch || typeof lastMatch.index !== 'number') {
260
+ return null;
261
+ }
262
+ const rawStartScalar = codeUnitOffsetToScalarIndex(boundaries, lastMatch.index);
263
+ const normalizedHref = normalizeAutoDetectedHref(lastMatch[0]);
264
+ const trimmedText = trimAutoLinkTrailingPunctuation(lastMatch[0]);
265
+ if (!normalizedHref || !trimmedText) {
266
+ return null;
267
+ }
268
+ const candidateEndScalar = rawStartScalar + unicodeScalarCount(trimmedText);
269
+ if (candidateEndScalar > prefixChars.length) {
270
+ return null;
271
+ }
272
+ if (!isAutoLinkBoundaryChar(prefixChars[rawStartScalar - 1])) {
273
+ return null;
274
+ }
275
+ for (let index = candidateEndScalar; index < localIndex; index += 1) {
276
+ if (!isAutoLinkTrailingDelimiterChar(prefixChars[index])) {
277
+ return null;
278
+ }
279
+ }
280
+ for (let index = rawStartScalar; index < candidateEndScalar; index += 1) {
281
+ if (block.linked[index]) {
282
+ return null;
283
+ }
284
+ }
285
+ const docFrom = block.docPositions[rawStartScalar];
286
+ const docTo = candidateEndScalar < block.docPositions.length
287
+ ? block.docPositions[candidateEndScalar]
288
+ : block.contentEnd;
289
+ if (!(docTo > docFrom)) {
290
+ return null;
291
+ }
292
+ return {
293
+ docFrom,
294
+ docTo,
295
+ href: normalizedHref,
296
+ };
297
+ }
298
+ function findAutoLinkCandidateInDocument(document, cursorDocPos) {
299
+ const visit = (node, pos, isRoot = false) => {
300
+ if (!isRecord(node)) {
301
+ return { candidate: null, nextPos: pos };
302
+ }
303
+ const nodeType = typeof node.type === 'string' ? node.type : '';
304
+ const content = Array.isArray(node.content) ? node.content : [];
305
+ if (nodeType === 'text') {
306
+ const text = typeof node.text === 'string' ? node.text : '';
307
+ return { candidate: null, nextPos: pos + unicodeScalarCount(text) };
308
+ }
309
+ if (isRoot && nodeType === 'doc') {
310
+ let nextPos = pos;
311
+ for (const child of content) {
312
+ const result = visit(child, nextPos);
313
+ if (result.candidate) {
314
+ return result;
315
+ }
316
+ nextPos = result.nextPos;
317
+ }
318
+ return { candidate: null, nextPos };
319
+ }
320
+ if (isInlineTextBlockNode(node)) {
321
+ const block = buildAutoLinkInlineBlock(node, pos);
322
+ return {
323
+ candidate: findAutoLinkCandidateInInlineBlock(block, cursorDocPos),
324
+ nextPos: block.nextPos,
325
+ };
326
+ }
327
+ if (content.length === 0) {
328
+ return { candidate: null, nextPos: pos + 1 };
329
+ }
330
+ let nextPos = pos + 1;
331
+ for (const child of content) {
332
+ const result = visit(child, nextPos);
333
+ if (result.candidate) {
334
+ return result;
335
+ }
336
+ nextPos = result.nextPos;
337
+ }
338
+ return { candidate: null, nextPos: nextPos + 1 };
339
+ };
340
+ return visit(document, 0, true).candidate;
341
+ }
342
+ function didContentChange(previousDocumentVersion, update) {
343
+ if (!update) {
344
+ return false;
345
+ }
346
+ return (previousDocumentVersion == null ||
347
+ typeof update.documentVersion !== 'number' ||
348
+ update.documentVersion !== previousDocumentVersion);
349
+ }
65
350
  function computeRenderedTextLength(elements) {
66
351
  let len = 0;
67
352
  let blockCount = 0;
@@ -134,7 +419,7 @@ function useSerializedValue(value, serialize, revision) {
134
419
  };
135
420
  return serialized;
136
421
  }
137
- exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEditor({ initialContent, initialJSON, value, valueJSON, valueJSONRevision, schema, placeholder, editable = true, maxLength, autoFocus = false, heightBehavior = 'autoGrow', showToolbar = true, toolbarPlacement = 'keyboard', toolbarItems = EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS, onToolbarAction, onRequestLink, onRequestImage, onContentChange, onContentChangeJSON, onSelectionChange, onActiveStateChange, onHistoryStateChange, onFocus, onBlur, style, containerStyle, theme, addons, remoteSelections, allowBase64Images = false, allowImageResizing = true, }, ref) {
422
+ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEditor({ initialContent, initialJSON, value, valueJSON, valueJSONRevision, schema, placeholder, editable = true, maxLength, autoFocus = false, heightBehavior = 'autoGrow', showToolbar = true, toolbarPlacement = 'keyboard', toolbarItems = EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS, onToolbarAction, onRequestLink, onRequestImage, autoDetectLinks = false, onContentChange, onContentChangeJSON, onSelectionChange, onActiveStateChange, onHistoryStateChange, onFocus, onBlur, style, containerStyle, theme, addons, remoteSelections, allowBase64Images = false, allowImageResizing = true, }, ref) {
138
423
  const bridgeRef = (0, react_1.useRef)(null);
139
424
  const nativeViewRef = (0, react_1.useRef)(null);
140
425
  const [isReady, setIsReady] = (0, react_1.useState)(false);
@@ -241,6 +526,74 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
241
526
  onContentChangeJSONRef.current?.(bridgeRef.current.getJson());
242
527
  }
243
528
  }, []);
529
+ const applyUpdateToNativeView = (0, react_1.useCallback)((update, previousDocumentVersion, skipNativeApplyIfContentUnchanged = false) => {
530
+ const contentChanged = didContentChange(previousDocumentVersion, update);
531
+ if (!skipNativeApplyIfContentUnchanged || contentChanged) {
532
+ const updateJson = JSON.stringify(update);
533
+ if (react_native_1.Platform.OS === 'android') {
534
+ setPendingNativeUpdate((current) => ({
535
+ json: updateJson,
536
+ revision: current.revision + 1,
537
+ }));
538
+ }
539
+ else {
540
+ try {
541
+ const applyResult = nativeViewRef.current?.applyEditorUpdate(updateJson);
542
+ if (isPromiseLike(applyResult)) {
543
+ void applyResult.catch(() => {
544
+ // The native view may already be torn down during navigation.
545
+ });
546
+ }
547
+ }
548
+ catch {
549
+ // The native view may already be torn down during navigation.
550
+ }
551
+ }
552
+ }
553
+ return contentChanged;
554
+ }, []);
555
+ const maybeApplyAutoDetectedLink = (0, react_1.useCallback)((update, previousDocumentVersion) => {
556
+ if (!autoDetectLinks ||
557
+ !update ||
558
+ !didContentChange(previousDocumentVersion, update) ||
559
+ !bridgeRef.current ||
560
+ bridgeRef.current.isDestroyed ||
561
+ !update.activeState.allowedMarks.includes('link') ||
562
+ update.selection.type !== 'text' ||
563
+ update.selection.anchor == null ||
564
+ update.selection.head == null ||
565
+ update.selection.anchor !== update.selection.head) {
566
+ return update;
567
+ }
568
+ const cursorDocPos = update.selection.head;
569
+ const candidate = findAutoLinkCandidateInDocument(bridgeRef.current.getJson(), cursorDocPos);
570
+ if (!candidate) {
571
+ return update;
572
+ }
573
+ const scalarFrom = bridgeRef.current.docToScalar(candidate.docFrom);
574
+ const scalarTo = bridgeRef.current.docToScalar(candidate.docTo);
575
+ if (!(scalarTo > scalarFrom)) {
576
+ return update;
577
+ }
578
+ const autoLinkUpdate = bridgeRef.current.setMarkAtSelectionScalar(scalarFrom, scalarTo, 'link', { href: candidate.href });
579
+ if (!autoLinkUpdate) {
580
+ return update;
581
+ }
582
+ bridgeRef.current.setSelection(update.selection.anchor, update.selection.head);
583
+ const selectionState = bridgeRef.current.getSelectionState();
584
+ if (selectionState) {
585
+ autoLinkUpdate.selection = selectionState.selection;
586
+ autoLinkUpdate.activeState = selectionState.activeState;
587
+ autoLinkUpdate.historyState = selectionState.historyState;
588
+ if (typeof selectionState.documentVersion === 'number') {
589
+ autoLinkUpdate.documentVersion = selectionState.documentVersion;
590
+ }
591
+ }
592
+ else {
593
+ autoLinkUpdate.selection = update.selection;
594
+ }
595
+ return autoLinkUpdate;
596
+ }, [autoDetectLinks]);
244
597
  // Warn if both value and valueJSON are set
245
598
  if (__DEV__ && value != null && valueJSON != null) {
246
599
  console.warn('NativeRichTextEditor: value and valueJSON are mutually exclusive. ' +
@@ -249,9 +602,15 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
249
602
  const runAndApply = (0, react_1.useCallback)((mutate, options) => {
250
603
  const previousDocumentVersion = documentVersionRef.current;
251
604
  const preservedSelection = options?.preserveLiveTextSelection === true ? selectionRef.current : null;
252
- const update = mutate();
605
+ let update = mutate();
253
606
  if (!update)
254
607
  return null;
608
+ if (!options?.skipAutoDetectLinks) {
609
+ update = maybeApplyAutoDetectedLink(update, previousDocumentVersion);
610
+ if (!update) {
611
+ return null;
612
+ }
613
+ }
255
614
  if (preservedSelection?.type === 'text' &&
256
615
  typeof preservedSelection.anchor === 'number' &&
257
616
  typeof preservedSelection.head === 'number' &&
@@ -264,31 +623,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
264
623
  head: preservedSelection.head,
265
624
  };
266
625
  }
267
- const contentChanged = previousDocumentVersion == null ||
268
- typeof update.documentVersion !== 'number' ||
269
- update.documentVersion !== previousDocumentVersion;
270
- if (!options?.skipNativeApplyIfContentUnchanged || contentChanged) {
271
- const updateJson = JSON.stringify(update);
272
- if (react_native_1.Platform.OS === 'android') {
273
- setPendingNativeUpdate((current) => ({
274
- json: updateJson,
275
- revision: current.revision + 1,
276
- }));
277
- }
278
- else {
279
- try {
280
- const applyResult = nativeViewRef.current?.applyEditorUpdate(updateJson);
281
- if (isPromiseLike(applyResult)) {
282
- void applyResult.catch(() => {
283
- // The native view may already be torn down during navigation.
284
- });
285
- }
286
- }
287
- catch {
288
- // The native view may already be torn down during navigation.
289
- }
290
- }
291
- }
626
+ applyUpdateToNativeView(update, previousDocumentVersion, options?.skipNativeApplyIfContentUnchanged);
292
627
  syncStateFromUpdate(update);
293
628
  onActiveStateChangeRef.current?.(update.activeState);
294
629
  onHistoryStateChangeRef.current?.(update.historyState);
@@ -297,7 +632,12 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
297
632
  }
298
633
  onSelectionChangeRef.current?.(update.selection);
299
634
  return update;
300
- }, [emitContentCallbacksForUpdate, syncStateFromUpdate]);
635
+ }, [
636
+ applyUpdateToNativeView,
637
+ emitContentCallbacksForUpdate,
638
+ maybeApplyAutoDetectedLink,
639
+ syncStateFromUpdate,
640
+ ]);
301
641
  (0, react_1.useEffect)(() => {
302
642
  const bridgeConfig = maxLength != null || serializedSchemaJson || allowBase64Images
303
643
  ? {
@@ -349,6 +689,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
349
689
  runAndApply(() => bridgeRef.current.replaceHtml(value), {
350
690
  suppressContentCallbacks: true,
351
691
  preserveLiveTextSelection: true,
692
+ skipAutoDetectLinks: true,
352
693
  });
353
694
  }, [value, runAndApply]);
354
695
  (0, react_1.useEffect)(() => {
@@ -362,6 +703,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
362
703
  runAndApply(() => bridgeRef.current.replaceJsonString(serializedValueJson), {
363
704
  suppressContentCallbacks: true,
364
705
  preserveLiveTextSelection: true,
706
+ skipAutoDetectLinks: true,
365
707
  });
366
708
  }, [serializedValueJson, value, runAndApply]);
367
709
  const updateToolbarFrame = (0, react_1.useCallback)(() => {
@@ -399,9 +741,15 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
399
741
  return;
400
742
  try {
401
743
  const previousDocumentVersion = documentVersionRef.current;
402
- const update = bridgeRef.current.parseUpdateJson(event.nativeEvent.updateJson);
744
+ const nativeUpdate = bridgeRef.current.parseUpdateJson(event.nativeEvent.updateJson);
745
+ if (!nativeUpdate)
746
+ return;
747
+ const update = maybeApplyAutoDetectedLink(nativeUpdate, previousDocumentVersion);
403
748
  if (!update)
404
749
  return;
750
+ if (update !== nativeUpdate) {
751
+ applyUpdateToNativeView(update, previousDocumentVersion);
752
+ }
405
753
  syncStateFromUpdate(update);
406
754
  onActiveStateChangeRef.current?.(update.activeState);
407
755
  onHistoryStateChangeRef.current?.(update.historyState);
@@ -411,7 +759,12 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
411
759
  catch {
412
760
  // Invalid JSON from native — skip
413
761
  }
414
- }, [emitContentCallbacksForUpdate, syncStateFromUpdate]);
762
+ }, [
763
+ applyUpdateToNativeView,
764
+ emitContentCallbacksForUpdate,
765
+ maybeApplyAutoDetectedLink,
766
+ syncStateFromUpdate,
767
+ ]);
415
768
  const handleSelectionChange = (0, react_1.useCallback)((event) => {
416
769
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
417
770
  return;
@@ -563,6 +916,39 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
563
916
  });
564
917
  return;
565
918
  }
919
+ if (parsed.type === 'mentionsSelectRequest') {
920
+ const suggestion = mentionSuggestionsByKeyRef.current.get(parsed.suggestionKey);
921
+ if (!suggestion || !bridgeRef.current || bridgeRef.current.isDestroyed)
922
+ return;
923
+ const selectionEvent = {
924
+ trigger: parsed.trigger,
925
+ suggestion,
926
+ attrs: parsed.attrs,
927
+ range: parsed.range,
928
+ };
929
+ let resolvedAttrs;
930
+ try {
931
+ resolvedAttrs =
932
+ addonsRef.current?.mentions?.resolveSelectionAttrs?.(selectionEvent);
933
+ }
934
+ catch (error) {
935
+ if (__DEV__) {
936
+ console.error('NativeRichTextEditor: mentions.resolveSelectionAttrs threw', error);
937
+ }
938
+ }
939
+ const finalAttrs = isRecord(resolvedAttrs)
940
+ ? { ...parsed.attrs, ...resolvedAttrs }
941
+ : parsed.attrs;
942
+ const update = runAndApply(() => bridgeRef.current?.insertContentJsonAtSelectionScalar(parsed.range.anchor, parsed.range.head, (0, addons_1.buildMentionFragmentJson)(finalAttrs)) ?? null);
943
+ if (update) {
944
+ addonsRef.current?.mentions?.onSelect?.({
945
+ trigger: parsed.trigger,
946
+ suggestion,
947
+ attrs: finalAttrs,
948
+ });
949
+ }
950
+ return;
951
+ }
566
952
  if (parsed.type === 'mentionsSelect') {
567
953
  const suggestion = mentionSuggestionsByKeyRef.current.get(parsed.suggestionKey);
568
954
  if (!suggestion)
@@ -573,7 +959,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
573
959
  attrs: parsed.attrs,
574
960
  });
575
961
  }
576
- }, []);
962
+ }, [runAndApply]);
577
963
  (0, react_1.useImperativeHandle)(ref, () => ({
578
964
  focus() {
579
965
  nativeViewRef.current?.focus?.();
package/dist/addons.d.ts CHANGED
@@ -22,10 +22,20 @@ export interface MentionSelectEvent {
22
22
  suggestion: MentionSuggestion;
23
23
  attrs: Record<string, unknown>;
24
24
  }
25
+ export interface MentionSelectionAttrsEvent {
26
+ trigger: string;
27
+ suggestion: MentionSuggestion;
28
+ attrs: Record<string, unknown>;
29
+ range: {
30
+ anchor: number;
31
+ head: number;
32
+ };
33
+ }
25
34
  export interface MentionsAddonConfig {
26
35
  trigger?: string;
27
36
  suggestions?: readonly MentionSuggestion[];
28
37
  theme?: EditorMentionTheme;
38
+ resolveSelectionAttrs?: (event: MentionSelectionAttrsEvent) => Record<string, unknown> | null | undefined;
29
39
  onQueryChange?: (event: MentionQueryChangeEvent) => void;
30
40
  onSelect?: (event: MentionSelectEvent) => void;
31
41
  }
@@ -42,6 +52,7 @@ export interface SerializedMentionSuggestion {
42
52
  export interface SerializedMentionsAddonConfig {
43
53
  trigger: string;
44
54
  theme?: EditorMentionTheme;
55
+ resolveSelectionAttrs?: boolean;
45
56
  suggestions: SerializedMentionSuggestion[];
46
57
  }
47
58
  export interface SerializedEditorAddons {
@@ -56,6 +67,15 @@ export type EditorAddonEvent = {
56
67
  head: number;
57
68
  };
58
69
  isActive: boolean;
70
+ } | {
71
+ type: 'mentionsSelectRequest';
72
+ trigger: string;
73
+ suggestionKey: string;
74
+ attrs: Record<string, unknown>;
75
+ range: {
76
+ anchor: number;
77
+ head: number;
78
+ };
59
79
  } | {
60
80
  type: 'mentionsSelect';
61
81
  trigger: string;
package/dist/addons.js CHANGED
@@ -39,6 +39,7 @@ function normalizeEditorAddons(addons) {
39
39
  const label = suggestion.label?.trim() || `${trigger}${suggestion.title}`;
40
40
  const attrs = {
41
41
  label,
42
+ mentionSuggestionChar: trigger,
42
43
  ...(suggestion.attrs ?? {}),
43
44
  };
44
45
  return {
@@ -53,6 +54,9 @@ function normalizeEditorAddons(addons) {
53
54
  mentions: {
54
55
  trigger,
55
56
  theme: addons.mentions.theme,
57
+ ...(typeof addons.mentions.resolveSelectionAttrs === 'function'
58
+ ? { resolveSelectionAttrs: true }
59
+ : {}),
56
60
  suggestions,
57
61
  },
58
62
  };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
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
+ 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
- export { MENTION_NODE_NAME, mentionNodeSpec, withMentionsSchema, buildMentionFragmentJson, type EditorAddons, type MentionsAddonConfig, type MentionSuggestion, type MentionQueryChangeEvent, type MentionSelectEvent, type EditorAddonEvent, } from './addons';
5
+ export { MENTION_NODE_NAME, mentionNodeSpec, withMentionsSchema, buildMentionFragmentJson, type EditorAddons, type MentionsAddonConfig, type MentionSuggestion, type MentionQueryChangeEvent, type MentionSelectionAttrsEvent, type MentionSelectEvent, type EditorAddonEvent, } from './addons';
6
6
  export { tiptapSchema, prosemirrorSchema, IMAGE_NODE_NAME, imageNodeSpec, withImagesSchema, buildImageFragmentJson, type SchemaDefinition, type NodeSpec, type MarkSpec, type AttrSpec, type ImageNodeAttributes, } from './schemas';
7
7
  export { createYjsCollaborationController, useYjsCollaboration, type YjsCollaborationOptions, type YjsCollaborationState, type YjsTransportStatus, type LocalAwarenessState, type LocalAwarenessUser, type UseYjsCollaborationResult, type YjsCollaborationController, } from './YjsCollaboration';
8
8
  export type { Selection, ActiveState, HistoryState, EditorUpdate, DocumentJSON, CollaborationPeer, EncodedCollaborationStateInput, } from './NativeEditorBridge';
@@ -27,11 +27,13 @@ struct NativeMentionsAddonConfig {
27
27
  let trigger: String
28
28
  let suggestions: [NativeMentionSuggestion]
29
29
  let theme: EditorMentionTheme?
30
+ let resolveSelectionAttrs: Bool
30
31
 
31
32
  init?(dictionary: [String: Any]) {
32
33
  let trigger = (dictionary["trigger"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
33
34
  self.trigger = (trigger?.isEmpty == false ? trigger : "@") ?? "@"
34
35
  self.suggestions = ((dictionary["suggestions"] as? [[String: Any]]) ?? []).compactMap(NativeMentionSuggestion.init(dictionary:))
36
+ self.resolveSelectionAttrs = dictionary["resolveSelectionAttrs"] as? Bool ?? false
35
37
  if let theme = dictionary["theme"] as? [String: Any] {
36
38
  self.theme = EditorMentionTheme(dictionary: theme)
37
39
  } else {
@@ -140,7 +140,7 @@ final class EditorLayoutManager: NSLayoutManager {
140
140
  let lineFragmentRect = self.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil)
141
141
  let attrs = textStorage.attributes(at: paragraphStart, effectiveRange: nil)
142
142
 
143
- let baseFont = attrs[.font] as? UIFont ?? .systemFont(ofSize: 16)
143
+ let baseFont = Self.markerBaseFont(from: attrs)
144
144
  let textColor = attrs[RenderBridgeAttributes.listMarkerColor] as? UIColor
145
145
  ?? attrs[.foregroundColor] as? UIColor
146
146
  ?? .label
@@ -465,6 +465,15 @@ final class EditorLayoutManager: NSLayoutManager {
465
465
  return baseFont.withSize(baseFont.pointSize * markerScale)
466
466
  }
467
467
 
468
+ static func markerBaseFont(
469
+ from attrs: [NSAttributedString.Key: Any],
470
+ fallback fallbackFont: UIFont = .systemFont(ofSize: 16)
471
+ ) -> UIFont {
472
+ (attrs[RenderBridgeAttributes.listMarkerBaseFont] as? UIFont)
473
+ ?? (attrs[.font] as? UIFont)
474
+ ?? fallbackFont
475
+ }
476
+
468
477
  private static func unorderedBulletGlyphBounds(for font: UIFont) -> CGRect {
469
478
  let ctFont = font as CTFont
470
479
  let bullet = UniChar(0x2022)
@@ -139,6 +139,31 @@ struct EditorMentionTheme {
139
139
  var optionHighlightedBackgroundColor: UIColor?
140
140
  var optionHighlightedTextColor: UIColor?
141
141
 
142
+ func merged(with override: EditorMentionTheme?) -> EditorMentionTheme {
143
+ guard let override else { return self }
144
+ var merged = self
145
+ merged.textColor = override.textColor ?? merged.textColor
146
+ merged.backgroundColor = override.backgroundColor ?? merged.backgroundColor
147
+ merged.borderColor = override.borderColor ?? merged.borderColor
148
+ merged.borderWidth = override.borderWidth ?? merged.borderWidth
149
+ merged.borderRadius = override.borderRadius ?? merged.borderRadius
150
+ merged.fontWeight = override.fontWeight ?? merged.fontWeight
151
+ merged.popoverBackgroundColor =
152
+ override.popoverBackgroundColor ?? merged.popoverBackgroundColor
153
+ merged.popoverBorderColor = override.popoverBorderColor ?? merged.popoverBorderColor
154
+ merged.popoverBorderWidth = override.popoverBorderWidth ?? merged.popoverBorderWidth
155
+ merged.popoverBorderRadius = override.popoverBorderRadius ?? merged.popoverBorderRadius
156
+ merged.popoverShadowColor = override.popoverShadowColor ?? merged.popoverShadowColor
157
+ merged.optionTextColor = override.optionTextColor ?? merged.optionTextColor
158
+ merged.optionSecondaryTextColor =
159
+ override.optionSecondaryTextColor ?? merged.optionSecondaryTextColor
160
+ merged.optionHighlightedBackgroundColor =
161
+ override.optionHighlightedBackgroundColor ?? merged.optionHighlightedBackgroundColor
162
+ merged.optionHighlightedTextColor =
163
+ override.optionHighlightedTextColor ?? merged.optionHighlightedTextColor
164
+ return merged
165
+ }
166
+
142
167
  init(dictionary: [String: Any]) {
143
168
  textColor = EditorTheme.color(from: dictionary["textColor"])
144
169
  backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])