@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,193 @@
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
+ import * as stylex from '@stylexjs/stylex';
26
+ import { useEffect, useId, useState } from 'react';
27
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
28
+ import { $isListItemNode, $isListNode } from '@lexical/list';
29
+ import { $getNodeByKey, $getRoot } from 'lexical';
30
+ import { CheckboxInput } from '@blue-patterns/core/CheckboxInput';
31
+ import { $getTaskState, $setTaskState, registerTaskListEditing } from "../markdownTaskList.js";
32
+
33
+ /** Where one task item's checkbox goes, in the container's coordinates. */
34
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
35
+ const styles = {
36
+ checkbox: {
37
+ kVAEAm: "x10l6tqk",
38
+ $$css: true
39
+ },
40
+ placement: (top, left) => [{
41
+ k87sOh: top != null ? "xjbys53" : top,
42
+ kbCHJM: left != null ? "xgq9j65" : left,
43
+ $$css: true
44
+ }, {
45
+ "--x-top": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(top),
46
+ "--x-left": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(left)
47
+ }]
48
+ };
49
+
50
+ /** The width of core CheckboxInput's small control. */
51
+ const CHECKBOX_SIZE = 20;
52
+
53
+ /** Marks a task item for the theme (editorTheme.ts). */
54
+ export const TASK_ATTRIBUTE = 'data-richtext-task';
55
+
56
+ /**
57
+ * Every bulleted list item with text of its own, in document order, and
58
+ * whether it is a task (markdownTaskList.ts).
59
+ */
60
+ function $bulletItems() {
61
+ const items = [];
62
+ const visit = node => {
63
+ if (!$isListNode(node)) {
64
+ return;
65
+ }
66
+ for (const item of node.getChildren()) {
67
+ if (!$isListItemNode(item)) {
68
+ continue;
69
+ }
70
+ const isWrapper = item.getChildrenSize() === 1 && $isListNode(item.getFirstChild());
71
+ if (!isWrapper && node.getListType() !== 'number') {
72
+ items.push({
73
+ item,
74
+ task: $getTaskState(item)
75
+ });
76
+ }
77
+ item.getChildren().forEach(visit);
78
+ }
79
+ };
80
+ $getRoot().getChildren().forEach(visit);
81
+ return items;
82
+ }
83
+ function samePlacements(a, b) {
84
+ return a.length === b.length && a.every((placement, index) => placement.key === b[index].key && placement.checked === b[index].checked && placement.label === b[index].label && placement.top === b[index].top && placement.left === b[index].left);
85
+ }
86
+ export function TaskCheckboxPlugin({
87
+ isReadOnly
88
+ }) {
89
+ const [editor] = useLexicalComposerContext();
90
+ const idPrefix = useId();
91
+ const [placements, setPlacements] = useState([]);
92
+
93
+ // Enter and Mod+Enter in task items (markdownTaskList.ts).
94
+ useEffect(() => isReadOnly ? undefined : registerTaskListEditing(editor), [editor, isReadOnly]);
95
+ useEffect(() => {
96
+ let frame = 0;
97
+ const measure = () => {
98
+ frame = 0;
99
+ const root = editor.getRootElement();
100
+ const container = root?.parentElement;
101
+ if (root == null || container == null) {
102
+ setPlacements(current => current.length === 0 ? current : []);
103
+ return;
104
+ }
105
+ const origin = container.getBoundingClientRect();
106
+ const next = editor.getEditorState().read(() => $bulletItems().flatMap(({
107
+ item,
108
+ task
109
+ }) => {
110
+ const element = editor.getElementByKey(item.getKey());
111
+ if (element == null) {
112
+ return [];
113
+ }
114
+ if (task == null) {
115
+ // A plain item keeps its marker and owns no checkbox.
116
+ element.removeAttribute(TASK_ATTRIBUTE);
117
+ element.removeAttribute('aria-owns');
118
+ return [];
119
+ }
120
+ // The checkbox below carries the item's state, so the item keeps
121
+ // the list item role Lexical replaces, and owns the checkbox. The
122
+ // attribute lets the theme drop its marker and make room.
123
+ element.setAttribute(TASK_ATTRIBUTE, '');
124
+ element.removeAttribute('role');
125
+ element.removeAttribute('aria-checked');
126
+ element.removeAttribute('tabindex');
127
+ element.setAttribute('aria-owns', `${idPrefix}-task-${item.getKey()}`);
128
+ const box = element.getBoundingClientRect();
129
+ const style = getComputedStyle(element);
130
+ const isRightToLeft = style.direction === 'rtl';
131
+ return [{
132
+ key: item.getKey(),
133
+ checked: task === 'checked',
134
+ label: item.getTextContent(),
135
+ top: box.top - origin.top + parseFloat(style.paddingTop || '0'),
136
+ left: (isRightToLeft ? box.right - CHECKBOX_SIZE : box.left) - origin.left
137
+ }];
138
+ }));
139
+ setPlacements(current => samePlacements(current, next) ? current : next);
140
+ };
141
+ const schedule = () => {
142
+ if (frame === 0) {
143
+ frame = requestAnimationFrame(measure);
144
+ }
145
+ };
146
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
147
+ const unregisterRoot = editor.registerRootListener(root => {
148
+ observer?.disconnect();
149
+ if (root != null) {
150
+ observer?.observe(root);
151
+ }
152
+ schedule();
153
+ });
154
+ const unregisterUpdate = editor.registerUpdateListener(schedule);
155
+ window.addEventListener('resize', schedule);
156
+ schedule();
157
+ return () => {
158
+ cancelAnimationFrame(frame);
159
+ observer?.disconnect();
160
+ unregisterRoot();
161
+ unregisterUpdate();
162
+ window.removeEventListener('resize', schedule);
163
+ };
164
+ }, [editor, idPrefix]);
165
+ return /*#__PURE__*/_jsx(_Fragment, {
166
+ children: placements.map(placement => /*#__PURE__*/_jsx("div", {
167
+ id: `${idPrefix}-task-${placement.key}`,
168
+ "data-richtext-task-checkbox": "",
169
+ ...stylex.props(styles.checkbox, styles.placement(placement.top, placement.left)),
170
+ children: /*#__PURE__*/_jsx(CheckboxInput, {
171
+ ref: input => {
172
+ // No tab stop after the document; see the file header.
173
+ if (input != null) {
174
+ input.tabIndex = -1;
175
+ }
176
+ },
177
+ size: "sm",
178
+ label: placement.label,
179
+ isLabelHidden: true,
180
+ value: placement.checked,
181
+ isReadOnly: isReadOnly,
182
+ onChange: checked => {
183
+ editor.update(() => {
184
+ const node = $getNodeByKey(placement.key);
185
+ if ($isListItemNode(node)) {
186
+ $setTaskState(node, checked ? 'checked' : 'unchecked');
187
+ }
188
+ });
189
+ }
190
+ })
191
+ }, placement.key))
192
+ });
193
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file ToolbarLinkPopover.tsx
3
+ * @input Uses React, @lexical/link, the lexical selection API, core Popover,
4
+ * useTranslator, linkEditing's toLinkHref, and linkForm's LinkForm.
5
+ * @output Exports useToolbarLink, the link popover's state and actions for an
6
+ * editor, ToolbarLinkPopover, the non-modal popover that holds the URL
7
+ * form, and isInsertLink, the Cmd/Ctrl+K test.
8
+ * @position Internal to @blue-patterns/richtext; the package entry exports
9
+ * none of it. A toolbar holds one link popover, anchored to its own Link
10
+ * button, so every Link button opens the same form with the same rules.
11
+ *
12
+ * SYNC: When modified, update:
13
+ * - /packages/richtext/src/RichTextEditorToolbar.tsx (its Link button)
14
+ * - /packages/richtext/src/RichTextEditorToolbarLink.test.tsx (tests)
15
+ */
16
+ import { type RefObject } from 'react';
17
+ import { type LexicalEditor } from 'lexical';
18
+ /**
19
+ * Whether `event` is the insert-link shortcut (Cmd/Ctrl+K). Uses Lexical's
20
+ * `isExactShortcutMatch`, which — unlike a loose `metaKey || ctrlKey` check —
21
+ * requires exactly the primary modifier and rejects the combo when other
22
+ * modifiers (Shift/Alt) are also held. Matches the playground's `isInsertLink`.
23
+ */
24
+ export declare function isInsertLink(event: KeyboardEvent): boolean;
25
+ /** The link popover's state and actions for one editor. */
26
+ export interface ToolbarLink {
27
+ /** Whether the popover is open. */
28
+ readonly isOpen: boolean;
29
+ /** Whether it opened for a link, which Remove then unlinks. */
30
+ readonly isEditing: boolean;
31
+ /** The field's value: the address being entered. */
32
+ readonly url: string;
33
+ /** The message for an address the rules refused, or empty. */
34
+ readonly error: string;
35
+ /**
36
+ * Opens the popover for the selection. With `promptForUrl`, asks it
37
+ * instead, or unlinks a selection that is in a link.
38
+ */
39
+ readonly toggle: () => void;
40
+ /** The popover's own open and close reports. */
41
+ readonly onOpenChange: (open: boolean) => void;
42
+ readonly onUrlChange: (url: string) => void;
43
+ /** Links the selection to the field's address; false when refused. */
44
+ readonly apply: () => boolean;
45
+ readonly remove: () => void;
46
+ readonly cancel: () => void;
47
+ }
48
+ /**
49
+ * The link popover's state for `editor`. It remembers the selection the
50
+ * popover opened for, because focus in the field takes the page's selection
51
+ * from the editor, and puts it back before linking, unlinking, or giving
52
+ * focus back to the editor. `buttonRef` is the Link button, which opening the
53
+ * popover scrolls into view.
54
+ */
55
+ export declare function useToolbarLink(editor: LexicalEditor, { buttonRef, hasNewTabLinks, promptForUrl, }: {
56
+ buttonRef: RefObject<HTMLElement | null>;
57
+ hasNewTabLinks: boolean;
58
+ promptForUrl?: (selectedText: string) => string | null | undefined;
59
+ }): ToolbarLink;
60
+ /**
61
+ * The non-modal popover a Link button opens, holding the URL form. It anchors
62
+ * to `anchorRef`, an element around the button that a press never scales,
63
+ * and finds the button inside it: it sets the button's popup state and opens
64
+ * on a press of it. Render it beside the toolbar that holds the button, not
65
+ * inside it: the toolbar is one roving tab stop, which would leave the form's
66
+ * controls out of Tab and take its arrow keys.
67
+ */
68
+ export declare function ToolbarLinkPopover({ link, anchorRef, size, }: {
69
+ link: ToolbarLink;
70
+ anchorRef: RefObject<HTMLElement | null>;
71
+ /** The toolbar's size, so the form's controls match its buttons. */
72
+ size: 'sm' | 'md' | 'lg';
73
+ }): import("react").JSX.Element;
74
+ //# sourceMappingURL=ToolbarLinkPopover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ToolbarLinkPopover.d.ts","sourceRoot":"","sources":["../../src/internal/ToolbarLinkPopover.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAK/E,OAAO,EASL,KAAK,aAAa,EAEnB,MAAM,SAAS,CAAC;AAWjB;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAE1D;AAWD,2DAA2D;AAC3D,MAAM,WAAW,WAAW;IAC1B,mCAAmC;IACnC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,oDAAoD;IACpD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;IAC5B,gDAAgD;IAChD,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/C,QAAQ,CAAC,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,sEAAsE;IACtE,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,aAAa,EACrB,EACE,SAAS,EACT,cAAc,EACd,YAAY,GACb,EAAE;IACD,SAAS,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACzC,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACpE,GACA,WAAW,CAiOb;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,IAAI,EACJ,SAAS,EACT,IAAI,GACL,EAAE;IACD,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACzC,oEAAoE;IACpE,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC1B,+BAuCA"}
@@ -0,0 +1,292 @@
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
+ import { useCallback, useEffect, useRef, useState } from 'react';
21
+ import { mergeRegister } from '@lexical/utils';
22
+ import { TOGGLE_LINK_COMMAND, $isLinkNode, $createLinkNode } from '@lexical/link';
23
+ import { Popover } from '@blue-patterns/core/Popover';
24
+ import { useTranslator } from '@blue-patterns/core/i18n';
25
+ import { SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, IS_APPLE, isExactShortcutMatch, $createTextNode, $getSelection, $isRangeSelection, $setSelection } from 'lexical';
26
+ import { toLinkHref } from "../linkEditing.js";
27
+ import { LinkForm } from "../linkForm.js";
28
+
29
+ /**
30
+ * The platform-primary modifier for shortcuts: Cmd on Apple, Ctrl elsewhere.
31
+ * Mirrors the Lexical playground's `CONTROL_OR_META`
32
+ * (packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts).
33
+ */
34
+ import { jsx as _jsx } from "react/jsx-runtime";
35
+ const CONTROL_OR_META = {
36
+ ctrlKey: !IS_APPLE,
37
+ metaKey: IS_APPLE
38
+ };
39
+
40
+ /**
41
+ * Whether `event` is the insert-link shortcut (Cmd/Ctrl+K). Uses Lexical's
42
+ * `isExactShortcutMatch`, which — unlike a loose `metaKey || ctrlKey` check —
43
+ * requires exactly the primary modifier and rejects the combo when other
44
+ * modifiers (Shift/Alt) are also held. Matches the playground's `isInsertLink`.
45
+ */
46
+ export function isInsertLink(event) {
47
+ return isExactShortcutMatch(event, 'k', CONTROL_OR_META);
48
+ }
49
+ /** Room for a typical address beside the link form's icon buttons. */
50
+ const LINK_POPOVER_WIDTH = 320;
51
+
52
+ /** The link popover's state and actions for one editor. */
53
+
54
+ /**
55
+ * The link popover's state for `editor`. It remembers the selection the
56
+ * popover opened for, because focus in the field takes the page's selection
57
+ * from the editor, and puts it back before linking, unlinking, or giving
58
+ * focus back to the editor. `buttonRef` is the Link button, which opening the
59
+ * popover scrolls into view.
60
+ */
61
+ export function useToolbarLink(editor, {
62
+ buttonRef,
63
+ hasNewTabLinks,
64
+ promptForUrl
65
+ }) {
66
+ const t = useTranslator();
67
+ const savedSelectionRef = useRef(null);
68
+ const lastLinkContextRef = useRef({
69
+ selectedText: '',
70
+ url: '',
71
+ isLink: false
72
+ });
73
+ const [isOpen, setIsOpen] = useState(false);
74
+ const [isEditing, setIsEditing] = useState(false);
75
+ const [url, setUrl] = useState('');
76
+ const [error, setError] = useState('');
77
+
78
+ // The popover acts on the last range selection. So a press that moved the
79
+ // editor's selection first still links the words that were selected.
80
+ const $noteSelection = useCallback(() => {
81
+ const selection = $getSelection();
82
+ if (!$isRangeSelection(selection)) {
83
+ return null;
84
+ }
85
+ const node = selection.anchor.getNode();
86
+ const parent = node.getParent();
87
+ const linkNode = $isLinkNode(node) ? node : $isLinkNode(parent) ? parent : null;
88
+ savedSelectionRef.current = selection.clone();
89
+ lastLinkContextRef.current = {
90
+ selectedText: selection.getTextContent(),
91
+ url: linkNode?.getURL() ?? '',
92
+ isLink: linkNode != null
93
+ };
94
+ return lastLinkContextRef.current;
95
+ }, []);
96
+
97
+ // Syncs with the Lexical editor's update and selection-change listeners.
98
+ useEffect(() => mergeRegister(editor.registerUpdateListener(({
99
+ editorState
100
+ }) => {
101
+ editorState.read($noteSelection);
102
+ }), editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
103
+ $noteSelection();
104
+ return false;
105
+ }, COMMAND_PRIORITY_CRITICAL)), [$noteSelection, editor]);
106
+ const readLinkContext = useCallback(() => editor.getEditorState().read($noteSelection) ?? lastLinkContextRef.current, [$noteSelection, editor]);
107
+ const restoreLinkSelection = useCallback(() => {
108
+ const savedSelection = savedSelectionRef.current;
109
+ if (!savedSelection) {
110
+ return;
111
+ }
112
+ editor.update(() => {
113
+ $setSelection(savedSelection.clone());
114
+ });
115
+ }, [editor]);
116
+ const applyLinkValue = useCallback(entered => {
117
+ const trimmed = entered.trim();
118
+ const href = toLinkHref(trimmed);
119
+ if (href == null) {
120
+ return false;
121
+ }
122
+ const linkAttributes = hasNewTabLinks ? {
123
+ target: '_blank',
124
+ rel: 'noopener noreferrer'
125
+ } : {};
126
+ const savedSelection = savedSelectionRef.current;
127
+ // One update restores the selection, decides, and applies. Inside a
128
+ // command listener (Cmd/Ctrl+K) Lexical queues an update, so a flag set
129
+ // in one update cannot steer code that runs after it.
130
+ editor.update(() => {
131
+ if (savedSelection) {
132
+ $setSelection(savedSelection.clone());
133
+ }
134
+ const selection = $getSelection();
135
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
136
+ const node = selection.anchor.getNode();
137
+ const isInLink = $isLinkNode(node) || $isLinkNode(node.getParent());
138
+ // At a caret outside a link there is no text to link, so the URL
139
+ // becomes the link's text. The toggle command would link the whole
140
+ // text node around the caret instead.
141
+ if (!isInLink) {
142
+ const linkNode = $createLinkNode(href, linkAttributes);
143
+ linkNode.append($createTextNode(trimmed));
144
+ selection.insertNodes([linkNode]);
145
+ return;
146
+ }
147
+ }
148
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
149
+ url: href,
150
+ ...linkAttributes
151
+ });
152
+ });
153
+ return true;
154
+ }, [editor, hasNewTabLinks]);
155
+ const toggle = useCallback(() => {
156
+ if (!editor.isEditable()) {
157
+ return;
158
+ }
159
+ const context = readLinkContext();
160
+ if (promptForUrl) {
161
+ // Preserve the existing synchronous extension point for consumers that
162
+ // already provide their own URL UI.
163
+ if (context.isLink) {
164
+ restoreLinkSelection();
165
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
166
+ return;
167
+ }
168
+ const entered = promptForUrl(context.selectedText);
169
+ if (entered != null && entered.trim() !== '') {
170
+ applyLinkValue(entered);
171
+ }
172
+ return;
173
+ }
174
+
175
+ // The field starts from the link's address, or from selected text that
176
+ // already reads as one; otherwise it is empty and shows its placeholder.
177
+ setUrl(context.url || (context.selectedText.startsWith('http') ? context.selectedText : ''));
178
+ setIsEditing(context.isLink);
179
+ setError('');
180
+ // Cmd/Ctrl+K can open the form while the Link button is scrolled out of
181
+ // view, in the page or in the toolbar's own row. Bring it back so the
182
+ // popover opens beside it.
183
+ buttonRef.current?.scrollIntoView?.({
184
+ block: 'nearest',
185
+ inline: 'nearest'
186
+ });
187
+ setIsOpen(true);
188
+ }, [applyLinkValue, buttonRef, editor, promptForUrl, readLinkContext, restoreLinkSelection]);
189
+ const close = useCallback(() => {
190
+ setIsOpen(false);
191
+ setError('');
192
+ }, []);
193
+
194
+ // The popover reports its own opening (a press on the Link button) and its
195
+ // closing (a press outside it).
196
+ const onOpenChange = useCallback(open => {
197
+ if (open) {
198
+ toggle();
199
+ } else {
200
+ close();
201
+ }
202
+ }, [close, toggle]);
203
+
204
+ // Closes the popover and gives the editor back its focus. The editable root
205
+ // takes focus first and Lexical then draws its selection into it, so the
206
+ // selection survives the field having held the page's selection.
207
+ const closeToEditor = useCallback(() => {
208
+ close();
209
+ editor.getRootElement()?.focus({
210
+ preventScroll: true
211
+ });
212
+ editor.focus();
213
+ }, [close, editor]);
214
+ const cancel = useCallback(() => {
215
+ restoreLinkSelection();
216
+ closeToEditor();
217
+ }, [closeToEditor, restoreLinkSelection]);
218
+ const apply = useCallback(() => {
219
+ if (!applyLinkValue(url)) {
220
+ setError(t('@astryx.richTextEditor.link.invalidUrl'));
221
+ return false;
222
+ }
223
+ closeToEditor();
224
+ return true;
225
+ }, [applyLinkValue, closeToEditor, t, url]);
226
+ const remove = useCallback(() => {
227
+ restoreLinkSelection();
228
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
229
+ closeToEditor();
230
+ }, [closeToEditor, editor, restoreLinkSelection]);
231
+ const onUrlChange = useCallback(value => {
232
+ setUrl(value);
233
+ setError('');
234
+ }, []);
235
+ return {
236
+ isOpen,
237
+ isEditing,
238
+ url,
239
+ error,
240
+ toggle,
241
+ onOpenChange,
242
+ onUrlChange,
243
+ apply,
244
+ remove,
245
+ cancel
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The non-modal popover a Link button opens, holding the URL form. It anchors
251
+ * to `anchorRef`, an element around the button that a press never scales,
252
+ * and finds the button inside it: it sets the button's popup state and opens
253
+ * on a press of it. Render it beside the toolbar that holds the button, not
254
+ * inside it: the toolbar is one roving tab stop, which would leave the form's
255
+ * controls out of Tab and take its arrow keys.
256
+ */
257
+ export function ToolbarLinkPopover({
258
+ link,
259
+ anchorRef,
260
+ size
261
+ }) {
262
+ const t = useTranslator();
263
+ return /*#__PURE__*/_jsx(Popover, {
264
+ anchorRef: anchorRef,
265
+ label: link.isEditing ? t('@astryx.richTextEditor.link.editTitle') : t('@astryx.richTextEditor.link.insertTitle'),
266
+ isOpen: link.isOpen,
267
+ onOpenChange: link.onOpenChange,
268
+ isModal: false
269
+ // Popover's focus entry focuses the URL field, the form's first
270
+ // control. Cancel is its close control.
271
+ ,
272
+ hasCloseButton: false
273
+ // Above the toolbar, so the popover does not cover the editor's
274
+ // first lines, where the linked words often are. It flips below
275
+ // only when there is no room above.
276
+ ,
277
+ placement: "above",
278
+ alignment: "start",
279
+ width: LINK_POPOVER_WIDTH,
280
+ padding: 2,
281
+ content: link.isOpen ? /*#__PURE__*/_jsx(LinkForm, {
282
+ url: link.url,
283
+ error: link.error,
284
+ isEditing: link.isEditing,
285
+ size: size,
286
+ onUrlChange: link.onUrlChange,
287
+ onApply: link.apply,
288
+ onRemove: link.remove,
289
+ onCancel: link.cancel
290
+ }) : null
291
+ });
292
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The address an entry becomes under the link rules (`sanitizeUrl`), or null
3
+ * when the rules refuse it.
4
+ */
5
+ export declare function toLinkHref(entered: string): string | null;
6
+ //# sourceMappingURL=linkEditing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linkEditing.d.ts","sourceRoot":"","sources":["../src/linkEditing.ts"],"names":[],"mappings":"AAYA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGzD"}
@@ -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.js";
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) {
18
+ const href = sanitizeUrl(entered.trim());
19
+ return href === 'about:blank' ? null : href;
20
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Remove's glyph. No shared icon name means "unlink", so it is fixed
3
+ * component artwork until component icon slots exist.
4
+ */
5
+ export declare const REMOVE_LINK_GLYPH: import("react").JSX.Element;
6
+ export interface LinkFormProps {
7
+ /** The field's value: the address being entered. */
8
+ url: string;
9
+ /** The message for a refused address, or empty. */
10
+ error: string;
11
+ /** Whether the form edits a link, which Remove then unlinks. */
12
+ isEditing: boolean;
13
+ /** The caller's control size, so the form's controls match it. */
14
+ size: 'sm' | 'md' | 'lg';
15
+ onUrlChange: (url: string) => void;
16
+ /** Applies the field's address; false when it was refused. */
17
+ onApply: () => boolean;
18
+ onRemove: () => void;
19
+ onCancel: () => void;
20
+ }
21
+ /**
22
+ * The link form: one URL field, then Apply, Remove (only when editing a
23
+ * link), and Cancel as icon buttons. Enter in the field
24
+ * applies, and Escape anywhere in the form cancels. A refused address shows
25
+ * the field's error status and keeps focus in the field.
26
+ */
27
+ export declare function LinkForm({ url, error, isEditing, size, onUrlChange, onApply, onRemove, onCancel, }: LinkFormProps): import("react").JSX.Element;
28
+ //# sourceMappingURL=linkForm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linkForm.d.ts","sourceRoot":"","sources":["../src/linkForm.tsx"],"names":[],"mappings":"AAqBA;;;GAGG;AACH,eAAO,MAAM,iBAAiB,6BAe7B,CAAC;AAEF,MAAM,WAAW,aAAa;IAC5B,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,SAAS,EAAE,OAAO,CAAC;IACnB,kEAAkE;IAClE,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,OAAO,CAAC;IACvB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,EACvB,GAAG,EACH,KAAK,EACL,SAAS,EACT,IAAI,EACJ,WAAW,EACX,OAAO,EACP,QAAQ,EACR,QAAQ,GACT,EAAE,aAAa,+BAkFf"}