@expensify/react-native-live-markdown 0.1.187 → 0.1.189

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 (75) hide show
  1. package/README.md +55 -8
  2. package/RNLiveMarkdown.podspec +2 -4
  3. package/android/build.gradle +4 -9
  4. package/android/src/main/cpp/CMakeLists.txt +14 -6
  5. package/android/src/main/cpp/MarkdownUtils.cpp +13 -24
  6. package/android/src/main/cpp/MarkdownUtils.h +2 -7
  7. package/android/src/main/cpp/OnLoad.cpp +6 -0
  8. package/android/src/main/java/com/expensify/livemarkdown/CustomFabricUIManager.java +2 -2
  9. package/android/src/main/java/com/expensify/livemarkdown/CustomMountingManager.java +4 -6
  10. package/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownModule.java +12 -0
  11. package/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java +17 -3
  12. package/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorViewManager.java +6 -0
  13. package/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +21 -34
  14. package/android/src/main/new_arch/MarkdownCommitHook.cpp +7 -4
  15. package/android/src/main/new_arch/MarkdownCommitHook.h +2 -0
  16. package/android/src/newarch/LiveMarkdownModuleSpec.java +9 -0
  17. package/android/src/oldarch/LiveMarkdownModuleSpec.java +12 -0
  18. package/android/src/oldarch/MarkdownTextInputDecoratorViewManagerSpec.java +1 -0
  19. package/apple/LiveMarkdownModule.h +16 -0
  20. package/apple/{RCTLiveMarkdownModule.mm → LiveMarkdownModule.mm} +31 -17
  21. package/apple/MarkdownCommitHook.mm +2 -0
  22. package/apple/MarkdownTextInputDecoratorComponentView.mm +4 -0
  23. package/apple/MarkdownTextInputDecoratorView.h +2 -0
  24. package/apple/MarkdownTextInputDecoratorView.mm +13 -0
  25. package/apple/MarkdownTextInputDecoratorViewManager.mm +9 -0
  26. package/apple/RCTMarkdownUtils.h +1 -0
  27. package/apple/RCTMarkdownUtils.mm +43 -45
  28. package/cpp/MarkdownGlobal.cpp +45 -0
  29. package/cpp/MarkdownGlobal.h +24 -0
  30. package/cpp/RuntimeDecorator.cpp +43 -0
  31. package/cpp/RuntimeDecorator.h +13 -0
  32. package/lib/commonjs/MarkdownTextInput.js +43 -2
  33. package/lib/commonjs/MarkdownTextInput.js.map +1 -1
  34. package/lib/commonjs/MarkdownTextInput.web.js +20 -14
  35. package/lib/commonjs/MarkdownTextInput.web.js.map +1 -1
  36. package/lib/commonjs/MarkdownTextInputDecoratorViewNativeComponent.js.map +1 -1
  37. package/lib/commonjs/index.js +7 -0
  38. package/lib/commonjs/index.js.map +1 -1
  39. package/lib/commonjs/parseExpensiMark.js +263 -0
  40. package/lib/commonjs/parseExpensiMark.js.map +1 -0
  41. package/lib/commonjs/web/utils/parserUtils.js +2 -2
  42. package/lib/commonjs/web/utils/parserUtils.js.map +1 -1
  43. package/lib/module/MarkdownTextInput.js +43 -2
  44. package/lib/module/MarkdownTextInput.js.map +1 -1
  45. package/lib/module/MarkdownTextInput.web.js +20 -14
  46. package/lib/module/MarkdownTextInput.web.js.map +1 -1
  47. package/lib/module/MarkdownTextInputDecoratorViewNativeComponent.js.map +1 -1
  48. package/lib/module/index.js +1 -0
  49. package/lib/module/index.js.map +1 -1
  50. package/lib/module/parseExpensiMark.js +258 -0
  51. package/lib/module/parseExpensiMark.js.map +1 -0
  52. package/lib/module/web/utils/parserUtils.js +2 -2
  53. package/lib/module/web/utils/parserUtils.js.map +1 -1
  54. package/lib/typescript/src/MarkdownTextInput.d.ts +9 -1
  55. package/lib/typescript/src/MarkdownTextInput.d.ts.map +1 -1
  56. package/lib/typescript/src/MarkdownTextInput.web.d.ts +2 -1
  57. package/lib/typescript/src/MarkdownTextInput.web.d.ts.map +1 -1
  58. package/lib/typescript/src/MarkdownTextInputDecoratorViewNativeComponent.d.ts +2 -1
  59. package/lib/typescript/src/MarkdownTextInputDecoratorViewNativeComponent.d.ts.map +1 -1
  60. package/lib/typescript/src/index.d.ts +2 -0
  61. package/lib/typescript/src/index.d.ts.map +1 -1
  62. package/lib/typescript/src/parseExpensiMark.d.ts +4 -0
  63. package/lib/typescript/src/parseExpensiMark.d.ts.map +1 -0
  64. package/lib/typescript/src/web/utils/parserUtils.d.ts +1 -1
  65. package/lib/typescript/src/web/utils/parserUtils.d.ts.map +1 -1
  66. package/package.json +13 -10
  67. package/src/MarkdownTextInput.tsx +62 -2
  68. package/src/MarkdownTextInput.web.tsx +23 -15
  69. package/src/MarkdownTextInputDecoratorViewNativeComponent.ts +2 -1
  70. package/src/index.tsx +2 -0
  71. package/src/parseExpensiMark.ts +277 -0
  72. package/src/web/utils/parserUtils.ts +2 -1
  73. package/apple/RCTLiveMarkdownModule.h +0 -16
  74. package/lib/parser/react-native-live-markdown-parser.js +0 -29
  75. package/parser/react-native-live-markdown-parser.js +0 -29
