@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,246 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file TaskCheckboxPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/list, lexical, and
8
+ * core CheckboxInput.
9
+ * @output Exports TaskCheckboxPlugin, which draws a real checkbox beside every
10
+ * task list item: toggling it, or Mod+Enter in the item, checks the item in
11
+ * the editor, and it is read-only in RichTextView.
12
+ * @position Rendered by RichTextEditor and RichTextView inside the positioned
13
+ * element that holds the content editable, like core Markdown's task lists
14
+ * (spec:AST-061 FR5). A list item cannot take the checkbox role (Lexical's
15
+ * own checkbox lists give it one), and nothing can be added inside the
16
+ * editable text; so the item stays a list item, marked for the theme to
17
+ * make room, and the checkbox is a sibling of the editable text, positioned
18
+ * over the space at the start of the item. Plain items in the same list
19
+ * keep their markers. The item owns its checkbox (`aria-owns`), so
20
+ * assistive technology finds the checkbox and its state inside the item, in
21
+ * document order. The checkboxes take no tab stop of their own, which would
22
+ * come after the whole document: in the editor, Mod+Enter checks the item
23
+ * holding the caret, and in the view they are read-only.
24
+ */
25
+
26
+ import * as stylex from '@stylexjs/stylex';
27
+ import {useEffect, useId, useState, type JSX} from 'react';
28
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
29
+ import {$isListItemNode, $isListNode, type ListItemNode} from '@lexical/list';
30
+ import {$getNodeByKey, $getRoot, type LexicalNode} from 'lexical';
31
+ import {CheckboxInput} from '@blue-patterns/core/CheckboxInput';
32
+ import {
33
+ $getTaskState,
34
+ $setTaskState,
35
+ registerTaskListEditing,
36
+ type TaskState,
37
+ } from '../markdownTaskList';
38
+
39
+ /** Where one task item's checkbox goes, in the container's coordinates. */
40
+ interface TaskPlacement {
41
+ readonly key: string;
42
+ readonly checked: boolean;
43
+ readonly label: string;
44
+ readonly top: number;
45
+ readonly left: number;
46
+ }
47
+
48
+ const styles = stylex.create({
49
+ checkbox: {
50
+ position: 'absolute',
51
+ },
52
+ placement: (top: number, left: number) => ({
53
+ top,
54
+ // eslint-disable-next-line @astryx/no-physical-properties -- intentional: `left` is a measured viewport coordinate, already resolved for direction above (`isRightToLeft ? box.right - CHECKBOX_SIZE : box.left`). `insetInlineStart` would flip the anchor edge again in RTL and double-correct the offset.
55
+ left,
56
+ }),
57
+ });
58
+
59
+ /** The width of core CheckboxInput's small control. */
60
+ const CHECKBOX_SIZE = 20;
61
+
62
+ /** Marks a task item for the theme (editorTheme.ts). */
63
+ export const TASK_ATTRIBUTE = 'data-richtext-task';
64
+
65
+ /**
66
+ * Every bulleted list item with text of its own, in document order, and
67
+ * whether it is a task (markdownTaskList.ts).
68
+ */
69
+ function $bulletItems(): Array<{
70
+ readonly item: ListItemNode;
71
+ readonly task: TaskState | null;
72
+ }> {
73
+ const items: Array<{item: ListItemNode; task: TaskState | null}> = [];
74
+ const visit = (node: LexicalNode) => {
75
+ if (!$isListNode(node)) {
76
+ return;
77
+ }
78
+ for (const item of node.getChildren()) {
79
+ if (!$isListItemNode(item)) {
80
+ continue;
81
+ }
82
+ const isWrapper =
83
+ item.getChildrenSize() === 1 && $isListNode(item.getFirstChild());
84
+ if (!isWrapper && node.getListType() !== 'number') {
85
+ items.push({item, task: $getTaskState(item)});
86
+ }
87
+ item.getChildren().forEach(visit);
88
+ }
89
+ };
90
+ $getRoot().getChildren().forEach(visit);
91
+ return items;
92
+ }
93
+
94
+ function samePlacements(
95
+ a: ReadonlyArray<TaskPlacement>,
96
+ b: ReadonlyArray<TaskPlacement>,
97
+ ): boolean {
98
+ return (
99
+ a.length === b.length &&
100
+ a.every(
101
+ (placement, index) =>
102
+ placement.key === b[index].key &&
103
+ placement.checked === b[index].checked &&
104
+ placement.label === b[index].label &&
105
+ placement.top === b[index].top &&
106
+ placement.left === b[index].left,
107
+ )
108
+ );
109
+ }
110
+
111
+ export function TaskCheckboxPlugin({
112
+ isReadOnly,
113
+ }: {
114
+ readonly isReadOnly: boolean;
115
+ }): JSX.Element {
116
+ const [editor] = useLexicalComposerContext();
117
+ const idPrefix = useId();
118
+ const [placements, setPlacements] = useState<ReadonlyArray<TaskPlacement>>(
119
+ [],
120
+ );
121
+
122
+ // Enter and Mod+Enter in task items (markdownTaskList.ts).
123
+ useEffect(
124
+ () => (isReadOnly ? undefined : registerTaskListEditing(editor)),
125
+ [editor, isReadOnly],
126
+ );
127
+
128
+ useEffect(() => {
129
+ let frame = 0;
130
+ const measure = () => {
131
+ frame = 0;
132
+ const root = editor.getRootElement();
133
+ const container = root?.parentElement;
134
+ if (root == null || container == null) {
135
+ setPlacements(current => (current.length === 0 ? current : []));
136
+ return;
137
+ }
138
+ const origin = container.getBoundingClientRect();
139
+ const next = editor.getEditorState().read(() =>
140
+ $bulletItems().flatMap(({item, task}) => {
141
+ const element = editor.getElementByKey(item.getKey());
142
+ if (element == null) {
143
+ return [];
144
+ }
145
+ if (task == null) {
146
+ // A plain item keeps its marker and owns no checkbox.
147
+ element.removeAttribute(TASK_ATTRIBUTE);
148
+ element.removeAttribute('aria-owns');
149
+ return [];
150
+ }
151
+ // The checkbox below carries the item's state, so the item keeps
152
+ // the list item role Lexical replaces, and owns the checkbox. The
153
+ // attribute lets the theme drop its marker and make room.
154
+ element.setAttribute(TASK_ATTRIBUTE, '');
155
+ element.removeAttribute('role');
156
+ element.removeAttribute('aria-checked');
157
+ element.removeAttribute('tabindex');
158
+ element.setAttribute(
159
+ 'aria-owns',
160
+ `${idPrefix}-task-${item.getKey()}`,
161
+ );
162
+ const box = element.getBoundingClientRect();
163
+ const style = getComputedStyle(element);
164
+ const isRightToLeft = style.direction === 'rtl';
165
+ return [
166
+ {
167
+ key: item.getKey(),
168
+ checked: task === 'checked',
169
+ label: item.getTextContent(),
170
+ top: box.top - origin.top + parseFloat(style.paddingTop || '0'),
171
+ left:
172
+ (isRightToLeft ? box.right - CHECKBOX_SIZE : box.left) -
173
+ origin.left,
174
+ },
175
+ ];
176
+ }),
177
+ );
178
+ setPlacements(current =>
179
+ samePlacements(current, next) ? current : next,
180
+ );
181
+ };
182
+ const schedule = () => {
183
+ if (frame === 0) {
184
+ frame = requestAnimationFrame(measure);
185
+ }
186
+ };
187
+ const observer =
188
+ typeof ResizeObserver === 'undefined'
189
+ ? null
190
+ : new ResizeObserver(schedule);
191
+ const unregisterRoot = editor.registerRootListener(root => {
192
+ observer?.disconnect();
193
+ if (root != null) {
194
+ observer?.observe(root);
195
+ }
196
+ schedule();
197
+ });
198
+ const unregisterUpdate = editor.registerUpdateListener(schedule);
199
+ window.addEventListener('resize', schedule);
200
+ schedule();
201
+ return () => {
202
+ cancelAnimationFrame(frame);
203
+ observer?.disconnect();
204
+ unregisterRoot();
205
+ unregisterUpdate();
206
+ window.removeEventListener('resize', schedule);
207
+ };
208
+ }, [editor, idPrefix]);
209
+
210
+ return (
211
+ <>
212
+ {placements.map(placement => (
213
+ <div
214
+ key={placement.key}
215
+ id={`${idPrefix}-task-${placement.key}`}
216
+ data-richtext-task-checkbox=""
217
+ {...stylex.props(
218
+ styles.checkbox,
219
+ styles.placement(placement.top, placement.left),
220
+ )}>
221
+ <CheckboxInput
222
+ ref={(input: HTMLInputElement | null) => {
223
+ // No tab stop after the document; see the file header.
224
+ if (input != null) {
225
+ input.tabIndex = -1;
226
+ }
227
+ }}
228
+ size="sm"
229
+ label={placement.label}
230
+ isLabelHidden
231
+ value={placement.checked}
232
+ isReadOnly={isReadOnly}
233
+ onChange={(checked: boolean) => {
234
+ editor.update(() => {
235
+ const node = $getNodeByKey(placement.key);
236
+ if ($isListItemNode(node)) {
237
+ $setTaskState(node, checked ? 'checked' : 'unchecked');
238
+ }
239
+ });
240
+ }}
241
+ />
242
+ </div>
243
+ ))}
244
+ </>
245
+ );
246
+ }
@@ -0,0 +1,392 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file ToolbarLinkPopover.tsx
7
+ * @input Uses React, @lexical/link, the lexical selection API, core Popover,
8
+ * useTranslator, linkEditing's toLinkHref, and linkForm's LinkForm.
9
+ * @output Exports useToolbarLink, the link popover's state and actions for an
10
+ * editor, ToolbarLinkPopover, the non-modal popover that holds the URL
11
+ * form, and isInsertLink, the Cmd/Ctrl+K test.
12
+ * @position Internal to @blue-patterns/richtext; the package entry exports
13
+ * none of it. A toolbar holds one link popover, anchored to its own Link
14
+ * button, so every Link button opens the same form with the same rules.
15
+ *
16
+ * SYNC: When modified, update:
17
+ * - /packages/richtext/src/RichTextEditorToolbar.tsx (its Link button)
18
+ * - /packages/richtext/src/RichTextEditorToolbarLink.test.tsx (tests)
19
+ */
20
+
21
+ import {useCallback, useEffect, useRef, useState, type RefObject} from 'react';
22
+ import {mergeRegister} from '@lexical/utils';
23
+ import {TOGGLE_LINK_COMMAND, $isLinkNode, $createLinkNode} from '@lexical/link';
24
+ import {Popover} from '@blue-patterns/core/Popover';
25
+ import {useTranslator} from '@blue-patterns/core/i18n';
26
+ import {
27
+ SELECTION_CHANGE_COMMAND,
28
+ COMMAND_PRIORITY_CRITICAL,
29
+ IS_APPLE,
30
+ isExactShortcutMatch,
31
+ $createTextNode,
32
+ $getSelection,
33
+ $isRangeSelection,
34
+ $setSelection,
35
+ type LexicalEditor,
36
+ type RangeSelection,
37
+ } from 'lexical';
38
+ import {toLinkHref} from '../linkEditing';
39
+ import {LinkForm} from '../linkForm';
40
+
41
+ /**
42
+ * The platform-primary modifier for shortcuts: Cmd on Apple, Ctrl elsewhere.
43
+ * Mirrors the Lexical playground's `CONTROL_OR_META`
44
+ * (packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts).
45
+ */
46
+ const CONTROL_OR_META = {ctrlKey: !IS_APPLE, metaKey: IS_APPLE};
47
+
48
+ /**
49
+ * Whether `event` is the insert-link shortcut (Cmd/Ctrl+K). Uses Lexical's
50
+ * `isExactShortcutMatch`, which — unlike a loose `metaKey || ctrlKey` check —
51
+ * requires exactly the primary modifier and rejects the combo when other
52
+ * modifiers (Shift/Alt) are also held. Matches the playground's `isInsertLink`.
53
+ */
54
+ export function isInsertLink(event: KeyboardEvent): boolean {
55
+ return isExactShortcutMatch(event, 'k', CONTROL_OR_META);
56
+ }
57
+
58
+ interface LinkContext {
59
+ selectedText: string;
60
+ url: string;
61
+ isLink: boolean;
62
+ }
63
+
64
+ /** Room for a typical address beside the link form's icon buttons. */
65
+ const LINK_POPOVER_WIDTH = 320;
66
+
67
+ /** The link popover's state and actions for one editor. */
68
+ export interface ToolbarLink {
69
+ /** Whether the popover is open. */
70
+ readonly isOpen: boolean;
71
+ /** Whether it opened for a link, which Remove then unlinks. */
72
+ readonly isEditing: boolean;
73
+ /** The field's value: the address being entered. */
74
+ readonly url: string;
75
+ /** The message for an address the rules refused, or empty. */
76
+ readonly error: string;
77
+ /**
78
+ * Opens the popover for the selection. With `promptForUrl`, asks it
79
+ * instead, or unlinks a selection that is in a link.
80
+ */
81
+ readonly toggle: () => void;
82
+ /** The popover's own open and close reports. */
83
+ readonly onOpenChange: (open: boolean) => void;
84
+ readonly onUrlChange: (url: string) => void;
85
+ /** Links the selection to the field's address; false when refused. */
86
+ readonly apply: () => boolean;
87
+ readonly remove: () => void;
88
+ readonly cancel: () => void;
89
+ }
90
+
91
+ /**
92
+ * The link popover's state for `editor`. It remembers the selection the
93
+ * popover opened for, because focus in the field takes the page's selection
94
+ * from the editor, and puts it back before linking, unlinking, or giving
95
+ * focus back to the editor. `buttonRef` is the Link button, which opening the
96
+ * popover scrolls into view.
97
+ */
98
+ export function useToolbarLink(
99
+ editor: LexicalEditor,
100
+ {
101
+ buttonRef,
102
+ hasNewTabLinks,
103
+ promptForUrl,
104
+ }: {
105
+ buttonRef: RefObject<HTMLElement | null>;
106
+ hasNewTabLinks: boolean;
107
+ promptForUrl?: (selectedText: string) => string | null | undefined;
108
+ },
109
+ ): ToolbarLink {
110
+ const t = useTranslator();
111
+ const savedSelectionRef = useRef<RangeSelection | null>(null);
112
+ const lastLinkContextRef = useRef<LinkContext>({
113
+ selectedText: '',
114
+ url: '',
115
+ isLink: false,
116
+ });
117
+ const [isOpen, setIsOpen] = useState(false);
118
+ const [isEditing, setIsEditing] = useState(false);
119
+ const [url, setUrl] = useState('');
120
+ const [error, setError] = useState('');
121
+
122
+ // The popover acts on the last range selection. So a press that moved the
123
+ // editor's selection first still links the words that were selected.
124
+ const $noteSelection = useCallback((): LinkContext | null => {
125
+ const selection = $getSelection();
126
+ if (!$isRangeSelection(selection)) {
127
+ return null;
128
+ }
129
+ const node = selection.anchor.getNode();
130
+ const parent = node.getParent();
131
+ const linkNode = $isLinkNode(node)
132
+ ? node
133
+ : $isLinkNode(parent)
134
+ ? parent
135
+ : null;
136
+ savedSelectionRef.current = selection.clone();
137
+ lastLinkContextRef.current = {
138
+ selectedText: selection.getTextContent(),
139
+ url: linkNode?.getURL() ?? '',
140
+ isLink: linkNode != null,
141
+ };
142
+ return lastLinkContextRef.current;
143
+ }, []);
144
+
145
+ // Syncs with the Lexical editor's update and selection-change listeners.
146
+ useEffect(
147
+ () =>
148
+ mergeRegister(
149
+ editor.registerUpdateListener(({editorState}) => {
150
+ editorState.read($noteSelection);
151
+ }),
152
+ editor.registerCommand(
153
+ SELECTION_CHANGE_COMMAND,
154
+ () => {
155
+ $noteSelection();
156
+ return false;
157
+ },
158
+ COMMAND_PRIORITY_CRITICAL,
159
+ ),
160
+ ),
161
+ [$noteSelection, editor],
162
+ );
163
+
164
+ const readLinkContext = useCallback(
165
+ (): LinkContext =>
166
+ editor.getEditorState().read($noteSelection) ??
167
+ lastLinkContextRef.current,
168
+ [$noteSelection, editor],
169
+ );
170
+
171
+ const restoreLinkSelection = useCallback(() => {
172
+ const savedSelection = savedSelectionRef.current;
173
+ if (!savedSelection) {
174
+ return;
175
+ }
176
+ editor.update(() => {
177
+ $setSelection(savedSelection.clone());
178
+ });
179
+ }, [editor]);
180
+
181
+ const applyLinkValue = useCallback(
182
+ (entered: string): boolean => {
183
+ const trimmed = entered.trim();
184
+ const href = toLinkHref(trimmed);
185
+ if (href == null) {
186
+ return false;
187
+ }
188
+
189
+ const linkAttributes = hasNewTabLinks
190
+ ? {target: '_blank', rel: 'noopener noreferrer'}
191
+ : {};
192
+ const savedSelection = savedSelectionRef.current;
193
+ // One update restores the selection, decides, and applies. Inside a
194
+ // command listener (Cmd/Ctrl+K) Lexical queues an update, so a flag set
195
+ // in one update cannot steer code that runs after it.
196
+ editor.update(() => {
197
+ if (savedSelection) {
198
+ $setSelection(savedSelection.clone());
199
+ }
200
+ const selection = $getSelection();
201
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
202
+ const node = selection.anchor.getNode();
203
+ const isInLink = $isLinkNode(node) || $isLinkNode(node.getParent());
204
+ // At a caret outside a link there is no text to link, so the URL
205
+ // becomes the link's text. The toggle command would link the whole
206
+ // text node around the caret instead.
207
+ if (!isInLink) {
208
+ const linkNode = $createLinkNode(href, linkAttributes);
209
+ linkNode.append($createTextNode(trimmed));
210
+ selection.insertNodes([linkNode]);
211
+ return;
212
+ }
213
+ }
214
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
215
+ url: href,
216
+ ...linkAttributes,
217
+ });
218
+ });
219
+ return true;
220
+ },
221
+ [editor, hasNewTabLinks],
222
+ );
223
+
224
+ const toggle = useCallback(() => {
225
+ if (!editor.isEditable()) {
226
+ return;
227
+ }
228
+
229
+ const context = readLinkContext();
230
+ if (promptForUrl) {
231
+ // Preserve the existing synchronous extension point for consumers that
232
+ // already provide their own URL UI.
233
+ if (context.isLink) {
234
+ restoreLinkSelection();
235
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
236
+ return;
237
+ }
238
+ const entered = promptForUrl(context.selectedText);
239
+ if (entered != null && entered.trim() !== '') {
240
+ applyLinkValue(entered);
241
+ }
242
+ return;
243
+ }
244
+
245
+ // The field starts from the link's address, or from selected text that
246
+ // already reads as one; otherwise it is empty and shows its placeholder.
247
+ setUrl(
248
+ context.url ||
249
+ (context.selectedText.startsWith('http') ? context.selectedText : ''),
250
+ );
251
+ setIsEditing(context.isLink);
252
+ setError('');
253
+ // Cmd/Ctrl+K can open the form while the Link button is scrolled out of
254
+ // view, in the page or in the toolbar's own row. Bring it back so the
255
+ // popover opens beside it.
256
+ buttonRef.current?.scrollIntoView?.({
257
+ block: 'nearest',
258
+ inline: 'nearest',
259
+ });
260
+ setIsOpen(true);
261
+ }, [
262
+ applyLinkValue,
263
+ buttonRef,
264
+ editor,
265
+ promptForUrl,
266
+ readLinkContext,
267
+ restoreLinkSelection,
268
+ ]);
269
+
270
+ const close = useCallback(() => {
271
+ setIsOpen(false);
272
+ setError('');
273
+ }, []);
274
+
275
+ // The popover reports its own opening (a press on the Link button) and its
276
+ // closing (a press outside it).
277
+ const onOpenChange = useCallback(
278
+ (open: boolean) => {
279
+ if (open) {
280
+ toggle();
281
+ } else {
282
+ close();
283
+ }
284
+ },
285
+ [close, toggle],
286
+ );
287
+
288
+ // Closes the popover and gives the editor back its focus. The editable root
289
+ // takes focus first and Lexical then draws its selection into it, so the
290
+ // selection survives the field having held the page's selection.
291
+ const closeToEditor = useCallback(() => {
292
+ close();
293
+ editor.getRootElement()?.focus({preventScroll: true});
294
+ editor.focus();
295
+ }, [close, editor]);
296
+
297
+ const cancel = useCallback(() => {
298
+ restoreLinkSelection();
299
+ closeToEditor();
300
+ }, [closeToEditor, restoreLinkSelection]);
301
+
302
+ const apply = useCallback((): boolean => {
303
+ if (!applyLinkValue(url)) {
304
+ setError(t('@astryx.richTextEditor.link.invalidUrl'));
305
+ return false;
306
+ }
307
+ closeToEditor();
308
+ return true;
309
+ }, [applyLinkValue, closeToEditor, t, url]);
310
+
311
+ const remove = useCallback(() => {
312
+ restoreLinkSelection();
313
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
314
+ closeToEditor();
315
+ }, [closeToEditor, editor, restoreLinkSelection]);
316
+
317
+ const onUrlChange = useCallback((value: string) => {
318
+ setUrl(value);
319
+ setError('');
320
+ }, []);
321
+
322
+ return {
323
+ isOpen,
324
+ isEditing,
325
+ url,
326
+ error,
327
+ toggle,
328
+ onOpenChange,
329
+ onUrlChange,
330
+ apply,
331
+ remove,
332
+ cancel,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * The non-modal popover a Link button opens, holding the URL form. It anchors
338
+ * to `anchorRef`, an element around the button that a press never scales,
339
+ * and finds the button inside it: it sets the button's popup state and opens
340
+ * on a press of it. Render it beside the toolbar that holds the button, not
341
+ * inside it: the toolbar is one roving tab stop, which would leave the form's
342
+ * controls out of Tab and take its arrow keys.
343
+ */
344
+ export function ToolbarLinkPopover({
345
+ link,
346
+ anchorRef,
347
+ size,
348
+ }: {
349
+ link: ToolbarLink;
350
+ anchorRef: RefObject<HTMLElement | null>;
351
+ /** The toolbar's size, so the form's controls match its buttons. */
352
+ size: 'sm' | 'md' | 'lg';
353
+ }) {
354
+ const t = useTranslator();
355
+ return (
356
+ <Popover
357
+ anchorRef={anchorRef as RefObject<HTMLElement>}
358
+ label={
359
+ link.isEditing
360
+ ? t('@astryx.richTextEditor.link.editTitle')
361
+ : t('@astryx.richTextEditor.link.insertTitle')
362
+ }
363
+ isOpen={link.isOpen}
364
+ onOpenChange={link.onOpenChange}
365
+ isModal={false}
366
+ // Popover's focus entry focuses the URL field, the form's first
367
+ // control. Cancel is its close control.
368
+ hasCloseButton={false}
369
+ // Above the toolbar, so the popover does not cover the editor's
370
+ // first lines, where the linked words often are. It flips below
371
+ // only when there is no room above.
372
+ placement="above"
373
+ alignment="start"
374
+ width={LINK_POPOVER_WIDTH}
375
+ padding={2}
376
+ content={
377
+ link.isOpen ? (
378
+ <LinkForm
379
+ url={link.url}
380
+ error={link.error}
381
+ isEditing={link.isEditing}
382
+ size={size}
383
+ onUrlChange={link.onUrlChange}
384
+ onApply={link.apply}
385
+ onRemove={link.remove}
386
+ onCancel={link.cancel}
387
+ />
388
+ ) : null
389
+ }
390
+ />
391
+ );
392
+ }
@@ -0,0 +1,20 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file linkEditing.ts
5
+ * @input An entered address
6
+ * @output Exports toLinkHref
7
+ * @position Internal to RichText. The link rules RichTextEditorToolbar's link
8
+ * popover applies: which address an entry becomes.
9
+ */
10
+
11
+ import {sanitizeUrl} from './linkUtils';
12
+
13
+ /**
14
+ * The address an entry becomes under the link rules (`sanitizeUrl`), or null
15
+ * when the rules refuse it.
16
+ */
17
+ export function toLinkHref(entered: string): string | null {
18
+ const href = sanitizeUrl(entered.trim());
19
+ return href === 'about:blank' ? null : href;
20
+ }