feat(@lexical/devtools): Added TreeView rendering instead of a simple textarea (#5830)

This commit is contained in:
Vlad Fedosov
2024-04-08 22:07:02 -04:00
committed by GitHub
parent 340d448e44
commit dc607587ff
34 changed files with 1791 additions and 831 deletions

274
package-lock.json generated
View File

@ -4901,6 +4901,10 @@
"resolved": "packages/lexical-devtools",
"link": true
},
"node_modules/@lexical/devtools-core": {
"resolved": "packages/lexical-devtools-core",
"link": true
},
"node_modules/@lexical/dragon": {
"resolved": "packages/lexical-dragon",
"link": true
@ -27557,9 +27561,9 @@
}
},
"node_modules/typedoc": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.12.tgz",
"integrity": "sha512-F+qhkK2VoTweDXd1c42GS/By2DvI2uDF4/EpG424dTexSHdtCH52C6IcAvMA6jR3DzAWZjHpUOW+E02kyPNUNw==",
"version": "0.25.13",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz",
"integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==",
"dev": true,
"dependencies": {
"lunr": "^2.3.9",
@ -31045,6 +31049,8 @@
"zustand": "^4.5.1"
},
"devDependencies": {
"@lexical/devtools-core": "0.14.2",
"@rollup/plugin-babel": "^6.0.4",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
@ -31054,6 +31060,153 @@
"wxt": "^0.17.0"
}
},
"packages/lexical-devtools-core": {
"name": "@lexical/devtools-core",
"version": "0.14.2",
"license": "MIT",
"dependencies": {
"@lexical/html": "0.14.2",
"@lexical/link": "0.14.2",
"@lexical/mark": "0.14.2",
"@lexical/table": "0.14.2",
"@lexical/utils": "0.14.2",
"lexical": "0.14.2"
},
"peerDependencies": {
"react": ">=17.x",
"react-dom": ">=17.x"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/html": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.14.2.tgz",
"integrity": "sha512-5uL0wSfS9H5/HNeCM4QaJMekoL1w4D81361RlC2ppKt1diSzLiWOITX1qElaTcnDJBGez5mv1ZNiRTutYOPV4Q==",
"dependencies": {
"@lexical/selection": "0.14.2",
"@lexical/utils": "0.14.2"
},
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/link": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.14.2.tgz",
"integrity": "sha512-XD4VdxtBm9Yx5vk2hDEDKY1BjgNVdfmxQHo6Y/kyImAHhGRiBWa6V1+l55qfgcjPW3tN2QY/gSKDCPQGk7vKJw==",
"dependencies": {
"@lexical/utils": "0.14.2"
},
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/list": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.14.2.tgz",
"integrity": "sha512-74MVHcYtTC5Plj+GGRV08uk9qbI1AaKc37NGLe3T08aVBqzqxXl1qZK9BhrM2mqTVXB98ZnOXkBk+07vke+b0Q==",
"dependencies": {
"@lexical/utils": "0.14.2"
},
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/mark": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.14.2.tgz",
"integrity": "sha512-8G1p2tuUkymWXvWgUUShZp5AgYIODUZrBYDpGsCBNkXuSdGagOirS5LhKeiT/68BnrPzGrlnCdmomnI/kMxh6w==",
"dependencies": {
"@lexical/utils": "0.14.2"
},
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/selection": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.14.2.tgz",
"integrity": "sha512-M122XXGEiBgaxEhL63d+su0pPri67/GlFIwGC/j3D0TN4Giyt/j0ToHhqvlIF6TfuXlBusIYbSuJ19ny12lCEg==",
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/table": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.14.2.tgz",
"integrity": "sha512-iwsZ5AqkM7RGyU38daK0XgpC8DG0TlEqEYsXhOLjCpAERY/+bgfdjxP8YWtUV5eIgHX0yY7FkqCUZUJSEcbUeA==",
"dependencies": {
"@lexical/utils": "0.14.2"
},
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/@lexical/utils": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.14.2.tgz",
"integrity": "sha512-IGknsaSyQbBJYKJJrrjNPaZuQPsJmFqGrCmNR6DcQNenWrFnmAliQPFA7HbszwRSxOFTo/BCAsIgXRQob6RjOQ==",
"dependencies": {
"@lexical/list": "0.14.2",
"@lexical/selection": "0.14.2",
"@lexical/table": "0.14.2"
},
"peerDependencies": {
"lexical": "0.14.2"
}
},
"packages/lexical-devtools-core/node_modules/lexical": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.14.2.tgz",
"integrity": "sha512-Uxe0jD2T4XY/+WKiVgnV6OH/GmsF1I0YStcSuMR3Alfhnv5MEYuCa482zo+S5zOPjB1x9j/b+TOLtZEMArwELw=="
},
"packages/lexical-devtools/node_modules/@rollup/plugin-babel": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz",
"integrity": "sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==",
"dev": true,
"dependencies": {
"@babel/helper-module-imports": "^7.18.6",
"@rollup/pluginutils": "^5.0.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0",
"@types/babel__core": "^7.1.9",
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"@types/babel__core": {
"optional": true
},
"rollup": {
"optional": true
}
}
},
"packages/lexical-devtools/node_modules/@rollup/pluginutils": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz",
"integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"packages/lexical-devtools/node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -31117,6 +31270,12 @@
"@esbuild/win32-x64": "0.20.2"
}
},
"packages/lexical-devtools/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"packages/lexical-devtools/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -35079,6 +35238,8 @@
"version": "file:packages/lexical-devtools",
"requires": {
"@eduardoac-skimlinks/webext-redux": "3.0.1-release-candidate",
"@lexical/devtools-core": "0.14.2",
"@rollup/plugin-babel": "^6.0.4",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
@ -35092,6 +35253,27 @@
"zustand": "^4.5.1"
},
"dependencies": {
"@rollup/plugin-babel": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz",
"integrity": "sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==",
"dev": true,
"requires": {
"@babel/helper-module-imports": "^7.18.6",
"@rollup/pluginutils": "^5.0.1"
}
},
"@rollup/pluginutils": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz",
"integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==",
"dev": true,
"requires": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
}
},
"@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -35142,6 +35324,12 @@
"@esbuild/win32-x64": "0.20.2"
}
},
"estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -35206,6 +35394,80 @@
}
}
},
"@lexical/devtools-core": {
"version": "file:packages/lexical-devtools-core",
"requires": {
"@lexical/html": "0.14.2",
"@lexical/link": "0.14.2",
"@lexical/mark": "0.14.2",
"@lexical/table": "0.14.2",
"@lexical/utils": "0.14.2",
"lexical": "0.14.2"
},
"dependencies": {
"@lexical/html": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.14.2.tgz",
"integrity": "sha512-5uL0wSfS9H5/HNeCM4QaJMekoL1w4D81361RlC2ppKt1diSzLiWOITX1qElaTcnDJBGez5mv1ZNiRTutYOPV4Q==",
"requires": {
"@lexical/selection": "0.14.2",
"@lexical/utils": "0.14.2"
}
},
"@lexical/link": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.14.2.tgz",
"integrity": "sha512-XD4VdxtBm9Yx5vk2hDEDKY1BjgNVdfmxQHo6Y/kyImAHhGRiBWa6V1+l55qfgcjPW3tN2QY/gSKDCPQGk7vKJw==",
"requires": {
"@lexical/utils": "0.14.2"
}
},
"@lexical/list": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.14.2.tgz",
"integrity": "sha512-74MVHcYtTC5Plj+GGRV08uk9qbI1AaKc37NGLe3T08aVBqzqxXl1qZK9BhrM2mqTVXB98ZnOXkBk+07vke+b0Q==",
"requires": {
"@lexical/utils": "0.14.2"
}
},
"@lexical/mark": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.14.2.tgz",
"integrity": "sha512-8G1p2tuUkymWXvWgUUShZp5AgYIODUZrBYDpGsCBNkXuSdGagOirS5LhKeiT/68BnrPzGrlnCdmomnI/kMxh6w==",
"requires": {
"@lexical/utils": "0.14.2"
}
},
"@lexical/selection": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.14.2.tgz",
"integrity": "sha512-M122XXGEiBgaxEhL63d+su0pPri67/GlFIwGC/j3D0TN4Giyt/j0ToHhqvlIF6TfuXlBusIYbSuJ19ny12lCEg=="
},
"@lexical/table": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.14.2.tgz",
"integrity": "sha512-iwsZ5AqkM7RGyU38daK0XgpC8DG0TlEqEYsXhOLjCpAERY/+bgfdjxP8YWtUV5eIgHX0yY7FkqCUZUJSEcbUeA==",
"requires": {
"@lexical/utils": "0.14.2"
}
},
"@lexical/utils": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.14.2.tgz",
"integrity": "sha512-IGknsaSyQbBJYKJJrrjNPaZuQPsJmFqGrCmNR6DcQNenWrFnmAliQPFA7HbszwRSxOFTo/BCAsIgXRQob6RjOQ==",
"requires": {
"@lexical/list": "0.14.2",
"@lexical/selection": "0.14.2",
"@lexical/table": "0.14.2"
}
},
"lexical": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.14.2.tgz",
"integrity": "sha512-Uxe0jD2T4XY/+WKiVgnV6OH/GmsF1I0YStcSuMR3Alfhnv5MEYuCa482zo+S5zOPjB1x9j/b+TOLtZEMArwELw=="
}
}
},
"@lexical/dragon": {
"version": "file:packages/lexical-dragon",
"requires": {
@ -51410,9 +51672,9 @@
}
},
"typedoc": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.12.tgz",
"integrity": "sha512-F+qhkK2VoTweDXd1c42GS/By2DvI2uDF4/EpG424dTexSHdtCH52C6IcAvMA6jR3DzAWZjHpUOW+E02kyPNUNw==",
"version": "0.25.13",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz",
"integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==",
"dev": true,
"requires": {
"lunr": "^2.3.9",

View File

@ -0,0 +1,11 @@
/**
* 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';
module.exports = require('./dist/LexicalDevtoolsCore.js');

View File

@ -0,0 +1,5 @@
# `@lexical/devtools-core`
[![See API Documentation](https://lexical.dev/img/see-api-documentation.svg)](https://lexical.dev/docs/api/modules/lexical_devtools-core)
This package contains tools necessary to debug and develop Lexical.

View File

@ -0,0 +1,46 @@
{
"name": "@lexical/devtools-core",
"description": "This package contains tools necessary to debug and develop Lexical.",
"keywords": [
"lexical",
"editor",
"rich-text",
"utils"
],
"license": "MIT",
"version": "0.14.2",
"main": "LexicalDevtoolsCore.js",
"types": "index.d.ts",
"dependencies": {
"lexical": "0.14.2",
"@lexical/utils": "0.14.2",
"@lexical/table": "0.14.2",
"@lexical/html": "0.14.2",
"@lexical/mark": "0.14.2",
"@lexical/link": "0.14.2"
},
"peerDependencies": {
"react": ">=17.x",
"react-dom": ">=17.x"
},
"repository": {
"type": "git",
"url": "https://github.com/facebook/lexical",
"directory": "packages/lexical-devtools-core"
},
"module": "LexicalDevtoolsCore.mjs",
"sideEffects": false,
"exports": {
".": {
"import": {
"types": "./index.d.ts",
"node": "./LexicalDevtoolsCore.node.mjs",
"default": "./LexicalDevtoolsCore.mjs"
},
"require": {
"types": "./index.d.ts",
"default": "./LexicalDevtoolsCore.js"
}
}
}
}

View File

@ -0,0 +1,249 @@
/**
* 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 {EditorSetOptions, EditorState} from 'lexical';
import * as React from 'react';
import {forwardRef, useCallback, useEffect, useRef, useState} from 'react';
const LARGE_EDITOR_STATE_SIZE = 1000;
export const TreeView = forwardRef<
HTMLPreElement,
{
editorState: EditorState;
treeTypeButtonClassName: string;
timeTravelButtonClassName: string;
timeTravelPanelButtonClassName: string;
timeTravelPanelClassName: string;
timeTravelPanelSliderClassName: string;
viewClassName: string;
generateContent: (exportDOM: boolean) => Promise<string>;
setEditorState: (state: EditorState, options?: EditorSetOptions) => void;
setEditorReadOnly: (isReadonly: boolean) => void;
}
>(function TreeViewWrapped(
{
treeTypeButtonClassName,
timeTravelButtonClassName,
timeTravelPanelSliderClassName,
timeTravelPanelButtonClassName,
viewClassName,
timeTravelPanelClassName,
editorState,
setEditorState,
setEditorReadOnly,
generateContent,
},
ref,
): JSX.Element {
const [timeStampedEditorStates, setTimeStampedEditorStates] = useState<
Array<[number, EditorState]>
>([]);
const [content, setContent] = useState<string>('');
const [timeTravelEnabled, setTimeTravelEnabled] = useState(false);
const [showExportDOM, setShowExportDOM] = useState(false);
const playingIndexRef = useRef(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLimited, setIsLimited] = useState(false);
const [showLimited, setShowLimited] = useState(false);
const lastEditorStateRef = useRef<null | EditorState>();
const lastGenerationID = useRef(0);
const generateTree = useCallback(
(exportDOM: boolean) => {
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],
);
useEffect(() => {
if (!showLimited && editorState._nodeMap.size > LARGE_EDITOR_STATE_SIZE) {
setIsLimited(true);
if (!showLimited) {
return;
}
}
// Prevent re-rendering if the editor state hasn't changed
if (lastEditorStateRef.current !== editorState) {
lastEditorStateRef.current = editorState;
generateTree(showExportDOM);
if (!timeTravelEnabled) {
setTimeStampedEditorStates((currentEditorStates) => [
...currentEditorStates,
[Date.now(), editorState],
]);
}
}
}, [
editorState,
generateTree,
showExportDOM,
showLimited,
timeTravelEnabled,
]);
const totalEditorStates = timeStampedEditorStates.length;
useEffect(() => {
if (isPlaying) {
let timeoutId: ReturnType<typeof setTimeout>;
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 (
<div className={viewClassName}>
{!showLimited && isLimited ? (
<div style={{padding: 20}}>
<span style={{marginRight: 20}}>
Detected large EditorState, this can impact debugging performance.
</span>
<button
onClick={() => {
setShowLimited(true);
}}
style={{
background: 'transparent',
border: '1px solid white',
color: 'white',
cursor: 'pointer',
padding: 5,
}}>
Show full tree
</button>
</div>
) : null}
{!showLimited ? (
<button
onClick={() => handleExportModeToggleClick()}
className={treeTypeButtonClassName}
type="button">
{showExportDOM ? 'Tree' : 'Export DOM'}
</button>
) : null}
{!timeTravelEnabled &&
(showLimited || !isLimited) &&
totalEditorStates > 2 && (
<button
onClick={() => {
setEditorReadOnly(true);
playingIndexRef.current = totalEditorStates - 1;
setTimeTravelEnabled(true);
}}
className={timeTravelButtonClassName}
type="button">
Time Travel
</button>
)}
{(showLimited || !isLimited) && <pre ref={ref}>{content}</pre>}
{timeTravelEnabled && (showLimited || !isLimited) && (
<div className={timeTravelPanelClassName}>
<button
className={timeTravelPanelButtonClassName}
onClick={() => {
if (playingIndexRef.current === totalEditorStates - 1) {
playingIndexRef.current = 1;
}
setIsPlaying(!isPlaying);
}}
type="button">
{isPlaying ? 'Pause' : 'Play'}
</button>
<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}
/>
<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">
Exit
</button>
</div>
)}
</div>
);
});

View File

@ -0,0 +1,517 @@
/**
* 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 {
BaseSelection,
ElementNode,
LexicalEditor,
LexicalNode,
ParagraphNode,
RangeSelection,
TextNode,
} from 'lexical';
import {$generateHtmlFromNodes} from '@lexical/html';
import {$isLinkNode, LinkNode} from '@lexical/link';
import {$isMarkNode} from '@lexical/mark';
import {$isTableSelection, TableSelection} from '@lexical/table';
import {
$getRoot,
$getSelection,
$isElementNode,
$isNodeSelection,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
LexicalCommand,
} from 'lexical';
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',
];
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',
];
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: ReadonlyArray<LexicalCommand<unknown> & {payload: unknown}>,
exportDOM: boolean,
): 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));
});
return htmlString;
}
let res = ' root\n';
const selectionString = editorState.read(() => {
const selection = $getSelection();
visitTree($getRoot(), (node: LexicalNode, indent: Array<string>) => {
const nodeKey = node.getKey();
const nodeKeyDisplay = `(${nodeKey})`;
const typeDisplay = node.getType() || '';
const isSelected = node.isSelected();
const idsDisplay = $isMarkNode(node)
? ` id: [ ${node.getIDs().join(', ')} ] `
: '';
res += `${isSelected ? SYMBOLS.selectedLine : ' '} ${indent.join(
' ',
)} ${nodeKeyDisplay} ${typeDisplay} ${idsDisplay} ${printNode(node)}\n`;
res += printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay,
});
});
return selection === null
? ': null'
: $isRangeSelection(selection)
? printRangeSelection(selection)
: $isTableSelection(selection)
? printTableSelection(selection)
: printNodeSelection(selection);
});
res += '\n selection' + selectionString;
res += '\n\n commands:';
if (commandsLog.length) {
for (const {type, payload} of commandsLog) {
res += `\n └ { type: ${type}, payload: ${
payload instanceof Event ? payload.constructor.name : payload
} }`;
}
} else {
res += '\n └ None dispatched.';
}
res += '\n\n editor:';
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);
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: 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,
visitor: (node: LexicalNode, indentArr: Array<string>) => void,
indent: Array<string> = [],
) {
const childNodes = currentNode.getChildren();
const childNodesLength = childNodes.length;
childNodes.forEach((childNode, i) => {
visitor(
childNode,
indent.concat(
i === childNodesLength - 1
? SYMBOLS.isLastChild
: SYMBOLS.hasNextSibling,
),
);
if ($isElementNode(childNode)) {
visitTree(
childNode,
visitor,
indent.concat(
i === childNodesLength - 1
? SYMBOLS.ancestorIsLastChild
: SYMBOLS.ancestorHasNextSibling,
),
);
}
});
}
function normalize(text: string) {
return Object.entries(NON_SINGLE_WIDTH_CHARS_REPLACEMENT).reduce(
(acc, [key, value]) => acc.replace(new RegExp(key, 'g'), String(value)),
text,
);
}
// TODO Pass via props to allow customizability
function printNode(node: LexicalNode) {
if ($isTextNode(node)) {
const text = node.getTextContent();
const title = text.length === 0 ? '(empty)' : `"${normalize(text)}"`;
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)}"`;
const properties = printAllLinkNodeProperties(node);
return [title, properties.length !== 0 ? `{ ${properties} }` : null]
.filter(Boolean)
.join(' ')
.trim();
} else if ($isParagraphNode(node)) {
const formatText = printTextFormatProperties(node);
return formatText !== '' ? `{ ${formatText} }` : '';
} 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),
]
.filter(Boolean)
.join(', ');
}
function printAllLinkNodeProperties(node: LinkNode) {
return [
printTargetProperties(node),
printRelProperties(node),
printTitleProperties(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 printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay,
}: {
indent: Array<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 + 3; // 2 for the spaces around + 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) {
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++) {
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: 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,
];
}

View File

@ -0,0 +1,12 @@
/** @module @lexical/devtools-core */
/**
* 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.
*
*/
export * from './generateContent';
export * from './TreeView';
export * from './useLexicalCommandsLog';

View File

@ -0,0 +1,65 @@
/**
* 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 {LexicalEditor} from 'lexical';
import {COMMAND_PRIORITY_CRITICAL, LexicalCommand} from 'lexical';
import {useEffect, useMemo, useState} from 'react';
export type LexicalCommandLog = ReadonlyArray<
LexicalCommand<unknown> & {payload: unknown}
>;
export function registerLexicalCommandLogger(
editor: LexicalEditor,
setLoggedCommands: (
v: (oldValue: LexicalCommandLog) => LexicalCommandLog,
) => void,
): () => void {
const unregisterCommandListeners = new Set<() => void>();
for (const [command] of editor._commands) {
unregisterCommandListeners.add(
editor.registerCommand(
command,
(payload) => {
setLoggedCommands((state) => {
const newState = [...state];
newState.push({
payload,
type: command.type ? command.type : 'UNKNOWN',
});
if (newState.length > 10) {
newState.shift();
}
return newState;
});
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
}
return () => unregisterCommandListeners.forEach((unregister) => unregister());
}
export function useLexicalCommandsLog(
editor: LexicalEditor,
): LexicalCommandLog {
const [loggedCommands, setLoggedCommands] = useState<LexicalCommandLog>([]);
useEffect(() => {
return registerLexicalCommandLogger(editor, setLoggedCommands);
}, [editor]);
return useMemo(() => loggedCommands, [loggedCommands]);
}

View File

@ -27,7 +27,9 @@
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"lexical": "0.14.2",
"@lexical/devtools-core": "0.14.2",
"wxt": "^0.17.0",
"vite": "^5.2.2"
"vite": "^5.2.2",
"@rollup/plugin-babel": "^6.0.4"
}
}

View File

@ -5,21 +5,19 @@
* LICENSE file in the root directory of this source tree.
*
*/
import {allowWindowMessaging, sendMessage} from 'webext-bridge/content-script';
import {allowWindowMessaging} from 'webext-bridge/content-script';
import useExtensionStore from '../../store';
import storeReadyPromise from '../../store-sync/content-script';
import injectScript from './injectScript';
export default defineContentScript({
main(ctx) {
main(_ctx) {
allowWindowMessaging('lexical-extension');
sendMessage('getTabID', null, 'background')
.then((tabID) => {
return storeReadyPromise(useExtensionStore).then(() => {
injectScript('/injected.js');
});
storeReadyPromise(useExtensionStore)
.then(() => {
injectScript('/injected.js');
})
.catch(console.error);
},

View File

@ -0,0 +1,80 @@
pre {
line-height: 1.1;
background: #222;
color: #fff;
margin: 0;
padding: 10px;
font-size: 12px;
overflow: auto;
max-height: 400px;
}
.tree-view-output {
display: block;
background: #222;
color: #fff;
padding: 0;
font-size: 12px;
margin: 1px auto 10px auto;
position: relative;
overflow: hidden;
border-radius: 10px;
}
.debug-timetravel-panel {
overflow: hidden;
padding: 0 0 10px 0;
margin: auto;
display: flex;
}
.debug-timetravel-panel-slider {
padding: 0;
flex: 8;
}
.debug-timetravel-panel-button {
padding: 0;
border: 0;
background: none;
flex: 1;
color: #fff;
font-size: 12px;
}
.debug-timetravel-panel-button:hover {
text-decoration: underline;
cursor: pointer;
}
.debug-timetravel-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 15px;
position: absolute;
background: none;
color: #fff;
}
.debug-timetravel-button:hover {
text-decoration: underline;
cursor: pointer;
}
.debug-treetype-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 85px;
position: absolute;
background: none;
color: #fff;
}
.debug-treetype-button:hover {
text-decoration: underline;
cursor: pointer;
}

View File

@ -6,6 +6,9 @@
*
*/
import './App.css';
import {TreeView} from '@lexical/devtools-core';
import * as React from 'react';
import {useState} from 'react';
import {sendMessage} from 'webext-bridge/devtools';
@ -30,7 +33,12 @@ function App({tabID}: Props) {
<>
<div>
<a href="https://lexical.dev" target="_blank">
<img src={lexicalLogo} className="logo" alt="Lexical logo" />
<img
src={lexicalLogo}
className="logo"
width={150}
alt="Lexical logo"
/>
</a>
</div>
{errorMessage !== '' ? (
@ -42,7 +50,7 @@ function App({tabID}: Props) {
) : (
<span>
Found <b>{lexicalCount}</b> editor{lexicalCount > 1 ? 's' : ''} on
the page
the page.
</span>
)}
<p>
@ -54,17 +62,41 @@ function App({tabID}: Props) {
</p>
</div>
{Object.entries(states).map(([key, state]) => (
<p key={key}>
<div key={key}>
<b>ID: {key}</b>
<br />
<textarea
readOnly={true}
value={JSON.stringify(state)}
rows={5}
cols={150}
<TreeView
viewClassName="tree-view-output"
treeTypeButtonClassName="debug-treetype-button"
timeTravelPanelClassName="debug-timetravel-panel"
timeTravelButtonClassName="debug-timetravel-button"
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
setEditorReadOnly={(isReadonly) =>
sendMessage(
'setEditorReadOnly',
{isReadonly, key},
`window@${tabID}`,
).catch((e) => setErrorMessage(e.stack))
}
editorState={state}
setEditorState={(editorState) =>
sendMessage(
'setEditorState',
{key, state: editorState},
`window@${tabID}`,
).catch((e) => setErrorMessage(e.stack))
}
generateContent={(exportDOM) =>
sendMessage(
'generateTreeViewContent',
{exportDOM, key},
`window@${tabID}`,
)
}
/>
<hr />
</p>
</div>
))}
</>
);

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<div id="root">Loading Lexical DevTools UI...</div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@ -10,7 +10,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import store from '../../store.ts';
import storeReadyPromise from '../../store-sync/content-script';
import storeReadyPromise from '../../store-sync/devtools';
import App from './App.tsx';
const tabID = browser.devtools.inspectedWindow.tabId;

View File

@ -6,60 +6,60 @@
*
*/
import type {LexicalEditor} from 'lexical';
import {generateContent, LexicalCommandLog} from '@lexical/devtools-core';
import {onMessage} from 'webext-bridge/window';
import {StoreApi} from 'zustand';
import {readEditorState} from '../../lexicalForExtension';
import {deserializeEditorState} from '../../serializeEditorState';
import {ExtensionState} from '../../store';
import {LexicalHTMLElement} from '../../types';
import scanAndListenForEditors from './scanAndListenForEditors';
import {
queryLexicalEditorByKey,
queryLexicalNodeByKey,
} from './utils/queryLexicalByKey';
const commandLog = new WeakMap<LexicalEditor, LexicalCommandLog>();
export default async function main(
tabID: number,
extensionStore: StoreApi<ExtensionState>,
) {
onMessage('refreshLexicalEditorsForTabID', () => {
scanAndListenForEditors(tabID, extensionStore);
return null;
});
scanAndListenForEditors(tabID, extensionStore);
}
function scanAndListenForEditors(
tabID: number,
extensionStore: StoreApi<ExtensionState>,
) {
const {setStatesForTab, lexicalState} = extensionStore.getState();
const states = lexicalState[tabID] ?? {};
const editors = queryLexicalNodes().map((node) => node.__lexicalEditor);
setStatesForTab(
tabID,
Object.fromEntries(editors.map((e) => [e._key, e.getEditorState()])),
onMessage('refreshLexicalEditorsForTabID', () =>
scanAndListenForEditors(tabID, extensionStore, commandLog),
);
editors.forEach((editor) => {
if (states[editor._key] !== undefined) {
// already registered
return;
onMessage('generateTreeViewContent', (message) => {
const editor = queryLexicalEditorByKey(message.data.key);
if (editor == null) {
throw new Error(`Can't find editor with key: ${message.data.key}`);
}
editor.registerUpdateListener((event) => {
const oldVal = extensionStore.getState().lexicalState[tabID];
setStatesForTab(tabID, {
...oldVal,
[editor._key]: event.editorState,
});
});
return readEditorState(editor, editor.getEditorState(), () =>
generateContent(
editor,
commandLog.get(editor) ?? [],
message.data.exportDOM,
),
);
});
}
onMessage('setEditorState', (message) => {
const editor = queryLexicalEditorByKey(message.data.key);
if (editor == null) {
throw new Error(`Can't find editor with key: ${message.data.key}`);
}
function queryLexicalNodes(): LexicalHTMLElement[] {
return Array.from(
document.querySelectorAll('div[data-lexical-editor]'),
).filter(isLexicalNode);
}
editor.setEditorState(deserializeEditorState(message.data.state));
});
onMessage('setEditorReadOnly', (message) => {
const editorNode = queryLexicalNodeByKey(message.data.key);
if (editorNode == null) {
throw new Error(`Can't find editor with key: ${message.data.key}`);
}
function isLexicalNode(
node: LexicalHTMLElement | Element,
): node is LexicalHTMLElement {
return (node as LexicalHTMLElement).__lexicalEditor !== undefined;
editorNode.contentEditable = message.data.isReadonly ? 'false' : 'true';
});
scanAndListenForEditors(tabID, extensionStore, commandLog);
}

View File

@ -0,0 +1,58 @@
/**
* 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 {LexicalEditor} from 'lexical';
import {
LexicalCommandLog,
registerLexicalCommandLogger,
} from '@lexical/devtools-core';
import {StoreApi} from 'zustand';
import {serializeEditorState} from '../../serializeEditorState';
import {ExtensionState} from '../../store';
import queryLexicalNodes from './utils/queryLexicalNodes';
export default function scanAndListenForEditors(
tabID: number,
extensionStore: StoreApi<ExtensionState>,
commandLog: WeakMap<LexicalEditor, LexicalCommandLog>,
) {
const {setStatesForTab, lexicalState} = extensionStore.getState();
const states = lexicalState[tabID] ?? {};
const editors = queryLexicalNodes().map((node) => node.__lexicalEditor);
setStatesForTab(
tabID,
Object.fromEntries(
editors.map((e) => {
return [e._key, serializeEditorState(e.getEditorState())];
}),
),
);
editors.forEach((editor) => {
if (states[editor._key] !== undefined) {
// already registered
return;
}
editor.registerUpdateListener((event) => {
const oldVal = extensionStore.getState().lexicalState[tabID];
setStatesForTab(tabID, {
...oldVal,
[editor._key]: serializeEditorState(event.editorState),
});
});
// TODO: validate that this will be garbage collected when the editor node is destroyed
registerLexicalCommandLogger(editor, (setter) => {
const oldVal = commandLog.get(editor) ?? [];
commandLog.set(editor, setter(oldVal));
});
});
}

View File

@ -0,0 +1,26 @@
/**
* 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 {LexicalEditor} from 'lexical';
import {LexicalHTMLElement} from '../../../types';
import queryLexicalNodes from './queryLexicalNodes';
export function queryLexicalEditorByKey(
key: string,
): LexicalEditor | undefined {
return queryLexicalNodes()
.map((node) => node.__lexicalEditor)
.find((lexicalEditor) => lexicalEditor._key === key);
}
export function queryLexicalNodeByKey(
key: string,
): LexicalHTMLElement | undefined {
return queryLexicalNodes().find((node) => node.__lexicalEditor._key === key);
}

View File

@ -0,0 +1,20 @@
/**
* 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 {LexicalHTMLElement} from '../../../types';
export default function queryLexicalNodes(): LexicalHTMLElement[] {
return Array.from(
document.querySelectorAll('div[data-lexical-editor]'),
).filter(isLexicalNode);
}
function isLexicalNode(
node: LexicalHTMLElement | Element,
): node is LexicalHTMLElement {
return (node as LexicalHTMLElement).__lexicalEditor !== undefined;
}

View File

@ -0,0 +1,111 @@
/**
* 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.
*
*/
/**
* Here we amend original Lexical API in order for the extension JS bundle to correctly work with
* the Lexical from the page bundle. This solves for the following issues:
* - Lexical relies on the module variable visibility scope for the "$" prefixed APIs to work correctly.
* And obviously code from the extension bundle does not share the same scope as the page.
* - "instanceof" operator does not work correctly again due to the same issue.
* So we hijack calls to the original Lexical APIs and implement extension specific workarounds
*/
import * as lexical from 'lexicalOriginal';
export * from 'lexicalOriginal';
let activeEditorState: null | lexical.EditorState = null;
let activeEditor: null | lexical.LexicalEditor = null;
let isReadOnlyMode = false;
function getActiveEditorState(): lexical.EditorState {
if (activeEditorState === null) {
throw new Error(
'Unable to find an active editor state. ' +
'State helpers or node methods can only be used ' +
'synchronously during the callback of ' +
'editor.update() or editorState.read().',
);
}
return activeEditorState;
}
function getActiveEditor(): lexical.LexicalEditor {
if (activeEditor === null) {
throw new Error(
'Unable to find an active editor state. ' +
'State helpers or node methods can only be used ' +
'synchronously during the callback of ' +
'editor.update() or editorState.read().',
);
}
return activeEditor;
}
export function $getRoot(): lexical.RootNode {
return getActiveEditorState()._nodeMap.get('root') as lexical.RootNode;
}
export function $getSelection(): null | lexical.BaseSelection {
return getActiveEditorState()._selection;
}
export function $isElementNode(
node: lexical.LexicalNode | null | undefined,
): node is lexical.ElementNode {
const editor = getActiveEditor();
const ElementNode = Object.getPrototypeOf(
editor._nodes.get('paragraph')!.klass,
);
return node instanceof ElementNode;
}
export function $isTextNode(
node: lexical.LexicalNode | null | undefined,
): node is lexical.TextNode {
const editor = getActiveEditor();
const TextNode = editor._nodes.get('text')!.klass;
return node instanceof TextNode;
}
export function $isRangeSelection(x: unknown): x is lexical.RangeSelection {
// Duck typing :P (and not instanceof RangeSelection) because extension operates
// from different JS bundle and has no reference to the RangeSelection used on the page
return x != null && typeof x === 'object' && 'applyDOMRange' in x;
}
export function $isNodeSelection(x: unknown): x is lexical.NodeSelection {
// Duck typing :P (and not instanceof NodeSelection) because extension operates
// from different JS bundle and has no reference to the NodeSelection used on the page
return x != null && typeof x === 'object' && '_nodes' in x;
}
export function readEditorState<V>(
editor: lexical.LexicalEditor,
editorState: lexical.EditorState,
callbackFn: () => V,
): V {
const previousActiveEditorState = activeEditorState;
const previousReadOnlyMode = isReadOnlyMode;
const previousActiveEditor = activeEditor;
activeEditorState = editorState;
isReadOnlyMode = true;
activeEditor = editor;
try {
return callbackFn();
} finally {
activeEditorState = previousActiveEditorState;
isReadOnlyMode = previousReadOnlyMode;
activeEditor = previousActiveEditor;
}
}

View File

@ -0,0 +1,87 @@
/**
* 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 {EditorState} from 'lexical';
// Because we want to restore state to it's original form as it comes back from the store we need to keep original references
// this is a temporary solution that shall be replaced with a deserialization from serialized form
const deserealizationMap = new Map<number, EditorState>();
let nextId = 0;
const serializePoint = (point: object) => {
const newPoint: {
[key: string]: unknown;
} = {};
for (const [key, value] of Object.entries(point)) {
if (key !== '_selection') {
newPoint[key] = value;
}
}
return newPoint;
};
export function deserializeEditorState(editorState: EditorState): EditorState {
if (
'deserealizationID' in editorState &&
typeof editorState.deserealizationID === 'number'
) {
const state = deserealizationMap.get(editorState.deserealizationID);
if (state == null) {
throw new Error(
`Can't find deserealization ref for state with id ${editorState.deserealizationID}`,
);
}
return state;
}
throw new Error(`State doesn't have a deserealizationID`);
}
// The existing editorState.toJSON() does not contain lexicalKeys, and selection info
// therefore, we have a custom serializeEditorState helper
export function serializeEditorState(editorState: EditorState) {
const nodeMap = Object.fromEntries(editorState._nodeMap); // convert from Map structure to JSON-friendly object
const selection = editorState._selection
? Object.assign({}, editorState._selection)
: null;
if (
selection &&
'anchor' in selection &&
typeof selection.anchor === 'object' &&
selection.anchor != null
) {
// remove _selection.anchor._selection property if present in RangeSelection or GridSelection
// otherwise, the recursive structure makes the selection object unserializable
selection.anchor = serializePoint(selection.anchor);
}
if (
selection &&
'focus' in selection &&
typeof selection.focus === 'object' &&
selection.focus != null
) {
// remove _selection.anchor._selection property if present in RangeSelection or GridSelection
// otherwise, the recursive structure makes the selection object unserializable
selection.focus = serializePoint(selection.focus);
}
const myID = nextId++;
deserealizationMap.set(myID, editorState);
return Object.assign({}, editorState, {
_nodeMap: nodeMap,
_selection: selection,
deserealizationID: myID,
toJSON: undefined,
});
}

View File

@ -0,0 +1,18 @@
/**
* 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 {StoreApi} from 'zustand';
import getConfiguration from './internal/getConfiguration';
import storeReadyPromiseBase from './internal/pages';
export default async function storeReadyPromise<T>(
store: StoreApi<T>,
): Promise<void> {
const configuration = getConfiguration(store);
await storeReadyPromiseBase(store, configuration);
}

View File

@ -8,6 +8,8 @@
"jsx": "react-jsx",
"paths": {
"lexical": ["../lexical/src/"],
"lexicalOriginal": ["../lexical/src/"],
"@lexical/devtools-core": ["../lexical-devtools-core/src/"],
"shared/canUseDOM": ["../shared/src/canUseDOM.ts"],
"shared/normalizeClassNames": ["../shared/src/normalizeClassNames.ts"],
"shared/invariant": ["../shared/src/invariant.ts"],

View File

@ -1,10 +1,20 @@
import {ProtocolWithReturn} from 'webext-bridge';
import type {ProtocolWithReturn} from 'webext-bridge';
import type {EditorState} from 'lexical';
declare module 'webext-bridge' {
export interface ProtocolMap {
getTabID: ProtocolWithReturn<null, number>;
storeSyncDispatch: string;
storeSyncGetState: ProtocolWithReturn<null, unknown>;
refreshLexicalEditorsForTabID: ProtocolWithReturn<null, null>;
refreshLexicalEditorsForTabID: ProtocolWithReturn<null, void>;
generateTreeViewContent: ProtocolWithReturn<
{key: string; exportDOM: boolean},
string
>;
setEditorState: ProtocolWithReturn<{key: string; state: EditorState}, void>;
setEditorReadOnly: ProtocolWithReturn<
{key: string; isReadonly: boolean},
void
>;
}
}

View File

@ -5,10 +5,13 @@
* LICENSE file in the root directory of this source tree.
*
*/
import babel from '@rollup/plugin-babel';
import react from '@vitejs/plugin-react';
import * as path from 'path';
import {defineConfig, UserManifest} from 'wxt';
import moduleResolution from '../shared/viteModuleResolution';
// See https://wxt.dev/api/config.html
export default defineConfig({
debug: !!process.env.DEBUG_WXT,
@ -66,7 +69,43 @@ export default defineConfig({
],
},
srcDir: './src',
vite: () => ({
plugins: [react()],
vite: (configEnv) => ({
define: {
__DEV__: configEnv.mode === 'development',
},
plugins: [
babel({
babelHelpers: 'bundled',
babelrc: false,
configFile: false,
exclude: '/**/node_modules/**',
extensions: ['jsx', 'js', 'ts', 'tsx', 'mjs'],
plugins: [
'@babel/plugin-transform-flow-strip-types',
[
require('../../scripts/error-codes/transform-error-messages'),
{
noMinify: true,
},
],
],
presets: ['@babel/preset-react'],
}),
react(),
],
resolve: {
alias: [
// See lexicalForExtension.ts for more details
{
find: /lexical$/,
replacement: path.resolve('./src/lexicalForExtension.ts'),
},
{
find: 'lexicalOriginal',
replacement: path.resolve('../lexical/src/index.ts'),
},
...moduleResolution,
],
},
}),
});

View File

@ -11,8 +11,8 @@ import react from '@vitejs/plugin-react';
import {defineConfig} from 'vite';
import {replaceCodePlugin} from 'vite-plugin-replace';
import moduleResolution from '../shared/viteModuleResolution';
import viteCopyEsm from './viteCopyEsm';
import moduleResolution from './viteModuleResolution';
// https://vitejs.dev/config/
export default defineConfig({

View File

@ -11,8 +11,8 @@ import react from '@vitejs/plugin-react';
import {defineConfig} from 'vite';
import {replaceCodePlugin} from 'vite-plugin-replace';
import moduleResolution from '../shared/viteModuleResolution';
import viteCopyEsm from './viteCopyEsm';
import moduleResolution from './viteModuleResolution';
// https://vitejs.dev/config/
export default defineConfig({

View File

@ -6,53 +6,16 @@
*
*/
import type {
BaseSelection,
EditorState,
ElementNode,
LexicalEditor,
LexicalNode,
ParagraphNode,
RangeSelection,
TextNode,
} from 'lexical';
import type {EditorState, LexicalEditor} from 'lexical';
import {$generateHtmlFromNodes} from '@lexical/html';
import {$isLinkNode, LinkNode} from '@lexical/link';
import {$isMarkNode} from '@lexical/mark';
import {$isTableSelection, TableSelection} from '@lexical/table';
import {mergeRegister} from '@lexical/utils';
import {
$getRoot,
$getSelection,
$isElementNode,
$isNodeSelection,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_HIGH,
LexicalCommand,
} from 'lexical';
generateContent,
TreeView as TreeViewCore,
useLexicalCommandsLog,
} from '@lexical/devtools-core';
import {mergeRegister} from '@lexical/utils';
import * as React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
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: '>',
});
import {useEffect, useState} from 'react';
export function TreeView({
treeTypeButtonClassName,
@ -71,110 +34,23 @@ export function TreeView({
timeTravelPanelSliderClassName: string;
viewClassName: string;
}): JSX.Element {
const [timeStampedEditorStates, setTimeStampedEditorStates] = useState<
Array<[number, EditorState]>
>([]);
const [content, setContent] = useState<string>('');
const [timeTravelEnabled, setTimeTravelEnabled] = useState(false);
const [showExportDOM, setShowExportDOM] = useState(false);
const playingIndexRef = useRef(0);
const treeElementRef = useRef<HTMLPreElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLimited, setIsLimited] = useState(false);
const [showLimited, setShowLimited] = useState(false);
const lastEditorStateRef = useRef<null | EditorState>(null);
const commandsLog = useLexicalCommandsLog(editor);
const generateTree = useCallback(
(editorState: EditorState) => {
const treeText = generateContent(editor, commandsLog, showExportDOM);
setContent(treeText);
if (!timeTravelEnabled) {
setTimeStampedEditorStates((currentEditorStates) => [
...currentEditorStates,
[Date.now(), editorState],
]);
}
},
[commandsLog, editor, timeTravelEnabled, showExportDOM],
const treeElementRef = React.createRef<HTMLPreElement>();
const [editorCurrentState, setEditorCurrentState] = useState<EditorState>(
editor.getEditorState(),
);
useEffect(() => {
const editorState = editor.getEditorState();
if (!showLimited && editorState._nodeMap.size < 1000) {
setContent(generateContent(editor, commandsLog, showExportDOM));
}
}, [commandsLog, editor, showLimited, showExportDOM]);
const commandsLog = useLexicalCommandsLog(editor);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
if (!showLimited && editorState._nodeMap.size > 1000) {
lastEditorStateRef.current = editorState;
setIsLimited(true);
if (!showLimited) {
return;
}
}
generateTree(editorState);
setEditorCurrentState(editorState);
}),
editor.registerEditableListener(() => {
const treeText = generateContent(editor, commandsLog, showExportDOM);
setContent(treeText);
setEditorCurrentState(editor.getEditorState());
}),
);
}, [
commandsLog,
editor,
showExportDOM,
isLimited,
generateTree,
showLimited,
]);
const totalEditorStates = timeStampedEditorStates.length;
useEffect(() => {
if (isPlaying) {
let timeoutId: ReturnType<typeof setTimeout>;
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);
}
editor.setEditorState(timeStampedEditorStates[index][1]);
play();
}, timeDiff);
};
play();
return () => {
clearTimeout(timeoutId);
};
}
}, [timeStampedEditorStates, isPlaying, editor, totalEditorStates]);
}, [editor]);
useEffect(() => {
const element = treeElementRef.current;
@ -188,627 +64,32 @@ export function TreeView({
element.__lexicalEditor = null;
};
}
}, [editor]);
}, [editor, treeElementRef]);
const handleEditorReadOnly = (isReadonly: boolean) => {
const rootElement = editor.getRootElement();
if (rootElement == null) {
return;
}
rootElement.contentEditable = isReadonly ? 'false' : 'true';
};
return (
<div className={viewClassName}>
{!showLimited && isLimited ? (
<div style={{padding: 20}}>
<span style={{marginRight: 20}}>
Detected large EditorState, this can impact debugging performance.
</span>
<button
onClick={() => {
setShowLimited(true);
const editorState = lastEditorStateRef.current;
if (editorState !== null) {
lastEditorStateRef.current = null;
generateTree(editorState);
}
}}
style={{
background: 'transparent',
border: '1px solid white',
color: 'white',
cursor: 'pointer',
padding: 5,
}}>
Show full tree
</button>
</div>
) : null}
{!showLimited ? (
<button
onClick={() => setShowExportDOM(!showExportDOM)}
className={treeTypeButtonClassName}
type="button">
{showExportDOM ? 'Tree' : 'Export DOM'}
</button>
) : null}
{!timeTravelEnabled &&
(showLimited || !isLimited) &&
totalEditorStates > 2 && (
<button
onClick={() => {
const rootElement = editor.getRootElement();
if (rootElement !== null) {
rootElement.contentEditable = 'false';
playingIndexRef.current = totalEditorStates - 1;
setTimeTravelEnabled(true);
}
}}
className={timeTravelButtonClassName}
type="button">
Time Travel
</button>
)}
{(showLimited || !isLimited) && <pre ref={treeElementRef}>{content}</pre>}
{timeTravelEnabled && (showLimited || !isLimited) && (
<div className={timeTravelPanelClassName}>
<button
className={timeTravelPanelButtonClassName}
onClick={() => {
if (playingIndexRef.current === totalEditorStates - 1) {
playingIndexRef.current = 1;
}
setIsPlaying(!isPlaying);
}}
type="button">
{isPlaying ? 'Pause' : 'Play'}
</button>
<input
className={timeTravelPanelSliderClassName}
ref={inputRef}
onChange={(event) => {
const editorStateIndex = Number(event.target.value);
const timeStampedEditorState =
timeStampedEditorStates[editorStateIndex];
if (timeStampedEditorState) {
playingIndexRef.current = editorStateIndex;
editor.setEditorState(timeStampedEditorState[1]);
}
}}
type="range"
min="1"
max={totalEditorStates - 1}
/>
<button
className={timeTravelPanelButtonClassName}
onClick={() => {
const rootElement = editor.getRootElement();
if (rootElement !== null) {
rootElement.contentEditable = 'true';
const index = timeStampedEditorStates.length - 1;
const timeStampedEditorState = timeStampedEditorStates[index];
editor.setEditorState(timeStampedEditorState[1]);
const input = inputRef.current;
if (input !== null) {
input.value = String(index);
}
setTimeTravelEnabled(false);
setIsPlaying(false);
}
}}
type="button">
Exit
</button>
</div>
)}
</div>
<TreeViewCore
treeTypeButtonClassName={treeTypeButtonClassName}
timeTravelButtonClassName={timeTravelButtonClassName}
timeTravelPanelSliderClassName={timeTravelPanelSliderClassName}
timeTravelPanelButtonClassName={timeTravelPanelButtonClassName}
viewClassName={viewClassName}
timeTravelPanelClassName={timeTravelPanelClassName}
setEditorReadOnly={handleEditorReadOnly}
editorState={editorCurrentState}
setEditorState={(state) => editor.setEditorState(state)}
generateContent={async (exportDOM) =>
generateContent(editor, commandsLog, exportDOM)
}
ref={treeElementRef}
/>
);
}
function useLexicalCommandsLog(
editor: LexicalEditor,
): ReadonlyArray<LexicalCommand<unknown> & {payload: unknown}> {
const [loggedCommands, setLoggedCommands] = useState<
ReadonlyArray<LexicalCommand<unknown> & {payload: unknown}>
>([]);
useEffect(() => {
const unregisterCommandListeners = new Set<() => void>();
for (const [command] of editor._commands) {
unregisterCommandListeners.add(
editor.registerCommand(
command,
(payload) => {
setLoggedCommands((state) => {
const newState = [...state];
newState.push({
payload,
type: command.type ? command.type : 'UNKNOWN',
});
if (newState.length > 10) {
newState.shift();
}
return newState;
});
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
}
return () =>
unregisterCommandListeners.forEach((unregister) => unregister());
}, [editor]);
return useMemo(() => loggedCommands, [loggedCommands]);
}
function printRangeSelection(selection: RangeSelection): string {
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: 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 generateContent(
editor: LexicalEditor,
commandsLog: ReadonlyArray<LexicalCommand<unknown> & {payload: unknown}>,
exportDOM: boolean,
): 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));
});
return htmlString;
}
let res = ' root\n';
const selectionString = editorState.read(() => {
const selection = $getSelection();
visitTree($getRoot(), (node: LexicalNode, indent: Array<string>) => {
const nodeKey = node.getKey();
const nodeKeyDisplay = `(${nodeKey})`;
const typeDisplay = node.getType() || '';
const isSelected = node.isSelected();
const idsDisplay = $isMarkNode(node)
? ` id: [ ${node.getIDs().join(', ')} ] `
: '';
res += `${isSelected ? SYMBOLS.selectedLine : ' '} ${indent.join(
' ',
)} ${nodeKeyDisplay} ${typeDisplay} ${idsDisplay} ${printNode(node)}\n`;
res += printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay,
});
});
return selection === null
? ': null'
: $isRangeSelection(selection)
? printRangeSelection(selection)
: $isTableSelection(selection)
? printTableSelection(selection)
: printNodeSelection(selection);
});
res += '\n selection' + selectionString;
res += '\n\n commands:';
if (commandsLog.length) {
for (const {type, payload} of commandsLog) {
res += `\n └ { type: ${type}, payload: ${
payload instanceof Event ? payload.constructor.name : payload
} }`;
}
} else {
res += '\n └ None dispatched.';
}
res += '\n\n editor:';
res += `\n └ namespace ${editorConfig.namespace}`;
if (compositionKey !== null) {
res += `\n └ compositionKey ${compositionKey}`;
}
res += `\n └ editable ${String(editable)}`;
return res;
}
function visitTree(
currentNode: ElementNode,
visitor: (node: LexicalNode, indentArr: Array<string>) => void,
indent: Array<string> = [],
) {
const childNodes = currentNode.getChildren();
const childNodesLength = childNodes.length;
childNodes.forEach((childNode, i) => {
visitor(
childNode,
indent.concat(
i === childNodesLength - 1
? SYMBOLS.isLastChild
: SYMBOLS.hasNextSibling,
),
);
if ($isElementNode(childNode)) {
visitTree(
childNode,
visitor,
indent.concat(
i === childNodesLength - 1
? SYMBOLS.ancestorIsLastChild
: SYMBOLS.ancestorHasNextSibling,
),
);
}
});
}
function normalize(text: string) {
return Object.entries(NON_SINGLE_WIDTH_CHARS_REPLACEMENT).reduce(
(acc, [key, value]) => acc.replace(new RegExp(key, 'g'), String(value)),
text,
);
}
// TODO Pass via props to allow customizability
function printNode(node: LexicalNode) {
if ($isTextNode(node)) {
const text = node.getTextContent();
const title = text.length === 0 ? '(empty)' : `"${normalize(text)}"`;
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)}"`;
const properties = printAllLinkNodeProperties(node);
return [title, properties.length !== 0 ? `{ ${properties} }` : null]
.filter(Boolean)
.join(' ')
.trim();
} else if ($isParagraphNode(node)) {
const formatText = printTextFormatProperties(node);
return formatText !== '' ? `{ ${formatText}}` : '';
} else {
return '';
}
}
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',
];
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',
];
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',
];
function printAllTextNodeProperties(node: TextNode) {
return [
printFormatProperties(node),
printDetailProperties(node),
printModeProperties(node),
]
.filter(Boolean)
.join(', ');
}
function printAllLinkNodeProperties(node: LinkNode) {
return [
printTargetProperties(node),
printRelProperties(node),
printTitleProperties(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 printTextFormatProperties(nodeOrSelection: ParagraphNode) {
let str = FORMAT_PREDICATES_PARAGRAPH.map((predicate) =>
predicate(nodeOrSelection),
)
.filter(Boolean)
.join(', ')
.toLocaleLowerCase();
if (str !== '') {
str = 'format: ' + 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 printSelectedCharsLine({
indent,
isSelected,
node,
nodeKeyDisplay,
selection,
typeDisplay,
}: {
indent: Array<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 + 3; // 2 for the spaces around + 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) {
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++) {
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: 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,
];
}

View File

@ -0,0 +1,8 @@
---
title: ''
sidebar_position: 20
sidebar_label: '@lexical/devtools-core'
cache_reset: 1
---
{@import ../../../lexical-devtools-core/README.md}

View File

@ -38,6 +38,7 @@ const config = {
'../lexical/src/index.ts',
'../lexical-clipboard/src/index.ts',
'../lexical-code/src/index.ts',
'../lexical-devtools-core/src/index.ts',
'../lexical-dragon/src/index.ts',
'../lexical-file/src/index.ts',
'../lexical-hashtag/src/index.ts',

View File

@ -57,6 +57,7 @@ const sidebars = {
'packages/lexical',
'packages/lexical-clipboard',
'packages/lexical-code',
'packages/lexical-devtools-core',
'packages/lexical-dragon',
'packages/lexical-file',
'packages/lexical-hashtag',

View File

@ -15,6 +15,7 @@ export type {
CreateEditorArgs,
EditableListener,
EditorConfig,
EditorSetOptions,
EditorThemeClasses,
HTMLConfig,
Klass,

View File

@ -98,6 +98,10 @@ const moduleResolution = [
find: '@lexical/yjs',
replacement: path.resolve('../lexical-yjs/src/index.ts'),
},
{
find: '@lexical/devtools-core',
replacement: path.resolve('../lexical-devtools-core/src/index.ts'),
},
{
find: 'shared',
replacement: path.resolve('../shared/src'),

View File

@ -148,6 +148,7 @@ const externals = [
'@lexical/overflow',
'@lexical/link',
'@lexical/markdown',
'@lexical/devtools-core',
'react-dom',
'react',
'yjs',
@ -489,6 +490,18 @@ const packages = [
packageName: 'lexical-dragon',
sourcePath: './packages/lexical-dragon/src/',
},
{
modules: [
{
outputFileName: 'LexicalDevtoolsCore',
sourceFileName: 'index.ts',
},
],
name: 'Lexical Devtools Core',
outputPath: './packages/lexical-devtools-core/dist/',
packageName: 'lexical-devtools-core',
sourcePath: './packages/lexical-devtools-core/src/',
},
{
modules: [
{

View File

@ -35,6 +35,7 @@
"@lexical/overflow": ["./packages/lexical-overflow/src/"],
"@lexical/markdown": ["./packages/lexical-markdown/src/"],
"@lexical/mark": ["./packages/lexical-mark/src/"],
"@lexical/devtools-core": ["./packages/lexical-devtools-core/src/"],
"@lexical/react/LexicalComposer": [
"./packages/lexical-react/src/LexicalComposer.tsx"