@eclosion-tech/react-native-yjs-text 0.1.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/README.md +323 -0
  4. package/SPEC.md +346 -0
  5. package/android/build.gradle +26 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/tech/eclosion/yjstext/YjsTextModule.kt +77 -0
  8. package/android/src/main/java/tech/eclosion/yjstext/YjsTextSupport.kt +135 -0
  9. package/android/src/main/java/tech/eclosion/yjstext/YjsTextView.kt +424 -0
  10. package/build/YTextInput.d.ts +23 -0
  11. package/build/YTextInput.d.ts.map +1 -0
  12. package/build/YTextInput.js +178 -0
  13. package/build/YTextInput.js.map +1 -0
  14. package/build/YTextRenderer.d.ts +15 -0
  15. package/build/YTextRenderer.d.ts.map +1 -0
  16. package/build/YTextRenderer.js +85 -0
  17. package/build/YTextRenderer.js.map +1 -0
  18. package/build/bridge.d.ts +88 -0
  19. package/build/bridge.d.ts.map +1 -0
  20. package/build/bridge.js +231 -0
  21. package/build/bridge.js.map +1 -0
  22. package/build/index.d.ts +13 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +12 -0
  25. package/build/index.js.map +1 -0
  26. package/build/internal/NativeYTextInputView.d.ts +114 -0
  27. package/build/internal/NativeYTextInputView.d.ts.map +1 -0
  28. package/build/internal/NativeYTextInputView.js +27 -0
  29. package/build/internal/NativeYTextInputView.js.map +1 -0
  30. package/build/internal/editorRegistry.d.ts +23 -0
  31. package/build/internal/editorRegistry.d.ts.map +1 -0
  32. package/build/internal/editorRegistry.js +26 -0
  33. package/build/internal/editorRegistry.js.map +1 -0
  34. package/build/schema.d.ts +51 -0
  35. package/build/schema.d.ts.map +1 -0
  36. package/build/schema.js +134 -0
  37. package/build/schema.js.map +1 -0
  38. package/build/types.d.ts +182 -0
  39. package/build/types.d.ts.map +1 -0
  40. package/build/types.js +11 -0
  41. package/build/types.js.map +1 -0
  42. package/build/useYTextEditor.d.ts +21 -0
  43. package/build/useYTextEditor.d.ts.map +1 -0
  44. package/build/useYTextEditor.js +166 -0
  45. package/build/useYTextEditor.js.map +1 -0
  46. package/expo-module.config.json +9 -0
  47. package/ios/YjsText.podspec +30 -0
  48. package/ios/YjsTextModule.swift +75 -0
  49. package/ios/YjsTextSupport.swift +135 -0
  50. package/ios/YjsTextView.swift +464 -0
  51. package/package.json +124 -0
  52. package/src/YTextInput.tsx +263 -0
  53. package/src/YTextRenderer.tsx +96 -0
  54. package/src/bridge.ts +283 -0
  55. package/src/index.ts +21 -0
  56. package/src/internal/NativeYTextInputView.tsx +126 -0
  57. package/src/internal/editorRegistry.ts +50 -0
  58. package/src/schema.ts +157 -0
  59. package/src/types.ts +194 -0
  60. package/src/useYTextEditor.ts +171 -0
