@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/LICENSE +22 -0
  3. package/README.md +88 -2
  4. package/astryx.integration.mjs +11 -0
  5. package/blocks/RichTextEditorShowcase.template.mjs +13 -0
  6. package/blocks/RichTextEditorShowcase.tsx +19 -0
  7. package/dist/RichTextEditor.d.ts +282 -0
  8. package/dist/RichTextEditor.d.ts.map +1 -0
  9. package/dist/RichTextEditor.js +657 -0
  10. package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
  11. package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
  12. package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
  13. package/dist/RichTextEditorToolbar.d.ts +186 -0
  14. package/dist/RichTextEditorToolbar.d.ts.map +1 -0
  15. package/dist/RichTextEditorToolbar.js +688 -0
  16. package/dist/RichTextView.d.ts +92 -0
  17. package/dist/RichTextView.d.ts.map +1 -0
  18. package/dist/RichTextView.js +267 -0
  19. package/dist/codeLanguages.d.ts +14 -0
  20. package/dist/codeLanguages.d.ts.map +1 -0
  21. package/dist/codeLanguages.js +34 -0
  22. package/dist/codeLineTokens.d.ts +53 -0
  23. package/dist/codeLineTokens.d.ts.map +1 -0
  24. package/dist/codeLineTokens.js +188 -0
  25. package/dist/editorNodes.d.ts +8 -0
  26. package/dist/editorNodes.d.ts.map +1 -0
  27. package/dist/editorNodes.js +30 -0
  28. package/dist/editorTheme.d.ts +7 -0
  29. package/dist/editorTheme.d.ts.map +1 -0
  30. package/dist/editorTheme.js +328 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +28 -0
  34. package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
  35. package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
  36. package/dist/internal/CodeBackspacePlugin.js +70 -0
  37. package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
  38. package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
  39. package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
  40. package/dist/internal/CodePastePlugin.d.ts +5 -0
  41. package/dist/internal/CodePastePlugin.d.ts.map +1 -0
  42. package/dist/internal/CodePastePlugin.js +104 -0
  43. package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
  44. package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
  45. package/dist/internal/CodeSyntaxPlugin.js +397 -0
  46. package/dist/internal/CodeTabPlugin.d.ts +2 -0
  47. package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
  48. package/dist/internal/CodeTabPlugin.js +256 -0
  49. package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
  50. package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
  51. package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
  52. package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
  53. package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
  54. package/dist/internal/ImageSourceCaretPlugin.js +105 -0
  55. package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
  56. package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
  57. package/dist/internal/ListItemShortcutsPlugin.js +314 -0
  58. package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
  59. package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
  60. package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
  61. package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
  62. package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
  63. package/dist/internal/SelectAllScopePlugin.js +67 -0
  64. package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
  65. package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
  66. package/dist/internal/TableColumnFloorPlugin.js +84 -0
  67. package/dist/internal/TablePastePlugin.d.ts +9 -0
  68. package/dist/internal/TablePastePlugin.d.ts.map +1 -0
  69. package/dist/internal/TablePastePlugin.js +183 -0
  70. package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
  71. package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
  72. package/dist/internal/TableScrollRegionPlugin.js +95 -0
  73. package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
  74. package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
  75. package/dist/internal/TaskCheckboxPlugin.js +193 -0
  76. package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
  77. package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
  78. package/dist/internal/ToolbarLinkPopover.js +292 -0
  79. package/dist/linkEditing.d.ts +6 -0
  80. package/dist/linkEditing.d.ts.map +1 -0
  81. package/dist/linkEditing.js +20 -0
  82. package/dist/linkForm.d.ts +28 -0
  83. package/dist/linkForm.d.ts.map +1 -0
  84. package/dist/linkForm.js +137 -0
  85. package/dist/linkUtils.d.ts +27 -0
  86. package/dist/linkUtils.d.ts.map +1 -0
  87. package/dist/linkUtils.js +101 -0
  88. package/dist/markdown.d.ts +17 -0
  89. package/dist/markdown.d.ts.map +1 -0
  90. package/dist/markdown.js +17 -0
  91. package/dist/markdownCharacterReferences.d.ts +135 -0
  92. package/dist/markdownCharacterReferences.d.ts.map +1 -0
  93. package/dist/markdownCharacterReferences.js +1127 -0
  94. package/dist/markdownCodeFence.d.ts +4 -0
  95. package/dist/markdownCodeFence.d.ts.map +1 -0
  96. package/dist/markdownCodeFence.js +167 -0
  97. package/dist/markdownExtensionNode.d.ts +88 -0
  98. package/dist/markdownExtensionNode.d.ts.map +1 -0
  99. package/dist/markdownExtensionNode.js +248 -0
  100. package/dist/markdownExtensions.d.ts +115 -0
  101. package/dist/markdownExtensions.d.ts.map +1 -0
  102. package/dist/markdownExtensions.js +392 -0
  103. package/dist/markdownHardLineBreak.d.ts +25 -0
  104. package/dist/markdownHardLineBreak.d.ts.map +1 -0
  105. package/dist/markdownHardLineBreak.js +59 -0
  106. package/dist/markdownHeading.d.ts +20 -0
  107. package/dist/markdownHeading.d.ts.map +1 -0
  108. package/dist/markdownHeading.js +30 -0
  109. package/dist/markdownLink.d.ts +5 -0
  110. package/dist/markdownLink.d.ts.map +1 -0
  111. package/dist/markdownLink.js +80 -0
  112. package/dist/markdownListExport.d.ts +3 -0
  113. package/dist/markdownListExport.d.ts.map +1 -0
  114. package/dist/markdownListExport.js +68 -0
  115. package/dist/markdownListIndentation.d.ts +8 -0
  116. package/dist/markdownListIndentation.d.ts.map +1 -0
  117. package/dist/markdownListIndentation.js +101 -0
  118. package/dist/markdownOrderedList.d.ts +3 -0
  119. package/dist/markdownOrderedList.d.ts.map +1 -0
  120. package/dist/markdownOrderedList.js +51 -0
  121. package/dist/markdownQuote.d.ts +22 -0
  122. package/dist/markdownQuote.d.ts.map +1 -0
  123. package/dist/markdownQuote.js +32 -0
  124. package/dist/markdownSerializers.d.ts +49 -0
  125. package/dist/markdownSerializers.d.ts.map +1 -0
  126. package/dist/markdownSerializers.js +83 -0
  127. package/dist/markdownSource.d.ts +71 -0
  128. package/dist/markdownSource.d.ts.map +1 -0
  129. package/dist/markdownSource.js +804 -0
  130. package/dist/markdownSpaceRuns.d.ts +24 -0
  131. package/dist/markdownSpaceRuns.d.ts.map +1 -0
  132. package/dist/markdownSpaceRuns.js +147 -0
  133. package/dist/markdownTable.d.ts +35 -0
  134. package/dist/markdownTable.d.ts.map +1 -0
  135. package/dist/markdownTable.js +227 -0
  136. package/dist/markdownTaskList.d.ts +48 -0
  137. package/dist/markdownTaskList.d.ts.map +1 -0
  138. package/dist/markdownTaskList.js +166 -0
  139. package/dist/markdownThematicBreak.d.ts +3 -0
  140. package/dist/markdownThematicBreak.d.ts.map +1 -0
  141. package/dist/markdownThematicBreak.js +55 -0
  142. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
  143. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
  144. package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
  145. package/dist/richtext.css +213 -0
  146. package/dist/textSemantics.d.ts +3 -0
  147. package/dist/textSemantics.d.ts.map +1 -0
  148. package/dist/textSemantics.js +87 -0
  149. package/package.json +158 -3
  150. package/src/RichTextEditor.doc.mjs +308 -0
  151. package/src/RichTextEditor.tsx +1109 -0
  152. package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
  153. package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
  154. package/src/RichTextEditorToolbar.doc.mjs +92 -0
  155. package/src/RichTextEditorToolbar.tsx +871 -0
  156. package/src/RichTextView.doc.mjs +101 -0
  157. package/src/RichTextView.tsx +351 -0
  158. package/src/codeLanguages.ts +42 -0
  159. package/src/codeLineTokens.ts +241 -0
  160. package/src/doc-mjs.d.ts +23 -0
  161. package/src/editorNodes.ts +44 -0
  162. package/src/editorTheme.ts +550 -0
  163. package/src/index.ts +59 -0
  164. package/src/internal/CodeBackspacePlugin.tsx +96 -0
  165. package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
  166. package/src/internal/CodePastePlugin.tsx +144 -0
  167. package/src/internal/CodeSyntaxPlugin.tsx +493 -0
  168. package/src/internal/CodeTabPlugin.tsx +298 -0
  169. package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
  170. package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
  171. package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
  172. package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
  173. package/src/internal/SelectAllScopePlugin.tsx +86 -0
  174. package/src/internal/TableColumnFloorPlugin.tsx +99 -0
  175. package/src/internal/TablePastePlugin.tsx +223 -0
  176. package/src/internal/TableScrollRegionPlugin.tsx +107 -0
  177. package/src/internal/TaskCheckboxPlugin.tsx +246 -0
  178. package/src/internal/ToolbarLinkPopover.tsx +392 -0
  179. package/src/linkEditing.ts +20 -0
  180. package/src/linkForm.tsx +156 -0
  181. package/src/linkUtils.ts +106 -0
  182. package/src/markdown.ts +25 -0
  183. package/src/markdownCharacterReferences.ts +1137 -0
  184. package/src/markdownCodeFence.ts +216 -0
  185. package/src/markdownExtensionNode.ts +339 -0
  186. package/src/markdownExtensions.ts +496 -0
  187. package/src/markdownHardLineBreak.ts +73 -0
  188. package/src/markdownHeading.ts +33 -0
  189. package/src/markdownLink.ts +86 -0
  190. package/src/markdownListExport.ts +85 -0
  191. package/src/markdownListIndentation.ts +105 -0
  192. package/src/markdownOrderedList.ts +60 -0
  193. package/src/markdownQuote.ts +35 -0
  194. package/src/markdownSerializers.ts +108 -0
  195. package/src/markdownSource.ts +1045 -0
  196. package/src/markdownSpaceRuns.ts +164 -0
  197. package/src/markdownTable.ts +309 -0
  198. package/src/markdownTaskList.ts +222 -0
  199. package/src/markdownThematicBreak.ts +66 -0
  200. package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
  201. 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
+ }