@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,424 @@
1
+ package tech.eclosion.yjstext
2
+
3
+ import android.content.Context
4
+ import android.graphics.Typeface
5
+ import android.text.Editable
6
+ import android.text.Spannable
7
+ import android.text.SpannableStringBuilder
8
+ import android.text.TextWatcher
9
+ import android.text.style.AbsoluteSizeSpan
10
+ import android.text.style.BackgroundColorSpan
11
+ import android.text.style.ForegroundColorSpan
12
+ import android.text.style.StrikethroughSpan
13
+ import android.text.style.StyleSpan
14
+ import android.text.style.TypefaceSpan
15
+ import android.text.style.UnderlineSpan
16
+ import android.util.TypedValue
17
+ import android.view.Gravity
18
+ import android.view.ViewGroup
19
+ import android.view.inputmethod.InputMethodManager
20
+ import android.widget.TextView
21
+ import androidx.appcompat.widget.AppCompatEditText
22
+ import expo.modules.kotlin.AppContext
23
+ import expo.modules.kotlin.viewevent.EventDispatcher
24
+ import expo.modules.kotlin.views.ExpoView
25
+ import org.json.JSONObject
26
+
27
+ /**
28
+ * Native editor view backing `<YTextInput>` on Android.
29
+ *
30
+ * Like the iOS counterpart, the Expo `View()` declaration needs the outer
31
+ * class to subclass `ExpoView` (a `LinearLayout` on Android), so we host an
32
+ * internal `AppCompatEditText` as the actual editable surface. All TextWatcher
33
+ * / focus / selection plumbing lives here.
34
+ *
35
+ * Edit capture uses a standard `TextWatcher`. `before/onTextChanged` deliver
36
+ * the changed range as `(start, before, count)` — we synthesise a `replace`
37
+ * edit `{from=start, to=start+before, text=s[start..start+count]}` and forward
38
+ * to JS. Less precise than iOS's `shouldChangeTextIn` (we can't intercept and
39
+ * reject), but sufficient for v0.1; full IME correctness via a custom
40
+ * `InputConnection` lands in v0.2 per the spec.
41
+ *
42
+ * Loop-breaking: when JS pushes runs down in response to a remote edit, we
43
+ * set `suppressEdits = true` for the duration of the programmatic
44
+ * `editText.text = ...` write so the TextWatcher doesn't bounce the change
45
+ * back up to JS.
46
+ */
47
+ class YjsTextView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
48
+ // MARK: Props (matched to iOS / JS bridge contract by name)
49
+
50
+ /**
51
+ * Runs from JS. We always rebuild on change — `runs` is the SOLE trigger
52
+ * for re-rendering text. We used to have a separate `contentVersion` Int
53
+ * prop that bumped on every Y.Text change to *force* a rebuild even
54
+ * when only attributes changed (the short-circuit on `runs` compared
55
+ * plain text only). That two-prop design was order-sensitive: Expo
56
+ * Modules / Fabric doesn't guarantee setter order, and `contentVersion`
57
+ * was sometimes applied before `runs` — so the version setter rebuilt
58
+ * from stale runs and the runs setter then short-circuited. Net result:
59
+ * every attribute toggle lagged by one update. Collapsing to a single
60
+ * trigger makes the race structurally impossible.
61
+ */
62
+ var runs: List<YjsTextRunRecord> = emptyList()
63
+ set(value) {
64
+ field = value
65
+ applyRuns()
66
+ }
67
+
68
+ var renderSpec: Map<String, YjsTextMarkStyleRecord> = emptyMap()
69
+ set(value) {
70
+ field = value
71
+ applyRuns()
72
+ }
73
+
74
+ /**
75
+ * Latest JS-pushed selection bundle. We re-apply only when the embedded
76
+ * `version` changes (so a no-op re-render where React sends the same
77
+ * object reference again doesn't fight the user's in-flight selection).
78
+ * See `YjsTextSelectionRecord` for why this is one bundled prop instead
79
+ * of two separate ones.
80
+ */
81
+ var pendingSelection: YjsTextSelectionRecord? = null
82
+ set(value) {
83
+ field = value
84
+ val next = value ?: return
85
+ if (next.version != lastAppliedSelectionVersion) {
86
+ lastAppliedSelectionVersion = next.version
87
+ setSelection(next.from, next.to)
88
+ }
89
+ }
90
+ private var lastAppliedSelectionVersion: Int = -1
91
+
92
+ var editable: Boolean = true
93
+ set(value) {
94
+ field = value
95
+ editText.isFocusable = value
96
+ editText.isFocusableInTouchMode = value
97
+ editText.isCursorVisible = value
98
+ editText.isEnabled = value
99
+ }
100
+
101
+ var placeholder: String? = null
102
+ set(value) {
103
+ field = value
104
+ editText.hint = value
105
+ }
106
+
107
+ var placeholderColor: Int? = null
108
+ set(value) {
109
+ field = value
110
+ if (value != null) editText.setHintTextColor(value)
111
+ }
112
+
113
+ var baseFontSize: Double? = null
114
+ set(value) {
115
+ field = value
116
+ applyBaseFont()
117
+ applyRuns()
118
+ }
119
+
120
+ var baseFontFamily: String? = null
121
+ set(value) {
122
+ field = value
123
+ applyBaseFont()
124
+ applyRuns()
125
+ }
126
+
127
+ var baseColor: Int? = null
128
+ set(value) {
129
+ field = value
130
+ if (value != null) editText.setTextColor(value)
131
+ applyRuns()
132
+ }
133
+
134
+ var baseFontWeight: String? = null
135
+ set(value) {
136
+ field = value
137
+ applyBaseFont()
138
+ applyRuns()
139
+ }
140
+
141
+ var baseFontStyle: String? = null
142
+ set(value) {
143
+ field = value
144
+ applyBaseFont()
145
+ applyRuns()
146
+ }
147
+
148
+ // MARK: Events
149
+
150
+ private val onContentChange by EventDispatcher<YjsTextContentChangePayload>()
151
+ private val onNativeSelectionChange by EventDispatcher<YjsTextSelectionEventPayload>()
152
+ private val onFocusChange by EventDispatcher<YjsTextFocusEventPayload>()
153
+
154
+ @Suppress("unused")
155
+ private val onMarkTap by EventDispatcher<YjsTextMarkTapPayload>()
156
+
157
+ // MARK: Internal state
158
+
159
+ internal val editText: HostEditText = HostEditText(context)
160
+ private var suppressEdits: Boolean = false
161
+ private var suppressSelectionEvents: Boolean = false
162
+
163
+ init {
164
+ orientation = VERTICAL
165
+ gravity = Gravity.TOP
166
+
167
+ editText.layoutParams = ViewGroup.LayoutParams(
168
+ ViewGroup.LayoutParams.MATCH_PARENT,
169
+ ViewGroup.LayoutParams.MATCH_PARENT
170
+ )
171
+ editText.background = null
172
+ editText.setPadding(0, 0, 0, 0)
173
+ editText.gravity = Gravity.TOP or Gravity.START
174
+ editText.includeFontPadding = false
175
+ editText.setHorizontallyScrolling(false)
176
+ editText.isVerticalScrollBarEnabled = false
177
+ editText.maxLines = Int.MAX_VALUE
178
+
179
+ editText.addTextChangedListener(EditWatcher())
180
+ editText.setOnFocusChangeListener { _, hasFocus ->
181
+ val payload = YjsTextFocusEventPayload().apply { focused = hasFocus }
182
+ onFocusChange(payload)
183
+ }
184
+ editText.selectionListener = { start, end ->
185
+ if (!suppressSelectionEvents) {
186
+ val payload = YjsTextSelectionEventPayload().apply {
187
+ from = start
188
+ to = end
189
+ }
190
+ onNativeSelectionChange(payload)
191
+ }
192
+ }
193
+
194
+ addView(editText)
195
+ applyBaseFont()
196
+ }
197
+
198
+ // MARK: Imperative
199
+
200
+ fun focusInput() {
201
+ editText.requestFocus()
202
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
203
+ imm?.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
204
+ }
205
+
206
+ fun blurInput() {
207
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
208
+ imm?.hideSoftInputFromWindow(editText.windowToken, 0)
209
+ editText.clearFocus()
210
+ }
211
+
212
+ fun isInputFocused(): Boolean = editText.isFocused
213
+
214
+ fun setSelection(from: Int, to: Int) {
215
+ val length = editText.text?.length ?: 0
216
+ val clampedFrom = from.coerceIn(0, length)
217
+ val clampedTo = to.coerceIn(clampedFrom, length)
218
+ suppressSelectionEvents = true
219
+ editText.setSelection(clampedFrom, clampedTo)
220
+ suppressSelectionEvents = false
221
+ }
222
+
223
+ // MARK: Render
224
+
225
+ private fun applyBaseFont() {
226
+ val size = (baseFontSize ?: 16.0).toFloat()
227
+ editText.textSize = size
228
+
229
+ val bold = baseFontWeight == "bold"
230
+ val italic = baseFontStyle == "italic"
231
+ val style = when {
232
+ bold && italic -> Typeface.BOLD_ITALIC
233
+ bold -> Typeface.BOLD
234
+ italic -> Typeface.ITALIC
235
+ else -> Typeface.NORMAL
236
+ }
237
+ val base = if (baseFontFamily != null) Typeface.create(baseFontFamily, style) else Typeface.defaultFromStyle(style)
238
+ editText.setTypeface(base, style)
239
+
240
+ baseColor?.let { editText.setTextColor(it) }
241
+ }
242
+
243
+ /**
244
+ * Rebuild and apply the spannable. Called whenever `runs`, `renderSpec`,
245
+ * or any base-style prop changes. No more `force` parameter / short-
246
+ * circuit: see the `runs` comment above for why.
247
+ */
248
+ private fun applyRuns() {
249
+ val newString = buildSpannableString()
250
+ val previousStart = editText.selectionStart.coerceAtLeast(0)
251
+ val previousEnd = editText.selectionEnd.coerceAtLeast(0)
252
+
253
+ // CRITICAL: suppress *both* TextWatcher and selection echoes around the
254
+ // `setText` call. `EditText.setText` resets the cursor to position 0 as
255
+ // part of replacing the underlying CharSequence, which triggers
256
+ // `onSelectionChanged(0, 0)`. If we let that fire `onNativeSelectionChange`
257
+ // to JS, the JS `selectionRef` is overwritten with `{0, 0}` and the next
258
+ // toolbar toggle reads a stale collapsed caret — drops into pending-mark
259
+ // mode and visually does nothing. See the matching iOS comment in
260
+ // `applyRunsIfNeeded` for the full story.
261
+ suppressEdits = true
262
+ suppressSelectionEvents = true
263
+ editText.setText(newString, TextView.BufferType.SPANNABLE)
264
+ suppressSelectionEvents = false
265
+ suppressEdits = false
266
+
267
+ val length = newString.length
268
+ val clampedStart = previousStart.coerceIn(0, length)
269
+ val clampedEnd = previousEnd.coerceIn(clampedStart, length)
270
+ suppressSelectionEvents = true
271
+ editText.setSelection(clampedStart, clampedEnd)
272
+ suppressSelectionEvents = false
273
+ }
274
+
275
+
276
+ private fun buildSpannableString(): SpannableStringBuilder {
277
+ val builder = SpannableStringBuilder()
278
+ var cursor = 0
279
+ for (run in runs) {
280
+ builder.append(run.text)
281
+ val runStart = cursor
282
+ val runEnd = cursor + run.text.length
283
+ cursor = runEnd
284
+ applyRunSpans(builder, run, runStart, runEnd)
285
+ }
286
+ return builder
287
+ }
288
+
289
+ private fun applyRunSpans(
290
+ builder: SpannableStringBuilder,
291
+ run: YjsTextRunRecord,
292
+ start: Int,
293
+ end: Int
294
+ ) {
295
+ if (start == end) return
296
+ val marks = parseMarks(run.marksJson)
297
+ var bold = false
298
+ var italic = false
299
+ var familyOverride: String? = null
300
+ var sizeOverride: Float? = null
301
+ var foreground: Int? = null
302
+ var background: Int? = null
303
+ var underline = false
304
+ var strike = false
305
+
306
+ for ((markName, markValue) in marks) {
307
+ // `null` / `false` mark values are the "explicitly off" sentinel
308
+ // produced by the insert path's `computeInsertAttrs` when a mark
309
+ // was toggled off via the pending overlay. Treat them as "mark
310
+ // absent" — without this, typed text after a pending-mark-off
311
+ // toggle would still get the mark's spans applied.
312
+ if (markValue == null || markValue == JSONObject.NULL) continue
313
+ if (markValue is Boolean && !markValue) continue
314
+ val style = renderSpec[markName] ?: continue
315
+ if (style.fontWeight == "bold") bold = true
316
+ if (style.fontStyle == "italic") italic = true
317
+ style.fontFamily?.let { familyOverride = it }
318
+ style.fontSize?.let { sizeOverride = it.toFloat() }
319
+ style.color?.let { YjsTextColor.parse(it)?.let { c -> foreground = c } }
320
+ style.backgroundColor?.let { YjsTextColor.parse(it)?.let { c -> background = c } }
321
+ style.textDecorationLine?.let { decoration ->
322
+ if (decoration.contains("underline")) underline = true
323
+ if (decoration.contains("line-through")) strike = true
324
+ }
325
+ }
326
+
327
+ val typefaceStyle = when {
328
+ bold && italic -> Typeface.BOLD_ITALIC
329
+ bold -> Typeface.BOLD
330
+ italic -> Typeface.ITALIC
331
+ else -> Typeface.NORMAL
332
+ }
333
+ if (typefaceStyle != Typeface.NORMAL) {
334
+ builder.setSpan(StyleSpan(typefaceStyle), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
335
+ }
336
+ familyOverride?.let {
337
+ builder.setSpan(TypefaceSpan(it), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
338
+ }
339
+ sizeOverride?.let {
340
+ // Treat the JS-side `fontSize` as SP (matches RN's TextStyle convention).
341
+ // `TypedValue.applyDimension(COMPLEX_UNIT_SP, ...)` respects user font
342
+ // scaling and supersedes the deprecated `DisplayMetrics.scaledDensity`.
343
+ val px = TypedValue.applyDimension(
344
+ TypedValue.COMPLEX_UNIT_SP,
345
+ it,
346
+ resources.displayMetrics
347
+ ).toInt()
348
+ builder.setSpan(AbsoluteSizeSpan(px), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
349
+ }
350
+ foreground?.let {
351
+ builder.setSpan(ForegroundColorSpan(it), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
352
+ }
353
+ background?.let {
354
+ builder.setSpan(BackgroundColorSpan(it), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
355
+ }
356
+ if (underline) {
357
+ builder.setSpan(UnderlineSpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
358
+ }
359
+ if (strike) {
360
+ builder.setSpan(StrikethroughSpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
361
+ }
362
+ }
363
+
364
+ private fun parseMarks(json: String): Map<String, Any?> {
365
+ if (json.isEmpty()) return emptyMap()
366
+ return try {
367
+ val obj = JSONObject(json)
368
+ val result = mutableMapOf<String, Any?>()
369
+ val keys = obj.keys()
370
+ while (keys.hasNext()) {
371
+ val key = keys.next()
372
+ result[key] = obj.opt(key)
373
+ }
374
+ result
375
+ } catch (_: Throwable) {
376
+ emptyMap()
377
+ }
378
+ }
379
+
380
+ // MARK: TextWatcher
381
+
382
+ private inner class EditWatcher : TextWatcher {
383
+ /// The `(start, before)` range captured before the change is applied,
384
+ /// stashed so onTextChanged can construct the `replace` payload using both
385
+ /// the old and new state. Both fields filled in in beforeTextChanged.
386
+ private var pendingFrom: Int = 0
387
+ private var pendingTo: Int = 0
388
+
389
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
390
+ if (suppressEdits) return
391
+ pendingFrom = start
392
+ pendingTo = start + count
393
+ }
394
+
395
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
396
+ if (suppressEdits || s == null) return
397
+ val insertedText = if (count > 0) s.subSequence(start, start + count).toString() else ""
398
+ val payload = YjsTextContentChangePayload().apply {
399
+ type = "replace"
400
+ from = pendingFrom
401
+ to = pendingTo
402
+ text = insertedText
403
+ }
404
+ onContentChange(payload)
405
+ }
406
+
407
+ override fun afterTextChanged(s: Editable?) { /* no-op */ }
408
+ }
409
+
410
+ /**
411
+ * Internal `AppCompatEditText` subclass that exposes selection changes via
412
+ * a typed Kotlin lambda. `onSelectionChanged` is `protected` on the parent
413
+ * so we can't observe it externally without subclassing.
414
+ */
415
+ internal class HostEditText(context: Context) : AppCompatEditText(context) {
416
+ /** Invoked on every selection change, including programmatic ones. */
417
+ var selectionListener: ((start: Int, end: Int) -> Unit)? = null
418
+
419
+ override fun onSelectionChanged(selStart: Int, selEnd: Int) {
420
+ super.onSelectionChanged(selStart, selEnd)
421
+ selectionListener?.invoke(selStart, selEnd)
422
+ }
423
+ }
424
+ }
@@ -0,0 +1,23 @@
1
+ import * as React from 'react';
2
+ import type { YTextInputProps } from './types';
3
+ /**
4
+ * Editable inline rich-text view backed by a Y.Text shared type.
5
+ *
6
+ * Mounts a native `UITextView` (iOS) or `AppCompatEditText` (Android), keeps it
7
+ * in sync with the Y.Text both ways:
8
+ *
9
+ * - User edits captured on the native side flow up as `replace` deltas,
10
+ * applied to the Y.Text inside a transaction tagged `ORIGIN_LOCAL_VIEW`.
11
+ * The component's own Y.Text observer skips re-rendering for these — the
12
+ * view already has the change locally.
13
+ *
14
+ * - Remote edits (any transaction with a different origin) cause the
15
+ * component to recompute runs and push them down as a new prop. The caret
16
+ * is preserved across these by capturing it as a Y.RelativePosition before
17
+ * every render and re-resolving it after.
18
+ *
19
+ * This component does not own the Y.Doc / Y.Text. The consumer creates and
20
+ * disposes of them.
21
+ */
22
+ export declare function YTextInput(props: YTextInputProps): React.ReactElement;
23
+ //# sourceMappingURL=YTextInput.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"YTextInput.d.ts","sourceRoot":"","sources":["../src/YTextInput.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAoB/B,OAAO,KAAK,EAA6B,eAAe,EAAE,MAAM,SAAS,CAAC;AAG1E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,KAAK,CAAC,YAAY,CA4NrE"}
@@ -0,0 +1,178 @@
1
+ import * as React from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { applyReplaceEdit, captureRelativeSelection, resolveRelativeSelection, ytextToRuns, } from './bridge';
4
+ import { NativeYTextInputView, parseMarkTapAttrs, serializeRun, } from './internal/NativeYTextInputView';
5
+ import { registerEditor, unregisterEditor } from './internal/editorRegistry';
6
+ import { compileRenderSpec } from './schema';
7
+ import { ORIGIN_LOCAL_VIEW } from './types';
8
+ /**
9
+ * Editable inline rich-text view backed by a Y.Text shared type.
10
+ *
11
+ * Mounts a native `UITextView` (iOS) or `AppCompatEditText` (Android), keeps it
12
+ * in sync with the Y.Text both ways:
13
+ *
14
+ * - User edits captured on the native side flow up as `replace` deltas,
15
+ * applied to the Y.Text inside a transaction tagged `ORIGIN_LOCAL_VIEW`.
16
+ * The component's own Y.Text observer skips re-rendering for these — the
17
+ * view already has the change locally.
18
+ *
19
+ * - Remote edits (any transaction with a different origin) cause the
20
+ * component to recompute runs and push them down as a new prop. The caret
21
+ * is preserved across these by capturing it as a Y.RelativePosition before
22
+ * every render and re-resolving it after.
23
+ *
24
+ * This component does not own the Y.Doc / Y.Text. The consumer creates and
25
+ * disposes of them.
26
+ */
27
+ export function YTextInput(props) {
28
+ const { yText, schema, style, placeholder, placeholderTextColor, autoFocus, editable = true, onSelectionChange, onFocus, onBlur, onSchemaViolation, } = props;
29
+ const nativeRef = React.useRef(null);
30
+ const focusedRef = React.useRef(false);
31
+ const selectionRef = React.useRef(null);
32
+ const relativeSelectionRef = React.useRef(null);
33
+ const pendingMarksRef = React.useRef({});
34
+ // We keep runs in state, but the *trigger* to re-derive them is Y.Text
35
+ // observe events, not React state changes. So we have a counter that bumps
36
+ // whenever a non-local Y.Text change happens; runs/selection are derived
37
+ // synchronously from yText.toDelta() at render time.
38
+ const [contentVersion, setContentVersion] = React.useState(0);
39
+ // The selection we *want* the native view to be at after the next re-render,
40
+ // bundled together with a monotonic `version` field that the native side
41
+ // uses to decide whether to re-apply. `from`/`to`/`version` MUST be sent
42
+ // as one atomic prop — see SerializedPendingSelection for why.
43
+ const [pendingSelection, setPendingSelection] = React.useState(null);
44
+ // Snapshot runs at render time from the current Y.Text. This is cheap
45
+ // (Y.Text.toDelta walks the type once) and means we don't have to manage
46
+ // a stale runs cache. We serialise marks to JSON because the bridge can't
47
+ // type-cleanly carry heterogeneous mark-attr dictionaries.
48
+ const serializedRuns = React.useMemo(() => ytextToRuns(yText).map(serializeRun), [yText, contentVersion]);
49
+ const renderSpec = React.useMemo(() => compileRenderSpec(schema), [schema]);
50
+ // Decompose the consumer-supplied style into a flat TextStyle so we can
51
+ // forward the baseline font/colour into the native view as discrete props.
52
+ // (The native view also receives the original style for layout.)
53
+ const flatStyle = React.useMemo(() => StyleSheet.flatten(style), [style]);
54
+ // Subscribe to Y.Text observe events. For non-local origins we have to
55
+ // re-render and reposition the caret; for local-view origins we no-op (the
56
+ // native view already has the change).
57
+ React.useEffect(() => {
58
+ function onYTextChange(_event, transaction) {
59
+ if (transaction.origin === ORIGIN_LOCAL_VIEW) {
60
+ // Local edit round-trip: the view's text already matches the Y.Text.
61
+ // We may still need to bump contentVersion to keep React's view of
62
+ // "what runs are" consistent, but we must NOT push the runs back down
63
+ // or we lose the caret. So skip entirely.
64
+ return;
65
+ }
66
+ // Remote / programmatic edit. Recover the caret via RelativePosition.
67
+ const rel = relativeSelectionRef.current;
68
+ if (rel) {
69
+ const resolved = resolveRelativeSelection(yText, rel);
70
+ if (resolved) {
71
+ setPendingSelection((prev) => ({
72
+ from: resolved.from,
73
+ to: resolved.to,
74
+ version: (prev?.version ?? 0) + 1,
75
+ }));
76
+ }
77
+ else {
78
+ // Lost positions — clamp caret to end.
79
+ const len = yText.length;
80
+ setPendingSelection((prev) => ({
81
+ from: len,
82
+ to: len,
83
+ version: (prev?.version ?? 0) + 1,
84
+ }));
85
+ }
86
+ }
87
+ setContentVersion((v) => v + 1);
88
+ }
89
+ yText.observe(onYTextChange);
90
+ return () => {
91
+ yText.unobserve(onYTextChange);
92
+ };
93
+ }, [yText]);
94
+ // Register this editor instance against the Y.Text so the imperative editor
95
+ // hook (or any consumer code) can find the live view.
96
+ React.useEffect(() => {
97
+ const handle = {
98
+ getSelection: () => (focusedRef.current ? selectionRef.current : null),
99
+ getPendingMarks: () => pendingMarksRef.current,
100
+ setPendingMarks: (marks) => {
101
+ pendingMarksRef.current = marks;
102
+ },
103
+ focus: () => {
104
+ nativeRef.current?.focus();
105
+ },
106
+ blur: () => {
107
+ nativeRef.current?.blur();
108
+ },
109
+ isFocused: () => focusedRef.current,
110
+ setSelection: (range) => {
111
+ setPendingSelection((prev) => ({
112
+ from: range.from,
113
+ to: range.to,
114
+ version: (prev?.version ?? 0) + 1,
115
+ }));
116
+ },
117
+ };
118
+ registerEditor(yText, handle);
119
+ return () => {
120
+ unregisterEditor(yText, handle);
121
+ };
122
+ }, [yText]);
123
+ // Capture a fresh relative selection whenever the absolute selection or the
124
+ // Y.Text content changes — so that subsequent remote edits can resolve back
125
+ // to "the same logical place".
126
+ React.useEffect(() => {
127
+ if (selectionRef.current) {
128
+ relativeSelectionRef.current = captureRelativeSelection(yText, selectionRef.current);
129
+ }
130
+ }, [yText, contentVersion]);
131
+ // autoFocus: defer to a mount-time effect so the native view has registered
132
+ // its tag with the bridge before we ask it to focus.
133
+ React.useEffect(() => {
134
+ if (autoFocus) {
135
+ const id = setTimeout(() => nativeRef.current?.focus(), 0);
136
+ return () => clearTimeout(id);
137
+ }
138
+ return undefined;
139
+ }, [autoFocus]);
140
+ // Edit captured from native. Apply to Y.Text inside a local transaction.
141
+ const handleContentChange = React.useCallback((event) => {
142
+ const { from, to, text } = event.nativeEvent;
143
+ applyReplaceEdit(yText, { type: 'replace', from, to, text }, schema, onSchemaViolation, pendingMarksRef.current);
144
+ // Any insert consumes pending marks.
145
+ if (text.length > 0) {
146
+ pendingMarksRef.current = {};
147
+ }
148
+ }, [yText, schema, onSchemaViolation]);
149
+ const handleSelectionChange = React.useCallback((event) => {
150
+ const sel = {
151
+ from: event.nativeEvent.from,
152
+ to: event.nativeEvent.to,
153
+ };
154
+ selectionRef.current = sel;
155
+ relativeSelectionRef.current = captureRelativeSelection(yText, sel);
156
+ // A selection change with a non-zero length, or that moves the caret
157
+ // away from a position with pending marks, clears pending marks. The
158
+ // simplest rule that matches user expectation: clear on any explicit
159
+ // selection change. Typing-driven selection moves already cleared
160
+ // pending marks in handleContentChange.
161
+ pendingMarksRef.current = {};
162
+ onSelectionChange?.(sel);
163
+ }, [yText, onSelectionChange]);
164
+ const handleFocusChange = React.useCallback((event) => {
165
+ const focused = event.nativeEvent.focused;
166
+ focusedRef.current = focused;
167
+ if (focused)
168
+ onFocus?.();
169
+ else
170
+ onBlur?.();
171
+ }, [onFocus, onBlur]);
172
+ const handleMarkTap = React.useCallback((event) => {
173
+ const { mark, attrsJson } = event.nativeEvent;
174
+ schema.marks[mark]?.onTap?.(parseMarkTapAttrs(attrsJson));
175
+ }, [schema]);
176
+ return (<NativeYTextInputView ref={nativeRef} runs={serializedRuns} renderSpec={renderSpec} pendingSelection={pendingSelection} editable={editable} placeholder={placeholder} placeholderColor={placeholderTextColor} baseFontSize={typeof flatStyle?.fontSize === 'number' ? flatStyle.fontSize : undefined} baseFontFamily={typeof flatStyle?.fontFamily === 'string' ? flatStyle.fontFamily : undefined} baseColor={typeof flatStyle?.color === 'string' ? flatStyle.color : undefined} baseFontWeight={typeof flatStyle?.fontWeight === 'string' ? flatStyle.fontWeight : undefined} baseFontStyle={typeof flatStyle?.fontStyle === 'string' ? flatStyle.fontStyle : undefined} style={style} onContentChange={handleContentChange} onNativeSelectionChange={handleSelectionChange} onFocusChange={handleFocusChange} onMarkTap={handleMarkTap}/>);
177
+ }
178
+ //# sourceMappingURL=YTextInput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"YTextInput.js","sourceRoot":"","sources":["../src/YTextInput.tsx"],"names":[],"mappings":"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","sourcesContent":["import * as React from 'react';\nimport { StyleSheet, type TextStyle } from 'react-native';\nimport * as Y from 'yjs';\n\nimport {\n applyReplaceEdit,\n captureRelativeSelection,\n resolveRelativeSelection,\n ytextToRuns,\n type RelativeSelection,\n} from './bridge';\nimport {\n NativeYTextInputView,\n parseMarkTapAttrs,\n serializeRun,\n type NativeYTextInputViewRef,\n type SerializedRun,\n} from './internal/NativeYTextInputView';\nimport { registerEditor, unregisterEditor, type EditorHandle } from './internal/editorRegistry';\nimport { compileRenderSpec } from './schema';\nimport type { MarkAttrs, SelectionRange, YTextInputProps } from './types';\nimport { ORIGIN_LOCAL_VIEW } from './types';\n\n/**\n * Editable inline rich-text view backed by a Y.Text shared type.\n *\n * Mounts a native `UITextView` (iOS) or `AppCompatEditText` (Android), keeps it\n * in sync with the Y.Text both ways:\n *\n * - User edits captured on the native side flow up as `replace` deltas,\n * applied to the Y.Text inside a transaction tagged `ORIGIN_LOCAL_VIEW`.\n * The component's own Y.Text observer skips re-rendering for these — the\n * view already has the change locally.\n *\n * - Remote edits (any transaction with a different origin) cause the\n * component to recompute runs and push them down as a new prop. The caret\n * is preserved across these by capturing it as a Y.RelativePosition before\n * every render and re-resolving it after.\n *\n * This component does not own the Y.Doc / Y.Text. The consumer creates and\n * disposes of them.\n */\nexport function YTextInput(props: YTextInputProps): React.ReactElement {\n const {\n yText,\n schema,\n style,\n placeholder,\n placeholderTextColor,\n autoFocus,\n editable = true,\n onSelectionChange,\n onFocus,\n onBlur,\n onSchemaViolation,\n } = props;\n\n const nativeRef = React.useRef<NativeYTextInputViewRef | null>(null);\n const focusedRef = React.useRef<boolean>(false);\n const selectionRef = React.useRef<SelectionRange | null>(null);\n const relativeSelectionRef = React.useRef<RelativeSelection | null>(null);\n const pendingMarksRef = React.useRef<MarkAttrs>({});\n\n // We keep runs in state, but the *trigger* to re-derive them is Y.Text\n // observe events, not React state changes. So we have a counter that bumps\n // whenever a non-local Y.Text change happens; runs/selection are derived\n // synchronously from yText.toDelta() at render time.\n const [contentVersion, setContentVersion] = React.useState(0);\n // The selection we *want* the native view to be at after the next re-render,\n // bundled together with a monotonic `version` field that the native side\n // uses to decide whether to re-apply. `from`/`to`/`version` MUST be sent\n // as one atomic prop — see SerializedPendingSelection for why.\n const [pendingSelection, setPendingSelection] = React.useState<{\n from: number;\n to: number;\n version: number;\n } | null>(null);\n\n // Snapshot runs at render time from the current Y.Text. This is cheap\n // (Y.Text.toDelta walks the type once) and means we don't have to manage\n // a stale runs cache. We serialise marks to JSON because the bridge can't\n // type-cleanly carry heterogeneous mark-attr dictionaries.\n const serializedRuns: SerializedRun[] = React.useMemo(\n () => ytextToRuns(yText).map(serializeRun),\n [yText, contentVersion]\n );\n const renderSpec = React.useMemo(() => compileRenderSpec(schema), [schema]);\n\n // Decompose the consumer-supplied style into a flat TextStyle so we can\n // forward the baseline font/colour into the native view as discrete props.\n // (The native view also receives the original style for layout.)\n const flatStyle = React.useMemo(\n () => StyleSheet.flatten(style) as TextStyle | undefined,\n [style]\n );\n\n // Subscribe to Y.Text observe events. For non-local origins we have to\n // re-render and reposition the caret; for local-view origins we no-op (the\n // native view already has the change).\n React.useEffect(() => {\n function onYTextChange(_event: Y.YTextEvent, transaction: Y.Transaction): void {\n if (transaction.origin === ORIGIN_LOCAL_VIEW) {\n // Local edit round-trip: the view's text already matches the Y.Text.\n // We may still need to bump contentVersion to keep React's view of\n // \"what runs are\" consistent, but we must NOT push the runs back down\n // or we lose the caret. So skip entirely.\n return;\n }\n // Remote / programmatic edit. Recover the caret via RelativePosition.\n const rel = relativeSelectionRef.current;\n if (rel) {\n const resolved = resolveRelativeSelection(yText, rel);\n if (resolved) {\n setPendingSelection((prev) => ({\n from: resolved.from,\n to: resolved.to,\n version: (prev?.version ?? 0) + 1,\n }));\n } else {\n // Lost positions — clamp caret to end.\n const len = yText.length;\n setPendingSelection((prev) => ({\n from: len,\n to: len,\n version: (prev?.version ?? 0) + 1,\n }));\n }\n }\n setContentVersion((v) => v + 1);\n }\n yText.observe(onYTextChange);\n return () => {\n yText.unobserve(onYTextChange);\n };\n }, [yText]);\n\n // Register this editor instance against the Y.Text so the imperative editor\n // hook (or any consumer code) can find the live view.\n React.useEffect(() => {\n const handle: EditorHandle = {\n getSelection: () => (focusedRef.current ? selectionRef.current : null),\n getPendingMarks: () => pendingMarksRef.current,\n setPendingMarks: (marks) => {\n pendingMarksRef.current = marks;\n },\n focus: () => {\n nativeRef.current?.focus();\n },\n blur: () => {\n nativeRef.current?.blur();\n },\n isFocused: () => focusedRef.current,\n setSelection: (range) => {\n setPendingSelection((prev) => ({\n from: range.from,\n to: range.to,\n version: (prev?.version ?? 0) + 1,\n }));\n },\n };\n registerEditor(yText, handle);\n return () => {\n unregisterEditor(yText, handle);\n };\n }, [yText]);\n\n // Capture a fresh relative selection whenever the absolute selection or the\n // Y.Text content changes — so that subsequent remote edits can resolve back\n // to \"the same logical place\".\n React.useEffect(() => {\n if (selectionRef.current) {\n relativeSelectionRef.current = captureRelativeSelection(yText, selectionRef.current);\n }\n }, [yText, contentVersion]);\n\n // autoFocus: defer to a mount-time effect so the native view has registered\n // its tag with the bridge before we ask it to focus.\n React.useEffect(() => {\n if (autoFocus) {\n const id = setTimeout(() => nativeRef.current?.focus(), 0);\n return () => clearTimeout(id);\n }\n return undefined;\n }, [autoFocus]);\n\n // Edit captured from native. Apply to Y.Text inside a local transaction.\n const handleContentChange = React.useCallback(\n (event: { nativeEvent: { type: 'replace'; from: number; to: number; text: string } }) => {\n const { from, to, text } = event.nativeEvent;\n applyReplaceEdit(\n yText,\n { type: 'replace', from, to, text },\n schema,\n onSchemaViolation,\n pendingMarksRef.current\n );\n // Any insert consumes pending marks.\n if (text.length > 0) {\n pendingMarksRef.current = {};\n }\n },\n [yText, schema, onSchemaViolation]\n );\n\n const handleSelectionChange = React.useCallback(\n (event: { nativeEvent: { from: number; to: number } }) => {\n const sel: SelectionRange = {\n from: event.nativeEvent.from,\n to: event.nativeEvent.to,\n };\n selectionRef.current = sel;\n relativeSelectionRef.current = captureRelativeSelection(yText, sel);\n // A selection change with a non-zero length, or that moves the caret\n // away from a position with pending marks, clears pending marks. The\n // simplest rule that matches user expectation: clear on any explicit\n // selection change. Typing-driven selection moves already cleared\n // pending marks in handleContentChange.\n pendingMarksRef.current = {};\n onSelectionChange?.(sel);\n },\n [yText, onSelectionChange]\n );\n\n const handleFocusChange = React.useCallback(\n (event: { nativeEvent: { focused: boolean } }) => {\n const focused = event.nativeEvent.focused;\n focusedRef.current = focused;\n if (focused) onFocus?.();\n else onBlur?.();\n },\n [onFocus, onBlur]\n );\n\n const handleMarkTap = React.useCallback(\n (event: { nativeEvent: { mark: string; attrsJson: string } }) => {\n const { mark, attrsJson } = event.nativeEvent;\n schema.marks[mark]?.onTap?.(parseMarkTapAttrs(attrsJson));\n },\n [schema]\n );\n\n return (\n <NativeYTextInputView\n ref={nativeRef}\n runs={serializedRuns}\n renderSpec={renderSpec}\n pendingSelection={pendingSelection}\n editable={editable}\n placeholder={placeholder}\n placeholderColor={placeholderTextColor}\n baseFontSize={typeof flatStyle?.fontSize === 'number' ? flatStyle.fontSize : undefined}\n baseFontFamily={typeof flatStyle?.fontFamily === 'string' ? flatStyle.fontFamily : undefined}\n baseColor={typeof flatStyle?.color === 'string' ? flatStyle.color : undefined}\n baseFontWeight={typeof flatStyle?.fontWeight === 'string' ? flatStyle.fontWeight : undefined}\n baseFontStyle={typeof flatStyle?.fontStyle === 'string' ? flatStyle.fontStyle : undefined}\n style={style}\n onContentChange={handleContentChange}\n onNativeSelectionChange={handleSelectionChange}\n onFocusChange={handleFocusChange}\n onMarkTap={handleMarkTap}\n />\n );\n}\n"]}