@lexical/markdown
Version:
This package contains Markdown helpers and functionality for Lexical.
676 lines (581 loc) • 18.6 kB
text/typescript
/**
* 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 {$isCodeNode} from '@lexical/code-core';
import invariant from '@lexical/internal/invariant';
import {
$addUpdateTag,
$createRangeSelection,
$getSelection,
$isLineBreakNode,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
$setSelection,
COLLABORATION_TAG,
COMMAND_PRIORITY_LOW,
COMPOSITION_END_TAG,
type ElementNode,
HISTORIC_TAG,
HISTORY_PUSH_TAG,
KEY_ENTER_COMMAND,
type LexicalEditor,
type LexicalNode,
mergeRegister,
TEXT_TYPE_TO_FORMAT,
type TextNode,
} from 'lexical';
import {canContainTransformableMarkdown} from './importTextTransformers';
import {
type ElementTransformer,
type MultilineElementTransformer,
type TextFormatTransformer,
type TextMatchTransformer,
type Transformer,
TRANSFORMERS,
} from './MarkdownTransformers';
import {indexBy, PUNCTUATION_OR_SPACE, transformersByType} from './utils';
function runElementTransformers(
parentNode: ElementNode,
anchorNode: TextNode,
anchorOffset: number,
elementTransformers: readonly ElementTransformer[],
triggerOnEnter?: boolean,
): boolean {
const grandParentNode = parentNode.getParent();
if (
!$isRootOrShadowRoot(grandParentNode) ||
parentNode.getFirstChild() !== anchorNode
) {
return false;
}
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 (!triggerOnEnter) {
if (textContent[anchorOffset - 1] !== ' ') {
return false;
}
}
for (const {regExp, replace} of elementTransformers) {
const match = textContent.match(regExp);
const expectedMatchLength =
triggerOnEnter || (match && match[0].endsWith(' '))
? anchorOffset
: anchorOffset - 1;
if (match && match[0].length === expectedMatchLength) {
const nextSiblings = anchorNode.getNextSiblings();
const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
const siblings = remainderNode
? [remainderNode, ...nextSiblings]
: nextSiblings;
if (replace(parentNode, siblings, match, false) !== false) {
leadingNode.remove();
return true;
}
}
}
return false;
}
function runMultilineElementTransformers(
parentNode: ElementNode,
anchorNode: TextNode,
anchorOffset: number,
elementTransformers: readonly MultilineElementTransformer[],
triggerOnEnter?: boolean,
): boolean {
const grandParentNode = parentNode.getParent();
if (
!$isRootOrShadowRoot(grandParentNode) ||
parentNode.getFirstChild() !== anchorNode
) {
return false;
}
const textContent = anchorNode.getTextContent();
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) {
if (
(regExpEnd && !('optional' in regExpEnd)) ||
(regExpEnd && 'optional' in regExpEnd && !regExpEnd.optional)
) {
continue;
}
const match = textContent.match(regExpStart);
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
? [remainderNode, ...nextSiblings]
: nextSiblings;
if (replace(parentNode, siblings, match, null, null, false) !== false) {
leadingNode.remove();
return true;
}
}
}
return false;
}
function runTextMatchTransformers(
anchorNode: TextNode,
anchorOffset: number,
transformersByTrigger: Readonly<Record<string, TextMatchTransformer[]>>,
): boolean {
let textContent = anchorNode.getTextContent();
const lastChar = textContent[anchorOffset - 1];
const transformers = transformersByTrigger[lastChar];
if (transformers == null) {
return false;
}
// If typing in the middle of content, remove the tail to do
// reg exp match up to a string end (caret position)
if (anchorOffset < textContent.length) {
textContent = textContent.slice(0, anchorOffset);
}
for (const transformer of transformers) {
if (!transformer.replace || !transformer.regExp) {
continue;
}
const match = textContent.match(transformer.regExp);
if (match === null) {
continue;
}
const startIndex = match.index || 0;
const endIndex = startIndex + match[0].length;
let replaceNode;
if (startIndex === 0) {
[replaceNode] = anchorNode.splitText(endIndex);
} else {
[, replaceNode] = anchorNode.splitText(startIndex, endIndex);
}
replaceNode.selectNext(0, 0);
transformer.replace(replaceNode, match);
return true;
}
return false;
}
function $runTextFormatTransformers(
anchorNode: TextNode,
anchorOffset: number,
textFormatTransformers: Readonly<
Record<string, readonly TextFormatTransformer[]>
>,
): boolean {
const textContent = anchorNode.getTextContent();
const closeTagEndIndex = anchorOffset - 1;
const closeChar = textContent[closeTagEndIndex];
// Quick check if we're possibly at the end of inline markdown style
const matchers = textFormatTransformers[closeChar];
if (!matchers) {
return false;
}
for (const matcher of matchers) {
const {tag} = matcher;
const tagLength = tag.length;
const closeTagStartIndex = closeTagEndIndex - tagLength + 1;
// If tag is not single char check if rest of it matches with text content
if (tagLength > 1) {
if (
!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)
) {
continue;
}
}
// Space before closing tag cancels inline markdown
if (textContent[closeTagStartIndex - 1] === ' ') {
continue;
}
// Some tags can not be used within words, hence should have newline/space/punctuation after it
const afterCloseTagChar = textContent[closeTagEndIndex + 1];
if (
matcher.intraword === false &&
afterCloseTagChar &&
!PUNCTUATION_OR_SPACE.test(afterCloseTagChar)
) {
continue;
}
const closeNode = anchorNode;
let openNode = closeNode;
let openTagStartIndex = getOpenTagStartIndex(
textContent,
closeTagStartIndex,
tag,
);
// Go through text node siblings and search for opening tag
// if haven't found it within the same text node as closing tag
let sibling: LexicalNode | null = openNode;
while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) {
if ($isLineBreakNode(sibling)) {
break;
}
if ($isTextNode(sibling)) {
if (sibling.hasFormat('code')) {
continue;
}
const siblingTextContent = sibling.getTextContent();
openNode = sibling;
openTagStartIndex = getOpenTagStartIndex(
siblingTextContent,
siblingTextContent.length,
tag,
);
}
}
// Opening tag is not found
if (openTagStartIndex < 0) {
continue;
}
// No content between opening and closing tag
if (
openNode === closeNode &&
openTagStartIndex + tagLength === closeTagStartIndex
) {
continue;
}
// Checking longer tags for repeating chars (e.g. *** vs **)
const prevOpenNodeText = openNode.getTextContent();
if (
openTagStartIndex > 0 &&
prevOpenNodeText[openTagStartIndex - 1] === closeChar
) {
continue;
}
// Some tags can not be used within words, hence should have newline/space/punctuation before it
const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1];
if (
matcher.intraword === false &&
beforeOpenTagChar &&
!PUNCTUATION_OR_SPACE.test(beforeOpenTagChar)
) {
continue;
}
// Per CommonMark, code spans take precedence over other inline formatting
if (
!matcher.format.includes('code') &&
$isInsideUnclosedCodeSpan(openNode, openTagStartIndex)
) {
continue;
}
// Clean text from opening and closing tags (starting from closing tag
// to prevent any offset shifts if we start from opening one)
const prevCloseNodeText = closeNode.getTextContent();
const closeNodeText =
prevCloseNodeText.slice(0, closeTagStartIndex) +
prevCloseNodeText.slice(closeTagEndIndex + 1);
closeNode.setTextContent(closeNodeText);
const openNodeText =
openNode === closeNode ? closeNodeText : prevOpenNodeText;
openNode.setTextContent(
openNodeText.slice(0, openTagStartIndex) +
openNodeText.slice(openTagStartIndex + tagLength),
);
const selection = $getSelection();
const nextSelection = $createRangeSelection();
$setSelection(nextSelection);
// Adjust offset based on deleted chars
const newOffset =
closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1;
nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text');
nextSelection.focus.set(closeNode.__key, newOffset, 'text');
// Apply formatting to selected text
for (const format of matcher.format) {
nextSelection.formatText(format, TEXT_TYPE_TO_FORMAT[format]);
}
// Collapse selection up to the focus point
nextSelection.anchor.set(
nextSelection.focus.key,
nextSelection.focus.offset,
nextSelection.focus.type,
);
// Remove formatting from collapsed selection
for (const format of matcher.format) {
if (nextSelection.hasFormat(format)) {
nextSelection.toggleFormat(format);
}
}
if ($isRangeSelection(selection)) {
nextSelection.format = selection.format;
}
return true;
}
return false;
}
// Per CommonMark spec, code spans take precedence over other inline
// formatting. Returns true if there is an unclosed backtick (code span
// opener) in the text preceding the given offset, which means the offset
// is inside a code span that hasn't been closed yet.
function $isInsideUnclosedCodeSpan(node: TextNode, offset: number): boolean {
let backtickCount = 0;
const text = node.getTextContent();
for (let i = 0; i < offset; i++) {
if (text[i] === '`') {
backtickCount++;
}
}
return backtickCount % 2 !== 0;
}
function getOpenTagStartIndex(
string: string,
maxIndex: number,
tag: string,
): number {
const tagLength = tag.length;
for (let i = maxIndex; i >= tagLength; i--) {
const startIndex = i - tagLength;
if (
isEqualSubString(string, startIndex, tag, 0, tagLength) && // Space after opening tag cancels transformation
string[startIndex + tagLength] !== ' '
) {
return startIndex;
}
}
return -1;
}
function isEqualSubString(
stringA: string,
aStart: number,
stringB: string,
bStart: number,
length: number,
): boolean {
for (let i = 0; i < length; i++) {
if (stringA[aStart + i] !== stringB[bStart + i]) {
return false;
}
}
return true;
}
export function registerMarkdownShortcuts(
editor: LexicalEditor,
transformers: Transformer[] = TRANSFORMERS,
): () => void {
const byType = transformersByType(transformers);
const elementTransformersForEnter = byType.element.filter(
t => t.triggerOnEnter,
);
const textFormatTransformersByTrigger = indexBy(
byType.textFormat,
({tag}) => tag[tag.length - 1],
);
const textMatchTransformersByTrigger = indexBy(
byType.textMatch,
({trigger}) => trigger,
);
// Composition end fires per IME commit (every CJK syllable, German dead-key
// resolve, etc.). Most of those have nothing to do with markdown, so only
// enter the transformer pass when the just-committed character can plausibly
// close a trigger. Space covers element/multilineElement triggers (`# `,
// `- `, ...) and text-match triggers carry their own single-character triggers.
const compositionEndTriggerChars = new Set<string>([' ']);
for (const t of byType.textFormat) {
compositionEndTriggerChars.add(t.tag.slice(-1));
}
for (const t of byType.textMatch) {
if (t.trigger !== undefined) {
compositionEndTriggerChars.add(t.trigger);
}
}
for (const transformer of transformers) {
const type = transformer.type;
if (
type === 'element' ||
type === 'text-match' ||
type === 'multiline-element'
) {
const dependencies = transformer.dependencies;
for (const node of dependencies) {
if (!editor.hasNode(node)) {
invariant(
false,
'MarkdownShortcuts: missing dependency %s for transformer. Ensure node dependency is included in editor initial config.',
node.getType(),
);
}
}
}
}
const $transform = (
parentNode: ElementNode,
anchorNode: TextNode,
anchorOffset: number,
): boolean => {
if (
runElementTransformers(
parentNode,
anchorNode,
anchorOffset,
byType.element,
)
) {
return true;
}
if (
runMultilineElementTransformers(
parentNode,
anchorNode,
anchorOffset,
byType.multilineElement,
)
) {
return true;
}
if (
runTextMatchTransformers(
anchorNode,
anchorOffset,
textMatchTransformersByTrigger,
)
) {
return true;
}
if (
$runTextFormatTransformers(
anchorNode,
anchorOffset,
textFormatTransformersByTrigger,
)
) {
return true;
}
return false;
};
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;
}
// composition end commits the composed text without moving the
// selection (compositionupdate already did) and may jump the anchor by
// more than one when multi-character commits land at once. Skip the
// typed-character heuristics so a trailing trigger character can still
// fire its transform.
const isCompositionEnd = tags.has(COMPOSITION_END_TAG);
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) && !isCompositionEnd)
) {
return;
}
const anchorKey = selection.anchor.key;
const anchorOffset = selection.anchor.offset;
const anchorNode = editorState._nodeMap.get(anchorKey);
if (
!$isTextNode(anchorNode) ||
!dirtyLeaves.has(anchorKey) ||
(!isCompositionEnd &&
anchorOffset !== 1 &&
anchorOffset > prevSelection.anchor.offset + 1)
) {
return;
}
if (isCompositionEnd) {
const closeChar = editorState.read(() => anchorNode.getTextContent())[
anchorOffset - 1
];
if (!compositionEndTriggerChars.has(closeChar)) {
return;
}
}
editor.update(() => {
if (!canContainTransformableMarkdown(anchorNode)) {
return;
}
const parentNode = anchorNode.getParent();
if (parentNode === null || $isCodeNode(parentNode)) {
return;
}
if ($transform(parentNode, anchorNode, selection.anchor.offset)) {
$addUpdateTag(HISTORY_PUSH_TAG);
}
});
},
),
editor.registerCommand(
KEY_ENTER_COMMAND,
event => {
if (event !== null && event.shiftKey) {
return false;
}
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 false;
}
const textContent = anchorNode.getTextContent();
if (anchorOffset !== textContent.length) {
return false;
}
if (
runMultilineElementTransformers(
parentNode,
anchorNode,
anchorOffset,
byType.multilineElement,
true,
) ||
runElementTransformers(
parentNode,
anchorNode,
anchorOffset,
elementTransformersForEnter,
true,
)
) {
if (event !== null) {
event.preventDefault();
}
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}