Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/lexical-code-core/src/CodeIndentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,32 @@ function $handleTab(shiftKey: boolean): null | LexicalCommand<void> {
return indentOrOutdent;
}

/**
* Outdent the single line the collapsed caret sits on.
*
* `$getCodeLines` drops a trailing line when the selection ends exactly at its
* start — for a collapsed caret that is always true, so the caret's own line
* never reaches the outdent loop and the line has to be resolved from the
* anchor here. Applies the same rule as that loop: strip a leading TabNode, or
* `tabSize` leading spaces when the extension is configured for them.
*/
function $outdentLineAtCaret(
selection: RangeSelection,
tabSize: number | undefined,
): void {
const anchorNode = selection.anchor.getNode();
// An element point (e.g. the caret on a blank line) has no line to outdent.
if (!$isCodeHighlightNode(anchorNode) && !$isTabNode(anchorNode)) {
return;
}
const firstOfLine = $getFirstCodeNodeOfLine(anchorNode);
if ($isTabNode(firstOfLine)) {
firstOfLine.remove();
} else if (tabSize !== undefined && $isCodeHighlightNode(firstOfLine)) {
$outdentLeadingSpaces(firstOfLine, tabSize, selection);
}
}

function $handleMultilineIndent(
type: LexicalCommand<void>,
tabSize?: number,
Expand All @@ -223,6 +249,8 @@ function $handleMultilineIndent(
if (codeLinesLength === 0 && selection.isCollapsed()) {
if (type === INDENT_CONTENT_COMMAND) {
selection.insertNodes([$createTabNode()]);
} else {
$outdentLineAtCaret(selection, tabSize);
}
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {
$createCodeHighlightNode,
$createCodeNode,
$isCodeNode,
CodeIndentExtension,
} from '@lexical/code';
import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createTabNode,
$getRoot,
$isTabNode,
configExtension,
defineExtension,
OUTDENT_CONTENT_COMMAND,
} from 'lexical';
import {assert, describe, expect, it} from 'vitest';

// Shift+Tab (OUTDENT_CONTENT_COMMAND) with a *collapsed* caret at column 0 of
// an indented code line silently did nothing: $getCodeLines drops a trailing
// line when the selection ends exactly at its start, which for a collapsed
// caret is the only line, so the outdent loop had nothing to work on.

function buildEditor(tabSize?: number) {
return buildEditorFromExtensions(
defineExtension({
$initialEditorState: () => {
const code = $createCodeNode('javascript');
code.append($createTabNode(), $createCodeHighlightNode('hello'));
$getRoot().append(code);
},
dependencies: [
tabSize === undefined
? CodeIndentExtension
: configExtension(CodeIndentExtension, {tabSize}),
RichTextExtension,
],
name: '[root-outdent]',
}),
);
}

function $codeText(): string {
const code = $getRoot().getFirstChildOrThrow();
assert($isCodeNode(code), 'expected a CodeNode');
return code.getTextContent();
}

describe('OUTDENT_CONTENT_COMMAND at the start of a code line', () => {
it('outdents when the caret is collapsed at column 0 (on the TabNode)', () => {
using editor = buildEditor();

editor.update(
() => {
const tab = $getRoot().getFirstDescendant();
assert($isTabNode(tab), 'expected a TabNode');
tab.select(0, 0);
},
{discrete: true},
);
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);

expect(editor.read($codeText)).toBe('hello');
});

it('outdents when the caret is collapsed at column 0 of the code text', () => {
using editor = buildEditor();

editor.update(
() => {
const text = $getRoot().getLastDescendant();
assert(text !== null, 'expected a text node');
text.selectStart();
},
{discrete: true},
);
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);

expect(editor.read($codeText)).toBe('hello');
});

it('still outdents from a non-zero column (unchanged behaviour)', () => {
using editor = buildEditor();

editor.update(
() => {
const text = $getRoot().getLastDescendant();
assert(text !== null, 'expected a text node');
text.select(1, 1);

Check failure on line 97 in packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts

View workflow job for this annotation

GitHub Actions / core-tests / integrity (24.x)

Property 'select' does not exist on type 'LexicalNode'.
},
{discrete: true},
);
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);

expect(editor.read($codeText)).toBe('hello');
});

it('strips a space indent from column 0 when tabSize is configured', () => {
using editor = buildEditorFromExtensions(
defineExtension({
$initialEditorState: () => {
const code = $createCodeNode('javascript');
code.append($createCodeHighlightNode(' hello'));
$getRoot().append(code);
},
dependencies: [
configExtension(CodeIndentExtension, {tabSize: 2}),
RichTextExtension,
],
name: '[root-outdent-spaces]',
}),
);

editor.update(
() => {
const text = $getRoot().getLastDescendant();
assert(text !== null, 'expected a text node');
text.selectStart();
},
{discrete: true},
);
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);

expect(editor.read($codeText)).toBe('hello');
});

it('is still a no-op on an unindented line', () => {
using editor = buildEditorFromExtensions(
defineExtension({
$initialEditorState: () => {
const code = $createCodeNode('javascript');
code.append($createCodeHighlightNode('hello'));
$getRoot().append(code);
},
dependencies: [CodeIndentExtension, RichTextExtension],
name: '[root-outdent-flat]',
}),
);

editor.update(
() => {
const text = $getRoot().getLastDescendant();
assert(text !== null, 'expected a text node');
text.selectStart();
},
{discrete: true},
);
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);

expect(editor.read($codeText)).toBe('hello');
});
});
Loading