@@ -0,0 +1,277 @@
1
+ 'worklet';
2
+
3
+ import {ExpensiMark} from 'expensify-common';
4
+ import {unescapeText} from 'expensify-common/dist/utils';
5
+ import type {MarkdownType, MarkdownRange} from './commonTypes';
6
+
7
+ const MAX_PARSABLE_LENGTH = 4000;
8
+
9
+ type Token = ['TEXT' | 'HTML', string];
10
+ type StackItem = {tag: string; children: Array<StackItem | string>};
11
+
12
+ function parseMarkdownToHTML(markdown: string): string {
13
+ const parser = new ExpensiMark();
14
+ const html = parser.replace(markdown, {
15
+ shouldKeepRawInput: true,
16
+ });
17
+ return html as string;
18
+ }
19
+
20
+ function parseHTMLToTokens(html: string): Token[] {
21
+ const tokens: Token[] = [];
22
+ let left = 0;
23
+ // eslint-disable-next-line no-constant-condition
24
+ while (true) {
25
+ const open = html.indexOf('<', left);
26
+ if (open === -1) {
27
+ if (left < html.length) {
28
+ tokens.push(['TEXT', html.substring(left)]);
29
+ }
30
+ break;
31
+ }
32
+ if (open !== left) {
33
+ tokens.push(['TEXT', html.substring(left, open)]);
34
+ }
35
+ const close = html.indexOf('>', open);
36
+ if (close === -1) {
37
+ throw new Error('[react-native-live-markdown] Error in function parseHTMLToTokens: Invalid HTML: no matching ">"');
38
+ }
39
+ tokens.push(['HTML', html.substring(open, close + 1)]);
40
+ left = close + 1;
41
+ }
42
+ return tokens;
43
+ }
44
+
45
+ function parseTokensToTree(tokens: Token[]): StackItem {
46
+ const stack: StackItem[] = [{tag: '<>', children: []}];
47
+ tokens.forEach(([type, payload]) => {
48
+ if (type === 'TEXT') {
49
+ const text = unescapeText(payload);
50
+ const top = stack[stack.length - 1];
51
+ top!.children.push(text);
52
+ } else if (type === 'HTML') {
53
+ if (payload.startsWith('</')) {
54
+ // closing tag
55
+ const child = stack.pop();
56
+ const top = stack[stack.length - 1];
57
+ top!.children.push(child!);
58
+ } else if (payload.endsWith('/>')) {
59
+ // self-closing tag
60
+ const top = stack[stack.length - 1];
61
+ top!.children.push({tag: payload, children: []});
62
+ } else {
63
+ // opening tag
64
+ stack.push({tag: payload, children: []});
65
+ }
66
+ } else {
67
+ throw new Error(
68
+ `[react-native-live-markdown] Error in function parseTokensToTree: Unknown token type: ${type as string}. Expected 'TEXT' or 'HTML'. Please ensure tokens only contain these types.`,
69
+ );
70
+ }
71
+ });
72
+ if (stack.length !== 1) {
73
+ const unclosedTags =
74
+ stack.length > 0
75
+ ? stack
76
+ .slice(1)
77
+ .map((item) => item.tag)
78
+ .join(', ')
79
+ : '';
80
+ throw new Error(
81
+ `[react-native-live-markdown] Invalid HTML structure: the following tags are not properly closed: ${unclosedTags}. Ensure each opening tag has a corresponding closing tag.`,
82
+ );
83
+ }
84
+ return stack[0]!;
85
+ }
86
+
87
+ function parseTreeToTextAndRanges(tree: StackItem): [string, MarkdownRange[]] {
88
+ let text = '';
89
+
90
+ function processChildren(node: StackItem | string) {
91
+ if (typeof node === 'string') {
92
+ text += node;
93
+ } else {
94
+ node.children.forEach(dfs);
95
+ }
96
+ }
97
+
98
+ function appendSyntax(syntax: string) {
99
+ addChildrenWithStyle(syntax, 'syntax');
100
+ }
101
+
102
+ function addChildrenWithStyle(node: StackItem | string, type: MarkdownType) {
103
+ const start = text.length;
104
+ processChildren(node);
105
+ const end = text.length;
106
+ ranges.push({type, start, length: end - start});
107
+ }
108
+
109
+ const ranges: MarkdownRange[] = [];
110
+ function dfs(node: StackItem | string) {
111
+ if (typeof node === 'string') {
112
+ text += node;
113
+ } else {
114
+ // eslint-disable-next-line no-lonely-if
115
+ if (node.tag === '<>') {
116
+ processChildren(node);
117
+ } else if (node.tag === '<strong>') {
118
+ appendSyntax('*');
119
+ addChildrenWithStyle(node, 'bold');
120
+ appendSyntax('*');
121
+ } else if (node.tag === '<em>') {
122
+ appendSyntax('_');
123
+ addChildrenWithStyle(node, 'italic');
124
+ appendSyntax('_');
125
+ } else if (node.tag === '<del>') {
126
+ appendSyntax('~');
127
+ addChildrenWithStyle(node, 'strikethrough');
128
+ appendSyntax('~');
129
+ } else if (node.tag === '<emoji>') {
130
+ addChildrenWithStyle(node, 'emoji');
131
+ } else if (node.tag === '<code>') {
132
+ appendSyntax('`');
133
+ addChildrenWithStyle(node, 'code');
134
+ appendSyntax('`');
135
+ } else if (node.tag === '<mention-here>') {
136
+ addChildrenWithStyle(node, 'mention-here');
137
+ } else if (node.tag === '<mention-user>') {
138
+ addChildrenWithStyle(node, 'mention-user');
139
+ } else if (node.tag === '<mention-report>') {
140
+ addChildrenWithStyle(node, 'mention-report');
141
+ } else if (node.tag === '<blockquote>') {
142
+ appendSyntax('>');
143
+ addChildrenWithStyle(node, 'blockquote');
144
+ // compensate for "> " at the beginning
145
+ if (ranges.length > 0) {
146
+ const curr = ranges[ranges.length - 1];
147
+ curr!.start -= 1;
148
+ curr!.length += 1;
149
+ }
150
+ } else if (node.tag === '<h1>') {
151
+ appendSyntax('# ');
152
+ addChildrenWithStyle(node, 'h1');
153
+ } else if (node.tag === '<br />') {
154
+ text += '\n';
155
+ } else if (node.tag.startsWith('<pre')) {
156
+ appendSyntax('```');
157
+ const content = node.children.join('').replaceAll('&#32;', ' ');
158
+ addChildrenWithStyle(content, 'pre');
159
+ appendSyntax('```');
160
+ } else if (node.tag.startsWith('<a href="')) {
161
+ const rawHref = node.tag.match(/href="([^"]*)"/)![1]!; // always present
162
+ const href = unescapeText(rawHref);
163
+ const isLabeledLink = node.tag.match(/data-link-variant="([^"]*)"/)![1] === 'labeled';
164
+ const dataRawHref = node.tag.match(/data-raw-href="([^"]*)"/);
165
+ const matchString = dataRawHref ? unescapeText(dataRawHref[1]!) : href;
166
+ if (!isLabeledLink && node.children.length === 1 && typeof node.children[0] === 'string' && (node.children[0] === matchString || `mailto:${node.children[0]}` === href)) {
167
+ addChildrenWithStyle(node.children[0], 'link');
168
+ } else {
169
+ appendSyntax('[');
170
+ processChildren(node);
171
+ appendSyntax('](');
172
+ addChildrenWithStyle(matchString, 'link');
173
+ appendSyntax(')');
174
+ }
175
+ } else if (node.tag.startsWith('<img src="')) {
176
+ const src = node.tag.match(/src="([^"]*)"/)![1]!; // always present
177
+ const alt = node.tag.match(/alt="([^"]*)"/);
178
+ const hasAlt = node.tag.match(/data-link-variant="([^"]*)"/)![1] === 'labeled';
179
+ const rawLink = node.tag.match(/data-raw-href="([^"]*)"/);
180
+ const linkString = rawLink ? unescapeText(rawLink[1]!) : src;
181
+
182
+ const start = text.length;
183
+ const length = 3 + (hasAlt ? 2 + unescapeText(alt?.[1] || '').length : 0) + linkString.length;
184
+ ranges.push({type: 'inline-image', start, length});
185
+
186
+ appendSyntax('!');
187
+ if (hasAlt) {
188
+ appendSyntax('[');
189
+ processChildren(unescapeText(alt?.[1] || ''));
190
+ appendSyntax(']');
191
+ }
192
+ appendSyntax('(');
193
+ addChildrenWithStyle(linkString, 'link');
194
+ appendSyntax(')');
195
+ } else if (node.tag.startsWith('<video data-expensify-source="')) {
196
+ const src = node.tag.match(/data-expensify-source="([^"]*)"/)![1]!; // always present
197
+ const rawLink = node.tag.match(/data-raw-href="([^"]*)"/);
198
+ const hasAlt = node.tag.match(/data-link-variant="([^"]*)"/)![1] === 'labeled';
199
+ const linkString = rawLink ? unescapeText(rawLink[1]!) : src;
200
+ appendSyntax('!');
201
+ if (hasAlt) {
202
+ appendSyntax('[');
203
+ node.children.forEach((child) => processChildren(child));
204
+ appendSyntax(']');
205
+ }
206
+ appendSyntax('(');
207
+ addChildrenWithStyle(linkString, 'link');
208
+ appendSyntax(')');
209
+ } else {
210
+ throw new Error(`[react-native-live-markdown] Error in function parseTreeToTextAndRanges: Unknown tag '${node.tag}'. This tag is not supported in this function's logic.`);
211
+ }
212
+ }
213
+ }
214
+ dfs(tree);
215
+ return [text, ranges];
216
+ }
217
+
218
+ // getTagPriority returns a priority for a tag, higher priority means the tag should be processed first
219
+ function getTagPriority(tag: string) {
220
+ switch (tag) {
221
+ case 'blockquote':
222
+ return 2;
223
+ case 'h1':
224
+ return 1;
225
+ default:
226
+ return 0;
227
+ }
228
+ }
229
+
230
+ function sortRanges(ranges: MarkdownRange[]) {
231
+ // sort ranges by start position, then by length, then by tag hierarchy
232
+ return ranges.sort((a, b) => a.start - b.start || b.length - a.length || getTagPriority(b.type) - getTagPriority(a.type) || 0);
233
+ }
234
+
235
+ function groupRanges(ranges: MarkdownRange[]) {
236
+ const lastVisibleRangeIndex: {[key in MarkdownType]?: number} = {};
237
+
238
+ return ranges.reduce((acc, range) => {
239
+ const start = range.start;
240
+ const end = range.start + range.length;
241
+
242
+ const rangeWithSameStyleIndex = lastVisibleRangeIndex[range.type];
243
+ const sameStyleRange = rangeWithSameStyleIndex !== undefined ? acc[rangeWithSameStyleIndex] : undefined;
244
+
245
+ if (sameStyleRange && sameStyleRange.start <= start && sameStyleRange.start + sameStyleRange.length >= end && range.length > 1) {
246
+ // increment depth of overlapping range
247
+ sameStyleRange.depth = (sameStyleRange.depth || 1) + 1;
248
+ } else {
249
+ lastVisibleRangeIndex[range.type] = acc.length;
250
+ acc.push(range);
251
+ }
252
+
253
+ return acc;
254
+ }, [] as MarkdownRange[]);
255
+ }
256
+
257
+ function parseExpensiMark(markdown: string): MarkdownRange[] {
258
+ if (markdown.length > MAX_PARSABLE_LENGTH) {
259
+ return [];
260
+ }
261
+ const html = parseMarkdownToHTML(markdown);
262
+ const tokens = parseHTMLToTokens(html);
263
+ const tree = parseTokensToTree(tokens);
264
+ const [text, ranges] = parseTreeToTextAndRanges(tree);
265
+ if (text !== markdown) {
266
+ throw new Error(
267
+ `[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify(
268
+ text,
269
+ )}'\nOriginal input: '${JSON.stringify(markdown)}'`,
270
+ );
271
+ }
272
+ const sortedRanges = sortRanges(ranges);
273
+ const groupedRanges = groupRanges(sortedRanges);
274
+ return groupedRanges;
275
+ }
276
+
277
+ export default parseExpensiMark;
@@ -272,6 +272,7 @@ function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, curso
272
272
  }
