@uiw/react-md-editor 4.1.5 → 4.1.7
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 +16 -1
- package/dist/mdeditor.css +5 -0
- package/dist/mdeditor.js +2382 -1031
- package/dist/mdeditor.min.css +1 -1
- package/dist/mdeditor.min.js +1 -1
- package/esm/Editor.factory.js +85 -11
- package/esm/Types.d.ts +6 -0
- package/esm/commands/index.cn.d.ts +2 -1
- package/esm/commands/index.cn.js +2 -2
- package/esm/commands/index.d.ts +2 -2
- package/esm/commands/index.js +2 -2
- package/esm/commands/list.d.ts +1 -1
- package/esm/commands/list.js +9 -3
- package/esm/components/TextArea/Markdown.common.d.ts +3 -5
- package/esm/components/TextArea/Markdown.common.js +2 -56
- package/esm/components/TextArea/Markdown.d.ts +3 -5
- package/esm/components/TextArea/Markdown.factory.d.ts +6 -0
- package/esm/components/TextArea/Markdown.factory.js +138 -0
- package/esm/components/TextArea/Markdown.js +2 -63
- package/esm/components/TextArea/factory.js +6 -2
- package/esm/components/TextArea/index.css +5 -0
- package/esm/components/TextArea/index.less +5 -0
- package/esm/components/TextArea/splitBlocks.d.ts +13 -0
- package/esm/components/TextArea/splitBlocks.js +111 -0
- package/esm/package.json +1 -0
- package/lib/Editor.factory.js +93 -12
- package/lib/Types.d.ts +6 -0
- package/lib/commands/index.cn.d.ts +2 -1
- package/lib/commands/index.cn.js +8 -1
- package/lib/commands/index.d.ts +2 -2
- package/lib/commands/index.js +6 -0
- package/lib/commands/list.d.ts +1 -1
- package/lib/commands/list.js +5 -1
- package/lib/components/TextArea/Markdown.common.d.ts +3 -5
- package/lib/components/TextArea/Markdown.common.js +3 -60
- package/lib/components/TextArea/Markdown.d.ts +3 -5
- package/lib/components/TextArea/Markdown.factory.d.ts +6 -0
- package/lib/components/TextArea/Markdown.factory.js +148 -0
- package/lib/components/TextArea/Markdown.js +3 -67
- package/lib/components/TextArea/factory.js +6 -2
- package/lib/components/TextArea/index.less +5 -0
- package/lib/components/TextArea/splitBlocks.d.ts +13 -0
- package/lib/components/TextArea/splitBlocks.js +117 -0
- package/markdown-editor.css +5 -0
- package/package.json +18 -12
- package/src/Editor.factory.tsx +83 -4
- package/src/Types.ts +6 -0
- package/src/commands/index.cn.ts +2 -0
- package/src/commands/index.ts +2 -1
- package/src/commands/list.tsx +11 -2
- package/src/components/TextArea/Markdown.common.tsx +3 -46
- package/src/components/TextArea/Markdown.factory.tsx +132 -0
- package/src/components/TextArea/Markdown.tsx +3 -55
- package/src/components/TextArea/factory.tsx +7 -4
- package/src/components/TextArea/index.less +5 -0
- package/src/components/TextArea/splitBlocks.ts +114 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useContext, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { rehype } from 'rehype';
|
|
3
|
+
import type rehypePrismPlus from 'rehype-prism-plus';
|
|
4
|
+
import { type IProps } from '../../Types';
|
|
5
|
+
import { EditorContext } from '../../Context';
|
|
6
|
+
import { splitBlocks } from './splitBlocks';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prism's markdown grammar takes quadratic time on long lines, so a single base64 image
|
|
10
|
+
* line can block the editor for minutes. Blocks with such a line are shown uncoloured.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_HIGHLIGHT_LINE_LENGTH = 1000;
|
|
13
|
+
|
|
14
|
+
export interface MarkdownProps extends IProps, React.HTMLAttributes<HTMLPreElement> {}
|
|
15
|
+
|
|
16
|
+
type HighlightedBlock = {
|
|
17
|
+
id: number;
|
|
18
|
+
html: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function html2Escape(sHtml: string) {
|
|
22
|
+
return sHtml.replace(
|
|
23
|
+
/[<&"]/g,
|
|
24
|
+
(c: string) => (({ '<': '<', '>': '>', '&': '&', '"': '"' }) as Record<string, string>)[c],
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasLongLine(text: string) {
|
|
29
|
+
let lineStart = 0;
|
|
30
|
+
while (lineStart <= text.length) {
|
|
31
|
+
const lineEnd = text.indexOf('\n', lineStart);
|
|
32
|
+
const end = lineEnd === -1 ? text.length : lineEnd;
|
|
33
|
+
if (end - lineStart > MAX_HIGHLIGHT_LINE_LENGTH) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
lineStart = end + 1;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCodeInnerHtml(html: string) {
|
|
42
|
+
const codeStart = html.indexOf('<code');
|
|
43
|
+
const contentStart = html.indexOf('>', codeStart) + 1;
|
|
44
|
+
const contentEnd = html.lastIndexOf('</code>');
|
|
45
|
+
return html.slice(contentStart, contentEnd);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MarkdownBlock = React.memo(function MarkdownBlock(props: { html: string }) {
|
|
49
|
+
return React.createElement('span', { dangerouslySetInnerHTML: { __html: props.html } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function createMarkdown(rehypePrism: typeof rehypePrismPlus) {
|
|
53
|
+
const processor = rehype()
|
|
54
|
+
.data('settings', { fragment: true })
|
|
55
|
+
// https://github.com/uiwjs/react-md-editor/issues/593
|
|
56
|
+
// @ts-ignore
|
|
57
|
+
.use(rehypePrism, { ignoreMissing: true });
|
|
58
|
+
|
|
59
|
+
function highlightBlock(block: string) {
|
|
60
|
+
const escaped = html2Escape(block);
|
|
61
|
+
if (hasLongLine(block)) {
|
|
62
|
+
return escaped;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const html = processor.processSync(`<pre><code class="language-markdown">${escaped}</code></pre>`).toString();
|
|
66
|
+
return getCodeInnerHtml(html);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return escaped;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Highlighting runs per block, and blocks whose text did not change reuse their cached
|
|
74
|
+
* HTML and DOM, so typing in a large document only re-highlights the edited block.
|
|
75
|
+
* https://github.com/uiwjs/react-md-editor/issues/620
|
|
76
|
+
*/
|
|
77
|
+
return function Markdown(props: MarkdownProps) {
|
|
78
|
+
const { prefixCls } = props;
|
|
79
|
+
const { markdown = '', highlightEnable, dispatch } = useContext(EditorContext);
|
|
80
|
+
const preRef = useRef<HTMLPreElement>(null);
|
|
81
|
+
const cacheRef = useRef(new Map<string, HighlightedBlock>());
|
|
82
|
+
const nextIdRef = useRef(0);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (preRef.current && dispatch) {
|
|
85
|
+
dispatch({ textareaPre: preRef.current });
|
|
86
|
+
}
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const blocks = useMemo(() => {
|
|
91
|
+
if (!highlightEnable) {
|
|
92
|
+
cacheRef.current = new Map();
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const cache = new Map<string, HighlightedBlock>();
|
|
96
|
+
const occurrences = new Map<string, number>();
|
|
97
|
+
const result = splitBlocks(markdown).map((block) => {
|
|
98
|
+
let highlighted = cacheRef.current.get(block) || cache.get(block);
|
|
99
|
+
if (!highlighted) {
|
|
100
|
+
highlighted = { id: nextIdRef.current++, html: highlightBlock(block) };
|
|
101
|
+
}
|
|
102
|
+
cache.set(block, highlighted);
|
|
103
|
+
const occurrence = occurrences.get(block) || 0;
|
|
104
|
+
occurrences.set(block, occurrence + 1);
|
|
105
|
+
return { key: `${highlighted.id}-${occurrence}`, html: highlighted.html };
|
|
106
|
+
});
|
|
107
|
+
cacheRef.current = cache;
|
|
108
|
+
return result;
|
|
109
|
+
}, [markdown, highlightEnable]);
|
|
110
|
+
|
|
111
|
+
if (!markdown) {
|
|
112
|
+
return <pre ref={preRef} className={`${prefixCls}-text-pre wmde-markdown-color`} />;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="wmde-markdown-color">
|
|
117
|
+
<pre ref={preRef} className={`language-markdown ${prefixCls}-text-pre wmde-markdown-color`}>
|
|
118
|
+
{highlightEnable ? (
|
|
119
|
+
<code className="language-markdown code-highlight">
|
|
120
|
+
{blocks.map((block) => (
|
|
121
|
+
<MarkdownBlock key={block.key} html={block.html} />
|
|
122
|
+
))}
|
|
123
|
+
{'\n'}
|
|
124
|
+
</code>
|
|
125
|
+
) : (
|
|
126
|
+
<code className="language-markdown">{`${markdown}\n`}</code>
|
|
127
|
+
)}
|
|
128
|
+
</pre>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -1,58 +1,6 @@
|
|
|
1
|
-
import React, { useContext, useEffect } from 'react';
|
|
2
|
-
import { rehype } from 'rehype';
|
|
3
1
|
import rehypePrism from 'rehype-prism-plus';
|
|
4
|
-
import {
|
|
5
|
-
import { EditorContext } from '../../Context';
|
|
2
|
+
import { createMarkdown } from './Markdown.factory';
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
return (
|
|
9
|
-
sHtml
|
|
10
|
-
// .replace(/```(\w+)?([\s\S]*?)(\s.+)?```/g, (str: string) => {
|
|
11
|
-
// return str.replace(
|
|
12
|
-
// /[<&"]/g,
|
|
13
|
-
// (c: string) => (({ '<': '<', '>': '>', '&': '&', '"': '"' } as Record<string, string>)[c]),
|
|
14
|
-
// );
|
|
15
|
-
// })
|
|
16
|
-
.replace(
|
|
17
|
-
/[<&"]/g,
|
|
18
|
-
(c: string) => (({ '<': '<', '>': '>', '&': '&', '"': '"' }) as Record<string, string>)[c],
|
|
19
|
-
)
|
|
20
|
-
);
|
|
21
|
-
}
|
|
4
|
+
export type { MarkdownProps } from './Markdown.factory';
|
|
22
5
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
export default function Markdown(props: MarkdownProps) {
|
|
26
|
-
const { prefixCls } = props;
|
|
27
|
-
const { markdown = '', highlightEnable, dispatch } = useContext(EditorContext);
|
|
28
|
-
const preRef = React.createRef<HTMLPreElement>();
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
if (preRef.current && dispatch) {
|
|
31
|
-
dispatch({ textareaPre: preRef.current });
|
|
32
|
-
}
|
|
33
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
|
-
}, []);
|
|
35
|
-
if (!markdown) {
|
|
36
|
-
return <pre ref={preRef} className={`${prefixCls}-text-pre wmde-markdown-color`} />;
|
|
37
|
-
}
|
|
38
|
-
let mdStr = `<pre class="language-markdown ${prefixCls}-text-pre wmde-markdown-color"><code class="language-markdown">${html2Escape(
|
|
39
|
-
String.raw`${markdown}`,
|
|
40
|
-
)}\n</code></pre>`;
|
|
41
|
-
|
|
42
|
-
if (highlightEnable) {
|
|
43
|
-
try {
|
|
44
|
-
mdStr = rehype()
|
|
45
|
-
.data('settings', { fragment: true })
|
|
46
|
-
// https://github.com/uiwjs/react-md-editor/issues/593
|
|
47
|
-
// @ts-ignore
|
|
48
|
-
.use(rehypePrism, { ignoreMissing: true })
|
|
49
|
-
.processSync(mdStr)
|
|
50
|
-
.toString();
|
|
51
|
-
} catch (error) {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return React.createElement('div', {
|
|
55
|
-
className: 'wmde-markdown-color',
|
|
56
|
-
dangerouslySetInnerHTML: { __html: mdStr || '' },
|
|
57
|
-
});
|
|
58
|
-
}
|
|
6
|
+
export default createMarkdown(rehypePrism);
|
|
@@ -25,8 +25,7 @@ export type RenderTextareaHandle = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export interface ITextAreaProps
|
|
28
|
-
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onScroll'>,
|
|
29
|
-
IProps {
|
|
28
|
+
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onScroll'>, IProps {
|
|
30
29
|
value?: string;
|
|
31
30
|
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
|
32
31
|
renderTextarea?: (
|
|
@@ -76,6 +75,10 @@ export function createTextArea(options?: { Markdown?: MarkdownComponent; useMinH
|
|
|
76
75
|
|
|
77
76
|
const textStyle: CSS.Properties =
|
|
78
77
|
MarkdownComponent && highlightEnable ? {} : { WebkitTextFillColor: 'initial', overflow: 'auto' };
|
|
78
|
+
const inputProps = { ...otherProps };
|
|
79
|
+
if (!inputProps['aria-label'] && !inputProps['aria-labelledby'] && !inputProps.id && !inputProps.title) {
|
|
80
|
+
inputProps['aria-label'] = 'Markdown editor';
|
|
81
|
+
}
|
|
79
82
|
|
|
80
83
|
return (
|
|
81
84
|
<div ref={warp} className={`${prefixCls}-area ${className || ''}`} onScroll={onScroll}>
|
|
@@ -84,7 +87,7 @@ export function createTextArea(options?: { Markdown?: MarkdownComponent; useMinH
|
|
|
84
87
|
React.cloneElement(
|
|
85
88
|
renderTextarea(
|
|
86
89
|
{
|
|
87
|
-
...
|
|
90
|
+
...inputProps,
|
|
88
91
|
value: markdown,
|
|
89
92
|
autoComplete: 'off',
|
|
90
93
|
spellCheck: 'false',
|
|
@@ -109,7 +112,7 @@ export function createTextArea(options?: { Markdown?: MarkdownComponent; useMinH
|
|
|
109
112
|
) : (
|
|
110
113
|
<Fragment>
|
|
111
114
|
{MarkdownComponent && highlightEnable && <MarkdownComponent prefixCls={prefixCls} />}
|
|
112
|
-
<Textarea prefixCls={prefixCls} {...
|
|
115
|
+
<Textarea prefixCls={prefixCls} {...inputProps} style={textStyle} />
|
|
113
116
|
</Fragment>
|
|
114
117
|
)}
|
|
115
118
|
</div>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const FENCE_OPEN = /^```/;
|
|
2
|
+
const FENCE_CLOSE = /^```\r?\n?$/;
|
|
3
|
+
const BLANK_LINE = /^[ \t]*\r?\n?$/;
|
|
4
|
+
const FRONT_MATTER_FENCE = /^---\r?\n?$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Splits markdown into blocks that Prism's markdown grammar highlights the same way
|
|
8
|
+
* on their own as it does in the full document, so unchanged blocks can be cached.
|
|
9
|
+
* Joining the returned blocks always yields the original string.
|
|
10
|
+
*
|
|
11
|
+
* A block ends where a run of blank lines starts, so each block keeps its leading blank
|
|
12
|
+
* lines (indented code needs them). No split happens inside a ``` fence, front matter or
|
|
13
|
+
* multiline markup or link references, or right before a `---` line, which Prism
|
|
14
|
+
* would read as front matter. Whitespace-only lines stay in their block because
|
|
15
|
+
* Prism's inline emphasis can span them.
|
|
16
|
+
* Fences follow Prism's rule, not CommonMark's, so the split matches what Prism sees.
|
|
17
|
+
*/
|
|
18
|
+
export function splitBlocks(markdown: string): string[] {
|
|
19
|
+
const lines: string[] = markdown.match(/[^\n]*\n|[^\n]+$/g) || [];
|
|
20
|
+
const blocks: string[] = [];
|
|
21
|
+
let current = '';
|
|
22
|
+
let pendingSplit = -1;
|
|
23
|
+
let inFence = false;
|
|
24
|
+
let inFrontMatter = /^(?:\s*[\r\n])?---(?:\r?\n|$)/.test(markdown);
|
|
25
|
+
let frontMatterStarted = false;
|
|
26
|
+
const syntax = { end: '', quote: '', brackets: 0, subset: 0 };
|
|
27
|
+
let previousIsBlank = true;
|
|
28
|
+
|
|
29
|
+
lines.forEach((line) => {
|
|
30
|
+
const isBlank = BLANK_LINE.test(line);
|
|
31
|
+
const canSplit = !inFence && !inFrontMatter && !syntax.end && !syntax.brackets;
|
|
32
|
+
if (/^\r?\n$/.test(line) && !previousIsBlank && canSplit) {
|
|
33
|
+
pendingSplit = current.length;
|
|
34
|
+
}
|
|
35
|
+
if (!isBlank && pendingSplit > 0) {
|
|
36
|
+
if (!FRONT_MATTER_FENCE.test(line)) {
|
|
37
|
+
blocks.push(current.slice(0, pendingSplit));
|
|
38
|
+
current = current.slice(pendingSplit);
|
|
39
|
+
}
|
|
40
|
+
pendingSplit = -1;
|
|
41
|
+
}
|
|
42
|
+
current += line;
|
|
43
|
+
previousIsBlank = isBlank;
|
|
44
|
+
|
|
45
|
+
if (inFrontMatter) {
|
|
46
|
+
if (FRONT_MATTER_FENCE.test(line)) {
|
|
47
|
+
inFrontMatter = !frontMatterStarted;
|
|
48
|
+
frontMatterStarted = true;
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (inFence) {
|
|
53
|
+
inFence = !FENCE_CLOSE.test(line);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!syntax.end && FENCE_OPEN.test(line)) {
|
|
57
|
+
inFence = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
updateSyntax(line, syntax);
|
|
61
|
+
});
|
|
62
|
+
if (current) {
|
|
63
|
+
blocks.push(current);
|
|
64
|
+
}
|
|
65
|
+
return blocks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Keep potential multiline Prism tokens together. Unclosed syntax conservatively
|
|
69
|
+
// keeps the rest of the document in one block until the user closes it.
|
|
70
|
+
function updateSyntax(line: string, state: { end: string; quote: string; brackets: number; subset: number }) {
|
|
71
|
+
let reference = false;
|
|
72
|
+
for (let i = 0; i < line.length; i++) {
|
|
73
|
+
const char = line[i];
|
|
74
|
+
if (state.end && state.end !== '>') {
|
|
75
|
+
const end = line.indexOf(state.end, i);
|
|
76
|
+
if (end === -1) return;
|
|
77
|
+
i = end + state.end.length - 1;
|
|
78
|
+
state.end = '';
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (state.end === '>') {
|
|
82
|
+
if (state.quote) {
|
|
83
|
+
if (char === state.quote) state.quote = '';
|
|
84
|
+
} else if (char === '"' || char === "'") {
|
|
85
|
+
state.quote = char;
|
|
86
|
+
} else if (char === '[') {
|
|
87
|
+
state.subset++;
|
|
88
|
+
} else if (char === ']' && state.subset) {
|
|
89
|
+
state.subset--;
|
|
90
|
+
} else if (char === '>' && !state.subset) {
|
|
91
|
+
state.end = '';
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (char === '\\') {
|
|
96
|
+
i++;
|
|
97
|
+
} else if (char === '<' && /[a-z!?/]/i.test(line[i + 1] || '')) {
|
|
98
|
+
state.end = line.startsWith('<!--', i)
|
|
99
|
+
? '-->'
|
|
100
|
+
: line.startsWith('<![CDATA[', i)
|
|
101
|
+
? ']]>'
|
|
102
|
+
: line.startsWith('<?', i)
|
|
103
|
+
? '?>'
|
|
104
|
+
: '>';
|
|
105
|
+
} else if (char === '[') {
|
|
106
|
+
state.brackets++;
|
|
107
|
+
} else if (char === ']') {
|
|
108
|
+
state.brackets = Math.max(0, state.brackets - 1);
|
|
109
|
+
reference = line[i + 1] === ':';
|
|
110
|
+
} else if (reference && (char === '"' || char === "'" || char === '(')) {
|
|
111
|
+
state.end = char === '(' ? ')' : char;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|