react-native-richify 1.0.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 (172) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/lib/commonjs/components/OverlayText.d.js +6 -0
  4. package/lib/commonjs/components/OverlayText.d.js.map +1 -0
  5. package/lib/commonjs/components/OverlayText.js +45 -0
  6. package/lib/commonjs/components/OverlayText.js.map +1 -0
  7. package/lib/commonjs/components/RichTextInput.d.js +6 -0
  8. package/lib/commonjs/components/RichTextInput.d.js.map +1 -0
  9. package/lib/commonjs/components/RichTextInput.js +160 -0
  10. package/lib/commonjs/components/RichTextInput.js.map +1 -0
  11. package/lib/commonjs/components/Toolbar.d.js +6 -0
  12. package/lib/commonjs/components/Toolbar.d.js.map +1 -0
  13. package/lib/commonjs/components/Toolbar.js +99 -0
  14. package/lib/commonjs/components/Toolbar.js.map +1 -0
  15. package/lib/commonjs/components/ToolbarButton.d.js +6 -0
  16. package/lib/commonjs/components/ToolbarButton.d.js.map +1 -0
  17. package/lib/commonjs/components/ToolbarButton.js +63 -0
  18. package/lib/commonjs/components/ToolbarButton.js.map +1 -0
  19. package/lib/commonjs/constants/defaultStyles.d.js +6 -0
  20. package/lib/commonjs/constants/defaultStyles.d.js.map +1 -0
  21. package/lib/commonjs/constants/defaultStyles.js +172 -0
  22. package/lib/commonjs/constants/defaultStyles.js.map +1 -0
  23. package/lib/commonjs/context/RichTextContext.d.js +6 -0
  24. package/lib/commonjs/context/RichTextContext.d.js.map +1 -0
  25. package/lib/commonjs/context/RichTextContext.js +61 -0
  26. package/lib/commonjs/context/RichTextContext.js.map +1 -0
  27. package/lib/commonjs/hooks/useFormatting.d.js +6 -0
  28. package/lib/commonjs/hooks/useFormatting.d.js.map +1 -0
  29. package/lib/commonjs/hooks/useFormatting.js +82 -0
  30. package/lib/commonjs/hooks/useFormatting.js.map +1 -0
  31. package/lib/commonjs/hooks/useRichText.d.js +6 -0
  32. package/lib/commonjs/hooks/useRichText.d.js.map +1 -0
  33. package/lib/commonjs/hooks/useRichText.js +136 -0
  34. package/lib/commonjs/hooks/useRichText.js.map +1 -0
  35. package/lib/commonjs/hooks/useSelection.d.js +6 -0
  36. package/lib/commonjs/hooks/useSelection.d.js.map +1 -0
  37. package/lib/commonjs/hooks/useSelection.js +39 -0
  38. package/lib/commonjs/hooks/useSelection.js.map +1 -0
  39. package/lib/commonjs/index.d.js +186 -0
  40. package/lib/commonjs/index.d.js.map +1 -0
  41. package/lib/commonjs/index.js +186 -0
  42. package/lib/commonjs/index.js.map +1 -0
  43. package/lib/commonjs/package.json +1 -0
  44. package/lib/commonjs/types/index.d.js +6 -0
  45. package/lib/commonjs/types/index.d.js.map +1 -0
  46. package/lib/commonjs/types/index.js +6 -0
  47. package/lib/commonjs/types/index.js.map +1 -0
  48. package/lib/commonjs/utils/formatter.d.js +13 -0
  49. package/lib/commonjs/utils/formatter.d.js.map +1 -0
  50. package/lib/commonjs/utils/formatter.js +229 -0
  51. package/lib/commonjs/utils/formatter.js.map +1 -0
  52. package/lib/commonjs/utils/parser.d.js +6 -0
  53. package/lib/commonjs/utils/parser.d.js.map +1 -0
  54. package/lib/commonjs/utils/parser.js +221 -0
  55. package/lib/commonjs/utils/parser.js.map +1 -0
  56. package/lib/commonjs/utils/styleMapper.d.js +6 -0
  57. package/lib/commonjs/utils/styleMapper.d.js.map +1 -0
  58. package/lib/commonjs/utils/styleMapper.js +87 -0
  59. package/lib/commonjs/utils/styleMapper.js.map +1 -0
  60. package/lib/module/components/OverlayText.d.js +4 -0
  61. package/lib/module/components/OverlayText.d.js.map +1 -0
  62. package/lib/module/components/OverlayText.js +41 -0
  63. package/lib/module/components/OverlayText.js.map +1 -0
  64. package/lib/module/components/RichTextInput.d.js +4 -0
  65. package/lib/module/components/RichTextInput.d.js.map +1 -0
  66. package/lib/module/components/RichTextInput.js +155 -0
  67. package/lib/module/components/RichTextInput.js.map +1 -0
  68. package/lib/module/components/Toolbar.d.js +4 -0
  69. package/lib/module/components/Toolbar.d.js.map +1 -0
  70. package/lib/module/components/Toolbar.js +95 -0
  71. package/lib/module/components/Toolbar.js.map +1 -0
  72. package/lib/module/components/ToolbarButton.d.js +4 -0
  73. package/lib/module/components/ToolbarButton.d.js.map +1 -0
  74. package/lib/module/components/ToolbarButton.js +59 -0
  75. package/lib/module/components/ToolbarButton.js.map +1 -0
  76. package/lib/module/constants/defaultStyles.d.js +4 -0
  77. package/lib/module/constants/defaultStyles.d.js.map +1 -0
  78. package/lib/module/constants/defaultStyles.js +168 -0
  79. package/lib/module/constants/defaultStyles.js.map +1 -0
  80. package/lib/module/context/RichTextContext.d.js +4 -0
  81. package/lib/module/context/RichTextContext.d.js.map +1 -0
  82. package/lib/module/context/RichTextContext.js +55 -0
  83. package/lib/module/context/RichTextContext.js.map +1 -0
  84. package/lib/module/hooks/useFormatting.d.js +11 -0
  85. package/lib/module/hooks/useFormatting.d.js.map +1 -0
  86. package/lib/module/hooks/useFormatting.js +78 -0
  87. package/lib/module/hooks/useFormatting.js.map +1 -0
  88. package/lib/module/hooks/useRichText.d.js +4 -0
  89. package/lib/module/hooks/useRichText.d.js.map +1 -0
  90. package/lib/module/hooks/useRichText.js +132 -0
  91. package/lib/module/hooks/useRichText.js.map +1 -0
  92. package/lib/module/hooks/useSelection.d.js +4 -0
  93. package/lib/module/hooks/useSelection.d.js.map +1 -0
  94. package/lib/module/hooks/useSelection.js +35 -0
  95. package/lib/module/hooks/useSelection.js.map +1 -0
  96. package/lib/module/index.d.js +15 -0
  97. package/lib/module/index.d.js.map +1 -0
  98. package/lib/module/index.js +25 -0
  99. package/lib/module/index.js.map +1 -0
  100. package/lib/module/types/index.d.js +4 -0
  101. package/lib/module/types/index.d.js.map +1 -0
  102. package/lib/module/types/index.js +4 -0
  103. package/lib/module/types/index.js.map +1 -0
  104. package/lib/module/utils/formatter.d.js +30 -0
  105. package/lib/module/utils/formatter.d.js.map +1 -0
  106. package/lib/module/utils/formatter.js +217 -0
  107. package/lib/module/utils/formatter.js.map +1 -0
  108. package/lib/module/utils/parser.d.js +4 -0
  109. package/lib/module/utils/parser.d.js.map +1 -0
  110. package/lib/module/utils/parser.js +211 -0
  111. package/lib/module/utils/parser.js.map +1 -0
  112. package/lib/module/utils/styleMapper.d.js +4 -0
  113. package/lib/module/utils/styleMapper.d.js.map +1 -0
  114. package/lib/module/utils/styleMapper.js +82 -0
  115. package/lib/module/utils/styleMapper.js.map +1 -0
  116. package/lib/typescript/src/components/OverlayText.d.ts +11 -0
  117. package/lib/typescript/src/components/OverlayText.d.ts.map +1 -0
  118. package/lib/typescript/src/components/RichTextInput.d.ts +21 -0
  119. package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -0
  120. package/lib/typescript/src/components/Toolbar.d.ts +13 -0
  121. package/lib/typescript/src/components/Toolbar.d.ts.map +1 -0
  122. package/lib/typescript/src/components/ToolbarButton.d.ts +8 -0
  123. package/lib/typescript/src/components/ToolbarButton.d.ts.map +1 -0
  124. package/lib/typescript/src/constants/defaultStyles.d.ts +46 -0
  125. package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -0
  126. package/lib/typescript/src/context/RichTextContext.d.ts +31 -0
  127. package/lib/typescript/src/context/RichTextContext.d.ts.map +1 -0
  128. package/lib/typescript/src/hooks/useFormatting.d.ts +26 -0
  129. package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -0
  130. package/lib/typescript/src/hooks/useRichText.d.ts +17 -0
  131. package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -0
  132. package/lib/typescript/src/hooks/useSelection.d.ts +14 -0
  133. package/lib/typescript/src/hooks/useSelection.d.ts.map +1 -0
  134. package/lib/typescript/src/index.d.ts +16 -0
  135. package/lib/typescript/src/index.d.ts.map +1 -0
  136. package/lib/typescript/src/types/index.d.ts +245 -0
  137. package/lib/typescript/src/types/index.d.ts.map +1 -0
  138. package/lib/typescript/src/utils/formatter.d.ts +29 -0
  139. package/lib/typescript/src/utils/formatter.d.ts.map +1 -0
  140. package/lib/typescript/src/utils/parser.d.ts +46 -0
  141. package/lib/typescript/src/utils/parser.d.ts.map +1 -0
  142. package/lib/typescript/src/utils/styleMapper.d.ts +16 -0
  143. package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -0
  144. package/package.json +83 -0
  145. package/src/components/OverlayText.d.ts +10 -0
  146. package/src/components/OverlayText.tsx +46 -0
  147. package/src/components/RichTextInput.d.ts +20 -0
  148. package/src/components/RichTextInput.tsx +174 -0
  149. package/src/components/Toolbar.d.ts +12 -0
  150. package/src/components/Toolbar.tsx +100 -0
  151. package/src/components/ToolbarButton.d.ts +7 -0
  152. package/src/components/ToolbarButton.tsx +65 -0
  153. package/src/constants/defaultStyles.d.ts +45 -0
  154. package/src/constants/defaultStyles.ts +144 -0
  155. package/src/context/RichTextContext.d.ts +30 -0
  156. package/src/context/RichTextContext.tsx +63 -0
  157. package/src/hooks/useFormatting.d.ts +25 -0
  158. package/src/hooks/useFormatting.ts +135 -0
  159. package/src/hooks/useRichText.d.ts +16 -0
  160. package/src/hooks/useRichText.ts +171 -0
  161. package/src/hooks/useSelection.d.ts +13 -0
  162. package/src/hooks/useSelection.ts +40 -0
  163. package/src/index.d.ts +15 -0
  164. package/src/index.ts +68 -0
  165. package/src/types/index.d.ts +244 -0
  166. package/src/types/index.ts +295 -0
  167. package/src/utils/formatter.d.ts +28 -0
  168. package/src/utils/formatter.ts +276 -0
  169. package/src/utils/parser.d.ts +45 -0
  170. package/src/utils/parser.ts +252 -0
  171. package/src/utils/styleMapper.d.ts +15 -0
  172. package/src/utils/styleMapper.ts +92 -0
