@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.
- package/README.md +55 -8
- package/RNLiveMarkdown.podspec +2 -4
- package/android/build.gradle +4 -9
- package/android/src/main/cpp/CMakeLists.txt +14 -6
- package/android/src/main/cpp/MarkdownUtils.cpp +13 -24
- package/android/src/main/cpp/MarkdownUtils.h +2 -7
- package/android/src/main/cpp/OnLoad.cpp +6 -0
- package/android/src/main/java/com/expensify/livemarkdown/CustomFabricUIManager.java +2 -2
- package/android/src/main/java/com/expensify/livemarkdown/CustomMountingManager.java +4 -6
- package/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownModule.java +12 -0
- package/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java +17 -3
- package/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorViewManager.java +6 -0
- package/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +21 -34
- package/android/src/main/new_arch/MarkdownCommitHook.cpp +7 -4
- package/android/src/main/new_arch/MarkdownCommitHook.h +2 -0
- package/android/src/newarch/LiveMarkdownModuleSpec.java +9 -0
- package/android/src/oldarch/LiveMarkdownModuleSpec.java +12 -0
- package/android/src/oldarch/MarkdownTextInputDecoratorViewManagerSpec.java +1 -0
- package/apple/LiveMarkdownModule.h +16 -0
- package/apple/{RCTLiveMarkdownModule.mm → LiveMarkdownModule.mm} +31 -17
- package/apple/MarkdownCommitHook.mm +2 -0
- package/apple/MarkdownTextInputDecoratorComponentView.mm +4 -0
- package/apple/MarkdownTextInputDecoratorView.h +2 -0
- package/apple/MarkdownTextInputDecoratorView.mm +13 -0
- package/apple/MarkdownTextInputDecoratorViewManager.mm +9 -0
- package/apple/RCTMarkdownUtils.h +1 -0
- package/apple/RCTMarkdownUtils.mm +43 -45
- package/cpp/MarkdownGlobal.cpp +45 -0
- package/cpp/MarkdownGlobal.h +24 -0
- package/cpp/RuntimeDecorator.cpp +43 -0
- package/cpp/RuntimeDecorator.h +13 -0
- package/lib/commonjs/MarkdownTextInput.js +43 -2
- package/lib/commonjs/MarkdownTextInput.js.map +1 -1
- package/lib/commonjs/MarkdownTextInput.web.js +20 -14
- package/lib/commonjs/MarkdownTextInput.web.js.map +1 -1
- package/lib/commonjs/MarkdownTextInputDecoratorViewNativeComponent.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/parseExpensiMark.js +263 -0
- package/lib/commonjs/parseExpensiMark.js.map +1 -0
- package/lib/commonjs/web/utils/parserUtils.js +2 -2
- package/lib/commonjs/web/utils/parserUtils.js.map +1 -1
- package/lib/module/MarkdownTextInput.js +43 -2
- package/lib/module/MarkdownTextInput.js.map +1 -1
- package/lib/module/MarkdownTextInput.web.js +20 -14
- package/lib/module/MarkdownTextInput.web.js.map +1 -1
- package/lib/module/MarkdownTextInputDecoratorViewNativeComponent.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/parseExpensiMark.js +258 -0
- package/lib/module/parseExpensiMark.js.map +1 -0
- package/lib/module/web/utils/parserUtils.js +2 -2
- package/lib/module/web/utils/parserUtils.js.map +1 -1
- package/lib/typescript/src/MarkdownTextInput.d.ts +9 -1
- package/lib/typescript/src/MarkdownTextInput.d.ts.map +1 -1
- package/lib/typescript/src/MarkdownTextInput.web.d.ts +2 -1
- package/lib/typescript/src/MarkdownTextInput.web.d.ts.map +1 -1
- package/lib/typescript/src/MarkdownTextInputDecoratorViewNativeComponent.d.ts +2 -1
- package/lib/typescript/src/MarkdownTextInputDecoratorViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/parseExpensiMark.d.ts +4 -0
- package/lib/typescript/src/parseExpensiMark.d.ts.map +1 -0
- package/lib/typescript/src/web/utils/parserUtils.d.ts +1 -1
- package/lib/typescript/src/web/utils/parserUtils.d.ts.map +1 -1
- package/package.json +13 -10
- package/src/MarkdownTextInput.tsx +62 -2
- package/src/MarkdownTextInput.web.tsx +23 -15
- package/src/MarkdownTextInputDecoratorViewNativeComponent.ts +2 -1
- package/src/index.tsx +2 -0
- package/src/parseExpensiMark.ts +277 -0
- package/src/web/utils/parserUtils.ts +2 -1
- package/apple/RCTLiveMarkdownModule.h +0 -16
- package/lib/parser/react-native-live-markdown-parser.js +0 -29
- 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(' ', ' ');
|
|
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 =
|
|
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
|