@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
text/typescript
/**
* 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');
}