UNPKG

@terrible-lexical/code

Version:

This package contains the functionality for the code blocks and code highlighting for Lexical.

408 lines (367 loc) 11.5 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ // eslint-disable-next-line simple-import-sort/imports import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, LexicalNode, NodeKey, ParagraphNode, RangeSelection, SerializedElementNode, Spread, TabNode, } from 'terrible-lexical'; import type {CodeHighlightNode} from '@terrible-lexical/code/src'; 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 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-java'; import 'prismjs/components/prism-cpp'; import {addClassNamesToElement, isHTMLElement} from '@terrible-lexical/utils/src'; import { $applyNodeReplacement, $createLineBreakNode, $createParagraphNode, ElementNode, $isTabNode, $createTabNode, } from 'terrible-lexical'; import { $isCodeHighlightNode, $createCodeHighlightNode, getFirstCodeNodeOfLine, } from './CodeHighlightNode'; import * as Prism from 'prismjs'; export type SerializedCodeNode = Spread< { language: string | null | undefined; }, 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 hasChildDOMNodeTag(node: Node, tagName: string) { for (const child of node.childNodes) { if (isHTMLElement(child) && child.tagName === tagName) { return true; } hasChildDOMNodeTag(child, tagName); } return false; } const LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; /** @noInheritDoc */ export class CodeNode extends ElementNode { /** @internal */ __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, config: EditorConfig, ): 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; } exportDOM(): DOMExportOutput { const element = document.createElement('pre'); element.setAttribute('spellcheck', 'false'); const language = this.getLanguage(); if (language) { element.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); } return {element}; } static importDOM(): DOMConversionMap | null { return { // Typically <pre> is used for code blocks, and <code> for inline code styles // but if it's a multi line <code> 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) || hasChildDOMNodeTag(node, 'BR')); 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 <table> since we matched it by nodeName if (isGitHubCodeTable(table as HTMLTableElement)) { return { conversion: convertTableElement, priority: 3, }; } return null; }, td: (node: Node) => { // element is a <td> 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: 3, }; } 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: 3, }; } return null; }, tr: (node: Node) => { // element is a <tr> 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: 3, }; } 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, restoreSelection = true, ): null | ParagraphNode | CodeHighlightNode | TabNode { 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, restoreSelection); 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; const focus = selection.focus; const firstPoint = anchor.isBefore(focus) ? anchor : focus; const firstSelectionNode = firstPoint.getNode(); if ( $isCodeHighlightNode(firstSelectionNode) || $isTabNode(firstSelectionNode) ) { let node = getFirstCodeNodeOfLine(firstSelectionNode); const insertNodes = []; // eslint-disable-next-line no-constant-condition while (true) { if ($isTabNode(node)) { insertNodes.push($createTabNode()); node = node.getNextSibling(); } else if ($isCodeHighlightNode(node)) { let spaces = 0; const text = node.getTextContent(); const textSize = node.getTextContentSize(); for (; spaces < textSize && text[spaces] === ' '; spaces++); if (spaces !== 0) { insertNodes.push($createCodeHighlightNode(' '.repeat(spaces))); } if (spaces !== textSize) { break; } node = node.getNextSibling(); } else { break; } } if (insertNodes.length > 0) { selection.insertNodes([$createLineBreakNode(), ...insertNodes]); return insertNodes[insertNodes.length - 1]; } } return null; } canIndent(): false { return false; } collapseAtStart(): boolean { 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 $applyNodeReplacement(new CodeNode(language)); } export function $isCodeNode( node: LexicalNode | null | undefined, ): node is CodeNode { return node instanceof CodeNode; } function convertPreElement(domNode: Node): DOMConversionOutput { let language; if (isHTMLElement(domNode)) { language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE); } return {node: $createCodeNode(language)}; } function convertDivElement(domNode: Node): DOMConversionOutput { // domNode is a <div> since we matched it by nodeName const div = domNode as HTMLDivElement; const isCode = isCodeElement(div); if (!isCode && !isCodeChildElement(div)) { return { node: null, }; } return { after: (childLexicalNodes) => { const domParent = domNode.parentNode; if (domParent != null && domNode !== domParent.lastChild) { childLexicalNodes.push($createLineBreakNode()); } return childLexicalNodes; }, node: isCode ? $createCodeNode() : null, }; } function convertTableElement(): DOMConversionOutput { return {node: $createCodeNode()}; } function convertCodeNoop(): DOMConversionOutput { return {node: null}; } function convertTableCellElement(domNode: Node): DOMConversionOutput { // domNode is a <td> 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: HTMLElement): boolean { return div.style.fontFamily.match('monospace') !== null; } function isCodeChildElement(node: HTMLElement): boolean { let parent = node.parentElement; while (parent !== null) { if (isCodeElement(parent)) { return true; } parent = parent.parentElement; } return false; } 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'); }