package/SPEC.md ADDED
@@ -0,0 +1,346 @@
1
+ # react-native-yjs-text
2
+
3
+ **Status:** Draft spec, pre-implementation
4
+
5
+ ## Overview
6
+
7
+ `react-native-yjs-text` is a React Native rich-text editor that uses `Y.Text` as its document model and renders natively on iOS and Android — no WebView, no `contenteditable` shim, no DOM.
8
+
9
+ The library exists because there is currently no production-grade way to do rich text editing in React Native without paying the WebView tax (worse performance, broken keyboard behavior, gesture/scroll conflicts, accessibility regressions, platform-specific bugs that don't reproduce in browsers). Every existing option — `react-native-pell-rich-editor`, `@10play/tentap-editor`, `react-native-cn-quill` — is a WebView wrapper around a browser-based editor.
10
+
11
+ The architectural bet: **Y.Text is already a platform-independent rich-text primitive.** It is a sequence of characters with arbitrary formatting attributes, which maps cleanly onto both `contenteditable` (via `y-prosemirror` on the web) and `NSAttributedString` / `Spannable` on native mobile. The model side is solved by upstream Yjs. The missing piece is the native bindings — the platform-specific adapters that translate user input into Y.Text operations and Y.Text state into rendered attributed text. That is what this library provides.
12
+
13
+ ## Non-goals
14
+
15
+ Explicit, to keep scope honest:
16
+
17
+ - **Not a multi-block document editor.** A `YTextInput` edits one `Y.Text` — one inline rich-text region, the equivalent of one paragraph or heading. Multi-block editing in a single view (Notion-style infinite-scroll-of-paragraphs in one editor) is *intentionally* out of scope. See "Document shape" below for why.
18
+ - **Not a toolbar / formatting UI.** The library exposes the editor API; the consumer builds whatever toolbar, slash menu, or floating selection UI it wants.
19
+ - **Not a Yjs port.** Yjs itself runs in JS via the existing `yjs` package. This library is the native-view side of the binding. (A native-side Yjs port is a possible future optimization but is explicitly v2+ territory.)
20
+ - **Not a sync / persistence layer.** The library is agnostic to where the `Y.Doc` containing the `Y.Text` lives — it works equally well with `y-websocket`, `y-indexeddb`, a custom provider talking to a backend, or a Yjs doc embedded in another database's row. Wiring that up is the consumer's responsibility.
21
+ - **Not a cross-platform abstraction layer for *all* text editing.** Plain text inputs are already cross-platform (React Native's `TextInput`). This library is for the specific case where you need formatting attributes and Yjs-backed collaborative semantics.
22
+ - **Not a complete WYSIWYG suite.** No tables, no embedded media as block-level structure, no nested complex layouts. Those belong in the consuming application's block model.
23
+
24
+ ## Document shape: `Y.Text` per editor
25
+
26
+ A library that binds Yjs to a native rich-text editor has two viable shapes for "what is a single editor instance editing":
27
+
28
+ | | `Y.Text` per editor | `Y.XmlFragment` per editor |
29
+ |---|---|---|
30
+ | What you edit | One inline rich-text region (one paragraph-equivalent) | A multi-block document (paragraphs + headings + lists + ...) |
31
+ | Block model lives where | The consuming application | Inside the editor |
32
+ | Multi-paragraph editing in one view | No — one editor per block | Yes |
33
+ | Editor surface area | Small (inline marks, selection within one run of text) | Large (block split/merge, multi-block selection, slash menus, cross-block keyboard nav) |
34
+ | Native event model fit | Excellent — one contiguous text region maps directly to `UITextView` / `EditText` | Awkward — multi-block selection and split/merge across multiple native text views is full of edge cases |
35
+ | Web equivalent | A `Y.Text` binding (custom) or TipTap with a one-block schema | Standard TipTap / `y-prosemirror` over `Y.XmlFragment` |
36
+
37
+ **`react-native-yjs-text` commits to the `Y.Text`-per-editor shape.** Each editor instance edits one `Y.Text`. Block-level structure (paragraph vs. heading vs. list, the distinction between "this is a quote block" and "this is a code block") is the consuming application's responsibility — typically each block is its own `Y.Text` in a tree the consumer maintains.
38
+
39
+ This is a deliberate scope decision, not a v0.1 limitation that we plan to grow out of. Reasons:
40
+
41
+ 1. **It's where modern block editors are converging.** Notion, Linear, ClickUp, Coda all use per-block rendering — independent permissions per block, independent collaboration cursors per block, independent AI mutations per block, simpler accessibility tree, native scroll behavior. The advantages outweigh the cost of managing block structure outside the editor. The `Y.XmlFragment`-in-one-editor pattern is the older paradigm; the industry is moving away from it.
42
+ 2. **It's the right fit for native mobile UX.** Single contiguous editable text regions map cleanly onto `UITextView` / `EditText`. Multi-block selection and split/merge across multiple native text views is a swamp of edge cases, especially on iOS.
43
+ 3. **It keeps the library small enough to actually ship.** The `Y.XmlFragment` shape adds a large surface area (block-level operations, slash menus, cross-block selection, split/merge keyboard handling) — features that double or triple the implementation cost.
44
+ 4. **It composes cleanly with any consumer block model.** A `ComponentNode` tree (Pear's case), a Slate-like document, a custom React Native list of blocks — all work the same way: each `Y.Text` is a leaf the consumer addresses, and the consumer handles tree-level operations on top.
45
+
46
+ A consumer that genuinely wants multi-block editing in one view should reach for a different library, or build one. If demand for a `Y.XmlFragment`-shaped library ever materializes, it should ship as a separate package (something like `react-native-yjs-prose`) that depends on `react-native-yjs-text` for the inline-rich-text leaves. That's a clean architectural split rather than feature creep on this library.
47
+
48
+ ## Architecture
49
+
50
+ ```
51
+ ┌─────────────────────────────────────────┐
52
+ │ Consumer application (e.g. Pear mobile) │
53
+ │ - manages the Y.Doc / Y.Text │
54
+ │ - builds toolbar / slash menu UI │
55
+ │ - composes multiple YTextInputs into │
56
+ │ its own block model │
57
+ └──────────────┬──────────────────────────┘
58
+ │ <YTextInput yText={...} schema={...} />
59
+
60
+ ┌──────────────▼──────────────────────────┐
61
+ │ react-native-yjs-text (TS layer) │
62
+ │ - YTextInput component │
63
+ │ - editor command API (commands.ts) │
64
+ │ - schema definitions / mark conventions│
65
+ │ - Y.Text ↔ native view event bridge │
66
+ └──────────────┬──────────────────────────┘
67
+ │ React Native bridge / JSI
68
+
69
+ ┌──────────────▼──────────┬───────────────┐
70
+ │ iOS native │ Android native│
71
+ │ - YTextInputView │ - YTextInputView
72
+ │ (subclass UITextView) │ (subclass AppCompatEditText)
73
+ │ - NSAttributedString │ - SpannableStringBuilder
74
+ │ - UITextViewDelegate │ - TextWatcher / InputConnection
75
+ └─────────────────────────┴───────────────┘
76
+ ```
77
+
78
+ The library *does not* own the `Y.Doc` or `Y.Text`. The consumer creates and owns them. The component subscribes to a passed-in `Y.Text` and renders/edits it.
79
+
80
+ ### Yjs object layering
81
+
82
+ A note on terminology, since the question comes up:
83
+
84
+ - **`Y.Doc`** is the *container* — the unit of synchronization and persistence. A consumer creates one `Y.Doc` per logical document (per page, per row, per editable cell — whatever granularity it wants) and arranges to sync it via whatever provider it prefers (`y-websocket`, `y-indexeddb`, a custom provider talking to a relational backend, etc.).
85
+ - **Shared types** (`Y.Text`, `Y.Map`, `Y.Array`, `Y.XmlFragment`) live inside a `Y.Doc`. They are what you read and write. You access them by name: `doc.getText('body')`, `doc.getMap('properties')`, etc.
86
+ - This library takes a `Y.Text` — one shared type out of one `Y.Doc`. The consumer chooses how many `Y.Doc`s exist, how they're synced, and which `Y.Text` inside which `Y.Doc` to plumb into each `YTextInput`.
87
+
88
+ This means the library has no opinion on, and no awareness of, the persistence / sync architecture above it. A `Y.Doc` backed by `y-websocket` to a Node server, a `Y.Doc` backed by `y-indexeddb` for offline-first, or a `Y.Doc` whose update bytes are stored as a column in a relational database row and synced over a custom protocol — all are equivalent from this library's perspective. The library handles only the editor side of the Yjs binding.
89
+
90
+ ## Public API
91
+
92
+ ### Component
93
+
94
+ ```tsx
95
+ import { YTextInput } from 'react-native-yjs-text';
96
+ import * as Y from 'yjs';
97
+
98
+ const doc = new Y.Doc();
99
+ const yText = doc.getText('content');
100
+
101
+ function MyEditor() {
102
+ return (
103
+ <YTextInput
104
+ yText={yText}
105
+ schema={defaultSchema}
106
+ style={styles.editor}
107
+ placeholder="Start typing..."
108
+ autoFocus
109
+ editable={true}
110
+ onSelectionChange={(selection) => {/* {from, to} as char offsets */}}
111
+ onFocus={() => {}}
112
+ onBlur={() => {}}
113
+ />
114
+ );
115
+ }
116
+ ```
117
+
118
+ ### Editor commands (imperative API)
119
+
120
+ For toolbar buttons and keyboard shortcuts:
121
+
122
+ ```ts
123
+ import { useYTextEditor } from 'react-native-yjs-text';
124
+
125
+ function Toolbar({ yText }) {
126
+ const editor = useYTextEditor(yText);
127
+
128
+ return (
129
+ <>
130
+ <Button onPress={() => editor.toggleMark('bold')} title="B" />
131
+ <Button onPress={() => editor.toggleMark('italic')} title="I" />
132
+ <Button
133
+ onPress={() => editor.setMark('link', { href: 'https://...' })}
134
+ title="Link"
135
+ />
136
+ </>
137
+ );
138
+ }
139
+ ```
140
+
141
+ Editor API surface (v0.1):
142
+
143
+ ```ts
144
+ interface YTextEditor {
145
+ // selection
146
+ getSelection(): { from: number; to: number } | null;
147
+ setSelection(range: { from: number; to: number }): void;
148
+
149
+ // marks (formatting attributes)
150
+ toggleMark(name: string, attrs?: Record<string, unknown>): void;
151
+ setMark(name: string, attrs?: Record<string, unknown>): void;
152
+ removeMark(name: string): void;
153
+ marksAtSelection(): Record<string, unknown>;
154
+
155
+ // text content
156
+ insertText(text: string, attrs?: Record<string, unknown>): void;
157
+ deleteRange(from: number, to: number): void;
158
+
159
+ // focus
160
+ focus(): void;
161
+ blur(): void;
162
+ isFocused(): boolean;
163
+ }
164
+ ```
165
+
166
+ All commands operate at the current selection unless given explicit ranges.
167
+
168
+ ### Schema
169
+
170
+ Schemas declare what marks are allowed and how each renders to native attributed text:
171
+
172
+ ```ts
173
+ import type { Schema } from 'react-native-yjs-text';
174
+
175
+ export const defaultSchema: Schema = {
176
+ marks: {
177
+ bold: { renderStyle: { fontWeight: 'bold' } },
178
+ italic: { renderStyle: { fontStyle: 'italic' } },
179
+ underline: { renderStyle: { textDecorationLine: 'underline' } },
180
+ strike: { renderStyle: { textDecorationLine: 'line-through' } },
181
+ code: { renderStyle: { fontFamily: 'Menlo', backgroundColor: '#f4f4f4' } },
182
+ link: {
183
+ attrs: { href: { type: 'string', required: true } },
184
+ renderStyle: { color: '#0066cc', textDecorationLine: 'underline' },
185
+ onTap: (attrs) => Linking.openURL(attrs.href),
186
+ },
187
+ },
188
+ };
189
+ ```
190
+
191
+ The schema is what makes the library safe for AI generation: typed marks, declared attributes, no schema-undeclared marks accepted. Consumers can register custom marks (e.g. `mention`, `highlight`) by extending the schema.
192
+
193
+ ### Mark conventions
194
+
195
+ To stay interoperable with `y-prosemirror` (so that the same `Y.Text` content edits identically on web via ProseMirror and on mobile via this library), the library defines canonical mark names:
196
+
197
+ | Mark name | Attributes | Semantics |
198
+ |---|---|---|
199
+ | `bold` | none | Bold text |
200
+ | `italic` | none | Italic text |
201
+ | `underline` | none | Underlined text |
202
+ | `strike` | none | Strikethrough text |
203
+ | `code` | none | Inline code |
204
+ | `link` | `href: string` | Hyperlink |
205
+
206
+ Custom marks are namespaced by the consumer (e.g. `pear.mention`, `pear.highlight`). The schema is the source of truth for what a given consumer accepts.
207
+
208
+ ### Read-only renderer
209
+
210
+ A lightweight read-only component for displaying rich text without the editing machinery:
211
+
212
+ ```tsx
213
+ import { YTextRenderer } from 'react-native-yjs-text';
214
+
215
+ <YTextRenderer yText={yText} schema={defaultSchema} style={styles.body} />
216
+ ```
217
+
218
+ This is a normal `Text` view under the hood with attributed spans — no `UITextView` / `EditText`, no event handling, no focus. Significantly cheaper than `YTextInput` and the right choice for lists of read-only content.
219
+
220
+ ## iOS implementation strategy
221
+
222
+ - **`YTextInputView` subclasses `UITextView`.** Inherits accessibility, selection handles, keyboard, IME for free.
223
+ - **Y.Text → `NSAttributedString` translation.** Walk the Y.Text content with formatting attributes, emit `NSAttributedString` runs with corresponding `NSAttributedString.Key` values (`.font`, `.foregroundColor`, `.underlineStyle`, `.link`).
224
+ - **Edit capture via `textView(_:shouldChangeTextIn:replacementText:)`.** Each delta becomes a Y.Text `insert` / `delete` / `format` op. For v0.1 this is sufficient for non-IME input.
225
+ - **Selection bridge.** `UITextView.selectedRange` is `NSRange` over character offsets, directly compatible with `Y.Text` offsets (with one caveat: `NSRange` is UTF-16-encoded, `Y.Text` is UTF-16-encoded in JS, so they agree by default).
226
+ - **Remote-edit handling.** When the `Y.Text` is mutated remotely (or by the consuming app), the view updates `attributedText` and uses `Y.RelativePosition` to keep the local caret in the right place across concurrent insertions.
227
+
228
+ iOS v0.1 known gaps: IME composition for CJK scripts uses `markedTextRange`, which doesn't fully integrate with `shouldChangeTextIn` in all edge cases. v0.1 will produce reasonable behavior for English/Latin; CJK IME correctness lands in v0.2.
229
+
230
+ ## Android implementation strategy
231
+
232
+ - **`YTextInputView` subclasses `AppCompatEditText`.**
233
+ - **Y.Text → `SpannableStringBuilder` translation.** Mark attributes map to standard `Span` types: `StyleSpan(BOLD)`, `StyleSpan(ITALIC)`, `UnderlineSpan`, `StrikethroughSpan`, `TypefaceSpan`, `URLSpan`, etc.
234
+ - **Edit capture via `TextWatcher` + `InputFilter`.** Less precise than `shouldChangeTextIn`, but sufficient for v0.1. Long-term: implement a custom `InputConnection` for full IME correctness.
235
+ - **Selection bridge.** `getSelectionStart()` / `getSelectionEnd()` ↔ Y.Text offsets, directly.
236
+ - **Remote-edit handling.** Same Y.RelativePosition pattern as iOS.
237
+
238
+ Android v0.1 known gaps: IME for CJK scripts and predictive text behave reasonably for short inputs but may have edge cases. v0.2 introduces a custom `InputConnection`.
239
+
240
+ ## JS ↔ native bridge
241
+
242
+ For v0.1: use the React Native standard bridge (or Fabric / TurboModules under the new architecture). Edits flow:
243
+
244
+ 1. User types → native view receives input event
245
+ 2. Native view emits an event to JS with the delta (`insert`, `delete`, or `format`)
246
+ 3. JS applies the delta to the `Y.Text` (this triggers the existing Yjs sync layer)
247
+ 4. The `Y.Text` change event fires; the view receives a prop update
248
+ 5. View updates its attributed text — *taking care to no-op for changes that originated locally* (otherwise we'd lose the caret)
249
+
250
+ This requires careful loop-breaking via origin tracking on Y.Text transactions, identical in shape to how `y-prosemirror` handles the same problem on web.
251
+
252
+ Performance note: For deep `Y.Text` mutations, sending each character event over the bridge is fine for human typing speeds (low tens of events/sec) but inadequate for paste of long content or remote bulk edits. v0.2 introduces JSI direct calls for the hot path.
253
+
254
+ ## Versioning roadmap
255
+
256
+ ### v0.1 — Editable on both platforms
257
+
258
+ - iOS + Android native views
259
+ - Default schema: `bold`, `italic`, `underline`, `strike`, `code`, `link`
260
+ - Editor command API (toggleMark, setMark, removeMark, getSelection, setSelection, insertText, deleteRange, focus/blur)
261
+ - `YTextRenderer` read-only component
262
+ - Yjs sync via standard bridge
263
+ - English / Latin-script editing
264
+ - Basic accessibility (inherited from `UITextView` / `EditText`)
265
+ - Example app
266
+
267
+ **Out of scope for v0.1:** CJK IME correctness, paste-from-clipboard with attribute preservation, hardware keyboard shortcuts.
268
+
269
+ ### v0.2 — Polish
270
+
271
+ - Full IME correctness on iOS via custom `UITextInput` conformance
272
+ - Full IME correctness on Android via custom `InputConnection`
273
+ - Paste-from-clipboard with attribute preservation (web → native fidelity)
274
+ - Hardware keyboard shortcuts (Cmd+B, Cmd+I, Cmd+K, etc.) with configurable bindings
275
+ - Better accessibility audit (VoiceOver, TalkBack)
276
+ - JSI direct calls for the typing hot path
277
+
278
+ ### v0.3 — Inline embeds
279
+
280
+ Mentions, hashtags, emoji-as-image, and other inline non-text nodes. Probably implemented as zero-width characters with formatting attributes that the renderer treats as opaque embeds, with consumer-provided render callbacks.
281
+
282
+ ### v1.0 — Production grade
283
+
284
+ Above + extended platform / accessibility / i18n testing matrix, performance benchmarks, full collaborative-cursor support, stable API.
285
+
286
+ Multi-block editing in one view is not on the roadmap. See "Document shape" above.
287
+
288
+ ## Open questions
289
+
290
+ These are unresolved and need decisions before or during implementation:
291
+
292
+ 1. **Inline non-text content (mentions, emoji-as-image).** Y.Text is fundamentally a sequence of characters with attribute spans. There are two viable encodings for inline embeds:
293
+ - **Sentinel character + attribute:** insert a zero-width or placeholder character with a `mention` formatting attribute carrying the data. Simple, works with Y.Text natively, breaks down for complex embed shapes.
294
+ - **Parallel Y.Array of inline nodes spliced in by offset:** richer, more like ProseMirror's inline node model, but requires the library to manage two structures in sync.
295
+
296
+ v0.1 will defer this; v0.3 needs a decision. The first option is likely correct for the 80% case.
297
+
298
+ 2. **Collaborative cursor / selection awareness.** Y.RelativePosition gives us the primitive. Should remote cursors be rendered by the library, or exposed as data for the consumer to render? Probably the latter (library exposes a `useRemoteCursors(yText)` hook, consumer renders them however they want — they may want avatars, names, etc.).
299
+
300
+ 3. **Selection persistence on remote insertion.** When a remote edit inserts characters before the local caret, the caret should shift to remain in "the same logical position." Y.RelativePosition handles this. Need to confirm the iOS / Android implementations preserve this correctly across the bridge.
301
+
302
+ 4. **Undo/redo semantics.** Yjs has `Y.UndoManager`. Should the library wire it into the platform's native undo (the "shake to undo" gesture on iOS, the system undo on Android), or expose `undo()` / `redo()` for the consumer to wire? Probably both — default platform undo wired by default, with an opt-out and an explicit `editor.undo()` available.
303
+
304
+ 5. **Schema validation timing.** Should marks that aren't in the schema be silently dropped on insert, throw, or warn? Likely: warn in dev, silently drop in prod, with an `onSchemaViolation` callback for consumers to plumb to error reporting.
305
+
306
+ 6. **Plain-text fallback.** If a consumer wants the editing surface but not formatting, is `YTextInput` with an empty mark set the right answer, or should there be a dedicated `YPlainTextInput`? Probably the former is fine — empty marks just gives you a Yjs-backed plain text input, which is itself a useful thing.
307
+
308
+ 7. **Web parity through `y-prosemirror`.** The library targets RN. The expectation is that consumers using both web and RN will use `y-prosemirror` + a Pear-defined ProseMirror schema on web, and `react-native-yjs-text` + an equivalent schema here, with the two schemas hand-aligned. Should we ship a *shared schema spec* (probably as a separate `yjs-text-schema` package) so the alignment is enforced rather than aspirational? Yes, probably, but not for v0.1.
309
+
310
+ ## License and governance
311
+
312
+ MIT or Apache 2.0 (final pick on initial commit). The library is intentionally permissively licensed to maximize adoption — every React Native team that's wanted non-WebView rich text editing should be able to use it without license friction. Pear remains AGPL; the two licenses don't conflict because the library is a separate work.
313
+
314
+ Governance: open-source maintainership from day one. Pear's interests are served by the library being healthy and widely used, not by exclusive control.
315
+
316
+ ## Relationship to Pear (and why this lives in pear-cloud temporarily)
317
+
318
+ Pear's editing layer commits to:
319
+
320
+ - `Y.Text` as the rich-text primitive within every text-bearing leaf of the `ComponentNode` tree
321
+ - `y-prosemirror` (or a future custom `Y.Text` binding) + a Pear-defined schema as the web editor
322
+ - `react-native-yjs-text` + the same schema as the mobile editor
323
+ - A renderer (web and RN) over the `ComponentNode` tree, with `RichText` components delegating their content to either `YTextRenderer` (read-only) or `YTextInput` (edit-mode)
324
+
325
+ ### How Yjs and SpacetimeDB layer together in Pear
326
+
327
+ This is the configuration the library has to work with in Pear, and is worth describing because it's a useful pattern for other consumers too:
328
+
329
+ - **SpacetimeDB is the outer substrate.** Every typed row — pages, properties, component trees, access rules, snapshots — lives in SpacetimeDB. Reducers mediate every mutation. Subscriptions push row changes to clients in real time. Permissions are enforced at the database layer.
330
+ - **Yjs is an inner substrate within specific cells.** The `Y.Doc` backing a `RichText` `ComponentNode` is serialized into a SpacetimeDB column (today: `PageYjsState`; in the merged architecture: per-`ComponentNode` Yjs state). SpacetimeDB does not try to merge the Yjs bytes — it stores them, fans out updates via subscription, and the client-side Yjs runtime handles the CRDT merge.
331
+ - **The two layers do orthogonal work.** SpacetimeDB owns row identity, schema, access control, mediation, attribution, and cross-document queries. Yjs owns intra-cell concurrent editing semantics. Neither replaces the other; they compose.
332
+
333
+ From this library's perspective, none of this matters — the library only sees a `Y.Text`. The fact that the `Y.Doc` containing that `Y.Text` is persisted and synced via a custom SpacetimeDB-backed provider is invisible to it. The same library, with the same API, can be used by a consumer running `y-websocket` to a Node server.
334
+
335
+ ### What this enables for Pear
336
+
337
+ This setup means the merged `CUSTOM_VIEW_PRIMITIVES.md` / `PEAR_PROGRAMMING.md` document can credibly claim *"no WebView anywhere in the stack"* as a real long-term commitment rather than aspiration. v1 of Pear mobile can ship with read-only rich text rendering (`YTextRenderer`, available day one of `react-native-yjs-text`) and defer editing to a desktop session or open the rich-text block in a controlled WebView; v1.5 of Pear mobile ships native editing when `react-native-yjs-text` v0.1 lands.
338
+
339
+ This spec lives in `pear-cloud` only until it's ready to move to its own repository. Implementation work will happen in the standalone repo from the start.
340
+
341
+ ## Next steps
342
+
343
+ 1. Review and revise this spec.
344
+ 2. Spike: 1-day proof-of-concept on iOS — `UITextView` subclass that renders a `Y.Text` with bold marks, captures typing, applies Y.Text ops, syncs back to the view. Confirms the architecture before committing to the repo split.
345
+ 3. If spike succeeds: create the standalone repository, copy this spec as the README, scaffold the package, ship v0.1 against the spec above.
346
+ 4. Update `PEAR_PROGRAMMING.md` / `CUSTOM_VIEW_PRIMITIVES.md` (in their merged form) to reference this library as Pear's rich-text editing primitive on mobile.
@@ -0,0 +1,26 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'tech.eclosion.yjstext'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "tech.eclosion.yjstext"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.0"
14
+ }
15
+ lintOptions {
16
+ abortOnError false
17
+ }
18
+ }
19
+
20
+ dependencies {
21
+ // AppCompatEditText: the editable surface inside YjsTextView. AppCompat is
22
+ // usually present transitively in any RN app, but we depend on it
23
+ // explicitly so the library can be consumed by a non-AppCompat-using host
24
+ // (rare but possible in greenfield Compose apps).
25
+ implementation 'androidx.appcompat:appcompat:1.7.0'
26
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,77 @@
1
+ package tech.eclosion.yjstext
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ /**
7
+ * Expo Module registration for `@eclosion-tech/react-native-yjs-text` on Android.
8
+ *
9
+ * Mirrors `YjsTextModule.swift` — same view name (`YjsText`), same set of
10
+ * events, same set of props, same imperative methods. The JS side talks to
11
+ * either platform indifferently as long as the contract stays in sync.
12
+ */
13
+ class YjsTextModule : Module() {
14
+ override fun definition() = ModuleDefinition {
15
+ Name("YjsText")
16
+
17
+ View(YjsTextView::class) {
18
+ Events(
19
+ "onContentChange",
20
+ "onNativeSelectionChange",
21
+ "onFocusChange",
22
+ "onMarkTap"
23
+ )
24
+
25
+ // MARK: - Props
26
+
27
+ Prop("runs") { view: YjsTextView, value: List<YjsTextRunRecord> ->
28
+ view.runs = value
29
+ }
30
+ Prop("renderSpec") { view: YjsTextView, value: Map<String, YjsTextMarkStyleRecord> ->
31
+ view.renderSpec = value
32
+ }
33
+ Prop("pendingSelection") { view: YjsTextView, value: YjsTextSelectionRecord? ->
34
+ view.pendingSelection = value
35
+ }
36
+ Prop("editable") { view: YjsTextView, value: Boolean ->
37
+ view.editable = value
38
+ }
39
+ Prop("placeholder") { view: YjsTextView, value: String? ->
40
+ view.placeholder = value
41
+ }
42
+ Prop("placeholderColor") { view: YjsTextView, value: String? ->
43
+ view.placeholderColor = YjsTextColor.parse(value)
44
+ }
45
+ Prop("baseFontSize") { view: YjsTextView, value: Double? ->
46
+ view.baseFontSize = value
47
+ }
48
+ Prop("baseFontFamily") { view: YjsTextView, value: String? ->
49
+ view.baseFontFamily = value
50
+ }
51
+ Prop("baseColor") { view: YjsTextView, value: String? ->
52
+ view.baseColor = YjsTextColor.parse(value)
53
+ }
54
+ Prop("baseFontWeight") { view: YjsTextView, value: String? ->
55
+ view.baseFontWeight = value
56
+ }
57
+ Prop("baseFontStyle") { view: YjsTextView, value: String? ->
58
+ view.baseFontStyle = value
59
+ }
60
+
61
+ // MARK: - View methods (callable via the view's React ref)
62
+
63
+ AsyncFunction("focus") { view: YjsTextView ->
64
+ view.focusInput()
65
+ }
66
+ AsyncFunction("blur") { view: YjsTextView ->
67
+ view.blurInput()
68
+ }
69
+ AsyncFunction("isFocused") { view: YjsTextView ->
70
+ view.isInputFocused()
71
+ }
72
+ AsyncFunction("setSelection") { view: YjsTextView, from: Int, to: Int ->
73
+ view.setSelection(from, to)
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,135 @@
1
+ package tech.eclosion.yjstext
2
+
3
+ import android.graphics.Color
4
+ import expo.modules.kotlin.records.Field
5
+ import expo.modules.kotlin.records.Record
6
+ import java.io.Serializable
7
+
8
+ /**
9
+ * Bridge records mirroring the iOS `YjsTextSupport.swift` shapes. Keep the
10
+ * field names byte-for-byte identical to their iOS counterparts and the JS
11
+ * `SerializedRun` / `CompiledRenderSpec` types — Expo Modules deserialises
12
+ * by field name.
13
+ */
14
+
15
+ class YjsTextRunRecord : Record, Serializable {
16
+ @Field var text: String = ""
17
+ @Field var marksJson: String = "{}"
18
+ }
19
+
20
+ class YjsTextMarkStyleRecord : Record, Serializable {
21
+ @Field var fontWeight: String? = null
22
+ @Field var fontStyle: String? = null
23
+ @Field var fontFamily: String? = null
24
+ @Field var fontSize: Double? = null
25
+ @Field var color: String? = null
26
+ @Field var backgroundColor: String? = null
27
+ @Field var textDecorationLine: String? = null
28
+ }
29
+
30
+ /**
31
+ * JS-pushed selection bundle. `version` is a monotonic counter bumped every
32
+ * time the JS side wants the native view to (re-)apply a selection — we
33
+ * re-apply only when it changes from the last value we observed.
34
+ *
35
+ * Bundled into a single Record (rather than two separate props) so the
36
+ * setter is atomic. With separate `selection` + `selectionVersion` props,
37
+ * Expo Modules' prop setter ordering on Fabric is unspecified, so the
38
+ * version setter could fire before the value setter and read a stale
39
+ * selection — producing a "selection jumps to previously selected text"
40
+ * bug. Bundling sidesteps that race.
41
+ */
42
+ class YjsTextSelectionRecord : Record, Serializable {
43
+ @Field var from: Int = 0
44
+ @Field var to: Int = 0
45
+ @Field var version: Int = -1
46
+ }
47
+
48
+ class YjsTextContentChangePayload : Record, Serializable {
49
+ @Field var type: String = "replace"
50
+ @Field var from: Int = 0
51
+ @Field var to: Int = 0
52
+ @Field var text: String = ""
53
+ }
54
+
55
+ class YjsTextSelectionEventPayload : Record, Serializable {
56
+ @Field var from: Int = 0
57
+ @Field var to: Int = 0
58
+ }
59
+
60
+ class YjsTextFocusEventPayload : Record, Serializable {
61
+ @Field var focused: Boolean = false
62
+ }
63
+
64
+ class YjsTextMarkTapPayload : Record, Serializable {
65
+ @Field var mark: String = ""
66
+ @Field var attrsJson: String = "{}"
67
+ }
68
+
69
+ /**
70
+ * CSS-flavoured colour parser used to turn `style.color` / `backgroundColor`
71
+ * strings sent from JS into Android `@ColorInt` integers.
72
+ *
73
+ * Android's `Color.parseColor` accepts `#rrggbb` / `#aarrggbb` (alpha-first)
74
+ * and a small set of named colours, but the web / iOS convention is
75
+ * `#rrggbbaa` (alpha-last). We translate alpha-last hex to the alpha-first
76
+ * form before delegating, and fall back to `null` (caller uses default) on
77
+ * any parse failure.
78
+ */
79
+ object YjsTextColor {
80
+ fun parse(raw: String?): Int? {
81
+ val s = raw?.trim().orEmpty()
82
+ if (s.isEmpty()) return null
83
+ return try {
84
+ when {
85
+ s.startsWith("#") -> parseHex(s.substring(1))
86
+ s.startsWith("rgb", ignoreCase = true) -> parseRgb(s)
87
+ else -> when (s.lowercase()) {
88
+ "black" -> Color.BLACK
89
+ "white" -> Color.WHITE
90
+ "transparent" -> Color.TRANSPARENT
91
+ else -> Color.parseColor(s)
92
+ }
93
+ }
94
+ } catch (_: Throwable) {
95
+ null
96
+ }
97
+ }
98
+
99
+ private fun parseHex(hex: String): Int? {
100
+ val upper = hex.uppercase()
101
+ val normalised = when (upper.length) {
102
+ 3 -> {
103
+ val r = upper[0].toString().repeat(2)
104
+ val g = upper[1].toString().repeat(2)
105
+ val b = upper[2].toString().repeat(2)
106
+ "FF$r$g$b"
107
+ }
108
+ 6 -> "FF$upper"
109
+ 8 -> {
110
+ // Web convention: #rrggbbaa. Android expects #aarrggbb.
111
+ val rrggbb = upper.substring(0, 6)
112
+ val aa = upper.substring(6, 8)
113
+ "$aa$rrggbb"
114
+ }
115
+ else -> return null
116
+ }
117
+ val value = normalised.toLong(16)
118
+ return value.toInt()
119
+ }
120
+
121
+ private fun parseRgb(raw: String): Int? {
122
+ val inner = raw
123
+ .replace(" ", "")
124
+ .replace("rgba(", "")
125
+ .replace("rgb(", "")
126
+ .replace(")", "")
127
+ val parts = inner.split(",")
128
+ if (parts.size !in 3..4) return null
129
+ val r = parts[0].toIntOrNull() ?: return null
130
+ val g = parts[1].toIntOrNull() ?: return null
131
+ val b = parts[2].toIntOrNull() ?: return null
132
+ val a = if (parts.size == 4) ((parts[3].toDoubleOrNull() ?: 1.0) * 255).toInt() else 255
133
+ return Color.argb(a.coerceIn(0, 255), r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255))
134
+ }
135
+ }