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
171 changes: 119 additions & 52 deletions packages/lexical-markdown/src/MarkdownShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
$isTextNode,
$setSelection,
COLLABORATION_TAG,
COMMAND_PRIORITY_LOW,
HISTORIC_TAG,
KEY_ENTER_COMMAND,
mergeRegister,
} from 'lexical';
import invariant from 'shared/invariant';

Expand Down Expand Up @@ -88,6 +91,7 @@ function runMultilineElementTransformers(
anchorNode: TextNode,
anchorOffset: number,
elementTransformers: ReadonlyArray<MultilineElementTransformer>,
triggerOnEnter?: boolean,
): boolean {
const grandParentNode = parentNode.getParent();

Expand All @@ -100,14 +104,16 @@ function runMultilineElementTransformers(

const textContent = anchorNode.getTextContent();

// Checking for anchorOffset position to prevent any checks for cases when caret is too far
// from a line start to be a part of block-level markdown trigger.
//
// TODO:
// Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
// since otherwise it won't be a markdown shortcut, but tables are exception
if (textContent[anchorOffset - 1] !== ' ') {
return false;
if (!triggerOnEnter) {
// Checking for anchorOffset position to prevent any checks for cases when caret is too far
// from a line start to be a part of block-level markdown trigger.
//
// TODO:
// Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
// since otherwise it won't be a markdown shortcut, but tables are exception
if (textContent[anchorOffset - 1] !== ' ') {
return false;
}
}

for (const {regExpStart, replace, regExpEnd} of elementTransformers) {
Expand All @@ -120,11 +126,16 @@ function runMultilineElementTransformers(

const match = textContent.match(regExpStart);

if (
match &&
match[0].length ===
(match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)
) {
if (match) {
const matchLength =
triggerOnEnter || match[0].endsWith(' ')
? anchorOffset
: anchorOffset - 1;

if (match[0].length !== matchLength) {
continue;
}

const nextSiblings = anchorNode.getNextSiblings();
const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
const siblings = remainderNode
Expand Down Expand Up @@ -471,58 +482,114 @@ export function registerMarkdownShortcuts(
);
};

return editor.registerUpdateListener(
({tags, dirtyLeaves, editorState, prevEditorState}) => {
// Ignore updates from collaboration and undo/redo (as changes already calculated)
if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
return;
}
return mergeRegister(
editor.registerUpdateListener(
({tags, dirtyLeaves, editorState, prevEditorState}) => {
// Ignore updates from collaboration and undo/redo (as changes already calculated)
if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
return;
}

// If editor is still composing (i.e. backticks) we must wait before the user confirms the key
if (editor.isComposing()) {
return;
}
// If editor is still composing (i.e. backticks) we must wait before the user confirms the key
if (editor.isComposing()) {
return;
}

const selection = editorState.read($getSelection);
const prevSelection = prevEditorState.read($getSelection);
const selection = editorState.read($getSelection);
const prevSelection = prevEditorState.read($getSelection);

// We expect selection to be a collapsed range and not match previous one (as we want
// to trigger transforms only as user types)
if (
!$isRangeSelection(prevSelection) ||
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
selection.is(prevSelection)
) {
return;
}

// We expect selection to be a collapsed range and not match previous one (as we want
// to trigger transforms only as user types)
if (
!$isRangeSelection(prevSelection) ||
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
selection.is(prevSelection)
) {
return;
}
const anchorKey = selection.anchor.key;
const anchorOffset = selection.anchor.offset;

const anchorKey = selection.anchor.key;
const anchorOffset = selection.anchor.offset;
const anchorNode = editorState._nodeMap.get(anchorKey);

const anchorNode = editorState._nodeMap.get(anchorKey);
if (
!$isTextNode(anchorNode) ||
!dirtyLeaves.has(anchorKey) ||
(anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1)
) {
return;
}

if (
!$isTextNode(anchorNode) ||
!dirtyLeaves.has(anchorKey) ||
(anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1)
) {
return;
}
editor.update(() => {
if (!canContainTransformableMarkdown(anchorNode)) {
return;
}

const parentNode = anchorNode.getParent();

if (parentNode === null || $isCodeNode(parentNode)) {
return;
}

$transform(parentNode, anchorNode, selection.anchor.offset);
});
},
),
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (event !== null && event.shiftKey) {
return false;
}

editor.update(() => {
if (!canContainTransformableMarkdown(anchorNode)) {
return;
const selection = $getSelection();

if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
}

const anchorOffset = selection.anchor.offset;
const anchorNode = selection.anchor.getNode();

if (
!$isTextNode(anchorNode) ||
!canContainTransformableMarkdown(anchorNode)
) {
return false;
}

const parentNode = anchorNode.getParent();

if (parentNode === null || $isCodeNode(parentNode)) {
return;
return false;
}

const textContent = anchorNode.getTextContent();

if (anchorOffset !== textContent.length) {
return false;
}

if (
runMultilineElementTransformers(
parentNode,
anchorNode,
anchorOffset,
byType.multilineElement,
true,
)
) {
if (event !== null) {
event.preventDefault();
}
return true;
}

$transform(parentNode, anchorNode, selection.anchor.offset);
});
},
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}
136 changes: 136 additions & 0 deletions packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
$insertNodes,
$isRangeSelection,
$setState,
KEY_ENTER_COMMAND,
} from 'lexical';
import {describe, expect, it} from 'vitest';

Expand Down Expand Up @@ -1185,6 +1186,141 @@ describe('Markdown', () => {
});
});
});

describe('Enter key triggers', () => {
it('should create an empty code block when ``` is typed and Enter is pressed', () => {
const editor = createHeadlessEditor({
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
LinkNode,
],
});

registerMarkdownShortcuts(editor, TRANSFORMERS);

editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.selectEnd();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('```');
}
},
{discrete: true},
);

editor.update(
() => {
editor.dispatchCommand(KEY_ENTER_COMMAND, null);
},
{discrete: true},
);

expect(editor.read(() => $generateHtmlFromNodes(editor))).toBe(
'<pre spellcheck="false"></pre>',
);
});

it('should create a code block with language when ```javascript is typed and Enter is pressed', () => {
const editor = createHeadlessEditor({
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
LinkNode,
],
});

registerMarkdownShortcuts(editor, TRANSFORMERS);

editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.selectEnd();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('```javascript');
}
},
{discrete: true},
);

editor.update(
() => {
editor.dispatchCommand(KEY_ENTER_COMMAND, null);
},
{discrete: true},
);

expect(editor.read(() => $generateHtmlFromNodes(editor))).toBe(
'<pre spellcheck="false" data-language="javascript"></pre>',
);
});

it('should not transform on Enter when replace returns false', () => {
const CANCELED_CODE: MultilineElementTransformer = {
dependencies: [CodeNode],
regExpEnd: {
optional: true,
regExp: /^[ \t]*`{3,}$/,
},
regExpStart: /^`{3,}(\w+)?/,
replace: () => {
return false;
},
type: 'multiline-element',
};

const editor = createHeadlessEditor({
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
LinkNode,
],
});

registerMarkdownShortcuts(editor, [CANCELED_CODE]);

editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.selectEnd();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('```');
}
},
{discrete: true},
);

editor.update(
() => {
editor.dispatchCommand(KEY_ENTER_COMMAND, null);
},
{discrete: true},
);

expect(editor.read(() => $generateHtmlFromNodes(editor))).toBe(
'<p><span style="white-space: pre-wrap;">```</span></p>',
);
});
});
});

describe('normalizeMarkdown - shouldMergeAdjacentLines = true', () => {
Expand Down
Loading