@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,126 @@
1
+ import { requireNativeView } from 'expo';
2
+ import * as React from 'react';
3
+ import type { StyleProp, ViewStyle } from 'react-native';
4
+
5
+ import type { CompiledRenderSpec } from '../schema';
6
+ import type { MarkAttrs, Run, SelectionRange } from '../types';
7
+
8
+ /**
9
+ * Selection bundle pushed to native as a single atomic prop.
10
+ *
11
+ * Bundling `from`/`to`/`version` into one Record (rather than two separate
12
+ * props on the native side) sidesteps Expo Modules' unspecified prop-setter
13
+ * ordering on Fabric: if the version prop were applied before the value,
14
+ * the native side would re-apply a stale selection and the user would see
15
+ * the previous selection "jump back" after every format toggle.
16
+ */
17
+ export interface SerializedPendingSelection {
18
+ from: number;
19
+ to: number;
20
+ version: number;
21
+ }
22
+
23
+ /**
24
+ * A single run as it goes over the bridge to native. Marks are JSON-serialised
25
+ * because Expo Module `Record` fields don't cleanly accept heterogeneous
26
+ * `[String: Any]` dictionaries (mark attrs are arbitrary per the schema), and
27
+ * the cost of a JSON round-trip per run on every render is negligible compared
28
+ * to the layout work native then does.
29
+ */
30
+ export interface SerializedRun {
31
+ text: string;
32
+ /** JSON-stringified `Record<string, unknown>` of mark name -> attrs. */
33
+ marksJson: string;
34
+ }
35
+
36
+ /** Serialise a public {@link Run} into the bridge representation. */
37
+ export function serializeRun(run: Run): SerializedRun {
38
+ return {
39
+ text: run.text,
40
+ marksJson: JSON.stringify(run.marks ?? {}),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Props sent to the native `YjsText` view across the bridge. Keep this shape
46
+ * stable — the iOS and Android sides parse it; adding a key requires native
47
+ * changes on both sides.
48
+ */
49
+ export interface NativeYTextInputViewProps {
50
+ /**
51
+ * Attributed runs to render. Native always rebuilds when this prop
52
+ * changes — we used to ship a sibling `contentVersion: number` prop to
53
+ * force re-apply on attr-only changes, but Expo Modules' Fabric setter
54
+ * ordering didn't guarantee `runs` was applied before `contentVersion`,
55
+ * which caused every toggle to render the *previous* runs. Collapsing
56
+ * to a single trigger sidesteps the race entirely. The JS side prevents
57
+ * redundant pushes by memoising this array on a `contentVersion` state
58
+ * that bumps only on real Y.Text changes.
59
+ */
60
+ runs: SerializedRun[];
61
+ /**
62
+ * Per-mark style spec. Inner keys are bounded by `RENDERABLE_TEXT_STYLE_KEYS`
63
+ * so this serialises cleanly into a typed Swift / Kotlin Record.
64
+ */
65
+ renderSpec: CompiledRenderSpec;
66
+ /**
67
+ * Atomic selection bundle. Native re-applies whenever the embedded
68
+ * `version` differs from the last value it observed. `null` means "no
69
+ * JS-driven selection yet" — native leaves the in-flight user selection
70
+ * alone.
71
+ */
72
+ pendingSelection: SerializedPendingSelection | null;
73
+ editable: boolean;
74
+ placeholder?: string;
75
+ placeholderColor?: string;
76
+ baseFontSize?: number;
77
+ baseFontFamily?: string;
78
+ baseColor?: string;
79
+ baseFontWeight?: string;
80
+ baseFontStyle?: string;
81
+ style?: StyleProp<ViewStyle>;
82
+
83
+ onContentChange?: (event: {
84
+ nativeEvent: { type: 'replace'; from: number; to: number; text: string };
85
+ }) => void;
86
+ onNativeSelectionChange?: (event: { nativeEvent: { from: number; to: number } }) => void;
87
+ onFocusChange?: (event: { nativeEvent: { focused: boolean } }) => void;
88
+ onMarkTap?: (event: { nativeEvent: { mark: string; attrsJson: string } }) => void;
89
+ }
90
+
91
+ /** Parse a mark-tap event payload from native back into a `MarkAttrs` object. */
92
+ export function parseMarkTapAttrs(attrsJson: string): MarkAttrs {
93
+ if (!attrsJson) return {};
94
+ try {
95
+ const parsed = JSON.parse(attrsJson);
96
+ return parsed && typeof parsed === 'object' ? parsed : {};
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Direct handle to the underlying native `UITextView` / `AppCompatEditText`.
104
+ * Exposed via {@link React.useImperativeHandle} so the JS layer can call
105
+ * focus/blur/setSelection without going through props.
106
+ *
107
+ * Implemented as Expo Modules `AsyncFunction`s on the native side that take a
108
+ * view tag and dispatch to the matching view instance.
109
+ */
110
+ export interface NativeYTextInputViewRef {
111
+ focus(): void;
112
+ blur(): void;
113
+ setSelection(range: SelectionRange): void;
114
+ isFocused(): boolean;
115
+ }
116
+
117
+ /** Resolved at module load: the Expo native view registered as `YjsText`. */
118
+ const NativeView: React.ComponentType<NativeYTextInputViewProps & { ref?: React.Ref<unknown> }> =
119
+ requireNativeView('YjsText');
120
+
121
+ export const NativeYTextInputView = React.forwardRef<
122
+ NativeYTextInputViewRef,
123
+ NativeYTextInputViewProps
124
+ >(function NativeYTextInputView(props, ref) {
125
+ return <NativeView {...props} ref={ref} />;
126
+ });
@@ -0,0 +1,50 @@
1
+ import type * as Y from 'yjs';
2
+
3
+ import type { MarkAttrs, SelectionRange } from '../types';
4
+
5
+ /**
6
+ * The slice of editor state a {@link YTextInput} contributes to the registry
7
+ * when it mounts. Lets the imperative {@link useYTextEditor} hook reach into
8
+ * the native view for focus / selection / etc. without prop-drilling refs.
9
+ */
10
+ export interface EditorHandle {
11
+ /** Current absolute selection, or `null` if the view isn't focused. */
12
+ getSelection(): SelectionRange | null;
13
+ /** Pending marks set by `toggleMark` while the caret is collapsed. */
14
+ getPendingMarks(): MarkAttrs;
15
+ setPendingMarks(marks: MarkAttrs): void;
16
+ /** Imperative view operations forwarded to the native view. */
17
+ focus(): void;
18
+ blur(): void;
19
+ isFocused(): boolean;
20
+ setSelection(range: SelectionRange): void;
21
+ }
22
+
23
+ /**
24
+ * The registry is keyed by Y.Text identity so that any consumer holding a
25
+ * reference to the same Y.Text can resolve the live editor handle without
26
+ * prop-drilling. A {@link Y.Text} maps to one handle at most — there's one
27
+ * active editor per logical text region by design (the per-block model
28
+ * documented in the spec).
29
+ *
30
+ * We use a `WeakMap` so unmounted editors don't pin their Y.Texts in memory
31
+ * forever.
32
+ */
33
+ const REGISTRY = new WeakMap<Y.Text, EditorHandle>();
34
+
35
+ export function registerEditor(yText: Y.Text, handle: EditorHandle): void {
36
+ REGISTRY.set(yText, handle);
37
+ }
38
+
39
+ export function unregisterEditor(yText: Y.Text, handle: EditorHandle): void {
40
+ // Only unregister if the current entry is still us — protects against the
41
+ // race where a new editor for the same Y.Text mounts before the old one's
42
+ // cleanup runs.
43
+ if (REGISTRY.get(yText) === handle) {
44
+ REGISTRY.delete(yText);
45
+ }
46
+ }
47
+
48
+ export function getEditor(yText: Y.Text): EditorHandle | undefined {
49
+ return REGISTRY.get(yText);
50
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type { TextStyle } from 'react-native';
2
+
3
+ import type { MarkAttrs, Schema } from './types';
4
+
5
+ /**
6
+ * The default schema covering the v0.1 canonical mark set documented in the
7
+ * spec. Consumers can use this as-is, extend it, or replace it entirely.
8
+ *
9
+ * Mark names and semantics match the y-prosemirror schema convention so that a
10
+ * Y.Text edited on web via ProseMirror and on mobile via this library renders
11
+ * identically.
12
+ */
13
+ export const defaultSchema: Schema = {
14
+ marks: {
15
+ bold: {
16
+ renderStyle: { fontWeight: 'bold' },
17
+ },
18
+ italic: {
19
+ renderStyle: { fontStyle: 'italic' },
20
+ },
21
+ underline: {
22
+ renderStyle: { textDecorationLine: 'underline' },
23
+ },
24
+ strike: {
25
+ renderStyle: { textDecorationLine: 'line-through' },
26
+ },
27
+ code: {
28
+ renderStyle: {
29
+ fontFamily: 'Menlo',
30
+ backgroundColor: '#f4f4f4',
31
+ },
32
+ },
33
+ link: {
34
+ attrs: {
35
+ href: { type: 'string', required: true },
36
+ },
37
+ renderStyle: {
38
+ color: '#0066cc',
39
+ textDecorationLine: 'underline',
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Subset of `TextStyle` keys forwarded to the native side. Keys outside this
47
+ * list are silently ignored — they may make sense on the web but have no
48
+ * cross-platform native equivalent, or require per-platform work we haven't
49
+ * done yet.
50
+ *
51
+ * If you need a key not listed here, open an issue. Adding one usually means
52
+ * teaching both the iOS NSAttributedString and Android Spannable layers how to
53
+ * apply it.
54
+ */
55
+ export const RENDERABLE_TEXT_STYLE_KEYS = [
56
+ 'fontWeight',
57
+ 'fontStyle',
58
+ 'fontFamily',
59
+ 'fontSize',
60
+ 'color',
61
+ 'backgroundColor',
62
+ 'textDecorationLine',
63
+ ] as const satisfies readonly (keyof TextStyle)[];
64
+
65
+ /**
66
+ * The compiled-down render spec sent over the bridge.
67
+ *
68
+ * One entry per mark, containing only the keys the native renderer knows how
69
+ * to handle. We compile once when the schema prop changes rather than parsing
70
+ * the whole `MarkSpec` on every render of every run.
71
+ */
72
+ export type CompiledRenderSpec = Record<
73
+ string,
74
+ Partial<Record<(typeof RENDERABLE_TEXT_STYLE_KEYS)[number], unknown>>
75
+ >;
76
+
77
+ /**
78
+ * Reduce a {@link Schema} to its renderable subset. Strips function callbacks
79
+ * (`onTap`), strips attribute schemas, normalises `textDecorationLine` to a
80
+ * single canonical string. Stable for memoisation: same input → equal output.
81
+ */
82
+ export function compileRenderSpec(schema: Schema): CompiledRenderSpec {
83
+ const out: CompiledRenderSpec = {};
84
+ for (const [name, spec] of Object.entries(schema.marks)) {
85
+ const style = spec.renderStyle;
86
+ if (!style) {
87
+ out[name] = {};
88
+ continue;
89
+ }
90
+ const compiled: CompiledRenderSpec[string] = {};
91
+ for (const key of RENDERABLE_TEXT_STYLE_KEYS) {
92
+ const value = style[key];
93
+ if (value !== undefined) {
94
+ compiled[key] = value;
95
+ }
96
+ }
97
+ out[name] = compiled;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Validate that every mark in `attrs` is declared in the schema and that its
104
+ * attributes match the declared shape. Returns the sanitised attrs (with
105
+ * undeclared marks stripped) and the list of violations.
106
+ *
107
+ * Used on insert paths (programmatic `insertText`, future paste handlers) to
108
+ * keep AI-generated or pasted content from smuggling in unknown marks.
109
+ */
110
+ export function validateMarks(
111
+ marks: MarkAttrs,
112
+ schema: Schema
113
+ ): {
114
+ sanitised: MarkAttrs;
115
+ violations: { mark: string; attrs: MarkAttrs }[];
116
+ } {
117
+ const sanitised: MarkAttrs = {};
118
+ const violations: { mark: string; attrs: MarkAttrs }[] = [];
119
+ for (const [markName, attrs] of Object.entries(marks)) {
120
+ const spec = schema.marks[markName];
121
+ if (!spec) {
122
+ violations.push({ mark: markName, attrs: (attrs as MarkAttrs) ?? {} });
123
+ continue;
124
+ }
125
+ if (!spec.attrs) {
126
+ sanitised[markName] = attrs ?? {};
127
+ continue;
128
+ }
129
+ const incoming = (attrs ?? {}) as MarkAttrs;
130
+ const validated: MarkAttrs = {};
131
+ let ok = true;
132
+ for (const [attrName, attrSpec] of Object.entries(spec.attrs)) {
133
+ const value = incoming[attrName];
134
+ if (value === undefined) {
135
+ if (attrSpec.required && attrSpec.default === undefined) {
136
+ ok = false;
137
+ break;
138
+ }
139
+ if (attrSpec.default !== undefined) {
140
+ validated[attrName] = attrSpec.default;
141
+ }
142
+ continue;
143
+ }
144
+ if (typeof value !== attrSpec.type) {
145
+ ok = false;
146
+ break;
147
+ }
148
+ validated[attrName] = value;
149
+ }
150
+ if (ok) {
151
+ sanitised[markName] = validated;
152
+ } else {
153
+ violations.push({ mark: markName, attrs: incoming });
154
+ }
155
+ }
156
+ return { sanitised, violations };
157
+ }
package/src/types.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { StyleProp, TextStyle } from 'react-native';
2
+ import type * as Y from 'yjs';
3
+
4
+ /**
5
+ * A formatting attribute attached to a span of text inside a {@link Y.Text}.
6
+ *
7
+ * Marks are the unit of inline formatting. They have a name (`bold`, `italic`,
8
+ * `link`, etc.) and an optional bag of attributes (`href` for links, `level` for
9
+ * headings if you ever invent one, etc.). They mirror the y-prosemirror mark
10
+ * convention so the same Y.Text content edits identically on web (via
11
+ * y-prosemirror over a ProseMirror schema) and on mobile (via this library).
12
+ */
13
+ export type MarkAttrs = Record<string, unknown>;
14
+
15
+ /**
16
+ * Declaration of a single mark in a {@link Schema}.
17
+ *
18
+ * @property attrs Optional schema for the mark's attributes. Validated on insert.
19
+ * If a mark takes no attributes (e.g. `bold`, `italic`), omit this entirely.
20
+ * @property renderStyle The style applied to spans bearing this mark when the
21
+ * editor renders to native text. Only a subset of RN's `TextStyle` is honoured
22
+ * on native (see {@link RENDERABLE_TEXT_STYLE_KEYS}); other keys are ignored.
23
+ * @property onTap Optional handler invoked when the user taps a span bearing
24
+ * this mark. The classic use case is `link` opening a URL. The handler runs in
25
+ * JS regardless of which platform fired the tap.
26
+ */
27
+ export interface MarkSpec {
28
+ attrs?: Record<
29
+ string,
30
+ {
31
+ type: 'string' | 'number' | 'boolean';
32
+ required?: boolean;
33
+ default?: unknown;
34
+ }
35
+ >;
36
+ renderStyle?: TextStyle;
37
+ onTap?: (attrs: MarkAttrs) => void;
38
+ }
39
+
40
+ /**
41
+ * The set of marks an editor instance accepts.
42
+ *
43
+ * The schema is the contract between the consuming application and the editor.
44
+ * Marks not declared here are dropped on insert (with an optional warning, see
45
+ * {@link YTextInputProps.onSchemaViolation}). This is what makes the editor safe
46
+ * for AI-generated content: an LLM cannot smuggle in `<script>`-equivalent
47
+ * marks that the consumer hasn't explicitly opted into.
48
+ */
49
+ export interface Schema {
50
+ marks: Record<string, MarkSpec>;
51
+ }
52
+
53
+ /**
54
+ * A contiguous run of text bearing the same formatting marks.
55
+ *
56
+ * This is the wire format used to push attributed text down to the native view.
57
+ * A {@link Y.Text} is converted to `Run[]` via {@link ytextToRuns}; the native
58
+ * side renders each run with the styles declared in its schema's marks.
59
+ */
60
+ export interface Run {
61
+ text: string;
62
+ marks: MarkAttrs;
63
+ }
64
+
65
+ /**
66
+ * A character-offset range inside a {@link Y.Text}.
67
+ *
68
+ * Offsets are UTF-16 code units, matching `Y.Text`'s native offsets, RN's
69
+ * `TextInput` selection, iOS `NSRange`, and Android `Editable.getSelectionStart`.
70
+ * A collapsed cursor is represented by `from === to`.
71
+ */
72
+ export interface SelectionRange {
73
+ from: number;
74
+ to: number;
75
+ }
76
+
77
+ /**
78
+ * The shape of an edit emitted by the native view when the user types, pastes,
79
+ * or deletes.
80
+ *
81
+ * v0.1 uses a single `replace`-shaped edit that subsumes insert (`from === to`),
82
+ * delete (`text === ''`), and replace. This matches iOS's
83
+ * `textView(_:shouldChangeTextIn:replacementText:)` directly.
84
+ *
85
+ * @internal
86
+ */
87
+ export interface ReplaceEdit {
88
+ type: 'replace';
89
+ from: number;
90
+ to: number;
91
+ text: string;
92
+ }
93
+
94
+ /**
95
+ * Props for the {@link YTextInput} editable component.
96
+ */
97
+ export interface YTextInputProps {
98
+ /** The Y.Text whose content this editor displays and mutates. */
99
+ yText: Y.Text;
100
+ /** The set of marks accepted by this editor. See {@link Schema}. */
101
+ schema: Schema;
102
+ /** RN text style applied as the baseline (font family, size, default color). */
103
+ style?: StyleProp<TextStyle>;
104
+ /** Placeholder text shown when the Y.Text is empty. */
105
+ placeholder?: string;
106
+ /** Colour for the placeholder text. Defaults to a muted grey. */
107
+ placeholderTextColor?: string;
108
+ /** Auto-focus on mount (raises the keyboard). Defaults to `false`. */
109
+ autoFocus?: boolean;
110
+ /** Whether the editor accepts input. Defaults to `true`. */
111
+ editable?: boolean;
112
+ /** Fires whenever the user moves the caret or changes the selected range. */
113
+ onSelectionChange?: (selection: SelectionRange) => void;
114
+ /** Fires when the editor gains focus. */
115
+ onFocus?: () => void;
116
+ /** Fires when the editor loses focus. */
117
+ onBlur?: () => void;
118
+ /**
119
+ * Fires when an insert contains a mark not declared in the schema.
120
+ * The mark is dropped silently regardless; this is your hook to log the
121
+ * violation (e.g. when an LLM generates an unrecognised mark).
122
+ */
123
+ onSchemaViolation?: (info: { mark: string; attrs: MarkAttrs }) => void;
124
+ }
125
+
126
+ /**
127
+ * Props for the {@link YTextRenderer} read-only component.
128
+ */
129
+ export interface YTextRendererProps {
130
+ /** The Y.Text to render. */
131
+ yText: Y.Text;
132
+ /** The schema describing how to render each mark. */
133
+ schema: Schema;
134
+ /** RN text style applied as the baseline. */
135
+ style?: StyleProp<TextStyle>;
136
+ /** Maximum lines to render before truncation. */
137
+ numberOfLines?: number;
138
+ }
139
+
140
+ /**
141
+ * The imperative editor API returned by {@link useYTextEditor}.
142
+ *
143
+ * All commands operate at the current selection unless given explicit ranges.
144
+ * Commands that mutate (`toggleMark`, `insertText`, ...) wrap the mutation in a
145
+ * Y.Doc transaction tagged with the {@link ORIGIN_LOCAL_VIEW} origin so the
146
+ * native view doesn't re-render itself in response.
147
+ */
148
+ export interface YTextEditor {
149
+ /** Current selection, or `null` if the editor isn't focused. */
150
+ getSelection(): SelectionRange | null;
151
+ /** Move the cursor / select a range. No-op if the editor isn't mounted. */
152
+ setSelection(range: SelectionRange): void;
153
+
154
+ /**
155
+ * Toggle the mark across the current selection. If the entire selection
156
+ * already has the mark, removes it; otherwise applies it.
157
+ */
158
+ toggleMark(name: string, attrs?: MarkAttrs): void;
159
+ /** Force-apply the mark across the current selection. */
160
+ setMark(name: string, attrs?: MarkAttrs): void;
161
+ /** Remove the mark from the current selection. */
162
+ removeMark(name: string): void;
163
+ /**
164
+ * Marks currently active at the selection's start.
165
+ *
166
+ * For a collapsed selection this is the marks of the character to the left,
167
+ * plus any `pending marks` (marks toggled while the selection was collapsed,
168
+ * which apply to the next character typed).
169
+ */
170
+ marksAtSelection(): MarkAttrs;
171
+
172
+ /** Insert text at the current selection, replacing any selected range. */
173
+ insertText(text: string, attrs?: MarkAttrs): void;
174
+ /** Delete the given range. Offsets are in UTF-16 code units. */
175
+ deleteRange(from: number, to: number): void;
176
+
177
+ /** Raise the keyboard / programmatically focus the editor. */
178
+ focus(): void;
179
+ /** Dismiss the keyboard / blur the editor. */
180
+ blur(): void;
181
+ /** Whether the editor currently has keyboard focus. */
182
+ isFocused(): boolean;
183
+ }
184
+
185
+ /**
186
+ * The Yjs transaction origin used by this library when applying user edits
187
+ * captured from the native view, or programmatic edits from {@link YTextEditor}.
188
+ *
189
+ * Consumers can read `transaction.origin === ORIGIN_LOCAL_VIEW` to distinguish
190
+ * edits coming from the editor itself from edits coming from sync providers
191
+ * (`y-websocket`, your custom backend, etc.). The library uses it internally to
192
+ * avoid re-rendering the view in response to its own writes.
193
+ */
194
+ export const ORIGIN_LOCAL_VIEW = Symbol('@eclosion-tech/react-native-yjs-text/local-view');
@@ -0,0 +1,171 @@
1
+ import { useMemo } from 'react';
2
+ import type * as Y from 'yjs';
3
+
4
+ import { formatRange, inheritedMarksAt, marksInRange, transactProgrammatic } from './bridge';
5
+ import { getEditor } from './internal/editorRegistry';
6
+ import { validateMarks } from './schema';
7
+ import type { MarkAttrs, Schema, SelectionRange, YTextEditor } from './types';
8
+
9
+ /**
10
+ * Build a stable imperative editor handle bound to `yText`.
11
+ *
12
+ * Returns the same `YTextEditor` instance across re-renders so that consumers
13
+ * can pass commands to event handlers without re-binding. The commands resolve
14
+ * the *current* live native view at call time via the editor registry — so
15
+ * unmounting and remounting the `YTextInput` doesn't break a toolbar that's
16
+ * holding the editor handle.
17
+ *
18
+ * If no `YTextInput` is currently mounted for this Y.Text, commands that need
19
+ * a live view (focus, selection-dependent mark toggles) gracefully no-op. Pure
20
+ * Y.Text mutations (`insertText` with explicit ranges via `deleteRange`,
21
+ * `setMark` on a known selection) still work — useful for programmatic edits.
22
+ *
23
+ * @param yText The Y.Text this editor mutates.
24
+ * @param schema The schema declaring which marks are valid.
25
+ */
26
+ export function useYTextEditor(yText: Y.Text, schema: Schema): YTextEditor {
27
+ return useMemo<YTextEditor>(() => {
28
+ function currentSelection(): SelectionRange | null {
29
+ const handle = getEditor(yText);
30
+ return handle?.getSelection() ?? null;
31
+ }
32
+
33
+ function pendingMarks(): MarkAttrs {
34
+ return getEditor(yText)?.getPendingMarks() ?? {};
35
+ }
36
+
37
+ function setPendingMarks(marks: MarkAttrs): void {
38
+ getEditor(yText)?.setPendingMarks(marks);
39
+ }
40
+
41
+ return {
42
+ getSelection: currentSelection,
43
+
44
+ setSelection(range) {
45
+ getEditor(yText)?.setSelection(range);
46
+ },
47
+
48
+ toggleMark(name, attrs) {
49
+ const sel = currentSelection();
50
+ if (!sel) return;
51
+ // Validate first — if the mark isn't in the schema, we drop on the floor.
52
+ const { sanitised, violations } = validateMarks({ [name]: attrs ?? {} }, schema);
53
+ if (violations.length > 0 || !(name in sanitised)) return;
54
+ const normalisedAttrs = sanitised[name] as MarkAttrs;
55
+
56
+ if (sel.from === sel.to) {
57
+ // Collapsed selection: toggle pending mark. The next typed
58
+ // character will adopt it (or strip it if turning off).
59
+ const pending = pendingMarks();
60
+ const inherited = inheritedMarksAt(yText, sel.from);
61
+ const isOnInherited = name in inherited;
62
+ const isOnPending = name in pending;
63
+ const isOn = isOnPending ? Boolean(pending[name]) : isOnInherited;
64
+ const next: MarkAttrs = { ...pending };
65
+ if (isOn) {
66
+ // Mark is currently on for the next-character — turn it off.
67
+ // We have to distinguish: if it's on via inheritance, set
68
+ // `false` so the insert path overrides; if only via pending,
69
+ // delete the pending entry.
70
+ if (isOnInherited) next[name] = false;
71
+ else delete next[name];
72
+ } else {
73
+ next[name] = normalisedAttrs;
74
+ }
75
+ setPendingMarks(next);
76
+ return;
77
+ }
78
+
79
+ // Range selection: format the range. Turn on unless every char
80
+ // already has the mark, in which case turn off.
81
+ const common = marksInRange(yText, sel.from, sel.to);
82
+ const allHaveIt = name in common;
83
+ formatRange(yText, sel.from, sel.to, name, allHaveIt ? null : normalisedAttrs);
84
+ },
85
+
86
+ setMark(name, attrs) {
87
+ const sel = currentSelection();
88
+ if (!sel) return;
89
+ const { sanitised, violations } = validateMarks({ [name]: attrs ?? {} }, schema);
90
+ if (violations.length > 0 || !(name in sanitised)) return;
91
+ if (sel.from === sel.to) {
92
+ setPendingMarks({ ...pendingMarks(), [name]: sanitised[name] as MarkAttrs });
93
+ return;
94
+ }
95
+ formatRange(yText, sel.from, sel.to, name, sanitised[name] as MarkAttrs);
96
+ },
97
+
98
+ removeMark(name) {
99
+ const sel = currentSelection();
100
+ if (!sel) return;
101
+ if (sel.from === sel.to) {
102
+ const pending = pendingMarks();
103
+ const inherited = inheritedMarksAt(yText, sel.from);
104
+ const next: MarkAttrs = { ...pending };
105
+ if (name in inherited) {
106
+ next[name] = false;
107
+ } else {
108
+ delete next[name];
109
+ }
110
+ setPendingMarks(next);
111
+ return;
112
+ }
113
+ formatRange(yText, sel.from, sel.to, name, null);
114
+ },
115
+
116
+ marksAtSelection() {
117
+ const sel = currentSelection();
118
+ if (!sel) return {};
119
+ const base =
120
+ sel.from === sel.to
121
+ ? inheritedMarksAt(yText, sel.from)
122
+ : marksInRange(yText, sel.from, sel.to);
123
+ // Overlay pending marks: `false` removes, anything truthy replaces.
124
+ const pending = pendingMarks();
125
+ const merged: MarkAttrs = { ...base };
126
+ for (const [name, value] of Object.entries(pending)) {
127
+ if (value === false) delete merged[name];
128
+ else merged[name] = value;
129
+ }
130
+ return merged;
131
+ },
132
+
133
+ insertText(text, attrs) {
134
+ const sel = currentSelection() ?? { from: yText.length, to: yText.length };
135
+ transactProgrammatic(yText, () => {
136
+ if (sel.from !== sel.to) {
137
+ yText.delete(sel.from, sel.to - sel.from);
138
+ }
139
+ if (text.length === 0) return;
140
+ const inherited = inheritedMarksAt(yText, sel.from);
141
+ const combined: MarkAttrs = {
142
+ ...inherited,
143
+ ...pendingMarks(),
144
+ ...(attrs ?? {}),
145
+ };
146
+ const { sanitised } = validateMarks(combined, schema);
147
+ yText.insert(sel.from, text, Object.keys(sanitised).length > 0 ? sanitised : undefined);
148
+ });
149
+ },
150
+
151
+ deleteRange(from, to) {
152
+ if (to <= from) return;
153
+ transactProgrammatic(yText, () => {
154
+ yText.delete(from, to - from);
155
+ });
156
+ },
157
+
158
+ focus() {
159
+ getEditor(yText)?.focus();
160
+ },
161
+
162
+ blur() {
163
+ getEditor(yText)?.blur();
164
+ },
165
+
166
+ isFocused() {
167
+ return getEditor(yText)?.isFocused() ?? false;
168
+ },
169
+ };
170
+ }, [yText, schema]);
171
+ }