diff --git a/.flowconfig b/.flowconfig index 0cb79250d82..c37a1b103fb 100644 --- a/.flowconfig +++ b/.flowconfig @@ -21,6 +21,7 @@ exact_by_default=true module.name_mapper='^lexical$' -> '/packages/lexical/flow/Lexical.js.flow' module.name_mapper='^@lexical/clipboard' -> '/packages/lexical-clipboard/flow/LexicalClipboard.js.flow' +module.name_mapper='^@lexical/devtools-core' -> '/packages/devtools-core/flow/LexicalDevToolsCore.js.flow' module.name_mapper='^@lexical/list' -> '/packages/lexical-list/flow/LexicalList.js.flow' module.name_mapper='^@lexical/table' -> '/packages/lexical-table/flow/LexicalTable.js.flow' module.name_mapper='^@lexical/file' -> '/packages/lexical-file/flow/LexicalFile.js.flow' @@ -125,4 +126,4 @@ nonstrict-import unclear-type [version] -^0.226.0 \ No newline at end of file +^0.226.0 diff --git a/jest.config.js b/jest.config.js index 2abd936ad2c..2aa0744776b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,6 +29,8 @@ module.exports = { '^@lexical/clipboard$': '/packages/lexical-clipboard/src/index.ts', '^@lexical/code$': '/packages/lexical-code/src/index.ts', + '^@lexical/devtools-core$': + '/packages/lexical-devools-core/src/index.ts', '^@lexical/dragon$': '/packages/lexical-dragon/src/index.ts', '^@lexical/file$': '/packages/lexical-file/src/index.ts', '^@lexical/hashtag$': '/packages/lexical-hashtag/src/index.ts', diff --git a/package-lock.json b/package-lock.json index 963a14a118f..487720c0ab9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/lexical-devtools-core/LexicalDevtoolsCore.js b/packages/lexical-devtools-core/LexicalDevtoolsCore.js new file mode 100644 index 00000000000..4cc48dfe64a --- /dev/null +++ b/packages/lexical-devtools-core/LexicalDevtoolsCore.js @@ -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'); diff --git a/packages/lexical-devtools-core/README.md b/packages/lexical-devtools-core/README.md new file mode 100644 index 00000000000..008d2198b68 --- /dev/null +++ b/packages/lexical-devtools-core/README.md @@ -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. diff --git a/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow b/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow new file mode 100644 index 00000000000..e124beb2302 --- /dev/null +++ b/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow @@ -0,0 +1,8 @@ +/** + * 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. + * + * @flow strict + */ diff --git a/packages/lexical-devtools-core/package.json b/packages/lexical-devtools-core/package.json new file mode 100644 index 00000000000..cf7645e988e --- /dev/null +++ b/packages/lexical-devtools-core/package.json @@ -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" + } + } + } +} diff --git a/packages/lexical-devtools-core/src/TreeView.tsx b/packages/lexical-devtools-core/src/TreeView.tsx new file mode 100644 index 00000000000..0ed8aea6ec4 --- /dev/null +++ b/packages/lexical-devtools-core/src/TreeView.tsx @@ -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; + 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(''); + const [timeTravelEnabled, setTimeTravelEnabled] = useState(false); + const [showExportDOM, setShowExportDOM] = useState(false); + const playingIndexRef = useRef(0); + const inputRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLimited, setIsLimited] = useState(false); + const [showLimited, setShowLimited] = useState(false); + const lastEditorStateRef = useRef(); + 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; + + 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 ( +
+ {!showLimited && isLimited ? ( +
+ + Detected large EditorState, this can impact debugging performance. + + +
+ ) : null} + {!showLimited ? ( + + ) : null} + {!timeTravelEnabled && + (showLimited || !isLimited) && + totalEditorStates > 2 && ( + + )} + {(showLimited || !isLimited) &&
{content}
} + {timeTravelEnabled && (showLimited || !isLimited) && ( +
+ + { + 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} + /> + +
+ )} +
+ ); +}); diff --git a/packages/lexical-devtools-core/src/generateContent.ts b/packages/lexical-devtools-core/src/generateContent.ts new file mode 100644 index 00000000000..db702c6ddd0 --- /dev/null +++ b/packages/lexical-devtools-core/src/generateContent.ts @@ -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> = + 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 = 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 & {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) => { + 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) => void, + indent: Array = [], +) { + 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; + 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, + ]; +} diff --git a/packages/lexical-devtools-core/src/index.ts b/packages/lexical-devtools-core/src/index.ts new file mode 100644 index 00000000000..a6e35d23e59 --- /dev/null +++ b/packages/lexical-devtools-core/src/index.ts @@ -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'; diff --git a/packages/lexical-devtools-core/src/useLexicalCommandsLog.ts b/packages/lexical-devtools-core/src/useLexicalCommandsLog.ts new file mode 100644 index 00000000000..2b41f260ac8 --- /dev/null +++ b/packages/lexical-devtools-core/src/useLexicalCommandsLog.ts @@ -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 & {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([]); + + useEffect(() => { + return registerLexicalCommandLogger(editor, setLoggedCommands); + }, [editor]); + + return useMemo(() => loggedCommands, [loggedCommands]); +} diff --git a/packages/lexical-devtools/package.json b/packages/lexical-devtools/package.json index c796300dded..ed8e52a5b9c 100644 --- a/packages/lexical-devtools/package.json +++ b/packages/lexical-devtools/package.json @@ -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" } } diff --git a/packages/lexical-devtools/src/entrypoints/content/index.ts b/packages/lexical-devtools/src/entrypoints/content/index.ts index 99e86c43ee7..69171ba4c62 100644 --- a/packages/lexical-devtools/src/entrypoints/content/index.ts +++ b/packages/lexical-devtools/src/entrypoints/content/index.ts @@ -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); }, diff --git a/packages/lexical-devtools/src/entrypoints/devtools-panel/App.css b/packages/lexical-devtools/src/entrypoints/devtools-panel/App.css new file mode 100644 index 00000000000..c28b42a72b1 --- /dev/null +++ b/packages/lexical-devtools/src/entrypoints/devtools-panel/App.css @@ -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; +} diff --git a/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx b/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx index bcfb1cb146f..404da2e32bd 100644 --- a/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx +++ b/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx @@ -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) { <> {errorMessage !== '' ? ( @@ -42,7 +50,7 @@ function App({tabID}: Props) { ) : ( Found {lexicalCount} editor{lexicalCount > 1 ? 's' : ''} on - the page + the page. )}

@@ -54,17 +62,41 @@ function App({tabID}: Props) {

{Object.entries(states).map(([key, state]) => ( -

+

ID: {key}
-