@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,164 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file markdownSpaceRuns.ts
5
+ * @input Lexical nodes to export as Markdown, and the transformers to use
6
+ * @output $convertToMarkdownKeepingTimeLinear: Lexical's Markdown export, in
7
+ * time linear in the text even when a text holds long runs of spaces
8
+ * @position Every Markdown export in this package goes through it
9
+ * (markdownSource.ts, markdownTable.ts). Lexical's text-format export splits
10
+ * each text into leading spaces, content, and trailing spaces with
11
+ * `/^(\s*)(.*?)(\s*)$/s`, which retries from every character of a long run
12
+ * of spaces inside the text, so its time grows with the square of the run.
13
+ * Each such run is exported as private-use stand-ins, which that pattern
14
+ * passes in one step each, and put back afterwards. Leading and trailing
15
+ * spaces, which decide where marks attach, are left as they are.
16
+ */
17
+
18
+ import type {ElementNode, LexicalNode} from 'lexical';
19
+ import type {Transformer} from '@lexical/markdown';
20
+ import {$isCodeNode} from '@lexical/code';
21
+ import {$convertToMarkdownString} from '@lexical/markdown';
22
+ import {$isElementNode, $isTextNode} from 'lexical';
23
+ import {absentCharacters} from './markdownCharacterReferences';
24
+
25
+ /**
26
+ * A run of spaces with other characters on both sides, long enough for
27
+ * Lexical's pattern to cost noticeably more than one step per space; shorter
28
+ * runs, as ordinary text has, export as they are.
29
+ */
30
+ const INNER_SPACE_RUN = /(?<=\S)\s{64,}(?=\S)/g;
31
+
32
+ /** Whether Lexical exports the text node's text as written, untouched. */
33
+ function isLiteralText(node: LexicalNode): boolean {
34
+ return (
35
+ $isTextNode(node) &&
36
+ (node.hasFormat('code') || $isCodeNode(node.getParent()))
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Lexical's `$convertToMarkdownString(transformers, root)`, with every long
42
+ * run of spaces inside a text exported as stand-ins and put back, so the
43
+ * export takes time linear in the text. The tree is read through views, never
44
+ * changed; a tree with no long run exports directly.
45
+ */
46
+ export function $convertToMarkdownKeepingTimeLinear(
47
+ transformers: Array<Transformer>,
48
+ root: ElementNode,
49
+ ): string {
50
+ const spaces = new Set<string>();
51
+ const findRuns = (node: LexicalNode) => {
52
+ if ($isTextNode(node)) {
53
+ if (!isLiteralText(node)) {
54
+ for (const run of node.getTextContent().match(INNER_SPACE_RUN) ?? []) {
55
+ for (const space of run) {
56
+ spaces.add(space);
57
+ }
58
+ }
59
+ }
60
+ } else if ($isElementNode(node) && !$isCodeNode(node)) {
61
+ node.getChildren().forEach(findRuns);
62
+ }
63
+ };
64
+ root.getChildren().forEach(findRuns);
65
+ if (spaces.size === 0) {
66
+ return $convertToMarkdownString(transformers, root);
67
+ }
68
+ // Every string the export can write — texts, and the properties nodes
69
+ // serialize, such as a link's address and title — so a stand-in occurs
70
+ // nowhere else in the output.
71
+ const written: string[] = [];
72
+ const collect = (node: LexicalNode) => {
73
+ written.push(JSON.stringify(node.exportJSON()));
74
+ if ($isTextNode(node)) {
75
+ written.push(node.getTextContent());
76
+ } else if ($isElementNode(node)) {
77
+ node.getChildren().forEach(collect);
78
+ }
79
+ };
80
+ root.getChildren().forEach(collect);
81
+ const free = absentCharacters(written.join(''));
82
+ const standIns = new Map<string, string>();
83
+ for (const space of spaces) {
84
+ const standIn = free.next().value;
85
+ if (standIn == null) {
86
+ return $convertToMarkdownString(transformers, root);
87
+ }
88
+ standIns.set(space, standIn);
89
+ }
90
+ const placed = {count: 0};
91
+ const children = viewsOf(root.getChildren(), standIns, placed);
92
+ const view = Object.create(root) as typeof root;
93
+ view.getChildren = <T extends LexicalNode>() => children as Array<T>;
94
+ let markdown = $convertToMarkdownString(transformers, view);
95
+ // Every stand-in in the output must be one this export placed; otherwise
96
+ // the plain export, slower but exact, is the answer.
97
+ let found = 0;
98
+ for (const standIn of standIns.values()) {
99
+ found += markdown.split(standIn).length - 1;
100
+ }
101
+ if (found !== placed.count) {
102
+ return $convertToMarkdownString(transformers, root);
103
+ }
104
+ for (const [space, standIn] of standIns) {
105
+ markdown = markdown.split(standIn).join(space);
106
+ }
107
+ return markdown;
108
+ }
109
+
110
+ /**
111
+ * Views of `nodes` whose texts hold stand-ins for their inner runs of
112
+ * spaces; a node with no such run is itself. Each view's siblings are the
113
+ * views beside it, as Lexical's exporter reads neighbors to place marks.
114
+ */
115
+ function viewsOf(
116
+ nodes: ReadonlyArray<LexicalNode>,
117
+ standIns: ReadonlyMap<string, string>,
118
+ placed: {count: number},
119
+ ): LexicalNode[] {
120
+ const views = nodes.map(node => viewOf(node, standIns, placed));
121
+ views.forEach((view, index) => {
122
+ if (view !== nodes[index]) {
123
+ view.getPreviousSibling = <T extends LexicalNode>() =>
124
+ (views[index - 1] ?? null) as T | null;
125
+ view.getNextSibling = <T extends LexicalNode>() =>
126
+ (views[index + 1] ?? null) as T | null;
127
+ }
128
+ });
129
+ return views;
130
+ }
131
+
132
+ function viewOf(
133
+ node: LexicalNode,
134
+ standIns: ReadonlyMap<string, string>,
135
+ placed: {count: number},
136
+ ): LexicalNode {
137
+ if ($isTextNode(node)) {
138
+ if (isLiteralText(node)) {
139
+ return node;
140
+ }
141
+ const text = node.getTextContent();
142
+ const stood = text.replace(INNER_SPACE_RUN, run => {
143
+ placed.count += run.length;
144
+ return Array.from(run, space => standIns.get(space) ?? space).join('');
145
+ });
146
+ if (stood === text) {
147
+ return node;
148
+ }
149
+ const view = Object.create(node) as typeof node;
150
+ view.getTextContent = () => stood;
151
+ return view;
152
+ }
153
+ if ($isElementNode(node) && !$isCodeNode(node)) {
154
+ const original = node.getChildren();
155
+ const children = viewsOf(original, standIns, placed);
156
+ if (children.every((child, index) => child === original[index])) {
157
+ return node;
158
+ }
159
+ const view = Object.create(node) as typeof node;
160
+ view.getChildren = <T extends LexicalNode>() => children as Array<T>;
161
+ return view;
162
+ }
163
+ return node;
164
+ }
@@ -0,0 +1,309 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file markdownTable.ts
5
+ * @input Uses @lexical/markdown (multiline element transformer, inline text
6
+ * transformers) and the @lexical/table nodes.
7
+ * @output Exports TABLE, the GFM pipe-table transformer, and DEFAULT_TRANSFORMERS,
8
+ * the editor's default Markdown transformers (Lexical's standard set plus TABLE).
9
+ * @position Internal to @blue-patterns/richtext. RichTextEditor.tsx (shortcuts,
10
+ * getMarkdown) and markdownSerializers.ts (headless import/export) default to
11
+ * DEFAULT_TRANSFORMERS so tables import, edit, and export the same way.
12
+ *
13
+ * SYNC: Table recognition mirrors core Markdown's parser (a line with a pipe
14
+ * followed by a delimiter row; body rows continue while they contain a pipe),
15
+ * so the same source is a table on both surfaces. Keep the two in step.
16
+ */
17
+
18
+ import {
19
+ $convertFromMarkdownString,
20
+ CODE,
21
+ HEADING,
22
+ TEXT_FORMAT_TRANSFORMERS,
23
+ TEXT_MATCH_TRANSFORMERS,
24
+ LINK,
25
+ ORDERED_LIST,
26
+ QUOTE,
27
+ TRANSFORMERS,
28
+ type MultilineElementTransformer,
29
+ type Transformer,
30
+ } from '@lexical/markdown';
31
+ import {
32
+ $createTableCellNode,
33
+ $createTableNode,
34
+ $createTableRowNode,
35
+ $isTableCellNode,
36
+ $isTableNode,
37
+ $isTableRowNode,
38
+ TableCellHeaderStates,
39
+ TableCellNode,
40
+ TableNode,
41
+ TableRowNode,
42
+ } from '@lexical/table';
43
+ import type {ElementFormatType} from 'lexical';
44
+ import {HARD_LINE_BREAK} from './markdownHardLineBreak';
45
+ import {THEMATIC_BREAK} from './markdownThematicBreak';
46
+ import {HEADING_MARKERS} from './markdownHeading';
47
+ import {QUOTE_MARKERS} from './markdownQuote';
48
+ import {LIST_EXPORT} from './markdownListExport';
49
+ import {TASK_LIST} from './markdownTaskList';
50
+ import {ORDERED_LIST_KEEPING_START} from './markdownOrderedList';
51
+ import {LINK_KEEPING_DESTINATIONS} from './markdownLink';
52
+ import {BACKTICK_CODE, TILDE_CODE} from './markdownCodeFence';
53
+ import {$convertToMarkdownKeepingTimeLinear} from './markdownSpaceRuns';
54
+
55
+ /**
56
+ * Cells hold inline Markdown only, so they are imported and exported with the
57
+ * inline transformers.
58
+ */
59
+ const CELL_TRANSFORMERS: Array<Transformer> = [
60
+ ...TEXT_FORMAT_TRANSFORMERS,
61
+ ...TEXT_MATCH_TRANSFORMERS.map(transformer =>
62
+ transformer === LINK ? LINK_KEEPING_DESTINATIONS : transformer,
63
+ ),
64
+ ];
65
+
66
+ type ColumnAlignment = 'left' | 'center' | 'right' | null;
67
+
68
+ /**
69
+ * Splits a row into trimmed cell sources: the row's indentation, one leading
70
+ * pipe, trailing spaces, and one trailing pipe are dropped, and the rest
71
+ * splits on unescaped pipes. An escaped pipe stays escaped for the cell's
72
+ * inline import.
73
+ */
74
+ function splitTableRow(line: string): Array<string> {
75
+ let start = 0;
76
+ let end = line.length;
77
+ while (start < end && (line[start] === ' ' || line[start] === '\t')) {
78
+ start++;
79
+ }
80
+ if (line[start] === '|') {
81
+ start++;
82
+ while (start < end && line[start] === ' ') {
83
+ start++;
84
+ }
85
+ }
86
+ while (end > start && line[end - 1] === ' ') {
87
+ end--;
88
+ }
89
+ if (end > start && line[end - 1] === '|') {
90
+ end--;
91
+ }
92
+ const content = line.slice(start, end);
93
+ const cells: Array<string> = [];
94
+ let current = '';
95
+ for (let index = 0; index < content.length; index++) {
96
+ const character = content[index];
97
+ if (character === '\\' && content[index + 1] === '|') {
98
+ current += '\\|';
99
+ index++;
100
+ } else if (character === '|') {
101
+ cells.push(current.trim());
102
+ current = '';
103
+ } else {
104
+ current += character;
105
+ }
106
+ }
107
+ cells.push(current.trim());
108
+ return cells;
109
+ }
110
+
111
+ /** A delimiter row: every non-empty cell is dashes with optional colons. */
112
+ function isDelimiterRow(line: string | undefined): boolean {
113
+ if (line == null || !line.includes('|')) {
114
+ return false;
115
+ }
116
+ const cells = line
117
+ .split('|')
118
+ .map(cell => cell.trim())
119
+ .filter(cell => cell.length > 0);
120
+ return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell));
121
+ }
122
+
123
+ function columnAlignment(delimiterCell: string): ColumnAlignment {
124
+ const left = delimiterCell.startsWith(':');
125
+ const right = delimiterCell.endsWith(':');
126
+ if (left && right) {
127
+ return 'center';
128
+ }
129
+ return right ? 'right' : left ? 'left' : null;
130
+ }
131
+
132
+ /**
133
+ * Alignment is stored as the cell's element format. Left and right map to
134
+ * logical start and end, so an aligned column follows the reading direction
135
+ * the way core Markdown's table does.
136
+ */
137
+ const ALIGNMENT_FORMAT: Record<
138
+ NonNullable<ColumnAlignment>,
139
+ ElementFormatType
140
+ > = {left: 'start', center: 'center', right: 'end'};
141
+
142
+ function delimiterFor(format: ElementFormatType): string {
143
+ switch (format) {
144
+ case 'left':
145
+ case 'start':
146
+ return ':---';
147
+ case 'center':
148
+ return ':---:';
149
+ case 'right':
150
+ case 'end':
151
+ return '---:';
152
+ default:
153
+ return '---';
154
+ }
155
+ }
156
+
157
+ function $createCell(
158
+ source: string,
159
+ isHeader: boolean,
160
+ alignment: ColumnAlignment,
161
+ ): TableCellNode {
162
+ const cell = $createTableCellNode(
163
+ isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS,
164
+ );
165
+ // The pipe was only escaped to keep it out of the row split.
166
+ $convertFromMarkdownString(
167
+ source.replace(/\\\|/g, '|'),
168
+ CELL_TRANSFORMERS,
169
+ cell,
170
+ );
171
+ if (alignment != null) {
172
+ cell.setFormat(ALIGNMENT_FORMAT[alignment]);
173
+ }
174
+ return cell;
175
+ }
176
+
177
+ function cellMarkdown(cell: TableCellNode): string {
178
+ // A cell is one line of Markdown, so paragraph breaks inside it become
179
+ // spaces, and literal pipes are escaped to stay inside the cell.
180
+ return oneLine($convertToMarkdownKeepingTimeLinear(CELL_TRANSFORMERS, cell))
181
+ .replace(/\|/g, '\\|')
182
+ .trim();
183
+ }
184
+
185
+ /**
186
+ * `markdown` with each run of spaces that holds a line break made one space.
187
+ * Read line by line: a pattern matching optional spaces around line breaks
188
+ * retries from every space in a long run without a line break, so its time
189
+ * grows with the square of the run.
190
+ */
191
+ function oneLine(markdown: string): string {
192
+ if (!markdown.includes('\n')) {
193
+ return markdown;
194
+ }
195
+ const lines = markdown.split('\n');
196
+ let joined = lines[0].trimEnd();
197
+ for (let index = 1; index < lines.length; index++) {
198
+ const isLast = index === lines.length - 1;
199
+ const line = isLast ? lines[index].trimStart() : lines[index].trim();
200
+ // A blank line is part of the run of spaces around it.
201
+ if (line !== '' || isLast) {
202
+ joined += ` ${line}`;
203
+ }
204
+ }
205
+ return joined;
206
+ }
207
+
208
+ /**
209
+ * GFM pipe tables. The header row is the first row and renders as header
210
+ * cells; delimiter-row colons set each column's alignment; rows shorter than
211
+ * the widest row gain empty cells, so no cell is dropped. Export writes the
212
+ * canonical form: outer pipes, one space of padding, and three-dash delimiters.
213
+ */
214
+ export const TABLE: MultilineElementTransformer = {
215
+ dependencies: [TableNode, TableRowNode, TableCellNode],
216
+ export(node) {
217
+ if (!$isTableNode(node)) {
218
+ return null;
219
+ }
220
+ const rows = node.getChildren().filter($isTableRowNode);
221
+ const lines: Array<string> = [];
222
+ rows.forEach((row, rowIndex) => {
223
+ const cells = row.getChildren().filter($isTableCellNode);
224
+ lines.push(`| ${cells.map(cellMarkdown).join(' | ')} |`);
225
+ if (rowIndex === 0) {
226
+ lines.push(
227
+ `| ${cells.map(cell => delimiterFor(cell.getFormatType())).join(' | ')} |`,
228
+ );
229
+ }
230
+ });
231
+ return lines.join('\n');
232
+ },
233
+ regExpStart: /\|/,
234
+ handleImportAfterStartMatch({lines, rootNode, startLineIndex}) {
235
+ if (!isDelimiterRow(lines[startLineIndex + 1])) {
236
+ return null;
237
+ }
238
+ const alignments = splitTableRow(lines[startLineIndex + 1]).map(
239
+ columnAlignment,
240
+ );
241
+ const rowSources: Array<Array<string>> = [
242
+ splitTableRow(lines[startLineIndex]),
243
+ ];
244
+ let lastLineIndex = startLineIndex + 1;
245
+ while (
246
+ lastLineIndex + 1 < lines.length &&
247
+ lines[lastLineIndex + 1].includes('|') &&
248
+ lines[lastLineIndex + 1].trim() !== ''
249
+ ) {
250
+ lastLineIndex++;
251
+ rowSources.push(splitTableRow(lines[lastLineIndex]));
252
+ }
253
+ const columnCount = Math.max(...rowSources.map(cells => cells.length));
254
+ const table = $createTableNode();
255
+ rowSources.forEach((cells, rowIndex) => {
256
+ const row = $createTableRowNode();
257
+ for (let column = 0; column < columnCount; column++) {
258
+ row.append(
259
+ $createCell(
260
+ cells[column] ?? '',
261
+ rowIndex === 0,
262
+ alignments[column] ?? null,
263
+ ),
264
+ );
265
+ }
266
+ table.append(row);
267
+ });
268
+ rootNode.append(table);
269
+ return [true, lastLineIndex];
270
+ },
271
+ replace() {
272
+ // Every table imports through handleImportAfterStartMatch.
273
+ return false;
274
+ },
275
+ type: 'multiline-element',
276
+ };
277
+
278
+ /**
279
+ * The editor's default Markdown transformers: thematic breaks, list export
280
+ * that nests to each parent's content column, GFM task list items, Lexical's
281
+ * standard set (with `~~~` fences beside its backtick ones, both reading the
282
+ * opening line's info string as core does, its ordered list keeping each
283
+ * nested list's start, and its
284
+ * link writing an unbalanced destination's parentheses escaped), hard line
285
+ * breaks for breaks typed in the editor, and GFM tables. Thematic breaks come first so a line such as `* * *` is a rule
286
+ * rather than a list item, and task items before bullets so `- [ ] text` is a
287
+ * task rather than a bullet that starts with `[ ]`.
288
+ */
289
+ export const DEFAULT_TRANSFORMERS: ReadonlyArray<Transformer> = [
290
+ THEMATIC_BREAK,
291
+ LIST_EXPORT,
292
+ TASK_LIST,
293
+ TILDE_CODE,
294
+ ...TRANSFORMERS.map(transformer =>
295
+ transformer === ORDERED_LIST
296
+ ? ORDERED_LIST_KEEPING_START
297
+ : transformer === LINK
298
+ ? LINK_KEEPING_DESTINATIONS
299
+ : transformer === CODE
300
+ ? BACKTICK_CODE
301
+ : transformer === QUOTE
302
+ ? QUOTE_MARKERS
303
+ : transformer === HEADING
304
+ ? HEADING_MARKERS
305
+ : transformer,
306
+ ),
307
+ HARD_LINE_BREAK,
308
+ TABLE,
309
+ ];
@@ -0,0 +1,222 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file markdownTaskList.ts
5
+ * @input Uses lexical (node state), @lexical/list, and @lexical/markdown
6
+ * (ElementTransformer).
7
+ * @output Exports TASK_LIST, the transformer that imports GFM task list
8
+ * items; $getTaskState / $setTaskState, which read and set whether a list
9
+ * item is a task and whether it is checked; and registerTaskListEditing,
10
+ * the editor's Enter and Mod+Enter behavior for task items.
11
+ * @position Part of DEFAULT_TRANSFORMERS (markdownTable.ts), ahead of
12
+ * Lexical's bullet transformer. It reads exactly the task items core
13
+ * Markdown reads (spec:AST-061 FR5): a `-`, `*`, or `+` bullet, one space,
14
+ * `[ ]`, `[x]`, or `[X]`, and a space. Lexical's own CHECK_LIST also takes a
15
+ * line with no bullet, `[]`, and missing spaces, and reads `[X]` as
16
+ * unchecked; those stay a paragraph or an ordinary bullet here.
17
+ *
18
+ * A task is a property of an item, not of its list: GFM lets task and
19
+ * plain items share one list, and Lexical keeps a checked state only in its
20
+ * all-checkbox lists. So task items live in ordinary bullet lists, beside
21
+ * plain items, and carry their state as node state; LIST_EXPORT writes
22
+ * `[ ]` or `[x]` for each task item. TaskCheckboxPlugin draws them, and
23
+ * registers registerTaskListEditing in the editor.
24
+ */
25
+
26
+ import {
27
+ $createListItemNode,
28
+ $createListNode,
29
+ $isListItemNode,
30
+ $isListNode,
31
+ ListItemNode,
32
+ ListNode,
33
+ } from '@lexical/list';
34
+ import type {ElementTransformer} from '@lexical/markdown';
35
+ import {$findMatchingParent, mergeRegister} from '@lexical/utils';
36
+ import {
37
+ $getSelection,
38
+ $getState,
39
+ $isRangeSelection,
40
+ $setState,
41
+ COMMAND_PRIORITY_LOW,
42
+ createState,
43
+ INSERT_PARAGRAPH_COMMAND,
44
+ KEY_ENTER_COMMAND,
45
+ type LexicalEditor,
46
+ } from 'lexical';
47
+
48
+ export type TaskState = 'checked' | 'unchecked';
49
+
50
+ // Whether an item is a task. A copy keeps it, so the item Enter makes after
51
+ // a task is a task too.
52
+ const taskKind = createState('astryxTask', {
53
+ parse: (value: unknown): boolean => value === true,
54
+ });
55
+
56
+ // Whether a task is checked.
57
+ const taskChecked = createState('astryxTaskChecked', {
58
+ parse: (value: unknown): boolean => value === true,
59
+ });
60
+
61
+ /**
62
+ * Whether `item` is a task and whether it is checked; null for a plain item.
63
+ * An item in one of Lexical's own checkbox lists is a task too.
64
+ */
65
+ export function $getTaskState(item: ListItemNode): TaskState | null {
66
+ if ($getState(item, taskKind)) {
67
+ return $getState(item, taskChecked) ? 'checked' : 'unchecked';
68
+ }
69
+ const list = item.getParent();
70
+ if ($isListNode(list) && list.getListType() === 'check') {
71
+ return item.getChecked() === true ? 'checked' : 'unchecked';
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Makes `item` a task in the given state, or a plain item for null. */
77
+ export function $setTaskState(
78
+ item: ListItemNode,
79
+ state: TaskState | null,
80
+ ): void {
81
+ const list = item.getParent();
82
+ if ($isListNode(list) && list.getListType() === 'check') {
83
+ item.setChecked(state === 'checked');
84
+ return;
85
+ }
86
+ $setState(item, taskKind, state != null);
87
+ $setState(item, taskChecked, state === 'checked');
88
+ }
89
+
90
+ const TASK_ITEM = /^(\s*)([-*+])\s\[([ xX])\]\s/;
91
+
92
+ /** Nesting depth from leading whitespace, counted as Lexical's lists do. */
93
+ function indentOf(whitespace: string): number {
94
+ const tabs = whitespace.match(/\t/g)?.length ?? 0;
95
+ const spaces = whitespace.match(/ /g)?.length ?? 0;
96
+ return tabs + Math.floor(spaces / 4);
97
+ }
98
+
99
+ export const TASK_LIST: ElementTransformer = {
100
+ dependencies: [ListNode, ListItemNode],
101
+ // LIST_EXPORT writes every list, task items included.
102
+ export: () => null,
103
+ regExp: TASK_ITEM,
104
+ replace: (parentNode, children, match, isImport) => {
105
+ const item = $createListItemNode();
106
+ $setState(item, taskKind, true);
107
+ $setState(item, taskChecked, match[3] !== ' ');
108
+ // A task item joins the bullet list beside it, as a plain item does.
109
+ const previous = parentNode.getPreviousSibling();
110
+ const next = parentNode.getNextSibling();
111
+ if ($isListNode(next) && next.getListType() === 'bullet') {
112
+ const first = next.getFirstChild();
113
+ if (first == null) {
114
+ next.append(item);
115
+ } else {
116
+ first.insertBefore(item);
117
+ }
118
+ parentNode.remove();
119
+ } else if ($isListNode(previous) && previous.getListType() === 'bullet') {
120
+ previous.append(item);
121
+ parentNode.remove();
122
+ } else {
123
+ const list = $createListNode('bullet');
124
+ list.append(item);
125
+ parentNode.replace(list);
126
+ }
127
+ item.append(...children);
128
+ if (!isImport) {
129
+ item.select(0, 0);
130
+ }
131
+ const indent = indentOf(match[1]);
132
+ if (indent > 0) {
133
+ item.setIndent(indent);
134
+ }
135
+ },
136
+ type: 'element',
137
+ };
138
+
139
+ /** The task item holding the selection's anchor, if any. */
140
+ function $selectedTaskItem(): ListItemNode | null {
141
+ const selection = $getSelection();
142
+ if (!$isRangeSelection(selection)) {
143
+ return null;
144
+ }
145
+ return $findMatchingParent(
146
+ selection.anchor.getNode(),
147
+ (node): node is ListItemNode =>
148
+ $isListItemNode(node) && $getTaskState(node) != null,
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Task editing in an editor:
154
+ *
155
+ * - Enter in a task makes the new item a task, unchecked, as Lexical's own
156
+ * checkbox items do. Lexical copies the item to make the new one, state and
157
+ * all, so the new item's state is set here, where Enter makes it — never
158
+ * inferred later from content, which would uncheck a written `- [x]` with
159
+ * no text. Enter at the start of a task leaves its text, and its state, on
160
+ * the item that holds it, and puts the new, unchecked item before it. Enter
161
+ * in an empty task is left to the list, which ends the list there.
162
+ * - Mod+Enter checks or unchecks the task holding the caret.
163
+ */
164
+ export function registerTaskListEditing(editor: LexicalEditor): () => void {
165
+ return mergeRegister(
166
+ editor.registerCommand(
167
+ INSERT_PARAGRAPH_COMMAND,
168
+ () => {
169
+ const selection = $getSelection();
170
+ const item = $selectedTaskItem();
171
+ if (
172
+ item == null ||
173
+ !$isRangeSelection(selection) ||
174
+ item.getTextContent().trim() === ''
175
+ ) {
176
+ return false;
177
+ }
178
+ const state = $getTaskState(item);
179
+ const isAtStart =
180
+ selection.isCollapsed() &&
181
+ selection.anchor.offset === 0 &&
182
+ (selection.anchor.getNode().is(item) ||
183
+ selection.anchor.getNode().is(item.getFirstDescendant()));
184
+ const created = selection.insertParagraph();
185
+ if (!$isListItemNode(created) || $getTaskState(created) == null) {
186
+ return true;
187
+ }
188
+ if (isAtStart) {
189
+ // The text moved to the new item: it keeps its state, and the item
190
+ // left empty before it is the new, unchecked one.
191
+ $setTaskState(created, state);
192
+ $setTaskState(item, 'unchecked');
193
+ } else {
194
+ $setTaskState(created, 'unchecked');
195
+ }
196
+ return true;
197
+ },
198
+ COMMAND_PRIORITY_LOW,
199
+ ),
200
+ editor.registerCommand(
201
+ KEY_ENTER_COMMAND,
202
+ event => {
203
+ const isModified =
204
+ event != null &&
205
+ (event.metaKey || event.ctrlKey) &&
206
+ !event.shiftKey &&
207
+ !event.altKey;
208
+ const item = isModified ? $selectedTaskItem() : null;
209
+ if (item == null) {
210
+ return false;
211
+ }
212
+ event?.preventDefault();
213
+ $setTaskState(
214
+ item,
215
+ $getTaskState(item) === 'checked' ? 'unchecked' : 'checked',
216
+ );
217
+ return true;
218
+ },
219
+ COMMAND_PRIORITY_LOW,
220
+ ),
221
+ );
222
+ }