@eclosion-tech/react-native-yjs-text 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/README.md +323 -0
  4. package/SPEC.md +346 -0
  5. package/android/build.gradle +26 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/tech/eclosion/yjstext/YjsTextModule.kt +77 -0
  8. package/android/src/main/java/tech/eclosion/yjstext/YjsTextSupport.kt +135 -0
  9. package/android/src/main/java/tech/eclosion/yjstext/YjsTextView.kt +424 -0
  10. package/build/YTextInput.d.ts +23 -0
  11. package/build/YTextInput.d.ts.map +1 -0
  12. package/build/YTextInput.js +178 -0
  13. package/build/YTextInput.js.map +1 -0
  14. package/build/YTextRenderer.d.ts +15 -0
  15. package/build/YTextRenderer.d.ts.map +1 -0
  16. package/build/YTextRenderer.js +85 -0
  17. package/build/YTextRenderer.js.map +1 -0
  18. package/build/bridge.d.ts +88 -0
  19. package/build/bridge.d.ts.map +1 -0
  20. package/build/bridge.js +231 -0
  21. package/build/bridge.js.map +1 -0
  22. package/build/index.d.ts +13 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +12 -0
  25. package/build/index.js.map +1 -0
  26. package/build/internal/NativeYTextInputView.d.ts +114 -0
  27. package/build/internal/NativeYTextInputView.d.ts.map +1 -0
  28. package/build/internal/NativeYTextInputView.js +27 -0
  29. package/build/internal/NativeYTextInputView.js.map +1 -0
  30. package/build/internal/editorRegistry.d.ts +23 -0
  31. package/build/internal/editorRegistry.d.ts.map +1 -0
  32. package/build/internal/editorRegistry.js +26 -0
  33. package/build/internal/editorRegistry.js.map +1 -0
  34. package/build/schema.d.ts +51 -0
  35. package/build/schema.d.ts.map +1 -0
  36. package/build/schema.js +134 -0
  37. package/build/schema.js.map +1 -0
  38. package/build/types.d.ts +182 -0
  39. package/build/types.d.ts.map +1 -0
  40. package/build/types.js +11 -0
  41. package/build/types.js.map +1 -0
  42. package/build/useYTextEditor.d.ts +21 -0
  43. package/build/useYTextEditor.d.ts.map +1 -0
  44. package/build/useYTextEditor.js +166 -0
  45. package/build/useYTextEditor.js.map +1 -0
  46. package/expo-module.config.json +9 -0
  47. package/ios/YjsText.podspec +30 -0
  48. package/ios/YjsTextModule.swift +75 -0
  49. package/ios/YjsTextSupport.swift +135 -0
  50. package/ios/YjsTextView.swift +464 -0
  51. package/package.json +124 -0
  52. package/src/YTextInput.tsx +263 -0
  53. package/src/YTextRenderer.tsx +96 -0
  54. package/src/bridge.ts +283 -0
  55. package/src/index.ts +21 -0
  56. package/src/internal/NativeYTextInputView.tsx +126 -0
  57. package/src/internal/editorRegistry.ts +50 -0
  58. package/src/schema.ts +157 -0
  59. package/src/types.ts +194 -0
  60. package/src/useYTextEditor.ts +171 -0
