diff --git a/package-lock.json b/package-lock.json index 107750e663f..1a78d58c960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28813,7 +28813,7 @@ "dev": true, "requires": { "@types/node": "*", - "playwright-core": "1.23.0-next-alpha-trueadm-fork" + "playwright-core": "1.23.1" } }, "@polka/url": { diff --git a/packages/lexical-code/LexicalCode.d.ts b/packages/lexical-code/LexicalCode.d.ts new file mode 100644 index 00000000000..7392daf1f79 --- /dev/null +++ b/packages/lexical-code/LexicalCode.d.ts @@ -0,0 +1,114 @@ +/** + * 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 { + EditorConfig, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + EditorThemeClasses, + LexicalEditor, + SerializedElementNode, + SerializedTextNode, +} from 'lexical'; + +import {ElementNode, TextNode} from 'lexical'; +import {Spread} from 'libdefs/globals'; + +declare class CodeNode extends ElementNode { + static getType(): string; + static clone(node: CodeNode): CodeNode; + constructor(key?: NodeKey); + createDOM(config: EditorConfig): HTMLElement; + updateDOM(prevNode: CodeNode, dom: HTMLElement): boolean; + insertNewAfter( + selection: RangeSelection, + ): null | ParagraphNode | CodeHighlightNode; + canInsertTab(): boolean; + collapseAtStart(): true; + setLanguage(language: string): void; + getLanguage(): string | void; + importJSON(serializedNode: SerializedCodeNode): CodeNode; + exportJSON(): SerializedElementNode; +} +declare function $createCodeNode(language?: string): CodeNode; +declare function $isCodeNode( + node: null | undefined | LexicalNode, +): node is CodeNode; + +declare function getFirstCodeHighlightNodeOfLine( + anchor: LexicalNode, +): null | undefined | CodeHighlightNode; + +declare function getLastCodeHighlightNodeOfLine( + anchor: LexicalNode, +): null | undefined | CodeHighlightNode; + +declare function getDefaultCodeLanguage(): string; +declare function getCodeLanguages(): Array; + +declare class CodeHighlightNode extends TextNode { + __highlightType: null | undefined | string; + constructor(text: string, highlightType?: string, key?: NodeKey); + static getType(): string; + static clone(node: CodeHighlightNode): CodeHighlightNode; + createDOM(config: EditorConfig): HTMLElement; + updateDOM( + prevNode: CodeHighlightNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean; + setFormat(format: number): this; +} +declare function getHighlightThemeClass( + theme: EditorThemeClasses, + highlightType: null | undefined | string, +): null | undefined | string; +declare function $createCodeHighlightNode( + text: string, + highlightType?: string, +): CodeHighlightNode; +declare function $isCodeHighlightNode( + node: LexicalNode | null | undefined, +): node is CodeHighlightNode; + +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< + { + language: string | null | undefined; + type: 'code'; + version: 1; + }, + SerializedElementNode +>; + +type SerializedCodeHighlightNode = Spread< + { + highlightType: string | null | undefined; + type: 'code-highlight'; + version: 1; + }, + SerializedTextNode +>; diff --git a/packages/lexical-code/src/CodeHighlightNode.ts b/packages/lexical-code/src/CodeHighlightNode.ts new file mode 100644 index 00000000000..8d487ec8f90 --- /dev/null +++ b/packages/lexical-code/src/CodeHighlightNode.ts @@ -0,0 +1,149 @@ +/** + * 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 | null | undefined, + 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 | null | undefined, +): string | null | undefined { + return ( + highlightType && + theme && + theme.codeHighlight && + theme.codeHighlight[highlightType] + ); +} + +export function $createCodeHighlightNode( + text: string, + highlightType?: string | null | undefined, +): 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..2e305b05c54 --- /dev/null +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -0,0 +1,322 @@ +/** + * 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/CodeLineNode.ts b/packages/lexical-code/src/CodeLineNode.ts new file mode 100644 index 00000000000..16d1f15d059 --- /dev/null +++ b/packages/lexical-code/src/CodeLineNode.ts @@ -0,0 +1,137 @@ +/** + * 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 { + EditorConfig, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedElementNode, + Spread, +} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import {$createParagraphNode, ElementNode} from 'lexical'; + +import {$createCodeHighlightNode} from './CodeHighlightNode'; +import {getFirstCodeHighlightNodeOfLine} from './HighlighterHelper'; + +type SerializedCodeLineNode = Spread< + { + type: 'code-line'; + version: 1; + }, + SerializedElementNode +>; + +export class CodeLineNode extends ElementNode { + static clone(node: CodeLineNode): CodeLineNode { + return new CodeLineNode(node.__key); + } + + constructor(key?: NodeKey) { + super(key); + } + + static getType(): string { + return 'code-line'; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('div'); + addClassNamesToElement(element, config.theme.codeLine); + return element; + } + + updateDOM(prevNode: CodeLineNode, dom: HTMLElement): boolean { + return false; + } + + 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; + } + + exportJSON(): SerializedCodeLineNode { + return { + ...super.exportJSON(), + type: 'code-line', + version: 1, + }; + } +} + +export function $createCodeLineNode(language?: string): CodeLineNode { + return new CodeLineNode(language); +} + +export function $isCodeLineNode( + node: LexicalNode | null | undefined, +): node is CodeLineNode { + return node instanceof CodeLineNode; +} diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts new file mode 100644 index 00000000000..83582fabf16 --- /dev/null +++ b/packages/lexical-code/src/CodeNode.ts @@ -0,0 +1,332 @@ +/** + * 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 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 | null | undefined, +): 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..f57c9742725 --- /dev/null +++ b/packages/lexical-code/src/EditorShortcuts.ts @@ -0,0 +1,432 @@ +/** + * 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(); + return true; +} + +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..4e07fd9484e --- /dev/null +++ b/packages/lexical-code/src/HighlighterHelper.ts @@ -0,0 +1,102 @@ +/** + * 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); +} + +export const CODE_LANGUAGE_FRIENDLY_NAME_MAP: Record = { + c: 'C', + clike: 'C-like', + css: 'CSS', + html: 'HTML', + js: 'JavaScript', + markdown: 'Markdown', + objc: 'Objective-C', + plain: 'Plain Text', + py: 'Python', + rust: 'Rust', + sql: 'SQL', + swift: 'Swift', + xml: 'XML', +}; + +export const CODE_LANGUAGE_MAP: Record = { + javascript: 'js', + md: 'markdown', + plaintext: 'plain', + python: 'py', + text: 'plain', +}; + +export function getLanguageFriendlyName(lang: string) { + const _lang = CODE_LANGUAGE_MAP[lang] || lang; + return CODE_LANGUAGE_FRIENDLY_NAME_MAP[_lang] || _lang; +} diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index 0413b782006..ad52e3e6fd1 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -5,1207 +5,36 @@ * 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 ->; - -export const CODE_LANGUAGE_FRIENDLY_NAME_MAP: Record = { - c: 'C', - clike: 'C-like', - css: 'CSS', - html: 'HTML', - js: 'JavaScript', - markdown: 'Markdown', - objc: 'Objective-C', - plain: 'Plain Text', - py: 'Python', - rust: 'Rust', - sql: 'SQL', - swift: 'Swift', - xml: 'XML', + CODE_LANGUAGE_FRIENDLY_NAME_MAP, + CODE_LANGUAGE_MAP, + getFirstCodeHighlightNodeOfLine, + getLanguageFriendlyName, + getLastCodeHighlightNodeOfLine, + updateCodeGutter, +} from './HighlighterHelper'; + +export { + $createCodeHighlightNode, + $createCodeNode, + $isCodeHighlightNode, + $isCodeNode, + CODE_LANGUAGE_FRIENDLY_NAME_MAP, + CODE_LANGUAGE_MAP, + CodeHighlightNode, + CodeNode, + getFirstCodeHighlightNodeOfLine, + getLanguageFriendlyName, + getLastCodeHighlightNodeOfLine, + registerCodeHighlighting, + registerCodeIndent, + updateCodeGutter, }; - -export const CODE_LANGUAGE_MAP: Record = { - javascript: 'js', - md: 'markdown', - plaintext: 'plain', - python: 'py', - text: 'plain', -}; - -export function getLanguageFriendlyName(lang: string) { - const _lang = CODE_LANGUAGE_MAP[lang] || lang; - return CODE_LANGUAGE_FRIENDLY_NAME_MAP[_lang] || _lang; -} - -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 | null | undefined, - 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 | null | undefined, -): string | null | undefined { - return ( - highlightType && - theme && - theme.codeHighlight && - theme.codeHighlight[highlightType] - ); -} - -export function $createCodeHighlightNode( - text: string, - highlightType?: string | null | undefined, -): 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 { - // Typically
 is used for code blocks, and  for inline code styles
-      // but if it's a multi line  we'll create a block. Pass through to
-      // inline format handled by TextNode otherwise
-      code: (node: Node) => {
-        const isMultiLine =
-          node.textContent != null && /\r?\n/.test(node.textContent);
-
-        return isMultiLine
-          ? {
-              conversion: convertPreElement,
-              priority: 1,
-            }
-          : null;
-      },
-      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 | null | undefined, -): 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(); - - return true; -} - -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): boolean => handleShiftLines(KEY_ARROW_UP_COMMAND, payload), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (payload): boolean => handleShiftLines(KEY_ARROW_DOWN_COMMAND, payload), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - MOVE_TO_END, - (payload): boolean => handleMoveTo(MOVE_TO_END, payload), - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - MOVE_TO_START, - (payload): boolean => handleMoveTo(MOVE_TO_START, payload), - COMMAND_PRIORITY_LOW, - ), - ); -} diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 601543b136e..e4dd7aa58ff 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -102,6 +102,11 @@ pre { border-bottom-right-radius: 10px; } +.clipboard-view-output { + max-width: 1000px; + margin: 0 auto; +} + pre::-webkit-scrollbar { background: transparent; width: 10px; 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; diff --git a/packages/lexical-playground/src/plugins/CommentPlugin.tsx b/packages/lexical-playground/src/plugins/CommentPlugin.tsx index ea37081a968..6f4a2d336cb 100644 --- a/packages/lexical-playground/src/plugins/CommentPlugin.tsx +++ b/packages/lexical-playground/src/plugins/CommentPlugin.tsx @@ -158,11 +158,14 @@ function PlainTextEditor({ onEscape, onChange, editorRef, + inputRef, placeholder = 'Type a comment...', }: { autoFocus?: boolean; className?: string; editorRef?: {current: null | LexicalEditor}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputRef?: {current: any}; onChange: (editorState: EditorState, editor: LexicalEditor) => void; onEscape: (e: KeyboardEvent) => boolean; placeholder?: string; @@ -178,7 +181,9 @@ function PlainTextEditor({ return ( -
+
} placeholder={{placeholder}} @@ -311,7 +316,7 @@ function CommentInputBox({ return true; }; - const submitComment = () => { + const submitComment = useCallback(() => { if (canSubmit) { let quote = editor.getEditorState().read(() => { const selection = $getSelection(); @@ -325,7 +330,27 @@ function CommentInputBox({ true, ); } - }; + }, [author, canSubmit, content, editor, submitAddComment]); + + // Submit comments and replies with CMD + Enter + useEffect(() => { + const inputBox = boxRef.current; + const listener = (event: KeyboardEvent) => { + if (event.key === 'Enter' && event.metaKey) { + submitComment(); + } + }; + + if (inputBox) { + inputBox.addEventListener('keydown', listener); + } + + return () => { + if (inputBox) { + inputBox.removeEventListener('keydown', listener); + } + }; + }, [submitComment]); const onChange = useOnChange(setContent, setCanSubmit); @@ -370,11 +395,12 @@ function CommentsComposer({ const [content, setContent] = useState(''); const [canSubmit, setCanSubmit] = useState(false); const editorRef = useRef(null); + const inputRef = useRef(null); const author = useCollabAuthorName(); const onChange = useOnChange(setContent, setCanSubmit); - const submitComment = () => { + const submitComment = useCallback(() => { if (canSubmit) { submitAddComment(createComment(content, author), false, thread); const editor = editorRef.current; @@ -382,7 +408,27 @@ function CommentsComposer({ editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); } } - }; + }, [author, canSubmit, content, submitAddComment, thread]); + + // Submit comments and replies with CMD + Enter + useEffect(() => { + const editor = inputRef.current; + const listener = (event: KeyboardEvent) => { + if (event.key === 'Enter' && event.metaKey) { + submitComment(); + } + }; + + if (editor) { + editor.addEventListener('keydown', listener); + } + + return () => { + if (editor) { + editor.removeEventListener('keydown', listener); + } + }; + }, [submitComment]); return ( <> @@ -394,6 +440,7 @@ function CommentsComposer({ }} onChange={onChange} editorRef={editorRef} + inputRef={inputRef} placeholder={placeholder} />