@lexical/devtools-core
Version:
This package contains tools necessary to debug and develop Lexical.
672 lines (597 loc) • 19.4 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.
*
*/
import type {LexicalCommandLog} from './useLexicalCommandsLog';
import {$generateHtmlFromNodes} from '@lexical/html';
import {$isLinkNode, type LinkNode} from '@lexical/link';
import {$isMarkNode} from '@lexical/mark';
import {$isTableSelection, type TableSelection} from '@lexical/table';
import {
$getRoot,
$getSelection,
$getSlot,
$getSlotHost,
$getSlotNames,
$getSlotNameWithinHost,
$isDecoratorNode,
$isElementNode,
$isNodeSelection,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
type BaseSelection,
type DecoratorNode,
type ElementNode,
type LexicalEditor,
type LexicalNode,
type ParagraphNode,
type RangeSelection,
type TextNode,
} from 'lexical';
export type CustomPrintNodeFn = (
node: LexicalNode,
obfuscateText?: boolean,
) => string | undefined;
const NON_SINGLE_WIDTH_CHARS_REPLACEMENT: Readonly<Record<string, string>> =
Object.freeze({
'\t': '\\t',
'\n': '\\n',
});
const NON_SINGLE_WIDTH_CHARS_REGEX = new RegExp(
Object.keys(NON_SINGLE_WIDTH_CHARS_REPLACEMENT).join('|'),
'g',
);
const SYMBOLS: Record<string, string> = Object.freeze({
ancestorHasNextSibling: '|',
ancestorIsLastChild: ' ',
hasNextSibling: '├',
isLastChild: '└',
selectedChar: '^',
selectedLine: '>',
});
const FORMAT_PREDICATES = [
(node: TextNode | RangeSelection) => node.hasFormat('bold') && 'Bold',
(node: TextNode | RangeSelection) => node.hasFormat('code') && 'Code',
(node: TextNode | RangeSelection) => node.hasFormat('italic') && 'Italic',
(node: TextNode | RangeSelection) =>
node.hasFormat('strikethrough') && 'Strikethrough',
(node: TextNode | RangeSelection) =>
node.hasFormat('subscript') && 'Subscript',
(node: TextNode | RangeSelection) =>
node.hasFormat('superscript') && 'Superscript',
(node: TextNode | RangeSelection) =>
node.hasFormat('underline') && 'Underline',
(node: TextNode | RangeSelection) =>
node.hasFormat('highlight') && 'Highlight',
];
const FORMAT_PREDICATES_PARAGRAPH = [
(node: ParagraphNode) => node.hasTextFormat('bold') && 'Bold',
(node: ParagraphNode) => node.hasTextFormat('code') && 'Code',
(node: ParagraphNode) => node.hasTextFormat('italic') && 'Italic',
(node: ParagraphNode) =>
node.hasTextFormat('strikethrough') && 'Strikethrough',
(node: ParagraphNode) => node.hasTextFormat('subscript') && 'Subscript',
(node: ParagraphNode) => node.hasTextFormat('superscript') && 'Superscript',
(node: ParagraphNode) => node.hasTextFormat('underline') && 'Underline',
(node: ParagraphNode) => node.hasTextFormat('highlight') && 'Highlight',
];
const DETAIL_PREDICATES = [
(node: TextNode) => node.isDirectionless() && 'Directionless',
(node: TextNode) => node.isUnmergeable() && 'Unmergeable',
];
const MODE_PREDICATES = [
(node: TextNode) => node.isToken() && 'Token',
(node: TextNode) => node.isSegmented() && 'Segmented',
];
export function generateContent(
editor: LexicalEditor,
commandsLog: LexicalCommandLog,
exportDOM: boolean,
customPrintNode?: CustomPrintNodeFn,
obfuscateText: boolean = false,
): string {
const editorState = editor.getEditorState();
const editorConfig = editor._config;
const compositionKey = editor._compositionKey;
const editable = editor._editable;
if (exportDOM) {
let htmlString = '';
editorState.read(
() => {
htmlString = printPrettyHTML($generateHtmlFromNodes(editor));
},
{editor},
);
return htmlString;
}
let res = ' root\n';
const selectionString = editorState.read(
() => {
const selection = $getSelection();
$visitTree(
$getRoot(),
(node: LexicalNode, indent: string[], slotName?: string) => {
const nodeKey = node.getKey();
const nodeKeyDisplay = `(${nodeKey})`;
const typeDisplay = node.getType() || '';
const isSelected = node.isSelected();
const slotPrefix =
slotName !== undefined ? `[slot: ${slotName}] ` : '';
const slotMeta = $slotValueMetaSuffix(node, slotName);
res += `${isSelected ? SYMBOLS.selectedLine : ' '} ${indent.join(
' ',
)} ${nodeKeyDisplay} ${slotPrefix}${typeDisplay}${slotMeta} ${printNode(
node,
customPrintNode,
obfuscateText,
)}\n`;
res += $printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay,
});
},
);
return selection === null
? ': null'
: $isRangeSelection(selection)
? $printRangeSelection(selection)
: $isTableSelection(selection)
? printTableSelection(selection)
: printNodeSelection(selection);
},
{editor},
);
res += '\n selection' + selectionString;
res += '\n\n commands:';
if (commandsLog.length) {
for (const {index, type, payload} of commandsLog) {
res += `\n └ ${index}. { type: ${type}, payload: ${
payload instanceof Event ? payload.constructor.name : payload
} }`;
}
} else {
res += '\n └ None dispatched.';
}
const {version} = editor.constructor;
res += `\n\n editor${version ? ` (v${version})` : ''}:`;
res += `\n └ namespace ${editorConfig.namespace}`;
if (compositionKey !== null) {
res += `\n └ compositionKey ${compositionKey}`;
}
res += `\n └ editable ${String(editable)}`;
return res;
}
function $printRangeSelection(selection: RangeSelection): string {
let res = '';
const formatText = printFormatProperties(selection);
const anchorSlot = $resolveSlotContext(selection.anchor.getNode());
const slotSuffix =
anchorSlot !== null
? ` (in slot "${anchorSlot.name}" of ${anchorSlot.host.getType()} { key: ${anchorSlot.host.getKey()} })`
: '';
res += `: range${slotSuffix} ${formatText !== '' ? `{ ${formatText} }` : ''} ${
selection.style !== '' ? `{ style: ${selection.style} } ` : ''
}`;
const anchor = selection.anchor;
const focus = selection.focus;
const anchorOffset = anchor.offset;
const focusOffset = focus.offset;
res += `\n ├ anchor { key: ${anchor.key}, offset: ${
anchorOffset === null ? 'null' : anchorOffset
}, type: ${anchor.type} }`;
res += `\n └ focus { key: ${focus.key}, offset: ${
focusOffset === null ? 'null' : focusOffset
}, type: ${focus.type} }`;
return res;
}
// Walk up from `node`, following both __parent and __slotHost, until a
// slot child surfaces — i.e. a node whose `$getSlotNameWithinHost` is not
// null. Returns the slot name and the host that owns it, so the devtools
// can show callers like the selection panel which named slot a caret /
// anchor currently sits in.
function $resolveSlotContext(
node: LexicalNode,
): {name: string; host: LexicalNode} | null {
let cursor: LexicalNode | null = node;
while (cursor !== null) {
const slotName = $getSlotNameWithinHost(cursor);
if (slotName !== null) {
const host = $getSlotHost(cursor);
if (host !== null) {
return {host, name: slotName};
}
}
cursor = cursor.getParent() ?? $getSlotHost(cursor);
}
return null;
}
// True when the node is a slot-friendly value that the devtools should
// annotate with its semantic role. Shadow-root ElementNodes (editable
// slot values like a Card's title) get `[shadow-root]`; non-inline
// DecoratorNodes that sit in a slot (atomic decorator slot values like a
// Figure's media) get `[atomic decorator]`. Regular children fall
// through with no suffix. Both branches gate on `slotName !== undefined`
// — other shadow-root ElementNodes (table cells, layout items, page
// content, collapsibles) aren't slot values and shouldn't pick up the
// marker outside this feature's scope.
function $slotValueMetaSuffix(
node: LexicalNode,
slotName: string | undefined,
): string {
if (slotName === undefined) {
return '';
}
if ($isElementNode(node) && node.isShadowRoot()) {
return ' [shadow-root]';
}
if ($isDecoratorNode(node) && !(node as DecoratorNode<unknown>).isInline()) {
return ' [atomic decorator]';
}
return '';
}
function printNodeSelection(selection: BaseSelection): string {
if (!$isNodeSelection(selection)) {
return '';
}
return `: node\n └ [${Array.from(selection._nodes).join(', ')}]`;
}
function printTableSelection(selection: TableSelection): string {
return `: table\n └ { table: ${selection.tableKey}, anchorCell: ${selection.anchor.key}, focusCell: ${selection.focus.key} }`;
}
function $visitTree(
currentNode: ElementNode | DecoratorNode<unknown>,
visitor: (node: LexicalNode, indentArr: string[], slotName?: string) => void,
indent: string[] = [],
) {
// Slot children walk first (slots-first, mirroring the reconciler /
// host DOM order), then the regular children list. `visitor` gets the
// slot name for slot values so the devtools can annotate them.
const slotEntries: [string, LexicalNode][] = [];
for (const slotName of $getSlotNames(currentNode)) {
const slotNode = $getSlot(currentNode, slotName);
if (slotNode !== null) {
slotEntries.push([slotName, slotNode]);
}
}
const childNodes = $isElementNode(currentNode)
? currentNode.getChildren()
: [];
const totalCount = slotEntries.length + childNodes.length;
let i = 0;
for (const [slotName, slotNode] of slotEntries) {
const isLast = i === totalCount - 1;
visitor(
slotNode,
indent.concat(isLast ? SYMBOLS.isLastChild : SYMBOLS.hasNextSibling),
slotName,
);
if ($isElementNode(slotNode) || $isDecoratorNode(slotNode)) {
$visitTree(
slotNode,
visitor,
indent.concat(
isLast ? SYMBOLS.ancestorIsLastChild : SYMBOLS.ancestorHasNextSibling,
),
);
}
i++;
}
for (const childNode of childNodes) {
const isLast = i === totalCount - 1;
visitor(
childNode,
indent.concat(isLast ? SYMBOLS.isLastChild : SYMBOLS.hasNextSibling),
);
// DecoratorNodes can host slots too, so recurse into them like the
// slot-entry loop above; a decorator's slot subtree would otherwise be
// invisible whenever the host is reached through the children channel.
if ($isElementNode(childNode) || $isDecoratorNode(childNode)) {
$visitTree(
childNode,
visitor,
indent.concat(
isLast ? SYMBOLS.ancestorIsLastChild : SYMBOLS.ancestorHasNextSibling,
),
);
}
i++;
}
}
function normalize(text: string, obfuscateText: boolean = false) {
const textToPrint = Object.entries(NON_SINGLE_WIDTH_CHARS_REPLACEMENT).reduce(
(acc, [key, value]) => acc.replace(new RegExp(key, 'g'), String(value)),
text,
);
if (obfuscateText) {
return textToPrint.replace(/[^\s]/g, '*');
}
return textToPrint;
}
function printNode(
node: LexicalNode,
customPrintNode?: CustomPrintNodeFn,
obfuscateText: boolean = false,
) {
const customPrint: string | undefined = customPrintNode
? customPrintNode(node, obfuscateText)
: undefined;
if (customPrint !== undefined && customPrint.length > 0) {
return customPrint;
}
if ($isTextNode(node)) {
const text = node.getTextContent();
const title =
text.length === 0 ? '(empty)' : `"${normalize(text, obfuscateText)}"`;
const properties = printAllTextNodeProperties(node);
return [title, properties.length !== 0 ? `{ ${properties} }` : null]
.filter(Boolean)
.join(' ')
.trim();
} else if ($isLinkNode(node)) {
const link = node.getURL();
const title =
link.length === 0 ? '(empty)' : `"${normalize(link, obfuscateText)}"`;
const properties = printAllLinkNodeProperties(node);
return [title, properties.length !== 0 ? `{ ${properties} }` : null]
.filter(Boolean)
.join(' ')
.trim();
} else if ($isMarkNode(node)) {
return `ids: [ ${node.getIDs().join(', ')} ]`;
} else if ($isParagraphNode(node)) {
const formatText = printTextFormatProperties(node);
let paragraphData = formatText !== '' ? `{ ${formatText} }` : '';
paragraphData += node.__style ? `(${node.__style})` : '';
return paragraphData;
} else {
return '';
}
}
function printTextFormatProperties(nodeOrSelection: ParagraphNode) {
let str = FORMAT_PREDICATES_PARAGRAPH.map(predicate =>
predicate(nodeOrSelection),
)
.filter(Boolean)
.join(', ')
.toLocaleLowerCase();
if (str !== '') {
str = 'format: ' + str;
}
return str;
}
function printAllTextNodeProperties(node: TextNode) {
return [
printFormatProperties(node),
printDetailProperties(node),
printModeProperties(node),
printStateProperties(node),
]
.filter(Boolean)
.join(', ');
}
function printAllLinkNodeProperties(node: LinkNode) {
return [
printTargetProperties(node),
printRelProperties(node),
printTitleProperties(node),
printStateProperties(node),
]
.filter(Boolean)
.join(', ');
}
function printDetailProperties(nodeOrSelection: TextNode) {
let str = DETAIL_PREDICATES.map(predicate => predicate(nodeOrSelection))
.filter(Boolean)
.join(', ')
.toLocaleLowerCase();
if (str !== '') {
str = 'detail: ' + str;
}
return str;
}
function printModeProperties(nodeOrSelection: TextNode) {
let str = MODE_PREDICATES.map(predicate => predicate(nodeOrSelection))
.filter(Boolean)
.join(', ')
.toLocaleLowerCase();
if (str !== '') {
str = 'mode: ' + str;
}
return str;
}
function printFormatProperties(nodeOrSelection: TextNode | RangeSelection) {
let str = FORMAT_PREDICATES.map(predicate => predicate(nodeOrSelection))
.filter(Boolean)
.join(', ')
.toLocaleLowerCase();
if (str !== '') {
str = 'format: ' + str;
}
return str;
}
function printTargetProperties(node: LinkNode) {
let str = node.getTarget();
// TODO Fix nullish on LinkNode
if (str != null) {
str = 'target: ' + str;
}
return str;
}
function printRelProperties(node: LinkNode) {
let str = node.getRel();
// TODO Fix nullish on LinkNode
if (str != null) {
str = 'rel: ' + str;
}
return str;
}
function printTitleProperties(node: LinkNode) {
let str = node.getTitle();
// TODO Fix nullish on LinkNode
if (str != null) {
str = 'title: ' + str;
}
return str;
}
function printStateProperties(node: LexicalNode) {
if (!node.__state) {
return false;
}
const states = [];
for (const [stateType, value] of node.__state.knownState.entries()) {
if (stateType.isEqual(value, stateType.defaultValue)) {
continue;
}
const textValue = JSON.stringify(stateType.unparse(value));
states.push(`[${stateType.key}: ${textValue}]`);
}
let str = states.join(',');
if (str !== '') {
str = 'state: ' + str;
}
return str;
}
function $printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay,
}: {
indent: string[];
isSelected: boolean;
node: LexicalNode;
nodeKeyDisplay: string;
selection: BaseSelection | null;
typeDisplay: string;
}) {
// No selection or node is not selected.
if (
!$isTextNode(node) ||
!$isRangeSelection(selection) ||
!isSelected ||
$isElementNode(node)
) {
return '';
}
// No selected characters.
const anchor = selection.anchor;
const focus = selection.focus;
if (
node.getTextContent() === '' ||
(anchor.getNode() === selection.focus.getNode() &&
anchor.offset === focus.offset)
) {
return '';
}
const [start, end] = $getSelectionStartEnd(node, selection);
if (start === end) {
return '';
}
const selectionLastIndent =
indent[indent.length - 1] === SYMBOLS.hasNextSibling
? SYMBOLS.ancestorHasNextSibling
: SYMBOLS.ancestorIsLastChild;
const indentionChars = [
...indent.slice(0, indent.length - 1),
selectionLastIndent,
];
const unselectedChars = Array(start + 1).fill(' ');
const selectedChars = Array(end - start).fill(SYMBOLS.selectedChar);
const paddingLength = typeDisplay.length + 2; // 1 for the space after + 1 for the double quote.
const nodePrintSpaces = Array(nodeKeyDisplay.length + paddingLength).fill(
' ',
);
return (
[
SYMBOLS.selectedLine,
indentionChars.join(' '),
[...nodePrintSpaces, ...unselectedChars, ...selectedChars].join(''),
].join(' ') + '\n'
);
}
function printPrettyHTML(str: string) {
// eslint-disable-next-line no-restricted-syntax
const div = document.createElement('div');
div.innerHTML = str.trim();
return prettifyHTML(div, 0).innerHTML;
}
function prettifyHTML(node: Element, level: number) {
const indentBefore = new Array(level++ + 1).join(' ');
const indentAfter = new Array(level - 1).join(' ');
let textNode;
for (let i = 0; i < node.children.length; i++) {
// eslint-disable-next-line no-restricted-syntax
textNode = document.createTextNode('\n' + indentBefore);
node.insertBefore(textNode, node.children[i]);
prettifyHTML(node.children[i], level);
if (node.lastElementChild === node.children[i]) {
// eslint-disable-next-line no-restricted-syntax
textNode = document.createTextNode('\n' + indentAfter);
node.appendChild(textNode);
}
}
return node;
}
function $getSelectionStartEnd(
node: LexicalNode,
selection: BaseSelection,
): [number, number] {
const anchorAndFocus = selection.getStartEndPoints();
if ($isNodeSelection(selection) || anchorAndFocus === null) {
return [-1, -1];
}
const [anchor, focus] = anchorAndFocus;
const textContent = node.getTextContent();
const textLength = textContent.length;
let start = -1;
let end = -1;
// Only one node is being selected.
if (anchor.type === 'text' && focus.type === 'text') {
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
if (
anchorNode === focusNode &&
node === anchorNode &&
anchor.offset !== focus.offset
) {
[start, end] =
anchor.offset < focus.offset
? [anchor.offset, focus.offset]
: [focus.offset, anchor.offset];
} else if (node === anchorNode) {
[start, end] = anchorNode.isBefore(focusNode)
? [anchor.offset, textLength]
: [0, anchor.offset];
} else if (node === focusNode) {
[start, end] = focusNode.isBefore(anchorNode)
? [focus.offset, textLength]
: [0, focus.offset];
} else {
// Node is within selection but not the anchor nor focus.
[start, end] = [0, textLength];
}
}
// Account for non-single width characters.
const numNonSingleWidthCharBeforeSelection = (
textContent.slice(0, start).match(NON_SINGLE_WIDTH_CHARS_REGEX) || []
).length;
const numNonSingleWidthCharInSelection = (
textContent.slice(start, end).match(NON_SINGLE_WIDTH_CHARS_REGEX) || []
).length;
return [
start + numNonSingleWidthCharBeforeSelection,
end +
numNonSingleWidthCharBeforeSelection +
numNonSingleWidthCharInSelection,
];
}