@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,96 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file CodeBackspacePlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
+ * lexical.
9
+ * @output Exports CodeBackspacePlugin, which makes Backspace at the very start
10
+ * of a code block turn the block into a paragraph holding its lines.
11
+ * @position Rendered by RichTextEditor; always on, not exported. Lexical's
12
+ * own Backspace there merges the code into the block above, and a heading
13
+ * above reads back as a longer heading and a stray paragraph.
14
+ */
15
+
16
+ import {useEffect} from 'react';
17
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
18
+ import {$isCodeNode, type CodeNode} from '@lexical/code';
19
+ import {
20
+ $createLineBreakNode,
21
+ $createParagraphNode,
22
+ $createTextNode,
23
+ $getSelection,
24
+ $isRangeSelection,
25
+ COMMAND_PRIORITY_LOW,
26
+ DELETE_CHARACTER_COMMAND,
27
+ type ParagraphNode,
28
+ } from 'lexical';
29
+
30
+ /** Start is offset 0 of the block or of its first child, not the start of a later line. */
31
+ function $codeBlockAtCaretStart(): CodeNode | null {
32
+ const selection = $getSelection();
33
+ if (
34
+ !$isRangeSelection(selection) ||
35
+ !selection.isCollapsed() ||
36
+ selection.anchor.offset !== 0
37
+ ) {
38
+ return null;
39
+ }
40
+ const node = selection.anchor.getNode();
41
+ const code = $isCodeNode(node) ? node : node.getParent();
42
+ if (!$isCodeNode(code)) {
43
+ return null;
44
+ }
45
+ return node.is(code) || node.is(code.getFirstChild()) ? code : null;
46
+ }
47
+
48
+ /**
49
+ * A newline inside text exports as a soft break, and the lines read back as
50
+ * one. A line break node exports as a hard break. Empty lines at the end of
51
+ * the code are dropped: a hard break that ends a paragraph reads back as a
52
+ * literal backslash.
53
+ */
54
+ function $paragraphOfLines(code: CodeNode): ParagraphNode {
55
+ const paragraph = $createParagraphNode();
56
+ code
57
+ .getTextContent()
58
+ .replace(/\n+$/, '')
59
+ .split('\n')
60
+ .forEach((line, index) => {
61
+ if (index > 0) {
62
+ paragraph.append($createLineBreakNode());
63
+ }
64
+ if (line !== '') {
65
+ paragraph.append($createTextNode(line));
66
+ }
67
+ });
68
+ return paragraph;
69
+ }
70
+
71
+ export function CodeBackspacePlugin(): null {
72
+ const [editor] = useLexicalComposerContext();
73
+ useEffect(
74
+ () =>
75
+ editor.registerCommand(
76
+ DELETE_CHARACTER_COMMAND,
77
+ isBackward => {
78
+ if (!isBackward || !editor.isEditable()) {
79
+ return false;
80
+ }
81
+ const code = $codeBlockAtCaretStart();
82
+ if (code == null) {
83
+ return false;
84
+ }
85
+ const paragraph = $paragraphOfLines(code);
86
+ code.replace(paragraph);
87
+ paragraph.selectStart();
88
+ return true;
89
+ },
90
+ // Ahead of the rich-text deletion, which would do the merge.
91
+ COMMAND_PRIORITY_LOW,
92
+ ),
93
+ [editor],
94
+ );
95
+ return null;
96
+ }
@@ -0,0 +1,282 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file CodeBlockHeaderPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode),
8
+ * lexical, and core IconButton, Icon, useClipboard, and useTranslator.
9
+ * @output Exports CodeBlockHeaderPlugin, which draws core CodeBlock's header
10
+ * over every fenced code block: the language label and a copy button, or
11
+ * just the copy button in the corner when the block names no language.
12
+ * @position Rendered by RichTextEditor and RichTextView inside the positioned
13
+ * element that holds the content editable. The headers are siblings of the
14
+ * editable text, never inside it, so they are not part of the document and
15
+ * cannot be edited (spec:AST-061 FR8, DEC-4). The code block's theme class
16
+ * reserves the header's height, so the code sits where core Markdown's does.
17
+ */
18
+
19
+ import * as stylex from '@stylexjs/stylex';
20
+ import {useEffect, useState, type JSX} from 'react';
21
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
22
+ import {$isCodeNode} from '@lexical/code';
23
+ import {$getNodeByKey, $getRoot} from 'lexical';
24
+ import {Icon} from '@blue-patterns/core/Icon';
25
+ import {IconButton} from '@blue-patterns/core/IconButton';
26
+ import {useClipboard} from '@blue-patterns/core/hooks';
27
+ import {useTranslator} from '@blue-patterns/core/i18n';
28
+ import type {CodeLanguage} from '@blue-patterns/core/CodeBlock';
29
+ import {listedLanguage} from '../codeLanguages';
30
+ import {
31
+ fontWeightVars,
32
+ spacingVars,
33
+ typeScaleVars,
34
+ typographyVars,
35
+ } from '@blue-patterns/core/theme/tokens.stylex';
36
+
37
+ /** Where one code block's header goes, in the container's coordinates. */
38
+ interface HeaderPlacement {
39
+ readonly key: string;
40
+ readonly language: string | null;
41
+ readonly top: number;
42
+ readonly left: number;
43
+ readonly width: number;
44
+ }
45
+
46
+ const styles = stylex.create({
47
+ // Core CodeBlock's compact header row: 8px above and below a small control,
48
+ // 16px at the sides.
49
+ header: {
50
+ position: 'absolute',
51
+ display: 'flex',
52
+ alignItems: 'center',
53
+ justifyContent: 'space-between',
54
+ paddingBlock: spacingVars['--spacing-2'],
55
+ paddingInline: spacingVars['--spacing-4'],
56
+ boxSizing: 'border-box',
57
+ },
58
+ // No language: the copy button alone, 8px from the top end corner.
59
+ corner: {
60
+ position: 'absolute',
61
+ display: 'flex',
62
+ justifyContent: 'flex-end',
63
+ padding: spacingVars['--spacing-2'],
64
+ boxSizing: 'border-box',
65
+ pointerEvents: 'none',
66
+ },
67
+ cornerButton: {
68
+ pointerEvents: 'auto',
69
+ },
70
+ title: {
71
+ fontSize: typeScaleVars['--text-supporting-size'],
72
+ fontFamily: typographyVars['--font-family-code'],
73
+ fontWeight: fontWeightVars['--font-weight-medium'],
74
+ lineHeight: typeScaleVars['--text-supporting-leading'],
75
+ color: 'var(--color-syntax-comment)',
76
+ minWidth: 0,
77
+ overflow: 'hidden',
78
+ textOverflow: 'ellipsis',
79
+ whiteSpace: 'nowrap',
80
+ },
81
+ copyButton: {
82
+ color: 'var(--color-syntax-comment)',
83
+ },
84
+ placement: (top: number, left: number, width: number) => ({
85
+ top,
86
+ // eslint-disable-next-line @astryx/no-physical-properties -- intentional: `left` is a measured viewport coordinate (`box.left - origin.left + element.clientLeft`), not an authored edge. `insetInlineStart` would resolve to the right edge in RTL and place the header off the block.
87
+ left,
88
+ width,
89
+ }),
90
+ });
91
+
92
+ /**
93
+ * The language a header names, as core CodeBlock decides it: none for a fence
94
+ * without one, and none for exactly `plaintext`, which core Markdown gives a
95
+ * fence with no info string.
96
+ */
97
+ function displayLanguageOf(language: string | null | undefined): string | null {
98
+ return language == null || language === '' || language === PLAIN_TEXT
99
+ ? null
100
+ : language;
101
+ }
102
+
103
+ const PLAIN_TEXT = 'plaintext';
104
+
105
+ function samePlacements(
106
+ a: ReadonlyArray<HeaderPlacement>,
107
+ b: ReadonlyArray<HeaderPlacement>,
108
+ ): boolean {
109
+ return (
110
+ a.length === b.length &&
111
+ a.every(
112
+ (placement, index) =>
113
+ placement.key === b[index].key &&
114
+ placement.language === b[index].language &&
115
+ placement.top === b[index].top &&
116
+ placement.left === b[index].left &&
117
+ placement.width === b[index].width,
118
+ )
119
+ );
120
+ }
121
+
122
+ function CodeBlockHeader({
123
+ placement,
124
+ label,
125
+ }: {
126
+ readonly placement: HeaderPlacement;
127
+ /** The matched language value's name, when a listed value claims the fence. */
128
+ readonly label?: string;
129
+ }): JSX.Element {
130
+ const [editor] = useLexicalComposerContext();
131
+ const t = useTranslator();
132
+ const {copy, isCopied} = useClipboard({
133
+ announce: t('@astryx.codeBlock.copied'),
134
+ });
135
+ const copyButton = (
136
+ <IconButton
137
+ variant="ghost"
138
+ size="sm"
139
+ icon={
140
+ <Icon icon={isCopied ? 'check' : 'copy'} size="sm" color="inherit" />
141
+ }
142
+ tooltip={t('@astryx.codeBlock.copyCode')}
143
+ label={
144
+ isCopied
145
+ ? t('@astryx.codeBlock.copied')
146
+ : t('@astryx.codeBlock.copyCode')
147
+ }
148
+ onClick={() => {
149
+ const code = editor
150
+ .getEditorState()
151
+ .read(() => $getNodeByKey(placement.key)?.getTextContent() ?? '');
152
+ void copy(code);
153
+ }}
154
+ xstyle={[
155
+ styles.copyButton,
156
+ placement.language == null && styles.cornerButton,
157
+ ]}
158
+ />
159
+ );
160
+ const position = styles.placement(
161
+ placement.top,
162
+ placement.left,
163
+ placement.width,
164
+ );
165
+ if (placement.language == null) {
166
+ return (
167
+ <div
168
+ data-richtext-code-header=""
169
+ {...stylex.props(styles.corner, position)}>
170
+ {copyButton}
171
+ </div>
172
+ );
173
+ }
174
+ return (
175
+ <div
176
+ data-richtext-code-header=""
177
+ {...stylex.props(styles.header, position)}>
178
+ <span {...stylex.props(styles.title)}>{label ?? placement.language}</span>
179
+ {copyButton}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Draws a header over each top-level code block and keeps it there as the
186
+ * document, the editor's size, or the window changes.
187
+ */
188
+ export function CodeBlockHeaderPlugin({
189
+ codeLanguages,
190
+ }: {
191
+ /** Language values a fence may name; a matched fence shows the value's name. */
192
+ codeLanguages?: ReadonlyArray<CodeLanguage>;
193
+ } = {}): JSX.Element {
194
+ const [editor] = useLexicalComposerContext();
195
+ const [placements, setPlacements] = useState<ReadonlyArray<HeaderPlacement>>(
196
+ [],
197
+ );
198
+
199
+ useEffect(() => {
200
+ let frame = 0;
201
+ const measure = () => {
202
+ frame = 0;
203
+ const root = editor.getRootElement();
204
+ const container = root?.parentElement;
205
+ if (root == null || container == null) {
206
+ setPlacements(current => (current.length === 0 ? current : []));
207
+ return;
208
+ }
209
+ const origin = container.getBoundingClientRect();
210
+ const next = editor.getEditorState().read(() =>
211
+ $getRoot()
212
+ .getChildren()
213
+ .filter($isCodeNode)
214
+ .flatMap(node => {
215
+ const element = editor.getElementByKey(node.getKey());
216
+ if (element == null) {
217
+ return [];
218
+ }
219
+ const box = element.getBoundingClientRect();
220
+ // Inside the block's border, so the header lines up with the
221
+ // padding the theme reserves for it.
222
+ return [
223
+ {
224
+ key: node.getKey(),
225
+ language: displayLanguageOf(node.getLanguage()),
226
+ top: box.top - origin.top + element.clientTop,
227
+ left: box.left - origin.left + element.clientLeft,
228
+ width: element.clientWidth,
229
+ },
230
+ ];
231
+ }),
232
+ );
233
+ setPlacements(current =>
234
+ samePlacements(current, next) ? current : next,
235
+ );
236
+ };
237
+ const schedule = () => {
238
+ if (frame === 0) {
239
+ frame = requestAnimationFrame(measure);
240
+ }
241
+ };
242
+ const observer =
243
+ typeof ResizeObserver === 'undefined'
244
+ ? null
245
+ : new ResizeObserver(schedule);
246
+ const unregisterRoot = editor.registerRootListener(root => {
247
+ observer?.disconnect();
248
+ if (root != null) {
249
+ observer?.observe(root);
250
+ }
251
+ schedule();
252
+ });
253
+ const unregisterUpdate = editor.registerUpdateListener(schedule);
254
+ window.addEventListener('resize', schedule);
255
+ schedule();
256
+ return () => {
257
+ cancelAnimationFrame(frame);
258
+ observer?.disconnect();
259
+ unregisterRoot();
260
+ unregisterUpdate();
261
+ window.removeEventListener('resize', schedule);
262
+ };
263
+ }, [editor]);
264
+
265
+ return (
266
+ <>
267
+ {placements.map(placement => (
268
+ <CodeBlockHeader
269
+ key={placement.key}
270
+ placement={placement}
271
+ // A fence matched in `codeLanguages` shows the value's name, as
272
+ // Markdown does (spec:AST-061).
273
+ label={
274
+ placement.language == null
275
+ ? undefined
276
+ : listedLanguage(codeLanguages, placement.language)?.name
277
+ }
278
+ />
279
+ ))}
280
+ </>
281
+ );
282
+ }
@@ -0,0 +1,144 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file CodePastePlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
+ * lexical.
9
+ * @output Exports CodePastePlugin, which makes rich content pasted or dropped
10
+ * into a code block go in as plain lines of code, and $startsInCodeBlock,
11
+ * which TablePastePlugin uses to leave such a paste to it.
12
+ * @position Rendered by RichTextEditor; always on; not exported. A paste
13
+ * whose selection starts in a code block goes in as plain text: the
14
+ * clipboard's plain text, or else one line per pasted block.
15
+ */
16
+
17
+ import {useEffect} from 'react';
18
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
19
+ import {$isCodeNode} from '@lexical/code';
20
+ import {
21
+ $addUpdateTag,
22
+ $findMatchingParent,
23
+ $getSelection,
24
+ $isElementNode,
25
+ $isRangeSelection,
26
+ $isSelectionCapturedInDecoratorInput,
27
+ COMMAND_PRIORITY_LOW,
28
+ isDOMNode,
29
+ mergeRegister,
30
+ PASTE_COMMAND,
31
+ PASTE_TAG,
32
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
33
+ type BaseSelection,
34
+ type LexicalNode,
35
+ type RangeSelection,
36
+ } from 'lexical';
37
+
38
+ /** Clipboard types that carry more than plain text. */
39
+ const RICH_TYPES = ['text/html', 'application/x-lexical-editor'];
40
+
41
+ /** Whether content inserted at the selection lands in a code block. */
42
+ export function $startsInCodeBlock(
43
+ selection: BaseSelection | null,
44
+ ): selection is RangeSelection {
45
+ if (!$isRangeSelection(selection)) {
46
+ return false;
47
+ }
48
+ const start = selection.isBackward() ? selection.focus : selection.anchor;
49
+ const node = start.getNode();
50
+ // A link (an autolinked URL) can sit between the text and its code block.
51
+ return $findMatchingParent(node, $isCodeNode) !== null;
52
+ }
53
+
54
+ /**
55
+ * Clipboard nodes as plain lines: each block's text on a line of its own (a
56
+ * paragraph, a heading, a list item, a table cell), and inline content
57
+ * between blocks on one line.
58
+ */
59
+ function $plainLinesOf(nodes: ReadonlyArray<LexicalNode>): string {
60
+ const lines: string[] = [];
61
+ let inline: string | null = null;
62
+ const endInline = () => {
63
+ if (inline != null) {
64
+ lines.push(inline);
65
+ inline = null;
66
+ }
67
+ };
68
+ const visit = (node: LexicalNode) => {
69
+ if (node.isInline()) {
70
+ inline = (inline ?? '') + node.getTextContent();
71
+ return;
72
+ }
73
+ endInline();
74
+ if (
75
+ $isElementNode(node) &&
76
+ node.getChildren().some(child => !child.isInline())
77
+ ) {
78
+ node.getChildren().forEach(visit);
79
+ endInline();
80
+ } else {
81
+ lines.push(node.getTextContent());
82
+ }
83
+ };
84
+ nodes.forEach(visit);
85
+ endInline();
86
+ return lines.join('\n');
87
+ }
88
+
89
+ export function CodePastePlugin(): null {
90
+ const [editor] = useLexicalComposerContext();
91
+ useEffect(
92
+ () =>
93
+ mergeRegister(
94
+ // Ahead of the rich-text paste, which inserts the clipboard's rich
95
+ // content when there is any.
96
+ editor.registerCommand(
97
+ PASTE_COMMAND,
98
+ event => {
99
+ // A paste into an input a decorator node renders is that input's.
100
+ if (
101
+ isDOMNode(event.target) &&
102
+ $isSelectionCapturedInDecoratorInput(event.target)
103
+ ) {
104
+ return false;
105
+ }
106
+ const data = 'clipboardData' in event ? event.clipboardData : null;
107
+ if (
108
+ !editor.isEditable() ||
109
+ data == null ||
110
+ !RICH_TYPES.some(type => data.types.includes(type))
111
+ ) {
112
+ return false;
113
+ }
114
+ const text = data.getData('text/plain');
115
+ const selection = $getSelection();
116
+ if (text === '' || !$startsInCodeBlock(selection)) {
117
+ return false;
118
+ }
119
+ event.preventDefault();
120
+ // Its own undo step, as every paste is.
121
+ $addUpdateTag(PASTE_TAG);
122
+ selection.insertRawText(text);
123
+ return true;
124
+ },
125
+ COMMAND_PRIORITY_LOW,
126
+ ),
127
+ // Every insertion of clipboard nodes, a paste with no plain text or a
128
+ // drop, comes through here; ahead of the table's own handler.
129
+ editor.registerCommand(
130
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
131
+ ({nodes, selection}) => {
132
+ if (!$startsInCodeBlock(selection)) {
133
+ return false;
134
+ }
135
+ selection.insertRawText($plainLinesOf(nodes));
136
+ return true;
137
+ },
138
+ COMMAND_PRIORITY_LOW,
139
+ ),
140
+ ),
141
+ [editor],
142
+ );
143
+ return null;
144
+ }