@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.
- package/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/SPEC.md +346 -0
- package/android/build.gradle +26 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/tech/eclosion/yjstext/YjsTextModule.kt +77 -0
- package/android/src/main/java/tech/eclosion/yjstext/YjsTextSupport.kt +135 -0
- package/android/src/main/java/tech/eclosion/yjstext/YjsTextView.kt +424 -0
- package/build/YTextInput.d.ts +23 -0
- package/build/YTextInput.d.ts.map +1 -0
- package/build/YTextInput.js +178 -0
- package/build/YTextInput.js.map +1 -0
- package/build/YTextRenderer.d.ts +15 -0
- package/build/YTextRenderer.d.ts.map +1 -0
- package/build/YTextRenderer.js +85 -0
- package/build/YTextRenderer.js.map +1 -0
- package/build/bridge.d.ts +88 -0
- package/build/bridge.d.ts.map +1 -0
- package/build/bridge.js +231 -0
- package/build/bridge.js.map +1 -0
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +12 -0
- package/build/index.js.map +1 -0
- package/build/internal/NativeYTextInputView.d.ts +114 -0
- package/build/internal/NativeYTextInputView.d.ts.map +1 -0
- package/build/internal/NativeYTextInputView.js +27 -0
- package/build/internal/NativeYTextInputView.js.map +1 -0
- package/build/internal/editorRegistry.d.ts +23 -0
- package/build/internal/editorRegistry.d.ts.map +1 -0
- package/build/internal/editorRegistry.js +26 -0
- package/build/internal/editorRegistry.js.map +1 -0
- package/build/schema.d.ts +51 -0
- package/build/schema.d.ts.map +1 -0
- package/build/schema.js +134 -0
- package/build/schema.js.map +1 -0
- package/build/types.d.ts +182 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +11 -0
- package/build/types.js.map +1 -0
- package/build/useYTextEditor.d.ts +21 -0
- package/build/useYTextEditor.d.ts.map +1 -0
- package/build/useYTextEditor.js +166 -0
- package/build/useYTextEditor.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/YjsText.podspec +30 -0
- package/ios/YjsTextModule.swift +75 -0
- package/ios/YjsTextSupport.swift +135 -0
- package/ios/YjsTextView.swift +464 -0
- package/package.json +124 -0
- package/src/YTextInput.tsx +263 -0
- package/src/YTextRenderer.tsx +96 -0
- package/src/bridge.ts +283 -0
- package/src/index.ts +21 -0
- package/src/internal/NativeYTextInputView.tsx +126 -0
- package/src/internal/editorRegistry.ts +50 -0
- package/src/schema.ts +157 -0
- package/src/types.ts +194 -0
- 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"]}
|