@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,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,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["YjsTextModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["tech.eclosion.yjstext.YjsTextModule"]
8
+ }
9
+ }
@@ -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
+ }