@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 +3 -0
- package/dist/CodePastePlugin.d.ts +2 -0
- package/dist/CodePastePlugin.d.ts.map +1 -0
- package/dist/CodePastePlugin.js +103 -0
- package/dist/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/DisabledLinkGuardPlugin.js +93 -0
- package/dist/RichTextEditor.d.ts +3 -2
- package/dist/RichTextEditor.d.ts.map +1 -1
- package/dist/RichTextEditor.js +13 -5
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +5 -5
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -1
- package/dist/RichTextEditorAutoLinkPlugin.js +12 -6
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/package.json +4 -4
- package/src/CodePastePlugin.tsx +143 -0
- package/src/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/RichTextEditor.doc.mjs +11 -1
- package/src/RichTextEditor.tsx +12 -3
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +1 -1
- package/src/RichTextEditorAutoLinkPlugin.tsx +18 -6
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
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 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|
package/dist/RichTextEditor.d.ts
CHANGED
|
@@ -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.
|
|
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":"AAIA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAOL,KAAK,SAAS,EACd,KAAK,GAAG,EACT,MAAM,OAAO,CAAC;AASf,OAAO,EAML,KAAK,kBAAkB,EACxB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAIlD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
|
package/dist/RichTextEditor.js
CHANGED
|
@@ -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(
|
|
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
|
|
10
|
-
* `AutoLinkNode` to be registered (it is, by
|
|
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,
|
|
28
|
-
*
|
|
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;
|
|
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
|
|
14
|
-
* `AutoLinkNode` to be registered (it is, by
|
|
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,
|
|
32
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
129
|
-
"@astryxdesign/cli": "0.7.12-canary.
|
|
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:
|
package/src/RichTextEditor.tsx
CHANGED
|
@@ -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.
|
|
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
|
|
14
|
-
* `AutoLinkNode` to be registered (it is, by
|
|
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,
|
|
32
|
-
*
|
|
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
|
|
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
|
+
};
|