@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.
Files changed (56) hide show
  1. package/README.md +16 -1
  2. package/dist/mdeditor.css +5 -0
  3. package/dist/mdeditor.js +2382 -1031
  4. package/dist/mdeditor.min.css +1 -1
  5. package/dist/mdeditor.min.js +1 -1
  6. package/esm/Editor.factory.js +85 -11
  7. package/esm/Types.d.ts +6 -0
  8. package/esm/commands/index.cn.d.ts +2 -1
  9. package/esm/commands/index.cn.js +2 -2
  10. package/esm/commands/index.d.ts +2 -2
  11. package/esm/commands/index.js +2 -2
  12. package/esm/commands/list.d.ts +1 -1
  13. package/esm/commands/list.js +9 -3
  14. package/esm/components/TextArea/Markdown.common.d.ts +3 -5
  15. package/esm/components/TextArea/Markdown.common.js +2 -56
  16. package/esm/components/TextArea/Markdown.d.ts +3 -5
  17. package/esm/components/TextArea/Markdown.factory.d.ts +6 -0
  18. package/esm/components/TextArea/Markdown.factory.js +138 -0
  19. package/esm/components/TextArea/Markdown.js +2 -63
  20. package/esm/components/TextArea/factory.js +6 -2
  21. package/esm/components/TextArea/index.css +5 -0
  22. package/esm/components/TextArea/index.less +5 -0
  23. package/esm/components/TextArea/splitBlocks.d.ts +13 -0
  24. package/esm/components/TextArea/splitBlocks.js +111 -0
  25. package/esm/package.json +1 -0
  26. package/lib/Editor.factory.js +93 -12
  27. package/lib/Types.d.ts +6 -0
  28. package/lib/commands/index.cn.d.ts +2 -1
  29. package/lib/commands/index.cn.js +8 -1
  30. package/lib/commands/index.d.ts +2 -2
  31. package/lib/commands/index.js +6 -0
  32. package/lib/commands/list.d.ts +1 -1
  33. package/lib/commands/list.js +5 -1
  34. package/lib/components/TextArea/Markdown.common.d.ts +3 -5
  35. package/lib/components/TextArea/Markdown.common.js +3 -60
  36. package/lib/components/TextArea/Markdown.d.ts +3 -5
  37. package/lib/components/TextArea/Markdown.factory.d.ts +6 -0
  38. package/lib/components/TextArea/Markdown.factory.js +148 -0
  39. package/lib/components/TextArea/Markdown.js +3 -67
  40. package/lib/components/TextArea/factory.js +6 -2
  41. package/lib/components/TextArea/index.less +5 -0
  42. package/lib/components/TextArea/splitBlocks.d.ts +13 -0
  43. package/lib/components/TextArea/splitBlocks.js +117 -0
  44. package/markdown-editor.css +5 -0
  45. package/package.json +18 -12
  46. package/src/Editor.factory.tsx +83 -4
  47. package/src/Types.ts +6 -0
  48. package/src/commands/index.cn.ts +2 -0
  49. package/src/commands/index.ts +2 -1
  50. package/src/commands/list.tsx +11 -2
  51. package/src/components/TextArea/Markdown.common.tsx +3 -46
  52. package/src/components/TextArea/Markdown.factory.tsx +132 -0
  53. package/src/components/TextArea/Markdown.tsx +3 -55
  54. package/src/components/TextArea/factory.tsx +7 -4
  55. package/src/components/TextArea/index.less +5 -0
  56. 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) => (({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }) 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 { type IProps } from '../../Types';
5
- import { EditorContext } from '../../Context';
2
+ import { createMarkdown } from './Markdown.factory';
6
3
 
7
- function html2Escape(sHtml: string) {
8
- return (
9
- sHtml
10
- // .replace(/```(\w+)?([\s\S]*?)(\s.+)?```/g, (str: string) => {
11
- // return str.replace(
12
- // /[<&"]/g,
13
- // (c: string) => (({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' } as Record<string, string>)[c]),
14
- // );
15
- // })
16
- .replace(
17
- /[<&"]/g,
18
- (c: string) => (({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }) as Record<string, string>)[c],
19
- )
20
- );
21
- }
4
+ export type { MarkdownProps } from './Markdown.factory';
22
5
 
23
- export interface MarkdownProps extends IProps, React.HTMLAttributes<HTMLPreElement> {}
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
- ...otherProps,
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} {...otherProps} style={textStyle} />
115
+ <Textarea prefixCls={prefixCls} {...inputProps} style={textStyle} />
113
116
  </Fragment>
114
117
  )}
115
118
  </div>
@@ -119,6 +119,11 @@
119
119
  .token.bold {
120
120
  font-weight: unset !important;
121
121
  }
122
+ .token.list,
123
+ .token.table {
124
+ display: inline;
125
+ font-size: inherit;
126
+ }
122
127
  .token.title {
123
128
  line-height: unset !important;
124
129
  font-size: unset !important;
@@ -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
+ }