/** * 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 {CodeExtension} from './CodeExtension'; import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, LexicalEditor, LexicalNode, LexicalUpdateJSON, NodeKey, ParagraphNode, RangeSelection, SerializedElementNode, Spread, TabNode, } from 'lexical'; import {getPeerDependencyFromEditor} from '@lexical/extension'; import warnOnlyOnce from '@lexical/internal/warnOnlyOnce'; import { $create, $createLineBreakNode, $createParagraphNode, $createTabNode, $getEditor, $isLineBreakNode, $isTabNode, $isTextNode, addClassNamesToElement, ElementNode, isHTMLElement, setDOMStyleFromCSS, } from 'lexical'; import { $createCodeHighlightNode, $isCodeHighlightNode, type CodeHighlightNode, } from './CodeHighlightNode'; import {$getFirstCodeNodeOfLine} from './FlatStructureUtils'; export type SerializedCodeNode = Spread< { language: string | null | undefined; theme?: string | undefined; }, SerializedElementNode >; export const DEFAULT_CODE_LANGUAGE = 'javascript'; /** @internal Configurable through the extensions. */ export const getDefaultCodeLanguage = (): string => DEFAULT_CODE_LANGUAGE; function hasChildDOMNodeTag(node: Node, tagName: string) { for (const child of node.childNodes) { if (isHTMLElement(child) && child.tagName === tagName) { return true; } if (hasChildDOMNodeTag(child, tagName)) { return true; } } return false; } const LANGUAGE_DATA_ATTRIBUTE = 'data-language'; const HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; const THEME_DATA_ATTRIBUTE = 'data-theme'; const noExtensionDeprecation = warnOnlyOnce( 'Using CodeNode without CodeExtension is deprecated', ); /** @noInheritDoc */ export class CodeNode extends ElementNode { /** @internal */ __language: string | null | undefined; /** @internal */ __theme: string | undefined; /** @internal */ __isSyntaxHighlightSupported: boolean; 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 = language || undefined; this.__isSyntaxHighlightSupported = false; this.__theme = undefined; } afterCloneFrom(prevNode: this): void { super.afterCloneFrom(prevNode); this.__language = prevNode.__language; this.__theme = prevNode.__theme; this.__isSyntaxHighlightSupported = prevNode.__isSyntaxHighlightSupported; } // 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); if (this.getIsSyntaxHighlightSupported()) { element.setAttribute(HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE, language); } } const theme = this.getTheme(); if (theme) { element.setAttribute(THEME_DATA_ATTRIBUTE, theme); } const style = this.getStyle(); if (style) { setDOMStyleFromCSS(element.style, style); } return element; } updateDOM(prevNode: this, 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); } const isSyntaxHighlightSupported = this.__isSyntaxHighlightSupported; const prevIsSyntaxHighlightSupported = prevNode.__isSyntaxHighlightSupported; if (prevIsSyntaxHighlightSupported && prevLanguage) { if (isSyntaxHighlightSupported && language) { if (language !== prevLanguage) { dom.setAttribute(HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE, language); } } else { dom.removeAttribute(HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE); } } else if (isSyntaxHighlightSupported && language) { dom.setAttribute(HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE, language); } const theme = this.__theme; const prevTheme = prevNode.__theme; if (theme) { if (theme !== prevTheme) { dom.setAttribute(THEME_DATA_ATTRIBUTE, theme); } } else if (prevTheme) { dom.removeAttribute(THEME_DATA_ATTRIBUTE); } const style = this.__style; const prevStyle = prevNode.__style; if (style !== prevStyle) { setDOMStyleFromCSS(dom.style, style, prevStyle); } return false; } exportDOM(editor: LexicalEditor): DOMExportOutput { const element = document.createElement('pre'); addClassNamesToElement(element, editor._config.theme.code); element.setAttribute('spellcheck', 'false'); const language = this.getLanguage(); if (language) { element.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); if (this.getIsSyntaxHighlightSupported()) { element.setAttribute(HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE, language); } } const theme = this.getTheme(); if (theme) { element.setAttribute(THEME_DATA_ATTRIBUTE, theme); } const style = this.getStyle(); if (style) { setDOMStyleFromCSS(element.style, style); } return {element}; } 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) || hasChildDOMNodeTag(node, 'BR'));

        return isMultiLine
          ? {
              conversion: $convertPreElement,
              priority: 1,
            }
          : null;
      },
      div: () => ({
        conversion: $convertDivElement,
        priority: 1,
      }),
      pre: () => ({
        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: 3,
          };
        }
        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: 3,
          };
        }
        return null;
      },
    };
  }

  static importJSON(serializedNode: SerializedCodeNode): CodeNode {
    return $createCodeNode().updateFromJSON(serializedNode);
  }

  updateFromJSON(serializedNode: LexicalUpdateJSON): this {
    return super
      .updateFromJSON(serializedNode)
      .setLanguage(serializedNode.language)
      .setTheme(serializedNode.theme);
  }

  exportJSON(): SerializedCodeNode {
    return {
      ...super.exportJSON(),
      language: this.getLanguage(),
      theme: this.getTheme(),
    };
  }

  // Mutation
  insertNewAfter(
    selection: RangeSelection,
    restoreSelection = true,
  ): null | ParagraphNode | CodeHighlightNode | TabNode {
    if (
      !getPeerDependencyFromEditor(
        $getEditor(),
        '@lexical/code',
      )
    ) {
      noExtensionDeprecation();
      const el = $exitCodeNodeOnEnter(selection);
      if (el) {
        return el;
      }
    }
    // 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, focus} = selection;
    const firstPoint = anchor.isBefore(focus) ? anchor : focus;
    const firstSelectionNode = firstPoint.getNode();
    if ($isTextNode(firstSelectionNode)) {
      let node: null | LexicalNode =
        $getFirstCodeNodeOfLine(firstSelectionNode);
      const insertNodes = [];

      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();
          while (spaces < textSize && text[spaces] === ' ') {
            spaces++;
          }
          if (spaces !== 0) {
            insertNodes.push($createCodeHighlightNode(' '.repeat(spaces)));
          }
          if (spaces !== textSize) {
            break;
          }
          node = node.getNextSibling();
        } else {
          break;
        }
      }
      const split = firstSelectionNode.splitText(anchor.offset)[0];
      const x = anchor.offset === 0 ? 0 : 1;
      const index = split.getIndexWithinParent() + x;
      const codeNode = firstSelectionNode.getParentOrThrow();
      const nodesToInsert = [$createLineBreakNode(), ...insertNodes];
      codeNode.splice(index, 0, nodesToInsert);
      const last = insertNodes[insertNodes.length - 1];
      if (last) {
        last.select();
      } else if (anchor.offset === 0) {
        split.selectPrevious();
      } else {
        split.getNextSibling()!.selectNext(0, 0);
      }
    }
    if ($isCodeNode(firstSelectionNode)) {
      const {offset} = selection.anchor;
      firstSelectionNode.splice(offset, 0, [$createLineBreakNode()]);
      firstSelectionNode.select(offset + 1, offset + 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 | null | undefined): this {
    const writable = this.getWritable();
    writable.__language = language || undefined;
    return writable;
  }

  getLanguage(): string | null | undefined {
    return this.getLatest().__language;
  }

  setIsSyntaxHighlightSupported(isSupported: boolean): this {
    const writable = this.getWritable();
    writable.__isSyntaxHighlightSupported = isSupported;
    return writable;
  }

  getIsSyntaxHighlightSupported(): boolean {
    return this.getLatest().__isSyntaxHighlightSupported;
  }

  setTheme(theme: string | null | undefined): this {
    const writable = this.getWritable();
    writable.__theme = theme || undefined;
    return writable;
  }

  getTheme(): string | undefined {
    return this.getLatest().__theme;
  }
}

