@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,216 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownCodeFence.ts
|
|
5
|
+
* @input Uses @lexical/markdown (CODE) and @lexical/code.
|
|
6
|
+
* @output Exports BACKTICK_CODE and TILDE_CODE, the transformers for fenced
|
|
7
|
+
* code blocks opened with backticks and with tildes.
|
|
8
|
+
* @position Part of DEFAULT_TRANSFORMERS (markdownTable.ts): BACKTICK_CODE
|
|
9
|
+
* stands in for Lexical's CODE, and TILDE_CODE reads the `~~~` fences CODE
|
|
10
|
+
* does not (CommonMark 0.31 §4.5, spec:AST-061 FR5). Both read the opening
|
|
11
|
+
* line as core Markdown does: the rest of the line after the fence is the
|
|
12
|
+
* info string, never code; the language is its first word, after any
|
|
13
|
+
* spaces (so `~~~ c++` reads `c++`), as core and CommonMark read it. A
|
|
14
|
+
* block closes at
|
|
15
|
+
* a fence of its own character at least as long, or at the end of the
|
|
16
|
+
* document, and its code is exactly the lines between, blank lines
|
|
17
|
+
* included, each less as much indentation as the opening fence has (up to
|
|
18
|
+
* three spaces), as core reads it. Each keeps its fence and info
|
|
19
|
+
* string (spec:AST-062): an edited block exports them as written, with its
|
|
20
|
+
* language first when that was changed, and its fence lengthened when its
|
|
21
|
+
* code holds a run of the fence's character that long.
|
|
22
|
+
* A one-line backtick block (```` ```code``` ````) stays Lexical's.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {$createCodeNode, $isCodeNode} from '@lexical/code';
|
|
26
|
+
import {
|
|
27
|
+
CODE,
|
|
28
|
+
type ElementTransformer,
|
|
29
|
+
type MultilineElementTransformer,
|
|
30
|
+
} from '@lexical/markdown';
|
|
31
|
+
import {
|
|
32
|
+
$createTextNode,
|
|
33
|
+
$getState,
|
|
34
|
+
$setState,
|
|
35
|
+
createState,
|
|
36
|
+
type ElementNode,
|
|
37
|
+
} from 'lexical';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The tilde fence a code block was opened with; null for a backtick block.
|
|
41
|
+
* Lexical's own fence state holds only backtick fences.
|
|
42
|
+
*/
|
|
43
|
+
const tildeFence = createState('astryxTildeFence', {
|
|
44
|
+
parse: (value: unknown): string | null =>
|
|
45
|
+
typeof value === 'string' && /^~{3,}$/.test(value) ? value : null,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The backtick fence a code block was opened with, when longer than three:
|
|
50
|
+
* Lexical's own fence state is set only by its transformer, which this file
|
|
51
|
+
* does not use to build blocks.
|
|
52
|
+
*/
|
|
53
|
+
const backtickFence = createState('astryxBacktickFence', {
|
|
54
|
+
parse: (value: unknown): string | null =>
|
|
55
|
+
typeof value === 'string' && /^`{4,}$/.test(value) ? value : null,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The info string a code block's opening fence carried, as written. */
|
|
59
|
+
const fenceInfo = createState('astryxFenceInfo', {
|
|
60
|
+
parse: (value: unknown): string | null =>
|
|
61
|
+
typeof value === 'string' && value !== '' ? value : null,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The language core Markdown reads from an info string, or null. */
|
|
65
|
+
function languageOf(info: string): string | null {
|
|
66
|
+
return /^\S+/.exec(info.trimStart())?.[0] ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An opening fence line: its indentation, its fence, and the info string. */
|
|
70
|
+
interface Opening {
|
|
71
|
+
readonly indentation: number;
|
|
72
|
+
readonly fence: string;
|
|
73
|
+
readonly info: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads an opening fence line: up to three spaces of indentation, then the
|
|
78
|
+
* fence (CommonMark 0.31 §4.5), as core Markdown reads it.
|
|
79
|
+
*/
|
|
80
|
+
function readOpening(line: string): Opening | null {
|
|
81
|
+
const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
|
|
82
|
+
if (match == null) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const [, indentation, fence, info] = match;
|
|
86
|
+
// A backtick fence's info string holds no backtick.
|
|
87
|
+
return fence.startsWith('`') && info.includes('`')
|
|
88
|
+
? null
|
|
89
|
+
: {indentation: indentation.length, fence, info};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The longest run of three or more of `character` in `text`, or 0. */
|
|
93
|
+
function longestFenceRun(text: string, character: '`' | '~'): number {
|
|
94
|
+
return Math.max(
|
|
95
|
+
0,
|
|
96
|
+
...(text.match(character === '`' ? /`{3,}/g : /~{3,}/g) ?? []).map(
|
|
97
|
+
run => run.length,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds the code block an opening line starts, from the lines after it up
|
|
104
|
+
* to its closing fence, and returns the index of the last line it used.
|
|
105
|
+
*/
|
|
106
|
+
function $importFencedCode(
|
|
107
|
+
rootNode: ElementNode,
|
|
108
|
+
lines: ReadonlyArray<string>,
|
|
109
|
+
startLineIndex: number,
|
|
110
|
+
opening: Opening,
|
|
111
|
+
): number {
|
|
112
|
+
const {indentation, fence, info} = opening;
|
|
113
|
+
const closing = new RegExp(
|
|
114
|
+
`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`,
|
|
115
|
+
);
|
|
116
|
+
// Each code line loses as much indentation as the opening fence has.
|
|
117
|
+
const fenceIndentation = new RegExp(`^ {0,${indentation}}`);
|
|
118
|
+
let end = startLineIndex + 1;
|
|
119
|
+
while (end < lines.length && !closing.test(lines[end])) {
|
|
120
|
+
end++;
|
|
121
|
+
}
|
|
122
|
+
// A fence left open runs to the end of the document, whose final line
|
|
123
|
+
// ending starts no line of code (CommonMark 0.31 §4.5), as core reads it.
|
|
124
|
+
const codeEnd =
|
|
125
|
+
end === lines.length && end > startLineIndex + 1 && lines[end - 1] === ''
|
|
126
|
+
? end - 1
|
|
127
|
+
: end;
|
|
128
|
+
// The code is the lines between the fences, exactly: Lexical's own
|
|
129
|
+
// transformer trims a space from the first line and drops blank first and
|
|
130
|
+
// last lines, adjustments meant for its own reading of the opening line.
|
|
131
|
+
const block = $createCodeNode(languageOf(info) ?? undefined);
|
|
132
|
+
block.append(
|
|
133
|
+
$createTextNode(
|
|
134
|
+
lines
|
|
135
|
+
.slice(startLineIndex + 1, codeEnd)
|
|
136
|
+
.map(line => line.replace(fenceIndentation, ''))
|
|
137
|
+
.join('\n'),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
rootNode.append(block);
|
|
141
|
+
if (fence.startsWith('~')) {
|
|
142
|
+
$setState(block, tildeFence, fence);
|
|
143
|
+
} else if (fence.length > 3) {
|
|
144
|
+
$setState(block, backtickFence, fence);
|
|
145
|
+
}
|
|
146
|
+
$setState(block, fenceInfo, info.trimEnd());
|
|
147
|
+
return Math.min(end, lines.length - 1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Writes a code block that keeps its fence or info string; null otherwise. */
|
|
151
|
+
const exportFencedCode: ElementTransformer['export'] = node => {
|
|
152
|
+
if (!$isCodeNode(node)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const tilde = $getState(node, tildeFence);
|
|
156
|
+
const backticks = $getState(node, backtickFence);
|
|
157
|
+
const info = $getState(node, fenceInfo);
|
|
158
|
+
if (tilde == null && backticks == null && info == null) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const text = node.getTextContent();
|
|
162
|
+
const language = node.getLanguage() ?? '';
|
|
163
|
+
let written = info ?? '';
|
|
164
|
+
if ((languageOf(written) ?? '') !== language) {
|
|
165
|
+
// The language changed: it leads, before the rest of the info string.
|
|
166
|
+
const previous = languageOf(written);
|
|
167
|
+
const rest =
|
|
168
|
+
previous == null ? '' : written.trimStart().slice(previous.length).trim();
|
|
169
|
+
written = rest === '' ? language : `${language} ${rest}`;
|
|
170
|
+
}
|
|
171
|
+
// A fence outlasts any run of its character in the code.
|
|
172
|
+
const fence =
|
|
173
|
+
tilde != null
|
|
174
|
+
? '~'.repeat(Math.max(tilde.length, longestFenceRun(text, '~') + 1))
|
|
175
|
+
: '`'.repeat(
|
|
176
|
+
Math.max((backticks ?? '```').length, longestFenceRun(text, '`') + 1),
|
|
177
|
+
);
|
|
178
|
+
return `${fence}${written}${text === '' ? '' : `\n${text}`}\n${fence}`;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const BACKTICK_CODE: MultilineElementTransformer = {
|
|
182
|
+
...CODE,
|
|
183
|
+
handleImportAfterStartMatch: args => {
|
|
184
|
+
const opening = readOpening(args.lines[args.startLineIndex]);
|
|
185
|
+
if (opening == null) {
|
|
186
|
+
return CODE.handleImportAfterStartMatch?.(args) ?? null;
|
|
187
|
+
}
|
|
188
|
+
return [
|
|
189
|
+
true,
|
|
190
|
+
$importFencedCode(
|
|
191
|
+
args.rootNode,
|
|
192
|
+
args.lines,
|
|
193
|
+
args.startLineIndex,
|
|
194
|
+
opening,
|
|
195
|
+
),
|
|
196
|
+
];
|
|
197
|
+
},
|
|
198
|
+
export: (node, exportChildren) =>
|
|
199
|
+
exportFencedCode(node, exportChildren) ??
|
|
200
|
+
CODE.export?.(node, exportChildren) ??
|
|
201
|
+
null,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const TILDE_CODE: MultilineElementTransformer = {
|
|
205
|
+
...CODE,
|
|
206
|
+
regExpStart: /^( {0,3}~{3,})(.*)$/,
|
|
207
|
+
regExpEnd: {optional: true, regExp: /^ {0,3}~{3,}[ \t]*$/},
|
|
208
|
+
handleImportAfterStartMatch: ({lines, rootNode, startLineIndex}) => {
|
|
209
|
+
const opening = readOpening(lines[startLineIndex]);
|
|
210
|
+
return opening == null
|
|
211
|
+
? null
|
|
212
|
+
: [true, $importFencedCode(rootNode, lines, startLineIndex, opening)];
|
|
213
|
+
},
|
|
214
|
+
export: (node, exportChildren) =>
|
|
215
|
+
exportFencedCode(node, exportChildren) ?? null,
|
|
216
|
+
};
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownExtensionNode.ts
|
|
5
|
+
* @input Uses lexical (DecoratorNode, node serialization, text formats).
|
|
6
|
+
* @output Exports RichTextExtensionNode, the editor node for one Markdown
|
|
7
|
+
* plugin node, with $createRichTextExtensionNode and
|
|
8
|
+
* $isRichTextExtensionNode.
|
|
9
|
+
* @position Registered with every RichText surface and serializer
|
|
10
|
+
* (editorNodes.ts), so stored state holding plugin nodes always loads. A
|
|
11
|
+
* node is atomic — the editor cannot type into it — and keeps the exact
|
|
12
|
+
* source it was read from, which is what it exports (spec:AST-064 FR7,
|
|
13
|
+
* FR10). Stored state keeps that source, the plugin's name and protocol
|
|
14
|
+
* version, the data the plugin derived for rendering (FR11), and the text
|
|
15
|
+
* formats the node sits inside, so a node inside emphasis, strong, or
|
|
16
|
+
* strikethrough stays inside it. Imports no React: the client surfaces
|
|
17
|
+
* register how an editor draws plugin nodes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
$isNodeSelection,
|
|
22
|
+
$isTextNode,
|
|
23
|
+
DecoratorNode,
|
|
24
|
+
TEXT_TYPE_TO_FORMAT,
|
|
25
|
+
type DOMConversionMap,
|
|
26
|
+
type DOMConversionOutput,
|
|
27
|
+
type DOMExportOutput,
|
|
28
|
+
type EditorConfig,
|
|
29
|
+
type LexicalEditor,
|
|
30
|
+
type LexicalNode,
|
|
31
|
+
type NodeKey,
|
|
32
|
+
type NodeSelection,
|
|
33
|
+
type RangeSelection,
|
|
34
|
+
type TextNode,
|
|
35
|
+
type SerializedLexicalNode,
|
|
36
|
+
type Spread,
|
|
37
|
+
type TextFormatType,
|
|
38
|
+
} from 'lexical';
|
|
39
|
+
import type {MarkdownPluginData} from '@blue-patterns/core/Markdown/plugins';
|
|
40
|
+
|
|
41
|
+
/** What one plugin node is: the parsed node's identity, data, and source. */
|
|
42
|
+
export interface RichTextExtensionNodeFacts {
|
|
43
|
+
readonly plugin: string;
|
|
44
|
+
readonly apiVersion: number;
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly display: 'inline' | 'block';
|
|
47
|
+
readonly data: MarkdownPluginData;
|
|
48
|
+
readonly source: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SerializedRichTextExtensionNode = Spread<
|
|
52
|
+
RichTextExtensionNodeFacts & {
|
|
53
|
+
/** The text formats the node sits inside, as a text node's format bits. */
|
|
54
|
+
readonly format: number;
|
|
55
|
+
},
|
|
56
|
+
SerializedLexicalNode
|
|
57
|
+
>;
|
|
58
|
+
|
|
59
|
+
/** Reads stored facts defensively: a missing field becomes an empty value. */
|
|
60
|
+
export function factsFrom(
|
|
61
|
+
value: Record<string, unknown>,
|
|
62
|
+
): RichTextExtensionNodeFacts {
|
|
63
|
+
const {plugin, apiVersion, name, display, data, source} = value;
|
|
64
|
+
return {
|
|
65
|
+
plugin: typeof plugin === 'string' ? plugin : '',
|
|
66
|
+
apiVersion: typeof apiVersion === 'number' ? apiVersion : 0,
|
|
67
|
+
name: typeof name === 'string' ? name : '',
|
|
68
|
+
display: display === 'block' ? 'block' : 'inline',
|
|
69
|
+
data: (data ?? null) as MarkdownPluginData,
|
|
70
|
+
source: typeof source === 'string' ? source : '',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Draws a plugin node's content in one editor, inside the text formats the
|
|
76
|
+
* node sits in; `nodeKey` lets it select the node. The client surfaces
|
|
77
|
+
* register one (MarkdownExtensionsPlugin); without one, a node shows its
|
|
78
|
+
* source.
|
|
79
|
+
*/
|
|
80
|
+
export type RichTextExtensionNodeDecorator = (
|
|
81
|
+
facts: RichTextExtensionNodeFacts,
|
|
82
|
+
format: number,
|
|
83
|
+
nodeKey: NodeKey,
|
|
84
|
+
) => unknown;
|
|
85
|
+
|
|
86
|
+
const decorators = new WeakMap<LexicalEditor, RichTextExtensionNodeDecorator>();
|
|
87
|
+
|
|
88
|
+
/** Sets how `editor` draws plugin nodes; returns a function that unsets it. */
|
|
89
|
+
export function setRichTextExtensionNodeDecorator(
|
|
90
|
+
editor: LexicalEditor,
|
|
91
|
+
decorator: RichTextExtensionNodeDecorator,
|
|
92
|
+
): () => void {
|
|
93
|
+
decorators.set(editor, decorator);
|
|
94
|
+
return () => {
|
|
95
|
+
if (decorators.get(editor) === decorator) {
|
|
96
|
+
decorators.delete(editor);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The attribute that carries a node's facts and formats in its element, so
|
|
103
|
+
* copying it as HTML — the browser's own copy, or the editor's HTML export —
|
|
104
|
+
* and pasting it into any RichText editor brings back the same node with the
|
|
105
|
+
* same source and marks.
|
|
106
|
+
*/
|
|
107
|
+
const FACTS_ATTRIBUTE = 'data-markdown-extension';
|
|
108
|
+
|
|
109
|
+
function factsAttribute(node: RichTextExtensionNode): string {
|
|
110
|
+
return JSON.stringify({...node.__facts, format: node.__format});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function convertExtensionElement(element: HTMLElement): DOMConversionOutput {
|
|
114
|
+
let node: RichTextExtensionNode | null = null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed: unknown = JSON.parse(
|
|
117
|
+
element.getAttribute(FACTS_ATTRIBUTE) ?? '',
|
|
118
|
+
);
|
|
119
|
+
if (parsed != null && typeof parsed === 'object') {
|
|
120
|
+
const value = parsed as Record<string, unknown>;
|
|
121
|
+
node = $createRichTextExtensionNode(
|
|
122
|
+
factsFrom(value),
|
|
123
|
+
typeof value.format === 'number' ? value.format : 0,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
node = null;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
node,
|
|
131
|
+
// The element's content is the node's rendering, not more document.
|
|
132
|
+
forChild: () => null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const importExtensionElement = (element: HTMLElement) =>
|
|
137
|
+
element.hasAttribute(FACTS_ATTRIBUTE)
|
|
138
|
+
? {conversion: convertExtensionElement, priority: 4 as const}
|
|
139
|
+
: null;
|
|
140
|
+
|
|
141
|
+
export class RichTextExtensionNode extends DecoratorNode<unknown> {
|
|
142
|
+
__facts: RichTextExtensionNodeFacts;
|
|
143
|
+
/**
|
|
144
|
+
* The text formats the node sits inside, as a text node's format bits. Only
|
|
145
|
+
* inline nodes carry any.
|
|
146
|
+
*/
|
|
147
|
+
__format: number;
|
|
148
|
+
|
|
149
|
+
static getType(): string {
|
|
150
|
+
return 'astryx-markdown-extension';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
static clone(node: RichTextExtensionNode): RichTextExtensionNode {
|
|
154
|
+
return new RichTextExtensionNode(node.__facts, node.__format, node.__key);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
static importJSON(
|
|
158
|
+
serialized: SerializedLexicalNode & Record<string, unknown>,
|
|
159
|
+
): RichTextExtensionNode {
|
|
160
|
+
// Stored state is read defensively, so a damaged node still loads.
|
|
161
|
+
const {format} = serialized;
|
|
162
|
+
return $createRichTextExtensionNode(
|
|
163
|
+
factsFrom(serialized),
|
|
164
|
+
typeof format === 'number' ? format : 0,
|
|
165
|
+
).updateFromJSON(serialized);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static importDOM(): DOMConversionMap {
|
|
169
|
+
return {span: importExtensionElement, div: importExtensionElement};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The Yjs binding and `$create` call the constructor with no arguments.
|
|
173
|
+
// The binding reads each class's default properties from that node.
|
|
174
|
+
// With no facts, the node gets the values that damaged stored facts get.
|
|
175
|
+
constructor(
|
|
176
|
+
facts: RichTextExtensionNodeFacts = factsFrom({}),
|
|
177
|
+
format = 0,
|
|
178
|
+
key?: NodeKey,
|
|
179
|
+
) {
|
|
180
|
+
super(key);
|
|
181
|
+
this.__facts = facts;
|
|
182
|
+
this.__format = facts.display === 'inline' ? format : 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
exportJSON(): SerializedRichTextExtensionNode {
|
|
186
|
+
const latest = this.getLatest();
|
|
187
|
+
return {
|
|
188
|
+
...super.exportJSON(),
|
|
189
|
+
...latest.__facts,
|
|
190
|
+
format: latest.__format,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The node's identity, data, and source. */
|
|
195
|
+
getFacts(): RichTextExtensionNodeFacts {
|
|
196
|
+
return this.getLatest().__facts;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The exact Markdown the node was read from. */
|
|
200
|
+
getSource(): string {
|
|
201
|
+
return this.getLatest().__facts.source;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The text formats the node sits inside, as a text node's format bits. */
|
|
205
|
+
getFormat(): number {
|
|
206
|
+
return this.getLatest().__format;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
hasFormat(type: TextFormatType): boolean {
|
|
210
|
+
return (this.getFormat() & TEXT_TYPE_TO_FORMAT[type]) !== 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Puts the node inside, or takes it out of, one text format. */
|
|
214
|
+
setFormatFlag(type: TextFormatType, isOn: boolean): this {
|
|
215
|
+
const writable = this.getWritable();
|
|
216
|
+
const flag = TEXT_TYPE_TO_FORMAT[type];
|
|
217
|
+
writable.__format = isOn
|
|
218
|
+
? writable.__format | flag
|
|
219
|
+
: writable.__format & ~flag;
|
|
220
|
+
return writable;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
createDOM(config: EditorConfig): HTMLElement {
|
|
224
|
+
const element = document.createElement(
|
|
225
|
+
this.__facts.display === 'inline' ? 'span' : 'div',
|
|
226
|
+
);
|
|
227
|
+
element.setAttribute(FACTS_ATTRIBUTE, factsAttribute(this));
|
|
228
|
+
if (this.__facts.display === 'block') {
|
|
229
|
+
// A block node is spaced and measured as Markdown spaces its block.
|
|
230
|
+
const className: unknown = config.theme.markdownExtensionBlock;
|
|
231
|
+
if (typeof className === 'string' && className !== '') {
|
|
232
|
+
element.className = className;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return element;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
exportDOM(): DOMExportOutput {
|
|
239
|
+
const element = document.createElement(
|
|
240
|
+
this.__facts.display === 'inline' ? 'span' : 'div',
|
|
241
|
+
);
|
|
242
|
+
element.setAttribute(FACTS_ATTRIBUTE, factsAttribute(this));
|
|
243
|
+
element.textContent = this.__facts.source;
|
|
244
|
+
return {element};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
updateDOM(prevNode: RichTextExtensionNode, dom: HTMLElement): false {
|
|
248
|
+
// A mark toggled on the node changes what its element carries.
|
|
249
|
+
if (prevNode.__format !== this.__format) {
|
|
250
|
+
dom.setAttribute(FACTS_ATTRIBUTE, factsAttribute(this));
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
isInline(): boolean {
|
|
256
|
+
return this.__facts.display === 'inline';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The node's text is its source, so plain-text copies and search see it. */
|
|
260
|
+
getTextContent(): string {
|
|
261
|
+
return this.getLatest().__facts.source;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
decorate(editor: LexicalEditor): unknown {
|
|
265
|
+
return (
|
|
266
|
+
decorators.get(editor)?.(this.__facts, this.__format, this.__key) ??
|
|
267
|
+
this.__facts.source
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function $createRichTextExtensionNode(
|
|
273
|
+
facts: RichTextExtensionNodeFacts,
|
|
274
|
+
format = 0,
|
|
275
|
+
): RichTextExtensionNode {
|
|
276
|
+
return new RichTextExtensionNode(facts, format);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function $isRichTextExtensionNode(
|
|
280
|
+
node: LexicalNode | null | undefined,
|
|
281
|
+
): node is RichTextExtensionNode {
|
|
282
|
+
return node instanceof RichTextExtensionNode;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The first text node a range selects characters of, as Lexical's formatText
|
|
287
|
+
* reads it: a range that starts at the end of a text node starts in the next.
|
|
288
|
+
* Null when the range selects no text, only nodes.
|
|
289
|
+
*/
|
|
290
|
+
export function $firstSelectedText(selection: RangeSelection): TextNode | null {
|
|
291
|
+
const texts = selection.getNodes().filter($isTextNode);
|
|
292
|
+
const isBackward = selection.isBackward();
|
|
293
|
+
const start = isBackward ? selection.focus : selection.anchor;
|
|
294
|
+
const end = isBackward ? selection.anchor : selection.focus;
|
|
295
|
+
let first: TextNode | undefined = texts[0];
|
|
296
|
+
let startOffset = start.type === 'element' ? 0 : start.offset;
|
|
297
|
+
if (
|
|
298
|
+
first != null &&
|
|
299
|
+
start.type === 'text' &&
|
|
300
|
+
startOffset === first.getTextContentSize()
|
|
301
|
+
) {
|
|
302
|
+
first = texts[1];
|
|
303
|
+
startOffset = 0;
|
|
304
|
+
}
|
|
305
|
+
const last = texts[texts.length - 1];
|
|
306
|
+
if (first == null || last == null) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const endOffset =
|
|
310
|
+
end.type === 'text' ? end.offset : last.getTextContentSize();
|
|
311
|
+
return first.is(last) && startOffset === endOffset ? null : first;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The inline plugin nodes a selection holds when it holds nothing else: a
|
|
316
|
+
* range that selects no text, only nodes, or a node selection of plugin
|
|
317
|
+
* nodes alone. Null otherwise. A format shown or toggled then belongs to
|
|
318
|
+
* them.
|
|
319
|
+
*/
|
|
320
|
+
export function $selectedExtensionNodesOnly(
|
|
321
|
+
selection: RangeSelection | NodeSelection,
|
|
322
|
+
): RichTextExtensionNode[] | null {
|
|
323
|
+
if ($isNodeSelection(selection)) {
|
|
324
|
+
const selected = selection.getNodes();
|
|
325
|
+
return selected.length > 0 &&
|
|
326
|
+
selected.every(node => $isRichTextExtensionNode(node) && node.isInline())
|
|
327
|
+
? (selected as RichTextExtensionNode[])
|
|
328
|
+
: null;
|
|
329
|
+
}
|
|
330
|
+
const nodes = selection
|
|
331
|
+
.getNodes()
|
|
332
|
+
.filter(
|
|
333
|
+
(node): node is RichTextExtensionNode =>
|
|
334
|
+
$isRichTextExtensionNode(node) && node.isInline(),
|
|
335
|
+
);
|
|
336
|
+
return nodes.length > 0 && $firstSelectedText(selection) == null
|
|
337
|
+
? nodes
|
|
338
|
+
: null;
|
|
339
|
+
}
|