@apollohg/react-native-prose-editor 0.4.1 → 0.4.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/README.md CHANGED
@@ -49,7 +49,6 @@ The minimum tested Expo version is SDK 54.
49
49
  Required peer dependencies:
50
50
 
51
51
  - `expo`
52
- - `expo-modules-core`
53
52
  - `react`
54
53
  - `react-native`
55
54
  - `@expo/vector-icons`
@@ -57,7 +56,7 @@ Required peer dependencies:
57
56
  Install the package:
58
57
 
59
58
  ```sh
60
- npm install @apollohg/react-native-prose-editor
59
+ npm install @apollohg/react-native-prose-editor@0.4.3
61
60
  ```
62
61
 
63
62
  For local package development in this repo:
@@ -111,6 +110,8 @@ For setup and customization details, start with the [Documentation Index](./docs
111
110
 
112
111
  For realtime collaboration, including the correct `useYjsCollaboration()` wiring, encoded-state persistence, remote cursors, and automatic reconnect behavior, see the [Collaboration Guide](./docs/modules/collaboration.md).
113
112
 
113
+ For whole-document JSON loads, `initialJSON`, controlled `valueJSON`, and `setContentJson()` will normalize an empty root document like `{ type: 'doc', content: [] }` to the active schema's empty text block so block-constrained schemas still load a valid empty document.
114
+
114
115
  ## Development
115
116
 
116
117
  Common commands:
@@ -1,4 +1,4 @@
1
- import type { SchemaDefinition } from './schemas';
1
+ import { type SchemaDefinition } from './schemas';
2
2
  export interface NativeEditorModule {
3
3
  editorCreate(configJson: string): number;
4
4
  editorDestroy(editorId: number): void;
@@ -150,6 +150,7 @@ export declare function parseCollaborationResultJson(json: string): Collaboratio
150
150
  export declare function _resetNativeModuleCache(): void;
151
151
  export declare class NativeEditorBridge {
152
152
  private _editorId;
153
+ private _schema?;
153
154
  private _destroyed;
154
155
  private _lastSelection;
155
156
  private _documentVersion;
@@ -8,6 +8,7 @@ exports.decodeCollaborationStateBase64 = decodeCollaborationStateBase64;
8
8
  exports.parseCollaborationResultJson = parseCollaborationResultJson;
9
9
  exports._resetNativeModuleCache = _resetNativeModuleCache;
10
10
  const expo_modules_core_1 = require("expo-modules-core");
11
+ const schemas_1 = require("./schemas");
11
12
  const ERR_DESTROYED = 'NativeEditorBridge: editor has been destroyed';
12
13
  const ERR_NATIVE_RESPONSE = 'NativeEditorBridge: invalid JSON response from native module';
13
14
  const ERR_INVALID_ENCODED_STATE = 'NativeEditorBridge: invalid encoded collaboration state';
@@ -233,6 +234,16 @@ function encodeCollaborationStateBase64(encodedState) {
233
234
  function decodeCollaborationStateBase64(base64) {
234
235
  return base64ToBytes(base64);
235
236
  }
237
+ function normalizeDocumentJsonString(jsonString, schema) {
238
+ try {
239
+ const parsed = JSON.parse(jsonString);
240
+ const normalized = (0, schemas_1.normalizeDocumentJson)(parsed, schema);
241
+ return normalized === parsed ? jsonString : JSON.stringify(normalized);
242
+ }
243
+ catch {
244
+ return jsonString;
245
+ }
246
+ }
236
247
  function parseCollaborationResultJson(json) {
237
248
  if (!json || json === '') {
238
249
  return {
@@ -275,7 +286,7 @@ function _resetNativeModuleCache() {
275
286
  _nativeModule = null;
276
287
  }
277
288
  class NativeEditorBridge {
278
- constructor(editorId) {
289
+ constructor(editorId, schema) {
279
290
  this._destroyed = false;
280
291
  this._lastSelection = { type: 'text', anchor: 0, head: 0 };
281
292
  this._documentVersion = 0;
@@ -283,10 +294,12 @@ class NativeEditorBridge {
283
294
  this._cachedJsonString = null;
284
295
  this._renderBlocksCache = null;
285
296
  this._editorId = editorId;
297
+ this._schema = schema;
286
298
  }
287
299
  /** Create a new editor instance backed by the Rust engine. */
288
300
  static create(config) {
289
301
  const configObj = {};
302
+ let parsedSchema;
290
303
  if (config?.maxLength != null)
291
304
  configObj.maxLength = config.maxLength;
292
305
  if (config?.allowBase64Images != null) {
@@ -294,14 +307,15 @@ class NativeEditorBridge {
294
307
  }
295
308
  if (config?.schemaJson != null) {
296
309
  try {
297
- configObj.schema = JSON.parse(config.schemaJson);
310
+ parsedSchema = JSON.parse(config.schemaJson);
311
+ configObj.schema = parsedSchema;
298
312
  }
299
313
  catch {
300
314
  // Fall back to the default schema when the provided JSON is invalid.
301
315
  }
302
316
  }
303
317
  const id = getNativeModule().editorCreate(JSON.stringify(configObj));
304
- return new NativeEditorBridge(id);
318
+ return new NativeEditorBridge(id, parsedSchema);
305
319
  }
306
320
  /** The underlying native editor ID. */
307
321
  get editorId() {
@@ -339,14 +353,15 @@ class NativeEditorBridge {
339
353
  }
340
354
  /** Set content from ProseMirror JSON. Returns render elements. */
341
355
  setJson(doc) {
342
- return this.setJsonString(JSON.stringify(doc));
356
+ return this.setJsonString(JSON.stringify((0, schemas_1.normalizeDocumentJson)(doc, this._schema)));
343
357
  }
344
358
  /** Set content from a serialized ProseMirror JSON string. Returns render elements. */
345
359
  setJsonString(jsonString) {
346
360
  this.assertNotDestroyed();
347
361
  this.invalidateContentCaches();
348
362
  this._renderBlocksCache = null;
349
- const json = getNativeModule().editorSetJson(this._editorId, jsonString);
363
+ const normalizedJsonString = normalizeDocumentJsonString(jsonString, this._schema);
364
+ const json = getNativeModule().editorSetJson(this._editorId, normalizedJsonString);
350
365
  return parseRenderElements(json);
351
366
  }
352
367
  /** Get content as raw ProseMirror JSON string. */
@@ -520,12 +535,13 @@ class NativeEditorBridge {
520
535
  }
521
536
  /** Replace entire document with JSON via transaction (preserves undo history). */
522
537
  replaceJson(doc) {
523
- return this.replaceJsonString(JSON.stringify(doc));
538
+ return this.replaceJsonString(JSON.stringify((0, schemas_1.normalizeDocumentJson)(doc, this._schema)));
524
539
  }
525
540
  /** Replace entire document with a serialized JSON transaction. */
526
541
  replaceJsonString(jsonString) {
527
542
  this.assertNotDestroyed();
528
- const json = getNativeModule().editorReplaceJson(this._editorId, jsonString);
543
+ const normalizedJsonString = normalizeDocumentJsonString(jsonString, this._schema);
544
+ const json = getNativeModule().editorReplaceJson(this._editorId, normalizedJsonString);
529
545
  return this.parseAndNoteUpdate(json);
530
546
  }
531
547
  /** Undo the last operation. Returns update or null if nothing to undo. */
@@ -4,8 +4,7 @@ import { type ActiveState, type DocumentJSON, type HistoryState, type Selection
4
4
  import { type EditorToolbarHeadingLevel, type EditorToolbarItem } from './EditorToolbar';
5
5
  import { type EditorTheme } from './EditorTheme';
6
6
  import { type EditorAddons } from './addons';
7
- import { type SchemaDefinition } from './schemas';
8
- import { type ImageNodeAttributes } from './schemas';
7
+ import { type ImageNodeAttributes, type SchemaDefinition } from './schemas';
9
8
  export type NativeRichTextEditorHeightBehavior = 'fixed' | 'autoGrow';
10
9
  export type NativeRichTextEditorToolbarPlacement = 'keyboard' | 'inline';
11
10
  export interface RemoteSelectionDecoration {
@@ -10,7 +10,6 @@ const EditorToolbar_1 = require("./EditorToolbar");
10
10
  const EditorTheme_1 = require("./EditorTheme");
11
11
  const addons_1 = require("./addons");
12
12
  const schemas_1 = require("./schemas");
13
- const schemas_2 = require("./schemas");
14
13
  const NativeEditorView = (0, expo_modules_core_1.requireNativeViewManager)('NativeEditor');
15
14
  const DEV_NATIVE_VIEW_KEY = __DEV__
16
15
  ? `native-editor-dev:${Math.random().toString(36).slice(2)}`
@@ -35,7 +34,7 @@ function mapToolbarChildForNative(item, activeState, editable, onRequestLink, on
35
34
  label: item.label,
36
35
  icon: item.icon,
37
36
  isActive: false,
38
- isDisabled: !editable || !onRequestImage || !activeState.insertableNodes.includes(schemas_2.IMAGE_NODE_NAME),
37
+ isDisabled: !editable || !onRequestImage || !activeState.insertableNodes.includes(schemas_1.IMAGE_NODE_NAME),
39
38
  };
40
39
  }
41
40
  return item;
@@ -188,9 +187,11 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
188
187
  : undefined;
189
188
  const mentionSuggestionsByKeyRef = (0, react_1.useRef)(new Map());
190
189
  mentionSuggestionsByKeyRef.current = new Map((addons?.mentions?.suggestions ?? []).map((suggestion) => [suggestion.key, suggestion]));
191
- const serializedSchemaJson = useSerializedValue(addons?.mentions != null ? (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema) : schema, (nextSchema) => stringifyCachedJson(nextSchema));
192
- const serializedInitialJson = useSerializedValue(initialJSON, stringifyCachedJson);
193
- const serializedValueJson = useSerializedValue(valueJSON, stringifyCachedJson, valueJSONRevision);
190
+ const bridgeSchema = addons?.mentions != null ? (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema) : schema;
191
+ const documentSchema = bridgeSchema ?? schemas_1.tiptapSchema;
192
+ const serializedSchemaJson = useSerializedValue(bridgeSchema, (nextSchema) => stringifyCachedJson(nextSchema));
193
+ const serializedInitialJson = useSerializedValue(initialJSON, (doc) => stringifyCachedJson((0, schemas_1.normalizeDocumentJson)(doc, documentSchema)));
194
+ const serializedValueJson = useSerializedValue(valueJSON, (doc) => stringifyCachedJson((0, schemas_1.normalizeDocumentJson)(doc, documentSchema)), valueJSONRevision);
194
195
  const themeJson = useSerializedValue(theme, EditorTheme_1.serializeEditorTheme);
195
196
  const addonsJson = useSerializedValue(addons, addons_1.serializeEditorAddons);
196
197
  const remoteSelectionsJson = useSerializedValue(remoteSelections, (selections) => serializeRemoteSelections(selections));
@@ -493,7 +494,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
493
494
  if (selection) {
494
495
  restoreSelection(selection);
495
496
  }
496
- return (bridgeRef.current?.insertContentJson((0, schemas_2.buildImageFragmentJson)({
497
+ return (bridgeRef.current?.insertContentJson((0, schemas_1.buildImageFragmentJson)({
497
498
  src: trimmedSrc,
498
499
  ...(attrs ?? {}),
499
500
  })) ?? null);
@@ -668,7 +669,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
668
669
  return null;
669
670
  const isLinkActive = activeState.marks.link === true;
670
671
  const allowsLink = activeState.allowedMarks.includes('link');
671
- const canInsertImage = activeState.insertableNodes.includes(schemas_2.IMAGE_NODE_NAME);
672
+ const canInsertImage = activeState.insertableNodes.includes(schemas_1.IMAGE_NODE_NAME);
672
673
  const canRequestLink = typeof onRequestLink === 'function';
673
674
  const canRequestImage = typeof onRequestImage === 'function';
674
675
  const cachedToolbarItems = toolbarItemsSerializationCacheRef.current;
package/dist/schemas.d.ts CHANGED
@@ -32,4 +32,6 @@ export declare function imageNodeSpec(name?: string): NodeSpec;
32
32
  export declare function withImagesSchema(schema: SchemaDefinition): SchemaDefinition;
33
33
  export declare function buildImageFragmentJson(attrs: ImageNodeAttributes): DocumentJSON;
34
34
  export declare const tiptapSchema: SchemaDefinition;
35
+ export declare function defaultEmptyDocument(schema?: SchemaDefinition): DocumentJSON;
36
+ export declare function normalizeDocumentJson(doc: DocumentJSON, schema?: SchemaDefinition): DocumentJSON;
35
37
  export declare const prosemirrorSchema: SchemaDefinition;
package/dist/schemas.js CHANGED
@@ -4,6 +4,8 @@ exports.prosemirrorSchema = exports.tiptapSchema = exports.IMAGE_NODE_NAME = voi
4
4
  exports.imageNodeSpec = imageNodeSpec;
5
5
  exports.withImagesSchema = withImagesSchema;
6
6
  exports.buildImageFragmentJson = buildImageFragmentJson;
7
+ exports.defaultEmptyDocument = defaultEmptyDocument;
8
+ exports.normalizeDocumentJson = normalizeDocumentJson;
7
9
  exports.IMAGE_NODE_NAME = 'image';
8
10
  const HEADING_LEVELS = [1, 2, 3, 4, 5, 6];
9
11
  function imageNodeSpec(name = exports.IMAGE_NODE_NAME) {
@@ -129,6 +131,67 @@ exports.tiptapSchema = {
129
131
  ],
130
132
  marks: MARKS,
131
133
  };
134
+ function acceptingGroupsForChildCount(content, existingChildCount) {
135
+ const tokens = content
136
+ .trim()
137
+ .split(/\s+/)
138
+ .filter(Boolean)
139
+ .map((token) => {
140
+ const quantifier = token[token.length - 1];
141
+ if (quantifier === '+' || quantifier === '*' || quantifier === '?') {
142
+ return {
143
+ group: token.slice(0, -1),
144
+ min: quantifier === '+' ? 1 : 0,
145
+ max: quantifier === '?' ? 1 : null,
146
+ };
147
+ }
148
+ return {
149
+ group: token,
150
+ min: 1,
151
+ max: 1,
152
+ };
153
+ });
154
+ let remaining = existingChildCount;
155
+ const acceptingGroups = [];
156
+ for (const token of tokens) {
157
+ if (remaining >= token.min) {
158
+ const consumed = token.max == null ? remaining : Math.min(remaining, token.max);
159
+ remaining = Math.max(0, remaining - consumed);
160
+ const atMax = token.max != null && consumed >= token.max;
161
+ if (!atMax) {
162
+ acceptingGroups.push(token.group);
163
+ }
164
+ continue;
165
+ }
166
+ acceptingGroups.push(token.group);
167
+ break;
168
+ }
169
+ return acceptingGroups;
170
+ }
171
+ function defaultEmptyDocument(schema = exports.tiptapSchema) {
172
+ const docNode = schema.nodes.find((node) => node.role === 'doc' || node.name === 'doc');
173
+ const acceptingGroups = docNode == null ? [] : acceptingGroupsForChildCount(docNode.content ?? '', 0);
174
+ const matchingTextBlocks = schema.nodes.filter((node) => node.role === 'textBlock' &&
175
+ acceptingGroups.some((group) => node.name === group || node.group === group));
176
+ const preferredTextBlock = matchingTextBlocks.find((node) => node.htmlTag === 'p' || node.name === 'paragraph') ??
177
+ matchingTextBlocks[0] ??
178
+ schema.nodes.find((node) => node.htmlTag === 'p' || node.name === 'paragraph') ??
179
+ schema.nodes.find((node) => node.role === 'textBlock');
180
+ return {
181
+ type: 'doc',
182
+ content: [{ type: preferredTextBlock?.name ?? 'paragraph' }],
183
+ };
184
+ }
185
+ function normalizeDocumentJson(doc, schema = exports.tiptapSchema) {
186
+ const root = doc;
187
+ if (root?.type !== 'doc') {
188
+ return doc;
189
+ }
190
+ if (Array.isArray(root.content) && root.content.length > 0) {
191
+ return doc;
192
+ }
193
+ return defaultEmptyDocument(schema);
194
+ }
132
195
  exports.prosemirrorSchema = {
133
196
  nodes: [
134
197
  {
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>libeditor_core.a</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64_x86_64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>libeditor_core.a</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
+ <string>x86_64</string>
17
18
  </array>
18
19
  <key>SupportedPlatform</key>
19
20
  <string>ios</string>
21
+ <key>SupportedPlatformVariant</key>
22
+ <string>simulator</string>
20
23
  </dict>
21
24
  <dict>
22
25
  <key>BinaryPath</key>
23
26
  <string>libeditor_core.a</string>
24
27
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64_x86_64-simulator</string>
28
+ <string>ios-arm64</string>
26
29
  <key>LibraryPath</key>
27
30
  <string>libeditor_core.a</string>
28
31
  <key>SupportedArchitectures</key>
29
32
  <array>
30
33
  <string>arm64</string>
31
- <string>x86_64</string>
32
34
  </array>
33
35
  <key>SupportedPlatform</key>
34
36
  <string>ios</string>
35
- <key>SupportedPlatformVariant</key>
36
- <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -1153,6 +1153,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1153
1153
  }
1154
1154
  }
1155
1155
 
1156
+ override func becomeFirstResponder() -> Bool {
1157
+ let didBecomeFirstResponder = super.becomeFirstResponder()
1158
+ if didBecomeFirstResponder {
1159
+ _ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
1160
+ refreshTypingAttributesForSelection()
1161
+ }
1162
+ return didBecomeFirstResponder
1163
+ }
1164
+
1156
1165
  private func isRenderedContentEmpty() -> Bool {
1157
1166
  let renderedText = textStorage.string
1158
1167
  guard !renderedText.isEmpty else { return true }
@@ -1169,6 +1178,23 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1169
1178
  return true
1170
1179
  }
1171
1180
 
1181
+ @discardableResult
1182
+ private func normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() -> Bool {
1183
+ guard textStorage.length == 1 else { return false }
1184
+ guard textStorage.string.unicodeScalars.elementsEqual([Self.emptyBlockPlaceholderScalar]) else {
1185
+ return false
1186
+ }
1187
+
1188
+ let currentRange = selectedRange
1189
+ guard currentRange.location != NSNotFound, currentRange.length == 0 else { return false }
1190
+ guard currentRange.location == textStorage.length else { return false }
1191
+
1192
+ let adjustedRange = NSRange(location: 0, length: 0)
1193
+ guard currentRange != adjustedRange else { return false }
1194
+ selectedRange = adjustedRange
1195
+ return true
1196
+ }
1197
+
1172
1198
  private func refreshPlaceholderVisibility() {
1173
1199
  placeholderLabel.isHidden = placeholder.isEmpty || !isRenderedContentEmpty()
1174
1200
  }
@@ -1947,6 +1973,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1947
1973
  func textViewDidChangeSelection(_ textView: UITextView) {
1948
1974
  guard textView === self else { return }
1949
1975
  guard !isApplyingRustState else { return }
1976
+ if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
1977
+ return
1978
+ }
1950
1979
  refreshNativeSelectionChromeVisibility()
1951
1980
  onSelectionOrContentMayChange?()
1952
1981
  scheduleSelectionSync()
@@ -2099,7 +2128,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2099
2128
  }
2100
2129
 
2101
2130
  func refreshSelectionVisualState() {
2131
+ _ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
2102
2132
  refreshNativeSelectionChromeVisibility()
2133
+ refreshTypingAttributesForSelection()
2103
2134
  onSelectionOrContentMayChange?()
2104
2135
  }
2105
2136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.4.1",
3
+ "version": "0.4.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",