export function $createCodeNode(
  language?: string | null | undefined,
  theme?: string | null | undefined,
): CodeNode {
  return $create(CodeNode).setLanguage(language).setTheme(theme);
}

export function $isCodeNode(
  node: LexicalNode | null | undefined,
): node is CodeNode {
  return node instanceof CodeNode;
}

function $convertPreElement(domNode: HTMLElement): DOMConversionOutput {
  const language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE);
  return {node: $createCodeNode(language)};
}

function $convertDivElement(domNode: Node): DOMConversionOutput {
  // domNode is a 
since we matched it by nodeName const div = domNode as HTMLDivElement; const isCode = isCodeElement(div); if (!isCode && !isCodeChildElement(div)) { return { node: null, }; } return { node: isCode ? $createCodeNode() : null, }; } function $convertTableElement(): DOMConversionOutput { return {node: $createCodeNode()}; } function convertCodeNoop(): DOMConversionOutput { return {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'); } export function $exitCodeNodeOnEnter( selection: RangeSelection, ): null | ParagraphNode { const {anchor} = selection; if (selection.isCollapsed() && anchor.type === 'element') { const codeNode = anchor.getNode(); if ($isCodeNode(codeNode)) { const childrenSize = codeNode.getChildrenSize(); if (childrenSize >= 2 && anchor.offset === childrenSize) { const lastChild = codeNode.getLastChild(); if ( $isLineBreakNode(lastChild) && $isLineBreakNode(lastChild.getPreviousSibling()) ) { const newElement = $createParagraphNode(); codeNode .splice(childrenSize - 2, 2, []) .insertAfter(newElement, false); newElement.select(); return newElement; } } } } return null; }
since we matched it by nodeName const td = node as HTMLTableCellElement; const table: HTMLTableElement | null = td.closest('table'); if (isGitHubCodeCell(td) || (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