@lexical/code-core 0.44.1-nightly.20260519.0 → 0.45.1-dev.0
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/dist/CodeImportExtension.d.ts +50 -0
- package/{CodeNode.d.ts → dist/CodeNode.d.ts} +1 -0
- package/{FlatStructureUtils.d.ts → dist/FlatStructureUtils.d.ts} +10 -1
- package/{LexicalCodeCore.dev.js → dist/LexicalCodeCore.dev.js} +367 -6
- package/{LexicalCodeCore.dev.mjs → dist/LexicalCodeCore.dev.mjs} +366 -8
- package/{LexicalCodeCore.js.flow → dist/LexicalCodeCore.js.flow} +2 -0
- package/{LexicalCodeCore.mjs → dist/LexicalCodeCore.mjs} +3 -0
- package/{LexicalCodeCore.node.mjs → dist/LexicalCodeCore.node.mjs} +3 -0
- package/dist/LexicalCodeCore.prod.js +9 -0
- package/dist/LexicalCodeCore.prod.mjs +9 -0
- package/{index.d.ts → dist/index.d.ts} +2 -1
- package/package.json +32 -16
- package/src/CodeExtension.ts +40 -0
- package/src/CodeHighlightNode.ts +171 -0
- package/src/CodeImportExtension.ts +403 -0
- package/src/CodeIndentation.ts +654 -0
- package/src/CodeNode.ts +503 -0
- package/src/FlatStructureUtils.ts +302 -0
- package/src/index.ts +37 -0
- package/LexicalCodeCore.prod.js +0 -9
- package/LexicalCodeCore.prod.mjs +0 -9
- /package/{CodeExtension.d.ts → dist/CodeExtension.d.ts} +0 -0
- /package/{CodeHighlightNode.d.ts → dist/CodeHighlightNode.d.ts} +0 -0
- /package/{CodeIndentation.d.ts → dist/CodeIndentation.d.ts} +0 -0
- /package/{LexicalCodeCore.js → dist/LexicalCodeCore.js} +0 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {CodeHighlightNode} from './CodeHighlightNode';
|
|
10
|
+
import type {
|
|
11
|
+
BaseSelection,
|
|
12
|
+
LexicalCommand,
|
|
13
|
+
LexicalEditor,
|
|
14
|
+
LineBreakNode,
|
|
15
|
+
RangeSelection,
|
|
16
|
+
TabNode,
|
|
17
|
+
} from 'lexical';
|
|
18
|
+
|
|
19
|
+
import {effect, namedSignals} from '@lexical/extension';
|
|
20
|
+
import invariant from '@lexical/internal/invariant';
|
|
21
|
+
import {
|
|
22
|
+
$createLineBreakNode,
|
|
23
|
+
$createPoint,
|
|
24
|
+
$createTabNode,
|
|
25
|
+
$getCaretRange,
|
|
26
|
+
$getCaretRangeInDirection,
|
|
27
|
+
$getSelection,
|
|
28
|
+
$getSiblingCaret,
|
|
29
|
+
$getTextPointCaret,
|
|
30
|
+
$insertNodes,
|
|
31
|
+
$isLineBreakNode,
|
|
32
|
+
$isRangeSelection,
|
|
33
|
+
$isTabNode,
|
|
34
|
+
$normalizeCaret,
|
|
35
|
+
$setSelectionFromCaretRange,
|
|
36
|
+
COMMAND_PRIORITY_LOW,
|
|
37
|
+
defineExtension,
|
|
38
|
+
INDENT_CONTENT_COMMAND,
|
|
39
|
+
INSERT_TAB_COMMAND,
|
|
40
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
41
|
+
KEY_ARROW_UP_COMMAND,
|
|
42
|
+
KEY_TAB_COMMAND,
|
|
43
|
+
mergeRegister,
|
|
44
|
+
MOVE_TO_END,
|
|
45
|
+
MOVE_TO_START,
|
|
46
|
+
OUTDENT_CONTENT_COMMAND,
|
|
47
|
+
safeCast,
|
|
48
|
+
} from 'lexical';
|
|
49
|
+
|
|
50
|
+
import {CodeExtension} from './CodeExtension';
|
|
51
|
+
import {$isCodeHighlightNode} from './CodeHighlightNode';
|
|
52
|
+
import {$isCodeNode} from './CodeNode';
|
|
53
|
+
import {
|
|
54
|
+
$getCodeLineDirection,
|
|
55
|
+
$getEndOfCodeInLine,
|
|
56
|
+
$getFirstCodeNodeOfLine,
|
|
57
|
+
$getLastCodeNodeOfLine,
|
|
58
|
+
$getStartOfCodeInLine,
|
|
59
|
+
$outdentLeadingSpaces,
|
|
60
|
+
} from './FlatStructureUtils';
|
|
61
|
+
|
|
62
|
+
function $isSelectionInCode(selection: null | BaseSelection): boolean {
|
|
63
|
+
if (!$isRangeSelection(selection)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const anchorNode = selection.anchor.getNode();
|
|
67
|
+
const maybeAnchorCodeNode = $isCodeNode(anchorNode)
|
|
68
|
+
? anchorNode
|
|
69
|
+
: anchorNode.getParent();
|
|
70
|
+
const focusNode = selection.focus.getNode();
|
|
71
|
+
const maybeFocusCodeNode = $isCodeNode(focusNode)
|
|
72
|
+
? focusNode
|
|
73
|
+
: focusNode.getParent();
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
$isCodeNode(maybeAnchorCodeNode) &&
|
|
77
|
+
maybeAnchorCodeNode.is(maybeFocusCodeNode)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns an Array of code lines
|
|
83
|
+
* Take the sequence of LineBreakNode | TabNode | CodeHighlightNode forming
|
|
84
|
+
* the selection and split it by LineBreakNode.
|
|
85
|
+
* If the selection ends at the start of the last line, it is considered empty.
|
|
86
|
+
* Empty lines are discarded.
|
|
87
|
+
*/
|
|
88
|
+
function $getCodeLines(
|
|
89
|
+
selection: RangeSelection,
|
|
90
|
+
): (CodeHighlightNode | TabNode)[][] {
|
|
91
|
+
const nodes = selection.getNodes();
|
|
92
|
+
const lines: (CodeHighlightNode | TabNode)[][] = [];
|
|
93
|
+
if (nodes.length === 1 && $isCodeNode(nodes[0])) {
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
let lastLine: (CodeHighlightNode | TabNode)[] = [];
|
|
97
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
98
|
+
const node = nodes[i];
|
|
99
|
+
invariant(
|
|
100
|
+
$isCodeHighlightNode(node) || $isTabNode(node) || $isLineBreakNode(node),
|
|
101
|
+
'Expected selection to be inside CodeBlock and consisting of CodeHighlightNode, TabNode and LineBreakNode',
|
|
102
|
+
);
|
|
103
|
+
if ($isLineBreakNode(node)) {
|
|
104
|
+
if (lastLine.length > 0) {
|
|
105
|
+
lines.push(lastLine);
|
|
106
|
+
lastLine = [];
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
lastLine.push(node);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (lastLine.length > 0) {
|
|
113
|
+
const selectionEnd = selection.isBackward()
|
|
114
|
+
? selection.anchor
|
|
115
|
+
: selection.focus;
|
|
116
|
+
|
|
117
|
+
// Discard the last line if the selection ends exactly at the
|
|
118
|
+
// start of the line (no real selection)
|
|
119
|
+
const lastPoint = $createPoint(lastLine[0].getKey(), 0, 'text');
|
|
120
|
+
if (!selectionEnd.is(lastPoint)) {
|
|
121
|
+
lines.push(lastLine);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function $handleTab(shiftKey: boolean): null | LexicalCommand<void> {
|
|
129
|
+
const selection = $getSelection();
|
|
130
|
+
if (!$isRangeSelection(selection) || !$isSelectionInCode(selection)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const indentOrOutdent = !shiftKey
|
|
134
|
+
? INDENT_CONTENT_COMMAND
|
|
135
|
+
: OUTDENT_CONTENT_COMMAND;
|
|
136
|
+
const tabOrOutdent = !shiftKey ? INSERT_TAB_COMMAND : OUTDENT_CONTENT_COMMAND;
|
|
137
|
+
|
|
138
|
+
const anchor = selection.anchor;
|
|
139
|
+
const focus = selection.focus;
|
|
140
|
+
|
|
141
|
+
// 1. early decision when there is no real selection
|
|
142
|
+
if (anchor.is(focus)) {
|
|
143
|
+
return tabOrOutdent;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. If only empty lines or multiple non-empty lines are selected: indent/outdent
|
|
147
|
+
const codeLines = $getCodeLines(selection);
|
|
148
|
+
if (codeLines.length !== 1) {
|
|
149
|
+
return indentOrOutdent;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const codeLine: (CodeHighlightNode | TabNode)[] = codeLines[0];
|
|
153
|
+
const codeLineLength = codeLine.length;
|
|
154
|
+
|
|
155
|
+
invariant(
|
|
156
|
+
codeLineLength !== 0,
|
|
157
|
+
'$getCodeLines only extracts non-empty lines',
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Take into account the direction of the selection
|
|
161
|
+
let selectionFirst;
|
|
162
|
+
let selectionLast;
|
|
163
|
+
if (selection.isBackward()) {
|
|
164
|
+
selectionFirst = focus;
|
|
165
|
+
selectionLast = anchor;
|
|
166
|
+
} else {
|
|
167
|
+
selectionFirst = anchor;
|
|
168
|
+
selectionLast = focus;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// find boundary elements of the line
|
|
172
|
+
// since codeLine only contains TabNode | CodeHighlightNode
|
|
173
|
+
// the result of these functions should is of Type TabNode | CodeHighlightNode
|
|
174
|
+
const firstOfLine = $getFirstCodeNodeOfLine(codeLine[0]);
|
|
175
|
+
const lastOfLine = $getLastCodeNodeOfLine(codeLine[0]);
|
|
176
|
+
|
|
177
|
+
const anchorOfLine = $createPoint(firstOfLine.getKey(), 0, 'text');
|
|
178
|
+
const focusOfLine = $createPoint(
|
|
179
|
+
lastOfLine.getKey(),
|
|
180
|
+
lastOfLine.getTextContentSize(),
|
|
181
|
+
'text',
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// 3. multiline because selection started strictly before the line
|
|
185
|
+
if (selectionFirst.isBefore(anchorOfLine)) {
|
|
186
|
+
return indentOrOutdent;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 4. multiline because the selection stops strictly after the line
|
|
190
|
+
if (focusOfLine.isBefore(selectionLast)) {
|
|
191
|
+
return indentOrOutdent;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The selection if within the line.
|
|
195
|
+
// 4. If it does not touch both borders, it needs a tab
|
|
196
|
+
if (
|
|
197
|
+
anchorOfLine.isBefore(selectionFirst) ||
|
|
198
|
+
selectionLast.isBefore(focusOfLine)
|
|
199
|
+
) {
|
|
200
|
+
return tabOrOutdent;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 5. Selection is matching a full line on non-empty code
|
|
204
|
+
return indentOrOutdent;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function $handleMultilineIndent(
|
|
208
|
+
type: LexicalCommand<void>,
|
|
209
|
+
tabSize?: number,
|
|
210
|
+
): boolean {
|
|
211
|
+
const selection = $getSelection();
|
|
212
|
+
if (!$isRangeSelection(selection) || !$isSelectionInCode(selection)) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const codeLines = $getCodeLines(selection);
|
|
217
|
+
const codeLinesLength = codeLines.length;
|
|
218
|
+
|
|
219
|
+
// Special Indent case
|
|
220
|
+
// Selection is collapsed at the beginning of a line
|
|
221
|
+
if (codeLinesLength === 0 && selection.isCollapsed()) {
|
|
222
|
+
if (type === INDENT_CONTENT_COMMAND) {
|
|
223
|
+
selection.insertNodes([$createTabNode()]);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Special Indent case
|
|
229
|
+
// Selection is matching only one LineBreak
|
|
230
|
+
if (
|
|
231
|
+
codeLinesLength === 0 &&
|
|
232
|
+
type === INDENT_CONTENT_COMMAND &&
|
|
233
|
+
selection.getTextContent() === '\n'
|
|
234
|
+
) {
|
|
235
|
+
const tabNode = $createTabNode();
|
|
236
|
+
const lineBreakNode = $createLineBreakNode();
|
|
237
|
+
const direction = selection.isBackward() ? 'previous' : 'next';
|
|
238
|
+
selection.insertNodes([tabNode, lineBreakNode]);
|
|
239
|
+
$setSelectionFromCaretRange(
|
|
240
|
+
$getCaretRangeInDirection(
|
|
241
|
+
$getCaretRange(
|
|
242
|
+
$getTextPointCaret(tabNode, 'next', 0),
|
|
243
|
+
$normalizeCaret($getSiblingCaret(lineBreakNode, 'next')),
|
|
244
|
+
),
|
|
245
|
+
direction,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Indent Non Empty Lines
|
|
253
|
+
for (let i = 0; i < codeLinesLength; i++) {
|
|
254
|
+
const line = codeLines[i];
|
|
255
|
+
// a line here is never empty
|
|
256
|
+
if (line.length > 0) {
|
|
257
|
+
let firstOfLine: null | CodeHighlightNode | TabNode | LineBreakNode =
|
|
258
|
+
line[0];
|
|
259
|
+
|
|
260
|
+
// make sure to consider the first node on the first line
|
|
261
|
+
// because the line might not be fully selected
|
|
262
|
+
if (i === 0) {
|
|
263
|
+
firstOfLine = $getFirstCodeNodeOfLine(firstOfLine);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (type === INDENT_CONTENT_COMMAND) {
|
|
267
|
+
const tabNode = $createTabNode();
|
|
268
|
+
firstOfLine.insertBefore(tabNode);
|
|
269
|
+
// First real code line may need selection adjustment
|
|
270
|
+
// when firstOfLine is at the selection boundary
|
|
271
|
+
if (i === 0) {
|
|
272
|
+
const anchorKey = selection.isBackward() ? 'focus' : 'anchor';
|
|
273
|
+
const anchorLine = $createPoint(firstOfLine.getKey(), 0, 'text');
|
|
274
|
+
|
|
275
|
+
if (selection[anchorKey].is(anchorLine)) {
|
|
276
|
+
selection[anchorKey].set(tabNode.getKey(), 0, 'text');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} else if ($isTabNode(firstOfLine)) {
|
|
280
|
+
firstOfLine.remove();
|
|
281
|
+
} else if (tabSize !== undefined && $isCodeHighlightNode(firstOfLine)) {
|
|
282
|
+
// Outdent space-indented lines (e.g. code formatted with prettier).
|
|
283
|
+
$outdentLeadingSpaces(firstOfLine, tabSize, selection);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function $handleShiftLines(
|
|
291
|
+
type: LexicalCommand<KeyboardEvent>,
|
|
292
|
+
event: KeyboardEvent,
|
|
293
|
+
): boolean {
|
|
294
|
+
// We only care about the alt+arrow keys
|
|
295
|
+
const selection = $getSelection();
|
|
296
|
+
if (!$isRangeSelection(selection)) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// I'm not quite sure why, but it seems like calling anchor.getNode() collapses the selection here
|
|
301
|
+
// So first, get the anchor and the focus, then get their nodes
|
|
302
|
+
const {anchor, focus} = selection;
|
|
303
|
+
const anchorOffset = anchor.offset;
|
|
304
|
+
const focusOffset = focus.offset;
|
|
305
|
+
const anchorNode = anchor.getNode();
|
|
306
|
+
const focusNode = focus.getNode();
|
|
307
|
+
const arrowIsUp = type === KEY_ARROW_UP_COMMAND;
|
|
308
|
+
|
|
309
|
+
// Ensure the selection is within the codeblock
|
|
310
|
+
if (
|
|
311
|
+
!$isSelectionInCode(selection) ||
|
|
312
|
+
!($isCodeHighlightNode(anchorNode) || $isTabNode(anchorNode)) ||
|
|
313
|
+
!($isCodeHighlightNode(focusNode) || $isTabNode(focusNode))
|
|
314
|
+
) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
if (!event.altKey) {
|
|
318
|
+
// Handle moving selection out of the code block, given there are no
|
|
319
|
+
// siblings that can natively take the selection.
|
|
320
|
+
if (selection.isCollapsed()) {
|
|
321
|
+
const codeNode = anchorNode.getParentOrThrow();
|
|
322
|
+
if (
|
|
323
|
+
arrowIsUp &&
|
|
324
|
+
anchorOffset === 0 &&
|
|
325
|
+
anchorNode.getPreviousSibling() === null
|
|
326
|
+
) {
|
|
327
|
+
const codeNodeSibling = codeNode.getPreviousSibling();
|
|
328
|
+
if (codeNodeSibling === null) {
|
|
329
|
+
codeNode.selectPrevious();
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
} else if (
|
|
334
|
+
!arrowIsUp &&
|
|
335
|
+
anchorOffset === anchorNode.getTextContentSize() &&
|
|
336
|
+
anchorNode.getNextSibling() === null
|
|
337
|
+
) {
|
|
338
|
+
const codeNodeSibling = codeNode.getNextSibling();
|
|
339
|
+
if (codeNodeSibling === null) {
|
|
340
|
+
codeNode.selectNext();
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let start;
|
|
350
|
+
let end;
|
|
351
|
+
if (anchorNode.isBefore(focusNode)) {
|
|
352
|
+
start = $getFirstCodeNodeOfLine(anchorNode);
|
|
353
|
+
end = $getLastCodeNodeOfLine(focusNode);
|
|
354
|
+
} else {
|
|
355
|
+
start = $getFirstCodeNodeOfLine(focusNode);
|
|
356
|
+
end = $getLastCodeNodeOfLine(anchorNode);
|
|
357
|
+
}
|
|
358
|
+
if (start == null || end == null) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const range = start.getNodesBetween(end);
|
|
363
|
+
for (let i = 0; i < range.length; i++) {
|
|
364
|
+
const node = range[i];
|
|
365
|
+
if (
|
|
366
|
+
!$isCodeHighlightNode(node) &&
|
|
367
|
+
!$isTabNode(node) &&
|
|
368
|
+
!$isLineBreakNode(node)
|
|
369
|
+
) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// After this point, we know the selection is within the codeblock. We may not be able to
|
|
375
|
+
// actually move the lines around, but we want to return true either way to prevent
|
|
376
|
+
// the event's default behavior
|
|
377
|
+
event.preventDefault();
|
|
378
|
+
event.stopPropagation(); // required to stop cursor movement under Firefox
|
|
379
|
+
|
|
380
|
+
const linebreak = arrowIsUp
|
|
381
|
+
? start.getPreviousSibling()
|
|
382
|
+
: end.getNextSibling();
|
|
383
|
+
if (!$isLineBreakNode(linebreak)) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
const sibling = arrowIsUp
|
|
387
|
+
? linebreak.getPreviousSibling()
|
|
388
|
+
: linebreak.getNextSibling();
|
|
389
|
+
if (sibling == null) {
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const maybeInsertionPoint =
|
|
394
|
+
$isCodeHighlightNode(sibling) ||
|
|
395
|
+
$isTabNode(sibling) ||
|
|
396
|
+
$isLineBreakNode(sibling)
|
|
397
|
+
? arrowIsUp
|
|
398
|
+
? $getFirstCodeNodeOfLine(sibling)
|
|
399
|
+
: $getLastCodeNodeOfLine(sibling)
|
|
400
|
+
: null;
|
|
401
|
+
let insertionPoint =
|
|
402
|
+
maybeInsertionPoint != null ? maybeInsertionPoint : sibling;
|
|
403
|
+
linebreak.remove();
|
|
404
|
+
range.forEach(node => node.remove());
|
|
405
|
+
if (type === KEY_ARROW_UP_COMMAND) {
|
|
406
|
+
range.forEach(node => insertionPoint.insertBefore(node));
|
|
407
|
+
insertionPoint.insertBefore(linebreak);
|
|
408
|
+
} else {
|
|
409
|
+
insertionPoint.insertAfter(linebreak);
|
|
410
|
+
insertionPoint = linebreak;
|
|
411
|
+
range.forEach(node => {
|
|
412
|
+
insertionPoint.insertAfter(node);
|
|
413
|
+
insertionPoint = node;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
selection.setTextNodeRange(anchorNode, anchorOffset, focusNode, focusOffset);
|
|
418
|
+
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function $handleMoveTo(
|
|
423
|
+
type: LexicalCommand<KeyboardEvent>,
|
|
424
|
+
event: KeyboardEvent,
|
|
425
|
+
): boolean {
|
|
426
|
+
const selection = $getSelection();
|
|
427
|
+
if (!$isRangeSelection(selection)) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const {anchor, focus} = selection;
|
|
432
|
+
const anchorNode = anchor.getNode();
|
|
433
|
+
const focusNode = focus.getNode();
|
|
434
|
+
const isMoveToStart = type === MOVE_TO_START;
|
|
435
|
+
|
|
436
|
+
// Ensure the selection is within the codeblock
|
|
437
|
+
if (
|
|
438
|
+
!$isSelectionInCode(selection) ||
|
|
439
|
+
!($isCodeHighlightNode(anchorNode) || $isTabNode(anchorNode)) ||
|
|
440
|
+
!($isCodeHighlightNode(focusNode) || $isTabNode(focusNode))
|
|
441
|
+
) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const focusLineNode = focusNode as CodeHighlightNode | TabNode;
|
|
446
|
+
const direction = $getCodeLineDirection(focusLineNode);
|
|
447
|
+
const moveToStart = direction === 'rtl' ? !isMoveToStart : isMoveToStart;
|
|
448
|
+
|
|
449
|
+
// Shift variant: let the non-shift branches resolve the target via
|
|
450
|
+
// framework helpers (`selectNext` / `selectStart` / `setTextNodeRange` /
|
|
451
|
+
// `node.select`), then restore the original anchor so we end up with an
|
|
452
|
+
// extended selection rather than a collapsed caret. This keeps point
|
|
453
|
+
// shapes (text vs. element) consistent between shift and non-shift.
|
|
454
|
+
const originalAnchorKey = anchor.key;
|
|
455
|
+
const originalAnchorOffset = anchor.offset;
|
|
456
|
+
const originalAnchorType = anchor.type;
|
|
457
|
+
if (moveToStart) {
|
|
458
|
+
const start = $getStartOfCodeInLine(focusLineNode, focus.offset);
|
|
459
|
+
if (start !== null) {
|
|
460
|
+
const {node, offset} = start;
|
|
461
|
+
if ($isLineBreakNode(node)) {
|
|
462
|
+
node.selectNext(0, 0);
|
|
463
|
+
} else {
|
|
464
|
+
selection.setTextNodeRange(node, offset, node, offset);
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
focusLineNode.getParentOrThrow().selectStart();
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
const node = $getEndOfCodeInLine(focusLineNode);
|
|
471
|
+
node.select();
|
|
472
|
+
}
|
|
473
|
+
if (event.shiftKey) {
|
|
474
|
+
selection.anchor.set(
|
|
475
|
+
originalAnchorKey,
|
|
476
|
+
originalAnchorOffset,
|
|
477
|
+
originalAnchorType,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
event.preventDefault();
|
|
482
|
+
event.stopPropagation();
|
|
483
|
+
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @internal
|
|
489
|
+
* Register the keyboard and command handlers that drive code-block
|
|
490
|
+
* indentation: Tab / Shift+Tab, INDENT/OUTDENT_CONTENT_COMMAND,
|
|
491
|
+
* INSERT_TAB_COMMAND, alt+arrow line shifting, and Home/End movement.
|
|
492
|
+
*
|
|
493
|
+
* Both `@lexical/code-shiki` and `@lexical/code-prism` use this via
|
|
494
|
+
* {@link CodeIndentExtension}; callers using `registerCodeHighlighting`
|
|
495
|
+
* will implicitly call this with tabSize of `undefined`.
|
|
496
|
+
*
|
|
497
|
+
* @param editor The editor to register on.
|
|
498
|
+
* @param tabSize When set, OUTDENT_CONTENT_COMMAND (Shift+Tab) also strips
|
|
499
|
+
* up to that many leading spaces from a code line. See
|
|
500
|
+
* {@link $outdentLeadingSpaces}.
|
|
501
|
+
*/
|
|
502
|
+
export function registerCodeIndentation(
|
|
503
|
+
editor: LexicalEditor,
|
|
504
|
+
tabSize?: number,
|
|
505
|
+
): () => void {
|
|
506
|
+
return mergeRegister(
|
|
507
|
+
editor.registerCommand(
|
|
508
|
+
KEY_TAB_COMMAND,
|
|
509
|
+
event => {
|
|
510
|
+
const command = $handleTab(event.shiftKey);
|
|
511
|
+
if (command === null) {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
editor.dispatchCommand(command, undefined);
|
|
516
|
+
return true;
|
|
517
|
+
},
|
|
518
|
+
COMMAND_PRIORITY_LOW,
|
|
519
|
+
),
|
|
520
|
+
editor.registerCommand(
|
|
521
|
+
INSERT_TAB_COMMAND,
|
|
522
|
+
() => {
|
|
523
|
+
const selection = $getSelection();
|
|
524
|
+
if (!$isSelectionInCode(selection)) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
$insertNodes([$createTabNode()]);
|
|
528
|
+
return true;
|
|
529
|
+
},
|
|
530
|
+
COMMAND_PRIORITY_LOW,
|
|
531
|
+
),
|
|
532
|
+
editor.registerCommand(
|
|
533
|
+
INDENT_CONTENT_COMMAND,
|
|
534
|
+
(): boolean => $handleMultilineIndent(INDENT_CONTENT_COMMAND),
|
|
535
|
+
COMMAND_PRIORITY_LOW,
|
|
536
|
+
),
|
|
537
|
+
editor.registerCommand(
|
|
538
|
+
OUTDENT_CONTENT_COMMAND,
|
|
539
|
+
(): boolean => $handleMultilineIndent(OUTDENT_CONTENT_COMMAND, tabSize),
|
|
540
|
+
COMMAND_PRIORITY_LOW,
|
|
541
|
+
),
|
|
542
|
+
editor.registerCommand(
|
|
543
|
+
KEY_ARROW_UP_COMMAND,
|
|
544
|
+
event => {
|
|
545
|
+
const selection = $getSelection();
|
|
546
|
+
if (!$isRangeSelection(selection)) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
const {anchor} = selection;
|
|
550
|
+
const anchorNode = anchor.getNode();
|
|
551
|
+
if (!$isSelectionInCode(selection)) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
// If at the start of a code block, prevent selection from moving out
|
|
555
|
+
if (
|
|
556
|
+
selection.isCollapsed() &&
|
|
557
|
+
anchor.offset === 0 &&
|
|
558
|
+
anchorNode.getPreviousSibling() === null &&
|
|
559
|
+
$isCodeNode(anchorNode.getParentOrThrow())
|
|
560
|
+
) {
|
|
561
|
+
event.preventDefault();
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
return $handleShiftLines(KEY_ARROW_UP_COMMAND, event);
|
|
565
|
+
},
|
|
566
|
+
COMMAND_PRIORITY_LOW,
|
|
567
|
+
),
|
|
568
|
+
editor.registerCommand(
|
|
569
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
570
|
+
event => {
|
|
571
|
+
const selection = $getSelection();
|
|
572
|
+
if (!$isRangeSelection(selection)) {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
const {anchor} = selection;
|
|
576
|
+
const anchorNode = anchor.getNode();
|
|
577
|
+
if (!$isSelectionInCode(selection)) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
// If at the end of a code block, prevent selection from moving out
|
|
581
|
+
if (
|
|
582
|
+
selection.isCollapsed() &&
|
|
583
|
+
anchor.offset === anchorNode.getTextContentSize() &&
|
|
584
|
+
anchorNode.getNextSibling() === null &&
|
|
585
|
+
$isCodeNode(anchorNode.getParentOrThrow())
|
|
586
|
+
) {
|
|
587
|
+
event.preventDefault();
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
return $handleShiftLines(KEY_ARROW_DOWN_COMMAND, event);
|
|
591
|
+
},
|
|
592
|
+
COMMAND_PRIORITY_LOW,
|
|
593
|
+
),
|
|
594
|
+
editor.registerCommand(
|
|
595
|
+
MOVE_TO_START,
|
|
596
|
+
event => $handleMoveTo(MOVE_TO_START, event),
|
|
597
|
+
COMMAND_PRIORITY_LOW,
|
|
598
|
+
),
|
|
599
|
+
editor.registerCommand(
|
|
600
|
+
MOVE_TO_END,
|
|
601
|
+
event => $handleMoveTo(MOVE_TO_END, event),
|
|
602
|
+
COMMAND_PRIORITY_LOW,
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export interface CodeIndentConfig {
|
|
608
|
+
/**
|
|
609
|
+
* When true, the indent commands are not registered on the editor.
|
|
610
|
+
* This signal can be flipped at runtime to enable or disable indent
|
|
611
|
+
* handling without rebuilding the editor.
|
|
612
|
+
*/
|
|
613
|
+
disabled: boolean;
|
|
614
|
+
/**
|
|
615
|
+
* When set, treats that many leading spaces on a code line as one indent
|
|
616
|
+
* level for the OUTDENT_CONTENT_COMMAND (Shift+Tab). See
|
|
617
|
+
* {@link registerCodeIndentation}. When undefined (the default), only
|
|
618
|
+
* TabNode removal is supported on outdent.
|
|
619
|
+
*
|
|
620
|
+
* Tab and INSERT_TAB_COMMAND continue to insert a TabNode regardless of
|
|
621
|
+
* this option.
|
|
622
|
+
*/
|
|
623
|
+
tabSize: number | undefined;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Adds keyboard-driven indentation to code blocks (Tab / Shift+Tab,
|
|
628
|
+
* alt+arrow line shifts, Home/End within a line). Both
|
|
629
|
+
* {@link "@lexical/code-shiki".CodeShikiExtension} and
|
|
630
|
+
* {@link "@lexical/code-prism".CodePrismExtension} declare this as a
|
|
631
|
+
* dependency, so it is activated automatically alongside either
|
|
632
|
+
* highlighter.
|
|
633
|
+
*
|
|
634
|
+
* Code blocks without syntax highlighting can use this extension on its
|
|
635
|
+
* own.
|
|
636
|
+
*/
|
|
637
|
+
export const CodeIndentExtension = defineExtension({
|
|
638
|
+
build: (editor, config) => namedSignals(config),
|
|
639
|
+
config: safeCast<CodeIndentConfig>({
|
|
640
|
+
disabled: false,
|
|
641
|
+
tabSize: undefined,
|
|
642
|
+
}),
|
|
643
|
+
dependencies: [CodeExtension],
|
|
644
|
+
name: '@lexical/code-indent',
|
|
645
|
+
register: (editor, config, state) => {
|
|
646
|
+
const stores = state.getOutput();
|
|
647
|
+
return effect(() => {
|
|
648
|
+
if (stores.disabled.value) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
return registerCodeIndentation(editor, stores.tabSize.value);
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
});
|