diff --git a/package-lock.json b/package-lock.json index 60bb263e824..a0e3a439ad2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28777,7 +28777,7 @@ "integrity": "sha512-8ouMBUboYslHom41W8bnSEn0TwlAMHhCACwOZeuiAgzukj7KobpZ+UBwrGE0jJ0UblJbKAQNRHXL+z7sDSkb6g==", "dev": true, "requires": { - "playwright-core": "1.23.0-next-alpha-trueadm-fork" + "playwright-core": "1.22.1" } }, "@polka/url": { diff --git a/packages/lexical-code/src/CodeHighlightNode.ts b/packages/lexical-code/src/CodeHighlightNode.ts new file mode 100644 index 00000000000..06596524aeb --- /dev/null +++ b/packages/lexical-code/src/CodeHighlightNode.ts @@ -0,0 +1,145 @@ +/** + * 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. + * + */ + +// eslint-disable-next-line simple-import-sort/imports +import type { + EditorConfig, + EditorThemeClasses, + LexicalNode, + NodeKey, + SerializedTextNode, + Spread, +} from 'lexical'; + +import { + addClassNamesToElement, + removeClassNamesFromElement, +} from '@lexical/utils'; +import {TextNode} from 'lexical'; + +type SerializedCodeHighlightNode = Spread< + { + highlightType: string | null | undefined; + type: 'code-highlight'; + version: 1; + }, + SerializedTextNode +>; +export class CodeHighlightNode extends TextNode { + __highlightType: string | null | undefined; + + constructor(text: string, highlightType?: string, key?: NodeKey) { + super(text, key); + this.__highlightType = highlightType; + } + + static getType(): string { + return 'code-highlight'; + } + + static clone(node: CodeHighlightNode): CodeHighlightNode { + return new CodeHighlightNode( + node.__text, + node.__highlightType || undefined, + node.__key, + ); + } + + getHighlightType(): string | null | undefined { + const self = this.getLatest(); + return self.__highlightType; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = super.createDOM(config); + const className = getHighlightThemeClass( + config.theme, + this.__highlightType, + ); + addClassNamesToElement(element, className); + return element; + } + + updateDOM( + prevNode: CodeHighlightNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + const update = super.updateDOM(prevNode, dom, config); + const prevClassName = getHighlightThemeClass( + config.theme, + prevNode.__highlightType, + ); + const nextClassName = getHighlightThemeClass( + config.theme, + this.__highlightType, + ); + if (prevClassName !== nextClassName) { + if (prevClassName) { + removeClassNamesFromElement(dom, prevClassName); + } + if (nextClassName) { + addClassNamesToElement(dom, nextClassName); + } + } + return update; + } + + static importJSON( + serializedNode: SerializedCodeHighlightNode, + ): CodeHighlightNode { + const node = $createCodeHighlightNode( + serializedNode.text, + serializedNode.highlightType, + ); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; + } + + exportJSON(): SerializedCodeHighlightNode { + return { + ...super.exportJSON(), + highlightType: this.getHighlightType(), + type: 'code-highlight', + version: 1, + }; + } + + // Prevent formatting (bold, underline, etc) + setFormat(format: number): this { + return this; + } +} + +function getHighlightThemeClass( + theme: EditorThemeClasses, + highlightType: string | undefined, +): string | undefined { + return ( + highlightType && + theme && + theme.codeHighlight && + theme.codeHighlight[highlightType] + ); +} + +export function $createCodeHighlightNode( + text: string, + highlightType?: string, +): CodeHighlightNode { + return new CodeHighlightNode(text, highlightType); +} + +export function $isCodeHighlightNode( + node: LexicalNode | CodeHighlightNode | null | undefined, +): node is CodeHighlightNode { + return node instanceof CodeHighlightNode; +} diff --git a/packages/lexical-code/src/CodeHighlighter.ts b/packages/lexical-code/src/CodeHighlighter.ts new file mode 100644 index 00000000000..c9dda7d0762 --- /dev/null +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -0,0 +1,323 @@ +/** + * 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. + * + */ + +// eslint-disable-next-line simple-import-sort/imports +import { + $createCodeLineNode, + $isCodeLineNode, + LexicalEditor, + LexicalNode, + $createTextNode, + $getNodeByKey, + $getSelection, + $isRangeSelection, + $isTextNode, + TextNode, +} from 'lexical'; + +import * as Prism from 'prismjs'; + +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-markup'; +import 'prismjs/components/prism-markdown'; +import 'prismjs/components/prism-c'; +import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-objectivec'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-swift'; + +import {mergeRegister} from '@lexical/utils'; +import { + $isCodeHighlightNode, + $createCodeHighlightNode, + CodeHighlightNode, +} from './CodeHighlightNode'; +import {CodeNode, $isCodeNode} from './CodeNode'; +import {updateCodeGutter} from './HighlighterHelper'; + +const DEFAULT_CODE_LANGUAGE = 'javascript'; + +function updateAndRetainSelection( + node: CodeNode, + updateFn: () => boolean, +): void { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.anchor) { + return; + } + + const anchor = selection.anchor; + const anchorOffset = anchor.offset; + const isNewLineAnchor = + anchor.type === 'element' && + $isCodeLineNode(node.getChildAtIndex(anchor.offset - 1)); + let textOffset = 0; + + // Calculating previous text offset (all text node prior to anchor + anchor own text offset) + if (!isNewLineAnchor) { + const anchorNode = anchor.getNode(); + textOffset = + anchorOffset + + anchorNode.getPreviousSiblings().reduce((offset, _node) => { + return ( + offset + ($isCodeLineNode(_node) ? 0 : _node.getTextContentSize()) + ); + }, 0); + } + + const hasChanges = updateFn(); + if (!hasChanges) { + return; + } + + // Non-text anchors only happen for line breaks, otherwise + // selection will be within text node (code highlight node) + if (isNewLineAnchor) { + anchor.getNode().select(anchorOffset, anchorOffset); + return; + } + + // If it was non-element anchor then we walk through child nodes + // and looking for a position of original text offset + node.getChildren().some((_node) => { + if ($isTextNode(_node)) { + const textContentSize = _node.getTextContentSize(); + if (textContentSize >= textOffset) { + _node.select(textOffset, textOffset); + return true; + } + textOffset -= textContentSize; + } + return false; + }); +} + +function getHighlightNodes( + tokens: (string | Prism.Token)[], +): Array { + const nodes: LexicalNode[] = []; + tokens.forEach((token) => { + if (typeof token === 'string') { + const partials = token.split('\n'); + + for (let i = 0; i < partials.length; i++) { + const text = partials[i]; + if (text.length) { + nodes.push($createCodeHighlightNode(text)); + } + if (i < partials.length - 1) { + nodes.push($createCodeLineNode()); + } + } + } else { + const {content} = token; + + if (typeof content === 'string') { + nodes.push($createCodeHighlightNode(content, token.type)); + } else if ( + Array.isArray(content) && + content.length === 1 && + typeof content[0] === 'string' + ) { + nodes.push($createCodeHighlightNode(content[0], token.type)); + } else if (Array.isArray(content)) { + nodes.push(...getHighlightNodes(content)); + } + } + }); + + return nodes; +} + +function codeNodeTransform( + node: CodeNode, + editor: LexicalEditor, + threshold?: number, +) { + if (isHighlighting) { + return; + } + isHighlighting = true; + // When new code block inserted it might not have language selected + if (node.getLanguage() === undefined) { + node.setLanguage(DEFAULT_CODE_LANGUAGE); + } + + // Using nested update call to pass `skipTransforms` since we don't want + // each individual codehighlight node to be transformed again as it's already + // in its final state + editor.update( + () => { + updateAndRetainSelection(node, () => { + const code = node.getTextContent(); + const tokens = Prism.tokenize( + code, + Prism.languages[node.getLanguage() || ''] || + Prism.languages[DEFAULT_CODE_LANGUAGE], + ); + const highlightNodes = getHighlightNodes(tokens); + const diffRange = getDiffRange(node.getChildren(), highlightNodes); + const {from, to, nodesForReplacement} = diffRange; + if (from !== to || nodesForReplacement.length) { + if (code.length <= threshold) { + node.splice(from, to - from, nodesForReplacement); + } else { + const codeContent = code.split('\n'); + node.clear(); + for (let i = 0; i < codeContent.length; i++) { + node.append($createTextNode(codeContent[i])); + if (i !== codeContent.length - 1) { + node.append($createCodeLineNode()); + } + } + } + + return true; + } + return false; + }); + }, + { + onUpdate: () => { + isHighlighting = false; + }, + skipTransforms: true, + }, + ); +} + +// Using `skipTransforms` to prevent extra transforms since reformatting the code +// will not affect code block content itself. + +// Using extra flag (`isHighlighting`) since both CodeNode and CodeHighlightNode +// trasnforms might be called at the same time (e.g. new CodeHighlight node inserted) and +// in both cases we'll rerun whole reformatting over CodeNode, which is redundant. +// Especially when pasting code into CodeBlock. +let isHighlighting = false; + +function textNodeTransform( + node: TextNode, + editor: LexicalEditor, + threshold?: number, +): void { + // Since CodeNode has flat children structure we only need to check + // if node's parent is a code node and run highlighting if so + const parentNode = node.getParent(); + if ($isCodeNode(parentNode)) { + codeNodeTransform(parentNode, editor, threshold); + } else if ($isCodeHighlightNode(node)) { + // When code block converted into paragraph or other element + // code highlight nodes converted back to normal text + node.replace($createTextNode(node.__text)); + } +} + +function isEqual(nodeA: LexicalNode, nodeB: LexicalNode): boolean { + // Only checking for code higlight nodes and linebreaks. If it's regular text node + // returning false so that it's transformed into code highlight node + if ($isCodeHighlightNode(nodeA) && $isCodeHighlightNode(nodeB)) { + return ( + nodeA.__text === nodeB.__text && + nodeA.__highlightType === nodeB.__highlightType + ); + } + + if ($isCodeLineNode(nodeA) && $isCodeLineNode(nodeB)) { + return true; + } + + return false; +} + +// Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes +// that needs to be replaced with `nodes` (subset of nextNodes) to make prevNodes equal to nextNodes. +function getDiffRange( + prevNodes: Array, + nextNodes: Array, +): { + from: number; + nodesForReplacement: Array; + to: number; +} { + let leadingMatch = 0; + while (leadingMatch < prevNodes.length) { + if (!isEqual(prevNodes[leadingMatch], nextNodes[leadingMatch])) { + break; + } + leadingMatch++; + } + + const prevNodesLength = prevNodes.length; + const nextNodesLength = nextNodes.length; + const maxTrailingMatch = + Math.min(prevNodesLength, nextNodesLength) - leadingMatch; + + let trailingMatch = 0; + while (trailingMatch < maxTrailingMatch) { + trailingMatch++; + if ( + !isEqual( + prevNodes[prevNodesLength - trailingMatch], + nextNodes[nextNodesLength - trailingMatch], + ) + ) { + trailingMatch--; + break; + } + } + + const from = leadingMatch; + const to = prevNodesLength - trailingMatch; + const nodesForReplacement = nextNodes.slice( + leadingMatch, + nextNodesLength - trailingMatch, + ); + return { + from, + nodesForReplacement, + to, + }; +} + +export function registerCodeHighlighting( + editor: LexicalEditor, + threshold?: number, +): () => void { + if (!editor.hasNodes([CodeNode, CodeHighlightNode])) { + throw new Error( + 'CodeHighlightPlugin: CodeNode or CodeHighlightNode not registered on editor', + ); + } + + return mergeRegister( + editor.registerMutationListener(CodeNode, (mutations) => { + editor.update(() => { + for (const [key, type] of mutations) { + if (type !== 'destroyed') { + const node = $getNodeByKey(key); + if (node !== null) { + updateCodeGutter(node as CodeNode, editor); + } + } + } + }); + }), + editor.registerNodeTransform(CodeNode, (node) => + codeNodeTransform(node, editor, threshold), + ), + editor.registerNodeTransform(TextNode, (node) => + textNodeTransform(node, editor, threshold), + ), + editor.registerNodeTransform(CodeHighlightNode, (node) => + textNodeTransform(node, editor, threshold), + ), + ); +} diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts new file mode 100644 index 00000000000..baf0c576be7 --- /dev/null +++ b/packages/lexical-code/src/CodeNode.ts @@ -0,0 +1,330 @@ +/** + * 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. + * + */ + +// eslint-disable-next-line simple-import-sort/imports +import type { + DOMConversionMap, + DOMConversionOutput, + EditorConfig, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedElementNode, + Spread, +} from 'lexical'; + +import * as Prism from 'prismjs'; + +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-markup'; +import 'prismjs/components/prism-markdown'; +import 'prismjs/components/prism-c'; +import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-objectivec'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-swift'; + +import {addClassNamesToElement} from '@lexical/utils'; +import { + $createCodeLineNode, + $createParagraphNode, + $getSelection, + $isRangeSelection, + ElementNode, +} from 'lexical'; +import {CodeHighlightNode, $createCodeHighlightNode} from './CodeHighlightNode'; +import {getFirstCodeHighlightNodeOfLine} from './HighlighterHelper'; + +type SerializedCodeNode = Spread< + { + language: string | null | undefined; + type: 'code'; + version: 1; + }, + SerializedElementNode +>; + +const mapToPrismLanguage = ( + language: string | null | undefined, +): string | null | undefined => { + // eslint-disable-next-line no-prototype-builtins + return language != null && Prism.languages.hasOwnProperty(language) + ? language + : undefined; +}; + +function convertPreElement(domNode: Node): DOMConversionOutput { + return {node: $createCodeNode()}; +} + +function convertDivElement(domNode: Node): DOMConversionOutput { + // domNode is a
since we matched it by nodeName + const div = domNode as HTMLDivElement; + return { + after: (childLexicalNodes) => { + const domParent = domNode.parentNode; + if (domParent != null && domNode !== domParent.lastChild) { + childLexicalNodes.push($createCodeLineNode()); + } + return childLexicalNodes; + }, + node: isCodeElement(div) ? $createCodeNode() : null, + }; +} + +function convertCodeNoop(): DOMConversionOutput { + return {node: null}; +} + +function convertTableCellElement(domNode: Node): DOMConversionOutput { + // domNode is a since we matched it by nodeName + const cell = domNode as HTMLTableCellElement; + + return { + after: (childLexicalNodes) => { + if (cell.parentNode && cell.parentNode.nextSibling) { + // Append newline between code lines + childLexicalNodes.push($createCodeLineNode()); + } + return childLexicalNodes; + }, + node: null, + }; +} +function isCodeElement(div: HTMLDivElement): boolean { + return div.style.fontFamily.match('monospace') !== null; +} + +function convertTableElement(): DOMConversionOutput { + return {node: $createCodeNode()}; +} + +function isGitHubCodeCell( + cell: HTMLTableCellElement, +): cell is HTMLTableCellElement { + return cell.classList.contains('js-file-line'); +} + +function isGitHubCodeTable(table: HTMLTableElement): table is HTMLTableElement { + return table.classList.contains('js-file-line-container'); +} + +const LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; + +export class CodeNode extends ElementNode { + __language: string | null | undefined; + + static getType(): string { + return 'code'; + } + + static clone(node: CodeNode): CodeNode { + return new CodeNode(node.__language, node.__key); + } + + constructor(language?: string | null | undefined, key?: NodeKey) { + super(key); + this.__language = mapToPrismLanguage(language); + } + + // View + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('code'); + addClassNamesToElement(element, config.theme.code); + element.setAttribute('spellcheck', 'false'); + const language = this.getLanguage(); + if (language) { + element.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); + } + return element; + } + updateDOM(prevNode: CodeNode, dom: HTMLElement): boolean { + const language = this.__language; + const prevLanguage = prevNode.__language; + + if (language) { + if (language !== prevLanguage) { + dom.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); + } + } else if (prevLanguage) { + dom.removeAttribute(LANGUAGE_DATA_ATTRIBUTE); + } + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + code: (node: Node) => ({ + conversion: convertPreElement, + priority: 0, + }), + div: (node: Node) => ({ + conversion: convertDivElement, + priority: 1, + }), + pre: (node: Node) => ({ + conversion: convertPreElement, + priority: 0, + }), + table: (node: Node) => { + const table = node; + // domNode is a since we matched it by nodeName + if (isGitHubCodeTable(table as HTMLTableElement)) { + return { + conversion: convertTableElement, + priority: 4, + }; + } + return null; + }, + td: (node: Node) => { + // element is a since we matched it by nodeName + const tr = node as HTMLTableCellElement; + const table: HTMLTableElement | null = tr.closest('table'); + if (table && isGitHubCodeTable(table)) { + return { + conversion: convertCodeNoop, + priority: 4, + }; + } + return null; + }, + }; + } + + static importJSON(serializedNode: SerializedCodeNode): CodeNode { + const node = $createCodeNode(serializedNode.language); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedCodeNode { + return { + ...super.exportJSON(), + language: this.getLanguage(), + type: 'code', + version: 1, + }; + } + + // Mutation + insertNewAfter( + selection: RangeSelection, + ): null | ParagraphNode | CodeHighlightNode { + const children = this.getChildren(); + const childrenLength = children.length; + + if ( + childrenLength >= 2 && + children[childrenLength - 1].getTextContent() === '\n' && + children[childrenLength - 2].getTextContent() === '\n' && + selection.isCollapsed() && + selection.anchor.key === this.__key && + selection.anchor.offset === childrenLength + ) { + children[childrenLength - 1].remove(); + children[childrenLength - 2].remove(); + const newElement = $createParagraphNode(); + this.insertAfter(newElement); + return newElement; + } + + // If the selection is within the codeblock, find all leading tabs and + // spaces of the current line. Create a new line that has all those + // tabs and spaces, such that leading indentation is preserved. + const anchor = selection.anchor.getNode(); + const firstNode = getFirstCodeHighlightNodeOfLine(anchor); + if (firstNode != null) { + let leadingWhitespace = 0; + const firstNodeText = firstNode.getTextContent(); + while ( + leadingWhitespace < firstNodeText.length && + /[\t ]/.test(firstNodeText[leadingWhitespace]) + ) { + leadingWhitespace += 1; + } + if (leadingWhitespace > 0) { + const whitespace = firstNodeText.substring(0, leadingWhitespace); + const indentedChild = $createCodeHighlightNode(whitespace); + anchor.insertAfter(indentedChild); + selection.insertNodes([$createCodeLineNode()]); + indentedChild.select(); + return indentedChild; + } + } + + return null; + } + + canInsertTab(): boolean { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; + } + return true; + } + + canIndent(): false { + return false; + } + + collapseAtStart(): true { + const paragraph = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => paragraph.append(child)); + this.replace(paragraph); + return true; + } + + setLanguage(language: string): void { + const writable = this.getWritable(); + writable.__language = mapToPrismLanguage(language); + } + + getLanguage(): string | null | undefined { + return this.getLatest().__language; + } +} + +export function $createCodeNode(language?: string): CodeNode { + return new CodeNode(language); +} + +export function $isCodeNode( + node: LexicalNode | null | undefined, +): node is CodeNode { + return node instanceof CodeNode; +} diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts new file mode 100644 index 00000000000..d8b63077fdb --- /dev/null +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -0,0 +1,431 @@ +/** + * 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. + * + */ + +// eslint-disable-next-line simple-import-sort/imports +import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; + +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-markup'; +import 'prismjs/components/prism-markdown'; +import 'prismjs/components/prism-c'; +import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-objectivec'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-swift'; + +import {mergeRegister} from '@lexical/utils'; +import { + $getNodeByKey, + $getSelection, + $isCodeLineNode, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + INDENT_CONTENT_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + MOVE_TO_END, + MOVE_TO_START, + OUTDENT_CONTENT_COMMAND, + TextNode, +} from 'lexical'; +import { + $isCodeHighlightNode, + CodeHighlightNode, + $createCodeHighlightNode, +} from './CodeHighlightNode'; +import {CodeNode} from './CodeNode'; +import { + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, + updateCodeGutter, +} from './HighlighterHelper'; + +function handleMultilineIndent(type: LexicalCommand): boolean { + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return false; + } + + // Only run multiline indent logic on selections exclusively composed of code highlights and linebreaks + const nodes = selection.getNodes(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (!$isCodeHighlightNode(node) && !$isCodeLineNode(node)) { + return false; + } + } + const startOfLine = getFirstCodeHighlightNodeOfLine(nodes[0]); + + if (startOfLine != null) { + doIndent(startOfLine, type); + } + + for (let i = 1; i < nodes.length; i++) { + const node = nodes[i]; + if ($isCodeLineNode(nodes[i - 1]) && $isCodeHighlightNode(node)) { + doIndent(node, type); + } + } + + return true; +} + +function doIndent(node: CodeHighlightNode, type: LexicalCommand) { + const text = node.getTextContent(); + if (type === INDENT_CONTENT_COMMAND) { + // If the codeblock node doesn't start with whitespace, we don't want to + // naively prepend a '\t'; Prism will then mangle all of our nodes when + // it separates the whitespace from the first non-whitespace node. This + // will lead to selection bugs when indenting lines that previously + // didn't start with a whitespace character + if (text.length > 0 && /\s/.test(text[0])) { + node.setTextContent('\t' + text); + } else { + const indentNode = $createCodeHighlightNode('\t'); + node.insertBefore(indentNode); + } + } else { + if (text.indexOf('\t') === 0) { + // Same as above - if we leave empty text nodes lying around, the resulting + // selection will be mangled + if (text.length === 1) { + node.remove(); + } else { + node.setTextContent(text.substring(1)); + } + } + } +} + +function handleShiftLines( + type: LexicalCommand, + event: KeyboardEvent, +): boolean { + // We only care about the alt+arrow keys + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + // I'm not quite sure why, but it seems like calling anchor.getNode() collapses the selection here + // So first, get the anchor and the focus, then get their nodes + const {anchor, focus} = selection; + const anchorOffset = anchor.offset; + const focusOffset = focus.offset; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + const arrowIsUp = type === KEY_ARROW_UP_COMMAND; + + // Ensure the selection is within the codeblock + if (!$isCodeHighlightNode(anchorNode) || !$isCodeHighlightNode(focusNode)) { + return false; + } + if (!event.altKey) { + // Handle moving selection out of the code block, given there are no + // sibling thats can natively take the selection. + if (selection.isCollapsed()) { + const codeNode = anchorNode.getParentOrThrow(); + if ( + arrowIsUp && + anchorOffset === 0 && + anchorNode.getPreviousSibling() === null + ) { + const codeNodeSibling = codeNode.getPreviousSibling(); + if (codeNodeSibling === null) { + codeNode.selectPrevious(); + event.preventDefault(); + return true; + } + } else if ( + !arrowIsUp && + anchorOffset === anchorNode.getTextContentSize() && + anchorNode.getNextSibling() === null + ) { + const codeNodeSibling = codeNode.getNextSibling(); + if (codeNodeSibling === null) { + codeNode.selectNext(); + event.preventDefault(); + return true; + } + } + } + return false; + } + + const start = getFirstCodeHighlightNodeOfLine(anchorNode); + const end = getLastCodeHighlightNodeOfLine(focusNode); + if (start == null || end == null) { + return false; + } + + const range = start.getNodesBetween(end); + for (let i = 0; i < range.length; i++) { + const node = range[i]; + if (!$isCodeHighlightNode(node) && !$isCodeLineNode(node)) { + return false; + } + } + + // After this point, we know the selection is within the codeblock. We may not be able to + // actually move the lines around, but we want to return true either way to prevent + // the event's default behavior + event.preventDefault(); + event.stopPropagation(); // required to stop cursor movement under Firefox + + const linebreak = arrowIsUp + ? start.getPreviousSibling() + : end.getNextSibling(); + if (!$isCodeLineNode(linebreak)) { + return true; + } + const sibling = arrowIsUp + ? linebreak.getPreviousSibling() + : linebreak.getNextSibling(); + if (sibling == null) { + return true; + } + + const maybeInsertionPoint = arrowIsUp + ? getFirstCodeHighlightNodeOfLine(sibling) + : getLastCodeHighlightNodeOfLine(sibling); + let insertionPoint = + maybeInsertionPoint != null ? maybeInsertionPoint : sibling; + linebreak.remove(); + range.forEach((node) => node.remove()); + if (type === KEY_ARROW_UP_COMMAND) { + range.forEach((node) => insertionPoint.insertBefore(node)); + insertionPoint.insertBefore(linebreak); + } else { + insertionPoint.insertAfter(linebreak); + insertionPoint = linebreak; + range.forEach((node) => { + insertionPoint.insertAfter(node); + insertionPoint = node; + }); + } + + selection.setTextNodeRange(anchorNode, anchorOffset, focusNode, focusOffset); + + return true; +} + +function isSpaceOrTabChar(char: string): boolean { + return char === ' ' || char === '\t'; +} + +function findFirstNotSpaceOrTabCharAtText( + text: string, + isForward: boolean, +): number { + const length = text.length; + let offset = -1; + + if (isForward) { + for (let i = 0; i < length; i++) { + const char = text[i]; + if (!isSpaceOrTabChar(char)) { + offset = i; + break; + } + } + } else { + for (let i = length - 1; i > -1; i--) { + const char = text[i]; + if (!isSpaceOrTabChar(char)) { + offset = i; + break; + } + } + } + + return offset; +} + +function getStartOfCodeInLine(anchor: LexicalNode): { + node: TextNode | null; + offset: number; +} { + let currentNode = null; + let currentNodeOffset = -1; + const previousSiblings = anchor.getPreviousSiblings(); + previousSiblings.push(anchor); + while (previousSiblings.length > 0) { + const node = previousSiblings.pop(); + if ($isCodeHighlightNode(node)) { + const text = node.getTextContent(); + const offset = findFirstNotSpaceOrTabCharAtText(text, true); + if (offset !== -1) { + currentNode = node; + currentNodeOffset = offset; + } + } + if ($isCodeLineNode(node)) { + break; + } + } + + if (currentNode === null) { + const nextSiblings = anchor.getNextSiblings(); + while (nextSiblings.length > 0) { + const node = nextSiblings.shift(); + if ($isCodeHighlightNode(node)) { + const text = node.getTextContent(); + const offset = findFirstNotSpaceOrTabCharAtText(text, true); + if (offset !== -1) { + currentNode = node; + currentNodeOffset = offset; + break; + } + } + if ($isCodeLineNode(node)) { + break; + } + } + } + + return { + node: currentNode, + offset: currentNodeOffset, + }; +} + +function getEndOfCodeInLine(anchor: LexicalNode): { + node: TextNode | null; + offset: number; +} { + let currentNode = null; + let currentNodeOffset = -1; + const nextSiblings = anchor.getNextSiblings(); + nextSiblings.unshift(anchor); + while (nextSiblings.length > 0) { + const node = nextSiblings.shift(); + if ($isCodeHighlightNode(node)) { + const text = node.getTextContent(); + const offset = findFirstNotSpaceOrTabCharAtText(text, false); + if (offset !== -1) { + currentNode = node; + currentNodeOffset = offset + 1; + } + } + if ($isCodeLineNode(node)) { + break; + } + } + + if (currentNode === null) { + const previousSiblings = anchor.getPreviousSiblings(); + while (previousSiblings.length > 0) { + const node = previousSiblings.pop(); + if ($isCodeHighlightNode(node)) { + const text = node.getTextContent(); + const offset = findFirstNotSpaceOrTabCharAtText(text, false); + if (offset !== -1) { + currentNode = node; + currentNodeOffset = offset + 1; + break; + } + } + if ($isCodeLineNode(node)) { + break; + } + } + } + + return { + node: currentNode, + offset: currentNodeOffset, + }; +} + +function handleMoveTo( + type: LexicalCommand, + event: KeyboardEvent, +): boolean { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + const {anchor, focus} = selection; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + const isMoveToStart = type === MOVE_TO_START; + + if (!$isCodeHighlightNode(anchorNode) || !$isCodeHighlightNode(focusNode)) { + return false; + } + + let node; + let offset; + + if (isMoveToStart) { + ({node, offset} = getStartOfCodeInLine(focusNode)); + } else { + ({node, offset} = getEndOfCodeInLine(focusNode)); + } + + if (node !== null && offset !== -1) { + selection.setTextNodeRange(node, offset, node, offset); + } + + event.preventDefault(); + event.stopPropagation(); +} + +export function registerCodeIndent(editor: LexicalEditor): () => void { + return mergeRegister( + editor.registerMutationListener(CodeNode, (mutations) => { + editor.update(() => { + for (const [key, type] of mutations) { + if (type !== 'destroyed') { + const node = $getNodeByKey(key); + if (node !== null) { + updateCodeGutter(node as CodeNode, editor); + } + } + } + }); + }), + editor.registerCommand( + INDENT_CONTENT_COMMAND, + (payload): boolean => handleMultilineIndent(INDENT_CONTENT_COMMAND), + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + OUTDENT_CONTENT_COMMAND, + (payload): boolean => handleMultilineIndent(OUTDENT_CONTENT_COMMAND), + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (payload: KeyboardEvent): boolean => + handleShiftLines(KEY_ARROW_UP_COMMAND, payload), + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (payload: KeyboardEvent): boolean => + handleShiftLines(KEY_ARROW_DOWN_COMMAND, payload), + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + MOVE_TO_END, + (payload: KeyboardEvent): boolean => handleMoveTo(MOVE_TO_END, payload), + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + MOVE_TO_START, + (payload: KeyboardEvent): boolean => handleMoveTo(MOVE_TO_START, payload), + COMMAND_PRIORITY_LOW, + ), + ); +} diff --git a/packages/lexical-code/src/HighlighterHelper.ts b/packages/lexical-code/src/HighlighterHelper.ts new file mode 100644 index 00000000000..9905b1b2b12 --- /dev/null +++ b/packages/lexical-code/src/HighlighterHelper.ts @@ -0,0 +1,73 @@ +/** + * 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. + * + */ + +// eslint-disable-next-line simple-import-sort/imports +import {$isCodeLineNode, LexicalNode, LexicalEditor} from 'lexical'; +import {CodeHighlightNode, $isCodeHighlightNode, CodeNode} from '@lexical/code'; + +export function getFirstCodeHighlightNodeOfLine( + anchor: LexicalNode, +): CodeHighlightNode | null | undefined { + let currentNode = null; + const previousSiblings = anchor.getPreviousSiblings(); + previousSiblings.push(anchor); + while (previousSiblings.length > 0) { + const node = previousSiblings.pop(); + if ($isCodeHighlightNode(node)) { + currentNode = node; + } + if ($isCodeLineNode(node)) { + break; + } + } + + return currentNode; +} + +export function getLastCodeHighlightNodeOfLine( + anchor: LexicalNode, +): CodeHighlightNode | null | undefined { + let currentNode = null; + const nextSiblings = anchor.getNextSiblings(); + nextSiblings.unshift(anchor); + while (nextSiblings.length > 0) { + const node = nextSiblings.shift(); + if ($isCodeHighlightNode(node)) { + currentNode = node; + } + if ($isCodeLineNode(node)) { + break; + } + } + + return currentNode; +} + +export function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { + const codeElement = editor.getElementByKey(node.getKey()); + if (codeElement === null) { + return; + } + const children = node.getChildren(); + const childrenLength = children.length; + // @ts-ignore: internal field + if (childrenLength === codeElement.__cachedChildrenLength) { + // Avoid updating the attribute if the children length hasn't changed. + return; + } + // @ts-ignore:: internal field + codeElement.__cachedChildrenLength = childrenLength; + let gutter = '1'; + let count = 1; + for (let i = 0; i < childrenLength; i++) { + if ($isCodeLineNode(children[i])) { + gutter += '\n' + ++count; + } + } + codeElement.setAttribute('data-gutter', gutter); +} diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index 38b171a559e..289a4dae53e 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -5,1162 +5,30 @@ * LICENSE file in the root directory of this source tree. * */ - -// eslint-disable-next-line simple-import-sort/imports -import type { - DOMConversionMap, - DOMConversionOutput, - EditorConfig, - EditorThemeClasses, - LexicalCommand, - LexicalEditor, - LexicalNode, - NodeKey, - ParagraphNode, - RangeSelection, - SerializedElementNode, - SerializedTextNode, - Spread, -} from 'lexical'; - -import * as Prism from 'prismjs'; - -import 'prismjs/components/prism-clike'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-markup'; -import 'prismjs/components/prism-markdown'; -import 'prismjs/components/prism-c'; -import 'prismjs/components/prism-css'; -import 'prismjs/components/prism-objectivec'; -import 'prismjs/components/prism-sql'; -import 'prismjs/components/prism-python'; -import 'prismjs/components/prism-rust'; -import 'prismjs/components/prism-swift'; - +import {registerCodeHighlighting} from './CodeHighlighter'; import { - addClassNamesToElement, - mergeRegister, - removeClassNamesFromElement, -} from '@lexical/utils'; + $createCodeHighlightNode, + $isCodeHighlightNode, + CodeHighlightNode, +} from './CodeHighlightNode'; +import {$createCodeNode, $isCodeNode, CodeNode} from './CodeNode'; +import {registerCodeIndent} from './EditorShortcuts'; import { - $createLineBreakNode, - $createParagraphNode, - $createTextNode, - $getNodeByKey, - $getSelection, - $isLineBreakNode, - $isRangeSelection, - $isTextNode, - COMMAND_PRIORITY_LOW, - ElementNode, - INDENT_CONTENT_COMMAND, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_UP_COMMAND, - MOVE_TO_END, - MOVE_TO_START, - OUTDENT_CONTENT_COMMAND, - TextNode, -} from 'lexical'; - -const DEFAULT_CODE_LANGUAGE = 'javascript'; - -type SerializedCodeNode = Spread< - { - language: string | null | undefined; - type: 'code'; - version: 1; - }, - SerializedElementNode ->; - -type SerializedCodeHighlightNode = Spread< - { - highlightType: string | null | undefined; - type: 'code-highlight'; - version: 1; - }, - SerializedTextNode ->; - -const mapToPrismLanguage = ( - language: string | null | undefined, -): string | null | undefined => { - // eslint-disable-next-line no-prototype-builtins - return language != null && Prism.languages.hasOwnProperty(language) - ? language - : undefined; + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, + updateCodeGutter, +} from './HighlighterHelper'; + +export { + $createCodeHighlightNode, + $createCodeNode, + $isCodeHighlightNode, + $isCodeNode, + CodeHighlightNode, + CodeNode, + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, + registerCodeHighlighting, + registerCodeIndent, + updateCodeGutter, }; - -export const getDefaultCodeLanguage = (): string => DEFAULT_CODE_LANGUAGE; - -export const getCodeLanguages = (): Array => - Object.keys(Prism.languages) - .filter( - // Prism has several language helpers mixed into languages object - // so filtering them out here to get langs list - (language) => typeof Prism.languages[language] !== 'function', - ) - .sort(); - -export class CodeHighlightNode extends TextNode { - __highlightType: string | null | undefined; - - constructor(text: string, highlightType?: string, key?: NodeKey) { - super(text, key); - this.__highlightType = highlightType; - } - - static getType(): string { - return 'code-highlight'; - } - - static clone(node: CodeHighlightNode): CodeHighlightNode { - return new CodeHighlightNode( - node.__text, - node.__highlightType || undefined, - node.__key, - ); - } - - getHighlightType(): string | null | undefined { - const self = this.getLatest(); - return self.__highlightType; - } - - createDOM(config: EditorConfig): HTMLElement { - const element = super.createDOM(config); - const className = getHighlightThemeClass( - config.theme, - this.__highlightType, - ); - addClassNamesToElement(element, className); - return element; - } - - updateDOM( - prevNode: CodeHighlightNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { - const update = super.updateDOM(prevNode, dom, config); - const prevClassName = getHighlightThemeClass( - config.theme, - prevNode.__highlightType, - ); - const nextClassName = getHighlightThemeClass( - config.theme, - this.__highlightType, - ); - if (prevClassName !== nextClassName) { - if (prevClassName) { - removeClassNamesFromElement(dom, prevClassName); - } - if (nextClassName) { - addClassNamesToElement(dom, nextClassName); - } - } - return update; - } - - static importJSON( - serializedNode: SerializedCodeHighlightNode, - ): CodeHighlightNode { - const node = $createCodeHighlightNode( - serializedNode.text, - serializedNode.highlightType, - ); - node.setFormat(serializedNode.format); - node.setDetail(serializedNode.detail); - node.setMode(serializedNode.mode); - node.setStyle(serializedNode.style); - return node; - } - - exportJSON(): SerializedCodeHighlightNode { - return { - ...super.exportJSON(), - highlightType: this.getHighlightType(), - type: 'code-highlight', - version: 1, - }; - } - - // Prevent formatting (bold, underline, etc) - setFormat(format: number): this { - return this; - } -} - -function getHighlightThemeClass( - theme: EditorThemeClasses, - highlightType: string | undefined, -): string | undefined { - return ( - highlightType && - theme && - theme.codeHighlight && - theme.codeHighlight[highlightType] - ); -} - -export function $createCodeHighlightNode( - text: string, - highlightType?: string, -): CodeHighlightNode { - return new CodeHighlightNode(text, highlightType); -} - -export function $isCodeHighlightNode( - node: LexicalNode | CodeHighlightNode | null | undefined, -): node is CodeHighlightNode { - return node instanceof CodeHighlightNode; -} - -const LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; - -export class CodeNode extends ElementNode { - __language: string | null | undefined; - - static getType(): string { - return 'code'; - } - - static clone(node: CodeNode): CodeNode { - return new CodeNode(node.__language, node.__key); - } - - constructor(language?: string | null | undefined, key?: NodeKey) { - super(key); - this.__language = mapToPrismLanguage(language); - } - - // View - createDOM(config: EditorConfig): HTMLElement { - const element = document.createElement('code'); - addClassNamesToElement(element, config.theme.code); - element.setAttribute('spellcheck', 'false'); - const language = this.getLanguage(); - if (language) { - element.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); - } - return element; - } - updateDOM(prevNode: CodeNode, dom: HTMLElement): boolean { - const language = this.__language; - const prevLanguage = prevNode.__language; - - if (language) { - if (language !== prevLanguage) { - dom.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); - } - } else if (prevLanguage) { - dom.removeAttribute(LANGUAGE_DATA_ATTRIBUTE); - } - return false; - } - - static importDOM(): DOMConversionMap | null { - return { - code: (node: Node) => ({ - conversion: convertPreElement, - priority: 0, - }), - div: (node: Node) => ({ - conversion: convertDivElement, - priority: 1, - }), - pre: (node: Node) => ({ - conversion: convertPreElement, - priority: 0, - }), - table: (node: Node) => { - const table = node; - // domNode is a
since we matched it by nodeName + const td = node as HTMLTableCellElement; + const table: HTMLTableElement | null = td.closest('table'); + + if (isGitHubCodeCell(td)) { + return { + conversion: convertTableCellElement, + priority: 4, + }; + } + if (table && isGitHubCodeTable(table)) { + // Return a no-op if it's a table cell in a code table, but not a code line. + // Otherwise it'll fall back to the T + return { + conversion: convertCodeNoop, + priority: 4, + }; + } + + return null; + }, + tr: (node: Node) => { + // element is a
since we matched it by nodeName - if (isGitHubCodeTable(table as HTMLTableElement)) { - return { - conversion: convertTableElement, - priority: 4, - }; - } - return null; - }, - td: (node: Node) => { - // element is a since we matched it by nodeName - const tr = node as HTMLTableCellElement; - const table: HTMLTableElement | null = tr.closest('table'); - if (table && isGitHubCodeTable(table)) { - return { - conversion: convertCodeNoop, - priority: 4, - }; - } - return null; - }, - }; - } - - static importJSON(serializedNode: SerializedCodeNode): CodeNode { - const node = $createCodeNode(serializedNode.language); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); - node.setDirection(serializedNode.direction); - return node; - } - - exportJSON(): SerializedCodeNode { - return { - ...super.exportJSON(), - language: this.getLanguage(), - type: 'code', - version: 1, - }; - } - - // Mutation - insertNewAfter( - selection: RangeSelection, - ): null | ParagraphNode | CodeHighlightNode { - const children = this.getChildren(); - const childrenLength = children.length; - - if ( - childrenLength >= 2 && - children[childrenLength - 1].getTextContent() === '\n' && - children[childrenLength - 2].getTextContent() === '\n' && - selection.isCollapsed() && - selection.anchor.key === this.__key && - selection.anchor.offset === childrenLength - ) { - children[childrenLength - 1].remove(); - children[childrenLength - 2].remove(); - const newElement = $createParagraphNode(); - this.insertAfter(newElement); - return newElement; - } - - // If the selection is within the codeblock, find all leading tabs and - // spaces of the current line. Create a new line that has all those - // tabs and spaces, such that leading indentation is preserved. - const anchor = selection.anchor.getNode(); - const firstNode = getFirstCodeHighlightNodeOfLine(anchor); - if (firstNode != null) { - let leadingWhitespace = 0; - const firstNodeText = firstNode.getTextContent(); - while ( - leadingWhitespace < firstNodeText.length && - /[\t ]/.test(firstNodeText[leadingWhitespace]) - ) { - leadingWhitespace += 1; - } - if (leadingWhitespace > 0) { - const whitespace = firstNodeText.substring(0, leadingWhitespace); - const indentedChild = $createCodeHighlightNode(whitespace); - anchor.insertAfter(indentedChild); - selection.insertNodes([$createLineBreakNode()]); - indentedChild.select(); - return indentedChild; - } - } - - return null; - } - - canInsertTab(): boolean { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return false; - } - return true; - } - - canIndent(): false { - return false; - } - - collapseAtStart(): true { - const paragraph = $createParagraphNode(); - const children = this.getChildren(); - children.forEach((child) => paragraph.append(child)); - this.replace(paragraph); - return true; - } - - setLanguage(language: string): void { - const writable = this.getWritable(); - writable.__language = mapToPrismLanguage(language); - } - - getLanguage(): string | null | undefined { - return this.getLatest().__language; - } -} - -export function $createCodeNode(language?: string): CodeNode { - return new CodeNode(language); -} - -export function $isCodeNode( - node: LexicalNode | null | undefined, -): node is CodeNode { - return node instanceof CodeNode; -} - -export function getFirstCodeHighlightNodeOfLine( - anchor: LexicalNode, -): CodeHighlightNode | null | undefined { - let currentNode = null; - const previousSiblings = anchor.getPreviousSiblings(); - previousSiblings.push(anchor); - while (previousSiblings.length > 0) { - const node = previousSiblings.pop(); - if ($isCodeHighlightNode(node)) { - currentNode = node; - } - if ($isLineBreakNode(node)) { - break; - } - } - - return currentNode; -} - -export function getLastCodeHighlightNodeOfLine( - anchor: LexicalNode, -): CodeHighlightNode | null | undefined { - let currentNode = null; - const nextSiblings = anchor.getNextSiblings(); - nextSiblings.unshift(anchor); - while (nextSiblings.length > 0) { - const node = nextSiblings.shift(); - if ($isCodeHighlightNode(node)) { - currentNode = node; - } - if ($isLineBreakNode(node)) { - break; - } - } - - return currentNode; -} - -function isSpaceOrTabChar(char: string): boolean { - return char === ' ' || char === '\t'; -} - -function findFirstNotSpaceOrTabCharAtText( - text: string, - isForward: boolean, -): number { - const length = text.length; - let offset = -1; - - if (isForward) { - for (let i = 0; i < length; i++) { - const char = text[i]; - if (!isSpaceOrTabChar(char)) { - offset = i; - break; - } - } - } else { - for (let i = length - 1; i > -1; i--) { - const char = text[i]; - if (!isSpaceOrTabChar(char)) { - offset = i; - break; - } - } - } - - return offset; -} - -export function getStartOfCodeInLine(anchor: LexicalNode): { - node: TextNode | null; - offset: number; -} { - let currentNode = null; - let currentNodeOffset = -1; - const previousSiblings = anchor.getPreviousSiblings(); - previousSiblings.push(anchor); - while (previousSiblings.length > 0) { - const node = previousSiblings.pop(); - if ($isCodeHighlightNode(node)) { - const text = node.getTextContent(); - const offset = findFirstNotSpaceOrTabCharAtText(text, true); - if (offset !== -1) { - currentNode = node; - currentNodeOffset = offset; - } - } - if ($isLineBreakNode(node)) { - break; - } - } - - if (currentNode === null) { - const nextSiblings = anchor.getNextSiblings(); - while (nextSiblings.length > 0) { - const node = nextSiblings.shift(); - if ($isCodeHighlightNode(node)) { - const text = node.getTextContent(); - const offset = findFirstNotSpaceOrTabCharAtText(text, true); - if (offset !== -1) { - currentNode = node; - currentNodeOffset = offset; - break; - } - } - if ($isLineBreakNode(node)) { - break; - } - } - } - - return { - node: currentNode, - offset: currentNodeOffset, - }; -} - -export function getEndOfCodeInLine(anchor: LexicalNode): { - node: TextNode | null; - offset: number; -} { - let currentNode = null; - let currentNodeOffset = -1; - const nextSiblings = anchor.getNextSiblings(); - nextSiblings.unshift(anchor); - while (nextSiblings.length > 0) { - const node = nextSiblings.shift(); - if ($isCodeHighlightNode(node)) { - const text = node.getTextContent(); - const offset = findFirstNotSpaceOrTabCharAtText(text, false); - if (offset !== -1) { - currentNode = node; - currentNodeOffset = offset + 1; - } - } - if ($isLineBreakNode(node)) { - break; - } - } - - if (currentNode === null) { - const previousSiblings = anchor.getPreviousSiblings(); - while (previousSiblings.length > 0) { - const node = previousSiblings.pop(); - if ($isCodeHighlightNode(node)) { - const text = node.getTextContent(); - const offset = findFirstNotSpaceOrTabCharAtText(text, false); - if (offset !== -1) { - currentNode = node; - currentNodeOffset = offset + 1; - break; - } - } - if ($isLineBreakNode(node)) { - break; - } - } - } - - return { - node: currentNode, - offset: currentNodeOffset, - }; -} - -function convertPreElement(domNode: Node): DOMConversionOutput { - return {node: $createCodeNode()}; -} - -function convertDivElement(domNode: Node): DOMConversionOutput { - // domNode is a
since we matched it by nodeName - const div = domNode as HTMLDivElement; - return { - after: (childLexicalNodes) => { - const domParent = domNode.parentNode; - if (domParent != null && domNode !== domParent.lastChild) { - childLexicalNodes.push($createLineBreakNode()); - } - return childLexicalNodes; - }, - node: isCodeElement(div) ? $createCodeNode() : null, - }; -} - -function convertTableElement(): DOMConversionOutput { - return {node: $createCodeNode()}; -} - -function convertCodeNoop(): DOMConversionOutput { - return {node: null}; -} - -function convertTableCellElement(domNode: Node): DOMConversionOutput { - // domNode is a
since we matched it by nodeName - const td = node as HTMLTableCellElement; - const table: HTMLTableElement | null = td.closest('table'); - - if (isGitHubCodeCell(td)) { - return { - conversion: convertTableCellElement, - priority: 4, - }; - } - if (table && isGitHubCodeTable(table)) { - // Return a no-op if it's a table cell in a code table, but not a code line. - // Otherwise it'll fall back to the T - return { - conversion: convertCodeNoop, - priority: 4, - }; - } - - return null; - }, - tr: (node: Node) => { - // element is a
since we matched it by nodeName - const cell = domNode as HTMLTableCellElement; - - return { - after: (childLexicalNodes) => { - if (cell.parentNode && cell.parentNode.nextSibling) { - // Append newline between code lines - childLexicalNodes.push($createLineBreakNode()); - } - return childLexicalNodes; - }, - node: null, - }; -} - -function isCodeElement(div: HTMLDivElement): boolean { - return div.style.fontFamily.match('monospace') !== null; -} - -function isGitHubCodeCell( - cell: HTMLTableCellElement, -): cell is HTMLTableCellElement { - return cell.classList.contains('js-file-line'); -} - -function isGitHubCodeTable(table: HTMLTableElement): table is HTMLTableElement { - return table.classList.contains('js-file-line-container'); -} - -function textNodeTransform(node: TextNode, editor: LexicalEditor): void { - // Since CodeNode has flat children structure we only need to check - // if node's parent is a code node and run highlighting if so - const parentNode = node.getParent(); - if ($isCodeNode(parentNode)) { - codeNodeTransform(parentNode, editor); - } else if ($isCodeHighlightNode(node)) { - // When code block converted into paragraph or other element - // code highlight nodes converted back to normal text - node.replace($createTextNode(node.__text)); - } -} - -function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { - const codeElement = editor.getElementByKey(node.getKey()); - if (codeElement === null) { - return; - } - const children = node.getChildren(); - const childrenLength = children.length; - // @ts-ignore: internal field - if (childrenLength === codeElement.__cachedChildrenLength) { - // Avoid updating the attribute if the children length hasn't changed. - return; - } - // @ts-ignore:: internal field - codeElement.__cachedChildrenLength = childrenLength; - let gutter = '1'; - let count = 1; - for (let i = 0; i < childrenLength; i++) { - if ($isLineBreakNode(children[i])) { - gutter += '\n' + ++count; - } - } - codeElement.setAttribute('data-gutter', gutter); -} - -// Using `skipTransforms` to prevent extra transforms since reformatting the code -// will not affect code block content itself. -// -// Using extra flag (`isHighlighting`) since both CodeNode and CodeHighlightNode -// trasnforms might be called at the same time (e.g. new CodeHighlight node inserted) and -// in both cases we'll rerun whole reformatting over CodeNode, which is redundant. -// Especially when pasting code into CodeBlock. -let isHighlighting = false; -function codeNodeTransform(node: CodeNode, editor: LexicalEditor) { - if (isHighlighting) { - return; - } - isHighlighting = true; - // When new code block inserted it might not have language selected - if (node.getLanguage() === undefined) { - node.setLanguage(DEFAULT_CODE_LANGUAGE); - } - - // Using nested update call to pass `skipTransforms` since we don't want - // each individual codehighlight node to be transformed again as it's already - // in its final state - editor.update( - () => { - updateAndRetainSelection(node, () => { - const code = node.getTextContent(); - const tokens = Prism.tokenize( - code, - Prism.languages[node.getLanguage() || ''] || - Prism.languages[DEFAULT_CODE_LANGUAGE], - ); - const highlightNodes = getHighlightNodes(tokens); - const diffRange = getDiffRange(node.getChildren(), highlightNodes); - const {from, to, nodesForReplacement} = diffRange; - if (from !== to || nodesForReplacement.length) { - node.splice(from, to - from, nodesForReplacement); - return true; - } - return false; - }); - }, - { - onUpdate: () => { - isHighlighting = false; - }, - skipTransforms: true, - }, - ); -} - -function getHighlightNodes( - tokens: (string | Prism.Token)[], -): Array { - const nodes: LexicalNode[] = []; - - tokens.forEach((token) => { - if (typeof token === 'string') { - const partials = token.split('\n'); - for (let i = 0; i < partials.length; i++) { - const text = partials[i]; - if (text.length) { - nodes.push($createCodeHighlightNode(text)); - } - if (i < partials.length - 1) { - nodes.push($createLineBreakNode()); - } - } - } else { - const {content} = token; - if (typeof content === 'string') { - nodes.push($createCodeHighlightNode(content, token.type)); - } else if ( - Array.isArray(content) && - content.length === 1 && - typeof content[0] === 'string' - ) { - nodes.push($createCodeHighlightNode(content[0], token.type)); - } else if (Array.isArray(content)) { - nodes.push(...getHighlightNodes(content)); - } - } - }); - - return nodes; -} - -// Wrapping update function into selection retainer, that tries to keep cursor at the same -// position as before. -function updateAndRetainSelection( - node: CodeNode, - updateFn: () => boolean, -): void { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.anchor) { - return; - } - - const anchor = selection.anchor; - const anchorOffset = anchor.offset; - const isNewLineAnchor = - anchor.type === 'element' && - $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); - let textOffset = 0; - - // Calculating previous text offset (all text node prior to anchor + anchor own text offset) - if (!isNewLineAnchor) { - const anchorNode = anchor.getNode(); - textOffset = - anchorOffset + - anchorNode.getPreviousSiblings().reduce((offset, _node) => { - return ( - offset + ($isLineBreakNode(_node) ? 0 : _node.getTextContentSize()) - ); - }, 0); - } - - const hasChanges = updateFn(); - if (!hasChanges) { - return; - } - - // Non-text anchors only happen for line breaks, otherwise - // selection will be within text node (code highlight node) - if (isNewLineAnchor) { - anchor.getNode().select(anchorOffset, anchorOffset); - return; - } - - // If it was non-element anchor then we walk through child nodes - // and looking for a position of original text offset - node.getChildren().some((_node) => { - if ($isTextNode(_node)) { - const textContentSize = _node.getTextContentSize(); - if (textContentSize >= textOffset) { - _node.select(textOffset, textOffset); - return true; - } - textOffset -= textContentSize; - } - return false; - }); -} - -// Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes -// that needs to be replaced with `nodes` (subset of nextNodes) to make prevNodes equal to nextNodes. -function getDiffRange( - prevNodes: Array, - nextNodes: Array, -): { - from: number; - nodesForReplacement: Array; - to: number; -} { - let leadingMatch = 0; - while (leadingMatch < prevNodes.length) { - if (!isEqual(prevNodes[leadingMatch], nextNodes[leadingMatch])) { - break; - } - leadingMatch++; - } - - const prevNodesLength = prevNodes.length; - const nextNodesLength = nextNodes.length; - const maxTrailingMatch = - Math.min(prevNodesLength, nextNodesLength) - leadingMatch; - - let trailingMatch = 0; - while (trailingMatch < maxTrailingMatch) { - trailingMatch++; - if ( - !isEqual( - prevNodes[prevNodesLength - trailingMatch], - nextNodes[nextNodesLength - trailingMatch], - ) - ) { - trailingMatch--; - break; - } - } - - const from = leadingMatch; - const to = prevNodesLength - trailingMatch; - const nodesForReplacement = nextNodes.slice( - leadingMatch, - nextNodesLength - trailingMatch, - ); - return { - from, - nodesForReplacement, - to, - }; -} - -function isEqual(nodeA: LexicalNode, nodeB: LexicalNode): boolean { - // Only checking for code higlight nodes and linebreaks. If it's regular text node - // returning false so that it's transformed into code highlight node - if ($isCodeHighlightNode(nodeA) && $isCodeHighlightNode(nodeB)) { - return ( - nodeA.__text === nodeB.__text && - nodeA.__highlightType === nodeB.__highlightType - ); - } - - if ($isLineBreakNode(nodeA) && $isLineBreakNode(nodeB)) { - return true; - } - - return false; -} - -function handleMultilineIndent(type: LexicalCommand): boolean { - const selection = $getSelection(); - - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return false; - } - - // Only run multiline indent logic on selections exclusively composed of code highlights and linebreaks - const nodes = selection.getNodes(); - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (!$isCodeHighlightNode(node) && !$isLineBreakNode(node)) { - return false; - } - } - const startOfLine = getFirstCodeHighlightNodeOfLine(nodes[0]); - - if (startOfLine != null) { - doIndent(startOfLine, type); - } - - for (let i = 1; i < nodes.length; i++) { - const node = nodes[i]; - if ($isLineBreakNode(nodes[i - 1]) && $isCodeHighlightNode(node)) { - doIndent(node, type); - } - } - - return true; -} - -function doIndent(node: CodeHighlightNode, type: LexicalCommand) { - const text = node.getTextContent(); - if (type === INDENT_CONTENT_COMMAND) { - // If the codeblock node doesn't start with whitespace, we don't want to - // naively prepend a '\t'; Prism will then mangle all of our nodes when - // it separates the whitespace from the first non-whitespace node. This - // will lead to selection bugs when indenting lines that previously - // didn't start with a whitespace character - if (text.length > 0 && /\s/.test(text[0])) { - node.setTextContent('\t' + text); - } else { - const indentNode = $createCodeHighlightNode('\t'); - node.insertBefore(indentNode); - } - } else { - if (text.indexOf('\t') === 0) { - // Same as above - if we leave empty text nodes lying around, the resulting - // selection will be mangled - if (text.length === 1) { - node.remove(); - } else { - node.setTextContent(text.substring(1)); - } - } - } -} - -function handleShiftLines( - type: LexicalCommand, - event: KeyboardEvent, -): boolean { - // We only care about the alt+arrow keys - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return false; - } - - // I'm not quite sure why, but it seems like calling anchor.getNode() collapses the selection here - // So first, get the anchor and the focus, then get their nodes - const {anchor, focus} = selection; - const anchorOffset = anchor.offset; - const focusOffset = focus.offset; - const anchorNode = anchor.getNode(); - const focusNode = focus.getNode(); - const arrowIsUp = type === KEY_ARROW_UP_COMMAND; - - // Ensure the selection is within the codeblock - if (!$isCodeHighlightNode(anchorNode) || !$isCodeHighlightNode(focusNode)) { - return false; - } - if (!event.altKey) { - // Handle moving selection out of the code block, given there are no - // sibling thats can natively take the selection. - if (selection.isCollapsed()) { - const codeNode = anchorNode.getParentOrThrow(); - if ( - arrowIsUp && - anchorOffset === 0 && - anchorNode.getPreviousSibling() === null - ) { - const codeNodeSibling = codeNode.getPreviousSibling(); - if (codeNodeSibling === null) { - codeNode.selectPrevious(); - event.preventDefault(); - return true; - } - } else if ( - !arrowIsUp && - anchorOffset === anchorNode.getTextContentSize() && - anchorNode.getNextSibling() === null - ) { - const codeNodeSibling = codeNode.getNextSibling(); - if (codeNodeSibling === null) { - codeNode.selectNext(); - event.preventDefault(); - return true; - } - } - } - return false; - } - - const start = getFirstCodeHighlightNodeOfLine(anchorNode); - const end = getLastCodeHighlightNodeOfLine(focusNode); - if (start == null || end == null) { - return false; - } - - const range = start.getNodesBetween(end); - for (let i = 0; i < range.length; i++) { - const node = range[i]; - if (!$isCodeHighlightNode(node) && !$isLineBreakNode(node)) { - return false; - } - } - - // After this point, we know the selection is within the codeblock. We may not be able to - // actually move the lines around, but we want to return true either way to prevent - // the event's default behavior - event.preventDefault(); - event.stopPropagation(); // required to stop cursor movement under Firefox - - const linebreak = arrowIsUp - ? start.getPreviousSibling() - : end.getNextSibling(); - if (!$isLineBreakNode(linebreak)) { - return true; - } - const sibling = arrowIsUp - ? linebreak.getPreviousSibling() - : linebreak.getNextSibling(); - if (sibling == null) { - return true; - } - - const maybeInsertionPoint = arrowIsUp - ? getFirstCodeHighlightNodeOfLine(sibling) - : getLastCodeHighlightNodeOfLine(sibling); - let insertionPoint = - maybeInsertionPoint != null ? maybeInsertionPoint : sibling; - linebreak.remove(); - range.forEach((node) => node.remove()); - if (type === KEY_ARROW_UP_COMMAND) { - range.forEach((node) => insertionPoint.insertBefore(node)); - insertionPoint.insertBefore(linebreak); - } else { - insertionPoint.insertAfter(linebreak); - insertionPoint = linebreak; - range.forEach((node) => { - insertionPoint.insertAfter(node); - insertionPoint = node; - }); - } - - selection.setTextNodeRange(anchorNode, anchorOffset, focusNode, focusOffset); - - return true; -} - -function handleMoveTo( - type: LexicalCommand, - event: KeyboardEvent, -): boolean { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return false; - } - - const {anchor, focus} = selection; - const anchorNode = anchor.getNode(); - const focusNode = focus.getNode(); - const isMoveToStart = type === MOVE_TO_START; - - if (!$isCodeHighlightNode(anchorNode) || !$isCodeHighlightNode(focusNode)) { - return false; - } - - let node; - let offset; - - if (isMoveToStart) { - ({node, offset} = getStartOfCodeInLine(focusNode)); - } else { - ({node, offset} = getEndOfCodeInLine(focusNode)); - } - - if (node !== null && offset !== -1) { - selection.setTextNodeRange(node, offset, node, offset); - } - - event.preventDefault(); - event.stopPropagation(); -} - -export function registerCodeHighlighting(editor: LexicalEditor): () => void { - if (!editor.hasNodes([CodeNode, CodeHighlightNode])) { - throw new Error( - 'CodeHighlightPlugin: CodeNode or CodeHighlightNode not registered on editor', - ); - } - - return mergeRegister( - editor.registerMutationListener(CodeNode, (mutations) => { - editor.update(() => { - for (const [key, type] of mutations) { - if (type !== 'destroyed') { - const node = $getNodeByKey(key); - if (node !== null) { - updateCodeGutter(node as CodeNode, editor); - } - } - } - }); - }), - editor.registerNodeTransform(CodeNode, (node) => - codeNodeTransform(node, editor), - ), - editor.registerNodeTransform(TextNode, (node) => - textNodeTransform(node, editor), - ), - editor.registerNodeTransform(CodeHighlightNode, (node) => - textNodeTransform(node, editor), - ), - editor.registerCommand( - INDENT_CONTENT_COMMAND, - (payload): boolean => handleMultilineIndent(INDENT_CONTENT_COMMAND), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - OUTDENT_CONTENT_COMMAND, - (payload): boolean => handleMultilineIndent(OUTDENT_CONTENT_COMMAND), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (payload: KeyboardEvent): boolean => - handleShiftLines(KEY_ARROW_UP_COMMAND, payload), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (payload: KeyboardEvent): boolean => - handleShiftLines(KEY_ARROW_DOWN_COMMAND, payload), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - MOVE_TO_END, - (payload: KeyboardEvent): boolean => handleMoveTo(MOVE_TO_END, payload), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - MOVE_TO_START, - (payload: KeyboardEvent): boolean => handleMoveTo(MOVE_TO_START, payload), - COMMAND_PRIORITY_LOW, - ), - ); -} diff --git a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts index 7813912ecb8..5cd8fd8442f 100644 --- a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts +++ b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts @@ -14,7 +14,7 @@ export default function CodeHighlightPlugin(): JSX.Element | null { const [editor] = useLexicalComposerContext(); useEffect(() => { - return registerCodeHighlighting(editor); + return registerCodeHighlighting(editor, 10000); }, [editor]); return null; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index c3179560701..0f548650350 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -637,6 +637,27 @@ declare export function $isLineBreakNode( node: ?LexicalNode, ): boolean %checks(node instanceof LineBreakNode); +/** + * LexicalCodeLineNode + */ + +declare export class CodeLineNode extends ElementNode { + static getType(): string; + static clone(node: CodeLineNode): CodeLineNode; + constructor(key?: NodeKey): void; + getTextContent(): 'div'; + createDOM(): HTMLElement; + updateDOM(): false; + static importJSON( + serializedCodeLineNode: SerializedCodeLineNode, + ): CodeLineNode; + // exportJSON(): SerializedCodeLineNode; +} +declare export function $createCodeLineNode(): CodeLineNode; +declare export function $isCodeLineNode( + node: ?ElementNode, +): boolean %checks(node instanceof CodeLineNode); + /** * LexicalRootNode */ @@ -797,7 +818,7 @@ declare export function $getRoot(): RootNode; declare export function $isLeafNode( node: ?LexicalNode, ): boolean %checks(node instanceof TextNode || - node instanceof LineBreakNode || + node instanceof CodeLineNode || node instanceof DecoratorNode); declare export function $setCompositionKey( compositionKey: null | NodeKey, @@ -869,6 +890,12 @@ export type SerializedLineBreakNode = { ... }; +export type SerializedCodeLineNode = { + ...SerializedElementNode, + type: 'codeline', + ... +}; + export type SerializedRootNode = { ...SerializedElementNode, type: 'root', diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 528e0dd89a3..09c93c26925 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -13,7 +13,7 @@ import type {Klass} from 'shared/types'; import getDOMSelection from 'shared/getDOMSelection'; import invariant from 'shared/invariant'; -import {$getRoot, $getSelection, TextNode} from '.'; +import {$getRoot, $getSelection, CodeLineNode, TextNode} from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; import {createEmptyEditorState} from './LexicalEditorState'; import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents'; @@ -59,6 +59,7 @@ export type EditorSetOptions = { export type EditorThemeClasses = { code?: EditorThemeClassName; + codeLine?: EditorThemeClassName; codeHighlight?: Record; hashtag?: EditorThemeClassName; heading?: { @@ -311,6 +312,7 @@ export function createEditor(editorConfig?: { RootNode, TextNode, LineBreakNode, + CodeLineNode, ParagraphNode, ...(config.nodes || []), ]; diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 785de49c87a..74e3541ba90 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -20,9 +20,9 @@ import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from 'shared/invariant'; import { + $isCodeLineNode, $isDecoratorNode, $isElementNode, - $isLineBreakNode, $isRootNode, $isTextNode, } from '.'; @@ -284,7 +284,7 @@ function isLastChildLineBreakOrDecorator( ): boolean { const childKey = children[children.length - 1]; const node = nodeMap.get(childKey); - return $isLineBreakNode(node) || $isDecoratorNode(node); + return $isCodeLineNode(node) || $isDecoratorNode(node); } // If we end an element with a LinkBreakNode, then we need to add an additonal
diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 0b434be5d1d..90081ba0fea 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -17,16 +17,16 @@ import getDOMSelection from 'shared/getDOMSelection'; import invariant from 'shared/invariant'; import { - $createLineBreakNode, + $createCodeLineNode, $createParagraphNode, $createTextNode, + $isCodeLineNode, $isDecoratorNode, $isElementNode, $isGridCellNode, $isGridNode, $isGridRowNode, $isLeafNode, - $isLineBreakNode, $isRootNode, $isTextNode, DecoratorNode, @@ -640,7 +640,7 @@ export class RangeSelection implements BaseSelection { } textContent += text; } else if ( - ($isDecoratorNode(node) || $isLineBreakNode(node)) && + ($isDecoratorNode(node) || $isCodeLineNode(node)) && (node !== lastNode || !this.isCollapsed()) ) { textContent += node.getTextContent(); @@ -715,7 +715,7 @@ export class RangeSelection implements BaseSelection { nodes.push($createTextNode(part)); } if (i !== length - 1) { - nodes.push($createLineBreakNode()); + nodes.push($createCodeLineNode()); } } this.insertNodes(nodes); @@ -1560,7 +1560,7 @@ export class RangeSelection implements BaseSelection { } insertLineBreak(selectStart?: boolean): void { - const lineBreakNode = $createLineBreakNode(); + const lineBreakNode = $createCodeLineNode(); const anchor = this.anchor; if (anchor.type === 'element') { const element = anchor.getNode(); diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 850de3c0b4a..3b306a87da2 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -35,9 +35,9 @@ import { $createTextNode, $getPreviousSelection, $getSelection, + $isCodeLineNode, $isDecoratorNode, $isElementNode, - $isLineBreakNode, $isRangeSelection, $isRootNode, $isTextNode, @@ -196,7 +196,7 @@ export function toggleTextFormatType( } export function $isLeafNode(node: LexicalNode | null | undefined): boolean { - return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node); + return $isTextNode(node) || $isCodeLineNode(node) || $isDecoratorNode(node); } export function $setNodeKey( diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 1ec346710c2..3b21842e427 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -129,6 +129,11 @@ export { $setSelection, } from './LexicalUtils'; export {VERSION} from './LexicalVersion'; +export { + $createCodeLineNode, + $isCodeLineNode, + CodeLineNode, +} from './nodes/LexicalCodeLineNode'; export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; export {$isElementNode, ElementNode} from './nodes/LexicalElementNode'; export {$isGridCellNode, GridCellNode} from './nodes/LexicalGridCellNode'; diff --git a/packages/lexical/src/nodes/LexicalCodeLineNode.ts b/packages/lexical/src/nodes/LexicalCodeLineNode.ts new file mode 100644 index 00000000000..1457d468005 --- /dev/null +++ b/packages/lexical/src/nodes/LexicalCodeLineNode.ts @@ -0,0 +1,171 @@ +/** + * 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 { + DOMConversionMap, + DOMConversionOutput, + NodeKey, + SerializedLexicalNode, +} from '../LexicalNode'; +import type { + EditorConfig, + ParagraphNode, + RangeSelection, + Spread, +} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import {$createParagraphNode} from 'lexical'; + +import {$createCodeHighlightNode} from '../../../lexical-code/src/CodeHighlightNode'; +import {getFirstCodeHighlightNodeOfLine} from '../../../lexical-code/src/HighlighterHelper'; +import {LexicalNode} from '../LexicalNode'; + +export type SerializedCodeLineNode = Spread< + { + type: 'codeline'; + }, + SerializedLexicalNode +>; + +export class CodeLineNode extends LexicalNode { + static getType(): string { + return 'codeline'; + } + + static clone(node: CodeLineNode): CodeLineNode { + return new CodeLineNode(node.__key); + } + + constructor(key?: NodeKey) { + super(key); + } + + getTextContent(): '\n' { + return '\n'; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('div'); + addClassNamesToElement(element, config.theme.codeLine); + return element; + } + + updateDOM(prevNode: CodeLineNode, dom: HTMLElement): boolean { + return false; + } + static importDOM(): DOMConversionMap | null { + return { + div: (node: Node) => { + const parentElement = node.parentElement; + // If the
is the only child, then skip including it + if ( + parentElement != null && + parentElement.firstChild === node && + parentElement.lastChild === node + ) { + return null; + } + return { + conversion: convertCodeLineElement, + priority: 0, + }; + }, + }; + } + + insertNewAfter( + selection: RangeSelection, + ): null | ParagraphNode | CodeLineNode { + // If the selection is within the codeblock, find all leading tabs and + // spaces of the current line. Create a new line that has all those + // tabs and spaces, such that leading indentation is preserved. + const anchor = selection.anchor.getNode(); + const firstNode = getFirstCodeHighlightNodeOfLine(anchor); + if (firstNode != null) { + const leadingIndent = firstNode.getTextContent().match(/^[\t\s]+/); + if (leadingIndent != null) { + const indentedChild = $createCodeLineNode(); + indentedChild.append($createCodeHighlightNode(leadingIndent[0])); + anchor.getParentOrThrow().insertAfter(indentedChild); + indentedChild.select(); + return indentedChild; + } + } + + // Escaping code block with 2 empty lines. Caret should be on the + // last, which (and previous as well) should be empty + if (!selection.isCollapsed() || selection.anchor.key !== this.__key) { + return null; + } + + const codeBlock = this.getParentOrThrow(); + const previousSibling = this.getPreviousSibling(); + if ( + !$isCodeLineNode(previousSibling) || + !previousSibling.isEmpty() || + !this.isEmpty() + ) { + return null; + } + + this.remove(); + previousSibling.remove(); + const newElement = $createParagraphNode(); + codeBlock.insertAfter(newElement); + newElement.select(); + return newElement; + } + + collapseAtStart(): boolean { + const codeBlock = this.getParentOrThrow(); + if (codeBlock.getFirstChild() !== this) { + return false; + } + const paragraphs = []; + for (const line of codeBlock.getChildren()) { + if ($isCodeLineNode(line)) { + paragraphs.push($createParagraphNode().append(...line.getChildren())); + } + } + codeBlock + .getParentOrThrow() + .splice(codeBlock.getIndexWithinParent(), 1, paragraphs); + return true; + } + + canInsertTab(): true { + return true; + } + + static importJSON( + serializedCodeLineNode: SerializedCodeLineNode, + ): CodeLineNode { + return $createCodeLineNode(); + } + + // exportJSON(): SerializedLexicalNode { + // return { + // type: 'codeline', + // version: 1, + // }; + // } +} + +function convertCodeLineElement(node: Node): DOMConversionOutput { + return {node: $createCodeLineNode()}; +} + +export function $createCodeLineNode(): CodeLineNode { + return new CodeLineNode(); +} + +export function $isCodeLineNode( + node: LexicalNode | null | undefined, +): node is CodeLineNode { + return node instanceof CodeLineNode; +}