@blue-patterns/richtext 0.0.0-stage → 0.7.18
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/CHANGELOG.md +275 -0
- package/LICENSE +22 -0
- package/README.md +88 -2
- package/astryx.integration.mjs +11 -0
- package/blocks/RichTextEditorShowcase.template.mjs +13 -0
- package/blocks/RichTextEditorShowcase.tsx +19 -0
- package/dist/RichTextEditor.d.ts +282 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +657 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
- package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
- package/dist/RichTextEditorToolbar.d.ts +186 -0
- package/dist/RichTextEditorToolbar.d.ts.map +1 -0
- package/dist/RichTextEditorToolbar.js +688 -0
- package/dist/RichTextView.d.ts +92 -0
- package/dist/RichTextView.d.ts.map +1 -0
- package/dist/RichTextView.js +267 -0
- package/dist/codeLanguages.d.ts +14 -0
- package/dist/codeLanguages.d.ts.map +1 -0
- package/dist/codeLanguages.js +34 -0
- package/dist/codeLineTokens.d.ts +53 -0
- package/dist/codeLineTokens.d.ts.map +1 -0
- package/dist/codeLineTokens.js +188 -0
- package/dist/editorNodes.d.ts +8 -0
- package/dist/editorNodes.d.ts.map +1 -0
- package/dist/editorNodes.js +30 -0
- package/dist/editorTheme.d.ts +7 -0
- package/dist/editorTheme.d.ts.map +1 -0
- package/dist/editorTheme.js +328 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
- package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
- package/dist/internal/CodeBackspacePlugin.js +70 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
- package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
- package/dist/internal/CodePastePlugin.d.ts +5 -0
- package/dist/internal/CodePastePlugin.d.ts.map +1 -0
- package/dist/internal/CodePastePlugin.js +104 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
- package/dist/internal/CodeSyntaxPlugin.js +397 -0
- package/dist/internal/CodeTabPlugin.d.ts +2 -0
- package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
- package/dist/internal/CodeTabPlugin.js +256 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
- package/dist/internal/ImageSourceCaretPlugin.js +105 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
- package/dist/internal/ListItemShortcutsPlugin.js +314 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
- package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
- package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
- package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
- package/dist/internal/SelectAllScopePlugin.js +67 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
- package/dist/internal/TableColumnFloorPlugin.js +84 -0
- package/dist/internal/TablePastePlugin.d.ts +9 -0
- package/dist/internal/TablePastePlugin.d.ts.map +1 -0
- package/dist/internal/TablePastePlugin.js +183 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
- package/dist/internal/TableScrollRegionPlugin.js +95 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
- package/dist/internal/TaskCheckboxPlugin.js +193 -0
- package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
- package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
- package/dist/internal/ToolbarLinkPopover.js +292 -0
- package/dist/linkEditing.d.ts +6 -0
- package/dist/linkEditing.d.ts.map +1 -0
- package/dist/linkEditing.js +20 -0
- package/dist/linkForm.d.ts +28 -0
- package/dist/linkForm.d.ts.map +1 -0
- package/dist/linkForm.js +137 -0
- package/dist/linkUtils.d.ts +27 -0
- package/dist/linkUtils.d.ts.map +1 -0
- package/dist/linkUtils.js +101 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +17 -0
- package/dist/markdownCharacterReferences.d.ts +135 -0
- package/dist/markdownCharacterReferences.d.ts.map +1 -0
- package/dist/markdownCharacterReferences.js +1127 -0
- package/dist/markdownCodeFence.d.ts +4 -0
- package/dist/markdownCodeFence.d.ts.map +1 -0
- package/dist/markdownCodeFence.js +167 -0
- package/dist/markdownExtensionNode.d.ts +88 -0
- package/dist/markdownExtensionNode.d.ts.map +1 -0
- package/dist/markdownExtensionNode.js +248 -0
- package/dist/markdownExtensions.d.ts +115 -0
- package/dist/markdownExtensions.d.ts.map +1 -0
- package/dist/markdownExtensions.js +392 -0
- package/dist/markdownHardLineBreak.d.ts +25 -0
- package/dist/markdownHardLineBreak.d.ts.map +1 -0
- package/dist/markdownHardLineBreak.js +59 -0
- package/dist/markdownHeading.d.ts +20 -0
- package/dist/markdownHeading.d.ts.map +1 -0
- package/dist/markdownHeading.js +30 -0
- package/dist/markdownLink.d.ts +5 -0
- package/dist/markdownLink.d.ts.map +1 -0
- package/dist/markdownLink.js +80 -0
- package/dist/markdownListExport.d.ts +3 -0
- package/dist/markdownListExport.d.ts.map +1 -0
- package/dist/markdownListExport.js +68 -0
- package/dist/markdownListIndentation.d.ts +8 -0
- package/dist/markdownListIndentation.d.ts.map +1 -0
- package/dist/markdownListIndentation.js +101 -0
- package/dist/markdownOrderedList.d.ts +3 -0
- package/dist/markdownOrderedList.d.ts.map +1 -0
- package/dist/markdownOrderedList.js +51 -0
- package/dist/markdownQuote.d.ts +22 -0
- package/dist/markdownQuote.d.ts.map +1 -0
- package/dist/markdownQuote.js +32 -0
- package/dist/markdownSerializers.d.ts +49 -0
- package/dist/markdownSerializers.d.ts.map +1 -0
- package/dist/markdownSerializers.js +83 -0
- package/dist/markdownSource.d.ts +71 -0
- package/dist/markdownSource.d.ts.map +1 -0
- package/dist/markdownSource.js +804 -0
- package/dist/markdownSpaceRuns.d.ts +24 -0
- package/dist/markdownSpaceRuns.d.ts.map +1 -0
- package/dist/markdownSpaceRuns.js +147 -0
- package/dist/markdownTable.d.ts +35 -0
- package/dist/markdownTable.d.ts.map +1 -0
- package/dist/markdownTable.js +227 -0
- package/dist/markdownTaskList.d.ts +48 -0
- package/dist/markdownTaskList.d.ts.map +1 -0
- package/dist/markdownTaskList.js +166 -0
- package/dist/markdownThematicBreak.d.ts +3 -0
- package/dist/markdownThematicBreak.d.ts.map +1 -0
- package/dist/markdownThematicBreak.js +55 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/dist/richtext.css +213 -0
- package/dist/textSemantics.d.ts +3 -0
- package/dist/textSemantics.d.ts.map +1 -0
- package/dist/textSemantics.js +87 -0
- package/package.json +158 -3
- package/src/RichTextEditor.doc.mjs +308 -0
- package/src/RichTextEditor.tsx +1109 -0
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
- package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
- package/src/RichTextEditorToolbar.doc.mjs +92 -0
- package/src/RichTextEditorToolbar.tsx +871 -0
- package/src/RichTextView.doc.mjs +101 -0
- package/src/RichTextView.tsx +351 -0
- package/src/codeLanguages.ts +42 -0
- package/src/codeLineTokens.ts +241 -0
- package/src/doc-mjs.d.ts +23 -0
- package/src/editorNodes.ts +44 -0
- package/src/editorTheme.ts +550 -0
- package/src/index.ts +59 -0
- package/src/internal/CodeBackspacePlugin.tsx +96 -0
- package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
- package/src/internal/CodePastePlugin.tsx +144 -0
- package/src/internal/CodeSyntaxPlugin.tsx +493 -0
- package/src/internal/CodeTabPlugin.tsx +298 -0
- package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
- package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
- package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
- package/src/internal/SelectAllScopePlugin.tsx +86 -0
- package/src/internal/TableColumnFloorPlugin.tsx +99 -0
- package/src/internal/TablePastePlugin.tsx +223 -0
- package/src/internal/TableScrollRegionPlugin.tsx +107 -0
- package/src/internal/TaskCheckboxPlugin.tsx +246 -0
- package/src/internal/ToolbarLinkPopover.tsx +392 -0
- package/src/linkEditing.ts +20 -0
- package/src/linkForm.tsx +156 -0
- package/src/linkUtils.ts +106 -0
- package/src/markdown.ts +25 -0
- package/src/markdownCharacterReferences.ts +1137 -0
- package/src/markdownCodeFence.ts +216 -0
- package/src/markdownExtensionNode.ts +339 -0
- package/src/markdownExtensions.ts +496 -0
- package/src/markdownHardLineBreak.ts +73 -0
- package/src/markdownHeading.ts +33 -0
- package/src/markdownLink.ts +86 -0
- package/src/markdownListExport.ts +85 -0
- package/src/markdownListIndentation.ts +105 -0
- package/src/markdownOrderedList.ts +60 -0
- package/src/markdownQuote.ts +35 -0
- package/src/markdownSerializers.ts +108 -0
- package/src/markdownSource.ts +1045 -0
- package/src/markdownSpaceRuns.ts +164 -0
- package/src/markdownTable.ts +309 -0
- package/src/markdownTaskList.ts +222 -0
- package/src/markdownThematicBreak.ts +66 -0
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
- package/src/textSemantics.ts +104 -0
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownSource.ts
|
|
5
|
+
* @input Uses lexical (node state, root, node classes), @lexical/markdown
|
|
6
|
+
* ($convertFromMarkdownString / $convertToMarkdownString), @lexical/code,
|
|
7
|
+
* and the plugin node shielding and export in markdownExtensions.ts.
|
|
8
|
+
* @output Exports importMarkdownKeepingSource and $exportMarkdownKeepingSource,
|
|
9
|
+
* the source-preserving Markdown import and export behind
|
|
10
|
+
* markdownToEditorStateJSON, editorStateJSONToMarkdown, and getMarkdown().
|
|
11
|
+
* @position Internal to @blue-patterns/richtext. Implements spec:AST-062: an
|
|
12
|
+
* untouched top-level block exports exactly as authored, and an edit
|
|
13
|
+
* regenerates only the blocks it changed.
|
|
14
|
+
*
|
|
15
|
+
* Import splits the source into chunks (runs of non-blank lines; a fenced code
|
|
16
|
+
* block is one chunk even across blank lines) and imports each chunk on its
|
|
17
|
+
* own. Once node transforms settle the tree, the top-level nodes from one or
|
|
18
|
+
* more consecutive chunks form a group, and the group's first node records the
|
|
19
|
+
* exact bytes it came from and its canonical Markdown at import. Facts about
|
|
20
|
+
* the whole document — its byte order mark, the bytes before the first block
|
|
21
|
+
* and after the last, its line ending — live on the root, so moving, copying,
|
|
22
|
+
* or deleting blocks never moves or duplicates them.
|
|
23
|
+
*
|
|
24
|
+
* Export reads the tree without changing it. Each group's canonical Markdown
|
|
25
|
+
* is regenerated (and cached per node, so an unchanged tree costs a lookup per
|
|
26
|
+
* group); when it still matches the recorded one, the recorded bytes are
|
|
27
|
+
* written instead.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {$convertFromMarkdownString, type Transformer} from '@lexical/markdown';
|
|
31
|
+
import {$isCodeNode} from '@lexical/code';
|
|
32
|
+
import {$isLinkNode} from '@lexical/link';
|
|
33
|
+
import {$isListItemNode, $isListNode} from '@lexical/list';
|
|
34
|
+
import {
|
|
35
|
+
$createParagraphNode,
|
|
36
|
+
$createTextNode,
|
|
37
|
+
$getRoot,
|
|
38
|
+
$getState,
|
|
39
|
+
$isElementNode,
|
|
40
|
+
$isLineBreakNode,
|
|
41
|
+
$isTextNode,
|
|
42
|
+
$setState,
|
|
43
|
+
createState,
|
|
44
|
+
type ElementNode,
|
|
45
|
+
type LexicalEditor,
|
|
46
|
+
type LexicalNode,
|
|
47
|
+
type NodeKey,
|
|
48
|
+
} from 'lexical';
|
|
49
|
+
import {isMarkedHardLineBreak} from './markdownHardLineBreak';
|
|
50
|
+
import {normalizeListIndentation} from './markdownListIndentation';
|
|
51
|
+
import {
|
|
52
|
+
$isImageSource,
|
|
53
|
+
$restoreCharacterReferences,
|
|
54
|
+
$restoreCodeSpans,
|
|
55
|
+
$restoreImages,
|
|
56
|
+
protectBackslashEscapes,
|
|
57
|
+
protectCharacterReferences,
|
|
58
|
+
protectCodeSpans,
|
|
59
|
+
protectImages,
|
|
60
|
+
protectLinkDestinationParentheses,
|
|
61
|
+
protectRefusedLinks,
|
|
62
|
+
$unwrapRefusedLinks,
|
|
63
|
+
} from './markdownCharacterReferences';
|
|
64
|
+
import {
|
|
65
|
+
$restoreExtensionSources,
|
|
66
|
+
absentCharacters,
|
|
67
|
+
extensionTextView,
|
|
68
|
+
shieldExtensionSources,
|
|
69
|
+
withExtensionExport,
|
|
70
|
+
} from './markdownExtensions';
|
|
71
|
+
import {$isRichTextExtensionNode} from './markdownExtensionNode';
|
|
72
|
+
import {$convertToMarkdownKeepingTimeLinear} from './markdownSpaceRuns';
|
|
73
|
+
import type {MarkdownPluginEntry} from '@blue-patterns/core/Markdown/plugins';
|
|
74
|
+
|
|
75
|
+
/** The whitespace and content one chunk of Markdown source was split into. */
|
|
76
|
+
export interface MarkdownChunk {
|
|
77
|
+
/** Blank lines before the chunk; only the first chunk has any. */
|
|
78
|
+
readonly leading: string;
|
|
79
|
+
/** The chunk's lines, without the line ending that ends the last one. */
|
|
80
|
+
readonly content: string;
|
|
81
|
+
/** Everything after the content up to the next chunk, or to the end. */
|
|
82
|
+
readonly trailing: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a group of top-level nodes was imported from. */
|
|
86
|
+
interface GroupRecord {
|
|
87
|
+
/** The import and chunk this group came from; unique across documents. */
|
|
88
|
+
readonly group: string;
|
|
89
|
+
/** The rest is recorded on the group's first node only. */
|
|
90
|
+
readonly size?: number;
|
|
91
|
+
readonly content?: string;
|
|
92
|
+
/** The bytes between this group and the next one. */
|
|
93
|
+
readonly trailing?: string;
|
|
94
|
+
/** A hash of the group's canonical Markdown at import. */
|
|
95
|
+
readonly canonicalHash?: string;
|
|
96
|
+
/** The line ending the group's source uses. */
|
|
97
|
+
readonly lineEnding?: string;
|
|
98
|
+
/**
|
|
99
|
+
* The group right after this one in the source, recorded only when no
|
|
100
|
+
* blank line separates them (a thematic break written under a block).
|
|
101
|
+
*/
|
|
102
|
+
readonly next?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The serialized form of a group record: short keys, and defaults left out,
|
|
107
|
+
* because every imported group carries one.
|
|
108
|
+
*/
|
|
109
|
+
interface SerializedGroupRecord {
|
|
110
|
+
g: string;
|
|
111
|
+
n?: number;
|
|
112
|
+
s?: string;
|
|
113
|
+
t?: string;
|
|
114
|
+
h?: string;
|
|
115
|
+
e?: string;
|
|
116
|
+
x?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Facts about the whole imported document, kept on the root. */
|
|
120
|
+
interface DocumentRecord {
|
|
121
|
+
readonly importId: string;
|
|
122
|
+
readonly byteOrderMark: boolean;
|
|
123
|
+
/** The bytes before the first block, without the byte order mark. */
|
|
124
|
+
readonly leading: string;
|
|
125
|
+
/** The bytes after the last block. */
|
|
126
|
+
readonly trailing: string;
|
|
127
|
+
/** The document's first line ending, or LF when it has none. */
|
|
128
|
+
readonly lineEnding: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseGroupRecord(value: unknown): GroupRecord | null {
|
|
132
|
+
if (typeof value !== 'object' || value === null) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const serialized = value as Partial<SerializedGroupRecord>;
|
|
136
|
+
if (typeof serialized.g !== 'string') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (typeof serialized.s !== 'string') {
|
|
140
|
+
return {group: serialized.g};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
group: serialized.g,
|
|
144
|
+
size: typeof serialized.n === 'number' ? serialized.n : 1,
|
|
145
|
+
content: serialized.s,
|
|
146
|
+
trailing: typeof serialized.t === 'string' ? serialized.t : '\n\n',
|
|
147
|
+
canonicalHash: typeof serialized.h === 'string' ? serialized.h : '',
|
|
148
|
+
lineEnding: typeof serialized.e === 'string' ? serialized.e : '\n',
|
|
149
|
+
...(typeof serialized.x === 'string' ? {next: serialized.x} : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function unparseGroupRecord(
|
|
154
|
+
record: GroupRecord | null,
|
|
155
|
+
): SerializedGroupRecord | null {
|
|
156
|
+
if (record == null) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const serialized: SerializedGroupRecord = {g: record.group};
|
|
160
|
+
if (record.content != null) {
|
|
161
|
+
serialized.s = record.content;
|
|
162
|
+
if (record.size !== 1) {
|
|
163
|
+
serialized.n = record.size;
|
|
164
|
+
}
|
|
165
|
+
if (record.trailing !== '\n\n') {
|
|
166
|
+
serialized.t = record.trailing;
|
|
167
|
+
}
|
|
168
|
+
serialized.h = record.canonicalHash;
|
|
169
|
+
if (record.next != null) {
|
|
170
|
+
serialized.x = record.next;
|
|
171
|
+
}
|
|
172
|
+
if (record.lineEnding !== '\n') {
|
|
173
|
+
serialized.e = record.lineEnding;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return serialized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isDocumentRecord(value: unknown): value is DocumentRecord {
|
|
180
|
+
return (
|
|
181
|
+
typeof value === 'object' &&
|
|
182
|
+
value !== null &&
|
|
183
|
+
typeof (value as {importId?: unknown}).importId === 'string'
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const groupState = createState('astryxMd', {
|
|
188
|
+
parse: parseGroupRecord,
|
|
189
|
+
unparse: unparseGroupRecord,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const documentState = createState('astryxMdDocument', {
|
|
193
|
+
parse: (value: unknown): DocumentRecord | null =>
|
|
194
|
+
isDocumentRecord(value) ? value : null,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const BLANK_LINE = /^[ \t]*\r?$/;
|
|
198
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
199
|
+
// A thematic break line (CommonMark 0.31), and the dash-only lines that
|
|
200
|
+
// underline a paragraph line above them as a heading instead.
|
|
201
|
+
const THEMATIC_BREAK_LINE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/;
|
|
202
|
+
const SETEXT_DASHES = /^ {0,3}-+[ \t]*\r?$/;
|
|
203
|
+
// Lines that start a block other than a paragraph.
|
|
204
|
+
const BLOCK_START =
|
|
205
|
+
/^ {0,3}(?:[-*+][ \t]|\d{1,9}[.)][ \t]|>|#{1,6}(?:[ \t]|\r?$)|\|)/;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Splits Markdown into chunks: runs of non-blank lines, with a fenced code
|
|
209
|
+
* block kept whole across blank lines, and a thematic break always a chunk of
|
|
210
|
+
* its own, so editing the block beside a rule never rewrites the rule
|
|
211
|
+
* (spec:AST-062 FR2). A dash-only line under paragraph lines underlines them
|
|
212
|
+
* instead, and stays with them. Joining every chunk's leading, content, and
|
|
213
|
+
* trailing text gives back the input exactly.
|
|
214
|
+
*/
|
|
215
|
+
/**
|
|
216
|
+
* The chunks of `markdown` that cover the same lines as `like`, the chunks of
|
|
217
|
+
* text with the same line count. Rewriting indentation can move a line across
|
|
218
|
+
* a chunk boundary rule — a thematic break indented into a list item becomes
|
|
219
|
+
* a break of its own — so the chunks follow the authored text, not the rule.
|
|
220
|
+
*/
|
|
221
|
+
function sameLines(
|
|
222
|
+
markdown: string,
|
|
223
|
+
like: ReadonlyArray<MarkdownChunk>,
|
|
224
|
+
): Array<MarkdownChunk> {
|
|
225
|
+
const lines = markdown.split('\n');
|
|
226
|
+
const lineEndings = (text: string) => text.split('\n').length - 1;
|
|
227
|
+
let line = 0;
|
|
228
|
+
return like.map(chunk => {
|
|
229
|
+
line += lineEndings(chunk.leading);
|
|
230
|
+
const count = lineEndings(chunk.content) + 1;
|
|
231
|
+
const content = lines.slice(line, line + count).join('\n');
|
|
232
|
+
line += count - 1 + lineEndings(chunk.trailing);
|
|
233
|
+
return {...chunk, content};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function splitMarkdownChunks(markdown: string): Array<MarkdownChunk> {
|
|
238
|
+
const lines = markdown.split('\n');
|
|
239
|
+
const chunks: Array<MarkdownChunk> = [];
|
|
240
|
+
let leading = '';
|
|
241
|
+
let index = 0;
|
|
242
|
+
// Blank lines before the first chunk.
|
|
243
|
+
while (index < lines.length && BLANK_LINE.test(lines[index])) {
|
|
244
|
+
if (index === lines.length - 1) {
|
|
245
|
+
// Whitespace with no content at all.
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
leading += `${lines[index]}\n`;
|
|
249
|
+
index++;
|
|
250
|
+
}
|
|
251
|
+
while (index < lines.length) {
|
|
252
|
+
const start = index;
|
|
253
|
+
let fence: string | null = null;
|
|
254
|
+
// Whether every line so far is paragraph text.
|
|
255
|
+
let isParagraph = true;
|
|
256
|
+
while (index < lines.length) {
|
|
257
|
+
const line = lines[index];
|
|
258
|
+
if (fence != null) {
|
|
259
|
+
index++;
|
|
260
|
+
const close = new RegExp(
|
|
261
|
+
`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*\\r?$`,
|
|
262
|
+
);
|
|
263
|
+
if (close.test(line)) {
|
|
264
|
+
fence = null;
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (BLANK_LINE.test(line)) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const isRule =
|
|
272
|
+
THEMATIC_BREAK_LINE.test(line) &&
|
|
273
|
+
!(isParagraph && index > start && SETEXT_DASHES.test(line));
|
|
274
|
+
if (isRule && index > start) {
|
|
275
|
+
// The rule starts a chunk of its own.
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
const open = FENCE_OPEN.exec(line);
|
|
279
|
+
if (open != null) {
|
|
280
|
+
fence = open[1];
|
|
281
|
+
}
|
|
282
|
+
if (open != null || BLOCK_START.test(line)) {
|
|
283
|
+
isParagraph = false;
|
|
284
|
+
}
|
|
285
|
+
index++;
|
|
286
|
+
if (isRule) {
|
|
287
|
+
// And ends it.
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
let content = lines.slice(start, index).join('\n');
|
|
292
|
+
let trailing = '';
|
|
293
|
+
// A CRLF line ending belongs to the trailing text, not the content.
|
|
294
|
+
if (content.endsWith('\r')) {
|
|
295
|
+
content = content.slice(0, -1);
|
|
296
|
+
trailing = '\r';
|
|
297
|
+
}
|
|
298
|
+
while (index < lines.length && BLANK_LINE.test(lines[index])) {
|
|
299
|
+
trailing += `\n${lines[index]}`;
|
|
300
|
+
index++;
|
|
301
|
+
}
|
|
302
|
+
if (index < lines.length) {
|
|
303
|
+
// The line ending before the next chunk's first line.
|
|
304
|
+
trailing += '\n';
|
|
305
|
+
}
|
|
306
|
+
chunks.push({
|
|
307
|
+
leading: chunks.length === 0 ? leading : '',
|
|
308
|
+
content,
|
|
309
|
+
trailing,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return chunks;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Lines without the carriage return before each line feed. */
|
|
316
|
+
function withoutCarriageReturns(text: string): string {
|
|
317
|
+
return text.replace(/\r(?=\n|$)/g, '');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The first line ending in `text`, or `fallback` when it has none. */
|
|
321
|
+
function lineEndingOf(text: string, fallback: string): string {
|
|
322
|
+
const index = text.indexOf('\n');
|
|
323
|
+
if (index === -1) {
|
|
324
|
+
return fallback;
|
|
325
|
+
}
|
|
326
|
+
return index > 0 && text[index - 1] === '\r' ? '\r\n' : '\n';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Rewrites every line ending in `text` as `lineEnding`. */
|
|
330
|
+
function withLineEnding(text: string, lineEnding: string): string {
|
|
331
|
+
const lf = text.replace(/\r\n/g, '\n');
|
|
332
|
+
return lineEnding === '\n' ? lf : lf.replace(/\n/g, lineEnding);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A 64-bit hash of `text`, from two independent 32-bit FNV-1a passes. Only
|
|
337
|
+
* equality matters: it tells an unchanged group from a changed one without
|
|
338
|
+
* storing the canonical Markdown itself.
|
|
339
|
+
*/
|
|
340
|
+
function hashOf(text: string): string {
|
|
341
|
+
let first = 0x811c9dc5;
|
|
342
|
+
let second = 0x01000193 ^ text.length;
|
|
343
|
+
for (let index = 0; index < text.length; index++) {
|
|
344
|
+
const code = text.charCodeAt(index);
|
|
345
|
+
first = Math.imul(first ^ code, 0x01000193) >>> 0;
|
|
346
|
+
second = Math.imul(second ^ code, 0x5bd1e995) >>> 0;
|
|
347
|
+
}
|
|
348
|
+
return first.toString(36) + second.toString(36).padStart(7, '0');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let importCount = 0;
|
|
352
|
+
|
|
353
|
+
/** An id no other import in this or any other document shares. */
|
|
354
|
+
function nextImportId(): string {
|
|
355
|
+
importCount++;
|
|
356
|
+
return `${Math.random().toString(36).slice(2, 8)}${importCount.toString(36)}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Imports Markdown into the editor's root and records its authored bytes. Runs
|
|
361
|
+
* two discrete updates: the first imports every chunk and stamps its nodes
|
|
362
|
+
* with the chunk's group id; the second runs after node transforms have
|
|
363
|
+
* settled the tree (adjacent lists merge, for example), so each group covers
|
|
364
|
+
* exactly the chunks that ended up in its nodes.
|
|
365
|
+
*/
|
|
366
|
+
export function importMarkdownKeepingSource(
|
|
367
|
+
editor: LexicalEditor,
|
|
368
|
+
markdown: string,
|
|
369
|
+
transformers: Array<Transformer>,
|
|
370
|
+
plugins: ReadonlyArray<MarkdownPluginEntry> = [],
|
|
371
|
+
): void {
|
|
372
|
+
const importId = nextImportId();
|
|
373
|
+
const byteOrderMark = markdown.startsWith('\uFEFF');
|
|
374
|
+
const body = byteOrderMark ? markdown.slice(1) : markdown;
|
|
375
|
+
const chunks = splitMarkdownChunks(body);
|
|
376
|
+
// Lexical reads the same chunks with list nesting spelled its way; the
|
|
377
|
+
// records keep the authored bytes. Only indentation changes, so each chunk
|
|
378
|
+
// is the same lines of the rewritten text.
|
|
379
|
+
const importChunks = sameLines(normalizeListIndentation(body), chunks);
|
|
380
|
+
const lineEnding = lineEndingOf(body, '\n');
|
|
381
|
+
editor.update(
|
|
382
|
+
() => {
|
|
383
|
+
const root = $getRoot();
|
|
384
|
+
root.clear();
|
|
385
|
+
chunks.forEach((chunk, index) => {
|
|
386
|
+
// The holder is attached while Lexical imports into it: Lexical only
|
|
387
|
+
// continues a paragraph, list item, or quote onto the next line
|
|
388
|
+
// inside an attached tree, and would otherwise start a new paragraph
|
|
389
|
+
// at every line ending.
|
|
390
|
+
const holder = $createParagraphNode();
|
|
391
|
+
root.append(holder);
|
|
392
|
+
// Lexical imports LF lines; the record keeps the authored endings.
|
|
393
|
+
// Adopted plugins' nodes, links core refuses (as their source text),
|
|
394
|
+
// then code spans, then backslash escapes and parentheses in
|
|
395
|
+
// link destinations, then character references go through as
|
|
396
|
+
// stand-ins: plugin nodes come back as extension nodes holding their
|
|
397
|
+
// source, escapes and parentheses as the literal characters,
|
|
398
|
+
// references decoded.
|
|
399
|
+
const shielded = shieldExtensionSources(
|
|
400
|
+
withoutCarriageReturns(importChunks[index]?.content ?? chunk.content),
|
|
401
|
+
plugins,
|
|
402
|
+
);
|
|
403
|
+
// Images go first: RichText has no image node, so each comes back as
|
|
404
|
+
// its source, whole. Links core refuses go next, on the source as
|
|
405
|
+
// written, so one whose text holds code (`[`x`](javascript:y)`)
|
|
406
|
+
// shows whole as core shows it; code spans next, so a safe link's
|
|
407
|
+
// code still reads as code.
|
|
408
|
+
const images = protectImages(shielded.markdown);
|
|
409
|
+
const refused = protectRefusedLinks(images.markdown);
|
|
410
|
+
const code = protectCodeSpans(refused.markdown);
|
|
411
|
+
const escaped = protectBackslashEscapes(code.markdown);
|
|
412
|
+
const destinations = protectLinkDestinationParentheses(
|
|
413
|
+
escaped.markdown,
|
|
414
|
+
);
|
|
415
|
+
const referenced = protectCharacterReferences(destinations.markdown);
|
|
416
|
+
$convertFromMarkdownString(referenced.markdown, transformers, holder);
|
|
417
|
+
$restoreCharacterReferences(
|
|
418
|
+
holder,
|
|
419
|
+
new Map([
|
|
420
|
+
...refused.standIns,
|
|
421
|
+
...code.standIns,
|
|
422
|
+
...escaped.standIns,
|
|
423
|
+
...destinations.standIns,
|
|
424
|
+
...referenced.standIns,
|
|
425
|
+
]),
|
|
426
|
+
);
|
|
427
|
+
$restoreCodeSpans(holder, code.spans);
|
|
428
|
+
$restoreImages(holder, images.images);
|
|
429
|
+
$unwrapRefusedLinks(holder);
|
|
430
|
+
$restoreExtensionSources(holder, shielded.standIns);
|
|
431
|
+
$joinSoftLineBreaks(holder);
|
|
432
|
+
for (const node of holder.getChildren()) {
|
|
433
|
+
$setState(node, groupState, {group: `${importId}:${index}`});
|
|
434
|
+
}
|
|
435
|
+
for (const node of holder.getChildren()) {
|
|
436
|
+
holder.insertBefore(node);
|
|
437
|
+
}
|
|
438
|
+
holder.remove();
|
|
439
|
+
});
|
|
440
|
+
$nestFollowingLists(root);
|
|
441
|
+
if (root.getChildrenSize() === 0) {
|
|
442
|
+
// Nothing to import: keep one empty paragraph, as Lexical does.
|
|
443
|
+
root.append($createParagraphNode());
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
{discrete: true},
|
|
447
|
+
);
|
|
448
|
+
editor.update(
|
|
449
|
+
() => {
|
|
450
|
+
$recordGroups(chunks, importId, transformers);
|
|
451
|
+
const root = $getRoot();
|
|
452
|
+
const hasGroups = root
|
|
453
|
+
.getChildren()
|
|
454
|
+
.some(node => $getState(node, groupState)?.content != null);
|
|
455
|
+
$setState(root, documentState, {
|
|
456
|
+
importId,
|
|
457
|
+
byteOrderMark,
|
|
458
|
+
leading: hasGroups ? (chunks[0]?.leading ?? '') : '',
|
|
459
|
+
trailing: hasGroups
|
|
460
|
+
? (chunks[chunks.length - 1]?.trailing ?? '')
|
|
461
|
+
: body,
|
|
462
|
+
lineEnding,
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
{discrete: true},
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Joins the lines of a paragraph, list item, or block quote across soft line
|
|
471
|
+
* breaks: a line ending without two trailing spaces or a backslash continues
|
|
472
|
+
* the block, as one space (CommonMark, soft line breaks; spec:AST-061 FR5).
|
|
473
|
+
* Lexical imports every line ending as a line break and marks the hard ones,
|
|
474
|
+
* so the unmarked ones outside code are the soft breaks.
|
|
475
|
+
*/
|
|
476
|
+
export function $joinSoftLineBreaks(element: ElementNode): void {
|
|
477
|
+
for (const child of element.getChildren()) {
|
|
478
|
+
if ($isElementNode(child) && !$isCodeNode(child)) {
|
|
479
|
+
$joinSoftLineBreaks(child);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const child of element.getChildren()) {
|
|
483
|
+
if (!$isLineBreakNode(child) || isMarkedHardLineBreak(child)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// The spaces around a soft break are part of it.
|
|
487
|
+
const previous = child.getPreviousSibling();
|
|
488
|
+
if ($isTextNode(previous) && !previous.hasFormat('code')) {
|
|
489
|
+
// Counted from the end: a pattern such as /[ \t]+$/ retries from every
|
|
490
|
+
// space in a long run that does not end the text, so its time grows
|
|
491
|
+
// with the square of the run.
|
|
492
|
+
const text = previous.getTextContent();
|
|
493
|
+
let end = text.length;
|
|
494
|
+
while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) {
|
|
495
|
+
end--;
|
|
496
|
+
}
|
|
497
|
+
previous.setTextContent(text.slice(0, end));
|
|
498
|
+
}
|
|
499
|
+
const next = child.getNextSibling();
|
|
500
|
+
if ($isTextNode(next) && !next.hasFormat('code')) {
|
|
501
|
+
next.setTextContent(next.getTextContent().replace(/^[ \t]+/, ''));
|
|
502
|
+
}
|
|
503
|
+
child.replace($createTextNode(' '));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Moves a list that begins nested right after another list into that list's
|
|
509
|
+
* last item. Lexical starts a new top-level list for an item of a different
|
|
510
|
+
* type (a bullet under `1. item`) and nests it there, while CommonMark nests
|
|
511
|
+
* it inside the item above; without this, writing it back would lose the
|
|
512
|
+
* nesting (spec:AST-061 FR5, spec:AST-062 FR3).
|
|
513
|
+
*/
|
|
514
|
+
export function $nestFollowingLists(root: ElementNode): void {
|
|
515
|
+
for (const node of root.getChildren()) {
|
|
516
|
+
const previous = node.getPreviousSibling();
|
|
517
|
+
if (!$isListNode(node) || !$isListNode(previous)) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
// Lexical keeps a nested list in an item of its own after its parent.
|
|
521
|
+
let first = node.getFirstChild();
|
|
522
|
+
while (
|
|
523
|
+
$isListItemNode(first) &&
|
|
524
|
+
first.getChildrenSize() === 1 &&
|
|
525
|
+
$isListNode(first.getFirstChild())
|
|
526
|
+
) {
|
|
527
|
+
previous.append(first);
|
|
528
|
+
first = node.getFirstChild();
|
|
529
|
+
}
|
|
530
|
+
if (node.getChildrenSize() === 0) {
|
|
531
|
+
node.remove();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Turns the first pass's chunk stamps into groups. Consecutive nodes stamped
|
|
538
|
+
* with one chunk form a group, and the group also covers every later chunk up
|
|
539
|
+
* to the next group's: those chunks merged into its nodes or imported to
|
|
540
|
+
* nothing. Chunks before the first group become part of it.
|
|
541
|
+
*/
|
|
542
|
+
function $recordGroups(
|
|
543
|
+
chunks: ReadonlyArray<MarkdownChunk>,
|
|
544
|
+
importId: string,
|
|
545
|
+
transformers: Array<Transformer>,
|
|
546
|
+
): void {
|
|
547
|
+
const children = $getRoot().getChildren();
|
|
548
|
+
const chunkOf = (node: LexicalNode): number | null => {
|
|
549
|
+
const group = $getState(node, groupState)?.group;
|
|
550
|
+
if (group == null || !group.startsWith(`${importId}:`)) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return Number(group.slice(importId.length + 1));
|
|
554
|
+
};
|
|
555
|
+
let index = 0;
|
|
556
|
+
let isFirstGroup = true;
|
|
557
|
+
while (index < children.length) {
|
|
558
|
+
const firstChunk = chunkOf(children[index]);
|
|
559
|
+
if (firstChunk == null) {
|
|
560
|
+
index++;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const group: Array<LexicalNode> = [];
|
|
564
|
+
while (index < children.length && chunkOf(children[index]) === firstChunk) {
|
|
565
|
+
group.push(children[index]);
|
|
566
|
+
index++;
|
|
567
|
+
}
|
|
568
|
+
const nextChunk =
|
|
569
|
+
index === children.length
|
|
570
|
+
? chunks.length
|
|
571
|
+
: (chunkOf(children[index]) ?? chunks.length);
|
|
572
|
+
const startChunk = isFirstGroup ? 0 : firstChunk;
|
|
573
|
+
isFirstGroup = false;
|
|
574
|
+
const covered = chunks.slice(startChunk, nextChunk);
|
|
575
|
+
const content = covered
|
|
576
|
+
.map(
|
|
577
|
+
(chunk, position) =>
|
|
578
|
+
(position === 0 ? '' : chunk.leading) +
|
|
579
|
+
(position === covered.length - 1
|
|
580
|
+
? chunk.content
|
|
581
|
+
: chunk.content + chunk.trailing),
|
|
582
|
+
)
|
|
583
|
+
.join('');
|
|
584
|
+
const isLastGroup = index === children.length;
|
|
585
|
+
const trailing = isLastGroup
|
|
586
|
+
? ''
|
|
587
|
+
: (covered[covered.length - 1]?.trailing ?? '');
|
|
588
|
+
const record: GroupRecord = {
|
|
589
|
+
group: `${importId}:${firstChunk}`,
|
|
590
|
+
size: group.length,
|
|
591
|
+
content,
|
|
592
|
+
trailing,
|
|
593
|
+
canonicalHash: $canonicalMarkdown(group, transformers).hash,
|
|
594
|
+
lineEnding: lineEndingOf(
|
|
595
|
+
content + (covered[covered.length - 1]?.trailing ?? ''),
|
|
596
|
+
'\n',
|
|
597
|
+
),
|
|
598
|
+
...(!isLastGroup && !ENDS_WITH_BLANK_LINE.test(trailing)
|
|
599
|
+
? {next: `${importId}:${nextChunk}`}
|
|
600
|
+
: {}),
|
|
601
|
+
};
|
|
602
|
+
group.forEach((node, position) => {
|
|
603
|
+
$setState(
|
|
604
|
+
node,
|
|
605
|
+
groupState,
|
|
606
|
+
position === 0 ? record : {group: record.group},
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* An element that lists `nodes` as its children without adopting them, so
|
|
614
|
+
* Lexical's exporter can read a run of top-level nodes on its own. The
|
|
615
|
+
* exporter only ever asks the root it is given for its children.
|
|
616
|
+
*/
|
|
617
|
+
function childrenOf(nodes: ReadonlyArray<LexicalNode>): ElementNode {
|
|
618
|
+
return {getChildren: () => [...nodes]} as unknown as ElementNode;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Every node in the runs' subtrees, in document order. */
|
|
622
|
+
function descendantsOf(nodes: ReadonlyArray<LexicalNode>): Array<LexicalNode> {
|
|
623
|
+
const out: Array<LexicalNode> = [];
|
|
624
|
+
const visit = (node: LexicalNode) => {
|
|
625
|
+
out.push(node);
|
|
626
|
+
if ($isElementNode(node)) {
|
|
627
|
+
node.getChildren().forEach(visit);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
nodes.forEach(visit);
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Canonical Markdown per run of nodes, keyed by the run's first node. Lexical
|
|
636
|
+
* keeps a node's object across editor states until that node itself changes,
|
|
637
|
+
* so a run whose every descendant is the same object as last time is
|
|
638
|
+
* unchanged and costs a walk instead of an export.
|
|
639
|
+
*/
|
|
640
|
+
const canonicalCache = new WeakMap<
|
|
641
|
+
LexicalNode,
|
|
642
|
+
{
|
|
643
|
+
readonly descendants: ReadonlyArray<LexicalNode>;
|
|
644
|
+
readonly transformers: ReadonlyArray<Transformer>;
|
|
645
|
+
readonly markdown: string;
|
|
646
|
+
readonly hash: string;
|
|
647
|
+
}
|
|
648
|
+
>();
|
|
649
|
+
|
|
650
|
+
function $canonicalMarkdown(
|
|
651
|
+
nodes: ReadonlyArray<LexicalNode>,
|
|
652
|
+
transformers: Array<Transformer>,
|
|
653
|
+
): {readonly markdown: string; readonly hash: string} {
|
|
654
|
+
const descendants = descendantsOf(nodes);
|
|
655
|
+
const cached = canonicalCache.get(nodes[0]);
|
|
656
|
+
if (
|
|
657
|
+
cached != null &&
|
|
658
|
+
cached.transformers === transformers &&
|
|
659
|
+
cached.descendants.length === descendants.length &&
|
|
660
|
+
cached.descendants.every((node, index) => node === descendants[index])
|
|
661
|
+
) {
|
|
662
|
+
return cached;
|
|
663
|
+
}
|
|
664
|
+
// Inline plugin nodes read as text inside the marks around them, so a mark
|
|
665
|
+
// put on or taken off a node alone changes the canonical form too.
|
|
666
|
+
const {placeholders, sources} = extensionPlaceholders(
|
|
667
|
+
descendants,
|
|
668
|
+
descendants.map(node => node.getTextContent()).join(''),
|
|
669
|
+
);
|
|
670
|
+
let markdown = $convertToMarkdownKeepingTimeLinear(
|
|
671
|
+
withExtensionExport(transformers),
|
|
672
|
+
childrenOf(
|
|
673
|
+
placeholders.size === 0
|
|
674
|
+
? nodes
|
|
675
|
+
: nodes.map(node => markedView(node, null, placeholders)),
|
|
676
|
+
),
|
|
677
|
+
);
|
|
678
|
+
for (const [placeholder, source] of sources) {
|
|
679
|
+
markdown = markdown.split(placeholder).join(source);
|
|
680
|
+
}
|
|
681
|
+
const entry = {descendants, transformers, markdown, hash: hashOf(markdown)};
|
|
682
|
+
canonicalCache.set(nodes[0], entry);
|
|
683
|
+
return entry;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Line starts that would turn literal text into a block structure. Each match
|
|
688
|
+
* is the spaces before the marker, where the escape goes: block markers may
|
|
689
|
+
* follow up to three spaces (CommonMark 0.31), and a list item's continuation
|
|
690
|
+
* line adds its own indentation, so any spaces count.
|
|
691
|
+
*/
|
|
692
|
+
const LINE_START_SYNTAX: ReadonlyArray<RegExp> = [
|
|
693
|
+
// Block quote or ATX heading marker.
|
|
694
|
+
/^ *(?=>|#{1,6}(?:[ \t]|$))/,
|
|
695
|
+
// Bullet list item.
|
|
696
|
+
/^ *(?=[-+](?:[ \t]|$))/,
|
|
697
|
+
// Setext underline or thematic break made of `=` or `-`.
|
|
698
|
+
/^ *(?=[=-][=\- \t]*$)/,
|
|
699
|
+
// Table delimiter row.
|
|
700
|
+
/^ *(?=[|:][|:\- \t]*-[|:\- \t]*$)/,
|
|
701
|
+
];
|
|
702
|
+
// An ordered list item escapes its delimiter, not its first character.
|
|
703
|
+
const ORDERED_LIST_START = /^( *\d{1,9})(?=[.)](?:[ \t]|$))/;
|
|
704
|
+
|
|
705
|
+
// Inline syntax Lexical's export leaves unescaped: link and image brackets and
|
|
706
|
+
// character references.
|
|
707
|
+
const INLINE_SYNTAX =
|
|
708
|
+
/[[\]]|&(?=#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};|[A-Za-z][A-Za-z0-9]*;)/g;
|
|
709
|
+
|
|
710
|
+
const PRIVATE_USE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
711
|
+
[0xe000, 0xf8ff],
|
|
712
|
+
[0xf0000, 0xffffd],
|
|
713
|
+
[0x100000, 0x10fffd],
|
|
714
|
+
];
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* A token that appears nowhere in `text`. There are 137,468 private-use code
|
|
718
|
+
* points, so a text shorter than that always misses one of them; a longer
|
|
719
|
+
* text cannot contain every one of the 137,468² ordered pairs.
|
|
720
|
+
*/
|
|
721
|
+
export function absentToken(text: string): string {
|
|
722
|
+
const present = new Set(text);
|
|
723
|
+
for (const [first, last] of PRIVATE_USE_RANGES) {
|
|
724
|
+
for (let codePoint = first; codePoint <= last; codePoint++) {
|
|
725
|
+
const candidate = String.fromCodePoint(codePoint);
|
|
726
|
+
if (!present.has(candidate)) {
|
|
727
|
+
return candidate;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (const [first, last] of PRIVATE_USE_RANGES) {
|
|
732
|
+
for (let a = first; a <= last; a++) {
|
|
733
|
+
for (let b = first; b <= last; b++) {
|
|
734
|
+
const candidate = String.fromCodePoint(a, b);
|
|
735
|
+
if (!text.includes(candidate)) {
|
|
736
|
+
return candidate;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
throw new Error('No escape token is absent from the text');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* A view of `node` whose text marks every character that needs a backslash
|
|
746
|
+
* with `token` (none when `token` is null), and whose inline plugin nodes read
|
|
747
|
+
* as text holding their placeholders. Views delegate everything else to the
|
|
748
|
+
* node they wrap, so Lexical's exporter reads them like the real tree without
|
|
749
|
+
* changing it.
|
|
750
|
+
*/
|
|
751
|
+
function markedView(
|
|
752
|
+
node: LexicalNode,
|
|
753
|
+
token: string | null,
|
|
754
|
+
placeholders: ReadonlyMap<NodeKey, string>,
|
|
755
|
+
): LexicalNode {
|
|
756
|
+
const placeholder = placeholders.get(node.getKey());
|
|
757
|
+
if (placeholder != null && $isRichTextExtensionNode(node)) {
|
|
758
|
+
return extensionTextView(node, placeholder);
|
|
759
|
+
}
|
|
760
|
+
if (placeholder != null && $isTextNode(node)) {
|
|
761
|
+
const view = Object.create(node) as typeof node;
|
|
762
|
+
view.getTextContent = () => placeholder;
|
|
763
|
+
return view;
|
|
764
|
+
}
|
|
765
|
+
if ($isTextNode(node)) {
|
|
766
|
+
if (node.hasFormat('code') || $isCodeNode(node.getParent())) {
|
|
767
|
+
return node;
|
|
768
|
+
}
|
|
769
|
+
if (token == null) {
|
|
770
|
+
return Object.create(node) as typeof node;
|
|
771
|
+
}
|
|
772
|
+
let text = node
|
|
773
|
+
.getTextContent()
|
|
774
|
+
.replace(INLINE_SYNTAX, match => token + match);
|
|
775
|
+
// A `!` right before a link would make the link an image.
|
|
776
|
+
if (text.endsWith('!') && $isLinkNode(node.getNextSibling())) {
|
|
777
|
+
text = text.slice(0, -1) + token + '!';
|
|
778
|
+
}
|
|
779
|
+
// A `(` right after an image that ends in `]` would become its
|
|
780
|
+
// destination.
|
|
781
|
+
const before = node.getPreviousSibling();
|
|
782
|
+
if (
|
|
783
|
+
text.startsWith('(') &&
|
|
784
|
+
before != null &&
|
|
785
|
+
$isImageSource(before) &&
|
|
786
|
+
before.getTextContent().endsWith(']')
|
|
787
|
+
) {
|
|
788
|
+
text = token + text;
|
|
789
|
+
}
|
|
790
|
+
const previous = node.getPreviousSibling();
|
|
791
|
+
if (previous == null || $isLineBreakNode(previous)) {
|
|
792
|
+
const ordered = ORDERED_LIST_START.exec(text);
|
|
793
|
+
const start = LINE_START_SYNTAX.map(pattern => pattern.exec(text)).find(
|
|
794
|
+
match => match != null,
|
|
795
|
+
);
|
|
796
|
+
const before = ordered?.[1] ?? start?.[0];
|
|
797
|
+
if (before != null) {
|
|
798
|
+
text = before + token + text.slice(before.length);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const view = Object.create(node) as typeof node;
|
|
802
|
+
view.getTextContent = () => text;
|
|
803
|
+
return view;
|
|
804
|
+
}
|
|
805
|
+
if ($isElementNode(node) && !$isCodeNode(node)) {
|
|
806
|
+
// Read once: each read walks every child, so reading per child would
|
|
807
|
+
// cost the square of their number — a run of tabs is one node per tab.
|
|
808
|
+
const original = node.getChildren();
|
|
809
|
+
const children = original.map(child =>
|
|
810
|
+
markedView(child, token, placeholders),
|
|
811
|
+
);
|
|
812
|
+
// Each view's siblings are the views beside it, so a text view sees a
|
|
813
|
+
// plugin node's text view as text with formats, not as a gap.
|
|
814
|
+
children.forEach((child, index) => {
|
|
815
|
+
if (child !== original[index]) {
|
|
816
|
+
child.getPreviousSibling = <T extends LexicalNode>() =>
|
|
817
|
+
(children[index - 1] ?? null) as T | null;
|
|
818
|
+
child.getNextSibling = <T extends LexicalNode>() =>
|
|
819
|
+
(children[index + 1] ?? null) as T | null;
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
const view = Object.create(node) as typeof node;
|
|
823
|
+
view.getChildren = <T extends LexicalNode>() => children as Array<T>;
|
|
824
|
+
return view;
|
|
825
|
+
}
|
|
826
|
+
return node;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// A reference image's label at the end of its source: `[b]` in `![a][b]`.
|
|
830
|
+
const IMAGE_LABEL_END = /\]\[(?:[^\\\]]|\\.)*\]$/;
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* An image's source as an export writes it. A shortcut reference image
|
|
834
|
+
* (`![a]`) right before a link writes in its collapsed form (`![a][]`), so
|
|
835
|
+
* the link's text never reads as its label.
|
|
836
|
+
*/
|
|
837
|
+
function $imageSourceForExport(node: LexicalNode): string {
|
|
838
|
+
const source = node.getTextContent();
|
|
839
|
+
const isShortcut = source.endsWith(']') && !IMAGE_LABEL_END.test(source);
|
|
840
|
+
return isShortcut && $isLinkNode(node.getNextSibling())
|
|
841
|
+
? `${source}[]`
|
|
842
|
+
: source;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* A placeholder for each inline plugin node among `nodes`, absent from
|
|
847
|
+
* `text`, and the source each placeholder stands for.
|
|
848
|
+
*/
|
|
849
|
+
function extensionPlaceholders(
|
|
850
|
+
nodes: ReadonlyArray<LexicalNode>,
|
|
851
|
+
text: string,
|
|
852
|
+
): {
|
|
853
|
+
readonly placeholders: ReadonlyMap<NodeKey, string>;
|
|
854
|
+
readonly sources: ReadonlyMap<string, string>;
|
|
855
|
+
} {
|
|
856
|
+
const placeholders = new Map<NodeKey, string>();
|
|
857
|
+
const sources = new Map<string, string>();
|
|
858
|
+
const free = absentCharacters(text);
|
|
859
|
+
for (const node of nodes) {
|
|
860
|
+
const source =
|
|
861
|
+
$isRichTextExtensionNode(node) && node.isInline()
|
|
862
|
+
? node.getSource()
|
|
863
|
+
: $isImageSource(node)
|
|
864
|
+
? $imageSourceForExport(node)
|
|
865
|
+
: null;
|
|
866
|
+
if (source != null) {
|
|
867
|
+
const placeholder = free.next().value;
|
|
868
|
+
if (placeholder == null) {
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
placeholders.set(node.getKey(), placeholder);
|
|
872
|
+
sources.set(placeholder, source);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return {placeholders, sources};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The canonical Markdown of a changed group, with literal text escaped so it
|
|
880
|
+
* imports again as the structure the editor showed (spec:AST-062 FR3).
|
|
881
|
+
*/
|
|
882
|
+
function $regeneratedMarkdown(
|
|
883
|
+
nodes: ReadonlyArray<LexicalNode>,
|
|
884
|
+
canonical: string,
|
|
885
|
+
transformers: Array<Transformer>,
|
|
886
|
+
): string {
|
|
887
|
+
// The token is absent from the plain export and from every text the views
|
|
888
|
+
// mark, so each token in the marked export is one of the marks.
|
|
889
|
+
const text = canonical + nodes.map(node => node.getTextContent()).join('');
|
|
890
|
+
const token = absentToken(text);
|
|
891
|
+
// Inline plugin nodes export as placeholders inside the text around them,
|
|
892
|
+
// so the marks around a node wrap it; each placeholder becomes the node's
|
|
893
|
+
// exact source afterwards.
|
|
894
|
+
const {placeholders, sources} = extensionPlaceholders(
|
|
895
|
+
descendantsOf(nodes),
|
|
896
|
+
text + token,
|
|
897
|
+
);
|
|
898
|
+
let marked = $convertToMarkdownKeepingTimeLinear(
|
|
899
|
+
withExtensionExport(transformers),
|
|
900
|
+
childrenOf(nodes.map(node => markedView(node, token, placeholders))),
|
|
901
|
+
)
|
|
902
|
+
.split(token)
|
|
903
|
+
.join('\\');
|
|
904
|
+
for (const [placeholder, source] of sources) {
|
|
905
|
+
marked = marked.split(placeholder).join(source);
|
|
906
|
+
}
|
|
907
|
+
return marked;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
interface ExportPiece {
|
|
911
|
+
readonly text: string;
|
|
912
|
+
/** Recorded bytes after the piece, when they belong to this document. */
|
|
913
|
+
readonly trailing: string | null;
|
|
914
|
+
/** Whether the piece is the group's recorded source, unchanged. */
|
|
915
|
+
readonly isAsWritten: boolean;
|
|
916
|
+
/** The piece's group, and the group that followed it in the source. */
|
|
917
|
+
readonly group: string | null;
|
|
918
|
+
readonly next: string | null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const ENDS_WITH_BLANK_LINE = /\n[ \t]*\r?\n[ \t\r]*$/;
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Exports the root per spec:AST-062: unchanged groups as authored, changed and
|
|
925
|
+
* new blocks in canonical form, all in the document's envelope. Reads the tree
|
|
926
|
+
* without changing it, so it runs in a read of the live editor state.
|
|
927
|
+
*/
|
|
928
|
+
/** Whether `text` is one thematic break line, as written. */
|
|
929
|
+
function isRuleLine(text: string): boolean {
|
|
930
|
+
return !text.includes('\n') && THEMATIC_BREAK_LINE.test(text);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Whether the bytes recorded after `piece` still separate it from `next`.
|
|
935
|
+
* Bytes ending in a blank line separate any two blocks. A single line break
|
|
936
|
+
* only follows or precedes a thematic break (a rule is a chunk of its own),
|
|
937
|
+
* and it belongs to the block it follows:
|
|
938
|
+
*
|
|
939
|
+
* - after a rule as written, it stays whatever comes next, because no line
|
|
940
|
+
* after a rule can join it;
|
|
941
|
+
* - between the same two blocks, both as written, it stays;
|
|
942
|
+
* - after a changed block, it stays only above the same rule as written when
|
|
943
|
+
* that rule cannot underline the block as a heading (`***`, `___`, or
|
|
944
|
+
* spaced dashes, not `---`).
|
|
945
|
+
*
|
|
946
|
+
* Everywhere else — next to a moved, inserted, deleted, or changed block — a
|
|
947
|
+
* blank line separates the two, so no block can join its neighbor.
|
|
948
|
+
*/
|
|
949
|
+
function keepsRecordedTrailing(piece: ExportPiece, next: ExportPiece): boolean {
|
|
950
|
+
// The last block's recorded trailing is empty: it separates nothing.
|
|
951
|
+
if (piece.trailing == null || !piece.trailing.includes('\n')) {
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
if (ENDS_WITH_BLANK_LINE.test(piece.trailing)) {
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
if (piece.isAsWritten && isRuleLine(piece.text)) {
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
if (piece.next == null || piece.next !== next.group || !next.isAsWritten) {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
return (
|
|
964
|
+
piece.isAsWritten ||
|
|
965
|
+
(isRuleLine(next.text) && !SETEXT_DASHES.test(next.text))
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export function $exportMarkdownKeepingSource(
|
|
970
|
+
transformers: ReadonlyArray<Transformer>,
|
|
971
|
+
): string {
|
|
972
|
+
const transformerList = transformers as Array<Transformer>;
|
|
973
|
+
const root = $getRoot();
|
|
974
|
+
const document = $getState(root, documentState);
|
|
975
|
+
const lineEnding = document?.lineEnding ?? '\n';
|
|
976
|
+
const children = root.getChildren();
|
|
977
|
+
const pieces: Array<ExportPiece> = [];
|
|
978
|
+
let index = 0;
|
|
979
|
+
while (index < children.length) {
|
|
980
|
+
const first = children[index];
|
|
981
|
+
const record = $getState(first, groupState);
|
|
982
|
+
const group: Array<LexicalNode> = [first];
|
|
983
|
+
index++;
|
|
984
|
+
if (record?.content != null) {
|
|
985
|
+
while (
|
|
986
|
+
index < children.length &&
|
|
987
|
+
$getState(children[index], groupState)?.group === record.group &&
|
|
988
|
+
$getState(children[index], groupState)?.content == null
|
|
989
|
+
) {
|
|
990
|
+
group.push(children[index]);
|
|
991
|
+
index++;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const canonical = $canonicalMarkdown(group, transformerList);
|
|
995
|
+
// A group imported into another document keeps its content but takes this
|
|
996
|
+
// document's line ending and separators.
|
|
997
|
+
const isOwn =
|
|
998
|
+
document != null &&
|
|
999
|
+
record?.group.startsWith(`${document.importId}:`) === true;
|
|
1000
|
+
if (
|
|
1001
|
+
record?.content != null &&
|
|
1002
|
+
group.length === record.size &&
|
|
1003
|
+
canonical.hash === record.canonicalHash
|
|
1004
|
+
) {
|
|
1005
|
+
pieces.push({
|
|
1006
|
+
text: isOwn
|
|
1007
|
+
? record.content
|
|
1008
|
+
: withLineEnding(record.content, lineEnding),
|
|
1009
|
+
trailing: isOwn ? (record.trailing ?? null) : null,
|
|
1010
|
+
isAsWritten: isOwn,
|
|
1011
|
+
group: record.group,
|
|
1012
|
+
next: record.next ?? null,
|
|
1013
|
+
});
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const regenerated = $regeneratedMarkdown(
|
|
1017
|
+
group,
|
|
1018
|
+
canonical.markdown,
|
|
1019
|
+
transformerList,
|
|
1020
|
+
);
|
|
1021
|
+
pieces.push({
|
|
1022
|
+
text: withLineEnding(
|
|
1023
|
+
regenerated,
|
|
1024
|
+
isOwn && record?.lineEnding != null ? record.lineEnding : lineEnding,
|
|
1025
|
+
),
|
|
1026
|
+
trailing: isOwn ? (record?.trailing ?? null) : null,
|
|
1027
|
+
isAsWritten: false,
|
|
1028
|
+
group: record?.group ?? null,
|
|
1029
|
+
next: isOwn ? (record?.next ?? null) : null,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
const separator = lineEnding + lineEnding;
|
|
1033
|
+
let output =
|
|
1034
|
+
(document?.byteOrderMark === true ? '\uFEFF' : '') +
|
|
1035
|
+
(document?.leading ?? '');
|
|
1036
|
+
pieces.forEach((piece, position) => {
|
|
1037
|
+
output += piece.text;
|
|
1038
|
+
if (position < pieces.length - 1) {
|
|
1039
|
+
output += keepsRecordedTrailing(piece, pieces[position + 1])
|
|
1040
|
+
? (piece.trailing ?? '')
|
|
1041
|
+
: separator;
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
return output + (document?.trailing ?? '');
|
|
1045
|
+
}
|