273
273
 
274
274
  function updateInputStructure(
275
+ parserFunction: (input: string) => MarkdownRange[],
275
276
  target: MarkdownTextInputElement,
276
277
  text: string,
277
278
  cursorPositionIndex: number | null,
@@ -291,7 +292,7 @@ function updateInputStructure(
291
292
  const selection = getCurrentCursorPosition(target);
292
293
  cursorPosition = selection ? selection.start : null;
293
294
  }
294
- const markdownRanges = global.parseExpensiMarkToRanges(text);
295
+ const markdownRanges = parserFunction(text);
295
296
  if (!text || targetElement.innerHTML === '<br>' || (targetElement && targetElement.innerHTML === '\n')) {
296
297
  targetElement.innerHTML = '';
297
298
  targetElement.innerText = '';
@@ -1,16 +0,0 @@
1
- #ifdef RCT_NEW_ARCH_ENABLED
2
-
3
- #import <RNLiveMarkdownSpec/RNLiveMarkdownSpec.h>
4
- #import <React/RCTEventEmitter.h>
5
- #import <React/RCTUIManager.h>
6
-
7
- NS_ASSUME_NONNULL_BEGIN
8
-
9
- // Without inheriting after RCTEventEmitter we don't get access to bridge
10
- @interface RCTLiveMarkdownModule
11
- : RCTEventEmitter <NativeLiveMarkdownModuleSpec>
12
- @end
13
-
14
- NS_ASSUME_NONNULL_END
15
-
16
- #endif // RCT_NEW_ARCH_ENABLED