@lexical/devtools-core
Version:
This package contains tools necessary to debug and develop Lexical.
559 lines (543 loc) • 21.8 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.
*
*/
;
var html = require('@lexical/html');
var link = require('@lexical/link');
var mark = require('@lexical/mark');
var table = require('@lexical/table');
var lexical = require('lexical');
var react = require('react');
var jsxRuntime = require('react/jsx-runtime');
/**
* 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 NON_SINGLE_WIDTH_CHARS_REPLACEMENT = 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 = Object.freeze({
ancestorHasNextSibling: '|',
ancestorIsLastChild: ' ',
hasNextSibling: '├',
isLastChild: '└',
selectedChar: '^',
selectedLine: '>'
});
const FORMAT_PREDICATES = [node => node.hasFormat('bold') && 'Bold', node => node.hasFormat('code') && 'Code', node => node.hasFormat('italic') && 'Italic', node => node.hasFormat('strikethrough') && 'Strikethrough', node => node.hasFormat('subscript') && 'Subscript', node => node.hasFormat('superscript') && 'Superscript', node => node.hasFormat('underline') && 'Underline', node => node.hasFormat('highlight') && 'Highlight'];
const FORMAT_PREDICATES_PARAGRAPH = [node => node.hasTextFormat('bold') && 'Bold', node => node.hasTextFormat('code') && 'Code', node => node.hasTextFormat('italic') && 'Italic', node => node.hasTextFormat('strikethrough') && 'Strikethrough', node => node.hasTextFormat('subscript') && 'Subscript', node => node.hasTextFormat('superscript') && 'Superscript', node => node.hasTextFormat('underline') && 'Underline', node => node.hasTextFormat('highlight') && 'Highlight'];
const DETAIL_PREDICATES = [node => node.isDirectionless() && 'Directionless', node => node.isUnmergeable() && 'Unmergeable'];
const MODE_PREDICATES = [node => node.isToken() && 'Token', node => node.isSegmented() && 'Segmented'];
function generateContent(editor, commandsLog, exportDOM, customPrintNode, obfuscateText = false) {
const editorState = editor.getEditorState();
const editorConfig = editor._config;
const compositionKey = editor._compositionKey;
const editable = editor._editable;
if (exportDOM) {
let htmlString = '';
editorState.read(() => {
htmlString = printPrettyHTML(html.$generateHtmlFromNodes(editor));
});
return htmlString;
}
let res = ' root\n';
const selectionString = editorState.read(() => {
const selection = lexical.$getSelection();
visitTree(lexical.$getRoot(), (node, indent) => {
const nodeKey = node.getKey();
const nodeKeyDisplay = `(${nodeKey})`;
const typeDisplay = node.getType() || '';
const isSelected = node.isSelected();
res += `${isSelected ? SYMBOLS.selectedLine : ' '} ${indent.join(' ')} ${nodeKeyDisplay} ${typeDisplay} ${printNode(node, customPrintNode, obfuscateText)}\n`;
res += $printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay
});
});
return selection === null ? ': null' : lexical.$isRangeSelection(selection) ? printRangeSelection(selection) : table.$isTableSelection(selection) ? printTableSelection(selection) : printNodeSelection(selection);
});
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) {
let res = '';
const formatText = printFormatProperties(selection);
res += `: range ${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;
}
function printNodeSelection(selection) {
if (!lexical.$isNodeSelection(selection)) {
return '';
}
return `: node\n └ [${Array.from(selection._nodes).join(', ')}]`;
}
function printTableSelection(selection) {
return `: table\n └ { table: ${selection.tableKey}, anchorCell: ${selection.anchor.key}, focusCell: ${selection.focus.key} }`;
}
function visitTree(currentNode, visitor, indent = []) {
const childNodes = currentNode.getChildren();
const childNodesLength = childNodes.length;
childNodes.forEach((childNode, i) => {
visitor(childNode, indent.concat(i === childNodesLength - 1 ? SYMBOLS.isLastChild : SYMBOLS.hasNextSibling));
if (lexical.$isElementNode(childNode)) {
visitTree(childNode, visitor, indent.concat(i === childNodesLength - 1 ? SYMBOLS.ancestorIsLastChild : SYMBOLS.ancestorHasNextSibling));
}
});
}
function normalize(text, obfuscateText = 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, customPrintNode, obfuscateText = false) {
const customPrint = customPrintNode ? customPrintNode(node, obfuscateText) : undefined;
if (customPrint !== undefined && customPrint.length > 0) {
return customPrint;
}
if (lexical.$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 (link.$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 (mark.$isMarkNode(node)) {
return `ids: [ ${node.getIDs().join(', ')} ]`;
} else if (lexical.$isParagraphNode(node)) {
const formatText = printTextFormatProperties(node);
let paragraphData = formatText !== '' ? `{ ${formatText} }` : '';
paragraphData += node.__style ? `(${node.__style})` : '';
return paragraphData;
} else {
return '';
}
}
function printTextFormatProperties(nodeOrSelection) {
let str = FORMAT_PREDICATES_PARAGRAPH.map(predicate => predicate(nodeOrSelection)).filter(Boolean).join(', ').toLocaleLowerCase();
if (str !== '') {
str = 'format: ' + str;
}
return str;
}
function printAllTextNodeProperties(node) {
return [printFormatProperties(node), printDetailProperties(node), printModeProperties(node), printStateProperties(node)].filter(Boolean).join(', ');
}
function printAllLinkNodeProperties(node) {
return [printTargetProperties(node), printRelProperties(node), printTitleProperties(node), printStateProperties(node)].filter(Boolean).join(', ');
}
function printDetailProperties(nodeOrSelection) {
let str = DETAIL_PREDICATES.map(predicate => predicate(nodeOrSelection)).filter(Boolean).join(', ').toLocaleLowerCase();
if (str !== '') {
str = 'detail: ' + str;
}
return str;
}
function printModeProperties(nodeOrSelection) {
let str = MODE_PREDICATES.map(predicate => predicate(nodeOrSelection)).filter(Boolean).join(', ').toLocaleLowerCase();
if (str !== '') {
str = 'mode: ' + str;
}
return str;
}
function printFormatProperties(nodeOrSelection) {
let str = FORMAT_PREDICATES.map(predicate => predicate(nodeOrSelection)).filter(Boolean).join(', ').toLocaleLowerCase();
if (str !== '') {
str = 'format: ' + str;
}
return str;
}
function printTargetProperties(node) {
let str = node.getTarget();
// TODO Fix nullish on LinkNode
if (str != null) {
str = 'target: ' + str;
}
return str;
}
function printRelProperties(node) {
let str = node.getRel();
// TODO Fix nullish on LinkNode
if (str != null) {
str = 'rel: ' + str;
}
return str;
}
function printTitleProperties(node) {
let str = node.getTitle();
// TODO Fix nullish on LinkNode
if (str != null) {
str = 'title: ' + str;
}
return str;
}
function printStateProperties(node) {
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
}) {
// No selection or node is not selected.
if (!lexical.$isTextNode(node) || !lexical.$isRangeSelection(selection) || !isSelected || lexical.$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) {
const div = document.createElement('div');
div.innerHTML = str.trim();
return prettifyHTML(div, 0).innerHTML;
}
function prettifyHTML(node, level) {
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++) {
textNode = document.createTextNode('\n' + indentBefore);
node.insertBefore(textNode, node.children[i]);
prettifyHTML(node.children[i], level);
if (node.lastElementChild === node.children[i]) {
textNode = document.createTextNode('\n' + indentAfter);
node.appendChild(textNode);
}
}
return node;
}
function $getSelectionStartEnd(node, selection) {
const anchorAndFocus = selection.getStartEndPoints();
if (lexical.$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];
}
/**
* 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 LARGE_EDITOR_STATE_SIZE = 1000;
const TreeView = /*#__PURE__*/react.forwardRef(function TreeViewWrapped({
treeTypeButtonClassName,
timeTravelButtonClassName,
timeTravelPanelSliderClassName,
timeTravelPanelButtonClassName,
viewClassName,
timeTravelPanelClassName,
editorState,
setEditorState,
setEditorReadOnly,
generateContent,
commandsLog = []
}, ref) {
const [timeStampedEditorStates, setTimeStampedEditorStates] = react.useState([]);
const [content, setContent] = react.useState('');
const [timeTravelEnabled, setTimeTravelEnabled] = react.useState(false);
const [showExportDOM, setShowExportDOM] = react.useState(false);
const playingIndexRef = react.useRef(0);
const inputRef = react.useRef(null);
const [isPlaying, setIsPlaying] = react.useState(false);
const [isLimited, setIsLimited] = react.useState(false);
const [showLimited, setShowLimited] = react.useState(false);
const lastEditorStateRef = react.useRef();
const lastCommandsLogRef = react.useRef([]);
const lastGenerationID = react.useRef(0);
const generateTree = react.useCallback(exportDOM => {
const myID = ++lastGenerationID.current;
generateContent(exportDOM).then(treeText => {
if (myID === lastGenerationID.current) {
setContent(treeText);
}
}).catch(err => {
if (myID === lastGenerationID.current) {
setContent(`Error rendering tree: ${err.message}\n\nStack:\n${err.stack}`);
}
});
}, [generateContent]);
react.useEffect(() => {
if (!showLimited && editorState._nodeMap.size > LARGE_EDITOR_STATE_SIZE) {
setIsLimited(true);
if (!showLimited) {
return;
}
}
// Update view when either editor state changes or new commands are logged
const shouldUpdate = lastEditorStateRef.current !== editorState || lastCommandsLogRef.current !== commandsLog;
if (shouldUpdate) {
// Check if it's a real editor state change
const isEditorStateChange = lastEditorStateRef.current !== editorState;
lastEditorStateRef.current = editorState;
lastCommandsLogRef.current = commandsLog;
generateTree(showExportDOM);
// Only record in time travel if there was an actual editor state change
if (!timeTravelEnabled && isEditorStateChange) {
setTimeStampedEditorStates(currentEditorStates => [...currentEditorStates, [Date.now(), editorState]]);
}
}
}, [editorState, generateTree, showExportDOM, showLimited, timeTravelEnabled, commandsLog]);
const totalEditorStates = timeStampedEditorStates.length;
react.useEffect(() => {
if (isPlaying) {
let timeoutId;
const play = () => {
const currentIndex = playingIndexRef.current;
if (currentIndex === totalEditorStates - 1) {
setIsPlaying(false);
return;
}
const currentTime = timeStampedEditorStates[currentIndex][0];
const nextTime = timeStampedEditorStates[currentIndex + 1][0];
const timeDiff = nextTime - currentTime;
timeoutId = setTimeout(() => {
playingIndexRef.current++;
const index = playingIndexRef.current;
const input = inputRef.current;
if (input !== null) {
input.value = String(index);
}
setEditorState(timeStampedEditorStates[index][1]);
play();
}, timeDiff);
};
play();
return () => {
clearTimeout(timeoutId);
};
}
}, [timeStampedEditorStates, isPlaying, totalEditorStates, setEditorState]);
const handleExportModeToggleClick = () => {
generateTree(!showExportDOM);
setShowExportDOM(!showExportDOM);
};
return /*#__PURE__*/jsxRuntime.jsxs("div", {
className: viewClassName,
children: [!showLimited && isLimited ? /*#__PURE__*/jsxRuntime.jsxs("div", {
style: {
padding: 20
},
children: [/*#__PURE__*/jsxRuntime.jsx("span", {
style: {
marginRight: 20
},
children: "Detected large EditorState, this can impact debugging performance."
}), /*#__PURE__*/jsxRuntime.jsx("button", {
onClick: () => {
setShowLimited(true);
},
style: {
background: 'transparent',
border: '1px solid white',
color: 'white',
cursor: 'pointer',
padding: 5
},
children: "Show full tree"
})]
}) : null, !showLimited ? /*#__PURE__*/jsxRuntime.jsx("button", {
onClick: () => handleExportModeToggleClick(),
className: treeTypeButtonClassName,
type: "button",
children: showExportDOM ? 'Tree' : 'Export DOM'
}) : null, !timeTravelEnabled && (showLimited || !isLimited) && totalEditorStates > 2 && /*#__PURE__*/jsxRuntime.jsx("button", {
onClick: () => {
setEditorReadOnly(true);
playingIndexRef.current = totalEditorStates - 1;
setTimeTravelEnabled(true);
},
className: timeTravelButtonClassName,
type: "button",
children: "Time Travel"
}), (showLimited || !isLimited) && /*#__PURE__*/jsxRuntime.jsx("pre", {
ref: ref,
children: content
}), timeTravelEnabled && (showLimited || !isLimited) && /*#__PURE__*/jsxRuntime.jsxs("div", {
className: timeTravelPanelClassName,
children: [/*#__PURE__*/jsxRuntime.jsx("button", {
className: timeTravelPanelButtonClassName,
onClick: () => {
if (playingIndexRef.current === totalEditorStates - 1) {
playingIndexRef.current = 1;
}
setIsPlaying(!isPlaying);
},
type: "button",
children: isPlaying ? 'Pause' : 'Play'
}), /*#__PURE__*/jsxRuntime.jsx("input", {
className: timeTravelPanelSliderClassName,
ref: inputRef,
onChange: event => {
const editorStateIndex = Number(event.target.value);
const timeStampedEditorState = timeStampedEditorStates[editorStateIndex];
if (timeStampedEditorState) {
playingIndexRef.current = editorStateIndex;
setEditorState(timeStampedEditorState[1]);
}
},
type: "range",
min: "1",
max: totalEditorStates - 1
}), /*#__PURE__*/jsxRuntime.jsx("button", {
className: timeTravelPanelButtonClassName,
onClick: () => {
setEditorReadOnly(false);
const index = timeStampedEditorStates.length - 1;
const timeStampedEditorState = timeStampedEditorStates[index];
setEditorState(timeStampedEditorState[1]);
const input = inputRef.current;
if (input !== null) {
input.value = String(index);
}
setTimeTravelEnabled(false);
setIsPlaying(false);
},
type: "button",
children: "Exit"
})]
})]
});
});
/**
* 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.
*
*/
function registerLexicalCommandLogger(editor, setLoggedCommands) {
const unregisterCommandListeners = new Set();
let i = 0;
for (const [command] of editor._commands) {
unregisterCommandListeners.add(editor.registerCommand(command, payload => {
setLoggedCommands(state => {
i += 1;
const newState = [...state];
newState.push({
index: i,
payload,
type: command.type ? command.type : 'UNKNOWN'
});
if (newState.length > 10) {
newState.shift();
}
return newState;
});
return false;
}, lexical.COMMAND_PRIORITY_CRITICAL));
}
return () => unregisterCommandListeners.forEach(unregister => unregister());
}
function useLexicalCommandsLog(editor) {
const [loggedCommands, setLoggedCommands] = react.useState([]);
react.useEffect(() => {
return registerLexicalCommandLogger(editor, setLoggedCommands);
}, [editor]);
return react.useMemo(() => loggedCommands, [loggedCommands]);
}
exports.TreeView = TreeView;
exports.generateContent = generateContent;
exports.registerLexicalCommandLogger = registerLexicalCommandLogger;
exports.useLexicalCommandsLog = useLexicalCommandsLog;