Skip to content
Merged
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
35 changes: 13 additions & 22 deletions packages/lexical-code/src/CodeHighlighterPrism.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
$getCaretRange,
$getCaretRangeInDirection,
$getNodeByKey,
$getRoot,
$getSelection,
$getSiblingCaret,
$getTextPointCaret,
Expand Down Expand Up @@ -843,23 +844,18 @@ export function registerCodeHighlighting(
KEY_ARROW_UP_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
if (!$isRangeSelection(selection) || !$isSelectionInCode(selection)) {
return false;
}
const firstNode = $getRoot().getFirstDescendant();
const {anchor} = selection;
const anchorNode = anchor.getNode();
if (!$isSelectionInCode(selection)) {
return false;
}
// If at the start of a code block, prevent selection from moving out
if (
selection.isCollapsed() &&
anchor.offset === 0 &&
anchorNode.getPreviousSibling() === null &&
$isCodeNode(anchorNode.getParentOrThrow())
firstNode &&
anchorNode &&
firstNode.getKey() === anchorNode.getKey()
) {
event.preventDefault();
return true;
return false;
}
return $handleShiftLines(KEY_ARROW_UP_COMMAND, event);
},
Expand All @@ -869,23 +865,18 @@ export function registerCodeHighlighting(
KEY_ARROW_DOWN_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
if (!$isRangeSelection(selection) || !$isSelectionInCode(selection)) {
return false;
}
const lastNode = $getRoot().getLastDescendant();
const {anchor} = selection;
const anchorNode = anchor.getNode();
if (!$isSelectionInCode(selection)) {
return false;
}
// If at the end of a code block, prevent selection from moving out
if (
selection.isCollapsed() &&
anchor.offset === anchorNode.getTextContentSize() &&
anchorNode.getNextSibling() === null &&
$isCodeNode(anchorNode.getParentOrThrow())
lastNode &&
anchorNode &&
lastNode.getKey() === anchorNode.getKey()
) {
event.preventDefault();
return true;
return false;
}
return $handleShiftLines(KEY_ARROW_DOWN_COMMAND, event);
},
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-link/src/LexicalAutoLinkExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,5 +599,6 @@ export const AutoLinkExtension = defineExtension({
return merged;
},
name: '@lexical/link/AutoLink',
nodes: [AutoLinkNode],
register: registerAutoLink,
});
247 changes: 114 additions & 133 deletions packages/lexical-link/src/__tests__/unit/LexicalAutoLinkExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,42 @@
*
*/

import {buildEditorFromExtensions} from '@lexical/extension';
import {
$isAutoLinkNode,
AutoLinkNode,
AutoLinkExtension,
createLinkMatcherWithRegExp,
registerAutoLink,
} from '@lexical/link';
import {
$create,
$createParagraphNode,
$createTextNode,
$getRoot,
$isElementNode,
$isParagraphNode,
$isTextNode,
configExtension,
defineExtension,
ElementNode,
type LexicalNode,
ParagraphNode,
TextNode,
} from 'lexical/src';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
} from 'lexical';
import {assert, describe, expect, test} from 'vitest';

