mirror of
https://github.com/facebook/lexical.git
synced 2025-08-06 16:39:33 +08:00
feat(@lexical/devtools): Added TreeView rendering instead of a simple textarea (#5830)
This commit is contained in:
274
package-lock.json
generated
274
package-lock.json
generated
@ -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",
|
||||
|
11
packages/lexical-devtools-core/LexicalDevtoolsCore.js
Normal file
11
packages/lexical-devtools-core/LexicalDevtoolsCore.js
Normal 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');
|
5
packages/lexical-devtools-core/README.md
Normal file
5
packages/lexical-devtools-core/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# `@lexical/devtools-core`
|
||||
|
||||
[](https://lexical.dev/docs/api/modules/lexical_devtools-core)
|
||||
|
||||
This package contains tools necessary to debug and develop Lexical.
|
46
packages/lexical-devtools-core/package.json
Normal file
46
packages/lexical-devtools-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
249
packages/lexical-devtools-core/src/TreeView.tsx
Normal file
249
packages/lexical-devtools-core/src/TreeView.tsx
Normal 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>
|
||||
);
|
||||
});
|
517
packages/lexical-devtools-core/src/generateContent.ts
Normal file
517
packages/lexical-devtools-core/src/generateContent.ts
Normal 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,
|
||||
];
|
||||
}
|
12
packages/lexical-devtools-core/src/index.ts
Normal file
12
packages/lexical-devtools-core/src/index.ts
Normal 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';
|
65
packages/lexical-devtools-core/src/useLexicalCommandsLog.ts
Normal file
65
packages/lexical-devtools-core/src/useLexicalCommandsLog.ts
Normal 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]);
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
111
packages/lexical-devtools/src/lexicalForExtension.ts
Normal file
111
packages/lexical-devtools/src/lexicalForExtension.ts
Normal 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;
|
||||
}
|
||||
}
|
87
packages/lexical-devtools/src/serializeEditorState.ts
Normal file
87
packages/lexical-devtools/src/serializeEditorState.ts
Normal 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,
|
||||
});
|
||||
}
|
18
packages/lexical-devtools/src/store-sync/devtools.ts
Normal file
18
packages/lexical-devtools/src/store-sync/devtools.ts
Normal 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);
|
||||
}
|
@ -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"],
|
||||
|
14
packages/lexical-devtools/types.d.ts
vendored
14
packages/lexical-devtools/types.d.ts
vendored
@ -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
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -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({
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: ''
|
||||
sidebar_position: 20
|
||||
sidebar_label: '@lexical/devtools-core'
|
||||
cache_reset: 1
|
||||
---
|
||||
|
||||
{@import ../../../lexical-devtools-core/README.md}
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -15,6 +15,7 @@ export type {
|
||||
CreateEditorArgs,
|
||||
EditableListener,
|
||||
EditorConfig,
|
||||
EditorSetOptions,
|
||||
EditorThemeClasses,
|
||||
HTMLConfig,
|
||||
Klass,
|
||||
|
@ -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'),
|
@ -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: [
|
||||
{
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user