UNPKG

@terrible-lexical/markdown

Version:

This package contains Markdown helpers and functionality for Lexical.

439 lines (373 loc) 11.7 kB
/** * 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 type { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, } from '@terrible-lexical/markdown/src'; import type {ElementNode, LexicalEditor, TextNode} from 'terrible-lexical'; import {$isCodeNode} from '@terrible-lexical/code/src'; import { $createRangeSelection, $getSelection, $isLineBreakNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode, $setSelection, } from 'terrible-lexical'; import invariant from '@terrible-lexical/shared/src/invariant'; import {TRANSFORMERS} from '.'; import {indexBy, PUNCTUATION_OR_SPACE, transformersByType} from './utils'; function runElementTransformers( parentNode: ElementNode, anchorNode: TextNode, anchorOffset: number, elementTransformers: ReadonlyArray<ElementTransformer>, ): 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 (textContent[anchorOffset - 1] !== ' ') { return false; } for (const {regExp, replace} of elementTransformers) { const match = textContent.match(regExp); if (match && match[0].length === anchorOffset) { const nextSiblings = anchorNode.getNextSiblings(); const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset); leadingNode.remove(); const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings; replace(parentNode, siblings, match, false); return true; } } return false; } function runTextMatchTransformers( anchorNode: TextNode, anchorOffset: number, transformersByTrigger: Readonly<Record<string, Array<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) { 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, ReadonlyArray<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: TextNode | null = openNode; while ( openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling<TextNode>()) ) { if ($isLineBreakNode(sibling)) { break; } if ($isTextNode(sibling)) { 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; } // 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) { if (!nextSelection.hasFormat(format)) { nextSelection.formatText(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; } 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: Array<Transformer> = TRANSFORMERS, ): () => void { const byType = transformersByType(transformers); const textFormatTransformersIndex = indexBy( byType.textFormat, ({tag}) => tag[tag.length - 1], ); const textMatchTransformersIndex = indexBy( byType.textMatch, ({trigger}) => trigger, ); for (const transformer of transformers) { const type = transformer.type; if (type === 'element' || type === 'text-match') { 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, ) => { if ( runElementTransformers( parentNode, anchorNode, anchorOffset, byType.element, ) ) { return; } if ( runTextMatchTransformers( anchorNode, anchorOffset, textMatchTransformersIndex, ) ) { return; } runTextFormatTransformers( anchorNode, anchorOffset, textFormatTransformersIndex, ); }; return editor.registerUpdateListener( ({tags, dirtyLeaves, editorState, prevEditorState}) => { // Ignore updates from undo/redo (as changes already calculated) if (tags.has('historic')) { 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); if ( !$isRangeSelection(prevSelection) || !$isRangeSelection(selection) || !selection.isCollapsed() ) { return; } const anchorKey = selection.anchor.key; const anchorOffset = selection.anchor.offset; const anchorNode = editorState._nodeMap.get(anchorKey); if ( !$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || (anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1) ) { return; } editor.update(() => { // Markdown is not available inside code if (anchorNode.hasFormat('code')) { return; } const parentNode = anchorNode.getParent(); if (parentNode === null || $isCodeNode(parentNode)) { return; } transform(parentNode, anchorNode, selection.anchor.offset); }); }, ); }