From 3b550970cba29c7f0e402c1e27477b370d59814e Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Wed, 8 Jun 2022 14:54:58 -0600 Subject: [PATCH 01/11] add code threshold for highlighting --- package-lock.json | 2 +- packages/lexical-code/LexicalCode.d.ts | 5 ++++- packages/lexical-code/src/index.ts | 17 +++++++++-------- .../src/plugins/CodeHighlightPlugin.ts | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6952c6b518..747f7d548d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28772,7 +28772,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/LexicalCode.d.ts b/packages/lexical-code/LexicalCode.d.ts index 0521ed65a22..5f13234ec12 100644 --- a/packages/lexical-code/LexicalCode.d.ts +++ b/packages/lexical-code/LexicalCode.d.ts @@ -78,7 +78,10 @@ declare function $isCodeHighlightNode( node: LexicalNode | null | undefined, ): node is CodeHighlightNode; -declare function registerCodeHighlighting(editor: LexicalEditor): () => void; +declare function registerCodeHighlighting( + editor: LexicalEditor, + threshold: number, +): () => void; type SerializedCodeNode = Spread< { diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index bf79b300d67..5b464ba4823 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -646,12 +646,12 @@ function isGitHubCodeTable(table: HTMLTableElement): table is HTMLTableElement { return table.classList.contains('js-file-line-container'); } -function textNodeTransform(node: TextNode, editor: LexicalEditor): void { +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); + 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 @@ -691,7 +691,7 @@ function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { // 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) { +function codeNodeTransform(node: CodeNode, editor: LexicalEditor, threshold?: number) { if (isHighlighting) { return; } @@ -716,7 +716,8 @@ function codeNodeTransform(node: CodeNode, editor: LexicalEditor) { const highlightNodes = getHighlightNodes(tokens); const diffRange = getDiffRange(node.getChildren(), highlightNodes); const {from, to, nodesForReplacement} = diffRange; - if (from !== to || nodesForReplacement.length) { + console.log("Length: ", code.length) + if ((from !== to || nodesForReplacement.length) && code.length <= threshold) { node.splice(from, to - from, nodesForReplacement); return true; } @@ -1097,7 +1098,7 @@ function handleMoveTo( event.stopPropagation(); } -export function registerCodeHighlighting(editor: LexicalEditor): () => void { +export function registerCodeHighlighting(editor: LexicalEditor, threshold: number): () => void { if (!editor.hasNodes([CodeNode, CodeHighlightNode])) { throw new Error( 'CodeHighlightPlugin: CodeNode or CodeHighlightNode not registered on editor', @@ -1118,13 +1119,13 @@ export function registerCodeHighlighting(editor: LexicalEditor): () => void { }); }), editor.registerNodeTransform(CodeNode, (node) => - codeNodeTransform(node, editor), + codeNodeTransform(node, editor, threshold), ), editor.registerNodeTransform(TextNode, (node) => - textNodeTransform(node, editor), + textNodeTransform(node, editor, threshold), ), editor.registerNodeTransform(CodeHighlightNode, (node) => - textNodeTransform(node, editor), + textNodeTransform(node, editor, threshold), ), editor.registerCommand( INDENT_CONTENT_COMMAND, diff --git a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts index 7813912ecb8..5bdb37e6c84 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, 50); }, [editor]); return null; From a1e96167a82f4a7c98e2869a85902dcffb90b6d9 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Wed, 8 Jun 2022 17:59:25 -0600 Subject: [PATCH 02/11] spluit highlighter code --- packages/lexical-code/src/CodeHighlighter.ts | 937 ++++++++++++++ packages/lexical-code/src/EditorShortcuts.ts | 710 +++++++++++ packages/lexical-code/src/index.ts | 1183 +----------------- 3 files changed, 1667 insertions(+), 1163 deletions(-) create mode 100644 packages/lexical-code/src/CodeHighlighter.ts create mode 100644 packages/lexical-code/src/EditorShortcuts.ts diff --git a/packages/lexical-code/src/CodeHighlighter.ts b/packages/lexical-code/src/CodeHighlighter.ts new file mode 100644 index 00000000000..d2f5c4c3c58 --- /dev/null +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -0,0 +1,937 @@ +/** + * 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, + EditorConfig, + EditorThemeClasses, + LexicalCommand, + LexicalEditor, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedElementNode, + SerializedTextNode, +} 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 {registerCodeIndent} from "./EditorShortcuts"; + +import { + addClassNamesToElement, + mergeRegister, + removeClassNamesFromElement, +} from '@lexical/utils'; + +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_START, + MOVE_TO_END, + OUTDENT_CONTENT_COMMAND, + TextNode, +} from 'lexical'; +import {Spread} from 'libdefs/globals'; + +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; +}; + +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.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', + }; + } + + // Prevent formatting (bold, underline, etc) + setFormat(format: number): this { + return this; + } +} + +export function $isCodeHighlightNode( + node: LexicalNode | CodeHighlightNode | null | undefined, +): node is CodeHighlightNode { + return node instanceof CodeHighlightNode; +} + +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; +} + +export function $createCodeHighlightNode( + text: string, + highlightType?: string, +): CodeHighlightNode { + return new CodeHighlightNode(text, highlightType); +} + +function getHighlightThemeClass( + theme: EditorThemeClasses, + highlightType: string | undefined, +): string | undefined { + return ( + highlightType && + theme && + theme.codeHighlight && + theme.codeHighlight[highlightType] + ); +} +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', + }; + } + + // 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; +} + +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); +} + +// 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; + }); +} + +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; +} + +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; +} + +// 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, + }; +} + +// 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, 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) && code.length <= threshold) { + node.splice(from, to - from, nodesForReplacement); + return true; + } + return false; + }); + }, + { + onUpdate: () => { + isHighlighting = false; + }, + skipTransforms: true, + }, + ); +} + +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 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 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 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, 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), + ), + registerCodeIndent(editor), + ); +} \ No newline at end of file diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts new file mode 100644 index 00000000000..7c08b3cc671 --- /dev/null +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -0,0 +1,710 @@ +/** + * 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. + * + */ + + +/** + * 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, + EditorThemeClasses, + LexicalCommand, + LexicalEditor, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedElementNode, + SerializedTextNode, +} 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 { CodeNode } from "./CodeHighlighter"; + +import { + addClassNamesToElement, + mergeRegister, + removeClassNamesFromElement, +} from '@lexical/utils'; + +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_START, + MOVE_TO_END, + OUTDENT_CONTENT_COMMAND, + TextNode, +} from 'lexical'; +import {Spread} from 'libdefs/globals'; + +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 +>; + +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.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', + }; + } + + // 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; +} + +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 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 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 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(); +} +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); +} + +export function registerCodeIndent(editor: LexicalEditor): () => void { + console.log("Start: ", "Started") + return( + 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/index.ts b/packages/lexical-code/src/index.ts index 5b464ba4823..8c2ea6a490d 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -1,1163 +1,20 @@ -/** - * 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, - EditorThemeClasses, - LexicalCommand, - LexicalEditor, - LexicalNode, - NodeKey, - ParagraphNode, - RangeSelection, - SerializedElementNode, - SerializedTextNode, -} 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, - mergeRegister, - removeClassNamesFromElement, -} from '@lexical/utils'; - -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_START, - MOVE_TO_END, - OUTDENT_CONTENT_COMMAND, - TextNode, -} from 'lexical'; -import {Spread} from 'libdefs/globals'; - -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; -}; - -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.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', - }; - } - - // 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 - 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', - }; - } - - // 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, 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 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, 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; - console.log("Length: ", code.length) - if ((from !== to || nodesForReplacement.length) && code.length <= threshold) { - 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, 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), - ), - 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, - ), - ); -} +import { $isCodeHighlightNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeNode, registerCodeHighlighting, $createCodeHighlightNode} from "./CodeHighlighter" + +import {getDefaultCodeLanguage, getCodeLanguages, $createCodeHighlightNode, $isCodeHighlightNode, getFirstCodeHighlightNodeOfLine, getLastCodeHighlightNodeOfLine, getStartOfCodeInLine, getEndOfCodeInLine, registerCodeIndent} from "./EditorShortcuts" + +export { + CodeHighlightNode, + CodeNode, + $createCodeNode, + $isCodeNode, + registerCodeHighlighting, + $createCodeHighlightNode, + getDefaultCodeLanguage, + getCodeLanguages, + $isCodeHighlightNode, + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, + getStartOfCodeInLine, + getEndOfCodeInLine, + registerCodeIndent +} \ No newline at end of file From 422b604f2dc9338c8554db351b9b1dbed87cd5bf Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Thu, 9 Jun 2022 12:32:05 -0600 Subject: [PATCH 03/11] split code highlighting plugin --- packages/lexical-code/src/CodeHighlighter.ts | 427 ++++-------------- packages/lexical-code/src/EditorShortcuts.ts | 262 +---------- .../lexical-code/src/HighlighterHelper.ts | 155 +++++++ packages/lexical-code/src/index.ts | 41 +- 4 files changed, 287 insertions(+), 598 deletions(-) create mode 100644 packages/lexical-code/src/HighlighterHelper.ts diff --git a/packages/lexical-code/src/CodeHighlighter.ts b/packages/lexical-code/src/CodeHighlighter.ts index d2f5c4c3c58..ef8f3ecf203 100644 --- a/packages/lexical-code/src/CodeHighlighter.ts +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -6,19 +6,17 @@ * */ +// eslint-disable-next-line simple-import-sort/imports import type { DOMConversionMap, DOMConversionOutput, EditorConfig, - EditorThemeClasses, - LexicalCommand, LexicalEditor, LexicalNode, NodeKey, ParagraphNode, RangeSelection, SerializedElementNode, - SerializedTextNode, } from 'lexical'; import * as Prism from 'prismjs'; @@ -35,14 +33,7 @@ import 'prismjs/components/prism-python'; import 'prismjs/components/prism-rust'; import 'prismjs/components/prism-swift'; -import {registerCodeIndent} from "./EditorShortcuts"; - -import { - addClassNamesToElement, - mergeRegister, - removeClassNamesFromElement, -} from '@lexical/utils'; - +import {addClassNamesToElement, mergeRegister} from '@lexical/utils'; import { $createLineBreakNode, $createParagraphNode, @@ -52,18 +43,18 @@ import { $isLineBreakNode, $isRangeSelection, $isTextNode, - COMMAND_PRIORITY_LOW, ElementNode, - INDENT_CONTENT_COMMAND, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_UP_COMMAND, - MOVE_TO_START, - MOVE_TO_END, - OUTDENT_CONTENT_COMMAND, TextNode, } from 'lexical'; import {Spread} from 'libdefs/globals'; +import {registerCodeIndent} from './EditorShortcuts'; +import { + $createCodeHighlightNode, + $isCodeHighlightNode, + CodeHighlightNode, +} from './HighlighterHelper'; + const DEFAULT_CODE_LANGUAGE = 'javascript'; type SerializedCodeNode = Spread< @@ -75,17 +66,6 @@ type SerializedCodeNode = Spread< SerializedElementNode >; -type SerializedCodeHighlightNode = Spread< - { - highlightType: string | null | undefined; - type: 'code-highlight'; - version: 1; - }, - SerializedTextNode ->; - - - const mapToPrismLanguage = ( language: string | null | undefined, ): string | null | undefined => { @@ -95,97 +75,6 @@ const mapToPrismLanguage = ( : undefined; }; -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.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', - }; - } - - // Prevent formatting (bold, underline, etc) - setFormat(format: number): this { - return this; - } -} - -export function $isCodeHighlightNode( - node: LexicalNode | CodeHighlightNode | null | undefined, -): node is CodeHighlightNode { - return node instanceof CodeHighlightNode; -} - export function getFirstCodeHighlightNodeOfLine( anchor: LexicalNode, ): CodeHighlightNode | null | undefined { @@ -224,24 +113,63 @@ export function getLastCodeHighlightNodeOfLine( return currentNode; } -export function $createCodeHighlightNode( - text: string, - highlightType?: string, -): CodeHighlightNode { - return new CodeHighlightNode(text, highlightType); +function convertPreElement(domNode: Node): DOMConversionOutput { + return {node: $createCodeNode()}; } -function getHighlightThemeClass( - theme: EditorThemeClasses, - highlightType: string | undefined, -): string | undefined { - return ( - highlightType && - theme && - theme.codeHighlight && - theme.codeHighlight[highlightType] - ); +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 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'); +} + const LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; export class CodeNode extends ElementNode { @@ -644,7 +572,11 @@ function getDiffRange( // 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, threshold?: number) { +function codeNodeTransform( + node: CodeNode, + editor: LexicalEditor, + threshold?: number, +) { if (isHighlighting) { return; } @@ -669,7 +601,10 @@ function codeNodeTransform(node: CodeNode, editor: LexicalEditor, threshold?: nu const highlightNodes = getHighlightNodes(tokens); const diffRange = getDiffRange(node.getChildren(), highlightNodes); const {from, to, nodesForReplacement} = diffRange; - if ((from !== to || nodesForReplacement.length) && code.length <= threshold) { + if ( + (from !== to || nodesForReplacement.length) && + code.length <= threshold + ) { node.splice(from, to - from, nodesForReplacement); return true; } @@ -685,7 +620,11 @@ function codeNodeTransform(node: CodeNode, editor: LexicalEditor, threshold?: nu ); } -function textNodeTransform(node: TextNode, editor: LexicalEditor, threshold?: number): void { +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(); @@ -698,212 +637,10 @@ function textNodeTransform(node: TextNode, editor: LexicalEditor, threshold?: nu } } -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 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 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, threshold: number): () => void { +export function registerCodeHighlighting( + editor: LexicalEditor, + threshold: number, +): () => void { if (!editor.hasNodes([CodeNode, CodeHighlightNode])) { throw new Error( 'CodeHighlightPlugin: CodeNode or CodeHighlightNode not registered on editor', @@ -934,4 +671,4 @@ export function registerCodeHighlighting(editor: LexicalEditor, threshold: numbe ), registerCodeIndent(editor), ); -} \ No newline at end of file +} diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts index 7c08b3cc671..fdbc858b032 100644 --- a/packages/lexical-code/src/EditorShortcuts.ts +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -6,64 +6,19 @@ * */ - -/** - * 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, - EditorThemeClasses, - LexicalCommand, - LexicalEditor, - LexicalNode, - NodeKey, - ParagraphNode, - RangeSelection, - SerializedElementNode, - SerializedTextNode, -} from 'lexical'; +import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; -import * as Prism from 'prismjs'; +import {CodeNode} from './CodeHighlighter'; -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 { CodeNode } from "./CodeHighlighter"; - -import { - addClassNamesToElement, - mergeRegister, - removeClassNamesFromElement, -} from '@lexical/utils'; +import {mergeRegister} from '@lexical/utils'; import { - $createLineBreakNode, - $createParagraphNode, - $createTextNode, $getNodeByKey, $getSelection, $isLineBreakNode, $isRangeSelection, - $isTextNode, COMMAND_PRIORITY_LOW, - ElementNode, INDENT_CONTENT_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, @@ -72,28 +27,14 @@ import { OUTDENT_CONTENT_COMMAND, TextNode, } from 'lexical'; -import {Spread} from 'libdefs/globals'; +import { + CodeHighlightNode, + $createCodeHighlightNode, + $isCodeHighlightNode, +} from './HighlighterHelper'; 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 ->; - export const getDefaultCodeLanguage = (): string => DEFAULT_CODE_LANGUAGE; export const getCodeLanguages = (): Array => @@ -105,116 +46,6 @@ export const getCodeLanguages = (): Array => ) .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.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', - }; - } - - // 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; -} - export function getFirstCodeHighlightNodeOfLine( anchor: LexicalNode, ): CodeHighlightNode | null | undefined { @@ -381,63 +212,6 @@ export function getEndOfCodeInLine(anchor: LexicalNode): { }; } -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 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 doIndent(node: CodeHighlightNode, type: LexicalCommand) { const text = node.getTextContent(); if (type === INDENT_CONTENT_COMMAND) { @@ -667,8 +441,19 @@ function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { } export function registerCodeIndent(editor: LexicalEditor): () => void { - console.log("Start: ", "Started") - return( + 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), @@ -703,8 +488,3 @@ export function registerCodeIndent(editor: LexicalEditor): () => void { ), ); } - - - - - diff --git a/packages/lexical-code/src/HighlighterHelper.ts b/packages/lexical-code/src/HighlighterHelper.ts new file mode 100644 index 00000000000..c547e3fda78 --- /dev/null +++ b/packages/lexical-code/src/HighlighterHelper.ts @@ -0,0 +1,155 @@ +/** + * 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, +} 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 { + addClassNamesToElement, + removeClassNamesFromElement, +} from '@lexical/utils'; + +import {TextNode} from 'lexical'; +import {Spread} from 'libdefs/globals'; + +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.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', + }; + } + + // 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/index.ts b/packages/lexical-code/src/index.ts index 8c2ea6a490d..2c152085f5b 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -1,20 +1,37 @@ -import { $isCodeHighlightNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeNode, registerCodeHighlighting, $createCodeHighlightNode} from "./CodeHighlighter" - -import {getDefaultCodeLanguage, getCodeLanguages, $createCodeHighlightNode, $isCodeHighlightNode, getFirstCodeHighlightNodeOfLine, getLastCodeHighlightNodeOfLine, getStartOfCodeInLine, getEndOfCodeInLine, registerCodeIndent} from "./EditorShortcuts" - -export { - CodeHighlightNode, - CodeNode, +import { $createCodeNode, $isCodeNode, + CodeNode, registerCodeHighlighting, - $createCodeHighlightNode, - getDefaultCodeLanguage, +} from './CodeHighlighter'; +import { getCodeLanguages, - $isCodeHighlightNode, + getDefaultCodeLanguage, + getEndOfCodeInLine, getFirstCodeHighlightNodeOfLine, getLastCodeHighlightNodeOfLine, getStartOfCodeInLine, + registerCodeIndent, +} from './EditorShortcuts'; +import { + $createCodeHighlightNode, + $isCodeHighlightNode, + CodeHighlightNode, +} from './HighlighterHelper'; + +export { + $createCodeHighlightNode, + $createCodeNode, + $isCodeHighlightNode, + $isCodeNode, + CodeHighlightNode, + CodeNode, + getCodeLanguages, + getDefaultCodeLanguage, getEndOfCodeInLine, - registerCodeIndent -} \ No newline at end of file + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, + getStartOfCodeInLine, + registerCodeHighlighting, + registerCodeIndent, +}; From bc1163d3776f16a034f8db3dfe90664c3702fb47 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Fri, 10 Jun 2022 10:02:09 -0600 Subject: [PATCH 04/11] update to highlighter code split --- packages/lexical-code/LexicalCode.d.ts | 11 + .../lexical-code/src/CodeHighlightNode.ts | 141 +++++++ packages/lexical-code/src/CodeHighlighter.ts | 371 +----------------- packages/lexical-code/src/CodeNode.ts | 327 +++++++++++++++ packages/lexical-code/src/EditorShortcuts.ts | 329 +++++++--------- .../lexical-code/src/HighlighterHelper.ts | 172 ++------ packages/lexical-code/src/index.ts | 26 +- 7 files changed, 678 insertions(+), 699 deletions(-) create mode 100644 packages/lexical-code/src/CodeHighlightNode.ts create mode 100644 packages/lexical-code/src/CodeNode.ts diff --git a/packages/lexical-code/LexicalCode.d.ts b/packages/lexical-code/LexicalCode.d.ts index 5f13234ec12..b97a131d89c 100644 --- a/packages/lexical-code/LexicalCode.d.ts +++ b/packages/lexical-code/LexicalCode.d.ts @@ -82,6 +82,17 @@ declare function registerCodeHighlighting( editor: LexicalEditor, threshold: number, ): () => void; +declare function registerCodeIndent(editor: LexicalEditor): () => void; +declare function getStartOfCodeInLine( + editor: LexicalEditor, + node: TextNode | null, + offset: number, +): () => void; +declare function getEndOfCodeInLine( + editor: LexicalEditor, + node: TextNode | null, + offset: number, +): () => void; type SerializedCodeNode = Spread< { diff --git a/packages/lexical-code/src/CodeHighlightNode.ts b/packages/lexical-code/src/CodeHighlightNode.ts new file mode 100644 index 00000000000..a65b235cc3e --- /dev/null +++ b/packages/lexical-code/src/CodeHighlightNode.ts @@ -0,0 +1,141 @@ +/** + * 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 { + addClassNamesToElement, + removeClassNamesFromElement, +} from '@lexical/utils'; +import { + EditorConfig, + EditorThemeClasses, + LexicalNode, + NodeKey, + SerializedTextNode, + TextNode, +} from 'lexical'; +import {Spread} from 'libdefs/globals'; + +type SerializedCodeHighlightNode = Spread< + { + highlightType: string | null | undefined; + type: 'code-highlight'; + version: 1; + }, + SerializedTextNode +>; + +function getHighlightThemeClass( + theme: EditorThemeClasses, + highlightType: string | undefined, +): string | undefined { + return ( + highlightType && + theme && + theme.codeHighlight && + theme.codeHighlight[highlightType] + ); +} +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.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', + }; + } + + // Prevent formatting (bold, underline, etc) + setFormat(format: number): this { + return this; + } +} + +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 index ef8f3ecf203..74553e6cd9e 100644 --- a/packages/lexical-code/src/CodeHighlighter.ts +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -7,18 +7,28 @@ */ // eslint-disable-next-line simple-import-sort/imports -import type { - DOMConversionMap, - DOMConversionOutput, - EditorConfig, +import { + $createLineBreakNode, + $createTextNode, + $getNodeByKey, + $getSelection, + $isLineBreakNode, + $isRangeSelection, + $isTextNode, + TextNode, LexicalEditor, LexicalNode, - NodeKey, - ParagraphNode, - RangeSelection, - SerializedElementNode, } from 'lexical'; +import { + CodeNode, + $isCodeNode, + registerCodeIndent, + $createCodeHighlightNode, + $isCodeHighlightNode, + CodeHighlightNode, +} from '@lexical/code'; + import * as Prism from 'prismjs'; import 'prismjs/components/prism-clike'; @@ -33,353 +43,10 @@ import 'prismjs/components/prism-python'; import 'prismjs/components/prism-rust'; import 'prismjs/components/prism-swift'; -import {addClassNamesToElement, mergeRegister} from '@lexical/utils'; -import { - $createLineBreakNode, - $createParagraphNode, - $createTextNode, - $getNodeByKey, - $getSelection, - $isLineBreakNode, - $isRangeSelection, - $isTextNode, - ElementNode, - TextNode, -} from 'lexical'; -import {Spread} from 'libdefs/globals'; - -import {registerCodeIndent} from './EditorShortcuts'; -import { - $createCodeHighlightNode, - $isCodeHighlightNode, - CodeHighlightNode, -} from './HighlighterHelper'; +import {mergeRegister} from '@lexical/utils'; const DEFAULT_CODE_LANGUAGE = 'javascript'; -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; -}; - -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 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 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'); -} - -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', - }; - } - - // 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; -} - function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { const codeElement = editor.getElementByKey(node.getKey()); if (codeElement === null) { diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts new file mode 100644 index 00000000000..06868a39da4 --- /dev/null +++ b/packages/lexical-code/src/CodeNode.ts @@ -0,0 +1,327 @@ +/** + * 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 '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 { + $createCodeHighlightNode, + CodeHighlightNode, + getFirstCodeHighlightNodeOfLine, +} from '@lexical/code'; +import {addClassNamesToElement} from '@lexical/utils'; +import { + $createLineBreakNode, + $createParagraphNode, + $getSelection, + $isRangeSelection, + DOMConversionMap, + DOMConversionOutput, + EditorConfig, + ElementNode, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedElementNode, +} from 'lexical'; +import {Spread} from 'libdefs/globals'; +import * as Prism from 'prismjs'; + +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($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'); +} + +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', + }; + } + + // 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; +} diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts index fdbc858b032..89655fc3554 100644 --- a/packages/lexical-code/src/EditorShortcuts.ts +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -7,12 +7,16 @@ */ // eslint-disable-next-line simple-import-sort/imports -import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; - -import {CodeNode} from './CodeHighlighter'; +import { + $createCodeHighlightNode, + $isCodeHighlightNode, + CodeHighlightNode, + CodeNode, + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, +} from '@lexical/code'; import {mergeRegister} from '@lexical/utils'; - import { $getNodeByKey, $getSelection, @@ -22,195 +26,14 @@ import { INDENT_CONTENT_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, - MOVE_TO_START, + LexicalCommand, + LexicalEditor, + LexicalNode, MOVE_TO_END, + MOVE_TO_START, OUTDENT_CONTENT_COMMAND, TextNode, } from 'lexical'; -import { - CodeHighlightNode, - $createCodeHighlightNode, - $isCodeHighlightNode, -} from './HighlighterHelper'; - -const DEFAULT_CODE_LANGUAGE = 'javascript'; - -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 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 doIndent(node: CodeHighlightNode, type: LexicalCommand) { const text = node.getTextContent(); @@ -382,6 +205,134 @@ function handleShiftLines( 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 ($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, + }; +} + +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 handleMoveTo( type: LexicalCommand, event: KeyboardEvent, diff --git a/packages/lexical-code/src/HighlighterHelper.ts b/packages/lexical-code/src/HighlighterHelper.ts index c547e3fda78..5bdb9891f6e 100644 --- a/packages/lexical-code/src/HighlighterHelper.ts +++ b/packages/lexical-code/src/HighlighterHelper.ts @@ -7,149 +7,43 @@ */ // eslint-disable-next-line simple-import-sort/imports -import type { - EditorConfig, - EditorThemeClasses, - LexicalNode, - NodeKey, - SerializedTextNode, -} 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 { - addClassNamesToElement, - removeClassNamesFromElement, -} from '@lexical/utils'; - -import {TextNode} from 'lexical'; -import {Spread} from 'libdefs/globals'; - -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); - } +import {$isLineBreakNode, LexicalNode} from 'lexical'; +import {CodeHighlightNode, $isCodeHighlightNode} 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 ($isLineBreakNode(node)) { + break; } - return update; - } - - static importJSON( - serializedNode: SerializedCodeHighlightNode, - ): CodeHighlightNode { - const node = $createCodeHighlightNode(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', - }; - } - - // 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] - ); + return currentNode; } -export function $createCodeHighlightNode( - text: string, - highlightType?: string, -): CodeHighlightNode { - return new CodeHighlightNode(text, highlightType); -} +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; + } + } -export function $isCodeHighlightNode( - node: LexicalNode | CodeHighlightNode | null | undefined, -): node is CodeHighlightNode { - return node instanceof CodeHighlightNode; + return currentNode; } diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index 2c152085f5b..25d7eae7d31 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -1,22 +1,14 @@ -import { - $createCodeNode, - $isCodeNode, - CodeNode, - registerCodeHighlighting, -} from './CodeHighlighter'; -import { - getCodeLanguages, - getDefaultCodeLanguage, - getEndOfCodeInLine, - getFirstCodeHighlightNodeOfLine, - getLastCodeHighlightNodeOfLine, - getStartOfCodeInLine, - registerCodeIndent, -} from './EditorShortcuts'; +import {registerCodeHighlighting} from './CodeHighlighter'; import { $createCodeHighlightNode, $isCodeHighlightNode, CodeHighlightNode, +} from './CodeHighlightNode'; +import {$createCodeNode, $isCodeNode, CodeNode} from './CodeNode'; +import {registerCodeIndent} from './EditorShortcuts'; +import { + getFirstCodeHighlightNodeOfLine, + getLastCodeHighlightNodeOfLine, } from './HighlighterHelper'; export { @@ -26,12 +18,8 @@ export { $isCodeNode, CodeHighlightNode, CodeNode, - getCodeLanguages, - getDefaultCodeLanguage, - getEndOfCodeInLine, getFirstCodeHighlightNodeOfLine, getLastCodeHighlightNodeOfLine, - getStartOfCodeInLine, registerCodeHighlighting, registerCodeIndent, }; From d4749b5b80f473c89c8ff0f2c4f816b108718e34 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Fri, 10 Jun 2022 10:07:09 -0600 Subject: [PATCH 05/11] remove unnecessary export --- packages/lexical-code/LexicalCode.d.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/lexical-code/LexicalCode.d.ts b/packages/lexical-code/LexicalCode.d.ts index b97a131d89c..17809cba2f2 100644 --- a/packages/lexical-code/LexicalCode.d.ts +++ b/packages/lexical-code/LexicalCode.d.ts @@ -83,16 +83,6 @@ declare function registerCodeHighlighting( threshold: number, ): () => void; declare function registerCodeIndent(editor: LexicalEditor): () => void; -declare function getStartOfCodeInLine( - editor: LexicalEditor, - node: TextNode | null, - offset: number, -): () => void; -declare function getEndOfCodeInLine( - editor: LexicalEditor, - node: TextNode | null, - offset: number, -): () => void; type SerializedCodeNode = Spread< { From b34d163c7794025e3fffc0fecc47b820bd5ef209 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Fri, 17 Jun 2022 14:10:02 -0600 Subject: [PATCH 06/11] add code highlighter option and split highlighting code from editor options --- package-lock.json | 2 +- .../lexical-code/src/CodeHighlightNode.ts | 145 ++ packages/lexical-code/src/CodeHighlighter.ts | 323 +++++ packages/lexical-code/src/CodeNode.ts | 330 +++++ packages/lexical-code/src/EditorShortcuts.ts | 431 ++++++ .../lexical-code/src/HighlighterHelper.ts | 73 + packages/lexical-code/src/index.ts | 1180 +---------------- .../src/plugins/CodeHighlightPlugin.ts | 2 +- 8 files changed, 1328 insertions(+), 1158 deletions(-) create mode 100644 packages/lexical-code/src/CodeHighlightNode.ts create mode 100644 packages/lexical-code/src/CodeHighlighter.ts create mode 100644 packages/lexical-code/src/CodeNode.ts create mode 100644 packages/lexical-code/src/EditorShortcuts.ts create mode 100644 packages/lexical-code/src/HighlighterHelper.ts 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..89a1bfd698a --- /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 { + $createLineBreakNode, + LexicalEditor, + LexicalNode, + $createTextNode, + $getNodeByKey, + $getSelection, + $isLineBreakNode, + $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' && + $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; + }); +} + +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; +} + +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($createLineBreakNode()); + } + } + } + + 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 ($isLineBreakNode(nodeA) && $isLineBreakNode(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..5fcdefa8325 --- /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 { + $createLineBreakNode, + $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($createLineBreakNode()); + } + 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 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 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([$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; +} diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts new file mode 100644 index 00000000000..03dc7e87b06 --- /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, + $isLineBreakNode, + $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) && !$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 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 ($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, + }; +} + +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 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..63c40c0a49c --- /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 {$isLineBreakNode, 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 ($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; +} + +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 ($isLineBreakNode(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..a08a6996b02 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, 30); }, [editor]); return null; From 97c65abd1375c5f005fe8ec6231af1cc43ee8d79 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Fri, 17 Jun 2022 14:20:57 -0600 Subject: [PATCH 07/11] set threshold to 10000 --- packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts index a08a6996b02..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, 30); + return registerCodeHighlighting(editor, 10000); }, [editor]); return null; From 19d1b3cb505ad6a4128718fc18b44315682fc522 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Mon, 27 Jun 2022 20:57:30 -0600 Subject: [PATCH 08/11] #2118 - Introduce that will wrap each line of code. --- packages/lexical-code/src/CodeHighlighter.ts | 14 +- packages/lexical-code/src/CodeNode.ts | 8 +- packages/lexical-code/src/EditorShortcuts.ts | 18 +- .../lexical-code/src/HighlighterHelper.ts | 8 +- .../src/plugins/CodeHighlightPlugin.ts | 2 +- packages/lexical/flow/Lexical.js.flow | 29 ++- packages/lexical/src/LexicalEditor.ts | 4 +- packages/lexical/src/LexicalReconciler.ts | 4 +- packages/lexical/src/LexicalSelection.ts | 10 +- packages/lexical/src/LexicalUtils.ts | 4 +- packages/lexical/src/index.ts | 5 + .../lexical/src/nodes/LexicalCodeLineNode.ts | 171 ++++++++++++++++++ 12 files changed, 241 insertions(+), 36 deletions(-) create mode 100644 packages/lexical/src/nodes/LexicalCodeLineNode.ts diff --git a/packages/lexical-code/src/CodeHighlighter.ts b/packages/lexical-code/src/CodeHighlighter.ts index 89a1bfd698a..c9dda7d0762 100644 --- a/packages/lexical-code/src/CodeHighlighter.ts +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -8,13 +8,13 @@ // eslint-disable-next-line simple-import-sort/imports import { - $createLineBreakNode, + $createCodeLineNode, + $isCodeLineNode, LexicalEditor, LexicalNode, $createTextNode, $getNodeByKey, $getSelection, - $isLineBreakNode, $isRangeSelection, $isTextNode, TextNode, @@ -58,7 +58,7 @@ function updateAndRetainSelection( const anchorOffset = anchor.offset; const isNewLineAnchor = anchor.type === 'element' && - $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); + $isCodeLineNode(node.getChildAtIndex(anchor.offset - 1)); let textOffset = 0; // Calculating previous text offset (all text node prior to anchor + anchor own text offset) @@ -68,7 +68,7 @@ function updateAndRetainSelection( anchorOffset + anchorNode.getPreviousSiblings().reduce((offset, _node) => { return ( - offset + ($isLineBreakNode(_node) ? 0 : _node.getTextContentSize()) + offset + ($isCodeLineNode(_node) ? 0 : _node.getTextContentSize()) ); }, 0); } @@ -114,7 +114,7 @@ function getHighlightNodes( nodes.push($createCodeHighlightNode(text)); } if (i < partials.length - 1) { - nodes.push($createLineBreakNode()); + nodes.push($createCodeLineNode()); } } } else { @@ -175,7 +175,7 @@ function codeNodeTransform( for (let i = 0; i < codeContent.length; i++) { node.append($createTextNode(codeContent[i])); if (i !== codeContent.length - 1) { - node.append($createLineBreakNode()); + node.append($createCodeLineNode()); } } } @@ -230,7 +230,7 @@ function isEqual(nodeA: LexicalNode, nodeB: LexicalNode): boolean { ); } - if ($isLineBreakNode(nodeA) && $isLineBreakNode(nodeB)) { + if ($isCodeLineNode(nodeA) && $isCodeLineNode(nodeB)) { return true; } diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts index 5fcdefa8325..baf0c576be7 100644 --- a/packages/lexical-code/src/CodeNode.ts +++ b/packages/lexical-code/src/CodeNode.ts @@ -35,7 +35,7 @@ import 'prismjs/components/prism-swift'; import {addClassNamesToElement} from '@lexical/utils'; import { - $createLineBreakNode, + $createCodeLineNode, $createParagraphNode, $getSelection, $isRangeSelection, @@ -73,7 +73,7 @@ function convertDivElement(domNode: Node): DOMConversionOutput { after: (childLexicalNodes) => { const domParent = domNode.parentNode; if (domParent != null && domNode !== domParent.lastChild) { - childLexicalNodes.push($createLineBreakNode()); + childLexicalNodes.push($createCodeLineNode()); } return childLexicalNodes; }, @@ -93,7 +93,7 @@ function convertTableCellElement(domNode: Node): DOMConversionOutput { after: (childLexicalNodes) => { if (cell.parentNode && cell.parentNode.nextSibling) { // Append newline between code lines - childLexicalNodes.push($createLineBreakNode()); + childLexicalNodes.push($createCodeLineNode()); } return childLexicalNodes; }, @@ -280,7 +280,7 @@ export class CodeNode extends ElementNode { const whitespace = firstNodeText.substring(0, leadingWhitespace); const indentedChild = $createCodeHighlightNode(whitespace); anchor.insertAfter(indentedChild); - selection.insertNodes([$createLineBreakNode()]); + selection.insertNodes([$createCodeLineNode()]); indentedChild.select(); return indentedChild; } diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts index 03dc7e87b06..d8b63077fdb 100644 --- a/packages/lexical-code/src/EditorShortcuts.ts +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -25,7 +25,7 @@ import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, $getSelection, - $isLineBreakNode, + $isCodeLineNode, $isRangeSelection, COMMAND_PRIORITY_LOW, INDENT_CONTENT_COMMAND, @@ -59,7 +59,7 @@ function handleMultilineIndent(type: LexicalCommand): boolean { const nodes = selection.getNodes(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; - if (!$isCodeHighlightNode(node) && !$isLineBreakNode(node)) { + if (!$isCodeHighlightNode(node) && !$isCodeLineNode(node)) { return false; } } @@ -71,7 +71,7 @@ function handleMultilineIndent(type: LexicalCommand): boolean { for (let i = 1; i < nodes.length; i++) { const node = nodes[i]; - if ($isLineBreakNode(nodes[i - 1]) && $isCodeHighlightNode(node)) { + if ($isCodeLineNode(nodes[i - 1]) && $isCodeHighlightNode(node)) { doIndent(node, type); } } @@ -170,7 +170,7 @@ function handleShiftLines( const range = start.getNodesBetween(end); for (let i = 0; i < range.length; i++) { const node = range[i]; - if (!$isCodeHighlightNode(node) && !$isLineBreakNode(node)) { + if (!$isCodeHighlightNode(node) && !$isCodeLineNode(node)) { return false; } } @@ -184,7 +184,7 @@ function handleShiftLines( const linebreak = arrowIsUp ? start.getPreviousSibling() : end.getNextSibling(); - if (!$isLineBreakNode(linebreak)) { + if (!$isCodeLineNode(linebreak)) { return true; } const sibling = arrowIsUp @@ -268,7 +268,7 @@ function getStartOfCodeInLine(anchor: LexicalNode): { currentNodeOffset = offset; } } - if ($isLineBreakNode(node)) { + if ($isCodeLineNode(node)) { break; } } @@ -286,7 +286,7 @@ function getStartOfCodeInLine(anchor: LexicalNode): { break; } } - if ($isLineBreakNode(node)) { + if ($isCodeLineNode(node)) { break; } } @@ -316,7 +316,7 @@ function getEndOfCodeInLine(anchor: LexicalNode): { currentNodeOffset = offset + 1; } } - if ($isLineBreakNode(node)) { + if ($isCodeLineNode(node)) { break; } } @@ -334,7 +334,7 @@ function getEndOfCodeInLine(anchor: LexicalNode): { break; } } - if ($isLineBreakNode(node)) { + if ($isCodeLineNode(node)) { break; } } diff --git a/packages/lexical-code/src/HighlighterHelper.ts b/packages/lexical-code/src/HighlighterHelper.ts index 63c40c0a49c..9905b1b2b12 100644 --- a/packages/lexical-code/src/HighlighterHelper.ts +++ b/packages/lexical-code/src/HighlighterHelper.ts @@ -7,7 +7,7 @@ */ // eslint-disable-next-line simple-import-sort/imports -import {$isLineBreakNode, LexicalNode, LexicalEditor} from 'lexical'; +import {$isCodeLineNode, LexicalNode, LexicalEditor} from 'lexical'; import {CodeHighlightNode, $isCodeHighlightNode, CodeNode} from '@lexical/code'; export function getFirstCodeHighlightNodeOfLine( @@ -21,7 +21,7 @@ export function getFirstCodeHighlightNodeOfLine( if ($isCodeHighlightNode(node)) { currentNode = node; } - if ($isLineBreakNode(node)) { + if ($isCodeLineNode(node)) { break; } } @@ -40,7 +40,7 @@ export function getLastCodeHighlightNodeOfLine( if ($isCodeHighlightNode(node)) { currentNode = node; } - if ($isLineBreakNode(node)) { + if ($isCodeLineNode(node)) { break; } } @@ -65,7 +65,7 @@ export function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { let gutter = '1'; let count = 1; for (let i = 0; i < childrenLength; i++) { - if ($isLineBreakNode(children[i])) { + if ($isCodeLineNode(children[i])) { gutter += '\n' + ++count; } } diff --git a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts index 5cd8fd8442f..a08a6996b02 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, 10000); + return registerCodeHighlighting(editor, 30); }, [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; +} From 0ad07392ff63a6f0d597fb2eb7424945c3831955 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Mon, 27 Jun 2022 21:13:31 -0600 Subject: [PATCH 09/11] update highlight threshold --- packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts index a08a6996b02..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, 30); + return registerCodeHighlighting(editor, 10000); }, [editor]); return null; From 79df980e8a20668acee918183a26183bacd6e74e Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Tue, 5 Jul 2022 12:26:09 -0600 Subject: [PATCH 10/11] move codeline node --- packages/lexical-code/src/CodeHighlighter.ts | 36 +++++++-- .../src/CodeLineNode.ts} | 78 ++++++------------- packages/lexical-code/src/CodeNode.ts | 2 +- packages/lexical-code/src/EditorShortcuts.ts | 2 +- .../lexical-code/src/HighlighterHelper.ts | 3 +- packages/lexical-code/src/index.ts | 8 ++ .../src/nodes/PlaygroundNodes.ts | 3 +- packages/lexical/flow/Lexical.js.flow | 29 +------ packages/lexical/src/LexicalEditor.ts | 3 +- packages/lexical/src/LexicalReconciler.ts | 4 +- packages/lexical/src/LexicalSelection.ts | 10 +-- packages/lexical/src/LexicalUtils.ts | 4 +- packages/lexical/src/index.ts | 5 -- 13 files changed, 78 insertions(+), 109 deletions(-) rename packages/{lexical/src/nodes/LexicalCodeLineNode.ts => lexical-code/src/CodeLineNode.ts} (66%) diff --git a/packages/lexical-code/src/CodeHighlighter.ts b/packages/lexical-code/src/CodeHighlighter.ts index c9dda7d0762..734918cebf1 100644 --- a/packages/lexical-code/src/CodeHighlighter.ts +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -8,8 +8,6 @@ // eslint-disable-next-line simple-import-sort/imports import { - $createCodeLineNode, - $isCodeLineNode, LexicalEditor, LexicalNode, $createTextNode, @@ -18,6 +16,8 @@ import { $isRangeSelection, $isTextNode, TextNode, + $isLineBreakNode, + $createLineBreakNode, } from 'lexical'; import * as Prism from 'prismjs'; @@ -42,6 +42,11 @@ import { } from './CodeHighlightNode'; import {CodeNode, $isCodeNode} from './CodeNode'; import {updateCodeGutter} from './HighlighterHelper'; +import { + $createCodeLineNode, + $isCodeLineNode, + CodeLineNode, +} from './CodeLineNode'; const DEFAULT_CODE_LANGUAGE = 'javascript'; @@ -58,7 +63,7 @@ function updateAndRetainSelection( const anchorOffset = anchor.offset; const isNewLineAnchor = anchor.type === 'element' && - $isCodeLineNode(node.getChildAtIndex(anchor.offset - 1)); + $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); let textOffset = 0; // Calculating previous text offset (all text node prior to anchor + anchor own text offset) @@ -68,7 +73,7 @@ function updateAndRetainSelection( anchorOffset + anchorNode.getPreviousSiblings().reduce((offset, _node) => { return ( - offset + ($isCodeLineNode(_node) ? 0 : _node.getTextContentSize()) + offset + ($isLineBreakNode(_node) ? 0 : _node.getTextContentSize()) ); }, 0); } @@ -114,7 +119,7 @@ function getHighlightNodes( nodes.push($createCodeHighlightNode(text)); } if (i < partials.length - 1) { - nodes.push($createCodeLineNode()); + nodes.push($createLineBreakNode()); } } } else { @@ -154,6 +159,7 @@ function codeNodeTransform( // 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, () => { @@ -220,6 +226,23 @@ function textNodeTransform( } } +function codeLineNodeTransform( + node: CodeLineNode, + 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 @@ -313,6 +336,9 @@ export function registerCodeHighlighting( editor.registerNodeTransform(CodeNode, (node) => codeNodeTransform(node, editor, threshold), ), + editor.registerNodeTransform(CodeLineNode, (node) => + codeLineNodeTransform(node, editor, threshold), + ), editor.registerNodeTransform(TextNode, (node) => textNodeTransform(node, editor, threshold), ), diff --git a/packages/lexical/src/nodes/LexicalCodeLineNode.ts b/packages/lexical-code/src/CodeLineNode.ts similarity index 66% rename from packages/lexical/src/nodes/LexicalCodeLineNode.ts rename to packages/lexical-code/src/CodeLineNode.ts index 1457d468005..16d1f15d059 100644 --- a/packages/lexical/src/nodes/LexicalCodeLineNode.ts +++ b/packages/lexical-code/src/CodeLineNode.ts @@ -5,38 +5,32 @@ * LICENSE file in the root directory of this source tree. * */ -import type { - DOMConversionMap, - DOMConversionOutput, - NodeKey, - SerializedLexicalNode, -} from '../LexicalNode'; + import type { EditorConfig, + LexicalNode, + NodeKey, ParagraphNode, RangeSelection, + SerializedElementNode, Spread, } from 'lexical'; import {addClassNamesToElement} from '@lexical/utils'; -import {$createParagraphNode} from 'lexical'; +import {$createParagraphNode, ElementNode} from 'lexical'; -import {$createCodeHighlightNode} from '../../../lexical-code/src/CodeHighlightNode'; -import {getFirstCodeHighlightNodeOfLine} from '../../../lexical-code/src/HighlighterHelper'; -import {LexicalNode} from '../LexicalNode'; +import {$createCodeHighlightNode} from './CodeHighlightNode'; +import {getFirstCodeHighlightNodeOfLine} from './HighlighterHelper'; -export type SerializedCodeLineNode = Spread< +type SerializedCodeLineNode = Spread< { - type: 'codeline'; + type: 'code-line'; + version: 1; }, - SerializedLexicalNode + SerializedElementNode >; -export class CodeLineNode extends LexicalNode { - static getType(): string { - return 'codeline'; - } - +export class CodeLineNode extends ElementNode { static clone(node: CodeLineNode): CodeLineNode { return new CodeLineNode(node.__key); } @@ -45,8 +39,8 @@ export class CodeLineNode extends LexicalNode { super(key); } - getTextContent(): '\n' { - return '\n'; + static getType(): string { + return 'code-line'; } createDOM(config: EditorConfig): HTMLElement { @@ -58,25 +52,6 @@ export class CodeLineNode extends LexicalNode { 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, @@ -142,26 +117,17 @@ export class CodeLineNode extends LexicalNode { return true; } - static importJSON( - serializedCodeLineNode: SerializedCodeLineNode, - ): CodeLineNode { - return $createCodeLineNode(); + exportJSON(): SerializedCodeLineNode { + return { + ...super.exportJSON(), + type: 'code-line', + version: 1, + }; } - - // exportJSON(): SerializedLexicalNode { - // return { - // type: 'codeline', - // version: 1, - // }; - // } -} - -function convertCodeLineElement(node: Node): DOMConversionOutput { - return {node: $createCodeLineNode()}; } -export function $createCodeLineNode(): CodeLineNode { - return new CodeLineNode(); +export function $createCodeLineNode(language?: string): CodeLineNode { + return new CodeLineNode(language); } export function $isCodeLineNode( diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts index baf0c576be7..74b1973a103 100644 --- a/packages/lexical-code/src/CodeNode.ts +++ b/packages/lexical-code/src/CodeNode.ts @@ -35,7 +35,6 @@ import 'prismjs/components/prism-swift'; import {addClassNamesToElement} from '@lexical/utils'; import { - $createCodeLineNode, $createParagraphNode, $getSelection, $isRangeSelection, @@ -43,6 +42,7 @@ import { } from 'lexical'; import {CodeHighlightNode, $createCodeHighlightNode} from './CodeHighlightNode'; import {getFirstCodeHighlightNodeOfLine} from './HighlighterHelper'; +import {$createCodeLineNode} from './CodeLineNode'; type SerializedCodeNode = Spread< { diff --git a/packages/lexical-code/src/EditorShortcuts.ts b/packages/lexical-code/src/EditorShortcuts.ts index d8b63077fdb..712416c97b6 100644 --- a/packages/lexical-code/src/EditorShortcuts.ts +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -25,7 +25,6 @@ import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, $getSelection, - $isCodeLineNode, $isRangeSelection, COMMAND_PRIORITY_LOW, INDENT_CONTENT_COMMAND, @@ -47,6 +46,7 @@ import { getLastCodeHighlightNodeOfLine, updateCodeGutter, } from './HighlighterHelper'; +import {$isCodeLineNode} from './CodeLineNode'; function handleMultilineIndent(type: LexicalCommand): boolean { const selection = $getSelection(); diff --git a/packages/lexical-code/src/HighlighterHelper.ts b/packages/lexical-code/src/HighlighterHelper.ts index 9905b1b2b12..d508a1a1240 100644 --- a/packages/lexical-code/src/HighlighterHelper.ts +++ b/packages/lexical-code/src/HighlighterHelper.ts @@ -7,8 +7,9 @@ */ // eslint-disable-next-line simple-import-sort/imports -import {$isCodeLineNode, LexicalNode, LexicalEditor} from 'lexical'; +import {LexicalNode, LexicalEditor} from 'lexical'; import {CodeHighlightNode, $isCodeHighlightNode, CodeNode} from '@lexical/code'; +import {$isCodeLineNode} from './CodeLineNode'; export function getFirstCodeHighlightNodeOfLine( anchor: LexicalNode, diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index 289a4dae53e..46b2c134cea 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -11,6 +11,11 @@ import { $isCodeHighlightNode, CodeHighlightNode, } from './CodeHighlightNode'; +import { + $createCodeLineNode, + $isCodeLineNode, + CodeLineNode, +} from './CodeLineNode'; import {$createCodeNode, $isCodeNode, CodeNode} from './CodeNode'; import {registerCodeIndent} from './EditorShortcuts'; import { @@ -21,10 +26,13 @@ import { export { $createCodeHighlightNode, + $createCodeLineNode, $createCodeNode, $isCodeHighlightNode, + $isCodeLineNode, $isCodeNode, CodeHighlightNode, + CodeLineNode, CodeNode, getFirstCodeHighlightNodeOfLine, getLastCodeHighlightNodeOfLine, diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts index ee7d7dfa631..5a27f58d9c6 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts @@ -9,7 +9,7 @@ import type {LexicalNode} from 'lexical'; import type {Klass} from 'shared/types'; -import {CodeHighlightNode, CodeNode} from '@lexical/code'; +import {CodeHighlightNode, CodeLineNode, CodeNode} from '@lexical/code'; import {HashtagNode} from '@lexical/hashtag'; import {AutoLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; @@ -43,6 +43,7 @@ const PlaygroundNodes: Array> = [ TableRowNode, HashtagNode, CodeHighlightNode, + CodeLineNode, AutoLinkNode, LinkNode, OverflowNode, diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 0f548650350..c3179560701 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -637,27 +637,6 @@ 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 */ @@ -818,7 +797,7 @@ declare export function $getRoot(): RootNode; declare export function $isLeafNode( node: ?LexicalNode, ): boolean %checks(node instanceof TextNode || - node instanceof CodeLineNode || + node instanceof LineBreakNode || node instanceof DecoratorNode); declare export function $setCompositionKey( compositionKey: null | NodeKey, @@ -890,12 +869,6 @@ 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 09c93c26925..d3040248885 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, CodeLineNode, TextNode} from '.'; +import {$getRoot, $getSelection, TextNode} from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; import {createEmptyEditorState} from './LexicalEditorState'; import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents'; @@ -312,7 +312,6 @@ 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 74e3541ba90..785de49c87a 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 $isCodeLineNode(node) || $isDecoratorNode(node); + return $isLineBreakNode(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 90081ba0fea..0b434be5d1d 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 { - $createCodeLineNode, + $createLineBreakNode, $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) || $isCodeLineNode(node)) && + ($isDecoratorNode(node) || $isLineBreakNode(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($createCodeLineNode()); + nodes.push($createLineBreakNode()); } } this.insertNodes(nodes); @@ -1560,7 +1560,7 @@ export class RangeSelection implements BaseSelection { } insertLineBreak(selectStart?: boolean): void { - const lineBreakNode = $createCodeLineNode(); + const lineBreakNode = $createLineBreakNode(); 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 3b306a87da2..850de3c0b4a 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) || $isCodeLineNode(node) || $isDecoratorNode(node); + return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node); } export function $setNodeKey( diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 3b21842e427..1ec346710c2 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -129,11 +129,6 @@ 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'; From 9f0acb61fb19d89d0a91678d9329495c2328b478 Mon Sep 17 00:00:00 2001 From: Lateef Azeez Date: Tue, 5 Jul 2022 12:33:30 -0600 Subject: [PATCH 11/11] update code split --- packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts b/packages/lexical-playground/src/plugins/CodeHighlightPlugin.ts index 5bdb37e6c84..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, 50); + return registerCodeHighlighting(editor, 10000); }, [editor]); return null;