@@ -0,0 +1,276 @@
1
+ import type {
2
+ StyledSegment,
3
+ FormatType,
4
+ FormatStyle,
5
+ HeadingLevel,
6
+ SelectionRange,
7
+ } from '@/types';
8
+ import {
9
+ createSegment,
10
+ findPositionInSegments,
11
+ splitSegment,
12
+ mergeAdjacentSegments,
13
+ segmentsToPlainText,
14
+ } from '@/utils/parser';
15
+ import { HEADING_FONT_SIZES } from '@/constants/defaultStyles';
16
+
17
+ /**
18
+ * Toggle an inline format (bold, italic, etc.) on the selected range.
19
+ *
20
+ * If the entire selection already has the format, it is removed.
21
+ * Otherwise, it is applied to the entire selection.
22
+ *
23
+ * Returns the new segments array.
24
+ */
25
+ export function toggleFormatOnSelection(
26
+ segments: StyledSegment[],
27
+ selection: SelectionRange,
28
+ format: FormatType,
29
+ ): StyledSegment[] {
30
+ if (selection.start === selection.end) {
31
+ // No selection — return unchanged (active styles handle this case)
32
+ return segments;
33
+ }
34
+
35
+ const { start, end } = normalizeSelection(selection);
36
+
37
+ // Extract the selected segments to check current state
38
+ const selectedSegments = extractSegmentsInRange(segments, start, end);
39
+ const allHaveFormat = selectedSegments.every((s) => !!s.styles[format]);
40
+
41
+ // Apply or remove the format
42
+ return applyStyleToRange(segments, start, end, {
43
+ [format]: !allHaveFormat,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Set a specific style property on the selected range.
49
+ */
50
+ export function setStyleOnSelection<K extends keyof FormatStyle>(
51
+ segments: StyledSegment[],
52
+ selection: SelectionRange,
53
+ key: K,
54
+ value: FormatStyle[K],
55
+ ): StyledSegment[] {
56
+ if (selection.start === selection.end) {
57
+ return segments;
58
+ }
59
+
60
+ const { start, end } = normalizeSelection(selection);
61
+ return applyStyleToRange(segments, start, end, { [key]: value });
62
+ }
63
+
64
+ /**
65
+ * Apply a heading level to the line containing the cursor/selection.
66
+ */
67
+ export function setHeadingOnLine(
68
+ segments: StyledSegment[],
69
+ selection: SelectionRange,
70
+ level: HeadingLevel,
71
+ ): StyledSegment[] {
72
+ const plainText = segmentsToPlainText(segments);
73
+ const { lineStart, lineEnd } = getLineRange(plainText, selection.start);
74
+
75
+ const headingStyle: Partial<FormatStyle> = {
76
+ heading: level,
77
+ fontSize: HEADING_FONT_SIZES[level],
78
+ bold: level !== 'none' ? true : undefined,
79
+ };
80
+
81
+ return applyStyleToRange(segments, lineStart, lineEnd, headingStyle);
82
+ }
83
+
84
+ /**
85
+ * Checks whether the given format is active across the entire selection.
86
+ */
87
+ export function isFormatActiveInSelection(
88
+ segments: StyledSegment[],
89
+ selection: SelectionRange,
90
+ format: FormatType,
91
+ ): boolean {
92
+ if (selection.start === selection.end) {
93
+ return false;
94
+ }
95
+
96
+ const { start, end } = normalizeSelection(selection);
97
+ const selected = extractSegmentsInRange(segments, start, end);
98
+ return selected.length > 0 && selected.every((s) => !!s.styles[format]);
99
+ }
100
+
101
+ /**
102
+ * Gets the format style that is common across the entire selection.
103
+ * For properties where segments disagree, the value is undefined.
104
+ */
105
+ export function getSelectionStyle(
106
+ segments: StyledSegment[],
107
+ selection: SelectionRange,
108
+ ): FormatStyle {
109
+ if (selection.start === selection.end) {
110
+ // Return style at cursor position
111
+ const pos = findPositionInSegments(segments, selection.start);
112
+ if (segments.length > 0) {
113
+ return { ...segments[pos.segmentIndex].styles };
114
+ }
115
+ return {};
116
+ }
117
+
118
+ const { start, end } = normalizeSelection(selection);
119
+ const selected = extractSegmentsInRange(segments, start, end);
120
+
121
+ if (selected.length === 0) return {};
122
+
123
+ const result: FormatStyle = { ...selected[0].styles };
124
+
125
+ for (let i = 1; i < selected.length; i++) {
126
+ const s = selected[i].styles;
127
+ if (result.bold !== undefined && result.bold !== !!s.bold)
128
+ result.bold = undefined;
129
+ if (result.italic !== undefined && result.italic !== !!s.italic)
130
+ result.italic = undefined;
131
+ if (result.underline !== undefined && result.underline !== !!s.underline)
132
+ result.underline = undefined;
133
+ if (
134
+ result.strikethrough !== undefined &&
135
+ result.strikethrough !== !!s.strikethrough
136
+ )
137
+ result.strikethrough = undefined;
138
+ if (result.code !== undefined && result.code !== !!s.code)
139
+ result.code = undefined;
140
+ if (result.color !== s.color) result.color = undefined;
141
+ if (result.backgroundColor !== s.backgroundColor)
142
+ result.backgroundColor = undefined;
143
+ if (result.fontSize !== s.fontSize) result.fontSize = undefined;
144
+ if (result.heading !== s.heading) result.heading = undefined;
145
+ }
146
+
147
+ return result;
148
+ }
149
+
150
+ // ─── Internal Helpers ────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Normalize selection so start <= end.
154
+ */
155
+ function normalizeSelection(selection: SelectionRange): SelectionRange {
156
+ return {
157
+ start: Math.min(selection.start, selection.end),
158
+ end: Math.max(selection.start, selection.end),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Extract the text segments that fall within [start, end) of the global text.
164
+ */
165
+ function extractSegmentsInRange(
166
+ segments: StyledSegment[],
167
+ start: number,
168
+ end: number,
169
+ ): StyledSegment[] {
170
+ const result: StyledSegment[] = [];
171
+ let pos = 0;
172
+
173
+ for (const seg of segments) {
174
+ const segStart = pos;
175
+ const segEnd = pos + seg.text.length;
176
+
177
+ if (segEnd <= start) {
178
+ pos = segEnd;
179
+ continue;
180
+ }
181
+ if (segStart >= end) {
182
+ break;
183
+ }
184
+
185
+ // This segment overlaps with [start, end)
186
+ const overlapStart = Math.max(segStart, start);
187
+ const overlapEnd = Math.min(segEnd, end);
188
+ result.push(
189
+ createSegment(
190
+ seg.text.slice(overlapStart - segStart, overlapEnd - segStart),
191
+ seg.styles,
192
+ ),
193
+ );
194
+
195
+ pos = segEnd;
196
+ }
197
+
198
+ return result;
199
+ }
200
+
201
+ /**
202
+ * Apply a partial style to a character range within the segments array.
203
+ * Splits segments at boundaries and applies the style delta to all segments in range.
204
+ */
205
+ function applyStyleToRange(
206
+ segments: StyledSegment[],
207
+ start: number,
208
+ end: number,
209
+ styleDelta: Partial<FormatStyle>,
210
+ ): StyledSegment[] {
211
+ const result: StyledSegment[] = [];
212
+ let pos = 0;
213
+
214
+ for (const seg of segments) {
215
+ const segStart = pos;
216
+ const segEnd = pos + seg.text.length;
217
+
218
+ if (segEnd <= start || segStart >= end) {
219
+ // Outside the range — keep as-is
220
+ result.push(createSegment(seg.text, seg.styles));
221
+ } else {
222
+ // Overlaps with range — may need to split
223
+ if (segStart < start) {
224
+ // Portion before the range
225
+ result.push(
226
+ createSegment(seg.text.slice(0, start - segStart), seg.styles),
227
+ );
228
+ }
229
+
230
+ // The overlapping portion — apply style delta
231
+ const overlapStart = Math.max(segStart, start);
232
+ const overlapEnd = Math.min(segEnd, end);
233
+ const newStyles = { ...seg.styles, ...styleDelta };
234
+ result.push(
235
+ createSegment(
236
+ seg.text.slice(overlapStart - segStart, overlapEnd - segStart),
237
+ newStyles,
238
+ ),
239
+ );
240
+
241
+ if (segEnd > end) {
242
+ // Portion after the range
243
+ result.push(
244
+ createSegment(seg.text.slice(end - segStart), seg.styles),
245
+ );
246
+ }
247
+ }
248
+
249
+ pos = segEnd;
250
+ }
251
+
252
+ return mergeAdjacentSegments(result);
253
+ }
254
+
255
+ /**
256
+ * Get the line start and end positions for the line containing the given position.
257
+ */
258
+ function getLineRange(
259
+ text: string,
260
+ position: number,
261
+ ): { lineStart: number; lineEnd: number } {
262
+ let lineStart = position;
263
+ while (lineStart > 0 && text[lineStart - 1] !== '\n') {
264
+ lineStart--;
265
+ }
266
+
267
+ let lineEnd = position;
268
+ while (lineEnd < text.length && text[lineEnd] !== '\n') {
269
+ lineEnd++;
270
+ }
271
+
272
+ return { lineStart, lineEnd };
273
+ }
274
+
275
+ // Re-export for convenience
276
+ export { createSegment } from '@/utils/parser';
@@ -0,0 +1,45 @@
1
+ import type { StyledSegment, FormatStyle } from '@/types';
2
+ /**
3
+ * Creates a new segment with the given text and optional styles.
4
+ */
5
+ export declare function createSegment(text: string, styles?: FormatStyle): StyledSegment;
6
+ /**
7
+ * Computes the total character length across all segments.
8
+ */
9
+ export declare function getTotalLength(segments: StyledSegment[]): number;
10
+ /**
11
+ * Converts an array of segments to plain text.
12
+ */
13
+ export declare function segmentsToPlainText(segments: StyledSegment[]): string;
14
+ /**
15
+ * Finds which segment and character offset a global position corresponds to.
16
+ * Returns { segmentIndex, offsetInSegment }.
17
+ */
18
+ export declare function findPositionInSegments(segments: StyledSegment[], globalPosition: number): {
19
+ segmentIndex: number;
20
+ offsetInSegment: number;
21
+ };
22
+ /**
23
+ * Splits a segment at the given offset, returning [before, after].
24
+ * If offset is 0 or at end, one side will have empty text.
25
+ */
26
+ export declare function splitSegment(segment: StyledSegment, offset: number): [StyledSegment, StyledSegment];
27
+ /**
28
+ * Checks if two FormatStyle objects are deeply equal.
29
+ */
30
+ export declare function areStylesEqual(a: FormatStyle, b: FormatStyle): boolean;
31
+ /**
32
+ * Merges adjacent segments that have identical styles.
33
+ * Returns a new array (does not mutate input).
34
+ */
35
+ export declare function mergeAdjacentSegments(segments: StyledSegment[]): StyledSegment[];
36
+ /**
37
+ * Given the old segments and new plain text (from TextInput onChange),
38
+ * reconcile the segments to preserve formatting while reflecting the text change.
39
+ *
40
+ * Strategy:
41
+ * 1. Find the diff region between old plain text and new plain text
42
+ * 2. Replace that region in the segment array
43
+ * 3. New text inserted at the diff point inherits the `activeStyles`
44
+ */
45
+ export declare function reconcileTextChange(oldSegments: StyledSegment[], newText: string, activeStyles: FormatStyle): StyledSegment[];
@@ -0,0 +1,252 @@
1
+ import type { StyledSegment, FormatStyle } from '@/types';
2
+ import { EMPTY_FORMAT_STYLE } from '@/constants/defaultStyles';
3
+
4
+ /**
5
+ * Creates a new segment with the given text and optional styles.
6
+ */
7
+ export function createSegment(
8
+ text: string,
9
+ styles: FormatStyle = { ...EMPTY_FORMAT_STYLE },
10
+ ): StyledSegment {
11
+ return { text, styles: { ...styles } };
12
+ }
13
+
14
+ /**
15
+ * Computes the total character length across all segments.
16
+ */
17
+ export function getTotalLength(segments: StyledSegment[]): number {
18
+ return segments.reduce((sum, seg) => sum + seg.text.length, 0);
19
+ }
20
+
21
+ /**
22
+ * Converts an array of segments to plain text.
23
+ */
24
+ export function segmentsToPlainText(segments: StyledSegment[]): string {
25
+ return segments.map((s) => s.text).join('');
26
+ }
27
+
28
+ /**
29
+ * Finds which segment and character offset a global position corresponds to.
30
+ * Returns { segmentIndex, offsetInSegment }.
31
+ */
32
+ export function findPositionInSegments(
33
+ segments: StyledSegment[],
34
+ globalPosition: number,
35
+ ): { segmentIndex: number; offsetInSegment: number } {
36
+ let remaining = globalPosition;
37
+
38
+ for (let i = 0; i < segments.length; i++) {
39
+ const segLen = segments[i].text.length;
40
+ if (remaining <= segLen) {
41
+ return { segmentIndex: i, offsetInSegment: remaining };
42
+ }
43
+ remaining -= segLen;
44
+ }
45
+
46
+ // Position is past the end — return end of last segment
47
+ const lastIndex = Math.max(0, segments.length - 1);
48
+ return {
49
+ segmentIndex: lastIndex,
50
+ offsetInSegment: segments.length > 0 ? segments[lastIndex].text.length : 0,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Splits a segment at the given offset, returning [before, after].
56
+ * If offset is 0 or at end, one side will have empty text.
57
+ */
58
+ export function splitSegment(
59
+ segment: StyledSegment,
60
+ offset: number,
61
+ ): [StyledSegment, StyledSegment] {
62
+ const before = createSegment(segment.text.slice(0, offset), segment.styles);
63
+ const after = createSegment(segment.text.slice(offset), segment.styles);
64
+ return [before, after];
65
+ }
66
+
67
+ /**
68
+ * Checks if two FormatStyle objects are deeply equal.
69
+ */
70
+ export function areStylesEqual(a: FormatStyle, b: FormatStyle): boolean {
71
+ return (
72
+ !!a.bold === !!b.bold &&
73
+ !!a.italic === !!b.italic &&
74
+ !!a.underline === !!b.underline &&
75
+ !!a.strikethrough === !!b.strikethrough &&
76
+ !!a.code === !!b.code &&
77
+ (a.color ?? undefined) === (b.color ?? undefined) &&
78
+ (a.backgroundColor ?? undefined) === (b.backgroundColor ?? undefined) &&
79
+ (a.fontSize ?? undefined) === (b.fontSize ?? undefined) &&
80
+ (a.heading ?? undefined) === (b.heading ?? undefined)
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Merges adjacent segments that have identical styles.
86
+ * Returns a new array (does not mutate input).
87
+ */
88
+ export function mergeAdjacentSegments(
89
+ segments: StyledSegment[],
90
+ ): StyledSegment[] {
91
+ if (segments.length === 0) {
92
+ return [createSegment('')];
93
+ }
94
+
95
+ const result: StyledSegment[] = [];
96
+ let last: StyledSegment | null = null;
97
+
98
+ for (const seg of segments) {
99
+ // Empty segment → acts as boundary
100
+ if (seg.text.length === 0) {
101
+ last = null; // break merge chain
102
+ continue;
103
+ }
104
+
105
+ if (last && areStylesEqual(last.styles, seg.styles)) {
106
+ last.text += seg.text;
107
+ } else {
108
+ const newSeg = createSegment(seg.text, seg.styles);
109
+ result.push(newSeg);
110
+ last = newSeg;
111
+ }
112
+ }
113
+
114
+ // If everything was empty
115
+ if (result.length === 0) {
116
+ return [createSegment('')];
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Given the old segments and new plain text (from TextInput onChange),
124
+ * reconcile the segments to preserve formatting while reflecting the text change.
125
+ *
126
+ * Strategy:
127
+ * 1. Find the diff region between old plain text and new plain text
128
+ * 2. Replace that region in the segment array
129
+ * 3. New text inserted at the diff point inherits the `activeStyles`
130
+ */
131
+ export function reconcileTextChange(
132
+ oldSegments: StyledSegment[],
133
+ newText: string,
134
+ activeStyles: FormatStyle,
135
+ ): StyledSegment[] {
136
+ const oldText = segmentsToPlainText(oldSegments);
137
+
138
+ if (newText === oldText) {
139
+ return oldSegments;
140
+ }
141
+
142
+ // Find common prefix length
143
+ let prefixLen = 0;
144
+ const minLen = Math.min(oldText.length, newText.length);
145
+ while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) {
146
+ prefixLen++;
147
+ }
148
+
149
+ // Find common suffix length (from end, but not overlapping with prefix)
150
+ let suffixLen = 0;
151
+ while (
152
+ suffixLen < minLen - prefixLen &&
153
+ oldText[oldText.length - 1 - suffixLen] ===
154
+ newText[newText.length - 1 - suffixLen]
155
+ ) {
156
+ suffixLen++;
157
+ }
158
+
159
+ const deleteStart = prefixLen;
160
+ const deleteEnd = oldText.length - suffixLen;
161
+ const insertedText = newText.slice(prefixLen, newText.length - suffixLen);
162
+
163
+ // Build new segments
164
+ // 1. Keep segments before deleteStart
165
+ // 2. Insert new text segment with activeStyles
166
+ // 3. Keep segments after deleteEnd
167
+
168
+ const result: StyledSegment[] = [];
169
+
170
+ let pos = 0;
171
+ let phase: 'before' | 'during' | 'after' = 'before';
172
+ let insertedNewSegment = false;
173
+
174
+ for (const seg of oldSegments) {
175
+ const segStart = pos;
176
+ const segEnd = pos + seg.text.length;
177
+
178
+ if (phase === 'before') {
179
+ if (segEnd <= deleteStart) {
180
+ // Entire segment is before delete region
181
+ result.push(createSegment(seg.text, seg.styles));
182
+ } else if (segStart < deleteStart) {
183
+ // Segment partially before delete region
184
+ result.push(
185
+ createSegment(seg.text.slice(0, deleteStart - segStart), seg.styles),
186
+ );
187
+
188
+ if (!insertedNewSegment && insertedText.length > 0) {
189
+ result.push(createSegment(insertedText, activeStyles));
190
+ insertedNewSegment = true;
191
+ }
192
+
193
+ if (segEnd > deleteEnd) {
194
+ // Segment also extends past delete region
195
+ result.push(
196
+ createSegment(seg.text.slice(deleteEnd - segStart), seg.styles),
197
+ );
198
+ phase = 'after';
199
+ } else {
200
+ phase = 'during';
201
+ }
202
+ } else {
203
+ // segStart >= deleteStart → we've reached the delete region
204
+ if (!insertedNewSegment && insertedText.length > 0) {
205
+ result.push(createSegment(insertedText, activeStyles));
206
+ insertedNewSegment = true;
207
+ }
208
+
209
+ if (segEnd <= deleteEnd) {
210
+ // Entire segment is within delete region — skip it
211
+ phase = segEnd === deleteEnd ? 'after' : 'during';
212
+ } else {
213
+ // Segment extends past delete region
214
+ result.push(
215
+ createSegment(seg.text.slice(deleteEnd - segStart), seg.styles),
216
+ );
217
+ phase = 'after';
218
+ }
219
+ }
220
+ } else if (phase === 'during') {
221
+ if (!insertedNewSegment && insertedText.length > 0) {
222
+ result.push(createSegment(insertedText, activeStyles));
223
+ insertedNewSegment = true;
224
+ }
225
+
226
+ if (segEnd <= deleteEnd) {
227
+ // Still in delete region — skip
228
+ if (segEnd === deleteEnd) {
229
+ phase = 'after';
230
+ }
231
+ } else {
232
+ // Segment extends past delete region
233
+ result.push(
234
+ createSegment(seg.text.slice(deleteEnd - segStart), seg.styles),
235
+ );
236
+ phase = 'after';
237
+ }
238
+ } else {
239
+ // phase === 'after'
240
+ result.push(createSegment(seg.text, seg.styles));
241
+ }
242
+
243
+ pos = segEnd;
244
+ }
245
+
246
+ // If we never inserted the new text (e.g., appending at end)
247
+ if (!insertedNewSegment && insertedText.length > 0) {
248
+ result.push(createSegment(insertedText, activeStyles));
249
+ }
250
+
251
+ return mergeAdjacentSegments(result);
252
+ }
@@ -0,0 +1,15 @@
1
+ import type { TextStyle } from 'react-native';
2
+ import type { FormatStyle, RichTextTheme, StyledSegment } from '@/types';
3
+ /**
4
+ * Maps a FormatStyle to a React Native TextStyle.
5
+ * Applies formatting properties based on the segment's style.
6
+ */
7
+ export declare function formatStyleToTextStyle(formatStyle: FormatStyle, theme?: RichTextTheme): TextStyle;
8
+ /**
9
+ * Maps an entire segment to its computed TextStyle (base + format).
10
+ */
11
+ export declare function segmentToTextStyle(segment: StyledSegment, theme?: RichTextTheme): TextStyle;
12
+ /**
13
+ * Batch-maps an array of segments to an array of TextStyles.
14
+ */
15
+ export declare function segmentsToTextStyles(segments: StyledSegment[], theme?: RichTextTheme): TextStyle[];
@@ -0,0 +1,92 @@
1
+ import type { TextStyle } from 'react-native';
2
+ import type { FormatStyle, RichTextTheme, StyledSegment } from '@/types';
3
+ import { DEFAULT_THEME, HEADING_FONT_SIZES } from '@/constants/defaultStyles';
4
+
5
+ /**
6
+ * Maps a FormatStyle to a React Native TextStyle.
7
+ * Applies formatting properties based on the segment's style.
8
+ */
9
+ export function formatStyleToTextStyle(
10
+ formatStyle: FormatStyle,
11
+ theme?: RichTextTheme,
12
+ ): TextStyle {
13
+ const resolvedTheme = theme ?? DEFAULT_THEME;
14
+ const style: TextStyle = {};
15
+
16
+ // Bold
17
+ if (formatStyle.bold) {
18
+ style.fontWeight = 'bold';
19
+ }
20
+
21
+ // Italic
22
+ if (formatStyle.italic) {
23
+ style.fontStyle = 'italic';
24
+ }
25
+
26
+ // Underline and strikethrough
27
+ if (formatStyle.underline && formatStyle.strikethrough) {
28
+ style.textDecorationLine = 'underline line-through';
29
+ } else if (formatStyle.underline) {
30
+ style.textDecorationLine = 'underline';
31
+ } else if (formatStyle.strikethrough) {
32
+ style.textDecorationLine = 'line-through';
33
+ }
34
+
35
+ // Code — apply monospace font and background
36
+ if (formatStyle.code) {
37
+ const codeStyle = resolvedTheme.codeStyle ?? DEFAULT_THEME.codeStyle;
38
+ if (codeStyle) {
39
+ Object.assign(style, codeStyle);
40
+ }
41
+ }
42
+
43
+ // Text color
44
+ if (formatStyle.color) {
45
+ style.color = formatStyle.color;
46
+ }
47
+
48
+ // Background color
49
+ if (formatStyle.backgroundColor) {
50
+ style.backgroundColor = formatStyle.backgroundColor;
51
+ }
52
+
53
+ // Font size
54
+ if (formatStyle.fontSize) {
55
+ style.fontSize = formatStyle.fontSize;
56
+ }
57
+
58
+ // Heading — overrides font size and weight
59
+ if (formatStyle.heading && formatStyle.heading !== 'none') {
60
+ style.fontSize = HEADING_FONT_SIZES[formatStyle.heading];
61
+ style.fontWeight = 'bold';
62
+ style.lineHeight = HEADING_FONT_SIZES[formatStyle.heading] * 1.3;
63
+ }
64
+
65
+ return style;
66
+ }
67
+
68
+ /**
69
+ * Maps an entire segment to its computed TextStyle (base + format).
70
+ */
71
+ export function segmentToTextStyle(
72
+ segment: StyledSegment,
73
+ theme?: RichTextTheme,
74
+ ): TextStyle {
75
+ const baseStyle = theme?.baseTextStyle ?? DEFAULT_THEME.baseTextStyle ?? {};
76
+ const formatStyle = formatStyleToTextStyle(segment.styles, theme);
77
+
78
+ return {
79
+ ...baseStyle,
80
+ ...formatStyle,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Batch-maps an array of segments to an array of TextStyles.
86
+ */
87
+ export function segmentsToTextStyles(
88
+ segments: StyledSegment[],
89
+ theme?: RichTextTheme,
90
+ ): TextStyle[] {
91
+ return segments.map((seg) => segmentToTextStyle(seg, theme));
92
+ }