class ExcludedParentNode extends ElementNode {
static getType(): string {
return 'excluded-parent';
}
static clone(node: ExcludedParentNode): ExcludedParentNode {
return new ExcludedParentNode(node.__key);
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): boolean {
return false;
$config() {
return this.config('excluded-parent', {extends: ElementNode});
}
}

function $createExcludedParentNode(): ExcludedParentNode {
return new ExcludedParentNode();
}

function $isExcludedParentNode(
node: LexicalNode | null | undefined,
): node is ExcludedParentNode {
return $isElementNode(node) && node.getType() === 'excluded-parent';
return node instanceof ExcludedParentNode;
}

const editorConfig = Object.freeze({
namespace: '',
nodes: [AutoLinkNode, ParagraphNode, TextNode, ExcludedParentNode],
const TestLexicalAutoLinkExtension = defineExtension({
dependencies: [AutoLinkExtension],
name: '[test root]',
nodes: [ExcludedParentNode],
theme: {
link: 'my-autolink-class',
text: {
Expand All @@ -74,121 +61,115 @@ const URL_MATCHER = createLinkMatcherWithRegExp(
);

describe('LexicalAutoLinkExtension tests', () => {
initializeUnitTest((testEnv) => {
test('registerAutoLink does not cause infinite transform loop with #1234.Another', async () => {
const {editor} = testEnv;

// Register AutoLink with a hashtag matcher that matches # followed by digits
const hashtagMatcher = createLinkMatcherWithRegExp(/#\d+/);
const unregister = registerAutoLink(editor, {
changeHandlers: [],
excludeParents: [],
matchers: [hashtagMatcher],
});

// Initialize content with #1234.Another - this should not cause an infinite loop
await editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const textNode = $createTextNode('#1234.Another');
paragraph.append(textNode);
root.append(paragraph);
});

// Verify content is correct and that #1234 was converted to an AutoLinkNode
editor.read(() => {
const root = $getRoot();
const paragraphNode = root.getFirstChild();
assert(
$isParagraphNode(paragraphNode),
'first root child must be a ParagraphNode',
);
expect(paragraphNode.getTextContent()).toBe('#1234.Another');

// Verify that #1234 was converted to an AutoLinkNode
const autoLinkNode = paragraphNode.getFirstChild();
assert(
$isAutoLinkNode(autoLinkNode),
'first child must be an AutoLinkNode',
test('registerAutoLink does not cause infinite transform loop with #1234.Another', async () => {
// Register AutoLink with a hashtag matcher that matches # followed by digits
const hashtagMatcher = createLinkMatcherWithRegExp(/#\d+/);

const editor = buildEditorFromExtensions({
$initialEditorState() {
// Initialize content with #1234.Another - this should not cause an infinite loop
$getRoot().append(
$createParagraphNode().append($createTextNode('#1234.Another')),
);

// The AutoLinkNode should contain "#1234" only (the matched portion)
expect(autoLinkNode.getTextContent()).toBe('#1234');

// Verify that ".Another" is separate text after the link (unmatched portion)
const nextSibling = autoLinkNode.getNextSibling();
assert($isTextNode(nextSibling), 'next sibling must be a TextNode');
expect(nextSibling.getTextContent()).toBe('.Another');
});

unregister();
},
dependencies: [
TestLexicalAutoLinkExtension,
configExtension(AutoLinkExtension, {
matchers: [hashtagMatcher],
}),
],
name: '[test override]',
});

test('excludeParents prevents auto-linking inside excluded parent nodes', async () => {
const {editor} = testEnv;

const unregister = registerAutoLink(editor, {
changeHandlers: [],
excludeParents: [$isExcludedParentNode],
matchers: [URL_MATCHER],
});

await editor.update(() => {
const root = $getRoot();
const excludedParent = $createExcludedParentNode();
const textNode = $createTextNode('https://example.com');
excludedParent.append(textNode);
root.append(excludedParent);
});

editor.read(() => {
const root = $getRoot();
const excludedParent = root.getFirstChild();
assert(
$isExcludedParentNode(excludedParent),
'first root child must be an ExcludedParentNode',
// Verify content is correct and that #1234 was converted to an AutoLinkNode
editor.read(() => {
const root = $getRoot();
const paragraphNode = root.getFirstChild();
assert(
$isParagraphNode(paragraphNode),
'first root child must be a ParagraphNode',
);
expect(paragraphNode.getTextContent()).toBe('#1234.Another');

// Verify that #1234 was converted to an AutoLinkNode
const autoLinkNode = paragraphNode.getFirstChild();
assert(
$isAutoLinkNode(autoLinkNode),
'first child must be an AutoLinkNode',
);

// The AutoLinkNode should contain "#1234" only (the matched portion)
expect(autoLinkNode.getTextContent()).toBe('#1234');

// Verify that ".Another" is separate text after the link (unmatched portion)
const nextSibling = autoLinkNode.getNextSibling();
assert($isTextNode(nextSibling), 'next sibling must be a TextNode');
expect(nextSibling.getTextContent()).toBe('.Another');
});
});

test('excludeParents prevents auto-linking inside excluded parent nodes', async () => {
const editor = buildEditorFromExtensions({
$initialEditorState() {
$getRoot().append(
$create(ExcludedParentNode).append(
$createTextNode('https://example.com'),
),
);
const child = excludedParent.getFirstChild();
assert($isTextNode(child), 'child must be a TextNode');
expect(child.getTextContent()).toBe('https://example.com');
});

unregister();
},
dependencies: [
TestLexicalAutoLinkExtension,
configExtension(AutoLinkExtension, {
excludeParents: [$isExcludedParentNode],
matchers: [URL_MATCHER],
}),
],
name: '[test override]',
});

test('excludeParents does not prevent auto-linking in non-excluded parents', async () => {
const {editor} = testEnv;

const unregister = registerAutoLink(editor, {
changeHandlers: [],
excludeParents: [$isExcludedParentNode],
matchers: [URL_MATCHER],
});

await editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const textNode = $createTextNode('https://example.com');
paragraph.append(textNode);
root.append(paragraph);
});
editor.read(() => {
const root = $getRoot();
const excludedParent = root.getFirstChild();
assert(
$isExcludedParentNode(excludedParent),
'first root child must be an ExcludedParentNode',
);
const child = excludedParent.getFirstChild();
assert($isTextNode(child), 'child must be a TextNode');
expect(child.getTextContent()).toBe('https://example.com');
});
});

editor.read(() => {
const root = $getRoot();
const paragraphNode = root.getFirstChild();
assert(
$isParagraphNode(paragraphNode),
'first root child must be a ParagraphNode',
test('excludeParents does not prevent auto-linking in non-excluded parents', async () => {
const editor = buildEditorFromExtensions({
$initialEditorState() {
$getRoot().append(
$createParagraphNode().append($createTextNode('https://example.com')),
);
const autoLinkNode = paragraphNode.getFirstChild();
assert(
$isAutoLinkNode(autoLinkNode),
'first child must be an AutoLinkNode',
);
expect(autoLinkNode.getTextContent()).toBe('https://example.com');
});
},
dependencies: [
TestLexicalAutoLinkExtension,
configExtension(AutoLinkExtension, {
excludeParents: [$isExcludedParentNode],
matchers: [URL_MATCHER],
}),
],
name: '[test override]',
});

unregister();
editor.read(() => {
const root = $getRoot();
const paragraphNode = root.getFirstChild();
assert(
$isParagraphNode(paragraphNode),
'first root child must be a ParagraphNode',
);
const autoLinkNode = paragraphNode.getFirstChild();
assert(
$isAutoLinkNode(autoLinkNode),
'first child must be an AutoLinkNode',
);
expect(autoLinkNode.getTextContent()).toBe('https://example.com');
});
}, editorConfig);
});
});
2 changes: 1 addition & 1 deletion packages/lexical-playground/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1442,7 +1442,7 @@ button.action-button:disabled {
height: 36px;
position: sticky;
top: 0;
z-index: 2;
z-index: 20;
overflow-y: hidden; /* disable vertical scroll*/
}

Expand Down
Loading
Loading