@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,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';
|