@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
@@ -0,0 +1,464 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// Native editor view backing `<YTextInput>` on iOS.
6
+ ///
7
+ /// The Expo Modules DSL requires our view to subclass `ExpoView` for prop /
8
+ /// event wiring, so we make `ExpoView` the outer container and host a
9
+ /// `UITextView` as a single full-bleed subview. Conceptually the user is
10
+ /// editing the `UITextView` — the `ExpoView` is just a Yoga/Fabric-friendly
11
+ /// shell.
12
+ ///
13
+ /// All Y.Text ↔ UITextView translation is contained here:
14
+ /// - JS pushes `runs` and `renderSpec` down → we build an `NSAttributedString`
15
+ /// - JS pushes a `selection` and bumped `selectionVersion` → we update the caret
16
+ /// - User types / pastes / deletes → `textView(_:shouldChangeTextIn:replacementText:)`
17
+ /// fires a `replace`-shaped `onContentChange` event up
18
+ /// - Selection moves → `textViewDidChangeSelection` fires `onNativeSelectionChange`
19
+ /// - Focus changes → `textViewDidBeginEditing` / `textViewDidEndEditing` fire `onFocusChange`
20
+ ///
21
+ /// Re-entrancy guard: when JS pushes runs down (in response to a remote edit
22
+ /// it received), we temporarily set `suppressEdits` so the `shouldChangeTextIn`
23
+ /// callback that fires while we mutate `attributedText` doesn't bounce back up
24
+ /// to JS. The standard loop-breaking pattern, identical in shape to how
25
+ /// `y-prosemirror` handles the same problem on web.
26
+ public class YjsTextView: ExpoView {
27
+ // MARK: Props (set by Expo Modules DSL setters in YjsTextModule)
28
+
29
+ /// New runs from JS. We always rebuild on change — the runs setter is the
30
+ /// SOLE trigger for re-rendering text. We used to have a separate
31
+ /// `contentVersion` Int prop that bumped on every Y.Text change to *force*
32
+ /// a rebuild even when only attributes changed (the short-circuit on
33
+ /// `runs` setter compared plain text only). That two-prop design was
34
+ /// order-sensitive: Expo Modules / Fabric doesn't guarantee setter order
35
+ /// within a render batch, and alphabetical / generated ordering can put
36
+ /// `contentVersion` before `runs`. When that happened, the `contentVersion`
37
+ /// setter rebuilt from the *previous* runs and the subsequent `runs`
38
+ /// setter short-circuited — so every attribute toggle visibly lagged by
39
+ /// one update. Collapsing to a single trigger (and always rebuilding)
40
+ /// makes that race structurally impossible.
41
+ ///
42
+ /// Cost: builds an `NSAttributedString` and re-assigns
43
+ /// `textView.attributedText` on every runs prop change. For typed input
44
+ /// the JS observer short-circuits (ORIGIN_LOCAL_VIEW), so React doesn't
45
+ /// re-derive runs and the setter doesn't fire — so this isn't a per-
46
+ /// keystroke cost. For format / programmatic edits it's one rebuild per
47
+ /// toggle, which is what we want.
48
+ var runs: [YjsTextRunRecord] = [] {
49
+ didSet { applyRuns() }
50
+ }
51
+ var renderSpec: [String: YjsTextMarkStyleRecord] = [:] {
52
+ didSet { applyRuns() }
53
+ }
54
+ /// Latest selection bundle pushed from JS. We re-apply only when the
55
+ /// embedded `version` field changes (so a no-op re-render from React,
56
+ /// where the same selection prop reference is sent again, doesn't fight
57
+ /// the user's in-flight selection). See `YjsTextSelectionRecord` for why
58
+ /// this is a single bundled Record instead of two separate props.
59
+ var pendingSelection: YjsTextSelectionRecord? = nil {
60
+ didSet {
61
+ guard let next = pendingSelection else { return }
62
+ if next.version != lastAppliedSelectionVersion {
63
+ lastAppliedSelectionVersion = next.version
64
+ setSelection(from: next.from, to: next.to)
65
+ }
66
+ }
67
+ }
68
+ private var lastAppliedSelectionVersion: Int = -1
69
+ var editable: Bool = true {
70
+ didSet { textView.isEditable = editable }
71
+ }
72
+ var placeholder: String? = nil {
73
+ didSet { updatePlaceholderVisibility() }
74
+ }
75
+ var placeholderColor: UIColor? = UIColor(white: 0.6, alpha: 1.0) {
76
+ didSet { placeholderLabel.textColor = placeholderColor }
77
+ }
78
+ var baseFontSize: Double? = nil {
79
+ didSet { applyBaseFont(); applyRuns() }
80
+ }
81
+ var baseFontFamily: String? = nil {
82
+ didSet { applyBaseFont(); applyRuns() }
83
+ }
84
+ var baseColor: UIColor? = nil {
85
+ didSet { applyBaseFont(); applyRuns() }
86
+ }
87
+ var baseFontWeight: String? = nil {
88
+ didSet { applyBaseFont(); applyRuns() }
89
+ }
90
+ var baseFontStyle: String? = nil {
91
+ didSet { applyBaseFont(); applyRuns() }
92
+ }
93
+
94
+ // MARK: Events
95
+ //
96
+ // Expo's Swift `EventDispatcher` takes `[String: Any?]` payloads — the
97
+ // generic-payload form only exists in the Kotlin DSL. We build matching
98
+ // dictionaries at the dispatch sites; the JS side sees the same shape on
99
+ // both platforms.
100
+
101
+ let onContentChange = EventDispatcher()
102
+ let onNativeSelectionChange = EventDispatcher()
103
+ let onFocusChange = EventDispatcher()
104
+ // Reserved for v0.3 inline-embed taps; unused in v0.1 but declared so the
105
+ // Module DSL's `Events(...)` registration matches the iOS/Android contract.
106
+ let onMarkTap = EventDispatcher()
107
+
108
+ // MARK: Internal state
109
+
110
+ private let textView = UITextView()
111
+ private let placeholderLabel = UILabel()
112
+ /// True while we're applying a JS-driven change to `textView.attributedText`,
113
+ /// so the `shouldChangeTextIn` delegate doesn't echo the change back to JS.
114
+ private var suppressEdits: Bool = false
115
+ /// Same idea for selection: when JS pushes a selection down, we set this
116
+ /// during the `selectedRange = ...` write so the delegate doesn't echo.
117
+ private var suppressSelectionEvents: Bool = false
118
+
119
+ // MARK: Init
120
+
121
+ public required init(appContext: AppContext? = nil) {
122
+ super.init(appContext: appContext)
123
+ clipsToBounds = true
124
+ backgroundColor = .clear
125
+
126
+ textView.translatesAutoresizingMaskIntoConstraints = false
127
+ textView.backgroundColor = .clear
128
+ // Match Expo's default content insets to RN's TextInput rather than
129
+ // UITextView's chunky default (8/0/8/0).
130
+ textView.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
131
+ textView.textContainer.lineFragmentPadding = 0
132
+ textView.delegate = self
133
+ textView.adjustsFontForContentSizeCategory = true
134
+ addSubview(textView)
135
+
136
+ placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
137
+ placeholderLabel.textColor = placeholderColor
138
+ placeholderLabel.numberOfLines = 0
139
+ placeholderLabel.isUserInteractionEnabled = false
140
+ addSubview(placeholderLabel)
141
+
142
+ NSLayoutConstraint.activate([
143
+ textView.topAnchor.constraint(equalTo: topAnchor),
144
+ textView.bottomAnchor.constraint(equalTo: bottomAnchor),
145
+ textView.leadingAnchor.constraint(equalTo: leadingAnchor),
146
+ textView.trailingAnchor.constraint(equalTo: trailingAnchor),
147
+ placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8),
148
+ placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
149
+ placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
150
+ ])
151
+
152
+ applyBaseFont()
153
+ updatePlaceholderVisibility()
154
+ }
155
+
156
+ // MARK: Imperative
157
+
158
+ func focusInput() {
159
+ if !textView.isFirstResponder { _ = textView.becomeFirstResponder() }
160
+ }
161
+
162
+ func blurInput() {
163
+ if textView.isFirstResponder { _ = textView.resignFirstResponder() }
164
+ }
165
+
166
+ func isInputFocused() -> Bool {
167
+ textView.isFirstResponder
168
+ }
169
+
170
+ func setSelection(from: Int, to: Int) {
171
+ let utf16Length = (textView.attributedText.string as NSString).length
172
+ let clampedFrom = max(0, min(utf16Length, from))
173
+ let clampedTo = max(clampedFrom, min(utf16Length, to))
174
+ let range = NSRange(location: clampedFrom, length: clampedTo - clampedFrom)
175
+ suppressSelectionEvents = true
176
+ textView.selectedRange = range
177
+ suppressSelectionEvents = false
178
+ }
179
+
180
+ // MARK: Render
181
+
182
+ /// Currently-resolved baseline font for the editor, derived purely from the
183
+ /// base* props (not read back from `textView.font`).
184
+ ///
185
+ /// We compute this once per prop change and feed it into every run's
186
+ /// attribute build, instead of reading `textView.font` at render time.
187
+ /// Reading from `textView.font` is unsafe because UIKit can mutate it under
188
+ /// us (Dynamic Type swaps, the "Bold Text" accessibility setting silently
189
+ /// folding `.traitBold` into the system font, etc.). If we used a mutated
190
+ /// `textView.font` as the seed, every run's traits would inherit the
191
+ /// mutation and the whole editor would render bold/italic on top of
192
+ /// whatever the marks themselves request. Caching `baseFontResolved`
193
+ /// breaks that loop.
194
+ private var baseFontResolved: UIFont = UIFont.systemFont(ofSize: 16)
195
+
196
+ private func applyBaseFont() {
197
+ // We discard `needsObliqueness` here: the textView's baseline `.font`
198
+ // doesn't carry per-character attributes, so obliqueness for the
199
+ // baseline can't be expressed at this level. The base case where it
200
+ // matters (italic baseline) is rare in practice — consumers nearly
201
+ // always leave fontStyle off the editor's outer style and apply
202
+ // italic via a mark. If they do want italic baseline + custom weight,
203
+ // they should set a font family that natively has the variant.
204
+ let (resolved, _) = makeFont(
205
+ family: baseFontFamily,
206
+ size: CGFloat(baseFontSize ?? 16),
207
+ bold: baseFontWeight == "bold",
208
+ italic: baseFontStyle == "italic"
209
+ )
210
+ baseFontResolved = resolved
211
+ textView.font = resolved
212
+ textView.textColor = baseColor ?? .label
213
+ placeholderLabel.font = resolved
214
+ }
215
+
216
+ /// Build a `UIFont` for the given combination of family / size / bold /
217
+ /// italic, returning both the font and a flag indicating whether the
218
+ /// caller must apply an `NSAttributedString.Key.obliqueness` attribute as
219
+ /// a fallback to slant the glyphs.
220
+ ///
221
+ /// The obliqueness fallback exists because iOS's
222
+ /// `UIFontDescriptor.withSymbolicTraits` on the system font's descriptor
223
+ /// is unreliable for **combined** bold+italic resolution: it returns a
224
+ /// non-nil italic descriptor, but `UIFont(descriptor:size:)` then resolves
225
+ /// to a font that has `.traitItalic` but has **lost** the bold weight
226
+ /// (because the symbolic-traits round-trip drops the `NSFontWeightTrait`
227
+ /// attribute). That produces the "bold disappears when italic is added"
228
+ /// symptom. We try multiple resolution paths and verify the result
229
+ /// actually carries both traits; if none does, we ship true bold + a
230
+ /// computational slant via `.obliqueness`, which is visually
231
+ /// indistinguishable from SF Pro Bold Italic for most rendering purposes.
232
+ private func makeFont(
233
+ family: String?,
234
+ size: CGFloat,
235
+ bold: Bool,
236
+ italic: Bool
237
+ ) -> (font: UIFont, needsObliqueness: Bool) {
238
+ if let family = family {
239
+ // Custom family: descriptor route is fine because the family resolves
240
+ // to a real font file that almost certainly carries its own bold /
241
+ // italic variants registered with the font system.
242
+ var traits: UIFontDescriptor.SymbolicTraits = []
243
+ if bold { traits.insert(.traitBold) }
244
+ if italic { traits.insert(.traitItalic) }
245
+ let baseDescriptor = UIFontDescriptor(name: family, size: size)
246
+ let descriptor: UIFontDescriptor
247
+ if traits.isEmpty {
248
+ descriptor = baseDescriptor
249
+ } else {
250
+ descriptor = baseDescriptor.withSymbolicTraits(traits) ?? baseDescriptor
251
+ }
252
+ return (UIFont(descriptor: descriptor, size: size), needsObliqueness: false)
253
+ }
254
+ // System font path — use the typed UIKit APIs.
255
+ switch (bold, italic) {
256
+ case (false, false):
257
+ return (UIFont.systemFont(ofSize: size), false)
258
+ case (true, false):
259
+ return (UIFont.systemFont(ofSize: size, weight: .bold), false)
260
+ case (false, true):
261
+ return (UIFont.italicSystemFont(ofSize: size), false)
262
+ case (true, true):
263
+ // Try in order: (1) symbolic [bold, italic] on the bare system
264
+ // descriptor, (2) symbolic .traitItalic on the bold-weighted
265
+ // descriptor. Verify each candidate actually carries BOTH traits
266
+ // before accepting — descriptor round-trips on iOS commonly drop
267
+ // either the bold weight or the italic trait and silently return a
268
+ // partial-match font.
269
+ let combinedCandidates: [UIFontDescriptor?] = [
270
+ UIFont.systemFont(ofSize: size).fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]),
271
+ UIFont.systemFont(ofSize: size, weight: .bold).fontDescriptor.withSymbolicTraits(.traitItalic),
272
+ ]
273
+ for case let descriptor? in combinedCandidates {
274
+ let candidate = UIFont(descriptor: descriptor, size: size)
275
+ let traits = candidate.fontDescriptor.symbolicTraits
276
+ if traits.contains(.traitBold) && traits.contains(.traitItalic) {
277
+ return (candidate, false)
278
+ }
279
+ }
280
+ // Neither descriptor route resolved a true bold-italic font. Fall
281
+ // back to true bold + an obliqueness slant applied as a string
282
+ // attribute by the caller.
283
+ return (UIFont.systemFont(ofSize: size, weight: .bold), needsObliqueness: true)
284
+ }
285
+ }
286
+
287
+ /// Rebuild and apply the attributed string. Called whenever `runs`,
288
+ /// `renderSpec`, or any base-style prop changes.
289
+ ///
290
+ /// No more `force` parameter / short-circuit: see the `runs` comment above
291
+ /// for why. React-side memoisation of `serializedRuns` means this only
292
+ /// fires when there's actually new data; redundant runs reapplies on
293
+ /// unrelated parent re-renders are prevented at the JS layer.
294
+ private func applyRuns() {
295
+ let newString = buildAttributedString()
296
+ let previousSelected = textView.selectedRange
297
+ // CRITICAL: we have to suppress *both* edit and selection events around
298
+ // the `attributedText = ...` assignment. UITextView resets `selectedRange`
299
+ // internally as part of assigning new attributed text, and that reset
300
+ // fires `textViewDidChangeSelection` with `{0, 0}`. If we let that bubble
301
+ // up to JS as an `onNativeSelectionChange`, the JS `selectionRef` gets
302
+ // overwritten with `{0, 0}` — so the next toolbar toggle reads a stale
303
+ // collapsed caret, drops into pending-mark mode, and visually does
304
+ // nothing. Suppressing both event channels here keeps JS's selection
305
+ // state aligned with what the user actually has selected.
306
+ suppressEdits = true
307
+ suppressSelectionEvents = true
308
+ textView.attributedText = newString
309
+ suppressSelectionEvents = false
310
+ suppressEdits = false
311
+
312
+ // Preserve caret across the assignment (UITextView resets selectedRange
313
+ // when attributedText is reassigned, so we restore explicitly).
314
+ let utf16Length = (newString.string as NSString).length
315
+ if previousSelected.location <= utf16Length {
316
+ let clampedEnd = min(utf16Length, previousSelected.location + previousSelected.length)
317
+ suppressSelectionEvents = true
318
+ textView.selectedRange = NSRange(
319
+ location: previousSelected.location,
320
+ length: clampedEnd - previousSelected.location
321
+ )
322
+ suppressSelectionEvents = false
323
+ }
324
+ updatePlaceholderVisibility()
325
+ }
326
+
327
+
328
+ private func buildAttributedString() -> NSAttributedString {
329
+ let result = NSMutableAttributedString()
330
+ let baseColorResolved = baseColor ?? .label
331
+ for run in runs {
332
+ let attrs = attributes(for: run, baseColor: baseColorResolved)
333
+ result.append(NSAttributedString(string: run.text, attributes: attrs))
334
+ }
335
+ return result
336
+ }
337
+
338
+ private func attributes(
339
+ for run: YjsTextRunRecord,
340
+ baseColor: UIColor
341
+ ) -> [NSAttributedString.Key: Any] {
342
+ var attrs: [NSAttributedString.Key: Any] = [:]
343
+ // CRITICAL: start each run from a CLEAN slate — base font traits and
344
+ // base props only, never from `textView.font` or some accumulated state.
345
+ // The previous design read `var traits = baseFont.fontDescriptor.symbolicTraits`
346
+ // off the live UITextView font, which UIKit can mutate (Dynamic Type,
347
+ // Bold Text accessibility setting, etc.) and then every run inherits
348
+ // that mutation, producing the "the entire editor is suddenly bold"
349
+ // symptom. Per-run we start from the JS prop values and OR in only the
350
+ // marks the run actually carries.
351
+ let baseFontBold = baseFontWeight == "bold"
352
+ let baseFontItalic = baseFontStyle == "italic"
353
+ var bold = baseFontBold
354
+ var italic = baseFontItalic
355
+ var familyOverride: String? = nil
356
+ var sizeOverride: CGFloat? = nil
357
+ var foreground = baseColor
358
+ var background: UIColor? = nil
359
+ var underline = false
360
+ var strike = false
361
+
362
+ let marks = parseMarks(run.marksJson)
363
+ for (markName, markValue) in marks {
364
+ // `null` / `NSNull` values are the "explicitly off" sentinel produced
365
+ // by the insert path's `computeInsertAttrs` when a mark was toggled
366
+ // off via pending overlays. Treat them as "mark not present" rather
367
+ // than "mark present with no attrs". Without this, typed text after
368
+ // a pending-mark-off toggle re-applies the mark's style.
369
+ if markValue is NSNull { continue }
370
+ if let bool = markValue as? Bool, !bool { continue }
371
+ guard let style = renderSpec[markName] else { continue }
372
+ if style.fontWeight == "bold" { bold = true }
373
+ if style.fontStyle == "italic" { italic = true }
374
+ if let family = style.fontFamily { familyOverride = family }
375
+ if let size = style.fontSize { sizeOverride = CGFloat(size) }
376
+ if let color = style.color, let parsed = YjsTextColor.parse(color) {
377
+ foreground = parsed
378
+ }
379
+ if let bg = style.backgroundColor, let parsed = YjsTextColor.parse(bg) {
380
+ background = parsed
381
+ }
382
+ if let decoration = style.textDecorationLine {
383
+ if decoration.contains("underline") { underline = true }
384
+ if decoration.contains("line-through") { strike = true }
385
+ }
386
+ }
387
+
388
+ let size = sizeOverride ?? CGFloat(baseFontSize ?? 16)
389
+ let family = familyOverride ?? baseFontFamily
390
+ let (font, needsObliqueness) = makeFont(family: family, size: size, bold: bold, italic: italic)
391
+
392
+ attrs[.font] = font
393
+ attrs[.foregroundColor] = foreground
394
+ if needsObliqueness {
395
+ // True bold-italic SF Pro wasn't available via the descriptor route;
396
+ // ship a computational slant so the glyphs still look italic while
397
+ // preserving the bold weight. 0.2 corresponds to ~12° which is the
398
+ // visual slope of SF Pro Italic at body sizes.
399
+ attrs[.obliqueness] = 0.2
400
+ }
401
+ if let bg = background { attrs[.backgroundColor] = bg }
402
+ if underline { attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue }
403
+ if strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
404
+ return attrs
405
+ }
406
+
407
+ private func parseMarks(_ json: String) -> [String: Any] {
408
+ guard let data = json.data(using: .utf8) else { return [:] }
409
+ let object = try? JSONSerialization.jsonObject(with: data, options: [])
410
+ return (object as? [String: Any]) ?? [:]
411
+ }
412
+
413
+ private func updatePlaceholderVisibility() {
414
+ let isEmpty = textView.attributedText.length == 0
415
+ placeholderLabel.text = placeholder
416
+ placeholderLabel.isHidden = !isEmpty || (placeholder?.isEmpty ?? true)
417
+ }
418
+ }
419
+
420
+ // MARK: - UITextViewDelegate
421
+
422
+ extension YjsTextView: UITextViewDelegate {
423
+ public func textView(
424
+ _ textView: UITextView,
425
+ shouldChangeTextIn range: NSRange,
426
+ replacementText text: String
427
+ ) -> Bool {
428
+ if suppressEdits { return true }
429
+ // Forward as a `replace` edit. Note: NSRange uses UTF-16 code units,
430
+ // which is the same encoding Y.Text offsets use, so no conversion needed.
431
+ onContentChange([
432
+ "type": "replace",
433
+ "from": range.location,
434
+ "to": range.location + range.length,
435
+ "text": text,
436
+ ])
437
+ // Returning `true` lets UITextView apply the change immediately so the
438
+ // user sees their typing without a JS round-trip. JS catches up with the
439
+ // Y.Text mutation; the resulting observe event is skipped (ORIGIN_LOCAL_VIEW)
440
+ // so we don't double-apply.
441
+ return true
442
+ }
443
+
444
+ public func textViewDidChange(_ textView: UITextView) {
445
+ updatePlaceholderVisibility()
446
+ }
447
+
448
+ public func textViewDidChangeSelection(_ textView: UITextView) {
449
+ if suppressSelectionEvents { return }
450
+ let range = textView.selectedRange
451
+ onNativeSelectionChange([
452
+ "from": range.location,
453
+ "to": range.location + range.length,
454
+ ])
455
+ }
456
+
457
+ public func textViewDidBeginEditing(_ textView: UITextView) {
458
+ onFocusChange(["focused": true])
459
+ }
460
+
461
+ public func textViewDidEndEditing(_ textView: UITextView) {
462
+ onFocusChange(["focused": false])
463
+ }
464
+ }
package/package.json ADDED
@@ -0,0 +1,124 @@
1
+ {
2
+ "name": "@eclosion-tech/react-native-yjs-text",
3
+ "version": "0.1.0",
4
+ "description": "Native React Native rich text editor backed by Y.Text — no WebView, no contenteditable",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "node internal/module_scripts/build.js",
9
+ "clean": "node internal/module_scripts/clean.js",
10
+ "lint": "eslint src/",
11
+ "test": "node internal/module_scripts/test.js",
12
+ "prepare": "node internal/module_scripts/prepare.js",
13
+ "open:ios": "node internal/module_scripts/open-ios.js",
14
+ "open:android": "node internal/module_scripts/open-android.js",
15
+ "example:ios": "pnpm --filter react-native-yjs-text-example exec expo run:ios",
16
+ "example:android": "pnpm --filter react-native-yjs-text-example exec expo run:android",
17
+ "example:start": "pnpm --filter react-native-yjs-text-example exec expo start",
18
+ "example-bare:ios": "pnpm --filter react-native-yjs-text-example-bare exec react-native run-ios",
19
+ "example-bare:android": "pnpm --filter react-native-yjs-text-example-bare exec react-native run-android",
20
+ "example-bare:start": "pnpm --filter react-native-yjs-text-example-bare exec react-native start"
21
+ },
22
+ "keywords": [
23
+ "react-native",
24
+ "expo",
25
+ "react-native-yjs-text",
26
+ "yjs",
27
+ "y-text",
28
+ "rich-text",
29
+ "rich-text-editor",
30
+ "crdt",
31
+ "collaborative",
32
+ "text-editor",
33
+ "native"
34
+ ],
35
+ "repository": "https://github.com/eclosion-tech/react-native-yjs-text",
36
+ "bugs": {
37
+ "url": "https://github.com/eclosion-tech/react-native-yjs-text/issues"
38
+ },
39
+ "author": "Eclosion Technologies <hello@eclosion.tech> (https://eclosion.tech)",
40
+ "license": "MIT",
41
+ "homepage": "https://github.com/eclosion-tech/react-native-yjs-text#readme",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": [
46
+ "build",
47
+ "src",
48
+ "!src/__tests__",
49
+ "ios",
50
+ "!ios/Pods",
51
+ "!ios/build",
52
+ "!ios/**/*.xcworkspace",
53
+ "!ios/**/*.xcodeproj",
54
+ "android/src",
55
+ "android/build.gradle",
56
+ "expo-module.config.json",
57
+ "SPEC.md",
58
+ "README.md",
59
+ "LICENSE",
60
+ "CHANGELOG.md"
61
+ ],
62
+ "devDependencies": {
63
+ "@babel/core": "^7.26.0",
64
+ "@babel/preset-env": "^7.26.0",
65
+ "@babel/preset-typescript": "^7.26.0",
66
+ "@testing-library/react": "^16.3.2",
67
+ "@types/jest": "^29.2.1",
68
+ "@types/react": "~19.1.1",
69
+ "babel-jest": "^29.7.0",
70
+ "babel-preset-expo": "~55.0.8",
71
+ "eslint": "~9.39.4",
72
+ "eslint-config-universe": "^15.0.3",
73
+ "expo": "^56.0.3",
74
+ "jest": "^29.7.0",
75
+ "jest-environment-jsdom": "^30.4.1",
76
+ "prettier": "^3.0.0",
77
+ "react": "19.2.3",
78
+ "react-dom": "^19.2.3",
79
+ "react-native": "0.82.1",
80
+ "react-test-renderer": "^19.2.6",
81
+ "typescript": "^5.9.2",
82
+ "yjs": "^13.6.27"
83
+ },
84
+ "jest": {
85
+ "testEnvironment": "jsdom",
86
+ "roots": [
87
+ "<rootDir>/src"
88
+ ],
89
+ "transform": {
90
+ "^.+\\.(ts|tsx|js|jsx)$": [
91
+ "babel-jest",
92
+ {
93
+ "presets": [
94
+ [
95
+ "@babel/preset-env",
96
+ {
97
+ "targets": {
98
+ "node": "current"
99
+ }
100
+ }
101
+ ],
102
+ [
103
+ "@babel/preset-react",
104
+ {
105
+ "runtime": "automatic"
106
+ }
107
+ ],
108
+ "@babel/preset-typescript"
109
+ ]
110
+ }
111
+ ]
112
+ },
113
+ "testMatch": [
114
+ "<rootDir>/src/**/*.test.ts",
115
+ "<rootDir>/src/**/*.test.tsx"
116
+ ]
117
+ },
118
+ "peerDependencies": {
119
+ "expo": "*",
120
+ "react": "*",
121
+ "react-native": "*",
122
+ "yjs": "^13.6.0"
123
+ }
124
+ }