@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,404 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file ListItemShortcutsPlugin.tsx
|
|
7
|
+
* @input Uses @lexical/react (composer context), @lexical/list,
|
|
8
|
+
* @lexical/rich-text, @lexical/markdown (Transformer), lexical, and the task
|
|
9
|
+
* state in markdownTaskList.ts.
|
|
10
|
+
* @output Exports ListItemShortcutsPlugin, which re-marks a list item when a
|
|
11
|
+
* Markdown block marker is typed at its start, keeping the item's text:
|
|
12
|
+
* `- `, `1. `, or `[ ] ` switches the item's list between bullets, numbers,
|
|
13
|
+
* and tasks, and `# ` or `> ` takes a top-level item out of its list as a
|
|
14
|
+
* heading or a quote, the rest of the list continuing after it.
|
|
15
|
+
* @position Rendered by RichTextEditor beside MarkdownShortcutPlugin, only
|
|
16
|
+
* when `hasMarkdownShortcuts` is on. Lexical's shortcuts never look inside a
|
|
17
|
+
* list. A marker counts only when the editor's `transformers` make that
|
|
18
|
+
* block from it, and a task takes the shape of the transformer that matched.
|
|
19
|
+
* Each re-mark is one undo step.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {useEffect} from 'react';
|
|
23
|
+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
24
|
+
import {
|
|
25
|
+
$createListNode,
|
|
26
|
+
$isListItemNode,
|
|
27
|
+
$isListNode,
|
|
28
|
+
type ListItemNode,
|
|
29
|
+
type ListNode,
|
|
30
|
+
} from '@lexical/list';
|
|
31
|
+
import {CHECK_LIST, type Transformer} from '@lexical/markdown';
|
|
32
|
+
import {
|
|
33
|
+
$createHeadingNode,
|
|
34
|
+
$createQuoteNode,
|
|
35
|
+
type HeadingTagType,
|
|
36
|
+
} from '@lexical/rich-text';
|
|
37
|
+
import {
|
|
38
|
+
$addUpdateTag,
|
|
39
|
+
$getNodeByKey,
|
|
40
|
+
$getSelection,
|
|
41
|
+
$isRangeSelection,
|
|
42
|
+
$isRootOrShadowRoot,
|
|
43
|
+
$isTextNode,
|
|
44
|
+
COLLABORATION_TAG,
|
|
45
|
+
HISTORIC_TAG,
|
|
46
|
+
HISTORY_PUSH_TAG,
|
|
47
|
+
type ElementNode,
|
|
48
|
+
type LexicalEditor,
|
|
49
|
+
type LexicalNode,
|
|
50
|
+
type NodeKey,
|
|
51
|
+
} from 'lexical';
|
|
52
|
+
import {$getTaskState, $setTaskState} from '../markdownTaskList';
|
|
53
|
+
|
|
54
|
+
/** What a marker typed at the start of a list item makes of it. */
|
|
55
|
+
type ListItemMarker =
|
|
56
|
+
| {readonly kind: 'heading'; readonly tag: HeadingTagType}
|
|
57
|
+
| {readonly kind: 'quote'}
|
|
58
|
+
| {readonly kind: 'bullet'}
|
|
59
|
+
| {readonly kind: 'number'; readonly start: number}
|
|
60
|
+
| {readonly kind: 'task'; readonly checked: boolean};
|
|
61
|
+
|
|
62
|
+
type BlockMarker = Extract<ListItemMarker, {kind: 'heading' | 'quote'}>;
|
|
63
|
+
|
|
64
|
+
/** Whether `marker` takes an item out of its list, rather than switching it. */
|
|
65
|
+
function isBlockMarker(marker: ListItemMarker): marker is BlockMarker {
|
|
66
|
+
return marker.kind === 'heading' || marker.kind === 'quote';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The marker `prefix` is (the text before the caret, ending in the space
|
|
71
|
+
* just typed), and the line that would make the same block at the start of a
|
|
72
|
+
* paragraph. A task marker needs a bullet there, as GFM task items do.
|
|
73
|
+
*/
|
|
74
|
+
function markerOf(
|
|
75
|
+
prefix: string,
|
|
76
|
+
): {readonly marker: ListItemMarker; readonly line: string} | null {
|
|
77
|
+
const heading = /^(#{1,6}) $/.exec(prefix);
|
|
78
|
+
if (heading != null) {
|
|
79
|
+
return {
|
|
80
|
+
marker: {kind: 'heading', tag: `h${heading[1].length}` as HeadingTagType},
|
|
81
|
+
line: prefix,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (prefix === '> ') {
|
|
85
|
+
return {marker: {kind: 'quote'}, line: prefix};
|
|
86
|
+
}
|
|
87
|
+
if (/^[-*+] $/.test(prefix)) {
|
|
88
|
+
return {marker: {kind: 'bullet'}, line: prefix};
|
|
89
|
+
}
|
|
90
|
+
const number = /^(\d{1,9})\. $/.exec(prefix);
|
|
91
|
+
if (number != null) {
|
|
92
|
+
return {marker: {kind: 'number', start: Number(number[1])}, line: prefix};
|
|
93
|
+
}
|
|
94
|
+
const task = /^([-*+] )?\[([ xX])\] $/.exec(prefix);
|
|
95
|
+
if (task != null) {
|
|
96
|
+
return {
|
|
97
|
+
marker: {kind: 'task', checked: task[2] !== ' '},
|
|
98
|
+
line: task[1] == null ? `- ${prefix}` : prefix,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The first of `transformers` that makes a block from `line`, all of it. */
|
|
105
|
+
function transformerFor(
|
|
106
|
+
line: string,
|
|
107
|
+
transformers: ReadonlyArray<Transformer>,
|
|
108
|
+
): Transformer | undefined {
|
|
109
|
+
return transformers.find(
|
|
110
|
+
transformer =>
|
|
111
|
+
transformer.type === 'element' &&
|
|
112
|
+
line.match(transformer.regExp)?.[0] === line,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether `node` is the item Lexical nests a list in, not an item itself. */
|
|
117
|
+
function $holdsNestedList(node: LexicalNode | null): node is ListItemNode {
|
|
118
|
+
return (
|
|
119
|
+
$isListItemNode(node) &&
|
|
120
|
+
node.getChildrenSize() === 1 &&
|
|
121
|
+
$isListNode(node.getFirstChild())
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The items of `list` that have content of their own. */
|
|
126
|
+
function $itemsOf(list: ListNode): Array<ListItemNode> {
|
|
127
|
+
return list
|
|
128
|
+
.getChildren()
|
|
129
|
+
.filter(
|
|
130
|
+
(child): child is ListItemNode =>
|
|
131
|
+
$isListItemNode(child) && !$holdsNestedList(child),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Switches the list holding `item` to bullets, numbers, or tasks. Tasks take
|
|
137
|
+
* the shape the matching transformer reads and writes: Lexical's `check`
|
|
138
|
+
* list for CHECK_LIST, or else RichText's bulleted items with task state
|
|
139
|
+
* (markdownTaskList.ts). Switching to tasks makes every item a task, keeping
|
|
140
|
+
* the state of those that already were, and `item` takes the marker's;
|
|
141
|
+
* switching to bullets or numbers makes every item a plain one. A numbered
|
|
142
|
+
* list starts at the marker's number when `item` is its first item.
|
|
143
|
+
*/
|
|
144
|
+
function $switchList(
|
|
145
|
+
list: ListNode,
|
|
146
|
+
item: ListItemNode,
|
|
147
|
+
marker: Extract<ListItemMarker, {kind: 'bullet' | 'number' | 'task'}>,
|
|
148
|
+
isCheckList: boolean,
|
|
149
|
+
): void {
|
|
150
|
+
const items = $itemsOf(list);
|
|
151
|
+
if (marker.kind === 'task' && isCheckList) {
|
|
152
|
+
// A check list already keeps each item's state; only another list is
|
|
153
|
+
// converted, carrying over RichText's task state.
|
|
154
|
+
if (list.getListType() !== 'check') {
|
|
155
|
+
const states = items.map(each => $getTaskState(each));
|
|
156
|
+
for (const each of items) {
|
|
157
|
+
$setTaskState(each, null);
|
|
158
|
+
}
|
|
159
|
+
list.setListType('check');
|
|
160
|
+
items.forEach((each, index) => {
|
|
161
|
+
each.setChecked(states[index] === 'checked');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
item.setChecked(marker.checked);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (marker.kind === 'task') {
|
|
168
|
+
// Read a check list's states first: Lexical reports no checked state
|
|
169
|
+
// once the list is no longer a check list.
|
|
170
|
+
const checkStates =
|
|
171
|
+
list.getListType() === 'check'
|
|
172
|
+
? items.map(each => each.getChecked() === true)
|
|
173
|
+
: null;
|
|
174
|
+
if (list.getListType() !== 'bullet') {
|
|
175
|
+
list.setListType('bullet');
|
|
176
|
+
}
|
|
177
|
+
items.forEach((each, index) => {
|
|
178
|
+
if (checkStates != null) {
|
|
179
|
+
each.setChecked(undefined);
|
|
180
|
+
$setTaskState(each, checkStates[index] ? 'checked' : 'unchecked');
|
|
181
|
+
} else if ($getTaskState(each) == null) {
|
|
182
|
+
$setTaskState(each, 'unchecked');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
$setTaskState(item, marker.checked ? 'checked' : 'unchecked');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const each of items) {
|
|
189
|
+
if ($getTaskState(each) != null) {
|
|
190
|
+
$setTaskState(each, null);
|
|
191
|
+
}
|
|
192
|
+
if (each.getChecked() !== undefined) {
|
|
193
|
+
each.setChecked(undefined);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const wasNumbered = list.getListType() === 'number';
|
|
197
|
+
list.setListType(marker.kind);
|
|
198
|
+
if (marker.kind === 'number') {
|
|
199
|
+
if (items[0]?.is(item) === true) {
|
|
200
|
+
list.setStart(marker.start);
|
|
201
|
+
} else if (!wasNumbered) {
|
|
202
|
+
list.setStart(1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Takes `item` out of `list` as `block`, holding the item's content. The
|
|
209
|
+
* items before it stay in `list`; the lists nested under it follow `block`
|
|
210
|
+
* at the top level, since a heading or a quote cannot hold them; and the
|
|
211
|
+
* items after it continue in a list of the same kind, numbered on from where
|
|
212
|
+
* they were. When the last of those nested lists has the same type, Lexical
|
|
213
|
+
* merges the items after it into that list, which numbers them on from its
|
|
214
|
+
* own items.
|
|
215
|
+
*/
|
|
216
|
+
function $takeOutOfList(
|
|
217
|
+
list: ListNode,
|
|
218
|
+
item: ListItemNode,
|
|
219
|
+
block: ElementNode,
|
|
220
|
+
): void {
|
|
221
|
+
const nested: Array<ListItemNode> = [];
|
|
222
|
+
let next = item.getNextSibling();
|
|
223
|
+
while ($holdsNestedList(next)) {
|
|
224
|
+
nested.push(next);
|
|
225
|
+
next = next.getNextSibling();
|
|
226
|
+
}
|
|
227
|
+
const after = next == null ? [] : [next, ...next.getNextSiblings()];
|
|
228
|
+
const before = $itemsOf(list).findIndex(each => each.is(item));
|
|
229
|
+
block.append(...item.getChildren());
|
|
230
|
+
list.insertAfter(block);
|
|
231
|
+
let last: LexicalNode = block;
|
|
232
|
+
for (const holder of nested) {
|
|
233
|
+
const child = holder.getFirstChild();
|
|
234
|
+
if ($isListNode(child)) {
|
|
235
|
+
last.insertAfter(child);
|
|
236
|
+
last = child;
|
|
237
|
+
}
|
|
238
|
+
holder.remove();
|
|
239
|
+
}
|
|
240
|
+
if (after.length > 0) {
|
|
241
|
+
const type = list.getListType();
|
|
242
|
+
const rest = $createListNode(
|
|
243
|
+
type,
|
|
244
|
+
type === 'number' ? list.getStart() + before + 1 : 1,
|
|
245
|
+
);
|
|
246
|
+
rest.append(...after);
|
|
247
|
+
last.insertAfter(rest);
|
|
248
|
+
}
|
|
249
|
+
item.remove();
|
|
250
|
+
if (list.getChildrenSize() === 0) {
|
|
251
|
+
list.remove();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Re-marks the item whose first text, `key`, starts with `prefix`. */
|
|
256
|
+
function $remark(
|
|
257
|
+
key: NodeKey,
|
|
258
|
+
prefix: string,
|
|
259
|
+
marker: ListItemMarker,
|
|
260
|
+
isCheckList: boolean,
|
|
261
|
+
): void {
|
|
262
|
+
const text = $getNodeByKey(key);
|
|
263
|
+
const item = text?.getParent();
|
|
264
|
+
const list = item?.getParent();
|
|
265
|
+
if (
|
|
266
|
+
!$isTextNode(text) ||
|
|
267
|
+
!text.getTextContent().startsWith(prefix) ||
|
|
268
|
+
!$isListItemNode(item) ||
|
|
269
|
+
!text.is(item.getFirstChild()) ||
|
|
270
|
+
!$isListNode(list) ||
|
|
271
|
+
(isBlockMarker(marker) && !$isRootOrShadowRoot(list.getParent()))
|
|
272
|
+
) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const rest = text.getTextContent().slice(prefix.length);
|
|
276
|
+
if (rest === '') {
|
|
277
|
+
text.remove();
|
|
278
|
+
} else {
|
|
279
|
+
text.setTextContent(rest);
|
|
280
|
+
}
|
|
281
|
+
if (isBlockMarker(marker)) {
|
|
282
|
+
const block =
|
|
283
|
+
marker.kind === 'heading'
|
|
284
|
+
? $createHeadingNode(marker.tag)
|
|
285
|
+
: $createQuoteNode();
|
|
286
|
+
$takeOutOfList(list, item, block);
|
|
287
|
+
block.selectStart();
|
|
288
|
+
} else {
|
|
289
|
+
$switchList(list, item, marker, isCheckList);
|
|
290
|
+
// Lexical merges a dirty list with a list of the same type after it, but
|
|
291
|
+
// not with one before it. Marking the list before it joins the two, as
|
|
292
|
+
// the Markdown reads back.
|
|
293
|
+
const previous = list.getPreviousSibling();
|
|
294
|
+
if (
|
|
295
|
+
$isListNode(previous) &&
|
|
296
|
+
previous.getListType() === list.getListType()
|
|
297
|
+
) {
|
|
298
|
+
previous.markDirty();
|
|
299
|
+
}
|
|
300
|
+
item.selectStart();
|
|
301
|
+
}
|
|
302
|
+
// Its own undo step, so undo returns to the marker as typed.
|
|
303
|
+
$addUpdateTag(HISTORY_PUSH_TAG);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Watches for a marker completed by a typed space at the start of a list
|
|
308
|
+
* item's text, and re-marks the item in an update of its own, as Lexical's
|
|
309
|
+
* Markdown shortcuts do for blocks in the root.
|
|
310
|
+
*/
|
|
311
|
+
function registerListItemShortcuts(
|
|
312
|
+
editor: LexicalEditor,
|
|
313
|
+
transformers: ReadonlyArray<Transformer>,
|
|
314
|
+
): () => void {
|
|
315
|
+
return editor.registerUpdateListener(
|
|
316
|
+
({tags, dirtyLeaves, editorState, prevEditorState}) => {
|
|
317
|
+
if (
|
|
318
|
+
tags.has(COLLABORATION_TAG) ||
|
|
319
|
+
tags.has(HISTORIC_TAG) ||
|
|
320
|
+
dirtyLeaves.size === 0 ||
|
|
321
|
+
editor.isComposing() ||
|
|
322
|
+
!editor.isEditable()
|
|
323
|
+
) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const previous = prevEditorState.read(() => {
|
|
327
|
+
const selection = $getSelection();
|
|
328
|
+
return $isRangeSelection(selection) && selection.isCollapsed()
|
|
329
|
+
? selection.anchor
|
|
330
|
+
: null;
|
|
331
|
+
});
|
|
332
|
+
if (previous == null) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const found = editorState.read(() => {
|
|
336
|
+
const selection = $getSelection();
|
|
337
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const {key, offset} = selection.anchor;
|
|
341
|
+
// One character typed, just now, in the same text.
|
|
342
|
+
if (
|
|
343
|
+
!dirtyLeaves.has(key) ||
|
|
344
|
+
previous.key !== key ||
|
|
345
|
+
previous.offset !== offset - 1
|
|
346
|
+
) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const text = selection.anchor.getNode();
|
|
350
|
+
if (!$isTextNode(text) || text.hasFormat('code')) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const prefix = text.getTextContent().slice(0, offset);
|
|
354
|
+
const match = markerOf(prefix);
|
|
355
|
+
const item = text.getParent();
|
|
356
|
+
const list = item?.getParent();
|
|
357
|
+
const transformer =
|
|
358
|
+
match == null ? undefined : transformerFor(match.line, transformers);
|
|
359
|
+
if (
|
|
360
|
+
match == null ||
|
|
361
|
+
transformer == null ||
|
|
362
|
+
!$isListItemNode(item) ||
|
|
363
|
+
!text.is(item.getFirstChild()) ||
|
|
364
|
+
!$isListNode(list)
|
|
365
|
+
) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
// A heading or a quote can only stand where a top-level block does;
|
|
369
|
+
// in a nested list the marker stays as typed.
|
|
370
|
+
if (
|
|
371
|
+
isBlockMarker(match.marker) &&
|
|
372
|
+
!$isRootOrShadowRoot(list.getParent())
|
|
373
|
+
) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
key,
|
|
378
|
+
prefix,
|
|
379
|
+
marker: match.marker,
|
|
380
|
+
isCheckList: transformer === CHECK_LIST,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
if (found != null) {
|
|
384
|
+
editor.update(() =>
|
|
385
|
+
$remark(found.key, found.prefix, found.marker, found.isCheckList),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function ListItemShortcutsPlugin({
|
|
393
|
+
transformers,
|
|
394
|
+
}: {
|
|
395
|
+
/** The editor's Markdown transformers; markers they do not make are left. */
|
|
396
|
+
readonly transformers: ReadonlyArray<Transformer>;
|
|
397
|
+
}): null {
|
|
398
|
+
const [editor] = useLexicalComposerContext();
|
|
399
|
+
useEffect(
|
|
400
|
+
() => registerListItemShortcuts(editor, transformers),
|
|
401
|
+
[editor, transformers],
|
|
402
|
+
);
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file MarkdownExtensionsPlugin.tsx
|
|
7
|
+
* @input Uses @lexical/react (composer context), @lexical/utils ($dfs),
|
|
8
|
+
* lexical, core's client-only MarkdownPluginNodeRenderer and server-safe
|
|
9
|
+
* parser, and the RichText adapter and extension node.
|
|
10
|
+
* @output Exports MarkdownExtensionsPlugin, which draws every Markdown plugin
|
|
11
|
+
* node in an editor with its plugin's renderer, and deriveExtensionNode.
|
|
12
|
+
* @position Rendered by RichTextEditor and RichTextView when they are given
|
|
13
|
+
* `markdownExtensions` (spec:AST-064 FR2). It checks the configuration
|
|
14
|
+
* first (FR6), so a conflicting one throws a RichTextExtensionError while
|
|
15
|
+
* the surface renders. Each node is derived again from its source with the
|
|
16
|
+
* adopted plugins and drawn by core's MarkdownPluginNodeRenderer, so it
|
|
17
|
+
* renders, falls back, and reports failures exactly as Markdown does (FR8,
|
|
18
|
+
* FR9); stored data is never drawn on its own, and a node its plugin no
|
|
19
|
+
* longer recognizes — or whose plugin is not adopted — shows its source
|
|
20
|
+
* (FR11). Without this plugin a node shows its source.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {useEffect, useMemo, type ReactNode} from 'react';
|
|
24
|
+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
25
|
+
import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection';
|
|
26
|
+
import type {Transformer} from '@lexical/markdown';
|
|
27
|
+
import {
|
|
28
|
+
$dfs,
|
|
29
|
+
addClassNamesToElement,
|
|
30
|
+
removeClassNamesFromElement,
|
|
31
|
+
} from '@lexical/utils';
|
|
32
|
+
import {
|
|
33
|
+
$getSelection,
|
|
34
|
+
$isRangeSelection,
|
|
35
|
+
CLICK_COMMAND,
|
|
36
|
+
COMMAND_PRIORITY_LOW,
|
|
37
|
+
FORMAT_TEXT_COMMAND,
|
|
38
|
+
HISTORY_MERGE_TAG,
|
|
39
|
+
TEXT_TYPE_TO_FORMAT,
|
|
40
|
+
type LexicalEditor,
|
|
41
|
+
type NodeKey,
|
|
42
|
+
type RangeSelection,
|
|
43
|
+
type TextFormatType,
|
|
44
|
+
} from 'lexical';
|
|
45
|
+
import {MarkdownPluginNodeRenderer} from '@blue-patterns/core/Markdown/plugin-renderer';
|
|
46
|
+
import {
|
|
47
|
+
parseInlineAst,
|
|
48
|
+
parseMarkdownAst,
|
|
49
|
+
} from '@blue-patterns/core/Markdown/parser';
|
|
50
|
+
import type {
|
|
51
|
+
MarkdownExtensionNode,
|
|
52
|
+
MarkdownPluginEntry,
|
|
53
|
+
} from '@blue-patterns/core/Markdown/plugins';
|
|
54
|
+
import {pluginsOf, type RichTextMarkdownExtension} from '../markdownExtensions';
|
|
55
|
+
import {
|
|
56
|
+
$firstSelectedText,
|
|
57
|
+
$isRichTextExtensionNode,
|
|
58
|
+
setRichTextExtensionNodeDecorator,
|
|
59
|
+
type RichTextExtensionNode,
|
|
60
|
+
type RichTextExtensionNodeFacts,
|
|
61
|
+
} from '../markdownExtensionNode';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The node core derives from a stored node's source with `plugins`, or null
|
|
65
|
+
* when no adopted plugin of the same protocol version reads that exact source
|
|
66
|
+
* as that node.
|
|
67
|
+
*/
|
|
68
|
+
export function deriveExtensionNode(
|
|
69
|
+
facts: RichTextExtensionNodeFacts,
|
|
70
|
+
plugins: ReadonlyArray<MarkdownPluginEntry>,
|
|
71
|
+
): MarkdownExtensionNode | null {
|
|
72
|
+
const plugin = plugins.find(entry => entry.name === facts.plugin);
|
|
73
|
+
if (plugin == null || plugin.apiVersion !== facts.apiVersion) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const nodes: ReadonlyArray<{readonly type: string}> =
|
|
77
|
+
facts.display === 'inline'
|
|
78
|
+
? parseInlineAst(facts.source, {plugins: [plugin]})
|
|
79
|
+
: parseMarkdownAst(facts.source, {plugins: [plugin]}).children;
|
|
80
|
+
const [node] = nodes;
|
|
81
|
+
if (nodes.length !== 1 || node?.type !== 'extension') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const extension = node as MarkdownExtensionNode;
|
|
85
|
+
return extension.plugin === facts.plugin &&
|
|
86
|
+
extension.name === facts.name &&
|
|
87
|
+
extension.display === facts.display &&
|
|
88
|
+
extension.source === facts.source
|
|
89
|
+
? extension
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The formats a plugin node can sit inside, outermost first, as RichText draws
|
|
95
|
+
* them around text (textSemantics.ts): a deletion, then emphasis, then strong.
|
|
96
|
+
*/
|
|
97
|
+
const MARKS: ReadonlyArray<readonly [TextFormatType, 'del' | 'em' | 'strong']> =
|
|
98
|
+
[
|
|
99
|
+
['strikethrough', 'del'],
|
|
100
|
+
['italic', 'em'],
|
|
101
|
+
['bold', 'strong'],
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** The formats a toggle can put a plugin node inside. */
|
|
105
|
+
const NODE_FORMATS: ReadonlySet<TextFormatType> = new Set(
|
|
106
|
+
MARKS.map(([format]) => format),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
function ExtensionNodeView({
|
|
110
|
+
facts,
|
|
111
|
+
format,
|
|
112
|
+
nodeKey,
|
|
113
|
+
plugins,
|
|
114
|
+
}: {
|
|
115
|
+
facts: RichTextExtensionNodeFacts;
|
|
116
|
+
format: number;
|
|
117
|
+
nodeKey: NodeKey;
|
|
118
|
+
plugins: ReadonlyArray<MarkdownPluginEntry>;
|
|
119
|
+
}): ReactNode {
|
|
120
|
+
const [editor] = useLexicalComposerContext();
|
|
121
|
+
const [isSelected, setSelected, clearSelection] =
|
|
122
|
+
useLexicalNodeSelection(nodeKey);
|
|
123
|
+
// A click on the node selects it whole, as a click on a rule does, so a
|
|
124
|
+
// pointer user can format or delete it like a keyboard user.
|
|
125
|
+
useEffect(
|
|
126
|
+
() =>
|
|
127
|
+
editor.registerCommand(
|
|
128
|
+
CLICK_COMMAND,
|
|
129
|
+
event => {
|
|
130
|
+
const element = editor.getElementByKey(nodeKey);
|
|
131
|
+
if (
|
|
132
|
+
!editor.isEditable() ||
|
|
133
|
+
element == null ||
|
|
134
|
+
!(event.target instanceof Node) ||
|
|
135
|
+
!element.contains(event.target)
|
|
136
|
+
) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
clearSelection();
|
|
140
|
+
setSelected(true);
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
COMMAND_PRIORITY_LOW,
|
|
144
|
+
),
|
|
145
|
+
[clearSelection, editor, nodeKey, setSelected],
|
|
146
|
+
);
|
|
147
|
+
// A selected node shows the focus ring a selected rule shows.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const element = editor.getElementByKey(nodeKey);
|
|
150
|
+
const className: unknown = editor._config.theme.markdownExtensionSelected;
|
|
151
|
+
if (element == null || typeof className !== 'string' || className === '') {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (isSelected) {
|
|
155
|
+
addClassNamesToElement(element, className);
|
|
156
|
+
} else {
|
|
157
|
+
removeClassNamesFromElement(element, className);
|
|
158
|
+
}
|
|
159
|
+
}, [editor, isSelected, nodeKey]);
|
|
160
|
+
const node = useMemo(
|
|
161
|
+
() => deriveExtensionNode(facts, plugins),
|
|
162
|
+
[facts, plugins],
|
|
163
|
+
);
|
|
164
|
+
let content: ReactNode =
|
|
165
|
+
node == null ? (
|
|
166
|
+
facts.source
|
|
167
|
+
) : (
|
|
168
|
+
<MarkdownPluginNodeRenderer plugins={plugins} node={node} />
|
|
169
|
+
);
|
|
170
|
+
// Inside emphasis, strong, or strikethrough, the node is drawn inside the
|
|
171
|
+
// same elements, as core Markdown draws it inside the text's.
|
|
172
|
+
for (const [type, Tag] of [...MARKS].reverse()) {
|
|
173
|
+
if ((format & TEXT_TYPE_TO_FORMAT[type]) !== 0) {
|
|
174
|
+
content = <Tag>{content}</Tag>;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return content;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Marks every plugin node so Lexical draws it again with the decorator the
|
|
182
|
+
* editor has now. Not an edit, so it adds nothing to undo history.
|
|
183
|
+
*/
|
|
184
|
+
function redrawExtensionNodes(editor: LexicalEditor): void {
|
|
185
|
+
editor.update(
|
|
186
|
+
() => {
|
|
187
|
+
for (const {node} of $dfs()) {
|
|
188
|
+
if ($isRichTextExtensionNode(node)) {
|
|
189
|
+
node.markDirty();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
{tag: HISTORY_MERGE_TAG},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether `format` will be on for the text a range selects once it is
|
|
199
|
+
* toggled, as Lexical's formatText decides it from the first selected
|
|
200
|
+
* character; null when the range selects no text, only nodes.
|
|
201
|
+
*/
|
|
202
|
+
function $nextTextFormat(
|
|
203
|
+
selection: RangeSelection,
|
|
204
|
+
format: TextFormatType,
|
|
205
|
+
): boolean | null {
|
|
206
|
+
const first = $firstSelectedText(selection);
|
|
207
|
+
return first == null
|
|
208
|
+
? null
|
|
209
|
+
: (first.getFormatFlags(format, null) & TEXT_TYPE_TO_FORMAT[format]) !== 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface MarkdownExtensionsPluginProps {
|
|
213
|
+
extensions: ReadonlyArray<RichTextMarkdownExtension>;
|
|
214
|
+
/** The surface's Markdown transformers, checked against the extensions. */
|
|
215
|
+
transformers: ReadonlyArray<Transformer>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function MarkdownExtensionsPlugin({
|
|
219
|
+
extensions,
|
|
220
|
+
transformers,
|
|
221
|
+
}: MarkdownExtensionsPluginProps): null {
|
|
222
|
+
const [editor] = useLexicalComposerContext();
|
|
223
|
+
const plugins = useMemo(
|
|
224
|
+
() => pluginsOf(extensions, transformers),
|
|
225
|
+
[extensions, transformers],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const unset = setRichTextExtensionNodeDecorator(
|
|
230
|
+
editor,
|
|
231
|
+
(facts, format, nodeKey) => (
|
|
232
|
+
<ExtensionNodeView
|
|
233
|
+
facts={facts}
|
|
234
|
+
format={format}
|
|
235
|
+
nodeKey={nodeKey}
|
|
236
|
+
plugins={plugins}
|
|
237
|
+
/>
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
redrawExtensionNodes(editor);
|
|
241
|
+
return () => {
|
|
242
|
+
unset();
|
|
243
|
+
redrawExtensionNodes(editor);
|
|
244
|
+
};
|
|
245
|
+
}, [editor, plugins]);
|
|
246
|
+
|
|
247
|
+
// Bold, italic, or strikethrough toggled on a selection takes the plugin
|
|
248
|
+
// nodes in it along, so they stay inside the marks the text around them
|
|
249
|
+
// gets — and a selection of nodes alone toggles them by themselves.
|
|
250
|
+
useEffect(
|
|
251
|
+
() =>
|
|
252
|
+
editor.registerCommand(
|
|
253
|
+
FORMAT_TEXT_COMMAND,
|
|
254
|
+
format => {
|
|
255
|
+
const selection = $getSelection();
|
|
256
|
+
if (!NODE_FORMATS.has(format) || selection == null) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
const nodes = selection
|
|
260
|
+
.getNodes()
|
|
261
|
+
.filter(
|
|
262
|
+
(node): node is RichTextExtensionNode =>
|
|
263
|
+
$isRichTextExtensionNode(node) && node.isInline(),
|
|
264
|
+
);
|
|
265
|
+
if (nodes.length === 0) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
// The state Lexical gives the selected text, read before it does;
|
|
269
|
+
// null when no text is selected, only nodes.
|
|
270
|
+
const textState = $isRangeSelection(selection)
|
|
271
|
+
? $nextTextFormat(selection, format)
|
|
272
|
+
: null;
|
|
273
|
+
if ($isRangeSelection(selection)) {
|
|
274
|
+
selection.formatText(format);
|
|
275
|
+
}
|
|
276
|
+
const isOn =
|
|
277
|
+
textState ?? !nodes.every(node => node.hasFormat(format));
|
|
278
|
+
for (const node of nodes) {
|
|
279
|
+
node.setFormatFlag(format, isOn);
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
},
|
|
283
|
+
COMMAND_PRIORITY_LOW,
|
|
284
|
+
),
|
|
285
|
+
[editor],
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return null;
|
|
289
|
+
}
|