@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,166 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { formatRange, inheritedMarksAt, marksInRange, transactProgrammatic } from './bridge';
|
|
3
|
+
import { getEditor } from './internal/editorRegistry';
|
|
4
|
+
import { validateMarks } from './schema';
|
|
5
|
+
/**
|
|
6
|
+
* Build a stable imperative editor handle bound to `yText`.
|
|
7
|
+
*
|
|
8
|
+
* Returns the same `YTextEditor` instance across re-renders so that consumers
|
|
9
|
+
* can pass commands to event handlers without re-binding. The commands resolve
|
|
10
|
+
* the *current* live native view at call time via the editor registry — so
|
|
11
|
+
* unmounting and remounting the `YTextInput` doesn't break a toolbar that's
|
|
12
|
+
* holding the editor handle.
|
|
13
|
+
*
|
|
14
|
+
* If no `YTextInput` is currently mounted for this Y.Text, commands that need
|
|
15
|
+
* a live view (focus, selection-dependent mark toggles) gracefully no-op. Pure
|
|
16
|
+
* Y.Text mutations (`insertText` with explicit ranges via `deleteRange`,
|
|
17
|
+
* `setMark` on a known selection) still work — useful for programmatic edits.
|
|
18
|
+
*
|
|
19
|
+
* @param yText The Y.Text this editor mutates.
|
|
20
|
+
* @param schema The schema declaring which marks are valid.
|
|
21
|
+
*/
|
|
22
|
+
export function useYTextEditor(yText, schema) {
|
|
23
|
+
return useMemo(() => {
|
|
24
|
+
function currentSelection() {
|
|
25
|
+
const handle = getEditor(yText);
|
|
26
|
+
return handle?.getSelection() ?? null;
|
|
27
|
+
}
|
|
28
|
+
function pendingMarks() {
|
|
29
|
+
return getEditor(yText)?.getPendingMarks() ?? {};
|
|
30
|
+
}
|
|
31
|
+
function setPendingMarks(marks) {
|
|
32
|
+
getEditor(yText)?.setPendingMarks(marks);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
getSelection: currentSelection,
|
|
36
|
+
setSelection(range) {
|
|
37
|
+
getEditor(yText)?.setSelection(range);
|
|
38
|
+
},
|
|
39
|
+
toggleMark(name, attrs) {
|
|
40
|
+
const sel = currentSelection();
|
|
41
|
+
if (!sel)
|
|
42
|
+
return;
|
|
43
|
+
// Validate first — if the mark isn't in the schema, we drop on the floor.
|
|
44
|
+
const { sanitised, violations } = validateMarks({ [name]: attrs ?? {} }, schema);
|
|
45
|
+
if (violations.length > 0 || !(name in sanitised))
|
|
46
|
+
return;
|
|
47
|
+
const normalisedAttrs = sanitised[name];
|
|
48
|
+
if (sel.from === sel.to) {
|
|
49
|
+
// Collapsed selection: toggle pending mark. The next typed
|
|
50
|
+
// character will adopt it (or strip it if turning off).
|
|
51
|
+
const pending = pendingMarks();
|
|
52
|
+
const inherited = inheritedMarksAt(yText, sel.from);
|
|
53
|
+
const isOnInherited = name in inherited;
|
|
54
|
+
const isOnPending = name in pending;
|
|
55
|
+
const isOn = isOnPending ? Boolean(pending[name]) : isOnInherited;
|
|
56
|
+
const next = { ...pending };
|
|
57
|
+
if (isOn) {
|
|
58
|
+
// Mark is currently on for the next-character — turn it off.
|
|
59
|
+
// We have to distinguish: if it's on via inheritance, set
|
|
60
|
+
// `false` so the insert path overrides; if only via pending,
|
|
61
|
+
// delete the pending entry.
|
|
62
|
+
if (isOnInherited)
|
|
63
|
+
next[name] = false;
|
|
64
|
+
else
|
|
65
|
+
delete next[name];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
next[name] = normalisedAttrs;
|
|
69
|
+
}
|
|
70
|
+
setPendingMarks(next);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Range selection: format the range. Turn on unless every char
|
|
74
|
+
// already has the mark, in which case turn off.
|
|
75
|
+
const common = marksInRange(yText, sel.from, sel.to);
|
|
76
|
+
const allHaveIt = name in common;
|
|
77
|
+
formatRange(yText, sel.from, sel.to, name, allHaveIt ? null : normalisedAttrs);
|
|
78
|
+
},
|
|
79
|
+
setMark(name, attrs) {
|
|
80
|
+
const sel = currentSelection();
|
|
81
|
+
if (!sel)
|
|
82
|
+
return;
|
|
83
|
+
const { sanitised, violations } = validateMarks({ [name]: attrs ?? {} }, schema);
|
|
84
|
+
if (violations.length > 0 || !(name in sanitised))
|
|
85
|
+
return;
|
|
86
|
+
if (sel.from === sel.to) {
|
|
87
|
+
setPendingMarks({ ...pendingMarks(), [name]: sanitised[name] });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
formatRange(yText, sel.from, sel.to, name, sanitised[name]);
|
|
91
|
+
},
|
|
92
|
+
removeMark(name) {
|
|
93
|
+
const sel = currentSelection();
|
|
94
|
+
if (!sel)
|
|
95
|
+
return;
|
|
96
|
+
if (sel.from === sel.to) {
|
|
97
|
+
const pending = pendingMarks();
|
|
98
|
+
const inherited = inheritedMarksAt(yText, sel.from);
|
|
99
|
+
const next = { ...pending };
|
|
100
|
+
if (name in inherited) {
|
|
101
|
+
next[name] = false;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
delete next[name];
|
|
105
|
+
}
|
|
106
|
+
setPendingMarks(next);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
formatRange(yText, sel.from, sel.to, name, null);
|
|
110
|
+
},
|
|
111
|
+
marksAtSelection() {
|
|
112
|
+
const sel = currentSelection();
|
|
113
|
+
if (!sel)
|
|
114
|
+
return {};
|
|
115
|
+
const base = sel.from === sel.to
|
|
116
|
+
? inheritedMarksAt(yText, sel.from)
|
|
117
|
+
: marksInRange(yText, sel.from, sel.to);
|
|
118
|
+
// Overlay pending marks: `false` removes, anything truthy replaces.
|
|
119
|
+
const pending = pendingMarks();
|
|
120
|
+
const merged = { ...base };
|
|
121
|
+
for (const [name, value] of Object.entries(pending)) {
|
|
122
|
+
if (value === false)
|
|
123
|
+
delete merged[name];
|
|
124
|
+
else
|
|
125
|
+
merged[name] = value;
|
|
126
|
+
}
|
|
127
|
+
return merged;
|
|
128
|
+
},
|
|
129
|
+
insertText(text, attrs) {
|
|
130
|
+
const sel = currentSelection() ?? { from: yText.length, to: yText.length };
|
|
131
|
+
transactProgrammatic(yText, () => {
|
|
132
|
+
if (sel.from !== sel.to) {
|
|
133
|
+
yText.delete(sel.from, sel.to - sel.from);
|
|
134
|
+
}
|
|
135
|
+
if (text.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
const inherited = inheritedMarksAt(yText, sel.from);
|
|
138
|
+
const combined = {
|
|
139
|
+
...inherited,
|
|
140
|
+
...pendingMarks(),
|
|
141
|
+
...(attrs ?? {}),
|
|
142
|
+
};
|
|
143
|
+
const { sanitised } = validateMarks(combined, schema);
|
|
144
|
+
yText.insert(sel.from, text, Object.keys(sanitised).length > 0 ? sanitised : undefined);
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
deleteRange(from, to) {
|
|
148
|
+
if (to <= from)
|
|
149
|
+
return;
|
|
150
|
+
transactProgrammatic(yText, () => {
|
|
151
|
+
yText.delete(from, to - from);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
focus() {
|
|
155
|
+
getEditor(yText)?.focus();
|
|
156
|
+
},
|
|
157
|
+
blur() {
|
|
158
|
+
getEditor(yText)?.blur();
|
|
159
|
+
},
|
|
160
|
+
isFocused() {
|
|
161
|
+
return getEditor(yText)?.isFocused() ?? false;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}, [yText, schema]);
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=useYTextEditor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useYTextEditor.js","sourceRoot":"","sources":["../src/useYTextEditor.ts"],"names":[],"mappings":"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","sourcesContent":["import { useMemo } from 'react';\nimport type * as Y from 'yjs';\n\nimport { formatRange, inheritedMarksAt, marksInRange, transactProgrammatic } from './bridge';\nimport { getEditor } from './internal/editorRegistry';\nimport { validateMarks } from './schema';\nimport type { MarkAttrs, Schema, SelectionRange, YTextEditor } from './types';\n\n/**\n * Build a stable imperative editor handle bound to `yText`.\n *\n * Returns the same `YTextEditor` instance across re-renders so that consumers\n * can pass commands to event handlers without re-binding. The commands resolve\n * the *current* live native view at call time via the editor registry — so\n * unmounting and remounting the `YTextInput` doesn't break a toolbar that's\n * holding the editor handle.\n *\n * If no `YTextInput` is currently mounted for this Y.Text, commands that need\n * a live view (focus, selection-dependent mark toggles) gracefully no-op. Pure\n * Y.Text mutations (`insertText` with explicit ranges via `deleteRange`,\n * `setMark` on a known selection) still work — useful for programmatic edits.\n *\n * @param yText The Y.Text this editor mutates.\n * @param schema The schema declaring which marks are valid.\n */\nexport function useYTextEditor(yText: Y.Text, schema: Schema): YTextEditor {\n return useMemo<YTextEditor>(() => {\n function currentSelection(): SelectionRange | null {\n const handle = getEditor(yText);\n return handle?.getSelection() ?? null;\n }\n\n function pendingMarks(): MarkAttrs {\n return getEditor(yText)?.getPendingMarks() ?? {};\n }\n\n function setPendingMarks(marks: MarkAttrs): void {\n getEditor(yText)?.setPendingMarks(marks);\n }\n\n return {\n getSelection: currentSelection,\n\n setSelection(range) {\n getEditor(yText)?.setSelection(range);\n },\n\n toggleMark(name, attrs) {\n const sel = currentSelection();\n if (!sel) return;\n // Validate first — if the mark isn't in the schema, we drop on the floor.\n const { sanitised, violations } = validateMarks({ [name]: attrs ?? {} }, schema);\n if (violations.length > 0 || !(name in sanitised)) return;\n const normalisedAttrs = sanitised[name] as MarkAttrs;\n\n if (sel.from === sel.to) {\n // Collapsed selection: toggle pending mark. The next typed\n // character will adopt it (or strip it if turning off).\n const pending = pendingMarks();\n const inherited = inheritedMarksAt(yText, sel.from);\n const isOnInherited = name in inherited;\n const isOnPending = name in pending;\n const isOn = isOnPending ? Boolean(pending[name]) : isOnInherited;\n const next: MarkAttrs = { ...pending };\n if (isOn) {\n // Mark is currently on for the next-character — turn it off.\n // We have to distinguish: if it's on via inheritance, set\n // `false` so the insert path overrides; if only via pending,\n // delete the pending entry.\n if (isOnInherited) next[name] = false;\n else delete next[name];\n } else {\n next[name] = normalisedAttrs;\n }\n setPendingMarks(next);\n return;\n }\n\n // Range selection: format the range. Turn on unless every char\n // already has the mark, in which case turn off.\n const common = marksInRange(yText, sel.from, sel.to);\n const allHaveIt = name in common;\n formatRange(yText, sel.from, sel.to, name, allHaveIt ? null : normalisedAttrs);\n },\n\n setMark(name, attrs) {\n const sel = currentSelection();\n if (!sel) return;\n const { sanitised, violations } = validateMarks({ [name]: attrs ?? {} }, schema);\n if (violations.length > 0 || !(name in sanitised)) return;\n if (sel.from === sel.to) {\n setPendingMarks({ ...pendingMarks(), [name]: sanitised[name] as MarkAttrs });\n return;\n }\n formatRange(yText, sel.from, sel.to, name, sanitised[name] as MarkAttrs);\n },\n\n removeMark(name) {\n const sel = currentSelection();\n if (!sel) return;\n if (sel.from === sel.to) {\n const pending = pendingMarks();\n const inherited = inheritedMarksAt(yText, sel.from);\n const next: MarkAttrs = { ...pending };\n if (name in inherited) {\n next[name] = false;\n } else {\n delete next[name];\n }\n setPendingMarks(next);\n return;\n }\n formatRange(yText, sel.from, sel.to, name, null);\n },\n\n marksAtSelection() {\n const sel = currentSelection();\n if (!sel) return {};\n const base =\n sel.from === sel.to\n ? inheritedMarksAt(yText, sel.from)\n : marksInRange(yText, sel.from, sel.to);\n // Overlay pending marks: `false` removes, anything truthy replaces.\n const pending = pendingMarks();\n const merged: MarkAttrs = { ...base };\n for (const [name, value] of Object.entries(pending)) {\n if (value === false) delete merged[name];\n else merged[name] = value;\n }\n return merged;\n },\n\n insertText(text, attrs) {\n const sel = currentSelection() ?? { from: yText.length, to: yText.length };\n transactProgrammatic(yText, () => {\n if (sel.from !== sel.to) {\n yText.delete(sel.from, sel.to - sel.from);\n }\n if (text.length === 0) return;\n const inherited = inheritedMarksAt(yText, sel.from);\n const combined: MarkAttrs = {\n ...inherited,\n ...pendingMarks(),\n ...(attrs ?? {}),\n };\n const { sanitised } = validateMarks(combined, schema);\n yText.insert(sel.from, text, Object.keys(sanitised).length > 0 ? sanitised : undefined);\n });\n },\n\n deleteRange(from, to) {\n if (to <= from) return;\n transactProgrammatic(yText, () => {\n yText.delete(from, to - from);\n });\n },\n\n focus() {\n getEditor(yText)?.focus();\n },\n\n blur() {\n getEditor(yText)?.blur();\n },\n\n isFocused() {\n return getEditor(yText)?.isFocused() ?? false;\n },\n };\n }, [yText, schema]);\n}\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'YjsText'
|
|
3
|
+
s.version = '0.1.0'
|
|
4
|
+
s.summary = 'Native React Native rich text editor backed by Y.Text — no WebView, no contenteditable'
|
|
5
|
+
s.description = <<-DESC
|
|
6
|
+
react-native-yjs-text renders a Y.Text shared type into a native UITextView (iOS)
|
|
7
|
+
or AppCompatEditText (Android), with bidirectional sync between user edits and the
|
|
8
|
+
Yjs CRDT. No WebView, no contenteditable, no browser shim — every editor instance
|
|
9
|
+
edits one Y.Text inline rich-text region. The consumer owns the Y.Doc and Y.Text;
|
|
10
|
+
the library is the native view binding.
|
|
11
|
+
DESC
|
|
12
|
+
s.author = { 'Eclosion Technologies' => 'hello@eclosion.tech' }
|
|
13
|
+
s.homepage = 'https://github.com/eclosion-tech/react-native-yjs-text'
|
|
14
|
+
s.license = { :type => 'MIT', :file => '../LICENSE' }
|
|
15
|
+
s.platforms = {
|
|
16
|
+
:ios => '16.4',
|
|
17
|
+
:tvos => '16.4'
|
|
18
|
+
}
|
|
19
|
+
s.source = { git: 'https://github.com/eclosion-tech/react-native-yjs-text.git' }
|
|
20
|
+
s.static_framework = true
|
|
21
|
+
|
|
22
|
+
s.dependency 'ExpoModulesCore'
|
|
23
|
+
|
|
24
|
+
# Swift/Objective-C compatibility
|
|
25
|
+
s.pod_target_xcconfig = {
|
|
26
|
+
'DEFINES_MODULE' => 'YES',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
30
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
/// Expo Module registration for `@eclosion-tech/react-native-yjs-text` on iOS.
|
|
4
|
+
///
|
|
5
|
+
/// The view's behaviour lives in `YjsTextView`; this file is just the wiring
|
|
6
|
+
/// declaration the Expo Modules DSL needs in order to:
|
|
7
|
+
/// - publish the four events the view dispatches
|
|
8
|
+
/// - bind every prop the JS side sends down to its `didSet` setter
|
|
9
|
+
/// - expose the imperative methods (`focus`, `blur`, `setSelection`,
|
|
10
|
+
/// `isFocused`) as `AsyncFunction`s that JS resolves via the view's ref
|
|
11
|
+
public class YjsTextModule: Module {
|
|
12
|
+
public func definition() -> ModuleDefinition {
|
|
13
|
+
Name("YjsText")
|
|
14
|
+
|
|
15
|
+
View(YjsTextView.self) {
|
|
16
|
+
Events(
|
|
17
|
+
"onContentChange",
|
|
18
|
+
"onNativeSelectionChange",
|
|
19
|
+
"onFocusChange",
|
|
20
|
+
"onMarkTap"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// MARK: - Props
|
|
24
|
+
|
|
25
|
+
Prop("runs") { (view: YjsTextView, value: [YjsTextRunRecord]) in
|
|
26
|
+
view.runs = value
|
|
27
|
+
}
|
|
28
|
+
Prop("renderSpec") { (view: YjsTextView, value: [String: YjsTextMarkStyleRecord]) in
|
|
29
|
+
view.renderSpec = value
|
|
30
|
+
}
|
|
31
|
+
Prop("pendingSelection") { (view: YjsTextView, value: YjsTextSelectionRecord?) in
|
|
32
|
+
view.pendingSelection = value
|
|
33
|
+
}
|
|
34
|
+
Prop("editable") { (view: YjsTextView, value: Bool) in
|
|
35
|
+
view.editable = value
|
|
36
|
+
}
|
|
37
|
+
Prop("placeholder") { (view: YjsTextView, value: String?) in
|
|
38
|
+
view.placeholder = value
|
|
39
|
+
}
|
|
40
|
+
Prop("placeholderColor") { (view: YjsTextView, value: String?) in
|
|
41
|
+
view.placeholderColor = YjsTextColor.parse(value) ?? UIColor(white: 0.6, alpha: 1.0)
|
|
42
|
+
}
|
|
43
|
+
Prop("baseFontSize") { (view: YjsTextView, value: Double?) in
|
|
44
|
+
view.baseFontSize = value
|
|
45
|
+
}
|
|
46
|
+
Prop("baseFontFamily") { (view: YjsTextView, value: String?) in
|
|
47
|
+
view.baseFontFamily = value
|
|
48
|
+
}
|
|
49
|
+
Prop("baseColor") { (view: YjsTextView, value: String?) in
|
|
50
|
+
view.baseColor = YjsTextColor.parse(value)
|
|
51
|
+
}
|
|
52
|
+
Prop("baseFontWeight") { (view: YjsTextView, value: String?) in
|
|
53
|
+
view.baseFontWeight = value
|
|
54
|
+
}
|
|
55
|
+
Prop("baseFontStyle") { (view: YjsTextView, value: String?) in
|
|
56
|
+
view.baseFontStyle = value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// MARK: - View methods (callable via the view's React ref)
|
|
60
|
+
|
|
61
|
+
AsyncFunction("focus") { (view: YjsTextView) in
|
|
62
|
+
view.focusInput()
|
|
63
|
+
}
|
|
64
|
+
AsyncFunction("blur") { (view: YjsTextView) in
|
|
65
|
+
view.blurInput()
|
|
66
|
+
}
|
|
67
|
+
AsyncFunction("isFocused") { (view: YjsTextView) -> Bool in
|
|
68
|
+
view.isInputFocused()
|
|
69
|
+
}
|
|
70
|
+
AsyncFunction("setSelection") { (view: YjsTextView, from: Int, to: Int) in
|
|
71
|
+
view.setSelection(from: from, to: to)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
// MARK: - Bridge Records
|
|
6
|
+
|
|
7
|
+
/// A single attributed run as sent from JS. `marksJson` is a JSON-stringified
|
|
8
|
+
/// `{ markName: attrs }` object — see the SerializedRun comment on the JS
|
|
9
|
+
/// side for why we use JSON instead of a typed Record field.
|
|
10
|
+
struct YjsTextRunRecord: Record {
|
|
11
|
+
@Field var text: String = ""
|
|
12
|
+
@Field var marksJson: String = "{}"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Per-mark style spec, mirrored from the JS `CompiledRenderSpec`. Only the
|
|
16
|
+
/// keys in this struct are honoured; unknown keys are dropped during
|
|
17
|
+
/// deserialisation.
|
|
18
|
+
struct YjsTextMarkStyleRecord: Record {
|
|
19
|
+
@Field var fontWeight: String? = nil
|
|
20
|
+
@Field var fontStyle: String? = nil
|
|
21
|
+
@Field var fontFamily: String? = nil
|
|
22
|
+
@Field var fontSize: Double? = nil
|
|
23
|
+
@Field var color: String? = nil
|
|
24
|
+
@Field var backgroundColor: String? = nil
|
|
25
|
+
@Field var textDecorationLine: String? = nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// JS-pushed selection bundle. `version` is a monotonic counter the JS side
|
|
29
|
+
/// bumps every time a selection change must be reflected on the native view —
|
|
30
|
+
/// we re-apply only when it changes.
|
|
31
|
+
///
|
|
32
|
+
/// This is a SINGLE Record rather than two separate props (`selection` and
|
|
33
|
+
/// `selectionVersion`) because Expo Modules doesn't guarantee setter order
|
|
34
|
+
/// within a render batch on Fabric. If `selectionVersion` were set before
|
|
35
|
+
/// `selection`, an `applySelectionIfNeeded()` triggered by the version
|
|
36
|
+
/// didSet would read a stale `selection` and push the *previous* range to
|
|
37
|
+
/// the textView — the "selection jumps to previously selected text" bug.
|
|
38
|
+
/// Bundling makes the update atomic: one setter, one didSet, no race.
|
|
39
|
+
struct YjsTextSelectionRecord: Record {
|
|
40
|
+
@Field var from: Int = 0
|
|
41
|
+
@Field var to: Int = 0
|
|
42
|
+
@Field var version: Int = -1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// MARK: - Event payloads (documentation only)
|
|
46
|
+
//
|
|
47
|
+
// Swift's `EventDispatcher` takes `[String: Any?]` dictionaries, not typed
|
|
48
|
+
// Records (the generic-payload form only exists in the Kotlin DSL). The
|
|
49
|
+
// dispatch sites in `YjsTextView` build matching dictionaries inline; the
|
|
50
|
+
// types below document the contract for human readers and stay in lockstep
|
|
51
|
+
// with the Kotlin / TypeScript shapes.
|
|
52
|
+
//
|
|
53
|
+
// onContentChange -> { type: "replace", from: Int, to: Int, text: String }
|
|
54
|
+
// onNativeSelectionChange -> { from: Int, to: Int }
|
|
55
|
+
// onFocusChange -> { focused: Bool }
|
|
56
|
+
// onMarkTap (reserved) -> { mark: String, attrsJson: String }
|
|
57
|
+
|
|
58
|
+
// MARK: - Utilities
|
|
59
|
+
|
|
60
|
+
enum YjsTextColor {
|
|
61
|
+
/// Parse a CSS-ish colour string (`#rrggbb`, `#rrggbbaa`, `rgb(...)`,
|
|
62
|
+
/// `rgba(...)`, named colours) into a `UIColor`. Returns nil on failure
|
|
63
|
+
/// rather than throwing — the caller falls back to the platform default.
|
|
64
|
+
static func parse(_ input: String?) -> UIColor? {
|
|
65
|
+
guard let raw = input?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
// Hex
|
|
69
|
+
if raw.hasPrefix("#") {
|
|
70
|
+
return parseHex(String(raw.dropFirst()))
|
|
71
|
+
}
|
|
72
|
+
// rgb / rgba
|
|
73
|
+
if raw.lowercased().hasPrefix("rgb") {
|
|
74
|
+
return parseRGB(raw)
|
|
75
|
+
}
|
|
76
|
+
// A handful of named colours that show up in the default schema.
|
|
77
|
+
switch raw.lowercased() {
|
|
78
|
+
case "black": return .black
|
|
79
|
+
case "white": return .white
|
|
80
|
+
case "transparent": return .clear
|
|
81
|
+
default: return nil
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private static func parseHex(_ hex: String) -> UIColor? {
|
|
86
|
+
var r: UInt64 = 0, g: UInt64 = 0, b: UInt64 = 0, a: UInt64 = 0xff
|
|
87
|
+
let s = hex.uppercased()
|
|
88
|
+
let scanner = Scanner(string: s)
|
|
89
|
+
var value: UInt64 = 0
|
|
90
|
+
guard scanner.scanHexInt64(&value) else { return nil }
|
|
91
|
+
switch s.count {
|
|
92
|
+
case 6:
|
|
93
|
+
r = (value & 0xff0000) >> 16
|
|
94
|
+
g = (value & 0x00ff00) >> 8
|
|
95
|
+
b = (value & 0x0000ff)
|
|
96
|
+
case 8:
|
|
97
|
+
r = (value & 0xff000000) >> 24
|
|
98
|
+
g = (value & 0x00ff0000) >> 16
|
|
99
|
+
b = (value & 0x0000ff00) >> 8
|
|
100
|
+
a = (value & 0x000000ff)
|
|
101
|
+
case 3:
|
|
102
|
+
r = ((value & 0xf00) >> 8) * 0x11
|
|
103
|
+
g = ((value & 0x0f0) >> 4) * 0x11
|
|
104
|
+
b = (value & 0x00f) * 0x11
|
|
105
|
+
default:
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
return UIColor(
|
|
109
|
+
red: CGFloat(r) / 255.0,
|
|
110
|
+
green: CGFloat(g) / 255.0,
|
|
111
|
+
blue: CGFloat(b) / 255.0,
|
|
112
|
+
alpha: CGFloat(a) / 255.0
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private static func parseRGB(_ raw: String) -> UIColor? {
|
|
117
|
+
let pieces = raw
|
|
118
|
+
.replacingOccurrences(of: " ", with: "")
|
|
119
|
+
.replacingOccurrences(of: "rgba(", with: "")
|
|
120
|
+
.replacingOccurrences(of: "rgb(", with: "")
|
|
121
|
+
.replacingOccurrences(of: ")", with: "")
|
|
122
|
+
.split(separator: ",")
|
|
123
|
+
guard pieces.count == 3 || pieces.count == 4 else { return nil }
|
|
124
|
+
let r = Double(pieces[0]) ?? 0
|
|
125
|
+
let g = Double(pieces[1]) ?? 0
|
|
126
|
+
let b = Double(pieces[2]) ?? 0
|
|
127
|
+
let a = pieces.count == 4 ? (Double(pieces[3]) ?? 1) : 1
|
|
128
|
+
return UIColor(
|
|
129
|
+
red: CGFloat(r) / 255.0,
|
|
130
|
+
green: CGFloat(g) / 255.0,
|
|
131
|
+
blue: CGFloat(b) / 255.0,
|
|
132
|
+
alpha: CGFloat(a)
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|