@@ -0,0 +1,263 @@
1
+ import * as React from 'react';
2
+ import { StyleSheet, type TextStyle } from 'react-native';
3
+ import * as Y from 'yjs';
4
+
5
+ import {
6
+ applyReplaceEdit,
7
+ captureRelativeSelection,
8
+ resolveRelativeSelection,
9
+ ytextToRuns,
10
+ type RelativeSelection,
11
+ } from './bridge';
12
+ import {
13
+ NativeYTextInputView,
14
+ parseMarkTapAttrs,
15
+ serializeRun,
16
+ type NativeYTextInputViewRef,
17
+ type SerializedRun,
18
+ } from './internal/NativeYTextInputView';
19
+ import { registerEditor, unregisterEditor, type EditorHandle } from './internal/editorRegistry';
20
+ import { compileRenderSpec } from './schema';
21
+ import type { MarkAttrs, SelectionRange, YTextInputProps } from './types';
22
+ import { ORIGIN_LOCAL_VIEW } from './types';
23
+
24
+ /**
25
+ * Editable inline rich-text view backed by a Y.Text shared type.
26
+ *
27
+ * Mounts a native `UITextView` (iOS) or `AppCompatEditText` (Android), keeps it
28
+ * in sync with the Y.Text both ways:
29
+ *
30
+ * - User edits captured on the native side flow up as `replace` deltas,
31
+ * applied to the Y.Text inside a transaction tagged `ORIGIN_LOCAL_VIEW`.
32
+ * The component's own Y.Text observer skips re-rendering for these — the
33
+ * view already has the change locally.
34
+ *
35
+ * - Remote edits (any transaction with a different origin) cause the
36
+ * component to recompute runs and push them down as a new prop. The caret
37
+ * is preserved across these by capturing it as a Y.RelativePosition before
38
+ * every render and re-resolving it after.
39
+ *
40
+ * This component does not own the Y.Doc / Y.Text. The consumer creates and
41
+ * disposes of them.
42
+ */
43
+ export function YTextInput(props: YTextInputProps): React.ReactElement {
44
+ const {
45
+ yText,
46
+ schema,
47
+ style,
48
+ placeholder,
49
+ placeholderTextColor,
50
+ autoFocus,
51
+ editable = true,
52
+ onSelectionChange,
53
+ onFocus,
54
+ onBlur,
55
+ onSchemaViolation,
56
+ } = props;
57
+
58
+ const nativeRef = React.useRef<NativeYTextInputViewRef | null>(null);
59
+ const focusedRef = React.useRef<boolean>(false);
60
+ const selectionRef = React.useRef<SelectionRange | null>(null);
61
+ const relativeSelectionRef = React.useRef<RelativeSelection | null>(null);
62
+ const pendingMarksRef = React.useRef<MarkAttrs>({});
63
+
64
+ // We keep runs in state, but the *trigger* to re-derive them is Y.Text
65
+ // observe events, not React state changes. So we have a counter that bumps
66
+ // whenever a non-local Y.Text change happens; runs/selection are derived
67
+ // synchronously from yText.toDelta() at render time.
68
+ const [contentVersion, setContentVersion] = React.useState(0);
69
+ // The selection we *want* the native view to be at after the next re-render,
70
+ // bundled together with a monotonic `version` field that the native side
71
+ // uses to decide whether to re-apply. `from`/`to`/`version` MUST be sent
72
+ // as one atomic prop — see SerializedPendingSelection for why.
73
+ const [pendingSelection, setPendingSelection] = React.useState<{
74
+ from: number;
75
+ to: number;
76
+ version: number;
77
+ } | null>(null);
78
+
79
+ // Snapshot runs at render time from the current Y.Text. This is cheap
80
+ // (Y.Text.toDelta walks the type once) and means we don't have to manage
81
+ // a stale runs cache. We serialise marks to JSON because the bridge can't
82
+ // type-cleanly carry heterogeneous mark-attr dictionaries.
83
+ const serializedRuns: SerializedRun[] = React.useMemo(
84
+ () => ytextToRuns(yText).map(serializeRun),
85
+ [yText, contentVersion]
86
+ );
87
+ const renderSpec = React.useMemo(() => compileRenderSpec(schema), [schema]);
88
+
89
+ // Decompose the consumer-supplied style into a flat TextStyle so we can
90
+ // forward the baseline font/colour into the native view as discrete props.
91
+ // (The native view also receives the original style for layout.)
92
+ const flatStyle = React.useMemo(
93
+ () => StyleSheet.flatten(style) as TextStyle | undefined,
94
+ [style]
95
+ );
96
+
97
+ // Subscribe to Y.Text observe events. For non-local origins we have to
98
+ // re-render and reposition the caret; for local-view origins we no-op (the
99
+ // native view already has the change).
100
+ React.useEffect(() => {
101
+ function onYTextChange(_event: Y.YTextEvent, transaction: Y.Transaction): void {
102
+ if (transaction.origin === ORIGIN_LOCAL_VIEW) {
103
+ // Local edit round-trip: the view's text already matches the Y.Text.
104
+ // We may still need to bump contentVersion to keep React's view of
105
+ // "what runs are" consistent, but we must NOT push the runs back down
106
+ // or we lose the caret. So skip entirely.
107
+ return;
108
+ }
109
+ // Remote / programmatic edit. Recover the caret via RelativePosition.
110
+ const rel = relativeSelectionRef.current;
111
+ if (rel) {
112
+ const resolved = resolveRelativeSelection(yText, rel);
113
+ if (resolved) {
114
+ setPendingSelection((prev) => ({
115
+ from: resolved.from,
116
+ to: resolved.to,
117
+ version: (prev?.version ?? 0) + 1,
118
+ }));
119
+ } else {
120
+ // Lost positions — clamp caret to end.
121
+ const len = yText.length;
122
+ setPendingSelection((prev) => ({
123
+ from: len,
124
+ to: len,
125
+ version: (prev?.version ?? 0) + 1,
126
+ }));
127
+ }
128
+ }
129
+ setContentVersion((v) => v + 1);
130
+ }
131
+ yText.observe(onYTextChange);
132
+ return () => {
133
+ yText.unobserve(onYTextChange);
134
+ };
135
+ }, [yText]);
136
+
137
+ // Register this editor instance against the Y.Text so the imperative editor
138
+ // hook (or any consumer code) can find the live view.
139
+ React.useEffect(() => {
140
+ const handle: EditorHandle = {
141
+ getSelection: () => (focusedRef.current ? selectionRef.current : null),
142
+ getPendingMarks: () => pendingMarksRef.current,
143
+ setPendingMarks: (marks) => {
144
+ pendingMarksRef.current = marks;
145
+ },
146
+ focus: () => {
147
+ nativeRef.current?.focus();
148
+ },
149
+ blur: () => {
150
+ nativeRef.current?.blur();
151
+ },
152
+ isFocused: () => focusedRef.current,
153
+ setSelection: (range) => {
154
+ setPendingSelection((prev) => ({
155
+ from: range.from,
156
+ to: range.to,
157
+ version: (prev?.version ?? 0) + 1,
158
+ }));
159
+ },
160
+ };
161
+ registerEditor(yText, handle);
162
+ return () => {
163
+ unregisterEditor(yText, handle);
164
+ };
165
+ }, [yText]);
166
+
167
+ // Capture a fresh relative selection whenever the absolute selection or the
168
+ // Y.Text content changes — so that subsequent remote edits can resolve back
169
+ // to "the same logical place".
170
+ React.useEffect(() => {
171
+ if (selectionRef.current) {
172
+ relativeSelectionRef.current = captureRelativeSelection(yText, selectionRef.current);
173
+ }
174
+ }, [yText, contentVersion]);
175
+
176
+ // autoFocus: defer to a mount-time effect so the native view has registered
177
+ // its tag with the bridge before we ask it to focus.
178
+ React.useEffect(() => {
179
+ if (autoFocus) {
180
+ const id = setTimeout(() => nativeRef.current?.focus(), 0);
181
+ return () => clearTimeout(id);
182
+ }
183
+ return undefined;
184
+ }, [autoFocus]);
185
+
186
+ // Edit captured from native. Apply to Y.Text inside a local transaction.
187
+ const handleContentChange = React.useCallback(
188
+ (event: { nativeEvent: { type: 'replace'; from: number; to: number; text: string } }) => {
189
+ const { from, to, text } = event.nativeEvent;
190
+ applyReplaceEdit(
191
+ yText,
192
+ { type: 'replace', from, to, text },
193
+ schema,
194
+ onSchemaViolation,
195
+ pendingMarksRef.current
196
+ );
197
+ // Any insert consumes pending marks.
198
+ if (text.length > 0) {
199
+ pendingMarksRef.current = {};
200
+ }
201
+ },
202
+ [yText, schema, onSchemaViolation]
203
+ );
204
+
205
+ const handleSelectionChange = React.useCallback(
206
+ (event: { nativeEvent: { from: number; to: number } }) => {
207
+ const sel: SelectionRange = {
208
+ from: event.nativeEvent.from,
209
+ to: event.nativeEvent.to,
210
+ };
211
+ selectionRef.current = sel;
212
+ relativeSelectionRef.current = captureRelativeSelection(yText, sel);
213
+ // A selection change with a non-zero length, or that moves the caret
214
+ // away from a position with pending marks, clears pending marks. The
215
+ // simplest rule that matches user expectation: clear on any explicit
216
+ // selection change. Typing-driven selection moves already cleared
217
+ // pending marks in handleContentChange.
218
+ pendingMarksRef.current = {};
219
+ onSelectionChange?.(sel);
220
+ },
221
+ [yText, onSelectionChange]
222
+ );
223
+
224
+ const handleFocusChange = React.useCallback(
225
+ (event: { nativeEvent: { focused: boolean } }) => {
226
+ const focused = event.nativeEvent.focused;
227
+ focusedRef.current = focused;
228
+ if (focused) onFocus?.();
229
+ else onBlur?.();
230
+ },
231
+ [onFocus, onBlur]
232
+ );
233
+
234
+ const handleMarkTap = React.useCallback(
235
+ (event: { nativeEvent: { mark: string; attrsJson: string } }) => {
236
+ const { mark, attrsJson } = event.nativeEvent;
237
+ schema.marks[mark]?.onTap?.(parseMarkTapAttrs(attrsJson));
238
+ },
239
+ [schema]
240
+ );
241
+
242
+ return (
243
+ <NativeYTextInputView
244
+ ref={nativeRef}
245
+ runs={serializedRuns}
246
+ renderSpec={renderSpec}
247
+ pendingSelection={pendingSelection}
248
+ editable={editable}
249
+ placeholder={placeholder}
250
+ placeholderColor={placeholderTextColor}
251
+ baseFontSize={typeof flatStyle?.fontSize === 'number' ? flatStyle.fontSize : undefined}
252
+ baseFontFamily={typeof flatStyle?.fontFamily === 'string' ? flatStyle.fontFamily : undefined}
253
+ baseColor={typeof flatStyle?.color === 'string' ? flatStyle.color : undefined}
254
+ baseFontWeight={typeof flatStyle?.fontWeight === 'string' ? flatStyle.fontWeight : undefined}
255
+ baseFontStyle={typeof flatStyle?.fontStyle === 'string' ? flatStyle.fontStyle : undefined}
256
+ style={style}
257
+ onContentChange={handleContentChange}
258
+ onNativeSelectionChange={handleSelectionChange}
259
+ onFocusChange={handleFocusChange}
260
+ onMarkTap={handleMarkTap}
261
+ />
262
+ );
263
+ }
@@ -0,0 +1,96 @@
1
+ import * as React from 'react';
2
+ import { Text, type TextStyle } from 'react-native';
3
+
4
+ import { ytextToRuns } from './bridge';
5
+ import type { MarkAttrs, Run, Schema, YTextRendererProps } from './types';
6
+
7
+ /**
8
+ * Read-only rich-text renderer for a Y.Text.
9
+ *
10
+ * Implemented as nested `<Text>` views — no native `UITextView` /
11
+ * `AppCompatEditText` instance, no event handling, no keyboard. Significantly
12
+ * cheaper than {@link YTextInput} and the right choice for lists of read-only
13
+ * content (feeds, notification bodies, message previews, etc.).
14
+ *
15
+ * Observes the Y.Text and re-renders on every change. The consumer should
16
+ * unmount this component when the underlying Y.Text is freed.
17
+ */
18
+ export function YTextRenderer(props: YTextRendererProps): React.ReactElement {
19
+ const { yText, schema, style, numberOfLines } = props;
20
+ const [version, setVersion] = React.useState(0);
21
+
22
+ React.useEffect(() => {
23
+ function onChange(): void {
24
+ setVersion((v) => v + 1);
25
+ }
26
+ yText.observe(onChange);
27
+ return () => {
28
+ yText.unobserve(onChange);
29
+ };
30
+ }, [yText]);
31
+
32
+ const runs = React.useMemo<Run[]>(() => ytextToRuns(yText), [yText, version]);
33
+
34
+ // Empty Y.Text — render an empty Text rather than nothing, so the consumer's
35
+ // layout (line height, spacing) is preserved.
36
+ if (runs.length === 0) {
37
+ return <Text style={style} numberOfLines={numberOfLines} />;
38
+ }
39
+
40
+ return (
41
+ <Text style={style} numberOfLines={numberOfLines}>
42
+ {runs.map((run, index) => (
43
+ <RunText key={index} run={run} schema={schema} />
44
+ ))}
45
+ </Text>
46
+ );
47
+ }
48
+
49
+ function RunText({ run, schema }: { run: Run; schema: Schema }): React.ReactElement {
50
+ // Layer each mark's renderStyle in declaration order; consumer-defined
51
+ // marks later in the schema map override earlier ones if they collide —
52
+ // EXCEPT for `textDecorationLine`, which RN's style-flatten would just
53
+ // overwrite (last write wins). Multiple marks can legitimately want both
54
+ // decorations at once (e.g. `underline` + `strike`), and RN accepts the
55
+ // combined token `"underline line-through"` for both. We accumulate the
56
+ // distinct decoration tokens across marks and apply the merged value as
57
+ // the final style layer.
58
+ const styles: TextStyle[] = [];
59
+ const decorationTokens = new Set<string>();
60
+ let onPress: (() => void) | undefined;
61
+ for (const markName of Object.keys(run.marks)) {
62
+ const value = run.marks[markName];
63
+ // `null` / `false` are the "explicitly off" sentinels produced by the
64
+ // insert path's `computeInsertAttrs`. Skip them so we don't re-apply
65
+ // the mark's renderStyle to text the user just toggled it off for.
66
+ if (value === null || value === false || value === undefined) continue;
67
+ const spec = schema.marks[markName];
68
+ if (!spec) continue;
69
+ if (spec.renderStyle) {
70
+ const { textDecorationLine, ...rest } = spec.renderStyle;
71
+ if (textDecorationLine && textDecorationLine !== 'none') {
72
+ for (const token of String(textDecorationLine).split(/\s+/)) {
73
+ if (token === 'underline' || token === 'line-through') {
74
+ decorationTokens.add(token);
75
+ }
76
+ }
77
+ }
78
+ styles.push(rest);
79
+ }
80
+ if (spec.onTap) {
81
+ const attrs = (run.marks[markName] as MarkAttrs) ?? {};
82
+ onPress = () => spec.onTap?.(attrs);
83
+ }
84
+ }
85
+ if (decorationTokens.size > 0) {
86
+ // Stable order: underline first, line-through second. RN accepts
87
+ // "underline line-through" / "none" / each on its own.
88
+ const ordered = ['underline', 'line-through'].filter((t) => decorationTokens.has(t));
89
+ styles.push({ textDecorationLine: ordered.join(' ') as TextStyle['textDecorationLine'] });
90
+ }
91
+ return (
92
+ <Text style={styles} onPress={onPress}>
93
+ {run.text}
94
+ </Text>
95
+ );
96
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,283 @@
1
+ import * as Y from 'yjs';
2
+
3
+ import { validateMarks } from './schema';
4
+ import type { MarkAttrs, ReplaceEdit, Run, Schema, SelectionRange } from './types';
5
+ import { ORIGIN_LOCAL_VIEW } from './types';
6
+
7
+ /**
8
+ * Convert a Y.Text into the array of attributed runs sent down to the native
9
+ * view as a prop.
10
+ *
11
+ * v0.1 only deals with string inserts — embeds (the future inline-non-text node
12
+ * story documented in the spec) are deferred. If the underlying delta contains
13
+ * a non-string insert we stringify defensively rather than throwing.
14
+ */
15
+ export function ytextToRuns(yText: Y.Text): Run[] {
16
+ const delta = yText.toDelta() as YDeltaOp[];
17
+ const runs: Run[] = [];
18
+ for (const op of delta) {
19
+ if (op.insert == null) continue;
20
+ const text = typeof op.insert === 'string' ? op.insert : String(op.insert);
21
+ if (text.length === 0) continue;
22
+ runs.push({ text, marks: op.attributes ?? {} });
23
+ }
24
+ return runs;
25
+ }
26
+
27
+ /**
28
+ * The shape `Y.Text.toDelta()` returns. Yjs's published types are loose here;
29
+ * we narrow to what we actually consume.
30
+ */
31
+ interface YDeltaOp {
32
+ insert?: string | object;
33
+ attributes?: MarkAttrs;
34
+ }
35
+
36
+ /**
37
+ * Compute the marks that should be inherited by an insertion at the given
38
+ * character offset. Mirrors Quill / ProseMirror behaviour: the inserted text
39
+ * adopts the marks of the character immediately to its left.
40
+ *
41
+ * At offset 0 there is no left character, so we return empty marks.
42
+ */
43
+ export function inheritedMarksAt(yText: Y.Text, position: number): MarkAttrs {
44
+ if (position <= 0) return {};
45
+ const delta = yText.toDelta() as YDeltaOp[];
46
+ let cursor = 0;
47
+ for (const op of delta) {
48
+ const len = typeof op.insert === 'string' ? op.insert.length : op.insert ? 1 : 0;
49
+ if (len === 0) continue;
50
+ if (cursor + len >= position) {
51
+ return op.attributes ? { ...op.attributes } : {};
52
+ }
53
+ cursor += len;
54
+ }
55
+ return {};
56
+ }
57
+
58
+ /**
59
+ * Get the marks that are common to *every* character in `[from, to)`. Used by
60
+ * `toggleMark` to decide whether to add or remove.
61
+ */
62
+ export function marksInRange(yText: Y.Text, from: number, to: number): MarkAttrs {
63
+ if (to <= from) return inheritedMarksAt(yText, from);
64
+ const delta = yText.toDelta() as YDeltaOp[];
65
+ let cursor = 0;
66
+ let common: MarkAttrs | null = null;
67
+ for (const op of delta) {
68
+ const len = typeof op.insert === 'string' ? op.insert.length : op.insert ? 1 : 0;
69
+ if (len === 0) continue;
70
+ const opStart = cursor;
71
+ const opEnd = cursor + len;
72
+ cursor = opEnd;
73
+ if (opEnd <= from) continue;
74
+ if (opStart >= to) break;
75
+ const attrs = op.attributes ?? {};
76
+ if (common === null) {
77
+ common = { ...attrs };
78
+ } else {
79
+ for (const key of Object.keys(common)) {
80
+ const a = JSON.stringify(common[key]);
81
+ const b = JSON.stringify(attrs[key]);
82
+ if (a !== b) delete common[key];
83
+ }
84
+ }
85
+ }
86
+ return common ?? {};
87
+ }
88
+
89
+ /**
90
+ * Apply a `replace` edit captured from the native view to the Y.Text.
91
+ *
92
+ * The edit is wrapped in a single Y.Doc transaction tagged with
93
+ * {@link ORIGIN_LOCAL_VIEW} so the observer loop in {@link YTextInput} knows
94
+ * to skip the resulting render-down (the view already has the change locally).
95
+ */
96
+ export function applyReplaceEdit(
97
+ yText: Y.Text,
98
+ edit: ReplaceEdit,
99
+ schema: Schema,
100
+ onSchemaViolation?: (info: { mark: string; attrs: MarkAttrs }) => void,
101
+ pendingMarks?: MarkAttrs
102
+ ): void {
103
+ const doc = yText.doc;
104
+ if (!doc) {
105
+ // Y.Text not attached to a doc — extremely defensive, shouldn't happen in
106
+ // normal use, but we'd rather no-op than crash.
107
+ return;
108
+ }
109
+ doc.transact(() => {
110
+ const deleteLen = edit.to - edit.from;
111
+ if (deleteLen > 0) {
112
+ yText.delete(edit.from, deleteLen);
113
+ }
114
+ if (edit.text.length > 0) {
115
+ const insertAttrs = computeInsertAttrs(
116
+ yText,
117
+ edit.from,
118
+ schema,
119
+ pendingMarks,
120
+ onSchemaViolation
121
+ );
122
+ yText.insert(
123
+ edit.from,
124
+ edit.text,
125
+ Object.keys(insertAttrs).length > 0 ? insertAttrs : undefined
126
+ );
127
+ }
128
+ }, ORIGIN_LOCAL_VIEW);
129
+ }
130
+
131
+ /**
132
+ * Compute the attributes to pass to `Y.Text.insert(...)` for a new run of text
133
+ * at `position`, given the inherited marks of the character to the left and
134
+ * the editor's pending-marks overlay.
135
+ *
136
+ * Crucially, the returned object actively **`null`s out** any mark that's
137
+ * being suppressed — either because the user toggled it off via the pending
138
+ * overlay, or because the schema sanitiser dropped it (unknown mark, failed
139
+ * validation). Y.Text inheriting an attribute on the new characters can only
140
+ * be cancelled by passing `null` for that key; passing `undefined` or
141
+ * omitting the key keeps the inheritance.
142
+ */
143
+ function computeInsertAttrs(
144
+ yText: Y.Text,
145
+ position: number,
146
+ schema: Schema,
147
+ pendingMarks: MarkAttrs | undefined,
148
+ onSchemaViolation?: (info: { mark: string; attrs: MarkAttrs }) => void
149
+ ): MarkAttrs {
150
+ const inherited = inheritedMarksAt(yText, position);
151
+ const pending = pendingMarks ?? {};
152
+
153
+ // Build the "intended" set of marks. Start from inherited, then apply
154
+ // pending: a truthy value adds/overrides, `false` removes.
155
+ const additions: MarkAttrs = { ...inherited };
156
+ const explicitRemovals = new Set<string>();
157
+ for (const [name, value] of Object.entries(pending)) {
158
+ if (value === false) {
159
+ explicitRemovals.add(name);
160
+ delete additions[name];
161
+ } else {
162
+ additions[name] = value as MarkAttrs;
163
+ }
164
+ }
165
+
166
+ const { sanitised, violations } = validateMarks(additions, schema);
167
+ if (onSchemaViolation) {
168
+ for (const v of violations) onSchemaViolation(v);
169
+ }
170
+
171
+ // Anything we *wanted* to keep but the sanitiser dropped (unknown mark,
172
+ // invalid attrs) should also be actively removed so it doesn't leak into
173
+ // the inserted characters via Y.Text inheritance.
174
+ for (const name of Object.keys(additions)) {
175
+ if (!(name in sanitised)) explicitRemovals.add(name);
176
+ }
177
+ // Inherited marks that the user didn't explicitly touch and that *did*
178
+ // survive sanitisation are already in `sanitised`. Inherited marks that
179
+ // *failed* sanitisation are caught by the loop above. We don't need to
180
+ // null any of them twice.
181
+
182
+ const out: MarkAttrs = { ...sanitised };
183
+ for (const name of explicitRemovals) {
184
+ out[name] = null;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Wrap a programmatic mutation (anything driven from JS — toolbar mark
191
+ * toggles, scripted inserts, deletes) in a Y.Doc transaction.
192
+ *
193
+ * Critically, we do NOT tag this with {@link ORIGIN_LOCAL_VIEW}. That origin
194
+ * is reserved for {@link applyReplaceEdit} — the round-trip path for text
195
+ * the user just typed into the native view, where the view *already* shows
196
+ * the change and re-pushing runs back down would clobber the in-flight IME
197
+ * composition / caret. Programmatic mutations, by contrast, haven't been
198
+ * reflected in the native view yet, so they MUST be observed by `YTextInput`
199
+ * to trigger an attributed-string update. Leaving the origin unset (or, in
200
+ * future, using a distinct `ORIGIN_LOCAL_PROGRAMMATIC` sentinel) is what
201
+ * causes the observer's `origin === ORIGIN_LOCAL_VIEW` short-circuit to
202
+ * fall through and recompute / repaint.
203
+ */
204
+ export function transactProgrammatic(yText: Y.Text, fn: () => void): void {
205
+ const doc = yText.doc;
206
+ if (!doc) {
207
+ fn();
208
+ return;
209
+ }
210
+ doc.transact(fn);
211
+ }
212
+
213
+ /**
214
+ * Carrier for a caret/range expressed as relative positions. Survives concurrent
215
+ * remote insertions/deletions: when the underlying Y.Text changes, resolving
216
+ * the relative positions against the new state gives us the new absolute offsets
217
+ * that correspond to "the same logical place" the user was at.
218
+ */
219
+ export interface RelativeSelection {
220
+ from: Y.RelativePosition;
221
+ to: Y.RelativePosition;
222
+ }
223
+
224
+ /**
225
+ * Capture the current absolute selection as a {@link RelativeSelection}.
226
+ *
227
+ * Y.RelativePosition has an "assoc" flag controlling which side of a tie the
228
+ * position lives on. For a caret we use:
229
+ * - `assoc = -1` for the `from` (start) — sticks to the *left* of a future
230
+ * insertion at the same offset, which is the conventional caret behaviour
231
+ * for the start of a selection
232
+ * - `assoc = 1` for the `to` (end) — sticks to the *right*
233
+ *
234
+ * For a collapsed caret both still work — the caret stays where the user "is".
235
+ */
236
+ export function captureRelativeSelection(
237
+ yText: Y.Text,
238
+ selection: SelectionRange
239
+ ): RelativeSelection {
240
+ return {
241
+ from: Y.createRelativePositionFromTypeIndex(yText, selection.from, -1),
242
+ to: Y.createRelativePositionFromTypeIndex(yText, selection.to, 1),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Resolve a {@link RelativeSelection} against the current Y.Text state.
248
+ *
249
+ * Returns `null` if either endpoint can no longer be resolved (e.g. the Y.Text
250
+ * was completely cleared remotely). Callers should fall back to a sensible
251
+ * default — typically clamping the caret to `yText.length`.
252
+ */
253
+ export function resolveRelativeSelection(
254
+ yText: Y.Text,
255
+ sel: RelativeSelection
256
+ ): SelectionRange | null {
257
+ const doc = yText.doc;
258
+ if (!doc) return null;
259
+ const from = Y.createAbsolutePositionFromRelativePosition(sel.from, doc);
260
+ const to = Y.createAbsolutePositionFromRelativePosition(sel.to, doc);
261
+ if (!from || !to || from.type !== yText || to.type !== yText) return null;
262
+ return { from: from.index, to: to.index };
263
+ }
264
+
265
+ /**
266
+ * Apply (or remove) a mark across a range. Removal is requested by passing
267
+ * `attrs = null` (matches the Yjs `format()` semantics).
268
+ */
269
+ export function formatRange(
270
+ yText: Y.Text,
271
+ from: number,
272
+ to: number,
273
+ markName: string,
274
+ attrs: MarkAttrs | null
275
+ ): void {
276
+ if (to <= from) return;
277
+ transactProgrammatic(yText, () => {
278
+ // Y.Text.format expects an attributes object where `null` values remove
279
+ // the corresponding mark. We pass the single mark either as its attrs
280
+ // object (apply) or as `null` (remove).
281
+ yText.format(from, to - from, { [markName]: attrs });
282
+ });
283
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @eclosion-tech/react-native-yjs-text
3
+ *
4
+ * Native React Native rich text editor backed by Y.Text. No WebView, no
5
+ * `contenteditable`, no DOM. See ./SPEC.md for the full design rationale.
6
+ */
7
+ export { YTextInput } from './YTextInput';
8
+ export { YTextRenderer } from './YTextRenderer';
9
+ export { useYTextEditor } from './useYTextEditor';
10
+ export { defaultSchema } from './schema';
11
+ export type {
12
+ MarkAttrs,
13
+ MarkSpec,
14
+ Run,
15
+ Schema,
16
+ SelectionRange,
17
+ YTextEditor,
18
+ YTextInputProps,
19
+ YTextRendererProps,
20
+ } from './types';
21
+ export { ORIGIN_LOCAL_VIEW } from './types';