@astryxdesign/richtext 0.7.12-canary.ccb4a6d → 0.7.12-canary.da3dbdc

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.
package/README.md CHANGED
@@ -74,6 +74,9 @@ if your app has its own reset) loads first.
74
74
  Backspace at the very start of a code block turns it into a paragraph of its
75
75
  lines, instead of merging the code into the block above.
76
76
 
77
+ Pasting rich content into a code block inserts its plain text, one line per
78
+ block: headings, lists, tables, and formatting never land inside the code.
79
+
77
80
  ## Examples
78
81
 
79
82
  Interactive examples live in Storybook under **RichText/RichTextEditor**
@@ -0,0 +1,2 @@
1
+ export declare function CodePastePlugin(): null;
2
+ //# sourceMappingURL=CodePastePlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CodePastePlugin.d.ts","sourceRoot":"","sources":["../src/CodePastePlugin.tsx"],"names":[],"mappings":"AAuFA,wBAAgB,eAAe,IAAI,IAAI,CAuDtC"}
@@ -0,0 +1,103 @@
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.
11
+ * @position Rendered by RichTextEditor; always on; not exported. A paste
12
+ * whose selection starts in a code block goes in as plain text: the
13
+ * clipboard's plain text, or else one line per pasted block.
14
+ */
15
+ import { useEffect } from 'react';
16
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
17
+ import { $isCodeNode } from '@lexical/code';
18
+ import { $addUpdateTag, $findMatchingParent, $getSelection, $isElementNode, $isRangeSelection, $isSelectionCapturedInDecoratorInput, COMMAND_PRIORITY_LOW, isDOMNode, mergeRegister, PASTE_COMMAND, PASTE_TAG, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND } from 'lexical';
19
+
20
+ /** Clipboard types that carry more than plain text. */
21
+ const RICH_TYPES = ['text/html', 'application/x-lexical-editor'];
22
+
23
+ /** Whether content inserted at the selection lands in a code block. */
24
+ function $startsInCodeBlock(selection) {
25
+ if (!$isRangeSelection(selection)) {
26
+ return false;
27
+ }
28
+ const start = selection.isBackward() ? selection.focus : selection.anchor;
29
+ const node = start.getNode();
30
+ // A link (an autolinked URL) can sit between the text and its code block.
31
+ return $findMatchingParent(node, $isCodeNode) !== null;
32
+ }
33
+
34
+ /**
35
+ * Clipboard nodes as plain lines: each block's text on a line of its own (a
36
+ * paragraph, a heading, a list item, a table cell), and inline content
37
+ * between blocks on one line.
38
+ */
39
+ function $plainLinesOf(nodes) {
40
+ const lines = [];
41
+ let inline = null;
42
+ const endInline = () => {
43
+ if (inline != null) {
44
+ lines.push(inline);
45
+ inline = null;
46
+ }
47
+ };
48
+ const visit = node => {
49
+ if (node.isInline()) {
50
+ inline = (inline ?? '') + node.getTextContent();
51
+ return;
52
+ }
53
+ endInline();
54
+ if ($isElementNode(node) && node.getChildren().some(child => !child.isInline())) {
55
+ node.getChildren().forEach(visit);
56
+ endInline();
57
+ } else {
58
+ lines.push(node.getTextContent());
59
+ }
60
+ };
61
+ nodes.forEach(visit);
62
+ endInline();
63
+ return lines.join('\n');
64
+ }
65
+ export function CodePastePlugin() {
66
+ const [editor] = useLexicalComposerContext();
67
+ useEffect(() => mergeRegister(
68
+ // Ahead of the rich-text paste, which inserts the clipboard's rich
69
+ // content when there is any.
70
+ editor.registerCommand(PASTE_COMMAND, event => {
71
+ // A paste into an input a decorator node renders is that input's.
72
+ if (isDOMNode(event.target) && $isSelectionCapturedInDecoratorInput(event.target)) {
73
+ return false;
74
+ }
75
+ const data = 'clipboardData' in event ? event.clipboardData : null;
76
+ if (!editor.isEditable() || data == null || !RICH_TYPES.some(type => data.types.includes(type))) {
77
+ return false;
78
+ }
79
+ const text = data.getData('text/plain');
80
+ const selection = $getSelection();
81
+ if (text === '' || !$startsInCodeBlock(selection)) {
82
+ return false;
83
+ }
84
+ event.preventDefault();
85
+ // Its own undo step, as every paste is.
86
+ $addUpdateTag(PASTE_TAG);
87
+ selection.insertRawText(text);
88
+ return true;
89
+ }, COMMAND_PRIORITY_LOW),
90
+ // Every insertion of clipboard nodes, a paste with no plain text or a
91
+ // drop, comes through here; ahead of the table's own handler.
92
+ editor.registerCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ({
93
+ nodes,
94
+ selection
95
+ }) => {
96
+ if (!$startsInCodeBlock(selection)) {
97
+ return false;
98
+ }
99
+ selection.insertRawText($plainLinesOf(nodes));
100
+ return true;
101
+ }, COMMAND_PRIORITY_LOW)), [editor]);
102
+ return null;
103
+ }
@@ -0,0 +1,4 @@
1
+ export declare function DisabledLinkGuardPlugin({ isDisabled }: {
2
+ isDisabled: boolean;
3
+ }): null;
4
+ //# sourceMappingURL=DisabledLinkGuardPlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DisabledLinkGuardPlugin.d.ts","sourceRoot":"","sources":["../src/DisabledLinkGuardPlugin.tsx"],"names":[],"mappings":"AAoEA,wBAAgB,uBAAuB,CAAC,EAAC,UAAU,EAAC,EAAE;IAAC,UAAU,EAAE,OAAO,CAAA;CAAC,QAwB1E"}
@@ -0,0 +1,93 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file DisabledLinkGuardPlugin.tsx
7
+ * @input The composer's editor, and whether RichTextEditor is disabled
8
+ * @output DisabledLinkGuardPlugin (internal; not re-exported from index.ts):
9
+ * while the editor is disabled, its links add no tab stop and never navigate
10
+ * @position Rendered by RichTextEditor inside its LexicalExtensionComposer
11
+ *
12
+ * A disabled editor is not editable, so the browser treats each link in it as
13
+ * a live link: a tab stop that navigates on a click or on Enter. A disabled
14
+ * control must not navigate, and it keeps the focus order it has when enabled,
15
+ * where its links are not tab stops.
16
+ */
17
+ import { useEffect } from 'react';
18
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
19
+
20
+ // Enter on a focused link and a screen reader's activation arrive as a click.
21
+ // A middle click arrives as auxclick.
22
+ function preventLinkActivation(event) {
23
+ if (event.target instanceof Element && event.target.closest('a[href]')) {
24
+ event.preventDefault();
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Guards the links under one root element. It records the links it changes,
30
+ * so it restores only those.
31
+ */
32
+ function createLinkGuard(root) {
33
+ const guarded = new Set();
34
+ const guard = () => {
35
+ for (const link of root.querySelectorAll('a[href]')) {
36
+ if (!guarded.has(link)) {
37
+ guarded.add(link);
38
+ link.setAttribute('tabindex', '-1');
39
+ // Assistive technology announces each link as disabled. Only the
40
+ // link ignores the pointer, so a wide table still scrolls.
41
+ link.setAttribute('aria-disabled', 'true');
42
+ if (link instanceof HTMLElement) {
43
+ link.style.pointerEvents = 'none';
44
+ }
45
+ }
46
+ }
47
+ };
48
+ guard();
49
+ root.addEventListener('click', preventLinkActivation, true);
50
+ root.addEventListener('auxclick', preventLinkActivation, true);
51
+ return {
52
+ guard,
53
+ release() {
54
+ for (const link of guarded) {
55
+ link.removeAttribute('tabindex');
56
+ link.removeAttribute('aria-disabled');
57
+ if (link instanceof HTMLElement) {
58
+ link.style.removeProperty('pointer-events');
59
+ }
60
+ }
61
+ guarded.clear();
62
+ root.removeEventListener('click', preventLinkActivation, true);
63
+ root.removeEventListener('auxclick', preventLinkActivation, true);
64
+ }
65
+ };
66
+ }
67
+ export function DisabledLinkGuardPlugin({
68
+ isDisabled
69
+ }) {
70
+ const [editor] = useLexicalComposerContext();
71
+ // Synchronizes the editor's link elements, which Lexical renders and owns,
72
+ // with the disabled state: guarded while disabled, restored when enabled.
73
+ useEffect(() => {
74
+ if (!isDisabled) {
75
+ return undefined;
76
+ }
77
+ let linkGuard = null;
78
+ const unregisterRoot = editor.registerRootListener(next => {
79
+ linkGuard?.release();
80
+ linkGuard = next == null ? null : createLinkGuard(next);
81
+ });
82
+ // A new or changed document renders new link elements.
83
+ const unregisterUpdate = editor.registerUpdateListener(() => {
84
+ linkGuard?.guard();
85
+ });
86
+ return () => {
87
+ unregisterUpdate();
88
+ unregisterRoot();
89
+ linkGuard?.release();
90
+ };
91
+ }, [editor, isDisabled]);
92
+ return null;
93
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file RichTextEditor.tsx
3
- * @input Uses React, useId, Lexical (lexical + @lexical/react, composed through
3
+ * @input Uses React, useId, a declared DOM boundary, Lexical (lexical + @lexical/react, composed through
4
4
  * LexicalExtensionComposer), Field, VisuallyHidden, useInputStatusIcon,
5
5
  * mergeProps, design tokens
6
6
  * @output Exports an accessibly labelled RichTextEditor component with a flush
@@ -122,7 +122,8 @@ export interface RichTextEditorProps extends Omit<BaseProps, 'onChange' | 'defau
122
122
  isReadOnly?: boolean;
123
123
  /**
124
124
  * Whether the editor is disabled: non-editable, dimmed, out of the tab
125
- * order, and announced as disabled. Wins when `isReadOnly` is also set.
125
+ * order, and announced as disabled. Its links do not navigate and are not
126
+ * tab stops. Wins when `isReadOnly` is also set.
126
127
  * Takes effect when changed after mount.
127
128
  * @default false
128
129
  */
@@ -1 +1 @@
1
- {"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file RichTextEditor.tsx
7
- * @input Uses React, useId, Lexical (lexical + @lexical/react, composed through
7
+ * @input Uses React, useId, a declared DOM boundary, Lexical (lexical + @lexical/react, composed through
8
8
  * LexicalExtensionComposer), Field, VisuallyHidden, useInputStatusIcon,
9
9
  * mergeProps, design tokens
10
10
  * @output Exports an accessibly labelled RichTextEditor component with a flush
@@ -51,9 +51,11 @@ import { TextSemanticsExtension } from "./textSemantics.js";
51
51
  import { TableColumnFloorPlugin } from "./TableColumnFloorPlugin.js";
52
52
  import { CodeBackspacePlugin } from "./CodeBackspacePlugin.js";
53
53
  import { CodeBlockHeaderPlugin } from "./CodeBlockHeaderPlugin.js";
54
+ import { CodePastePlugin } from "./CodePastePlugin.js";
54
55
  import { CodeSyntaxPlugin } from "./CodeSyntaxPlugin.js";
55
56
  import { MarkdownExtensionsPlugin } from "./MarkdownExtensionsPlugin.js";
56
57
  import { TaskCheckboxPlugin } from "./TaskCheckboxPlugin.js";
58
+ import { DisabledLinkGuardPlugin } from "./DisabledLinkGuardPlugin.js";
57
59
  import { SelectAllScopePlugin } from "./SelectAllScopePlugin.js";
58
60
  import { ImageSourceCaretPlugin } from "./ImageSourceCaretPlugin.js";
59
61
  import { $generateHtmlFromNodes } from '@lexical/html';
@@ -61,6 +63,7 @@ import { DEFAULT_NODES } from "./editorNodes.js";
61
63
  import { $exportMarkdownKeepingSource } from "./markdownSource.js";
62
64
  import { DEFAULT_TRANSFORMERS } from "./markdownTable.js";
63
65
  import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, defineExtension, KEY_DOWN_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, mergeRegister } from 'lexical';
66
+ import { rendererManagedRootProps } from "./privateAttributes/layerPortalBoundary.stylex.js";
64
67
 
65
68
  /**
66
69
  * Serialized state for an empty editor: a root containing a single empty
@@ -295,7 +298,7 @@ export const RichTextEditor = function RichTextEditor({
295
298
  ...mergeProps(themeProps('rich-text-editor', {
296
299
  size,
297
300
  status: status?.type ?? null
298
- }), stylex.props(inputWrapperStyles.base, styles.wrapper,
301
+ }), stylex.props(inputWrapperStyles.base, inputWrapperStyles.textField, styles.wrapper,
299
302
  // Only disabled is dimmed, matching TextArea: a read-only editor
300
303
  // keeps full-opacity text and stays keyboard-reachable.
301
304
  isDisabled && inputWrapperStyles.disabled, isDisabled && styles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && !isReadOnly && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], xstyle), className, style),
@@ -333,6 +336,7 @@ export const RichTextEditor = function RichTextEditor({
333
336
  ...{
334
337
  className: "x1n2onr6 xh8yej3 x9ynric xjm74w1 xf9zqsd xw6l6zx"
335
338
  },
339
+ dir: rest.dir,
336
340
  children: [/*#__PURE__*/_jsx(RichTextPlugin, {
337
341
  contentEditable: /*#__PURE__*/_jsx(EditorContentEditable, {
338
342
  id: inputID,
@@ -352,7 +356,9 @@ export const RichTextEditor = function RichTextEditor({
352
356
  ErrorBoundary: LexicalErrorBoundary
353
357
  }), /*#__PURE__*/_jsx(HistoryPlugin, {}), /*#__PURE__*/_jsx(ListPlugin, {}), /*#__PURE__*/_jsx(TaskCheckboxPlugin, {
354
358
  isReadOnly: isReadOnly || isDisabled
355
- }), /*#__PURE__*/_jsx(LinkPlugin, {}), /*#__PURE__*/_jsx(TablePlugin, {
359
+ }), /*#__PURE__*/_jsx(LinkPlugin, {}), /*#__PURE__*/_jsx(DisabledLinkGuardPlugin, {
360
+ isDisabled: isDisabled
361
+ }), /*#__PURE__*/_jsx(TablePlugin, {
356
362
  hasCellMerge: false,
357
363
  hasCellBackgroundColor: false,
358
364
  hasTabHandler: false,
@@ -361,7 +367,7 @@ export const RichTextEditor = function RichTextEditor({
361
367
  codeLanguages: codeLanguages
362
368
  }), /*#__PURE__*/_jsx(CodeSyntaxPlugin, {
363
369
  codeLanguages: codeLanguages
364
- }), /*#__PURE__*/_jsx(CodeBackspacePlugin, {}), markdownExtensions != null && markdownExtensions.length > 0 ? /*#__PURE__*/_jsx(MarkdownExtensionsPlugin, {
370
+ }), /*#__PURE__*/_jsx(CodeBackspacePlugin, {}), /*#__PURE__*/_jsx(CodePastePlugin, {}), markdownExtensions != null && markdownExtensions.length > 0 ? /*#__PURE__*/_jsx(MarkdownExtensionsPlugin, {
365
371
  extensions: markdownExtensions,
366
372
  transformers: markdownTransformers
367
373
  }) : null, /*#__PURE__*/_jsx(TabIndentationPlugin, {}), /*#__PURE__*/_jsx(TabFocusEscapePlugin, {}), /*#__PURE__*/_jsx(SelectAllScopePlugin, {}), /*#__PURE__*/_jsx(ImageSourceCaretPlugin, {}), hasMarkdownShortcuts && /*#__PURE__*/_jsx(MarkdownShortcutPlugin, {
@@ -621,7 +627,9 @@ function EditorContentEditable({
621
627
  'aria-readonly': 'true'
622
628
  } : null),
623
629
  ...stylex.props(styles.contentEditable, dynamicStyles.contentEditableMinHeight(minHeight)),
624
- ...rest
630
+ ...rest,
631
+ // Lexical reconciles this DOM, so Layer keeps corrective portals out.
632
+ ...rendererManagedRootProps
625
633
  };
626
634
  if (placeholderText) {
627
635
  return /*#__PURE__*/_jsx(ContentEditable, {
@@ -6,8 +6,9 @@
6
6
  * @output Exports `RichTextEditorAutoLinkPlugin` and its props type, plus the
7
7
  * default `DEFAULT_LINK_MATCHERS`.
8
8
  * @position @astryxdesign/richtext. Drop into RichTextEditor's `plugins` slot to
9
- * auto-linkify URLs and emails as they are typed or pasted. Requires
10
- * `AutoLinkNode` to be registered (it is, by default).
9
+ * auto-linkify URLs and emails as they are typed or pasted, except inside
10
+ * a code block. Requires `AutoLinkNode` to be registered (it is, by
11
+ * default).
11
12
  *
12
13
  * SYNC: When modified, update:
13
14
  * - /packages/richtext/src/index.ts (exports / barrel)
@@ -24,9 +25,8 @@
24
25
  * and new-tab defaults, so a consumer supplies a richer matcher set — an
25
26
  * application-specific identifier or ticket syntax, say — through the
26
27
  * `matchers` prop without changing the contract. An application needing a
27
- * hardened transform (expanded separators, code-block exclusion) substitutes
28
- * its own plugin in the same slot; astryx stays on the upstream
29
- * `AutoLinkPlugin`.
28
+ * hardened transform (expanded separators, say) substitutes its own plugin in
29
+ * the same slot; astryx stays on the upstream `AutoLinkPlugin`.
30
30
  */
31
31
  import { type LinkMatcher } from '@lexical/react/LexicalAutoLinkPlugin';
32
32
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"RichTextEditorAutoLinkPlugin.d.ts","sourceRoot":"","sources":["../src/RichTextEditorAutoLinkPlugin.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sCAAsC,CAAC;AAG9C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB;;;CAG1B,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,WAAW,EAc9C,CAAC;AAEF,MAAM,WAAW,iCAAiC;IAChD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;CACjE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,4BAA4B,CAAC,EAC3C,QAAgC,EAChC,QAAQ,GACT,EAAE,iCAAiC,+BAEnC;yBALe,4BAA4B"}
1
+ {"version":3,"file":"RichTextEditorAutoLinkPlugin.d.ts","sourceRoot":"","sources":["../src/RichTextEditorAutoLinkPlugin.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sCAAsC,CAAC;AAK9C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB;;;CAG1B,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,WAAW,EAc9C,CAAC;AAMF,MAAM,WAAW,iCAAiC;IAChD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;CACjE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,4BAA4B,CAAC,EAC3C,QAAgC,EAChC,QAAQ,GACT,EAAE,iCAAiC,+BAQnC;yBAXe,4BAA4B"}
@@ -10,8 +10,9 @@
10
10
  * @output Exports `RichTextEditorAutoLinkPlugin` and its props type, plus the
11
11
  * default `DEFAULT_LINK_MATCHERS`.
12
12
  * @position @astryxdesign/richtext. Drop into RichTextEditor's `plugins` slot to
13
- * auto-linkify URLs and emails as they are typed or pasted. Requires
14
- * `AutoLinkNode` to be registered (it is, by default).
13
+ * auto-linkify URLs and emails as they are typed or pasted, except inside
14
+ * a code block. Requires `AutoLinkNode` to be registered (it is, by
15
+ * default).
15
16
  *
16
17
  * SYNC: When modified, update:
17
18
  * - /packages/richtext/src/index.ts (exports / barrel)
@@ -28,11 +29,11 @@
28
29
  * and new-tab defaults, so a consumer supplies a richer matcher set — an
29
30
  * application-specific identifier or ticket syntax, say — through the
30
31
  * `matchers` prop without changing the contract. An application needing a
31
- * hardened transform (expanded separators, code-block exclusion) substitutes
32
- * its own plugin in the same slot; astryx stays on the upstream
33
- * `AutoLinkPlugin`.
32
+ * hardened transform (expanded separators, say) substitutes its own plugin in
33
+ * the same slot; astryx stays on the upstream `AutoLinkPlugin`.
34
34
  */
35
35
  import { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin';
36
+ import { $isCodeNode } from '@lexical/code';
36
37
  import { URL_MATCHER, EMAIL_MATCHER, sanitizeUrl } from "./linkUtils.js";
37
38
 
38
39
  /**
@@ -65,6 +66,10 @@ export const DEFAULT_LINK_MATCHERS = [text => {
65
66
  attributes: NEW_TAB_LINK_ATTRIBUTES
66
67
  } : null;
67
68
  }];
69
+
70
+ // Text in a code block is never linked. The upstream plugin registers again
71
+ // when this array changes. Keep it a module constant.
72
+ const EXCLUDE_PARENTS = [$isCodeNode];
68
73
  /**
69
74
  * Automatically converts typed/pasted URLs and email addresses into links.
70
75
  * Render it inside the editor's `plugins` slot — it reaches the editor via
@@ -87,7 +92,8 @@ export function RichTextEditorAutoLinkPlugin({
87
92
  }) {
88
93
  return /*#__PURE__*/_jsx(AutoLinkPlugin, {
89
94
  matchers: matchers,
90
- onChange: onChange
95
+ onChange: onChange,
96
+ excludeParents: EXCLUDE_PARENTS
91
97
  });
92
98
  }
93
99
  RichTextEditorAutoLinkPlugin.displayName = 'RichTextEditorAutoLinkPlugin';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file privateAttributes/layerPortalBoundary.stylex.ts
3
+ * @input The editable root Lexical reconciles
4
+ * @output rendererManagedRootProps, which marks that root
5
+ * @position Owner module of the private layer-portal-boundary attribute in
6
+ * RichText: the only RichText file that writes its name. Core's Layer reads
7
+ * the mark through its own owner module of the same name. No package entry
8
+ * exports it.
9
+ */
10
+ /** Spread on the editable root, whose DOM Lexical reconciles. */
11
+ export declare const rendererManagedRootProps: Readonly<Record<`data-${string}`, string>>;
12
+ //# sourceMappingURL=layerPortalBoundary.stylex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layerPortalBoundary.stylex.d.ts","sourceRoot":"","sources":["../../src/privateAttributes/layerPortalBoundary.stylex.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,iEAAiE;AACjE,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAC7C,MAAM,CAAC,QAAQ,MAAM,EAAE,EAAE,MAAM,CAAC,CAGjC,CAAC"}
@@ -0,0 +1,16 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file privateAttributes/layerPortalBoundary.stylex.ts
5
+ * @input The editable root Lexical reconciles
6
+ * @output rendererManagedRootProps, which marks that root
7
+ * @position Owner module of the private layer-portal-boundary attribute in
8
+ * RichText: the only RichText file that writes its name. Core's Layer reads
9
+ * the mark through its own owner module of the same name. No package entry
10
+ * exports it.
11
+ */
12
+
13
+ /** Spread on the editable root, whose DOM Lexical reconciles. */
14
+ export const rendererManagedRootProps = {
15
+ 'data-astryx-layer-portal-boundary': 'external'
16
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astryxdesign/richtext",
3
3
  "displayName": "Rich Text",
4
- "version": "0.7.12-canary.ccb4a6d",
4
+ "version": "0.7.12-canary.da3dbdc",
5
5
  "description": "Astryx rich text — a Lexical-based rich text editor and viewer, styled with Astryx design tokens.",
6
6
  "author": "Meta Open Source",
7
7
  "license": "MIT",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@stylexjs/stylex": ">=0.10.0",
69
- "@astryxdesign/core": "0.7.12-canary.ccb4a6d",
69
+ "@astryxdesign/core": "0.7.12-canary.da3dbdc",
70
70
  "react": ">=19.0.0",
71
71
  "react-dom": ">=19.0.0",
72
72
  "lexical": "^0.52.0",
@@ -125,8 +125,8 @@
125
125
  }
126
126
  },
127
127
  "devDependencies": {
128
- "@astryxdesign/a11y-spec": "0.7.12-canary.ccb4a6d",
129
- "@astryxdesign/cli": "0.7.12-canary.ccb4a6d",
128
+ "@astryxdesign/a11y-spec": "0.7.12-canary.da3dbdc",
129
+ "@astryxdesign/cli": "0.7.12-canary.da3dbdc",
130
130
  "@babel/cli": "^7.29.7",
131
131
  "@babel/core": "^7.29.7",
132
132
  "@babel/preset-react": "^7.29.7",
@@ -0,0 +1,143 @@
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.
11
+ * @position Rendered by RichTextEditor; always on; not exported. A paste
12
+ * whose selection starts in a code block goes in as plain text: the
13
+ * clipboard's plain text, or else one line per pasted block.
14
+ */
15
+
16
+ import {useEffect} from 'react';
17
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
18
+ import {$isCodeNode} from '@lexical/code';
19
+ import {
20
+ $addUpdateTag,
21
+ $findMatchingParent,
22
+ $getSelection,
23
+ $isElementNode,
24
+ $isRangeSelection,
25
+ $isSelectionCapturedInDecoratorInput,
26
+ COMMAND_PRIORITY_LOW,
27
+ isDOMNode,
28
+ mergeRegister,
29
+ PASTE_COMMAND,
30
+ PASTE_TAG,
31
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
32
+ type BaseSelection,
33
+ type LexicalNode,
34
+ type RangeSelection,
35
+ } from 'lexical';
36
+
37
+ /** Clipboard types that carry more than plain text. */
38
+ const RICH_TYPES = ['text/html', 'application/x-lexical-editor'];
39
+
40
+ /** Whether content inserted at the selection lands in a code block. */
41
+ function $startsInCodeBlock(
42
+ selection: BaseSelection | null,
43
+ ): selection is RangeSelection {
44
+ if (!$isRangeSelection(selection)) {
45
+ return false;
46
+ }
47
+ const start = selection.isBackward() ? selection.focus : selection.anchor;
48
+ const node = start.getNode();
49
+ // A link (an autolinked URL) can sit between the text and its code block.
50
+ return $findMatchingParent(node, $isCodeNode) !== null;
51
+ }
52
+
53
+ /**
54
+ * Clipboard nodes as plain lines: each block's text on a line of its own (a
55
+ * paragraph, a heading, a list item, a table cell), and inline content
56
+ * between blocks on one line.
57
+ */
58
+ function $plainLinesOf(nodes: ReadonlyArray<LexicalNode>): string {
59
+ const lines: string[] = [];
60
+ let inline: string | null = null;
61
+ const endInline = () => {
62
+ if (inline != null) {
63
+ lines.push(inline);
64
+ inline = null;
65
+ }
66
+ };
67
+ const visit = (node: LexicalNode) => {
68
+ if (node.isInline()) {
69
+ inline = (inline ?? '') + node.getTextContent();
70
+ return;
71
+ }
72
+ endInline();
73
+ if (
74
+ $isElementNode(node) &&
75
+ node.getChildren().some(child => !child.isInline())
76
+ ) {
77
+ node.getChildren().forEach(visit);
78
+ endInline();
79
+ } else {
80
+ lines.push(node.getTextContent());
81
+ }
82
+ };
83
+ nodes.forEach(visit);
84
+ endInline();
85
+ return lines.join('\n');
86
+ }
87
+
88
+ export function CodePastePlugin(): null {
89
+ const [editor] = useLexicalComposerContext();
90
+ useEffect(
91
+ () =>
92
+ mergeRegister(
93
+ // Ahead of the rich-text paste, which inserts the clipboard's rich
94
+ // content when there is any.
95
+ editor.registerCommand(
96
+ PASTE_COMMAND,
97
+ event => {
98
+ // A paste into an input a decorator node renders is that input's.
99
+ if (
100
+ isDOMNode(event.target) &&
101
+ $isSelectionCapturedInDecoratorInput(event.target)
102
+ ) {
103
+ return false;
104
+ }
105
+ const data = 'clipboardData' in event ? event.clipboardData : null;
106
+ if (
107
+ !editor.isEditable() ||
108
+ data == null ||
109
+ !RICH_TYPES.some(type => data.types.includes(type))
110
+ ) {
111
+ return false;
112
+ }
113
+ const text = data.getData('text/plain');
114
+ const selection = $getSelection();
115
+ if (text === '' || !$startsInCodeBlock(selection)) {
116
+ return false;
117
+ }
118
+ event.preventDefault();
119
+ // Its own undo step, as every paste is.
120
+ $addUpdateTag(PASTE_TAG);
121
+ selection.insertRawText(text);
122
+ return true;
123
+ },
124
+ COMMAND_PRIORITY_LOW,
125
+ ),
126
+ // Every insertion of clipboard nodes, a paste with no plain text or a
127
+ // drop, comes through here; ahead of the table's own handler.
128
+ editor.registerCommand(
129
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
130
+ ({nodes, selection}) => {
131
+ if (!$startsInCodeBlock(selection)) {
132
+ return false;
133
+ }
134
+ selection.insertRawText($plainLinesOf(nodes));
135
+ return true;
136
+ },
137
+ COMMAND_PRIORITY_LOW,
138
+ ),
139
+ ),
140
+ [editor],
141
+ );
142
+ return null;
143
+ }
@@ -0,0 +1,93 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file DisabledLinkGuardPlugin.tsx
7
+ * @input The composer's editor, and whether RichTextEditor is disabled
8
+ * @output DisabledLinkGuardPlugin (internal; not re-exported from index.ts):
9
+ * while the editor is disabled, its links add no tab stop and never navigate
10
+ * @position Rendered by RichTextEditor inside its LexicalExtensionComposer
11
+ *
12
+ * A disabled editor is not editable, so the browser treats each link in it as
13
+ * a live link: a tab stop that navigates on a click or on Enter. A disabled
14
+ * control must not navigate, and it keeps the focus order it has when enabled,
15
+ * where its links are not tab stops.
16
+ */
17
+
18
+ import {useEffect} from 'react';
19
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
20
+
21
+ // Enter on a focused link and a screen reader's activation arrive as a click.
22
+ // A middle click arrives as auxclick.
23
+ function preventLinkActivation(event: MouseEvent) {
24
+ if (event.target instanceof Element && event.target.closest('a[href]')) {
25
+ event.preventDefault();
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Guards the links under one root element. It records the links it changes,
31
+ * so it restores only those.
32
+ */
33
+ function createLinkGuard(root: HTMLElement) {
34
+ const guarded = new Set<Element>();
35
+ const guard = () => {
36
+ for (const link of root.querySelectorAll('a[href]')) {
37
+ if (!guarded.has(link)) {
38
+ guarded.add(link);
39
+ link.setAttribute('tabindex', '-1');
40
+ // Assistive technology announces each link as disabled. Only the
41
+ // link ignores the pointer, so a wide table still scrolls.
42
+ link.setAttribute('aria-disabled', 'true');
43
+ if (link instanceof HTMLElement) {
44
+ link.style.pointerEvents = 'none';
45
+ }
46
+ }
47
+ }
48
+ };
49
+ guard();
50
+ root.addEventListener('click', preventLinkActivation, true);
51
+ root.addEventListener('auxclick', preventLinkActivation, true);
52
+ return {
53
+ guard,
54
+ release() {
55
+ for (const link of guarded) {
56
+ link.removeAttribute('tabindex');
57
+ link.removeAttribute('aria-disabled');
58
+ if (link instanceof HTMLElement) {
59
+ link.style.removeProperty('pointer-events');
60
+ }
61
+ }
62
+ guarded.clear();
63
+ root.removeEventListener('click', preventLinkActivation, true);
64
+ root.removeEventListener('auxclick', preventLinkActivation, true);
65
+ },
66
+ };
67
+ }
68
+
69
+ export function DisabledLinkGuardPlugin({isDisabled}: {isDisabled: boolean}) {
70
+ const [editor] = useLexicalComposerContext();
71
+ // Synchronizes the editor's link elements, which Lexical renders and owns,
72
+ // with the disabled state: guarded while disabled, restored when enabled.
73
+ useEffect(() => {
74
+ if (!isDisabled) {
75
+ return undefined;
76
+ }
77
+ let linkGuard: ReturnType<typeof createLinkGuard> | null = null;
78
+ const unregisterRoot = editor.registerRootListener(next => {
79
+ linkGuard?.release();
80
+ linkGuard = next == null ? null : createLinkGuard(next);
81
+ });
82
+ // A new or changed document renders new link elements.
83
+ const unregisterUpdate = editor.registerUpdateListener(() => {
84
+ linkGuard?.guard();
85
+ });
86
+ return () => {
87
+ unregisterUpdate();
88
+ unregisterRoot();
89
+ linkGuard?.release();
90
+ };
91
+ }, [editor, isDisabled]);
92
+ return null;
93
+ }
@@ -85,7 +85,7 @@ export const docs = {
85
85
  name: 'isDisabled',
86
86
  type: 'boolean',
87
87
  description:
88
- 'Whether the editor is disabled: non-editable, dimmed, out of the tab order, and announced as disabled. Wins when isReadOnly is also set. Takes effect when changed after mount.',
88
+ 'Whether the editor is disabled: non-editable, dimmed, out of the tab order, and announced as disabled. Its links do not navigate and are not tab stops. Wins when isReadOnly is also set. Takes effect when changed after mount.',
89
89
  default: 'false',
90
90
  },
91
91
  {
@@ -243,6 +243,11 @@ export const docs = {
243
243
  description:
244
244
  'Backspace at the very start of a code block turns it into a paragraph holding its lines, each on its own line, with the caret at its start. The code never merges into the block above, so a heading above keeps its own text. Backspace anywhere else in a code block deletes as usual.',
245
245
  },
246
+ {
247
+ guidance: true,
248
+ description:
249
+ 'Pasting into a code block inserts plain code. Rich content, such as a web page, a word processor document, or blocks copied from another editor, goes in as the plain text on the clipboard; when the clipboard has no plain text, each pasted block goes in as one line of its text. Headings, lists, tables, and formatting never land inside a code block.',
250
+ },
246
251
  {
247
252
  guidance: true,
248
253
  description:
@@ -283,6 +288,11 @@ export const docs = {
283
288
  description:
284
289
  "The toolbar's glyphs are themeable. Each control resolves its icon from the core icon registry under a stable richtext:* key (see RICHTEXT_ICON_KEYS), falling back to a bundled inline SVG. A theme restyles any glyph without forking the toolbar by naming its key: defineTheme({icons: {'richtext:bold': <MyBoldIcon />}}). Undo and redo mirror under right-to-left direction, whichever glyph draws them.",
285
290
  },
291
+ {
292
+ guidance: true,
293
+ description:
294
+ "To switch between reading (Markdown or RichTextView) and editing the same document in place, give the editor's field its inset and border outside the reading width, so every paragraph keeps its line count across the switch. Define the frame with stylex.create, for example const inPlace = stylex.create({editorFrame: {marginInline: `calc(-1 * (${spacingVars['--spacing-2']} + ${borderVars['--border-width']}))`}}), with spacingVars and borderVars from @astryxdesign/core/theme/tokens.stylex, and pass xstyle={inPlace.editorFrame} to the RichTextEditor. The recipe assumes no status: a status icon reserves another spacing-6 at the inline end. The field then extends past the column on both sides; leave room for it in the surrounding padding. A standalone editor in a form keeps its default width, so its border lines up with the fields around it.",
295
+ },
286
296
  {
287
297
  guidance: false,
288
298
  description:
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file RichTextEditor.tsx
7
- * @input Uses React, useId, Lexical (lexical + @lexical/react, composed through
7
+ * @input Uses React, useId, a declared DOM boundary, Lexical (lexical + @lexical/react, composed through
8
8
  * LexicalExtensionComposer), Field, VisuallyHidden, useInputStatusIcon,
9
9
  * mergeProps, design tokens
10
10
  * @output Exports an accessibly labelled RichTextEditor component with a flush
@@ -79,11 +79,13 @@ import {TextSemanticsExtension} from './textSemantics';
79
79
  import {TableColumnFloorPlugin} from './TableColumnFloorPlugin';
80
80
  import {CodeBackspacePlugin} from './CodeBackspacePlugin';
81
81
  import {CodeBlockHeaderPlugin} from './CodeBlockHeaderPlugin';
82
+ import {CodePastePlugin} from './CodePastePlugin';
82
83
  import {CodeSyntaxPlugin} from './CodeSyntaxPlugin';
83
84
  import {MarkdownExtensionsPlugin} from './MarkdownExtensionsPlugin';
84
85
  import type {RichTextMarkdownExtension} from './markdownExtensions';
85
86
  import type {CodeLanguage} from '@astryxdesign/core/CodeBlock';
86
87
  import {TaskCheckboxPlugin} from './TaskCheckboxPlugin';
88
+ import {DisabledLinkGuardPlugin} from './DisabledLinkGuardPlugin';
87
89
  import {SelectAllScopePlugin} from './SelectAllScopePlugin';
88
90
  import {ImageSourceCaretPlugin} from './ImageSourceCaretPlugin';
89
91
  import {type Transformer} from '@lexical/markdown';
@@ -106,6 +108,7 @@ import {
106
108
  type LexicalNode,
107
109
  type EditorThemeClasses,
108
110
  } from 'lexical';
111
+ import {rendererManagedRootProps} from './privateAttributes/layerPortalBoundary.stylex';
109
112
 
110
113
  /**
111
114
  * Serialized state for an empty editor: a root containing a single empty
@@ -359,7 +362,8 @@ export interface RichTextEditorProps extends Omit<
359
362
  isReadOnly?: boolean;
360
363
  /**
361
364
  * Whether the editor is disabled: non-editable, dimmed, out of the tab
362
- * order, and announced as disabled. Wins when `isReadOnly` is also set.
365
+ * order, and announced as disabled. Its links do not navigate and are not
366
+ * tab stops. Wins when `isReadOnly` is also set.
363
367
  * Takes effect when changed after mount.
364
368
  * @default false
365
369
  */
@@ -660,6 +664,7 @@ export const RichTextEditor = function RichTextEditor({
660
664
  }),
661
665
  stylex.props(
662
666
  inputWrapperStyles.base,
667
+ inputWrapperStyles.textField,
663
668
  styles.wrapper,
664
669
  // Only disabled is dimmed, matching TextArea: a read-only editor
665
670
  // keeps full-opacity text and stays keyboard-reachable.
@@ -687,7 +692,7 @@ export const RichTextEditor = function RichTextEditor({
687
692
  !!statusIcon && styles.editorBodyWithStatus,
688
693
  size === 'lg' && editorBodySizeStyles.lg,
689
694
  )}>
690
- <div {...stylex.props(styles.editorRoot)}>
695
+ <div {...stylex.props(styles.editorRoot)} dir={rest.dir}>
691
696
  <RichTextPlugin
692
697
  contentEditable={
693
698
  <EditorContentEditable
@@ -712,6 +717,7 @@ export const RichTextEditor = function RichTextEditor({
712
717
  <ListPlugin />
713
718
  <TaskCheckboxPlugin isReadOnly={isReadOnly || isDisabled} />
714
719
  <LinkPlugin />
720
+ <DisabledLinkGuardPlugin isDisabled={isDisabled} />
715
721
  {/* Tab keeps its editor meaning inside a table (indent, and
716
722
  Escape then Tab to leave the editor); arrow keys move
717
723
  between cells. GFM tables have no merged cells or cell
@@ -728,6 +734,7 @@ export const RichTextEditor = function RichTextEditor({
728
734
  {/* Backspace at the very start of a code block makes it a
729
735
  paragraph, instead of merging it into the block above. */}
730
736
  <CodeBackspacePlugin />
737
+ <CodePastePlugin />
731
738
  {markdownExtensions != null && markdownExtensions.length > 0 ? (
732
739
  <MarkdownExtensionsPlugin
733
740
  extensions={markdownExtensions}
@@ -1065,6 +1072,8 @@ function EditorContentEditable({
1065
1072
  dynamicStyles.contentEditableMinHeight(minHeight),
1066
1073
  ),
1067
1074
  ...rest,
1075
+ // Lexical reconciles this DOM, so Layer keeps corrective portals out.
1076
+ ...rendererManagedRootProps,
1068
1077
  };
1069
1078
  if (placeholderText) {
1070
1079
  return (
@@ -26,7 +26,7 @@ export const docs = {
26
26
  ],
27
27
  usage: {
28
28
  description:
29
- "Turns typed and pasted URLs and email addresses into links. Render it in RichTextEditor's plugins slot; it reaches the editor through the editor's Lexical composer and uses AutoLinkNode, which the editor registers by default.",
29
+ "Turns typed and pasted URLs and email addresses into links. Text inside a code block stays code and is never linked. Render it in RichTextEditor's plugins slot; it reaches the editor through the editor's Lexical composer and uses AutoLinkNode, which the editor registers by default.",
30
30
  bestPractices: [
31
31
  {
32
32
  guidance: true,
@@ -10,8 +10,9 @@
10
10
  * @output Exports `RichTextEditorAutoLinkPlugin` and its props type, plus the
11
11
  * default `DEFAULT_LINK_MATCHERS`.
12
12
  * @position @astryxdesign/richtext. Drop into RichTextEditor's `plugins` slot to
13
- * auto-linkify URLs and emails as they are typed or pasted. Requires
14
- * `AutoLinkNode` to be registered (it is, by default).
13
+ * auto-linkify URLs and emails as they are typed or pasted, except inside
14
+ * a code block. Requires `AutoLinkNode` to be registered (it is, by
15
+ * default).
15
16
  *
16
17
  * SYNC: When modified, update:
17
18
  * - /packages/richtext/src/index.ts (exports / barrel)
@@ -28,9 +29,8 @@
28
29
  * and new-tab defaults, so a consumer supplies a richer matcher set — an
29
30
  * application-specific identifier or ticket syntax, say — through the
30
31
  * `matchers` prop without changing the contract. An application needing a
31
- * hardened transform (expanded separators, code-block exclusion) substitutes
32
- * its own plugin in the same slot; astryx stays on the upstream
33
- * `AutoLinkPlugin`.
32
+ * hardened transform (expanded separators, say) substitutes its own plugin in
33
+ * the same slot; astryx stays on the upstream `AutoLinkPlugin`.
34
34
  */
35
35
 
36
36
  import {
@@ -38,6 +38,8 @@ import {
38
38
  createLinkMatcherWithRegExp,
39
39
  type LinkMatcher,
40
40
  } from '@lexical/react/LexicalAutoLinkPlugin';
41
+ import {$isCodeNode} from '@lexical/code';
42
+ import type {ElementNode} from 'lexical';
41
43
  import {URL_MATCHER, EMAIL_MATCHER, sanitizeUrl} from './linkUtils';
42
44
 
43
45
  /**
@@ -72,6 +74,10 @@ export const DEFAULT_LINK_MATCHERS: LinkMatcher[] = [
72
74
  },
73
75
  ];
74
76
 
77
+ // Text in a code block is never linked. The upstream plugin registers again
78
+ // when this array changes. Keep it a module constant.
79
+ const EXCLUDE_PARENTS: ((parent: ElementNode) => boolean)[] = [$isCodeNode];
80
+
75
81
  export interface RichTextEditorAutoLinkPluginProps {
76
82
  /**
77
83
  * Link matchers to use. Defaults to {@link DEFAULT_LINK_MATCHERS} (URLs +
@@ -108,7 +114,13 @@ export function RichTextEditorAutoLinkPlugin({
108
114
  matchers = DEFAULT_LINK_MATCHERS,
109
115
  onChange,
110
116
  }: RichTextEditorAutoLinkPluginProps) {
111
- return <AutoLinkPlugin matchers={matchers} onChange={onChange} />;
117
+ return (
118
+ <AutoLinkPlugin
119
+ matchers={matchers}
120
+ onChange={onChange}
121
+ excludeParents={EXCLUDE_PARENTS}
122
+ />
123
+ );
112
124
  }
113
125
 
114
126
  RichTextEditorAutoLinkPlugin.displayName = 'RichTextEditorAutoLinkPlugin';
@@ -0,0 +1,18 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file privateAttributes/layerPortalBoundary.stylex.ts
5
+ * @input The editable root Lexical reconciles
6
+ * @output rendererManagedRootProps, which marks that root
7
+ * @position Owner module of the private layer-portal-boundary attribute in
8
+ * RichText: the only RichText file that writes its name. Core's Layer reads
9
+ * the mark through its own owner module of the same name. No package entry
10
+ * exports it.
11
+ */
12
+
13
+ /** Spread on the editable root, whose DOM Lexical reconciles. */
14
+ export const rendererManagedRootProps: Readonly<
15
+ Record<`data-${string}`, string>
16
+ > = {
17
+ 'data-astryx-layer-portal-boundary': 'external',
18
+ };