Skip to content
Open
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
19 changes: 9 additions & 10 deletions packages/lexical-code-core/src/CodeImportExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import {$createCodeNode} from './CodeNode';

const LANGUAGE_DATA_ATTRIBUTE = 'data-language';
const THEME_DATA_ATTRIBUTE = 'data-theme';

/**
* True for elements whose `font-family` mentions `monospace` — the
Expand Down Expand Up @@ -63,11 +64,10 @@ const GitHubCodeTableOverlayRules = /* @__PURE__ */ defineOverlayRules([

const PreRule = /* @__PURE__ */ defineImportRule({
$import: (ctx, el) => [
$createCodeNode(el.getAttribute(LANGUAGE_DATA_ATTRIBUTE)).splice(
0,
0,
ctx.$importChildren(el),
),
$createCodeNode(
el.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
el.getAttribute(THEME_DATA_ATTRIBUTE),
).splice(0, 0, ctx.$importChildren(el)),
],
match: sel.tag('pre'),
name: '@lexical/code/pre',
Expand All @@ -87,11 +87,10 @@ const MultilineCodeRule = /* @__PURE__ */ defineImportRule({
return $next();
}
return [
$createCodeNode(el.getAttribute(LANGUAGE_DATA_ATTRIBUTE)).splice(
0,
0,
ctx.$importChildren(el),
),
$createCodeNode(
el.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
el.getAttribute(THEME_DATA_ATTRIBUTE),
).splice(0, 0, ctx.$importChildren(el)),
];
},
match: sel.tag('code'),
Expand Down
49 changes: 45 additions & 4 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 Expand Up @@ -392,10 +420,15 @@ function $handleShiftLines(
return true;
}

// A LineBreakNode sibling means the adjacent line is blank, so it has no
// node of its own to anchor the move to — $getFirstCodeNodeOfLine /
// $getLastCodeNodeOfLine hand that linebreak straight back, and it belongs
// to a *different* line. Anchoring on it splices the moving line into the
// line on the far side of the blank one, merging the two.
const adjacentLineIsBlank = $isLineBreakNode(sibling);
const maybeInsertionPoint =
$isCodeHighlightNode(sibling) ||
$isTabNode(sibling) ||
$isLineBreakNode(sibling)
!adjacentLineIsBlank &&
($isCodeHighlightNode(sibling) || $isTabNode(sibling))
? arrowIsUp
? $getFirstCodeNodeOfLine(sibling)
: $getLastCodeNodeOfLine(sibling)
Expand All @@ -404,7 +437,15 @@ function $handleShiftLines(
maybeInsertionPoint != null ? maybeInsertionPoint : sibling;
linebreak.remove();
range.forEach(node => node.remove());
if (type === KEY_ARROW_UP_COMMAND) {
if (adjacentLineIsBlank) {
// The blank line's position is immediately after the sibling linebreak,
// in both directions.
range.forEach(node => {
insertionPoint.insertAfter(node);
insertionPoint = node;
});
insertionPoint.insertAfter(linebreak);
} else if (type === KEY_ARROW_UP_COMMAND) {
range.forEach(node => insertionPoint.insertBefore(node));
insertionPoint.insertBefore(linebreak);
} else {
Expand Down
5 changes: 4 additions & 1 deletion packages/lexical-code-core/src/CodeNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,10 @@ export function $isCodeNode(

function $convertPreElement(domNode: HTMLElement): DOMConversionOutput {
const language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE);
return {node: $createCodeNode(language)};
// exportDOM writes data-theme next to data-language, so read it back here
// too — otherwise the theme is dropped on every HTML round trip.
const theme = domNode.getAttribute(THEME_DATA_ATTRIBUTE);
return {node: $createCodeNode(language, theme)};
}

function $convertDivElement(domNode: Node): DOMConversionOutput {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ describe('CodeImportExtension', () => {
});
});

test('<pre data-theme="poimandres"> restores the theme', () => {
using editor = buildEditor();
importInto(
editor,
'<pre data-language="ts" data-theme="poimandres">x</pre>',
);
editor.read(() => {
const node = $rootCode();
expect(node.getLanguage()).toBe('ts');
expect(node.getTheme()).toBe('poimandres');
});
});

test('multi-line <code data-theme> restores the theme', () => {
using editor = buildEditor();
importInto(
editor,
'<code data-language="ts" data-theme="poimandres">a\nb</code>',
);
editor.read(() => {
const node = $rootCode();
expect(node.getLanguage()).toBe('ts');
expect(node.getTheme()).toBe('poimandres');
});
});

test('multi-line <code> imports as CodeNode (not inline)', () => {
using editor = buildEditor();
importInto(editor, '<code>line1\nline2</code>');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createLineBreakNode,
$createParagraphNode,
$getRoot,
$isParagraphNode,
Expand All @@ -23,6 +24,7 @@ import {
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_UP_COMMAND,
type LexicalCommand,
} from 'lexical';
import {describe, expect, it} from 'vitest';

Expand Down Expand Up @@ -375,4 +377,63 @@ describe('CodeIndentExtension', () => {
);
});
});

describe('shiftLines', () => {
// "A", "" and "B" — a blank line between two lines of code.
function buildBlankLineEditor(caretOnLastLine: boolean) {
const ext = defineExtension({
$initialEditorState: () => {
const codeNode = $createCodeNode('javascript');
const first = $createCodeHighlightNode('A');
const last = $createCodeHighlightNode('B');
codeNode.append(
first,
$createLineBreakNode(),
$createLineBreakNode(),
last,
);
$getRoot().append(codeNode);
(caretOnLastLine ? last : first).select(0, 0);
},
dependencies: [CodeIndentExtension, RichTextExtension],
name: '[root-shift-lines]',
});
return buildEditorFromExtensions(ext);
}

function shift(
editor: ReturnType<typeof buildBlankLineEditor>,
command: LexicalCommand<KeyboardEvent>,
) {
const key = command === KEY_ARROW_UP_COMMAND ? 'ArrowUp' : 'ArrowDown';
editor.dispatchCommand(
command,
new KeyboardEvent('keydown', {altKey: true, key}),
);
}

it('moves a line up past a blank line without merging it into the line above', () => {
using editor = buildBlankLineEditor(true);

shift(editor, KEY_ARROW_UP_COMMAND);

editor.read(() => {
const codeNode = $getRoot().getFirstChildOrThrow();
expect($isCodeNode(codeNode)).toBe(true);
expect(codeNode.getTextContent()).toBe('A\nB\n');
});
});

it('moves a line down past a blank line without merging it into the line below', () => {
using editor = buildBlankLineEditor(false);

shift(editor, KEY_ARROW_DOWN_COMMAND);

editor.read(() => {
const codeNode = $getRoot().getFirstChildOrThrow();
expect($isCodeNode(codeNode)).toBe(true);
expect(codeNode.getTextContent()).toBe('\nA\nB');
});
});
});
});
34 changes: 31 additions & 3 deletions packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
*
*/

import {$createCodeNode} from '@lexical/code-core';
import {$createCodeNode, $isCodeNode, CodeNode} from '@lexical/code-core';
import {$generateNodesFromDOM} from '@lexical/html';
import {$getRoot, type EditorConfig} from 'lexical';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
import {describe, expect, it} from 'vitest';
import {assert, describe, expect, it} from 'vitest';

const editorConfig = {
namespace: '',
Expand Down Expand Up @@ -68,10 +69,37 @@ describe('CodeNode', () => {
expect(exportedElement!.style.padding).toBe('1px');
expect(exportedElement!.style.color).toBe('blue');
});

it('round-trips the theme through exportDOM/importDOM', async () => {
const {editor} = testEnv;

let exportedElement!: HTMLElement;

await editor.update(() => {
const codeNode = $createCodeNode('javascript', 'poimandres');
$getRoot().append(codeNode);
exportedElement = codeNode.exportDOM(editor).element as HTMLElement;
});

expect(exportedElement.getAttribute('data-language')).toBe(
'javascript',
);
expect(exportedElement.getAttribute('data-theme')).toBe('poimandres');

const doc = document.implementation.createHTMLDocument();
doc.body.append(exportedElement);

await editor.update(() => {
const [node] = $generateNodesFromDOM(editor, doc);
assert($isCodeNode(node), 'expected a CodeNode');
expect(node.getLanguage()).toBe('javascript');
expect(node.getTheme()).toBe('poimandres');
});
});
},
{
namespace: 'test',
nodes: [],
nodes: [CodeNode],
theme: editorConfig.theme,
},
);
Expand Down
Loading
Loading