@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,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
|
+
}
|