@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release. See [`SPEC.md`](./SPEC.md) for the full design and the
|
|
6
|
+
[`README`](./README.md) for usage.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- TypeScript layer
|
|
11
|
+
- `YTextInput`, `YTextRenderer`, `useYTextEditor`
|
|
12
|
+
- `defaultSchema` covering the v0.1 canonical mark set
|
|
13
|
+
(`bold`, `italic`, `underline`, `strike`, `code`, `link`)
|
|
14
|
+
- `Y.Text` ↔ runs delta translation with loop-breaking and
|
|
15
|
+
`Y.RelativePosition`-based caret stability
|
|
16
|
+
- iOS native view (`YjsTextView`) backed by `UITextView`
|
|
17
|
+
- `NSAttributedString` rendering with schema-driven mark styles
|
|
18
|
+
- Edit capture via `textView(_:shouldChangeTextIn:replacementText:)`
|
|
19
|
+
- Focus / selection events, imperative `focus` / `blur` / `setSelection`
|
|
20
|
+
- Android native view (`YjsTextView`) backed by `AppCompatEditText`
|
|
21
|
+
- `SpannableStringBuilder` rendering with the same schema-driven marks
|
|
22
|
+
- Edit capture via `TextWatcher`
|
|
23
|
+
- Focus / selection events, imperative methods matching iOS
|
|
24
|
+
- Expo Modules registration and `expo-module.config.json` wiring
|
|
25
|
+
- Example Expo app (`example/`) exercising every default mark and
|
|
26
|
+
programmatic mutations via `useYTextEditor`
|
|
27
|
+
- Example bare RN app (`example-bare/`) using `@react-native-community/cli`
|
|
28
|
+
scaffolding with Expo Modules autolinking wired in via Podfile,
|
|
29
|
+
settings.gradle, `AppDelegate.swift`, and `MainApplication.kt`. Proves the
|
|
30
|
+
library installs without the Expo app shell.
|
|
31
|
+
- pnpm workspace setup so the examples consume the library directly from
|
|
32
|
+
source, with Metro configs (one per example) that follow the workspace
|
|
33
|
+
symlink and block-list the library's hoisted `node_modules` to avoid
|
|
34
|
+
duplicate-React hook errors
|
|
35
|
+
- Jest suite (59 tests, ~1.5s, `babel-jest` + jsdom env)
|
|
36
|
+
- `schema.test.ts` — `compileRenderSpec` / `validateMarks` (required attrs,
|
|
37
|
+
type checks, unknown-mark rejection, declared-default fallback)
|
|
38
|
+
- `bridge.test.ts` — `ytextToRuns`, `inheritedMarksAt`, `marksInRange`,
|
|
39
|
+
`applyReplaceEdit` (insert / delete / replace / inherit / sanitise /
|
|
40
|
+
null-out-on-violation), `formatRange` (origin tag + observer round-trip),
|
|
41
|
+
and round-trip stability of `Y.RelativePosition` selection across remote
|
|
42
|
+
edits
|
|
43
|
+
- `useYTextEditor.test.ts` — every imperative command driven through a
|
|
44
|
+
fake `EditorHandle`, including the collapsed-caret pending-mark
|
|
45
|
+
behaviour and the marks-at-selection overlay logic
|
|
46
|
+
- `YTextRenderer.test.tsx` — verifies merged underline+strike decorations
|
|
47
|
+
and that `null` / `false` mark values are not applied
|
|
48
|
+
|
|
49
|
+
### Fixed (during initial build verification)
|
|
50
|
+
|
|
51
|
+
- `applyReplaceEdit` actively `null`s out any inherited mark that fails the
|
|
52
|
+
schema sanitiser (instead of silently letting it leak into newly inserted
|
|
53
|
+
characters via Y.Text's default attribute inheritance). Also honours
|
|
54
|
+
`pendingMarks[mark] = false` as an explicit "do not inherit".
|
|
55
|
+
- Android: switched `EditText.BufferType` → `TextView.BufferType`
|
|
56
|
+
(`EditText` doesn't re-expose the nested enum in Kotlin) and replaced the
|
|
57
|
+
deprecated `DisplayMetrics.scaledDensity` with
|
|
58
|
+
`TypedValue.applyDimension(COMPLEX_UNIT_SP, ...)`.
|
|
59
|
+
- `formatRange` no longer tags its transaction with `ORIGIN_LOCAL_VIEW`, so
|
|
60
|
+
toolbar-driven format changes round-trip through the Y.Text observer and
|
|
61
|
+
trigger a re-render of the editor view (previously only the read-only
|
|
62
|
+
`YTextRenderer` would update on the first toggle).
|
|
63
|
+
- `null` / `false` mark values are now treated as "explicitly off" everywhere
|
|
64
|
+
(JS renderer, iOS `attributes(for:)`, Android `applyRunSpans`) so a Y.Text
|
|
65
|
+
delta of `{ bold: null }` does not visually re-bold the affected run.
|
|
66
|
+
- Selection prop is now sent to native as a single atomic
|
|
67
|
+
`pendingSelection: { from, to, version }` record. Splitting it into
|
|
68
|
+
separate `selection` + `selectionVersion` props was triggering Fabric prop
|
|
69
|
+
re-ordering races where the version setter fired before the range setter
|
|
70
|
+
and the caret jumped back to a stale selection.
|
|
71
|
+
- `runs` setter on both iOS and Android now unconditionally rebuilds the
|
|
72
|
+
attributed/spannable text. The previous `contentVersion` companion prop
|
|
73
|
+
was racing with `runs` under Fabric (version setter fired first, runs
|
|
74
|
+
arrived second) and causing every format toggle to appear one edit
|
|
75
|
+
behind.
|
|
76
|
+
- iOS native re-assignment of `attributedText` / Android re-assignment of
|
|
77
|
+
`editText.text` is now wrapped in a `suppressSelectionEvents` guard.
|
|
78
|
+
UIKit / Android both fire a synthetic selection-change to `{0, 0}` as
|
|
79
|
+
part of swapping the text in place; without the guard that
|
|
80
|
+
`onNativeSelectionChange` would overwrite JS's `selectionRef` and the
|
|
81
|
+
next toolbar tap would read a collapsed caret and silently do nothing.
|
|
82
|
+
- iOS combined bold + italic on system fonts: `UIFontDescriptor`'s symbolic-
|
|
83
|
+
trait path drops `.traitBold` when `.traitItalic` is also requested.
|
|
84
|
+
`makeFont` now tries multiple descriptor paths and, when no true bold-
|
|
85
|
+
italic font can be resolved, returns an `NSObliqueness` slant on top of
|
|
86
|
+
the bold font so the user still sees both effects.
|
|
87
|
+
|
|
88
|
+
### Known limitations (see `SPEC.md` § Versioning roadmap)
|
|
89
|
+
|
|
90
|
+
- CJK / complex IME composition uses each platform's default editor
|
|
91
|
+
behaviour
|
|
92
|
+
- Paste from clipboard does not preserve formatting attributes
|
|
93
|
+
- Hardware keyboard shortcuts not bound by default
|
|
94
|
+
- Android: applying `code` (or any other `backgroundColor` mark) over a
|
|
95
|
+
selected range visually masks the system selection highlight. The
|
|
96
|
+
selection is still active and toolbar toggles still apply to it; this is
|
|
97
|
+
a draw-order quirk of `BackgroundColorSpan` in the Android text pipeline.
|
|
98
|
+
- All bridge traffic goes through the standard Expo Modules ABI (no JSI
|
|
99
|
+
fast path yet)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# @eclosion-tech/react-native-yjs-text
|
|
2
|
+
|
|
3
|
+
Native React Native rich text editor backed by `Y.Text`. **No WebView, no
|
|
4
|
+
`contenteditable`, no DOM.**
|
|
5
|
+
|
|
6
|
+
Built as an Expo Module — works in any Expo SDK 52+ project (and in bare RN
|
|
7
|
+
projects via `expo-modules-core`).
|
|
8
|
+
|
|
9
|
+
> **Status:** v0.1.0 — alpha. The TypeScript layer, Swift iOS view, and Kotlin
|
|
10
|
+
> Android view all compile cleanly. Both example apps (Expo-managed in
|
|
11
|
+
> `example/` and bare RN in `example-bare/`) link the library and produce a
|
|
12
|
+
> working `.app` / `.apk`. A 50-case Jest suite covers the bridge, schema,
|
|
13
|
+
> and editor hook. The remaining v0.1 work is on-device behavioural
|
|
14
|
+
> verification (typing, mark toggling, caret stability under remote edits).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
Every existing rich-text editor in React Native today wraps a browser editor
|
|
21
|
+
in a `WebView`: `react-native-pell-rich-editor`, `@10play/tentap-editor`,
|
|
22
|
+
`react-native-cn-quill`. They all pay the WebView tax — worse performance,
|
|
23
|
+
broken keyboard behaviour, gesture / scroll conflicts, accessibility
|
|
24
|
+
regressions, platform-specific bugs that don't reproduce in browsers.
|
|
25
|
+
|
|
26
|
+
`Y.Text` is already a platform-independent rich-text primitive — a sequence of
|
|
27
|
+
characters with arbitrary formatting attributes. That maps cleanly onto
|
|
28
|
+
`contenteditable` (via `y-prosemirror` on the web) **and** onto
|
|
29
|
+
`NSAttributedString` / `Spannable` on native mobile. This library is the
|
|
30
|
+
native-view side of that binding.
|
|
31
|
+
|
|
32
|
+
See [`SPEC.md`](./SPEC.md) for the full design rationale, non-goals, and
|
|
33
|
+
versioning roadmap.
|
|
34
|
+
|
|
35
|
+
## What ships in v0.1
|
|
36
|
+
|
|
37
|
+
- `YTextInput` — editable view backed by `UITextView` (iOS) /
|
|
38
|
+
`AppCompatEditText` (Android)
|
|
39
|
+
- `YTextRenderer` — read-only renderer that uses RN's native `<Text>` with
|
|
40
|
+
attributed spans (no `UITextView` instance, much cheaper)
|
|
41
|
+
- `useYTextEditor` — imperative editor hook (`toggleMark`, `setMark`,
|
|
42
|
+
`removeMark`, `insertText`, `deleteRange`, `focus`, `blur`,
|
|
43
|
+
`getSelection`, `setSelection`, `marksAtSelection`)
|
|
44
|
+
- `defaultSchema` — `bold`, `italic`, `underline`, `strike`, `code`, `link`
|
|
45
|
+
(matching the y-prosemirror mark convention so the same `Y.Text` content
|
|
46
|
+
edits identically on web)
|
|
47
|
+
- Bidirectional sync between user edits and `Y.Text` mutations, with
|
|
48
|
+
`Y.RelativePosition`-based caret preservation across remote insertions
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
### Expo (managed or prebuild)
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npx expo install @eclosion-tech/react-native-yjs-text yjs isomorphic-webcrypto
|
|
56
|
+
npx expo prebuild # if you don't already have native projects
|
|
57
|
+
npx expo run:ios # or run:android
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Expo Go can't load the library (native code); you need a dev build.
|
|
61
|
+
|
|
62
|
+
> **Why `isomorphic-webcrypto`?** yjs's underlying utility lib `lib0`
|
|
63
|
+
> generates random doc / client IDs via `crypto.getRandomValues`. React
|
|
64
|
+
> Native doesn't expose webcrypto by default, so `lib0`'s RN entry-point
|
|
65
|
+
> hard-requires `isomorphic-webcrypto` and expects the consumer to install
|
|
66
|
+
> it. (You can alternatively install `react-native-get-random-values` and
|
|
67
|
+
> `import 'react-native-get-random-values'` *before* the first yjs import —
|
|
68
|
+
> then `lib0` skips its webcrypto shim and uses the polyfilled native one.
|
|
69
|
+
> Either works; pick whichever your app already has.)
|
|
70
|
+
|
|
71
|
+
### Bare React Native (RNCLI scaffold, brownfield, etc.)
|
|
72
|
+
|
|
73
|
+
The library uses the Expo Modules API for its native side, so a bare RN host
|
|
74
|
+
needs Expo Modules autolinking. **This does not turn your app into "an Expo
|
|
75
|
+
app"** — no app shell, no Expo Go runtime, no expo-router. You're only
|
|
76
|
+
opting into native autolinking.
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm install @eclosion-tech/react-native-yjs-text yjs expo isomorphic-webcrypto
|
|
80
|
+
# expo brings in expo-modules-core + the autolinking scripts; nothing else
|
|
81
|
+
# isomorphic-webcrypto is required by yjs's lib0 dep — see the Expo section
|
|
82
|
+
# above for the alternative `react-native-get-random-values` route
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then three small wiring changes:
|
|
86
|
+
|
|
87
|
+
1. **`ios/Podfile`** — load Expo's autolinking + call `use_expo_modules!`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
require File.join(
|
|
91
|
+
File.dirname(`node --print "require.resolve('expo/package.json')"`),
|
|
92
|
+
"scripts/autolinking"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
platform :ios, '16.4' # expo-modules-core requires 16.4+
|
|
96
|
+
|
|
97
|
+
target 'YourApp' do
|
|
98
|
+
use_expo_modules!
|
|
99
|
+
config = use_native_modules!
|
|
100
|
+
# ...rest of the standard RN Podfile target...
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
2. **`ios/YourApp/AppDelegate.swift`** — subclass Expo's app-delegate /
|
|
105
|
+
factory wrappers so Expo Modules register at boot:
|
|
106
|
+
|
|
107
|
+
```swift
|
|
108
|
+
internal import Expo // matches the access level of the generated
|
|
109
|
+
// ExpoModulesProvider.swift
|
|
110
|
+
import React
|
|
111
|
+
import ReactAppDependencyProvider
|
|
112
|
+
|
|
113
|
+
@main
|
|
114
|
+
class AppDelegate: ExpoAppDelegate {
|
|
115
|
+
var window: UIWindow?
|
|
116
|
+
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
|
|
117
|
+
var reactNativeFactory: RCTReactNativeFactory?
|
|
118
|
+
|
|
119
|
+
public override func application(/* ... */) -> Bool {
|
|
120
|
+
let delegate = ReactNativeDelegate()
|
|
121
|
+
let factory = ExpoReactNativeFactory(delegate: delegate)
|
|
122
|
+
delegate.dependencyProvider = RCTAppDependencyProvider()
|
|
123
|
+
reactNativeDelegate = delegate
|
|
124
|
+
reactNativeFactory = factory
|
|
125
|
+
window = UIWindow(frame: UIScreen.main.bounds)
|
|
126
|
+
factory.startReactNative(withModuleName: "YourApp",
|
|
127
|
+
in: window, launchOptions: launchOptions)
|
|
128
|
+
return super.application(application,
|
|
129
|
+
didFinishLaunchingWithOptions: launchOptions)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { /* ... */ }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
3. **`android/settings.gradle`** — pull in the Expo autolinking gradle
|
|
137
|
+
plugin and call `expoAutolinking.useExpoModules()`. **`android/app/
|
|
138
|
+
src/main/java/.../MainApplication.kt`** — replace
|
|
139
|
+
`DefaultReactHost.getDefaultReactHost(...)` with
|
|
140
|
+
`ExpoReactHostFactory.getDefaultReactHost(...)` and add
|
|
141
|
+
`ApplicationLifecycleDispatcher.onApplicationCreate(this)` /
|
|
142
|
+
`onConfigurationChanged(this, newConfig)`.
|
|
143
|
+
|
|
144
|
+
The complete working set of changes for a fresh `@react-native-community/
|
|
145
|
+
cli init` project lives in [`example-bare/`](./example-bare/) — copy from
|
|
146
|
+
there if you'd rather diff against a known-good reference than apply the
|
|
147
|
+
snippets above by hand.
|
|
148
|
+
|
|
149
|
+
## Usage
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
import {
|
|
153
|
+
YTextInput,
|
|
154
|
+
YTextRenderer,
|
|
155
|
+
defaultSchema,
|
|
156
|
+
useYTextEditor,
|
|
157
|
+
} from '@eclosion-tech/react-native-yjs-text';
|
|
158
|
+
import * as Y from 'yjs';
|
|
159
|
+
|
|
160
|
+
const doc = new Y.Doc();
|
|
161
|
+
const yText = doc.getText('content');
|
|
162
|
+
|
|
163
|
+
function Editor() {
|
|
164
|
+
const editor = useYTextEditor(yText, defaultSchema);
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<Toolbar editor={editor} />
|
|
168
|
+
<YTextInput
|
|
169
|
+
yText={yText}
|
|
170
|
+
schema={defaultSchema}
|
|
171
|
+
style={{ fontSize: 16, color: '#111' }}
|
|
172
|
+
placeholder="Start typing…"
|
|
173
|
+
/>
|
|
174
|
+
<YTextRenderer yText={yText} schema={defaultSchema} />
|
|
175
|
+
</>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function Toolbar({ editor }) {
|
|
180
|
+
return (
|
|
181
|
+
<View>
|
|
182
|
+
<Button onPress={() => editor.toggleMark('bold')} title="B" />
|
|
183
|
+
<Button onPress={() => editor.toggleMark('italic')} title="I" />
|
|
184
|
+
<Button
|
|
185
|
+
onPress={() => editor.setMark('link', { href: 'https://yjs.dev' })}
|
|
186
|
+
title="Link"
|
|
187
|
+
/>
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The library **does not own the `Y.Doc` / `Y.Text`** — the consumer creates,
|
|
194
|
+
syncs (via `y-websocket`, `y-indexeddb`, a custom provider, whatever), and
|
|
195
|
+
disposes of them. The component subscribes to a passed-in `Y.Text` and edits
|
|
196
|
+
it in place.
|
|
197
|
+
|
|
198
|
+
See [`example/App.tsx`](./example/App.tsx) for a full demo that exercises
|
|
199
|
+
every default mark, programmatic mutations, and `YTextRenderer`.
|
|
200
|
+
|
|
201
|
+
## Schemas
|
|
202
|
+
|
|
203
|
+
A schema declares which marks the editor accepts. Marks not in the schema
|
|
204
|
+
are dropped on insert — this is what makes the editor safe for AI-generated
|
|
205
|
+
content. Custom marks are declared by extending `defaultSchema`:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { defaultSchema, type Schema } from '@eclosion-tech/react-native-yjs-text';
|
|
209
|
+
|
|
210
|
+
const schema: Schema = {
|
|
211
|
+
marks: {
|
|
212
|
+
...defaultSchema.marks,
|
|
213
|
+
'pear.mention': {
|
|
214
|
+
attrs: { userId: { type: 'string', required: true } },
|
|
215
|
+
renderStyle: { color: '#0066cc', backgroundColor: '#e6f0ff' },
|
|
216
|
+
onTap: (attrs) => console.log('mention tapped', attrs.userId),
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Only a subset of RN's `TextStyle` survives the bridge into native rendering:
|
|
223
|
+
`fontWeight`, `fontStyle`, `fontFamily`, `fontSize`, `color`,
|
|
224
|
+
`backgroundColor`, `textDecorationLine`. Keys outside that list are silently
|
|
225
|
+
dropped (a deliberate cross-platform-safety choice — see
|
|
226
|
+
`RENDERABLE_TEXT_STYLE_KEYS` in `src/schema.ts`).
|
|
227
|
+
|
|
228
|
+
## Building & running
|
|
229
|
+
|
|
230
|
+
This repo is a pnpm workspace with three members:
|
|
231
|
+
|
|
232
|
+
- `.` — the library
|
|
233
|
+
- `example/` — Expo SDK 56 demo (uses `expo run:ios` / `expo run:android`)
|
|
234
|
+
- `example-bare/` — vanilla `@react-native-community/cli` demo (no Expo
|
|
235
|
+
runtime around the host app, only Expo Modules autolinking on the native
|
|
236
|
+
side)
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
# From the repo root
|
|
240
|
+
pnpm install # installs all three workspace members
|
|
241
|
+
pnpm prepare # builds the library's TS into `build/`
|
|
242
|
+
pnpm test # runs the Jest suite (50 tests, ~1s)
|
|
243
|
+
|
|
244
|
+
# Expo example
|
|
245
|
+
cd example
|
|
246
|
+
npx pod-install # or: cd ios && pod install
|
|
247
|
+
npx expo run:ios # or: npx expo run:android
|
|
248
|
+
|
|
249
|
+
# Bare RN example
|
|
250
|
+
cd example-bare
|
|
251
|
+
(cd ios && pod install)
|
|
252
|
+
npx react-native run-ios # or: npx react-native run-android
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The bare example doubles as the canonical integration test for non-Expo
|
|
256
|
+
hosts; if it boots, your `@react-native-community/cli init`-shaped project
|
|
257
|
+
will too.
|
|
258
|
+
|
|
259
|
+
## Architecture
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
┌─────────────────────────────────────────┐
|
|
263
|
+
│ Consumer application │
|
|
264
|
+
│ - manages the Y.Doc / Y.Text │
|
|
265
|
+
│ - builds toolbar / slash menu UI │
|
|
266
|
+
│ - composes multiple YTextInputs into │
|
|
267
|
+
│ its own block model │
|
|
268
|
+
└──────────────┬──────────────────────────┘
|
|
269
|
+
│ <YTextInput yText={...} schema={...} />
|
|
270
|
+
│
|
|
271
|
+
┌──────────────▼──────────────────────────┐
|
|
272
|
+
│ react-native-yjs-text (TS layer) │
|
|
273
|
+
│ - YTextInput / YTextRenderer │
|
|
274
|
+
│ - useYTextEditor command API │
|
|
275
|
+
│ - schema definitions / mark validation│
|
|
276
|
+
│ - Y.Text ↔ runs ↔ native event bridge │
|
|
277
|
+
└──────────────┬──────────────────────────┘
|
|
278
|
+
│ Expo Modules (Fabric / TurboModules)
|
|
279
|
+
│
|
|
280
|
+
┌──────────────▼──────────┬───────────────┐
|
|
281
|
+
│ iOS native │ Android native│
|
|
282
|
+
│ - YjsTextView (ExpoView │ - YjsTextView │
|
|
283
|
+
│ + UITextView subview) │ (ExpoView + │
|
|
284
|
+
│ - NSAttributedString │ AppCompatEditText)
|
|
285
|
+
│ - UITextViewDelegate │ - SpannableStringBuilder
|
|
286
|
+
│ shouldChangeTextIn │ - TextWatcher │
|
|
287
|
+
└─────────────────────────┴───────────────┘
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Known gaps in v0.1
|
|
291
|
+
|
|
292
|
+
Per the spec ([`SPEC.md` § Versioning roadmap](./SPEC.md#versioning-roadmap)):
|
|
293
|
+
|
|
294
|
+
- **IME composition for CJK / Korean / IMEs with multi-stage input.** We
|
|
295
|
+
hand the composition string straight through to `Y.Text` on every keystroke
|
|
296
|
+
in the composition session, which collaborators see as a flurry of
|
|
297
|
+
character-by-character inserts and deletes instead of one atomic
|
|
298
|
+
composition commit. A "compose locally, transact on commit" path lands in
|
|
299
|
+
v0.2 once we add `UITextInput.markedTextRange` / Android
|
|
300
|
+
`InputConnection.setComposingText` handling.
|
|
301
|
+
- **Paste from system clipboard** loses formatting — content is inserted as
|
|
302
|
+
plain runs.
|
|
303
|
+
- **Hardware-keyboard shortcuts** (⌘B / Ctrl+B etc.) are not bound by
|
|
304
|
+
default; consumers can wire their own via the imperative editor API.
|
|
305
|
+
- **Bridge traffic** goes through the standard Expo Modules ABI on every
|
|
306
|
+
edit. v0.2 will move the typing-hot-path event (`onContentChange`) to a
|
|
307
|
+
JSI direct call to skip JSON ser/de per keystroke.
|
|
308
|
+
- **No first-party block model.** Each `YTextInput` edits one inline
|
|
309
|
+
region; the consumer composes them into paragraphs / headings / lists.
|
|
310
|
+
See `SPEC.md` for why this is by design.
|
|
311
|
+
- **Selection highlight is visually masked by `backgroundColor` marks.**
|
|
312
|
+
Both `UITextView` (iOS) and `EditText` (Android) draw `NSAttributedString.
|
|
313
|
+
backgroundColor` / `BackgroundColorSpan` on top of the system selection
|
|
314
|
+
highlight, so selecting text that already has, e.g., the default `code`
|
|
315
|
+
mark applied makes the selection rectangle invisible inside the marked
|
|
316
|
+
range. The selection is still active functionally — toolbar toggles and
|
|
317
|
+
keyboard actions work as normal — only the visual rectangle is occluded.
|
|
318
|
+
iOS users still see the two grab-handle circles. Mirrors how Mobile
|
|
319
|
+
Safari, iOS Notes, and most platform editors render the same situation.
|
|
320
|
+
|
|
321
|
+
## License
|
|
322
|
+
|
|
323
|
+
MIT. See [`LICENSE`](./LICENSE).
|