@lexical/code
Version:
This package contains the functionality for the code blocks and code highlighting for Lexical.
1,519 lines (1,452 loc) • 54.5 kB
JavaScript
/**
* 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.
*
*/
'use strict';
var utils = require('@lexical/utils');
var lexical = require('lexical');
require('prismjs');
require('prismjs/components/prism-clike');
require('prismjs/components/prism-javascript');
require('prismjs/components/prism-markup');
require('prismjs/components/prism-markdown');
require('prismjs/components/prism-c');
require('prismjs/components/prism-css');
require('prismjs/components/prism-objectivec');
require('prismjs/components/prism-sql');
require('prismjs/components/prism-powershell');
require('prismjs/components/prism-python');
require('prismjs/components/prism-rust');
require('prismjs/components/prism-swift');
require('prismjs/components/prism-typescript');
require('prismjs/components/prism-java');
require('prismjs/components/prism-cpp');
/**
* 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.
*
*/
// Do not require this module directly! Use normal `invariant` calls.
function formatDevErrorMessage(message) {
throw new Error(message);
}
/**
* 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.
*
*/
// invariant(condition, message) will refine types based on "condition", and
// if "condition" is false will throw an error. This function is special-cased
// in flow itself, so we can't name it anything else.
function invariant(cond, message, ...args) {
if (cond) {
return;
}
throw new Error('Internal Lexical error: invariant() is meant to be replaced at compile ' + 'time. There is no runtime version. Error: ' + message);
}
/**
* 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.
*
*/
const DEFAULT_CODE_LANGUAGE = 'javascript';
const getDefaultCodeLanguage = () => DEFAULT_CODE_LANGUAGE;
function hasChildDOMNodeTag(node, tagName) {
for (const child of node.childNodes) {
if (utils.isHTMLElement(child) && child.tagName === tagName) {
return true;
}
hasChildDOMNodeTag(child, tagName);
}
return false;
}
const LANGUAGE_DATA_ATTRIBUTE = 'data-language';
const HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language';
const THEME_DATA_ATTRIBUTE = 'data-theme';
/** @noInheritDoc */
class CodeNode extends lexical.ElementNode {
/** @internal */
__language;
/** @internal */
__theme;
/** @internal */
__isSyntaxHighlightSupported;
static getType() {
return 'code';
}
static clone(node) {
return new CodeNode(node.__language, node.__key);
}
constructor(language, key) {
super(key);
this.__language = language || undefined;
this.__isSyntaxHighlightSupported = false;
this.__theme = undefined;
}
afterCloneFrom(prevNode) {
super.afterCloneFrom(prevNode);
this.__language = prevNode.__language;
this.__theme = prevNode.__theme;
this.__isSyntaxHighlightSupported = prevNode.__isSyntaxHighlightSupported;
}
// View
createDOM(config) {
const element = document.createElement('code');
utils.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) {
element.setAttribute('style', style);
}
return element;
}
updateDOM(prevNode, dom, config) {
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) {
if (style !== prevStyle) {
dom.setAttribute('style', style);
}
} else if (prevStyle) {
dom.removeAttribute('style');
}
return false;
}
exportDOM(editor) {
const element = document.createElement('pre');
utils.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) {
element.setAttribute('style', style);
}
return {
element
};
}
static importDOM() {
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 => {
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 => {
const table = node;
// domNode is a <table> since we matched it by nodeName
if (isGitHubCodeTable(table)) {
return {
conversion: $convertTableElement,
priority: 3
};
}
return null;
},
td: node => {
// element is a <td> since we matched it by nodeName
const td = node;
const table = 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 => {
// element is a <tr> since we matched it by nodeName
const tr = node;
const table = tr.closest('table');
if (table && isGitHubCodeTable(table)) {
return {
conversion: convertCodeNoop,
priority: 3
};
}
return null;
}
};
}
static importJSON(serializedNode) {
return $createCodeNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode) {
return super.updateFromJSON(serializedNode).setLanguage(serializedNode.language).setTheme(serializedNode.theme);
}
exportJSON() {
return {
...super.exportJSON(),
language: this.getLanguage(),
theme: this.getTheme()
};
}
// Mutation
insertNewAfter(selection, restoreSelection = true) {
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 = lexical.$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,
focus
} = selection;
const firstPoint = anchor.isBefore(focus) ? anchor : focus;
const firstSelectionNode = firstPoint.getNode();
if (lexical.$isTextNode(firstSelectionNode)) {
let node = $getFirstCodeNodeOfLine(firstSelectionNode);
const insertNodes = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if (lexical.$isTabNode(node)) {
insertNodes.push(lexical.$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 = [lexical.$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, [lexical.$createLineBreakNode()]);
firstSelectionNode.select(offset + 1, offset + 1);
}
return null;
}
canIndent() {
return false;
}
collapseAtStart() {
const paragraph = lexical.$createParagraphNode();
const children = this.getChildren();
children.forEach(child => paragraph.append(child));
this.replace(paragraph);
return true;
}
setLanguage(language) {
const writable = this.getWritable();
writable.__language = language || undefined;
return writable;
}
getLanguage() {
return this.getLatest().__language;
}
setIsSyntaxHighlightSupported(isSupported) {
const writable = this.getWritable();
writable.__isSyntaxHighlightSupported = isSupported;
return writable;
}
getIsSyntaxHighlightSupported() {
return this.getLatest().__isSyntaxHighlightSupported;
}
setTheme(theme) {
const writable = this.getWritable();
writable.__theme = theme || undefined;
return writable;
}
getTheme() {
return this.getLatest().__theme;
}
}
function $createCodeNode(language, theme) {
return lexical.$create(CodeNode).setLanguage(language).setTheme(theme);
}
function $isCodeNode(node) {
return node instanceof CodeNode;
}
function $convertPreElement(domNode) {
const language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE);
return {
node: $createCodeNode(language)
};
}
function $convertDivElement(domNode) {
// domNode is a <div> since we matched it by nodeName
const div = domNode;
const isCode = isCodeElement(div);
if (!isCode && !isCodeChildElement(div)) {
return {
node: null
};
}
return {
node: isCode ? $createCodeNode() : null
};
}
function $convertTableElement() {
return {
node: $createCodeNode()
};
}
function convertCodeNoop() {
return {
node: null
};
}
function isCodeElement(div) {
return div.style.fontFamily.match('monospace') !== null;
}
function isCodeChildElement(node) {
let parent = node.parentElement;
while (parent !== null) {
if (isCodeElement(parent)) {
return true;
}
parent = parent.parentElement;
}
return false;
}
function isGitHubCodeCell(cell) {
return cell.classList.contains('js-file-line');
}
function isGitHubCodeTable(table) {
return table.classList.contains('js-file-line-container');
}
/**
* 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.
*
*/
/** @noInheritDoc */
class CodeHighlightNode extends lexical.TextNode {
/** @internal */
__highlightType;
constructor(text = '', highlightType, key) {
super(text, key);
this.__highlightType = highlightType;
}
static getType() {
return 'code-highlight';
}
static clone(node) {
return new CodeHighlightNode(node.__text, node.__highlightType || undefined, node.__key);
}
getHighlightType() {
const self = this.getLatest();
return self.__highlightType;
}
setHighlightType(highlightType) {
const self = this.getWritable();
self.__highlightType = highlightType || undefined;
return self;
}
canHaveFormat() {
return false;
}
createDOM(config) {
const element = super.createDOM(config);
const className = getHighlightThemeClass(config.theme, this.__highlightType);
utils.addClassNamesToElement(element, className);
return element;
}
updateDOM(prevNode, dom, config) {
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) {
utils.removeClassNamesFromElement(dom, prevClassName);
}
if (nextClassName) {
utils.addClassNamesToElement(dom, nextClassName);
}
}
return update;
}
static importJSON(serializedNode) {
return $createCodeHighlightNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode) {
return super.updateFromJSON(serializedNode).setHighlightType(serializedNode.highlightType);
}
exportJSON() {
return {
...super.exportJSON(),
highlightType: this.getHighlightType()
};
}
// Prevent formatting (bold, underline, etc)
setFormat(format) {
return this;
}
isParentRequired() {
return true;
}
createParentElementNode() {
return $createCodeNode();
}
}
function getHighlightThemeClass(theme, highlightType) {
return highlightType && theme && theme.codeHighlight && theme.codeHighlight[highlightType];
}
function $createCodeHighlightNode(text = '', highlightType) {
return lexical.$applyNodeReplacement(new CodeHighlightNode(text, highlightType));
}
function $isCodeHighlightNode(node) {
return node instanceof CodeHighlightNode;
}
function $getLastMatchingCodeNode(anchor, direction) {
let matchingNode = anchor;
for (let caret = lexical.$getSiblingCaret(anchor, direction); caret && ($isCodeHighlightNode(caret.origin) || lexical.$isTabNode(caret.origin)); caret = utils.$getAdjacentCaret(caret)) {
matchingNode = caret.origin;
}
return matchingNode;
}
function $getFirstCodeNodeOfLine(anchor) {
return $getLastMatchingCodeNode(anchor, 'previous');
}
function $getLastCodeNodeOfLine(anchor) {
return $getLastMatchingCodeNode(anchor, 'next');
}
/**
* Determines the visual writing direction of a code line.
*
* Scans the line segments (CodeHighlightNode/TabNode) from start to end
* and returns the first strong direction found ("ltr" or "rtl").
* If no strong character is found, falls back to the parent element's
* direction. Returns null if indeterminate.
*/
function $getCodeLineDirection(anchor) {
const start = $getFirstCodeNodeOfLine(anchor);
const end = $getLastCodeNodeOfLine(anchor);
let node = start;
while (node !== null) {
if ($isCodeHighlightNode(node)) {
const direction = lexical.getTextDirection(node.getTextContent());
if (direction !== null) {
return direction;
}
}
if (node === end) {
break;
}
node = node.getNextSibling();
}
const parent = start.getParent();
if (lexical.$isElementNode(parent)) {
const parentDirection = parent.getDirection();
if (parentDirection === 'ltr' || parentDirection === 'rtl') {
return parentDirection;
}
}
return null;
}
function $getStartOfCodeInLine(anchor, offset) {
let last = null;
let lastNonBlank = null;
let node = anchor;
let nodeOffset = offset;
let nodeTextContent = anchor.getTextContent();
// eslint-disable-next-line no-constant-condition
while (true) {
if (nodeOffset === 0) {
node = node.getPreviousSibling();
if (node === null) {
break;
}
if (!($isCodeHighlightNode(node) || lexical.$isTabNode(node) || lexical.$isLineBreakNode(node))) {
formatDevErrorMessage(`Expected a valid Code Node: CodeHighlightNode, TabNode, LineBreakNode`);
}
if (lexical.$isLineBreakNode(node)) {
last = {
node,
offset: 1
};
break;
}
nodeOffset = Math.max(0, node.getTextContentSize() - 1);
nodeTextContent = node.getTextContent();
} else {
nodeOffset--;
}
const character = nodeTextContent[nodeOffset];
if ($isCodeHighlightNode(node) && character !== ' ') {
lastNonBlank = {
node,
offset: nodeOffset
};
}
}
// lastNonBlank !== null: anchor in the middle of code; move to line beginning
if (lastNonBlank !== null) {
return lastNonBlank;
}
// Spaces, tabs or nothing ahead of anchor
let codeCharacterAtAnchorOffset = null;
if (offset < anchor.getTextContentSize()) {
if ($isCodeHighlightNode(anchor)) {
codeCharacterAtAnchorOffset = anchor.getTextContent()[offset];
}
} else {
const nextSibling = anchor.getNextSibling();
if ($isCodeHighlightNode(nextSibling)) {
codeCharacterAtAnchorOffset = nextSibling.getTextContent()[0];
}
}
if (codeCharacterAtAnchorOffset !== null && codeCharacterAtAnchorOffset !== ' ') {
// Borderline whitespace and code, move to line beginning
return last;
} else {
const nextNonBlank = findNextNonBlankInLine(anchor, offset);
if (nextNonBlank !== null) {
return nextNonBlank;
} else {
return last;
}
}
}
function findNextNonBlankInLine(anchor, offset) {
let node = anchor;
let nodeOffset = offset;
let nodeTextContent = anchor.getTextContent();
let nodeTextContentSize = anchor.getTextContentSize();
// eslint-disable-next-line no-constant-condition
while (true) {
if (!$isCodeHighlightNode(node) || nodeOffset === nodeTextContentSize) {
node = node.getNextSibling();
if (node === null || lexical.$isLineBreakNode(node)) {
return null;
}
if ($isCodeHighlightNode(node)) {
nodeOffset = 0;
nodeTextContent = node.getTextContent();
nodeTextContentSize = node.getTextContentSize();
}
}
if ($isCodeHighlightNode(node)) {
if (nodeTextContent[nodeOffset] !== ' ') {
return {
node,
offset: nodeOffset
};
}
nodeOffset++;
}
}
}
function $getEndOfCodeInLine(anchor) {
const lastNode = $getLastCodeNodeOfLine(anchor);
if (!!lexical.$isLineBreakNode(lastNode)) {
formatDevErrorMessage(`Unexpected lineBreakNode in getEndOfCodeInLine`);
}
return lastNode;
}
/**
* 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.
*
*/
/**
* Add code blocks to the editor (syntax highlighting provided separately)
*/
const CodeExtension = lexical.defineExtension({
name: '@lexical/code',
nodes: () => [CodeNode, CodeHighlightNode]
});
(function (Prism) {
Prism.languages.diff = {
'coord': [
// Match all kinds of coord lines (prefixed by "+++", "---" or "***").
/^(?:\*{3}|-{3}|\+{3}).*$/m,
// Match "@@ ... @@" coord lines in unified diff.
/^@@.*@@$/m,
// Match coord lines in normal diff (starts with a number).
/^\d.*$/m
]
// deleted, inserted, unchanged, diff
};
/**
* A map from the name of a block to its line prefix.
*
* @type {Object<string, string>}
*/
var PREFIXES = {
'deleted-sign': '-',
'deleted-arrow': '<',
'inserted-sign': '+',
'inserted-arrow': '>',
'unchanged': ' ',
'diff': '!',
};
// add a token for each prefix
Object.keys(PREFIXES).forEach(function (name) {
var prefix = PREFIXES[name];
var alias = [];
if (!/^\w+$/.test(name)) { // "deleted-sign" -> "deleted"
alias.push(/\w+/.exec(name)[0]);
}
if (name === 'diff') {
alias.push('bold');
}
Prism.languages.diff[name] = {
pattern: RegExp('^(?:[' + prefix + '].*(?:\r\n?|\n|(?![\\s\\S])))+', 'm'),
alias: alias,
inside: {
'line': {
pattern: /(.)(?=[\s\S]).*(?:\r\n?|\n)?/,
lookbehind: true
},
'prefix': {
pattern: /[\s\S]/,
alias: /\w+/.exec(name)[0]
}
}
};
});
// make prefixes available to Diff plugin
Object.defineProperty(Prism.languages.diff, 'PREFIXES', {
value: PREFIXES
});
}(Prism));
/**
* 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.
*
*/
const Prism$1 = globalThis.Prism || window.Prism;
const CODE_LANGUAGE_FRIENDLY_NAME_MAP = {
c: 'C',
clike: 'C-like',
cpp: 'C++',
css: 'CSS',
html: 'HTML',
java: 'Java',
js: 'JavaScript',
markdown: 'Markdown',
objc: 'Objective-C',
plain: 'Plain Text',
powershell: 'PowerShell',
py: 'Python',
rust: 'Rust',
sql: 'SQL',
swift: 'Swift',
typescript: 'TypeScript',
xml: 'XML'
};
const CODE_LANGUAGE_MAP = {
cpp: 'cpp',
java: 'java',
javascript: 'js',
md: 'markdown',
plaintext: 'plain',
python: 'py',
text: 'plain',
ts: 'typescript'
};
function normalizeCodeLang(lang) {
return CODE_LANGUAGE_MAP[lang] || lang;
}
function getLanguageFriendlyName(lang) {
const _lang = normalizeCodeLang(lang);
return CODE_LANGUAGE_FRIENDLY_NAME_MAP[_lang] || _lang;
}
const getCodeLanguages = () => Object.keys(Prism$1.languages).filter(
// Prism has several language helpers mixed into languages object
// so filtering them out here to get langs list
language => typeof Prism$1.languages[language] !== 'function').sort();
function getCodeLanguageOptions() {
const options = [];
for (const [lang, friendlyName] of Object.entries(CODE_LANGUAGE_FRIENDLY_NAME_MAP)) {
options.push([lang, friendlyName]);
}
return options;
}
// Prism has no theme support
function getCodeThemeOptions() {
const options = [];
return options;
}
function getDiffedLanguage(language) {
const DIFF_LANGUAGE_REGEX = /^diff-([\w-]+)/i;
const diffLanguageMatch = DIFF_LANGUAGE_REGEX.exec(language);
return diffLanguageMatch ? diffLanguageMatch[1] : null;
}
function isCodeLanguageLoaded(language) {
const diffedLanguage = getDiffedLanguage(language);
const langId = diffedLanguage ? diffedLanguage : language;
try {
// eslint-disable-next-line no-prototype-builtins
return langId ? Prism$1.languages.hasOwnProperty(langId) : false;
} catch (_unused) {
return false;
}
}
async function loadCodeLanguage(language, editor, codeNodeKey) {
// NOT IMPLEMENTED
}
function getTextContent(token) {
if (typeof token === 'string') {
return token;
} else if (Array.isArray(token)) {
return token.map(getTextContent).join('');
} else {
return getTextContent(token.content);
}
}
// The following code is extracted/adapted from prismjs v2
// It will probably be possible to use it directly from prism v2
// in the future when prismjs v2 is published and Lexical upgrades
// the prismsjs dependency
function tokenizeDiffHighlight(tokens, language) {
const diffLanguage = language;
const diffGrammar = Prism$1.languages[diffLanguage];
const env = {
tokens
};
const PREFIXES = Prism$1.languages.diff.PREFIXES;
for (const token of env.tokens) {
if (typeof token === 'string' || !(token.type in PREFIXES) || !Array.isArray(token.content)) {
continue;
}
const type = token.type;
let insertedPrefixes = 0;
const getPrefixToken = () => {
insertedPrefixes++;
return new Prism$1.Token('prefix', PREFIXES[type], type.replace(/^(\w+).*/, '$1'));
};
const withoutPrefixes = token.content.filter(t => typeof t === 'string' || t.type !== 'prefix');
const prefixCount = token.content.length - withoutPrefixes.length;
const diffTokens = Prism$1.tokenize(getTextContent(withoutPrefixes), diffGrammar);
// re-insert prefixes
// always add a prefix at the start
diffTokens.unshift(getPrefixToken());
const LINE_BREAK = /\r\n|\n/g;
const insertAfterLineBreakString = text => {
const result = [];
LINE_BREAK.lastIndex = 0;
let last = 0;
let m;
while (insertedPrefixes < prefixCount && (m = LINE_BREAK.exec(text))) {
const end = m.index + m[0].length;
result.push(text.slice(last, end));
last = end;
result.push(getPrefixToken());
}
if (result.length === 0) {
return undefined;
}
if (last < text.length) {
result.push(text.slice(last));
}
return result;
};
const insertAfterLineBreak = toks => {
for (let i = 0; i < toks.length && insertedPrefixes < prefixCount; i++) {
const tok = toks[i];
if (typeof tok === 'string') {
const inserted = insertAfterLineBreakString(tok);
if (inserted) {
toks.splice(i, 1, ...inserted);
i += inserted.length - 1;
}
} else if (typeof tok.content === 'string') {
const inserted = insertAfterLineBreakString(tok.content);
if (inserted) {
tok.content = inserted;
}
} else if (Array.isArray(tok.content)) {
insertAfterLineBreak(tok.content);
} else {
insertAfterLineBreak([tok.content]);
}
}
};
insertAfterLineBreak(diffTokens);
if (insertedPrefixes < prefixCount) {
// we are missing the last prefix
diffTokens.push(getPrefixToken());
}
token.content = diffTokens;
}
return env.tokens;
}
function $getHighlightNodes(codeNode, language) {
const DIFF_LANGUAGE_REGEX = /^diff-([\w-]+)/i;
const diffLanguageMatch = DIFF_LANGUAGE_REGEX.exec(language);
const code = codeNode.getTextContent();
let tokens = Prism$1.tokenize(code, Prism$1.languages[diffLanguageMatch ? 'diff' : language]);
if (diffLanguageMatch) {
tokens = tokenizeDiffHighlight(tokens, diffLanguageMatch[1]);
}
return $mapTokensToLexicalStructure(tokens);
}
function $mapTokensToLexicalStructure(tokens, type) {
const nodes = [];
for (const token of tokens) {
if (typeof token === 'string') {
const partials = token.split(/(\n|\t)/);
const partialsLength = partials.length;
for (let i = 0; i < partialsLength; i++) {
const part = partials[i];
if (part === '\n' || part === '\r\n') {
nodes.push(lexical.$createLineBreakNode());
} else if (part === '\t') {
nodes.push(lexical.$createTabNode());
} else if (part.length > 0) {
nodes.push($createCodeHighlightNode(part, type));
}
}
} else {
const {
content,
alias
} = token;
if (typeof content === 'string') {
nodes.push(...$mapTokensToLexicalStructure([content], token.type === 'prefix' && typeof alias === 'string' ? alias : token.type));
} else if (Array.isArray(content)) {
nodes.push(...$mapTokensToLexicalStructure(content, token.type === 'unchanged' ? undefined : token.type));
}
}
}
return nodes;
}
const PrismTokenizer = {
$tokenize(codeNode, language) {
return $getHighlightNodes(codeNode, language || this.defaultLanguage);
},
defaultLanguage: DEFAULT_CODE_LANGUAGE,
tokenize(code, language) {
return Prism$1.tokenize(code, Prism$1.languages[language || ''] || Prism$1.languages[this.defaultLanguage]);
}
};
function $textNodeTransform(node, editor, tokenizer) {
// 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, tokenizer);
} else if ($isCodeHighlightNode(node)) {
// When code block converted into paragraph or other element
// code highlight nodes converted back to normal text
node.replace(lexical.$createTextNode(node.__text));
}
}
function updateCodeGutter(node, editor) {
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 (lexical.$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 cache (`nodesCurrentlyHighlighting`) since both CodeNode and CodeHighlightNode
// transforms 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.
const nodesCurrentlyHighlighting = new Set();
function codeNodeTransform(node, editor, tokenizer) {
const nodeKey = node.getKey();
const cacheKey = editor.getKey() + '/' + nodeKey;
// When new code block inserted it might not have language selected
if (node.getLanguage() === undefined) {
node.setLanguage(tokenizer.defaultLanguage);
}
const language = node.getLanguage() || tokenizer.defaultLanguage;
if (isCodeLanguageLoaded(language)) {
if (!node.getIsSyntaxHighlightSupported()) {
node.setIsSyntaxHighlightSupported(true);
}
} else {
if (node.getIsSyntaxHighlightSupported()) {
node.setIsSyntaxHighlightSupported(false);
}
loadCodeLanguage(language, editor, nodeKey);
return;
}
if (nodesCurrentlyHighlighting.has(cacheKey)) {
return;
}
nodesCurrentlyHighlighting.add(cacheKey);
// Using nested update call to pass `skipTransforms` since we don't want
// each individual CodeHighlightNode to be transformed again as it's already
// in its final state
editor.update(() => {
$updateAndRetainSelection(nodeKey, () => {
const currentNode = lexical.$getNodeByKey(nodeKey);
if (!$isCodeNode(currentNode) || !currentNode.isAttached()) {
return false;
}
//const DIFF_LANGUAGE_REGEX = /^diff-([\w-]+)/i;
const currentLanguage = currentNode.getLanguage() || tokenizer.defaultLanguage;
//const diffLanguageMatch = DIFF_LANGUAGE_REGEX.exec(currentLanguage);
const highlightNodes = tokenizer.$tokenize(currentNode, currentLanguage);
const diffRange = getDiffRange(currentNode.getChildren(), highlightNodes);
const {
from,
to,
nodesForReplacement
} = diffRange;
if (from !== to || nodesForReplacement.length) {
node.splice(from, to - from, nodesForReplacement);
return true;
}
return false;
});
}, {
onUpdate: () => {
nodesCurrentlyHighlighting.delete(cacheKey);
},
skipTransforms: true
});
}
// Wrapping update function into selection retainer, that tries to keep cursor at the same
// position as before.
function $updateAndRetainSelection(nodeKey, updateFn) {
const node = lexical.$getNodeByKey(nodeKey);
if (!$isCodeNode(node) || !node.isAttached()) {
return;
}
const selection = lexical.$getSelection();
// If it's not range selection (or null selection) there's no need to change it,
// but we can still run highlighting logic
if (!lexical.$isRangeSelection(selection)) {
updateFn();
return;
}
const anchor = selection.anchor;
const anchorOffset = anchor.offset;
const isNewLineAnchor = anchor.type === 'element' && lexical.$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 + _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 => {
const isText = lexical.$isTextNode(_node);
if (isText || lexical.$isLineBreakNode(_node)) {
const textContentSize = _node.getTextContentSize();
if (isText && 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, nextNodes) {
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, nodeB) {
// Only checking for code highlight nodes, tabs and linebreaks. If it's regular text node
// returning false so that it's transformed into code highlight node
return $isCodeHighlightNode(nodeA) && $isCodeHighlightNode(nodeB) && nodeA.__text === nodeB.__text && nodeA.__highlightType === nodeB.__highlightType || lexical.$isTabNode(nodeA) && lexical.$isTabNode(nodeB) || lexical.$isLineBreakNode(nodeA) && lexical.$isLineBreakNode(nodeB);
}
/**
* Returns a boolean.
* Check that the selection span is within a single CodeNode.
* This is used to guard against executing handlers that can only be
* applied in a single CodeNode context
*/
function $isSelectionInCode(selection) {
if (!lexical.$isRangeSelection(selection)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const maybeAnchorCodeNode = $isCodeNode(anchorNode) ? anchorNode : anchorNode.getParent();
const focusNode = selection.focus.getNode();
const maybeFocusCodeNode = $isCodeNode(focusNode) ? focusNode : focusNode.getParent();
return $isCodeNode(maybeAnchorCodeNode) && maybeAnchorCodeNode.is(maybeFocusCodeNode);
}
/**
* Returns an Array of code lines
* Take the sequence of LineBreakNode | TabNode | CodeHighlightNode forming
* the selection and split it by LineBreakNode.
* If the selection ends at the start of the last line, it is considered empty.
* Empty lines are discarded.
*/
function $getCodeLines(selection) {
const nodes = selection.getNodes();
const lines = [];
if (nodes.length === 1 && $isCodeNode(nodes[0])) {
return lines;
}
let lastLine = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (!($isCodeHighlightNode(node) || lexical.$isTabNode(node) || lexical.$isLineBreakNode(node))) {
formatDevErrorMessage(`Expected selection to be inside CodeBlock and consisting of CodeHighlightNode, TabNode and LineBreakNode`);
}
if (lexical.$isLineBreakNode(node)) {
if (lastLine.length > 0) {
lines.push(lastLine);
lastLine = [];
}
} else {
lastLine.push(node);
}
}
if (lastLine.length > 0) {
const selectionEnd = selection.isBackward() ? selection.anchor : selection.focus;
// Discard the last line if the selection ends exactly at the
// start of the line (no real selection)
const lastPoint = lexical.$createPoint(lastLine[0].getKey(), 0, 'text');
if (!selectionEnd.is(lastPoint)) {
lines.push(lastLine);
}
}
return lines;
}
function $handleTab(shiftKey) {
const selection = lexical.$getSelection();
if (!lexical.$isRangeSelection(selection) || !$isSelectionInCode(selection)) {
return null;
}
const indentOrOutdent = !shiftKey ? lexical.INDENT_CONTENT_COMMAND : lexical.OUTDENT_CONTENT_COMMAND;
const tabOrOutdent = !shiftKey ? lexical.INSERT_TAB_COMMAND : lexical.OUTDENT_CONTENT_COMMAND;
const anchor = selection.anchor;
const focus = selection.focus;
// 1. early decision when there is no real selection
if (anchor.is(focus)) {
return tabOrOutdent;
}
// 2. If only empty lines or multiple non-empty lines are selected: indent/outdent
const codeLines = $getCodeLines(selection);
if (codeLines.length !== 1) {
return indentOrOutdent;
}
const codeLine = codeLines[0];
const codeLineLength = codeLine.length;
if (!(codeLineLength !== 0)) {
formatDevErrorMessage(`$getCodeLines only extracts non-empty lines`);
} // Take into account the direction of the selection
let selectionFirst;
let selectionLast;
if (selection.isBackward()) {
selectionFirst = focus;
selectionLast = anchor;
} else {
selectionFirst = anchor;
selectionLast = focus;
}
// find boundary elements of the line
// since codeLine only contains TabNode | CodeHighlightNode
// the result of these functions should is of Type TabNode | CodeHighlightNode
const firstOfLine = $getFirstCodeNodeOfLine(codeLine[0]);
const lastOfLine = $getLastCodeNodeOfLine(codeLine[0]);
const anchorOfLine = lexical.$createPoint(firstOfLine.getKey(), 0, 'text');
const focusOfLine = lexical.$createPoint(lastOfLine.getKey(), lastOfLine.getTextContentSize(), 'text');
// 3. multiline because selection started strictly before the line
if (selectionFirst.isBefore(anchorOfLine)) {
return indentOrOutdent;
}
// 4. multiline because the selection stops strictly after the line
if (focusOfLine.isBefore(selectionLast)) {
return indentOrOutdent;
}
// The selection if within the line.
// 4. If it does not touch both borders, it needs a tab
if (anchorOfLine.isBefore(selectionFirst) || selectionLast.isBefore(focusOfLine)) {
return tabOrOutdent;
}
// 5. Selection is matching a full line on non-empty code
return indentOrOutdent;
}
function $handleMultilineIndent(type) {
const selection = lexical.$getSelection();
if (!lexical.$isRangeSelection(selection) || !$isSelectionInCode(selection)) {
return false;
}
const codeLines = $getCodeLines(selection);
const codeLinesLength = codeLines.length;
// Special Indent case
// Selection is collapsed at the beginning of a line
if (codeLinesLength === 0 && selection.isCollapsed()) {
if (type === lexical.INDENT_CONTENT_COMMAND) {
selection.insertNodes([lexical.$createTabNode()]);
}
return true;
}
// Special Indent case
// Selection is matching only one LineBreak
if (codeLinesLength === 0 && type === lexical.INDENT_CONTENT_COMMAND && selection.getTextContent() === '\n') {
const tabNode = lexical.$createTabNode();
const lineBreakNode = lexical.$createLineBreakNode();
const direction = selection.isBackward() ? 'previous' : 'next';
selection.insertNodes([tabNode, lineBreakNode]);
lexical.$setSelectionFromCaretRange(lexical.$getCaretRangeInDirection(lexical.$getCaretRange(lexical.$getTextPointCaret(tabNode, 'next', 0), lexical.$normalizeCaret(lexical.$getSiblingCaret(lineBreakNode, 'next'))), direction));
return true;
}
// Indent Non Empty Lines
for (let i = 0; i < codeLinesLength; i++) {
const line = codeLines[i];
// a line here is never empty
if (line.length > 0) {
let firstOfLine = line[0];
// make sure to consider the first node on the first line
// because the line might not be fully selected
if (i === 0) {
firstOfLine = $getFirstCodeNodeOfLine(firstOfLine);
}
if (type === lexical.INDENT_CONTENT_COMMAND) {
const tabNode = lexical.$createTabNode();
firstOfLine.insertBefore(tabNode);
// First real code line may need selection adjustment
// when firstOfLine is at the selection boundary
if (i === 0) {
const anchorKey = selection.isBackward() ? 'focus' : 'anchor';
const anchorLine = lexical.$createPoint(firstOfLine.getKey(), 0, 'text');
if (selection[anchorKey].is(anchorLine)) {
selection[anchorKey].set(tabNode.getKey(), 0, 'text');
}
}
} else if (lexical.$isTabNode(firstOfLine)) {
firstOfLine.remove();
}
}
}
return true;
}
function $handleShiftLines(type, event) {
// We only care about the alt+arrow keys
const selection = lexical.$getSelection();
if (!lexical.$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 === lexical.KEY_ARROW_UP_COMMAND;
// Ensure the selection is within the codeblock
if (!$isSelectionInCode(selection) || !($isCodeHighlightNode(anchorNode) || lexical.$isTabNode(anchorNode)) || !($isCodeHighlightNode(focusNode) || lexical.$isTabNode(focusNode))) {
return false;
}
if (!event.altKey) {
// Handle moving selection out of the code block, given there are no
// siblings that 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;
}
let start;
let end;
if (anchorNode.isBefore(focusNode)) {
start = $getFirstCodeNodeOfLine(anchorNode);
end = $getLastCodeNodeOfLine(focusNode);
} else {
start = $getFirstCodeNodeOfLine(focusNode);
end = $getLastCodeNodeOfLine(anchorNode);
}
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) && !lexical.$isTabNode(node) && !lexical.$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 (!lexical.$isLineBreakNode(linebreak)) {
return true;
}
const sibling = arrowIsUp ? linebreak.getPreviousSibling() : linebreak.getNextSibling();
if (sibling == null) {
return true;
}
const maybeInsertionPoint = $isCodeHighlightNode(sibling) || lexical.$isTabNode(sibling) || lexical.$isLineBreakNode(sibling) ? arrowIsUp ? $getFirstCodeNodeOfLine(sibling) : $getLastCodeNodeOfLine(sibling) : null;
let insertionPoint = maybeInsertionPoint != null ? maybeInsertionPoint : sibling;
linebreak.remove();
range.forEach(node => node.remove());
if (type === lexical.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, event) {
const selection = lexical.$getSelection();
if (!lexical.$isRangeSelection(selection)) {
return false;
}
const {
anchor,
focus
} = selection;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
const isMoveToStart = type === lexical.MOVE_TO_START;
// Ensure the selection is within the codeblock
if (!$isSelectionInCode(selection) || !($isCodeHighlightNode(anchorNode) || lexical.$isTabNode(anchorNode)) || !($isCodeHighlightNode(focusNode) || lexical.$isTabNode(focusNode))) {
return false;
}
const focusLineNode = focusNode;
const direction = $getCodeLineDirection(focusLineNode);
const moveToStart = direction === 'rtl' ? !isMoveToStart : isMoveToStart;
if (moveToStart) {
const start = $getStartOfCodeInLine(focusLineNode, focus.offset);
if (start !== null) {
const {
node,
offset
} = start;
if (lexical.$isLineBreakNode(node)) {
node.selectNext(0, 0);
} else {
selection.setTextNodeRange(node, offset, node, offset);
}
} else {
focusLineNode.getParentOrThrow().selectStart();
}
} else {
const node = $getEndOfCodeInLine(focusLineNode);
node.select();
}
event.preventDefault();
event.stopPropagation();
return true;
}
function registerCodeHighlighting(editor, tokenizer) {
if (!editor.hasNodes([CodeNode, CodeHighlightNode])) {
throw new Error('CodeHighlightPlugin: CodeNode or CodeHighlightNode not registered on editor');
}
if (tokenizer == null) {
tokenizer = PrismTokenizer;
}
const registrations = [];
// Only register the mutation listener if not in headless mode
if (editor._headless !== true) {
registrations.push(editor.registerMutationListener(CodeNode, mutations => {
editor.getEditorState().read(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = lexical.$getNodeByKey(key);
if (node !== null) {
updateCodeGutter(node, editor);
}
}
}
});
}, {
skipInitialization: false
}));
}
// Add the rest of the registrations
registrations.push(editor.registerNodeTransform(CodeNode, node => codeNodeTransform(node, editor, tokenizer)), editor.registerNodeTransform(lexical.TextNode, node => $textNodeTransform(node, editor, tokenizer)), editor.registerNodeTransform(CodeHighlightNode, node => $textNodeTransform(node, editor, tokenizer)), editor.registerCommand(lexical.KEY_TAB_COMMAND, event => {
const command = $handleTab(event.shiftKey);
if (command === null) {
return false;
}
event.preventDefault();
editor.dispatchCommand(command, undefined);
return true;
}, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.INSERT_TAB_COMMAND, () => {
const selection = lexical.$getSelectio