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