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
56 changes: 41 additions & 15 deletions packages/lexical-extension/src/TabIndentationExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@
*
*/

import type {LexicalCommand, LexicalEditor, RangeSelection} from 'lexical';
import type {
ElementNode,
LexicalCommand,
LexicalEditor,
RangeSelection,
} from 'lexical';

import {$getNearestBlockElementAncestorOrThrow} from '@lexical/utils';
import {
$getNearestBlockElementAncestorOrThrow,
$handleIndentAndOutdent,
} from '@lexical/utils';
import {
$createRangeSelection,
$getSelection,
Expand Down Expand Up @@ -59,9 +67,18 @@ function $indentOverTab(selection: RangeSelection): boolean {
return false;
}

export type CanIndentPredicate = (node: ElementNode) => boolean;

function $defaultCanIndent(node: ElementNode) {
return node.canBeEmpty();
}

export function registerTabIndentation(
editor: LexicalEditor,
maxIndent?: number | ReadonlySignal<null | number>,
$canIndent:
| CanIndentPredicate
| ReadonlySignal<CanIndentPredicate> = $defaultCanIndent,
) {
return mergeRegister(
editor.registerCommand<KeyboardEvent>(
Expand Down Expand Up @@ -92,22 +109,22 @@ export function registerTabIndentation(
? maxIndent.peek()
: null;

if (currentMaxIndent == null) {
return false;
}

const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}

const indents = selection
.getNodes()
.map((node) =>
$getNearestBlockElementAncestorOrThrow(node).getIndent(),
);
const $currentCanIndent =
typeof $canIndent === 'function' ? $canIndent : $canIndent.peek();

return Math.max(...indents) + 1 >= currentMaxIndent;
return $handleIndentAndOutdent((block) => {
if ($currentCanIndent(block)) {
const newIndent = block.getIndent() + 1;
if (!currentMaxIndent || newIndent < currentMaxIndent) {
block.setIndent(newIndent);
}
}
});
},
COMMAND_PRIORITY_CRITICAL,
),
Expand All @@ -117,6 +134,11 @@ export function registerTabIndentation(
export interface TabIndentationConfig {
disabled: boolean;
maxIndent: null | number;
/**
* By default, indents are set on all elements for which the {@link ElementNode.canIndent} returns true.
* This option allows you to set indents for specific nodes without overriding the method for others.
*/
$canIndent: CanIndentPredicate;
}

/**
Expand All @@ -128,13 +150,17 @@ export const TabIndentationExtension = defineExtension({
build(editor, config, state) {
return namedSignals(config);
},
config: safeCast<TabIndentationConfig>({disabled: false, maxIndent: null}),
config: safeCast<TabIndentationConfig>({
$canIndent: $defaultCanIndent,
disabled: false,
maxIndent: null,
}),
name: '@lexical/extension/TabIndentation',
register(editor, config, state) {
const {disabled, maxIndent} = state.getOutput();
const {disabled, maxIndent, $canIndent} = state.getOutput();
return effect(() => {
if (!disabled.value) {
return registerTabIndentation(editor, maxIndent);
return registerTabIndentation(editor, maxIndent, $canIndent);
}
});
},
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export {
untracked,
} from './signals';
export {
type CanIndentPredicate,
registerTabIndentation,
type TabIndentationConfig,
TabIndentationExtension,
Expand Down
11 changes: 8 additions & 3 deletions packages/lexical-link/src/LexicalAutoLinkExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,18 +509,20 @@ function getTextNodesToMatch(textNode: TextNode): TextNode[] {
export interface AutoLinkConfig {
matchers: LinkMatcher[];
changeHandlers: ChangeHandler[];
excludeParents: Array<(parent: ElementNode) => boolean>;
}

const defaultConfig: AutoLinkConfig = {
changeHandlers: [],
excludeParents: [],
matchers: [],
};

export function registerAutoLink(
editor: LexicalEditor,
config: AutoLinkConfig = defaultConfig,
): () => void {
const {matchers, changeHandlers} = config;
const {matchers, changeHandlers, excludeParents} = config;
const onChange: ChangeHandler = (url, prevUrl) => {
for (const handler of changeHandlers) {
handler(url, prevUrl);
Expand All @@ -532,7 +534,10 @@ export function registerAutoLink(
const previous = textNode.getPreviousSibling();
if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
handleLinkEdit(parent, matchers, onChange);
} else if (!$isLinkNode(parent)) {
} else if (
!$isLinkNode(parent) &&
!excludeParents.some((pred) => pred(parent))
) {
if (
textNode.isSimpleText() &&
(startsWithSeparator(textNode.getTextContent()) ||
Expand Down Expand Up @@ -585,7 +590,7 @@ export const AutoLinkExtension = defineExtension({
dependencies: [LinkExtension],
mergeConfig(config, overrides) {
const merged = shallowMergeConfig(config, overrides);
for (const k of ['matchers', 'changeHandlers'] as const) {
for (const k of ['matchers', 'changeHandlers', 'excludeParents'] as const) {
const v = overrides[k];
if (Array.isArray(v)) {
(merged[k] as unknown[]) = [...config[k], ...v];
Expand Down
25 changes: 9 additions & 16 deletions packages/lexical-link/src/LexicalLinkNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,24 +422,17 @@ export class AutoLinkNode extends LinkNode {
}

insertNewAfter(
selection: RangeSelection,
_: RangeSelection,
restoreSelection = true,
): null | ElementNode {
const element = this.getParentOrThrow().insertNewAfter(
selection,
restoreSelection,
);
if ($isElementNode(element)) {
const linkNode = $createAutoLinkNode(this.__url, {
isUnlinked: this.__isUnlinked,
rel: this.__rel,
target: this.__target,
title: this.__title,
});
element.append(linkNode);
return linkNode;
}
return null;
const linkNode = $createAutoLinkNode(this.__url, {
isUnlinked: this.__isUnlinked,
rel: this.__rel,
target: this.__target,
title: this.__title,
});
this.insertAfter(linkNode, restoreSelection);
return linkNode;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,45 @@ import {
$createParagraphNode,
$createTextNode,
$getRoot,
$isElementNode,
$isParagraphNode,
$isTextNode,
ElementNode,
type LexicalNode,
ParagraphNode,
TextNode,
} from 'lexical/src';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
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;
}
}

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

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

const editorConfig = Object.freeze({
namespace: '',
nodes: [AutoLinkNode, ParagraphNode, TextNode],
nodes: [AutoLinkNode, ParagraphNode, TextNode, ExcludedParentNode],
theme: {
link: 'my-autolink-class',
text: {
Expand All @@ -41,6 +69,10 @@ const editorConfig = Object.freeze({
},
});

const URL_MATCHER = createLinkMatcherWithRegExp(
/https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
);

describe('LexicalAutoLinkExtension tests', () => {
initializeUnitTest((testEnv) => {
test('registerAutoLink does not cause infinite transform loop with #1234.Another', async () => {
Expand All @@ -50,6 +82,7 @@ describe('LexicalAutoLinkExtension tests', () => {
const hashtagMatcher = createLinkMatcherWithRegExp(/#\d+/);
const unregister = registerAutoLink(editor, {
changeHandlers: [],
excludeParents: [],
matchers: [hashtagMatcher],
});

Expand Down Expand Up @@ -90,5 +123,72 @@ describe('LexicalAutoLinkExtension tests', () => {

unregister();
});

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',
);
const child = excludedParent.getFirstChild();
assert($isTextNode(child), 'child must be a TextNode');
expect(child.getTextContent()).toBe('https://example.com');
});

unregister();
});

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 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');
});

unregister();
});
}, editorConfig);
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
SerializedAutoLinkNode,
} from '@lexical/link';
import {
$createParagraphNode,
$createRangeSelection,
$getRoot,
$selectAll,
ParagraphNode,
Expand Down Expand Up @@ -501,5 +503,28 @@ describe('LexicalAutoAutoLinkNode tests', () => {
const link = paragraph.children[0] as SerializedAutoLinkNode;
expect(link.title).toBe('Lexical Website');
});

test('AutoLinkNode.insertNewAfter does not create new paragraph', async () => {
const {editor} = testEnv;
await editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
const autoLink = $createAutoLinkNode('https://example.com');
const textNode = new TextNode('https://example.com');
autoLink.append(textNode);
paragraph.append(autoLink);
root.append(paragraph);

const selection = $createRangeSelection();
const newNode = autoLink.insertNewAfter(selection, false);
// Should create a sibling AutoLinkNode in the same paragraph
expect($isAutoLinkNode(newNode)).toBe(true);
// The original paragraph should now contain two AutoLinkNodes
expect(paragraph.getChildrenSize()).toBe(2);
// No new paragraph should be created
expect(root.getChildrenSize()).toBe(1);
});
});
});
});
Loading