Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
/node_modules
/.vscode
/composer
+/coverage
Homestead.yaml
.env
.idea
absWorkingDir: path.join(__dirname, '../..'),
alias: {
'@icons': './resources/icons',
+ lexical: './resources/js/wysiwyg/lexical/core',
+ '@lexical': './resources/js/wysiwyg/lexical',
},
banner: {
js: '// See the "/licenses" URI for full package license details',
--- /dev/null
+/**
+ * For a detailed explanation regarding each configuration property, visit:
+ * https://jestjs.io/docs/configuration
+ */
+
+import type {Config} from 'jest';
+import {pathsToModuleNameMapper} from "ts-jest";
+import { compilerOptions } from './tsconfig.json';
+
+const config: Config = {
+ // All imported modules in your tests should be mocked automatically
+ // automock: false,
+
+ // Stop running tests after `n` failures
+ // bail: 0,
+
+ // The directory where Jest should store its cached dependency information
+ // cacheDirectory: "/tmp/jest_rs",
+
+ // Automatically clear mock calls, instances, contexts and results before every test
+ clearMocks: true,
+
+ // Indicates whether the coverage information should be collected while executing the test
+ collectCoverage: true,
+
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
+ // collectCoverageFrom: undefined,
+
+ // The directory where Jest should output its coverage files
+ coverageDirectory: "coverage",
+
+ // An array of regexp pattern strings used to skip coverage collection
+ // coveragePathIgnorePatterns: [
+ // "/node_modules/"
+ // ],
+
+ // Indicates which provider should be used to instrument code for coverage
+ coverageProvider: "v8",
+
+ // A list of reporter names that Jest uses when writing coverage reports
+ // coverageReporters: [
+ // "json",
+ // "text",
+ // "lcov",
+ // "clover"
+ // ],
+
+ // An object that configures minimum threshold enforcement for coverage results
+ // coverageThreshold: undefined,
+
+ // A path to a custom dependency extractor
+ // dependencyExtractor: undefined,
+
+ // Make calling deprecated APIs throw helpful error messages
+ // errorOnDeprecated: false,
+
+ // The default configuration for fake timers
+ // fakeTimers: {
+ // "enableGlobally": false
+ // },
+
+ // Force coverage collection from ignored files using an array of glob patterns
+ // forceCoverageMatch: [],
+
+ // A path to a module which exports an async function that is triggered once before all test suites
+ // globalSetup: undefined,
+
+ // A path to a module which exports an async function that is triggered once after all test suites
+ // globalTeardown: undefined,
+
+ // A set of global variables that need to be available in all test environments
+ globals: {
+ __DEV__: true,
+ },
+
+ // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
+ // maxWorkers: "50%",
+
+ // An array of directory names to be searched recursively up from the requiring module's location
+ // moduleDirectories: [
+ // "node_modules"
+ // ],
+
+ // An array of file extensions your modules use
+ // moduleFileExtensions: [
+ // "js",
+ // "mjs",
+ // "cjs",
+ // "jsx",
+ // "ts",
+ // "tsx",
+ // "json",
+ // "node"
+ // ],
+
+ modulePaths: ['/home/dan/web/bookstack/'],
+
+ // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
+ moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
+
+ // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
+ // modulePathIgnorePatterns: [],
+
+ // Activates notifications for test results
+ // notify: false,
+
+ // An enum that specifies notification mode. Requires { notify: true }
+ // notifyMode: "failure-change",
+
+ // A preset that is used as a base for Jest's configuration
+ // preset: undefined,
+
+ // Run tests from one or more projects
+ // projects: undefined,
+
+ // Use this configuration option to add custom reporters to Jest
+ // reporters: undefined,
+
+ // Automatically reset mock state before every test
+ // resetMocks: false,
+
+ // Reset the module registry before running each individual test
+ // resetModules: false,
+
+ // A path to a custom resolver
+ // resolver: undefined,
+
+ // Automatically restore mock state and implementation before every test
+ // restoreMocks: false,
+
+ // The root directory that Jest should scan for tests and modules within
+ // rootDir: undefined,
+
+ // A list of paths to directories that Jest should use to search for files in
+ roots: [
+ "./resources/js"
+ ],
+
+ // Allows you to use a custom runner instead of Jest's default test runner
+ // runner: "jest-runner",
+
+ // The paths to modules that run some code to configure or set up the testing environment before each test
+ // setupFiles: [],
+
+ // A list of paths to modules that run some code to configure or set up the testing framework before each test
+ // setupFilesAfterEnv: [],
+
+ // The number of seconds after which a test is considered as slow and reported as such in the results.
+ // slowTestThreshold: 5,
+
+ // A list of paths to snapshot serializer modules Jest should use for snapshot testing
+ // snapshotSerializers: [],
+
+ // The test environment that will be used for testing
+ testEnvironment: "jsdom",
+
+ // Options that will be passed to the testEnvironment
+ // testEnvironmentOptions: {},
+
+ // Adds a location field to test results
+ // testLocationInResults: false,
+
+ // The glob patterns Jest uses to detect test files
+ // testMatch: [
+ // "**/__tests__/**/*.[jt]s?(x)",
+ // "**/?(*.)+(spec|test).[tj]s?(x)"
+ // ],
+
+ // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
+ // testPathIgnorePatterns: [
+ // "/node_modules/"
+ // ],
+
+ // The regexp pattern or array of patterns that Jest uses to detect test files
+ // testRegex: [],
+
+ // This option allows the use of a custom results processor
+ // testResultsProcessor: undefined,
+
+ // This option allows use of a custom test runner
+ // testRunner: "jest-circus/runner",
+
+ // A map from regular expressions to paths to transformers
+ transform: {
+ "^.+.tsx?$": ["ts-jest",{}],
+ },
+
+ // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
+ // transformIgnorePatterns: [
+ // "/node_modules/",
+ // "\\.pnp\\.[^\\/]+$"
+ // ],
+
+ // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
+ // unmockedModulePathPatterns: undefined,
+
+ // Indicates whether each individual test should be reported during the run
+ // verbose: undefined,
+
+ // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
+ // watchPathIgnorePatterns: [],
+
+ // Whether to use watchman for file crawling
+ // watchman: true,
+};
+
+export default config;
"@codemirror/state": "^6.3.3",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.22.2",
- "@lexical/history": "^0.17.0",
- "@lexical/html": "^0.17.0",
- "@lexical/link": "^0.17.0",
- "@lexical/list": "^0.17.0",
- "@lexical/rich-text": "^0.17.0",
- "@lexical/selection": "^0.17.0",
- "@lexical/table": "^0.17.0",
- "@lexical/utils": "^0.17.0",
"@lezer/highlight": "^1.2.0",
"@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
+ "@types/jest": "^29.5.13",
"codemirror": "^6.0.1",
"idb-keyval": "^6.2.1",
- "lexical": "^0.17.0",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"snabbdom": "^3.5.1",
},
"devDependencies": {
"@lezer/generator": "^1.5.1",
+ "babel-jest": "^29.7.0",
"chokidar-cli": "^3.0",
"esbuild": "^0.20",
"eslint": "^8.55.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.0",
+ "jest": "^29.7.0",
+ "jest-environment-jsdom": "^29.7.0",
"livereload": "^0.9.3",
"npm-run-all": "^4.1.5",
"sass": "^1.69.5",
+ "ts-jest": "^29.2.5",
+ "ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
},
- "node_modules/@codemirror/autocomplete": {
- "version": "6.18.0",
- "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz",
- "integrity": "sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==",
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
"dependencies": {
- "@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.17.0",
- "@lezer/common": "^1.0.0"
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
},
- "peerDependencies": {
- "@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0",
- "@lezer/common": "^1.0.0"
+ "engines": {
+ "node": ">=6.0.0"
}
},
- "node_modules/@codemirror/commands": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz",
- "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==",
+ "node_modules/@babel/code-frame": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
+ "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
"dependencies": {
- "@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.4.0",
- "@codemirror/view": "^6.27.0",
- "@lezer/common": "^1.1.0"
+ "@babel/highlight": "^7.24.7",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/lang-css": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz",
- "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==",
- "dependencies": {
- "@codemirror/autocomplete": "^6.0.0",
- "@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@lezer/common": "^1.0.2",
- "@lezer/css": "^1.1.7"
+ "node_modules/@babel/compat-data": {
+ "version": "7.25.4",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
+ "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/lang-html": {
- "version": "6.4.9",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
- "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
+ "node_modules/@babel/core": {
+ "version": "7.25.2",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
+ "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
+ "dev": true,
"dependencies": {
- "@codemirror/autocomplete": "^6.0.0",
- "@codemirror/lang-css": "^6.0.0",
- "@codemirror/lang-javascript": "^6.0.0",
- "@codemirror/language": "^6.4.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.17.0",
- "@lezer/common": "^1.0.0",
- "@lezer/css": "^1.1.0",
- "@lezer/html": "^1.3.0"
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.24.7",
+ "@babel/generator": "^7.25.0",
+ "@babel/helper-compilation-targets": "^7.25.2",
+ "@babel/helper-module-transforms": "^7.25.2",
+ "@babel/helpers": "^7.25.0",
+ "@babel/parser": "^7.25.0",
+ "@babel/template": "^7.25.0",
+ "@babel/traverse": "^7.25.2",
+ "@babel/types": "^7.25.2",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
}
},
- "node_modules/@codemirror/lang-javascript": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
- "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==",
- "dependencies": {
- "@codemirror/autocomplete": "^6.0.0",
- "@codemirror/language": "^6.6.0",
- "@codemirror/lint": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.17.0",
- "@lezer/common": "^1.0.0",
- "@lezer/javascript": "^1.0.0"
+ "node_modules/@babel/core/node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
}
},
- "node_modules/@codemirror/lang-json": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz",
- "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==",
+ "node_modules/@babel/generator": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz",
+ "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==",
+ "dev": true,
"dependencies": {
- "@codemirror/language": "^6.0.0",
- "@lezer/json": "^1.0.0"
+ "@babel/types": "^7.25.6",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "jsesc": "^2.5.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/lang-markdown": {
- "version": "6.2.5",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz",
- "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==",
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.25.2",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
+ "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
+ "dev": true,
"dependencies": {
- "@codemirror/autocomplete": "^6.7.1",
- "@codemirror/lang-html": "^6.0.0",
- "@codemirror/language": "^6.3.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0",
- "@lezer/common": "^1.2.1",
- "@lezer/markdown": "^1.0.0"
+ "@babel/compat-data": "^7.25.2",
+ "@babel/helper-validator-option": "^7.24.8",
+ "browserslist": "^4.23.1",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/lang-php": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz",
- "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==",
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz",
+ "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==",
+ "dev": true,
"dependencies": {
- "@codemirror/lang-html": "^6.0.0",
- "@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@lezer/common": "^1.0.0",
- "@lezer/php": "^1.0.0"
+ "@babel/traverse": "^7.24.7",
+ "@babel/types": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/lang-xml": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
- "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.25.2",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz",
+ "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==",
+ "dev": true,
"dependencies": {
- "@codemirror/autocomplete": "^6.0.0",
- "@codemirror/language": "^6.4.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0",
- "@lezer/common": "^1.0.0",
- "@lezer/xml": "^1.0.0"
+ "@babel/helper-module-imports": "^7.24.7",
+ "@babel/helper-simple-access": "^7.24.7",
+ "@babel/helper-validator-identifier": "^7.24.7",
+ "@babel/traverse": "^7.25.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
}
},
- "node_modules/@codemirror/language": {
- "version": "6.10.2",
- "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz",
- "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==",
- "dependencies": {
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.23.0",
- "@lezer/common": "^1.1.0",
- "@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.0.0",
- "style-mod": "^4.0.0"
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz",
+ "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/legacy-modes": {
- "version": "6.4.1",
- "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.1.tgz",
- "integrity": "sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==",
+ "node_modules/@babel/helper-simple-access": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz",
+ "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==",
+ "dev": true,
"dependencies": {
- "@codemirror/language": "^6.0.0"
+ "@babel/traverse": "^7.24.7",
+ "@babel/types": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/lint": {
- "version": "6.8.1",
- "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz",
- "integrity": "sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==",
- "dependencies": {
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0",
- "crelt": "^1.0.5"
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
+ "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/search": {
- "version": "6.5.6",
- "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz",
- "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==",
- "dependencies": {
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0",
- "crelt": "^1.0.5"
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
+ "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/state": {
- "version": "6.4.1",
- "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
- "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A=="
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
+ "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
},
- "node_modules/@codemirror/theme-one-dark": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz",
- "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==",
+ "node_modules/@babel/helpers": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
+ "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
+ "dev": true,
"dependencies": {
- "@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0",
- "@lezer/highlight": "^1.0.0"
+ "@babel/template": "^7.25.0",
+ "@babel/types": "^7.25.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@codemirror/view": {
- "version": "6.33.0",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.33.0.tgz",
- "integrity": "sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==",
+ "node_modules/@babel/highlight": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
+ "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
"dependencies": {
- "@codemirror/state": "^6.4.0",
- "style-mod": "^4.1.0",
- "w3c-keyname": "^2.2.4"
+ "@babel/helper-validator-identifier": "^7.24.7",
+ "chalk": "^2.4.2",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
- "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "aix"
- ],
+ "node_modules/@babel/highlight/node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=4"
}
},
- "node_modules/@esbuild/android-arm": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
- "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "android"
- ],
+ "node_modules/@babel/highlight/node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=4"
}
},
- "node_modules/@esbuild/android-arm64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
- "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
+ "node_modules/@babel/highlight/node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dependencies": {
+ "color-name": "1.1.3"
}
},
- "node_modules/@esbuild/android-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
- "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "android"
- ],
+ "node_modules/@babel/highlight/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
+ },
+ "node_modules/@babel/highlight/node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"engines": {
- "node": ">=12"
+ "node": ">=0.8.0"
}
},
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
- "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
+ "node_modules/@babel/highlight/node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"engines": {
- "node": ">=12"
+ "node": ">=4"
}
},
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
- "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
+ "node_modules/@babel/highlight/node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=4"
}
},
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
- "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/parser": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
+ "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
"dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
+ "dependencies": {
+ "@babel/types": "^7.25.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=6.0.0"
}
},
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
- "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
"dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-arm": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
- "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@babel/plugin-syntax-bigint": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
+ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
- "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
- "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
- "cpu": [
- "ia32"
- ],
+ "node_modules/@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
- "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
- "cpu": [
- "loong64"
- ],
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.6.tgz",
+ "integrity": "sha512-sXaDXaJN9SNLymBdlWFA+bjzBhFD617ZaFiY13dGt7TVslVvVgA6fkZOP7Ki3IGElC45lwHdOTrCtKZGVAWeLQ==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.24.8"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
- "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
- "cpu": [
- "mips64el"
- ],
+ "node_modules/@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
- "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
- "cpu": [
- "ppc64"
- ],
+ "node_modules/@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
- "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
- "cpu": [
- "riscv64"
- ],
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz",
+ "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.24.7"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
- "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
- "cpu": [
- "s390x"
- ],
+ "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/linux-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
- "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
- "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
"dev": true,
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
- "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
"dev": true,
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
- "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
"dev": true,
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
- "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
"dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
- "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
- "cpu": [
- "ia32"
- ],
+ "node_modules/@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
"dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/win32-x64": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
- "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
"dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
- "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.25.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz",
+ "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==",
"dev": true,
"dependencies": {
- "eslint-visitor-keys": "^3.3.0"
+ "@babel/helper-plugin-utils": "^7.24.8"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": ">=6.9.0"
},
"peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@eslint-community/regexpp": {
- "version": "4.11.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
- "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
+ "node_modules/@babel/template": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
+ "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
"dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.24.7",
+ "@babel/parser": "^7.25.0",
+ "@babel/types": "^7.25.0"
+ },
"engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "node_modules/@babel/traverse": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz",
+ "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==",
"dev": true,
"dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
+ "@babel/code-frame": "^7.24.7",
+ "@babel/generator": "^7.25.6",
+ "@babel/parser": "^7.25.6",
+ "@babel/template": "^7.25.0",
+ "@babel/types": "^7.25.6",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
+ "node": ">=6.9.0"
}
},
- "node_modules/@eslint/js": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
- "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "node_modules/@babel/traverse/node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true,
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": ">=4"
}
},
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.11.14",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
- "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
- "deprecated": "Use @eslint/config-array instead",
+ "node_modules/@babel/types": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
+ "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
"dev": true,
"dependencies": {
- "@humanwhocodes/object-schema": "^2.0.2",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
+ "@babel/helper-string-parser": "^7.24.8",
+ "@babel/helper-validator-identifier": "^7.24.7",
+ "to-fast-properties": "^2.0.0"
},
"engines": {
- "node": ">=10.10.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
+ "node": ">=6.9.0"
}
},
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
- "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
- "deprecated": "Use @eslint/object-schema instead",
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
- "node_modules/@lexical/clipboard": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.17.1.tgz",
- "integrity": "sha512-OVqnEfWX8XN5xxuMPo6BfgGKHREbz++D5V5ISOiml0Z8fV/TQkdgwqbBJcUdJHGRHWSUwdK7CWGs/VALvVvZyw==",
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz",
+ "integrity": "sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==",
"dependencies": {
- "@lexical/html": "0.17.1",
- "@lexical/list": "0.17.1",
- "@lexical/selection": "0.17.1",
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ },
+ "peerDependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0"
}
},
- "node_modules/@lexical/history": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.17.1.tgz",
- "integrity": "sha512-OU/ohajz4FXchUhghsWC7xeBPypFe50FCm5OePwo767G7P233IztgRKIng2pTT4zhCPW7S6Mfl53JoFHKehpWA==",
+ "node_modules/@codemirror/commands": {
+ "version": "6.6.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz",
+ "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==",
"dependencies": {
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
}
},
- "node_modules/@lexical/html": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.17.1.tgz",
- "integrity": "sha512-yGG+K2DXl7Wn2DpNuZ0Y3uCHJgfHkJN3/MmnFb4jLnH1FoJJiuy7WJb/BRRh9H+6xBJ9v70iv+kttDJ0u1xp5w==",
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz",
+ "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==",
"dependencies": {
- "@lexical/selection": "0.17.1",
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
}
},
- "node_modules/@lexical/link": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.17.1.tgz",
- "integrity": "sha512-qFJEKBesZAtR8kfJfIVXRFXVw6dwcpmGCW7duJbtBRjdLjralOxrlVKyFhW9PEXGhi4Mdq2Ux16YnnDncpORdQ==",
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.9",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
+ "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
"dependencies": {
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.0"
}
},
- "node_modules/@lexical/list": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.17.1.tgz",
- "integrity": "sha512-k9ZnmQuBvW+xVUtWJZwoGtiVG2cy+hxzkLGU4jTq1sqxRIoSeGcjvhFAK8JSEj4i21SgkB1FmkWXoYK5kbwtRA==",
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
+ "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==",
"dependencies": {
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
}
},
- "node_modules/@lexical/rich-text": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.17.1.tgz",
- "integrity": "sha512-T3kvj4P1OpedX9jvxN3WN8NP1Khol6mCW2ScFIRNRz2dsXgyN00thH1Q1J/uyu7aKyGS7rzcY0rb1Pz1qFufqQ==",
+ "node_modules/@codemirror/lang-json": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz",
+ "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==",
"dependencies": {
- "@lexical/clipboard": "0.17.1",
- "@lexical/selection": "0.17.1",
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/language": "^6.0.0",
+ "@lezer/json": "^1.0.0"
}
},
- "node_modules/@lexical/selection": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.17.1.tgz",
- "integrity": "sha512-qBKVn+lMV2YIoyRELNr1/QssXx/4c0id9NCB/BOuYlG8du5IjviVJquEF56NEv2t0GedDv4BpUwkhXT2QbNAxA==",
+ "node_modules/@codemirror/lang-markdown": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz",
+ "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==",
"dependencies": {
- "lexical": "0.17.1"
+ "@codemirror/autocomplete": "^6.7.1",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.3.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/markdown": "^1.0.0"
}
},
- "node_modules/@lexical/table": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.17.1.tgz",
- "integrity": "sha512-2fUYPmxhyuMQX3MRvSsNaxbgvwGNJpHaKx1Ldc+PT2MvDZ6ALZkfsxbi0do54Q3i7dOon8/avRp4TuVaCnqvoA==",
+ "node_modules/@codemirror/lang-php": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz",
+ "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==",
"dependencies": {
- "@lexical/utils": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/php": "^1.0.0"
}
},
- "node_modules/@lexical/utils": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.17.1.tgz",
- "integrity": "sha512-jCQER5EsvhLNxKH3qgcpdWj/necUb82Xjp8qWQ3c0tyL07hIRm2tDRA/s9mQmvcP855HEZSmGVmR5SKtkcEAVg==",
+ "node_modules/@codemirror/lang-xml": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
+ "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"dependencies": {
- "@lexical/list": "0.17.1",
- "@lexical/selection": "0.17.1",
- "@lexical/table": "0.17.1",
- "lexical": "0.17.1"
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/xml": "^1.0.0"
}
},
- "node_modules/@lezer/common": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
- "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ=="
- },
- "node_modules/@lezer/css": {
- "version": "1.1.8",
- "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz",
- "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==",
+ "node_modules/@codemirror/language": {
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz",
+ "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==",
"dependencies": {
- "@lezer/common": "^1.2.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.0.0"
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
}
},
- "node_modules/@lezer/generator": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.1.tgz",
- "integrity": "sha512-MgPJN9Si+ccxzXl3OAmCeZuUKw4XiPl4y664FX/hnnyG9CTqUPq65N3/VGPA2jD23D7QgMTtNqflta+cPN+5mQ==",
- "dev": true,
+ "node_modules/@codemirror/legacy-modes": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.1.tgz",
+ "integrity": "sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz",
+ "integrity": "sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz",
+ "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
+ "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A=="
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz",
+ "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.33.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.33.0.tgz",
+ "integrity": "sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==",
+ "dependencies": {
+ "@codemirror/state": "^6.4.0",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
+ "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
+ "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
+ "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
+ "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
+ "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
+ "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
+ "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
+ "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
+ "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
+ "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
+ "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
+ "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
+ "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
+ "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
+ "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
+ "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
+ "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
+ "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
+ "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
+ "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
+ "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
+ "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
+ "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+ "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "dev": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.11.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
+ "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
+ "dev": true,
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.11.14",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+ "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "deprecated": "Use @eslint/config-array instead",
+ "dev": true,
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.2",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
+ "dev": true
+ },
+ "node_modules/@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/console": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
+ "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/core": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
+ "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+ "dev": true,
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/reporters": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-changed-files": "^29.7.0",
+ "jest-config": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-resolve-dependencies": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jest/environment": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
+ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
+ "dev": true,
+ "dependencies": {
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-mock": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
+ "dev": true,
+ "dependencies": {
+ "expect": "^29.7.0",
+ "jest-snapshot": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/expect-utils": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
+ "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
+ "dependencies": {
+ "jest-get-type": "^29.6.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/fake-timers": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
+ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@sinonjs/fake-timers": "^10.0.2",
+ "@types/node": "*",
+ "jest-message-util": "^29.7.0",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/globals": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
+ "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/expect": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "jest-mock": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/reporters": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
+ "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
+ "dev": true,
+ "dependencies": {
+ "@bcoe/v8-coverage": "^0.2.3",
+ "@jest/console": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "collect-v8-coverage": "^1.0.0",
+ "exit": "^0.1.2",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "istanbul-lib-coverage": "^3.0.0",
+ "istanbul-lib-instrument": "^6.0.0",
+ "istanbul-lib-report": "^3.0.0",
+ "istanbul-lib-source-maps": "^4.0.0",
+ "istanbul-reports": "^3.1.3",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "slash": "^3.0.0",
+ "string-length": "^4.0.1",
+ "strip-ansi": "^6.0.0",
+ "v8-to-istanbul": "^9.0.1"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/source-map": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
+ "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "callsites": "^3.0.0",
+ "graceful-fs": "^4.2.9"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/test-result": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
+ "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
+ "dev": true,
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "collect-v8-coverage": "^1.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/test-sequencer": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
+ "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
+ "dev": true,
+ "dependencies": {
+ "@jest/test-result": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/transform": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
+ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "babel-plugin-istanbul": "^6.1.1",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^2.0.0",
+ "fast-json-stable-stringify": "^2.1.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pirates": "^4.0.4",
+ "slash": "^3.0.0",
+ "write-file-atomic": "^4.0.2"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+ "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
+ "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ=="
+ },
+ "node_modules/@lezer/css": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz",
+ "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/generator": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.1.tgz",
+ "integrity": "sha512-MgPJN9Si+ccxzXl3OAmCeZuUKw4XiPl4y664FX/hnnyG9CTqUPq65N3/VGPA2jD23D7QgMTtNqflta+cPN+5mQ==",
+ "dev": true,
"dependencies": {
"@lezer/common": "^1.1.0",
"@lezer/lr": "^1.3.0"
},
"bin": {
- "lezer-generator": "src/lezer-generator.cjs"
+ "lezer-generator": "src/lezer-generator.cjs"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
+ "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/html": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
+ "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.4.17",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz",
+ "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/json": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz",
+ "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
+ "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/markdown": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.1.tgz",
+ "integrity": "sha512-DGlzU/i8DC8k0uz1F+jeePrkATl0jWakauTzftMQOcbaMkHbNSRki/4E2tOzJWsVpoKYhe7iTJ03aepdwVUXUA==",
+ "dependencies": {
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/php": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz",
+ "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.1.0"
+ }
+ },
+ "node_modules/@lezer/xml": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.5.tgz",
+ "integrity": "sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "dev": true
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="
+ },
+ "node_modules/@sinonjs/commons": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+ "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+ "dev": true,
+ "dependencies": {
+ "type-detect": "4.0.8"
+ }
+ },
+ "node_modules/@sinonjs/fake-timers": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+ "dev": true,
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.0"
+ }
+ },
+ "node_modules/@ssddanbrown/codemirror-lang-smarty": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-smarty/-/codemirror-lang-smarty-1.0.0.tgz",
+ "integrity": "sha512-F0ut1kmdbT3eORk3xVIKfQsGCZiQdh+6sLayBa0+FTex2gyIQlVQZRRA7bPSlchI3uZtWwNnqGNz5O/QLWRlFg=="
+ },
+ "node_modules/@ssddanbrown/codemirror-lang-twig": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-twig/-/codemirror-lang-twig-1.0.0.tgz",
+ "integrity": "sha512-7WIMIh8Ssc54TooGCY57WU2rKEqZZrcV2tZSVRPtd0gKYsrDEKCSLWpQjUWEx7bdgh3NKHUjq1O4ugIzI/+dwQ==",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
+ "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tsconfig/node10": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+ "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+ "dev": true
+ },
+ "node_modules/@tsconfig/node12": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+ "dev": true
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true
+ },
+ "node_modules/@tsconfig/node16": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+ "dev": true
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.6.8",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
+ "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.20.6",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
+ "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.20.7"
+ }
+ },
+ "node_modules/@types/graceful-fs": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
+ "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/istanbul-lib-coverage": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="
+ },
+ "node_modules/@types/istanbul-lib-report": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
+ "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
+ "dependencies": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "node_modules/@types/istanbul-reports": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
+ "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
+ "dependencies": {
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "node_modules/@types/jest": {
+ "version": "29.5.13",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz",
+ "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==",
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
+ "node_modules/@types/jsdom": {
+ "version": "20.0.1",
+ "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
+ "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/tough-cookie": "*",
+ "parse5": "^7.0.0"
+ }
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "22.5.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz",
+ "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==",
+ "dependencies": {
+ "undici-types": "~6.19.2"
+ }
+ },
+ "node_modules/@types/stack-utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
+ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="
+ },
+ "node_modules/@types/tough-cookie": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+ "dev": true
+ },
+ "node_modules/@types/yargs": {
+ "version": "17.0.33",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
+ "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==",
+ "dependencies": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "node_modules/@types/yargs-parser": {
+ "version": "21.0.3",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
+ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "dev": true
+ },
+ "node_modules/abab": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
+ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
+ "deprecated": "Use your platform's native atob() and btoa() methods instead",
+ "dev": true
+ },
+ "node_modules/acorn": {
+ "version": "8.12.1",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
+ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-globals": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
+ "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.1.0",
+ "acorn-walk": "^8.0.2"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-escapes/node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+ "dev": true
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz",
+ "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.5",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
+ "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
+ "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz",
+ "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.2.0",
+ "es-abstract": "^1.22.1",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz",
+ "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.2.0",
+ "es-abstract": "^1.22.1",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz",
+ "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==",
+ "dev": true,
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.5",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.22.3",
+ "es-errors": "^1.2.1",
+ "get-intrinsic": "^1.2.3",
+ "is-array-buffer": "^3.0.4",
+ "is-shared-array-buffer": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/babel-jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
+ "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
+ "dev": true,
+ "dependencies": {
+ "@jest/transform": "^29.7.0",
+ "@types/babel__core": "^7.1.14",
+ "babel-plugin-istanbul": "^6.1.1",
+ "babel-preset-jest": "^29.6.3",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.8.0"
+ }
+ },
+ "node_modules/babel-plugin-istanbul": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@istanbuljs/load-nyc-config": "^1.0.0",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-instrument": "^5.0.4",
+ "test-exclude": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
+ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-jest-hoist": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz",
+ "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.3.3",
+ "@babel/types": "^7.3.3",
+ "@types/babel__core": "^7.1.14",
+ "@types/babel__traverse": "^7.0.6"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/babel-preset-current-node-syntax": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz",
+ "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-syntax-async-generators": "^7.8.4",
+ "@babel/plugin-syntax-bigint": "^7.8.3",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5",
+ "@babel/plugin-syntax-import-attributes": "^7.24.7",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/plugin-syntax-json-strings": "^7.8.3",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+ "@babel/plugin-syntax-top-level-await": "^7.14.5"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/babel-preset-jest": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
+ "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==",
+ "dev": true,
+ "dependencies": {
+ "babel-plugin-jest-hoist": "^29.6.3",
+ "babel-preset-current-node-syntax": "^1.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.23.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
+ "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001646",
+ "electron-to-chromium": "^1.5.4",
+ "node-releases": "^2.0.18",
+ "update-browserslist-db": "^1.1.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bs-logger": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
+ "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
+ "dev": true,
+ "dependencies": {
+ "fast-json-stable-stringify": "2.x"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "dependencies": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
+ "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001660",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz",
+ "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/char-regex": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar-cli": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz",
+ "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==",
+ "dev": true,
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "lodash.debounce": "^4.0.8",
+ "lodash.throttle": "^4.1.1",
+ "yargs": "^13.3.0"
+ },
+ "bin": {
+ "chokidar": "index.js"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ }
+ },
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz",
+ "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==",
+ "dev": true
+ },
+ "node_modules/cliui": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
+ "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^3.1.0",
+ "strip-ansi": "^5.2.0",
+ "wrap-ansi": "^5.1.0"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+ "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/co": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+ "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
+ "dev": true,
+ "engines": {
+ "iojs": ">= 1.0.0",
+ "node": ">= 0.12.0"
+ }
+ },
+ "node_modules/codemirror": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
+ "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/collect-v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
+ "dev": true
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/confusing-browser-globals": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
+ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==",
+ "dev": true
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true
+ },
+ "node_modules/create-jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
+ "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "prompts": "^2.0.1"
+ },
+ "bin": {
+ "create-jest": "bin/create-jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@lezer/highlight": {
+ "node_modules/create-require": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+ "dev": true
+ },
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssom": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
+ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
+ "dev": true
+ },
+ "node_modules/cssstyle": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
+ "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
+ "dev": true,
+ "dependencies": {
+ "cssom": "~0.3.6"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cssstyle/node_modules/cssom": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
+ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
+ "dev": true
+ },
+ "node_modules/data-urls": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
+ "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
+ "dev": true,
+ "dependencies": {
+ "abab": "^2.0.6",
+ "whatwg-mimetype": "^3.0.0",
+ "whatwg-url": "^11.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz",
+ "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.6",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz",
+ "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz",
+ "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.6",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
+ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
+ "dev": true
+ },
+ "node_modules/dedent": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
+ "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==",
+ "dev": true,
+ "peerDependencies": {
+ "babel-plugin-macros": "^3.1.0"
+ },
+ "peerDependenciesMeta": {
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
"version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
- "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
"dependencies": {
- "@lezer/common": "^1.0.0"
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/@lezer/html": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
- "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-newline": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
+ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/diff": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
"dependencies": {
- "@lezer/common": "^1.2.0",
- "@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.0.0"
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
- "node_modules/@lezer/javascript": {
- "version": "1.4.17",
- "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz",
- "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==",
+ "node_modules/domexception": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
+ "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
"dependencies": {
- "@lezer/common": "^1.2.0",
- "@lezer/highlight": "^1.1.3",
- "@lezer/lr": "^1.3.0"
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
}
},
- "node_modules/@lezer/json": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz",
- "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==",
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
"dependencies": {
- "@lezer/common": "^1.2.0",
- "@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.0.0"
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
}
},
- "node_modules/@lezer/lr": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
- "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
- "dependencies": {
- "@lezer/common": "^1.0.0"
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.25",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.25.tgz",
+ "integrity": "sha512-kMb204zvK3PsSlgvvwzI3wBIcAw15tRkYk+NQdsjdDtcQWTp2RABbMQ9rUBy8KNEOM+/E6ep+XC3AykiWZld4g==",
+ "dev": true
+ },
+ "node_modules/emittery": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
+ "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/emittery?sponsor=1"
}
},
- "node_modules/@lezer/markdown": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.1.tgz",
- "integrity": "sha512-DGlzU/i8DC8k0uz1F+jeePrkATl0jWakauTzftMQOcbaMkHbNSRki/4E2tOzJWsVpoKYhe7iTJ03aepdwVUXUA==",
- "dependencies": {
- "@lezer/common": "^1.0.0",
- "@lezer/highlight": "^1.0.0"
+ "node_modules/emoji-regex": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
+ "dev": true
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
}
},
- "node_modules/@lezer/php": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz",
- "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==",
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
"dependencies": {
- "@lezer/common": "^1.2.0",
- "@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.1.0"
+ "is-arrayish": "^0.2.1"
}
},
- "node_modules/@lezer/xml": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.5.tgz",
- "integrity": "sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==",
+ "node_modules/es-abstract": {
+ "version": "1.23.3",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz",
+ "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==",
+ "dev": true,
"dependencies": {
- "@lezer/common": "^1.2.0",
- "@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.0.0"
+ "array-buffer-byte-length": "^1.0.1",
+ "arraybuffer.prototype.slice": "^1.0.3",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.7",
+ "data-view-buffer": "^1.0.1",
+ "data-view-byte-length": "^1.0.1",
+ "data-view-byte-offset": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-set-tostringtag": "^2.0.3",
+ "es-to-primitive": "^1.2.1",
+ "function.prototype.name": "^1.1.6",
+ "get-intrinsic": "^1.2.4",
+ "get-symbol-description": "^1.0.2",
+ "globalthis": "^1.0.3",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.0.7",
+ "is-array-buffer": "^3.0.4",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.1",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.3",
+ "is-string": "^1.0.7",
+ "is-typed-array": "^1.1.13",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.13.1",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.5",
+ "regexp.prototype.flags": "^1.5.2",
+ "safe-array-concat": "^1.1.2",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.trim": "^1.2.9",
+ "string.prototype.trimend": "^1.0.8",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.2",
+ "typed-array-byte-length": "^1.0.1",
+ "typed-array-byte-offset": "^1.0.2",
+ "typed-array-length": "^1.0.6",
+ "unbox-primitive": "^1.0.2",
+ "which-typed-array": "^1.1.15"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "node_modules/es-define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
+ "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"dev": true,
"dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
+ "get-intrinsic": "^1.2.4"
},
"engines": {
- "node": ">= 8"
+ "node": ">= 0.4"
}
},
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
- "node": ">= 8"
+ "node": ">= 0.4"
}
},
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "node_modules/es-object-atoms": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
+ "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
"dev": true,
"dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
+ "es-errors": "^1.3.0"
},
"engines": {
- "node": ">= 8"
+ "node": ">= 0.4"
}
},
- "node_modules/@rtsao/scc": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
- "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
- "dev": true
- },
- "node_modules/@ssddanbrown/codemirror-lang-smarty": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-smarty/-/codemirror-lang-smarty-1.0.0.tgz",
- "integrity": "sha512-F0ut1kmdbT3eORk3xVIKfQsGCZiQdh+6sLayBa0+FTex2gyIQlVQZRRA7bPSlchI3uZtWwNnqGNz5O/QLWRlFg=="
- },
- "node_modules/@ssddanbrown/codemirror-lang-twig": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-twig/-/codemirror-lang-twig-1.0.0.tgz",
- "integrity": "sha512-7WIMIh8Ssc54TooGCY57WU2rKEqZZrcV2tZSVRPtd0gKYsrDEKCSLWpQjUWEx7bdgh3NKHUjq1O4ugIzI/+dwQ==",
+ "node_modules/es-set-tostringtag": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz",
+ "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==",
+ "dev": true,
"dependencies": {
- "@codemirror/language": "^6.0.0",
- "@lezer/highlight": "^1.0.0",
- "@lezer/lr": "^1.0.0"
+ "get-intrinsic": "^1.2.4",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
}
},
- "node_modules/@types/json5": {
- "version": "0.0.29",
- "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
- "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
- "dev": true
+ "node_modules/es-shim-unscopables": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz",
+ "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==",
+ "dev": true,
+ "dependencies": {
+ "hasown": "^2.0.0"
+ }
},
- "node_modules/@ungap/structured-clone": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
- "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
- "dev": true
+ "node_modules/es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "dev": true,
+ "dependencies": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
},
- "node_modules/acorn": {
- "version": "8.12.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
- "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "node_modules/esbuild": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
+ "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
+ "hasInstallScript": true,
"bin": {
- "acorn": "bin/acorn"
+ "esbuild": "bin/esbuild"
},
"engines": {
- "node": ">=0.4.0"
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.20.2",
+ "@esbuild/android-arm": "0.20.2",
+ "@esbuild/android-arm64": "0.20.2",
+ "@esbuild/android-x64": "0.20.2",
+ "@esbuild/darwin-arm64": "0.20.2",
+ "@esbuild/darwin-x64": "0.20.2",
+ "@esbuild/freebsd-arm64": "0.20.2",
+ "@esbuild/freebsd-x64": "0.20.2",
+ "@esbuild/linux-arm": "0.20.2",
+ "@esbuild/linux-arm64": "0.20.2",
+ "@esbuild/linux-ia32": "0.20.2",
+ "@esbuild/linux-loong64": "0.20.2",
+ "@esbuild/linux-mips64el": "0.20.2",
+ "@esbuild/linux-ppc64": "0.20.2",
+ "@esbuild/linux-riscv64": "0.20.2",
+ "@esbuild/linux-s390x": "0.20.2",
+ "@esbuild/linux-x64": "0.20.2",
+ "@esbuild/netbsd-x64": "0.20.2",
+ "@esbuild/openbsd-x64": "0.20.2",
+ "@esbuild/sunos-x64": "0.20.2",
+ "@esbuild/win32-arm64": "0.20.2",
+ "@esbuild/win32-ia32": "0.20.2",
+ "@esbuild/win32-x64": "0.20.2"
}
},
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ "engines": {
+ "node": ">=6"
}
},
- "node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
+ "engines": {
+ "node": ">=10"
},
"funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"dev": true,
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
"engines": {
- "node": ">=8"
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
}
},
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "node_modules/eslint": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"dependencies": {
- "color-convert": "^2.0.1"
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.0",
+ "@humanwhocodes/config-array": "^0.11.14",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
},
"engines": {
- "node": ">=8"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "node_modules/eslint-config-airbnb-base": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz",
+ "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==",
"dev": true,
"dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
+ "confusing-browser-globals": "^1.0.10",
+ "object.assign": "^4.1.2",
+ "object.entries": "^1.1.5",
+ "semver": "^6.3.0"
},
"engines": {
- "node": ">= 8"
+ "node": "^10.12.0 || >=12.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^7.32.0 || ^8.2.0",
+ "eslint-plugin-import": "^2.25.2"
}
},
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
+ "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
},
- "node_modules/array-buffer-byte-length": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz",
- "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==",
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.5",
- "is-array-buffer": "^3.0.4"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "ms": "^2.1.1"
}
},
- "node_modules/array-includes": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
- "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
+ "node_modules/eslint-module-utils": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz",
+ "integrity": "sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.7",
- "define-properties": "^1.2.1",
- "es-abstract": "^1.23.2",
- "es-object-atoms": "^1.0.0",
- "get-intrinsic": "^1.2.4",
- "is-string": "^1.0.7"
+ "debug": "^3.2.7"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=4"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
}
},
- "node_modules/array.prototype.findlastindex": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
- "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==",
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.7",
- "define-properties": "^1.2.1",
- "es-abstract": "^1.23.2",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.0.0",
- "es-shim-unscopables": "^1.0.2"
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz",
+ "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==",
+ "dev": true,
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.8",
+ "array.prototype.findlastindex": "^1.2.5",
+ "array.prototype.flat": "^1.3.2",
+ "array.prototype.flatmap": "^1.3.2",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.9.0",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.15.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.0",
+ "semver": "^6.3.1",
+ "tsconfig-paths": "^3.15.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=4"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8"
}
},
- "node_modules/array.prototype.flat": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz",
- "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==",
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.2.0",
- "es-abstract": "^1.22.1",
- "es-shim-unscopables": "^1.0.0"
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=0.10.0"
}
},
- "node_modules/array.prototype.flatmap": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz",
- "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==",
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.2.0",
- "es-abstract": "^1.22.1",
- "es-shim-unscopables": "^1.0.0"
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/arraybuffer.prototype.slice": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz",
- "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==",
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
- "dependencies": {
- "array-buffer-byte-length": "^1.0.1",
- "call-bind": "^1.0.5",
- "define-properties": "^1.2.1",
- "es-abstract": "^1.22.3",
- "es-errors": "^1.2.1",
- "get-intrinsic": "^1.2.3",
- "is-array-buffer": "^3.0.4",
- "is-shared-array-buffer": "^1.0.2"
- },
"engines": {
- "node": ">= 0.4"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/available-typed-arrays": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
- "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "node_modules/eslint/node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"dependencies": {
- "possible-typed-array-names": "^1.0.0"
+ "is-glob": "^4.0.3"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=10.13.0"
}
},
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
- },
- "node_modules/binary-extensions": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
- "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
"engines": {
- "node": ">=8"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
}
},
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true,
"dependencies": {
- "fill-range": "^7.1.1"
+ "estraverse": "^5.1.0"
},
"engines": {
- "node": ">=8"
+ "node": ">=0.10"
}
},
- "node_modules/call-bind": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
- "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
"dependencies": {
- "es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2",
- "get-intrinsic": "^1.2.4",
- "set-function-length": "^1.2.1"
+ "estraverse": "^5.2.0"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=4.0"
}
},
- "node_modules/callsites": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"engines": {
- "node": ">=6"
+ "node": ">=4.0"
}
},
- "node_modules/camelcase": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
- "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"engines": {
- "node": ">=6"
+ "node": ">=0.10.0"
}
},
- "node_modules/chalk": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
- "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
- "node_modules/chokidar": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
- "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "node_modules/exit": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
+ "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
"dev": true,
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
"engines": {
- "node": ">= 8.10.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
+ "node": ">= 0.8.0"
}
},
- "node_modules/chokidar-cli": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz",
- "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==",
- "dev": true,
+ "node_modules/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
"dependencies": {
- "chokidar": "^3.5.2",
- "lodash.debounce": "^4.0.8",
- "lodash.throttle": "^4.1.1",
- "yargs": "^13.3.0"
- },
- "bin": {
- "chokidar": "index.js"
+ "@jest/expect-utils": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0"
},
"engines": {
- "node": ">= 8.10.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/cliui": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
- "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
- "dev": true,
- "dependencies": {
- "string-width": "^3.1.0",
- "strip-ansi": "^5.2.0",
- "wrap-ansi": "^5.1.0"
- }
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
},
- "node_modules/cliui/node_modules/ansi-regex": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
- "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
- "dev": true,
- "engines": {
- "node": ">=6"
- }
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
},
- "node_modules/cliui/node_modules/strip-ansi": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
- "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^4.1.0"
- },
- "engines": {
- "node": ">=6"
- }
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
},
- "node_modules/codemirror": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
- "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
+ "node_modules/fastq": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+ "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "dev": true,
"dependencies": {
- "@codemirror/autocomplete": "^6.0.0",
- "@codemirror/commands": "^6.0.0",
- "@codemirror/language": "^6.0.0",
- "@codemirror/lint": "^6.0.0",
- "@codemirror/search": "^6.0.0",
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0"
+ "reusify": "^1.0.4"
}
},
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "node_modules/fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "dependencies": {
+ "bser": "2.1.1"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"dev": true,
"dependencies": {
- "color-name": "~1.1.4"
+ "flat-cache": "^3.0.4"
},
"engines": {
- "node": ">=7.0.0"
+ "node": "^10.12.0 || >=12.0.0"
}
},
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true
- },
- "node_modules/confusing-browser-globals": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
- "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==",
- "dev": true
+ "node_modules/filelist": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
+ "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
+ "dev": true,
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
},
- "node_modules/crelt": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
- "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
},
- "node_modules/cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
+ "brace-expansion": "^2.0.1"
},
"engines": {
- "node": ">= 8"
+ "node": ">=10"
}
},
- "node_modules/data-view-buffer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz",
- "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==",
- "dev": true,
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": {
- "call-bind": "^1.0.6",
- "es-errors": "^1.3.0",
- "is-data-view": "^1.0.1"
+ "to-regex-range": "^5.0.1"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=8"
}
},
- "node_modules/data-view-byte-length": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz",
- "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==",
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.7",
- "es-errors": "^1.3.0",
- "is-data-view": "^1.0.1"
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=10"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/data-view-byte-offset": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz",
- "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==",
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.6",
- "es-errors": "^1.3.0",
- "is-data-view": "^1.0.1"
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^10.12.0 || >=12.0.0"
}
},
- "node_modules/debug": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
- "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "node_modules/flatted": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "dev": true
+ },
+ "node_modules/for-each": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+ "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
"dev": true,
"dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
+ "is-callable": "^1.1.3"
}
},
- "node_modules/decamelize": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
- "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "node_modules/form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
"engines": {
- "node": ">=0.10.0"
+ "node": ">= 6"
}
},
- "node_modules/deep-is": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
- "node_modules/define-data-property": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
- "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
- "dependencies": {
- "es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "gopd": "^1.0.1"
- },
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">= 0.4"
- },
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/define-properties": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
- "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "node_modules/function.prototype.name": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz",
+ "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==",
"dev": true,
"dependencies": {
- "define-data-property": "^1.0.1",
- "has-property-descriptors": "^1.0.0",
- "object-keys": "^1.1.1"
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.2.0",
+ "es-abstract": "^1.22.1",
+ "functions-have-names": "^1.2.3"
},
"engines": {
"node": ">= 0.4"
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/doctrine": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
- "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/emoji-regex": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
- "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
- "dev": true
- },
- "node_modules/entities": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
"engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
+ "node": ">=6.9.0"
}
},
- "node_modules/error-ex": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
- "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
- "dependencies": {
- "is-arrayish": "^0.2.1"
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
}
},
- "node_modules/es-abstract": {
- "version": "1.23.3",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz",
- "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==",
+ "node_modules/get-intrinsic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
+ "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"dev": true,
"dependencies": {
- "array-buffer-byte-length": "^1.0.1",
- "arraybuffer.prototype.slice": "^1.0.3",
- "available-typed-arrays": "^1.0.7",
- "call-bind": "^1.0.7",
- "data-view-buffer": "^1.0.1",
- "data-view-byte-length": "^1.0.1",
- "data-view-byte-offset": "^1.0.0",
- "es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
- "es-object-atoms": "^1.0.0",
- "es-set-tostringtag": "^2.0.3",
- "es-to-primitive": "^1.2.1",
- "function.prototype.name": "^1.1.6",
- "get-intrinsic": "^1.2.4",
- "get-symbol-description": "^1.0.2",
- "globalthis": "^1.0.3",
- "gopd": "^1.0.1",
- "has-property-descriptors": "^1.0.2",
- "has-proto": "^1.0.3",
+ "function-bind": "^1.1.2",
+ "has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
- "hasown": "^2.0.2",
- "internal-slot": "^1.0.7",
- "is-array-buffer": "^3.0.4",
- "is-callable": "^1.2.7",
- "is-data-view": "^1.0.1",
- "is-negative-zero": "^2.0.3",
- "is-regex": "^1.1.4",
- "is-shared-array-buffer": "^1.0.3",
- "is-string": "^1.0.7",
- "is-typed-array": "^1.1.13",
- "is-weakref": "^1.0.2",
- "object-inspect": "^1.13.1",
- "object-keys": "^1.1.1",
- "object.assign": "^4.1.5",
- "regexp.prototype.flags": "^1.5.2",
- "safe-array-concat": "^1.1.2",
- "safe-regex-test": "^1.0.3",
- "string.prototype.trim": "^1.2.9",
- "string.prototype.trimend": "^1.0.8",
- "string.prototype.trimstart": "^1.0.8",
- "typed-array-buffer": "^1.0.2",
- "typed-array-byte-length": "^1.0.1",
- "typed-array-byte-offset": "^1.0.2",
- "typed-array-length": "^1.0.6",
- "unbox-primitive": "^1.0.2",
- "which-typed-array": "^1.1.15"
+ "hasown": "^2.0.0"
},
"engines": {
"node": ">= 0.4"
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/es-define-property": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
- "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true,
- "dependencies": {
- "get-intrinsic": "^1.2.4"
- },
"engines": {
- "node": ">= 0.4"
+ "node": ">=8.0.0"
}
},
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"engines": {
- "node": ">= 0.4"
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/es-object-atoms": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
- "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
+ "node_modules/get-symbol-description": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz",
+ "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==",
"dev": true,
"dependencies": {
- "es-errors": "^1.3.0"
+ "call-bind": "^1.0.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.4"
},
"engines": {
"node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/es-set-tostringtag": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz",
- "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==",
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"dependencies": {
- "get-intrinsic": "^1.2.4",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.1"
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/es-shim-unscopables": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz",
- "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==",
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
- "hasown": "^2.0.0"
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
}
},
- "node_modules/es-to-primitive": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
- "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
"dependencies": {
- "is-callable": "^1.1.4",
- "is-date-object": "^1.0.1",
- "is-symbol": "^1.0.2"
+ "type-fest": "^0.20.2"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=8"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/esbuild": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
- "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
- "hasInstallScript": true,
- "bin": {
- "esbuild": "bin/esbuild"
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
},
"engines": {
- "node": ">=12"
+ "node": ">= 0.4"
},
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.20.2",
- "@esbuild/android-arm": "0.20.2",
- "@esbuild/android-arm64": "0.20.2",
- "@esbuild/android-x64": "0.20.2",
- "@esbuild/darwin-arm64": "0.20.2",
- "@esbuild/darwin-x64": "0.20.2",
- "@esbuild/freebsd-arm64": "0.20.2",
- "@esbuild/freebsd-x64": "0.20.2",
- "@esbuild/linux-arm": "0.20.2",
- "@esbuild/linux-arm64": "0.20.2",
- "@esbuild/linux-ia32": "0.20.2",
- "@esbuild/linux-loong64": "0.20.2",
- "@esbuild/linux-mips64el": "0.20.2",
- "@esbuild/linux-ppc64": "0.20.2",
- "@esbuild/linux-riscv64": "0.20.2",
- "@esbuild/linux-s390x": "0.20.2",
- "@esbuild/linux-x64": "0.20.2",
- "@esbuild/netbsd-x64": "0.20.2",
- "@esbuild/openbsd-x64": "0.20.2",
- "@esbuild/sunos-x64": "0.20.2",
- "@esbuild/win32-arm64": "0.20.2",
- "@esbuild/win32-ia32": "0.20.2",
- "@esbuild/win32-x64": "0.20.2"
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "node_modules/gopd": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
+ "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
"dev": true,
- "engines": {
- "node": ">=10"
+ "dependencies": {
+ "get-intrinsic": "^1.1.3"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/eslint": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
- "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true
+ },
+ "node_modules/has-bigints": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
+ "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.0",
- "@humanwhocodes/config-array": "^0.11.14",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
- "debug": "^4.3.2",
- "doctrine": "^3.0.0",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
- },
- "bin": {
- "eslint": "bin/eslint.js"
+ "es-define-property": "^1.0.0"
},
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
+ "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
+ "dev": true,
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": ">= 0.4"
},
"funding": {
- "url": "https://opencollective.com/eslint"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/eslint-config-airbnb-base": {
- "version": "15.0.0",
- "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz",
- "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==",
+ "node_modules/has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"dev": true,
- "dependencies": {
- "confusing-browser-globals": "^1.0.10",
- "object.assign": "^4.1.2",
- "object.entries": "^1.1.5",
- "semver": "^6.3.0"
- },
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">= 0.4"
},
- "peerDependencies": {
- "eslint": "^7.32.0 || ^8.2.0",
- "eslint-plugin-import": "^2.25.2"
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/eslint-import-resolver-node": {
- "version": "0.3.9",
- "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
- "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
- "debug": "^3.2.7",
- "is-core-module": "^2.13.0",
- "resolve": "^1.22.4"
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/eslint-import-resolver-node/node_modules/debug": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
- "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
- "ms": "^2.1.1"
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
}
},
- "node_modules/eslint-module-utils": {
- "version": "2.11.0",
- "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz",
- "integrity": "sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==",
+ "node_modules/hosted-git-info": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "dev": true
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
+ "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
"dev": true,
"dependencies": {
- "debug": "^3.2.7"
+ "whatwg-encoding": "^2.0.0"
},
"engines": {
- "node": ">=4"
- },
- "peerDependenciesMeta": {
- "eslint": {
- "optional": true
- }
+ "node": ">=12"
}
},
- "node_modules/eslint-module-utils/node_modules/debug": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
- "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+ "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"dev": true,
"dependencies": {
- "ms": "^2.1.1"
+ "@tootallnate/once": "2",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
}
},
- "node_modules/eslint-plugin-import": {
- "version": "2.30.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz",
- "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==",
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"dependencies": {
- "@rtsao/scc": "^1.1.0",
- "array-includes": "^3.1.8",
- "array.prototype.findlastindex": "^1.2.5",
- "array.prototype.flat": "^1.3.2",
- "array.prototype.flatmap": "^1.3.2",
- "debug": "^3.2.7",
- "doctrine": "^2.1.0",
- "eslint-import-resolver-node": "^0.3.9",
- "eslint-module-utils": "^2.9.0",
- "hasown": "^2.0.2",
- "is-core-module": "^2.15.1",
- "is-glob": "^4.0.3",
- "minimatch": "^3.1.2",
- "object.fromentries": "^2.0.8",
- "object.groupby": "^1.0.3",
- "object.values": "^1.2.0",
- "semver": "^6.3.1",
- "tsconfig-paths": "^3.15.0"
+ "agent-base": "6",
+ "debug": "4"
},
"engines": {
- "node": ">=4"
- },
- "peerDependencies": {
- "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8"
+ "node": ">= 6"
}
},
- "node_modules/eslint-plugin-import/node_modules/debug": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
- "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
- "dependencies": {
- "ms": "^2.1.1"
+ "engines": {
+ "node": ">=10.17.0"
}
},
- "node_modules/eslint-plugin-import/node_modules/doctrine": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
- "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"dependencies": {
- "esutils": "^2.0.2"
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/eslint-scope": {
- "version": "7.2.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
- "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "node_modules/idb-keyval": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
+ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immutable": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
+ "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+ "dev": true
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
"dev": true,
"dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": ">=6"
},
"funding": {
- "url": "https://opencollective.com/eslint"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "node_modules/import-local": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
+ "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
"dev": true,
+ "dependencies": {
+ "pkg-dir": "^4.2.0",
+ "resolve-cwd": "^3.0.0"
+ },
+ "bin": {
+ "import-local-fixture": "fixtures/cli.js"
+ },
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": ">=8"
},
"funding": {
- "url": "https://opencollective.com/eslint"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/eslint/node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
- "dependencies": {
- "is-glob": "^4.0.3"
- },
"engines": {
- "node": ">=10.13.0"
+ "node": ">=0.8.19"
}
},
- "node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/internal-slot": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz",
+ "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.0",
+ "side-channel": "^1.0.4"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
+ "node": ">= 0.4"
}
},
- "node_modules/esquery": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
- "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "node_modules/is-array-buffer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
+ "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==",
"dev": true,
"dependencies": {
- "estraverse": "^5.1.0"
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.2.1"
},
"engines": {
- "node": ">=0.10"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
- "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "node_modules/is-bigint": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+ "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
"dev": true,
"dependencies": {
- "estraverse": "^5.2.0"
+ "has-bigints": "^1.0.1"
},
- "engines": {
- "node": ">=4.0"
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
"engines": {
- "node": ">=4.0"
+ "node": ">=8"
}
},
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "node_modules/is-boolean-object": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+ "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
"dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
"engines": {
- "node": ">=0.10.0"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
- "dev": true
- },
- "node_modules/fastq": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
- "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"dev": true,
- "dependencies": {
- "reusify": "^1.0.4"
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/file-entry-cache": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
- "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "node_modules/is-core-module": {
+ "version": "2.15.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
+ "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
"dev": true,
"dependencies": {
- "flat-cache": "^3.0.4"
+ "hasown": "^2.0.2"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "node_modules/is-data-view": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz",
+ "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==",
"dev": true,
"dependencies": {
- "to-regex-range": "^5.0.1"
+ "is-typed-array": "^1.1.13"
},
"engines": {
- "node": ">=8"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/find-up": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
- "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "node_modules/is-date-object": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+ "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
"dev": true,
"dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
+ "has-tostringtag": "^1.0.0"
},
"engines": {
- "node": ">=10"
+ "node": ">= 0.4"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/flat-cache": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
- "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.3",
- "rimraf": "^3.0.2"
- },
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=0.10.0"
}
},
- "node_modules/flatted": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
- "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
- "dev": true
- },
- "node_modules/for-each": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
- "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+ "node_modules/is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
"dev": true,
- "dependencies": {
- "is-callable": "^1.1.3"
+ "engines": {
+ "node": ">=4"
}
},
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "node_modules/is-generator-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
+ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
"dev": true,
- "hasInstallScript": true,
- "optional": true,
- "os": [
- "darwin"
- ],
"engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "node": ">=6"
}
},
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
}
},
- "node_modules/function.prototype.name": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz",
- "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==",
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
"dev": true,
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.2.0",
- "es-abstract": "^1.22.1",
- "functions-have-names": "^1.2.3"
- },
"engines": {
"node": ">= 0.4"
},
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/functions-have-names": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
- "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
+ "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
"dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true,
"engines": {
- "node": "6.* || 8.* || >= 10.*"
+ "node": ">=8"
}
},
- "node_modules/get-intrinsic": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
- "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true
+ },
+ "node_modules/is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
"dev": true,
"dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3",
- "hasown": "^2.0.0"
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/get-symbol-description": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz",
- "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==",
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.5",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.4"
+ "call-bind": "^1.0.7"
},
"engines": {
"node": ">= 0.4"
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
"engines": {
- "node": "*"
+ "node": ">=8"
},
"funding": {
- "url": "https://github.com/sponsors/isaacs"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "node_modules/is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
"dev": true,
"dependencies": {
- "is-glob": "^4.0.1"
+ "has-tostringtag": "^1.0.0"
},
"engines": {
- "node": ">= 6"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/globals": {
- "version": "13.24.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "node_modules/is-symbol": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+ "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
"dev": true,
"dependencies": {
- "type-fest": "^0.20.2"
+ "has-symbols": "^1.0.2"
},
"engines": {
- "node": ">=8"
+ "node": ">= 0.4"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/globalthis": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
- "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "node_modules/is-typed-array": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz",
+ "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==",
"dev": true,
"dependencies": {
- "define-properties": "^1.2.1",
- "gopd": "^1.0.1"
+ "which-typed-array": "^1.1.14"
},
"engines": {
"node": ">= 0.4"
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/gopd": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
- "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
+ "node_modules/is-weakref": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+ "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
"dev": true,
"dependencies": {
- "get-intrinsic": "^1.1.3"
+ "call-bind": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
- "node_modules/has-bigints": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
- "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": ">=8"
}
},
- "node_modules/has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "node_modules/istanbul-lib-instrument": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
+ "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.23.9",
+ "@babel/parser": "^7.23.9",
+ "@istanbuljs/schema": "^0.1.3",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-instrument/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
"engines": {
- "node": ">=8"
+ "node": ">=10"
}
},
- "node_modules/has-property-descriptors": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
- "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"dependencies": {
- "es-define-property": "^1.0.0"
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": ">=10"
}
},
- "node_modules/has-proto": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
- "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+ "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
"dev": true,
+ "dependencies": {
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0",
+ "source-map": "^0.6.1"
+ },
"engines": {
- "node": ">= 0.4"
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+ "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "dev": true,
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": ">=8"
}
},
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "node_modules/jake": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
+ "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
"dev": true,
+ "dependencies": {
+ "async": "^3.2.3",
+ "chalk": "^4.0.2",
+ "filelist": "^1.0.4",
+ "minimatch": "^3.1.2"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
"engines": {
- "node": ">= 0.4"
+ "node": ">=10"
+ }
+ },
+ "node_modules/jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
+ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
+ "dev": true,
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "import-local": "^3.0.2",
+ "jest-cli": "^29.7.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
}
},
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "node_modules/jest-changed-files": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
+ "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==",
"dev": true,
"dependencies": {
- "has-symbols": "^1.0.3"
+ "execa": "^5.0.0",
+ "jest-util": "^29.7.0",
+ "p-limit": "^3.1.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-circus": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
+ "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
+ "dev": true,
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/expect": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "co": "^4.6.0",
+ "dedent": "^1.0.0",
+ "is-generator-fn": "^2.0.0",
+ "jest-each": "^29.7.0",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "p-limit": "^3.1.0",
+ "pretty-format": "^29.7.0",
+ "pure-rand": "^6.0.0",
+ "slash": "^3.0.0",
+ "stack-utils": "^2.0.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-cli": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
+ "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
+ "dev": true,
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "create-jest": "^29.7.0",
+ "exit": "^0.1.2",
+ "import-local": "^3.0.2",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "yargs": "^17.3.1"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
}
},
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "node_modules/jest-cli/node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"dependencies": {
- "function-bind": "^1.1.2"
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=12"
}
},
- "node_modules/hosted-git-info": {
- "version": "2.8.9",
- "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
- "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "node_modules/jest-cli/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
- "node_modules/idb-keyval": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
- "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
- },
- "node_modules/ignore": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "node_modules/jest-cli/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
- "node": ">= 4"
+ "node": ">=8"
}
},
- "node_modules/immutable": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
- "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
- "dev": true
+ "node_modules/jest-cli/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
},
- "node_modules/import-fresh": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
- "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "node_modules/jest-cli/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
},
"engines": {
- "node": ">=6"
+ "node": ">=10"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "node_modules/jest-cli/node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"engines": {
- "node": ">=0.8.19"
+ "node": ">=10"
}
},
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "node_modules/jest-cli/node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
}
},
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
- },
- "node_modules/internal-slot": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz",
- "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==",
+ "node_modules/jest-cli/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/jest-config": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
+ "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
"dev": true,
"dependencies": {
- "es-errors": "^1.3.0",
- "hasown": "^2.0.0",
- "side-channel": "^1.0.4"
+ "@babel/core": "^7.11.6",
+ "@jest/test-sequencer": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "babel-jest": "^29.7.0",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "deepmerge": "^4.2.2",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "jest-circus": "^29.7.0",
+ "jest-environment-node": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "parse-json": "^5.2.0",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-json-comments": "^3.1.1"
},
"engines": {
- "node": ">= 0.4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "@types/node": "*",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
}
},
- "node_modules/is-array-buffer": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
- "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==",
+ "node_modules/jest-config/node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.2.1"
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=8"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-arrayish": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
- "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
- "dev": true
- },
- "node_modules/is-bigint": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
- "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
- "dev": true,
+ "node_modules/jest-diff": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
+ "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
"dependencies": {
- "has-bigints": "^1.0.1"
+ "chalk": "^4.0.0",
+ "diff-sequences": "^29.6.3",
+ "jest-get-type": "^29.6.3",
+ "pretty-format": "^29.7.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "node_modules/jest-docblock": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
+ "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
"dev": true,
"dependencies": {
- "binary-extensions": "^2.0.0"
+ "detect-newline": "^3.0.0"
},
"engines": {
- "node": ">=8"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-boolean-object": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
- "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+ "node_modules/jest-each": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
+ "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.2",
- "has-tostringtag": "^1.0.0"
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "jest-get-type": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "pretty-format": "^29.7.0"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-callable": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
- "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "node_modules/jest-environment-jsdom": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
+ "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==",
"dev": true,
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/jsdom": "^20.0.0",
+ "@types/node": "*",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jsdom": "^20.0.0"
+ },
"engines": {
- "node": ">= 0.4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "peerDependencies": {
+ "canvas": "^2.5.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
}
},
- "node_modules/is-core-module": {
- "version": "2.15.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
- "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
+ "node_modules/jest-environment-node": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
+ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==",
"dev": true,
"dependencies": {
- "hasown": "^2.0.2"
+ "@jest/environment": "^29.7.0",
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-data-view": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz",
- "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==",
+ "node_modules/jest-get-type": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
+ "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-haste-map": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz",
+ "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==",
"dev": true,
"dependencies": {
- "is-typed-array": "^1.1.13"
+ "@jest/types": "^29.6.3",
+ "@types/graceful-fs": "^4.1.3",
+ "@types/node": "*",
+ "anymatch": "^3.0.3",
+ "fb-watchman": "^2.0.0",
+ "graceful-fs": "^4.2.9",
+ "jest-regex-util": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "walker": "^1.0.8"
},
"engines": {
- "node": ">= 0.4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "optionalDependencies": {
+ "fsevents": "^2.3.2"
}
},
- "node_modules/is-date-object": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
- "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+ "node_modules/jest-leak-detector": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
+ "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
"dev": true,
"dependencies": {
- "has-tostringtag": "^1.0.0"
+ "jest-get-type": "^29.6.3",
+ "pretty-format": "^29.7.0"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
+ "node_modules/jest-matcher-utils": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
+ "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
+ "dependencies": {
+ "chalk": "^4.0.0",
+ "jest-diff": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "pretty-format": "^29.7.0"
+ },
"engines": {
- "node": ">=0.10.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-fullwidth-code-point": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
- "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
- "dev": true,
+ "node_modules/jest-message-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
+ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
+ "dependencies": {
+ "@babel/code-frame": "^7.12.13",
+ "@jest/types": "^29.6.3",
+ "@types/stack-utils": "^2.0.0",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "stack-utils": "^2.0.3"
+ },
"engines": {
- "node": ">=4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "node_modules/jest-mock": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
+ "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
"dev": true,
"dependencies": {
- "is-extglob": "^2.1.1"
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-util": "^29.7.0"
},
"engines": {
- "node": ">=0.10.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-negative-zero": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
- "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "node_modules/jest-pnp-resolver": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz",
+ "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==",
"dev": true,
"engines": {
- "node": ">= 0.4"
+ "node": ">=6"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "peerDependencies": {
+ "jest-resolve": "*"
+ },
+ "peerDependenciesMeta": {
+ "jest-resolve": {
+ "optional": true
+ }
}
},
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "node_modules/jest-regex-util": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
+ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
"dev": true,
"engines": {
- "node": ">=0.12.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-number-object": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
- "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
+ "node_modules/jest-resolve": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
+ "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
"dev": true,
"dependencies": {
- "has-tostringtag": "^1.0.0"
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-pnp-resolver": "^1.2.2",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "resolve": "^1.20.0",
+ "resolve.exports": "^2.0.0",
+ "slash": "^3.0.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-resolve-dependencies": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz",
+ "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==",
+ "dev": true,
+ "dependencies": {
+ "jest-regex-util": "^29.6.3",
+ "jest-snapshot": "^29.7.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
- "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "node_modules/jest-runner": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
+ "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/environment": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "emittery": "^0.13.1",
+ "graceful-fs": "^4.2.9",
+ "jest-docblock": "^29.7.0",
+ "jest-environment-node": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-leak-detector": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-resolve": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "p-limit": "^3.1.0",
+ "source-map-support": "0.5.13"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-runtime": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
+ "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/globals": "^29.7.0",
+ "@jest/source-map": "^29.6.3",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "cjs-module-lexer": "^1.0.0",
+ "collect-v8-coverage": "^1.0.0",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-mock": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-bom": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-runtime/node_modules/strip-bom": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"engines": {
"node": ">=8"
}
},
- "node_modules/is-regex": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
- "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "node_modules/jest-snapshot": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
+ "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.2",
- "has-tostringtag": "^1.0.0"
+ "@babel/core": "^7.11.6",
+ "@babel/generator": "^7.7.2",
+ "@babel/plugin-syntax-jsx": "^7.7.2",
+ "@babel/plugin-syntax-typescript": "^7.7.2",
+ "@babel/types": "^7.3.3",
+ "@jest/expect-utils": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "babel-preset-current-node-syntax": "^1.0.0",
+ "chalk": "^4.0.0",
+ "expect": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-diff": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "natural-compare": "^1.4.0",
+ "pretty-format": "^29.7.0",
+ "semver": "^7.5.3"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-shared-array-buffer": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz",
- "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==",
+ "node_modules/jest-snapshot/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
- "dependencies": {
- "call-bind": "^1.0.7"
+ "bin": {
+ "semver": "bin/semver.js"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">=10"
+ }
+ },
+ "node_modules/jest-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
+ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "graceful-fs": "^4.2.9",
+ "picomatch": "^2.2.3"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-string": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
- "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "node_modules/jest-validate": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
+ "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
"dev": true,
"dependencies": {
- "has-tostringtag": "^1.0.0"
+ "@jest/types": "^29.6.3",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.0.0",
+ "jest-get-type": "^29.6.3",
+ "leven": "^3.1.0",
+ "pretty-format": "^29.7.0"
},
"engines": {
- "node": ">= 0.4"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-validate/node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-symbol": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
- "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+ "node_modules/jest-watcher": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
+ "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
"dev": true,
"dependencies": {
- "has-symbols": "^1.0.2"
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "emittery": "^0.13.1",
+ "jest-util": "^29.7.0",
+ "string-length": "^4.0.1"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-typed-array": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz",
- "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==",
+ "node_modules/jest-worker": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz",
+ "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==",
"dev": true,
"dependencies": {
- "which-typed-array": "^1.1.14"
+ "@types/node": "*",
+ "jest-util": "^29.7.0",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
},
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/is-weakref": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
- "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+ "node_modules/jest-worker/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"dependencies": {
- "call-bind": "^1.0.2"
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/isarray": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
- "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
- "dev": true
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
},
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "20.0.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
+ "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
+ "dev": true,
+ "dependencies": {
+ "abab": "^2.0.6",
+ "acorn": "^8.8.1",
+ "acorn-globals": "^7.0.0",
+ "cssom": "^0.5.0",
+ "cssstyle": "^2.3.0",
+ "data-urls": "^3.0.2",
+ "decimal.js": "^10.4.2",
+ "domexception": "^4.0.0",
+ "escodegen": "^2.0.0",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^3.0.0",
+ "http-proxy-agent": "^5.0.0",
+ "https-proxy-agent": "^5.0.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.2",
+ "parse5": "^7.1.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^4.1.2",
+ "w3c-xmlserializer": "^4.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^2.0.0",
+ "whatwg-mimetype": "^3.0.0",
+ "whatwg-url": "^11.0.0",
+ "ws": "^8.11.0",
+ "xml-name-validator": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "canvas": "^2.5.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
"dev": true
},
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"json-buffer": "3.0.1"
}
},
+ "node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/leven": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"node": ">= 0.8.0"
}
},
- "node_modules/lexical": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.17.1.tgz",
- "integrity": "sha512-72/MhR7jqmyqD10bmJw8gztlCm4KDDT+TPtU4elqXrEvHoO5XENi34YAEUD9gIkPfqSwyLa9mwAX1nKzIr5xEA=="
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
},
"node_modules/linkify-it": {
"version": "5.0.0",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"dev": true
},
+ "node_modules/lodash.memoize": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
+ "dev": true
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"dev": true
},
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true
+ },
+ "node_modules/makeerror": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+ "dev": true,
+ "dependencies": {
+ "tmpl": "1.0.5"
+ }
+ },
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"node": ">= 0.10.0"
}
},
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
+ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
+ "dev": true
+ },
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"which": "bin/which"
}
},
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz",
+ "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==",
+ "dev": true
+ },
"node_modules/object-inspect": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
"wrappy": "1"
}
},
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"node": ">=4"
}
},
+ "node_modules/parse5": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
+ "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+ "dev": true,
+ "dependencies": {
+ "entities": "^4.4.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"node": ">=4"
}
},
+ "node_modules/picocolors": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
+ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
+ },
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
"engines": {
"node": ">=8.6"
},
"node": ">=4"
}
},
+ "node_modules/pirates": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+ "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pkg-dir/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pkg-dir/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pkg-dir/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pkg-dir/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/psl": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
+ "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
+ "dev": true
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"node": ">=6"
}
},
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ]
+ },
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "dev": true
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
}
]
},
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
+ },
"node_modules/read-pkg": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true
+ },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/resolve-cwd": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
+ "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
+ "dev": true,
+ "dependencies": {
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve-cwd/node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"node": ">=4"
}
},
+ "node_modules/resolve.exports": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz",
+ "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
"node_modules/sass": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz",
"node": ">=14.0.0"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/snabbdom": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.2.tgz",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz",
"integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg=="
},
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
+ "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==",
+ "dev": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
"node_modules/spdx-correct": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==",
"dev": true
},
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ },
+ "node_modules/stack-utils": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+ "dependencies": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/stack-utils/node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-length": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
+ "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
+ "dev": true,
+ "dependencies": {
+ "char-regex": "^1.0.2",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"node": ">=4"
}
},
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true
+ },
+ "node_modules/test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
+ "node_modules/tmpl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+ "dev": true
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+ "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "dev": true,
+ "dependencies": {
+ "psl": "^1.1.33",
+ "punycode": "^2.1.1",
+ "universalify": "^0.2.0",
+ "url-parse": "^1.5.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
+ "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ts-jest": {
+ "version": "29.2.5",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz",
+ "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==",
+ "dev": true,
+ "dependencies": {
+ "bs-logger": "^0.2.6",
+ "ejs": "^3.1.10",
+ "fast-json-stable-stringify": "^2.1.0",
+ "jest-util": "^29.0.0",
+ "json5": "^2.2.3",
+ "lodash.memoize": "^4.1.2",
+ "make-error": "^1.3.6",
+ "semver": "^7.6.3",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "ts-jest": "cli.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": ">=7.0.0-beta.0 <8",
+ "@jest/transform": "^29.0.0",
+ "@jest/types": "^29.0.0",
+ "babel-jest": "^29.0.0",
+ "jest": "^29.0.0",
+ "typescript": ">=4.3 <6"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "@jest/transform": {
+ "optional": true
+ },
+ "@jest/types": {
+ "optional": true
+ },
+ "babel-jest": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ts-jest/node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ts-jest/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ts-jest/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ts-node": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+ "dev": true,
+ "dependencies": {
+ "@cspotcode/source-map-support": "^0.8.0",
+ "@tsconfig/node10": "^1.0.7",
+ "@tsconfig/node12": "^1.0.7",
+ "@tsconfig/node14": "^1.0.0",
+ "@tsconfig/node16": "^1.0.2",
+ "acorn": "^8.4.1",
+ "acorn-walk": "^8.1.1",
+ "arg": "^4.1.0",
+ "create-require": "^1.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "v8-compile-cache-lib": "^3.0.1",
+ "yn": "3.1.1"
+ },
+ "bin": {
+ "ts-node": "dist/bin.js",
+ "ts-node-cwd": "dist/bin-cwd.js",
+ "ts-node-esm": "dist/bin-esm.js",
+ "ts-node-script": "dist/bin-script.js",
+ "ts-node-transpile-only": "dist/bin-transpile.js",
+ "ts-script": "dist/bin-script-deprecated.js"
+ },
+ "peerDependencies": {
+ "@swc/core": ">=1.2.50",
+ "@swc/wasm": ">=1.2.50",
+ "@types/node": "*",
+ "typescript": ">=2.7"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "@swc/wasm": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
"node": ">= 0.8.0"
}
},
+ "node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici-types": {
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
+ },
+ "node_modules/universalify": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
+ "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.1.2",
+ "picocolors": "^1.0.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"punycode": "^2.1.0"
}
},
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "dev": true,
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
+ "node_modules/v8-compile-cache-lib": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+ "dev": true
+ },
+ "node_modules/v8-to-istanbul": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.12",
+ "@types/istanbul-lib-coverage": "^2.0.1",
+ "convert-source-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
+ "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
+ "dev": true,
+ "dependencies": {
+ "xml-name-validator": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/walker": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+ "dev": true,
+ "dependencies": {
+ "makeerror": "1.0.12"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
+ "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+ "dev": true,
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
+ "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
+ "dev": true,
+ "dependencies": {
+ "tr46": "^3.0.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
+ "node_modules/write-file-atomic": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz",
+ "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^3.0.7"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+ "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true
+ },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true
},
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
"node_modules/yargs": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
"node": ">=4"
}
},
+ "node_modules/yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads",
"lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"",
"fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"",
- "ts:lint": "tsc --noEmit"
+ "ts:lint": "tsc --noEmit",
+ "test": "jest"
},
"devDependencies": {
"@lezer/generator": "^1.5.1",
+ "babel-jest": "^29.7.0",
"chokidar-cli": "^3.0",
"esbuild": "^0.20",
"eslint": "^8.55.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.0",
+ "jest": "^29.7.0",
+ "jest-environment-jsdom": "^29.7.0",
"livereload": "^0.9.3",
"npm-run-all": "^4.1.5",
"sass": "^1.69.5",
+ "ts-jest": "^29.2.5",
+ "ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"dependencies": {
"@codemirror/state": "^6.3.3",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.22.2",
- "@lexical/history": "^0.17.0",
- "@lexical/html": "^0.17.0",
- "@lexical/link": "^0.17.0",
- "@lexical/list": "^0.17.0",
- "@lexical/rich-text": "^0.17.0",
- "@lexical/selection": "^0.17.0",
- "@lexical/table": "^0.17.0",
- "@lexical/utils": "^0.17.0",
"@lezer/highlight": "^1.2.0",
"@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
+ "@types/jest": "^29.5.13",
"codemirror": "^6.0.1",
"idb-keyval": "^6.2.1",
- "lexical": "^0.17.0",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"snabbdom": "^3.5.1",
import * as componentMap from './components';
import {ComponentStore} from './services/components.ts';
+// eslint-disable-next-line no-underscore-dangle
+window.__DEV__ = false;
+
// Url retrieval function
window.baseUrl = function baseUrl(path) {
let targetPath = path;
import {HttpManager} from "./services/http";
declare global {
+ const __DEV__: boolean;
+
interface Window {
- $components: ComponentStore,
- $events: EventManager,
- $http: HttpManager,
+ $components: ComponentStore;
+ $events: EventManager;
+ $http: HttpManager;
baseUrl: (path: string) => string;
}
}
\ No newline at end of file
--- /dev/null
+MIT License
+
+Copyright (c) Meta Platforms, Inc. and affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
--- /dev/null
+/**
+ * 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 {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
+import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
+import {objectKlassEquals} from '@lexical/utils';
+import {
+ $cloneWithProperties,
+ $createTabNode,
+ $getEditor,
+ $getRoot,
+ $getSelection,
+ $isElementNode,
+ $isRangeSelection,
+ $isTextNode,
+ $parseSerializedNode,
+ BaseSelection,
+ COMMAND_PRIORITY_CRITICAL,
+ COPY_COMMAND,
+ isSelectionWithinEditor,
+ LexicalEditor,
+ LexicalNode,
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
+ SerializedElementNode,
+ SerializedTextNode,
+} from 'lexical';
+import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
+import invariant from 'lexical/shared/invariant';
+
+const getDOMSelection = (targetWindow: Window | null): Selection | null =>
+ CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
+
+export interface LexicalClipboardData {
+ 'text/html'?: string | undefined;
+ 'application/x-lexical-editor'?: string | undefined;
+ 'text/plain': string;
+}
+
+/**
+ * Returns the *currently selected* Lexical content as an HTML string, relying on the
+ * logic defined in the exportDOM methods on the LexicalNode classes. Note that
+ * this will not return the HTML content of the entire editor (unless all the content is included
+ * in the current selection).
+ *
+ * @param editor - LexicalEditor instance to get HTML content from
+ * @param selection - The selection to use (default is $getSelection())
+ * @returns a string of HTML content
+ */
+export function $getHtmlContent(
+ editor: LexicalEditor,
+ selection = $getSelection(),
+): string {
+ if (selection == null) {
+ invariant(false, 'Expected valid LexicalSelection');
+ }
+
+ // If we haven't selected anything
+ if (
+ ($isRangeSelection(selection) && selection.isCollapsed()) ||
+ selection.getNodes().length === 0
+ ) {
+ return '';
+ }
+
+ return $generateHtmlFromNodes(editor, selection);
+}
+
+/**
+ * Returns the *currently selected* Lexical content as a JSON string, relying on the
+ * logic defined in the exportJSON methods on the LexicalNode classes. Note that
+ * this will not return the JSON content of the entire editor (unless all the content is included
+ * in the current selection).
+ *
+ * @param editor - LexicalEditor instance to get the JSON content from
+ * @param selection - The selection to use (default is $getSelection())
+ * @returns
+ */
+export function $getLexicalContent(
+ editor: LexicalEditor,
+ selection = $getSelection(),
+): null | string {
+ if (selection == null) {
+ invariant(false, 'Expected valid LexicalSelection');
+ }
+
+ // If we haven't selected anything
+ if (
+ ($isRangeSelection(selection) && selection.isCollapsed()) ||
+ selection.getNodes().length === 0
+ ) {
+ return null;
+ }
+
+ return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
+}
+
+/**
+ * Attempts to insert content of the mime-types text/plain or text/uri-list from
+ * the provided DataTransfer object into the editor at the provided selection.
+ * text/uri-list is only used if text/plain is not also provided.
+ *
+ * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
+ * @param selection the selection to use as the insertion point for the content in the DataTransfer object
+ */
+export function $insertDataTransferForPlainText(
+ dataTransfer: DataTransfer,
+ selection: BaseSelection,
+): void {
+ const text =
+ dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
+
+ if (text != null) {
+ selection.insertRawText(text);
+ }
+}
+
+/**
+ * Attempts to insert content of the mime-types application/x-lexical-editor, text/html,
+ * text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer
+ * object into the editor at the provided selection.
+ *
+ * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
+ * @param selection the selection to use as the insertion point for the content in the DataTransfer object
+ * @param editor the LexicalEditor the content is being inserted into.
+ */
+export function $insertDataTransferForRichText(
+ dataTransfer: DataTransfer,
+ selection: BaseSelection,
+ editor: LexicalEditor,
+): void {
+ const lexicalString = dataTransfer.getData('application/x-lexical-editor');
+
+ if (lexicalString) {
+ try {
+ const payload = JSON.parse(lexicalString);
+ if (
+ payload.namespace === editor._config.namespace &&
+ Array.isArray(payload.nodes)
+ ) {
+ const nodes = $generateNodesFromSerializedNodes(payload.nodes);
+ return $insertGeneratedNodes(editor, nodes, selection);
+ }
+ } catch {
+ // Fail silently.
+ }
+ }
+
+ const htmlString = dataTransfer.getData('text/html');
+ if (htmlString) {
+ try {
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(htmlString, 'text/html');
+ const nodes = $generateNodesFromDOM(editor, dom);
+ return $insertGeneratedNodes(editor, nodes, selection);
+ } catch {
+ // Fail silently.
+ }
+ }
+
+ // Multi-line plain text in rich text mode pasted as separate paragraphs
+ // instead of single paragraph with linebreaks.
+ // Webkit-specific: Supports read 'text/uri-list' in clipboard.
+ const text =
+ dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
+ if (text != null) {
+ if ($isRangeSelection(selection)) {
+ const parts = text.split(/(\r?\n|\t)/);
+ if (parts[parts.length - 1] === '') {
+ parts.pop();
+ }
+ for (let i = 0; i < parts.length; i++) {
+ const currentSelection = $getSelection();
+ if ($isRangeSelection(currentSelection)) {
+ const part = parts[i];
+ if (part === '\n' || part === '\r\n') {
+ currentSelection.insertParagraph();
+ } else if (part === '\t') {
+ currentSelection.insertNodes([$createTabNode()]);
+ } else {
+ currentSelection.insertText(part);
+ }
+ }
+ }
+ } else {
+ selection.insertRawText(text);
+ }
+ }
+}
+
+/**
+ * Inserts Lexical nodes into the editor using different strategies depending on
+ * some simple selection-based heuristics. If you're looking for a generic way to
+ * to insert nodes into the editor at a specific selection point, you probably want
+ * {@link lexical.$insertNodes}
+ *
+ * @param editor LexicalEditor instance to insert the nodes into.
+ * @param nodes The nodes to insert.
+ * @param selection The selection to insert the nodes into.
+ */
+export function $insertGeneratedNodes(
+ editor: LexicalEditor,
+ nodes: Array<LexicalNode>,
+ selection: BaseSelection,
+): void {
+ if (
+ !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
+ nodes,
+ selection,
+ })
+ ) {
+ selection.insertNodes(nodes);
+ }
+ return;
+}
+
+export interface BaseSerializedNode {
+ children?: Array<BaseSerializedNode>;
+ type: string;
+ version: number;
+}
+
+function exportNodeToJSON<T extends LexicalNode>(node: T): BaseSerializedNode {
+ const serializedNode = node.exportJSON();
+ const nodeClass = node.constructor;
+
+ if (serializedNode.type !== nodeClass.getType()) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .exportJSON().',
+ nodeClass.name,
+ );
+ }
+
+ if ($isElementNode(node)) {
+ const serializedChildren = (serializedNode as SerializedElementNode)
+ .children;
+ if (!Array.isArray(serializedChildren)) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
+ nodeClass.name,
+ );
+ }
+ }
+
+ return serializedNode;
+}
+
+function $appendNodesToJSON(
+ editor: LexicalEditor,
+ selection: BaseSelection | null,
+ currentNode: LexicalNode,
+ targetArray: Array<BaseSerializedNode> = [],
+): boolean {
+ let shouldInclude =
+ selection !== null ? currentNode.isSelected(selection) : true;
+ const shouldExclude =
+ $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
+ let target = currentNode;
+
+ if (selection !== null) {
+ let clone = $cloneWithProperties(currentNode);
+ clone =
+ $isTextNode(clone) && selection !== null
+ ? $sliceSelectedTextNodeContent(selection, clone)
+ : clone;
+ target = clone;
+ }
+ const children = $isElementNode(target) ? target.getChildren() : [];
+
+ const serializedNode = exportNodeToJSON(target);
+
+ // TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
+ // which uses getLatest() to get the text from the original node with the same key.
+ // This is a deeper issue with the word "clone" here, it's still a reference to the
+ // same node as far as the LexicalEditor is concerned since it shares a key.
+ // We need a way to create a clone of a Node in memory with its own key, but
+ // until then this hack will work for the selected text extract use case.
+ if ($isTextNode(target)) {
+ const text = target.__text;
+ // If an uncollapsed selection ends or starts at the end of a line of specialized,
+ // TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
+ // with text of length 0. We don't want this, it makes a confusing mess. Reset!
+ if (text.length > 0) {
+ (serializedNode as SerializedTextNode).text = text;
+ } else {
+ shouldInclude = false;
+ }
+ }
+
+ for (let i = 0; i < children.length; i++) {
+ const childNode = children[i];
+ const shouldIncludeChild = $appendNodesToJSON(
+ editor,
+ selection,
+ childNode,
+ serializedNode.children,
+ );
+
+ if (
+ !shouldInclude &&
+ $isElementNode(currentNode) &&
+ shouldIncludeChild &&
+ currentNode.extractWithChild(childNode, selection, 'clone')
+ ) {
+ shouldInclude = true;
+ }
+ }
+
+ if (shouldInclude && !shouldExclude) {
+ targetArray.push(serializedNode);
+ } else if (Array.isArray(serializedNode.children)) {
+ for (let i = 0; i < serializedNode.children.length; i++) {
+ const serializedChildNode = serializedNode.children[i];
+ targetArray.push(serializedChildNode);
+ }
+ }
+
+ return shouldInclude;
+}
+
+// TODO why $ function with Editor instance?
+/**
+ * Gets the Lexical JSON of the nodes inside the provided Selection.
+ *
+ * @param editor LexicalEditor to get the JSON content from.
+ * @param selection Selection to get the JSON content from.
+ * @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects.
+ */
+export function $generateJSONFromSelectedNodes<
+ SerializedNode extends BaseSerializedNode,
+>(
+ editor: LexicalEditor,
+ selection: BaseSelection | null,
+): {
+ namespace: string;
+ nodes: Array<SerializedNode>;
+} {
+ const nodes: Array<SerializedNode> = [];
+ const root = $getRoot();
+ const topLevelChildren = root.getChildren();
+ for (let i = 0; i < topLevelChildren.length; i++) {
+ const topLevelNode = topLevelChildren[i];
+ $appendNodesToJSON(editor, selection, topLevelNode, nodes);
+ }
+ return {
+ namespace: editor._config.namespace,
+ nodes,
+ };
+}
+
+/**
+ * This method takes an array of objects conforming to the BaseSeralizedNode interface and returns
+ * an Array containing instances of the corresponding LexicalNode classes registered on the editor.
+ * Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes}
+ *
+ * @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
+ * @returns an Array of Lexical Node objects.
+ */
+export function $generateNodesFromSerializedNodes(
+ serializedNodes: Array<BaseSerializedNode>,
+): Array<LexicalNode> {
+ const nodes = [];
+ for (let i = 0; i < serializedNodes.length; i++) {
+ const serializedNode = serializedNodes[i];
+ const node = $parseSerializedNode(serializedNode);
+ if ($isTextNode(node)) {
+ $addNodeStyle(node);
+ }
+ nodes.push(node);
+ }
+ return nodes;
+}
+
+const EVENT_LATENCY = 50;
+let clipboardEventTimeout: null | number = null;
+
+// TODO custom selection
+// TODO potentially have a node customizable version for plain text
+/**
+ * Copies the content of the current selection to the clipboard in
+ * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
+ * formats.
+ *
+ * @param editor the LexicalEditor instance to copy content from
+ * @param event the native browser ClipboardEvent to add the content to.
+ * @returns
+ */
+export async function copyToClipboard(
+ editor: LexicalEditor,
+ event: null | ClipboardEvent,
+ data?: LexicalClipboardData,
+): Promise<boolean> {
+ if (clipboardEventTimeout !== null) {
+ // Prevent weird race conditions that can happen when this function is run multiple times
+ // synchronously. In the future, we can do better, we can cancel/override the previously running job.
+ return false;
+ }
+ if (event !== null) {
+ return new Promise((resolve, reject) => {
+ editor.update(() => {
+ resolve($copyToClipboardEvent(editor, event, data));
+ });
+ });
+ }
+
+ const rootElement = editor.getRootElement();
+ const windowDocument =
+ editor._window == null ? window.document : editor._window.document;
+ const domSelection = getDOMSelection(editor._window);
+ if (rootElement === null || domSelection === null) {
+ return false;
+ }
+ const element = windowDocument.createElement('span');
+ element.style.cssText = 'position: fixed; top: -1000px;';
+ element.append(windowDocument.createTextNode('#'));
+ rootElement.append(element);
+ const range = new Range();
+ range.setStart(element, 0);
+ range.setEnd(element, 1);
+ domSelection.removeAllRanges();
+ domSelection.addRange(range);
+ return new Promise((resolve, reject) => {
+ const removeListener = editor.registerCommand(
+ COPY_COMMAND,
+ (secondEvent) => {
+ if (objectKlassEquals(secondEvent, ClipboardEvent)) {
+ removeListener();
+ if (clipboardEventTimeout !== null) {
+ window.clearTimeout(clipboardEventTimeout);
+ clipboardEventTimeout = null;
+ }
+ resolve(
+ $copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data),
+ );
+ }
+ // Block the entire copy flow while we wait for the next ClipboardEvent
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ );
+ // If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
+ // the listener will be quickly freed so that the user can reuse it again
+ clipboardEventTimeout = window.setTimeout(() => {
+ removeListener();
+ clipboardEventTimeout = null;
+ resolve(false);
+ }, EVENT_LATENCY);
+ windowDocument.execCommand('copy');
+ element.remove();
+ });
+}
+
+// TODO shouldn't pass editor (pass namespace directly)
+function $copyToClipboardEvent(
+ editor: LexicalEditor,
+ event: ClipboardEvent,
+ data?: LexicalClipboardData,
+): boolean {
+ if (data === undefined) {
+ const domSelection = getDOMSelection(editor._window);
+ if (!domSelection) {
+ return false;
+ }
+ const anchorDOM = domSelection.anchorNode;
+ const focusDOM = domSelection.focusNode;
+ if (
+ anchorDOM !== null &&
+ focusDOM !== null &&
+ !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
+ ) {
+ return false;
+ }
+ const selection = $getSelection();
+ if (selection === null) {
+ return false;
+ }
+ data = $getClipboardDataFromSelection(selection);
+ }
+ event.preventDefault();
+ const clipboardData = event.clipboardData;
+ if (clipboardData === null) {
+ return false;
+ }
+ setLexicalClipboardDataTransfer(clipboardData, data);
+ return true;
+}
+
+const clipboardDataFunctions = [
+ ['text/html', $getHtmlContent],
+ ['application/x-lexical-editor', $getLexicalContent],
+] as const;
+
+/**
+ * Serialize the content of the current selection to strings in
+ * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
+ * formats (as available).
+ *
+ * @param selection the selection to serialize (defaults to $getSelection())
+ * @returns LexicalClipboardData
+ */
+export function $getClipboardDataFromSelection(
+ selection: BaseSelection | null = $getSelection(),
+): LexicalClipboardData {
+ const clipboardData: LexicalClipboardData = {
+ 'text/plain': selection ? selection.getTextContent() : '',
+ };
+ if (selection) {
+ const editor = $getEditor();
+ for (const [mimeType, $editorFn] of clipboardDataFunctions) {
+ const v = $editorFn(editor, selection);
+ if (v !== null) {
+ clipboardData[mimeType] = v;
+ }
+ }
+ }
+ return clipboardData;
+}
+
+/**
+ * Call setData on the given clipboardData for each MIME type present
+ * in the given data (from {@link $getClipboardDataFromSelection})
+ *
+ * @param clipboardData the event.clipboardData to populate from data
+ * @param data The lexical data
+ */
+export function setLexicalClipboardDataTransfer(
+ clipboardData: DataTransfer,
+ data: LexicalClipboardData,
+) {
+ for (const k in data) {
+ const v = data[k as keyof LexicalClipboardData];
+ if (v !== undefined) {
+ clipboardData.setData(k, v);
+ }
+ }
+}
--- /dev/null
+/**
+ * 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 {
+ $generateJSONFromSelectedNodes,
+ $generateNodesFromSerializedNodes,
+ $getClipboardDataFromSelection,
+ $getHtmlContent,
+ $getLexicalContent,
+ $insertDataTransferForPlainText,
+ $insertDataTransferForRichText,
+ $insertGeneratedNodes,
+ copyToClipboard,
+ type LexicalClipboardData,
+ setLexicalClipboardDataTransfer,
+} from './clipboard';
--- /dev/null
+/**
+ * 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,
+ ElementFormatType,
+ LexicalCommand,
+ LexicalNode,
+ TextFormatType,
+} from 'lexical';
+
+export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent;
+
+export function createCommand<T>(type?: string): LexicalCommand<T> {
+ return __DEV__ ? {type} : {};
+}
+
+export const SELECTION_CHANGE_COMMAND: LexicalCommand<void> = createCommand(
+ 'SELECTION_CHANGE_COMMAND',
+);
+export const SELECTION_INSERT_CLIPBOARD_NODES_COMMAND: LexicalCommand<{
+ nodes: Array<LexicalNode>;
+ selection: BaseSelection;
+}> = createCommand('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND');
+export const CLICK_COMMAND: LexicalCommand<MouseEvent> =
+ createCommand('CLICK_COMMAND');
+export const DELETE_CHARACTER_COMMAND: LexicalCommand<boolean> = createCommand(
+ 'DELETE_CHARACTER_COMMAND',
+);
+export const INSERT_LINE_BREAK_COMMAND: LexicalCommand<boolean> = createCommand(
+ 'INSERT_LINE_BREAK_COMMAND',
+);
+export const INSERT_PARAGRAPH_COMMAND: LexicalCommand<void> = createCommand(
+ 'INSERT_PARAGRAPH_COMMAND',
+);
+export const CONTROLLED_TEXT_INSERTION_COMMAND: LexicalCommand<
+ InputEvent | string
+> = createCommand('CONTROLLED_TEXT_INSERTION_COMMAND');
+export const PASTE_COMMAND: LexicalCommand<PasteCommandType> =
+ createCommand('PASTE_COMMAND');
+export const REMOVE_TEXT_COMMAND: LexicalCommand<InputEvent | null> =
+ createCommand('REMOVE_TEXT_COMMAND');
+export const DELETE_WORD_COMMAND: LexicalCommand<boolean> = createCommand(
+ 'DELETE_WORD_COMMAND',
+);
+export const DELETE_LINE_COMMAND: LexicalCommand<boolean> = createCommand(
+ 'DELETE_LINE_COMMAND',
+);
+export const FORMAT_TEXT_COMMAND: LexicalCommand<TextFormatType> =
+ createCommand('FORMAT_TEXT_COMMAND');
+export const UNDO_COMMAND: LexicalCommand<void> = createCommand('UNDO_COMMAND');
+export const REDO_COMMAND: LexicalCommand<void> = createCommand('REDO_COMMAND');
+export const KEY_DOWN_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEYDOWN_COMMAND');
+export const KEY_ARROW_RIGHT_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_ARROW_RIGHT_COMMAND');
+export const MOVE_TO_END: LexicalCommand<KeyboardEvent> =
+ createCommand('MOVE_TO_END');
+export const KEY_ARROW_LEFT_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_ARROW_LEFT_COMMAND');
+export const MOVE_TO_START: LexicalCommand<KeyboardEvent> =
+ createCommand('MOVE_TO_START');
+export const KEY_ARROW_UP_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_ARROW_UP_COMMAND');
+export const KEY_ARROW_DOWN_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_ARROW_DOWN_COMMAND');
+export const KEY_ENTER_COMMAND: LexicalCommand<KeyboardEvent | null> =
+ createCommand('KEY_ENTER_COMMAND');
+export const KEY_SPACE_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_SPACE_COMMAND');
+export const KEY_BACKSPACE_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_BACKSPACE_COMMAND');
+export const KEY_ESCAPE_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_ESCAPE_COMMAND');
+export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_DELETE_COMMAND');
+export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_TAB_COMMAND');
+export const INSERT_TAB_COMMAND: LexicalCommand<void> =
+ createCommand('INSERT_TAB_COMMAND');
+export const INDENT_CONTENT_COMMAND: LexicalCommand<void> = createCommand(
+ 'INDENT_CONTENT_COMMAND',
+);
+export const OUTDENT_CONTENT_COMMAND: LexicalCommand<void> = createCommand(
+ 'OUTDENT_CONTENT_COMMAND',
+);
+export const DROP_COMMAND: LexicalCommand<DragEvent> =
+ createCommand('DROP_COMMAND');
+export const FORMAT_ELEMENT_COMMAND: LexicalCommand<ElementFormatType> =
+ createCommand('FORMAT_ELEMENT_COMMAND');
+export const DRAGSTART_COMMAND: LexicalCommand<DragEvent> =
+ createCommand('DRAGSTART_COMMAND');
+export const DRAGOVER_COMMAND: LexicalCommand<DragEvent> =
+ createCommand('DRAGOVER_COMMAND');
+export const DRAGEND_COMMAND: LexicalCommand<DragEvent> =
+ createCommand('DRAGEND_COMMAND');
+export const COPY_COMMAND: LexicalCommand<
+ ClipboardEvent | KeyboardEvent | null
+> = createCommand('COPY_COMMAND');
+export const CUT_COMMAND: LexicalCommand<
+ ClipboardEvent | KeyboardEvent | null
+> = createCommand('CUT_COMMAND');
+export const SELECT_ALL_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('SELECT_ALL_COMMAND');
+export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand(
+ 'CLEAR_EDITOR_COMMAND',
+);
+export const CLEAR_HISTORY_COMMAND: LexicalCommand<void> = createCommand(
+ 'CLEAR_HISTORY_COMMAND',
+);
+export const CAN_REDO_COMMAND: LexicalCommand<boolean> =
+ createCommand('CAN_REDO_COMMAND');
+export const CAN_UNDO_COMMAND: LexicalCommand<boolean> =
+ createCommand('CAN_UNDO_COMMAND');
+export const FOCUS_COMMAND: LexicalCommand<FocusEvent> =
+ createCommand('FOCUS_COMMAND');
+export const BLUR_COMMAND: LexicalCommand<FocusEvent> =
+ createCommand('BLUR_COMMAND');
+export const KEY_MODIFIER_COMMAND: LexicalCommand<KeyboardEvent> =
+ createCommand('KEY_MODIFIER_COMMAND');
--- /dev/null
+/**
+ * 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 {ElementFormatType} from './nodes/LexicalElementNode';
+import type {
+ TextDetailType,
+ TextFormatType,
+ TextModeType,
+} from './nodes/LexicalTextNode';
+
+import {
+ IS_APPLE_WEBKIT,
+ IS_FIREFOX,
+ IS_IOS,
+ IS_SAFARI,
+} from 'lexical/shared/environment';
+
+// DOM
+export const DOM_ELEMENT_TYPE = 1;
+export const DOM_TEXT_TYPE = 3;
+
+// Reconciling
+export const NO_DIRTY_NODES = 0;
+export const HAS_DIRTY_NODES = 1;
+export const FULL_RECONCILE = 2;
+
+// Text node modes
+export const IS_NORMAL = 0;
+export const IS_TOKEN = 1;
+export const IS_SEGMENTED = 2;
+// IS_INERT = 3
+
+// Text node formatting
+export const IS_BOLD = 1;
+export const IS_ITALIC = 1 << 1;
+export const IS_STRIKETHROUGH = 1 << 2;
+export const IS_UNDERLINE = 1 << 3;
+export const IS_CODE = 1 << 4;
+export const IS_SUBSCRIPT = 1 << 5;
+export const IS_SUPERSCRIPT = 1 << 6;
+export const IS_HIGHLIGHT = 1 << 7;
+
+export const IS_ALL_FORMATTING =
+ IS_BOLD |
+ IS_ITALIC |
+ IS_STRIKETHROUGH |
+ IS_UNDERLINE |
+ IS_CODE |
+ IS_SUBSCRIPT |
+ IS_SUPERSCRIPT |
+ IS_HIGHLIGHT;
+
+// Text node details
+export const IS_DIRECTIONLESS = 1;
+export const IS_UNMERGEABLE = 1 << 1;
+
+// Element node formatting
+export const IS_ALIGN_LEFT = 1;
+export const IS_ALIGN_CENTER = 2;
+export const IS_ALIGN_RIGHT = 3;
+export const IS_ALIGN_JUSTIFY = 4;
+export const IS_ALIGN_START = 5;
+export const IS_ALIGN_END = 6;
+
+// Reconciliation
+export const NON_BREAKING_SPACE = '\u00A0';
+const ZERO_WIDTH_SPACE = '\u200b';
+
+// For iOS/Safari we use a non breaking space, otherwise the cursor appears
+// overlapping the composed text.
+export const COMPOSITION_SUFFIX: string =
+ IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT
+ ? NON_BREAKING_SPACE
+ : ZERO_WIDTH_SPACE;
+export const DOUBLE_LINE_BREAK = '\n\n';
+
+// For FF, we need to use a non-breaking space, or it gets composition
+// in a stuck state.
+export const COMPOSITION_START_CHAR: string = IS_FIREFOX
+ ? NON_BREAKING_SPACE
+ : COMPOSITION_SUFFIX;
+const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
+const LTR =
+ 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' +
+ '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' +
+ '\uFE00-\uFE6F\uFEFD-\uFFFF';
+
+// eslint-disable-next-line no-misleading-character-class
+export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']');
+// eslint-disable-next-line no-misleading-character-class
+export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']');
+
+export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
+ bold: IS_BOLD,
+ code: IS_CODE,
+ highlight: IS_HIGHLIGHT,
+ italic: IS_ITALIC,
+ strikethrough: IS_STRIKETHROUGH,
+ subscript: IS_SUBSCRIPT,
+ superscript: IS_SUPERSCRIPT,
+ underline: IS_UNDERLINE,
+};
+
+export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
+ directionless: IS_DIRECTIONLESS,
+ unmergeable: IS_UNMERGEABLE,
+};
+
+export const ELEMENT_TYPE_TO_FORMAT: Record<
+ Exclude<ElementFormatType, ''>,
+ number
+> = {
+ center: IS_ALIGN_CENTER,
+ end: IS_ALIGN_END,
+ justify: IS_ALIGN_JUSTIFY,
+ left: IS_ALIGN_LEFT,
+ right: IS_ALIGN_RIGHT,
+ start: IS_ALIGN_START,
+};
+
+export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
+ [IS_ALIGN_CENTER]: 'center',
+ [IS_ALIGN_END]: 'end',
+ [IS_ALIGN_JUSTIFY]: 'justify',
+ [IS_ALIGN_LEFT]: 'left',
+ [IS_ALIGN_RIGHT]: 'right',
+ [IS_ALIGN_START]: 'start',
+};
+
+export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
+ normal: IS_NORMAL,
+ segmented: IS_SEGMENTED,
+ token: IS_TOKEN,
+};
+
+export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
+ [IS_NORMAL]: 'normal',
+ [IS_SEGMENTED]: 'segmented',
+ [IS_TOKEN]: 'token',
+};
--- /dev/null
+/**
+ * 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 {EditorState, SerializedEditorState} from './LexicalEditorState';
+import type {
+ DOMConversion,
+ DOMConversionMap,
+ DOMExportOutput,
+ DOMExportOutputMap,
+ NodeKey,
+} from './LexicalNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {$getRoot, $getSelection, TextNode} from '.';
+import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants';
+import {createEmptyEditorState} from './LexicalEditorState';
+import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents';
+import {$flushRootMutations, initMutationObserver} from './LexicalMutations';
+import {LexicalNode} from './LexicalNode';
+import {
+ $commitPendingUpdates,
+ internalGetActiveEditor,
+ parseEditorState,
+ triggerListeners,
+ updateEditor,
+} from './LexicalUpdates';
+import {
+ createUID,
+ dispatchCommand,
+ getCachedClassNameArray,
+ getCachedTypeToNodeMap,
+ getDefaultView,
+ getDOMSelection,
+ markAllNodesAsDirty,
+} from './LexicalUtils';
+import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
+import {DecoratorNode} from './nodes/LexicalDecoratorNode';
+import {LineBreakNode} from './nodes/LexicalLineBreakNode';
+import {ParagraphNode} from './nodes/LexicalParagraphNode';
+import {RootNode} from './nodes/LexicalRootNode';
+import {TabNode} from './nodes/LexicalTabNode';
+
+export type Spread<T1, T2> = Omit<T2, keyof T1> & T1;
+
+// https://github.com/microsoft/TypeScript/issues/3841
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type KlassConstructor<Cls extends GenericConstructor<any>> =
+ GenericConstructor<InstanceType<Cls>> & {[k in keyof Cls]: Cls[k]};
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type GenericConstructor<T> = new (...args: any[]) => T;
+
+export type Klass<T extends LexicalNode> = InstanceType<
+ T['constructor']
+> extends T
+ ? T['constructor']
+ : GenericConstructor<T> & T['constructor'];
+
+export type EditorThemeClassName = string;
+
+export type TextNodeThemeClasses = {
+ base?: EditorThemeClassName;
+ bold?: EditorThemeClassName;
+ code?: EditorThemeClassName;
+ highlight?: EditorThemeClassName;
+ italic?: EditorThemeClassName;
+ strikethrough?: EditorThemeClassName;
+ subscript?: EditorThemeClassName;
+ superscript?: EditorThemeClassName;
+ underline?: EditorThemeClassName;
+ underlineStrikethrough?: EditorThemeClassName;
+ [key: string]: EditorThemeClassName | undefined;
+};
+
+export type EditorUpdateOptions = {
+ onUpdate?: () => void;
+ skipTransforms?: true;
+ tag?: string;
+ discrete?: true;
+};
+
+export type EditorSetOptions = {
+ tag?: string;
+};
+
+export type EditorFocusOptions = {
+ defaultSelection?: 'rootStart' | 'rootEnd';
+};
+
+export type EditorThemeClasses = {
+ blockCursor?: EditorThemeClassName;
+ characterLimit?: EditorThemeClassName;
+ code?: EditorThemeClassName;
+ codeHighlight?: Record<string, EditorThemeClassName>;
+ hashtag?: EditorThemeClassName;
+ heading?: {
+ h1?: EditorThemeClassName;
+ h2?: EditorThemeClassName;
+ h3?: EditorThemeClassName;
+ h4?: EditorThemeClassName;
+ h5?: EditorThemeClassName;
+ h6?: EditorThemeClassName;
+ };
+ hr?: EditorThemeClassName;
+ image?: EditorThemeClassName;
+ link?: EditorThemeClassName;
+ list?: {
+ ul?: EditorThemeClassName;
+ ulDepth?: Array<EditorThemeClassName>;
+ ol?: EditorThemeClassName;
+ olDepth?: Array<EditorThemeClassName>;
+ checklist?: EditorThemeClassName;
+ listitem?: EditorThemeClassName;
+ listitemChecked?: EditorThemeClassName;
+ listitemUnchecked?: EditorThemeClassName;
+ nested?: {
+ list?: EditorThemeClassName;
+ listitem?: EditorThemeClassName;
+ };
+ };
+ ltr?: EditorThemeClassName;
+ mark?: EditorThemeClassName;
+ markOverlap?: EditorThemeClassName;
+ paragraph?: EditorThemeClassName;
+ quote?: EditorThemeClassName;
+ root?: EditorThemeClassName;
+ rtl?: EditorThemeClassName;
+ table?: EditorThemeClassName;
+ tableAddColumns?: EditorThemeClassName;
+ tableAddRows?: EditorThemeClassName;
+ tableCellActionButton?: EditorThemeClassName;
+ tableCellActionButtonContainer?: EditorThemeClassName;
+ tableCellPrimarySelected?: EditorThemeClassName;
+ tableCellSelected?: EditorThemeClassName;
+ tableCell?: EditorThemeClassName;
+ tableCellEditing?: EditorThemeClassName;
+ tableCellHeader?: EditorThemeClassName;
+ tableCellResizer?: EditorThemeClassName;
+ tableCellSortedIndicator?: EditorThemeClassName;
+ tableResizeRuler?: EditorThemeClassName;
+ tableRow?: EditorThemeClassName;
+ tableSelected?: EditorThemeClassName;
+ text?: TextNodeThemeClasses;
+ embedBlock?: {
+ base?: EditorThemeClassName;
+ focus?: EditorThemeClassName;
+ };
+ indent?: EditorThemeClassName;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [key: string]: any;
+};
+
+export type EditorConfig = {
+ disableEvents?: boolean;
+ namespace: string;
+ theme: EditorThemeClasses;
+};
+
+export type LexicalNodeReplacement = {
+ replace: Klass<LexicalNode>;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ with: <T extends {new (...args: any): any}>(
+ node: InstanceType<T>,
+ ) => LexicalNode;
+ withKlass?: Klass<LexicalNode>;
+};
+
+export type HTMLConfig = {
+ export?: DOMExportOutputMap;
+ import?: DOMConversionMap;
+};
+
+export type CreateEditorArgs = {
+ disableEvents?: boolean;
+ editorState?: EditorState;
+ namespace?: string;
+ nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
+ onError?: ErrorHandler;
+ parentEditor?: LexicalEditor;
+ editable?: boolean;
+ theme?: EditorThemeClasses;
+ html?: HTMLConfig;
+};
+
+export type RegisteredNodes = Map<string, RegisteredNode>;
+
+export type RegisteredNode = {
+ klass: Klass<LexicalNode>;
+ transforms: Set<Transform<LexicalNode>>;
+ replace: null | ((node: LexicalNode) => LexicalNode);
+ replaceWithKlass: null | Klass<LexicalNode>;
+ exportDOM?: (
+ editor: LexicalEditor,
+ targetNode: LexicalNode,
+ ) => DOMExportOutput;
+};
+
+export type Transform<T extends LexicalNode> = (node: T) => void;
+
+export type ErrorHandler = (error: Error) => void;
+
+export type MutationListeners = Map<MutationListener, Klass<LexicalNode>>;
+
+export type MutatedNodes = Map<Klass<LexicalNode>, Map<NodeKey, NodeMutation>>;
+
+export type NodeMutation = 'created' | 'updated' | 'destroyed';
+
+export interface MutationListenerOptions {
+ /**
+ * Skip the initial call of the listener with pre-existing DOM nodes.
+ *
+ * The default is currently true for backwards compatibility with <= 0.16.1
+ * but this default is expected to change to false in 0.17.0.
+ */
+ skipInitialization?: boolean;
+}
+
+const DEFAULT_SKIP_INITIALIZATION = true;
+
+export type UpdateListener = (arg0: {
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
+ dirtyLeaves: Set<NodeKey>;
+ editorState: EditorState;
+ normalizedNodes: Set<NodeKey>;
+ prevEditorState: EditorState;
+ tags: Set<string>;
+}) => void;
+
+export type DecoratorListener<T = never> = (
+ decorator: Record<NodeKey, T>,
+) => void;
+
+export type RootListener = (
+ rootElement: null | HTMLElement,
+ prevRootElement: null | HTMLElement,
+) => void;
+
+export type TextContentListener = (text: string) => void;
+
+export type MutationListener = (
+ nodes: Map<NodeKey, NodeMutation>,
+ payload: {
+ updateTags: Set<string>;
+ dirtyLeaves: Set<string>;
+ prevEditorState: EditorState;
+ },
+) => void;
+
+export type CommandListener<P> = (payload: P, editor: LexicalEditor) => boolean;
+
+export type EditableListener = (editable: boolean) => void;
+
+export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4;
+
+export const COMMAND_PRIORITY_EDITOR = 0;
+export const COMMAND_PRIORITY_LOW = 1;
+export const COMMAND_PRIORITY_NORMAL = 2;
+export const COMMAND_PRIORITY_HIGH = 3;
+export const COMMAND_PRIORITY_CRITICAL = 4;
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export type LexicalCommand<TPayload> = {
+ type?: string;
+};
+
+/**
+ * Type helper for extracting the payload type from a command.
+ *
+ * @example
+ * ```ts
+ * const MY_COMMAND = createCommand<SomeType>();
+ *
+ * // ...
+ *
+ * editor.registerCommand(MY_COMMAND, payload => {
+ * // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to
+ * handleMyCommand(editor, payload);
+ * return true;
+ * });
+ *
+ * function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType<typeof MY_COMMAND>) {
+ * // `payload` is of type `SomeType`, extracted from the command.
+ * }
+ * ```
+ */
+export type CommandPayloadType<TCommand extends LexicalCommand<unknown>> =
+ TCommand extends LexicalCommand<infer TPayload> ? TPayload : never;
+
+type Commands = Map<
+ LexicalCommand<unknown>,
+ Array<Set<CommandListener<unknown>>>
+>;
+type Listeners = {
+ decorator: Set<DecoratorListener>;
+ mutation: MutationListeners;
+ editable: Set<EditableListener>;
+ root: Set<RootListener>;
+ textcontent: Set<TextContentListener>;
+ update: Set<UpdateListener>;
+};
+
+export type Listener =
+ | DecoratorListener
+ | EditableListener
+ | MutationListener
+ | RootListener
+ | TextContentListener
+ | UpdateListener;
+
+export type ListenerType =
+ | 'update'
+ | 'root'
+ | 'decorator'
+ | 'textcontent'
+ | 'mutation'
+ | 'editable';
+
+export type TransformerType = 'text' | 'decorator' | 'element' | 'root';
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+type DOMConversionCache = Map<
+ string,
+ Array<(node: Node) => DOMConversion | null>
+>;
+
+export type SerializedEditor = {
+ editorState: SerializedEditorState;
+};
+
+export function resetEditor(
+ editor: LexicalEditor,
+ prevRootElement: null | HTMLElement,
+ nextRootElement: null | HTMLElement,
+ pendingEditorState: EditorState,
+): void {
+ const keyNodeMap = editor._keyToDOMMap;
+ keyNodeMap.clear();
+ editor._editorState = createEmptyEditorState();
+ editor._pendingEditorState = pendingEditorState;
+ editor._compositionKey = null;
+ editor._dirtyType = NO_DIRTY_NODES;
+ editor._cloneNotNeeded.clear();
+ editor._dirtyLeaves = new Set();
+ editor._dirtyElements.clear();
+ editor._normalizedNodes = new Set();
+ editor._updateTags = new Set();
+ editor._updates = [];
+ editor._blockCursorElement = null;
+
+ const observer = editor._observer;
+
+ if (observer !== null) {
+ observer.disconnect();
+ editor._observer = null;
+ }
+
+ // Remove all the DOM nodes from the root element
+ if (prevRootElement !== null) {
+ prevRootElement.textContent = '';
+ }
+
+ if (nextRootElement !== null) {
+ nextRootElement.textContent = '';
+ keyNodeMap.set('root', nextRootElement);
+ }
+}
+
+function initializeConversionCache(
+ nodes: RegisteredNodes,
+ additionalConversions?: DOMConversionMap,
+): DOMConversionCache {
+ const conversionCache = new Map();
+ const handledConversions = new Set();
+ const addConversionsToCache = (map: DOMConversionMap) => {
+ Object.keys(map).forEach((key) => {
+ let currentCache = conversionCache.get(key);
+
+ if (currentCache === undefined) {
+ currentCache = [];
+ conversionCache.set(key, currentCache);
+ }
+
+ currentCache.push(map[key]);
+ });
+ };
+ nodes.forEach((node) => {
+ const importDOM = node.klass.importDOM;
+
+ if (importDOM == null || handledConversions.has(importDOM)) {
+ return;
+ }
+
+ handledConversions.add(importDOM);
+ const map = importDOM.call(node.klass);
+
+ if (map !== null) {
+ addConversionsToCache(map);
+ }
+ });
+ if (additionalConversions) {
+ addConversionsToCache(additionalConversions);
+ }
+ return conversionCache;
+}
+
+/**
+ * Creates a new LexicalEditor attached to a single contentEditable (provided in the config). This is
+ * the lowest-level initialization API for a LexicalEditor. If you're using React or another framework,
+ * consider using the appropriate abstractions, such as LexicalComposer
+ * @param editorConfig - the editor configuration.
+ * @returns a LexicalEditor instance
+ */
+export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor {
+ const config = editorConfig || {};
+ const activeEditor = internalGetActiveEditor();
+ const theme = config.theme || {};
+ const parentEditor =
+ editorConfig === undefined ? activeEditor : config.parentEditor || null;
+ const disableEvents = config.disableEvents || false;
+ const editorState = createEmptyEditorState();
+ const namespace =
+ config.namespace ||
+ (parentEditor !== null ? parentEditor._config.namespace : createUID());
+ const initialEditorState = config.editorState;
+ const nodes = [
+ RootNode,
+ TextNode,
+ LineBreakNode,
+ TabNode,
+ ParagraphNode,
+ ArtificialNode__DO_NOT_USE,
+ ...(config.nodes || []),
+ ];
+ const {onError, html} = config;
+ const isEditable = config.editable !== undefined ? config.editable : true;
+ let registeredNodes: Map<string, RegisteredNode>;
+
+ if (editorConfig === undefined && activeEditor !== null) {
+ registeredNodes = activeEditor._nodes;
+ } else {
+ registeredNodes = new Map();
+ for (let i = 0; i < nodes.length; i++) {
+ let klass = nodes[i];
+ let replace: RegisteredNode['replace'] = null;
+ let replaceWithKlass: RegisteredNode['replaceWithKlass'] = null;
+
+ if (typeof klass !== 'function') {
+ const options = klass;
+ klass = options.replace;
+ replace = options.with;
+ replaceWithKlass = options.withKlass || null;
+ }
+ // Ensure custom nodes implement required methods and replaceWithKlass is instance of base klass.
+ if (__DEV__) {
+ // ArtificialNode__DO_NOT_USE can get renamed, so we use the type
+ const nodeType =
+ Object.prototype.hasOwnProperty.call(klass, 'getType') &&
+ klass.getType();
+ const name = klass.name;
+
+ if (replaceWithKlass) {
+ invariant(
+ replaceWithKlass.prototype instanceof klass,
+ "%s doesn't extend the %s",
+ replaceWithKlass.name,
+ name,
+ );
+ }
+
+ if (
+ name !== 'RootNode' &&
+ nodeType !== 'root' &&
+ nodeType !== 'artificial'
+ ) {
+ const proto = klass.prototype;
+ ['getType', 'clone'].forEach((method) => {
+ // eslint-disable-next-line no-prototype-builtins
+ if (!klass.hasOwnProperty(method)) {
+ console.warn(`${name} must implement static "${method}" method`);
+ }
+ });
+ if (
+ // eslint-disable-next-line no-prototype-builtins
+ !klass.hasOwnProperty('importDOM') &&
+ // eslint-disable-next-line no-prototype-builtins
+ klass.hasOwnProperty('exportDOM')
+ ) {
+ console.warn(
+ `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`,
+ );
+ }
+ if (proto instanceof DecoratorNode) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (!proto.hasOwnProperty('decorate')) {
+ console.warn(
+ `${proto.constructor.name} must implement "decorate" method`,
+ );
+ }
+ }
+ if (
+ // eslint-disable-next-line no-prototype-builtins
+ !klass.hasOwnProperty('importJSON')
+ ) {
+ console.warn(
+ `${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`,
+ );
+ }
+ if (
+ // eslint-disable-next-line no-prototype-builtins
+ !proto.hasOwnProperty('exportJSON')
+ ) {
+ console.warn(
+ `${name} should implement "exportJSON" method to ensure JSON and default HTML serialization works as expected`,
+ );
+ }
+ }
+ }
+ const type = klass.getType();
+ const transform = klass.transform();
+ const transforms = new Set<Transform<LexicalNode>>();
+ if (transform !== null) {
+ transforms.add(transform);
+ }
+ registeredNodes.set(type, {
+ exportDOM: html && html.export ? html.export.get(klass) : undefined,
+ klass,
+ replace,
+ replaceWithKlass,
+ transforms,
+ });
+ }
+ }
+ const editor = new LexicalEditor(
+ editorState,
+ parentEditor,
+ registeredNodes,
+ {
+ disableEvents,
+ namespace,
+ theme,
+ },
+ onError ? onError : console.error,
+ initializeConversionCache(registeredNodes, html ? html.import : undefined),
+ isEditable,
+ );
+
+ if (initialEditorState !== undefined) {
+ editor._pendingEditorState = initialEditorState;
+ editor._dirtyType = FULL_RECONCILE;
+ }
+
+ return editor;
+}
+export class LexicalEditor {
+ ['constructor']!: KlassConstructor<typeof LexicalEditor>;
+
+ /** The version with build identifiers for this editor (since 0.17.1) */
+ static version: string | undefined;
+
+ /** @internal */
+ _headless: boolean;
+ /** @internal */
+ _parentEditor: null | LexicalEditor;
+ /** @internal */
+ _rootElement: null | HTMLElement;
+ /** @internal */
+ _editorState: EditorState;
+ /** @internal */
+ _pendingEditorState: null | EditorState;
+ /** @internal */
+ _compositionKey: null | NodeKey;
+ /** @internal */
+ _deferred: Array<() => void>;
+ /** @internal */
+ _keyToDOMMap: Map<NodeKey, HTMLElement>;
+ /** @internal */
+ _updates: Array<[() => void, EditorUpdateOptions | undefined]>;
+ /** @internal */
+ _updating: boolean;
+ /** @internal */
+ _listeners: Listeners;
+ /** @internal */
+ _commands: Commands;
+ /** @internal */
+ _nodes: RegisteredNodes;
+ /** @internal */
+ _decorators: Record<NodeKey, unknown>;
+ /** @internal */
+ _pendingDecorators: null | Record<NodeKey, unknown>;
+ /** @internal */
+ _config: EditorConfig;
+ /** @internal */
+ _dirtyType: 0 | 1 | 2;
+ /** @internal */
+ _cloneNotNeeded: Set<NodeKey>;
+ /** @internal */
+ _dirtyLeaves: Set<NodeKey>;
+ /** @internal */
+ _dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
+ /** @internal */
+ _normalizedNodes: Set<NodeKey>;
+ /** @internal */
+ _updateTags: Set<string>;
+ /** @internal */
+ _observer: null | MutationObserver;
+ /** @internal */
+ _key: string;
+ /** @internal */
+ _onError: ErrorHandler;
+ /** @internal */
+ _htmlConversions: DOMConversionCache;
+ /** @internal */
+ _window: null | Window;
+ /** @internal */
+ _editable: boolean;
+ /** @internal */
+ _blockCursorElement: null | HTMLDivElement;
+
+ /** @internal */
+ constructor(
+ editorState: EditorState,
+ parentEditor: null | LexicalEditor,
+ nodes: RegisteredNodes,
+ config: EditorConfig,
+ onError: ErrorHandler,
+ htmlConversions: DOMConversionCache,
+ editable: boolean,
+ ) {
+ this._parentEditor = parentEditor;
+ // The root element associated with this editor
+ this._rootElement = null;
+ // The current editor state
+ this._editorState = editorState;
+ // Handling of drafts and updates
+ this._pendingEditorState = null;
+ // Used to help co-ordinate selection and events
+ this._compositionKey = null;
+ this._deferred = [];
+ // Used during reconciliation
+ this._keyToDOMMap = new Map();
+ this._updates = [];
+ this._updating = false;
+ // Listeners
+ this._listeners = {
+ decorator: new Set(),
+ editable: new Set(),
+ mutation: new Map(),
+ root: new Set(),
+ textcontent: new Set(),
+ update: new Set(),
+ };
+ // Commands
+ this._commands = new Map();
+ // Editor configuration for theme/context.
+ this._config = config;
+ // Mapping of types to their nodes
+ this._nodes = nodes;
+ // React node decorators for portals
+ this._decorators = {};
+ this._pendingDecorators = null;
+ // Used to optimize reconciliation
+ this._dirtyType = NO_DIRTY_NODES;
+ this._cloneNotNeeded = new Set();
+ this._dirtyLeaves = new Set();
+ this._dirtyElements = new Map();
+ this._normalizedNodes = new Set();
+ this._updateTags = new Set();
+ // Handling of DOM mutations
+ this._observer = null;
+ // Used for identifying owning editors
+ this._key = createUID();
+
+ this._onError = onError;
+ this._htmlConversions = htmlConversions;
+ this._editable = editable;
+ this._headless = parentEditor !== null && parentEditor._headless;
+ this._window = null;
+ this._blockCursorElement = null;
+ }
+
+ /**
+ *
+ * @returns true if the editor is currently in "composition" mode due to receiving input
+ * through an IME, or 3P extension, for example. Returns false otherwise.
+ */
+ isComposing(): boolean {
+ return this._compositionKey != null;
+ }
+ /**
+ * Registers a listener for Editor update event. Will trigger the provided callback
+ * each time the editor goes through an update (via {@link LexicalEditor.update}) until the
+ * teardown function is called.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerUpdateListener(listener: UpdateListener): () => void {
+ const listenerSetOrMap = this._listeners.update;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(listener);
+ };
+ }
+ /**
+ * Registers a listener for for when the editor changes between editable and non-editable states.
+ * Will trigger the provided callback each time the editor transitions between these states until the
+ * teardown function is called.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerEditableListener(listener: EditableListener): () => void {
+ const listenerSetOrMap = this._listeners.editable;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(listener);
+ };
+ }
+ /**
+ * Registers a listener for when the editor's decorator object changes. The decorator object contains
+ * all DecoratorNode keys -> their decorated value. This is primarily used with external UI frameworks.
+ *
+ * Will trigger the provided callback each time the editor transitions between these states until the
+ * teardown function is called.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerDecoratorListener<T>(listener: DecoratorListener<T>): () => void {
+ const listenerSetOrMap = this._listeners.decorator;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(listener);
+ };
+ }
+ /**
+ * Registers a listener for when Lexical commits an update to the DOM and the text content of
+ * the editor changes from the previous state of the editor. If the text content is the
+ * same between updates, no notifications to the listeners will happen.
+ *
+ * Will trigger the provided callback each time the editor transitions between these states until the
+ * teardown function is called.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerTextContentListener(listener: TextContentListener): () => void {
+ const listenerSetOrMap = this._listeners.textcontent;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(listener);
+ };
+ }
+ /**
+ * Registers a listener for when the editor's root DOM element (the content editable
+ * Lexical attaches to) changes. This is primarily used to attach event listeners to the root
+ * element. The root listener function is executed directly upon registration and then on
+ * any subsequent update.
+ *
+ * Will trigger the provided callback each time the editor transitions between these states until the
+ * teardown function is called.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerRootListener(listener: RootListener): () => void {
+ const listenerSetOrMap = this._listeners.root;
+ listener(this._rootElement, null);
+ listenerSetOrMap.add(listener);
+ return () => {
+ listener(null, this._rootElement);
+ listenerSetOrMap.delete(listener);
+ };
+ }
+ /**
+ * Registers a listener that will trigger anytime the provided command
+ * is dispatched, subject to priority. Listeners that run at a higher priority can "intercept"
+ * commands and prevent them from propagating to other handlers by returning true.
+ *
+ * Listeners registered at the same priority level will run deterministically in the order of registration.
+ *
+ * @param command - the command that will trigger the callback.
+ * @param listener - the function that will execute when the command is dispatched.
+ * @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerCommand<P>(
+ command: LexicalCommand<P>,
+ listener: CommandListener<P>,
+ priority: CommandListenerPriority,
+ ): () => void {
+ if (priority === undefined) {
+ invariant(false, 'Listener for type "command" requires a "priority".');
+ }
+
+ const commandsMap = this._commands;
+
+ if (!commandsMap.has(command)) {
+ commandsMap.set(command, [
+ new Set(),
+ new Set(),
+ new Set(),
+ new Set(),
+ new Set(),
+ ]);
+ }
+
+ const listenersInPriorityOrder = commandsMap.get(command);
+
+ if (listenersInPriorityOrder === undefined) {
+ invariant(
+ false,
+ 'registerCommand: Command %s not found in command map',
+ String(command),
+ );
+ }
+
+ const listeners = listenersInPriorityOrder[priority];
+ listeners.add(listener as CommandListener<unknown>);
+ return () => {
+ listeners.delete(listener as CommandListener<unknown>);
+
+ if (
+ listenersInPriorityOrder.every(
+ (listenersSet) => listenersSet.size === 0,
+ )
+ ) {
+ commandsMap.delete(command);
+ }
+ };
+ }
+
+ /**
+ * Registers a listener that will run when a Lexical node of the provided class is
+ * mutated. The listener will receive a list of nodes along with the type of mutation
+ * that was performed on each: created, destroyed, or updated.
+ *
+ * One common use case for this is to attach DOM event listeners to the underlying DOM nodes as Lexical nodes are created.
+ * {@link LexicalEditor.getElementByKey} can be used for this.
+ *
+ * If any existing nodes are in the DOM, and skipInitialization is not true, the listener
+ * will be called immediately with an updateTag of 'registerMutationListener' where all
+ * nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option
+ * (default is currently true for backwards compatibility in 0.16.x but will change to false in 0.17.0).
+ *
+ * @param klass - The class of the node that you want to listen to mutations on.
+ * @param listener - The logic you want to run when the node is mutated.
+ * @param options - see {@link MutationListenerOptions}
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerMutationListener(
+ klass: Klass<LexicalNode>,
+ listener: MutationListener,
+ options?: MutationListenerOptions,
+ ): () => void {
+ const klassToMutate = this.resolveRegisteredNodeAfterReplacements(
+ this.getRegisteredNode(klass),
+ ).klass;
+ const mutations = this._listeners.mutation;
+ mutations.set(listener, klassToMutate);
+ const skipInitialization = options && options.skipInitialization;
+ if (
+ !(skipInitialization === undefined
+ ? DEFAULT_SKIP_INITIALIZATION
+ : skipInitialization)
+ ) {
+ this.initializeMutationListener(listener, klassToMutate);
+ }
+
+ return () => {
+ mutations.delete(listener);
+ };
+ }
+
+ /** @internal */
+ private getRegisteredNode(klass: Klass<LexicalNode>): RegisteredNode {
+ const registeredNode = this._nodes.get(klass.getType());
+
+ if (registeredNode === undefined) {
+ invariant(
+ false,
+ 'Node %s has not been registered. Ensure node has been passed to createEditor.',
+ klass.name,
+ );
+ }
+
+ return registeredNode;
+ }
+
+ /** @internal */
+ private resolveRegisteredNodeAfterReplacements(
+ registeredNode: RegisteredNode,
+ ): RegisteredNode {
+ while (registeredNode.replaceWithKlass) {
+ registeredNode = this.getRegisteredNode(registeredNode.replaceWithKlass);
+ }
+ return registeredNode;
+ }
+
+ /** @internal */
+ private initializeMutationListener(
+ listener: MutationListener,
+ klass: Klass<LexicalNode>,
+ ): void {
+ const prevEditorState = this._editorState;
+ const nodeMap = getCachedTypeToNodeMap(prevEditorState).get(
+ klass.getType(),
+ );
+ if (!nodeMap) {
+ return;
+ }
+ const nodeMutationMap = new Map<string, NodeMutation>();
+ for (const k of nodeMap.keys()) {
+ nodeMutationMap.set(k, 'created');
+ }
+ if (nodeMutationMap.size > 0) {
+ listener(nodeMutationMap, {
+ dirtyLeaves: new Set(),
+ prevEditorState,
+ updateTags: new Set(['registerMutationListener']),
+ });
+ }
+ }
+
+ /** @internal */
+ private registerNodeTransformToKlass<T extends LexicalNode>(
+ klass: Klass<T>,
+ listener: Transform<T>,
+ ): RegisteredNode {
+ const registeredNode = this.getRegisteredNode(klass);
+ registeredNode.transforms.add(listener as Transform<LexicalNode>);
+
+ return registeredNode;
+ }
+
+ /**
+ * Registers a listener that will run when a Lexical node of the provided class is
+ * marked dirty during an update. The listener will continue to run as long as the node
+ * is marked dirty. There are no guarantees around the order of transform execution!
+ *
+ * Watch out for infinite loops. See [Node Transforms](https://lexical.dev/docs/concepts/transforms)
+ * @param klass - The class of the node that you want to run transforms on.
+ * @param listener - The logic you want to run when the node is updated.
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerNodeTransform<T extends LexicalNode>(
+ klass: Klass<T>,
+ listener: Transform<T>,
+ ): () => void {
+ const registeredNode = this.registerNodeTransformToKlass(klass, listener);
+ const registeredNodes = [registeredNode];
+
+ const replaceWithKlass = registeredNode.replaceWithKlass;
+ if (replaceWithKlass != null) {
+ const registeredReplaceWithNode = this.registerNodeTransformToKlass(
+ replaceWithKlass,
+ listener as Transform<LexicalNode>,
+ );
+ registeredNodes.push(registeredReplaceWithNode);
+ }
+
+ markAllNodesAsDirty(this, klass.getType());
+ return () => {
+ registeredNodes.forEach((node) =>
+ node.transforms.delete(listener as Transform<LexicalNode>),
+ );
+ };
+ }
+
+ /**
+ * Used to assert that a certain node is registered, usually by plugins to ensure nodes that they
+ * depend on have been registered.
+ * @returns True if the editor has registered the provided node type, false otherwise.
+ */
+ hasNode<T extends Klass<LexicalNode>>(node: T): boolean {
+ return this._nodes.has(node.getType());
+ }
+
+ /**
+ * Used to assert that certain nodes are registered, usually by plugins to ensure nodes that they
+ * depend on have been registered.
+ * @returns True if the editor has registered all of the provided node types, false otherwise.
+ */
+ hasNodes<T extends Klass<LexicalNode>>(nodes: Array<T>): boolean {
+ return nodes.every(this.hasNode.bind(this));
+ }
+
+ /**
+ * Dispatches a command of the specified type with the specified payload.
+ * This triggers all command listeners (set by {@link LexicalEditor.registerCommand})
+ * for this type, passing them the provided payload.
+ * @param type - the type of command listeners to trigger.
+ * @param payload - the data to pass as an argument to the command listeners.
+ */
+ dispatchCommand<TCommand extends LexicalCommand<unknown>>(
+ type: TCommand,
+ payload: CommandPayloadType<TCommand>,
+ ): boolean {
+ return dispatchCommand(this, type, payload);
+ }
+
+ /**
+ * Gets a map of all decorators in the editor.
+ * @returns A mapping of call decorator keys to their decorated content
+ */
+ getDecorators<T>(): Record<NodeKey, T> {
+ return this._decorators as Record<NodeKey, T>;
+ }
+
+ /**
+ *
+ * @returns the current root element of the editor. If you want to register
+ * an event listener, do it via {@link LexicalEditor.registerRootListener}, since
+ * this reference may not be stable.
+ */
+ getRootElement(): null | HTMLElement {
+ return this._rootElement;
+ }
+
+ /**
+ * Gets the key of the editor
+ * @returns The editor key
+ */
+ getKey(): string {
+ return this._key;
+ }
+
+ /**
+ * Imperatively set the root contenteditable element that Lexical listens
+ * for events on.
+ */
+ setRootElement(nextRootElement: null | HTMLElement): void {
+ const prevRootElement = this._rootElement;
+
+ if (nextRootElement !== prevRootElement) {
+ const classNames = getCachedClassNameArray(this._config.theme, 'root');
+ const pendingEditorState = this._pendingEditorState || this._editorState;
+ this._rootElement = nextRootElement;
+ resetEditor(this, prevRootElement, nextRootElement, pendingEditorState);
+
+ if (prevRootElement !== null) {
+ // TODO: remove this flag once we no longer use UEv2 internally
+ if (!this._config.disableEvents) {
+ removeRootElementEvents(prevRootElement);
+ }
+ if (classNames != null) {
+ prevRootElement.classList.remove(...classNames);
+ }
+ }
+
+ if (nextRootElement !== null) {
+ const windowObj = getDefaultView(nextRootElement);
+ const style = nextRootElement.style;
+ style.userSelect = 'text';
+ style.whiteSpace = 'pre-wrap';
+ style.wordBreak = 'break-word';
+ nextRootElement.setAttribute('data-lexical-editor', 'true');
+ this._window = windowObj;
+ this._dirtyType = FULL_RECONCILE;
+ initMutationObserver(this);
+
+ this._updateTags.add('history-merge');
+
+ $commitPendingUpdates(this);
+
+ // TODO: remove this flag once we no longer use UEv2 internally
+ if (!this._config.disableEvents) {
+ addRootElementEvents(nextRootElement, this);
+ }
+ if (classNames != null) {
+ nextRootElement.classList.add(...classNames);
+ }
+ } else {
+ // If content editable is unmounted we'll reset editor state back to original
+ // (or pending) editor state since there will be no reconciliation
+ this._editorState = pendingEditorState;
+ this._pendingEditorState = null;
+ this._window = null;
+ }
+
+ triggerListeners('root', this, false, nextRootElement, prevRootElement);
+ }
+ }
+
+ /**
+ * Gets the underlying HTMLElement associated with the LexicalNode for the given key.
+ * @returns the HTMLElement rendered by the LexicalNode associated with the key.
+ * @param key - the key of the LexicalNode.
+ */
+ getElementByKey(key: NodeKey): HTMLElement | null {
+ return this._keyToDOMMap.get(key) || null;
+ }
+
+ /**
+ * Gets the active editor state.
+ * @returns The editor state
+ */
+ getEditorState(): EditorState {
+ return this._editorState;
+ }
+
+ /**
+ * Imperatively set the EditorState. Triggers reconciliation like an update.
+ * @param editorState - the state to set the editor
+ * @param options - options for the update.
+ */
+ setEditorState(editorState: EditorState, options?: EditorSetOptions): void {
+ if (editorState.isEmpty()) {
+ invariant(
+ false,
+ "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.",
+ );
+ }
+
+ $flushRootMutations(this);
+ const pendingEditorState = this._pendingEditorState;
+ const tags = this._updateTags;
+ const tag = options !== undefined ? options.tag : null;
+
+ if (pendingEditorState !== null && !pendingEditorState.isEmpty()) {
+ if (tag != null) {
+ tags.add(tag);
+ }
+
+ $commitPendingUpdates(this);
+ }
+
+ this._pendingEditorState = editorState;
+ this._dirtyType = FULL_RECONCILE;
+ this._dirtyElements.set('root', false);
+ this._compositionKey = null;
+
+ if (tag != null) {
+ tags.add(tag);
+ }
+
+ $commitPendingUpdates(this);
+ }
+
+ /**
+ * Parses a SerializedEditorState (usually produced by {@link EditorState.toJSON}) and returns
+ * and EditorState object that can be, for example, passed to {@link LexicalEditor.setEditorState}. Typically,
+ * deserialization from JSON stored in a database uses this method.
+ * @param maybeStringifiedEditorState
+ * @param updateFn
+ * @returns
+ */
+ parseEditorState(
+ maybeStringifiedEditorState: string | SerializedEditorState,
+ updateFn?: () => void,
+ ): EditorState {
+ const serializedEditorState =
+ typeof maybeStringifiedEditorState === 'string'
+ ? JSON.parse(maybeStringifiedEditorState)
+ : maybeStringifiedEditorState;
+ return parseEditorState(serializedEditorState, this, updateFn);
+ }
+
+ /**
+ * Executes a read of the editor's state, with the
+ * editor context available (useful for exporting and read-only DOM
+ * operations). Much like update, but prevents any mutation of the
+ * editor's state. Any pending updates will be flushed immediately before
+ * the read.
+ * @param callbackFn - A function that has access to read-only editor state.
+ */
+ read<T>(callbackFn: () => T): T {
+ $commitPendingUpdates(this);
+ return this.getEditorState().read(callbackFn, {editor: this});
+ }
+
+ /**
+ * Executes an update to the editor state. The updateFn callback is the ONLY place
+ * where Lexical editor state can be safely mutated.
+ * @param updateFn - A function that has access to writable editor state.
+ * @param options - A bag of options to control the behavior of the update.
+ * @param options.onUpdate - A function to run once the update is complete.
+ * Useful for synchronizing updates in some cases.
+ * @param options.skipTransforms - Setting this to true will suppress all node
+ * transforms for this update cycle.
+ * @param options.tag - A tag to identify this update, in an update listener, for instance.
+ * Some tags are reserved by the core and control update behavior in different ways.
+ * @param options.discrete - If true, prevents this update from being batched, forcing it to
+ * run synchronously.
+ */
+ update(updateFn: () => void, options?: EditorUpdateOptions): void {
+ updateEditor(this, updateFn, options);
+ }
+
+ /**
+ * Focuses the editor
+ * @param callbackFn - A function to run after the editor is focused.
+ * @param options - A bag of options
+ * @param options.defaultSelection - Where to move selection when the editor is
+ * focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd.
+ */
+ focus(callbackFn?: () => void, options: EditorFocusOptions = {}): void {
+ const rootElement = this._rootElement;
+
+ if (rootElement !== null) {
+ // This ensures that iOS does not trigger caps lock upon focus
+ rootElement.setAttribute('autocapitalize', 'off');
+ updateEditor(
+ this,
+ () => {
+ const selection = $getSelection();
+ const root = $getRoot();
+
+ if (selection !== null) {
+ // Marking the selection dirty will force the selection back to it
+ selection.dirty = true;
+ } else if (root.getChildrenSize() !== 0) {
+ if (options.defaultSelection === 'rootStart') {
+ root.selectStart();
+ } else {
+ root.selectEnd();
+ }
+ }
+ },
+ {
+ onUpdate: () => {
+ rootElement.removeAttribute('autocapitalize');
+ if (callbackFn) {
+ callbackFn();
+ }
+ },
+ tag: 'focus',
+ },
+ );
+ // In the case where onUpdate doesn't fire (due to the focus update not
+ // occuring).
+ if (this._pendingEditorState === null) {
+ rootElement.removeAttribute('autocapitalize');
+ }
+ }
+ }
+
+ /**
+ * Removes focus from the editor.
+ */
+ blur(): void {
+ const rootElement = this._rootElement;
+
+ if (rootElement !== null) {
+ rootElement.blur();
+ }
+
+ const domSelection = getDOMSelection(this._window);
+
+ if (domSelection !== null) {
+ domSelection.removeAllRanges();
+ }
+ }
+ /**
+ * Returns true if the editor is editable, false otherwise.
+ * @returns True if the editor is editable, false otherwise.
+ */
+ isEditable(): boolean {
+ return this._editable;
+ }
+ /**
+ * Sets the editable property of the editor. When false, the
+ * editor will not listen for user events on the underling contenteditable.
+ * @param editable - the value to set the editable mode to.
+ */
+ setEditable(editable: boolean): void {
+ if (this._editable !== editable) {
+ this._editable = editable;
+ triggerListeners('editable', this, true, editable);
+ }
+ }
+ /**
+ * Returns a JSON-serializable javascript object NOT a JSON string.
+ * You still must call JSON.stringify (or something else) to turn the
+ * state into a string you can transfer over the wire and store in a database.
+ *
+ * See {@link LexicalNode.exportJSON}
+ *
+ * @returns A JSON-serializable javascript object
+ */
+ toJSON(): SerializedEditor {
+ return {
+ editorState: this._editorState.toJSON(),
+ };
+ }
+}
+
+LexicalEditor.version = '0.17.1';
--- /dev/null
+/**
+ * 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 './LexicalEditor';
+import type {LexicalNode, NodeMap, SerializedLexicalNode} from './LexicalNode';
+import type {BaseSelection} from './LexicalSelection';
+import type {SerializedElementNode} from './nodes/LexicalElementNode';
+import type {SerializedRootNode} from './nodes/LexicalRootNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {readEditorState} from './LexicalUpdates';
+import {$getRoot} from './LexicalUtils';
+import {$isElementNode} from './nodes/LexicalElementNode';
+import {$createRootNode} from './nodes/LexicalRootNode';
+
+export interface SerializedEditorState<
+ T extends SerializedLexicalNode = SerializedLexicalNode,
+> {
+ root: SerializedRootNode<T>;
+}
+
+export function editorStateHasDirtySelection(
+ editorState: EditorState,
+ editor: LexicalEditor,
+): boolean {
+ const currentSelection = editor.getEditorState()._selection;
+
+ const pendingSelection = editorState._selection;
+
+ // Check if we need to update because of changes in selection
+ if (pendingSelection !== null) {
+ if (pendingSelection.dirty || !pendingSelection.is(currentSelection)) {
+ return true;
+ }
+ } else if (currentSelection !== null) {
+ return true;
+ }
+
+ return false;
+}
+
+export function cloneEditorState(current: EditorState): EditorState {
+ return new EditorState(new Map(current._nodeMap));
+}
+
+export function createEmptyEditorState(): EditorState {
+ return new EditorState(new Map([['root', $createRootNode()]]));
+}
+
+function exportNodeToJSON<SerializedNode extends SerializedLexicalNode>(
+ node: LexicalNode,
+): SerializedNode {
+ const serializedNode = node.exportJSON();
+ const nodeClass = node.constructor;
+
+ if (serializedNode.type !== nodeClass.getType()) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not match the serialized type. Check if .exportJSON() is implemented and it is returning the correct type.',
+ nodeClass.name,
+ );
+ }
+
+ if ($isElementNode(node)) {
+ const serializedChildren = (serializedNode as SerializedElementNode)
+ .children;
+ if (!Array.isArray(serializedChildren)) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
+ nodeClass.name,
+ );
+ }
+
+ const children = node.getChildren();
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const serializedChildNode = exportNodeToJSON(child);
+ serializedChildren.push(serializedChildNode);
+ }
+ }
+
+ // @ts-expect-error
+ return serializedNode;
+}
+
+export interface EditorStateReadOptions {
+ editor?: LexicalEditor | null;
+}
+
+export class EditorState {
+ _nodeMap: NodeMap;
+ _selection: null | BaseSelection;
+ _flushSync: boolean;
+ _readOnly: boolean;
+
+ constructor(nodeMap: NodeMap, selection?: null | BaseSelection) {
+ this._nodeMap = nodeMap;
+ this._selection = selection || null;
+ this._flushSync = false;
+ this._readOnly = false;
+ }
+
+ isEmpty(): boolean {
+ return this._nodeMap.size === 1 && this._selection === null;
+ }
+
+ read<V>(callbackFn: () => V, options?: EditorStateReadOptions): V {
+ return readEditorState(
+ (options && options.editor) || null,
+ this,
+ callbackFn,
+ );
+ }
+
+ clone(selection?: null | BaseSelection): EditorState {
+ const editorState = new EditorState(
+ this._nodeMap,
+ selection === undefined ? this._selection : selection,
+ );
+ editorState._readOnly = true;
+
+ return editorState;
+ }
+ toJSON(): SerializedEditorState {
+ return readEditorState(null, this, () => ({
+ root: exportNodeToJSON($getRoot()),
+ }));
+ }
+}
--- /dev/null
+/**
+ * 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 './LexicalEditor';
+import type {NodeKey} from './LexicalNode';
+import type {ElementNode} from './nodes/LexicalElementNode';
+import type {TextNode} from './nodes/LexicalTextNode';
+
+import {
+ CAN_USE_BEFORE_INPUT,
+ IS_ANDROID_CHROME,
+ IS_APPLE_WEBKIT,
+ IS_FIREFOX,
+ IS_IOS,
+ IS_SAFARI,
+} from 'lexical/shared/environment';
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $getPreviousSelection,
+ $getRoot,
+ $getSelection,
+ $isElementNode,
+ $isNodeSelection,
+ $isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ $setCompositionKey,
+ BLUR_COMMAND,
+ CLICK_COMMAND,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ COPY_COMMAND,
+ CUT_COMMAND,
+ DELETE_CHARACTER_COMMAND,
+ DELETE_LINE_COMMAND,
+ DELETE_WORD_COMMAND,
+ DRAGEND_COMMAND,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND,
+ FOCUS_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INSERT_LINE_BREAK_COMMAND,
+ INSERT_PARAGRAPH_COMMAND,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_DOWN_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ KEY_SPACE_COMMAND,
+ KEY_TAB_COMMAND,
+ MOVE_TO_END,
+ MOVE_TO_START,
+ ParagraphNode,
+ PASTE_COMMAND,
+ REDO_COMMAND,
+ REMOVE_TEXT_COMMAND,
+ SELECTION_CHANGE_COMMAND,
+ UNDO_COMMAND,
+} from '.';
+import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
+import {
+ COMPOSITION_START_CHAR,
+ DOM_ELEMENT_TYPE,
+ DOM_TEXT_TYPE,
+ DOUBLE_LINE_BREAK,
+ IS_ALL_FORMATTING,
+} from './LexicalConstants';
+import {
+ $internalCreateRangeSelection,
+ RangeSelection,
+} from './LexicalSelection';
+import {getActiveEditor, updateEditor} from './LexicalUpdates';
+import {
+ $flushMutations,
+ $getNodeByKey,
+ $isSelectionCapturedInDecorator,
+ $isTokenOrSegmented,
+ $setSelection,
+ $shouldInsertTextAfterOrBeforeTextNode,
+ $updateSelectedTextFromDOM,
+ $updateTextNodeFromDOMContent,
+ dispatchCommand,
+ doesContainGrapheme,
+ getAnchorTextFromDOM,
+ getDOMSelection,
+ getDOMTextNode,
+ getEditorPropertyFromDOMNode,
+ getEditorsToPropagate,
+ getNearestEditorFromDOMNode,
+ getWindow,
+ isBackspace,
+ isBold,
+ isCopy,
+ isCut,
+ isDelete,
+ isDeleteBackward,
+ isDeleteForward,
+ isDeleteLineBackward,
+ isDeleteLineForward,
+ isDeleteWordBackward,
+ isDeleteWordForward,
+ isEscape,
+ isFirefoxClipboardEvents,
+ isItalic,
+ isLexicalEditor,
+ isLineBreak,
+ isModifier,
+ isMoveBackward,
+ isMoveDown,
+ isMoveForward,
+ isMoveToEnd,
+ isMoveToStart,
+ isMoveUp,
+ isOpenLineBreak,
+ isParagraph,
+ isRedo,
+ isSelectAll,
+ isSelectionWithinEditor,
+ isSpace,
+ isTab,
+ isUnderline,
+ isUndo,
+} from './LexicalUtils';
+
+type RootElementRemoveHandles = Array<() => void>;
+type RootElementEvents = Array<
+ [
+ string,
+ Record<string, unknown> | ((event: Event, editor: LexicalEditor) => void),
+ ]
+>;
+const PASS_THROUGH_COMMAND = Object.freeze({});
+const ANDROID_COMPOSITION_LATENCY = 30;
+const rootElementEvents: RootElementEvents = [
+ ['keydown', onKeyDown],
+ ['pointerdown', onPointerDown],
+ ['compositionstart', onCompositionStart],
+ ['compositionend', onCompositionEnd],
+ ['input', onInput],
+ ['click', onClick],
+ ['cut', PASS_THROUGH_COMMAND],
+ ['copy', PASS_THROUGH_COMMAND],
+ ['dragstart', PASS_THROUGH_COMMAND],
+ ['dragover', PASS_THROUGH_COMMAND],
+ ['dragend', PASS_THROUGH_COMMAND],
+ ['paste', PASS_THROUGH_COMMAND],
+ ['focus', PASS_THROUGH_COMMAND],
+ ['blur', PASS_THROUGH_COMMAND],
+ ['drop', PASS_THROUGH_COMMAND],
+];
+
+if (CAN_USE_BEFORE_INPUT) {
+ rootElementEvents.push([
+ 'beforeinput',
+ (event, editor) => onBeforeInput(event as InputEvent, editor),
+ ]);
+}
+
+let lastKeyDownTimeStamp = 0;
+let lastKeyCode: null | string = null;
+let lastBeforeInputInsertTextTimeStamp = 0;
+let unprocessedBeforeInputData: null | string = null;
+const rootElementsRegistered = new WeakMap<Document, number>();
+let isSelectionChangeFromDOMUpdate = false;
+let isSelectionChangeFromMouseDown = false;
+let isInsertLineBreak = false;
+let isFirefoxEndingComposition = false;
+let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [
+ 0,
+ '',
+ 0,
+ 'root',
+ 0,
+];
+
+// This function is used to determine if Lexical should attempt to override
+// the default browser behavior for insertion of text and use its own internal
+// heuristics. This is an extremely important function, and makes much of Lexical
+// work as intended between different browsers and across word, line and character
+// boundary/formats. It also is important for text replacement, node schemas and
+// composition mechanics.
+
+function $shouldPreventDefaultAndInsertText(
+ selection: RangeSelection,
+ domTargetRange: null | StaticRange,
+ text: string,
+ timeStamp: number,
+ isBeforeInput: boolean,
+): boolean {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const editor = getActiveEditor();
+ const domSelection = getDOMSelection(editor._window);
+ const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null;
+ const anchorKey = anchor.key;
+ const backingAnchorElement = editor.getElementByKey(anchorKey);
+ const textLength = text.length;
+
+ return (
+ anchorKey !== focus.key ||
+ // If we're working with a non-text node.
+ !$isTextNode(anchorNode) ||
+ // If we are replacing a range with a single character or grapheme, and not composing.
+ (((!isBeforeInput &&
+ (!CAN_USE_BEFORE_INPUT ||
+ // We check to see if there has been
+ // a recent beforeinput event for "textInput". If there has been one in the last
+ // 50ms then we proceed as normal. However, if there is not, then this is likely
+ // a dangling `input` event caused by execCommand('insertText').
+ lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) ||
+ (anchorNode.isDirty() && textLength < 2) ||
+ doesContainGrapheme(text)) &&
+ anchor.offset !== focus.offset &&
+ !anchorNode.isComposing()) ||
+ // Any non standard text node.
+ $isTokenOrSegmented(anchorNode) ||
+ // If the text length is more than a single character and we're either
+ // dealing with this in "beforeinput" or where the node has already recently
+ // been changed (thus is dirty).
+ (anchorNode.isDirty() && textLength > 1) ||
+ // If the DOM selection element is not the same as the backing node during beforeinput.
+ ((isBeforeInput || !CAN_USE_BEFORE_INPUT) &&
+ backingAnchorElement !== null &&
+ !anchorNode.isComposing() &&
+ domAnchorNode !== getDOMTextNode(backingAnchorElement)) ||
+ // If TargetRange is not the same as the DOM selection; browser trying to edit random parts
+ // of the editor.
+ (domSelection !== null &&
+ domTargetRange !== null &&
+ (!domTargetRange.collapsed ||
+ domTargetRange.startContainer !== domSelection.anchorNode ||
+ domTargetRange.startOffset !== domSelection.anchorOffset)) ||
+ // Check if we're changing from bold to italics, or some other format.
+ anchorNode.getFormat() !== selection.format ||
+ anchorNode.getStyle() !== selection.style ||
+ // One last set of heuristics to check against.
+ $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode)
+ );
+}
+
+function shouldSkipSelectionChange(
+ domNode: null | Node,
+ offset: number,
+): boolean {
+ return (
+ domNode !== null &&
+ domNode.nodeValue !== null &&
+ domNode.nodeType === DOM_TEXT_TYPE &&
+ offset !== 0 &&
+ offset !== domNode.nodeValue.length
+ );
+}
+
+function onSelectionChange(
+ domSelection: Selection,
+ editor: LexicalEditor,
+ isActive: boolean,
+): void {
+ const {
+ anchorNode: anchorDOM,
+ anchorOffset,
+ focusNode: focusDOM,
+ focusOffset,
+ } = domSelection;
+ if (isSelectionChangeFromDOMUpdate) {
+ isSelectionChangeFromDOMUpdate = false;
+
+ // If native DOM selection is on a DOM element, then
+ // we should continue as usual, as Lexical's selection
+ // may have normalized to a better child. If the DOM
+ // element is a text node, we can safely apply this
+ // optimization and skip the selection change entirely.
+ // We also need to check if the offset is at the boundary,
+ // because in this case, we might need to normalize to a
+ // sibling instead.
+ if (
+ shouldSkipSelectionChange(anchorDOM, anchorOffset) &&
+ shouldSkipSelectionChange(focusDOM, focusOffset)
+ ) {
+ return;
+ }
+ }
+ updateEditor(editor, () => {
+ // Non-active editor don't need any extra logic for selection, it only needs update
+ // to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
+ if (!isActive) {
+ $setSelection(null);
+ return;
+ }
+
+ if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
+ return;
+ }
+
+ const selection = $getSelection();
+
+ // Update the selection format
+ if ($isRangeSelection(selection)) {
+ const anchor = selection.anchor;
+ const anchorNode = anchor.getNode();
+
+ if (selection.isCollapsed()) {
+ // Badly interpreted range selection when collapsed - #1482
+ if (
+ domSelection.type === 'Range' &&
+ domSelection.anchorNode === domSelection.focusNode
+ ) {
+ selection.dirty = true;
+ }
+
+ // If we have marked a collapsed selection format, and we're
+ // within the given time range – then attempt to use that format
+ // instead of getting the format from the anchor node.
+ const windowEvent = getWindow(editor).event;
+ const currentTimeStamp = windowEvent
+ ? windowEvent.timeStamp
+ : performance.now();
+ const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] =
+ collapsedSelectionFormat;
+
+ const root = $getRoot();
+ const isRootTextContentEmpty =
+ editor.isComposing() === false && root.getTextContent() === '';
+
+ if (
+ currentTimeStamp < timeStamp + 200 &&
+ anchor.offset === lastOffset &&
+ anchor.key === lastKey
+ ) {
+ selection.format = lastFormat;
+ selection.style = lastStyle;
+ } else {
+ if (anchor.type === 'text') {
+ invariant(
+ $isTextNode(anchorNode),
+ 'Point.getNode() must return TextNode when type is text',
+ );
+ selection.format = anchorNode.getFormat();
+ selection.style = anchorNode.getStyle();
+ } else if (anchor.type === 'element' && !isRootTextContentEmpty) {
+ const lastNode = anchor.getNode();
+ selection.style = '';
+ if (
+ lastNode instanceof ParagraphNode &&
+ lastNode.getChildrenSize() === 0
+ ) {
+ selection.format = lastNode.getTextFormat();
+ selection.style = lastNode.getTextStyle();
+ } else {
+ selection.format = 0;
+ }
+ }
+ }
+ } else {
+ const anchorKey = anchor.key;
+ const focus = selection.focus;
+ const focusKey = focus.key;
+ const nodes = selection.getNodes();
+ const nodesLength = nodes.length;
+ const isBackward = selection.isBackward();
+ const startOffset = isBackward ? focusOffset : anchorOffset;
+ const endOffset = isBackward ? anchorOffset : focusOffset;
+ const startKey = isBackward ? focusKey : anchorKey;
+ const endKey = isBackward ? anchorKey : focusKey;
+ let combinedFormat = IS_ALL_FORMATTING;
+ let hasTextNodes = false;
+ for (let i = 0; i < nodesLength; i++) {
+ const node = nodes[i];
+ const textContentSize = node.getTextContentSize();
+ if (
+ $isTextNode(node) &&
+ textContentSize !== 0 &&
+ // Exclude empty text nodes at boundaries resulting from user's selection
+ !(
+ (i === 0 &&
+ node.__key === startKey &&
+ startOffset === textContentSize) ||
+ (i === nodesLength - 1 &&
+ node.__key === endKey &&
+ endOffset === 0)
+ )
+ ) {
+ // TODO: what about style?
+ hasTextNodes = true;
+ combinedFormat &= node.getFormat();
+ if (combinedFormat === 0) {
+ break;
+ }
+ }
+ }
+
+ selection.format = hasTextNodes ? combinedFormat : 0;
+ }
+ }
+
+ dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
+ });
+}
+
+// This is a work-around is mainly Chrome specific bug where if you select
+// the contents of an empty block, you cannot easily unselect anything.
+// This results in a tiny selection box that looks buggy/broken. This can
+// also help other browsers when selection might "appear" lost, when it
+// really isn't.
+function onClick(event: PointerEvent, editor: LexicalEditor): void {
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+ const domSelection = getDOMSelection(editor._window);
+ const lastSelection = $getPreviousSelection();
+
+ if (domSelection) {
+ if ($isRangeSelection(selection)) {
+ const anchor = selection.anchor;
+ const anchorNode = anchor.getNode();
+
+ if (
+ anchor.type === 'element' &&
+ anchor.offset === 0 &&
+ selection.isCollapsed() &&
+ !$isRootNode(anchorNode) &&
+ $getRoot().getChildrenSize() === 1 &&
+ anchorNode.getTopLevelElementOrThrow().isEmpty() &&
+ lastSelection !== null &&
+ selection.is(lastSelection)
+ ) {
+ domSelection.removeAllRanges();
+ selection.dirty = true;
+ } else if (event.detail === 3 && !selection.isCollapsed()) {
+ // Tripple click causing selection to overflow into the nearest element. In that
+ // case visually it looks like a single element content is selected, focus node
+ // is actually at the beginning of the next element (if present) and any manipulations
+ // with selection (formatting) are affecting second element as well
+ const focus = selection.focus;
+ const focusNode = focus.getNode();
+ if (anchorNode !== focusNode) {
+ if ($isElementNode(anchorNode)) {
+ anchorNode.select(0);
+ } else {
+ anchorNode.getParentOrThrow().select(0);
+ }
+ }
+ }
+ } else if (event.pointerType === 'touch') {
+ // This is used to update the selection on touch devices when the user clicks on text after a
+ // node selection. See isSelectionChangeFromMouseDown for the inverse
+ const domAnchorNode = domSelection.anchorNode;
+ if (domAnchorNode !== null) {
+ const nodeType = domAnchorNode.nodeType;
+ // If the user is attempting to click selection back onto text, then
+ // we should attempt create a range selection.
+ // When we click on an empty paragraph node or the end of a paragraph that ends
+ // with an image/poll, the nodeType will be ELEMENT_NODE
+ if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
+ const newSelection = $internalCreateRangeSelection(
+ lastSelection,
+ domSelection,
+ editor,
+ event,
+ );
+ $setSelection(newSelection);
+ }
+ }
+ }
+ }
+
+ dispatchCommand(editor, CLICK_COMMAND, event);
+ });
+}
+
+function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
+ // TODO implement text drag & drop
+ const target = event.target;
+ const pointerType = event.pointerType;
+ if (target instanceof Node && pointerType !== 'touch') {
+ updateEditor(editor, () => {
+ // Drag & drop should not recompute selection until mouse up; otherwise the initially
+ // selected content is lost.
+ if (!$isSelectionCapturedInDecorator(target)) {
+ isSelectionChangeFromMouseDown = true;
+ }
+ });
+ }
+}
+
+function getTargetRange(event: InputEvent): null | StaticRange {
+ if (!event.getTargetRanges) {
+ return null;
+ }
+ const targetRanges = event.getTargetRanges();
+ if (targetRanges.length === 0) {
+ return null;
+ }
+ return targetRanges[0];
+}
+
+function $canRemoveText(
+ anchorNode: TextNode | ElementNode,
+ focusNode: TextNode | ElementNode,
+): boolean {
+ return (
+ anchorNode !== focusNode ||
+ $isElementNode(anchorNode) ||
+ $isElementNode(focusNode) ||
+ !anchorNode.isToken() ||
+ !focusNode.isToken()
+ );
+}
+
+function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
+ return (
+ lastKeyCode === 'MediaLast' &&
+ timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
+ );
+}
+
+function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
+ const inputType = event.inputType;
+ const targetRange = getTargetRange(event);
+
+ // We let the browser do its own thing for composition.
+ if (
+ inputType === 'deleteCompositionText' ||
+ // If we're pasting in FF, we shouldn't get this event
+ // as the `paste` event should have triggered, unless the
+ // user has dom.event.clipboardevents.enabled disabled in
+ // about:config. In that case, we need to process the
+ // pasted content in the DOM mutation phase.
+ (IS_FIREFOX && isFirefoxClipboardEvents(editor))
+ ) {
+ return;
+ } else if (inputType === 'insertCompositionText') {
+ return;
+ }
+
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+
+ if (inputType === 'deleteContentBackward') {
+ if (selection === null) {
+ // Use previous selection
+ const prevSelection = $getPreviousSelection();
+
+ if (!$isRangeSelection(prevSelection)) {
+ return;
+ }
+
+ $setSelection(prevSelection.clone());
+ }
+
+ if ($isRangeSelection(selection)) {
+ const isSelectionAnchorSameAsFocus =
+ selection.anchor.key === selection.focus.key;
+
+ if (
+ isPossiblyAndroidKeyPress(event.timeStamp) &&
+ editor.isComposing() &&
+ isSelectionAnchorSameAsFocus
+ ) {
+ $setCompositionKey(null);
+ lastKeyDownTimeStamp = 0;
+ // Fixes an Android bug where selection flickers when backspacing
+ setTimeout(() => {
+ updateEditor(editor, () => {
+ $setCompositionKey(null);
+ });
+ }, ANDROID_COMPOSITION_LATENCY);
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+ anchorNode.markDirty();
+ selection.format = anchorNode.getFormat();
+ invariant(
+ $isTextNode(anchorNode),
+ 'Anchor node must be a TextNode',
+ );
+ selection.style = anchorNode.getStyle();
+ }
+ } else {
+ $setCompositionKey(null);
+ event.preventDefault();
+ // Chromium Android at the moment seems to ignore the preventDefault
+ // on 'deleteContentBackward' and still deletes the content. Which leads
+ // to multiple deletions. So we let the browser handle the deletion in this case.
+ const selectedNodeText = selection.anchor.getNode().getTextContent();
+ const hasSelectedAllTextInNode =
+ selection.anchor.offset === 0 &&
+ selection.focus.offset === selectedNodeText.length;
+ const shouldLetBrowserHandleDelete =
+ IS_ANDROID_CHROME &&
+ isSelectionAnchorSameAsFocus &&
+ !hasSelectedAllTextInNode;
+ if (!shouldLetBrowserHandleDelete) {
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
+ }
+ }
+ return;
+ }
+ }
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const data = event.data;
+
+ // This represents the case when two beforeinput events are triggered at the same time (without a
+ // full event loop ending at input). This happens with MacOS with the default keyboard settings,
+ // a combination of autocorrection + autocapitalization.
+ // Having Lexical run everything in controlled mode would fix the issue without additional code
+ // but this would kill the massive performance win from the most common typing event.
+ // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
+ // content, a job that would usually be the input event's responsibility.
+ if (unprocessedBeforeInputData !== null) {
+ $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
+ }
+
+ if (
+ (!selection.dirty || unprocessedBeforeInputData !== null) &&
+ selection.isCollapsed() &&
+ !$isRootNode(selection.anchor.getNode()) &&
+ targetRange !== null
+ ) {
+ selection.applyDOMRange(targetRange);
+ }
+
+ unprocessedBeforeInputData = null;
+
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+
+ if (inputType === 'insertText' || inputType === 'insertTranspose') {
+ if (data === '\n') {
+ event.preventDefault();
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
+ } else if (data === DOUBLE_LINE_BREAK) {
+ event.preventDefault();
+ dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
+ } else if (data == null && event.dataTransfer) {
+ // Gets around a Safari text replacement bug.
+ const text = event.dataTransfer.getData('text/plain');
+ event.preventDefault();
+ selection.insertRawText(text);
+ } else if (
+ data != null &&
+ $shouldPreventDefaultAndInsertText(
+ selection,
+ targetRange,
+ data,
+ event.timeStamp,
+ true,
+ )
+ ) {
+ event.preventDefault();
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
+ } else {
+ unprocessedBeforeInputData = data;
+ }
+ lastBeforeInputInsertTextTimeStamp = event.timeStamp;
+ return;
+ }
+
+ // Prevent the browser from carrying out
+ // the input event, so we can control the
+ // output.
+ event.preventDefault();
+
+ switch (inputType) {
+ case 'insertFromYank':
+ case 'insertFromDrop':
+ case 'insertReplacementText': {
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
+ break;
+ }
+
+ case 'insertFromComposition': {
+ // This is the end of composition
+ $setCompositionKey(null);
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
+ break;
+ }
+
+ case 'insertLineBreak': {
+ // Used for Android
+ $setCompositionKey(null);
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
+ break;
+ }
+
+ case 'insertParagraph': {
+ // Used for Android
+ $setCompositionKey(null);
+
+ // Safari does not provide the type "insertLineBreak".
+ // So instead, we need to infer it from the keyboard event.
+ // We do not apply this logic to iOS to allow newline auto-capitalization
+ // work without creating linebreaks when pressing Enter
+ if (isInsertLineBreak && !IS_IOS) {
+ isInsertLineBreak = false;
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
+ } else {
+ dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
+ }
+
+ break;
+ }
+
+ case 'insertFromPaste':
+ case 'insertFromPasteAsQuotation': {
+ dispatchCommand(editor, PASTE_COMMAND, event);
+ break;
+ }
+
+ case 'deleteByComposition': {
+ if ($canRemoveText(anchorNode, focusNode)) {
+ dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
+ }
+
+ break;
+ }
+
+ case 'deleteByDrag':
+ case 'deleteByCut': {
+ dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
+ break;
+ }
+
+ case 'deleteContent': {
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
+ break;
+ }
+
+ case 'deleteWordBackward': {
+ dispatchCommand(editor, DELETE_WORD_COMMAND, true);
+ break;
+ }
+
+ case 'deleteWordForward': {
+ dispatchCommand(editor, DELETE_WORD_COMMAND, false);
+ break;
+ }
+
+ case 'deleteHardLineBackward':
+ case 'deleteSoftLineBackward': {
+ dispatchCommand(editor, DELETE_LINE_COMMAND, true);
+ break;
+ }
+
+ case 'deleteContentForward':
+ case 'deleteHardLineForward':
+ case 'deleteSoftLineForward': {
+ dispatchCommand(editor, DELETE_LINE_COMMAND, false);
+ break;
+ }
+
+ case 'formatStrikeThrough': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
+ break;
+ }
+
+ case 'formatBold': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
+ break;
+ }
+
+ case 'formatItalic': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
+ break;
+ }
+
+ case 'formatUnderline': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
+ break;
+ }
+
+ case 'historyUndo': {
+ dispatchCommand(editor, UNDO_COMMAND, undefined);
+ break;
+ }
+
+ case 'historyRedo': {
+ dispatchCommand(editor, REDO_COMMAND, undefined);
+ break;
+ }
+
+ default:
+ // NO-OP
+ }
+ });
+}
+
+function onInput(event: InputEvent, editor: LexicalEditor): void {
+ // We don't want the onInput to bubble, in the case of nested editors.
+ event.stopPropagation();
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+ const data = event.data;
+ const targetRange = getTargetRange(event);
+
+ if (
+ data != null &&
+ $isRangeSelection(selection) &&
+ $shouldPreventDefaultAndInsertText(
+ selection,
+ targetRange,
+ data,
+ event.timeStamp,
+ false,
+ )
+ ) {
+ // Given we're over-riding the default behavior, we will need
+ // to ensure to disable composition before dispatching the
+ // insertText command for when changing the sequence for FF.
+ if (isFirefoxEndingComposition) {
+ $onCompositionEndImpl(editor, data);
+ isFirefoxEndingComposition = false;
+ }
+ const anchor = selection.anchor;
+ const anchorNode = anchor.getNode();
+ const domSelection = getDOMSelection(editor._window);
+ if (domSelection === null) {
+ return;
+ }
+ const isBackward = selection.isBackward();
+ const startOffset = isBackward
+ ? selection.anchor.offset
+ : selection.focus.offset;
+ const endOffset = isBackward
+ ? selection.focus.offset
+ : selection.anchor.offset;
+ // If the content is the same as inserted, then don't dispatch an insertion.
+ // Given onInput doesn't take the current selection (it uses the previous)
+ // we can compare that against what the DOM currently says.
+ if (
+ !CAN_USE_BEFORE_INPUT ||
+ selection.isCollapsed() ||
+ !$isTextNode(anchorNode) ||
+ domSelection.anchorNode === null ||
+ anchorNode.getTextContent().slice(0, startOffset) +
+ data +
+ anchorNode.getTextContent().slice(startOffset + endOffset) !==
+ getAnchorTextFromDOM(domSelection.anchorNode)
+ ) {
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
+ }
+
+ const textLength = data.length;
+
+ // Another hack for FF, as it's possible that the IME is still
+ // open, even though compositionend has already fired (sigh).
+ if (
+ IS_FIREFOX &&
+ textLength > 1 &&
+ event.inputType === 'insertCompositionText' &&
+ !editor.isComposing()
+ ) {
+ selection.anchor.offset -= textLength;
+ }
+
+ // This ensures consistency on Android.
+ if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
+ lastKeyDownTimeStamp = 0;
+ $setCompositionKey(null);
+ }
+ } else {
+ const characterData = data !== null ? data : undefined;
+ $updateSelectedTextFromDOM(false, editor, characterData);
+
+ // onInput always fires after onCompositionEnd for FF.
+ if (isFirefoxEndingComposition) {
+ $onCompositionEndImpl(editor, data || undefined);
+ isFirefoxEndingComposition = false;
+ }
+ }
+
+ // Also flush any other mutations that might have occurred
+ // since the change.
+ $flushMutations();
+ });
+ unprocessedBeforeInputData = null;
+}
+
+function onCompositionStart(
+ event: CompositionEvent,
+ editor: LexicalEditor,
+): void {
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection) && !editor.isComposing()) {
+ const anchor = selection.anchor;
+ const node = selection.anchor.getNode();
+ $setCompositionKey(anchor.key);
+
+ if (
+ // If it has been 30ms since the last keydown, then we should
+ // apply the empty space heuristic. We can't do this for Safari,
+ // as the keydown fires after composition start.
+ event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
+ // FF has issues around composing multibyte characters, so we also
+ // need to invoke the empty space heuristic below.
+ anchor.type === 'element' ||
+ !selection.isCollapsed() ||
+ node.getFormat() !== selection.format ||
+ ($isTextNode(node) && node.getStyle() !== selection.style)
+ ) {
+ // We insert a zero width character, ready for the composition
+ // to get inserted into the new node we create. If
+ // we don't do this, Safari will fail on us because
+ // there is no text node matching the selection.
+ dispatchCommand(
+ editor,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ COMPOSITION_START_CHAR,
+ );
+ }
+ }
+ });
+}
+
+function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
+ const compositionKey = editor._compositionKey;
+ $setCompositionKey(null);
+
+ // Handle termination of composition.
+ if (compositionKey !== null && data != null) {
+ // Composition can sometimes move to an adjacent DOM node when backspacing.
+ // So check for the empty case.
+ if (data === '') {
+ const node = $getNodeByKey(compositionKey);
+ const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
+
+ if (
+ textNode !== null &&
+ textNode.nodeValue !== null &&
+ $isTextNode(node)
+ ) {
+ $updateTextNodeFromDOMContent(
+ node,
+ textNode.nodeValue,
+ null,
+ null,
+ true,
+ );
+ }
+
+ return;
+ }
+
+ // Composition can sometimes be that of a new line. In which case, we need to
+ // handle that accordingly.
+ if (data[data.length - 1] === '\n') {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ // If the last character is a line break, we also need to insert
+ // a line break.
+ const focus = selection.focus;
+ selection.anchor.set(focus.key, focus.offset, focus.type);
+ dispatchCommand(editor, KEY_ENTER_COMMAND, null);
+ return;
+ }
+ }
+ }
+
+ $updateSelectedTextFromDOM(true, editor, data);
+}
+
+function onCompositionEnd(
+ event: CompositionEvent,
+ editor: LexicalEditor,
+): void {
+ // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
+ // fire onInput before onCompositionEnd. To ensure the sequence works
+ // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
+ // defer handling of onCompositionEnd in Firefox till we have processed
+ // the logic in onInput.
+ if (IS_FIREFOX) {
+ isFirefoxEndingComposition = true;
+ } else {
+ updateEditor(editor, () => {
+ $onCompositionEndImpl(editor, event.data);
+ });
+ }
+}
+
+function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
+ lastKeyDownTimeStamp = event.timeStamp;
+ lastKeyCode = event.key;
+ if (editor.isComposing()) {
+ return;
+ }
+
+ const {key, shiftKey, ctrlKey, metaKey, altKey} = event;
+
+ if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
+ return;
+ }
+
+ if (key == null) {
+ return;
+ }
+
+ if (isMoveForward(key, ctrlKey, altKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
+ } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) {
+ dispatchCommand(editor, MOVE_TO_END, event);
+ } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
+ } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) {
+ dispatchCommand(editor, MOVE_TO_START, event);
+ } else if (isMoveUp(key, ctrlKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
+ } else if (isMoveDown(key, ctrlKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
+ } else if (isLineBreak(key, shiftKey)) {
+ isInsertLineBreak = true;
+ dispatchCommand(editor, KEY_ENTER_COMMAND, event);
+ } else if (isSpace(key)) {
+ dispatchCommand(editor, KEY_SPACE_COMMAND, event);
+ } else if (isOpenLineBreak(key, ctrlKey)) {
+ event.preventDefault();
+ isInsertLineBreak = true;
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
+ } else if (isParagraph(key, shiftKey)) {
+ isInsertLineBreak = false;
+ dispatchCommand(editor, KEY_ENTER_COMMAND, event);
+ } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) {
+ if (isBackspace(key)) {
+ dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
+ } else {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
+ }
+ } else if (isEscape(key)) {
+ dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
+ } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) {
+ if (isDelete(key)) {
+ dispatchCommand(editor, KEY_DELETE_COMMAND, event);
+ } else {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
+ }
+ } else if (isDeleteWordBackward(key, altKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_WORD_COMMAND, true);
+ } else if (isDeleteWordForward(key, altKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_WORD_COMMAND, false);
+ } else if (isDeleteLineBackward(key, metaKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_LINE_COMMAND, true);
+ } else if (isDeleteLineForward(key, metaKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_LINE_COMMAND, false);
+ } else if (isBold(key, altKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
+ } else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
+ } else if (isItalic(key, altKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
+ } else if (isTab(key, altKey, ctrlKey, metaKey)) {
+ dispatchCommand(editor, KEY_TAB_COMMAND, event);
+ } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, UNDO_COMMAND, undefined);
+ } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, REDO_COMMAND, undefined);
+ } else {
+ const prevSelection = editor._editorState._selection;
+ if ($isNodeSelection(prevSelection)) {
+ if (isCopy(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, COPY_COMMAND, event);
+ } else if (isCut(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, CUT_COMMAND, event);
+ } else if (isSelectAll(key, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, SELECT_ALL_COMMAND, event);
+ }
+ // FF does it well (no need to override behavior)
+ } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, SELECT_ALL_COMMAND, event);
+ }
+ }
+
+ if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
+ dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
+ }
+}
+
+function getRootElementRemoveHandles(
+ rootElement: HTMLElement,
+): RootElementRemoveHandles {
+ // @ts-expect-error: internal field
+ let eventHandles = rootElement.__lexicalEventHandles;
+
+ if (eventHandles === undefined) {
+ eventHandles = [];
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEventHandles = eventHandles;
+ }
+
+ return eventHandles;
+}
+
+// Mapping root editors to their active nested editors, contains nested editors
+// mapping only, so if root editor is selected map will have no reference to free up memory
+const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();
+
+function onDocumentSelectionChange(event: Event): void {
+ const target = event.target as null | Element | Document;
+ const targetWindow =
+ target == null
+ ? null
+ : target.nodeType === 9
+ ? (target as Document).defaultView
+ : (target as Element).ownerDocument.defaultView;
+ const domSelection = getDOMSelection(targetWindow);
+ if (domSelection === null) {
+ return;
+ }
+ const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
+ if (nextActiveEditor === null) {
+ return;
+ }
+
+ if (isSelectionChangeFromMouseDown) {
+ isSelectionChangeFromMouseDown = false;
+ updateEditor(nextActiveEditor, () => {
+ const lastSelection = $getPreviousSelection();
+ const domAnchorNode = domSelection.anchorNode;
+ if (domAnchorNode === null) {
+ return;
+ }
+ const nodeType = domAnchorNode.nodeType;
+ // If the user is attempting to click selection back onto text, then
+ // we should attempt create a range selection.
+ // When we click on an empty paragraph node or the end of a paragraph that ends
+ // with an image/poll, the nodeType will be ELEMENT_NODE
+ if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
+ return;
+ }
+ const newSelection = $internalCreateRangeSelection(
+ lastSelection,
+ domSelection,
+ nextActiveEditor,
+ event,
+ );
+ $setSelection(newSelection);
+ });
+ }
+
+ // When editor receives selection change event, we're checking if
+ // it has any sibling editors (within same parent editor) that were active
+ // before, and trigger selection change on it to nullify selection.
+ const editors = getEditorsToPropagate(nextActiveEditor);
+ const rootEditor = editors[editors.length - 1];
+ const rootEditorKey = rootEditor._key;
+ const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
+ const prevActiveEditor = activeNestedEditor || rootEditor;
+
+ if (prevActiveEditor !== nextActiveEditor) {
+ onSelectionChange(domSelection, prevActiveEditor, false);
+ }
+
+ onSelectionChange(domSelection, nextActiveEditor, true);
+
+ // If newly selected editor is nested, then add it to the map, clean map otherwise
+ if (nextActiveEditor !== rootEditor) {
+ activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
+ } else if (activeNestedEditor) {
+ activeNestedEditorsMap.delete(rootEditorKey);
+ }
+}
+
+function stopLexicalPropagation(event: Event): void {
+ // We attach a special property to ensure the same event doesn't re-fire
+ // for parent editors.
+ // @ts-ignore
+ event._lexicalHandled = true;
+}
+
+function hasStoppedLexicalPropagation(event: Event): boolean {
+ // @ts-ignore
+ const stopped = event._lexicalHandled === true;
+ return stopped;
+}
+
+export type EventHandler = (event: Event, editor: LexicalEditor) => void;
+
+export function addRootElementEvents(
+ rootElement: HTMLElement,
+ editor: LexicalEditor,
+): void {
+ // We only want to have a single global selectionchange event handler, shared
+ // between all editor instances.
+ const doc = rootElement.ownerDocument;
+ const documentRootElementsCount = rootElementsRegistered.get(doc);
+ if (
+ documentRootElementsCount === undefined ||
+ documentRootElementsCount < 1
+ ) {
+ doc.addEventListener('selectionchange', onDocumentSelectionChange);
+ }
+ rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
+
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEditor = editor;
+ const removeHandles = getRootElementRemoveHandles(rootElement);
+
+ for (let i = 0; i < rootElementEvents.length; i++) {
+ const [eventName, onEvent] = rootElementEvents[i];
+ const eventHandler =
+ typeof onEvent === 'function'
+ ? (event: Event) => {
+ if (hasStoppedLexicalPropagation(event)) {
+ return;
+ }
+ stopLexicalPropagation(event);
+ if (editor.isEditable() || eventName === 'click') {
+ onEvent(event, editor);
+ }
+ }
+ : (event: Event) => {
+ if (hasStoppedLexicalPropagation(event)) {
+ return;
+ }
+ stopLexicalPropagation(event);
+ const isEditable = editor.isEditable();
+ switch (eventName) {
+ case 'cut':
+ return (
+ isEditable &&
+ dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
+ );
+
+ case 'copy':
+ return dispatchCommand(
+ editor,
+ COPY_COMMAND,
+ event as ClipboardEvent,
+ );
+
+ case 'paste':
+ return (
+ isEditable &&
+ dispatchCommand(
+ editor,
+ PASTE_COMMAND,
+ event as ClipboardEvent,
+ )
+ );
+
+ case 'dragstart':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
+ );
+
+ case 'dragover':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
+ );
+
+ case 'dragend':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
+ );
+
+ case 'focus':
+ return (
+ isEditable &&
+ dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
+ );
+
+ case 'blur': {
+ return (
+ isEditable &&
+ dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
+ );
+ }
+
+ case 'drop':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
+ );
+ }
+ };
+ rootElement.addEventListener(eventName, eventHandler);
+ removeHandles.push(() => {
+ rootElement.removeEventListener(eventName, eventHandler);
+ });
+ }
+}
+
+export function removeRootElementEvents(rootElement: HTMLElement): void {
+ const doc = rootElement.ownerDocument;
+ const documentRootElementsCount = rootElementsRegistered.get(doc);
+ invariant(
+ documentRootElementsCount !== undefined,
+ 'Root element not registered',
+ );
+
+ // We only want to have a single global selectionchange event handler, shared
+ // between all editor instances.
+ const newCount = documentRootElementsCount - 1;
+ invariant(newCount >= 0, 'Root element count less than 0');
+ rootElementsRegistered.set(doc, newCount);
+ if (newCount === 0) {
+ doc.removeEventListener('selectionchange', onDocumentSelectionChange);
+ }
+
+ const editor = getEditorPropertyFromDOMNode(rootElement);
+
+ if (isLexicalEditor(editor)) {
+ cleanActiveNestedEditorsMap(editor);
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEditor = null;
+ } else if (editor) {
+ invariant(
+ false,
+ 'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
+ );
+ }
+
+ const removeHandles = getRootElementRemoveHandles(rootElement);
+
+ for (let i = 0; i < removeHandles.length; i++) {
+ removeHandles[i]();
+ }
+
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEventHandles = [];
+}
+
+function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
+ if (editor._parentEditor !== null) {
+ // For nested editor cleanup map if this editor was marked as active
+ const editors = getEditorsToPropagate(editor);
+ const rootEditor = editors[editors.length - 1];
+ const rootEditorKey = rootEditor._key;
+
+ if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
+ activeNestedEditorsMap.delete(rootEditorKey);
+ }
+ } else {
+ // For top-level editors cleanup map
+ activeNestedEditorsMap.delete(editor._key);
+ }
+}
+
+export function markSelectionChangeFromDOMUpdate(): void {
+ isSelectionChangeFromDOMUpdate = true;
+}
+
+export function markCollapsedSelectionFormat(
+ format: number,
+ style: string,
+ offset: number,
+ key: NodeKey,
+ timeStamp: number,
+): void {
+ collapsedSelectionFormat = [format, style, offset, key, timeStamp];
+}
--- /dev/null
+/**
+ * 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 {ElementNode} from '.';
+import type {LexicalEditor} from './LexicalEditor';
+import type {EditorState} from './LexicalEditorState';
+import type {NodeKey, NodeMap} from './LexicalNode';
+
+import {$isElementNode} from '.';
+import {cloneDecorators} from './LexicalUtils';
+
+export function $garbageCollectDetachedDecorators(
+ editor: LexicalEditor,
+ pendingEditorState: EditorState,
+): void {
+ const currentDecorators = editor._decorators;
+ const pendingDecorators = editor._pendingDecorators;
+ let decorators = pendingDecorators || currentDecorators;
+ const nodeMap = pendingEditorState._nodeMap;
+ let key;
+
+ for (key in decorators) {
+ if (!nodeMap.has(key)) {
+ if (decorators === currentDecorators) {
+ decorators = cloneDecorators(editor);
+ }
+
+ delete decorators[key];
+ }
+ }
+}
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+function $garbageCollectDetachedDeepChildNodes(
+ node: ElementNode,
+ parentKey: NodeKey,
+ prevNodeMap: NodeMap,
+ nodeMap: NodeMap,
+ nodeMapDelete: Array<NodeKey>,
+ dirtyNodes: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+): void {
+ let child = node.getFirstChild();
+
+ while (child !== null) {
+ const childKey = child.__key;
+ // TODO Revise condition below, redundant? LexicalNode already cleans up children when moving Nodes
+ if (child.__parent === parentKey) {
+ if ($isElementNode(child)) {
+ $garbageCollectDetachedDeepChildNodes(
+ child,
+ childKey,
+ prevNodeMap,
+ nodeMap,
+ nodeMapDelete,
+ dirtyNodes,
+ );
+ }
+
+ // If we have created a node and it was dereferenced, then also
+ // remove it from out dirty nodes Set.
+ if (!prevNodeMap.has(childKey)) {
+ dirtyNodes.delete(childKey);
+ }
+ nodeMapDelete.push(childKey);
+ }
+ child = child.getNextSibling();
+ }
+}
+
+export function $garbageCollectDetachedNodes(
+ prevEditorState: EditorState,
+ editorState: EditorState,
+ dirtyLeaves: Set<NodeKey>,
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+): void {
+ const prevNodeMap = prevEditorState._nodeMap;
+ const nodeMap = editorState._nodeMap;
+ // Store dirtyElements in a queue for later deletion; deleting dirty subtrees too early will
+ // hinder accessing .__next on child nodes
+ const nodeMapDelete: Array<NodeKey> = [];
+
+ for (const [nodeKey] of dirtyElements) {
+ const node = nodeMap.get(nodeKey);
+ if (node !== undefined) {
+ // Garbage collect node and its children if they exist
+ if (!node.isAttached()) {
+ if ($isElementNode(node)) {
+ $garbageCollectDetachedDeepChildNodes(
+ node,
+ nodeKey,
+ prevNodeMap,
+ nodeMap,
+ nodeMapDelete,
+ dirtyElements,
+ );
+ }
+ // If we have created a node and it was dereferenced, then also
+ // remove it from out dirty nodes Set.
+ if (!prevNodeMap.has(nodeKey)) {
+ dirtyElements.delete(nodeKey);
+ }
+ nodeMapDelete.push(nodeKey);
+ }
+ }
+ }
+ for (const nodeKey of nodeMapDelete) {
+ nodeMap.delete(nodeKey);
+ }
+
+ for (const nodeKey of dirtyLeaves) {
+ const node = nodeMap.get(nodeKey);
+ if (node !== undefined && !node.isAttached()) {
+ if (!prevNodeMap.has(nodeKey)) {
+ dirtyLeaves.delete(nodeKey);
+ }
+ nodeMap.delete(nodeKey);
+ }
+ }
+}
--- /dev/null
+/**
+ * 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 {TextNode} from '.';
+import type {LexicalEditor} from './LexicalEditor';
+import type {BaseSelection} from './LexicalSelection';
+
+import {IS_FIREFOX} from 'lexical/shared/environment';
+
+import {
+ $getSelection,
+ $isDecoratorNode,
+ $isElementNode,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+} from '.';
+import {DOM_TEXT_TYPE} from './LexicalConstants';
+import {updateEditor} from './LexicalUpdates';
+import {
+ $getNearestNodeFromDOMNode,
+ $getNodeFromDOMNode,
+ $updateTextNodeFromDOMContent,
+ getDOMSelection,
+ getWindow,
+ internalGetRoot,
+ isFirefoxClipboardEvents,
+} from './LexicalUtils';
+// The time between a text entry event and the mutation observer firing.
+const TEXT_MUTATION_VARIANCE = 100;
+
+let isProcessingMutations = false;
+let lastTextEntryTimeStamp = 0;
+
+export function getIsProcessingMutations(): boolean {
+ return isProcessingMutations;
+}
+
+function updateTimeStamp(event: Event) {
+ lastTextEntryTimeStamp = event.timeStamp;
+}
+
+function initTextEntryListener(editor: LexicalEditor): void {
+ if (lastTextEntryTimeStamp === 0) {
+ getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
+ }
+}
+
+function isManagedLineBreak(
+ dom: Node,
+ target: Node,
+ editor: LexicalEditor,
+): boolean {
+ return (
+ // @ts-expect-error: internal field
+ target.__lexicalLineBreak === dom ||
+ // @ts-ignore We intentionally add this to the Node.
+ dom[`__lexicalKey_${editor._key}`] !== undefined
+ );
+}
+
+function getLastSelection(editor: LexicalEditor): null | BaseSelection {
+ return editor.getEditorState().read(() => {
+ const selection = $getSelection();
+ return selection !== null ? selection.clone() : null;
+ });
+}
+
+function $handleTextMutation(
+ target: Text,
+ node: TextNode,
+ editor: LexicalEditor,
+): void {
+ const domSelection = getDOMSelection(editor._window);
+ let anchorOffset = null;
+ let focusOffset = null;
+
+ if (domSelection !== null && domSelection.anchorNode === target) {
+ anchorOffset = domSelection.anchorOffset;
+ focusOffset = domSelection.focusOffset;
+ }
+
+ const text = target.nodeValue;
+ if (text !== null) {
+ $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
+ }
+}
+
+function shouldUpdateTextNodeFromMutation(
+ selection: null | BaseSelection,
+ targetDOM: Node,
+ targetNode: TextNode,
+): boolean {
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+ if (
+ anchorNode.is(targetNode) &&
+ selection.format !== anchorNode.getFormat()
+ ) {
+ return false;
+ }
+ }
+ return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
+}
+
+export function $flushMutations(
+ editor: LexicalEditor,
+ mutations: Array<MutationRecord>,
+ observer: MutationObserver,
+): void {
+ isProcessingMutations = true;
+ const shouldFlushTextMutations =
+ performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
+
+ try {
+ updateEditor(editor, () => {
+ const selection = $getSelection() || getLastSelection(editor);
+ const badDOMTargets = new Map();
+ const rootElement = editor.getRootElement();
+ // We use the current editor state, as that reflects what is
+ // actually "on screen".
+ const currentEditorState = editor._editorState;
+ const blockCursorElement = editor._blockCursorElement;
+ let shouldRevertSelection = false;
+ let possibleTextForFirefoxPaste = '';
+
+ for (let i = 0; i < mutations.length; i++) {
+ const mutation = mutations[i];
+ const type = mutation.type;
+ const targetDOM = mutation.target;
+ let targetNode = $getNearestNodeFromDOMNode(
+ targetDOM,
+ currentEditorState,
+ );
+
+ if (
+ (targetNode === null && targetDOM !== rootElement) ||
+ $isDecoratorNode(targetNode)
+ ) {
+ continue;
+ }
+
+ if (type === 'characterData') {
+ // Text mutations are deferred and passed to mutation listeners to be
+ // processed outside of the Lexical engine.
+ if (
+ shouldFlushTextMutations &&
+ $isTextNode(targetNode) &&
+ shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
+ ) {
+ $handleTextMutation(
+ // nodeType === DOM_TEXT_TYPE is a Text DOM node
+ targetDOM as Text,
+ targetNode,
+ editor,
+ );
+ }
+ } else if (type === 'childList') {
+ shouldRevertSelection = true;
+ // We attempt to "undo" any changes that have occurred outside
+ // of Lexical. We want Lexical's editor state to be source of truth.
+ // To the user, these will look like no-ops.
+ const addedDOMs = mutation.addedNodes;
+
+ for (let s = 0; s < addedDOMs.length; s++) {
+ const addedDOM = addedDOMs[s];
+ const node = $getNodeFromDOMNode(addedDOM);
+ const parentDOM = addedDOM.parentNode;
+
+ if (
+ parentDOM != null &&
+ addedDOM !== blockCursorElement &&
+ node === null &&
+ (addedDOM.nodeName !== 'BR' ||
+ !isManagedLineBreak(addedDOM, parentDOM, editor))
+ ) {
+ if (IS_FIREFOX) {
+ const possibleText =
+ (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
+
+ if (possibleText) {
+ possibleTextForFirefoxPaste += possibleText;
+ }
+ }
+
+ parentDOM.removeChild(addedDOM);
+ }
+ }
+
+ const removedDOMs = mutation.removedNodes;
+ const removedDOMsLength = removedDOMs.length;
+
+ if (removedDOMsLength > 0) {
+ let unremovedBRs = 0;
+
+ for (let s = 0; s < removedDOMsLength; s++) {
+ const removedDOM = removedDOMs[s];
+
+ if (
+ (removedDOM.nodeName === 'BR' &&
+ isManagedLineBreak(removedDOM, targetDOM, editor)) ||
+ blockCursorElement === removedDOM
+ ) {
+ targetDOM.appendChild(removedDOM);
+ unremovedBRs++;
+ }
+ }
+
+ if (removedDOMsLength !== unremovedBRs) {
+ if (targetDOM === rootElement) {
+ targetNode = internalGetRoot(currentEditorState);
+ }
+
+ badDOMTargets.set(targetDOM, targetNode);
+ }
+ }
+ }
+ }
+
+ // Now we process each of the unique target nodes, attempting
+ // to restore their contents back to the source of truth, which
+ // is Lexical's "current" editor state. This is basically like
+ // an internal revert on the DOM.
+ if (badDOMTargets.size > 0) {
+ for (const [targetDOM, targetNode] of badDOMTargets) {
+ if ($isElementNode(targetNode)) {
+ const childKeys = targetNode.getChildrenKeys();
+ let currentDOM = targetDOM.firstChild;
+
+ for (let s = 0; s < childKeys.length; s++) {
+ const key = childKeys[s];
+ const correctDOM = editor.getElementByKey(key);
+
+ if (correctDOM === null) {
+ continue;
+ }
+
+ if (currentDOM == null) {
+ targetDOM.appendChild(correctDOM);
+ currentDOM = correctDOM;
+ } else if (currentDOM !== correctDOM) {
+ targetDOM.replaceChild(correctDOM, currentDOM);
+ }
+
+ currentDOM = currentDOM.nextSibling;
+ }
+ } else if ($isTextNode(targetNode)) {
+ targetNode.markDirty();
+ }
+ }
+ }
+
+ // Capture all the mutations made during this function. This
+ // also prevents us having to process them on the next cycle
+ // of onMutation, as these mutations were made by us.
+ const records = observer.takeRecords();
+
+ // Check for any random auto-added <br> elements, and remove them.
+ // These get added by the browser when we undo the above mutations
+ // and this can lead to a broken UI.
+ if (records.length > 0) {
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i];
+ const addedNodes = record.addedNodes;
+ const target = record.target;
+
+ for (let s = 0; s < addedNodes.length; s++) {
+ const addedDOM = addedNodes[s];
+ const parentDOM = addedDOM.parentNode;
+
+ if (
+ parentDOM != null &&
+ addedDOM.nodeName === 'BR' &&
+ !isManagedLineBreak(addedDOM, target, editor)
+ ) {
+ parentDOM.removeChild(addedDOM);
+ }
+ }
+ }
+
+ // Clear any of those removal mutations
+ observer.takeRecords();
+ }
+
+ if (selection !== null) {
+ if (shouldRevertSelection) {
+ selection.dirty = true;
+ $setSelection(selection);
+ }
+
+ if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
+ selection.insertRawText(possibleTextForFirefoxPaste);
+ }
+ }
+ });
+ } finally {
+ isProcessingMutations = false;
+ }
+}
+
+export function $flushRootMutations(editor: LexicalEditor): void {
+ const observer = editor._observer;
+
+ if (observer !== null) {
+ const mutations = observer.takeRecords();
+ $flushMutations(editor, mutations, observer);
+ }
+}
+
+export function initMutationObserver(editor: LexicalEditor): void {
+ initTextEntryListener(editor);
+ editor._observer = new MutationObserver(
+ (mutations: Array<MutationRecord>, observer: MutationObserver) => {
+ $flushMutations(editor, mutations, observer);
+ },
+ );
+}
--- /dev/null
+/**
+ * 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.
+ *
+ */
+
+/* eslint-disable no-constant-condition */
+import type {EditorConfig, LexicalEditor} from './LexicalEditor';
+import type {BaseSelection, RangeSelection} from './LexicalSelection';
+import type {Klass, KlassConstructor} from 'lexical';
+
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $createParagraphNode,
+ $isDecoratorNode,
+ $isElementNode,
+ $isRootNode,
+ $isTextNode,
+ type DecoratorNode,
+ ElementNode,
+} from '.';
+import {
+ $getSelection,
+ $isNodeSelection,
+ $isRangeSelection,
+ $moveSelectionPointToEnd,
+ $updateElementSelectionOnCreateDeleteNode,
+ moveSelectionPointToSibling,
+} from './LexicalSelection';
+import {
+ errorOnReadOnly,
+ getActiveEditor,
+ getActiveEditorState,
+} from './LexicalUpdates';
+import {
+ $cloneWithProperties,
+ $getCompositionKey,
+ $getNodeByKey,
+ $isRootOrShadowRoot,
+ $maybeMoveChildrenSelectionToParent,
+ $setCompositionKey,
+ $setNodeKey,
+ $setSelection,
+ errorOnInsertTextNodeOnRoot,
+ internalMarkNodeAsDirty,
+ removeFromParent,
+} from './LexicalUtils';
+
+export type NodeMap = Map<NodeKey, LexicalNode>;
+
+export type SerializedLexicalNode = {
+ type: string;
+ version: number;
+};
+
+export function $removeNode(
+ nodeToRemove: LexicalNode,
+ restoreSelection: boolean,
+ preserveEmptyParent?: boolean,
+): void {
+ errorOnReadOnly();
+ const key = nodeToRemove.__key;
+ const parent = nodeToRemove.getParent();
+ if (parent === null) {
+ return;
+ }
+ const selection = $maybeMoveChildrenSelectionToParent(nodeToRemove);
+ let selectionMoved = false;
+ if ($isRangeSelection(selection) && restoreSelection) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ if (anchor.key === key) {
+ moveSelectionPointToSibling(
+ anchor,
+ nodeToRemove,
+ parent,
+ nodeToRemove.getPreviousSibling(),
+ nodeToRemove.getNextSibling(),
+ );
+ selectionMoved = true;
+ }
+ if (focus.key === key) {
+ moveSelectionPointToSibling(
+ focus,
+ nodeToRemove,
+ parent,
+ nodeToRemove.getPreviousSibling(),
+ nodeToRemove.getNextSibling(),
+ );
+ selectionMoved = true;
+ }
+ } else if (
+ $isNodeSelection(selection) &&
+ restoreSelection &&
+ nodeToRemove.isSelected()
+ ) {
+ nodeToRemove.selectPrevious();
+ }
+
+ if ($isRangeSelection(selection) && restoreSelection && !selectionMoved) {
+ // Doing this is O(n) so lets avoid it unless we need to do it
+ const index = nodeToRemove.getIndexWithinParent();
+ removeFromParent(nodeToRemove);
+ $updateElementSelectionOnCreateDeleteNode(selection, parent, index, -1);
+ } else {
+ removeFromParent(nodeToRemove);
+ }
+
+ if (
+ !preserveEmptyParent &&
+ !$isRootOrShadowRoot(parent) &&
+ !parent.canBeEmpty() &&
+ parent.isEmpty()
+ ) {
+ $removeNode(parent, restoreSelection);
+ }
+ if (restoreSelection && $isRootNode(parent) && parent.isEmpty()) {
+ parent.selectEnd();
+ }
+}
+
+export type DOMConversion<T extends HTMLElement = HTMLElement> = {
+ conversion: DOMConversionFn<T>;
+ priority?: 0 | 1 | 2 | 3 | 4;
+};
+
+export type DOMConversionFn<T extends HTMLElement = HTMLElement> = (
+ element: T,
+) => DOMConversionOutput | null;
+
+export type DOMChildConversion = (
+ lexicalNode: LexicalNode,
+ parentLexicalNode: LexicalNode | null | undefined,
+) => LexicalNode | null | undefined;
+
+export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
+ NodeName,
+ (node: T) => DOMConversion<T> | null
+>;
+type NodeName = string;
+
+export type DOMConversionOutput = {
+ after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
+ forChild?: DOMChildConversion;
+ node: null | LexicalNode | Array<LexicalNode>;
+};
+
+export type DOMExportOutputMap = Map<
+ Klass<LexicalNode>,
+ (editor: LexicalEditor, target: LexicalNode) => DOMExportOutput
+>;
+
+export type DOMExportOutput = {
+ after?: (
+ generatedElement: HTMLElement | Text | null | undefined,
+ ) => HTMLElement | Text | null | undefined;
+ element: HTMLElement | Text | null;
+};
+
+export type NodeKey = string;
+
+export class LexicalNode {
+ // Allow us to look up the type including static props
+ ['constructor']!: KlassConstructor<typeof LexicalNode>;
+ /** @internal */
+ __type: string;
+ /** @internal */
+ //@ts-ignore We set the key in the constructor.
+ __key: string;
+ /** @internal */
+ __parent: null | NodeKey;
+ /** @internal */
+ __prev: null | NodeKey;
+ /** @internal */
+ __next: null | NodeKey;
+
+ // Flow doesn't support abstract classes unfortunately, so we can't _force_
+ // subclasses of Node to implement statics. All subclasses of Node should have
+ // a static getType and clone method though. We define getType and clone here so we can call it
+ // on any Node, and we throw this error by default since the subclass should provide
+ // their own implementation.
+ /**
+ * Returns the string type of this node. Every node must
+ * implement this and it MUST BE UNIQUE amongst nodes registered
+ * on the editor.
+ *
+ */
+ static getType(): string {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .getType().',
+ this.name,
+ );
+ }
+
+ /**
+ * Clones this node, creating a new node with a different key
+ * and adding it to the EditorState (but not attaching it anywhere!). All nodes must
+ * implement this method.
+ *
+ */
+ static clone(_data: unknown): LexicalNode {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .clone().',
+ this.name,
+ );
+ }
+
+ /**
+ * Perform any state updates on the clone of prevNode that are not already
+ * handled by the constructor call in the static clone method. If you have
+ * state to update in your clone that is not handled directly by the
+ * constructor, it is advisable to override this method but it is required
+ * to include a call to `super.afterCloneFrom(prevNode)` in your
+ * implementation. This is only intended to be called by
+ * {@link $cloneWithProperties} function or via a super call.
+ *
+ * @example
+ * ```ts
+ * class ClassesTextNode extends TextNode {
+ * // Not shown: static getType, static importJSON, exportJSON, createDOM, updateDOM
+ * __classes = new Set<string>();
+ * static clone(node: ClassesTextNode): ClassesTextNode {
+ * // The inherited TextNode constructor is used here, so
+ * // classes is not set by this method.
+ * return new ClassesTextNode(node.__text, node.__key);
+ * }
+ * afterCloneFrom(node: this): void {
+ * // This calls TextNode.afterCloneFrom and LexicalNode.afterCloneFrom
+ * // for necessary state updates
+ * super.afterCloneFrom(node);
+ * this.__addClasses(node.__classes);
+ * }
+ * // This method is a private implementation detail, it is not
+ * // suitable for the public API because it does not call getWritable
+ * __addClasses(classNames: Iterable<string>): this {
+ * for (const className of classNames) {
+ * this.__classes.add(className);
+ * }
+ * return this;
+ * }
+ * addClass(...classNames: string[]): this {
+ * return this.getWritable().__addClasses(classNames);
+ * }
+ * removeClass(...classNames: string[]): this {
+ * const node = this.getWritable();
+ * for (const className of classNames) {
+ * this.__classes.delete(className);
+ * }
+ * return this;
+ * }
+ * getClasses(): Set<string> {
+ * return this.getLatest().__classes;
+ * }
+ * }
+ * ```
+ *
+ */
+ afterCloneFrom(prevNode: this) {
+ this.__parent = prevNode.__parent;
+ this.__next = prevNode.__next;
+ this.__prev = prevNode.__prev;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ static importDOM?: () => DOMConversionMap<any> | null;
+
+ constructor(key?: NodeKey) {
+ this.__type = this.constructor.getType();
+ this.__parent = null;
+ this.__prev = null;
+ this.__next = null;
+ $setNodeKey(this, key);
+
+ if (__DEV__) {
+ if (this.__type !== 'root') {
+ errorOnReadOnly();
+ errorOnTypeKlassMismatch(this.__type, this.constructor);
+ }
+ }
+ }
+ // Getters and Traversers
+
+ /**
+ * Returns the string type of this node.
+ */
+ getType(): string {
+ return this.__type;
+ }
+
+ isInline(): boolean {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .isInline().',
+ this.constructor.name,
+ );
+ }
+
+ /**
+ * Returns true if there is a path between this node and the RootNode, false otherwise.
+ * This is a way of determining if the node is "attached" EditorState. Unattached nodes
+ * won't be reconciled and will ultimatelt be cleaned up by the Lexical GC.
+ */
+ isAttached(): boolean {
+ let nodeKey: string | null = this.__key;
+ while (nodeKey !== null) {
+ if (nodeKey === 'root') {
+ return true;
+ }
+
+ const node: LexicalNode | null = $getNodeByKey(nodeKey);
+
+ if (node === null) {
+ break;
+ }
+ nodeKey = node.__parent;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this node is contained within the provided Selection., false otherwise.
+ * Relies on the algorithms implemented in {@link BaseSelection.getNodes} to determine
+ * what's included.
+ *
+ * @param selection - The selection that we want to determine if the node is in.
+ */
+ isSelected(selection?: null | BaseSelection): boolean {
+ const targetSelection = selection || $getSelection();
+ if (targetSelection == null) {
+ return false;
+ }
+
+ const isSelected = targetSelection
+ .getNodes()
+ .some((n) => n.__key === this.__key);
+
+ if ($isTextNode(this)) {
+ return isSelected;
+ }
+ // For inline images inside of element nodes.
+ // Without this change the image will be selected if the cursor is before or after it.
+ const isElementRangeSelection =
+ $isRangeSelection(targetSelection) &&
+ targetSelection.anchor.type === 'element' &&
+ targetSelection.focus.type === 'element';
+
+ if (isElementRangeSelection) {
+ if (targetSelection.isCollapsed()) {
+ return false;
+ }
+
+ const parentNode = this.getParent();
+ if ($isDecoratorNode(this) && this.isInline() && parentNode) {
+ const firstPoint = targetSelection.isBackward()
+ ? targetSelection.focus
+ : targetSelection.anchor;
+ const firstElement = firstPoint.getNode() as ElementNode;
+ if (
+ firstPoint.offset === firstElement.getChildrenSize() &&
+ firstElement.is(parentNode) &&
+ firstElement.getLastChildOrThrow().is(this)
+ ) {
+ return false;
+ }
+ }
+ }
+ return isSelected;
+ }
+
+ /**
+ * Returns this nodes key.
+ */
+ getKey(): NodeKey {
+ // Key is stable between copies
+ return this.__key;
+ }
+
+ /**
+ * Returns the zero-based index of this node within the parent.
+ */
+ getIndexWithinParent(): number {
+ const parent = this.getParent();
+ if (parent === null) {
+ return -1;
+ }
+ let node = parent.getFirstChild();
+ let index = 0;
+ while (node !== null) {
+ if (this.is(node)) {
+ return index;
+ }
+ index++;
+ node = node.getNextSibling();
+ }
+ return -1;
+ }
+
+ /**
+ * Returns the parent of this node, or null if none is found.
+ */
+ getParent<T extends ElementNode>(): T | null {
+ const parent = this.getLatest().__parent;
+ if (parent === null) {
+ return null;
+ }
+ return $getNodeByKey<T>(parent);
+ }
+
+ /**
+ * Returns the parent of this node, or throws if none is found.
+ */
+ getParentOrThrow<T extends ElementNode>(): T {
+ const parent = this.getParent<T>();
+ if (parent === null) {
+ invariant(false, 'Expected node %s to have a parent.', this.__key);
+ }
+ return parent;
+ }
+
+ /**
+ * Returns the highest (in the EditorState tree)
+ * non-root ancestor of this node, or null if none is found. See {@link lexical!$isRootOrShadowRoot}
+ * for more information on which Elements comprise "roots".
+ */
+ getTopLevelElement(): ElementNode | DecoratorNode<unknown> | null {
+ let node: ElementNode | this | null = this;
+ while (node !== null) {
+ const parent: ElementNode | null = node.getParent();
+ if ($isRootOrShadowRoot(parent)) {
+ invariant(
+ $isElementNode(node) || (node === this && $isDecoratorNode(node)),
+ 'Children of root nodes must be elements or decorators',
+ );
+ return node;
+ }
+ node = parent;
+ }
+ return null;
+ }
+
+ /**
+ * Returns the highest (in the EditorState tree)
+ * non-root ancestor of this node, or throws if none is found. See {@link lexical!$isRootOrShadowRoot}
+ * for more information on which Elements comprise "roots".
+ */
+ getTopLevelElementOrThrow(): ElementNode | DecoratorNode<unknown> {
+ const parent = this.getTopLevelElement();
+ if (parent === null) {
+ invariant(
+ false,
+ 'Expected node %s to have a top parent element.',
+ this.__key,
+ );
+ }
+ return parent;
+ }
+
+ /**
+ * Returns a list of the every ancestor of this node,
+ * all the way up to the RootNode.
+ *
+ */
+ getParents(): Array<ElementNode> {
+ const parents: Array<ElementNode> = [];
+ let node = this.getParent();
+ while (node !== null) {
+ parents.push(node);
+ node = node.getParent();
+ }
+ return parents;
+ }
+
+ /**
+ * Returns a list of the keys of every ancestor of this node,
+ * all the way up to the RootNode.
+ *
+ */
+ getParentKeys(): Array<NodeKey> {
+ const parents = [];
+ let node = this.getParent();
+ while (node !== null) {
+ parents.push(node.__key);
+ node = node.getParent();
+ }
+ return parents;
+ }
+
+ /**
+ * Returns the "previous" siblings - that is, the node that comes
+ * before this one in the same parent.
+ *
+ */
+ getPreviousSibling<T extends LexicalNode>(): T | null {
+ const self = this.getLatest();
+ const prevKey = self.__prev;
+ return prevKey === null ? null : $getNodeByKey<T>(prevKey);
+ }
+
+ /**
+ * Returns the "previous" siblings - that is, the nodes that come between
+ * this one and the first child of it's parent, inclusive.
+ *
+ */
+ getPreviousSiblings<T extends LexicalNode>(): Array<T> {
+ const siblings: Array<T> = [];
+ const parent = this.getParent();
+ if (parent === null) {
+ return siblings;
+ }
+ let node: null | T = parent.getFirstChild();
+ while (node !== null) {
+ if (node.is(this)) {
+ break;
+ }
+ siblings.push(node);
+ node = node.getNextSibling();
+ }
+ return siblings;
+ }
+
+ /**
+ * Returns the "next" siblings - that is, the node that comes
+ * after this one in the same parent
+ *
+ */
+ getNextSibling<T extends LexicalNode>(): T | null {
+ const self = this.getLatest();
+ const nextKey = self.__next;
+ return nextKey === null ? null : $getNodeByKey<T>(nextKey);
+ }
+
+ /**
+ * Returns all "next" siblings - that is, the nodes that come between this
+ * one and the last child of it's parent, inclusive.
+ *
+ */
+ getNextSiblings<T extends LexicalNode>(): Array<T> {
+ const siblings: Array<T> = [];
+ let node: null | T = this.getNextSibling();
+ while (node !== null) {
+ siblings.push(node);
+ node = node.getNextSibling();
+ }
+ return siblings;
+ }
+
+ /**
+ * Returns the closest common ancestor of this node and the provided one or null
+ * if one cannot be found.
+ *
+ * @param node - the other node to find the common ancestor of.
+ */
+ getCommonAncestor<T extends ElementNode = ElementNode>(
+ node: LexicalNode,
+ ): T | null {
+ const a = this.getParents();
+ const b = node.getParents();
+ if ($isElementNode(this)) {
+ a.unshift(this);
+ }
+ if ($isElementNode(node)) {
+ b.unshift(node);
+ }
+ const aLength = a.length;
+ const bLength = b.length;
+ if (aLength === 0 || bLength === 0 || a[aLength - 1] !== b[bLength - 1]) {
+ return null;
+ }
+ const bSet = new Set(b);
+ for (let i = 0; i < aLength; i++) {
+ const ancestor = a[i] as T;
+ if (bSet.has(ancestor)) {
+ return ancestor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the provided node is the exact same one as this node, from Lexical's perspective.
+ * Always use this instead of referential equality.
+ *
+ * @param object - the node to perform the equality comparison on.
+ */
+ is(object: LexicalNode | null | undefined): boolean {
+ if (object == null) {
+ return false;
+ }
+ return this.__key === object.__key;
+ }
+
+ /**
+ * Returns true if this node logical precedes the target node in the editor state.
+ *
+ * @param targetNode - the node we're testing to see if it's after this one.
+ */
+ isBefore(targetNode: LexicalNode): boolean {
+ if (this === targetNode) {
+ return false;
+ }
+ if (targetNode.isParentOf(this)) {
+ return true;
+ }
+ if (this.isParentOf(targetNode)) {
+ return false;
+ }
+ const commonAncestor = this.getCommonAncestor(targetNode);
+ let indexA = 0;
+ let indexB = 0;
+ let node: this | ElementNode | LexicalNode = this;
+ while (true) {
+ const parent: ElementNode = node.getParentOrThrow();
+ if (parent === commonAncestor) {
+ indexA = node.getIndexWithinParent();
+ break;
+ }
+ node = parent;
+ }
+ node = targetNode;
+ while (true) {
+ const parent: ElementNode = node.getParentOrThrow();
+ if (parent === commonAncestor) {
+ indexB = node.getIndexWithinParent();
+ break;
+ }
+ node = parent;
+ }
+ return indexA < indexB;
+ }
+
+ /**
+ * Returns true if this node is the parent of the target node, false otherwise.
+ *
+ * @param targetNode - the would-be child node.
+ */
+ isParentOf(targetNode: LexicalNode): boolean {
+ const key = this.__key;
+ if (key === targetNode.__key) {
+ return false;
+ }
+ let node: ElementNode | LexicalNode | null = targetNode;
+ while (node !== null) {
+ if (node.__key === key) {
+ return true;
+ }
+ node = node.getParent();
+ }
+ return false;
+ }
+
+ // TO-DO: this function can be simplified a lot
+ /**
+ * Returns a list of nodes that are between this node and
+ * the target node in the EditorState.
+ *
+ * @param targetNode - the node that marks the other end of the range of nodes to be returned.
+ */
+ getNodesBetween(targetNode: LexicalNode): Array<LexicalNode> {
+ const isBefore = this.isBefore(targetNode);
+ const nodes = [];
+ const visited = new Set();
+ let node: LexicalNode | this | null = this;
+ while (true) {
+ if (node === null) {
+ break;
+ }
+ const key = node.__key;
+ if (!visited.has(key)) {
+ visited.add(key);
+ nodes.push(node);
+ }
+ if (node === targetNode) {
+ break;
+ }
+ const child: LexicalNode | null = $isElementNode(node)
+ ? isBefore
+ ? node.getFirstChild()
+ : node.getLastChild()
+ : null;
+ if (child !== null) {
+ node = child;
+ continue;
+ }
+ const nextSibling: LexicalNode | null = isBefore
+ ? node.getNextSibling()
+ : node.getPreviousSibling();
+ if (nextSibling !== null) {
+ node = nextSibling;
+ continue;
+ }
+ const parent: LexicalNode | null = node.getParentOrThrow();
+ if (!visited.has(parent.__key)) {
+ nodes.push(parent);
+ }
+ if (parent === targetNode) {
+ break;
+ }
+ let parentSibling = null;
+ let ancestor: LexicalNode | null = parent;
+ do {
+ if (ancestor === null) {
+ invariant(false, 'getNodesBetween: ancestor is null');
+ }
+ parentSibling = isBefore
+ ? ancestor.getNextSibling()
+ : ancestor.getPreviousSibling();
+ ancestor = ancestor.getParent();
+ if (ancestor !== null) {
+ if (parentSibling === null && !visited.has(ancestor.__key)) {
+ nodes.push(ancestor);
+ }
+ } else {
+ break;
+ }
+ } while (parentSibling === null);
+ node = parentSibling;
+ }
+ if (!isBefore) {
+ nodes.reverse();
+ }
+ return nodes;
+ }
+
+ /**
+ * Returns true if this node has been marked dirty during this update cycle.
+ *
+ */
+ isDirty(): boolean {
+ const editor = getActiveEditor();
+ const dirtyLeaves = editor._dirtyLeaves;
+ return dirtyLeaves !== null && dirtyLeaves.has(this.__key);
+ }
+
+ /**
+ * Returns the latest version of the node from the active EditorState.
+ * This is used to avoid getting values from stale node references.
+ *
+ */
+ getLatest(): this {
+ const latest = $getNodeByKey<this>(this.__key);
+ if (latest === null) {
+ invariant(
+ false,
+ 'Lexical node does not exist in active editor state. Avoid using the same node references between nested closures from editorState.read/editor.update.',
+ );
+ }
+ return latest;
+ }
+
+ /**
+ * Returns a mutable version of the node using {@link $cloneWithProperties}
+ * if necessary. Will throw an error if called outside of a Lexical Editor
+ * {@link LexicalEditor.update} callback.
+ *
+ */
+ getWritable(): this {
+ errorOnReadOnly();
+ const editorState = getActiveEditorState();
+ const editor = getActiveEditor();
+ const nodeMap = editorState._nodeMap;
+ const key = this.__key;
+ // Ensure we get the latest node from pending state
+ const latestNode = this.getLatest();
+ const cloneNotNeeded = editor._cloneNotNeeded;
+ const selection = $getSelection();
+ if (selection !== null) {
+ selection.setCachedNodes(null);
+ }
+ if (cloneNotNeeded.has(key)) {
+ // Transforms clear the dirty node set on each iteration to keep track on newly dirty nodes
+ internalMarkNodeAsDirty(latestNode);
+ return latestNode;
+ }
+ const mutableNode = $cloneWithProperties(latestNode);
+ cloneNotNeeded.add(key);
+ internalMarkNodeAsDirty(mutableNode);
+ // Update reference in node map
+ nodeMap.set(key, mutableNode);
+
+ return mutableNode;
+ }
+
+ /**
+ * Returns the text content of the node. Override this for
+ * custom nodes that should have a representation in plain text
+ * format (for copy + paste, for example)
+ *
+ */
+ getTextContent(): string {
+ return '';
+ }
+
+ /**
+ * Returns the length of the string produced by calling getTextContent on this node.
+ *
+ */
+ getTextContentSize(): number {
+ return this.getTextContent().length;
+ }
+
+ // View
+
+ /**
+ * Called during the reconciliation process to determine which nodes
+ * to insert into the DOM for this Lexical Node.
+ *
+ * This method must return exactly one HTMLElement. Nested elements are not supported.
+ *
+ * Do not attempt to update the Lexical EditorState during this phase of the update lifecyle.
+ *
+ * @param _config - allows access to things like the EditorTheme (to apply classes) during reconciliation.
+ * @param _editor - allows access to the editor for context during reconciliation.
+ *
+ * */
+ createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
+ invariant(false, 'createDOM: base method not extended');
+ }
+
+ /**
+ * Called when a node changes and should update the DOM
+ * in whatever way is necessary to make it align with any changes that might
+ * have happened during the update.
+ *
+ * Returning "true" here will cause lexical to unmount and recreate the DOM node
+ * (by calling createDOM). You would need to do this if the element tag changes,
+ * for instance.
+ *
+ * */
+ updateDOM(
+ _prevNode: unknown,
+ _dom: HTMLElement,
+ _config: EditorConfig,
+ ): boolean {
+ invariant(false, 'updateDOM: base method not extended');
+ }
+
+ /**
+ * Controls how the this node is serialized to HTML. This is important for
+ * copy and paste between Lexical and non-Lexical editors, or Lexical editors with different namespaces,
+ * in which case the primary transfer format is HTML. It's also important if you're serializing
+ * to HTML for any other reason via {@link @lexical/html!$generateHtmlFromNodes}. You could
+ * also use this method to build your own HTML renderer.
+ *
+ * */
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const element = this.createDOM(editor._config, editor);
+ return {element};
+ }
+
+ /**
+ * Controls how the this node is serialized to JSON. This is important for
+ * copy and paste between Lexical editors sharing the same namespace. It's also important
+ * if you're serializing to JSON for persistent storage somewhere.
+ * See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
+ *
+ * */
+ exportJSON(): SerializedLexicalNode {
+ invariant(false, 'exportJSON: base method not extended');
+ }
+
+ /**
+ * Controls how the this node is deserialized from JSON. This is usually boilerplate,
+ * but provides an abstraction between the node implementation and serialized interface that can
+ * be important if you ever make breaking changes to a node schema (by adding or removing properties).
+ * See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
+ *
+ * */
+ static importJSON(_serializedNode: SerializedLexicalNode): LexicalNode {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .importJSON().',
+ this.name,
+ );
+ }
+ /**
+ * @experimental
+ *
+ * Registers the returned function as a transform on the node during
+ * Editor initialization. Most such use cases should be addressed via
+ * the {@link LexicalEditor.registerNodeTransform} API.
+ *
+ * Experimental - use at your own risk.
+ */
+ static transform(): ((node: LexicalNode) => void) | null {
+ return null;
+ }
+
+ // Setters and mutators
+
+ /**
+ * Removes this LexicalNode from the EditorState. If the node isn't re-inserted
+ * somewhere, the Lexical garbage collector will eventually clean it up.
+ *
+ * @param preserveEmptyParent - If falsy, the node's parent will be removed if
+ * it's empty after the removal operation. This is the default behavior, subject to
+ * other node heuristics such as {@link ElementNode#canBeEmpty}
+ * */
+ remove(preserveEmptyParent?: boolean): void {
+ $removeNode(this, true, preserveEmptyParent);
+ }
+
+ /**
+ * Replaces this LexicalNode with the provided node, optionally transferring the children
+ * of the replaced node to the replacing node.
+ *
+ * @param replaceWith - The node to replace this one with.
+ * @param includeChildren - Whether or not to transfer the children of this node to the replacing node.
+ * */
+ replace<N extends LexicalNode>(replaceWith: N, includeChildren?: boolean): N {
+ errorOnReadOnly();
+ let selection = $getSelection();
+ if (selection !== null) {
+ selection = selection.clone();
+ }
+ errorOnInsertTextNodeOnRoot(this, replaceWith);
+ const self = this.getLatest();
+ const toReplaceKey = this.__key;
+ const key = replaceWith.__key;
+ const writableReplaceWith = replaceWith.getWritable();
+ const writableParent = this.getParentOrThrow().getWritable();
+ const size = writableParent.__size;
+ removeFromParent(writableReplaceWith);
+ const prevSibling = self.getPreviousSibling();
+ const nextSibling = self.getNextSibling();
+ const prevKey = self.__prev;
+ const nextKey = self.__next;
+ const parentKey = self.__parent;
+ $removeNode(self, false, true);
+
+ if (prevSibling === null) {
+ writableParent.__first = key;
+ } else {
+ const writablePrevSibling = prevSibling.getWritable();
+ writablePrevSibling.__next = key;
+ }
+ writableReplaceWith.__prev = prevKey;
+ if (nextSibling === null) {
+ writableParent.__last = key;
+ } else {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = key;
+ }
+ writableReplaceWith.__next = nextKey;
+ writableReplaceWith.__parent = parentKey;
+ writableParent.__size = size;
+ if (includeChildren) {
+ invariant(
+ $isElementNode(this) && $isElementNode(writableReplaceWith),
+ 'includeChildren should only be true for ElementNodes',
+ );
+ this.getChildren().forEach((child: LexicalNode) => {
+ writableReplaceWith.append(child);
+ });
+ }
+ if ($isRangeSelection(selection)) {
+ $setSelection(selection);
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ if (anchor.key === toReplaceKey) {
+ $moveSelectionPointToEnd(anchor, writableReplaceWith);
+ }
+ if (focus.key === toReplaceKey) {
+ $moveSelectionPointToEnd(focus, writableReplaceWith);
+ }
+ }
+ if ($getCompositionKey() === toReplaceKey) {
+ $setCompositionKey(key);
+ }
+ return writableReplaceWith;
+ }
+
+ /**
+ * Inserts a node after this LexicalNode (as the next sibling).
+ *
+ * @param nodeToInsert - The node to insert after this one.
+ * @param restoreSelection - Whether or not to attempt to resolve the
+ * selection to the appropriate place after the operation is complete.
+ * */
+ insertAfter(nodeToInsert: LexicalNode, restoreSelection = true): LexicalNode {
+ errorOnReadOnly();
+ errorOnInsertTextNodeOnRoot(this, nodeToInsert);
+ const writableSelf = this.getWritable();
+ const writableNodeToInsert = nodeToInsert.getWritable();
+ const oldParent = writableNodeToInsert.getParent();
+ const selection = $getSelection();
+ let elementAnchorSelectionOnNode = false;
+ let elementFocusSelectionOnNode = false;
+ if (oldParent !== null) {
+ // TODO: this is O(n), can we improve?
+ const oldIndex = nodeToInsert.getIndexWithinParent();
+ removeFromParent(writableNodeToInsert);
+ if ($isRangeSelection(selection)) {
+ const oldParentKey = oldParent.__key;
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ elementAnchorSelectionOnNode =
+ anchor.type === 'element' &&
+ anchor.key === oldParentKey &&
+ anchor.offset === oldIndex + 1;
+ elementFocusSelectionOnNode =
+ focus.type === 'element' &&
+ focus.key === oldParentKey &&
+ focus.offset === oldIndex + 1;
+ }
+ }
+ const nextSibling = this.getNextSibling();
+ const writableParent = this.getParentOrThrow().getWritable();
+ const insertKey = writableNodeToInsert.__key;
+ const nextKey = writableSelf.__next;
+ if (nextSibling === null) {
+ writableParent.__last = insertKey;
+ } else {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = insertKey;
+ }
+ writableParent.__size++;
+ writableSelf.__next = insertKey;
+ writableNodeToInsert.__next = nextKey;
+ writableNodeToInsert.__prev = writableSelf.__key;
+ writableNodeToInsert.__parent = writableSelf.__parent;
+ if (restoreSelection && $isRangeSelection(selection)) {
+ const index = this.getIndexWithinParent();
+ $updateElementSelectionOnCreateDeleteNode(
+ selection,
+ writableParent,
+ index + 1,
+ );
+ const writableParentKey = writableParent.__key;
+ if (elementAnchorSelectionOnNode) {
+ selection.anchor.set(writableParentKey, index + 2, 'element');
+ }
+ if (elementFocusSelectionOnNode) {
+ selection.focus.set(writableParentKey, index + 2, 'element');
+ }
+ }
+ return nodeToInsert;
+ }
+
+ /**
+ * Inserts a node before this LexicalNode (as the previous sibling).
+ *
+ * @param nodeToInsert - The node to insert before this one.
+ * @param restoreSelection - Whether or not to attempt to resolve the
+ * selection to the appropriate place after the operation is complete.
+ * */
+ insertBefore(
+ nodeToInsert: LexicalNode,
+ restoreSelection = true,
+ ): LexicalNode {
+ errorOnReadOnly();
+ errorOnInsertTextNodeOnRoot(this, nodeToInsert);
+ const writableSelf = this.getWritable();
+ const writableNodeToInsert = nodeToInsert.getWritable();
+ const insertKey = writableNodeToInsert.__key;
+ removeFromParent(writableNodeToInsert);
+ const prevSibling = this.getPreviousSibling();
+ const writableParent = this.getParentOrThrow().getWritable();
+ const prevKey = writableSelf.__prev;
+ // TODO: this is O(n), can we improve?
+ const index = this.getIndexWithinParent();
+ if (prevSibling === null) {
+ writableParent.__first = insertKey;
+ } else {
+ const writablePrevSibling = prevSibling.getWritable();
+ writablePrevSibling.__next = insertKey;
+ }
+ writableParent.__size++;
+ writableSelf.__prev = insertKey;
+ writableNodeToInsert.__prev = prevKey;
+ writableNodeToInsert.__next = writableSelf.__key;
+ writableNodeToInsert.__parent = writableSelf.__parent;
+ const selection = $getSelection();
+ if (restoreSelection && $isRangeSelection(selection)) {
+ const parent = this.getParentOrThrow();
+ $updateElementSelectionOnCreateDeleteNode(selection, parent, index);
+ }
+ return nodeToInsert;
+ }
+
+ /**
+ * Whether or not this node has a required parent. Used during copy + paste operations
+ * to normalize nodes that would otherwise be orphaned. For example, ListItemNodes without
+ * a ListNode parent or TextNodes with a ParagraphNode parent.
+ *
+ * */
+ isParentRequired(): boolean {
+ return false;
+ }
+
+ /**
+ * The creation logic for any required parent. Should be implemented if {@link isParentRequired} returns true.
+ *
+ * */
+ createParentElementNode(): ElementNode {
+ return $createParagraphNode();
+ }
+
+ selectStart(): RangeSelection {
+ return this.selectPrevious();
+ }
+
+ selectEnd(): RangeSelection {
+ return this.selectNext(0, 0);
+ }
+
+ /**
+ * Moves selection to the previous sibling of this node, at the specified offsets.
+ *
+ * @param anchorOffset - The anchor offset for selection.
+ * @param focusOffset - The focus offset for selection
+ * */
+ selectPrevious(anchorOffset?: number, focusOffset?: number): RangeSelection {
+ errorOnReadOnly();
+ const prevSibling = this.getPreviousSibling();
+ const parent = this.getParentOrThrow();
+ if (prevSibling === null) {
+ return parent.select(0, 0);
+ }
+ if ($isElementNode(prevSibling)) {
+ return prevSibling.select();
+ } else if (!$isTextNode(prevSibling)) {
+ const index = prevSibling.getIndexWithinParent() + 1;
+ return parent.select(index, index);
+ }
+ return prevSibling.select(anchorOffset, focusOffset);
+ }
+
+ /**
+ * Moves selection to the next sibling of this node, at the specified offsets.
+ *
+ * @param anchorOffset - The anchor offset for selection.
+ * @param focusOffset - The focus offset for selection
+ * */
+ selectNext(anchorOffset?: number, focusOffset?: number): RangeSelection {
+ errorOnReadOnly();
+ const nextSibling = this.getNextSibling();
+ const parent = this.getParentOrThrow();
+ if (nextSibling === null) {
+ return parent.select();
+ }
+ if ($isElementNode(nextSibling)) {
+ return nextSibling.select(0, 0);
+ } else if (!$isTextNode(nextSibling)) {
+ const index = nextSibling.getIndexWithinParent();
+ return parent.select(index, index);
+ }
+ return nextSibling.select(anchorOffset, focusOffset);
+ }
+
+ /**
+ * Marks a node dirty, triggering transforms and
+ * forcing it to be reconciled during the update cycle.
+ *
+ * */
+ markDirty(): void {
+ this.getWritable();
+ }
+}
+
+function errorOnTypeKlassMismatch(
+ type: string,
+ klass: Klass<LexicalNode>,
+): void {
+ const registeredNode = getActiveEditor()._nodes.get(type);
+ // Common error - split in its own invariant
+ if (registeredNode === undefined) {
+ invariant(
+ false,
+ 'Create node: Attempted to create node %s that was not configured to be used on the editor.',
+ klass.name,
+ );
+ }
+ const editorKlass = registeredNode.klass;
+ if (editorKlass !== klass) {
+ invariant(
+ false,
+ 'Create node: Type %s in node %s does not match registered node %s with the same type',
+ type,
+ klass.name,
+ editorKlass.name,
+ );
+ }
+}
+
+/**
+ * Insert a series of nodes after this LexicalNode (as next siblings)
+ *
+ * @param firstToInsert - The first node to insert after this one.
+ * @param lastToInsert - The last node to insert after this one. Must be a
+ * later sibling of FirstNode. If not provided, it will be its last sibling.
+ */
+export function insertRangeAfter(
+ node: LexicalNode,
+ firstToInsert: LexicalNode,
+ lastToInsert?: LexicalNode,
+) {
+ const lastToInsert2 =
+ lastToInsert || firstToInsert.getParentOrThrow().getLastChild()!;
+ let current = firstToInsert;
+ const nodesToInsert = [firstToInsert];
+ while (current !== lastToInsert2) {
+ if (!current.getNextSibling()) {
+ invariant(
+ false,
+ 'insertRangeAfter: lastToInsert must be a later sibling of firstToInsert',
+ );
+ }
+ current = current.getNextSibling()!;
+ nodesToInsert.push(current);
+ }
+
+ let currentNode: LexicalNode = node;
+ for (const nodeToInsert of nodesToInsert) {
+ currentNode = currentNode.insertAfter(nodeToInsert);
+ }
+}
--- /dev/null
+/**
+ * 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 {RangeSelection, TextNode} from '.';
+import type {PointType} from './LexicalSelection';
+
+import {$isElementNode, $isTextNode} from '.';
+import {getActiveEditor} from './LexicalUpdates';
+
+function $canSimpleTextNodesBeMerged(
+ node1: TextNode,
+ node2: TextNode,
+): boolean {
+ const node1Mode = node1.__mode;
+ const node1Format = node1.__format;
+ const node1Style = node1.__style;
+ const node2Mode = node2.__mode;
+ const node2Format = node2.__format;
+ const node2Style = node2.__style;
+ return (
+ (node1Mode === null || node1Mode === node2Mode) &&
+ (node1Format === null || node1Format === node2Format) &&
+ (node1Style === null || node1Style === node2Style)
+ );
+}
+
+function $mergeTextNodes(node1: TextNode, node2: TextNode): TextNode {
+ const writableNode1 = node1.mergeWithSibling(node2);
+
+ const normalizedNodes = getActiveEditor()._normalizedNodes;
+
+ normalizedNodes.add(node1.__key);
+ normalizedNodes.add(node2.__key);
+ return writableNode1;
+}
+
+export function $normalizeTextNode(textNode: TextNode): void {
+ let node = textNode;
+
+ if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) {
+ node.remove();
+ return;
+ }
+
+ // Backward
+ let previousNode;
+
+ while (
+ (previousNode = node.getPreviousSibling()) !== null &&
+ $isTextNode(previousNode) &&
+ previousNode.isSimpleText() &&
+ !previousNode.isUnmergeable()
+ ) {
+ if (previousNode.__text === '') {
+ previousNode.remove();
+ } else if ($canSimpleTextNodesBeMerged(previousNode, node)) {
+ node = $mergeTextNodes(previousNode, node);
+ break;
+ } else {
+ break;
+ }
+ }
+
+ // Forward
+ let nextNode;
+
+ while (
+ (nextNode = node.getNextSibling()) !== null &&
+ $isTextNode(nextNode) &&
+ nextNode.isSimpleText() &&
+ !nextNode.isUnmergeable()
+ ) {
+ if (nextNode.__text === '') {
+ nextNode.remove();
+ } else if ($canSimpleTextNodesBeMerged(node, nextNode)) {
+ node = $mergeTextNodes(node, nextNode);
+ break;
+ } else {
+ break;
+ }
+ }
+}
+
+export function $normalizeSelection(selection: RangeSelection): RangeSelection {
+ $normalizePoint(selection.anchor);
+ $normalizePoint(selection.focus);
+ return selection;
+}
+
+function $normalizePoint(point: PointType): void {
+ while (point.type === 'element') {
+ const node = point.getNode();
+ const offset = point.offset;
+ let nextNode;
+ let nextOffsetAtEnd;
+ if (offset === node.getChildrenSize()) {
+ nextNode = node.getChildAtIndex(offset - 1);
+ nextOffsetAtEnd = true;
+ } else {
+ nextNode = node.getChildAtIndex(offset);
+ nextOffsetAtEnd = false;
+ }
+ if ($isTextNode(nextNode)) {
+ point.set(
+ nextNode.__key,
+ nextOffsetAtEnd ? nextNode.getTextContentSize() : 0,
+ 'text',
+ );
+ break;
+ } else if (!$isElementNode(nextNode)) {
+ break;
+ }
+ point.set(
+ nextNode.__key,
+ nextOffsetAtEnd ? nextNode.getChildrenSize() : 0,
+ 'element',
+ );
+ }
+}
--- /dev/null
+/**
+ * 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 {
+ EditorConfig,
+ LexicalEditor,
+ MutatedNodes,
+ MutationListeners,
+ RegisteredNodes,
+} from './LexicalEditor';
+import type {NodeKey, NodeMap} from './LexicalNode';
+import type {ElementNode} from './nodes/LexicalElementNode';
+
+import invariant from 'lexical/shared/invariant';
+import normalizeClassNames from 'lexical/shared/normalizeClassNames';
+
+import {
+ $isDecoratorNode,
+ $isElementNode,
+ $isLineBreakNode,
+ $isParagraphNode,
+ $isRootNode,
+ $isTextNode,
+} from '.';
+import {
+ DOUBLE_LINE_BREAK,
+ FULL_RECONCILE,
+ IS_ALIGN_CENTER,
+ IS_ALIGN_END,
+ IS_ALIGN_JUSTIFY,
+ IS_ALIGN_LEFT,
+ IS_ALIGN_RIGHT,
+ IS_ALIGN_START,
+} from './LexicalConstants';
+import {EditorState} from './LexicalEditorState';
+import {
+ $textContentRequiresDoubleLinebreakAtEnd,
+ cloneDecorators,
+ getElementByKeyOrThrow,
+ getTextDirection,
+ setMutatedNode,
+} from './LexicalUtils';
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+let subTreeTextContent = '';
+let subTreeDirectionedTextContent = '';
+let subTreeTextFormat: number | null = null;
+let subTreeTextStyle: string = '';
+let editorTextContent = '';
+let activeEditorConfig: EditorConfig;
+let activeEditor: LexicalEditor;
+let activeEditorNodes: RegisteredNodes;
+let treatAllNodesAsDirty = false;
+let activeEditorStateReadOnly = false;
+let activeMutationListeners: MutationListeners;
+let activeTextDirection: 'ltr' | 'rtl' | null = null;
+let activeDirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
+let activeDirtyLeaves: Set<NodeKey>;
+let activePrevNodeMap: NodeMap;
+let activeNextNodeMap: NodeMap;
+let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement>;
+let mutatedNodes: MutatedNodes;
+
+function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
+ const node = activePrevNodeMap.get(key);
+
+ if (parentDOM !== null) {
+ const dom = getPrevElementByKeyOrThrow(key);
+ if (dom.parentNode === parentDOM) {
+ parentDOM.removeChild(dom);
+ }
+ }
+
+ // This logic is really important, otherwise we will leak DOM nodes
+ // when their corresponding LexicalNodes are removed from the editor state.
+ if (!activeNextNodeMap.has(key)) {
+ activeEditor._keyToDOMMap.delete(key);
+ }
+
+ if ($isElementNode(node)) {
+ const children = createChildrenArray(node, activePrevNodeMap);
+ destroyChildren(children, 0, children.length - 1, null);
+ }
+
+ if (node !== undefined) {
+ setMutatedNode(
+ mutatedNodes,
+ activeEditorNodes,
+ activeMutationListeners,
+ node,
+ 'destroyed',
+ );
+ }
+}
+
+function destroyChildren(
+ children: Array<NodeKey>,
+ _startIndex: number,
+ endIndex: number,
+ dom: null | HTMLElement,
+): void {
+ let startIndex = _startIndex;
+
+ for (; startIndex <= endIndex; ++startIndex) {
+ const child = children[startIndex];
+
+ if (child !== undefined) {
+ destroyNode(child, dom);
+ }
+ }
+}
+
+function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void {
+ domStyle.setProperty('text-align', value);
+}
+
+const DEFAULT_INDENT_VALUE = '40px';
+
+function setElementIndent(dom: HTMLElement, indent: number): void {
+ const indentClassName = activeEditorConfig.theme.indent;
+
+ if (typeof indentClassName === 'string') {
+ const elementHasClassName = dom.classList.contains(indentClassName);
+
+ if (indent > 0 && !elementHasClassName) {
+ dom.classList.add(indentClassName);
+ } else if (indent < 1 && elementHasClassName) {
+ dom.classList.remove(indentClassName);
+ }
+ }
+
+ const indentationBaseValue =
+ getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') ||
+ DEFAULT_INDENT_VALUE;
+
+ dom.style.setProperty(
+ 'padding-inline-start',
+ indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`,
+ );
+}
+
+function setElementFormat(dom: HTMLElement, format: number): void {
+ const domStyle = dom.style;
+
+ if (format === 0) {
+ setTextAlign(domStyle, '');
+ } else if (format === IS_ALIGN_LEFT) {
+ setTextAlign(domStyle, 'left');
+ } else if (format === IS_ALIGN_CENTER) {
+ setTextAlign(domStyle, 'center');
+ } else if (format === IS_ALIGN_RIGHT) {
+ setTextAlign(domStyle, 'right');
+ } else if (format === IS_ALIGN_JUSTIFY) {
+ setTextAlign(domStyle, 'justify');
+ } else if (format === IS_ALIGN_START) {
+ setTextAlign(domStyle, 'start');
+ } else if (format === IS_ALIGN_END) {
+ setTextAlign(domStyle, 'end');
+ }
+}
+
+function $createNode(
+ key: NodeKey,
+ parentDOM: null | HTMLElement,
+ insertDOM: null | Node,
+): HTMLElement {
+ const node = activeNextNodeMap.get(key);
+
+ if (node === undefined) {
+ invariant(false, 'createNode: node does not exist in nodeMap');
+ }
+ const dom = node.createDOM(activeEditorConfig, activeEditor);
+ storeDOMWithKey(key, dom, activeEditor);
+
+ // This helps preserve the text, and stops spell check tools from
+ // merging or break the spans (which happens if they are missing
+ // this attribute).
+ if ($isTextNode(node)) {
+ dom.setAttribute('data-lexical-text', 'true');
+ } else if ($isDecoratorNode(node)) {
+ dom.setAttribute('data-lexical-decorator', 'true');
+ }
+
+ if ($isElementNode(node)) {
+ const indent = node.__indent;
+ const childrenSize = node.__size;
+
+ if (indent !== 0) {
+ setElementIndent(dom, indent);
+ }
+ if (childrenSize !== 0) {
+ const endIndex = childrenSize - 1;
+ const children = createChildrenArray(node, activeNextNodeMap);
+ $createChildrenWithDirection(children, endIndex, node, dom);
+ }
+ const format = node.__format;
+
+ if (format !== 0) {
+ setElementFormat(dom, format);
+ }
+ if (!node.isInline()) {
+ reconcileElementTerminatingLineBreak(null, node, dom);
+ }
+ if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
+ subTreeTextContent += DOUBLE_LINE_BREAK;
+ editorTextContent += DOUBLE_LINE_BREAK;
+ }
+ } else {
+ const text = node.getTextContent();
+
+ if ($isDecoratorNode(node)) {
+ const decorator = node.decorate(activeEditor, activeEditorConfig);
+
+ if (decorator !== null) {
+ reconcileDecorator(key, decorator);
+ }
+ // Decorators are always non editable
+ dom.contentEditable = 'false';
+ } else if ($isTextNode(node)) {
+ if (!node.isDirectionless()) {
+ subTreeDirectionedTextContent += text;
+ }
+ }
+ subTreeTextContent += text;
+ editorTextContent += text;
+ }
+
+ if (parentDOM !== null) {
+ if (insertDOM != null) {
+ parentDOM.insertBefore(dom, insertDOM);
+ } else {
+ // @ts-expect-error: internal field
+ const possibleLineBreak = parentDOM.__lexicalLineBreak;
+
+ if (possibleLineBreak != null) {
+ parentDOM.insertBefore(dom, possibleLineBreak);
+ } else {
+ parentDOM.appendChild(dom);
+ }
+ }
+ }
+
+ if (__DEV__) {
+ // Freeze the node in DEV to prevent accidental mutations
+ Object.freeze(node);
+ }
+
+ setMutatedNode(
+ mutatedNodes,
+ activeEditorNodes,
+ activeMutationListeners,
+ node,
+ 'created',
+ );
+ return dom;
+}
+
+function $createChildrenWithDirection(
+ children: Array<NodeKey>,
+ endIndex: number,
+ element: ElementNode,
+ dom: HTMLElement,
+): void {
+ const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent;
+ subTreeDirectionedTextContent = '';
+ $createChildren(children, element, 0, endIndex, dom, null);
+ reconcileBlockDirection(element, dom);
+ subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent;
+}
+
+function $createChildren(
+ children: Array<NodeKey>,
+ element: ElementNode,
+ _startIndex: number,
+ endIndex: number,
+ dom: null | HTMLElement,
+ insertDOM: null | HTMLElement,
+): void {
+ const previousSubTreeTextContent = subTreeTextContent;
+ subTreeTextContent = '';
+ let startIndex = _startIndex;
+
+ for (; startIndex <= endIndex; ++startIndex) {
+ $createNode(children[startIndex], dom, insertDOM);
+ const node = activeNextNodeMap.get(children[startIndex]);
+ if (node !== null && $isTextNode(node)) {
+ if (subTreeTextFormat === null) {
+ subTreeTextFormat = node.getFormat();
+ }
+ if (subTreeTextStyle === '') {
+ subTreeTextStyle = node.getStyle();
+ }
+ }
+ }
+ if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
+ subTreeTextContent += DOUBLE_LINE_BREAK;
+ }
+ // @ts-expect-error: internal field
+ dom.__lexicalTextContent = subTreeTextContent;
+ subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
+}
+
+function isLastChildLineBreakOrDecorator(
+ childKey: NodeKey,
+ nodeMap: NodeMap,
+): boolean {
+ const node = nodeMap.get(childKey);
+ return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
+}
+
+// If we end an element with a LineBreakNode, then we need to add an additional <br>
+function reconcileElementTerminatingLineBreak(
+ prevElement: null | ElementNode,
+ nextElement: ElementNode,
+ dom: HTMLElement,
+): void {
+ const prevLineBreak =
+ prevElement !== null &&
+ (prevElement.__size === 0 ||
+ isLastChildLineBreakOrDecorator(
+ prevElement.__last as NodeKey,
+ activePrevNodeMap,
+ ));
+ const nextLineBreak =
+ nextElement.__size === 0 ||
+ isLastChildLineBreakOrDecorator(
+ nextElement.__last as NodeKey,
+ activeNextNodeMap,
+ );
+
+ if (prevLineBreak) {
+ if (!nextLineBreak) {
+ // @ts-expect-error: internal field
+ const element = dom.__lexicalLineBreak;
+
+ if (element != null) {
+ try {
+ dom.removeChild(element);
+ } catch (error) {
+ if (typeof error === 'object' && error != null) {
+ const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
+ element.tagName
+ }.`;
+ throw new Error(msg);
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ // @ts-expect-error: internal field
+ dom.__lexicalLineBreak = null;
+ }
+ } else if (nextLineBreak) {
+ const element = document.createElement('br');
+ // @ts-expect-error: internal field
+ dom.__lexicalLineBreak = element;
+ dom.appendChild(element);
+ }
+}
+
+function reconcileParagraphFormat(element: ElementNode): void {
+ if (
+ $isParagraphNode(element) &&
+ subTreeTextFormat != null &&
+ subTreeTextFormat !== element.__textFormat &&
+ !activeEditorStateReadOnly
+ ) {
+ element.setTextFormat(subTreeTextFormat);
+ element.setTextStyle(subTreeTextStyle);
+ }
+}
+
+function reconcileParagraphStyle(element: ElementNode): void {
+ if (
+ $isParagraphNode(element) &&
+ subTreeTextStyle !== '' &&
+ subTreeTextStyle !== element.__textStyle &&
+ !activeEditorStateReadOnly
+ ) {
+ element.setTextStyle(subTreeTextStyle);
+ }
+}
+
+function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void {
+ const previousSubTreeDirectionTextContent: string =
+ // @ts-expect-error: internal field
+ dom.__lexicalDirTextContent;
+ // @ts-expect-error: internal field
+ const previousDirection: string = dom.__lexicalDir;
+
+ if (
+ previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent ||
+ previousDirection !== activeTextDirection
+ ) {
+ const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === '';
+ const direction = hasEmptyDirectionedTextContent
+ ? activeTextDirection
+ : getTextDirection(subTreeDirectionedTextContent);
+
+ if (direction !== previousDirection) {
+ const classList = dom.classList;
+ const theme = activeEditorConfig.theme;
+ let previousDirectionTheme =
+ previousDirection !== null ? theme[previousDirection] : undefined;
+ let nextDirectionTheme =
+ direction !== null ? theme[direction] : undefined;
+
+ // Remove the old theme classes if they exist
+ if (previousDirectionTheme !== undefined) {
+ if (typeof previousDirectionTheme === 'string') {
+ const classNamesArr = normalizeClassNames(previousDirectionTheme);
+ previousDirectionTheme = theme[previousDirection] = classNamesArr;
+ }
+
+ // @ts-ignore: intentional
+ classList.remove(...previousDirectionTheme);
+ }
+
+ if (
+ direction === null ||
+ (hasEmptyDirectionedTextContent && direction === 'ltr')
+ ) {
+ // Remove direction
+ dom.removeAttribute('dir');
+ } else {
+ // Apply the new theme classes if they exist
+ if (nextDirectionTheme !== undefined) {
+ if (typeof nextDirectionTheme === 'string') {
+ const classNamesArr = normalizeClassNames(nextDirectionTheme);
+ // @ts-expect-error: intentional
+ nextDirectionTheme = theme[direction] = classNamesArr;
+ }
+
+ if (nextDirectionTheme !== undefined) {
+ classList.add(...nextDirectionTheme);
+ }
+ }
+
+ // Update direction
+ dom.dir = direction;
+ }
+
+ if (!activeEditorStateReadOnly) {
+ const writableNode = element.getWritable();
+ writableNode.__dir = direction;
+ }
+ }
+
+ activeTextDirection = direction;
+ // @ts-expect-error: internal field
+ dom.__lexicalDirTextContent = subTreeDirectionedTextContent;
+ // @ts-expect-error: internal field
+ dom.__lexicalDir = direction;
+ }
+}
+
+function $reconcileChildrenWithDirection(
+ prevElement: ElementNode,
+ nextElement: ElementNode,
+ dom: HTMLElement,
+): void {
+ const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent;
+ subTreeDirectionedTextContent = '';
+ subTreeTextFormat = null;
+ subTreeTextStyle = '';
+ $reconcileChildren(prevElement, nextElement, dom);
+ reconcileBlockDirection(nextElement, dom);
+ reconcileParagraphFormat(nextElement);
+ reconcileParagraphStyle(nextElement);
+ subTreeDirectionedTextContent = previousSubTreeDirectionTextContent;
+}
+
+function createChildrenArray(
+ element: ElementNode,
+ nodeMap: NodeMap,
+): Array<NodeKey> {
+ const children = [];
+ let nodeKey = element.__first;
+ while (nodeKey !== null) {
+ const node = nodeMap.get(nodeKey);
+ if (node === undefined) {
+ invariant(false, 'createChildrenArray: node does not exist in nodeMap');
+ }
+ children.push(nodeKey);
+ nodeKey = node.__next;
+ }
+ return children;
+}
+
+function $reconcileChildren(
+ prevElement: ElementNode,
+ nextElement: ElementNode,
+ dom: HTMLElement,
+): void {
+ const previousSubTreeTextContent = subTreeTextContent;
+ const prevChildrenSize = prevElement.__size;
+ const nextChildrenSize = nextElement.__size;
+ subTreeTextContent = '';
+
+ if (prevChildrenSize === 1 && nextChildrenSize === 1) {
+ const prevFirstChildKey = prevElement.__first as NodeKey;
+ const nextFrstChildKey = nextElement.__first as NodeKey;
+ if (prevFirstChildKey === nextFrstChildKey) {
+ $reconcileNode(prevFirstChildKey, dom);
+ } else {
+ const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
+ const replacementDOM = $createNode(nextFrstChildKey, null, null);
+ try {
+ dom.replaceChild(replacementDOM, lastDOM);
+ } catch (error) {
+ if (typeof error === 'object' && error != null) {
+ const msg = `${error.toString()} Parent: ${
+ dom.tagName
+ }, new child: {tag: ${
+ replacementDOM.tagName
+ } key: ${nextFrstChildKey}}, old child: {tag: ${
+ lastDOM.tagName
+ }, key: ${prevFirstChildKey}}.`;
+ throw new Error(msg);
+ } else {
+ throw error;
+ }
+ }
+ destroyNode(prevFirstChildKey, null);
+ }
+ const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
+ if ($isTextNode(nextChildNode)) {
+ if (subTreeTextFormat === null) {
+ subTreeTextFormat = nextChildNode.getFormat();
+ }
+ if (subTreeTextStyle === '') {
+ subTreeTextStyle = nextChildNode.getStyle();
+ }
+ }
+ } else {
+ const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
+ const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
+
+ if (prevChildrenSize === 0) {
+ if (nextChildrenSize !== 0) {
+ $createChildren(
+ nextChildren,
+ nextElement,
+ 0,
+ nextChildrenSize - 1,
+ dom,
+ null,
+ );
+ }
+ } else if (nextChildrenSize === 0) {
+ if (prevChildrenSize !== 0) {
+ // @ts-expect-error: internal field
+ const lexicalLineBreak = dom.__lexicalLineBreak;
+ const canUseFastPath = lexicalLineBreak == null;
+ destroyChildren(
+ prevChildren,
+ 0,
+ prevChildrenSize - 1,
+ canUseFastPath ? null : dom,
+ );
+
+ if (canUseFastPath) {
+ // Fast path for removing DOM nodes
+ dom.textContent = '';
+ }
+ }
+ } else {
+ $reconcileNodeChildren(
+ nextElement,
+ prevChildren,
+ nextChildren,
+ prevChildrenSize,
+ nextChildrenSize,
+ dom,
+ );
+ }
+ }
+
+ if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
+ subTreeTextContent += DOUBLE_LINE_BREAK;
+ }
+
+ // @ts-expect-error: internal field
+ dom.__lexicalTextContent = subTreeTextContent;
+ subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
+}
+
+function $reconcileNode(
+ key: NodeKey,
+ parentDOM: HTMLElement | null,
+): HTMLElement {
+ const prevNode = activePrevNodeMap.get(key);
+ let nextNode = activeNextNodeMap.get(key);
+
+ if (prevNode === undefined || nextNode === undefined) {
+ invariant(
+ false,
+ 'reconcileNode: prevNode or nextNode does not exist in nodeMap',
+ );
+ }
+
+ const isDirty =
+ treatAllNodesAsDirty ||
+ activeDirtyLeaves.has(key) ||
+ activeDirtyElements.has(key);
+ const dom = getElementByKeyOrThrow(activeEditor, key);
+
+ // If the node key points to the same instance in both states
+ // and isn't dirty, we just update the text content cache
+ // and return the existing DOM Node.
+ if (prevNode === nextNode && !isDirty) {
+ if ($isElementNode(prevNode)) {
+ // @ts-expect-error: internal field
+ const previousSubTreeTextContent = dom.__lexicalTextContent;
+
+ if (previousSubTreeTextContent !== undefined) {
+ subTreeTextContent += previousSubTreeTextContent;
+ editorTextContent += previousSubTreeTextContent;
+ }
+
+ // @ts-expect-error: internal field
+ const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent;
+
+ if (previousSubTreeDirectionTextContent !== undefined) {
+ subTreeDirectionedTextContent += previousSubTreeDirectionTextContent;
+ }
+ } else {
+ const text = prevNode.getTextContent();
+
+ if ($isTextNode(prevNode) && !prevNode.isDirectionless()) {
+ subTreeDirectionedTextContent += text;
+ }
+
+ editorTextContent += text;
+ subTreeTextContent += text;
+ }
+
+ return dom;
+ }
+ // If the node key doesn't point to the same instance in both maps,
+ // it means it were cloned. If they're also dirty, we mark them as mutated.
+ if (prevNode !== nextNode && isDirty) {
+ setMutatedNode(
+ mutatedNodes,
+ activeEditorNodes,
+ activeMutationListeners,
+ nextNode,
+ 'updated',
+ );
+ }
+
+ // Update node. If it returns true, we need to unmount and re-create the node
+ if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
+ const replacementDOM = $createNode(key, null, null);
+
+ if (parentDOM === null) {
+ invariant(false, 'reconcileNode: parentDOM is null');
+ }
+
+ parentDOM.replaceChild(replacementDOM, dom);
+ destroyNode(key, null);
+ return replacementDOM;
+ }
+
+ if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
+ // Reconcile element children
+ const nextIndent = nextNode.__indent;
+
+ if (nextIndent !== prevNode.__indent) {
+ setElementIndent(dom, nextIndent);
+ }
+
+ const nextFormat = nextNode.__format;
+
+ if (nextFormat !== prevNode.__format) {
+ setElementFormat(dom, nextFormat);
+ }
+ if (isDirty) {
+ $reconcileChildrenWithDirection(prevNode, nextNode, dom);
+ if (!$isRootNode(nextNode) && !nextNode.isInline()) {
+ reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
+ }
+ }
+
+ if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
+ subTreeTextContent += DOUBLE_LINE_BREAK;
+ editorTextContent += DOUBLE_LINE_BREAK;
+ }
+ } else {
+ const text = nextNode.getTextContent();
+
+ if ($isDecoratorNode(nextNode)) {
+ const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
+
+ if (decorator !== null) {
+ reconcileDecorator(key, decorator);
+ }
+ } else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) {
+ // Handle text content, for LTR, LTR cases.
+ subTreeDirectionedTextContent += text;
+ }
+
+ subTreeTextContent += text;
+ editorTextContent += text;
+ }
+
+ if (
+ !activeEditorStateReadOnly &&
+ $isRootNode(nextNode) &&
+ nextNode.__cachedText !== editorTextContent
+ ) {
+ // Cache the latest text content.
+ const nextRootNode = nextNode.getWritable();
+ nextRootNode.__cachedText = editorTextContent;
+ nextNode = nextRootNode;
+ }
+
+ if (__DEV__) {
+ // Freeze the node in DEV to prevent accidental mutations
+ Object.freeze(nextNode);
+ }
+
+ return dom;
+}
+
+function reconcileDecorator(key: NodeKey, decorator: unknown): void {
+ let pendingDecorators = activeEditor._pendingDecorators;
+ const currentDecorators = activeEditor._decorators;
+
+ if (pendingDecorators === null) {
+ if (currentDecorators[key] === decorator) {
+ return;
+ }
+
+ pendingDecorators = cloneDecorators(activeEditor);
+ }
+
+ pendingDecorators[key] = decorator;
+}
+
+function getFirstChild(element: HTMLElement): Node | null {
+ return element.firstChild;
+}
+
+function getNextSibling(element: HTMLElement): Node | null {
+ let nextSibling = element.nextSibling;
+ if (
+ nextSibling !== null &&
+ nextSibling === activeEditor._blockCursorElement
+ ) {
+ nextSibling = nextSibling.nextSibling;
+ }
+ return nextSibling;
+}
+
+function $reconcileNodeChildren(
+ nextElement: ElementNode,
+ prevChildren: Array<NodeKey>,
+ nextChildren: Array<NodeKey>,
+ prevChildrenLength: number,
+ nextChildrenLength: number,
+ dom: HTMLElement,
+): void {
+ const prevEndIndex = prevChildrenLength - 1;
+ const nextEndIndex = nextChildrenLength - 1;
+ let prevChildrenSet: Set<NodeKey> | undefined;
+ let nextChildrenSet: Set<NodeKey> | undefined;
+ let siblingDOM: null | Node = getFirstChild(dom);
+ let prevIndex = 0;
+ let nextIndex = 0;
+
+ while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
+ const prevKey = prevChildren[prevIndex];
+ const nextKey = nextChildren[nextIndex];
+
+ if (prevKey === nextKey) {
+ siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
+ prevIndex++;
+ nextIndex++;
+ } else {
+ if (prevChildrenSet === undefined) {
+ prevChildrenSet = new Set(prevChildren);
+ }
+
+ if (nextChildrenSet === undefined) {
+ nextChildrenSet = new Set(nextChildren);
+ }
+
+ const nextHasPrevKey = nextChildrenSet.has(prevKey);
+ const prevHasNextKey = prevChildrenSet.has(nextKey);
+
+ if (!nextHasPrevKey) {
+ // Remove prev
+ siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
+ destroyNode(prevKey, dom);
+ prevIndex++;
+ } else if (!prevHasNextKey) {
+ // Create next
+ $createNode(nextKey, dom, siblingDOM);
+ nextIndex++;
+ } else {
+ // Move next
+ const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
+
+ if (childDOM === siblingDOM) {
+ siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
+ } else {
+ if (siblingDOM != null) {
+ dom.insertBefore(childDOM, siblingDOM);
+ } else {
+ dom.appendChild(childDOM);
+ }
+
+ $reconcileNode(nextKey, dom);
+ }
+
+ prevIndex++;
+ nextIndex++;
+ }
+ }
+
+ const node = activeNextNodeMap.get(nextKey);
+ if (node !== null && $isTextNode(node)) {
+ if (subTreeTextFormat === null) {
+ subTreeTextFormat = node.getFormat();
+ }
+ if (subTreeTextStyle === '') {
+ subTreeTextStyle = node.getStyle();
+ }
+ }
+ }
+
+ const appendNewChildren = prevIndex > prevEndIndex;
+ const removeOldChildren = nextIndex > nextEndIndex;
+
+ if (appendNewChildren && !removeOldChildren) {
+ const previousNode = nextChildren[nextEndIndex + 1];
+ const insertDOM =
+ previousNode === undefined
+ ? null
+ : activeEditor.getElementByKey(previousNode);
+ $createChildren(
+ nextChildren,
+ nextElement,
+ nextIndex,
+ nextEndIndex,
+ dom,
+ insertDOM,
+ );
+ } else if (removeOldChildren && !appendNewChildren) {
+ destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
+ }
+}
+
+export function $reconcileRoot(
+ prevEditorState: EditorState,
+ nextEditorState: EditorState,
+ editor: LexicalEditor,
+ dirtyType: 0 | 1 | 2,
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+ dirtyLeaves: Set<NodeKey>,
+): MutatedNodes {
+ // We cache text content to make retrieval more efficient.
+ // The cache must be rebuilt during reconciliation to account for any changes.
+ subTreeTextContent = '';
+ editorTextContent = '';
+ subTreeDirectionedTextContent = '';
+ // Rather than pass around a load of arguments through the stack recursively
+ // we instead set them as bindings within the scope of the module.
+ treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
+ activeTextDirection = null;
+ activeEditor = editor;
+ activeEditorConfig = editor._config;
+ activeEditorNodes = editor._nodes;
+ activeMutationListeners = activeEditor._listeners.mutation;
+ activeDirtyElements = dirtyElements;
+ activeDirtyLeaves = dirtyLeaves;
+ activePrevNodeMap = prevEditorState._nodeMap;
+ activeNextNodeMap = nextEditorState._nodeMap;
+ activeEditorStateReadOnly = nextEditorState._readOnly;
+ activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
+ // We keep track of mutated nodes so we can trigger mutation
+ // listeners later in the update cycle.
+ const currentMutatedNodes = new Map();
+ mutatedNodes = currentMutatedNodes;
+ $reconcileNode('root', null);
+ // We don't want a bunch of void checks throughout the scope
+ // so instead we make it seem that these values are always set.
+ // We also want to make sure we clear them down, otherwise we
+ // can leak memory.
+ // @ts-ignore
+ activeEditor = undefined;
+ // @ts-ignore
+ activeEditorNodes = undefined;
+ // @ts-ignore
+ activeDirtyElements = undefined;
+ // @ts-ignore
+ activeDirtyLeaves = undefined;
+ // @ts-ignore
+ activePrevNodeMap = undefined;
+ // @ts-ignore
+ activeNextNodeMap = undefined;
+ // @ts-ignore
+ activeEditorConfig = undefined;
+ // @ts-ignore
+ activePrevKeyToDOMMap = undefined;
+ // @ts-ignore
+ mutatedNodes = undefined;
+
+ return currentMutatedNodes;
+}
+
+export function storeDOMWithKey(
+ key: NodeKey,
+ dom: HTMLElement,
+ editor: LexicalEditor,
+): void {
+ const keyToDOMMap = editor._keyToDOMMap;
+ // @ts-ignore We intentionally add this to the Node.
+ dom['__lexicalKey_' + editor._key] = key;
+ keyToDOMMap.set(key, dom);
+}
+
+function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
+ const element = activePrevKeyToDOMMap.get(key);
+
+ if (element === undefined) {
+ invariant(
+ false,
+ 'Reconciliation: could not find DOM element for node key %s',
+ key,
+ );
+ }
+
+ return element;
+}
--- /dev/null
+/**
+ * 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 './LexicalEditor';
+import type {EditorState} from './LexicalEditorState';
+import type {NodeKey} from './LexicalNode';
+import type {ElementNode} from './nodes/LexicalElementNode';
+import type {TextFormatType} from './nodes/LexicalTextNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $createLineBreakNode,
+ $createParagraphNode,
+ $createTextNode,
+ $isDecoratorNode,
+ $isElementNode,
+ $isLineBreakNode,
+ $isRootNode,
+ $isTextNode,
+ $setSelection,
+ SELECTION_CHANGE_COMMAND,
+ TextNode,
+} from '.';
+import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
+import {
+ markCollapsedSelectionFormat,
+ markSelectionChangeFromDOMUpdate,
+} from './LexicalEvents';
+import {getIsProcessingMutations} from './LexicalMutations';
+import {insertRangeAfter, LexicalNode} from './LexicalNode';
+import {
+ getActiveEditor,
+ getActiveEditorState,
+ isCurrentlyReadOnlyMode,
+} from './LexicalUpdates';
+import {
+ $getAdjacentNode,
+ $getAncestor,
+ $getCompositionKey,
+ $getNearestRootOrShadowRoot,
+ $getNodeByKey,
+ $getNodeFromDOM,
+ $getRoot,
+ $hasAncestor,
+ $isTokenOrSegmented,
+ $setCompositionKey,
+ doesContainGrapheme,
+ getDOMSelection,
+ getDOMTextNode,
+ getElementByKeyOrThrow,
+ getTextNodeOffset,
+ INTERNAL_$isBlock,
+ isSelectionCapturedInDecoratorInput,
+ isSelectionWithinEditor,
+ removeDOMBlockCursorElement,
+ scrollIntoViewIfNeeded,
+ toggleTextFormatType,
+} from './LexicalUtils';
+import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
+
+export type TextPointType = {
+ _selection: BaseSelection;
+ getNode: () => TextNode;
+ is: (point: PointType) => boolean;
+ isBefore: (point: PointType) => boolean;
+ key: NodeKey;
+ offset: number;
+ set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
+ type: 'text';
+};
+
+export type ElementPointType = {
+ _selection: BaseSelection;
+ getNode: () => ElementNode;
+ is: (point: PointType) => boolean;
+ isBefore: (point: PointType) => boolean;
+ key: NodeKey;
+ offset: number;
+ set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
+ type: 'element';
+};
+
+export type PointType = TextPointType | ElementPointType;
+
+export class Point {
+ key: NodeKey;
+ offset: number;
+ type: 'text' | 'element';
+ _selection: BaseSelection | null;
+
+ constructor(key: NodeKey, offset: number, type: 'text' | 'element') {
+ this._selection = null;
+ this.key = key;
+ this.offset = offset;
+ this.type = type;
+ }
+
+ is(point: PointType): boolean {
+ return (
+ this.key === point.key &&
+ this.offset === point.offset &&
+ this.type === point.type
+ );
+ }
+
+ isBefore(b: PointType): boolean {
+ let aNode = this.getNode();
+ let bNode = b.getNode();
+ const aOffset = this.offset;
+ const bOffset = b.offset;
+
+ if ($isElementNode(aNode)) {
+ const aNodeDescendant = aNode.getDescendantByIndex<ElementNode>(aOffset);
+ aNode = aNodeDescendant != null ? aNodeDescendant : aNode;
+ }
+ if ($isElementNode(bNode)) {
+ const bNodeDescendant = bNode.getDescendantByIndex<ElementNode>(bOffset);
+ bNode = bNodeDescendant != null ? bNodeDescendant : bNode;
+ }
+ if (aNode === bNode) {
+ return aOffset < bOffset;
+ }
+ return aNode.isBefore(bNode);
+ }
+
+ getNode(): LexicalNode {
+ const key = this.key;
+ const node = $getNodeByKey(key);
+ if (node === null) {
+ invariant(false, 'Point.getNode: node not found');
+ }
+ return node;
+ }
+
+ set(key: NodeKey, offset: number, type: 'text' | 'element'): void {
+ const selection = this._selection;
+ const oldKey = this.key;
+ this.key = key;
+ this.offset = offset;
+ this.type = type;
+ if (!isCurrentlyReadOnlyMode()) {
+ if ($getCompositionKey() === oldKey) {
+ $setCompositionKey(key);
+ }
+ if (selection !== null) {
+ selection.setCachedNodes(null);
+ selection.dirty = true;
+ }
+ }
+ }
+}
+
+export function $createPoint(
+ key: NodeKey,
+ offset: number,
+ type: 'text' | 'element',
+): PointType {
+ // @ts-expect-error: intentionally cast as we use a class for perf reasons
+ return new Point(key, offset, type);
+}
+
+function selectPointOnNode(point: PointType, node: LexicalNode): void {
+ let key = node.__key;
+ let offset = point.offset;
+ let type: 'element' | 'text' = 'element';
+ if ($isTextNode(node)) {
+ type = 'text';
+ const textContentLength = node.getTextContentSize();
+ if (offset > textContentLength) {
+ offset = textContentLength;
+ }
+ } else if (!$isElementNode(node)) {
+ const nextSibling = node.getNextSibling();
+ if ($isTextNode(nextSibling)) {
+ key = nextSibling.__key;
+ offset = 0;
+ type = 'text';
+ } else {
+ const parentNode = node.getParent();
+ if (parentNode) {
+ key = parentNode.__key;
+ offset = node.getIndexWithinParent() + 1;
+ }
+ }
+ }
+ point.set(key, offset, type);
+}
+
+export function $moveSelectionPointToEnd(
+ point: PointType,
+ node: LexicalNode,
+): void {
+ if ($isElementNode(node)) {
+ const lastNode = node.getLastDescendant();
+ if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
+ selectPointOnNode(point, lastNode);
+ } else {
+ selectPointOnNode(point, node);
+ }
+ } else {
+ selectPointOnNode(point, node);
+ }
+}
+
+function $transferStartingElementPointToTextPoint(
+ start: ElementPointType,
+ end: PointType,
+ format: number,
+ style: string,
+): void {
+ const element = start.getNode();
+ const placementNode = element.getChildAtIndex(start.offset);
+ const textNode = $createTextNode();
+ const target = $isRootNode(element)
+ ? $createParagraphNode().append(textNode)
+ : textNode;
+ textNode.setFormat(format);
+ textNode.setStyle(style);
+ if (placementNode === null) {
+ element.append(target);
+ } else {
+ placementNode.insertBefore(target);
+ }
+ // Transfer the element point to a text point.
+ if (start.is(end)) {
+ end.set(textNode.__key, 0, 'text');
+ }
+ start.set(textNode.__key, 0, 'text');
+}
+
+function $setPointValues(
+ point: PointType,
+ key: NodeKey,
+ offset: number,
+ type: 'text' | 'element',
+): void {
+ point.key = key;
+ point.offset = offset;
+ point.type = type;
+}
+
+export interface BaseSelection {
+ _cachedNodes: Array<LexicalNode> | null;
+ dirty: boolean;
+
+ clone(): BaseSelection;
+ extract(): Array<LexicalNode>;
+ getNodes(): Array<LexicalNode>;
+ getTextContent(): string;
+ insertText(text: string): void;
+ insertRawText(text: string): void;
+ is(selection: null | BaseSelection): boolean;
+ insertNodes(nodes: Array<LexicalNode>): void;
+ getStartEndPoints(): null | [PointType, PointType];
+ isCollapsed(): boolean;
+ isBackward(): boolean;
+ getCachedNodes(): LexicalNode[] | null;
+ setCachedNodes(nodes: LexicalNode[] | null): void;
+}
+
+export class NodeSelection implements BaseSelection {
+ _nodes: Set<NodeKey>;
+ _cachedNodes: Array<LexicalNode> | null;
+ dirty: boolean;
+
+ constructor(objects: Set<NodeKey>) {
+ this._cachedNodes = null;
+ this._nodes = objects;
+ this.dirty = false;
+ }
+
+ getCachedNodes(): LexicalNode[] | null {
+ return this._cachedNodes;
+ }
+
+ setCachedNodes(nodes: LexicalNode[] | null): void {
+ this._cachedNodes = nodes;
+ }
+
+ is(selection: null | BaseSelection): boolean {
+ if (!$isNodeSelection(selection)) {
+ return false;
+ }
+ const a: Set<NodeKey> = this._nodes;
+ const b: Set<NodeKey> = selection._nodes;
+ return a.size === b.size && Array.from(a).every((key) => b.has(key));
+ }
+
+ isCollapsed(): boolean {
+ return false;
+ }
+
+ isBackward(): boolean {
+ return false;
+ }
+
+ getStartEndPoints(): null {
+ return null;
+ }
+
+ add(key: NodeKey): void {
+ this.dirty = true;
+ this._nodes.add(key);
+ this._cachedNodes = null;
+ }
+
+ delete(key: NodeKey): void {
+ this.dirty = true;
+ this._nodes.delete(key);
+ this._cachedNodes = null;
+ }
+
+ clear(): void {
+ this.dirty = true;
+ this._nodes.clear();
+ this._cachedNodes = null;
+ }
+
+ has(key: NodeKey): boolean {
+ return this._nodes.has(key);
+ }
+
+ clone(): NodeSelection {
+ return new NodeSelection(new Set(this._nodes));
+ }
+
+ extract(): Array<LexicalNode> {
+ return this.getNodes();
+ }
+
+ insertRawText(text: string): void {
+ // Do nothing?
+ }
+
+ insertText(): void {
+ // Do nothing?
+ }
+
+ insertNodes(nodes: Array<LexicalNode>) {
+ const selectedNodes = this.getNodes();
+ const selectedNodesLength = selectedNodes.length;
+ const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
+ let selectionAtEnd: RangeSelection;
+ // Insert nodes
+ if ($isTextNode(lastSelectedNode)) {
+ selectionAtEnd = lastSelectedNode.select();
+ } else {
+ const index = lastSelectedNode.getIndexWithinParent() + 1;
+ selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
+ }
+ selectionAtEnd.insertNodes(nodes);
+ // Remove selected nodes
+ for (let i = 0; i < selectedNodesLength; i++) {
+ selectedNodes[i].remove();
+ }
+ }
+
+ getNodes(): Array<LexicalNode> {
+ const cachedNodes = this._cachedNodes;
+ if (cachedNodes !== null) {
+ return cachedNodes;
+ }
+ const objects = this._nodes;
+ const nodes = [];
+ for (const object of objects) {
+ const node = $getNodeByKey(object);
+ if (node !== null) {
+ nodes.push(node);
+ }
+ }
+ if (!isCurrentlyReadOnlyMode()) {
+ this._cachedNodes = nodes;
+ }
+ return nodes;
+ }
+
+ getTextContent(): string {
+ const nodes = this.getNodes();
+ let textContent = '';
+ for (let i = 0; i < nodes.length; i++) {
+ textContent += nodes[i].getTextContent();
+ }
+ return textContent;
+ }
+}
+
+export function $isRangeSelection(x: unknown): x is RangeSelection {
+ return x instanceof RangeSelection;
+}
+
+export class RangeSelection implements BaseSelection {
+ format: number;
+ style: string;
+ anchor: PointType;
+ focus: PointType;
+ _cachedNodes: Array<LexicalNode> | null;
+ dirty: boolean;
+
+ constructor(
+ anchor: PointType,
+ focus: PointType,
+ format: number,
+ style: string,
+ ) {
+ this.anchor = anchor;
+ this.focus = focus;
+ anchor._selection = this;
+ focus._selection = this;
+ this._cachedNodes = null;
+ this.format = format;
+ this.style = style;
+ this.dirty = false;
+ }
+
+ getCachedNodes(): LexicalNode[] | null {
+ return this._cachedNodes;
+ }
+
+ setCachedNodes(nodes: LexicalNode[] | null): void {
+ this._cachedNodes = nodes;
+ }
+
+ /**
+ * Used to check if the provided selections is equal to this one by value,
+ * inluding anchor, focus, format, and style properties.
+ * @param selection - the Selection to compare this one to.
+ * @returns true if the Selections are equal, false otherwise.
+ */
+ is(selection: null | BaseSelection): boolean {
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ return (
+ this.anchor.is(selection.anchor) &&
+ this.focus.is(selection.focus) &&
+ this.format === selection.format &&
+ this.style === selection.style
+ );
+ }
+
+ /**
+ * Returns whether the Selection is "collapsed", meaning the anchor and focus are
+ * the same node and have the same offset.
+ *
+ * @returns true if the Selection is collapsed, false otherwise.
+ */
+ isCollapsed(): boolean {
+ return this.anchor.is(this.focus);
+ }
+
+ /**
+ * Gets all the nodes in the Selection. Uses caching to make it generally suitable
+ * for use in hot paths.
+ *
+ * @returns an Array containing all the nodes in the Selection
+ */
+ getNodes(): Array<LexicalNode> {
+ const cachedNodes = this._cachedNodes;
+ if (cachedNodes !== null) {
+ return cachedNodes;
+ }
+ const anchor = this.anchor;
+ const focus = this.focus;
+ const isBefore = anchor.isBefore(focus);
+ const firstPoint = isBefore ? anchor : focus;
+ const lastPoint = isBefore ? focus : anchor;
+ let firstNode = firstPoint.getNode();
+ let lastNode = lastPoint.getNode();
+ const startOffset = firstPoint.offset;
+ const endOffset = lastPoint.offset;
+
+ if ($isElementNode(firstNode)) {
+ const firstNodeDescendant =
+ firstNode.getDescendantByIndex<ElementNode>(startOffset);
+ firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
+ }
+ if ($isElementNode(lastNode)) {
+ let lastNodeDescendant =
+ lastNode.getDescendantByIndex<ElementNode>(endOffset);
+ // We don't want to over-select, as node selection infers the child before
+ // the last descendant, not including that descendant.
+ if (
+ lastNodeDescendant !== null &&
+ lastNodeDescendant !== firstNode &&
+ lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
+ ) {
+ lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
+ }
+ lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
+ }
+
+ let nodes: Array<LexicalNode>;
+
+ if (firstNode.is(lastNode)) {
+ if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
+ nodes = [];
+ } else {
+ nodes = [firstNode];
+ }
+ } else {
+ nodes = firstNode.getNodesBetween(lastNode);
+ }
+ if (!isCurrentlyReadOnlyMode()) {
+ this._cachedNodes = nodes;
+ }
+ return nodes;
+ }
+
+ /**
+ * Sets this Selection to be of type "text" at the provided anchor and focus values.
+ *
+ * @param anchorNode - the anchor node to set on the Selection
+ * @param anchorOffset - the offset to set on the Selection
+ * @param focusNode - the focus node to set on the Selection
+ * @param focusOffset - the focus offset to set on the Selection
+ */
+ setTextNodeRange(
+ anchorNode: TextNode,
+ anchorOffset: number,
+ focusNode: TextNode,
+ focusOffset: number,
+ ): void {
+ $setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text');
+ $setPointValues(this.focus, focusNode.__key, focusOffset, 'text');
+ this._cachedNodes = null;
+ this.dirty = true;
+ }
+
+ /**
+ * Gets the (plain) text content of all the nodes in the selection.
+ *
+ * @returns a string representing the text content of all the nodes in the Selection
+ */
+ getTextContent(): string {
+ const nodes = this.getNodes();
+ if (nodes.length === 0) {
+ return '';
+ }
+ const firstNode = nodes[0];
+ const lastNode = nodes[nodes.length - 1];
+ const anchor = this.anchor;
+ const focus = this.focus;
+ const isBefore = anchor.isBefore(focus);
+ const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
+ let textContent = '';
+ let prevWasElement = true;
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ if ($isElementNode(node) && !node.isInline()) {
+ if (!prevWasElement) {
+ textContent += '\n';
+ }
+ if (node.isEmpty()) {
+ prevWasElement = false;
+ } else {
+ prevWasElement = true;
+ }
+ } else {
+ prevWasElement = false;
+ if ($isTextNode(node)) {
+ let text = node.getTextContent();
+ if (node === firstNode) {
+ if (node === lastNode) {
+ if (
+ anchor.type !== 'element' ||
+ focus.type !== 'element' ||
+ focus.offset === anchor.offset
+ ) {
+ text =
+ anchorOffset < focusOffset
+ ? text.slice(anchorOffset, focusOffset)
+ : text.slice(focusOffset, anchorOffset);
+ }
+ } else {
+ text = isBefore
+ ? text.slice(anchorOffset)
+ : text.slice(focusOffset);
+ }
+ } else if (node === lastNode) {
+ text = isBefore
+ ? text.slice(0, focusOffset)
+ : text.slice(0, anchorOffset);
+ }
+ textContent += text;
+ } else if (
+ ($isDecoratorNode(node) || $isLineBreakNode(node)) &&
+ (node !== lastNode || !this.isCollapsed())
+ ) {
+ textContent += node.getTextContent();
+ }
+ }
+ }
+ return textContent;
+ }
+
+ /**
+ * Attempts to map a DOM selection range onto this Lexical Selection,
+ * setting the anchor, focus, and type accordingly
+ *
+ * @param range a DOM Selection range conforming to the StaticRange interface.
+ */
+ applyDOMRange(range: StaticRange): void {
+ const editor = getActiveEditor();
+ const currentEditorState = editor.getEditorState();
+ const lastSelection = currentEditorState._selection;
+ const resolvedSelectionPoints = $internalResolveSelectionPoints(
+ range.startContainer,
+ range.startOffset,
+ range.endContainer,
+ range.endOffset,
+ editor,
+ lastSelection,
+ );
+ if (resolvedSelectionPoints === null) {
+ return;
+ }
+ const [anchorPoint, focusPoint] = resolvedSelectionPoints;
+ $setPointValues(
+ this.anchor,
+ anchorPoint.key,
+ anchorPoint.offset,
+ anchorPoint.type,
+ );
+ $setPointValues(
+ this.focus,
+ focusPoint.key,
+ focusPoint.offset,
+ focusPoint.type,
+ );
+ this._cachedNodes = null;
+ }
+
+ /**
+ * Creates a new RangeSelection, copying over all the property values from this one.
+ *
+ * @returns a new RangeSelection with the same property values as this one.
+ */
+ clone(): RangeSelection {
+ const anchor = this.anchor;
+ const focus = this.focus;
+ const selection = new RangeSelection(
+ $createPoint(anchor.key, anchor.offset, anchor.type),
+ $createPoint(focus.key, focus.offset, focus.type),
+ this.format,
+ this.style,
+ );
+ return selection;
+ }
+
+ /**
+ * Toggles the provided format on all the TextNodes in the Selection.
+ *
+ * @param format a string TextFormatType to toggle on the TextNodes in the selection
+ */
+ toggleFormat(format: TextFormatType): void {
+ this.format = toggleTextFormatType(this.format, format, null);
+ this.dirty = true;
+ }
+
+ /**
+ * Sets the value of the style property on the Selection
+ *
+ * @param style - the style to set at the value of the style property.
+ */
+ setStyle(style: string): void {
+ this.style = style;
+ this.dirty = true;
+ }
+
+ /**
+ * Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection
+ * has the specified format.
+ *
+ * @param type the TextFormatType to check for.
+ * @returns true if the provided format is currently toggled on on the Selection, false otherwise.
+ */
+ hasFormat(type: TextFormatType): boolean {
+ const formatFlag = TEXT_TYPE_TO_FORMAT[type];
+ return (this.format & formatFlag) !== 0;
+ }
+
+ /**
+ * Attempts to insert the provided text into the EditorState at the current Selection.
+ * converts tabs, newlines, and carriage returns into LexicalNodes.
+ *
+ * @param text the text to insert into the Selection
+ */
+ insertRawText(text: string): void {
+ const parts = text.split(/(\r?\n|\t)/);
+ const nodes = [];
+ const length = parts.length;
+ for (let i = 0; i < length; i++) {
+ const part = parts[i];
+ if (part === '\n' || part === '\r\n') {
+ nodes.push($createLineBreakNode());
+ } else if (part === '\t') {
+ nodes.push($createTabNode());
+ } else {
+ nodes.push($createTextNode(part));
+ }
+ }
+ this.insertNodes(nodes);
+ }
+
+ /**
+ * Attempts to insert the provided text into the EditorState at the current Selection as a new
+ * Lexical TextNode, according to a series of insertion heuristics based on the selection type and position.
+ *
+ * @param text the text to insert into the Selection
+ */
+ insertText(text: string): void {
+ const anchor = this.anchor;
+ const focus = this.focus;
+ const format = this.format;
+ const style = this.style;
+ let firstPoint = anchor;
+ let endPoint = focus;
+ if (!this.isCollapsed() && focus.isBefore(anchor)) {
+ firstPoint = focus;
+ endPoint = anchor;
+ }
+ if (firstPoint.type === 'element') {
+ $transferStartingElementPointToTextPoint(
+ firstPoint,
+ endPoint,
+ format,
+ style,
+ );
+ }
+ const startOffset = firstPoint.offset;
+ let endOffset = endPoint.offset;
+ const selectedNodes = this.getNodes();
+ const selectedNodesLength = selectedNodes.length;
+ let firstNode: TextNode = selectedNodes[0] as TextNode;
+
+ if (!$isTextNode(firstNode)) {
+ invariant(false, 'insertText: first node is not a text node');
+ }
+ const firstNodeText = firstNode.getTextContent();
+ const firstNodeTextLength = firstNodeText.length;
+ const firstNodeParent = firstNode.getParentOrThrow();
+ const lastIndex = selectedNodesLength - 1;
+ let lastNode = selectedNodes[lastIndex];
+
+ if (selectedNodesLength === 1 && endPoint.type === 'element') {
+ endOffset = firstNodeTextLength;
+ endPoint.set(firstPoint.key, endOffset, 'text');
+ }
+
+ if (
+ this.isCollapsed() &&
+ startOffset === firstNodeTextLength &&
+ (firstNode.isSegmented() ||
+ firstNode.isToken() ||
+ !firstNode.canInsertTextAfter() ||
+ (!firstNodeParent.canInsertTextAfter() &&
+ firstNode.getNextSibling() === null))
+ ) {
+ let nextSibling = firstNode.getNextSibling<TextNode>();
+ if (
+ !$isTextNode(nextSibling) ||
+ !nextSibling.canInsertTextBefore() ||
+ $isTokenOrSegmented(nextSibling)
+ ) {
+ nextSibling = $createTextNode();
+ nextSibling.setFormat(format);
+ nextSibling.setStyle(style);
+ if (!firstNodeParent.canInsertTextAfter()) {
+ firstNodeParent.insertAfter(nextSibling);
+ } else {
+ firstNode.insertAfter(nextSibling);
+ }
+ }
+ nextSibling.select(0, 0);
+ firstNode = nextSibling;
+ if (text !== '') {
+ this.insertText(text);
+ return;
+ }
+ } else if (
+ this.isCollapsed() &&
+ startOffset === 0 &&
+ (firstNode.isSegmented() ||
+ firstNode.isToken() ||
+ !firstNode.canInsertTextBefore() ||
+ (!firstNodeParent.canInsertTextBefore() &&
+ firstNode.getPreviousSibling() === null))
+ ) {
+ let prevSibling = firstNode.getPreviousSibling<TextNode>();
+ if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) {
+ prevSibling = $createTextNode();
+ prevSibling.setFormat(format);
+ if (!firstNodeParent.canInsertTextBefore()) {
+ firstNodeParent.insertBefore(prevSibling);
+ } else {
+ firstNode.insertBefore(prevSibling);
+ }
+ }
+ prevSibling.select();
+ firstNode = prevSibling;
+ if (text !== '') {
+ this.insertText(text);
+ return;
+ }
+ } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
+ const textNode = $createTextNode(firstNode.getTextContent());
+ textNode.setFormat(format);
+ firstNode.replace(textNode);
+ firstNode = textNode;
+ } else if (!this.isCollapsed() && text !== '') {
+ // When the firstNode or lastNode parents are elements that
+ // do not allow text to be inserted before or after, we first
+ // clear the content. Then we normalize selection, then insert
+ // the new content.
+ const lastNodeParent = lastNode.getParent();
+
+ if (
+ !firstNodeParent.canInsertTextBefore() ||
+ !firstNodeParent.canInsertTextAfter() ||
+ ($isElementNode(lastNodeParent) &&
+ (!lastNodeParent.canInsertTextBefore() ||
+ !lastNodeParent.canInsertTextAfter()))
+ ) {
+ this.insertText('');
+ $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
+ this.insertText(text);
+ return;
+ }
+ }
+
+ if (selectedNodesLength === 1) {
+ if (firstNode.isToken()) {
+ const textNode = $createTextNode(text);
+ textNode.select();
+ firstNode.replace(textNode);
+ return;
+ }
+ const firstNodeFormat = firstNode.getFormat();
+ const firstNodeStyle = firstNode.getStyle();
+
+ if (
+ startOffset === endOffset &&
+ (firstNodeFormat !== format || firstNodeStyle !== style)
+ ) {
+ if (firstNode.getTextContent() === '') {
+ firstNode.setFormat(format);
+ firstNode.setStyle(style);
+ } else {
+ const textNode = $createTextNode(text);
+ textNode.setFormat(format);
+ textNode.setStyle(style);
+ textNode.select();
+ if (startOffset === 0) {
+ firstNode.insertBefore(textNode, false);
+ } else {
+ const [targetNode] = firstNode.splitText(startOffset);
+ targetNode.insertAfter(textNode, false);
+ }
+ // When composing, we need to adjust the anchor offset so that
+ // we correctly replace that right range.
+ if (textNode.isComposing() && this.anchor.type === 'text') {
+ this.anchor.offset -= text.length;
+ }
+ return;
+ }
+ } else if ($isTabNode(firstNode)) {
+ // We don't need to check for delCount because there is only the entire selected node case
+ // that can hit here for content size 1 and with canInsertTextBeforeAfter false
+ const textNode = $createTextNode(text);
+ textNode.setFormat(format);
+ textNode.setStyle(style);
+ textNode.select();
+ firstNode.replace(textNode);
+ return;
+ }
+ const delCount = endOffset - startOffset;
+
+ firstNode = firstNode.spliceText(startOffset, delCount, text, true);
+ if (firstNode.getTextContent() === '') {
+ firstNode.remove();
+ } else if (this.anchor.type === 'text') {
+ if (firstNode.isComposing()) {
+ // When composing, we need to adjust the anchor offset so that
+ // we correctly replace that right range.
+ this.anchor.offset -= text.length;
+ } else {
+ this.format = firstNodeFormat;
+ this.style = firstNodeStyle;
+ }
+ }
+ } else {
+ const markedNodeKeysForKeep = new Set([
+ ...firstNode.getParentKeys(),
+ ...lastNode.getParentKeys(),
+ ]);
+
+ // We have to get the parent elements before the next section,
+ // as in that section we might mutate the lastNode.
+ const firstElement = $isElementNode(firstNode)
+ ? firstNode
+ : firstNode.getParentOrThrow();
+ let lastElement = $isElementNode(lastNode)
+ ? lastNode
+ : lastNode.getParentOrThrow();
+ let lastElementChild = lastNode;
+
+ // If the last element is inline, we should instead look at getting
+ // the nodes of its parent, rather than itself. This behavior will
+ // then better match how text node insertions work. We will need to
+ // also update the last element's child accordingly as we do this.
+ if (!firstElement.is(lastElement) && lastElement.isInline()) {
+ // Keep traversing till we have a non-inline element parent.
+ do {
+ lastElementChild = lastElement;
+ lastElement = lastElement.getParentOrThrow();
+ } while (lastElement.isInline());
+ }
+
+ // Handle mutations to the last node.
+ if (
+ (endPoint.type === 'text' &&
+ (endOffset !== 0 || lastNode.getTextContent() === '')) ||
+ (endPoint.type === 'element' &&
+ lastNode.getIndexWithinParent() < endOffset)
+ ) {
+ if (
+ $isTextNode(lastNode) &&
+ !lastNode.isToken() &&
+ endOffset !== lastNode.getTextContentSize()
+ ) {
+ if (lastNode.isSegmented()) {
+ const textNode = $createTextNode(lastNode.getTextContent());
+ lastNode.replace(textNode);
+ lastNode = textNode;
+ }
+ // root node selections only select whole nodes, so no text splice is necessary
+ if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
+ lastNode = (lastNode as TextNode).spliceText(0, endOffset, '');
+ }
+ markedNodeKeysForKeep.add(lastNode.__key);
+ } else {
+ const lastNodeParent = lastNode.getParentOrThrow();
+ if (
+ !lastNodeParent.canBeEmpty() &&
+ lastNodeParent.getChildrenSize() === 1
+ ) {
+ lastNodeParent.remove();
+ } else {
+ lastNode.remove();
+ }
+ }
+ } else {
+ markedNodeKeysForKeep.add(lastNode.__key);
+ }
+
+ // Either move the remaining nodes of the last parent to after
+ // the first child, or remove them entirely. If the last parent
+ // is the same as the first parent, this logic also works.
+ const lastNodeChildren = lastElement.getChildren();
+ const selectedNodesSet = new Set(selectedNodes);
+ const firstAndLastElementsAreEqual = firstElement.is(lastElement);
+
+ // We choose a target to insert all nodes after. In the case of having
+ // and inline starting parent element with a starting node that has no
+ // siblings, we should insert after the starting parent element, otherwise
+ // we will incorrectly merge into the starting parent element.
+ // TODO: should we keep on traversing parents if we're inside another
+ // nested inline element?
+ const insertionTarget =
+ firstElement.isInline() && firstNode.getNextSibling() === null
+ ? firstElement
+ : firstNode;
+
+ for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
+ const lastNodeChild = lastNodeChildren[i];
+
+ if (
+ lastNodeChild.is(firstNode) ||
+ ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode))
+ ) {
+ break;
+ }
+
+ if (lastNodeChild.isAttached()) {
+ if (
+ !selectedNodesSet.has(lastNodeChild) ||
+ lastNodeChild.is(lastElementChild)
+ ) {
+ if (!firstAndLastElementsAreEqual) {
+ insertionTarget.insertAfter(lastNodeChild, false);
+ }
+ } else {
+ lastNodeChild.remove();
+ }
+ }
+ }
+
+ if (!firstAndLastElementsAreEqual) {
+ // Check if we have already moved out all the nodes of the
+ // last parent, and if so, traverse the parent tree and mark
+ // them all as being able to deleted too.
+ let parent: ElementNode | null = lastElement;
+ let lastRemovedParent = null;
+
+ while (parent !== null) {
+ const children = parent.getChildren();
+ const childrenLength = children.length;
+ if (
+ childrenLength === 0 ||
+ children[childrenLength - 1].is(lastRemovedParent)
+ ) {
+ markedNodeKeysForKeep.delete(parent.__key);
+ lastRemovedParent = parent;
+ }
+ parent = parent.getParent();
+ }
+ }
+
+ // Ensure we do splicing after moving of nodes, as splicing
+ // can have side-effects (in the case of hashtags).
+ if (!firstNode.isToken()) {
+ firstNode = firstNode.spliceText(
+ startOffset,
+ firstNodeTextLength - startOffset,
+ text,
+ true,
+ );
+ if (firstNode.getTextContent() === '') {
+ firstNode.remove();
+ } else if (firstNode.isComposing() && this.anchor.type === 'text') {
+ // When composing, we need to adjust the anchor offset so that
+ // we correctly replace that right range.
+ this.anchor.offset -= text.length;
+ }
+ } else if (startOffset === firstNodeTextLength) {
+ firstNode.select();
+ } else {
+ const textNode = $createTextNode(text);
+ textNode.select();
+ firstNode.replace(textNode);
+ }
+
+ // Remove all selected nodes that haven't already been removed.
+ for (let i = 1; i < selectedNodesLength; i++) {
+ const selectedNode = selectedNodes[i];
+ const key = selectedNode.__key;
+ if (!markedNodeKeysForKeep.has(key)) {
+ selectedNode.remove();
+ }
+ }
+ }
+ }
+
+ /**
+ * Removes the text in the Selection, adjusting the EditorState accordingly.
+ */
+ removeText(): void {
+ this.insertText('');
+ }
+
+ /**
+ * Applies the provided format to the TextNodes in the Selection, splitting or
+ * merging nodes as necessary.
+ *
+ * @param formatType the format type to apply to the nodes in the Selection.
+ */
+ formatText(formatType: TextFormatType): void {
+ if (this.isCollapsed()) {
+ this.toggleFormat(formatType);
+ // When changing format, we should stop composition
+ $setCompositionKey(null);
+ return;
+ }
+
+ const selectedNodes = this.getNodes();
+ const selectedTextNodes: Array<TextNode> = [];
+ for (const selectedNode of selectedNodes) {
+ if ($isTextNode(selectedNode)) {
+ selectedTextNodes.push(selectedNode);
+ }
+ }
+
+ const selectedTextNodesLength = selectedTextNodes.length;
+ if (selectedTextNodesLength === 0) {
+ this.toggleFormat(formatType);
+ // When changing format, we should stop composition
+ $setCompositionKey(null);
+ return;
+ }
+
+ const anchor = this.anchor;
+ const focus = this.focus;
+ const isBackward = this.isBackward();
+ const startPoint = isBackward ? focus : anchor;
+ const endPoint = isBackward ? anchor : focus;
+
+ let firstIndex = 0;
+ let firstNode = selectedTextNodes[0];
+ let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;
+
+ // In case selection started at the end of text node use next text node
+ if (
+ startPoint.type === 'text' &&
+ startOffset === firstNode.getTextContentSize()
+ ) {
+ firstIndex = 1;
+ firstNode = selectedTextNodes[1];
+ startOffset = 0;
+ }
+
+ if (firstNode == null) {
+ return;
+ }
+
+ const firstNextFormat = firstNode.getFormatFlags(formatType, null);
+
+ const lastIndex = selectedTextNodesLength - 1;
+ let lastNode = selectedTextNodes[lastIndex];
+ const endOffset =
+ endPoint.type === 'text'
+ ? endPoint.offset
+ : lastNode.getTextContentSize();
+
+ // Single node selected
+ if (firstNode.is(lastNode)) {
+ // No actual text is selected, so do nothing.
+ if (startOffset === endOffset) {
+ return;
+ }
+ // The entire node is selected or it is token, so just format it
+ if (
+ $isTokenOrSegmented(firstNode) ||
+ (startOffset === 0 && endOffset === firstNode.getTextContentSize())
+ ) {
+ firstNode.setFormat(firstNextFormat);
+ } else {
+ // Node is partially selected, so split it into two nodes
+ // add style the selected one.
+ const splitNodes = firstNode.splitText(startOffset, endOffset);
+ const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
+ replacement.setFormat(firstNextFormat);
+
+ // Update selection only if starts/ends on text node
+ if (startPoint.type === 'text') {
+ startPoint.set(replacement.__key, 0, 'text');
+ }
+ if (endPoint.type === 'text') {
+ endPoint.set(replacement.__key, endOffset - startOffset, 'text');
+ }
+ }
+
+ this.format = firstNextFormat;
+
+ return;
+ }
+ // Multiple nodes selected
+ // The entire first node isn't selected, so split it
+ if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
+ [, firstNode as TextNode] = firstNode.splitText(startOffset);
+ startOffset = 0;
+ }
+ firstNode.setFormat(firstNextFormat);
+
+ const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat);
+ // If the offset is 0, it means no actual characters are selected,
+ // so we skip formatting the last node altogether.
+ if (endOffset > 0) {
+ if (
+ endOffset !== lastNode.getTextContentSize() &&
+ !$isTokenOrSegmented(lastNode)
+ ) {
+ [lastNode as TextNode] = lastNode.splitText(endOffset);
+ }
+ lastNode.setFormat(lastNextFormat);
+ }
+
+ // Process all text nodes in between
+ for (let i = firstIndex + 1; i < lastIndex; i++) {
+ const textNode = selectedTextNodes[i];
+ const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat);
+ textNode.setFormat(nextFormat);
+ }
+
+ // Update selection only if starts/ends on text node
+ if (startPoint.type === 'text') {
+ startPoint.set(firstNode.__key, startOffset, 'text');
+ }
+ if (endPoint.type === 'text') {
+ endPoint.set(lastNode.__key, endOffset, 'text');
+ }
+
+ this.format = firstNextFormat | lastNextFormat;
+ }
+
+ /**
+ * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
+ * current Selection according to a set of heuristics that determine how surrounding nodes
+ * should be changed, replaced, or moved to accomodate the incoming ones.
+ *
+ * @param nodes - the nodes to insert
+ */
+ insertNodes(nodes: Array<LexicalNode>): void {
+ if (nodes.length === 0) {
+ return;
+ }
+ if (this.anchor.key === 'root') {
+ this.insertParagraph();
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'Expected RangeSelection after insertParagraph',
+ );
+ return selection.insertNodes(nodes);
+ }
+
+ const firstPoint = this.isBackward() ? this.focus : this.anchor;
+ const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!;
+
+ const last = nodes[nodes.length - 1]!;
+
+ // CASE 1: insert inside a code block
+ if ('__language' in firstBlock && $isElementNode(firstBlock)) {
+ if ('__language' in nodes[0]) {
+ this.insertText(nodes[0].getTextContent());
+ } else {
+ const index = $removeTextAndSplitBlock(this);
+ firstBlock.splice(index, 0, nodes);
+ last.selectEnd();
+ }
+ return;
+ }
+
+ // CASE 2: All elements of the array are inline
+ const notInline = (node: LexicalNode) =>
+ ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();
+
+ if (!nodes.some(notInline)) {
+ invariant(
+ $isElementNode(firstBlock),
+ "Expected 'firstBlock' to be an ElementNode",
+ );
+ const index = $removeTextAndSplitBlock(this);
+ firstBlock.splice(index, 0, nodes);
+ last.selectEnd();
+ return;
+ }
+
+ // CASE 3: At least 1 element of the array is not inline
+ const blocksParent = $wrapInlineNodes(nodes);
+ const nodeToSelect = blocksParent.getLastDescendant()!;
+ const blocks = blocksParent.getChildren();
+ const isMergeable = (node: LexicalNode): node is ElementNode =>
+ $isElementNode(node) &&
+ INTERNAL_$isBlock(node) &&
+ !node.isEmpty() &&
+ $isElementNode(firstBlock) &&
+ (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty());
+
+ const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
+ const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
+ const lastToInsert = blocks[blocks.length - 1];
+ let firstToInsert = blocks[0];
+ if (isMergeable(firstToInsert)) {
+ invariant(
+ $isElementNode(firstBlock),
+ "Expected 'firstBlock' to be an ElementNode",
+ );
+ firstBlock.append(...firstToInsert.getChildren());
+ firstToInsert = blocks[1];
+ }
+ if (firstToInsert) {
+ insertRangeAfter(firstBlock, firstToInsert);
+ }
+ const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!;
+
+ if (
+ insertedParagraph &&
+ $isElementNode(lastInsertedBlock) &&
+ (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
+ ) {
+ lastInsertedBlock.append(...insertedParagraph.getChildren());
+ insertedParagraph.remove();
+ }
+ if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
+ firstBlock.remove();
+ }
+
+ nodeToSelect.selectEnd();
+
+ // To understand this take a look at the test "can wrap post-linebreak nodes into new element"
+ const lastChild = $isElementNode(firstBlock)
+ ? firstBlock.getLastChild()
+ : null;
+ if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
+ lastChild.remove();
+ }
+ }
+
+ /**
+ * Inserts a new ParagraphNode into the EditorState at the current Selection
+ *
+ * @returns the newly inserted node.
+ */
+ insertParagraph(): ElementNode | null {
+ if (this.anchor.key === 'root') {
+ const paragraph = $createParagraphNode();
+ $getRoot().splice(this.anchor.offset, 0, [paragraph]);
+ paragraph.select();
+ return paragraph;
+ }
+ const index = $removeTextAndSplitBlock(this);
+ const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!;
+ invariant($isElementNode(block), 'Expected ancestor to be an ElementNode');
+ const firstToAppend = block.getChildAtIndex(index);
+ const nodesToInsert = firstToAppend
+ ? [firstToAppend, ...firstToAppend.getNextSiblings()]
+ : [];
+ const newBlock = block.insertNewAfter(this, false) as ElementNode | null;
+ if (newBlock) {
+ newBlock.append(...nodesToInsert);
+ newBlock.selectStart();
+ return newBlock;
+ }
+ // if newBlock is null, it means that block is of type CodeNode.
+ return null;
+ }
+
+ /**
+ * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
+ * current Selection.
+ */
+ insertLineBreak(selectStart?: boolean): void {
+ const lineBreak = $createLineBreakNode();
+ this.insertNodes([lineBreak]);
+ // this is used in MacOS with the command 'ctrl-O' (openLineBreak)
+ if (selectStart) {
+ const parent = lineBreak.getParentOrThrow();
+ const index = lineBreak.getIndexWithinParent();
+ parent.select(index, index);
+ }
+ }
+
+ /**
+ * Extracts the nodes in the Selection, splitting nodes where necessary
+ * to get offset-level precision.
+ *
+ * @returns The nodes in the Selection
+ */
+ extract(): Array<LexicalNode> {
+ const selectedNodes = this.getNodes();
+ const selectedNodesLength = selectedNodes.length;
+ const lastIndex = selectedNodesLength - 1;
+ const anchor = this.anchor;
+ const focus = this.focus;
+ let firstNode = selectedNodes[0];
+ let lastNode = selectedNodes[lastIndex];
+ const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
+
+ if (selectedNodesLength === 0) {
+ return [];
+ } else if (selectedNodesLength === 1) {
+ if ($isTextNode(firstNode) && !this.isCollapsed()) {
+ const startOffset =
+ anchorOffset > focusOffset ? focusOffset : anchorOffset;
+ const endOffset =
+ anchorOffset > focusOffset ? anchorOffset : focusOffset;
+ const splitNodes = firstNode.splitText(startOffset, endOffset);
+ const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
+ return node != null ? [node] : [];
+ }
+ return [firstNode];
+ }
+ const isBefore = anchor.isBefore(focus);
+
+ if ($isTextNode(firstNode)) {
+ const startOffset = isBefore ? anchorOffset : focusOffset;
+ if (startOffset === firstNode.getTextContentSize()) {
+ selectedNodes.shift();
+ } else if (startOffset !== 0) {
+ [, firstNode] = firstNode.splitText(startOffset);
+ selectedNodes[0] = firstNode;
+ }
+ }
+ if ($isTextNode(lastNode)) {
+ const lastNodeText = lastNode.getTextContent();
+ const lastNodeTextLength = lastNodeText.length;
+ const endOffset = isBefore ? focusOffset : anchorOffset;
+ if (endOffset === 0) {
+ selectedNodes.pop();
+ } else if (endOffset !== lastNodeTextLength) {
+ [lastNode] = lastNode.splitText(endOffset);
+ selectedNodes[lastIndex] = lastNode;
+ }
+ }
+ return selectedNodes;
+ }
+
+ /**
+ * Modifies the Selection according to the parameters and a set of heuristics that account for
+ * various node types. Can be used to safely move or extend selection by one logical "unit" without
+ * dealing explicitly with all the possible node types.
+ *
+ * @param alter the type of modification to perform
+ * @param isBackward whether or not selection is backwards
+ * @param granularity the granularity at which to apply the modification
+ */
+ modify(
+ alter: 'move' | 'extend',
+ isBackward: boolean,
+ granularity: 'character' | 'word' | 'lineboundary',
+ ): void {
+ const focus = this.focus;
+ const anchor = this.anchor;
+ const collapse = alter === 'move';
+
+ // Handle the selection movement around decorators.
+ const possibleNode = $getAdjacentNode(focus, isBackward);
+ if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
+ // Make it possible to move selection from range selection to
+ // node selection on the node.
+ if (collapse && possibleNode.isKeyboardSelectable()) {
+ const nodeSelection = $createNodeSelection();
+ nodeSelection.add(possibleNode.__key);
+ $setSelection(nodeSelection);
+ return;
+ }
+ const sibling = isBackward
+ ? possibleNode.getPreviousSibling()
+ : possibleNode.getNextSibling();
+
+ if (!$isTextNode(sibling)) {
+ const parent = possibleNode.getParentOrThrow();
+ let offset;
+ let elementKey;
+
+ if ($isElementNode(sibling)) {
+ elementKey = sibling.__key;
+ offset = isBackward ? sibling.getChildrenSize() : 0;
+ } else {
+ offset = possibleNode.getIndexWithinParent();
+ elementKey = parent.__key;
+ if (!isBackward) {
+ offset++;
+ }
+ }
+ focus.set(elementKey, offset, 'element');
+ if (collapse) {
+ anchor.set(elementKey, offset, 'element');
+ }
+ return;
+ } else {
+ const siblingKey = sibling.__key;
+ const offset = isBackward ? sibling.getTextContent().length : 0;
+ focus.set(siblingKey, offset, 'text');
+ if (collapse) {
+ anchor.set(siblingKey, offset, 'text');
+ }
+ return;
+ }
+ }
+ const editor = getActiveEditor();
+ const domSelection = getDOMSelection(editor._window);
+
+ if (!domSelection) {
+ return;
+ }
+ const blockCursorElement = editor._blockCursorElement;
+ const rootElement = editor._rootElement;
+ // Remove the block cursor element if it exists. This will ensure selection
+ // works as intended. If we leave it in the DOM all sorts of strange bugs
+ // occur. :/
+ if (
+ rootElement !== null &&
+ blockCursorElement !== null &&
+ $isElementNode(possibleNode) &&
+ !possibleNode.isInline() &&
+ !possibleNode.canBeEmpty()
+ ) {
+ removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
+ }
+ // We use the DOM selection.modify API here to "tell" us what the selection
+ // will be. We then use it to update the Lexical selection accordingly. This
+ // is much more reliable than waiting for a beforeinput and using the ranges
+ // from getTargetRanges(), and is also better than trying to do it ourselves
+ // using Intl.Segmenter or other workarounds that struggle with word segments
+ // and line segments (especially with word wrapping and non-Roman languages).
+ moveNativeSelection(
+ domSelection,
+ alter,
+ isBackward ? 'backward' : 'forward',
+ granularity,
+ );
+ // Guard against no ranges
+ if (domSelection.rangeCount > 0) {
+ const range = domSelection.getRangeAt(0);
+ // Apply the DOM selection to our Lexical selection.
+ const anchorNode = this.anchor.getNode();
+ const root = $isRootNode(anchorNode)
+ ? anchorNode
+ : $getNearestRootOrShadowRoot(anchorNode);
+ this.applyDOMRange(range);
+ this.dirty = true;
+ if (!collapse) {
+ // Validate selection; make sure that the new extended selection respects shadow roots
+ const nodes = this.getNodes();
+ const validNodes = [];
+ let shrinkSelection = false;
+ for (let i = 0; i < nodes.length; i++) {
+ const nextNode = nodes[i];
+ if ($hasAncestor(nextNode, root)) {
+ validNodes.push(nextNode);
+ } else {
+ shrinkSelection = true;
+ }
+ }
+ if (shrinkSelection && validNodes.length > 0) {
+ // validNodes length check is a safeguard against an invalid selection; as getNodes()
+ // will return an empty array in this case
+ if (isBackward) {
+ const firstValidNode = validNodes[0];
+ if ($isElementNode(firstValidNode)) {
+ firstValidNode.selectStart();
+ } else {
+ firstValidNode.getParentOrThrow().selectStart();
+ }
+ } else {
+ const lastValidNode = validNodes[validNodes.length - 1];
+ if ($isElementNode(lastValidNode)) {
+ lastValidNode.selectEnd();
+ } else {
+ lastValidNode.getParentOrThrow().selectEnd();
+ }
+ }
+ }
+
+ // Because a range works on start and end, we might need to flip
+ // the anchor and focus points to match what the DOM has, not what
+ // the range has specifically.
+ if (
+ domSelection.anchorNode !== range.startContainer ||
+ domSelection.anchorOffset !== range.startOffset
+ ) {
+ $swapPoints(this);
+ }
+ }
+ }
+ }
+ /**
+ * Helper for handling forward character and word deletion that prevents element nodes
+ * like a table, columns layout being destroyed
+ *
+ * @param anchor the anchor
+ * @param anchorNode the anchor node in the selection
+ * @param isBackward whether or not selection is backwards
+ */
+ forwardDeletion(
+ anchor: PointType,
+ anchorNode: TextNode | ElementNode,
+ isBackward: boolean,
+ ): boolean {
+ if (
+ !isBackward &&
+ // Delete forward handle case
+ ((anchor.type === 'element' &&
+ $isElementNode(anchorNode) &&
+ anchor.offset === anchorNode.getChildrenSize()) ||
+ (anchor.type === 'text' &&
+ anchor.offset === anchorNode.getTextContentSize()))
+ ) {
+ const parent = anchorNode.getParent();
+ const nextSibling =
+ anchorNode.getNextSibling() ||
+ (parent === null ? null : parent.getNextSibling());
+
+ if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Performs one logical character deletion operation on the EditorState based on the current Selection.
+ * Handles different node types.
+ *
+ * @param isBackward whether or not the selection is backwards.
+ */
+ deleteCharacter(isBackward: boolean): void {
+ const wasCollapsed = this.isCollapsed();
+ if (this.isCollapsed()) {
+ const anchor = this.anchor;
+ let anchorNode: TextNode | ElementNode | null = anchor.getNode();
+ if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
+ return;
+ }
+
+ // Handle the deletion around decorators.
+ const focus = this.focus;
+ const possibleNode = $getAdjacentNode(focus, isBackward);
+ if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
+ // Make it possible to move selection from range selection to
+ // node selection on the node.
+ if (
+ possibleNode.isKeyboardSelectable() &&
+ $isElementNode(anchorNode) &&
+ anchorNode.getChildrenSize() === 0
+ ) {
+ anchorNode.remove();
+ const nodeSelection = $createNodeSelection();
+ nodeSelection.add(possibleNode.__key);
+ $setSelection(nodeSelection);
+ } else {
+ possibleNode.remove();
+ const editor = getActiveEditor();
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
+ }
+ return;
+ } else if (
+ !isBackward &&
+ $isElementNode(possibleNode) &&
+ $isElementNode(anchorNode) &&
+ anchorNode.isEmpty()
+ ) {
+ anchorNode.remove();
+ possibleNode.selectStart();
+ return;
+ }
+ this.modify('extend', isBackward, 'character');
+
+ if (!this.isCollapsed()) {
+ const focusNode = focus.type === 'text' ? focus.getNode() : null;
+ anchorNode = anchor.type === 'text' ? anchor.getNode() : null;
+
+ if (focusNode !== null && focusNode.isSegmented()) {
+ const offset = focus.offset;
+ const textContentSize = focusNode.getTextContentSize();
+ if (
+ focusNode.is(anchorNode) ||
+ (isBackward && offset !== textContentSize) ||
+ (!isBackward && offset !== 0)
+ ) {
+ $removeSegment(focusNode, isBackward, offset);
+ return;
+ }
+ } else if (anchorNode !== null && anchorNode.isSegmented()) {
+ const offset = anchor.offset;
+ const textContentSize = anchorNode.getTextContentSize();
+ if (
+ anchorNode.is(focusNode) ||
+ (isBackward && offset !== 0) ||
+ (!isBackward && offset !== textContentSize)
+ ) {
+ $removeSegment(anchorNode, isBackward, offset);
+ return;
+ }
+ }
+ $updateCaretSelectionForUnicodeCharacter(this, isBackward);
+ } else if (isBackward && anchor.offset === 0) {
+ // Special handling around rich text nodes
+ const element =
+ anchor.type === 'element'
+ ? anchor.getNode()
+ : anchor.getNode().getParentOrThrow();
+ if (element.collapseAtStart(this)) {
+ return;
+ }
+ }
+ }
+ this.removeText();
+ if (
+ isBackward &&
+ !wasCollapsed &&
+ this.isCollapsed() &&
+ this.anchor.type === 'element' &&
+ this.anchor.offset === 0
+ ) {
+ const anchorNode = this.anchor.getNode();
+ if (
+ anchorNode.isEmpty() &&
+ $isRootNode(anchorNode.getParent()) &&
+ anchorNode.getIndexWithinParent() === 0
+ ) {
+ anchorNode.collapseAtStart(this);
+ }
+ }
+ }
+
+ /**
+ * Performs one logical line deletion operation on the EditorState based on the current Selection.
+ * Handles different node types.
+ *
+ * @param isBackward whether or not the selection is backwards.
+ */
+ deleteLine(isBackward: boolean): void {
+ if (this.isCollapsed()) {
+ // Since `domSelection.modify('extend', ..., 'lineboundary')` works well for text selections
+ // but doesn't properly handle selections which end on elements, a space character is added
+ // for such selections transforming their anchor's type to 'text'
+ const anchorIsElement = this.anchor.type === 'element';
+ if (anchorIsElement) {
+ this.insertText(' ');
+ }
+
+ this.modify('extend', isBackward, 'lineboundary');
+
+ // If selection is extended to cover text edge then extend it one character more
+ // to delete its parent element. Otherwise text content will be deleted but empty
+ // parent node will remain
+ const endPoint = isBackward ? this.focus : this.anchor;
+ if (endPoint.offset === 0) {
+ this.modify('extend', isBackward, 'character');
+ }
+
+ // Adjusts selection to include an extra character added for element anchors to remove it
+ if (anchorIsElement) {
+ const startPoint = isBackward ? this.anchor : this.focus;
+ startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type);
+ }
+ }
+ this.removeText();
+ }
+
+ /**
+ * Performs one logical word deletion operation on the EditorState based on the current Selection.
+ * Handles different node types.
+ *
+ * @param isBackward whether or not the selection is backwards.
+ */
+ deleteWord(isBackward: boolean): void {
+ if (this.isCollapsed()) {
+ const anchor = this.anchor;
+ const anchorNode: TextNode | ElementNode | null = anchor.getNode();
+ if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
+ return;
+ }
+ this.modify('extend', isBackward, 'word');
+ }
+ this.removeText();
+ }
+
+ /**
+ * Returns whether the Selection is "backwards", meaning the focus
+ * logically precedes the anchor in the EditorState.
+ * @returns true if the Selection is backwards, false otherwise.
+ */
+ isBackward(): boolean {
+ return this.focus.isBefore(this.anchor);
+ }
+
+ getStartEndPoints(): null | [PointType, PointType] {
+ return [this.anchor, this.focus];
+ }
+}
+
+export function $isNodeSelection(x: unknown): x is NodeSelection {
+ return x instanceof NodeSelection;
+}
+
+function getCharacterOffset(point: PointType): number {
+ const offset = point.offset;
+ if (point.type === 'text') {
+ return offset;
+ }
+
+ const parent = point.getNode();
+ return offset === parent.getChildrenSize()
+ ? parent.getTextContent().length
+ : 0;
+}
+
+export function $getCharacterOffsets(
+ selection: BaseSelection,
+): [number, number] {
+ const anchorAndFocus = selection.getStartEndPoints();
+ if (anchorAndFocus === null) {
+ return [0, 0];
+ }
+ const [anchor, focus] = anchorAndFocus;
+ if (
+ anchor.type === 'element' &&
+ focus.type === 'element' &&
+ anchor.key === focus.key &&
+ anchor.offset === focus.offset
+ ) {
+ return [0, 0];
+ }
+ return [getCharacterOffset(anchor), getCharacterOffset(focus)];
+}
+
+function $swapPoints(selection: RangeSelection): void {
+ const focus = selection.focus;
+ const anchor = selection.anchor;
+ const anchorKey = anchor.key;
+ const anchorOffset = anchor.offset;
+ const anchorType = anchor.type;
+
+ $setPointValues(anchor, focus.key, focus.offset, focus.type);
+ $setPointValues(focus, anchorKey, anchorOffset, anchorType);
+ selection._cachedNodes = null;
+}
+
+function moveNativeSelection(
+ domSelection: Selection,
+ alter: 'move' | 'extend',
+ direction: 'backward' | 'forward' | 'left' | 'right',
+ granularity: 'character' | 'word' | 'lineboundary',
+): void {
+ // Selection.modify() method applies a change to the current selection or cursor position,
+ // but is still non-standard in some browsers.
+ domSelection.modify(alter, direction, granularity);
+}
+
+function $updateCaretSelectionForUnicodeCharacter(
+ selection: RangeSelection,
+ isBackward: boolean,
+): void {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+
+ if (
+ anchorNode === focusNode &&
+ anchor.type === 'text' &&
+ focus.type === 'text'
+ ) {
+ // Handling of multibyte characters
+ const anchorOffset = anchor.offset;
+ const focusOffset = focus.offset;
+ const isBefore = anchorOffset < focusOffset;
+ const startOffset = isBefore ? anchorOffset : focusOffset;
+ const endOffset = isBefore ? focusOffset : anchorOffset;
+ const characterOffset = endOffset - 1;
+
+ if (startOffset !== characterOffset) {
+ const text = anchorNode.getTextContent().slice(startOffset, endOffset);
+ if (!doesContainGrapheme(text)) {
+ if (isBackward) {
+ focus.offset = characterOffset;
+ } else {
+ anchor.offset = characterOffset;
+ }
+ }
+ }
+ } else {
+ // TODO Handling of multibyte characters
+ }
+}
+
+function $removeSegment(
+ node: TextNode,
+ isBackward: boolean,
+ offset: number,
+): void {
+ const textNode = node;
+ const textContent = textNode.getTextContent();
+ const split = textContent.split(/(?=\s)/g);
+ const splitLength = split.length;
+ let segmentOffset = 0;
+ let restoreOffset: number | undefined = 0;
+
+ for (let i = 0; i < splitLength; i++) {
+ const text = split[i];
+ const isLast = i === splitLength - 1;
+ restoreOffset = segmentOffset;
+ segmentOffset += text.length;
+
+ if (
+ (isBackward && segmentOffset === offset) ||
+ segmentOffset > offset ||
+ isLast
+ ) {
+ split.splice(i, 1);
+ if (isLast) {
+ restoreOffset = undefined;
+ }
+ break;
+ }
+ }
+ const nextTextContent = split.join('').trim();
+
+ if (nextTextContent === '') {
+ textNode.remove();
+ } else {
+ textNode.setTextContent(nextTextContent);
+ textNode.select(restoreOffset, restoreOffset);
+ }
+}
+
+function shouldResolveAncestor(
+ resolvedElement: ElementNode,
+ resolvedOffset: number,
+ lastPoint: null | PointType,
+): boolean {
+ const parent = resolvedElement.getParent();
+ return (
+ lastPoint === null ||
+ parent === null ||
+ !parent.canBeEmpty() ||
+ parent !== lastPoint.getNode()
+ );
+}
+
+function $internalResolveSelectionPoint(
+ dom: Node,
+ offset: number,
+ lastPoint: null | PointType,
+ editor: LexicalEditor,
+): null | PointType {
+ let resolvedOffset = offset;
+ let resolvedNode: TextNode | LexicalNode | null;
+ // If we have selection on an element, we will
+ // need to figure out (using the offset) what text
+ // node should be selected.
+
+ if (dom.nodeType === DOM_ELEMENT_TYPE) {
+ // Resolve element to a ElementNode, or TextNode, or null
+ let moveSelectionToEnd = false;
+ // Given we're moving selection to another node, selection is
+ // definitely dirty.
+ // We use the anchor to find which child node to select
+ const childNodes = dom.childNodes;
+ const childNodesLength = childNodes.length;
+ const blockCursorElement = editor._blockCursorElement;
+ // If the anchor is the same as length, then this means we
+ // need to select the very last text node.
+ if (resolvedOffset === childNodesLength) {
+ moveSelectionToEnd = true;
+ resolvedOffset = childNodesLength - 1;
+ }
+ let childDOM = childNodes[resolvedOffset];
+ let hasBlockCursor = false;
+ if (childDOM === blockCursorElement) {
+ childDOM = childNodes[resolvedOffset + 1];
+ hasBlockCursor = true;
+ } else if (blockCursorElement !== null) {
+ const blockCursorElementParent = blockCursorElement.parentNode;
+ if (dom === blockCursorElementParent) {
+ const blockCursorOffset = Array.prototype.indexOf.call(
+ blockCursorElementParent.children,
+ blockCursorElement,
+ );
+ if (offset > blockCursorOffset) {
+ resolvedOffset--;
+ }
+ }
+ }
+ resolvedNode = $getNodeFromDOM(childDOM);
+
+ if ($isTextNode(resolvedNode)) {
+ resolvedOffset = getTextNodeOffset(resolvedNode, moveSelectionToEnd);
+ } else {
+ let resolvedElement = $getNodeFromDOM(dom);
+ // Ensure resolvedElement is actually a element.
+ if (resolvedElement === null) {
+ return null;
+ }
+ if ($isElementNode(resolvedElement)) {
+ resolvedOffset = Math.min(
+ resolvedElement.getChildrenSize(),
+ resolvedOffset,
+ );
+ let child = resolvedElement.getChildAtIndex(resolvedOffset);
+ if (
+ $isElementNode(child) &&
+ shouldResolveAncestor(child, resolvedOffset, lastPoint)
+ ) {
+ const descendant = moveSelectionToEnd
+ ? child.getLastDescendant()
+ : child.getFirstDescendant();
+ if (descendant === null) {
+ resolvedElement = child;
+ } else {
+ child = descendant;
+ resolvedElement = $isElementNode(child)
+ ? child
+ : child.getParentOrThrow();
+ }
+ resolvedOffset = 0;
+ }
+ if ($isTextNode(child)) {
+ resolvedNode = child;
+ resolvedElement = null;
+ resolvedOffset = getTextNodeOffset(child, moveSelectionToEnd);
+ } else if (
+ child !== resolvedElement &&
+ moveSelectionToEnd &&
+ !hasBlockCursor
+ ) {
+ resolvedOffset++;
+ }
+ } else {
+ const index = resolvedElement.getIndexWithinParent();
+ // When selecting decorators, there can be some selection issues when using resolvedOffset,
+ // and instead we should be checking if we're using the offset
+ if (
+ offset === 0 &&
+ $isDecoratorNode(resolvedElement) &&
+ $getNodeFromDOM(dom) === resolvedElement
+ ) {
+ resolvedOffset = index;
+ } else {
+ resolvedOffset = index + 1;
+ }
+ resolvedElement = resolvedElement.getParentOrThrow();
+ }
+ if ($isElementNode(resolvedElement)) {
+ return $createPoint(resolvedElement.__key, resolvedOffset, 'element');
+ }
+ }
+ } else {
+ // TextNode or null
+ resolvedNode = $getNodeFromDOM(dom);
+ }
+ if (!$isTextNode(resolvedNode)) {
+ return null;
+ }
+ return $createPoint(resolvedNode.__key, resolvedOffset, 'text');
+}
+
+function resolveSelectionPointOnBoundary(
+ point: TextPointType,
+ isBackward: boolean,
+ isCollapsed: boolean,
+): void {
+ const offset = point.offset;
+ const node = point.getNode();
+
+ if (offset === 0) {
+ const prevSibling = node.getPreviousSibling();
+ const parent = node.getParent();
+
+ if (!isBackward) {
+ if (
+ $isElementNode(prevSibling) &&
+ !isCollapsed &&
+ prevSibling.isInline()
+ ) {
+ point.key = prevSibling.__key;
+ point.offset = prevSibling.getChildrenSize();
+ // @ts-expect-error: intentional
+ point.type = 'element';
+ } else if ($isTextNode(prevSibling)) {
+ point.key = prevSibling.__key;
+ point.offset = prevSibling.getTextContent().length;
+ }
+ } else if (
+ (isCollapsed || !isBackward) &&
+ prevSibling === null &&
+ $isElementNode(parent) &&
+ parent.isInline()
+ ) {
+ const parentSibling = parent.getPreviousSibling();
+ if ($isTextNode(parentSibling)) {
+ point.key = parentSibling.__key;
+ point.offset = parentSibling.getTextContent().length;
+ }
+ }
+ } else if (offset === node.getTextContent().length) {
+ const nextSibling = node.getNextSibling();
+ const parent = node.getParent();
+
+ if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
+ point.key = nextSibling.__key;
+ point.offset = 0;
+ // @ts-expect-error: intentional
+ point.type = 'element';
+ } else if (
+ (isCollapsed || isBackward) &&
+ nextSibling === null &&
+ $isElementNode(parent) &&
+ parent.isInline() &&
+ !parent.canInsertTextAfter()
+ ) {
+ const parentSibling = parent.getNextSibling();
+ if ($isTextNode(parentSibling)) {
+ point.key = parentSibling.__key;
+ point.offset = 0;
+ }
+ }
+ }
+}
+
+function $normalizeSelectionPointsForBoundaries(
+ anchor: PointType,
+ focus: PointType,
+ lastSelection: null | BaseSelection,
+): void {
+ if (anchor.type === 'text' && focus.type === 'text') {
+ const isBackward = anchor.isBefore(focus);
+ const isCollapsed = anchor.is(focus);
+
+ // Attempt to normalize the offset to the previous sibling if we're at the
+ // start of a text node and the sibling is a text node or inline element.
+ resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
+ resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);
+
+ if (isCollapsed) {
+ focus.key = anchor.key;
+ focus.offset = anchor.offset;
+ focus.type = anchor.type;
+ }
+ const editor = getActiveEditor();
+
+ if (
+ editor.isComposing() &&
+ editor._compositionKey !== anchor.key &&
+ $isRangeSelection(lastSelection)
+ ) {
+ const lastAnchor = lastSelection.anchor;
+ const lastFocus = lastSelection.focus;
+ $setPointValues(
+ anchor,
+ lastAnchor.key,
+ lastAnchor.offset,
+ lastAnchor.type,
+ );
+ $setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type);
+ }
+ }
+}
+
+function $internalResolveSelectionPoints(
+ anchorDOM: null | Node,
+ anchorOffset: number,
+ focusDOM: null | Node,
+ focusOffset: number,
+ editor: LexicalEditor,
+ lastSelection: null | BaseSelection,
+): null | [PointType, PointType] {
+ if (
+ anchorDOM === null ||
+ focusDOM === null ||
+ !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
+ ) {
+ return null;
+ }
+ const resolvedAnchorPoint = $internalResolveSelectionPoint(
+ anchorDOM,
+ anchorOffset,
+ $isRangeSelection(lastSelection) ? lastSelection.anchor : null,
+ editor,
+ );
+ if (resolvedAnchorPoint === null) {
+ return null;
+ }
+ const resolvedFocusPoint = $internalResolveSelectionPoint(
+ focusDOM,
+ focusOffset,
+ $isRangeSelection(lastSelection) ? lastSelection.focus : null,
+ editor,
+ );
+ if (resolvedFocusPoint === null) {
+ return null;
+ }
+ if (
+ resolvedAnchorPoint.type === 'element' &&
+ resolvedFocusPoint.type === 'element'
+ ) {
+ const anchorNode = $getNodeFromDOM(anchorDOM);
+ const focusNode = $getNodeFromDOM(focusDOM);
+ // Ensure if we're selecting the content of a decorator that we
+ // return null for this point, as it's not in the controlled scope
+ // of Lexical.
+ if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
+ return null;
+ }
+ }
+
+ // Handle normalization of selection when it is at the boundaries.
+ $normalizeSelectionPointsForBoundaries(
+ resolvedAnchorPoint,
+ resolvedFocusPoint,
+ lastSelection,
+ );
+
+ return [resolvedAnchorPoint, resolvedFocusPoint];
+}
+
+export function $isBlockElementNode(
+ node: LexicalNode | null | undefined,
+): node is ElementNode {
+ return $isElementNode(node) && !node.isInline();
+}
+
+// This is used to make a selection when the existing
+// selection is null, i.e. forcing selection on the editor
+// when it current exists outside the editor.
+
+export function $internalMakeRangeSelection(
+ anchorKey: NodeKey,
+ anchorOffset: number,
+ focusKey: NodeKey,
+ focusOffset: number,
+ anchorType: 'text' | 'element',
+ focusType: 'text' | 'element',
+): RangeSelection {
+ const editorState = getActiveEditorState();
+ const selection = new RangeSelection(
+ $createPoint(anchorKey, anchorOffset, anchorType),
+ $createPoint(focusKey, focusOffset, focusType),
+ 0,
+ '',
+ );
+ selection.dirty = true;
+ editorState._selection = selection;
+ return selection;
+}
+
+export function $createRangeSelection(): RangeSelection {
+ const anchor = $createPoint('root', 0, 'element');
+ const focus = $createPoint('root', 0, 'element');
+ return new RangeSelection(anchor, focus, 0, '');
+}
+
+export function $createNodeSelection(): NodeSelection {
+ return new NodeSelection(new Set());
+}
+
+export function $internalCreateSelection(
+ editor: LexicalEditor,
+): null | BaseSelection {
+ const currentEditorState = editor.getEditorState();
+ const lastSelection = currentEditorState._selection;
+ const domSelection = getDOMSelection(editor._window);
+
+ if ($isRangeSelection(lastSelection) || lastSelection == null) {
+ return $internalCreateRangeSelection(
+ lastSelection,
+ domSelection,
+ editor,
+ null,
+ );
+ }
+ return lastSelection.clone();
+}
+
+export function $createRangeSelectionFromDom(
+ domSelection: Selection | null,
+ editor: LexicalEditor,
+): null | RangeSelection {
+ return $internalCreateRangeSelection(null, domSelection, editor, null);
+}
+
+export function $internalCreateRangeSelection(
+ lastSelection: null | BaseSelection,
+ domSelection: Selection | null,
+ editor: LexicalEditor,
+ event: UIEvent | Event | null,
+): null | RangeSelection {
+ const windowObj = editor._window;
+ if (windowObj === null) {
+ return null;
+ }
+ // When we create a selection, we try to use the previous
+ // selection where possible, unless an actual user selection
+ // change has occurred. When we do need to create a new selection
+ // we validate we can have text nodes for both anchor and focus
+ // nodes. If that holds true, we then return that selection
+ // as a mutable object that we use for the editor state for this
+ // update cycle. If a selection gets changed, and requires a
+ // update to native DOM selection, it gets marked as "dirty".
+ // If the selection changes, but matches with the existing
+ // DOM selection, then we only need to sync it. Otherwise,
+ // we generally bail out of doing an update to selection during
+ // reconciliation unless there are dirty nodes that need
+ // reconciling.
+
+ const windowEvent = event || windowObj.event;
+ const eventType = windowEvent ? windowEvent.type : undefined;
+ const isSelectionChange = eventType === 'selectionchange';
+ const useDOMSelection =
+ !getIsProcessingMutations() &&
+ (isSelectionChange ||
+ eventType === 'beforeinput' ||
+ eventType === 'compositionstart' ||
+ eventType === 'compositionend' ||
+ (eventType === 'click' &&
+ windowEvent &&
+ (windowEvent as InputEvent).detail === 3) ||
+ eventType === 'drop' ||
+ eventType === undefined);
+ let anchorDOM, focusDOM, anchorOffset, focusOffset;
+
+ if (!$isRangeSelection(lastSelection) || useDOMSelection) {
+ if (domSelection === null) {
+ return null;
+ }
+ anchorDOM = domSelection.anchorNode;
+ focusDOM = domSelection.focusNode;
+ anchorOffset = domSelection.anchorOffset;
+ focusOffset = domSelection.focusOffset;
+ if (
+ isSelectionChange &&
+ $isRangeSelection(lastSelection) &&
+ !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
+ ) {
+ return lastSelection.clone();
+ }
+ } else {
+ return lastSelection.clone();
+ }
+ // Let's resolve the text nodes from the offsets and DOM nodes we have from
+ // native selection.
+ const resolvedSelectionPoints = $internalResolveSelectionPoints(
+ anchorDOM,
+ anchorOffset,
+ focusDOM,
+ focusOffset,
+ editor,
+ lastSelection,
+ );
+ if (resolvedSelectionPoints === null) {
+ return null;
+ }
+ const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints;
+ return new RangeSelection(
+ resolvedAnchorPoint,
+ resolvedFocusPoint,
+ !$isRangeSelection(lastSelection) ? 0 : lastSelection.format,
+ !$isRangeSelection(lastSelection) ? '' : lastSelection.style,
+ );
+}
+
+export function $getSelection(): null | BaseSelection {
+ const editorState = getActiveEditorState();
+ return editorState._selection;
+}
+
+export function $getPreviousSelection(): null | BaseSelection {
+ const editor = getActiveEditor();
+ return editor._editorState._selection;
+}
+
+export function $updateElementSelectionOnCreateDeleteNode(
+ selection: RangeSelection,
+ parentNode: LexicalNode,
+ nodeOffset: number,
+ times = 1,
+): void {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+ if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
+ return;
+ }
+ const parentKey = parentNode.__key;
+ // Single node. We shift selection but never redimension it
+ if (selection.isCollapsed()) {
+ const selectionOffset = anchor.offset;
+ if (
+ (nodeOffset <= selectionOffset && times > 0) ||
+ (nodeOffset < selectionOffset && times < 0)
+ ) {
+ const newSelectionOffset = Math.max(0, selectionOffset + times);
+ anchor.set(parentKey, newSelectionOffset, 'element');
+ focus.set(parentKey, newSelectionOffset, 'element');
+ // The new selection might point to text nodes, try to resolve them
+ $updateSelectionResolveTextNodes(selection);
+ }
+ } else {
+ // Multiple nodes selected. We shift or redimension selection
+ const isBackward = selection.isBackward();
+ const firstPoint = isBackward ? focus : anchor;
+ const firstPointNode = firstPoint.getNode();
+ const lastPoint = isBackward ? anchor : focus;
+ const lastPointNode = lastPoint.getNode();
+ if (parentNode.is(firstPointNode)) {
+ const firstPointOffset = firstPoint.offset;
+ if (
+ (nodeOffset <= firstPointOffset && times > 0) ||
+ (nodeOffset < firstPointOffset && times < 0)
+ ) {
+ firstPoint.set(
+ parentKey,
+ Math.max(0, firstPointOffset + times),
+ 'element',
+ );
+ }
+ }
+ if (parentNode.is(lastPointNode)) {
+ const lastPointOffset = lastPoint.offset;
+ if (
+ (nodeOffset <= lastPointOffset && times > 0) ||
+ (nodeOffset < lastPointOffset && times < 0)
+ ) {
+ lastPoint.set(
+ parentKey,
+ Math.max(0, lastPointOffset + times),
+ 'element',
+ );
+ }
+ }
+ }
+ // The new selection might point to text nodes, try to resolve them
+ $updateSelectionResolveTextNodes(selection);
+}
+
+function $updateSelectionResolveTextNodes(selection: RangeSelection): void {
+ const anchor = selection.anchor;
+ const anchorOffset = anchor.offset;
+ const focus = selection.focus;
+ const focusOffset = focus.offset;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+ if (selection.isCollapsed()) {
+ if (!$isElementNode(anchorNode)) {
+ return;
+ }
+ const childSize = anchorNode.getChildrenSize();
+ const anchorOffsetAtEnd = anchorOffset >= childSize;
+ const child = anchorOffsetAtEnd
+ ? anchorNode.getChildAtIndex(childSize - 1)
+ : anchorNode.getChildAtIndex(anchorOffset);
+ if ($isTextNode(child)) {
+ let newOffset = 0;
+ if (anchorOffsetAtEnd) {
+ newOffset = child.getTextContentSize();
+ }
+ anchor.set(child.__key, newOffset, 'text');
+ focus.set(child.__key, newOffset, 'text');
+ }
+ return;
+ }
+ if ($isElementNode(anchorNode)) {
+ const childSize = anchorNode.getChildrenSize();
+ const anchorOffsetAtEnd = anchorOffset >= childSize;
+ const child = anchorOffsetAtEnd
+ ? anchorNode.getChildAtIndex(childSize - 1)
+ : anchorNode.getChildAtIndex(anchorOffset);
+ if ($isTextNode(child)) {
+ let newOffset = 0;
+ if (anchorOffsetAtEnd) {
+ newOffset = child.getTextContentSize();
+ }
+ anchor.set(child.__key, newOffset, 'text');
+ }
+ }
+ if ($isElementNode(focusNode)) {
+ const childSize = focusNode.getChildrenSize();
+ const focusOffsetAtEnd = focusOffset >= childSize;
+ const child = focusOffsetAtEnd
+ ? focusNode.getChildAtIndex(childSize - 1)
+ : focusNode.getChildAtIndex(focusOffset);
+ if ($isTextNode(child)) {
+ let newOffset = 0;
+ if (focusOffsetAtEnd) {
+ newOffset = child.getTextContentSize();
+ }
+ focus.set(child.__key, newOffset, 'text');
+ }
+ }
+}
+
+export function applySelectionTransforms(
+ nextEditorState: EditorState,
+ editor: LexicalEditor,
+): void {
+ const prevEditorState = editor.getEditorState();
+ const prevSelection = prevEditorState._selection;
+ const nextSelection = nextEditorState._selection;
+ if ($isRangeSelection(nextSelection)) {
+ const anchor = nextSelection.anchor;
+ const focus = nextSelection.focus;
+ let anchorNode;
+
+ if (anchor.type === 'text') {
+ anchorNode = anchor.getNode();
+ anchorNode.selectionTransform(prevSelection, nextSelection);
+ }
+ if (focus.type === 'text') {
+ const focusNode = focus.getNode();
+ if (anchorNode !== focusNode) {
+ focusNode.selectionTransform(prevSelection, nextSelection);
+ }
+ }
+ }
+}
+
+export function moveSelectionPointToSibling(
+ point: PointType,
+ node: LexicalNode,
+ parent: ElementNode,
+ prevSibling: LexicalNode | null,
+ nextSibling: LexicalNode | null,
+): void {
+ let siblingKey = null;
+ let offset = 0;
+ let type: 'text' | 'element' | null = null;
+ if (prevSibling !== null) {
+ siblingKey = prevSibling.__key;
+ if ($isTextNode(prevSibling)) {
+ offset = prevSibling.getTextContentSize();
+ type = 'text';
+ } else if ($isElementNode(prevSibling)) {
+ offset = prevSibling.getChildrenSize();
+ type = 'element';
+ }
+ } else {
+ if (nextSibling !== null) {
+ siblingKey = nextSibling.__key;
+ if ($isTextNode(nextSibling)) {
+ type = 'text';
+ } else if ($isElementNode(nextSibling)) {
+ type = 'element';
+ }
+ }
+ }
+ if (siblingKey !== null && type !== null) {
+ point.set(siblingKey, offset, type);
+ } else {
+ offset = node.getIndexWithinParent();
+ if (offset === -1) {
+ // Move selection to end of parent
+ offset = parent.getChildrenSize();
+ }
+ point.set(parent.__key, offset, 'element');
+ }
+}
+
+export function adjustPointOffsetForMergedSibling(
+ point: PointType,
+ isBefore: boolean,
+ key: NodeKey,
+ target: TextNode,
+ textLength: number,
+): void {
+ if (point.type === 'text') {
+ point.key = key;
+ if (!isBefore) {
+ point.offset += textLength;
+ }
+ } else if (point.offset > target.getIndexWithinParent()) {
+ point.offset -= 1;
+ }
+}
+
+export function updateDOMSelection(
+ prevSelection: BaseSelection | null,
+ nextSelection: BaseSelection | null,
+ editor: LexicalEditor,
+ domSelection: Selection,
+ tags: Set<string>,
+ rootElement: HTMLElement,
+ nodeCount: number,
+): void {
+ const anchorDOMNode = domSelection.anchorNode;
+ const focusDOMNode = domSelection.focusNode;
+ const anchorOffset = domSelection.anchorOffset;
+ const focusOffset = domSelection.focusOffset;
+ const activeElement = document.activeElement;
+
+ // TODO: make this not hard-coded, and add another config option
+ // that makes this configurable.
+ if (
+ (tags.has('collaboration') && activeElement !== rootElement) ||
+ (activeElement !== null &&
+ isSelectionCapturedInDecoratorInput(activeElement))
+ ) {
+ return;
+ }
+
+ if (!$isRangeSelection(nextSelection)) {
+ // We don't remove selection if the prevSelection is null because
+ // of editor.setRootElement(). If this occurs on init when the
+ // editor is already focused, then this can cause the editor to
+ // lose focus.
+ if (
+ prevSelection !== null &&
+ isSelectionWithinEditor(editor, anchorDOMNode, focusDOMNode)
+ ) {
+ domSelection.removeAllRanges();
+ }
+
+ return;
+ }
+
+ const anchor = nextSelection.anchor;
+ const focus = nextSelection.focus;
+ const anchorKey = anchor.key;
+ const focusKey = focus.key;
+ const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
+ const focusDOM = getElementByKeyOrThrow(editor, focusKey);
+ const nextAnchorOffset = anchor.offset;
+ const nextFocusOffset = focus.offset;
+ const nextFormat = nextSelection.format;
+ const nextStyle = nextSelection.style;
+ const isCollapsed = nextSelection.isCollapsed();
+ let nextAnchorNode: HTMLElement | Text | null = anchorDOM;
+ let nextFocusNode: HTMLElement | Text | null = focusDOM;
+ let anchorFormatOrStyleChanged = false;
+
+ if (anchor.type === 'text') {
+ nextAnchorNode = getDOMTextNode(anchorDOM);
+ const anchorNode = anchor.getNode();
+ anchorFormatOrStyleChanged =
+ anchorNode.getFormat() !== nextFormat ||
+ anchorNode.getStyle() !== nextStyle;
+ } else if (
+ $isRangeSelection(prevSelection) &&
+ prevSelection.anchor.type === 'text'
+ ) {
+ anchorFormatOrStyleChanged = true;
+ }
+
+ if (focus.type === 'text') {
+ nextFocusNode = getDOMTextNode(focusDOM);
+ }
+
+ // If we can't get an underlying text node for selection, then
+ // we should avoid setting selection to something incorrect.
+ if (nextAnchorNode === null || nextFocusNode === null) {
+ return;
+ }
+
+ if (
+ isCollapsed &&
+ (prevSelection === null ||
+ anchorFormatOrStyleChanged ||
+ ($isRangeSelection(prevSelection) &&
+ (prevSelection.format !== nextFormat ||
+ prevSelection.style !== nextStyle)))
+ ) {
+ markCollapsedSelectionFormat(
+ nextFormat,
+ nextStyle,
+ nextAnchorOffset,
+ anchorKey,
+ performance.now(),
+ );
+ }
+
+ // Diff against the native DOM selection to ensure we don't do
+ // an unnecessary selection update. We also skip this check if
+ // we're moving selection to within an element, as this can
+ // sometimes be problematic around scrolling.
+ if (
+ anchorOffset === nextAnchorOffset &&
+ focusOffset === nextFocusOffset &&
+ anchorDOMNode === nextAnchorNode &&
+ focusDOMNode === nextFocusNode && // Badly interpreted range selection when collapsed - #1482
+ !(domSelection.type === 'Range' && isCollapsed)
+ ) {
+ // If the root element does not have focus, ensure it has focus
+ if (activeElement === null || !rootElement.contains(activeElement)) {
+ rootElement.focus({
+ preventScroll: true,
+ });
+ }
+ if (anchor.type !== 'element') {
+ return;
+ }
+ }
+
+ // Apply the updated selection to the DOM. Note: this will trigger
+ // a "selectionchange" event, although it will be asynchronous.
+ try {
+ domSelection.setBaseAndExtent(
+ nextAnchorNode,
+ nextAnchorOffset,
+ nextFocusNode,
+ nextFocusOffset,
+ );
+ } catch (error) {
+ // If we encounter an error, continue. This can sometimes
+ // occur with FF and there's no good reason as to why it
+ // should happen.
+ if (__DEV__) {
+ console.warn(error);
+ }
+ }
+ if (
+ !tags.has('skip-scroll-into-view') &&
+ nextSelection.isCollapsed() &&
+ rootElement !== null &&
+ rootElement === document.activeElement
+ ) {
+ const selectionTarget: null | Range | HTMLElement | Text =
+ nextSelection instanceof RangeSelection &&
+ nextSelection.anchor.type === 'element'
+ ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
+ null
+ : domSelection.rangeCount > 0
+ ? domSelection.getRangeAt(0)
+ : null;
+ if (selectionTarget !== null) {
+ let selectionRect: DOMRect;
+ if (selectionTarget instanceof Text) {
+ const range = document.createRange();
+ range.selectNode(selectionTarget);
+ selectionRect = range.getBoundingClientRect();
+ } else {
+ selectionRect = selectionTarget.getBoundingClientRect();
+ }
+ scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
+ }
+ }
+
+ markSelectionChangeFromDOMUpdate();
+}
+
+export function $insertNodes(nodes: Array<LexicalNode>) {
+ let selection = $getSelection() || $getPreviousSelection();
+
+ if (selection === null) {
+ selection = $getRoot().selectEnd();
+ }
+ selection.insertNodes(nodes);
+}
+
+export function $getTextContent(): string {
+ const selection = $getSelection();
+ if (selection === null) {
+ return '';
+ }
+ return selection.getTextContent();
+}
+
+function $removeTextAndSplitBlock(selection: RangeSelection): number {
+ let selection_ = selection;
+ if (!selection.isCollapsed()) {
+ selection_.removeText();
+ }
+ // A new selection can originate as a result of node replacement, in which case is registered via
+ // $setSelection
+ const newSelection = $getSelection();
+ if ($isRangeSelection(newSelection)) {
+ selection_ = newSelection;
+ }
+
+ invariant(
+ $isRangeSelection(selection_),
+ 'Unexpected dirty selection to be null',
+ );
+
+ const anchor = selection_.anchor;
+ let node = anchor.getNode();
+ let offset = anchor.offset;
+
+ while (!INTERNAL_$isBlock(node)) {
+ [node, offset] = $splitNodeAtPoint(node, offset);
+ }
+
+ return offset;
+}
+
+function $splitNodeAtPoint(
+ node: LexicalNode,
+ offset: number,
+): [parent: ElementNode, offset: number] {
+ const parent = node.getParent();
+ if (!parent) {
+ const paragraph = $createParagraphNode();
+ $getRoot().append(paragraph);
+ paragraph.select();
+ return [$getRoot(), 0];
+ }
+
+ if ($isTextNode(node)) {
+ const split = node.splitText(offset);
+ if (split.length === 0) {
+ return [parent, node.getIndexWithinParent()];
+ }
+ const x = offset === 0 ? 0 : 1;
+ const index = split[0].getIndexWithinParent() + x;
+
+ return [parent, index];
+ }
+
+ if (!$isElementNode(node) || offset === 0) {
+ return [parent, node.getIndexWithinParent()];
+ }
+
+ const firstToAppend = node.getChildAtIndex(offset);
+ if (firstToAppend) {
+ const insertPoint = new RangeSelection(
+ $createPoint(node.__key, offset, 'element'),
+ $createPoint(node.__key, offset, 'element'),
+ 0,
+ '',
+ );
+ const newElement = node.insertNewAfter(insertPoint) as ElementNode | null;
+ if (newElement) {
+ newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
+ }
+ }
+ return [parent, node.getIndexWithinParent() + 1];
+}
+
+function $wrapInlineNodes(nodes: LexicalNode[]) {
+ // We temporarily insert the topLevelNodes into an arbitrary ElementNode,
+ // since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
+ const virtualRoot = $createParagraphNode();
+
+ let currentBlock = null;
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+
+ const isLineBreakNode = $isLineBreakNode(node);
+
+ if (
+ isLineBreakNode ||
+ ($isDecoratorNode(node) && node.isInline()) ||
+ ($isElementNode(node) && node.isInline()) ||
+ $isTextNode(node) ||
+ node.isParentRequired()
+ ) {
+ if (currentBlock === null) {
+ currentBlock = node.createParentElementNode();
+ virtualRoot.append(currentBlock);
+ // In the case of LineBreakNode, we just need to
+ // add an empty ParagraphNode to the topLevelBlocks.
+ if (isLineBreakNode) {
+ continue;
+ }
+ }
+
+ if (currentBlock !== null) {
+ currentBlock.append(node);
+ }
+ } else {
+ virtualRoot.append(node);
+ currentBlock = null;
+ }
+ }
+
+ return virtualRoot;
+}
--- /dev/null
+/**
+ * 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 {SerializedEditorState} from './LexicalEditorState';
+import type {LexicalNode, SerializedLexicalNode} from './LexicalNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {$isElementNode, $isTextNode, SELECTION_CHANGE_COMMAND} from '.';
+import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants';
+import {
+ CommandPayloadType,
+ EditorUpdateOptions,
+ LexicalCommand,
+ LexicalEditor,
+ Listener,
+ MutatedNodes,
+ RegisteredNodes,
+ resetEditor,
+ Transform,
+} from './LexicalEditor';
+import {
+ cloneEditorState,
+ createEmptyEditorState,
+ EditorState,
+ editorStateHasDirtySelection,
+} from './LexicalEditorState';
+import {
+ $garbageCollectDetachedDecorators,
+ $garbageCollectDetachedNodes,
+} from './LexicalGC';
+import {initMutationObserver} from './LexicalMutations';
+import {$normalizeTextNode} from './LexicalNormalization';
+import {$reconcileRoot} from './LexicalReconciler';
+import {
+ $internalCreateSelection,
+ $isNodeSelection,
+ $isRangeSelection,
+ applySelectionTransforms,
+ updateDOMSelection,
+} from './LexicalSelection';
+import {
+ $getCompositionKey,
+ getDOMSelection,
+ getEditorPropertyFromDOMNode,
+ getEditorStateTextContent,
+ getEditorsToPropagate,
+ getRegisteredNodeOrThrow,
+ isLexicalEditor,
+ removeDOMBlockCursorElement,
+ scheduleMicroTask,
+ updateDOMBlockCursorElement,
+} from './LexicalUtils';
+
+let activeEditorState: null | EditorState = null;
+let activeEditor: null | LexicalEditor = null;
+let isReadOnlyMode = false;
+let isAttemptingToRecoverFromReconcilerError = false;
+let infiniteTransformCount = 0;
+
+const observerOptions = {
+ characterData: true,
+ childList: true,
+ subtree: true,
+};
+
+export function isCurrentlyReadOnlyMode(): boolean {
+ return (
+ isReadOnlyMode ||
+ (activeEditorState !== null && activeEditorState._readOnly)
+ );
+}
+
+export function errorOnReadOnly(): void {
+ if (isReadOnlyMode) {
+ invariant(false, 'Cannot use method in read-only mode.');
+ }
+}
+
+export function errorOnInfiniteTransforms(): void {
+ if (infiniteTransformCount > 99) {
+ invariant(
+ false,
+ 'One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.',
+ );
+ }
+}
+
+export function getActiveEditorState(): EditorState {
+ if (activeEditorState === null) {
+ invariant(
+ false,
+ 'Unable to find an active editor state. ' +
+ 'State helpers or node methods can only be used ' +
+ 'synchronously during the callback of ' +
+ 'editor.update(), editor.read(), or editorState.read().%s',
+ collectBuildInformation(),
+ );
+ }
+
+ return activeEditorState;
+}
+
+export function getActiveEditor(): LexicalEditor {
+ if (activeEditor === null) {
+ invariant(
+ false,
+ 'Unable to find an active editor. ' +
+ 'This method can only be used ' +
+ 'synchronously during the callback of ' +
+ 'editor.update() or editor.read().%s',
+ collectBuildInformation(),
+ );
+ }
+ return activeEditor;
+}
+
+function collectBuildInformation(): string {
+ let compatibleEditors = 0;
+ const incompatibleEditors = new Set<string>();
+ const thisVersion = LexicalEditor.version;
+ if (typeof window !== 'undefined') {
+ for (const node of document.querySelectorAll('[contenteditable]')) {
+ const editor = getEditorPropertyFromDOMNode(node);
+ if (isLexicalEditor(editor)) {
+ compatibleEditors++;
+ } else if (editor) {
+ let version = String(
+ (
+ editor.constructor as typeof editor['constructor'] &
+ Record<string, unknown>
+ ).version || '<0.17.1',
+ );
+ if (version === thisVersion) {
+ version +=
+ ' (separately built, likely a bundler configuration issue)';
+ }
+ incompatibleEditors.add(version);
+ }
+ }
+ }
+ let output = ` Detected on the page: ${compatibleEditors} compatible editor(s) with version ${thisVersion}`;
+ if (incompatibleEditors.size) {
+ output += ` and incompatible editors with versions ${Array.from(
+ incompatibleEditors,
+ ).join(', ')}`;
+ }
+ return output;
+}
+
+export function internalGetActiveEditor(): LexicalEditor | null {
+ return activeEditor;
+}
+
+export function internalGetActiveEditorState(): EditorState | null {
+ return activeEditorState;
+}
+
+export function $applyTransforms(
+ editor: LexicalEditor,
+ node: LexicalNode,
+ transformsCache: Map<string, Array<Transform<LexicalNode>>>,
+) {
+ const type = node.__type;
+ const registeredNode = getRegisteredNodeOrThrow(editor, type);
+ let transformsArr = transformsCache.get(type);
+
+ if (transformsArr === undefined) {
+ transformsArr = Array.from(registeredNode.transforms);
+ transformsCache.set(type, transformsArr);
+ }
+
+ const transformsArrLength = transformsArr.length;
+
+ for (let i = 0; i < transformsArrLength; i++) {
+ transformsArr[i](node);
+
+ if (!node.isAttached()) {
+ break;
+ }
+ }
+}
+
+function $isNodeValidForTransform(
+ node: LexicalNode,
+ compositionKey: null | string,
+): boolean {
+ return (
+ node !== undefined &&
+ // We don't want to transform nodes being composed
+ node.__key !== compositionKey &&
+ node.isAttached()
+ );
+}
+
+function $normalizeAllDirtyTextNodes(
+ editorState: EditorState,
+ editor: LexicalEditor,
+): void {
+ const dirtyLeaves = editor._dirtyLeaves;
+ const nodeMap = editorState._nodeMap;
+
+ for (const nodeKey of dirtyLeaves) {
+ const node = nodeMap.get(nodeKey);
+
+ if (
+ $isTextNode(node) &&
+ node.isAttached() &&
+ node.isSimpleText() &&
+ !node.isUnmergeable()
+ ) {
+ $normalizeTextNode(node);
+ }
+ }
+}
+
+/**
+ * Transform heuristic:
+ * 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1.
+ * The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too.
+ * 2. We transform elements. If element transforms generate additional dirty nodes we repeat step 1.
+ * If element transforms only generate additional dirty elements we only repeat step 2.
+ *
+ * Note that to keep track of newly dirty nodes and subtrees we leverage the editor._dirtyNodes and
+ * editor._subtrees which we reset in every loop.
+ */
+function $applyAllTransforms(
+ editorState: EditorState,
+ editor: LexicalEditor,
+): void {
+ const dirtyLeaves = editor._dirtyLeaves;
+ const dirtyElements = editor._dirtyElements;
+ const nodeMap = editorState._nodeMap;
+ const compositionKey = $getCompositionKey();
+ const transformsCache = new Map();
+
+ let untransformedDirtyLeaves = dirtyLeaves;
+ let untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
+ let untransformedDirtyElements = dirtyElements;
+ let untransformedDirtyElementsLength = untransformedDirtyElements.size;
+
+ while (
+ untransformedDirtyLeavesLength > 0 ||
+ untransformedDirtyElementsLength > 0
+ ) {
+ if (untransformedDirtyLeavesLength > 0) {
+ // We leverage editor._dirtyLeaves to track the new dirty leaves after the transforms
+ editor._dirtyLeaves = new Set();
+
+ for (const nodeKey of untransformedDirtyLeaves) {
+ const node = nodeMap.get(nodeKey);
+
+ if (
+ $isTextNode(node) &&
+ node.isAttached() &&
+ node.isSimpleText() &&
+ !node.isUnmergeable()
+ ) {
+ $normalizeTextNode(node);
+ }
+
+ if (
+ node !== undefined &&
+ $isNodeValidForTransform(node, compositionKey)
+ ) {
+ $applyTransforms(editor, node, transformsCache);
+ }
+
+ dirtyLeaves.add(nodeKey);
+ }
+
+ untransformedDirtyLeaves = editor._dirtyLeaves;
+ untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
+
+ // We want to prioritize node transforms over element transforms
+ if (untransformedDirtyLeavesLength > 0) {
+ infiniteTransformCount++;
+ continue;
+ }
+ }
+
+ // All dirty leaves have been processed. Let's do elements!
+ // We have previously processed dirty leaves, so let's restart the editor leaves Set to track
+ // new ones caused by element transforms
+ editor._dirtyLeaves = new Set();
+ editor._dirtyElements = new Map();
+
+ for (const currentUntransformedDirtyElement of untransformedDirtyElements) {
+ const nodeKey = currentUntransformedDirtyElement[0];
+ const intentionallyMarkedAsDirty = currentUntransformedDirtyElement[1];
+ if (nodeKey !== 'root' && !intentionallyMarkedAsDirty) {
+ continue;
+ }
+
+ const node = nodeMap.get(nodeKey);
+
+ if (
+ node !== undefined &&
+ $isNodeValidForTransform(node, compositionKey)
+ ) {
+ $applyTransforms(editor, node, transformsCache);
+ }
+
+ dirtyElements.set(nodeKey, intentionallyMarkedAsDirty);
+ }
+
+ untransformedDirtyLeaves = editor._dirtyLeaves;
+ untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
+ untransformedDirtyElements = editor._dirtyElements;
+ untransformedDirtyElementsLength = untransformedDirtyElements.size;
+ infiniteTransformCount++;
+ }
+
+ editor._dirtyLeaves = dirtyLeaves;
+ editor._dirtyElements = dirtyElements;
+}
+
+type InternalSerializedNode = {
+ children?: Array<InternalSerializedNode>;
+ type: string;
+ version: number;
+};
+
+export function $parseSerializedNode(
+ serializedNode: SerializedLexicalNode,
+): LexicalNode {
+ const internalSerializedNode: InternalSerializedNode = serializedNode;
+ return $parseSerializedNodeImpl(
+ internalSerializedNode,
+ getActiveEditor()._nodes,
+ );
+}
+
+function $parseSerializedNodeImpl<
+ SerializedNode extends InternalSerializedNode,
+>(
+ serializedNode: SerializedNode,
+ registeredNodes: RegisteredNodes,
+): LexicalNode {
+ const type = serializedNode.type;
+ const registeredNode = registeredNodes.get(type);
+
+ if (registeredNode === undefined) {
+ invariant(false, 'parseEditorState: type "%s" + not found', type);
+ }
+
+ const nodeClass = registeredNode.klass;
+
+ if (serializedNode.type !== nodeClass.getType()) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .importJSON().',
+ nodeClass.name,
+ );
+ }
+
+ const node = nodeClass.importJSON(serializedNode);
+ const children = serializedNode.children;
+
+ if ($isElementNode(node) && Array.isArray(children)) {
+ for (let i = 0; i < children.length; i++) {
+ const serializedJSONChildNode = children[i];
+ const childNode = $parseSerializedNodeImpl(
+ serializedJSONChildNode,
+ registeredNodes,
+ );
+ node.append(childNode);
+ }
+ }
+
+ return node;
+}
+
+export function parseEditorState(
+ serializedEditorState: SerializedEditorState,
+ editor: LexicalEditor,
+ updateFn: void | (() => void),
+): EditorState {
+ const editorState = createEmptyEditorState();
+ const previousActiveEditorState = activeEditorState;
+ const previousReadOnlyMode = isReadOnlyMode;
+ const previousActiveEditor = activeEditor;
+ const previousDirtyElements = editor._dirtyElements;
+ const previousDirtyLeaves = editor._dirtyLeaves;
+ const previousCloneNotNeeded = editor._cloneNotNeeded;
+ const previousDirtyType = editor._dirtyType;
+ editor._dirtyElements = new Map();
+ editor._dirtyLeaves = new Set();
+ editor._cloneNotNeeded = new Set();
+ editor._dirtyType = 0;
+ activeEditorState = editorState;
+ isReadOnlyMode = false;
+ activeEditor = editor;
+
+ try {
+ const registeredNodes = editor._nodes;
+ const serializedNode = serializedEditorState.root;
+ $parseSerializedNodeImpl(serializedNode, registeredNodes);
+ if (updateFn) {
+ updateFn();
+ }
+
+ // Make the editorState immutable
+ editorState._readOnly = true;
+
+ if (__DEV__) {
+ handleDEVOnlyPendingUpdateGuarantees(editorState);
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ editor._onError(error);
+ }
+ } finally {
+ editor._dirtyElements = previousDirtyElements;
+ editor._dirtyLeaves = previousDirtyLeaves;
+ editor._cloneNotNeeded = previousCloneNotNeeded;
+ editor._dirtyType = previousDirtyType;
+ activeEditorState = previousActiveEditorState;
+ isReadOnlyMode = previousReadOnlyMode;
+ activeEditor = previousActiveEditor;
+ }
+
+ return editorState;
+}
+
+// This technically isn't an update but given we need
+// exposure to the module's active bindings, we have this
+// function here
+
+export function readEditorState<V>(
+ editor: LexicalEditor | null,
+ editorState: 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;
+ }
+}
+
+function handleDEVOnlyPendingUpdateGuarantees(
+ pendingEditorState: EditorState,
+): void {
+ // Given we can't Object.freeze the nodeMap as it's a Map,
+ // we instead replace its set, clear and delete methods.
+ const nodeMap = pendingEditorState._nodeMap;
+
+ nodeMap.set = () => {
+ throw new Error('Cannot call set() on a frozen Lexical node map');
+ };
+
+ nodeMap.clear = () => {
+ throw new Error('Cannot call clear() on a frozen Lexical node map');
+ };
+
+ nodeMap.delete = () => {
+ throw new Error('Cannot call delete() on a frozen Lexical node map');
+ };
+}
+
+export function $commitPendingUpdates(
+ editor: LexicalEditor,
+ recoveryEditorState?: EditorState,
+): void {
+ const pendingEditorState = editor._pendingEditorState;
+ const rootElement = editor._rootElement;
+ const shouldSkipDOM = editor._headless || rootElement === null;
+
+ if (pendingEditorState === null) {
+ return;
+ }
+
+ // ======
+ // Reconciliation has started.
+ // ======
+
+ const currentEditorState = editor._editorState;
+ const currentSelection = currentEditorState._selection;
+ const pendingSelection = pendingEditorState._selection;
+ const needsUpdate = editor._dirtyType !== NO_DIRTY_NODES;
+ const previousActiveEditorState = activeEditorState;
+ const previousReadOnlyMode = isReadOnlyMode;
+ const previousActiveEditor = activeEditor;
+ const previouslyUpdating = editor._updating;
+ const observer = editor._observer;
+ let mutatedNodes = null;
+ editor._pendingEditorState = null;
+ editor._editorState = pendingEditorState;
+
+ if (!shouldSkipDOM && needsUpdate && observer !== null) {
+ activeEditor = editor;
+ activeEditorState = pendingEditorState;
+ isReadOnlyMode = false;
+ // We don't want updates to sync block the reconciliation.
+ editor._updating = true;
+ try {
+ const dirtyType = editor._dirtyType;
+ const dirtyElements = editor._dirtyElements;
+ const dirtyLeaves = editor._dirtyLeaves;
+ observer.disconnect();
+
+ mutatedNodes = $reconcileRoot(
+ currentEditorState,
+ pendingEditorState,
+ editor,
+ dirtyType,
+ dirtyElements,
+ dirtyLeaves,
+ );
+ } catch (error) {
+ // Report errors
+ if (error instanceof Error) {
+ editor._onError(error);
+ }
+
+ // Reset editor and restore incoming editor state to the DOM
+ if (!isAttemptingToRecoverFromReconcilerError) {
+ resetEditor(editor, null, rootElement, pendingEditorState);
+ initMutationObserver(editor);
+ editor._dirtyType = FULL_RECONCILE;
+ isAttemptingToRecoverFromReconcilerError = true;
+ $commitPendingUpdates(editor, currentEditorState);
+ isAttemptingToRecoverFromReconcilerError = false;
+ } else {
+ // To avoid a possible situation of infinite loops, lets throw
+ throw error;
+ }
+
+ return;
+ } finally {
+ observer.observe(rootElement as Node, observerOptions);
+ editor._updating = previouslyUpdating;
+ activeEditorState = previousActiveEditorState;
+ isReadOnlyMode = previousReadOnlyMode;
+ activeEditor = previousActiveEditor;
+ }
+ }
+
+ if (!pendingEditorState._readOnly) {
+ pendingEditorState._readOnly = true;
+ if (__DEV__) {
+ handleDEVOnlyPendingUpdateGuarantees(pendingEditorState);
+ if ($isRangeSelection(pendingSelection)) {
+ Object.freeze(pendingSelection.anchor);
+ Object.freeze(pendingSelection.focus);
+ }
+ Object.freeze(pendingSelection);
+ }
+ }
+
+ const dirtyLeaves = editor._dirtyLeaves;
+ const dirtyElements = editor._dirtyElements;
+ const normalizedNodes = editor._normalizedNodes;
+ const tags = editor._updateTags;
+ const deferred = editor._deferred;
+ const nodeCount = pendingEditorState._nodeMap.size;
+
+ if (needsUpdate) {
+ editor._dirtyType = NO_DIRTY_NODES;
+ editor._cloneNotNeeded.clear();
+ editor._dirtyLeaves = new Set();
+ editor._dirtyElements = new Map();
+ editor._normalizedNodes = new Set();
+ editor._updateTags = new Set();
+ }
+ $garbageCollectDetachedDecorators(editor, pendingEditorState);
+
+ // ======
+ // Reconciliation has finished. Now update selection and trigger listeners.
+ // ======
+
+ const domSelection = shouldSkipDOM ? null : getDOMSelection(editor._window);
+
+ // Attempt to update the DOM selection, including focusing of the root element,
+ // and scroll into view if needed.
+ if (
+ editor._editable &&
+ // domSelection will be null in headless
+ domSelection !== null &&
+ (needsUpdate || pendingSelection === null || pendingSelection.dirty)
+ ) {
+ activeEditor = editor;
+ activeEditorState = pendingEditorState;
+ try {
+ if (observer !== null) {
+ observer.disconnect();
+ }
+ if (needsUpdate || pendingSelection === null || pendingSelection.dirty) {
+ const blockCursorElement = editor._blockCursorElement;
+ if (blockCursorElement !== null) {
+ removeDOMBlockCursorElement(
+ blockCursorElement,
+ editor,
+ rootElement as HTMLElement,
+ );
+ }
+ updateDOMSelection(
+ currentSelection,
+ pendingSelection,
+ editor,
+ domSelection,
+ tags,
+ rootElement as HTMLElement,
+ nodeCount,
+ );
+ }
+ updateDOMBlockCursorElement(
+ editor,
+ rootElement as HTMLElement,
+ pendingSelection,
+ );
+ if (observer !== null) {
+ observer.observe(rootElement as Node, observerOptions);
+ }
+ } finally {
+ activeEditor = previousActiveEditor;
+ activeEditorState = previousActiveEditorState;
+ }
+ }
+
+ if (mutatedNodes !== null) {
+ triggerMutationListeners(
+ editor,
+ mutatedNodes,
+ tags,
+ dirtyLeaves,
+ currentEditorState,
+ );
+ }
+ if (
+ !$isRangeSelection(pendingSelection) &&
+ pendingSelection !== null &&
+ (currentSelection === null || !currentSelection.is(pendingSelection))
+ ) {
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
+ }
+ /**
+ * Capture pendingDecorators after garbage collecting detached decorators
+ */
+ const pendingDecorators = editor._pendingDecorators;
+ if (pendingDecorators !== null) {
+ editor._decorators = pendingDecorators;
+ editor._pendingDecorators = null;
+ triggerListeners('decorator', editor, true, pendingDecorators);
+ }
+
+ // If reconciler fails, we reset whole editor (so current editor state becomes empty)
+ // and attempt to re-render pendingEditorState. If that goes through we trigger
+ // listeners, but instead use recoverEditorState which is current editor state before reset
+ // This specifically important for collab that relies on prevEditorState from update
+ // listener to calculate delta of changed nodes/properties
+ triggerTextContentListeners(
+ editor,
+ recoveryEditorState || currentEditorState,
+ pendingEditorState,
+ );
+ triggerListeners('update', editor, true, {
+ dirtyElements,
+ dirtyLeaves,
+ editorState: pendingEditorState,
+ normalizedNodes,
+ prevEditorState: recoveryEditorState || currentEditorState,
+ tags,
+ });
+ triggerDeferredUpdateCallbacks(editor, deferred);
+ $triggerEnqueuedUpdates(editor);
+}
+
+function triggerTextContentListeners(
+ editor: LexicalEditor,
+ currentEditorState: EditorState,
+ pendingEditorState: EditorState,
+): void {
+ const currentTextContent = getEditorStateTextContent(currentEditorState);
+ const latestTextContent = getEditorStateTextContent(pendingEditorState);
+
+ if (currentTextContent !== latestTextContent) {
+ triggerListeners('textcontent', editor, true, latestTextContent);
+ }
+}
+
+function triggerMutationListeners(
+ editor: LexicalEditor,
+ mutatedNodes: MutatedNodes,
+ updateTags: Set<string>,
+ dirtyLeaves: Set<string>,
+ prevEditorState: EditorState,
+): void {
+ const listeners = Array.from(editor._listeners.mutation);
+ const listenersLength = listeners.length;
+
+ for (let i = 0; i < listenersLength; i++) {
+ const [listener, klass] = listeners[i];
+ const mutatedNodesByType = mutatedNodes.get(klass);
+ if (mutatedNodesByType !== undefined) {
+ listener(mutatedNodesByType, {
+ dirtyLeaves,
+ prevEditorState,
+ updateTags,
+ });
+ }
+ }
+}
+
+export function triggerListeners(
+ type: 'update' | 'root' | 'decorator' | 'textcontent' | 'editable',
+ editor: LexicalEditor,
+ isCurrentlyEnqueuingUpdates: boolean,
+ ...payload: unknown[]
+): void {
+ const previouslyUpdating = editor._updating;
+ editor._updating = isCurrentlyEnqueuingUpdates;
+
+ try {
+ const listeners = Array.from<Listener>(editor._listeners[type]);
+ for (let i = 0; i < listeners.length; i++) {
+ // @ts-ignore
+ listeners[i].apply(null, payload);
+ }
+ } finally {
+ editor._updating = previouslyUpdating;
+ }
+}
+
+export function triggerCommandListeners<
+ TCommand extends LexicalCommand<unknown>,
+>(
+ editor: LexicalEditor,
+ type: TCommand,
+ payload: CommandPayloadType<TCommand>,
+): boolean {
+ if (editor._updating === false || activeEditor !== editor) {
+ let returnVal = false;
+ editor.update(() => {
+ returnVal = triggerCommandListeners(editor, type, payload);
+ });
+ return returnVal;
+ }
+
+ const editors = getEditorsToPropagate(editor);
+
+ for (let i = 4; i >= 0; i--) {
+ for (let e = 0; e < editors.length; e++) {
+ const currentEditor = editors[e];
+ const commandListeners = currentEditor._commands;
+ const listenerInPriorityOrder = commandListeners.get(type);
+
+ if (listenerInPriorityOrder !== undefined) {
+ const listenersSet = listenerInPriorityOrder[i];
+
+ if (listenersSet !== undefined) {
+ const listeners = Array.from(listenersSet);
+ const listenersLength = listeners.length;
+
+ for (let j = 0; j < listenersLength; j++) {
+ if (listeners[j](payload, editor) === true) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+function $triggerEnqueuedUpdates(editor: LexicalEditor): void {
+ const queuedUpdates = editor._updates;
+
+ if (queuedUpdates.length !== 0) {
+ const queuedUpdate = queuedUpdates.shift();
+ if (queuedUpdate) {
+ const [updateFn, options] = queuedUpdate;
+ $beginUpdate(editor, updateFn, options);
+ }
+ }
+}
+
+function triggerDeferredUpdateCallbacks(
+ editor: LexicalEditor,
+ deferred: Array<() => void>,
+): void {
+ editor._deferred = [];
+
+ if (deferred.length !== 0) {
+ const previouslyUpdating = editor._updating;
+ editor._updating = true;
+
+ try {
+ for (let i = 0; i < deferred.length; i++) {
+ deferred[i]();
+ }
+ } finally {
+ editor._updating = previouslyUpdating;
+ }
+ }
+}
+
+function processNestedUpdates(
+ editor: LexicalEditor,
+ initialSkipTransforms?: boolean,
+): boolean {
+ const queuedUpdates = editor._updates;
+ let skipTransforms = initialSkipTransforms || false;
+
+ // Updates might grow as we process them, we so we'll need
+ // to handle each update as we go until the updates array is
+ // empty.
+ while (queuedUpdates.length !== 0) {
+ const queuedUpdate = queuedUpdates.shift();
+ if (queuedUpdate) {
+ const [nextUpdateFn, options] = queuedUpdate;
+
+ let onUpdate;
+ let tag;
+
+ if (options !== undefined) {
+ onUpdate = options.onUpdate;
+ tag = options.tag;
+
+ if (options.skipTransforms) {
+ skipTransforms = true;
+ }
+ if (options.discrete) {
+ const pendingEditorState = editor._pendingEditorState;
+ invariant(
+ pendingEditorState !== null,
+ 'Unexpected empty pending editor state on discrete nested update',
+ );
+ pendingEditorState._flushSync = true;
+ }
+
+ if (onUpdate) {
+ editor._deferred.push(onUpdate);
+ }
+
+ if (tag) {
+ editor._updateTags.add(tag);
+ }
+ }
+
+ nextUpdateFn();
+ }
+ }
+
+ return skipTransforms;
+}
+
+function $beginUpdate(
+ editor: LexicalEditor,
+ updateFn: () => void,
+ options?: EditorUpdateOptions,
+): void {
+ const updateTags = editor._updateTags;
+ let onUpdate;
+ let tag;
+ let skipTransforms = false;
+ let discrete = false;
+
+ if (options !== undefined) {
+ onUpdate = options.onUpdate;
+ tag = options.tag;
+
+ if (tag != null) {
+ updateTags.add(tag);
+ }
+
+ skipTransforms = options.skipTransforms || false;
+ discrete = options.discrete || false;
+ }
+
+ if (onUpdate) {
+ editor._deferred.push(onUpdate);
+ }
+
+ const currentEditorState = editor._editorState;
+ let pendingEditorState = editor._pendingEditorState;
+ let editorStateWasCloned = false;
+
+ if (pendingEditorState === null || pendingEditorState._readOnly) {
+ pendingEditorState = editor._pendingEditorState = cloneEditorState(
+ pendingEditorState || currentEditorState,
+ );
+ editorStateWasCloned = true;
+ }
+ pendingEditorState._flushSync = discrete;
+
+ const previousActiveEditorState = activeEditorState;
+ const previousReadOnlyMode = isReadOnlyMode;
+ const previousActiveEditor = activeEditor;
+ const previouslyUpdating = editor._updating;
+ activeEditorState = pendingEditorState;
+ isReadOnlyMode = false;
+ editor._updating = true;
+ activeEditor = editor;
+
+ try {
+ if (editorStateWasCloned) {
+ if (editor._headless) {
+ if (currentEditorState._selection !== null) {
+ pendingEditorState._selection = currentEditorState._selection.clone();
+ }
+ } else {
+ pendingEditorState._selection = $internalCreateSelection(editor);
+ }
+ }
+
+ const startingCompositionKey = editor._compositionKey;
+ updateFn();
+ skipTransforms = processNestedUpdates(editor, skipTransforms);
+ applySelectionTransforms(pendingEditorState, editor);
+
+ if (editor._dirtyType !== NO_DIRTY_NODES) {
+ if (skipTransforms) {
+ $normalizeAllDirtyTextNodes(pendingEditorState, editor);
+ } else {
+ $applyAllTransforms(pendingEditorState, editor);
+ }
+
+ processNestedUpdates(editor);
+ $garbageCollectDetachedNodes(
+ currentEditorState,
+ pendingEditorState,
+ editor._dirtyLeaves,
+ editor._dirtyElements,
+ );
+ }
+
+ const endingCompositionKey = editor._compositionKey;
+
+ if (startingCompositionKey !== endingCompositionKey) {
+ pendingEditorState._flushSync = true;
+ }
+
+ const pendingSelection = pendingEditorState._selection;
+
+ if ($isRangeSelection(pendingSelection)) {
+ const pendingNodeMap = pendingEditorState._nodeMap;
+ const anchorKey = pendingSelection.anchor.key;
+ const focusKey = pendingSelection.focus.key;
+
+ if (
+ pendingNodeMap.get(anchorKey) === undefined ||
+ pendingNodeMap.get(focusKey) === undefined
+ ) {
+ invariant(
+ false,
+ 'updateEditor: selection has been lost because the previously selected nodes have been removed and ' +
+ "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.",
+ );
+ }
+ } else if ($isNodeSelection(pendingSelection)) {
+ // TODO: we should also validate node selection?
+ if (pendingSelection._nodes.size === 0) {
+ pendingEditorState._selection = null;
+ }
+ }
+ } catch (error) {
+ // Report errors
+ if (error instanceof Error) {
+ editor._onError(error);
+ }
+
+ // Restore existing editor state to the DOM
+ editor._pendingEditorState = currentEditorState;
+ editor._dirtyType = FULL_RECONCILE;
+
+ editor._cloneNotNeeded.clear();
+
+ editor._dirtyLeaves = new Set();
+
+ editor._dirtyElements.clear();
+
+ $commitPendingUpdates(editor);
+ return;
+ } finally {
+ activeEditorState = previousActiveEditorState;
+ isReadOnlyMode = previousReadOnlyMode;
+ activeEditor = previousActiveEditor;
+ editor._updating = previouslyUpdating;
+ infiniteTransformCount = 0;
+ }
+
+ const shouldUpdate =
+ editor._dirtyType !== NO_DIRTY_NODES ||
+ editorStateHasDirtySelection(pendingEditorState, editor);
+
+ if (shouldUpdate) {
+ if (pendingEditorState._flushSync) {
+ pendingEditorState._flushSync = false;
+ $commitPendingUpdates(editor);
+ } else if (editorStateWasCloned) {
+ scheduleMicroTask(() => {
+ $commitPendingUpdates(editor);
+ });
+ }
+ } else {
+ pendingEditorState._flushSync = false;
+
+ if (editorStateWasCloned) {
+ updateTags.clear();
+ editor._deferred = [];
+ editor._pendingEditorState = null;
+ }
+ }
+}
+
+export function updateEditor(
+ editor: LexicalEditor,
+ updateFn: () => void,
+ options?: EditorUpdateOptions,
+): void {
+ if (editor._updating) {
+ editor._updates.push([updateFn, options]);
+ } else {
+ $beginUpdate(editor, updateFn, options);
+ }
+}
--- /dev/null
+/**
+ * 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 {
+ CommandPayloadType,
+ EditorConfig,
+ EditorThemeClasses,
+ Klass,
+ LexicalCommand,
+ MutatedNodes,
+ MutationListeners,
+ NodeMutation,
+ RegisteredNode,
+ RegisteredNodes,
+ Spread,
+} from './LexicalEditor';
+import type {EditorState} from './LexicalEditorState';
+import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode';
+import type {
+ BaseSelection,
+ PointType,
+ RangeSelection,
+} from './LexicalSelection';
+import type {RootNode} from './nodes/LexicalRootNode';
+import type {TextFormatType, TextNode} from './nodes/LexicalTextNode';
+
+import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
+import {IS_APPLE, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'lexical/shared/environment';
+import invariant from 'lexical/shared/invariant';
+import normalizeClassNames from 'lexical/shared/normalizeClassNames';
+
+import {
+ $createTextNode,
+ $getPreviousSelection,
+ $getSelection,
+ $isDecoratorNode,
+ $isElementNode,
+ $isLineBreakNode,
+ $isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ DecoratorNode,
+ ElementNode,
+ LineBreakNode,
+} from '.';
+import {
+ COMPOSITION_SUFFIX,
+ DOM_TEXT_TYPE,
+ HAS_DIRTY_NODES,
+ LTR_REGEX,
+ RTL_REGEX,
+ TEXT_TYPE_TO_FORMAT,
+} from './LexicalConstants';
+import {LexicalEditor} from './LexicalEditor';
+import {$flushRootMutations} from './LexicalMutations';
+import {$normalizeSelection} from './LexicalNormalization';
+import {
+ errorOnInfiniteTransforms,
+ errorOnReadOnly,
+ getActiveEditor,
+ getActiveEditorState,
+ internalGetActiveEditorState,
+ isCurrentlyReadOnlyMode,
+ triggerCommandListeners,
+ updateEditor,
+} from './LexicalUpdates';
+
+export const emptyFunction = () => {
+ return;
+};
+
+let keyCounter = 1;
+
+export function resetRandomKey(): void {
+ keyCounter = 1;
+}
+
+export function generateRandomKey(): string {
+ return '' + keyCounter++;
+}
+
+export function getRegisteredNodeOrThrow(
+ editor: LexicalEditor,
+ nodeType: string,
+): RegisteredNode {
+ const registeredNode = editor._nodes.get(nodeType);
+ if (registeredNode === undefined) {
+ invariant(false, 'registeredNode: Type %s not found', nodeType);
+ }
+ return registeredNode;
+}
+
+export const isArray = Array.isArray;
+
+export const scheduleMicroTask: (fn: () => void) => void =
+ typeof queueMicrotask === 'function'
+ ? queueMicrotask
+ : (fn) => {
+ // No window prefix intended (#1400)
+ Promise.resolve().then(fn);
+ };
+
+export function $isSelectionCapturedInDecorator(node: Node): boolean {
+ return $isDecoratorNode($getNearestNodeFromDOMNode(node));
+}
+
+export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean {
+ const activeElement = document.activeElement as HTMLElement;
+
+ if (activeElement === null) {
+ return false;
+ }
+ const nodeName = activeElement.nodeName;
+
+ return (
+ $isDecoratorNode($getNearestNodeFromDOMNode(anchorDOM)) &&
+ (nodeName === 'INPUT' ||
+ nodeName === 'TEXTAREA' ||
+ (activeElement.contentEditable === 'true' &&
+ getEditorPropertyFromDOMNode(activeElement) == null))
+ );
+}
+
+export function isSelectionWithinEditor(
+ editor: LexicalEditor,
+ anchorDOM: null | Node,
+ focusDOM: null | Node,
+): boolean {
+ const rootElement = editor.getRootElement();
+ try {
+ return (
+ rootElement !== null &&
+ rootElement.contains(anchorDOM) &&
+ rootElement.contains(focusDOM) &&
+ // Ignore if selection is within nested editor
+ anchorDOM !== null &&
+ !isSelectionCapturedInDecoratorInput(anchorDOM as Node) &&
+ getNearestEditorFromDOMNode(anchorDOM) === editor
+ );
+ } catch (error) {
+ return false;
+ }
+}
+
+/**
+ * @returns true if the given argument is a LexicalEditor instance from this build of Lexical
+ */
+export function isLexicalEditor(editor: unknown): editor is LexicalEditor {
+ // Check instanceof to prevent issues with multiple embedded Lexical installations
+ return editor instanceof LexicalEditor;
+}
+
+export function getNearestEditorFromDOMNode(
+ node: Node | null,
+): LexicalEditor | null {
+ let currentNode = node;
+ while (currentNode != null) {
+ const editor = getEditorPropertyFromDOMNode(currentNode);
+ if (isLexicalEditor(editor)) {
+ return editor;
+ }
+ currentNode = getParentElement(currentNode);
+ }
+ return null;
+}
+
+/** @internal */
+export function getEditorPropertyFromDOMNode(node: Node | null): unknown {
+ // @ts-expect-error: internal field
+ return node ? node.__lexicalEditor : null;
+}
+
+export function getTextDirection(text: string): 'ltr' | 'rtl' | null {
+ if (RTL_REGEX.test(text)) {
+ return 'rtl';
+ }
+ if (LTR_REGEX.test(text)) {
+ return 'ltr';
+ }
+ return null;
+}
+
+export function $isTokenOrSegmented(node: TextNode): boolean {
+ return node.isToken() || node.isSegmented();
+}
+
+function isDOMNodeLexicalTextNode(node: Node): node is Text {
+ return node.nodeType === DOM_TEXT_TYPE;
+}
+
+export function getDOMTextNode(element: Node | null): Text | null {
+ let node = element;
+ while (node != null) {
+ if (isDOMNodeLexicalTextNode(node)) {
+ return node;
+ }
+ node = node.firstChild;
+ }
+ return null;
+}
+
+export function toggleTextFormatType(
+ format: number,
+ type: TextFormatType,
+ alignWithFormat: null | number,
+): number {
+ const activeFormat = TEXT_TYPE_TO_FORMAT[type];
+ if (
+ alignWithFormat !== null &&
+ (format & activeFormat) === (alignWithFormat & activeFormat)
+ ) {
+ return format;
+ }
+ let newFormat = format ^ activeFormat;
+ if (type === 'subscript') {
+ newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript;
+ } else if (type === 'superscript') {
+ newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript;
+ }
+ return newFormat;
+}
+
+export function $isLeafNode(
+ node: LexicalNode | null | undefined,
+): node is TextNode | LineBreakNode | DecoratorNode<unknown> {
+ return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node);
+}
+
+export function $setNodeKey(
+ node: LexicalNode,
+ existingKey: NodeKey | null | undefined,
+): void {
+ if (existingKey != null) {
+ if (__DEV__) {
+ errorOnNodeKeyConstructorMismatch(node, existingKey);
+ }
+ node.__key = existingKey;
+ return;
+ }
+ errorOnReadOnly();
+ errorOnInfiniteTransforms();
+ const editor = getActiveEditor();
+ const editorState = getActiveEditorState();
+ const key = generateRandomKey();
+ editorState._nodeMap.set(key, node);
+ // TODO Split this function into leaf/element
+ if ($isElementNode(node)) {
+ editor._dirtyElements.set(key, true);
+ } else {
+ editor._dirtyLeaves.add(key);
+ }
+ editor._cloneNotNeeded.add(key);
+ editor._dirtyType = HAS_DIRTY_NODES;
+ node.__key = key;
+}
+
+function errorOnNodeKeyConstructorMismatch(
+ node: LexicalNode,
+ existingKey: NodeKey,
+) {
+ const editorState = internalGetActiveEditorState();
+ if (!editorState) {
+ // tests expect to be able to do this kind of clone without an active editor state
+ return;
+ }
+ const existingNode = editorState._nodeMap.get(existingKey);
+ if (existingNode && existingNode.constructor !== node.constructor) {
+ // Lifted condition to if statement because the inverted logic is a bit confusing
+ if (node.constructor.name !== existingNode.constructor.name) {
+ invariant(
+ false,
+ 'Lexical node with constructor %s attempted to re-use key from node in active editor state with constructor %s. Keys must not be re-used when the type is changed.',
+ node.constructor.name,
+ existingNode.constructor.name,
+ );
+ } else {
+ invariant(
+ false,
+ 'Lexical node with constructor %s attempted to re-use key from node in active editor state with different constructor with the same name (possibly due to invalid Hot Module Replacement). Keys must not be re-used when the type is changed.',
+ node.constructor.name,
+ );
+ }
+ }
+}
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+function internalMarkParentElementsAsDirty(
+ parentKey: NodeKey,
+ nodeMap: NodeMap,
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+): void {
+ let nextParentKey: string | null = parentKey;
+ while (nextParentKey !== null) {
+ if (dirtyElements.has(nextParentKey)) {
+ return;
+ }
+ const node = nodeMap.get(nextParentKey);
+ if (node === undefined) {
+ break;
+ }
+ dirtyElements.set(nextParentKey, false);
+ nextParentKey = node.__parent;
+ }
+}
+
+// TODO #6031 this function or their callers have to adjust selection (i.e. insertBefore)
+export function removeFromParent(node: LexicalNode): void {
+ const oldParent = node.getParent();
+ if (oldParent !== null) {
+ const writableNode = node.getWritable();
+ const writableParent = oldParent.getWritable();
+ const prevSibling = node.getPreviousSibling();
+ const nextSibling = node.getNextSibling();
+ // TODO: this function duplicates a bunch of operations, can be simplified.
+ if (prevSibling === null) {
+ if (nextSibling !== null) {
+ const writableNextSibling = nextSibling.getWritable();
+ writableParent.__first = nextSibling.__key;
+ writableNextSibling.__prev = null;
+ } else {
+ writableParent.__first = null;
+ }
+ } else {
+ const writablePrevSibling = prevSibling.getWritable();
+ if (nextSibling !== null) {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = writablePrevSibling.__key;
+ writablePrevSibling.__next = writableNextSibling.__key;
+ } else {
+ writablePrevSibling.__next = null;
+ }
+ writableNode.__prev = null;
+ }
+ if (nextSibling === null) {
+ if (prevSibling !== null) {
+ const writablePrevSibling = prevSibling.getWritable();
+ writableParent.__last = prevSibling.__key;
+ writablePrevSibling.__next = null;
+ } else {
+ writableParent.__last = null;
+ }
+ } else {
+ const writableNextSibling = nextSibling.getWritable();
+ if (prevSibling !== null) {
+ const writablePrevSibling = prevSibling.getWritable();
+ writablePrevSibling.__next = writableNextSibling.__key;
+ writableNextSibling.__prev = writablePrevSibling.__key;
+ } else {
+ writableNextSibling.__prev = null;
+ }
+ writableNode.__next = null;
+ }
+ writableParent.__size--;
+ writableNode.__parent = null;
+ }
+}
+
+// Never use this function directly! It will break
+// the cloning heuristic. Instead use node.getWritable().
+export function internalMarkNodeAsDirty(node: LexicalNode): void {
+ errorOnInfiniteTransforms();
+ const latest = node.getLatest();
+ const parent = latest.__parent;
+ const editorState = getActiveEditorState();
+ const editor = getActiveEditor();
+ const nodeMap = editorState._nodeMap;
+ const dirtyElements = editor._dirtyElements;
+ if (parent !== null) {
+ internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements);
+ }
+ const key = latest.__key;
+ editor._dirtyType = HAS_DIRTY_NODES;
+ if ($isElementNode(node)) {
+ dirtyElements.set(key, true);
+ } else {
+ // TODO split internally MarkNodeAsDirty into two dedicated Element/leave functions
+ editor._dirtyLeaves.add(key);
+ }
+}
+
+export function internalMarkSiblingsAsDirty(node: LexicalNode) {
+ const previousNode = node.getPreviousSibling();
+ const nextNode = node.getNextSibling();
+ if (previousNode !== null) {
+ internalMarkNodeAsDirty(previousNode);
+ }
+ if (nextNode !== null) {
+ internalMarkNodeAsDirty(nextNode);
+ }
+}
+
+export function $setCompositionKey(compositionKey: null | NodeKey): void {
+ errorOnReadOnly();
+ const editor = getActiveEditor();
+ const previousCompositionKey = editor._compositionKey;
+ if (compositionKey !== previousCompositionKey) {
+ editor._compositionKey = compositionKey;
+ if (previousCompositionKey !== null) {
+ const node = $getNodeByKey(previousCompositionKey);
+ if (node !== null) {
+ node.getWritable();
+ }
+ }
+ if (compositionKey !== null) {
+ const node = $getNodeByKey(compositionKey);
+ if (node !== null) {
+ node.getWritable();
+ }
+ }
+ }
+}
+
+export function $getCompositionKey(): null | NodeKey {
+ if (isCurrentlyReadOnlyMode()) {
+ return null;
+ }
+ const editor = getActiveEditor();
+ return editor._compositionKey;
+}
+
+export function $getNodeByKey<T extends LexicalNode>(
+ key: NodeKey,
+ _editorState?: EditorState,
+): T | null {
+ const editorState = _editorState || getActiveEditorState();
+ const node = editorState._nodeMap.get(key) as T;
+ if (node === undefined) {
+ return null;
+ }
+ return node;
+}
+
+export function $getNodeFromDOMNode(
+ dom: Node,
+ editorState?: EditorState,
+): LexicalNode | null {
+ const editor = getActiveEditor();
+ // @ts-ignore We intentionally add this to the Node.
+ const key = dom[`__lexicalKey_${editor._key}`];
+ if (key !== undefined) {
+ return $getNodeByKey(key, editorState);
+ }
+ return null;
+}
+
+export function $getNearestNodeFromDOMNode(
+ startingDOM: Node,
+ editorState?: EditorState,
+): LexicalNode | null {
+ let dom: Node | null = startingDOM;
+ while (dom != null) {
+ const node = $getNodeFromDOMNode(dom, editorState);
+ if (node !== null) {
+ return node;
+ }
+ dom = getParentElement(dom);
+ }
+ return null;
+}
+
+export function cloneDecorators(
+ editor: LexicalEditor,
+): Record<NodeKey, unknown> {
+ const currentDecorators = editor._decorators;
+ const pendingDecorators = Object.assign({}, currentDecorators);
+ editor._pendingDecorators = pendingDecorators;
+ return pendingDecorators;
+}
+
+export function getEditorStateTextContent(editorState: EditorState): string {
+ return editorState.read(() => $getRoot().getTextContent());
+}
+
+export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void {
+ // Mark all existing text nodes as dirty
+ updateEditor(
+ editor,
+ () => {
+ const editorState = getActiveEditorState();
+ if (editorState.isEmpty()) {
+ return;
+ }
+ if (type === 'root') {
+ $getRoot().markDirty();
+ return;
+ }
+ const nodeMap = editorState._nodeMap;
+ for (const [, node] of nodeMap) {
+ node.markDirty();
+ }
+ },
+ editor._pendingEditorState === null
+ ? {
+ tag: 'history-merge',
+ }
+ : undefined,
+ );
+}
+
+export function $getRoot(): RootNode {
+ return internalGetRoot(getActiveEditorState());
+}
+
+export function internalGetRoot(editorState: EditorState): RootNode {
+ return editorState._nodeMap.get('root') as RootNode;
+}
+
+export function $setSelection(selection: null | BaseSelection): void {
+ errorOnReadOnly();
+ const editorState = getActiveEditorState();
+ if (selection !== null) {
+ if (__DEV__) {
+ if (Object.isFrozen(selection)) {
+ invariant(
+ false,
+ '$setSelection called on frozen selection object. Ensure selection is cloned before passing in.',
+ );
+ }
+ }
+ selection.dirty = true;
+ selection.setCachedNodes(null);
+ }
+ editorState._selection = selection;
+}
+
+export function $flushMutations(): void {
+ errorOnReadOnly();
+ const editor = getActiveEditor();
+ $flushRootMutations(editor);
+}
+
+export function $getNodeFromDOM(dom: Node): null | LexicalNode {
+ const editor = getActiveEditor();
+ const nodeKey = getNodeKeyFromDOM(dom, editor);
+ if (nodeKey === null) {
+ const rootElement = editor.getRootElement();
+ if (dom === rootElement) {
+ return $getNodeByKey('root');
+ }
+ return null;
+ }
+ return $getNodeByKey(nodeKey);
+}
+
+export function getTextNodeOffset(
+ node: TextNode,
+ moveSelectionToEnd: boolean,
+): number {
+ return moveSelectionToEnd ? node.getTextContentSize() : 0;
+}
+
+function getNodeKeyFromDOM(
+ // Note that node here refers to a DOM Node, not an Lexical Node
+ dom: Node,
+ editor: LexicalEditor,
+): NodeKey | null {
+ let node: Node | null = dom;
+ while (node != null) {
+ // @ts-ignore We intentionally add this to the Node.
+ const key: NodeKey = node[`__lexicalKey_${editor._key}`];
+ if (key !== undefined) {
+ return key;
+ }
+ node = getParentElement(node);
+ }
+ return null;
+}
+
+export function doesContainGrapheme(str: string): boolean {
+ return /[\uD800-\uDBFF][\uDC00-\uDFFF]/g.test(str);
+}
+
+export function getEditorsToPropagate(
+ editor: LexicalEditor,
+): Array<LexicalEditor> {
+ const editorsToPropagate = [];
+ let currentEditor: LexicalEditor | null = editor;
+ while (currentEditor !== null) {
+ editorsToPropagate.push(currentEditor);
+ currentEditor = currentEditor._parentEditor;
+ }
+ return editorsToPropagate;
+}
+
+export function createUID(): string {
+ return Math.random()
+ .toString(36)
+ .replace(/[^a-z]+/g, '')
+ .substr(0, 5);
+}
+
+export function getAnchorTextFromDOM(anchorNode: Node): null | string {
+ if (anchorNode.nodeType === DOM_TEXT_TYPE) {
+ return anchorNode.nodeValue;
+ }
+ return null;
+}
+
+export function $updateSelectedTextFromDOM(
+ isCompositionEnd: boolean,
+ editor: LexicalEditor,
+ data?: string,
+): void {
+ // Update the text content with the latest composition text
+ const domSelection = getDOMSelection(editor._window);
+ if (domSelection === null) {
+ return;
+ }
+ const anchorNode = domSelection.anchorNode;
+ let {anchorOffset, focusOffset} = domSelection;
+ if (anchorNode !== null) {
+ let textContent = getAnchorTextFromDOM(anchorNode);
+ const node = $getNearestNodeFromDOMNode(anchorNode);
+ if (textContent !== null && $isTextNode(node)) {
+ // Data is intentionally truthy, as we check for boolean, null and empty string.
+ if (textContent === COMPOSITION_SUFFIX && data) {
+ const offset = data.length;
+ textContent = data;
+ anchorOffset = offset;
+ focusOffset = offset;
+ }
+
+ if (textContent !== null) {
+ $updateTextNodeFromDOMContent(
+ node,
+ textContent,
+ anchorOffset,
+ focusOffset,
+ isCompositionEnd,
+ );
+ }
+ }
+ }
+}
+
+export function $updateTextNodeFromDOMContent(
+ textNode: TextNode,
+ textContent: string,
+ anchorOffset: null | number,
+ focusOffset: null | number,
+ compositionEnd: boolean,
+): void {
+ let node = textNode;
+
+ if (node.isAttached() && (compositionEnd || !node.isDirty())) {
+ const isComposing = node.isComposing();
+ let normalizedTextContent = textContent;
+
+ if (
+ (isComposing || compositionEnd) &&
+ textContent[textContent.length - 1] === COMPOSITION_SUFFIX
+ ) {
+ normalizedTextContent = textContent.slice(0, -1);
+ }
+ const prevTextContent = node.getTextContent();
+
+ if (compositionEnd || normalizedTextContent !== prevTextContent) {
+ if (normalizedTextContent === '') {
+ $setCompositionKey(null);
+ if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT) {
+ // For composition (mainly Android), we have to remove the node on a later update
+ const editor = getActiveEditor();
+ setTimeout(() => {
+ editor.update(() => {
+ if (node.isAttached()) {
+ node.remove();
+ }
+ });
+ }, 20);
+ } else {
+ node.remove();
+ }
+ return;
+ }
+ const parent = node.getParent();
+ const prevSelection = $getPreviousSelection();
+ const prevTextContentSize = node.getTextContentSize();
+ const compositionKey = $getCompositionKey();
+ const nodeKey = node.getKey();
+
+ if (
+ node.isToken() ||
+ (compositionKey !== null &&
+ nodeKey === compositionKey &&
+ !isComposing) ||
+ // Check if character was added at the start or boundaries when not insertable, and we need
+ // to clear this input from occurring as that action wasn't permitted.
+ ($isRangeSelection(prevSelection) &&
+ ((parent !== null &&
+ !parent.canInsertTextBefore() &&
+ prevSelection.anchor.offset === 0) ||
+ (prevSelection.anchor.key === textNode.__key &&
+ prevSelection.anchor.offset === 0 &&
+ !node.canInsertTextBefore() &&
+ !isComposing) ||
+ (prevSelection.focus.key === textNode.__key &&
+ prevSelection.focus.offset === prevTextContentSize &&
+ !node.canInsertTextAfter() &&
+ !isComposing)))
+ ) {
+ node.markDirty();
+ return;
+ }
+ const selection = $getSelection();
+
+ if (
+ !$isRangeSelection(selection) ||
+ anchorOffset === null ||
+ focusOffset === null
+ ) {
+ node.setTextContent(normalizedTextContent);
+ return;
+ }
+ selection.setTextNodeRange(node, anchorOffset, node, focusOffset);
+
+ if (node.isSegmented()) {
+ const originalTextContent = node.getTextContent();
+ const replacement = $createTextNode(originalTextContent);
+ node.replace(replacement);
+ node = replacement;
+ }
+ node.setTextContent(normalizedTextContent);
+ }
+ }
+}
+
+function $previousSiblingDoesNotAcceptText(node: TextNode): boolean {
+ const previousSibling = node.getPreviousSibling();
+
+ return (
+ ($isTextNode(previousSibling) ||
+ ($isElementNode(previousSibling) && previousSibling.isInline())) &&
+ !previousSibling.canInsertTextAfter()
+ );
+}
+
+// This function is connected to $shouldPreventDefaultAndInsertText and determines whether the
+// TextNode boundaries are writable or we should use the previous/next sibling instead. For example,
+// in the case of a LinkNode, boundaries are not writable.
+export function $shouldInsertTextAfterOrBeforeTextNode(
+ selection: RangeSelection,
+ node: TextNode,
+): boolean {
+ if (node.isSegmented()) {
+ return true;
+ }
+ if (!selection.isCollapsed()) {
+ return false;
+ }
+ const offset = selection.anchor.offset;
+ const parent = node.getParentOrThrow();
+ const isToken = node.isToken();
+ if (offset === 0) {
+ return (
+ !node.canInsertTextBefore() ||
+ (!parent.canInsertTextBefore() && !node.isComposing()) ||
+ isToken ||
+ $previousSiblingDoesNotAcceptText(node)
+ );
+ } else if (offset === node.getTextContentSize()) {
+ return (
+ !node.canInsertTextAfter() ||
+ (!parent.canInsertTextAfter() && !node.isComposing()) ||
+ isToken
+ );
+ } else {
+ return false;
+ }
+}
+
+export function isTab(
+ key: string,
+ altKey: boolean,
+ ctrlKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return key === 'Tab' && !altKey && !ctrlKey && !metaKey;
+}
+
+export function isBold(
+ key: string,
+ altKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return (
+ key.toLowerCase() === 'b' && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isItalic(
+ key: string,
+ altKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return (
+ key.toLowerCase() === 'i' && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isUnderline(
+ key: string,
+ altKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return (
+ key.toLowerCase() === 'u' && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isParagraph(key: string, shiftKey: boolean): boolean {
+ return isReturn(key) && !shiftKey;
+}
+
+export function isLineBreak(key: string, shiftKey: boolean): boolean {
+ return isReturn(key) && shiftKey;
+}
+
+// Inserts a new line after the selection
+
+export function isOpenLineBreak(key: string, ctrlKey: boolean): boolean {
+ // 79 = KeyO
+ return IS_APPLE && ctrlKey && key.toLowerCase() === 'o';
+}
+
+export function isDeleteWordBackward(
+ key: string,
+ altKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return isBackspace(key) && (IS_APPLE ? altKey : ctrlKey);
+}
+
+export function isDeleteWordForward(
+ key: string,
+ altKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return isDelete(key) && (IS_APPLE ? altKey : ctrlKey);
+}
+
+export function isDeleteLineBackward(key: string, metaKey: boolean): boolean {
+ return IS_APPLE && metaKey && isBackspace(key);
+}
+
+export function isDeleteLineForward(key: string, metaKey: boolean): boolean {
+ return IS_APPLE && metaKey && isDelete(key);
+}
+
+export function isDeleteBackward(
+ key: string,
+ altKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ if (IS_APPLE) {
+ if (altKey || metaKey) {
+ return false;
+ }
+ return isBackspace(key) || (key.toLowerCase() === 'h' && ctrlKey);
+ }
+ if (ctrlKey || altKey || metaKey) {
+ return false;
+ }
+ return isBackspace(key);
+}
+
+export function isDeleteForward(
+ key: string,
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ altKey: boolean,
+ metaKey: boolean,
+): boolean {
+ if (IS_APPLE) {
+ if (shiftKey || altKey || metaKey) {
+ return false;
+ }
+ return isDelete(key) || (key.toLowerCase() === 'd' && ctrlKey);
+ }
+ if (ctrlKey || altKey || metaKey) {
+ return false;
+ }
+ return isDelete(key);
+}
+
+export function isUndo(
+ key: string,
+ shiftKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return (
+ key.toLowerCase() === 'z' && !shiftKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isRedo(
+ key: string,
+ shiftKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ if (IS_APPLE) {
+ return key.toLowerCase() === 'z' && metaKey && shiftKey;
+ }
+ return (
+ (key.toLowerCase() === 'y' && ctrlKey) ||
+ (key.toLowerCase() === 'z' && ctrlKey && shiftKey)
+ );
+}
+
+export function isCopy(
+ key: string,
+ shiftKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ if (shiftKey) {
+ return false;
+ }
+ if (key.toLowerCase() === 'c') {
+ return IS_APPLE ? metaKey : ctrlKey;
+ }
+
+ return false;
+}
+
+export function isCut(
+ key: string,
+ shiftKey: boolean,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ if (shiftKey) {
+ return false;
+ }
+ if (key.toLowerCase() === 'x') {
+ return IS_APPLE ? metaKey : ctrlKey;
+ }
+
+ return false;
+}
+
+function isArrowLeft(key: string): boolean {
+ return key === 'ArrowLeft';
+}
+
+function isArrowRight(key: string): boolean {
+ return key === 'ArrowRight';
+}
+
+function isArrowUp(key: string): boolean {
+ return key === 'ArrowUp';
+}
+
+function isArrowDown(key: string): boolean {
+ return key === 'ArrowDown';
+}
+
+export function isMoveBackward(
+ key: string,
+ ctrlKey: boolean,
+ altKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return isArrowLeft(key) && !ctrlKey && !metaKey && !altKey;
+}
+
+export function isMoveToStart(
+ key: string,
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ altKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return isArrowLeft(key) && !altKey && !shiftKey && (ctrlKey || metaKey);
+}
+
+export function isMoveForward(
+ key: string,
+ ctrlKey: boolean,
+ altKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return isArrowRight(key) && !ctrlKey && !metaKey && !altKey;
+}
+
+export function isMoveToEnd(
+ key: string,
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ altKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return isArrowRight(key) && !altKey && !shiftKey && (ctrlKey || metaKey);
+}
+
+export function isMoveUp(
+ key: string,
+ ctrlKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return isArrowUp(key) && !ctrlKey && !metaKey;
+}
+
+export function isMoveDown(
+ key: string,
+ ctrlKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return isArrowDown(key) && !ctrlKey && !metaKey;
+}
+
+export function isModifier(
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ altKey: boolean,
+ metaKey: boolean,
+): boolean {
+ return ctrlKey || shiftKey || altKey || metaKey;
+}
+
+export function isSpace(key: string): boolean {
+ return key === ' ';
+}
+
+export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean {
+ if (IS_APPLE) {
+ return metaKey;
+ }
+ return ctrlKey;
+}
+
+export function isReturn(key: string): boolean {
+ return key === 'Enter';
+}
+
+export function isBackspace(key: string): boolean {
+ return key === 'Backspace';
+}
+
+export function isEscape(key: string): boolean {
+ return key === 'Escape';
+}
+
+export function isDelete(key: string): boolean {
+ return key === 'Delete';
+}
+
+export function isSelectAll(
+ key: string,
+ metaKey: boolean,
+ ctrlKey: boolean,
+): boolean {
+ return key.toLowerCase() === 'a' && controlOrMeta(metaKey, ctrlKey);
+}
+
+export function $selectAll(): void {
+ const root = $getRoot();
+ const selection = root.select(0, root.getChildrenSize());
+ $setSelection($normalizeSelection(selection));
+}
+
+export function getCachedClassNameArray(
+ classNamesTheme: EditorThemeClasses,
+ classNameThemeType: string,
+): Array<string> {
+ if (classNamesTheme.__lexicalClassNameCache === undefined) {
+ classNamesTheme.__lexicalClassNameCache = {};
+ }
+ const classNamesCache = classNamesTheme.__lexicalClassNameCache;
+ const cachedClassNames = classNamesCache[classNameThemeType];
+ if (cachedClassNames !== undefined) {
+ return cachedClassNames;
+ }
+ const classNames = classNamesTheme[classNameThemeType];
+ // As we're using classList, we need
+ // to handle className tokens that have spaces.
+ // The easiest way to do this to convert the
+ // className tokens to an array that can be
+ // applied to classList.add()/remove().
+ if (typeof classNames === 'string') {
+ const classNamesArr = normalizeClassNames(classNames);
+ classNamesCache[classNameThemeType] = classNamesArr;
+ return classNamesArr;
+ }
+ return classNames;
+}
+
+export function setMutatedNode(
+ mutatedNodes: MutatedNodes,
+ registeredNodes: RegisteredNodes,
+ mutationListeners: MutationListeners,
+ node: LexicalNode,
+ mutation: NodeMutation,
+) {
+ if (mutationListeners.size === 0) {
+ return;
+ }
+ const nodeType = node.__type;
+ const nodeKey = node.__key;
+ const registeredNode = registeredNodes.get(nodeType);
+ if (registeredNode === undefined) {
+ invariant(false, 'Type %s not in registeredNodes', nodeType);
+ }
+ const klass = registeredNode.klass;
+ let mutatedNodesByType = mutatedNodes.get(klass);
+ if (mutatedNodesByType === undefined) {
+ mutatedNodesByType = new Map();
+ mutatedNodes.set(klass, mutatedNodesByType);
+ }
+ const prevMutation = mutatedNodesByType.get(nodeKey);
+ // If the node has already been "destroyed", yet we are
+ // re-making it, then this means a move likely happened.
+ // We should change the mutation to be that of "updated"
+ // instead.
+ const isMove = prevMutation === 'destroyed' && mutation === 'created';
+ if (prevMutation === undefined || isMove) {
+ mutatedNodesByType.set(nodeKey, isMove ? 'updated' : mutation);
+ }
+}
+
+export function $nodesOfType<T extends LexicalNode>(klass: Klass<T>): Array<T> {
+ const klassType = klass.getType();
+ const editorState = getActiveEditorState();
+ if (editorState._readOnly) {
+ const nodes = getCachedTypeToNodeMap(editorState).get(klassType) as
+ | undefined
+ | Map<string, T>;
+ return nodes ? Array.from(nodes.values()) : [];
+ }
+ const nodes = editorState._nodeMap;
+ const nodesOfType: Array<T> = [];
+ for (const [, node] of nodes) {
+ if (
+ node instanceof klass &&
+ node.__type === klassType &&
+ node.isAttached()
+ ) {
+ nodesOfType.push(node as T);
+ }
+ }
+ return nodesOfType;
+}
+
+function resolveElement(
+ element: ElementNode,
+ isBackward: boolean,
+ focusOffset: number,
+): LexicalNode | null {
+ const parent = element.getParent();
+ let offset = focusOffset;
+ let block = element;
+ if (parent !== null) {
+ if (isBackward && focusOffset === 0) {
+ offset = block.getIndexWithinParent();
+ block = parent;
+ } else if (!isBackward && focusOffset === block.getChildrenSize()) {
+ offset = block.getIndexWithinParent() + 1;
+ block = parent;
+ }
+ }
+ return block.getChildAtIndex(isBackward ? offset - 1 : offset);
+}
+
+export function $getAdjacentNode(
+ focus: PointType,
+ isBackward: boolean,
+): null | LexicalNode {
+ const focusOffset = focus.offset;
+ if (focus.type === 'element') {
+ const block = focus.getNode();
+ return resolveElement(block, isBackward, focusOffset);
+ } else {
+ const focusNode = focus.getNode();
+ if (
+ (isBackward && focusOffset === 0) ||
+ (!isBackward && focusOffset === focusNode.getTextContentSize())
+ ) {
+ const possibleNode = isBackward
+ ? focusNode.getPreviousSibling()
+ : focusNode.getNextSibling();
+ if (possibleNode === null) {
+ return resolveElement(
+ focusNode.getParentOrThrow(),
+ isBackward,
+ focusNode.getIndexWithinParent() + (isBackward ? 0 : 1),
+ );
+ }
+ return possibleNode;
+ }
+ }
+ return null;
+}
+
+export function isFirefoxClipboardEvents(editor: LexicalEditor): boolean {
+ const event = getWindow(editor).event;
+ const inputType = event && (event as InputEvent).inputType;
+ return (
+ inputType === 'insertFromPaste' ||
+ inputType === 'insertFromPasteAsQuotation'
+ );
+}
+
+export function dispatchCommand<TCommand extends LexicalCommand<unknown>>(
+ editor: LexicalEditor,
+ command: TCommand,
+ payload: CommandPayloadType<TCommand>,
+): boolean {
+ return triggerCommandListeners(editor, command, payload);
+}
+
+export function $textContentRequiresDoubleLinebreakAtEnd(
+ node: ElementNode,
+): boolean {
+ return !$isRootNode(node) && !node.isLastChild() && !node.isInline();
+}
+
+export function getElementByKeyOrThrow(
+ editor: LexicalEditor,
+ key: NodeKey,
+): HTMLElement {
+ const element = editor._keyToDOMMap.get(key);
+
+ if (element === undefined) {
+ invariant(
+ false,
+ 'Reconciliation: could not find DOM element for node key %s',
+ key,
+ );
+ }
+
+ return element;
+}
+
+export function getParentElement(node: Node): HTMLElement | null {
+ const parentElement =
+ (node as HTMLSlotElement).assignedSlot || node.parentElement;
+ return parentElement !== null && parentElement.nodeType === 11
+ ? ((parentElement as unknown as ShadowRoot).host as HTMLElement)
+ : parentElement;
+}
+
+export function scrollIntoViewIfNeeded(
+ editor: LexicalEditor,
+ selectionRect: DOMRect,
+ rootElement: HTMLElement,
+): void {
+ const doc = rootElement.ownerDocument;
+ const defaultView = doc.defaultView;
+
+ if (defaultView === null) {
+ return;
+ }
+ let {top: currentTop, bottom: currentBottom} = selectionRect;
+ let targetTop = 0;
+ let targetBottom = 0;
+ let element: HTMLElement | null = rootElement;
+
+ while (element !== null) {
+ const isBodyElement = element === doc.body;
+ if (isBodyElement) {
+ targetTop = 0;
+ targetBottom = getWindow(editor).innerHeight;
+ } else {
+ const targetRect = element.getBoundingClientRect();
+ targetTop = targetRect.top;
+ targetBottom = targetRect.bottom;
+ }
+ let diff = 0;
+
+ if (currentTop < targetTop) {
+ diff = -(targetTop - currentTop);
+ } else if (currentBottom > targetBottom) {
+ diff = currentBottom - targetBottom;
+ }
+
+ if (diff !== 0) {
+ if (isBodyElement) {
+ // Only handles scrolling of Y axis
+ defaultView.scrollBy(0, diff);
+ } else {
+ const scrollTop = element.scrollTop;
+ element.scrollTop += diff;
+ const yOffset = element.scrollTop - scrollTop;
+ currentTop -= yOffset;
+ currentBottom -= yOffset;
+ }
+ }
+ if (isBodyElement) {
+ break;
+ }
+ element = getParentElement(element);
+ }
+}
+
+export function $hasUpdateTag(tag: string): boolean {
+ const editor = getActiveEditor();
+ return editor._updateTags.has(tag);
+}
+
+export function $addUpdateTag(tag: string): void {
+ errorOnReadOnly();
+ const editor = getActiveEditor();
+ editor._updateTags.add(tag);
+}
+
+export function $maybeMoveChildrenSelectionToParent(
+ parentNode: LexicalNode,
+): BaseSelection | null {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection) || !$isElementNode(parentNode)) {
+ return selection;
+ }
+ const {anchor, focus} = selection;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+ if ($hasAncestor(anchorNode, parentNode)) {
+ anchor.set(parentNode.__key, 0, 'element');
+ }
+ if ($hasAncestor(focusNode, parentNode)) {
+ focus.set(parentNode.__key, 0, 'element');
+ }
+ return selection;
+}
+
+export function $hasAncestor(
+ child: LexicalNode,
+ targetNode: LexicalNode,
+): boolean {
+ let parent = child.getParent();
+ while (parent !== null) {
+ if (parent.is(targetNode)) {
+ return true;
+ }
+ parent = parent.getParent();
+ }
+ return false;
+}
+
+export function getDefaultView(domElem: HTMLElement): Window | null {
+ const ownerDoc = domElem.ownerDocument;
+ return (ownerDoc && ownerDoc.defaultView) || null;
+}
+
+export function getWindow(editor: LexicalEditor): Window {
+ const windowObj = editor._window;
+ if (windowObj === null) {
+ invariant(false, 'window object not found');
+ }
+ return windowObj;
+}
+
+export function $isInlineElementOrDecoratorNode(node: LexicalNode): boolean {
+ return (
+ ($isElementNode(node) && node.isInline()) ||
+ ($isDecoratorNode(node) && node.isInline())
+ );
+}
+
+export function $getNearestRootOrShadowRoot(
+ node: LexicalNode,
+): RootNode | ElementNode {
+ let parent = node.getParentOrThrow();
+ while (parent !== null) {
+ if ($isRootOrShadowRoot(parent)) {
+ return parent;
+ }
+ parent = parent.getParentOrThrow();
+ }
+ return parent;
+}
+
+const ShadowRootNodeBrand: unique symbol = Symbol.for(
+ '@lexical/ShadowRootNodeBrand',
+);
+type ShadowRootNode = Spread<
+ {isShadowRoot(): true; [ShadowRootNodeBrand]: never},
+ ElementNode
+>;
+export function $isRootOrShadowRoot(
+ node: null | LexicalNode,
+): node is RootNode | ShadowRootNode {
+ return $isRootNode(node) || ($isElementNode(node) && node.isShadowRoot());
+}
+
+/**
+ * Returns a shallow clone of node with a new key
+ *
+ * @param node - The node to be copied.
+ * @returns The copy of the node.
+ */
+export function $copyNode<T extends LexicalNode>(node: T): T {
+ const copy = node.constructor.clone(node) as T;
+ $setNodeKey(copy, null);
+ return copy;
+}
+
+export function $applyNodeReplacement<N extends LexicalNode>(
+ node: LexicalNode,
+): N {
+ const editor = getActiveEditor();
+ const nodeType = node.constructor.getType();
+ const registeredNode = editor._nodes.get(nodeType);
+ if (registeredNode === undefined) {
+ invariant(
+ false,
+ '$initializeNode failed. Ensure node has been registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.',
+ );
+ }
+ const replaceFunc = registeredNode.replace;
+ if (replaceFunc !== null) {
+ const replacementNode = replaceFunc(node) as N;
+ if (!(replacementNode instanceof node.constructor)) {
+ invariant(
+ false,
+ '$initializeNode failed. Ensure replacement node is a subclass of the original node.',
+ );
+ }
+ return replacementNode;
+ }
+ return node as N;
+}
+
+export function errorOnInsertTextNodeOnRoot(
+ node: LexicalNode,
+ insertNode: LexicalNode,
+): void {
+ const parentNode = node.getParent();
+ if (
+ $isRootNode(parentNode) &&
+ !$isElementNode(insertNode) &&
+ !$isDecoratorNode(insertNode)
+ ) {
+ invariant(
+ false,
+ 'Only element or decorator nodes can be inserted in to the root node',
+ );
+ }
+}
+
+export function $getNodeByKeyOrThrow<N extends LexicalNode>(key: NodeKey): N {
+ const node = $getNodeByKey<N>(key);
+ if (node === null) {
+ invariant(
+ false,
+ "Expected node with key %s to exist but it's not in the nodeMap.",
+ key,
+ );
+ }
+ return node;
+}
+
+function createBlockCursorElement(editorConfig: EditorConfig): HTMLDivElement {
+ const theme = editorConfig.theme;
+ const element = document.createElement('div');
+ element.contentEditable = 'false';
+ element.setAttribute('data-lexical-cursor', 'true');
+ let blockCursorTheme = theme.blockCursor;
+ if (blockCursorTheme !== undefined) {
+ if (typeof blockCursorTheme === 'string') {
+ const classNamesArr = normalizeClassNames(blockCursorTheme);
+ // @ts-expect-error: intentional
+ blockCursorTheme = theme.blockCursor = classNamesArr;
+ }
+ if (blockCursorTheme !== undefined) {
+ element.classList.add(...blockCursorTheme);
+ }
+ }
+ return element;
+}
+
+function needsBlockCursor(node: null | LexicalNode): boolean {
+ return (
+ ($isDecoratorNode(node) || ($isElementNode(node) && !node.canBeEmpty())) &&
+ !node.isInline()
+ );
+}
+
+export function removeDOMBlockCursorElement(
+ blockCursorElement: HTMLElement,
+ editor: LexicalEditor,
+ rootElement: HTMLElement,
+) {
+ rootElement.style.removeProperty('caret-color');
+ editor._blockCursorElement = null;
+ const parentElement = blockCursorElement.parentElement;
+ if (parentElement !== null) {
+ parentElement.removeChild(blockCursorElement);
+ }
+}
+
+export function updateDOMBlockCursorElement(
+ editor: LexicalEditor,
+ rootElement: HTMLElement,
+ nextSelection: null | BaseSelection,
+): void {
+ let blockCursorElement = editor._blockCursorElement;
+
+ if (
+ $isRangeSelection(nextSelection) &&
+ nextSelection.isCollapsed() &&
+ nextSelection.anchor.type === 'element' &&
+ rootElement.contains(document.activeElement)
+ ) {
+ const anchor = nextSelection.anchor;
+ const elementNode = anchor.getNode();
+ const offset = anchor.offset;
+ const elementNodeSize = elementNode.getChildrenSize();
+ let isBlockCursor = false;
+ let insertBeforeElement: null | HTMLElement = null;
+
+ if (offset === elementNodeSize) {
+ const child = elementNode.getChildAtIndex(offset - 1);
+ if (needsBlockCursor(child)) {
+ isBlockCursor = true;
+ }
+ } else {
+ const child = elementNode.getChildAtIndex(offset);
+ if (needsBlockCursor(child)) {
+ const sibling = (child as LexicalNode).getPreviousSibling();
+ if (sibling === null || needsBlockCursor(sibling)) {
+ isBlockCursor = true;
+ insertBeforeElement = editor.getElementByKey(
+ (child as LexicalNode).__key,
+ );
+ }
+ }
+ }
+ if (isBlockCursor) {
+ const elementDOM = editor.getElementByKey(
+ elementNode.__key,
+ ) as HTMLElement;
+ if (blockCursorElement === null) {
+ editor._blockCursorElement = blockCursorElement =
+ createBlockCursorElement(editor._config);
+ }
+ rootElement.style.caretColor = 'transparent';
+ if (insertBeforeElement === null) {
+ elementDOM.appendChild(blockCursorElement);
+ } else {
+ elementDOM.insertBefore(blockCursorElement, insertBeforeElement);
+ }
+ return;
+ }
+ }
+ // Remove cursor
+ if (blockCursorElement !== null) {
+ removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
+ }
+}
+
+export function getDOMSelection(targetWindow: null | Window): null | Selection {
+ return !CAN_USE_DOM ? null : (targetWindow || window).getSelection();
+}
+
+export function $splitNode(
+ node: ElementNode,
+ offset: number,
+): [ElementNode | null, ElementNode] {
+ let startNode = node.getChildAtIndex(offset);
+ if (startNode == null) {
+ startNode = node;
+ }
+
+ invariant(
+ !$isRootOrShadowRoot(node),
+ 'Can not call $splitNode() on root element',
+ );
+
+ const recurse = <T extends LexicalNode>(
+ currentNode: T,
+ ): [ElementNode, ElementNode, T] => {
+ const parent = currentNode.getParentOrThrow();
+ const isParentRoot = $isRootOrShadowRoot(parent);
+ // The node we start split from (leaf) is moved, but its recursive
+ // parents are copied to create separate tree
+ const nodeToMove =
+ currentNode === startNode && !isParentRoot
+ ? currentNode
+ : $copyNode(currentNode);
+
+ if (isParentRoot) {
+ invariant(
+ $isElementNode(currentNode) && $isElementNode(nodeToMove),
+ 'Children of a root must be ElementNode',
+ );
+
+ currentNode.insertAfter(nodeToMove);
+ return [currentNode, nodeToMove, nodeToMove];
+ } else {
+ const [leftTree, rightTree, newParent] = recurse(parent);
+ const nextSiblings = currentNode.getNextSiblings();
+
+ newParent.append(nodeToMove, ...nextSiblings);
+ return [leftTree, rightTree, nodeToMove];
+ }
+ };
+
+ const [leftTree, rightTree] = recurse(startNode);
+
+ return [leftTree, rightTree];
+}
+
+export function $findMatchingParent(
+ startingNode: LexicalNode,
+ findFn: (node: LexicalNode) => boolean,
+): LexicalNode | null {
+ let curr: ElementNode | LexicalNode | null = startingNode;
+
+ while (curr !== $getRoot() && curr != null) {
+ if (findFn(curr)) {
+ return curr;
+ }
+
+ curr = curr.getParent();
+ }
+
+ return null;
+}
+
+/**
+ * @param x - The element being tested
+ * @returns Returns true if x is an HTML anchor tag, false otherwise
+ */
+export function isHTMLAnchorElement(x: Node): x is HTMLAnchorElement {
+ return isHTMLElement(x) && x.tagName === 'A';
+}
+
+/**
+ * @param x - The element being testing
+ * @returns Returns true if x is an HTML element, false otherwise.
+ */
+export function isHTMLElement(x: Node | EventTarget): x is HTMLElement {
+ // @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors
+ return x.nodeType === 1;
+}
+
+/**
+ *
+ * @param node - the Dom Node to check
+ * @returns if the Dom Node is an inline node
+ */
+export function isInlineDomNode(node: Node) {
+ const inlineNodes = new RegExp(
+ /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
+ 'i',
+ );
+ return node.nodeName.match(inlineNodes) !== null;
+}
+
+/**
+ *
+ * @param node - the Dom Node to check
+ * @returns if the Dom Node is a block node
+ */
+export function isBlockDomNode(node: Node) {
+ const blockNodes = new RegExp(
+ /^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/,
+ 'i',
+ );
+ return node.nodeName.match(blockNodes) !== null;
+}
+
+/**
+ * This function is for internal use of the library.
+ * Please do not use it as it may change in the future.
+ */
+export function INTERNAL_$isBlock(
+ node: LexicalNode,
+): node is ElementNode | DecoratorNode<unknown> {
+ if ($isRootNode(node) || ($isDecoratorNode(node) && !node.isInline())) {
+ return true;
+ }
+ if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
+ return false;
+ }
+
+ const firstChild = node.getFirstChild();
+ const isLeafElement =
+ firstChild === null ||
+ $isLineBreakNode(firstChild) ||
+ $isTextNode(firstChild) ||
+ firstChild.isInline();
+
+ return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
+}
+
+export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
+ node: LexicalNode,
+ predicate: (ancestor: LexicalNode) => ancestor is NodeType,
+) {
+ let parent = node;
+ while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
+ parent = parent.getParentOrThrow();
+ }
+ return predicate(parent) ? parent : null;
+}
+
+/**
+ * Utility function for accessing current active editor instance.
+ * @returns Current active editor
+ */
+export function $getEditor(): LexicalEditor {
+ return getActiveEditor();
+}
+
+/** @internal */
+export type TypeToNodeMap = Map<string, NodeMap>;
+/**
+ * @internal
+ * Compute a cached Map of node type to nodes for a frozen EditorState
+ */
+const cachedNodeMaps = new WeakMap<EditorState, TypeToNodeMap>();
+const EMPTY_TYPE_TO_NODE_MAP: TypeToNodeMap = new Map();
+export function getCachedTypeToNodeMap(
+ editorState: EditorState,
+): TypeToNodeMap {
+ // If this is a new Editor it may have a writable this._editorState
+ // with only a 'root' entry.
+ if (!editorState._readOnly && editorState.isEmpty()) {
+ return EMPTY_TYPE_TO_NODE_MAP;
+ }
+ invariant(
+ editorState._readOnly,
+ 'getCachedTypeToNodeMap called with a writable EditorState',
+ );
+ let typeToNodeMap = cachedNodeMaps.get(editorState);
+ if (!typeToNodeMap) {
+ typeToNodeMap = new Map();
+ cachedNodeMaps.set(editorState, typeToNodeMap);
+ for (const [nodeKey, node] of editorState._nodeMap) {
+ const nodeType = node.__type;
+ let nodeMap = typeToNodeMap.get(nodeType);
+ if (!nodeMap) {
+ nodeMap = new Map();
+ typeToNodeMap.set(nodeType, nodeMap);
+ }
+ nodeMap.set(nodeKey, node);
+ }
+ }
+ return typeToNodeMap;
+}
+
+/**
+ * Returns a clone of a node using `node.constructor.clone()` followed by
+ * `clone.afterCloneFrom(node)`. The resulting clone must have the same key,
+ * parent/next/prev pointers, and other properties that are not set by
+ * `node.constructor.clone` (format, style, etc.). This is primarily used by
+ * {@link LexicalNode.getWritable} to create a writable version of an
+ * existing node. The clone is the same logical node as the original node,
+ * do not try and use this function to duplicate or copy an existing node.
+ *
+ * Does not mutate the EditorState.
+ * @param node - The node to be cloned.
+ * @returns The clone of the node.
+ */
+export function $cloneWithProperties<T extends LexicalNode>(latestNode: T): T {
+ const constructor = latestNode.constructor;
+ const mutableNode = constructor.clone(latestNode) as T;
+ mutableNode.afterCloneFrom(latestNode);
+ if (__DEV__) {
+ invariant(
+ mutableNode.__key === latestNode.__key,
+ "$cloneWithProperties: %s.clone(node) (with type '%s') did not return a node with the same key, make sure to specify node.__key as the last argument to the constructor",
+ constructor.name,
+ constructor.getType(),
+ );
+ invariant(
+ mutableNode.__parent === latestNode.__parent &&
+ mutableNode.__next === latestNode.__next &&
+ mutableNode.__prev === latestNode.__prev,
+ "$cloneWithProperties: %s.clone(node) (with type '%s') overrided afterCloneFrom but did not call super.afterCloneFrom(prevNode)",
+ constructor.name,
+ constructor.getType(),
+ );
+ }
+ return mutableNode;
+}
--- /dev/null
+/**
+ * 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 {$insertDataTransferForRichText} from '@lexical/clipboard';
+import {
+ $createParagraphNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+} from 'lexical';
+import {
+ DataTransferMock,
+ initializeUnitTest,
+ invariant,
+} from 'lexical/__tests__/utils';
+
+describe('CodeBlock tests', () => {
+ initializeUnitTest(
+ (testEnv) => {
+ beforeEach(async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.select();
+ });
+ });
+
+ /**
+ * Code example for tests:
+ *
+ * function run() {
+ * return [null, undefined, 2, ""];
+ * }
+ *
+ */
+ const EXPECTED_HTML = `<code spellcheck="false" dir="ltr"><span data-lexical-text="true">function run() {</span><br><span data-lexical-text="true"> return [null, undefined, 2, ""];</span><br><span data-lexical-text="true">}</span></code>`;
+
+ const CODE_PASTING_TESTS = [
+ {
+ expectedHTML: EXPECTED_HTML,
+ name: 'VS Code',
+ pastedHTML: `<meta charset='utf-8'><div style="color: #d4d4d4;background-color: #1e1e1e;font-family: Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 12px;line-height: 18px;white-space: pre;"><div><span style="color: #569cd6;">function</span><span style="color: #d4d4d4;"> </span><span style="color: #dcdcaa;">run</span><span style="color: #d4d4d4;">() {</span></div><div><span style="color: #d4d4d4;"> </span><span style="color: #c586c0;">return</span><span style="color: #d4d4d4;"> [</span><span style="color: #569cd6;">null</span><span style="color: #d4d4d4;">, </span><span style="color: #569cd6;">undefined</span><span style="color: #d4d4d4;">, </span><span style="color: #b5cea8;">2</span><span style="color: #d4d4d4;">, </span><span style="color: #ce9178;">""</span><span style="color: #d4d4d4;">];</span></div><div><span style="color: #d4d4d4;">}</span></div></div>`,
+ },
+ {
+ expectedHTML: EXPECTED_HTML,
+ name: 'Quip',
+ pastedHTML: `<meta charset='utf-8'><pre>function run() {<br> return [null, undefined, 2, ""];<br>}</pre>`,
+ },
+ {
+ expectedHTML: EXPECTED_HTML,
+ name: 'WebStorm / Idea',
+ pastedHTML: `<html><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"></head><body><pre style="background-color:#2b2b2b;color:#a9b7c6;font-family:'JetBrains Mono',monospace;font-size:9.8pt;"><span style="color:#cc7832;">function </span><span style="color:#ffc66d;">run</span>() {<br>  <span style="color:#cc7832;">return </span>[<span style="color:#cc7832;">null, undefined, </span><span style="color:#6897bb;">2</span><span style="color:#cc7832;">, </span><span style="color:#6a8759;">""</span>]<span style="color:#cc7832;">;<br></span>}</pre></body></html>`,
+ },
+ {
+ expectedHTML: `<code spellcheck="false" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">function</strong><span data-lexical-text="true"> run() {</span><br><span data-lexical-text="true"> </span><strong class="editor-text-bold" data-lexical-text="true">return</strong><span data-lexical-text="true"> [</span><strong class="editor-text-bold" data-lexical-text="true">null</strong><span data-lexical-text="true">, </span><strong class="editor-text-bold" data-lexical-text="true">undefined</strong><span data-lexical-text="true">, 2, ""];</span><br><span data-lexical-text="true">}</span></code>`,
+ name: 'Postman IDE',
+ pastedHTML: `<meta charset='utf-8'><div style="color: #000000;background-color: #fffffe;font-family: Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 12px;line-height: 18px;white-space: pre;"><div><span style="color: #800555;font-weight: bold;">function</span><span style="color: #000000;"> run() {</span></div><div><span style="color: #000000;"> </span><span style="color: #800555;font-weight: bold;">return</span><span style="color: #000000;"> [</span><span style="color: #800555;font-weight: bold;">null</span><span style="color: #000000;">, </span><span style="color: #800555;font-weight: bold;">undefined</span><span style="color: #000000;">, </span><span style="color: #ff00aa;">2</span><span style="color: #000000;">, </span><span style="color: #2a00ff;">""</span><span style="color: #000000;">];</span></div><div><span style="color: #000000;">}</span></div></div>`,
+ },
+ {
+ expectedHTML: EXPECTED_HTML,
+ name: 'Slack message',
+ pastedHTML: `<meta charset='utf-8'><pre class="c-mrkdwn__pre" data-stringify-type="pre" style="box-sizing: inherit; margin: 4px 0px; padding: 8px; --saf-0:rgba(var(--sk_foreground_low,29,28,29),0.13); font-size: 12px; line-height: 1.50001; font-variant-ligatures: none; white-space: pre-wrap; word-break: break-word; word-break: normal; tab-size: 4; font-family: Monaco, Menlo, Consolas, "Courier New", monospace !important; border: 1px solid var(--saf-0); border-radius: 4px; background: rgba(var(--sk_foreground_min,29,28,29),0.04); counter-reset: list-0 0 list-1 0 list-2 0 list-3 0 list-4 0 list-5 0 list-6 0 list-7 0 list-8 0 list-9 0; color: rgb(29, 28, 29); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">function run() {\n return [null, undefined, 2, ""];\n}</pre>`,
+ },
+ {
+ expectedHTML: `<code spellcheck="false" dir="ltr"><span data-lexical-text="true">const Lexical = requireCond('gk', 'runtime_is_dev', {</span><br><span data-lexical-text="true"> true: 'Lexical.dev',</span><br><span data-lexical-text="true"> false: 'Lexical.prod',</span><br><span data-lexical-text="true">});</span></code>`,
+ name: 'CodeHub',
+ pastedHTML: `<meta charset='utf-8'><div style="color: #000000;background-color: #fffffe;font-family: 'monaco,monospace', Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 13px;line-height: 20px;white-space: pre;"><div><span style="color: #ff0000;">const</span><span style="color: #000000;"> </span><span style="color: #800000;">Lexical</span><span style="color: #000000;"> = </span><span style="color: #383838;">requireCond</span><span style="color: #000000;">(</span><span style="color: #863b00;">'gk'</span><span style="color: #000000;">, </span><span style="color: #863b00;">'runtime_is_dev'</span><span style="color: #000000;">, {</span></div><div><span style="color: #000000;"> </span><span style="color: #863b00;">true</span><span style="color: #000000;">: </span><span style="color: #863b00;">'Lexical.dev'</span><span style="color: #000000;">,</span></div><div><span style="color: #000000;"> </span><span style="color: #863b00;">false</span><span style="color: #000000;">: </span><span style="color: #863b00;">'Lexical.prod'</span><span style="color: #000000;">,</span></div><div><span style="color: #000000;">});</span></div></div>`,
+ },
+ {
+ expectedHTML: EXPECTED_HTML,
+ name: 'GitHub / Gist',
+ pastedHTML: `<meta charset='utf-8'><table class="highlight tab-size js-file-line-container js-code-nav-container js-tagsearch-file" data-tab-size="8" data-paste-markdown-skip="" data-tagsearch-lang="JavaScript" data-tagsearch-path="example.js" style="box-sizing: border-box; border-spacing: 0px; border-collapse: collapse; tab-size: 8; color: rgb(36, 41, 47); font-family: -apple-system, "system-ui", "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><tbody style="box-sizing: border-box;"><tr style="box-sizing: border-box;"><td id="file-example-js-LC1" class="blob-code blob-code-inner js-file-line" style="box-sizing: border-box; padding: 0px 10px; position: relative; line-height: 20px; vertical-align: top; overflow: visible; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; color: var(--color-fg-default); overflow-wrap: normal; white-space: pre;"><span class="pl-k" style="box-sizing: border-box; color: var(--color-prettylights-syntax-keyword);">function</span> <span class="pl-en" style="box-sizing: border-box; color: var(--color-prettylights-syntax-entity);">run</span><span class="pl-kos" style="box-sizing: border-box;">(</span><span class="pl-kos" style="box-sizing: border-box;">)</span> <span class="pl-kos" style="box-sizing: border-box;">{</span></td></tr><tr style="box-sizing: border-box; background-color: transparent;"><td id="file-example-js-L2" class="blob-num js-line-number js-code-nav-line-number" data-line-number="2" style="box-sizing: border-box; padding: 0px 10px; width: 50px; min-width: 50px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 20px; color: var(--color-fg-subtle); text-align: right; white-space: nowrap; vertical-align: top; cursor: pointer; user-select: none;"></td><td id="file-example-js-LC2" class="blob-code blob-code-inner js-file-line" style="box-sizing: border-box; padding: 0px 10px; position: relative; line-height: 20px; vertical-align: top; overflow: visible; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; color: var(--color-fg-default); overflow-wrap: normal; white-space: pre;"> <span class="pl-k" style="box-sizing: border-box; color: var(--color-prettylights-syntax-keyword);">return</span> <span class="pl-kos" style="box-sizing: border-box;">[</span><span class="pl-c1" style="box-sizing: border-box; color: var(--color-prettylights-syntax-constant);">null</span><span class="pl-kos" style="box-sizing: border-box;">,</span> <span class="pl-c1" style="box-sizing: border-box; color: var(--color-prettylights-syntax-constant);">undefined</span><span class="pl-kos" style="box-sizing: border-box;">,</span> <span class="pl-c1" style="box-sizing: border-box; color: var(--color-prettylights-syntax-constant);">2</span><span class="pl-kos" style="box-sizing: border-box;">,</span> <span class="pl-s" style="box-sizing: border-box; color: var(--color-prettylights-syntax-string);">""</span><span class="pl-kos" style="box-sizing: border-box;">]</span><span class="pl-kos" style="box-sizing: border-box;">;</span></td></tr><tr style="box-sizing: border-box;"><td id="file-example-js-L3" class="blob-num js-line-number js-code-nav-line-number" data-line-number="3" style="box-sizing: border-box; padding: 0px 10px; width: 50px; min-width: 50px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 20px; color: var(--color-fg-subtle); text-align: right; white-space: nowrap; vertical-align: top; cursor: pointer; user-select: none;"></td><td id="file-example-js-LC3" class="blob-code blob-code-inner js-file-line" style="box-sizing: border-box; padding: 0px 10px; position: relative; line-height: 20px; vertical-align: top; overflow: visible; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; color: var(--color-fg-default); overflow-wrap: normal; white-space: pre;"><span class="pl-kos" style="box-sizing: border-box;">}</span></td></tr></tbody></table>`,
+ },
+ {
+ expectedHTML: `<p><code spellcheck="false" data-lexical-text="true"><span>12</span></code></p>`,
+ name: 'Single line <code>',
+ pastedHTML: `<meta charset='utf-8'><code>12</code>`,
+ },
+ {
+ expectedHTML: `<code spellcheck="false"><span data-lexical-text="true">1</span><br><span data-lexical-text="true">2</span></code>`,
+ name: 'Multiline <code>',
+ // TODO This is not correct. This resembles how Lexical exports code right now but
+ // semantically it should be wrapped in a pre
+ pastedHTML: `<meta charset='utf-8'><code>1<br>2</code>`,
+ },
+ {
+ expectedHTML: `<p dir="ltr"><strong class="editor-text-bold editor-text-italic editor-text-underline" data-lexical-text="true">Hello </strong><sub data-lexical-text="true"><strong class="editor-text-bold editor-text-italic">World </strong></sub><sup data-lexical-text="true"><strong class="editor-text-bold editor-text-italic editor-text-underline">Lexical</strong></sup></p>`,
+ name: 'Multiple text formats',
+ pastedHTML: `<strong style="font-weight: 700; font-style: italic; text-decoration: underline; color: rgb(0, 0, 0); font-size: 15px; text-align: left; text-indent: 0px; background-color: rgb(255, 255, 255);">Hello </strong><sub style="color: rgb(0, 0, 0); font-style: normal; font-weight: 400; text-align: left; text-indent: 0px; background-color: rgb(255, 255, 255);"><strong style="font-weight: 700; font-style: italic; text-decoration: line-through; font-size: 0.8em; vertical-align: sub !important;">World </strong></sub><sup style="color: rgb(0, 0, 0); font-style: normal; font-weight: 400; text-align: left; text-indent: 0px; background-color: rgb(255, 255, 255);"><strong style="font-weight: 700; font-style: italic; text-decoration: underline line-through; font-size: 0.8em; vertical-align: super;">Lexical</strong></sup>`,
+ },
+ {
+ expectedHTML: `<h1 dir="ltr"><span data-lexical-text="true">My document</span></h1>`,
+ name: 'Title from Google Docs',
+ pastedHTML: `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-whatever"><span style="font-size:26pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">My document</span></b>`,
+ },
+ {
+ expectedHTML: `<h1 dir="ltr"><span data-lexical-text="true">My document</span></h1>`,
+ name: 'Title from Google Docs Wrapped in Paragraph',
+ pastedHTML: `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-wjatever"><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:3pt;"><span style="font-size:26pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">My document</span></p></b>`,
+ },
+ {
+ expectedHTML: `<p dir="ltr"><sub data-lexical-text="true"><span>subscript</span></sub><span data-lexical-text="true"> and </span><sup data-lexical-text="true"><span>superscript</span></sup></p>`,
+ name: 'Subscript and Superscript',
+ pastedHTML: `<b style="font-weight:normal;" id="docs-internal-guid-374b5f9d-7fff-9120-bcb0-1f5c1b6d59fa"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span style="font-size:0.6em;vertical-align:sub;">subscript</span></span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> and </span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span style="font-size:0.6em;vertical-align:super;">superscript</span></span></b>`,
+ },
+ ];
+
+ CODE_PASTING_TESTS.forEach((testCase, i) => {
+ test(`Code block html paste: ${testCase.name}`, async () => {
+ const {editor} = testEnv;
+
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData('text/html', testCase.pastedHTML);
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'isRangeSelection(selection)',
+ );
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ expect(testEnv.innerHTML).toBe(testCase.expectedHTML);
+ });
+ });
+ },
+ {
+ namespace: 'test',
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ },
+ );
+});
--- /dev/null
+/**
+ * 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 {$insertDataTransferForRichText} from '@lexical/clipboard';
+import {
+ $createParagraphNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+} from 'lexical';
+import {
+ DataTransferMock,
+ initializeUnitTest,
+ invariant,
+} from 'lexical/src/__tests__/utils';
+
+describe('HTMLCopyAndPaste tests', () => {
+ initializeUnitTest(
+ (testEnv) => {
+ beforeEach(async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.select();
+ });
+ });
+
+ const HTML_COPY_PASTING_TESTS = [
+ {
+ expectedHTML: `<p dir="ltr"><span data-lexical-text="true">Hello!</span></p>`,
+ name: 'plain DOM text node',
+ pastedHTML: `Hello!`,
+ },
+ {
+ expectedHTML: `<p dir="ltr"><span data-lexical-text="true">Hello!</span></p><p><br></p>`,
+ name: 'a paragraph element',
+ pastedHTML: `<p>Hello!<p>`,
+ },
+ {
+ expectedHTML: `<p><span data-lexical-text="true">123</span></p><p><span data-lexical-text="true">456</span></p>`,
+ name: 'a single div',
+ pastedHTML: `123
+ <div>
+ 456
+ </div>`,
+ },
+ {
+ expectedHTML: `<p dir="ltr"><span data-lexical-text="true">a b c d e</span></p><p dir="ltr"><span data-lexical-text="true">f g h</span></p>`,
+ name: 'multiple nested spans and divs',
+ pastedHTML: `<div>
+ a b
+ <span>
+ c d
+ <span>e</span>
+ </span>
+ <div>
+ f
+ <span>g h</span>
+ </div>
+ </div>`,
+ },
+ {
+ expectedHTML: `<p><span data-lexical-text="true">123</span></p><p><span data-lexical-text="true">456</span></p>`,
+ name: 'nested span in a div',
+ pastedHTML: `<div>
+ <span>
+ 123
+ <div>456</div>
+ </span>
+ </div>`,
+ },
+ {
+ expectedHTML: `<p><span data-lexical-text="true">123</span></p><p><span data-lexical-text="true">456</span></p>`,
+ name: 'nested div in a span',
+ pastedHTML: ` <span>123<div>456</div></span>`,
+ },
+ {
+ expectedHTML: `<ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1" dir="ltr"><span data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2" dir="ltr"><span data-lexical-text="true">todo</span></li><li value="3"><ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1" dir="ltr"><span data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2" dir="ltr"><span data-lexical-text="true">todo</span></li></ul></li><li role="checkbox" tabindex="-1" aria-checked="false" value="3" dir="ltr"><span data-lexical-text="true">todo</span></li></ul>`,
+ name: 'google doc checklist',
+ pastedHTML: `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-1980f960-7fff-f4df-4ba3-26c6e1508542"><ul style="margin-top:0;margin-bottom:0;padding-inline-start:28px;"><li dir="ltr" role="checkbox" aria-checked="true" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAABbElEQVR4Ae3bsU4CYRDEcRsxodZE8Q0BbS258l5MwESJNL6HOfrPKdhyxeBcwk5mkn9F98sGIOSuPM/zPM/zPI+xG/SEtuiAWpEOaIOWaDIWziP6RK14OzSjX44ITvTBvqRn1MRaMIHeBIE2TKBBEGhgArWkKmtJBjKQgQxkIANd/Aw0NVC+O7RHvYFynHasN1COE/UGynGiXgOIjxOtdIH4OGJAfBwxID6OGBAfRwiIjyMARMCpCjRF5+72Dzhd5R+rHfpC92NeTlWgLl5PkQg4RYBynBSJgFMGKMNJkQg4lYFeUDuFRMCpBXQOEgGnDtA/kPg4xT7m2y/tCd9zKgOdviTC5RQEIiAFjh4QASlw9IAISIEjCURAWvmf1UDKcQwUSDmOgWLdMcxA7BnIQAYykIEM5EcRvplAW0GgNRNoKQg0ZwJN0E4I5x1dI+pmgSSA84BG2QQt0LrYG/eAXtGccjme53me53me9wPjPWZWjhktAQAAAABJRU5ErkJggg==" width="18.4px" height="18.4px" alt="checked" aria-roledescription="checkbox" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">done</span></p></li><li dir="ltr" role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="18.4px" height="18.4px" alt="unchecked" aria-roledescription="checkbox" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">todo</span></p></li><ul style="margin-top:0;margin-bottom:0;padding-inline-start:28px;"><li dir="ltr" role="checkbox" aria-checked="true" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;" aria-level="2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAABbElEQVR4Ae3bsU4CYRDEcRsxodZE8Q0BbS258l5MwESJNL6HOfrPKdhyxeBcwk5mkn9F98sGIOSuPM/zPM/zPI+xG/SEtuiAWpEOaIOWaDIWziP6RK14OzSjX44ITvTBvqRn1MRaMIHeBIE2TKBBEGhgArWkKmtJBjKQgQxkIANd/Aw0NVC+O7RHvYFynHasN1COE/UGynGiXgOIjxOtdIH4OGJAfBwxID6OGBAfRwiIjyMARMCpCjRF5+72Dzhd5R+rHfpC92NeTlWgLl5PkQg4RYBynBSJgFMGKMNJkQg4lYFeUDuFRMCpBXQOEgGnDtA/kPg4xT7m2y/tCd9zKgOdviTC5RQEIiAFjh4QASlw9IAISIEjCURAWvmf1UDKcQwUSDmOgWLdMcxA7BnIQAYykIEM5EcRvplAW0GgNRNoKQg0ZwJN0E4I5x1dI+pmgSSA84BG2QQt0LrYG/eAXtGccjme53me53me9wPjPWZWjhktAQAAAABJRU5ErkJggg==" width="18.4px" height="18.4px" alt="checked" aria-roledescription="checkbox" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">done</span></p></li><li dir="ltr" role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="18.4px" height="18.4px" alt="unchecked" aria-roledescription="checkbox" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">todo</span></p></li></ul><li dir="ltr" role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="18.4px" height="18.4px" alt="unchecked" aria-roledescription="checkbox" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11.5pt;font-family:'Optimistic Text',sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">todo</span></p></li></ul></b>`,
+ },
+ {
+ expectedHTML: `<p dir="ltr" style="text-align: start;"><span data-lexical-text="true">checklist</span></p><ul><li role="checkbox" tabindex="-1" aria-checked="true" value="1" dir="ltr" style="text-align: start;"><span data-lexical-text="true">done</span></li><li role="checkbox" tabindex="-1" aria-checked="false" value="2" dir="ltr" style="text-align: start;"><span data-lexical-text="true">todo</span></li></ul>`,
+ name: 'github checklist',
+ pastedHTML: `<meta charset='utf-8'><p dir="auto" style="box-sizing: border-box; margin-top: 0px !important; margin-bottom: 16px; color: rgb(31, 35, 40); font-family: -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">checklist</p><ul class="contains-task-list" style="box-sizing: border-box; padding: 0px; margin-top: 0px; margin-bottom: 0px !important; position: relative; color: rgb(31, 35, 40); font-family: -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><li class="task-list-item enabled" style="box-sizing: border-box; list-style-type: none; padding: 2px 15px 2px 42px; margin-right: -15px; margin-left: -15px; line-height: 1.5; border: 0px;"><span class="handle" style="box-sizing: border-box; display: block; float: left; width: 20px; padding: 2px 0px 0px 2px; margin-left: -43px; opacity: 0;"><svg class="drag-handle" aria-hidden="true" width="16" height="16"><path d="M10 13a1 1 0 100-2 1 1 0 000 2zm-4 0a1 1 0 100-2 1 1 0 000 2zm1-5a1 1 0 11-2 0 1 1 0 012 0zm3 1a1 1 0 100-2 1 1 0 000 2zm1-5a1 1 0 11-2 0 1 1 0 012 0zM6 5a1 1 0 100-2 1 1 0 000 2z"></path></svg></span><input type="checkbox" id="" class="task-list-item-checkbox" checked="" style="box-sizing: border-box; font: inherit; margin: 0px 0.2em 0.25em -1.4em; overflow: visible; padding: 0px; vertical-align: middle;"><span></span>done</li><li class="task-list-item enabled" style="box-sizing: border-box; list-style-type: none; margin-top: 0px; padding: 2px 15px 2px 42px; margin-right: -15px; margin-left: -15px; line-height: 1.5; border: 0px;"><span class="handle" style="box-sizing: border-box; display: block; float: left; width: 20px; padding: 2px 0px 0px 2px; margin-left: -43px; opacity: 0;"><svg class="drag-handle" aria-hidden="true" width="16" height="16"><path d="M10 13a1 1 0 100-2 1 1 0 000 2zm-4 0a1 1 0 100-2 1 1 0 000 2zm1-5a1 1 0 11-2 0 1 1 0 012 0zm3 1a1 1 0 100-2 1 1 0 000 2zm1-5a1 1 0 11-2 0 1 1 0 012 0zM6 5a1 1 0 100-2 1 1 0 000 2z"></path></svg></span><input type="checkbox" id="" class="task-list-item-checkbox" style="box-sizing: border-box; font: inherit; margin: 0px 0.2em 0.25em -1.4em; overflow: visible; padding: 0px; vertical-align: middle;"><span></span>todo</li></ul>`,
+ },
+ ];
+
+ HTML_COPY_PASTING_TESTS.forEach((testCase, i) => {
+ test(`HTML copy paste: ${testCase.name}`, async () => {
+ const {editor} = testEnv;
+
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData('text/html', testCase.pastedHTML);
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'isRangeSelection(selection)',
+ );
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ expect(testEnv.innerHTML).toBe(testCase.expectedHTML);
+ });
+ });
+ },
+ {
+ namespace: 'test',
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ },
+ );
+});
--- /dev/null
+/**
+ * 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 {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
+import {
+ $createTableCellNode,
+ $createTableNode,
+ $createTableRowNode,
+ TableCellNode,
+ TableRowNode,
+} from '@lexical/table';
+import {
+ $createLineBreakNode,
+ $createNodeSelection,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getEditor,
+ $getNearestNodeFromDOMNode,
+ $getNodeByKey,
+ $getRoot,
+ $isParagraphNode,
+ $isTextNode,
+ $parseSerializedNode,
+ $setCompositionKey,
+ $setSelection,
+ COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_LOW,
+ createCommand,
+ createEditor,
+ EditorState,
+ ElementNode,
+ type Klass,
+ type LexicalEditor,
+ type LexicalNode,
+ type LexicalNodeReplacement,
+ ParagraphNode,
+ RootNode,
+ TextNode,
+} from 'lexical';
+
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $createTestDecoratorNode,
+ $createTestElementNode,
+ $createTestInlineElementNode,
+ createTestEditor,
+ createTestHeadlessEditor,
+ TestTextNode,
+} from '../utils';
+
+describe('LexicalEditor tests', () => {
+ let container: HTMLElement;
+ let reactRoot: Root;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ reactRoot = createRoot(container);
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container);
+ // @ts-ignore
+ container = null;
+
+ jest.restoreAllMocks();
+ });
+
+ function useLexicalEditor(
+ rootElementRef: React.RefObject<HTMLDivElement>,
+ onError?: (error: Error) => void,
+ nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>,
+ ) {
+ const editor = useMemo(
+ () =>
+ createTestEditor({
+ nodes: nodes ?? [],
+ onError: onError || jest.fn(),
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ }),
+ [onError, nodes],
+ );
+
+ useEffect(() => {
+ const rootElement = rootElementRef.current;
+
+ editor.setRootElement(rootElement);
+ }, [rootElementRef, editor]);
+
+ return editor;
+ }
+
+ let editor: LexicalEditor;
+
+ function init(onError?: (error: Error) => void) {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase() {
+ editor = useLexicalEditor(ref, onError);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase />);
+ });
+ }
+
+ async function update(fn: () => void) {
+ editor.update(fn);
+
+ return Promise.resolve().then();
+ }
+
+ describe('read()', () => {
+ it('Can read the editor state', async () => {
+ init(function onError(err) {
+ throw err;
+ });
+ expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
+ expect(editor.read(() => $getEditor())).toBe(editor);
+ const onUpdate = jest.fn();
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ },
+ {onUpdate},
+ );
+ expect(onUpdate).toHaveBeenCalledTimes(0);
+ // This read will flush pending updates
+ expect(editor.read(() => $getRoot().getTextContent())).toEqual(
+ 'This works!',
+ );
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ // Check to make sure there is not an unexpected reconciliation
+ await Promise.resolve().then();
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ editor.read(() => {
+ const rootElement = editor.getRootElement();
+ expect(rootElement).toBeDefined();
+ // The root never works for this call
+ expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null);
+ const paragraphDom = rootElement!.querySelector('p');
+ expect(paragraphDom).toBeDefined();
+ expect(
+ $isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)),
+ ).toBe(true);
+ expect(
+ $getNearestNodeFromDOMNode(paragraphDom!)!.getTextContent(),
+ ).toBe('This works!');
+ const textDom = paragraphDom!.querySelector('span');
+ expect(textDom).toBeDefined();
+ expect($isTextNode($getNearestNodeFromDOMNode(textDom!))).toBe(true);
+ expect($getNearestNodeFromDOMNode(textDom!)!.getTextContent()).toBe(
+ 'This works!',
+ );
+ expect(
+ $getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(),
+ ).toBe('This works!');
+ });
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ });
+ it('runs transforms the editor state', async () => {
+ init(function onError(err) {
+ throw err;
+ });
+ expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
+ expect(editor.read(() => $getEditor())).toBe(editor);
+ editor.registerNodeTransform(TextNode, (node) => {
+ if (node.getTextContent() === 'This works!') {
+ node.replace($createTextNode('Transforms work!'));
+ }
+ });
+ const onUpdate = jest.fn();
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ },
+ {onUpdate},
+ );
+ expect(onUpdate).toHaveBeenCalledTimes(0);
+ // This read will flush pending updates
+ expect(editor.read(() => $getRoot().getTextContent())).toEqual(
+ 'Transforms work!',
+ );
+ expect(editor.getRootElement()!.textContent).toEqual('Transforms work!');
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ // Check to make sure there is not an unexpected reconciliation
+ await Promise.resolve().then();
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(editor.read(() => $getRoot().getTextContent())).toEqual(
+ 'Transforms work!',
+ );
+ });
+ it('can be nested in an update or read', async () => {
+ init(function onError(err) {
+ throw err;
+ });
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ editor.read(() => {
+ expect($getRoot().getTextContent()).toBe('This works!');
+ });
+ editor.read(() => {
+ // Nesting update in read works, although it is discouraged in the documentation.
+ editor.update(() => {
+ expect($getRoot().getTextContent()).toBe('This works!');
+ });
+ });
+ // Updating after a nested read will fail as it has already been committed
+ expect(() => {
+ root.append(
+ $createParagraphNode().append(
+ $createTextNode('update-read-update'),
+ ),
+ );
+ }).toThrow();
+ });
+ editor.read(() => {
+ editor.read(() => {
+ expect($getRoot().getTextContent()).toBe('This works!');
+ });
+ });
+ });
+ });
+
+ it('Should create an editor with an initial editor state', async () => {
+ const rootElement = document.createElement('div');
+
+ container.appendChild(rootElement);
+
+ const initialEditor = createTestEditor({
+ onError: jest.fn(),
+ });
+
+ initialEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ });
+
+ initialEditor.setRootElement(rootElement);
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ expect(container.innerHTML).toBe(
+ '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
+ );
+
+ const initialEditorState = initialEditor.getEditorState();
+ initialEditor.setRootElement(null);
+
+ expect(container.innerHTML).toBe(
+ '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"></div>',
+ );
+
+ editor = createTestEditor({
+ editorState: initialEditorState,
+ onError: jest.fn(),
+ });
+ editor.setRootElement(rootElement);
+
+ expect(editor.getEditorState()).toEqual(initialEditorState);
+ expect(container.innerHTML).toBe(
+ '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
+ );
+ });
+
+ it('Should handle nested updates in the correct sequence', async () => {
+ init();
+ const onUpdate = jest.fn();
+
+ let log: Array<string> = [];
+
+ editor.registerUpdateListener(onUpdate);
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ });
+
+ editor.update(
+ () => {
+ log.push('A1');
+ // To enforce the update
+ $getRoot().markDirty();
+ editor.update(
+ () => {
+ log.push('B1');
+ editor.update(
+ () => {
+ log.push('C1');
+ },
+ {
+ onUpdate: () => {
+ log.push('F1');
+ },
+ },
+ );
+ },
+ {
+ onUpdate: () => {
+ log.push('E1');
+ },
+ },
+ );
+ },
+ {
+ onUpdate: () => {
+ log.push('D1');
+ },
+ },
+ );
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(log).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1']);
+
+ log = [];
+ editor.update(
+ () => {
+ log.push('A2');
+ // To enforce the update
+ $getRoot().markDirty();
+ },
+ {
+ onUpdate: () => {
+ log.push('B2');
+ editor.update(
+ () => {
+ // force flush sync
+ $setCompositionKey('root');
+ log.push('D2');
+ },
+ {
+ onUpdate: () => {
+ log.push('F2');
+ },
+ },
+ );
+ log.push('C2');
+ editor.update(
+ () => {
+ log.push('E2');
+ },
+ {
+ onUpdate: () => {
+ log.push('G2');
+ },
+ },
+ );
+ },
+ },
+ );
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ expect(log).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2']);
+
+ log = [];
+ editor.registerNodeTransform(TextNode, () => {
+ log.push('TextTransform A3');
+ editor.update(
+ () => {
+ log.push('TextTransform B3');
+ },
+ {
+ onUpdate: () => {
+ log.push('TextTransform C3');
+ },
+ },
+ );
+ });
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ expect(log).toEqual([
+ 'TextTransform A3',
+ 'TextTransform B3',
+ 'TextTransform C3',
+ ]);
+
+ log = [];
+ editor.update(
+ () => {
+ log.push('A3');
+ $getRoot().getLastDescendant()!.markDirty();
+ },
+ {
+ onUpdate: () => {
+ log.push('B3');
+ },
+ },
+ );
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ expect(log).toEqual([
+ 'A3',
+ 'TextTransform A3',
+ 'TextTransform B3',
+ 'B3',
+ 'TextTransform C3',
+ ]);
+ });
+
+ it('nested update after selection update triggers exactly 1 update', async () => {
+ init();
+ const onUpdate = jest.fn();
+ editor.registerUpdateListener(onUpdate);
+ editor.update(() => {
+ $setSelection($createRangeSelection());
+ editor.update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Sync update')),
+ );
+ });
+ });
+
+ await Promise.resolve().then();
+
+ const textContent = editor
+ .getEditorState()
+ .read(() => $getRoot().getTextContent());
+ expect(textContent).toBe('Sync update');
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('update does not call onUpdate callback when no dirty nodes', () => {
+ init();
+
+ const fn = jest.fn();
+ editor.update(
+ () => {
+ //
+ },
+ {
+ onUpdate: fn,
+ },
+ );
+ expect(fn).toHaveBeenCalledTimes(0);
+ });
+
+ it('editor.focus() callback is called', async () => {
+ init();
+
+ await editor.update(() => {
+ const root = $getRoot();
+ root.append($createParagraphNode());
+ });
+
+ const fn = jest.fn();
+
+ await editor.focus(fn);
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it('Synchronously runs three transforms, two of them depend on the other', async () => {
+ init();
+
+ // 2. Add italics
+ const italicsListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (
+ node.getTextContent() === 'foo' &&
+ node.hasFormat('bold') &&
+ !node.hasFormat('italic')
+ ) {
+ node.toggleFormat('italic');
+ }
+ });
+
+ // 1. Add bold
+ const boldListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
+ node.toggleFormat('bold');
+ }
+ });
+
+ // 2. Add underline
+ const underlineListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (
+ node.getTextContent() === 'foo' &&
+ node.hasFormat('bold') &&
+ !node.hasFormat('underline')
+ ) {
+ node.toggleFormat('underline');
+ }
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.append($createTextNode('foo'));
+ });
+ italicsListener();
+ boldListener();
+ underlineListener();
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><strong class="editor-text-bold editor-text-italic editor-text-underline" data-lexical-text="true">foo</strong></p></div>',
+ );
+ });
+
+ it('Synchronously runs three transforms, two of them depend on the other (2)', async () => {
+ await init();
+
+ // Add transform makes everything dirty the first time (let's not leverage this here)
+ const skipFirst = [true, true, true];
+
+ // 2. (Block transform) Add text
+ const testParagraphListener = editor.registerNodeTransform(
+ ParagraphNode,
+ (paragraph) => {
+ if (skipFirst[0]) {
+ skipFirst[0] = false;
+
+ return;
+ }
+
+ if (paragraph.isEmpty()) {
+ paragraph.append($createTextNode('foo'));
+ }
+ },
+ );
+
+ // 2. (Text transform) Add bold to text
+ const boldListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
+ node.toggleFormat('bold');
+ }
+ });
+
+ // 3. (Block transform) Add italics to bold text
+ const italicsListener = editor.registerNodeTransform(
+ ParagraphNode,
+ (paragraph) => {
+ const child = paragraph.getLastDescendant();
+
+ if (
+ $isTextNode(child) &&
+ child.hasFormat('bold') &&
+ !child.hasFormat('italic')
+ ) {
+ child.toggleFormat('italic');
+ }
+ },
+ );
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild();
+ paragraph!.markDirty();
+ });
+
+ testParagraphListener();
+ boldListener();
+ italicsListener();
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><strong class="editor-text-bold editor-text-italic" data-lexical-text="true">foo</strong></p></div>',
+ );
+ });
+
+ it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => {
+ const hasRun = [false, false, false];
+ init();
+
+ // 1. [Foo] into [<empty>,Fo,o,<empty>,!,<empty>]
+ const fooListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (node.getTextContent() === 'Foo' && !hasRun[0]) {
+ const [before, after] = node.splitText(2);
+
+ before.insertBefore($createTextNode(''));
+ after.insertAfter($createTextNode(''));
+ after.insertAfter($createTextNode('!'));
+ after.insertAfter($createTextNode(''));
+
+ hasRun[0] = true;
+ }
+ });
+
+ // 2. [Foo!] into [<empty>,Fo,o!,<empty>,!,<empty>]
+ const megaFooListener = editor.registerNodeTransform(
+ ParagraphNode,
+ (paragraph) => {
+ const child = paragraph.getFirstChild();
+
+ if (
+ $isTextNode(child) &&
+ child.getTextContent() === 'Foo!' &&
+ !hasRun[1]
+ ) {
+ const [before, after] = child.splitText(2);
+
+ before.insertBefore($createTextNode(''));
+ after.insertAfter($createTextNode(''));
+ after.insertAfter($createTextNode('!'));
+ after.insertAfter($createTextNode(''));
+
+ hasRun[1] = true;
+ }
+ },
+ );
+
+ // 3. [Foo!!] into formatted bold [<empty>,Fo,o!!,<empty>]
+ const boldFooListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (node.getTextContent() === 'Foo!!' && !hasRun[2]) {
+ node.toggleFormat('bold');
+
+ const [before, after] = node.splitText(2);
+ before.insertBefore($createTextNode(''));
+ after.insertAfter($createTextNode(''));
+
+ hasRun[2] = true;
+ }
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+
+ root.append(paragraph);
+ paragraph.append($createTextNode('Foo'));
+ });
+
+ fooListener();
+ megaFooListener();
+ boldFooListener();
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">Foo!!</strong></p></div>',
+ );
+ });
+
+ it('text transform runs when node is removed', async () => {
+ init();
+
+ const executeTransform = jest.fn();
+ let hasBeenRemoved = false;
+ const removeListener = editor.registerNodeTransform(TextNode, (node) => {
+ if (hasBeenRemoved) {
+ executeTransform();
+ }
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.append(
+ $createTextNode('Foo').toggleUnmergeable(),
+ $createTextNode('Bar').toggleUnmergeable(),
+ );
+ });
+
+ await editor.update(() => {
+ $getRoot().getLastDescendant()!.remove();
+ hasBeenRemoved = true;
+ });
+
+ expect(executeTransform).toHaveBeenCalledTimes(1);
+
+ removeListener();
+ });
+
+ it('transforms only run on nodes that were explicitly marked as dirty', async () => {
+ init();
+
+ let executeParagraphNodeTransform = () => {
+ return;
+ };
+
+ let executeTextNodeTransform = () => {
+ return;
+ };
+
+ const removeParagraphTransform = editor.registerNodeTransform(
+ ParagraphNode,
+ (node) => {
+ executeParagraphNodeTransform();
+ },
+ );
+ const removeTextNodeTransform = editor.registerNodeTransform(
+ TextNode,
+ (node) => {
+ executeTextNodeTransform();
+ },
+ );
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.append($createTextNode('Foo'));
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild() as ParagraphNode;
+ const textNode = paragraph.getFirstChild() as TextNode;
+
+ textNode.getWritable();
+
+ executeParagraphNodeTransform = jest.fn();
+ executeTextNodeTransform = jest.fn();
+ });
+
+ expect(executeParagraphNodeTransform).toHaveBeenCalledTimes(0);
+ expect(executeTextNodeTransform).toHaveBeenCalledTimes(1);
+
+ removeParagraphTransform();
+ removeTextNodeTransform();
+ });
+
+ describe('transforms on siblings', () => {
+ let textNodeKeys: string[];
+ let textTransformCount: number[];
+ let removeTransform: () => void;
+
+ beforeEach(async () => {
+ init();
+
+ textNodeKeys = [];
+ textTransformCount = [];
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph0 = $createParagraphNode();
+ const paragraph1 = $createParagraphNode();
+ const textNodes: Array<LexicalNode> = [];
+
+ for (let i = 0; i < 6; i++) {
+ const node = $createTextNode(String(i)).toggleUnmergeable();
+ textNodes.push(node);
+ textNodeKeys.push(node.getKey());
+ textTransformCount[i] = 0;
+ }
+
+ root.append(paragraph0, paragraph1);
+ paragraph0.append(...textNodes.slice(0, 3));
+ paragraph1.append(...textNodes.slice(3));
+ });
+
+ removeTransform = editor.registerNodeTransform(TextNode, (node) => {
+ textTransformCount[Number(node.__text)]++;
+ });
+ });
+
+ afterEach(() => {
+ removeTransform();
+ });
+
+ it('on remove', async () => {
+ await editor.update(() => {
+ const textNode1 = $getNodeByKey(textNodeKeys[1])!;
+ textNode1.remove();
+ });
+ expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1]);
+ });
+
+ it('on replace', async () => {
+ await editor.update(() => {
+ const textNode1 = $getNodeByKey(textNodeKeys[1])!;
+ const textNode4 = $getNodeByKey(textNodeKeys[4])!;
+ textNode4.replace(textNode1);
+ });
+ expect(textTransformCount).toEqual([2, 2, 2, 2, 1, 2]);
+ });
+
+ it('on insertBefore', async () => {
+ await editor.update(() => {
+ const textNode1 = $getNodeByKey(textNodeKeys[1])!;
+ const textNode4 = $getNodeByKey(textNodeKeys[4])!;
+ textNode4.insertBefore(textNode1);
+ });
+ expect(textTransformCount).toEqual([2, 2, 2, 2, 2, 1]);
+ });
+
+ it('on insertAfter', async () => {
+ await editor.update(() => {
+ const textNode1 = $getNodeByKey(textNodeKeys[1])!;
+ const textNode4 = $getNodeByKey(textNodeKeys[4])!;
+ textNode4.insertAfter(textNode1);
+ });
+ expect(textTransformCount).toEqual([2, 2, 2, 1, 2, 2]);
+ });
+
+ it('on splitText', async () => {
+ await editor.update(() => {
+ const textNode1 = $getNodeByKey(textNodeKeys[1]) as TextNode;
+ textNode1.setTextContent('67');
+ textNode1.splitText(1);
+ textTransformCount.push(0, 0);
+ });
+ expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1, 1, 1]);
+ });
+
+ it('on append', async () => {
+ await editor.update(() => {
+ const paragraph1 = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph1.append($createTextNode('6').toggleUnmergeable());
+ textTransformCount.push(0);
+ });
+ expect(textTransformCount).toEqual([1, 1, 2, 1, 1, 1, 1]);
+ });
+ });
+
+ it('Detects infinite recursivity on transforms', async () => {
+ const errorListener = jest.fn();
+ init(errorListener);
+
+ const boldListener = editor.registerNodeTransform(TextNode, (node) => {
+ node.toggleFormat('bold');
+ });
+
+ expect(errorListener).toHaveBeenCalledTimes(0);
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.append($createTextNode('foo'));
+ });
+
+ expect(errorListener).toHaveBeenCalledTimes(1);
+ boldListener();
+ });
+
+ it('Should be able to update an editor state without a root element', () => {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase({element}: {element: HTMLElement | null}) {
+ editor = useMemo(() => createTestEditor(), []);
+
+ useEffect(() => {
+ editor.setRootElement(element);
+ }, [element]);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase element={null} />);
+ });
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ });
+
+ expect(container.innerHTML).toBe('<div contenteditable="true"></div>');
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase element={ref.current} />);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
+ );
+ });
+
+ it('Should be able to recover from an update error', async () => {
+ const errorListener = jest.fn();
+ init(errorListener);
+ editor.update(() => {
+ const root = $getRoot();
+
+ if (root.getFirstChild() === null) {
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('This works!');
+ root.append(paragraph);
+ paragraph.append(text);
+ }
+ });
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
+ );
+ expect(errorListener).toHaveBeenCalledTimes(0);
+
+ editor.update(() => {
+ const root = $getRoot();
+ root
+ .getFirstChild<ElementNode>()!
+ .getFirstChild<ElementNode>()!
+ .getFirstChild<TextNode>()!
+ .setTextContent('Foo');
+ });
+
+ expect(errorListener).toHaveBeenCalledTimes(1);
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
+ );
+ });
+
+ it('Should be able to handle a change in root element', async () => {
+ const rootListener = jest.fn();
+ const updateListener = jest.fn();
+
+ function TestBase({changeElement}: {changeElement: boolean}) {
+ editor = useMemo(() => createTestEditor(), []);
+
+ useEffect(() => {
+ editor.update(() => {
+ const root = $getRoot();
+ const firstChild = root.getFirstChild() as ParagraphNode | null;
+ const text = changeElement ? 'Change successful' : 'Not changed';
+
+ if (firstChild === null) {
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode(text);
+ paragraph.append(textNode);
+ root.append(paragraph);
+ } else {
+ const textNode = firstChild.getFirstChild() as TextNode;
+ textNode.setTextContent(text);
+ }
+ });
+ }, [changeElement]);
+
+ useEffect(() => {
+ return editor.registerRootListener(rootListener);
+ }, []);
+
+ useEffect(() => {
+ return editor.registerUpdateListener(updateListener);
+ }, []);
+
+ const ref = useCallback((node: HTMLElement | null) => {
+ editor.setRootElement(node);
+ }, []);
+
+ return changeElement ? (
+ <span ref={ref} contentEditable={true} />
+ ) : (
+ <div ref={ref} contentEditable={true} />
+ );
+ }
+
+ await ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase changeElement={false} />);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">Not changed</span></p></div>',
+ );
+
+ await ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase changeElement={true} />);
+ });
+
+ expect(rootListener).toHaveBeenCalledTimes(3);
+ expect(updateListener).toHaveBeenCalledTimes(3);
+ expect(container.innerHTML).toBe(
+ '<span contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">Change successful</span></p></span>',
+ );
+ });
+
+ for (const editable of [true, false]) {
+ it(`Retains pendingEditor while rootNode is not set (${
+ editable ? 'editable' : 'non-editable'
+ })`, async () => {
+ const JSON_EDITOR_STATE =
+ '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
+ init();
+ const contentEditable = editor.getRootElement();
+ editor.setEditable(editable);
+ editor.setRootElement(null);
+ const editorState = editor.parseEditorState(JSON_EDITOR_STATE);
+ editor.setEditorState(editorState);
+ editor.update(() => {
+ //
+ });
+ editor.setRootElement(contentEditable);
+ expect(JSON.stringify(editor.getEditorState().toJSON())).toBe(
+ JSON_EDITOR_STATE,
+ );
+ });
+ }
+
+ describe('With node decorators', () => {
+ function useDecorators() {
+ const [decorators, setDecorators] = useState(() =>
+ editor.getDecorators<ReactNode>(),
+ );
+
+ // Subscribe to changes
+ useEffect(() => {
+ return editor.registerDecoratorListener<ReactNode>((nextDecorators) => {
+ setDecorators(nextDecorators);
+ });
+ }, []);
+
+ const decoratedPortals = useMemo(
+ () =>
+ Object.keys(decorators).map((nodeKey) => {
+ const reactDecorator = decorators[nodeKey];
+ const element = editor.getElementByKey(nodeKey)!;
+
+ return createPortal(reactDecorator, element);
+ }),
+ [decorators],
+ );
+
+ return decoratedPortals;
+ }
+
+ afterEach(async () => {
+ // Clean up so we are not calling setState outside of act
+ await ReactTestUtils.act(async () => {
+ reactRoot.render(null);
+ await Promise.resolve().then();
+ });
+ });
+
+ it('Should correctly render React component into Lexical node #1', async () => {
+ const listener = jest.fn();
+
+ function Test() {
+ editor = useMemo(() => createTestEditor(), []);
+
+ useEffect(() => {
+ editor.registerRootListener(listener);
+ }, []);
+
+ const ref = useCallback((node: HTMLDivElement | null) => {
+ editor.setRootElement(node);
+ }, []);
+
+ const decorators = useDecorators();
+
+ return (
+ <>
+ <div ref={ref} contentEditable={true} />
+ {decorators}
+ </>
+ );
+ }
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<Test />);
+ });
+ // Update the editor with the decorator
+ await ReactTestUtils.act(async () => {
+ await editor.update(() => {
+ const paragraph = $createParagraphNode();
+ const test = $createTestDecoratorNode();
+ paragraph.append(test);
+ $getRoot().append(paragraph);
+ });
+ });
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p>' +
+ '<span data-lexical-decorator="true"><span>Hello world</span></span><br></p></div>',
+ );
+ });
+
+ it('Should correctly render React component into Lexical node #2', async () => {
+ const listener = jest.fn();
+
+ function Test({divKey}: {divKey: number}): JSX.Element {
+ function TestPlugin() {
+ [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ return editor.registerRootListener(listener);
+ }, []);
+
+ return null;
+ }
+
+ return (
+ <TestComposer>
+ <RichTextPlugin
+ contentEditable={
+ // @ts-ignore
+ // eslint-disable-next-line jsx-a11y/aria-role
+ <ContentEditable key={divKey} role={null} spellCheck={null} />
+ }
+ placeholder={null}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ <TestPlugin />
+ </TestComposer>
+ );
+ }
+
+ await ReactTestUtils.act(async () => {
+ reactRoot.render(<Test divKey={0} />);
+ // Wait for update to complete
+ await Promise.resolve().then();
+ });
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
+ );
+
+ await ReactTestUtils.act(async () => {
+ reactRoot.render(<Test divKey={1} />);
+ // Wait for update to complete
+ await Promise.resolve().then();
+ });
+
+ expect(listener).toHaveBeenCalledTimes(5);
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
+ );
+
+ // Wait for update to complete
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild()!;
+ expect(root).toEqual({
+ __cachedText: '',
+ __dir: null,
+ __first: paragraph.getKey(),
+ __format: 0,
+ __indent: 0,
+ __key: 'root',
+ __last: paragraph.getKey(),
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __type: 'root',
+ });
+ expect(paragraph).toEqual({
+ __dir: null,
+ __first: null,
+ __format: 0,
+ __indent: 0,
+ __key: paragraph.getKey(),
+ __last: null,
+ __next: null,
+ __parent: 'root',
+ __prev: null,
+ __size: 0,
+ __style: '',
+ __textFormat: 0,
+ __textStyle: '',
+ __type: 'paragraph',
+ });
+ });
+ });
+ });
+
+ describe('parseEditorState()', () => {
+ let originalText: TextNode;
+ let parsedParagraph: ParagraphNode;
+ let parsedRoot: RootNode;
+ let parsedText: TextNode;
+ let paragraphKey: string;
+ let textKey: string;
+ let parsedEditorState: EditorState;
+
+ it('exportJSON API - parses parsed JSON', async () => {
+ await update(() => {
+ const paragraph = $createParagraphNode();
+ originalText = $createTextNode('Hello world');
+ originalText.select(6, 11);
+ paragraph.append(originalText);
+ $getRoot().append(paragraph);
+ });
+ const stringifiedEditorState = JSON.stringify(editor.getEditorState());
+ const parsedEditorStateFromObject = editor.parseEditorState(
+ JSON.parse(stringifiedEditorState),
+ );
+ parsedEditorStateFromObject.read(() => {
+ const root = $getRoot();
+ expect(root.getTextContent()).toMatch(/Hello world/);
+ });
+ });
+
+ describe('range selection', () => {
+ beforeEach(async () => {
+ await init();
+
+ await update(() => {
+ const paragraph = $createParagraphNode();
+ originalText = $createTextNode('Hello world');
+ originalText.select(6, 11);
+ paragraph.append(originalText);
+ $getRoot().append(paragraph);
+ });
+ const stringifiedEditorState = JSON.stringify(
+ editor.getEditorState().toJSON(),
+ );
+ parsedEditorState = editor.parseEditorState(stringifiedEditorState);
+ parsedEditorState.read(() => {
+ parsedRoot = $getRoot();
+ parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
+ paragraphKey = parsedParagraph.getKey();
+ parsedText = parsedParagraph.getFirstChild() as TextNode;
+ textKey = parsedText.getKey();
+ });
+ });
+
+ it('Parses the nodes of a stringified editor state', async () => {
+ expect(parsedRoot).toEqual({
+ __cachedText: null,
+ __dir: 'ltr',
+ __first: paragraphKey,
+ __format: 0,
+ __indent: 0,
+ __key: 'root',
+ __last: paragraphKey,
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __type: 'root',
+ });
+ expect(parsedParagraph).toEqual({
+ __dir: 'ltr',
+ __first: textKey,
+ __format: 0,
+ __indent: 0,
+ __key: paragraphKey,
+ __last: textKey,
+ __next: null,
+ __parent: 'root',
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __textFormat: 0,
+ __textStyle: '',
+ __type: 'paragraph',
+ });
+ expect(parsedText).toEqual({
+ __detail: 0,
+ __format: 0,
+ __key: textKey,
+ __mode: 0,
+ __next: null,
+ __parent: paragraphKey,
+ __prev: null,
+ __style: '',
+ __text: 'Hello world',
+ __type: 'text',
+ });
+ });
+
+ it('Parses the text content of the editor state', async () => {
+ expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
+ null,
+ );
+ expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
+ 'Hello world',
+ );
+ });
+ });
+
+ describe('node selection', () => {
+ beforeEach(async () => {
+ init();
+
+ await update(() => {
+ const paragraph = $createParagraphNode();
+ originalText = $createTextNode('Hello world');
+ const selection = $createNodeSelection();
+ selection.add(originalText.getKey());
+ $setSelection(selection);
+ paragraph.append(originalText);
+ $getRoot().append(paragraph);
+ });
+ const stringifiedEditorState = JSON.stringify(
+ editor.getEditorState().toJSON(),
+ );
+ parsedEditorState = editor.parseEditorState(stringifiedEditorState);
+ parsedEditorState.read(() => {
+ parsedRoot = $getRoot();
+ parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
+ paragraphKey = parsedParagraph.getKey();
+ parsedText = parsedParagraph.getFirstChild() as TextNode;
+ textKey = parsedText.getKey();
+ });
+ });
+
+ it('Parses the nodes of a stringified editor state', async () => {
+ expect(parsedRoot).toEqual({
+ __cachedText: null,
+ __dir: 'ltr',
+ __first: paragraphKey,
+ __format: 0,
+ __indent: 0,
+ __key: 'root',
+ __last: paragraphKey,
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __type: 'root',
+ });
+ expect(parsedParagraph).toEqual({
+ __dir: 'ltr',
+ __first: textKey,
+ __format: 0,
+ __indent: 0,
+ __key: paragraphKey,
+ __last: textKey,
+ __next: null,
+ __parent: 'root',
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __textFormat: 0,
+ __textStyle: '',
+ __type: 'paragraph',
+ });
+ expect(parsedText).toEqual({
+ __detail: 0,
+ __format: 0,
+ __key: textKey,
+ __mode: 0,
+ __next: null,
+ __parent: paragraphKey,
+ __prev: null,
+ __style: '',
+ __text: 'Hello world',
+ __type: 'text',
+ });
+ });
+
+ it('Parses the text content of the editor state', async () => {
+ expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
+ null,
+ );
+ expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
+ 'Hello world',
+ );
+ });
+ });
+ });
+
+ describe('$parseSerializedNode()', () => {
+ it('parses serialized nodes', async () => {
+ const expectedTextContent = 'Hello world\n\nHello world';
+ let actualTextContent: string;
+ let root: RootNode;
+ await update(() => {
+ root = $getRoot();
+ root.clear();
+ const paragraph = $createParagraphNode();
+ paragraph.append($createTextNode('Hello world'));
+ root.append(paragraph);
+ });
+ const stringifiedEditorState = JSON.stringify(editor.getEditorState());
+ const parsedEditorStateJson = JSON.parse(stringifiedEditorState);
+ const rootJson = parsedEditorStateJson.root;
+ await update(() => {
+ const children = rootJson.children.map($parseSerializedNode);
+ root = $getRoot();
+ root.append(...children);
+ actualTextContent = root.getTextContent();
+ });
+ expect(actualTextContent!).toEqual(expectedTextContent);
+ });
+ });
+
+ describe('Node children', () => {
+ beforeEach(async () => {
+ init();
+
+ await reset();
+ });
+
+ async function reset() {
+ init();
+
+ await update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ });
+ }
+
+ it('moves node to different tree branches', async () => {
+ function $createElementNodeWithText(text: string) {
+ const elementNode = $createTestElementNode();
+ const textNode = $createTextNode(text);
+ elementNode.append(textNode);
+
+ return [elementNode, textNode];
+ }
+
+ let paragraphNodeKey: string;
+ let elementNode1Key: string;
+ let textNode1Key: string;
+ let elementNode2Key: string;
+ let textNode2Key: string;
+
+ await update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraphNodeKey = paragraph.getKey();
+
+ const [elementNode1, textNode1] = $createElementNodeWithText('A');
+ elementNode1Key = elementNode1.getKey();
+ textNode1Key = textNode1.getKey();
+
+ const [elementNode2, textNode2] = $createElementNodeWithText('B');
+ elementNode2Key = elementNode2.getKey();
+ textNode2Key = textNode2.getKey();
+
+ paragraph.append(elementNode1, elementNode2);
+ });
+
+ await update(() => {
+ const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
+ const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
+ elementNode1.append(elementNode2);
+ });
+ const keys = [
+ paragraphNodeKey!,
+ elementNode1Key!,
+ textNode1Key!,
+ elementNode2Key!,
+ textNode2Key!,
+ ];
+
+ for (let i = 0; i < keys.length; i++) {
+ expect(editor._editorState._nodeMap.has(keys[i])).toBe(true);
+ expect(editor._keyToDOMMap.has(keys[i])).toBe(true);
+ }
+
+ expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root
+ expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div dir="ltr"><span data-lexical-text="true">A</span><div dir="ltr"><span data-lexical-text="true">B</span></div></div></p></div>',
+ );
+ });
+
+ it('moves node to different tree branches (inverse)', async () => {
+ function $createElementNodeWithText(text: string) {
+ const elementNode = $createTestElementNode();
+ const textNode = $createTextNode(text);
+ elementNode.append(textNode);
+
+ return elementNode;
+ }
+
+ let elementNode1Key: string;
+ let elementNode2Key: string;
+
+ await update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+
+ const elementNode1 = $createElementNodeWithText('A');
+ elementNode1Key = elementNode1.getKey();
+
+ const elementNode2 = $createElementNodeWithText('B');
+ elementNode2Key = elementNode2.getKey();
+
+ paragraph.append(elementNode1, elementNode2);
+ });
+
+ await update(() => {
+ const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
+ const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
+ elementNode2.append(elementNode1);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div dir="ltr"><span data-lexical-text="true">B</span><div dir="ltr"><span data-lexical-text="true">A</span></div></div></p></div>',
+ );
+ });
+
+ it('moves node to different tree branches (node appended twice in two different branches)', async () => {
+ function $createElementNodeWithText(text: string) {
+ const elementNode = $createTestElementNode();
+ const textNode = $createTextNode(text);
+ elementNode.append(textNode);
+
+ return elementNode;
+ }
+
+ let elementNode1Key: string;
+ let elementNode2Key: string;
+ let elementNode3Key: string;
+
+ await update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+
+ const elementNode1 = $createElementNodeWithText('A');
+ elementNode1Key = elementNode1.getKey();
+
+ const elementNode2 = $createElementNodeWithText('B');
+ elementNode2Key = elementNode2.getKey();
+
+ const elementNode3 = $createElementNodeWithText('C');
+ elementNode3Key = elementNode3.getKey();
+
+ paragraph.append(elementNode1, elementNode2, elementNode3);
+ });
+
+ await update(() => {
+ const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
+ const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
+ const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode;
+ elementNode2.append(elementNode3);
+ elementNode1.append(elementNode3);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div dir="ltr"><span data-lexical-text="true">A</span><div dir="ltr"><span data-lexical-text="true">C</span></div></div><div dir="ltr"><span data-lexical-text="true">B</span></div></p></div>',
+ );
+ });
+ });
+
+ it('can subscribe and unsubscribe from commands and the callback is fired', () => {
+ init();
+
+ const commandListener = jest.fn();
+ const command = createCommand('TEST_COMMAND');
+ const payload = 'testPayload';
+ const removeCommandListener = editor.registerCommand(
+ command,
+ commandListener,
+ COMMAND_PRIORITY_EDITOR,
+ );
+ editor.dispatchCommand(command, payload);
+ editor.dispatchCommand(command, payload);
+ editor.dispatchCommand(command, payload);
+
+ expect(commandListener).toHaveBeenCalledTimes(3);
+ expect(commandListener).toHaveBeenCalledWith(payload, editor);
+
+ removeCommandListener();
+
+ editor.dispatchCommand(command, payload);
+ editor.dispatchCommand(command, payload);
+ editor.dispatchCommand(command, payload);
+
+ expect(commandListener).toHaveBeenCalledTimes(3);
+ expect(commandListener).toHaveBeenCalledWith(payload, editor);
+ });
+
+ it('removes the command from the command map when no listener are attached', () => {
+ init();
+
+ const commandListener = jest.fn();
+ const commandListenerTwo = jest.fn();
+ const command = createCommand('TEST_COMMAND');
+ const removeCommandListener = editor.registerCommand(
+ command,
+ commandListener,
+ COMMAND_PRIORITY_EDITOR,
+ );
+ const removeCommandListenerTwo = editor.registerCommand(
+ command,
+ commandListenerTwo,
+ COMMAND_PRIORITY_EDITOR,
+ );
+
+ expect(editor._commands).toEqual(
+ new Map([
+ [
+ command,
+ [
+ new Set([commandListener, commandListenerTwo]),
+ new Set(),
+ new Set(),
+ new Set(),
+ new Set(),
+ ],
+ ],
+ ]),
+ );
+
+ removeCommandListener();
+
+ expect(editor._commands).toEqual(
+ new Map([
+ [
+ command,
+ [
+ new Set([commandListenerTwo]),
+ new Set(),
+ new Set(),
+ new Set(),
+ new Set(),
+ ],
+ ],
+ ]),
+ );
+
+ removeCommandListenerTwo();
+
+ expect(editor._commands).toEqual(new Map());
+ });
+
+ it('can register transforms before updates', async () => {
+ init();
+
+ const emptyTransform = () => {
+ return;
+ };
+
+ const removeTextTransform = editor.registerNodeTransform(
+ TextNode,
+ emptyTransform,
+ );
+ const removeParagraphTransform = editor.registerNodeTransform(
+ ParagraphNode,
+ emptyTransform,
+ );
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ });
+
+ removeTextTransform();
+ removeParagraphTransform();
+ });
+
+ it('textcontent listener', async () => {
+ init();
+
+ const fn = jest.fn();
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('foo');
+ root.append(paragraph);
+ paragraph.append(textNode);
+ });
+ editor.registerTextContentListener((text) => {
+ fn(text);
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const child = root.getLastDescendant()!;
+ child.insertAfter($createTextNode('bar'));
+ });
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ expect(fn).toHaveBeenCalledWith('foobar');
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const child = root.getLastDescendant()!;
+ child.insertAfter($createLineBreakNode());
+ });
+
+ expect(fn).toHaveBeenCalledTimes(2);
+ expect(fn).toHaveBeenCalledWith('foobar\n');
+
+ await editor.update(() => {
+ const root = $getRoot();
+ root.clear();
+ const paragraph = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.append($createTextNode('bar'));
+ paragraph2.append($createTextNode('yar'));
+ paragraph.insertAfter(paragraph2);
+ });
+
+ expect(fn).toHaveBeenCalledTimes(3);
+ expect(fn).toHaveBeenCalledWith('bar\n\nyar');
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ root.getLastChild()!.insertAfter(paragraph);
+ paragraph.append($createTextNode('bar2'));
+ paragraph2.append($createTextNode('yar2'));
+ paragraph.insertAfter(paragraph2);
+ });
+
+ expect(fn).toHaveBeenCalledTimes(4);
+ expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
+ });
+
+ it('mutation listener', async () => {
+ init();
+
+ const paragraphNodeMutations = jest.fn();
+ const textNodeMutations = jest.fn();
+ editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
+ skipInitialization: false,
+ });
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+ const paragraphKeys: string[] = [];
+ const textNodeKeys: string[] = [];
+
+ // No await intentional (batch with next)
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('foo');
+ root.append(paragraph);
+ paragraph.append(textNode);
+ paragraphKeys.push(paragraph.getKey());
+ textNodeKeys.push(textNode.getKey());
+ });
+
+ await editor.update(() => {
+ const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
+ const textNode2 = $createTextNode('bar').toggleFormat('bold');
+ const textNode3 = $createTextNode('xyz').toggleFormat('italic');
+ textNode.insertAfter(textNode2);
+ textNode2.insertAfter(textNode3);
+ textNodeKeys.push(textNode2.getKey());
+ textNodeKeys.push(textNode3.getKey());
+ });
+
+ await editor.update(() => {
+ $getRoot().clear();
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+
+ paragraphKeys.push(paragraph.getKey());
+
+ // Created and deleted in the same update (not attached to node)
+ textNodeKeys.push($createTextNode('zzz').getKey());
+ root.append(paragraph);
+ });
+
+ expect(paragraphNodeMutations.mock.calls.length).toBe(3);
+ expect(textNodeMutations.mock.calls.length).toBe(2);
+
+ const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
+ paragraphNodeMutations.mock.calls;
+ const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
+
+ expect(paragraphMutation1[0].size).toBe(1);
+ expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created');
+ expect(paragraphMutation1[0].size).toBe(1);
+ expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed');
+ expect(paragraphMutation3[0].size).toBe(1);
+ expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created');
+ expect(textNodeMutation1[0].size).toBe(3);
+ expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
+ expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
+ expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
+ expect(textNodeMutation2[0].size).toBe(3);
+ expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
+ expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
+ expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
+ });
+ it('mutation listener on newly initialized editor', async () => {
+ editor = createEditor();
+ const textNodeMutations = jest.fn();
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+ expect(textNodeMutations.mock.calls.length).toBe(0);
+ });
+ it('mutation listener with setEditorState', async () => {
+ init();
+
+ await editor.update(() => {
+ $getRoot().append($createParagraphNode());
+ });
+
+ const initialEditorState = editor.getEditorState();
+ const textNodeMutations = jest.fn();
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+ const textNodeKeys: string[] = [];
+
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const textNode1 = $createTextNode('foo');
+ paragraph.append(textNode1);
+ textNodeKeys.push(textNode1.getKey());
+ });
+
+ const fooEditorState = editor.getEditorState();
+
+ await editor.setEditorState(initialEditorState);
+ // This line should have no effect on the mutation listeners
+ const parsedFooEditorState = editor.parseEditorState(
+ JSON.stringify(fooEditorState),
+ );
+
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const textNode2 = $createTextNode('bar').toggleFormat('bold');
+ const textNode3 = $createTextNode('xyz').toggleFormat('italic');
+ paragraph.append(textNode2, textNode3);
+ textNodeKeys.push(textNode2.getKey(), textNode3.getKey());
+ });
+
+ await editor.setEditorState(parsedFooEditorState);
+
+ expect(textNodeMutations.mock.calls.length).toBe(4);
+
+ const [
+ textNodeMutation1,
+ textNodeMutation2,
+ textNodeMutation3,
+ textNodeMutation4,
+ ] = textNodeMutations.mock.calls;
+
+ expect(textNodeMutation1[0].size).toBe(1);
+ expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
+ expect(textNodeMutation2[0].size).toBe(1);
+ expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
+ expect(textNodeMutation3[0].size).toBe(2);
+ expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created');
+ expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created');
+ expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState
+ expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed');
+ expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed');
+ });
+
+ it('mutation listener set for original node should work with the replaced node', async () => {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase() {
+ editor = useLexicalEditor(ref, undefined, [
+ TestTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) => new TestTextNode(node.getTextContent()),
+ withKlass: TestTextNode,
+ },
+ ]);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase />);
+ });
+
+ const textNodeMutations = jest.fn();
+ const textNodeMutationsB = jest.fn();
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+ const textNodeKeys: string[] = [];
+
+ // No await intentional (batch with next)
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('foo');
+ root.append(paragraph);
+ paragraph.append(textNode);
+ textNodeKeys.push(textNode.getKey());
+ });
+
+ await editor.update(() => {
+ const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
+ const textNode2 = $createTextNode('bar').toggleFormat('bold');
+ const textNode3 = $createTextNode('xyz').toggleFormat('italic');
+ textNode.insertAfter(textNode2);
+ textNode2.insertAfter(textNode3);
+ textNodeKeys.push(textNode2.getKey());
+ textNodeKeys.push(textNode3.getKey());
+ });
+
+ editor.registerMutationListener(TextNode, textNodeMutationsB, {
+ skipInitialization: false,
+ });
+
+ await editor.update(() => {
+ $getRoot().clear();
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+
+ // Created and deleted in the same update (not attached to node)
+ textNodeKeys.push($createTextNode('zzz').getKey());
+ root.append(paragraph);
+ });
+
+ expect(textNodeMutations.mock.calls.length).toBe(2);
+ expect(textNodeMutationsB.mock.calls.length).toBe(2);
+
+ const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
+
+ expect(textNodeMutation1[0].size).toBe(3);
+ expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
+ expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
+ expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
+ expect([...textNodeMutation1[1].updateTags]).toEqual([]);
+ expect(textNodeMutation2[0].size).toBe(3);
+ expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
+ expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
+ expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
+ expect([...textNodeMutation2[1].updateTags]).toEqual([]);
+
+ const [textNodeMutationB1, textNodeMutationB2] =
+ textNodeMutationsB.mock.calls;
+
+ expect(textNodeMutationB1[0].size).toBe(3);
+ expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
+ expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created');
+ expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created');
+ expect([...textNodeMutationB1[1].updateTags]).toEqual([
+ 'registerMutationListener',
+ ]);
+ expect(textNodeMutationB2[0].size).toBe(3);
+ expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed');
+ expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed');
+ expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed');
+ expect([...textNodeMutationB2[1].updateTags]).toEqual([]);
+ });
+
+ it('mutation listener should work with the replaced node', async () => {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase() {
+ editor = useLexicalEditor(ref, undefined, [
+ TestTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) => new TestTextNode(node.getTextContent()),
+ withKlass: TestTextNode,
+ },
+ ]);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase />);
+ });
+
+ const textNodeMutations = jest.fn();
+ const textNodeMutationsB = jest.fn();
+ editor.registerMutationListener(TestTextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+ const textNodeKeys: string[] = [];
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('foo');
+ root.append(paragraph);
+ paragraph.append(textNode);
+ textNodeKeys.push(textNode.getKey());
+ });
+
+ editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
+ skipInitialization: false,
+ });
+
+ expect(textNodeMutations.mock.calls.length).toBe(1);
+
+ const [textNodeMutation1] = textNodeMutations.mock.calls;
+
+ expect(textNodeMutation1[0].size).toBe(1);
+ expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
+ expect([...textNodeMutation1[1].updateTags]).toEqual([]);
+
+ const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
+
+ expect(textNodeMutationB1[0].size).toBe(1);
+ expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
+ expect([...textNodeMutationB1[1].updateTags]).toEqual([
+ 'registerMutationListener',
+ ]);
+ });
+
+ it('mutation listeners does not trigger when other node types are mutated', async () => {
+ init();
+
+ const paragraphNodeMutations = jest.fn();
+ const textNodeMutations = jest.fn();
+ editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
+ skipInitialization: false,
+ });
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+
+ await editor.update(() => {
+ $getRoot().append($createParagraphNode());
+ });
+
+ expect(paragraphNodeMutations.mock.calls.length).toBe(1);
+ expect(textNodeMutations.mock.calls.length).toBe(0);
+ });
+
+ it('mutation listeners with normalization', async () => {
+ init();
+
+ const textNodeMutations = jest.fn();
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+ const textNodeKeys: string[] = [];
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode1 = $createTextNode('foo');
+ const textNode2 = $createTextNode('bar');
+
+ textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
+ root.append(paragraph);
+ paragraph.append(textNode1, textNode2);
+ });
+
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const textNode3 = $createTextNode('xyz').toggleFormat('bold');
+ paragraph.append(textNode3);
+ textNodeKeys.push(textNode3.getKey());
+ });
+
+ await editor.update(() => {
+ const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
+ textNode3.toggleFormat('bold'); // Normalize with foobar
+ });
+
+ expect(textNodeMutations.mock.calls.length).toBe(3);
+
+ const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
+ textNodeMutations.mock.calls;
+
+ expect(textNodeMutation1[0].size).toBe(1);
+ expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
+ expect(textNodeMutation2[0].size).toBe(2);
+ expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created');
+ expect(textNodeMutation3[0].size).toBe(2);
+ expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated');
+ expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed');
+ });
+
+ it('mutation "update" listener', async () => {
+ init();
+
+ const paragraphNodeMutations = jest.fn();
+ const textNodeMutations = jest.fn();
+
+ editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
+ skipInitialization: false,
+ });
+ editor.registerMutationListener(TextNode, textNodeMutations, {
+ skipInitialization: false,
+ });
+
+ const paragraphNodeKeys: string[] = [];
+ const textNodeKeys: string[] = [];
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode1 = $createTextNode('foo');
+ textNodeKeys.push(textNode1.getKey());
+ paragraphNodeKeys.push(paragraph.getKey());
+ root.append(paragraph);
+ paragraph.append(textNode1);
+ });
+
+ expect(paragraphNodeMutations.mock.calls.length).toBe(1);
+
+ const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
+ expect(textNodeMutations.mock.calls.length).toBe(1);
+
+ const [textNodeMutation1] = textNodeMutations.mock.calls;
+
+ expect(textNodeMutation1[0].size).toBe(1);
+ expect(paragraphNodeMutation1[0].size).toBe(1);
+
+ // Change first text node's content.
+ await editor.update(() => {
+ const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode;
+ textNode1.setTextContent('Test'); // Normalize with foobar
+ });
+
+ // Append text node to paragraph.
+ await editor.update(() => {
+ const paragraphNode1 = $getNodeByKey(
+ paragraphNodeKeys[0],
+ ) as ParagraphNode;
+ const textNode1 = $createTextNode('foo');
+ paragraphNode1.append(textNode1);
+ });
+
+ expect(textNodeMutations.mock.calls.length).toBe(3);
+
+ const textNodeMutation2 = textNodeMutations.mock.calls[1];
+
+ // Show TextNode was updated when text content changed.
+ expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated');
+ expect(paragraphNodeMutations.mock.calls.length).toBe(2);
+
+ const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
+
+ // Show ParagraphNode was updated when new text node was appended.
+ expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
+
+ let tableCellKey: string;
+ let tableRowKey: string;
+
+ const tableCellMutations = jest.fn();
+ const tableRowMutations = jest.fn();
+
+ editor.registerMutationListener(TableCellNode, tableCellMutations, {
+ skipInitialization: false,
+ });
+ editor.registerMutationListener(TableRowNode, tableRowMutations, {
+ skipInitialization: false,
+ });
+ // Create Table
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const tableCell = $createTableCellNode(0);
+ const tableRow = $createTableRowNode();
+ const table = $createTableNode();
+
+ tableRow.append(tableCell);
+ table.append(tableRow);
+ root.append(table);
+
+ tableRowKey = tableRow.getKey();
+ tableCellKey = tableCell.getKey();
+ });
+ // Add New Table Cell To Row
+
+ await editor.update(() => {
+ const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
+ const tableCell = $createTableCellNode(0);
+ tableRow.append(tableCell);
+ });
+
+ // Update Table Cell
+ await editor.update(() => {
+ const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
+ tableCell.toggleHeaderStyle(1);
+ });
+
+ expect(tableCellMutations.mock.calls.length).toBe(3);
+ const tableCellMutation3 = tableCellMutations.mock.calls[2];
+
+ // Show table cell is updated when header value changes.
+ expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated');
+ expect(tableRowMutations.mock.calls.length).toBe(2);
+
+ const tableRowMutation2 = tableRowMutations.mock.calls[1];
+
+ // Show row is updated when a new child is added.
+ expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
+ });
+
+ it('editable listener', () => {
+ init();
+
+ const editableFn = jest.fn();
+ editor.registerEditableListener(editableFn);
+
+ expect(editor.isEditable()).toBe(true);
+
+ editor.setEditable(false);
+
+ expect(editor.isEditable()).toBe(false);
+
+ editor.setEditable(true);
+
+ expect(editableFn.mock.calls).toEqual([[false], [true]]);
+ });
+
+ it('does not add new listeners while triggering existing', async () => {
+ const updateListener = jest.fn();
+ const mutationListener = jest.fn();
+ const nodeTransformListener = jest.fn();
+ const textContentListener = jest.fn();
+ const editableListener = jest.fn();
+ const commandListener = jest.fn();
+ const TEST_COMMAND = createCommand('TEST_COMMAND');
+
+ init();
+
+ editor.registerUpdateListener(() => {
+ updateListener();
+
+ editor.registerUpdateListener(() => {
+ updateListener();
+ });
+ });
+
+ editor.registerMutationListener(
+ TextNode,
+ (map) => {
+ mutationListener();
+ editor.registerMutationListener(
+ TextNode,
+ () => {
+ mutationListener();
+ },
+ {skipInitialization: true},
+ );
+ },
+ {skipInitialization: false},
+ );
+
+ editor.registerNodeTransform(ParagraphNode, () => {
+ nodeTransformListener();
+ editor.registerNodeTransform(ParagraphNode, () => {
+ nodeTransformListener();
+ });
+ });
+
+ editor.registerEditableListener(() => {
+ editableListener();
+ editor.registerEditableListener(() => {
+ editableListener();
+ });
+ });
+
+ editor.registerTextContentListener(() => {
+ textContentListener();
+ editor.registerTextContentListener(() => {
+ textContentListener();
+ });
+ });
+
+ editor.registerCommand(
+ TEST_COMMAND,
+ (): boolean => {
+ commandListener();
+ editor.registerCommand(
+ TEST_COMMAND,
+ commandListener,
+ COMMAND_PRIORITY_LOW,
+ );
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Hello world')),
+ );
+ });
+
+ editor.dispatchCommand(TEST_COMMAND, false);
+
+ editor.setEditable(false);
+
+ expect(updateListener).toHaveBeenCalledTimes(1);
+ expect(editableListener).toHaveBeenCalledTimes(1);
+ expect(commandListener).toHaveBeenCalledTimes(1);
+ expect(textContentListener).toHaveBeenCalledTimes(1);
+ expect(nodeTransformListener).toHaveBeenCalledTimes(1);
+ expect(mutationListener).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls mutation listener with initial state', async () => {
+ // TODO add tests for node replacement
+ const mutationListenerA = jest.fn();
+ const mutationListenerB = jest.fn();
+ const mutationListenerC = jest.fn();
+ init();
+
+ editor.registerMutationListener(TextNode, mutationListenerA, {
+ skipInitialization: false,
+ });
+ expect(mutationListenerA).toHaveBeenCalledTimes(0);
+
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Hello world')),
+ );
+ });
+
+ function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
+ return {asymmetricMatch};
+ }
+
+ expect(mutationListenerA).toHaveBeenCalledTimes(1);
+ expect(mutationListenerA).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ updateTags: asymmetricMatcher(
+ (s: Set<string>) => !s.has('registerMutationListener'),
+ ),
+ }),
+ );
+ editor.registerMutationListener(TextNode, mutationListenerB, {
+ skipInitialization: false,
+ });
+ editor.registerMutationListener(TextNode, mutationListenerC, {
+ skipInitialization: true,
+ });
+ expect(mutationListenerA).toHaveBeenCalledTimes(1);
+ expect(mutationListenerB).toHaveBeenCalledTimes(1);
+ expect(mutationListenerB).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ updateTags: asymmetricMatcher((s: Set<string>) =>
+ s.has('registerMutationListener'),
+ ),
+ }),
+ );
+ expect(mutationListenerC).toHaveBeenCalledTimes(0);
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Another update!')),
+ );
+ });
+ expect(mutationListenerA).toHaveBeenCalledTimes(2);
+ expect(mutationListenerB).toHaveBeenCalledTimes(2);
+ expect(mutationListenerC).toHaveBeenCalledTimes(1);
+ [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => {
+ expect(fn).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ updateTags: asymmetricMatcher(
+ (s: Set<string>) => !s.has('registerMutationListener'),
+ ),
+ }),
+ );
+ });
+ });
+
+ it('can use discrete for synchronous updates', () => {
+ init();
+ const onUpdate = jest.fn();
+ editor.registerUpdateListener(onUpdate);
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Sync update')),
+ );
+ },
+ {
+ discrete: true,
+ },
+ );
+
+ const textContent = editor
+ .getEditorState()
+ .read(() => $getRoot().getTextContent());
+ expect(textContent).toBe('Sync update');
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('can use discrete after a non-discrete update to flush the entire queue', () => {
+ const headless = createTestHeadlessEditor();
+ const onUpdate = jest.fn();
+ headless.registerUpdateListener(onUpdate);
+ headless.update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Async update')),
+ );
+ });
+ headless.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Sync update')),
+ );
+ },
+ {
+ discrete: true,
+ },
+ );
+
+ const textContent = headless
+ .getEditorState()
+ .read(() => $getRoot().getTextContent());
+ expect(textContent).toBe('Async update\n\nSync update');
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
+ init();
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Async update')),
+ );
+ },
+ {
+ discrete: true,
+ },
+ );
+
+ const headless = createTestHeadlessEditor(editor.getEditorState());
+ headless.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Sync update')),
+ );
+ },
+ {
+ discrete: true,
+ },
+ );
+ const textContent = headless
+ .getEditorState()
+ .read(() => $getRoot().getTextContent());
+ expect(textContent).toBe('Async update\n\nSync update');
+ });
+
+ it('can use discrete in a nested update to flush the entire queue', () => {
+ init();
+ const onUpdate = jest.fn();
+ editor.registerUpdateListener(onUpdate);
+ editor.update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Async update')),
+ );
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Sync update')),
+ );
+ },
+ {
+ discrete: true,
+ },
+ );
+ });
+
+ const textContent = editor
+ .getEditorState()
+ .read(() => $getRoot().getTextContent());
+ expect(textContent).toBe('Async update\n\nSync update');
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not include linebreak into inline elements', async () => {
+ init();
+
+ await editor.update(() => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('Hello'),
+ $createTestInlineElementNode(),
+ ),
+ );
+ });
+
+ expect(container.firstElementChild?.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Hello</span><a></a></p>',
+ );
+ });
+
+ it('reconciles state without root element', () => {
+ editor = createTestEditor({});
+ const state = editor.parseEditorState(
+ `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`,
+ );
+ editor.setEditorState(state);
+ expect(editor._editorState).toBe(state);
+ expect(editor._pendingEditorState).toBe(null);
+ });
+
+ describe('node replacement', () => {
+ it('should work correctly', async () => {
+ const onError = jest.fn();
+
+ const newEditor = createTestEditor({
+ nodes: [
+ TestTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) => new TestTextNode(node.getTextContent()),
+ },
+ ],
+ onError: onError,
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ });
+
+ newEditor.setRootElement(container);
+
+ await newEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('123');
+ root.append(paragraph);
+ paragraph.append(text);
+ expect(text instanceof TestTextNode).toBe(true);
+ expect(text.getTextContent()).toBe('123');
+ });
+
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('should fail if node keys are re-used', async () => {
+ const onError = jest.fn();
+
+ const newEditor = createTestEditor({
+ nodes: [
+ TestTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) =>
+ new TestTextNode(node.getTextContent(), node.getKey()),
+ },
+ ],
+ onError: onError,
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ });
+
+ newEditor.setRootElement(container);
+
+ await newEditor.update(() => {
+ // this will throw
+ $createTextNode('123');
+ expect(false).toBe('unreachable');
+ });
+
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
+ }),
+ );
+ });
+
+ it('node transform to the nodes specified by "replace" should not be applied to the nodes specified by "with" when "withKlass" is not specified', async () => {
+ const onError = jest.fn();
+
+ const newEditor = createTestEditor({
+ nodes: [
+ TestTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) => new TestTextNode(node.getTextContent()),
+ },
+ ],
+ onError: onError,
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ });
+
+ newEditor.setRootElement(container);
+
+ const mockTransform = jest.fn();
+ const removeTransform = newEditor.registerNodeTransform(
+ TextNode,
+ mockTransform,
+ );
+
+ await newEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('123');
+ root.append(paragraph);
+ paragraph.append(text);
+ expect(text instanceof TestTextNode).toBe(true);
+ expect(text.getTextContent()).toBe('123');
+ });
+
+ await newEditor.getEditorState().read(() => {
+ expect(mockTransform).toHaveBeenCalledTimes(0);
+ });
+
+ expect(onError).not.toHaveBeenCalled();
+ removeTransform();
+ });
+
+ it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => {
+ const onError = jest.fn();
+
+ const newEditor = createTestEditor({
+ nodes: [
+ TestTextNode,
+ {
+ replace: TextNode,
+ with: (node: TextNode) => new TestTextNode(node.getTextContent()),
+ withKlass: TestTextNode,
+ },
+ ],
+ onError: onError,
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ });
+
+ newEditor.setRootElement(container);
+
+ const mockTransform = jest.fn();
+ const removeTransform = newEditor.registerNodeTransform(
+ TextNode,
+ mockTransform,
+ );
+
+ await newEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('123');
+ root.append(paragraph);
+ paragraph.append(text);
+ expect(text instanceof TestTextNode).toBe(true);
+ expect(text.getTextContent()).toBe('123');
+ });
+
+ await newEditor.getEditorState().read(() => {
+ expect(mockTransform).toHaveBeenCalledTimes(1);
+ });
+
+ expect(onError).not.toHaveBeenCalled();
+ removeTransform();
+ });
+ });
+
+ it('recovers from reconciler failure and trigger proper prev editor state', async () => {
+ const updateListener = jest.fn();
+ const textListener = jest.fn();
+ const onError = jest.fn();
+ const updateError = new Error('Failed updateDOM');
+
+ init(onError);
+
+ editor.registerUpdateListener(updateListener);
+ editor.registerTextContentListener(textListener);
+
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('Hello')),
+ );
+ });
+
+ // Cause reconciler error in update dom, so that it attempts to fallback by
+ // reseting editor and rerendering whole content
+ jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => {
+ throw updateError;
+ });
+
+ const editorState = editor.getEditorState();
+
+ editor.registerUpdateListener(updateListener);
+
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('world')),
+ );
+ });
+
+ expect(onError).toBeCalledWith(updateError);
+ expect(textListener).toBeCalledWith('Hello\n\nworld');
+ expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
+ });
+
+ it('should call importDOM methods only once', async () => {
+ jest.spyOn(ParagraphNode, 'importDOM');
+
+ class CustomParagraphNode extends ParagraphNode {
+ static getType() {
+ return 'custom-paragraph';
+ }
+
+ static clone(node: CustomParagraphNode) {
+ return new CustomParagraphNode(node.__key);
+ }
+
+ static importJSON() {
+ return new CustomParagraphNode();
+ }
+
+ exportJSON() {
+ return {...super.exportJSON(), type: 'custom-paragraph'};
+ }
+ }
+
+ createTestEditor({nodes: [CustomParagraphNode]});
+
+ expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
+ });
+
+ it('root element count is always positive', () => {
+ const newEditor1 = createTestEditor();
+ const newEditor2 = createTestEditor();
+
+ const container1 = document.createElement('div');
+ const container2 = document.createElement('div');
+
+ newEditor1.setRootElement(container1);
+ newEditor1.setRootElement(null);
+
+ newEditor1.setRootElement(container1);
+ newEditor2.setRootElement(container2);
+ newEditor1.setRootElement(null);
+ newEditor2.setRootElement(null);
+ });
+
+ describe('html config', () => {
+ it('should override export output function', async () => {
+ const onError = jest.fn();
+
+ const newEditor = createTestEditor({
+ html: {
+ export: new Map([
+ [
+ TextNode,
+ (_, target) => {
+ invariant($isTextNode(target));
+
+ return {
+ element: target.hasFormat('bold')
+ ? document.createElement('bor')
+ : document.createElement('foo'),
+ };
+ },
+ ],
+ ]),
+ },
+ onError: onError,
+ });
+
+ newEditor.setRootElement(container);
+
+ newEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode();
+ root.append(paragraph);
+ paragraph.append(text);
+
+ const selection = $createNodeSelection();
+ selection.add(text.getKey());
+
+ const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
+ expect(htmlFoo).toBe('<foo></foo>');
+
+ text.toggleFormat('bold');
+
+ const htmlBold = $generateHtmlFromNodes(newEditor, selection);
+ expect(htmlBold).toBe('<bor></bor>');
+ });
+
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('should override import conversion function', async () => {
+ const onError = jest.fn();
+
+ const newEditor = createTestEditor({
+ html: {
+ import: {
+ figure: () => ({
+ conversion: () => ({node: $createTextNode('yolo')}),
+ priority: 4,
+ }),
+ },
+ },
+ onError: onError,
+ });
+
+ newEditor.setRootElement(container);
+
+ newEditor.update(() => {
+ const html = '<figure></figure>';
+
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(html, 'text/html');
+ const node = $generateNodesFromDOM(newEditor, dom)[0];
+
+ expect(node).toEqual({
+ __detail: 0,
+ __format: 0,
+ __key: node.getKey(),
+ __mode: 0,
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __style: '',
+ __text: 'yolo',
+ __type: 'text',
+ });
+ });
+
+ expect(onError).not.toHaveBeenCalled();
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createTextNode,
+ $getEditor,
+ $getRoot,
+ ParagraphNode,
+ TextNode,
+} from 'lexical';
+
+import {EditorState} from '../../LexicalEditorState';
+import {$createRootNode, RootNode} from '../../nodes/LexicalRootNode';
+import {initializeUnitTest} from '../utils';
+
+describe('LexicalEditorState tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('constructor', async () => {
+ const root = $createRootNode();
+ const nodeMap = new Map([['root', root]]);
+
+ const editorState = new EditorState(nodeMap);
+ expect(editorState._nodeMap).toBe(nodeMap);
+ expect(editorState._selection).toBe(null);
+ });
+
+ test('read()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('foo');
+ paragraph.append(text);
+ $getRoot().append(paragraph);
+ });
+
+ let root!: RootNode;
+ let paragraph!: ParagraphNode;
+ let text!: TextNode;
+
+ editor.getEditorState().read(() => {
+ root = $getRoot();
+ paragraph = root.getFirstChild()!;
+ text = paragraph.getFirstChild()!;
+ });
+
+ expect(root).toEqual({
+ __cachedText: 'foo',
+ __dir: 'ltr',
+ __first: '1',
+ __format: 0,
+ __indent: 0,
+ __key: 'root',
+ __last: '1',
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __type: 'root',
+ });
+ expect(paragraph).toEqual({
+ __dir: 'ltr',
+ __first: '2',
+ __format: 0,
+ __indent: 0,
+ __key: '1',
+ __last: '2',
+ __next: null,
+ __parent: 'root',
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __textFormat: 0,
+ __textStyle: '',
+ __type: 'paragraph',
+ });
+ expect(text).toEqual({
+ __detail: 0,
+ __format: 0,
+ __key: '2',
+ __mode: 0,
+ __next: null,
+ __parent: '1',
+ __prev: null,
+ __style: '',
+ __text: 'foo',
+ __type: 'text',
+ });
+ expect(() => editor.getEditorState().read(() => $getEditor())).toThrow(
+ /Unable to find an active editor/,
+ );
+ expect(
+ editor.getEditorState().read(() => $getEditor(), {editor: editor}),
+ ).toBe(editor);
+ });
+
+ test('toJSON()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Hello world');
+ text.select(6, 11);
+ paragraph.append(text);
+ $getRoot().append(paragraph);
+ });
+
+ expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual(
+ `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`,
+ );
+ });
+
+ test('ensure garbage collection works as expected', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('foo');
+ paragraph.append(text);
+ $getRoot().append(paragraph);
+ });
+ // Remove the first node, which should cause a GC for everything
+
+ await editor.update(() => {
+ $getRoot().getFirstChild()!.remove();
+ });
+
+ expect(editor.getEditorState()._nodeMap).toEqual(
+ new Map([
+ [
+ 'root',
+ {
+ __cachedText: '',
+ __dir: null,
+ __first: null,
+ __format: 0,
+ __indent: 0,
+ __key: 'root',
+ __last: null,
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __size: 0,
+ __style: '',
+ __type: 'root',
+ },
+ ],
+ ]),
+ );
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {ListItemNode, ListNode} from '@lexical/list';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {ContentEditable} from '@lexical/react/LexicalContentEditable';
+import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
+import {ListPlugin} from '@lexical/react/LexicalListPlugin';
+import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
+import {
+ INDENT_CONTENT_COMMAND,
+ LexicalEditor,
+ OUTDENT_CONTENT_COMMAND,
+} from 'lexical';
+import {
+ expectHtmlToBeEqual,
+ html,
+ TestComposer,
+} from 'lexical/src/__tests__/utils';
+import {createRoot, Root} from 'react-dom/client';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+import {
+ INSERT_UNORDERED_LIST_COMMAND,
+ REMOVE_LIST_COMMAND,
+} from '../../../../lexical-list/src/index';
+
+describe('@lexical/list tests', () => {
+ let container: HTMLDivElement;
+ let reactRoot: Root;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ reactRoot = createRoot(container);
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ // @ts-ignore
+ container = null;
+
+ jest.restoreAllMocks();
+ });
+
+ // Shared instance across tests
+ let editor: LexicalEditor;
+
+ function Test(): JSX.Element {
+ function TestPlugin() {
+ // Plugin used just to get our hands on the Editor object
+ [editor] = useLexicalComposerContext();
+ return null;
+ }
+
+ return (
+ <TestComposer config={{nodes: [ListNode, ListItemNode], theme: {}}}>
+ <RichTextPlugin
+ contentEditable={<ContentEditable />}
+ placeholder={
+ <div className="editor-placeholder">Enter some text...</div>
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ <TestPlugin />
+ <ListPlugin />
+ </TestComposer>
+ );
+ }
+
+ test('Toggle an empty list on/off', async () => {
+ ReactTestUtils.act(() => {
+ reactRoot.render(<Test key="MegaSeeds, Morty!" />);
+ });
+
+ await ReactTestUtils.act(async () => {
+ await editor.update(() => {
+ editor.focus();
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
+ });
+ });
+
+ expectHtmlToBeEqual(
+ container.innerHTML,
+ html`
+ <div
+ contenteditable="true"
+ role="textbox"
+ spellcheck="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <br />
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await ReactTestUtils.act(async () => {
+ await editor.update(() => {
+ editor.focus();
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
+ });
+ });
+
+ expectHtmlToBeEqual(
+ container.innerHTML,
+ html`
+ <div
+ contenteditable="true"
+ role="textbox"
+ spellcheck="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <p>
+ <br />
+ </p>
+ </div>
+ <div class="editor-placeholder">Enter some text...</div>
+ `,
+ );
+ });
+
+ test('Can create a list and indent/outdent it', async () => {
+ ReactTestUtils.act(() => {
+ reactRoot.render(<Test key="MegaSeeds, Morty!" />);
+ });
+
+ await ReactTestUtils.act(async () => {
+ await editor.update(() => {
+ editor.focus();
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
+ });
+ });
+
+ expectHtmlToBeEqual(
+ container.innerHTML,
+ html`
+ <div
+ contenteditable="true"
+ role="textbox"
+ spellcheck="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <br />
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await ReactTestUtils.act(async () => {
+ await editor.update(() => {
+ editor.focus();
+ editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
+ });
+ });
+
+ expectHtmlToBeEqual(
+ container.innerHTML,
+ html`
+ <div
+ contenteditable="true"
+ role="textbox"
+ spellcheck="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li value="1"><br /></li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await ReactTestUtils.act(async () => {
+ await editor.update(() => {
+ editor.focus();
+ editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
+ });
+ });
+
+ expectHtmlToBeEqual(
+ container.innerHTML,
+ html`
+ <div
+ contenteditable="true"
+ role="textbox"
+ spellcheck="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <br />
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createRangeSelection,
+ $getRoot,
+ $getSelection,
+ $isDecoratorNode,
+ $isElementNode,
+ $isRangeSelection,
+ $setSelection,
+ createEditor,
+ DecoratorNode,
+ ElementNode,
+ LexicalEditor,
+ NodeKey,
+ ParagraphNode,
+ RangeSelection,
+ SerializedTextNode,
+ TextNode,
+} from 'lexical';
+
+import {LexicalNode} from '../../LexicalNode';
+import {$createParagraphNode} from '../../nodes/LexicalParagraphNode';
+import {$createTextNode} from '../../nodes/LexicalTextNode';
+import {
+ $createTestInlineElementNode,
+ initializeUnitTest,
+ TestElementNode,
+ TestInlineElementNode,
+} from '../utils';
+
+class TestNode extends LexicalNode {
+ static getType(): string {
+ return 'test';
+ }
+
+ static clone(node: TestNode) {
+ return new TestNode(node.__key);
+ }
+
+ createDOM() {
+ return document.createElement('div');
+ }
+
+ static importJSON() {
+ return new TestNode();
+ }
+
+ exportJSON() {
+ return {type: 'test', version: 1};
+ }
+}
+
+class InlineDecoratorNode extends DecoratorNode<string> {
+ static getType(): string {
+ return 'inline-decorator';
+ }
+
+ static clone(): InlineDecoratorNode {
+ return new InlineDecoratorNode();
+ }
+
+ static importJSON() {
+ return new InlineDecoratorNode();
+ }
+
+ exportJSON() {
+ return {type: 'inline-decorator', version: 1};
+ }
+
+ createDOM(): HTMLElement {
+ return document.createElement('span');
+ }
+
+ isInline(): true {
+ return true;
+ }
+
+ isParentRequired(): true {
+ return true;
+ }
+
+ decorate() {
+ return 'inline-decorator';
+ }
+}
+
+// This is a hack to bypass the node type validation on LexicalNode. We never want to create
+// an LexicalNode directly but we're testing the base functionality in this module.
+LexicalNode.getType = function () {
+ return 'node';
+};
+
+describe('LexicalNode tests', () => {
+ initializeUnitTest(
+ (testEnv) => {
+ let paragraphNode: ParagraphNode;
+ let textNode: TextNode;
+
+ beforeEach(async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const rootNode = $getRoot();
+ paragraphNode = new ParagraphNode();
+ textNode = new TextNode('foo');
+ paragraphNode.append(textNode);
+ rootNode.append(paragraphNode);
+ });
+ });
+
+ test('LexicalNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode('__custom_key__');
+ expect(node.__type).toBe('node');
+ expect(node.__key).toBe('__custom_key__');
+ expect(node.__parent).toBe(null);
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(() => new LexicalNode()).toThrow();
+ expect(() => new LexicalNode('__custom_key__')).toThrow();
+ });
+ });
+
+ test('LexicalNode.constructor: type change detected', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const validNode = new TextNode(textNode.__text, textNode.__key);
+ expect(textNode.getLatest()).toBe(textNode);
+ expect(validNode.getLatest()).toBe(textNode);
+ expect(() => new TestNode(textNode.__key)).toThrowError(
+ /TestNode.*re-use key.*TextNode/,
+ );
+ });
+ });
+
+ test('LexicalNode.clone()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode('__custom_key__');
+
+ expect(() => LexicalNode.clone(node)).toThrow();
+ });
+ });
+ test('LexicalNode.afterCloneFrom()', () => {
+ class VersionedTextNode extends TextNode {
+ // ['constructor']!: KlassConstructor<typeof VersionedTextNode>;
+ __version = 0;
+ static getType(): 'vtext' {
+ return 'vtext';
+ }
+ static clone(node: VersionedTextNode): VersionedTextNode {
+ return new VersionedTextNode(node.__text, node.__key);
+ }
+ static importJSON(node: SerializedTextNode): VersionedTextNode {
+ throw new Error('Not implemented');
+ }
+ exportJSON(): SerializedTextNode {
+ throw new Error('Not implemented');
+ }
+ afterCloneFrom(node: this): void {
+ super.afterCloneFrom(node);
+ this.__version = node.__version + 1;
+ }
+ }
+ const editor = createEditor({
+ nodes: [VersionedTextNode],
+ onError(err) {
+ throw err;
+ },
+ });
+ let versionedTextNode: VersionedTextNode;
+
+ editor.update(
+ () => {
+ versionedTextNode = new VersionedTextNode('test');
+ $getRoot().append($createParagraphNode().append(versionedTextNode));
+ expect(versionedTextNode.__version).toEqual(0);
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ expect(versionedTextNode.getLatest().__version).toEqual(0);
+ expect(
+ versionedTextNode.setTextContent('update').setMode('token')
+ .__version,
+ ).toEqual(1);
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ let latest = versionedTextNode.getLatest();
+ expect(versionedTextNode.__version).toEqual(0);
+ expect(versionedTextNode.__mode).toEqual(0);
+ expect(versionedTextNode.getMode()).toEqual('token');
+ expect(latest.__version).toEqual(1);
+ expect(latest.__mode).toEqual(1);
+ latest = latest.setTextContent('another update');
+ expect(latest.__version).toEqual(2);
+ expect(latest.getWritable().__version).toEqual(2);
+ expect(
+ versionedTextNode.getLatest().getWritable().__version,
+ ).toEqual(2);
+ expect(versionedTextNode.getLatest().__version).toEqual(2);
+ expect(versionedTextNode.__mode).toEqual(0);
+ expect(versionedTextNode.getLatest().__mode).toEqual(1);
+ expect(versionedTextNode.getMode()).toEqual('token');
+ },
+ {discrete: true},
+ );
+ });
+
+ test('LexicalNode.getType()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode('__custom_key__');
+ expect(node.getType()).toEqual(node.__type);
+ });
+ });
+
+ test('LexicalNode.isAttached()', async () => {
+ const {editor} = testEnv;
+ let node: LexicalNode;
+
+ await editor.update(() => {
+ node = new LexicalNode('__custom_key__');
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(node.isAttached()).toBe(false);
+ expect(textNode.isAttached()).toBe(true);
+ expect(paragraphNode.isAttached()).toBe(true);
+ });
+
+ expect(() => textNode.isAttached()).toThrow();
+ });
+
+ test('LexicalNode.isSelected()', async () => {
+ const {editor} = testEnv;
+ let node: LexicalNode;
+
+ await editor.update(() => {
+ node = new LexicalNode('__custom_key__');
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(node.isSelected()).toBe(false);
+ expect(textNode.isSelected()).toBe(false);
+ expect(paragraphNode.isSelected()).toBe(false);
+ });
+
+ await editor.update(() => {
+ textNode.select(0, 0);
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.isSelected()).toBe(true);
+ });
+
+ expect(() => textNode.isSelected()).toThrow();
+ });
+
+ test('LexicalNode.isSelected(): selected text node', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ expect(paragraphNode.isSelected()).toBe(false);
+ expect(textNode.isSelected()).toBe(false);
+ });
+
+ await editor.update(() => {
+ textNode.select(0, 0);
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.isSelected()).toBe(true);
+ expect(paragraphNode.isSelected()).toBe(false);
+ });
+ });
+
+ test('LexicalNode.isSelected(): selected block node range', async () => {
+ const {editor} = testEnv;
+ let newParagraphNode: ParagraphNode;
+ let newTextNode: TextNode;
+
+ await editor.update(() => {
+ expect(paragraphNode.isSelected()).toBe(false);
+ expect(textNode.isSelected()).toBe(false);
+ newParagraphNode = new ParagraphNode();
+ newTextNode = new TextNode('bar');
+ newParagraphNode.append(newTextNode);
+ paragraphNode.insertAfter(newParagraphNode);
+ expect(newParagraphNode.isSelected()).toBe(false);
+ expect(newTextNode.isSelected()).toBe(false);
+ });
+
+ await editor.update(() => {
+ textNode.select(0, 0);
+ const selection = $getSelection();
+
+ expect(selection).not.toBe(null);
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.anchor.type = 'text';
+ selection.anchor.offset = 1;
+ selection.anchor.key = textNode.getKey();
+ selection.focus.type = 'text';
+ selection.focus.offset = 1;
+ selection.focus.key = newTextNode.getKey();
+ });
+
+ await Promise.resolve().then();
+
+ await editor.update(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.key).toBe(textNode.getKey());
+ expect(selection.focus.key).toBe(newTextNode.getKey());
+ expect(paragraphNode.isSelected()).toBe(true);
+ expect(textNode.isSelected()).toBe(true);
+ expect(newParagraphNode.isSelected()).toBe(true);
+ expect(newTextNode.isSelected()).toBe(true);
+ });
+ });
+
+ test('LexicalNode.isSelected(): with custom range selection', async () => {
+ const {editor} = testEnv;
+ let newParagraphNode: ParagraphNode;
+ let newTextNode: TextNode;
+
+ await editor.update(() => {
+ expect(paragraphNode.isSelected()).toBe(false);
+ expect(textNode.isSelected()).toBe(false);
+ newParagraphNode = new ParagraphNode();
+ newTextNode = new TextNode('bar');
+ newParagraphNode.append(newTextNode);
+ paragraphNode.insertAfter(newParagraphNode);
+ expect(newParagraphNode.isSelected()).toBe(false);
+ expect(newTextNode.isSelected()).toBe(false);
+ });
+
+ await editor.update(() => {
+ const rangeSelection = $createRangeSelection();
+
+ rangeSelection.anchor.type = 'text';
+ rangeSelection.anchor.offset = 1;
+ rangeSelection.anchor.key = textNode.getKey();
+ rangeSelection.focus.type = 'text';
+ rangeSelection.focus.offset = 1;
+ rangeSelection.focus.key = newTextNode.getKey();
+
+ expect(paragraphNode.isSelected(rangeSelection)).toBe(true);
+ expect(textNode.isSelected(rangeSelection)).toBe(true);
+ expect(newParagraphNode.isSelected(rangeSelection)).toBe(true);
+ expect(newTextNode.isSelected(rangeSelection)).toBe(true);
+ });
+
+ await Promise.resolve().then();
+ });
+
+ describe('LexicalNode.isSelected(): with inline decorator node', () => {
+ let editor: LexicalEditor;
+ let paragraphNode1: ParagraphNode;
+ let paragraphNode2: ParagraphNode;
+ let paragraphNode3: ParagraphNode;
+ let inlineDecoratorNode: InlineDecoratorNode;
+ let names: Record<NodeKey, string>;
+ beforeEach(() => {
+ editor = testEnv.editor;
+ editor.update(() => {
+ inlineDecoratorNode = new InlineDecoratorNode();
+ paragraphNode1 = $createParagraphNode();
+ paragraphNode2 = $createParagraphNode().append(inlineDecoratorNode);
+ paragraphNode3 = $createParagraphNode();
+ names = {
+ [inlineDecoratorNode.getKey()]: 'd',
+ [paragraphNode1.getKey()]: 'p1',
+ [paragraphNode2.getKey()]: 'p2',
+ [paragraphNode3.getKey()]: 'p3',
+ };
+ $getRoot()
+ .clear()
+ .append(paragraphNode1, paragraphNode2, paragraphNode3);
+ });
+ });
+ const cases: {
+ label: string;
+ isSelected: boolean;
+ update: () => void;
+ }[] = [
+ {
+ isSelected: true,
+ label: 'whole editor',
+ update() {
+ $getRoot().select(0);
+ },
+ },
+ {
+ isSelected: true,
+ label: 'containing paragraph',
+ update() {
+ paragraphNode2.select(0);
+ },
+ },
+ {
+ isSelected: true,
+ label: 'before and containing',
+ update() {
+ paragraphNode2
+ .select(0)
+ .anchor.set(paragraphNode1.getKey(), 0, 'element');
+ },
+ },
+ {
+ isSelected: true,
+ label: 'containing and after',
+ update() {
+ paragraphNode2
+ .select(0)
+ .focus.set(paragraphNode3.getKey(), 0, 'element');
+ },
+ },
+ {
+ isSelected: true,
+ label: 'before and after',
+ update() {
+ paragraphNode1
+ .select(0)
+ .focus.set(paragraphNode3.getKey(), 0, 'element');
+ },
+ },
+ {
+ isSelected: false,
+ label: 'collapsed before',
+ update() {
+ paragraphNode2.select(0, 0);
+ },
+ },
+ {
+ isSelected: false,
+ label: 'in another element',
+ update() {
+ paragraphNode1.select(0);
+ },
+ },
+ {
+ isSelected: false,
+ label: 'before',
+ update() {
+ paragraphNode1
+ .select(0)
+ .focus.set(paragraphNode2.getKey(), 0, 'element');
+ },
+ },
+ {
+ isSelected: false,
+ label: 'collapsed after',
+ update() {
+ paragraphNode2.selectEnd();
+ },
+ },
+ {
+ isSelected: false,
+ label: 'after',
+ update() {
+ paragraphNode3
+ .select(0)
+ .anchor.set(
+ paragraphNode2.getKey(),
+ paragraphNode2.getChildrenSize(),
+ 'element',
+ );
+ },
+ },
+ ];
+ for (const {label, isSelected, update} of cases) {
+ test(`${isSelected ? 'is' : "isn't"} selected ${label}`, () => {
+ editor.update(update);
+ const $verify = () => {
+ const selection = $getSelection() as RangeSelection;
+ expect($isRangeSelection(selection)).toBe(true);
+ const dbg = [selection.anchor, selection.focus]
+ .map(
+ (point) =>
+ `(${names[point.key] || point.key}:${point.offset})`,
+ )
+ .join(' ');
+ const nodes = `[${selection
+ .getNodes()
+ .map((k) => names[k.__key] || k.__key)
+ .join(',')}]`;
+ expect([dbg, nodes, inlineDecoratorNode.isSelected()]).toEqual([
+ dbg,
+ nodes,
+ isSelected,
+ ]);
+ };
+ editor.read($verify);
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const backwards = $createRangeSelection();
+ backwards.anchor.set(
+ selection.focus.key,
+ selection.focus.offset,
+ selection.focus.type,
+ );
+ backwards.focus.set(
+ selection.anchor.key,
+ selection.anchor.offset,
+ selection.anchor.type,
+ );
+ $setSelection(backwards);
+ }
+ expect($isRangeSelection(selection)).toBe(true);
+ });
+ editor.read($verify);
+ });
+ }
+ });
+
+ test('LexicalNode.getKey()', async () => {
+ expect(textNode.getKey()).toEqual(textNode.__key);
+ });
+
+ test('LexicalNode.getParent()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ expect(node.getParent()).toBe(null);
+ });
+
+ await editor.getEditorState().read(() => {
+ const rootNode = $getRoot();
+ expect(textNode.getParent()).toBe(paragraphNode);
+ expect(paragraphNode.getParent()).toBe(rootNode);
+ });
+ expect(() => textNode.getParent()).toThrow();
+ });
+
+ test('LexicalNode.getParentOrThrow()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ expect(() => node.getParentOrThrow()).toThrow();
+ });
+
+ await editor.getEditorState().read(() => {
+ const rootNode = $getRoot();
+ expect(textNode.getParent()).toBe(paragraphNode);
+ expect(paragraphNode.getParent()).toBe(rootNode);
+ });
+ expect(() => textNode.getParentOrThrow()).toThrow();
+ });
+
+ test('LexicalNode.getTopLevelElement()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ expect(node.getTopLevelElement()).toBe(null);
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.getTopLevelElement()).toBe(paragraphNode);
+ expect(paragraphNode.getTopLevelElement()).toBe(paragraphNode);
+ });
+ expect(() => textNode.getTopLevelElement()).toThrow();
+ await editor.update(() => {
+ const node = new InlineDecoratorNode();
+ expect(node.getTopLevelElement()).toBe(null);
+ $getRoot().append(node);
+ expect(node.getTopLevelElement()).toBe(node);
+ });
+ editor.getEditorState().read(() => {
+ const elementNodes: ElementNode[] = [];
+ const decoratorNodes: DecoratorNode<unknown>[] = [];
+ for (const child of $getRoot().getChildren()) {
+ expect(child.getTopLevelElement()).toBe(child);
+ if ($isElementNode(child)) {
+ elementNodes.push(child);
+ } else if ($isDecoratorNode(child)) {
+ decoratorNodes.push(child);
+ } else {
+ throw new Error(
+ 'Expecting all children to be ElementNode or DecoratorNode',
+ );
+ }
+ }
+ expect(decoratorNodes).toHaveLength(1);
+ expect(elementNodes).toHaveLength(1);
+ });
+ });
+
+ test('LexicalNode.getTopLevelElementOrThrow()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ expect(() => node.getTopLevelElementOrThrow()).toThrow();
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.getTopLevelElementOrThrow()).toBe(paragraphNode);
+ expect(paragraphNode.getTopLevelElementOrThrow()).toBe(paragraphNode);
+ });
+ expect(() => textNode.getTopLevelElementOrThrow()).toThrow();
+ await editor.update(() => {
+ const node = new InlineDecoratorNode();
+ expect(() => node.getTopLevelElementOrThrow()).toThrow();
+ $getRoot().append(node);
+ expect(node.getTopLevelElementOrThrow()).toBe(node);
+ });
+ });
+
+ test('LexicalNode.getParents()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ expect(node.getParents()).toEqual([]);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ const rootNode = $getRoot();
+ expect(textNode.getParents()).toEqual([paragraphNode, rootNode]);
+ expect(paragraphNode.getParents()).toEqual([rootNode]);
+ });
+ expect(() => textNode.getParents()).toThrow();
+ });
+
+ test('LexicalNode.getPreviousSibling()', async () => {
+ const {editor} = testEnv;
+ let barTextNode: TextNode;
+
+ await editor.update(() => {
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ paragraphNode.append(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(barTextNode.getPreviousSibling()).toEqual({
+ ...textNode,
+ __next: '3',
+ });
+ expect(textNode.getPreviousSibling()).toEqual(null);
+ });
+ expect(() => textNode.getPreviousSibling()).toThrow();
+ });
+
+ test('LexicalNode.getPreviousSiblings()', async () => {
+ const {editor} = testEnv;
+ let barTextNode: TextNode;
+ let bazTextNode: TextNode;
+
+ await editor.update(() => {
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ bazTextNode = new TextNode('baz');
+ bazTextNode.toggleUnmergeable();
+ paragraphNode.append(barTextNode, bazTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span><span data-lexical-text="true">baz</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(bazTextNode.getPreviousSiblings()).toEqual([
+ {
+ ...textNode,
+ __next: '3',
+ },
+ {
+ ...barTextNode,
+ __prev: '2',
+ },
+ ]);
+ expect(barTextNode.getPreviousSiblings()).toEqual([
+ {
+ ...textNode,
+ __next: '3',
+ },
+ ]);
+ expect(textNode.getPreviousSiblings()).toEqual([]);
+ });
+ expect(() => textNode.getPreviousSiblings()).toThrow();
+ });
+
+ test('LexicalNode.getNextSibling()', async () => {
+ const {editor} = testEnv;
+ let barTextNode: TextNode;
+
+ await editor.update(() => {
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ paragraphNode.append(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(barTextNode.getNextSibling()).toEqual(null);
+ expect(textNode.getNextSibling()).toEqual(barTextNode);
+ });
+ expect(() => textNode.getNextSibling()).toThrow();
+ });
+
+ test('LexicalNode.getNextSiblings()', async () => {
+ const {editor} = testEnv;
+ let barTextNode: TextNode;
+ let bazTextNode: TextNode;
+
+ await editor.update(() => {
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ bazTextNode = new TextNode('baz');
+ bazTextNode.toggleUnmergeable();
+ paragraphNode.append(barTextNode, bazTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span><span data-lexical-text="true">baz</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(bazTextNode.getNextSiblings()).toEqual([]);
+ expect(barTextNode.getNextSiblings()).toEqual([bazTextNode]);
+ expect(textNode.getNextSiblings()).toEqual([
+ barTextNode,
+ bazTextNode,
+ ]);
+ });
+ expect(() => textNode.getNextSiblings()).toThrow();
+ });
+
+ test('LexicalNode.getCommonAncestor()', async () => {
+ const {editor} = testEnv;
+ let quxTextNode: TextNode;
+ let barParagraphNode: ParagraphNode;
+ let barTextNode: TextNode;
+ let bazParagraphNode: ParagraphNode;
+ let bazTextNode: TextNode;
+
+ await editor.update(() => {
+ const rootNode = $getRoot();
+ barParagraphNode = new ParagraphNode();
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ bazParagraphNode = new ParagraphNode();
+ bazTextNode = new TextNode('baz');
+ bazTextNode.toggleUnmergeable();
+ quxTextNode = new TextNode('qux');
+ quxTextNode.toggleUnmergeable();
+ paragraphNode.append(quxTextNode);
+ expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(null);
+ barParagraphNode.append(barTextNode);
+ bazParagraphNode.append(bazTextNode);
+ expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(null);
+ rootNode.append(barParagraphNode, bazParagraphNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">qux</span></p><p dir="ltr"><span data-lexical-text="true">bar</span></p><p dir="ltr"><span data-lexical-text="true">baz</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ const rootNode = $getRoot();
+ expect(textNode.getCommonAncestor(rootNode)).toBe(rootNode);
+ expect(quxTextNode.getCommonAncestor(rootNode)).toBe(rootNode);
+ expect(barTextNode.getCommonAncestor(rootNode)).toBe(rootNode);
+ expect(bazTextNode.getCommonAncestor(rootNode)).toBe(rootNode);
+ expect(textNode.getCommonAncestor(quxTextNode)).toBe(
+ paragraphNode.getLatest(),
+ );
+ expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(rootNode);
+ expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(rootNode);
+ });
+
+ expect(() => textNode.getCommonAncestor(barTextNode)).toThrow();
+ });
+
+ test('LexicalNode.isBefore()', async () => {
+ const {editor} = testEnv;
+ let barTextNode: TextNode;
+ let bazTextNode: TextNode;
+
+ await editor.update(() => {
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ bazTextNode = new TextNode('baz');
+ bazTextNode.toggleUnmergeable();
+ paragraphNode.append(barTextNode, bazTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span><span data-lexical-text="true">baz</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.isBefore(textNode)).toBe(false);
+ expect(textNode.isBefore(barTextNode)).toBe(true);
+ expect(textNode.isBefore(bazTextNode)).toBe(true);
+ expect(barTextNode.isBefore(bazTextNode)).toBe(true);
+ expect(bazTextNode.isBefore(barTextNode)).toBe(false);
+ expect(bazTextNode.isBefore(textNode)).toBe(false);
+ });
+ expect(() => textNode.isBefore(barTextNode)).toThrow();
+ });
+
+ test('LexicalNode.isParentOf()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ const rootNode = $getRoot();
+ expect(rootNode.isParentOf(textNode)).toBe(true);
+ expect(rootNode.isParentOf(paragraphNode)).toBe(true);
+ expect(paragraphNode.isParentOf(textNode)).toBe(true);
+ expect(paragraphNode.isParentOf(rootNode)).toBe(false);
+ expect(textNode.isParentOf(paragraphNode)).toBe(false);
+ expect(textNode.isParentOf(rootNode)).toBe(false);
+ });
+ expect(() => paragraphNode.isParentOf(textNode)).toThrow();
+ });
+
+ test('LexicalNode.getNodesBetween()', async () => {
+ const {editor} = testEnv;
+ let barTextNode: TextNode;
+ let bazTextNode: TextNode;
+ let newParagraphNode: ParagraphNode;
+ let quxTextNode: TextNode;
+
+ await editor.update(() => {
+ const rootNode = $getRoot();
+ barTextNode = new TextNode('bar');
+ barTextNode.toggleUnmergeable();
+ bazTextNode = new TextNode('baz');
+ bazTextNode.toggleUnmergeable();
+ newParagraphNode = new ParagraphNode();
+ quxTextNode = new TextNode('qux');
+ quxTextNode.toggleUnmergeable();
+ rootNode.append(newParagraphNode);
+ paragraphNode.append(barTextNode, bazTextNode);
+ newParagraphNode.append(quxTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span><span data-lexical-text="true">baz</span></p><p dir="ltr"><span data-lexical-text="true">qux</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.getNodesBetween(textNode)).toEqual([textNode]);
+ expect(textNode.getNodesBetween(barTextNode)).toEqual([
+ textNode,
+ barTextNode,
+ ]);
+ expect(textNode.getNodesBetween(bazTextNode)).toEqual([
+ textNode,
+ barTextNode,
+ bazTextNode,
+ ]);
+ expect(textNode.getNodesBetween(quxTextNode)).toEqual([
+ textNode,
+ barTextNode,
+ bazTextNode,
+ paragraphNode.getLatest(),
+ newParagraphNode,
+ quxTextNode,
+ ]);
+ });
+ expect(() => textNode.getNodesBetween(bazTextNode)).toThrow();
+ });
+
+ test('LexicalNode.isToken()', async () => {
+ const {editor} = testEnv;
+ let tokenTextNode: TextNode;
+
+ await editor.update(() => {
+ tokenTextNode = new TextNode('token').setMode('token');
+ paragraphNode.append(tokenTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">token</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.isToken()).toBe(false);
+ expect(tokenTextNode.isToken()).toBe(true);
+ });
+ expect(() => textNode.isToken()).toThrow();
+ });
+
+ test('LexicalNode.isSegmented()', async () => {
+ const {editor} = testEnv;
+ let segmentedTextNode: TextNode;
+
+ await editor.update(() => {
+ segmentedTextNode = new TextNode('segmented').setMode('segmented');
+ paragraphNode.append(segmentedTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">segmented</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.isSegmented()).toBe(false);
+ expect(segmentedTextNode.isSegmented()).toBe(true);
+ });
+ expect(() => textNode.isSegmented()).toThrow();
+ });
+
+ test('LexicalNode.isDirectionless()', async () => {
+ const {editor} = testEnv;
+ let directionlessTextNode: TextNode;
+
+ await editor.update(() => {
+ directionlessTextNode = new TextNode(
+ 'directionless',
+ ).toggleDirectionless();
+ directionlessTextNode.toggleUnmergeable();
+ paragraphNode.append(directionlessTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">directionless</span></p></div>',
+ );
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.isDirectionless()).toBe(false);
+ expect(directionlessTextNode.isDirectionless()).toBe(true);
+ });
+ expect(() => directionlessTextNode.isDirectionless()).toThrow();
+ });
+
+ test('LexicalNode.getLatest()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.getLatest()).toBe(textNode);
+ });
+ expect(() => textNode.getLatest()).toThrow();
+ });
+
+ test('LexicalNode.getLatest(): garbage collected node', async () => {
+ const {editor} = testEnv;
+ let node: LexicalNode;
+ let text: TextNode;
+ let block: TestElementNode;
+
+ await editor.update(() => {
+ node = new LexicalNode();
+ node.getLatest();
+ text = new TextNode('');
+ text.getLatest();
+ block = new TestElementNode();
+ block.getLatest();
+ });
+
+ await editor.update(() => {
+ expect(() => node.getLatest()).toThrow();
+ expect(() => text.getLatest()).toThrow();
+ expect(() => block.getLatest()).toThrow();
+ });
+ });
+
+ test('LexicalNode.getTextContent()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ expect(node.getTextContent()).toBe('');
+ });
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.getTextContent()).toBe('foo');
+ });
+ expect(() => textNode.getTextContent()).toThrow();
+ });
+
+ test('LexicalNode.getTextContentSize()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ expect(textNode.getTextContentSize()).toBe('foo'.length);
+ });
+ expect(() => textNode.getTextContentSize()).toThrow();
+ });
+
+ test('LexicalNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ editor.update(() => {
+ const node = new LexicalNode();
+ expect(() =>
+ node.createDOM(
+ {
+ namespace: '',
+ theme: {},
+ },
+ editor,
+ ),
+ ).toThrow();
+ });
+ });
+
+ test('LexicalNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ // @ts-expect-error
+ expect(() => node.updateDOM()).toThrow();
+ });
+ });
+
+ test('LexicalNode.remove()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ expect(() => textNode.remove()).toThrow();
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const node = new LexicalNode();
+ node.remove();
+ expect(node.getParent()).toBe(null);
+ textNode.remove();
+ expect(textNode.getParent()).toBe(null);
+ expect(editor._dirtyLeaves.has(textNode.getKey()));
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
+ );
+ expect(() => textNode.remove()).toThrow();
+ });
+
+ test('LexicalNode.replace()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ // @ts-expect-error
+ expect(() => textNode.replace()).toThrow();
+ });
+ expect(() => textNode.remove()).toThrow();
+ });
+
+ test('LexicalNode.replace(): from another parent', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+ let barTextNode: TextNode;
+
+ await editor.update(() => {
+ const rootNode = $getRoot();
+ const barParagraphNode = new ParagraphNode();
+ barTextNode = new TextNode('bar');
+ barParagraphNode.append(barTextNode);
+ rootNode.append(barParagraphNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p><p dir="ltr"><span data-lexical-text="true">bar</span></p></div>',
+ );
+
+ await editor.update(() => {
+ textNode.replace(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">bar</span></p><p dir="ltr"><br></p></div>',
+ );
+ });
+
+ test('LexicalNode.replace(): text', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar');
+ textNode.replace(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">bar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.replace(): token', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar').setMode('token');
+ textNode.replace(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">bar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.replace(): segmented', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar').setMode('segmented');
+ textNode.replace(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">bar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.replace(): directionless', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode(`bar`).toggleDirectionless();
+ textNode.replace(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">bar</span></p></div>',
+ );
+ // TODO: add text direction validations
+ });
+
+ test('LexicalNode.replace() within canBeEmpty: false', async () => {
+ const {editor} = testEnv;
+
+ jest
+ .spyOn(TestInlineElementNode.prototype, 'canBeEmpty')
+ .mockReturnValue(false);
+
+ await editor.update(() => {
+ textNode = $createTextNode('Hello');
+
+ $getRoot()
+ .clear()
+ .append(
+ $createParagraphNode().append(
+ $createTestInlineElementNode().append(textNode),
+ ),
+ );
+
+ textNode.replace($createTextNode('world'));
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><a dir="ltr"><span data-lexical-text="true">world</span></a></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertAfter()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ // @ts-expect-error
+ expect(() => textNode.insertAfter()).toThrow();
+ });
+ // @ts-expect-error
+ expect(() => textNode.insertAfter()).toThrow();
+ });
+
+ test('LexicalNode.insertAfter(): text', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar');
+ textNode.insertAfter(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foobar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertAfter(): token', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar').setMode('token');
+ textNode.insertAfter(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertAfter(): segmented', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar').setMode('token');
+ textNode.insertAfter(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">bar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertAfter(): directionless', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode(`bar`).toggleDirectionless();
+ textNode.insertAfter(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foobar</span></p></div>',
+ );
+ // TODO: add text direction validations
+ });
+
+ test('LexicalNode.insertAfter() move blocks around', async () => {
+ const {editor} = testEnv;
+ let block1: ParagraphNode,
+ block2: ParagraphNode,
+ block3: ParagraphNode,
+ text1: TextNode,
+ text2: TextNode,
+ text3: TextNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ root.clear();
+ block1 = new ParagraphNode();
+ block2 = new ParagraphNode();
+ block3 = new ParagraphNode();
+ text1 = new TextNode('A');
+ text2 = new TextNode('B');
+ text3 = new TextNode('C');
+ block1.append(text1);
+ block2.append(text2);
+ block3.append(text3);
+ root.append(block1, block2, block3);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">A</span></p><p dir="ltr"><span data-lexical-text="true">B</span></p><p dir="ltr"><span data-lexical-text="true">C</span></p></div>',
+ );
+
+ await editor.update(() => {
+ text1.insertAfter(block2);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">A</span><p dir="ltr"><span data-lexical-text="true">B</span></p></p><p dir="ltr"><span data-lexical-text="true">C</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertAfter() move blocks around #2', async () => {
+ const {editor} = testEnv;
+ let block1: ParagraphNode,
+ block2: ParagraphNode,
+ block3: ParagraphNode,
+ text1: TextNode,
+ text2: TextNode,
+ text3: TextNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ root.clear();
+ block1 = new ParagraphNode();
+ block2 = new ParagraphNode();
+ block3 = new ParagraphNode();
+ text1 = new TextNode('A');
+ text1.toggleUnmergeable();
+ text2 = new TextNode('B');
+ text2.toggleUnmergeable();
+ text3 = new TextNode('C');
+ text3.toggleUnmergeable();
+ block1.append(text1);
+ block2.append(text2);
+ block3.append(text3);
+ root.append(block1);
+ root.append(block2);
+ root.append(block3);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">A</span></p><p dir="ltr"><span data-lexical-text="true">B</span></p><p dir="ltr"><span data-lexical-text="true">C</span></p></div>',
+ );
+
+ await editor.update(() => {
+ text3.insertAfter(text1);
+ text3.insertAfter(text2);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p><p><br></p><p dir="ltr"><span data-lexical-text="true">C</span><span data-lexical-text="true">B</span><span data-lexical-text="true">A</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertBefore()', async () => {
+ const {editor} = testEnv;
+
+ await editor.getEditorState().read(() => {
+ // @ts-expect-error
+ expect(() => textNode.insertBefore()).toThrow();
+ });
+ // @ts-expect-error
+ expect(() => textNode.insertBefore()).toThrow();
+ });
+
+ test('LexicalNode.insertBefore(): from another parent', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+ let barTextNode;
+
+ await editor.update(() => {
+ const rootNode = $getRoot();
+ const barParagraphNode = new ParagraphNode();
+ barTextNode = new TextNode('bar');
+ barParagraphNode.append(barTextNode);
+ rootNode.append(barParagraphNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p><p dir="ltr"><span data-lexical-text="true">bar</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertBefore(): text', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar');
+ textNode.insertBefore(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">barfoo</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertBefore(): token', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar').setMode('token');
+ textNode.insertBefore(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">bar</span><span data-lexical-text="true">foo</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertBefore(): segmented', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar').setMode('segmented');
+ textNode.insertBefore(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">bar</span><span data-lexical-text="true">foo</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.insertBefore(): directionless', async () => {
+ const {editor} = testEnv;
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">foo</span></p></div>',
+ );
+
+ await editor.update(() => {
+ const barTextNode = new TextNode(`bar`).toggleDirectionless();
+ textNode.insertBefore(barTextNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">barfoo</span></p></div>',
+ );
+ });
+
+ test('LexicalNode.selectNext()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const barTextNode = new TextNode('bar');
+ textNode.insertAfter(barTextNode);
+
+ expect(barTextNode.isSelected()).not.toBe(true);
+
+ textNode.selectNext();
+
+ expect(barTextNode.isSelected()).toBe(true);
+ // TODO: additional validation of anchorOffset and focusOffset
+ });
+ });
+
+ test('LexicalNode.selectNext(): no next sibling', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const selection = textNode.selectNext();
+ expect(selection.anchor.getNode()).toBe(paragraphNode);
+ expect(selection.anchor.offset).toBe(1);
+ });
+ });
+
+ test('LexicalNode.selectNext(): non-text node', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const barNode = new TestNode();
+ textNode.insertAfter(barNode);
+ const selection = textNode.selectNext();
+
+ expect(selection.anchor.getNode()).toBe(textNode.getParent());
+ expect(selection.anchor.offset).toBe(1);
+ });
+ });
+ },
+ {
+ namespace: '',
+ nodes: [LexicalNode, TestNode, InlineDecoratorNode],
+ theme: {},
+ },
+ );
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ RangeSelection,
+} from 'lexical';
+
+import {$normalizeSelection} from '../../LexicalNormalization';
+import {
+ $createTestDecoratorNode,
+ $createTestElementNode,
+ initializeUnitTest,
+} from '../utils';
+
+describe('LexicalNormalization tests', () => {
+ initializeUnitTest((testEnv) => {
+ describe('$normalizeSelection', () => {
+ for (const reversed of [false, true]) {
+ const getAnchor = (x: RangeSelection) =>
+ reversed ? x.focus : x.anchor;
+ const getFocus = (x: RangeSelection) => (reversed ? x.anchor : x.focus);
+ const reversedStr = reversed ? ' (reversed)' : '';
+
+ test(`paragraph to text nodes${reversedStr}`, async () => {
+ const {editor} = testEnv;
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text1 = $createTextNode('a');
+ const text2 = $createTextNode('b');
+ paragraph.append(text1, text2);
+ root.append(paragraph);
+
+ const selection = paragraph.select();
+ getAnchor(selection).set(paragraph.__key, 0, 'element');
+ getFocus(selection).set(paragraph.__key, 2, 'element');
+
+ const normalizedSelection = $normalizeSelection(selection);
+ expect(getAnchor(normalizedSelection).type).toBe('text');
+ expect(getAnchor(normalizedSelection).getNode().__key).toBe(
+ text1.__key,
+ );
+ expect(getAnchor(normalizedSelection).offset).toBe(0);
+ expect(getFocus(normalizedSelection).type).toBe('text');
+ expect(getFocus(normalizedSelection).getNode().__key).toBe(
+ text2.__key,
+ );
+ expect(getFocus(normalizedSelection).offset).toBe(1);
+ });
+ });
+
+ test(`paragraph to text node + element${reversedStr}`, async () => {
+ const {editor} = testEnv;
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text1 = $createTextNode('a');
+ const elementNode = $createTestElementNode();
+ paragraph.append(text1, elementNode);
+ root.append(paragraph);
+
+ const selection = paragraph.select();
+ getAnchor(selection).set(paragraph.__key, 0, 'element');
+ getFocus(selection).set(paragraph.__key, 2, 'element');
+
+ const normalizedSelection = $normalizeSelection(selection);
+ expect(getAnchor(normalizedSelection).type).toBe('text');
+ expect(getAnchor(normalizedSelection).getNode().__key).toBe(
+ text1.__key,
+ );
+ expect(getAnchor(normalizedSelection).offset).toBe(0);
+ expect(getFocus(normalizedSelection).type).toBe('element');
+ expect(getFocus(normalizedSelection).getNode().__key).toBe(
+ elementNode.__key,
+ );
+ expect(getFocus(normalizedSelection).offset).toBe(0);
+ });
+ });
+
+ test(`paragraph to text node + decorator${reversedStr}`, async () => {
+ const {editor} = testEnv;
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text1 = $createTextNode('a');
+ const decoratorNode = $createTestDecoratorNode();
+ paragraph.append(text1, decoratorNode);
+ root.append(paragraph);
+
+ const selection = paragraph.select();
+ getAnchor(selection).set(paragraph.__key, 0, 'element');
+ getFocus(selection).set(paragraph.__key, 2, 'element');
+
+ const normalizedSelection = $normalizeSelection(selection);
+ expect(getAnchor(normalizedSelection).type).toBe('text');
+ expect(getAnchor(normalizedSelection).getNode().__key).toBe(
+ text1.__key,
+ );
+ expect(getAnchor(normalizedSelection).offset).toBe(0);
+ expect(getFocus(normalizedSelection).type).toBe('element');
+ expect(getFocus(normalizedSelection).getNode().__key).toBe(
+ paragraph.__key,
+ );
+ expect(getFocus(normalizedSelection).offset).toBe(2);
+ });
+ });
+
+ test(`text + text node${reversedStr}`, async () => {
+ const {editor} = testEnv;
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text1 = $createTextNode('a');
+ const text2 = $createTextNode('b');
+ paragraph.append(text1, text2);
+ root.append(paragraph);
+
+ const selection = paragraph.select();
+ getAnchor(selection).set(text1.__key, 0, 'text');
+ getFocus(selection).set(text2.__key, 1, 'text');
+
+ const normalizedSelection = $normalizeSelection(selection);
+ expect(getAnchor(normalizedSelection).type).toBe('text');
+ expect(getAnchor(normalizedSelection).getNode().__key).toBe(
+ text1.__key,
+ );
+ expect(getAnchor(normalizedSelection).offset).toBe(0);
+ expect(getFocus(normalizedSelection).type).toBe('text');
+ expect(getFocus(normalizedSelection).getNode().__key).toBe(
+ text2.__key,
+ );
+ expect(getFocus(normalizedSelection).offset).toBe(1);
+ });
+ });
+
+ test(`paragraph to test element to text + text${reversedStr}`, async () => {
+ const {editor} = testEnv;
+ editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const elementNode = $createTestElementNode();
+ const text1 = $createTextNode('a');
+ const text2 = $createTextNode('b');
+ elementNode.append(text1, text2);
+ paragraph.append(elementNode);
+ root.append(paragraph);
+
+ const selection = paragraph.select();
+ getAnchor(selection).set(text1.__key, 0, 'text');
+ getFocus(selection).set(text2.__key, 1, 'text');
+
+ const normalizedSelection = $normalizeSelection(selection);
+ expect(getAnchor(normalizedSelection).type).toBe('text');
+ expect(getAnchor(normalizedSelection).getNode().__key).toBe(
+ text1.__key,
+ );
+ expect(getAnchor(normalizedSelection).offset).toBe(0);
+ expect(getFocus(normalizedSelection).type).toBe('text');
+ expect(getFocus(normalizedSelection).getNode().__key).toBe(
+ text2.__key,
+ );
+ expect(getFocus(normalizedSelection).offset).toBe(1);
+ });
+ });
+ }
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$createLinkNode, $isLinkNode} from '@lexical/link';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $isParagraphNode,
+ $isTextNode,
+ LexicalEditor,
+ RangeSelection,
+} from 'lexical';
+
+import {initializeUnitTest, invariant} from '../utils';
+
+describe('LexicalSelection tests', () => {
+ initializeUnitTest((testEnv) => {
+ describe('Inserting text either side of inline elements', () => {
+ const setup = async (
+ mode: 'start-of-paragraph' | 'mid-paragraph' | 'end-of-paragraph',
+ ) => {
+ const {container, editor} = testEnv;
+
+ if (!container) {
+ throw new Error('Expected container to be truthy');
+ }
+
+ await editor.update(() => {
+ const root = $getRoot();
+ if (root.getFirstChild() !== null) {
+ throw new Error('Expected root to be childless');
+ }
+
+ const paragraph = $createParagraphNode();
+ if (mode === 'start-of-paragraph') {
+ paragraph.append(
+ $createLinkNode('https://', {}).append($createTextNode('a')),
+ $createTextNode('b'),
+ );
+ } else if (mode === 'mid-paragraph') {
+ paragraph.append(
+ $createTextNode('a'),
+ $createLinkNode('https://', {}).append($createTextNode('b')),
+ $createTextNode('c'),
+ );
+ } else {
+ paragraph.append(
+ $createTextNode('a'),
+ $createLinkNode('https://', {}).append($createTextNode('b')),
+ );
+ }
+
+ root.append(paragraph);
+ });
+
+ const expectation =
+ mode === 'start-of-paragraph'
+ ? '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><a href="https://" dir="ltr"><span data-lexical-text="true">a</span></a><span data-lexical-text="true">b</span></p></div>'
+ : mode === 'mid-paragraph'
+ ? '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">c</span></p></div>'
+ : '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a></p></div>';
+
+ expect(container.innerHTML).toBe(expectation);
+
+ return {container, editor};
+ };
+
+ const $insertTextOrNodes = (
+ selection: RangeSelection,
+ method: 'insertText' | 'insertNodes',
+ ) => {
+ if (method === 'insertText') {
+ // Insert text (mirroring what LexicalClipboard does when pasting
+ // inline plain text)
+ selection.insertText('x');
+ } else {
+ // Insert a paragraph bearing a single text node (mirroring what
+ // LexicalClipboard does when pasting inline rich text)
+ selection.insertNodes([
+ $createParagraphNode().append($createTextNode('x')),
+ ]);
+ }
+ };
+
+ describe('Inserting text before inline elements', () => {
+ describe('Start-of-paragraph inline elements', () => {
+ const insertText = async ({
+ container,
+ editor,
+ method,
+ }: {
+ container: HTMLDivElement;
+ editor: LexicalEditor;
+ method: 'insertText' | 'insertNodes';
+ }) => {
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isParagraphNode(paragraph));
+ const linkNode = paragraph.getFirstChildOrThrow();
+ invariant($isLinkNode(linkNode));
+
+ // Place the cursor at the start of the link node
+ // For review: is there a way to select "outside" of the link
+ // node?
+ const selection = linkNode.select(0, 0);
+ $insertTextOrNodes(selection, method);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">x</span><a href="https://" dir="ltr"><span data-lexical-text="true">a</span></a><span data-lexical-text="true">b</span></p></div>',
+ );
+ };
+
+ test('Can insert text before a start-of-paragraph inline element, using insertText', async () => {
+ const {container, editor} = await setup('start-of-paragraph');
+
+ await insertText({container, editor, method: 'insertText'});
+ });
+
+ // TODO: https://github.com/facebook/lexical/issues/4295
+ // test('Can insert text before a start-of-paragraph inline element, using insertNodes', async () => {
+ // const {container, editor} = await setup('start-of-paragraph');
+
+ // await insertText({container, editor, method: 'insertNodes'});
+ // });
+ });
+
+ describe('Mid-paragraph inline elements', () => {
+ const insertText = async ({
+ container,
+ editor,
+ method,
+ }: {
+ container: HTMLDivElement;
+ editor: LexicalEditor;
+ method: 'insertText' | 'insertNodes';
+ }) => {
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isParagraphNode(paragraph));
+ const textNode = paragraph.getFirstChildOrThrow();
+ invariant($isTextNode(textNode));
+
+ // Place the cursor between the link and the first text node by
+ // selecting the end of the text node
+ const selection = textNode.select(1, 1);
+ $insertTextOrNodes(selection, method);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">ax</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">c</span></p></div>',
+ );
+ };
+
+ test('Can insert text before a mid-paragraph inline element, using insertText', async () => {
+ const {container, editor} = await setup('mid-paragraph');
+
+ await insertText({container, editor, method: 'insertText'});
+ });
+
+ test('Can insert text before a mid-paragraph inline element, using insertNodes', async () => {
+ const {container, editor} = await setup('mid-paragraph');
+
+ await insertText({container, editor, method: 'insertNodes'});
+ });
+ });
+
+ describe('End-of-paragraph inline elements', () => {
+ const insertText = async ({
+ container,
+ editor,
+ method,
+ }: {
+ container: HTMLDivElement;
+ editor: LexicalEditor;
+ method: 'insertText' | 'insertNodes';
+ }) => {
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isParagraphNode(paragraph));
+ const textNode = paragraph.getFirstChildOrThrow();
+ invariant($isTextNode(textNode));
+
+ // Place the cursor before the link element by selecting the end
+ // of the text node
+ const selection = textNode.select(1, 1);
+ $insertTextOrNodes(selection, method);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">ax</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a></p></div>',
+ );
+ };
+
+ test('Can insert text before an end-of-paragraph inline element, using insertText', async () => {
+ const {container, editor} = await setup('end-of-paragraph');
+
+ await insertText({container, editor, method: 'insertText'});
+ });
+
+ test('Can insert text before an end-of-paragraph inline element, using insertNodes', async () => {
+ const {container, editor} = await setup('end-of-paragraph');
+
+ await insertText({container, editor, method: 'insertNodes'});
+ });
+ });
+ });
+
+ describe('Inserting text after inline elements', () => {
+ describe('Start-of-paragraph inline elements', () => {
+ const insertText = async ({
+ container,
+ editor,
+ method,
+ }: {
+ container: HTMLDivElement;
+ editor: LexicalEditor;
+ method: 'insertText' | 'insertNodes';
+ }) => {
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isParagraphNode(paragraph));
+ const textNode = paragraph.getLastChildOrThrow();
+ invariant($isTextNode(textNode));
+
+ // Place the cursor between the link and the last text node by
+ // selecting the start of the text node
+ const selection = textNode.select(0, 0);
+ $insertTextOrNodes(selection, method);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><a href="https://" dir="ltr"><span data-lexical-text="true">a</span></a><span data-lexical-text="true">xb</span></p></div>',
+ );
+ };
+
+ test('Can insert text after a start-of-paragraph inline element, using insertText', async () => {
+ const {container, editor} = await setup('start-of-paragraph');
+
+ await insertText({container, editor, method: 'insertText'});
+ });
+
+ // TODO: https://github.com/facebook/lexical/issues/4295
+ // test('Can insert text after a start-of-paragraph inline element, using insertNodes', async () => {
+ // const {container, editor} = await setup('start-of-paragraph');
+
+ // await insertText({container, editor, method: 'insertNodes'});
+ // });
+ });
+
+ describe('Mid-paragraph inline elements', () => {
+ const insertText = async ({
+ container,
+ editor,
+ method,
+ }: {
+ container: HTMLDivElement;
+ editor: LexicalEditor;
+ method: 'insertText' | 'insertNodes';
+ }) => {
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isParagraphNode(paragraph));
+ const textNode = paragraph.getLastChildOrThrow();
+ invariant($isTextNode(textNode));
+
+ // Place the cursor between the link and the last text node by
+ // selecting the start of the text node
+ const selection = textNode.select(0, 0);
+ $insertTextOrNodes(selection, method);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">xc</span></p></div>',
+ );
+ };
+
+ test('Can insert text after a mid-paragraph inline element, using insertText', async () => {
+ const {container, editor} = await setup('mid-paragraph');
+
+ await insertText({container, editor, method: 'insertText'});
+ });
+
+ // TODO: https://github.com/facebook/lexical/issues/4295
+ // test('Can insert text after a mid-paragraph inline element, using insertNodes', async () => {
+ // const {container, editor} = await setup('mid-paragraph');
+
+ // await insertText({container, editor, method: 'insertNodes'});
+ // });
+ });
+
+ describe('End-of-paragraph inline elements', () => {
+ const insertText = async ({
+ container,
+ editor,
+ method,
+ }: {
+ container: HTMLDivElement;
+ editor: LexicalEditor;
+ method: 'insertText' | 'insertNodes';
+ }) => {
+ await editor.update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isParagraphNode(paragraph));
+ const linkNode = paragraph.getLastChildOrThrow();
+ invariant($isLinkNode(linkNode));
+
+ // Place the cursor at the end of the link element
+ // For review: not sure if there's a better way to select
+ // "outside" of the link element.
+ const selection = linkNode.select(1, 1);
+ $insertTextOrNodes(selection, method);
+ });
+
+ expect(container.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">x</span></p></div>',
+ );
+ };
+
+ test('Can insert text after an end-of-paragraph inline element, using insertText', async () => {
+ const {container, editor} = await setup('end-of-paragraph');
+
+ await insertText({container, editor, method: 'insertText'});
+ });
+
+ // TODO: https://github.com/facebook/lexical/issues/4295
+ // test('Can insert text after an end-of-paragraph inline element, using insertNodes', async () => {
+ // const {container, editor} = await setup('end-of-paragraph');
+
+ // await insertText({container, editor, method: 'insertNodes'});
+ // });
+ });
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$createCodeHighlightNode, $createCodeNode} from '@lexical/code';
+import {$createLinkNode} from '@lexical/link';
+import {$createListItemNode, $createListNode} from '@lexical/list';
+import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
+import {$createTableNodeWithDimensions} from '@lexical/table';
+import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
+
+import {initializeUnitTest} from '../utils';
+
+function $createEditorContent() {
+ const root = $getRoot();
+ if (root.getFirstChild() === null) {
+ const heading = $createHeadingNode('h1');
+ heading.append($createTextNode('Welcome to the playground'));
+ root.append(heading);
+ const quote = $createQuoteNode();
+ quote.append(
+ $createTextNode(
+ `In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. ` +
+ `You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.`,
+ ),
+ );
+ root.append(quote);
+ const paragraph = $createParagraphNode();
+ paragraph.append(
+ $createTextNode('The playground is a demo environment built with '),
+ $createTextNode('@lexical/react').toggleFormat('code'),
+ $createTextNode('.'),
+ $createTextNode(' Try typing in '),
+ $createTextNode('some text').toggleFormat('bold'),
+ $createTextNode(' with '),
+ $createTextNode('different').toggleFormat('italic'),
+ $createTextNode(' formats.'),
+ );
+ root.append(paragraph);
+ const paragraph2 = $createParagraphNode();
+ paragraph2.append(
+ $createTextNode(
+ 'Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!',
+ ),
+ );
+ root.append(paragraph2);
+ const paragraph3 = $createParagraphNode();
+ paragraph3.append(
+ $createTextNode(`If you'd like to find out more about Lexical, you can:`),
+ );
+ root.append(paragraph3);
+ const list = $createListNode('bullet');
+ list.append(
+ $createListItemNode().append(
+ $createTextNode(`Visit the `),
+ $createLinkNode('https://lexical.dev/').append(
+ $createTextNode('Lexical website'),
+ ),
+ $createTextNode(` for documentation and more information.`),
+ ),
+ $createListItemNode().append(
+ $createTextNode(`Check out the code on our `),
+ $createLinkNode('https://github.com/facebook/lexical').append(
+ $createTextNode('GitHub repository'),
+ ),
+ $createTextNode(`.`),
+ ),
+ $createListItemNode().append(
+ $createTextNode(`Playground code can be found `),
+ $createLinkNode(
+ 'https://github.com/facebook/lexical/tree/main/packages/lexical-playground',
+ ).append($createTextNode('here')),
+ $createTextNode(`.`),
+ ),
+ $createListItemNode().append(
+ $createTextNode(`Join our `),
+ $createLinkNode('https://discord.com/invite/KmG4wQnnD9').append(
+ $createTextNode('Discord Server'),
+ ),
+ $createTextNode(` and chat with the team.`),
+ ),
+ );
+ root.append(list);
+ const paragraph4 = $createParagraphNode();
+ paragraph4.append(
+ $createTextNode(
+ `Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).`,
+ ),
+ );
+ root.append(paragraph4);
+ const codeBlock = $createCodeNode('javascript');
+ codeBlock.append($createCodeHighlightNode('const lexical = "awesome"'));
+ root.append(codeBlock);
+ const table = $createTableNodeWithDimensions(5, 5, true);
+ root.append(table);
+ }
+}
+
+describe('LexicalSerialization tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('serializes and deserializes from JSON', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ $createEditorContent();
+ });
+
+ const stringifiedEditorState = JSON.stringify(editor.getEditorState());
+ const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"const lexical = \\"awesome\\"","type":"code-highlight","version":1}],"direction":"ltr","format":"","indent":0,"type":"code","version":1,"language":"javascript"},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1}],"direction":"ltr","format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`;
+
+ expect(stringifiedEditorState).toBe(expectedStringifiedEditorState);
+
+ const editorState = editor.parseEditorState(stringifiedEditorState);
+
+ const otherStringifiedEditorState = JSON.stringify(editorState);
+
+ expect(otherStringifiedEditorState).toBe(
+ `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"const lexical = \\"awesome\\"","type":"code-highlight","version":1}],"direction":"ltr","format":"","indent":0,"type":"code","version":1,"language":"javascript"},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`,
+ );
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $getNodeByKey,
+ $getRoot,
+ $isTokenOrSegmented,
+ $nodesOfType,
+ emptyFunction,
+ generateRandomKey,
+ getCachedTypeToNodeMap,
+ getTextDirection,
+ isArray,
+ isSelectionWithinEditor,
+ resetRandomKey,
+ scheduleMicroTask,
+} from '../../LexicalUtils';
+import {
+ $createParagraphNode,
+ ParagraphNode,
+} from '../../nodes/LexicalParagraphNode';
+import {$createTextNode, TextNode} from '../../nodes/LexicalTextNode';
+import {initializeUnitTest} from '../utils';
+
+describe('LexicalUtils tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('scheduleMicroTask(): native', async () => {
+ jest.resetModules();
+
+ let flag = false;
+
+ scheduleMicroTask(() => {
+ flag = true;
+ });
+
+ expect(flag).toBe(false);
+
+ await null;
+
+ expect(flag).toBe(true);
+ });
+
+ test('scheduleMicroTask(): promise', async () => {
+ jest.resetModules();
+ const nativeQueueMicrotask = window.queueMicrotask;
+ const fn = jest.fn();
+ try {
+ // @ts-ignore
+ window.queueMicrotask = undefined;
+ scheduleMicroTask(fn);
+ } finally {
+ // Reset it before yielding control
+ window.queueMicrotask = nativeQueueMicrotask;
+ }
+
+ expect(fn).toHaveBeenCalledTimes(0);
+
+ await null;
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ test('emptyFunction()', () => {
+ expect(emptyFunction).toBeInstanceOf(Function);
+ expect(emptyFunction.length).toBe(0);
+ expect(emptyFunction()).toBe(undefined);
+ });
+
+ test('resetRandomKey()', () => {
+ resetRandomKey();
+ const key1 = generateRandomKey();
+ resetRandomKey();
+ const key2 = generateRandomKey();
+ expect(typeof key1).toBe('string');
+ expect(typeof key2).toBe('string');
+ expect(key1).not.toBe('');
+ expect(key2).not.toBe('');
+ expect(key1).toEqual(key2);
+ });
+
+ test('generateRandomKey()', () => {
+ const key1 = generateRandomKey();
+ const key2 = generateRandomKey();
+ expect(typeof key1).toBe('string');
+ expect(typeof key2).toBe('string');
+ expect(key1).not.toBe('');
+ expect(key2).not.toBe('');
+ expect(key1).not.toEqual(key2);
+ });
+
+ test('isArray()', () => {
+ expect(isArray).toBeInstanceOf(Function);
+ expect(isArray).toBe(Array.isArray);
+ });
+
+ test('isSelectionWithinEditor()', async () => {
+ const {editor} = testEnv;
+ let textNode: TextNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ textNode = $createTextNode('foo');
+ paragraph.append(textNode);
+ root.append(paragraph);
+ });
+
+ await editor.update(() => {
+ const domSelection = window.getSelection()!;
+
+ expect(
+ isSelectionWithinEditor(
+ editor,
+ domSelection.anchorNode,
+ domSelection.focusNode,
+ ),
+ ).toBe(false);
+
+ textNode.select(0, 0);
+ });
+
+ await editor.update(() => {
+ const domSelection = window.getSelection()!;
+
+ expect(
+ isSelectionWithinEditor(
+ editor,
+ domSelection.anchorNode,
+ domSelection.focusNode,
+ ),
+ ).toBe(true);
+ });
+ });
+
+ test('getTextDirection()', () => {
+ expect(getTextDirection('')).toBe(null);
+ expect(getTextDirection(' ')).toBe(null);
+ expect(getTextDirection('0')).toBe(null);
+ expect(getTextDirection('A')).toBe('ltr');
+ expect(getTextDirection('Z')).toBe('ltr');
+ expect(getTextDirection('a')).toBe('ltr');
+ expect(getTextDirection('z')).toBe('ltr');
+ expect(getTextDirection('\u00C0')).toBe('ltr');
+ expect(getTextDirection('\u00D6')).toBe('ltr');
+ expect(getTextDirection('\u00D8')).toBe('ltr');
+ expect(getTextDirection('\u00F6')).toBe('ltr');
+ expect(getTextDirection('\u00F8')).toBe('ltr');
+ expect(getTextDirection('\u02B8')).toBe('ltr');
+ expect(getTextDirection('\u0300')).toBe('ltr');
+ expect(getTextDirection('\u0590')).toBe('ltr');
+ expect(getTextDirection('\u0800')).toBe('ltr');
+ expect(getTextDirection('\u1FFF')).toBe('ltr');
+ expect(getTextDirection('\u200E')).toBe('ltr');
+ expect(getTextDirection('\u2C00')).toBe('ltr');
+ expect(getTextDirection('\uFB1C')).toBe('ltr');
+ expect(getTextDirection('\uFE00')).toBe('ltr');
+ expect(getTextDirection('\uFE6F')).toBe('ltr');
+ expect(getTextDirection('\uFEFD')).toBe('ltr');
+ expect(getTextDirection('\uFFFF')).toBe('ltr');
+ expect(getTextDirection(`\u0591`)).toBe('rtl');
+ expect(getTextDirection(`\u07FF`)).toBe('rtl');
+ expect(getTextDirection(`\uFB1D`)).toBe('rtl');
+ expect(getTextDirection(`\uFDFD`)).toBe('rtl');
+ expect(getTextDirection(`\uFE70`)).toBe('rtl');
+ expect(getTextDirection(`\uFEFC`)).toBe('rtl');
+ });
+
+ test('isTokenOrSegmented()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = $createTextNode('foo');
+ expect($isTokenOrSegmented(node)).toBe(false);
+
+ const tokenNode = $createTextNode().setMode('token');
+ expect($isTokenOrSegmented(tokenNode)).toBe(true);
+
+ const segmentedNode = $createTextNode('foo').setMode('segmented');
+ expect($isTokenOrSegmented(segmentedNode)).toBe(true);
+ });
+ });
+
+ test('$getNodeByKey', async () => {
+ const {editor} = testEnv;
+ let paragraphNode: ParagraphNode;
+ let textNode: TextNode;
+
+ await editor.update(() => {
+ const rootNode = $getRoot();
+ paragraphNode = new ParagraphNode();
+ textNode = new TextNode('foo');
+ paragraphNode.append(textNode);
+ rootNode.append(paragraphNode);
+ });
+
+ await editor.getEditorState().read(() => {
+ expect($getNodeByKey('1')).toBe(paragraphNode);
+ expect($getNodeByKey('2')).toBe(textNode);
+ expect($getNodeByKey('3')).toBe(null);
+ });
+
+ // @ts-expect-error
+ expect(() => $getNodeByKey()).toThrow();
+ });
+
+ test('$nodesOfType', async () => {
+ const {editor} = testEnv;
+ const paragraphKeys: string[] = [];
+
+ const $paragraphKeys = () =>
+ $nodesOfType(ParagraphNode).map((node) => node.getKey());
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ $createParagraphNode();
+ root.append(paragraph1, paragraph2);
+ paragraphKeys.push(paragraph1.getKey(), paragraph2.getKey());
+ const currentParagraphKeys = $paragraphKeys();
+ expect(currentParagraphKeys).toHaveLength(paragraphKeys.length);
+ expect(currentParagraphKeys).toEqual(
+ expect.arrayContaining(paragraphKeys),
+ );
+ });
+ editor.getEditorState().read(() => {
+ const currentParagraphKeys = $paragraphKeys();
+ expect(currentParagraphKeys).toHaveLength(paragraphKeys.length);
+ expect(currentParagraphKeys).toEqual(
+ expect.arrayContaining(paragraphKeys),
+ );
+ });
+ });
+
+ test('getCachedTypeToNodeMap', async () => {
+ const {editor} = testEnv;
+ const paragraphKeys: string[] = [];
+
+ const initialTypeToNodeMap = getCachedTypeToNodeMap(
+ editor.getEditorState(),
+ );
+ expect(getCachedTypeToNodeMap(editor.getEditorState())).toBe(
+ initialTypeToNodeMap,
+ );
+ expect([...initialTypeToNodeMap.keys()]).toEqual(['root']);
+ expect(initialTypeToNodeMap.get('root')).toMatchObject({size: 1});
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode().append(
+ $createTextNode('a'),
+ );
+ const paragraph2 = $createParagraphNode().append(
+ $createTextNode('b'),
+ );
+ // these will be garbage collected and not in the readonly map
+ $createParagraphNode().append($createTextNode('c'));
+ root.append(paragraph1, paragraph2);
+ paragraphKeys.push(paragraph1.getKey(), paragraph2.getKey());
+ },
+ {discrete: true},
+ );
+
+ const typeToNodeMap = getCachedTypeToNodeMap(editor.getEditorState());
+ // verify that the initial cache was not used
+ expect(typeToNodeMap).not.toBe(initialTypeToNodeMap);
+ // verify that the cache is used for subsequent calls
+ expect(getCachedTypeToNodeMap(editor.getEditorState())).toBe(
+ typeToNodeMap,
+ );
+ expect(typeToNodeMap.size).toEqual(3);
+ expect([...typeToNodeMap.keys()]).toEqual(
+ expect.arrayContaining(['root', 'paragraph', 'text']),
+ );
+ const paragraphMap = typeToNodeMap.get('paragraph')!;
+ expect(paragraphMap.size).toEqual(paragraphKeys.length);
+ expect([...paragraphMap.keys()]).toEqual(
+ expect.arrayContaining(paragraphKeys),
+ );
+ const textMap = typeToNodeMap.get('text')!;
+ expect(textMap.size).toEqual(2);
+ expect(
+ [...textMap.values()].map((node) => (node as TextNode).__text),
+ ).toEqual(expect.arrayContaining(['a', 'b']));
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {createHeadlessEditor} from '@lexical/headless';
+import {AutoLinkNode, LinkNode} from '@lexical/link';
+import {ListItemNode, ListNode} from '@lexical/list';
+
+import {HeadingNode, QuoteNode} from '@lexical/rich-text';
+import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
+
+import {
+ $isRangeSelection,
+ createEditor,
+ DecoratorNode,
+ EditorState,
+ EditorThemeClasses,
+ ElementNode,
+ Klass,
+ LexicalEditor,
+ LexicalNode,
+ RangeSelection,
+ SerializedElementNode,
+ SerializedLexicalNode,
+ SerializedTextNode,
+ TextNode,
+} from 'lexical';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+import {
+ CreateEditorArgs,
+ HTMLConfig,
+ LexicalNodeReplacement,
+} from '../../LexicalEditor';
+import {resetRandomKey} from '../../LexicalUtils';
+
+
+type TestEnv = {
+ readonly container: HTMLDivElement;
+ readonly editor: LexicalEditor;
+ readonly outerHTML: string;
+ readonly innerHTML: string;
+};
+
+export function initializeUnitTest(
+ runTests: (testEnv: TestEnv) => void,
+ editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
+) {
+ const testEnv = {
+ _container: null as HTMLDivElement | null,
+ _editor: null as LexicalEditor | null,
+ get container() {
+ if (!this._container) {
+ throw new Error('testEnv.container not initialized.');
+ }
+ return this._container;
+ },
+ set container(container) {
+ this._container = container;
+ },
+ get editor() {
+ if (!this._editor) {
+ throw new Error('testEnv.editor not initialized.');
+ }
+ return this._editor;
+ },
+ set editor(editor) {
+ this._editor = editor;
+ },
+ get innerHTML() {
+ return (this.container.firstChild as HTMLElement).innerHTML;
+ },
+ get outerHTML() {
+ return this.container.innerHTML;
+ },
+ reset() {
+ this._container = null;
+ this._editor = null;
+ },
+ };
+
+ beforeEach(async () => {
+ resetRandomKey();
+
+ testEnv.container = document.createElement('div');
+ document.body.appendChild(testEnv.container);
+
+ const useLexicalEditor = (
+ rootElementRef: React.RefObject<HTMLDivElement>,
+ ) => {
+ const lexicalEditor = React.useMemo(() => {
+ const lexical = createTestEditor(editorConfig);
+ return lexical;
+ }, []);
+
+ React.useEffect(() => {
+ const rootElement = rootElementRef.current;
+ lexicalEditor.setRootElement(rootElement);
+ }, [rootElementRef, lexicalEditor]);
+ return lexicalEditor;
+ };
+
+ const Editor = () => {
+ testEnv.editor = useLexicalEditor(ref);
+ const context = createLexicalComposerContext(
+ null,
+ editorConfig?.theme ?? {},
+ );
+ return (
+ <LexicalComposerContext.Provider value={[testEnv.editor, context]}>
+ <div ref={ref} contentEditable={true} />
+ {plugins}
+ </LexicalComposerContext.Provider>
+ );
+ };
+
+ ReactTestUtils.act(() => {
+ createRoot(testEnv.container).render(<Editor />);
+ });
+ });
+
+ afterEach(() => {
+ document.body.removeChild(testEnv.container);
+ testEnv.reset();
+ });
+
+ runTests(testEnv);
+}
+
+export function initializeClipboard() {
+ Object.defineProperty(window, 'DragEvent', {
+ value: class DragEvent {},
+ });
+ Object.defineProperty(window, 'ClipboardEvent', {
+ value: class ClipboardEvent {},
+ });
+}
+
+export type SerializedTestElementNode = SerializedElementNode;
+
+export class TestElementNode extends ElementNode {
+ static getType(): string {
+ return 'test_block';
+ }
+
+ static clone(node: TestElementNode) {
+ return new TestElementNode(node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedTestElementNode,
+ ): TestInlineElementNode {
+ const node = $createTestInlineElementNode();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedTestElementNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_block',
+ version: 1,
+ };
+ }
+
+ createDOM() {
+ return document.createElement('div');
+ }
+
+ updateDOM() {
+ return false;
+ }
+}
+
+export function $createTestElementNode(): TestElementNode {
+ return new TestElementNode();
+}
+
+type SerializedTestTextNode = SerializedTextNode;
+
+export class TestTextNode extends TextNode {
+ static getType() {
+ return 'test_text';
+ }
+
+ static clone(node: TestTextNode): TestTextNode {
+ return new TestTextNode(node.__text, node.__key);
+ }
+
+ static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
+ return new TestTextNode(serializedNode.text);
+ }
+
+ exportJSON(): SerializedTestTextNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_text',
+ version: 1,
+ };
+ }
+}
+
+export type SerializedTestInlineElementNode = SerializedElementNode;
+
+export class TestInlineElementNode extends ElementNode {
+ static getType(): string {
+ return 'test_inline_block';
+ }
+
+ static clone(node: TestInlineElementNode) {
+ return new TestInlineElementNode(node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedTestInlineElementNode,
+ ): TestInlineElementNode {
+ const node = $createTestInlineElementNode();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedTestInlineElementNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_inline_block',
+ version: 1,
+ };
+ }
+
+ createDOM() {
+ return document.createElement('a');
+ }
+
+ updateDOM() {
+ return false;
+ }
+
+ isInline() {
+ return true;
+ }
+}
+
+export function $createTestInlineElementNode(): TestInlineElementNode {
+ return new TestInlineElementNode();
+}
+
+export type SerializedTestShadowRootNode = SerializedElementNode;
+
+export class TestShadowRootNode extends ElementNode {
+ static getType(): string {
+ return 'test_shadow_root';
+ }
+
+ static clone(node: TestShadowRootNode) {
+ return new TestElementNode(node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedTestShadowRootNode,
+ ): TestShadowRootNode {
+ const node = $createTestShadowRootNode();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedTestShadowRootNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_block',
+ version: 1,
+ };
+ }
+
+ createDOM() {
+ return document.createElement('div');
+ }
+
+ updateDOM() {
+ return false;
+ }
+
+ isShadowRoot() {
+ return true;
+ }
+}
+
+export function $createTestShadowRootNode(): TestShadowRootNode {
+ return new TestShadowRootNode();
+}
+
+export type SerializedTestSegmentedNode = SerializedTextNode;
+
+export class TestSegmentedNode extends TextNode {
+ static getType(): string {
+ return 'test_segmented';
+ }
+
+ static clone(node: TestSegmentedNode): TestSegmentedNode {
+ return new TestSegmentedNode(node.__text, node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedTestSegmentedNode,
+ ): TestSegmentedNode {
+ const node = $createTestSegmentedNode(serializedNode.text);
+ node.setFormat(serializedNode.format);
+ node.setDetail(serializedNode.detail);
+ node.setMode(serializedNode.mode);
+ node.setStyle(serializedNode.style);
+ return node;
+ }
+
+ exportJSON(): SerializedTestSegmentedNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_segmented',
+ version: 1,
+ };
+ }
+}
+
+export function $createTestSegmentedNode(text: string): TestSegmentedNode {
+ return new TestSegmentedNode(text).setMode('segmented');
+}
+
+export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
+
+export class TestExcludeFromCopyElementNode extends ElementNode {
+ static getType(): string {
+ return 'test_exclude_from_copy_block';
+ }
+
+ static clone(node: TestExcludeFromCopyElementNode) {
+ return new TestExcludeFromCopyElementNode(node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedTestExcludeFromCopyElementNode,
+ ): TestExcludeFromCopyElementNode {
+ const node = $createTestExcludeFromCopyElementNode();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedTestExcludeFromCopyElementNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_exclude_from_copy_block',
+ version: 1,
+ };
+ }
+
+ createDOM() {
+ return document.createElement('div');
+ }
+
+ updateDOM() {
+ return false;
+ }
+
+ excludeFromCopy() {
+ return true;
+ }
+}
+
+export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
+ return new TestExcludeFromCopyElementNode();
+}
+
+export type SerializedTestDecoratorNode = SerializedLexicalNode;
+
+export class TestDecoratorNode extends DecoratorNode<JSX.Element> {
+ static getType(): string {
+ return 'test_decorator';
+ }
+
+ static clone(node: TestDecoratorNode) {
+ return new TestDecoratorNode(node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedTestDecoratorNode,
+ ): TestDecoratorNode {
+ return $createTestDecoratorNode();
+ }
+
+ exportJSON(): SerializedTestDecoratorNode {
+ return {
+ ...super.exportJSON(),
+ type: 'test_decorator',
+ version: 1,
+ };
+ }
+
+ static importDOM() {
+ return {
+ 'test-decorator': (domNode: HTMLElement) => {
+ return {
+ conversion: () => ({node: $createTestDecoratorNode()}),
+ };
+ },
+ };
+ }
+
+ exportDOM() {
+ return {
+ element: document.createElement('test-decorator'),
+ };
+ }
+
+ getTextContent() {
+ return 'Hello world';
+ }
+
+ createDOM() {
+ return document.createElement('span');
+ }
+
+ updateDOM() {
+ return false;
+ }
+
+ decorate() {
+ return <Decorator text={'Hello world'} />;
+ }
+}
+
+function Decorator({text}: {text: string}): JSX.Element {
+ return <span>{text}</span>;
+}
+
+export function $createTestDecoratorNode(): TestDecoratorNode {
+ return new TestDecoratorNode();
+}
+
+const DEFAULT_NODES: NonNullable<InitialConfigType['nodes']> = [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ CodeNode,
+ TableNode,
+ TableCellNode,
+ TableRowNode,
+ HashtagNode,
+ CodeHighlightNode,
+ AutoLinkNode,
+ LinkNode,
+ OverflowNode,
+ TestElementNode,
+ TestSegmentedNode,
+ TestExcludeFromCopyElementNode,
+ TestDecoratorNode,
+ TestInlineElementNode,
+ TestShadowRootNode,
+ TestTextNode,
+];
+
+export function createTestEditor(
+ config: {
+ namespace?: string;
+ editorState?: EditorState;
+ theme?: EditorThemeClasses;
+ parentEditor?: LexicalEditor;
+ nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
+ onError?: (error: Error) => void;
+ disableEvents?: boolean;
+ readOnly?: boolean;
+ html?: HTMLConfig;
+ } = {},
+): LexicalEditor {
+ const customNodes = config.nodes || [];
+ const editor = createEditor({
+ namespace: config.namespace,
+ onError: (e) => {
+ throw e;
+ },
+ ...config,
+ nodes: DEFAULT_NODES.concat(customNodes),
+ });
+ return editor;
+}
+
+export function createTestHeadlessEditor(
+ editorState?: EditorState,
+): LexicalEditor {
+ return createHeadlessEditor({
+ editorState,
+ onError: (error) => {
+ throw error;
+ },
+ });
+}
+
+export function $assertRangeSelection(selection: unknown): RangeSelection {
+ if (!$isRangeSelection(selection)) {
+ throw new Error(`Expected RangeSelection, got ${selection}`);
+ }
+ return selection;
+}
+
+export function invariant(cond?: boolean, message?: string): asserts cond {
+ if (cond) {
+ return;
+ }
+ throw new Error(`Invariant: ${message}`);
+}
+
+export class ClipboardDataMock {
+ getData: jest.Mock<string, [string]>;
+ setData: jest.Mock<void, [string, string]>;
+
+ constructor() {
+ this.getData = jest.fn();
+ this.setData = jest.fn();
+ }
+}
+
+export class DataTransferMock implements DataTransfer {
+ _data: Map<string, string> = new Map();
+ get dropEffect(): DataTransfer['dropEffect'] {
+ throw new Error('Getter not implemented.');
+ }
+ get effectAllowed(): DataTransfer['effectAllowed'] {
+ throw new Error('Getter not implemented.');
+ }
+ get files(): FileList {
+ throw new Error('Getter not implemented.');
+ }
+ get items(): DataTransferItemList {
+ throw new Error('Getter not implemented.');
+ }
+ get types(): ReadonlyArray<string> {
+ return Array.from(this._data.keys());
+ }
+ clearData(dataType?: string): void {
+ //
+ }
+ getData(dataType: string): string {
+ return this._data.get(dataType) || '';
+ }
+ setData(dataType: string, data: string): void {
+ this._data.set(dataType, data);
+ }
+ setDragImage(image: Element, x: number, y: number): void {
+ //
+ }
+}
+
+export class EventMock implements Event {
+ get bubbles(): boolean {
+ throw new Error('Getter not implemented.');
+ }
+ get cancelBubble(): boolean {
+ throw new Error('Gettter not implemented.');
+ }
+ get cancelable(): boolean {
+ throw new Error('Gettter not implemented.');
+ }
+ get composed(): boolean {
+ throw new Error('Gettter not implemented.');
+ }
+ get currentTarget(): EventTarget | null {
+ throw new Error('Gettter not implemented.');
+ }
+ get defaultPrevented(): boolean {
+ throw new Error('Gettter not implemented.');
+ }
+ get eventPhase(): number {
+ throw new Error('Gettter not implemented.');
+ }
+ get isTrusted(): boolean {
+ throw new Error('Gettter not implemented.');
+ }
+ get returnValue(): boolean {
+ throw new Error('Gettter not implemented.');
+ }
+ get srcElement(): EventTarget | null {
+ throw new Error('Gettter not implemented.');
+ }
+ get target(): EventTarget | null {
+ throw new Error('Gettter not implemented.');
+ }
+ get timeStamp(): number {
+ throw new Error('Gettter not implemented.');
+ }
+ get type(): string {
+ throw new Error('Gettter not implemented.');
+ }
+ composedPath(): EventTarget[] {
+ throw new Error('Method not implemented.');
+ }
+ initEvent(
+ type: string,
+ bubbles?: boolean | undefined,
+ cancelable?: boolean | undefined,
+ ): void {
+ throw new Error('Method not implemented.');
+ }
+ stopImmediatePropagation(): void {
+ return;
+ }
+ stopPropagation(): void {
+ return;
+ }
+ NONE = 0 as const;
+ CAPTURING_PHASE = 1 as const;
+ AT_TARGET = 2 as const;
+ BUBBLING_PHASE = 3 as const;
+ preventDefault() {
+ return;
+ }
+}
+
+export class KeyboardEventMock extends EventMock implements KeyboardEvent {
+ altKey = false;
+ get charCode(): number {
+ throw new Error('Getter not implemented.');
+ }
+ get code(): string {
+ throw new Error('Getter not implemented.');
+ }
+ ctrlKey = false;
+ get isComposing(): boolean {
+ throw new Error('Getter not implemented.');
+ }
+ get key(): string {
+ throw new Error('Getter not implemented.');
+ }
+ get keyCode(): number {
+ throw new Error('Getter not implemented.');
+ }
+ get location(): number {
+ throw new Error('Getter not implemented.');
+ }
+ metaKey = false;
+ get repeat(): boolean {
+ throw new Error('Getter not implemented.');
+ }
+ shiftKey = false;
+ constructor(type: void | string) {
+ super();
+ }
+ getModifierState(keyArg: string): boolean {
+ throw new Error('Method not implemented.');
+ }
+ initKeyboardEvent(
+ typeArg: string,
+ bubblesArg?: boolean | undefined,
+ cancelableArg?: boolean | undefined,
+ viewArg?: Window | null | undefined,
+ keyArg?: string | undefined,
+ locationArg?: number | undefined,
+ ctrlKey?: boolean | undefined,
+ altKey?: boolean | undefined,
+ shiftKey?: boolean | undefined,
+ metaKey?: boolean | undefined,
+ ): void {
+ throw new Error('Method not implemented.');
+ }
+ DOM_KEY_LOCATION_STANDARD = 0 as const;
+ DOM_KEY_LOCATION_LEFT = 1 as const;
+ DOM_KEY_LOCATION_RIGHT = 2 as const;
+ DOM_KEY_LOCATION_NUMPAD = 3 as const;
+ get detail(): number {
+ throw new Error('Getter not implemented.');
+ }
+ get view(): Window | null {
+ throw new Error('Getter not implemented.');
+ }
+ get which(): number {
+ throw new Error('Getter not implemented.');
+ }
+ initUIEvent(
+ typeArg: string,
+ bubblesArg?: boolean | undefined,
+ cancelableArg?: boolean | undefined,
+ viewArg?: Window | null | undefined,
+ detailArg?: number | undefined,
+ ): void {
+ throw new Error('Method not implemented.');
+ }
+}
+
+export function tabKeyboardEvent() {
+ return new KeyboardEventMock('keydown');
+}
+
+export function shiftTabKeyboardEvent() {
+ const keyboardEvent = new KeyboardEventMock('keydown');
+ keyboardEvent.shiftKey = true;
+ return keyboardEvent;
+}
+
+export function generatePermutations<T>(
+ values: T[],
+ maxLength = values.length,
+): T[][] {
+ if (maxLength > values.length) {
+ throw new Error('maxLength over values.length');
+ }
+ const result: T[][] = [];
+ const current: T[] = [];
+ const seen = new Set();
+ (function permutationsImpl() {
+ if (current.length > maxLength) {
+ return;
+ }
+ result.push(current.slice());
+ for (let i = 0; i < values.length; i++) {
+ const key = values[i];
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ current.push(key);
+ permutationsImpl();
+ seen.delete(key);
+ current.pop();
+ }
+ })();
+ return result;
+}
+
+// This tag function is just used to trigger prettier auto-formatting.
+// (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
+export function html(
+ partials: TemplateStringsArray,
+ ...params: string[]
+): string {
+ let output = '';
+ for (let i = 0; i < partials.length; i++) {
+ output += partials[i];
+ if (i < partials.length - 1) {
+ output += params[i];
+ }
+ }
+ return output;
+}
--- /dev/null
+/**
+ * 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 type {PasteCommandType} from './LexicalCommands';
+export type {
+ CommandListener,
+ CommandListenerPriority,
+ CommandPayloadType,
+ CreateEditorArgs,
+ EditableListener,
+ EditorConfig,
+ EditorSetOptions,
+ EditorThemeClasses,
+ EditorThemeClassName,
+ EditorUpdateOptions,
+ HTMLConfig,
+ Klass,
+ KlassConstructor,
+ LexicalCommand,
+ LexicalEditor,
+ LexicalNodeReplacement,
+ MutationListener,
+ NodeMutation,
+ SerializedEditor,
+ Spread,
+ Transform,
+} from './LexicalEditor';
+export type {
+ EditorState,
+ EditorStateReadOptions,
+ SerializedEditorState,
+} from './LexicalEditorState';
+export type {
+ DOMChildConversion,
+ DOMConversion,
+ DOMConversionFn,
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ LexicalNode,
+ NodeKey,
+ NodeMap,
+ SerializedLexicalNode,
+} from './LexicalNode';
+export type {
+ BaseSelection,
+ ElementPointType as ElementPoint,
+ NodeSelection,
+ Point,
+ PointType,
+ RangeSelection,
+ TextPointType as TextPoint,
+} from './LexicalSelection';
+export type {
+ ElementFormatType,
+ SerializedElementNode,
+} from './nodes/LexicalElementNode';
+export type {SerializedRootNode} from './nodes/LexicalRootNode';
+export type {
+ SerializedTextNode,
+ TextFormatType,
+ TextModeType,
+} from './nodes/LexicalTextNode';
+
+// TODO Move this somewhere else and/or recheck if we still need this
+export {
+ BLUR_COMMAND,
+ CAN_REDO_COMMAND,
+ CAN_UNDO_COMMAND,
+ CLEAR_EDITOR_COMMAND,
+ CLEAR_HISTORY_COMMAND,
+ CLICK_COMMAND,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ COPY_COMMAND,
+ createCommand,
+ CUT_COMMAND,
+ DELETE_CHARACTER_COMMAND,
+ DELETE_LINE_COMMAND,
+ DELETE_WORD_COMMAND,
+ DRAGEND_COMMAND,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND,
+ FOCUS_COMMAND,
+ FORMAT_ELEMENT_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INDENT_CONTENT_COMMAND,
+ INSERT_LINE_BREAK_COMMAND,
+ INSERT_PARAGRAPH_COMMAND,
+ INSERT_TAB_COMMAND,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_DOWN_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ KEY_MODIFIER_COMMAND,
+ KEY_SPACE_COMMAND,
+ KEY_TAB_COMMAND,
+ MOVE_TO_END,
+ MOVE_TO_START,
+ OUTDENT_CONTENT_COMMAND,
+ PASTE_COMMAND,
+ REDO_COMMAND,
+ REMOVE_TEXT_COMMAND,
+ SELECT_ALL_COMMAND,
+ SELECTION_CHANGE_COMMAND,
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
+ UNDO_COMMAND,
+} from './LexicalCommands';
+export {
+ IS_ALL_FORMATTING,
+ IS_BOLD,
+ IS_CODE,
+ IS_HIGHLIGHT,
+ IS_ITALIC,
+ IS_STRIKETHROUGH,
+ IS_SUBSCRIPT,
+ IS_SUPERSCRIPT,
+ IS_UNDERLINE,
+ TEXT_TYPE_TO_FORMAT,
+} from './LexicalConstants';
+export {
+ COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ COMMAND_PRIORITY_NORMAL,
+ createEditor,
+} from './LexicalEditor';
+export type {EventHandler} from './LexicalEvents';
+export {$normalizeSelection as $normalizeSelection__EXPERIMENTAL} from './LexicalNormalization';
+export {
+ $createNodeSelection,
+ $createPoint,
+ $createRangeSelection,
+ $createRangeSelectionFromDom,
+ $getCharacterOffsets,
+ $getPreviousSelection,
+ $getSelection,
+ $getTextContent,
+ $insertNodes,
+ $isBlockElementNode,
+ $isNodeSelection,
+ $isRangeSelection,
+} from './LexicalSelection';
+export {$parseSerializedNode, isCurrentlyReadOnlyMode} from './LexicalUpdates';
+export {
+ $addUpdateTag,
+ $applyNodeReplacement,
+ $cloneWithProperties,
+ $copyNode,
+ $getAdjacentNode,
+ $getEditor,
+ $getNearestNodeFromDOMNode,
+ $getNearestRootOrShadowRoot,
+ $getNodeByKey,
+ $getNodeByKeyOrThrow,
+ $getRoot,
+ $hasAncestor,
+ $hasUpdateTag,
+ $isInlineElementOrDecoratorNode,
+ $isLeafNode,
+ $isRootOrShadowRoot,
+ $isTokenOrSegmented,
+ $nodesOfType,
+ $selectAll,
+ $setCompositionKey,
+ $setSelection,
+ $splitNode,
+ getEditorPropertyFromDOMNode,
+ getNearestEditorFromDOMNode,
+ isBlockDomNode,
+ isHTMLAnchorElement,
+ isHTMLElement,
+ isInlineDomNode,
+ isLexicalEditor,
+ isSelectionCapturedInDecoratorInput,
+ isSelectionWithinEditor,
+ resetRandomKey,
+} from './LexicalUtils';
+export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
+export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode';
+export {$isElementNode, ElementNode} from './nodes/LexicalElementNode';
+export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode';
+export {
+ $createLineBreakNode,
+ $isLineBreakNode,
+ LineBreakNode,
+} from './nodes/LexicalLineBreakNode';
+export type {SerializedParagraphNode} from './nodes/LexicalParagraphNode';
+export {
+ $createParagraphNode,
+ $isParagraphNode,
+ ParagraphNode,
+} from './nodes/LexicalParagraphNode';
+export {$isRootNode, RootNode} from './nodes/LexicalRootNode';
+export type {SerializedTabNode} from './nodes/LexicalTabNode';
+export {$createTabNode, $isTabNode, TabNode} from './nodes/LexicalTabNode';
+export {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode';
--- /dev/null
+/**
+ * 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 {EditorConfig} from 'lexical';
+
+import {ElementNode} from './LexicalElementNode';
+
+// TODO: Cleanup ArtificialNode__DO_NOT_USE #5966
+export class ArtificialNode__DO_NOT_USE extends ElementNode {
+ static getType(): string {
+ return 'artificial';
+ }
+
+ createDOM(config: EditorConfig): HTMLElement {
+ // this isnt supposed to be used and is not used anywhere but defining it to appease the API
+ const dom = document.createElement('div');
+ return dom;
+ }
+}
--- /dev/null
+/**
+ * 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 {KlassConstructor, LexicalEditor} from '../LexicalEditor';
+import type {NodeKey} from '../LexicalNode';
+import type {ElementNode} from './LexicalElementNode';
+
+import {EditorConfig} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {LexicalNode} from '../LexicalNode';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export interface DecoratorNode<T> {
+ getTopLevelElement(): ElementNode | this | null;
+ getTopLevelElementOrThrow(): ElementNode | this;
+}
+
+/** @noInheritDoc */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export class DecoratorNode<T> extends LexicalNode {
+ ['constructor']!: KlassConstructor<typeof DecoratorNode<T>>;
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ /**
+ * The returned value is added to the LexicalEditor._decorators
+ */
+ decorate(editor: LexicalEditor, config: EditorConfig): T {
+ invariant(false, 'decorate: base method not extended');
+ }
+
+ isIsolated(): boolean {
+ return false;
+ }
+
+ isInline(): boolean {
+ return true;
+ }
+
+ isKeyboardSelectable(): boolean {
+ return true;
+ }
+}
+
+export function $isDecoratorNode<T>(
+ node: LexicalNode | null | undefined,
+): node is DecoratorNode<T> {
+ return node instanceof DecoratorNode;
+}
--- /dev/null
+/**
+ * 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 {NodeKey, SerializedLexicalNode} from '../LexicalNode';
+import type {
+ BaseSelection,
+ PointType,
+ RangeSelection,
+} from '../LexicalSelection';
+import type {KlassConstructor, Spread} from 'lexical';
+
+import invariant from 'lexical/shared/invariant';
+
+import {$isTextNode, TextNode} from '../index';
+import {
+ DOUBLE_LINE_BREAK,
+ ELEMENT_FORMAT_TO_TYPE,
+ ELEMENT_TYPE_TO_FORMAT,
+} from '../LexicalConstants';
+import {LexicalNode} from '../LexicalNode';
+import {
+ $getSelection,
+ $internalMakeRangeSelection,
+ $isRangeSelection,
+ moveSelectionPointToSibling,
+} from '../LexicalSelection';
+import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates';
+import {
+ $getNodeByKey,
+ $isRootOrShadowRoot,
+ removeFromParent,
+} from '../LexicalUtils';
+
+export type SerializedElementNode<
+ T extends SerializedLexicalNode = SerializedLexicalNode,
+> = Spread<
+ {
+ children: Array<T>;
+ direction: 'ltr' | 'rtl' | null;
+ format: ElementFormatType;
+ indent: number;
+ },
+ SerializedLexicalNode
+>;
+
+export type ElementFormatType =
+ | 'left'
+ | 'start'
+ | 'center'
+ | 'right'
+ | 'end'
+ | 'justify'
+ | '';
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export interface ElementNode {
+ getTopLevelElement(): ElementNode | null;
+ getTopLevelElementOrThrow(): ElementNode;
+}
+
+/** @noInheritDoc */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export class ElementNode extends LexicalNode {
+ ['constructor']!: KlassConstructor<typeof ElementNode>;
+ /** @internal */
+ __first: null | NodeKey;
+ /** @internal */
+ __last: null | NodeKey;
+ /** @internal */
+ __size: number;
+ /** @internal */
+ __format: number;
+ /** @internal */
+ __style: string;
+ /** @internal */
+ __indent: number;
+ /** @internal */
+ __dir: 'ltr' | 'rtl' | null;
+
+ constructor(key?: NodeKey) {
+ super(key);
+ this.__first = null;
+ this.__last = null;
+ this.__size = 0;
+ this.__format = 0;
+ this.__style = '';
+ this.__indent = 0;
+ this.__dir = null;
+ }
+
+ afterCloneFrom(prevNode: this) {
+ super.afterCloneFrom(prevNode);
+ this.__first = prevNode.__first;
+ this.__last = prevNode.__last;
+ this.__size = prevNode.__size;
+ this.__indent = prevNode.__indent;
+ this.__format = prevNode.__format;
+ this.__style = prevNode.__style;
+ this.__dir = prevNode.__dir;
+ }
+
+ getFormat(): number {
+ const self = this.getLatest();
+ return self.__format;
+ }
+ getFormatType(): ElementFormatType {
+ const format = this.getFormat();
+ return ELEMENT_FORMAT_TO_TYPE[format] || '';
+ }
+ getStyle(): string {
+ const self = this.getLatest();
+ return self.__style;
+ }
+ getIndent(): number {
+ const self = this.getLatest();
+ return self.__indent;
+ }
+ getChildren<T extends LexicalNode>(): Array<T> {
+ const children: Array<T> = [];
+ let child: T | null = this.getFirstChild();
+ while (child !== null) {
+ children.push(child);
+ child = child.getNextSibling();
+ }
+ return children;
+ }
+ getChildrenKeys(): Array<NodeKey> {
+ const children: Array<NodeKey> = [];
+ let child: LexicalNode | null = this.getFirstChild();
+ while (child !== null) {
+ children.push(child.__key);
+ child = child.getNextSibling();
+ }
+ return children;
+ }
+ getChildrenSize(): number {
+ const self = this.getLatest();
+ return self.__size;
+ }
+ isEmpty(): boolean {
+ return this.getChildrenSize() === 0;
+ }
+ isDirty(): boolean {
+ const editor = getActiveEditor();
+ const dirtyElements = editor._dirtyElements;
+ return dirtyElements !== null && dirtyElements.has(this.__key);
+ }
+ isLastChild(): boolean {
+ const self = this.getLatest();
+ const parentLastChild = this.getParentOrThrow().getLastChild();
+ return parentLastChild !== null && parentLastChild.is(self);
+ }
+ getAllTextNodes(): Array<TextNode> {
+ const textNodes = [];
+ let child: LexicalNode | null = this.getFirstChild();
+ while (child !== null) {
+ if ($isTextNode(child)) {
+ textNodes.push(child);
+ }
+ if ($isElementNode(child)) {
+ const subChildrenNodes = child.getAllTextNodes();
+ textNodes.push(...subChildrenNodes);
+ }
+ child = child.getNextSibling();
+ }
+ return textNodes;
+ }
+ getFirstDescendant<T extends LexicalNode>(): null | T {
+ let node = this.getFirstChild<T>();
+ while ($isElementNode(node)) {
+ const child = node.getFirstChild<T>();
+ if (child === null) {
+ break;
+ }
+ node = child;
+ }
+ return node;
+ }
+ getLastDescendant<T extends LexicalNode>(): null | T {
+ let node = this.getLastChild<T>();
+ while ($isElementNode(node)) {
+ const child = node.getLastChild<T>();
+ if (child === null) {
+ break;
+ }
+ node = child;
+ }
+ return node;
+ }
+ getDescendantByIndex<T extends LexicalNode>(index: number): null | T {
+ const children = this.getChildren<T>();
+ const childrenLength = children.length;
+ // For non-empty element nodes, we resolve its descendant
+ // (either a leaf node or the bottom-most element)
+ if (index >= childrenLength) {
+ const resolvedNode = children[childrenLength - 1];
+ return (
+ ($isElementNode(resolvedNode) && resolvedNode.getLastDescendant()) ||
+ resolvedNode ||
+ null
+ );
+ }
+ const resolvedNode = children[index];
+ return (
+ ($isElementNode(resolvedNode) && resolvedNode.getFirstDescendant()) ||
+ resolvedNode ||
+ null
+ );
+ }
+ getFirstChild<T extends LexicalNode>(): null | T {
+ const self = this.getLatest();
+ const firstKey = self.__first;
+ return firstKey === null ? null : $getNodeByKey<T>(firstKey);
+ }
+ getFirstChildOrThrow<T extends LexicalNode>(): T {
+ const firstChild = this.getFirstChild<T>();
+ if (firstChild === null) {
+ invariant(false, 'Expected node %s to have a first child.', this.__key);
+ }
+ return firstChild;
+ }
+ getLastChild<T extends LexicalNode>(): null | T {
+ const self = this.getLatest();
+ const lastKey = self.__last;
+ return lastKey === null ? null : $getNodeByKey<T>(lastKey);
+ }
+ getLastChildOrThrow<T extends LexicalNode>(): T {
+ const lastChild = this.getLastChild<T>();
+ if (lastChild === null) {
+ invariant(false, 'Expected node %s to have a last child.', this.__key);
+ }
+ return lastChild;
+ }
+ getChildAtIndex<T extends LexicalNode>(index: number): null | T {
+ const size = this.getChildrenSize();
+ let node: null | T;
+ let i;
+ if (index < size / 2) {
+ node = this.getFirstChild<T>();
+ i = 0;
+ while (node !== null && i <= index) {
+ if (i === index) {
+ return node;
+ }
+ node = node.getNextSibling();
+ i++;
+ }
+ return null;
+ }
+ node = this.getLastChild<T>();
+ i = size - 1;
+ while (node !== null && i >= index) {
+ if (i === index) {
+ return node;
+ }
+ node = node.getPreviousSibling();
+ i--;
+ }
+ return null;
+ }
+ getTextContent(): string {
+ let textContent = '';
+ const children = this.getChildren();
+ const childrenLength = children.length;
+ for (let i = 0; i < childrenLength; i++) {
+ const child = children[i];
+ textContent += child.getTextContent();
+ if (
+ $isElementNode(child) &&
+ i !== childrenLength - 1 &&
+ !child.isInline()
+ ) {
+ textContent += DOUBLE_LINE_BREAK;
+ }
+ }
+ return textContent;
+ }
+ getTextContentSize(): number {
+ let textContentSize = 0;
+ const children = this.getChildren();
+ const childrenLength = children.length;
+ for (let i = 0; i < childrenLength; i++) {
+ const child = children[i];
+ textContentSize += child.getTextContentSize();
+ if (
+ $isElementNode(child) &&
+ i !== childrenLength - 1 &&
+ !child.isInline()
+ ) {
+ textContentSize += DOUBLE_LINE_BREAK.length;
+ }
+ }
+ return textContentSize;
+ }
+ getDirection(): 'ltr' | 'rtl' | null {
+ const self = this.getLatest();
+ return self.__dir;
+ }
+ hasFormat(type: ElementFormatType): boolean {
+ if (type !== '') {
+ const formatFlag = ELEMENT_TYPE_TO_FORMAT[type];
+ return (this.getFormat() & formatFlag) !== 0;
+ }
+ return false;
+ }
+
+ // Mutators
+
+ select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
+ errorOnReadOnly();
+ const selection = $getSelection();
+ let anchorOffset = _anchorOffset;
+ let focusOffset = _focusOffset;
+ const childrenCount = this.getChildrenSize();
+ if (!this.canBeEmpty()) {
+ if (_anchorOffset === 0 && _focusOffset === 0) {
+ const firstChild = this.getFirstChild();
+ if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
+ return firstChild.select(0, 0);
+ }
+ } else if (
+ (_anchorOffset === undefined || _anchorOffset === childrenCount) &&
+ (_focusOffset === undefined || _focusOffset === childrenCount)
+ ) {
+ const lastChild = this.getLastChild();
+ if ($isTextNode(lastChild) || $isElementNode(lastChild)) {
+ return lastChild.select();
+ }
+ }
+ }
+ if (anchorOffset === undefined) {
+ anchorOffset = childrenCount;
+ }
+ if (focusOffset === undefined) {
+ focusOffset = childrenCount;
+ }
+ const key = this.__key;
+ if (!$isRangeSelection(selection)) {
+ return $internalMakeRangeSelection(
+ key,
+ anchorOffset,
+ key,
+ focusOffset,
+ 'element',
+ 'element',
+ );
+ } else {
+ selection.anchor.set(key, anchorOffset, 'element');
+ selection.focus.set(key, focusOffset, 'element');
+ selection.dirty = true;
+ }
+ return selection;
+ }
+ selectStart(): RangeSelection {
+ const firstNode = this.getFirstDescendant();
+ return firstNode ? firstNode.selectStart() : this.select();
+ }
+ selectEnd(): RangeSelection {
+ const lastNode = this.getLastDescendant();
+ return lastNode ? lastNode.selectEnd() : this.select();
+ }
+ clear(): this {
+ const writableSelf = this.getWritable();
+ const children = this.getChildren();
+ children.forEach((child) => child.remove());
+ return writableSelf;
+ }
+ append(...nodesToAppend: LexicalNode[]): this {
+ return this.splice(this.getChildrenSize(), 0, nodesToAppend);
+ }
+ setDirection(direction: 'ltr' | 'rtl' | null): this {
+ const self = this.getWritable();
+ self.__dir = direction;
+ return self;
+ }
+ setFormat(type: ElementFormatType): this {
+ const self = this.getWritable();
+ self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] : 0;
+ return this;
+ }
+ setStyle(style: string): this {
+ const self = this.getWritable();
+ self.__style = style || '';
+ return this;
+ }
+ setIndent(indentLevel: number): this {
+ const self = this.getWritable();
+ self.__indent = indentLevel;
+ return this;
+ }
+ splice(
+ start: number,
+ deleteCount: number,
+ nodesToInsert: Array<LexicalNode>,
+ ): this {
+ const nodesToInsertLength = nodesToInsert.length;
+ const oldSize = this.getChildrenSize();
+ const writableSelf = this.getWritable();
+ const writableSelfKey = writableSelf.__key;
+ const nodesToInsertKeys = [];
+ const nodesToRemoveKeys = [];
+ const nodeAfterRange = this.getChildAtIndex(start + deleteCount);
+ let nodeBeforeRange = null;
+ let newSize = oldSize - deleteCount + nodesToInsertLength;
+
+ if (start !== 0) {
+ if (start === oldSize) {
+ nodeBeforeRange = this.getLastChild();
+ } else {
+ const node = this.getChildAtIndex(start);
+ if (node !== null) {
+ nodeBeforeRange = node.getPreviousSibling();
+ }
+ }
+ }
+
+ if (deleteCount > 0) {
+ let nodeToDelete =
+ nodeBeforeRange === null
+ ? this.getFirstChild()
+ : nodeBeforeRange.getNextSibling();
+ for (let i = 0; i < deleteCount; i++) {
+ if (nodeToDelete === null) {
+ invariant(false, 'splice: sibling not found');
+ }
+ const nextSibling = nodeToDelete.getNextSibling();
+ const nodeKeyToDelete = nodeToDelete.__key;
+ const writableNodeToDelete = nodeToDelete.getWritable();
+ removeFromParent(writableNodeToDelete);
+ nodesToRemoveKeys.push(nodeKeyToDelete);
+ nodeToDelete = nextSibling;
+ }
+ }
+
+ let prevNode = nodeBeforeRange;
+ for (let i = 0; i < nodesToInsertLength; i++) {
+ const nodeToInsert = nodesToInsert[i];
+ if (prevNode !== null && nodeToInsert.is(prevNode)) {
+ nodeBeforeRange = prevNode = prevNode.getPreviousSibling();
+ }
+ const writableNodeToInsert = nodeToInsert.getWritable();
+ if (writableNodeToInsert.__parent === writableSelfKey) {
+ newSize--;
+ }
+ removeFromParent(writableNodeToInsert);
+ const nodeKeyToInsert = nodeToInsert.__key;
+ if (prevNode === null) {
+ writableSelf.__first = nodeKeyToInsert;
+ writableNodeToInsert.__prev = null;
+ } else {
+ const writablePrevNode = prevNode.getWritable();
+ writablePrevNode.__next = nodeKeyToInsert;
+ writableNodeToInsert.__prev = writablePrevNode.__key;
+ }
+ if (nodeToInsert.__key === writableSelfKey) {
+ invariant(false, 'append: attempting to append self');
+ }
+ // Set child parent to self
+ writableNodeToInsert.__parent = writableSelfKey;
+ nodesToInsertKeys.push(nodeKeyToInsert);
+ prevNode = nodeToInsert;
+ }
+
+ if (start + deleteCount === oldSize) {
+ if (prevNode !== null) {
+ const writablePrevNode = prevNode.getWritable();
+ writablePrevNode.__next = null;
+ writableSelf.__last = prevNode.__key;
+ }
+ } else if (nodeAfterRange !== null) {
+ const writableNodeAfterRange = nodeAfterRange.getWritable();
+ if (prevNode !== null) {
+ const writablePrevNode = prevNode.getWritable();
+ writableNodeAfterRange.__prev = prevNode.__key;
+ writablePrevNode.__next = nodeAfterRange.__key;
+ } else {
+ writableNodeAfterRange.__prev = null;
+ }
+ }
+
+ writableSelf.__size = newSize;
+
+ // In case of deletion we need to adjust selection, unlink removed nodes
+ // and clean up node itself if it becomes empty. None of these needed
+ // for insertion-only cases
+ if (nodesToRemoveKeys.length) {
+ // Adjusting selection, in case node that was anchor/focus will be deleted
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const nodesToRemoveKeySet = new Set(nodesToRemoveKeys);
+ const nodesToInsertKeySet = new Set(nodesToInsertKeys);
+
+ const {anchor, focus} = selection;
+ if (isPointRemoved(anchor, nodesToRemoveKeySet, nodesToInsertKeySet)) {
+ moveSelectionPointToSibling(
+ anchor,
+ anchor.getNode(),
+ this,
+ nodeBeforeRange,
+ nodeAfterRange,
+ );
+ }
+ if (isPointRemoved(focus, nodesToRemoveKeySet, nodesToInsertKeySet)) {
+ moveSelectionPointToSibling(
+ focus,
+ focus.getNode(),
+ this,
+ nodeBeforeRange,
+ nodeAfterRange,
+ );
+ }
+ // Cleanup if node can't be empty
+ if (newSize === 0 && !this.canBeEmpty() && !$isRootOrShadowRoot(this)) {
+ this.remove();
+ }
+ }
+ }
+
+ return writableSelf;
+ }
+ // JSON serialization
+ exportJSON(): SerializedElementNode {
+ return {
+ children: [],
+ direction: this.getDirection(),
+ format: this.getFormatType(),
+ indent: this.getIndent(),
+ type: 'element',
+ version: 1,
+ };
+ }
+ // These are intended to be extends for specific element heuristics.
+ insertNewAfter(
+ selection: RangeSelection,
+ restoreSelection?: boolean,
+ ): null | LexicalNode {
+ return null;
+ }
+ canIndent(): boolean {
+ return true;
+ }
+ /*
+ * This method controls the behavior of a the node during backwards
+ * deletion (i.e., backspace) when selection is at the beginning of
+ * the node (offset 0)
+ */
+ collapseAtStart(selection: RangeSelection): boolean {
+ return false;
+ }
+ excludeFromCopy(destination?: 'clone' | 'html'): boolean {
+ return false;
+ }
+ /** @deprecated @internal */
+ canReplaceWith(replacement: LexicalNode): boolean {
+ return true;
+ }
+ /** @deprecated @internal */
+ canInsertAfter(node: LexicalNode): boolean {
+ return true;
+ }
+ canBeEmpty(): boolean {
+ return true;
+ }
+ canInsertTextBefore(): boolean {
+ return true;
+ }
+ canInsertTextAfter(): boolean {
+ return true;
+ }
+ isInline(): boolean {
+ return false;
+ }
+ // A shadow root is a Node that behaves like RootNode. The shadow root (and RootNode) mark the
+ // end of the hiercharchy, most implementations should treat it as there's nothing (upwards)
+ // beyond this point. For example, node.getTopLevelElement(), when performed inside a TableCellNode
+ // will return the immediate first child underneath TableCellNode instead of RootNode.
+ isShadowRoot(): boolean {
+ return false;
+ }
+ /** @deprecated @internal */
+ canMergeWith(node: ElementNode): boolean {
+ return false;
+ }
+ extractWithChild(
+ child: LexicalNode,
+ selection: BaseSelection | null,
+ destination: 'clone' | 'html',
+ ): boolean {
+ return false;
+ }
+
+ /**
+ * Determines whether this node, when empty, can merge with a first block
+ * of nodes being inserted.
+ *
+ * This method is specifically called in {@link RangeSelection.insertNodes}
+ * to determine merging behavior during nodes insertion.
+ *
+ * @example
+ * // In a ListItemNode or QuoteNode implementation:
+ * canMergeWhenEmpty(): true {
+ * return true;
+ * }
+ */
+ canMergeWhenEmpty(): boolean {
+ return false;
+ }
+}
+
+export function $isElementNode(
+ node: LexicalNode | null | undefined,
+): node is ElementNode {
+ return node instanceof ElementNode;
+}
+
+function isPointRemoved(
+ point: PointType,
+ nodesToRemoveKeySet: Set<NodeKey>,
+ nodesToInsertKeySet: Set<NodeKey>,
+): boolean {
+ let node: ElementNode | TextNode | null = point.getNode();
+ while (node) {
+ const nodeKey = node.__key;
+ if (nodesToRemoveKeySet.has(nodeKey) && !nodesToInsertKeySet.has(nodeKey)) {
+ return true;
+ }
+ node = node.getParent();
+ }
+ return false;
+}
--- /dev/null
+/**
+ * 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 {KlassConstructor} from '../LexicalEditor';
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ NodeKey,
+ SerializedLexicalNode,
+} from '../LexicalNode';
+
+import {DOM_TEXT_TYPE} from '../LexicalConstants';
+import {LexicalNode} from '../LexicalNode';
+import {$applyNodeReplacement, isBlockDomNode} from '../LexicalUtils';
+
+export type SerializedLineBreakNode = SerializedLexicalNode;
+
+/** @noInheritDoc */
+export class LineBreakNode extends LexicalNode {
+ ['constructor']!: KlassConstructor<typeof LineBreakNode>;
+ static getType(): string {
+ return 'linebreak';
+ }
+
+ static clone(node: LineBreakNode): LineBreakNode {
+ return new LineBreakNode(node.__key);
+ }
+
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ getTextContent(): '\n' {
+ return '\n';
+ }
+
+ createDOM(): HTMLElement {
+ return document.createElement('br');
+ }
+
+ updateDOM(): false {
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ br: (node: Node) => {
+ if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) {
+ return null;
+ }
+ return {
+ conversion: $convertLineBreakElement,
+ priority: 0,
+ };
+ },
+ };
+ }
+
+ static importJSON(
+ serializedLineBreakNode: SerializedLineBreakNode,
+ ): LineBreakNode {
+ return $createLineBreakNode();
+ }
+
+ exportJSON(): SerializedLexicalNode {
+ return {
+ type: 'linebreak',
+ version: 1,
+ };
+ }
+}
+
+function $convertLineBreakElement(node: Node): DOMConversionOutput {
+ return {node: $createLineBreakNode()};
+}
+
+export function $createLineBreakNode(): LineBreakNode {
+ return $applyNodeReplacement(new LineBreakNode());
+}
+
+export function $isLineBreakNode(
+ node: LexicalNode | null | undefined,
+): node is LineBreakNode {
+ return node instanceof LineBreakNode;
+}
+
+function isOnlyChildInBlockNode(node: Node): boolean {
+ const parentElement = node.parentElement;
+ if (parentElement !== null && isBlockDomNode(parentElement)) {
+ const firstChild = parentElement.firstChild!;
+ if (
+ firstChild === node ||
+ (firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild))
+ ) {
+ const lastChild = parentElement.lastChild!;
+ if (
+ lastChild === node ||
+ (lastChild.previousSibling === node &&
+ isWhitespaceDomTextNode(lastChild))
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+function isLastChildInBlockNode(node: Node): boolean {
+ const parentElement = node.parentElement;
+ if (parentElement !== null && isBlockDomNode(parentElement)) {
+ // check if node is first child, because only childs dont count
+ const firstChild = parentElement.firstChild!;
+ if (
+ firstChild === node ||
+ (firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild))
+ ) {
+ return false;
+ }
+
+ // check if its last child
+ const lastChild = parentElement.lastChild!;
+ if (
+ lastChild === node ||
+ (lastChild.previousSibling === node && isWhitespaceDomTextNode(lastChild))
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function isWhitespaceDomTextNode(node: Node): boolean {
+ return (
+ node.nodeType === DOM_TEXT_TYPE &&
+ /^( |\t|\r?\n)+$/.test(node.textContent || '')
+ );
+}
--- /dev/null
+/**
+ * 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 {
+ EditorConfig,
+ KlassConstructor,
+ LexicalEditor,
+ Spread,
+} from '../LexicalEditor';
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ LexicalNode,
+ NodeKey,
+} from '../LexicalNode';
+import type {
+ ElementFormatType,
+ SerializedElementNode,
+} from './LexicalElementNode';
+import type {RangeSelection} from 'lexical';
+
+import {TEXT_TYPE_TO_FORMAT} from '../LexicalConstants';
+import {
+ $applyNodeReplacement,
+ getCachedClassNameArray,
+ isHTMLElement,
+} from '../LexicalUtils';
+import {ElementNode} from './LexicalElementNode';
+import {$isTextNode, TextFormatType} from './LexicalTextNode';
+
+export type SerializedParagraphNode = Spread<
+ {
+ textFormat: number;
+ textStyle: string;
+ },
+ SerializedElementNode
+>;
+
+/** @noInheritDoc */
+export class ParagraphNode extends ElementNode {
+ ['constructor']!: KlassConstructor<typeof ParagraphNode>;
+ /** @internal */
+ __textFormat: number;
+ __textStyle: string;
+
+ constructor(key?: NodeKey) {
+ super(key);
+ this.__textFormat = 0;
+ this.__textStyle = '';
+ }
+
+ static getType(): string {
+ return 'paragraph';
+ }
+
+ getTextFormat(): number {
+ const self = this.getLatest();
+ return self.__textFormat;
+ }
+
+ setTextFormat(type: number): this {
+ const self = this.getWritable();
+ self.__textFormat = type;
+ return self;
+ }
+
+ hasTextFormat(type: TextFormatType): boolean {
+ const formatFlag = TEXT_TYPE_TO_FORMAT[type];
+ return (this.getTextFormat() & formatFlag) !== 0;
+ }
+
+ getTextStyle(): string {
+ const self = this.getLatest();
+ return self.__textStyle;
+ }
+
+ setTextStyle(style: string): this {
+ const self = this.getWritable();
+ self.__textStyle = style;
+ return self;
+ }
+
+ static clone(node: ParagraphNode): ParagraphNode {
+ return new ParagraphNode(node.__key);
+ }
+
+ afterCloneFrom(prevNode: this) {
+ super.afterCloneFrom(prevNode);
+ this.__textFormat = prevNode.__textFormat;
+ this.__textStyle = prevNode.__textStyle;
+ }
+
+ // View
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const dom = document.createElement('p');
+ const classNames = getCachedClassNameArray(config.theme, 'paragraph');
+ if (classNames !== undefined) {
+ const domClassList = dom.classList;
+ domClassList.add(...classNames);
+ }
+ return dom;
+ }
+ updateDOM(
+ prevNode: ParagraphNode,
+ dom: HTMLElement,
+ config: EditorConfig,
+ ): boolean {
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ p: (node: Node) => ({
+ conversion: $convertParagraphElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const {element} = super.exportDOM(editor);
+
+ if (element && isHTMLElement(element)) {
+ if (this.isEmpty()) {
+ element.append(document.createElement('br'));
+ }
+
+ const formatType = this.getFormatType();
+ element.style.textAlign = formatType;
+
+ const direction = this.getDirection();
+ if (direction) {
+ element.dir = direction;
+ }
+ const indent = this.getIndent();
+ if (indent > 0) {
+ // padding-inline-start is not widely supported in email HTML, but
+ // Lexical Reconciler uses padding-inline-start. Using text-indent instead.
+ element.style.textIndent = `${indent * 20}px`;
+ }
+ }
+
+ return {
+ element,
+ };
+ }
+
+ static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode {
+ const node = $createParagraphNode();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ node.setTextFormat(serializedNode.textFormat);
+ return node;
+ }
+
+ exportJSON(): SerializedParagraphNode {
+ return {
+ ...super.exportJSON(),
+ textFormat: this.getTextFormat(),
+ textStyle: this.getTextStyle(),
+ type: 'paragraph',
+ version: 1,
+ };
+ }
+
+ // Mutation
+
+ insertNewAfter(
+ rangeSelection: RangeSelection,
+ restoreSelection: boolean,
+ ): ParagraphNode {
+ const newElement = $createParagraphNode();
+ newElement.setTextFormat(rangeSelection.format);
+ newElement.setTextStyle(rangeSelection.style);
+ const direction = this.getDirection();
+ newElement.setDirection(direction);
+ newElement.setFormat(this.getFormatType());
+ newElement.setStyle(this.getTextStyle());
+ this.insertAfter(newElement, restoreSelection);
+ return newElement;
+ }
+
+ collapseAtStart(): boolean {
+ const children = this.getChildren();
+ // If we have an empty (trimmed) first paragraph and try and remove it,
+ // delete the paragraph as long as we have another sibling to go to
+ if (
+ children.length === 0 ||
+ ($isTextNode(children[0]) && children[0].getTextContent().trim() === '')
+ ) {
+ const nextSibling = this.getNextSibling();
+ if (nextSibling !== null) {
+ this.selectNext();
+ this.remove();
+ return true;
+ }
+ const prevSibling = this.getPreviousSibling();
+ if (prevSibling !== null) {
+ this.selectPrevious();
+ this.remove();
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+function $convertParagraphElement(element: HTMLElement): DOMConversionOutput {
+ const node = $createParagraphNode();
+ if (element.style) {
+ node.setFormat(element.style.textAlign as ElementFormatType);
+ const indent = parseInt(element.style.textIndent, 10) / 20;
+ if (indent > 0) {
+ node.setIndent(indent);
+ }
+ }
+ return {node};
+}
+
+export function $createParagraphNode(): ParagraphNode {
+ return $applyNodeReplacement(new ParagraphNode());
+}
+
+export function $isParagraphNode(
+ node: LexicalNode | null | undefined,
+): node is ParagraphNode {
+ return node instanceof ParagraphNode;
+}
--- /dev/null
+/**
+ * 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 {LexicalNode, SerializedLexicalNode} from '../LexicalNode';
+import type {SerializedElementNode} from './LexicalElementNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {NO_DIRTY_NODES} from '../LexicalConstants';
+import {getActiveEditor, isCurrentlyReadOnlyMode} from '../LexicalUpdates';
+import {$getRoot} from '../LexicalUtils';
+import {$isDecoratorNode} from './LexicalDecoratorNode';
+import {$isElementNode, ElementNode} from './LexicalElementNode';
+
+export type SerializedRootNode<
+ T extends SerializedLexicalNode = SerializedLexicalNode,
+> = SerializedElementNode<T>;
+
+/** @noInheritDoc */
+export class RootNode extends ElementNode {
+ /** @internal */
+ __cachedText: null | string;
+
+ static getType(): string {
+ return 'root';
+ }
+
+ static clone(): RootNode {
+ return new RootNode();
+ }
+
+ constructor() {
+ super('root');
+ this.__cachedText = null;
+ }
+
+ getTopLevelElementOrThrow(): never {
+ invariant(
+ false,
+ 'getTopLevelElementOrThrow: root nodes are not top level elements',
+ );
+ }
+
+ getTextContent(): string {
+ const cachedText = this.__cachedText;
+ if (
+ isCurrentlyReadOnlyMode() ||
+ getActiveEditor()._dirtyType === NO_DIRTY_NODES
+ ) {
+ if (cachedText !== null) {
+ return cachedText;
+ }
+ }
+ return super.getTextContent();
+ }
+
+ remove(): never {
+ invariant(false, 'remove: cannot be called on root nodes');
+ }
+
+ replace<N = LexicalNode>(node: N): never {
+ invariant(false, 'replace: cannot be called on root nodes');
+ }
+
+ insertBefore(nodeToInsert: LexicalNode): LexicalNode {
+ invariant(false, 'insertBefore: cannot be called on root nodes');
+ }
+
+ insertAfter(nodeToInsert: LexicalNode): LexicalNode {
+ invariant(false, 'insertAfter: cannot be called on root nodes');
+ }
+
+ // View
+
+ updateDOM(prevNode: RootNode, dom: HTMLElement): false {
+ return false;
+ }
+
+ // Mutate
+
+ append(...nodesToAppend: LexicalNode[]): this {
+ for (let i = 0; i < nodesToAppend.length; i++) {
+ const node = nodesToAppend[i];
+ if (!$isElementNode(node) && !$isDecoratorNode(node)) {
+ invariant(
+ false,
+ 'rootNode.append: Only element or decorator nodes can be appended to the root node',
+ );
+ }
+ }
+ return super.append(...nodesToAppend);
+ }
+
+ static importJSON(serializedNode: SerializedRootNode): RootNode {
+ // We don't create a root, and instead use the existing root.
+ const node = $getRoot();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedRootNode {
+ return {
+ children: [],
+ direction: this.getDirection(),
+ format: this.getFormatType(),
+ indent: this.getIndent(),
+ type: 'root',
+ version: 1,
+ };
+ }
+
+ collapseAtStart(): true {
+ return true;
+ }
+}
+
+export function $createRootNode(): RootNode {
+ return new RootNode();
+}
+
+export function $isRootNode(
+ node: RootNode | LexicalNode | null | undefined,
+): node is RootNode {
+ return node instanceof RootNode;
+}
--- /dev/null
+/**
+ * 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 {DOMConversionMap, NodeKey} from '../LexicalNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {IS_UNMERGEABLE} from '../LexicalConstants';
+import {LexicalNode} from '../LexicalNode';
+import {$applyNodeReplacement} from '../LexicalUtils';
+import {
+ SerializedTextNode,
+ TextDetailType,
+ TextModeType,
+ TextNode,
+} from './LexicalTextNode';
+
+export type SerializedTabNode = SerializedTextNode;
+
+/** @noInheritDoc */
+export class TabNode extends TextNode {
+ static getType(): string {
+ return 'tab';
+ }
+
+ static clone(node: TabNode): TabNode {
+ return new TabNode(node.__key);
+ }
+
+ afterCloneFrom(prevNode: this): void {
+ super.afterCloneFrom(prevNode);
+ // TabNode __text can be either '\t' or ''. insertText will remove the empty Node
+ this.__text = prevNode.__text;
+ }
+
+ constructor(key?: NodeKey) {
+ super('\t', key);
+ this.__detail = IS_UNMERGEABLE;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return null;
+ }
+
+ static importJSON(serializedTabNode: SerializedTabNode): TabNode {
+ const node = $createTabNode();
+ node.setFormat(serializedTabNode.format);
+ node.setStyle(serializedTabNode.style);
+ return node;
+ }
+
+ exportJSON(): SerializedTabNode {
+ return {
+ ...super.exportJSON(),
+ type: 'tab',
+ version: 1,
+ };
+ }
+
+ setTextContent(_text: string): this {
+ invariant(false, 'TabNode does not support setTextContent');
+ }
+
+ setDetail(_detail: TextDetailType | number): this {
+ invariant(false, 'TabNode does not support setDetail');
+ }
+
+ setMode(_type: TextModeType): this {
+ invariant(false, 'TabNode does not support setMode');
+ }
+
+ canInsertTextBefore(): boolean {
+ return false;
+ }
+
+ canInsertTextAfter(): boolean {
+ return false;
+ }
+}
+
+export function $createTabNode(): TabNode {
+ return $applyNodeReplacement(new TabNode());
+}
+
+export function $isTabNode(
+ node: LexicalNode | null | undefined,
+): node is TabNode {
+ return node instanceof TabNode;
+}
--- /dev/null
+/**
+ * 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 {
+ EditorConfig,
+ KlassConstructor,
+ LexicalEditor,
+ Spread,
+ TextNodeThemeClasses,
+} from '../LexicalEditor';
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ NodeKey,
+ SerializedLexicalNode,
+} from '../LexicalNode';
+import type {BaseSelection, RangeSelection} from '../LexicalSelection';
+import type {ElementNode} from './LexicalElementNode';
+
+import {IS_FIREFOX} from 'lexical/shared/environment';
+import invariant from 'lexical/shared/invariant';
+
+import {
+ COMPOSITION_SUFFIX,
+ DETAIL_TYPE_TO_DETAIL,
+ DOM_ELEMENT_TYPE,
+ DOM_TEXT_TYPE,
+ IS_BOLD,
+ IS_CODE,
+ IS_DIRECTIONLESS,
+ IS_HIGHLIGHT,
+ IS_ITALIC,
+ IS_SEGMENTED,
+ IS_STRIKETHROUGH,
+ IS_SUBSCRIPT,
+ IS_SUPERSCRIPT,
+ IS_TOKEN,
+ IS_UNDERLINE,
+ IS_UNMERGEABLE,
+ TEXT_MODE_TO_TYPE,
+ TEXT_TYPE_TO_FORMAT,
+ TEXT_TYPE_TO_MODE,
+} from '../LexicalConstants';
+import {LexicalNode} from '../LexicalNode';
+import {
+ $getSelection,
+ $internalMakeRangeSelection,
+ $isRangeSelection,
+ $updateElementSelectionOnCreateDeleteNode,
+ adjustPointOffsetForMergedSibling,
+} from '../LexicalSelection';
+import {errorOnReadOnly} from '../LexicalUpdates';
+import {
+ $applyNodeReplacement,
+ $getCompositionKey,
+ $setCompositionKey,
+ getCachedClassNameArray,
+ internalMarkSiblingsAsDirty,
+ isHTMLElement,
+ isInlineDomNode,
+ toggleTextFormatType,
+} from '../LexicalUtils';
+import {$createLineBreakNode} from './LexicalLineBreakNode';
+import {$createTabNode} from './LexicalTabNode';
+
+export type SerializedTextNode = Spread<
+ {
+ detail: number;
+ format: number;
+ mode: TextModeType;
+ style: string;
+ text: string;
+ },
+ SerializedLexicalNode
+>;
+
+export type TextDetailType = 'directionless' | 'unmergable';
+
+export type TextFormatType =
+ | 'bold'
+ | 'underline'
+ | 'strikethrough'
+ | 'italic'
+ | 'highlight'
+ | 'code'
+ | 'subscript'
+ | 'superscript';
+
+export type TextModeType = 'normal' | 'token' | 'segmented';
+
+export type TextMark = {end: null | number; id: string; start: null | number};
+
+export type TextMarks = Array<TextMark>;
+
+function getElementOuterTag(node: TextNode, format: number): string | null {
+ if (format & IS_CODE) {
+ return 'code';
+ }
+ if (format & IS_HIGHLIGHT) {
+ return 'mark';
+ }
+ if (format & IS_SUBSCRIPT) {
+ return 'sub';
+ }
+ if (format & IS_SUPERSCRIPT) {
+ return 'sup';
+ }
+ return null;
+}
+
+function getElementInnerTag(node: TextNode, format: number): string {
+ if (format & IS_BOLD) {
+ return 'strong';
+ }
+ if (format & IS_ITALIC) {
+ return 'em';
+ }
+ return 'span';
+}
+
+function setTextThemeClassNames(
+ tag: string,
+ prevFormat: number,
+ nextFormat: number,
+ dom: HTMLElement,
+ textClassNames: TextNodeThemeClasses,
+): void {
+ const domClassList = dom.classList;
+ // Firstly we handle the base theme.
+ let classNames = getCachedClassNameArray(textClassNames, 'base');
+ if (classNames !== undefined) {
+ domClassList.add(...classNames);
+ }
+ // Secondly we handle the special case: underline + strikethrough.
+ // We have to do this as we need a way to compose the fact that
+ // the same CSS property will need to be used: text-decoration.
+ // In an ideal world we shouldn't have to do this, but there's no
+ // easy workaround for many atomic CSS systems today.
+ classNames = getCachedClassNameArray(
+ textClassNames,
+ 'underlineStrikethrough',
+ );
+ let hasUnderlineStrikethrough = false;
+ const prevUnderlineStrikethrough =
+ prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH;
+ const nextUnderlineStrikethrough =
+ nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH;
+
+ if (classNames !== undefined) {
+ if (nextUnderlineStrikethrough) {
+ hasUnderlineStrikethrough = true;
+ if (!prevUnderlineStrikethrough) {
+ domClassList.add(...classNames);
+ }
+ } else if (prevUnderlineStrikethrough) {
+ domClassList.remove(...classNames);
+ }
+ }
+
+ for (const key in TEXT_TYPE_TO_FORMAT) {
+ const format = key;
+ const flag = TEXT_TYPE_TO_FORMAT[format];
+ classNames = getCachedClassNameArray(textClassNames, key);
+ if (classNames !== undefined) {
+ if (nextFormat & flag) {
+ if (
+ hasUnderlineStrikethrough &&
+ (key === 'underline' || key === 'strikethrough')
+ ) {
+ if (prevFormat & flag) {
+ domClassList.remove(...classNames);
+ }
+ continue;
+ }
+ if (
+ (prevFormat & flag) === 0 ||
+ (prevUnderlineStrikethrough && key === 'underline') ||
+ key === 'strikethrough'
+ ) {
+ domClassList.add(...classNames);
+ }
+ } else if (prevFormat & flag) {
+ domClassList.remove(...classNames);
+ }
+ }
+ }
+}
+
+function diffComposedText(a: string, b: string): [number, number, string] {
+ const aLength = a.length;
+ const bLength = b.length;
+ let left = 0;
+ let right = 0;
+
+ while (left < aLength && left < bLength && a[left] === b[left]) {
+ left++;
+ }
+ while (
+ right + left < aLength &&
+ right + left < bLength &&
+ a[aLength - right - 1] === b[bLength - right - 1]
+ ) {
+ right++;
+ }
+
+ return [left, aLength - left - right, b.slice(left, bLength - right)];
+}
+
+function setTextContent(
+ nextText: string,
+ dom: HTMLElement,
+ node: TextNode,
+): void {
+ const firstChild = dom.firstChild;
+ const isComposing = node.isComposing();
+ // Always add a suffix if we're composing a node
+ const suffix = isComposing ? COMPOSITION_SUFFIX : '';
+ const text: string = nextText + suffix;
+
+ if (firstChild == null) {
+ dom.textContent = text;
+ } else {
+ const nodeValue = firstChild.nodeValue;
+ if (nodeValue !== text) {
+ if (isComposing || IS_FIREFOX) {
+ // We also use the diff composed text for general text in FF to avoid
+ // the spellcheck red line from flickering.
+ const [index, remove, insert] = diffComposedText(
+ nodeValue as string,
+ text,
+ );
+ if (remove !== 0) {
+ // @ts-expect-error
+ firstChild.deleteData(index, remove);
+ }
+ // @ts-expect-error
+ firstChild.insertData(index, insert);
+ } else {
+ firstChild.nodeValue = text;
+ }
+ }
+ }
+}
+
+function createTextInnerDOM(
+ innerDOM: HTMLElement,
+ node: TextNode,
+ innerTag: string,
+ format: number,
+ text: string,
+ config: EditorConfig,
+): void {
+ setTextContent(text, innerDOM, node);
+ const theme = config.theme;
+ // Apply theme class names
+ const textClassNames = theme.text;
+
+ if (textClassNames !== undefined) {
+ setTextThemeClassNames(innerTag, 0, format, innerDOM, textClassNames);
+ }
+}
+
+function wrapElementWith(
+ element: HTMLElement | Text,
+ tag: string,
+): HTMLElement {
+ const el = document.createElement(tag);
+ el.appendChild(element);
+ return el;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export interface TextNode {
+ getTopLevelElement(): ElementNode | null;
+ getTopLevelElementOrThrow(): ElementNode;
+}
+
+/** @noInheritDoc */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export class TextNode extends LexicalNode {
+ ['constructor']!: KlassConstructor<typeof TextNode>;
+ __text: string;
+ /** @internal */
+ __format: number;
+ /** @internal */
+ __style: string;
+ /** @internal */
+ __mode: 0 | 1 | 2 | 3;
+ /** @internal */
+ __detail: number;
+
+ static getType(): string {
+ return 'text';
+ }
+
+ static clone(node: TextNode): TextNode {
+ return new TextNode(node.__text, node.__key);
+ }
+
+ afterCloneFrom(prevNode: this): void {
+ super.afterCloneFrom(prevNode);
+ this.__format = prevNode.__format;
+ this.__style = prevNode.__style;
+ this.__mode = prevNode.__mode;
+ this.__detail = prevNode.__detail;
+ }
+
+ constructor(text: string, key?: NodeKey) {
+ super(key);
+ this.__text = text;
+ this.__format = 0;
+ this.__style = '';
+ this.__mode = 0;
+ this.__detail = 0;
+ }
+
+ /**
+ * Returns a 32-bit integer that represents the TextFormatTypes currently applied to the
+ * TextNode. You probably don't want to use this method directly - consider using TextNode.hasFormat instead.
+ *
+ * @returns a number representing the format of the text node.
+ */
+ getFormat(): number {
+ const self = this.getLatest();
+ return self.__format;
+ }
+
+ /**
+ * Returns a 32-bit integer that represents the TextDetailTypes currently applied to the
+ * TextNode. You probably don't want to use this method directly - consider using TextNode.isDirectionless
+ * or TextNode.isUnmergeable instead.
+ *
+ * @returns a number representing the detail of the text node.
+ */
+ getDetail(): number {
+ const self = this.getLatest();
+ return self.__detail;
+ }
+
+ /**
+ * Returns the mode (TextModeType) of the TextNode, which may be "normal", "token", or "segmented"
+ *
+ * @returns TextModeType.
+ */
+ getMode(): TextModeType {
+ const self = this.getLatest();
+ return TEXT_TYPE_TO_MODE[self.__mode];
+ }
+
+ /**
+ * Returns the styles currently applied to the node. This is analogous to CSSText in the DOM.
+ *
+ * @returns CSSText-like string of styles applied to the underlying DOM node.
+ */
+ getStyle(): string {
+ const self = this.getLatest();
+ return self.__style;
+ }
+
+ /**
+ * Returns whether or not the node is in "token" mode. TextNodes in token mode can be navigated through character-by-character
+ * with a RangeSelection, but are deleted as a single entity (not invdividually by character).
+ *
+ * @returns true if the node is in token mode, false otherwise.
+ */
+ isToken(): boolean {
+ const self = this.getLatest();
+ return self.__mode === IS_TOKEN;
+ }
+
+ /**
+ *
+ * @returns true if Lexical detects that an IME or other 3rd-party script is attempting to
+ * mutate the TextNode, false otherwise.
+ */
+ isComposing(): boolean {
+ return this.__key === $getCompositionKey();
+ }
+
+ /**
+ * Returns whether or not the node is in "segemented" mode. TextNodes in segemented mode can be navigated through character-by-character
+ * with a RangeSelection, but are deleted in space-delimited "segments".
+ *
+ * @returns true if the node is in segmented mode, false otherwise.
+ */
+ isSegmented(): boolean {
+ const self = this.getLatest();
+ return self.__mode === IS_SEGMENTED;
+ }
+ /**
+ * Returns whether or not the node is "directionless". Directionless nodes don't respect changes between RTL and LTR modes.
+ *
+ * @returns true if the node is directionless, false otherwise.
+ */
+ isDirectionless(): boolean {
+ const self = this.getLatest();
+ return (self.__detail & IS_DIRECTIONLESS) !== 0;
+ }
+ /**
+ * Returns whether or not the node is unmergeable. In some scenarios, Lexical tries to merge
+ * adjacent TextNodes into a single TextNode. If a TextNode is unmergeable, this won't happen.
+ *
+ * @returns true if the node is unmergeable, false otherwise.
+ */
+ isUnmergeable(): boolean {
+ const self = this.getLatest();
+ return (self.__detail & IS_UNMERGEABLE) !== 0;
+ }
+
+ /**
+ * Returns whether or not the node has the provided format applied. Use this with the human-readable TextFormatType
+ * string values to get the format of a TextNode.
+ *
+ * @param type - the TextFormatType to check for.
+ *
+ * @returns true if the node has the provided format, false otherwise.
+ */
+ hasFormat(type: TextFormatType): boolean {
+ const formatFlag = TEXT_TYPE_TO_FORMAT[type];
+ return (this.getFormat() & formatFlag) !== 0;
+ }
+
+ /**
+ * Returns whether or not the node is simple text. Simple text is defined as a TextNode that has the string type "text"
+ * (i.e., not a subclass) and has no mode applied to it (i.e., not segmented or token).
+ *
+ * @returns true if the node is simple text, false otherwise.
+ */
+ isSimpleText(): boolean {
+ return this.__type === 'text' && this.__mode === 0;
+ }
+
+ /**
+ * Returns the text content of the node as a string.
+ *
+ * @returns a string representing the text content of the node.
+ */
+ getTextContent(): string {
+ const self = this.getLatest();
+ return self.__text;
+ }
+
+ /**
+ * Returns the format flags applied to the node as a 32-bit integer.
+ *
+ * @returns a number representing the TextFormatTypes applied to the node.
+ */
+ getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
+ const self = this.getLatest();
+ const format = self.__format;
+ return toggleTextFormatType(format, type, alignWithFormat);
+ }
+
+ /**
+ *
+ * @returns true if the text node supports font styling, false otherwise.
+ */
+ canHaveFormat(): boolean {
+ return true;
+ }
+
+ // View
+
+ createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
+ const format = this.__format;
+ const outerTag = getElementOuterTag(this, format);
+ const innerTag = getElementInnerTag(this, format);
+ const tag = outerTag === null ? innerTag : outerTag;
+ const dom = document.createElement(tag);
+ let innerDOM = dom;
+ if (this.hasFormat('code')) {
+ dom.setAttribute('spellcheck', 'false');
+ }
+ if (outerTag !== null) {
+ innerDOM = document.createElement(innerTag);
+ dom.appendChild(innerDOM);
+ }
+ const text = this.__text;
+ createTextInnerDOM(innerDOM, this, innerTag, format, text, config);
+ const style = this.__style;
+ if (style !== '') {
+ dom.style.cssText = style;
+ }
+ return dom;
+ }
+
+ updateDOM(
+ prevNode: TextNode,
+ dom: HTMLElement,
+ config: EditorConfig,
+ ): boolean {
+ const nextText = this.__text;
+ const prevFormat = prevNode.__format;
+ const nextFormat = this.__format;
+ const prevOuterTag = getElementOuterTag(this, prevFormat);
+ const nextOuterTag = getElementOuterTag(this, nextFormat);
+ const prevInnerTag = getElementInnerTag(this, prevFormat);
+ const nextInnerTag = getElementInnerTag(this, nextFormat);
+ const prevTag = prevOuterTag === null ? prevInnerTag : prevOuterTag;
+ const nextTag = nextOuterTag === null ? nextInnerTag : nextOuterTag;
+
+ if (prevTag !== nextTag) {
+ return true;
+ }
+ if (prevOuterTag === nextOuterTag && prevInnerTag !== nextInnerTag) {
+ // should always be an element
+ const prevInnerDOM: HTMLElement = dom.firstChild as HTMLElement;
+ if (prevInnerDOM == null) {
+ invariant(false, 'updateDOM: prevInnerDOM is null or undefined');
+ }
+ const nextInnerDOM = document.createElement(nextInnerTag);
+ createTextInnerDOM(
+ nextInnerDOM,
+ this,
+ nextInnerTag,
+ nextFormat,
+ nextText,
+ config,
+ );
+ dom.replaceChild(nextInnerDOM, prevInnerDOM);
+ return false;
+ }
+ let innerDOM = dom;
+ if (nextOuterTag !== null) {
+ if (prevOuterTag !== null) {
+ innerDOM = dom.firstChild as HTMLElement;
+ if (innerDOM == null) {
+ invariant(false, 'updateDOM: innerDOM is null or undefined');
+ }
+ }
+ }
+ setTextContent(nextText, innerDOM, this);
+ const theme = config.theme;
+ // Apply theme class names
+ const textClassNames = theme.text;
+
+ if (textClassNames !== undefined && prevFormat !== nextFormat) {
+ setTextThemeClassNames(
+ nextInnerTag,
+ prevFormat,
+ nextFormat,
+ innerDOM,
+ textClassNames,
+ );
+ }
+ const prevStyle = prevNode.__style;
+ const nextStyle = this.__style;
+ if (prevStyle !== nextStyle) {
+ dom.style.cssText = nextStyle;
+ }
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ '#text': () => ({
+ conversion: $convertTextDOMNode,
+ priority: 0,
+ }),
+ b: () => ({
+ conversion: convertBringAttentionToElement,
+ priority: 0,
+ }),
+ code: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ em: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ i: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ s: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ span: () => ({
+ conversion: convertSpanElement,
+ priority: 0,
+ }),
+ strong: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ sub: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ sup: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ u: () => ({
+ conversion: convertTextFormatElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ static importJSON(serializedNode: SerializedTextNode): TextNode {
+ const node = $createTextNode(serializedNode.text);
+ node.setFormat(serializedNode.format);
+ node.setDetail(serializedNode.detail);
+ node.setMode(serializedNode.mode);
+ node.setStyle(serializedNode.style);
+ return node;
+ }
+
+ // This improves Lexical's basic text output in copy+paste plus
+ // for headless mode where people might use Lexical to generate
+ // HTML content and not have the ability to use CSS classes.
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ let {element} = super.exportDOM(editor);
+ invariant(
+ element !== null && isHTMLElement(element),
+ 'Expected TextNode createDOM to always return a HTMLElement',
+ );
+ element.style.whiteSpace = 'pre-wrap';
+ // This is the only way to properly add support for most clients,
+ // even if it's semantically incorrect to have to resort to using
+ // <b>, <u>, <s>, <i> elements.
+ if (this.hasFormat('bold')) {
+ element = wrapElementWith(element, 'b');
+ }
+ if (this.hasFormat('italic')) {
+ element = wrapElementWith(element, 'i');
+ }
+ if (this.hasFormat('strikethrough')) {
+ element = wrapElementWith(element, 's');
+ }
+ if (this.hasFormat('underline')) {
+ element = wrapElementWith(element, 'u');
+ }
+
+ return {
+ element,
+ };
+ }
+
+ exportJSON(): SerializedTextNode {
+ return {
+ detail: this.getDetail(),
+ format: this.getFormat(),
+ mode: this.getMode(),
+ style: this.getStyle(),
+ text: this.getTextContent(),
+ type: 'text',
+ version: 1,
+ };
+ }
+
+ // Mutators
+ selectionTransform(
+ prevSelection: null | BaseSelection,
+ nextSelection: RangeSelection,
+ ): void {
+ return;
+ }
+
+ /**
+ * Sets the node format to the provided TextFormatType or 32-bit integer. Note that the TextFormatType
+ * version of the argument can only specify one format and doing so will remove all other formats that
+ * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleFormat}
+ *
+ * @param format - TextFormatType or 32-bit integer representing the node format.
+ *
+ * @returns this TextNode.
+ * // TODO 0.12 This should just be a `string`.
+ */
+ setFormat(format: TextFormatType | number): this {
+ const self = this.getWritable();
+ self.__format =
+ typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format;
+ return self;
+ }
+
+ /**
+ * Sets the node detail to the provided TextDetailType or 32-bit integer. Note that the TextDetailType
+ * version of the argument can only specify one detail value and doing so will remove all other detail values that
+ * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleDirectionless}
+ * or {@link TextNode.toggleUnmergeable}
+ *
+ * @param detail - TextDetailType or 32-bit integer representing the node detail.
+ *
+ * @returns this TextNode.
+ * // TODO 0.12 This should just be a `string`.
+ */
+ setDetail(detail: TextDetailType | number): this {
+ const self = this.getWritable();
+ self.__detail =
+ typeof detail === 'string' ? DETAIL_TYPE_TO_DETAIL[detail] : detail;
+ return self;
+ }
+
+ /**
+ * Sets the node style to the provided CSSText-like string. Set this property as you
+ * would an HTMLElement style attribute to apply inline styles to the underlying DOM Element.
+ *
+ * @param style - CSSText to be applied to the underlying HTMLElement.
+ *
+ * @returns this TextNode.
+ */
+ setStyle(style: string): this {
+ const self = this.getWritable();
+ self.__style = style;
+ return self;
+ }
+
+ /**
+ * Applies the provided format to this TextNode if it's not present. Removes it if it's present.
+ * The subscript and superscript formats are mutually exclusive.
+ * Prefer using this method to turn specific formats on and off.
+ *
+ * @param type - TextFormatType to toggle.
+ *
+ * @returns this TextNode.
+ */
+ toggleFormat(type: TextFormatType): this {
+ const format = this.getFormat();
+ const newFormat = toggleTextFormatType(format, type, null);
+ return this.setFormat(newFormat);
+ }
+
+ /**
+ * Toggles the directionless detail value of the node. Prefer using this method over setDetail.
+ *
+ * @returns this TextNode.
+ */
+ toggleDirectionless(): this {
+ const self = this.getWritable();
+ self.__detail ^= IS_DIRECTIONLESS;
+ return self;
+ }
+
+ /**
+ * Toggles the unmergeable detail value of the node. Prefer using this method over setDetail.
+ *
+ * @returns this TextNode.
+ */
+ toggleUnmergeable(): this {
+ const self = this.getWritable();
+ self.__detail ^= IS_UNMERGEABLE;
+ return self;
+ }
+
+ /**
+ * Sets the mode of the node.
+ *
+ * @returns this TextNode.
+ */
+ setMode(type: TextModeType): this {
+ const mode = TEXT_MODE_TO_TYPE[type];
+ if (this.__mode === mode) {
+ return this;
+ }
+ const self = this.getWritable();
+ self.__mode = mode;
+ return self;
+ }
+
+ /**
+ * Sets the text content of the node.
+ *
+ * @param text - the string to set as the text value of the node.
+ *
+ * @returns this TextNode.
+ */
+ setTextContent(text: string): this {
+ if (this.__text === text) {
+ return this;
+ }
+ const self = this.getWritable();
+ self.__text = text;
+ return self;
+ }
+
+ /**
+ * Sets the current Lexical selection to be a RangeSelection with anchor and focus on this TextNode at the provided offsets.
+ *
+ * @param _anchorOffset - the offset at which the Selection anchor will be placed.
+ * @param _focusOffset - the offset at which the Selection focus will be placed.
+ *
+ * @returns the new RangeSelection.
+ */
+ select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
+ errorOnReadOnly();
+ let anchorOffset = _anchorOffset;
+ let focusOffset = _focusOffset;
+ const selection = $getSelection();
+ const text = this.getTextContent();
+ const key = this.__key;
+ if (typeof text === 'string') {
+ const lastOffset = text.length;
+ if (anchorOffset === undefined) {
+ anchorOffset = lastOffset;
+ }
+ if (focusOffset === undefined) {
+ focusOffset = lastOffset;
+ }
+ } else {
+ anchorOffset = 0;
+ focusOffset = 0;
+ }
+ if (!$isRangeSelection(selection)) {
+ return $internalMakeRangeSelection(
+ key,
+ anchorOffset,
+ key,
+ focusOffset,
+ 'text',
+ 'text',
+ );
+ } else {
+ const compositionKey = $getCompositionKey();
+ if (
+ compositionKey === selection.anchor.key ||
+ compositionKey === selection.focus.key
+ ) {
+ $setCompositionKey(key);
+ }
+ selection.setTextNodeRange(this, anchorOffset, this, focusOffset);
+ }
+ return selection;
+ }
+
+ selectStart(): RangeSelection {
+ return this.select(0, 0);
+ }
+
+ selectEnd(): RangeSelection {
+ const size = this.getTextContentSize();
+ return this.select(size, size);
+ }
+
+ /**
+ * Inserts the provided text into this TextNode at the provided offset, deleting the number of characters
+ * specified. Can optionally calculate a new selection after the operation is complete.
+ *
+ * @param offset - the offset at which the splice operation should begin.
+ * @param delCount - the number of characters to delete, starting from the offset.
+ * @param newText - the text to insert into the TextNode at the offset.
+ * @param moveSelection - optional, whether or not to move selection to the end of the inserted substring.
+ *
+ * @returns this TextNode.
+ */
+ spliceText(
+ offset: number,
+ delCount: number,
+ newText: string,
+ moveSelection?: boolean,
+ ): TextNode {
+ const writableSelf = this.getWritable();
+ const text = writableSelf.__text;
+ const handledTextLength = newText.length;
+ let index = offset;
+ if (index < 0) {
+ index = handledTextLength + index;
+ if (index < 0) {
+ index = 0;
+ }
+ }
+ const selection = $getSelection();
+ if (moveSelection && $isRangeSelection(selection)) {
+ const newOffset = offset + handledTextLength;
+ selection.setTextNodeRange(
+ writableSelf,
+ newOffset,
+ writableSelf,
+ newOffset,
+ );
+ }
+
+ const updatedText =
+ text.slice(0, index) + newText + text.slice(index + delCount);
+
+ writableSelf.__text = updatedText;
+ return writableSelf;
+ }
+
+ /**
+ * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
+ * when a user event would cause text to be inserted before them in the editor. If true, Lexical will attempt
+ * to insert text into this node. If false, it will insert the text in a new sibling node.
+ *
+ * @returns true if text can be inserted before the node, false otherwise.
+ */
+ canInsertTextBefore(): boolean {
+ return true;
+ }
+
+ /**
+ * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
+ * when a user event would cause text to be inserted after them in the editor. If true, Lexical will attempt
+ * to insert text into this node. If false, it will insert the text in a new sibling node.
+ *
+ * @returns true if text can be inserted after the node, false otherwise.
+ */
+ canInsertTextAfter(): boolean {
+ return true;
+ }
+
+ /**
+ * Splits this TextNode at the provided character offsets, forming new TextNodes from the substrings
+ * formed by the split, and inserting those new TextNodes into the editor, replacing the one that was split.
+ *
+ * @param splitOffsets - rest param of the text content character offsets at which this node should be split.
+ *
+ * @returns an Array containing the newly-created TextNodes.
+ */
+ splitText(...splitOffsets: Array<number>): Array<TextNode> {
+ errorOnReadOnly();
+ const self = this.getLatest();
+ const textContent = self.getTextContent();
+ const key = self.__key;
+ const compositionKey = $getCompositionKey();
+ const offsetsSet = new Set(splitOffsets);
+ const parts = [];
+ const textLength = textContent.length;
+ let string = '';
+ for (let i = 0; i < textLength; i++) {
+ if (string !== '' && offsetsSet.has(i)) {
+ parts.push(string);
+ string = '';
+ }
+ string += textContent[i];
+ }
+ if (string !== '') {
+ parts.push(string);
+ }
+ const partsLength = parts.length;
+ if (partsLength === 0) {
+ return [];
+ } else if (parts[0] === textContent) {
+ return [self];
+ }
+ const firstPart = parts[0];
+ const parent = self.getParent();
+ let writableNode;
+ const format = self.getFormat();
+ const style = self.getStyle();
+ const detail = self.__detail;
+ let hasReplacedSelf = false;
+
+ if (self.isSegmented()) {
+ // Create a new TextNode
+ writableNode = $createTextNode(firstPart);
+ writableNode.__format = format;
+ writableNode.__style = style;
+ writableNode.__detail = detail;
+ hasReplacedSelf = true;
+ } else {
+ // For the first part, update the existing node
+ writableNode = self.getWritable();
+ writableNode.__text = firstPart;
+ }
+
+ // Handle selection
+ const selection = $getSelection();
+
+ // Then handle all other parts
+ const splitNodes: TextNode[] = [writableNode];
+ let textSize = firstPart.length;
+
+ for (let i = 1; i < partsLength; i++) {
+ const part = parts[i];
+ const partSize = part.length;
+ const sibling = $createTextNode(part).getWritable();
+ sibling.__format = format;
+ sibling.__style = style;
+ sibling.__detail = detail;
+ const siblingKey = sibling.__key;
+ const nextTextSize = textSize + partSize;
+
+ if ($isRangeSelection(selection)) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+
+ if (
+ anchor.key === key &&
+ anchor.type === 'text' &&
+ anchor.offset > textSize &&
+ anchor.offset <= nextTextSize
+ ) {
+ anchor.key = siblingKey;
+ anchor.offset -= textSize;
+ selection.dirty = true;
+ }
+ if (
+ focus.key === key &&
+ focus.type === 'text' &&
+ focus.offset > textSize &&
+ focus.offset <= nextTextSize
+ ) {
+ focus.key = siblingKey;
+ focus.offset -= textSize;
+ selection.dirty = true;
+ }
+ }
+ if (compositionKey === key) {
+ $setCompositionKey(siblingKey);
+ }
+ textSize = nextTextSize;
+ splitNodes.push(sibling);
+ }
+
+ // Insert the nodes into the parent's children
+ if (parent !== null) {
+ internalMarkSiblingsAsDirty(this);
+ const writableParent = parent.getWritable();
+ const insertionIndex = this.getIndexWithinParent();
+ if (hasReplacedSelf) {
+ writableParent.splice(insertionIndex, 0, splitNodes);
+ this.remove();
+ } else {
+ writableParent.splice(insertionIndex, 1, splitNodes);
+ }
+
+ if ($isRangeSelection(selection)) {
+ $updateElementSelectionOnCreateDeleteNode(
+ selection,
+ parent,
+ insertionIndex,
+ partsLength - 1,
+ );
+ }
+ }
+
+ return splitNodes;
+ }
+
+ /**
+ * Merges the target TextNode into this TextNode, removing the target node.
+ *
+ * @param target - the TextNode to merge into this one.
+ *
+ * @returns this TextNode.
+ */
+ mergeWithSibling(target: TextNode): TextNode {
+ const isBefore = target === this.getPreviousSibling();
+ if (!isBefore && target !== this.getNextSibling()) {
+ invariant(
+ false,
+ 'mergeWithSibling: sibling must be a previous or next sibling',
+ );
+ }
+ const key = this.__key;
+ const targetKey = target.__key;
+ const text = this.__text;
+ const textLength = text.length;
+ const compositionKey = $getCompositionKey();
+
+ if (compositionKey === targetKey) {
+ $setCompositionKey(key);
+ }
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ if (anchor !== null && anchor.key === targetKey) {
+ adjustPointOffsetForMergedSibling(
+ anchor,
+ isBefore,
+ key,
+ target,
+ textLength,
+ );
+ selection.dirty = true;
+ }
+ if (focus !== null && focus.key === targetKey) {
+ adjustPointOffsetForMergedSibling(
+ focus,
+ isBefore,
+ key,
+ target,
+ textLength,
+ );
+ selection.dirty = true;
+ }
+ }
+ const targetText = target.__text;
+ const newText = isBefore ? targetText + text : text + targetText;
+ this.setTextContent(newText);
+ const writableSelf = this.getWritable();
+ target.remove();
+ return writableSelf;
+ }
+
+ /**
+ * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
+ * when used with the registerLexicalTextEntity function. If you're using registerLexicalTextEntity, the
+ * node class that you create and replace matched text with should return true from this method.
+ *
+ * @returns true if the node is to be treated as a "text entity", false otherwise.
+ */
+ isTextEntity(): boolean {
+ return false;
+ }
+}
+
+function convertSpanElement(domNode: HTMLSpanElement): DOMConversionOutput {
+ // domNode is a <span> since we matched it by nodeName
+ const span = domNode;
+ const style = span.style;
+
+ return {
+ forChild: applyTextFormatFromStyle(style),
+ node: null,
+ };
+}
+
+function convertBringAttentionToElement(
+ domNode: HTMLElement,
+): DOMConversionOutput {
+ // domNode is a <b> since we matched it by nodeName
+ const b = domNode;
+ // Google Docs wraps all copied HTML in a <b> with font-weight normal
+ const hasNormalFontWeight = b.style.fontWeight === 'normal';
+
+ return {
+ forChild: applyTextFormatFromStyle(
+ b.style,
+ hasNormalFontWeight ? undefined : 'bold',
+ ),
+ node: null,
+ };
+}
+
+const preParentCache = new WeakMap<Node, null | Node>();
+
+function isNodePre(node: Node): boolean {
+ return (
+ node.nodeName === 'PRE' ||
+ (node.nodeType === DOM_ELEMENT_TYPE &&
+ (node as HTMLElement).style !== undefined &&
+ (node as HTMLElement).style.whiteSpace !== undefined &&
+ (node as HTMLElement).style.whiteSpace.startsWith('pre'))
+ );
+}
+
+export function findParentPreDOMNode(node: Node) {
+ let cached;
+ let parent = node.parentNode;
+ const visited = [node];
+ while (
+ parent !== null &&
+ (cached = preParentCache.get(parent)) === undefined &&
+ !isNodePre(parent)
+ ) {
+ visited.push(parent);
+ parent = parent.parentNode;
+ }
+ const resultNode = cached === undefined ? parent : cached;
+ for (let i = 0; i < visited.length; i++) {
+ preParentCache.set(visited[i], resultNode);
+ }
+ return resultNode;
+}
+
+function $convertTextDOMNode(domNode: Node): DOMConversionOutput {
+ const domNode_ = domNode as Text;
+ const parentDom = domNode.parentElement;
+ invariant(
+ parentDom !== null,
+ 'Expected parentElement of Text not to be null',
+ );
+ let textContent = domNode_.textContent || '';
+ // No collapse and preserve segment break for pre, pre-wrap and pre-line
+ if (findParentPreDOMNode(domNode_) !== null) {
+ const parts = textContent.split(/(\r?\n|\t)/);
+ const nodes: Array<LexicalNode> = [];
+ const length = parts.length;
+ for (let i = 0; i < length; i++) {
+ const part = parts[i];
+ if (part === '\n' || part === '\r\n') {
+ nodes.push($createLineBreakNode());
+ } else if (part === '\t') {
+ nodes.push($createTabNode());
+ } else if (part !== '') {
+ nodes.push($createTextNode(part));
+ }
+ }
+ return {node: nodes};
+ }
+ textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' ');
+ if (textContent === '') {
+ return {node: null};
+ }
+ if (textContent[0] === ' ') {
+ // Traverse backward while in the same line. If content contains new line or tab -> pontential
+ // delete, other elements can borrow from this one. Deletion depends on whether it's also the
+ // last space (see next condition: textContent[textContent.length - 1] === ' '))
+ let previousText: null | Text = domNode_;
+ let isStartOfLine = true;
+ while (
+ previousText !== null &&
+ (previousText = findTextInLine(previousText, false)) !== null
+ ) {
+ const previousTextContent = previousText.textContent || '';
+ if (previousTextContent.length > 0) {
+ if (/[ \t\n]$/.test(previousTextContent)) {
+ textContent = textContent.slice(1);
+ }
+ isStartOfLine = false;
+ break;
+ }
+ }
+ if (isStartOfLine) {
+ textContent = textContent.slice(1);
+ }
+ }
+ if (textContent[textContent.length - 1] === ' ') {
+ // Traverse forward while in the same line, preserve if next inline will require a space
+ let nextText: null | Text = domNode_;
+ let isEndOfLine = true;
+ while (
+ nextText !== null &&
+ (nextText = findTextInLine(nextText, true)) !== null
+ ) {
+ const nextTextContent = (nextText.textContent || '').replace(
+ /^( |\t|\r?\n)+/,
+ '',
+ );
+ if (nextTextContent.length > 0) {
+ isEndOfLine = false;
+ break;
+ }
+ }
+ if (isEndOfLine) {
+ textContent = textContent.slice(0, textContent.length - 1);
+ }
+ }
+ if (textContent === '') {
+ return {node: null};
+ }
+ return {node: $createTextNode(textContent)};
+}
+
+function findTextInLine(text: Text, forward: boolean): null | Text {
+ let node: Node = text;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ let sibling: null | Node;
+ while (
+ (sibling = forward ? node.nextSibling : node.previousSibling) === null
+ ) {
+ const parentElement = node.parentElement;
+ if (parentElement === null) {
+ return null;
+ }
+ node = parentElement;
+ }
+ node = sibling;
+ if (node.nodeType === DOM_ELEMENT_TYPE) {
+ const display = (node as HTMLElement).style.display;
+ if (
+ (display === '' && !isInlineDomNode(node)) ||
+ (display !== '' && !display.startsWith('inline'))
+ ) {
+ return null;
+ }
+ }
+ let descendant: null | Node = node;
+ while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
+ node = descendant;
+ }
+ if (node.nodeType === DOM_TEXT_TYPE) {
+ return node as Text;
+ } else if (node.nodeName === 'BR') {
+ return null;
+ }
+ }
+}
+
+const nodeNameToTextFormat: Record<string, TextFormatType> = {
+ code: 'code',
+ em: 'italic',
+ i: 'italic',
+ s: 'strikethrough',
+ strong: 'bold',
+ sub: 'subscript',
+ sup: 'superscript',
+ u: 'underline',
+};
+
+function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
+ const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
+ if (format === undefined) {
+ return {node: null};
+ }
+ return {
+ forChild: applyTextFormatFromStyle(domNode.style, format),
+ node: null,
+ };
+}
+
+export function $createTextNode(text = ''): TextNode {
+ return $applyNodeReplacement(new TextNode(text));
+}
+
+export function $isTextNode(
+ node: LexicalNode | null | undefined,
+): node is TextNode {
+ return node instanceof TextNode;
+}
+
+function applyTextFormatFromStyle(
+ style: CSSStyleDeclaration,
+ shouldApply?: TextFormatType,
+) {
+ const fontWeight = style.fontWeight;
+ const textDecoration = style.textDecoration.split(' ');
+ // Google Docs uses span tags + font-weight for bold text
+ const hasBoldFontWeight = fontWeight === '700' || fontWeight === 'bold';
+ // Google Docs uses span tags + text-decoration: line-through for strikethrough text
+ const hasLinethroughTextDecoration = textDecoration.includes('line-through');
+ // Google Docs uses span tags + font-style for italic text
+ const hasItalicFontStyle = style.fontStyle === 'italic';
+ // Google Docs uses span tags + text-decoration: underline for underline text
+ const hasUnderlineTextDecoration = textDecoration.includes('underline');
+ // Google Docs uses span tags + vertical-align to specify subscript and superscript
+ const verticalAlign = style.verticalAlign;
+
+ return (lexicalNode: LexicalNode) => {
+ if (!$isTextNode(lexicalNode)) {
+ return lexicalNode;
+ }
+ if (hasBoldFontWeight && !lexicalNode.hasFormat('bold')) {
+ lexicalNode.toggleFormat('bold');
+ }
+ if (
+ hasLinethroughTextDecoration &&
+ !lexicalNode.hasFormat('strikethrough')
+ ) {
+ lexicalNode.toggleFormat('strikethrough');
+ }
+ if (hasItalicFontStyle && !lexicalNode.hasFormat('italic')) {
+ lexicalNode.toggleFormat('italic');
+ }
+ if (hasUnderlineTextDecoration && !lexicalNode.hasFormat('underline')) {
+ lexicalNode.toggleFormat('underline');
+ }
+ if (verticalAlign === 'sub' && !lexicalNode.hasFormat('subscript')) {
+ lexicalNode.toggleFormat('subscript');
+ }
+ if (verticalAlign === 'super' && !lexicalNode.hasFormat('superscript')) {
+ lexicalNode.toggleFormat('superscript');
+ }
+
+ if (shouldApply && !lexicalNode.hasFormat(shouldApply)) {
+ lexicalNode.toggleFormat(shouldApply);
+ }
+
+ return lexicalNode;
+ };
+}
--- /dev/null
+/**
+ * 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 {
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ TextNode,
+} from 'lexical';
+import * as React from 'react';
+import {createRef, useEffect} from 'react';
+import {createRoot} from 'react-dom/client';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+import {
+ $createTestElementNode,
+ createTestEditor,
+} from '../../../__tests__/utils';
+
+describe('LexicalElementNode tests', () => {
+ let container: HTMLElement;
+
+ beforeEach(async () => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+
+ await init();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container);
+ // @ts-ignore
+ container = null;
+ });
+
+ async function update(fn: () => void) {
+ editor.update(fn);
+ return Promise.resolve().then();
+ }
+
+ function useLexicalEditor(rootElementRef: React.RefObject<HTMLDivElement>) {
+ const editor = React.useMemo(() => createTestEditor(), []);
+
+ useEffect(() => {
+ const rootElement = rootElementRef.current;
+ editor.setRootElement(rootElement);
+ }, [rootElementRef, editor]);
+
+ return editor;
+ }
+
+ let editor: LexicalEditor;
+
+ async function init() {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase() {
+ editor = useLexicalEditor(ref);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ createRoot(container).render(<TestBase />);
+ });
+
+ // Insert initial block
+ await update(() => {
+ const block = $createTestElementNode();
+ const text = $createTextNode('Foo');
+ const text2 = $createTextNode('Bar');
+ // Prevent text nodes from combining.
+ text2.setMode('segmented');
+ const text3 = $createTextNode('Baz');
+ // Some operations require a selection to exist, hence
+ // we make a selection in the setup code.
+ text.select(0, 0);
+ block.append(text, text2, text3);
+ $getRoot().append(block);
+ });
+ }
+
+ describe('exportJSON()', () => {
+ test('should return and object conforming to the expected schema', async () => {
+ await update(() => {
+ const node = $createTestElementNode();
+
+ // If you broke this test, you changed the public interface of a
+ // serialized Lexical Core Node. Please ensure the correct adapter
+ // logic is in place in the corresponding importJSON method
+ // to accomodate these changes.
+
+ expect(node.exportJSON()).toStrictEqual({
+ children: [],
+ direction: null,
+ format: '',
+ indent: 0,
+ type: 'test_block',
+ version: 1,
+ });
+ });
+ });
+ });
+
+ describe('getChildren()', () => {
+ test('no children', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ const children = block.getChildren();
+ expect(children).toHaveLength(0);
+ expect(children).toEqual([]);
+ });
+ });
+
+ test('some children', async () => {
+ await update(() => {
+ const children = $getRoot().getFirstChild<ElementNode>()!.getChildren();
+ expect(children).toHaveLength(3);
+ });
+ });
+ });
+
+ describe('getAllTextNodes()', () => {
+ test('basic', async () => {
+ await update(() => {
+ const textNodes = $getRoot()
+ .getFirstChild<ElementNode>()!
+ .getAllTextNodes();
+ expect(textNodes).toHaveLength(3);
+ });
+ });
+
+ test('nested', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ const innerBlock = $createTestElementNode();
+ const text = $createTextNode('Foo');
+ text.select(0, 0);
+ const text2 = $createTextNode('Bar');
+ const text3 = $createTextNode('Baz');
+ const text4 = $createTextNode('Qux');
+ block.append(text, innerBlock, text4);
+ innerBlock.append(text2, text3);
+ const children = block.getAllTextNodes();
+
+ expect(children).toHaveLength(4);
+ expect(children).toEqual([text, text2, text3, text4]);
+
+ const innerInnerBlock = $createTestElementNode();
+ const text5 = $createTextNode('More');
+ const text6 = $createTextNode('Stuff');
+ innerInnerBlock.append(text5, text6);
+ innerBlock.append(innerInnerBlock);
+ const children2 = block.getAllTextNodes();
+
+ expect(children2).toHaveLength(6);
+ expect(children2).toEqual([text, text2, text3, text5, text6, text4]);
+
+ $getRoot().append(block);
+ });
+ });
+ });
+
+ describe('getFirstChild()', () => {
+ test('basic', async () => {
+ await update(() => {
+ expect(
+ $getRoot()
+ .getFirstChild<ElementNode>()!
+ .getFirstChild()!
+ .getTextContent(),
+ ).toBe('Foo');
+ });
+ });
+
+ test('empty', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ expect(block.getFirstChild()).toBe(null);
+ });
+ });
+ });
+
+ describe('getLastChild()', () => {
+ test('basic', async () => {
+ await update(() => {
+ expect(
+ $getRoot()
+ .getFirstChild<ElementNode>()!
+ .getLastChild()!
+ .getTextContent(),
+ ).toBe('Baz');
+ });
+ });
+
+ test('empty', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ expect(block.getLastChild()).toBe(null);
+ });
+ });
+ });
+
+ describe('getTextContent()', () => {
+ test('basic', async () => {
+ await update(() => {
+ expect($getRoot().getFirstChild()!.getTextContent()).toBe('FooBarBaz');
+ });
+ });
+
+ test('empty', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ expect(block.getTextContent()).toBe('');
+ });
+ });
+
+ test('nested', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ const innerBlock = $createTestElementNode();
+ const text = $createTextNode('Foo');
+ text.select(0, 0);
+ const text2 = $createTextNode('Bar');
+ const text3 = $createTextNode('Baz');
+ text3.setMode('token');
+ const text4 = $createTextNode('Qux');
+ block.append(text, innerBlock, text4);
+ innerBlock.append(text2, text3);
+
+ expect(block.getTextContent()).toEqual('FooBarBaz\n\nQux');
+
+ const innerInnerBlock = $createTestElementNode();
+ const text5 = $createTextNode('More');
+ text5.setMode('token');
+ const text6 = $createTextNode('Stuff');
+ innerInnerBlock.append(text5, text6);
+ innerBlock.append(innerInnerBlock);
+
+ expect(block.getTextContent()).toEqual('FooBarBazMoreStuff\n\nQux');
+
+ $getRoot().append(block);
+ });
+ });
+ });
+
+ describe('getTextContentSize()', () => {
+ test('basic', async () => {
+ await update(() => {
+ expect($getRoot().getFirstChild()!.getTextContentSize()).toBe(
+ $getRoot().getFirstChild()!.getTextContent().length,
+ );
+ });
+ });
+
+ test('child node getTextContentSize() can be overridden and is then reflected when calling the same method on parent node', async () => {
+ await update(() => {
+ const block = $createTestElementNode();
+ const text = $createTextNode('Foo');
+ text.getTextContentSize = () => 1;
+ block.append(text);
+
+ expect(block.getTextContentSize()).toBe(1);
+ });
+ });
+ });
+
+ describe('splice', () => {
+ let block: ElementNode;
+
+ beforeEach(async () => {
+ await update(() => {
+ block = $getRoot().getFirstChildOrThrow();
+ });
+ });
+
+ const BASE_INSERTIONS: Array<{
+ deleteCount: number;
+ deleteOnly: boolean | null | undefined;
+ expectedText: string;
+ name: string;
+ start: number;
+ }> = [
+ // Do nothing
+ {
+ deleteCount: 0,
+ deleteOnly: true,
+ expectedText: 'FooBarBaz',
+ name: 'Do nothing',
+ start: 0,
+ },
+ // Insert
+ {
+ deleteCount: 0,
+ deleteOnly: false,
+ expectedText: 'QuxQuuzFooBarBaz',
+ name: 'Insert in the beginning',
+ start: 0,
+ },
+ {
+ deleteCount: 0,
+ deleteOnly: false,
+ expectedText: 'FooQuxQuuzBarBaz',
+ name: 'Insert in the middle',
+ start: 1,
+ },
+ {
+ deleteCount: 0,
+ deleteOnly: false,
+ expectedText: 'FooBarBazQuxQuuz',
+ name: 'Insert in the end',
+ start: 3,
+ },
+ // Delete
+ {
+ deleteCount: 1,
+ deleteOnly: true,
+ expectedText: 'BarBaz',
+ name: 'Delete in the beginning',
+ start: 0,
+ },
+ {
+ deleteCount: 1,
+ deleteOnly: true,
+ expectedText: 'FooBaz',
+ name: 'Delete in the middle',
+ start: 1,
+ },
+ {
+ deleteCount: 1,
+ deleteOnly: true,
+ expectedText: 'FooBar',
+ name: 'Delete in the end',
+ start: 2,
+ },
+ {
+ deleteCount: 3,
+ deleteOnly: true,
+ expectedText: '',
+ name: 'Delete all',
+ start: 0,
+ },
+ // Replace
+ {
+ deleteCount: 1,
+ deleteOnly: false,
+ expectedText: 'QuxQuuzBarBaz',
+ name: 'Replace in the beginning',
+ start: 0,
+ },
+ {
+ deleteCount: 1,
+ deleteOnly: false,
+ expectedText: 'FooQuxQuuzBaz',
+ name: 'Replace in the middle',
+ start: 1,
+ },
+ {
+ deleteCount: 1,
+ deleteOnly: false,
+ expectedText: 'FooBarQuxQuuz',
+ name: 'Replace in the end',
+ start: 2,
+ },
+ {
+ deleteCount: 3,
+ deleteOnly: false,
+ expectedText: 'QuxQuuz',
+ name: 'Replace all',
+ start: 0,
+ },
+ ];
+
+ BASE_INSERTIONS.forEach((testCase) => {
+ it(`Plain text: ${testCase.name}`, async () => {
+ await update(() => {
+ block.splice(
+ testCase.start,
+ testCase.deleteCount,
+ testCase.deleteOnly
+ ? []
+ : [$createTextNode('Qux'), $createTextNode('Quuz')],
+ );
+
+ expect(block.getTextContent()).toEqual(testCase.expectedText);
+ });
+ });
+ });
+
+ let nodes: Record<string, LexicalNode> = {};
+
+ const NESTED_ELEMENTS_TESTS: Array<{
+ deleteCount: number;
+ deleteOnly?: boolean;
+ expectedSelection: () => {
+ anchor: {
+ key: string;
+ offset: number;
+ type: string;
+ };
+ focus: {
+ key: string;
+ offset: number;
+ type: string;
+ };
+ };
+ expectedText: string;
+ name: string;
+ start: number;
+ }> = [
+ {
+ deleteCount: 0,
+ deleteOnly: true,
+ expectedSelection: () => {
+ return {
+ anchor: {
+ key: nodes.nestedText1.__key,
+ offset: 1,
+ type: 'text',
+ },
+ focus: {
+ key: nodes.nestedText1.__key,
+ offset: 1,
+ type: 'text',
+ },
+ };
+ },
+ expectedText: 'FooWiz\n\nFuz\n\nBar',
+ name: 'Do nothing',
+ start: 1,
+ },
+ {
+ deleteCount: 1,
+ deleteOnly: true,
+ expectedSelection: () => {
+ return {
+ anchor: {
+ key: nodes.text1.__key,
+ offset: 3,
+ type: 'text',
+ },
+ focus: {
+ key: nodes.text1.__key,
+ offset: 3,
+ type: 'text',
+ },
+ };
+ },
+ expectedText: 'FooFuz\n\nBar',
+ name: 'Delete selected element (selection moves to the previous)',
+ start: 1,
+ },
+ {
+ deleteCount: 1,
+ expectedSelection: () => {
+ return {
+ anchor: {
+ key: nodes.text1.__key,
+ offset: 3,
+ type: 'text',
+ },
+ focus: {
+ key: nodes.text1.__key,
+ offset: 3,
+ type: 'text',
+ },
+ };
+ },
+ expectedText: 'FooQuxQuuzFuz\n\nBar',
+ name: 'Replace selected element (selection moves to the previous)',
+ start: 1,
+ },
+ {
+ deleteCount: 2,
+ deleteOnly: true,
+ expectedSelection: () => {
+ return {
+ anchor: {
+ key: nodes.nestedText2.__key,
+ offset: 0,
+ type: 'text',
+ },
+ focus: {
+ key: nodes.nestedText2.__key,
+ offset: 0,
+ type: 'text',
+ },
+ };
+ },
+ expectedText: 'Fuz\n\nBar',
+ name: 'Delete selected with previous element (selection moves to the next)',
+ start: 0,
+ },
+ {
+ deleteCount: 4,
+ deleteOnly: true,
+ expectedSelection: () => {
+ return {
+ anchor: {
+ key: block.__key,
+ offset: 0,
+ type: 'element',
+ },
+ focus: {
+ key: block.__key,
+ offset: 0,
+ type: 'element',
+ },
+ };
+ },
+ expectedText: '',
+ name: 'Delete selected with all siblings (selection moves up to the element)',
+ start: 0,
+ },
+ ];
+
+ NESTED_ELEMENTS_TESTS.forEach((testCase) => {
+ it(`Nested elements: ${testCase.name}`, async () => {
+ await update(() => {
+ const text1 = $createTextNode('Foo');
+ const text2 = $createTextNode('Bar');
+
+ const nestedBlock1 = $createTestElementNode();
+ const nestedText1 = $createTextNode('Wiz');
+ nestedBlock1.append(nestedText1);
+
+ const nestedBlock2 = $createTestElementNode();
+ const nestedText2 = $createTextNode('Fuz');
+ nestedBlock2.append(nestedText2);
+
+ block.clear();
+ block.append(text1, nestedBlock1, nestedBlock2, text2);
+ nestedText1.select(1, 1);
+
+ expect(block.getTextContent()).toEqual('FooWiz\n\nFuz\n\nBar');
+
+ nodes = {
+ nestedBlock1,
+ nestedBlock2,
+ nestedText1,
+ nestedText2,
+ text1,
+ text2,
+ };
+ });
+
+ await update(() => {
+ block.splice(
+ testCase.start,
+ testCase.deleteCount,
+ testCase.deleteOnly
+ ? []
+ : [$createTextNode('Qux'), $createTextNode('Quuz')],
+ );
+ });
+
+ await update(() => {
+ expect(block.getTextContent()).toEqual(testCase.expectedText);
+
+ const selection = $getSelection();
+ const expectedSelection = testCase.expectedSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect({
+ key: selection.anchor.key,
+ offset: selection.anchor.offset,
+ type: selection.anchor.type,
+ }).toEqual(expectedSelection.anchor);
+ expect({
+ key: selection.focus.key,
+ offset: selection.focus.offset,
+ type: selection.focus.type,
+ }).toEqual(expectedSelection.focus);
+ });
+ });
+ });
+
+ it('Running transforms for inserted nodes, their previous siblings and new siblings', async () => {
+ const transforms = new Set();
+ const expectedTransforms: string[] = [];
+
+ const removeTransform = editor.registerNodeTransform(TextNode, (node) => {
+ transforms.add(node.__key);
+ });
+
+ await update(() => {
+ const anotherBlock = $createTestElementNode();
+ const text1 = $createTextNode('1');
+ // Prevent text nodes from combining
+ const text2 = $createTextNode('2');
+ text2.setMode('segmented');
+ const text3 = $createTextNode('3');
+ anotherBlock.append(text1, text2, text3);
+ $getRoot().append(anotherBlock);
+
+ // Expect inserted node, its old siblings and new siblings to receive
+ // transformer calls
+ expectedTransforms.push(
+ text1.__key,
+ text2.__key,
+ text3.__key,
+ block.getChildAtIndex(0)!.__key,
+ block.getChildAtIndex(1)!.__key,
+ );
+ });
+
+ await update(() => {
+ block.splice(1, 0, [
+ $getRoot().getLastChild<ElementNode>()!.getChildAtIndex(1)!,
+ ]);
+ });
+
+ removeTransform();
+
+ await update(() => {
+ expect(block.getTextContent()).toEqual('Foo2BarBaz');
+ expectedTransforms.forEach((key) => {
+ expect(transforms).toContain(key);
+ });
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createTextNode,
+ $getNodeByKey,
+ $getRoot,
+ $isElementNode,
+} from 'lexical';
+
+import {
+ $createTestElementNode,
+ generatePermutations,
+ initializeUnitTest,
+ invariant,
+} from '../../../__tests__/utils';
+
+describe('LexicalGC tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('RootNode.clear() with a child and subchild', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode('foo')),
+ );
+ });
+ expect(editor.getEditorState()._nodeMap.size).toBe(3);
+ await editor.update(() => {
+ $getRoot().clear();
+ });
+ expect(editor.getEditorState()._nodeMap.size).toBe(1);
+ });
+
+ test('RootNode.clear() with a child and three subchildren', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const text1 = $createTextNode('foo');
+ const text2 = $createTextNode('bar').toggleUnmergeable();
+ const text3 = $createTextNode('zzz').toggleUnmergeable();
+ const paragraph = $createParagraphNode();
+ paragraph.append(text1, text2, text3);
+ $getRoot().append(paragraph);
+ });
+ expect(editor.getEditorState()._nodeMap.size).toBe(5);
+ await editor.update(() => {
+ $getRoot().clear();
+ });
+ expect(editor.getEditorState()._nodeMap.size).toBe(1);
+ });
+
+ for (let i = 0; i < 3; i++) {
+ test(`RootNode.clear() with a child and three subchildren, subchild ${i} removed first`, async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const text1 = $createTextNode('foo'); // 1
+ const text2 = $createTextNode('bar').toggleUnmergeable(); // 2
+ const text3 = $createTextNode('zzz').toggleUnmergeable(); // 3
+ const paragraph = $createParagraphNode(); // 4
+ paragraph.append(text1, text2, text3);
+ $getRoot().append(paragraph);
+ });
+ expect(editor.getEditorState()._nodeMap.size).toBe(5);
+ await editor.update(() => {
+ const root = $getRoot();
+ const firstChild = root.getFirstChild();
+ invariant($isElementNode(firstChild));
+ const subchild = firstChild.getChildAtIndex(i)!;
+ expect(subchild.getTextContent()).toBe(['foo', 'bar', 'zzz'][i]);
+ subchild.remove();
+ root.clear();
+ });
+ expect(editor.getEditorState()._nodeMap.size).toEqual(1);
+ });
+ }
+
+ const permutations2 = generatePermutations<string>(
+ ['1', '2', '3', '4', '5', '6'],
+ 2,
+ );
+ for (let i = 0; i < permutations2.length; i++) {
+ const removeKeys = permutations2[i];
+ /**
+ * R
+ * P
+ * T TE T
+ * T T
+ */
+ test(`RootNode.clear() with a complex tree, nodes ${removeKeys.toString()} removed first`, async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const testElement = $createTestElementNode(); // 1
+ const testElementText1 = $createTextNode('te1').toggleUnmergeable(); // 2
+ const testElementText2 = $createTextNode('te2').toggleUnmergeable(); // 3
+ const text1 = $createTextNode('a').toggleUnmergeable(); // 4
+ const text2 = $createTextNode('b').toggleUnmergeable(); // 5
+ const paragraph = $createParagraphNode(); // 6
+ testElement.append(testElementText1, testElementText2);
+ paragraph.append(text1, testElement, text2);
+ $getRoot().append(paragraph);
+ });
+ expect(editor.getEditorState()._nodeMap.size).toBe(7);
+ await editor.update(() => {
+ for (const key of removeKeys) {
+ const node = $getNodeByKey(String(key))!;
+ node.remove();
+ }
+ $getRoot().clear();
+ });
+ expect(editor.getEditorState()._nodeMap.size).toEqual(1);
+ });
+ }
+ });
+});
--- /dev/null
+/**
+ * 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 {$createLineBreakNode, $isLineBreakNode} from 'lexical';
+
+import {initializeUnitTest} from '../../../__tests__/utils';
+
+describe('LexicalLineBreakNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('LineBreakNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const lineBreakNode = $createLineBreakNode();
+
+ expect(lineBreakNode.getType()).toEqual('linebreak');
+ expect(lineBreakNode.getTextContent()).toEqual('\n');
+ });
+ });
+
+ test('LineBreakNode.exportJSON() should return and object conforming to the expected schema', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = $createLineBreakNode();
+
+ // If you broke this test, you changed the public interface of a
+ // serialized Lexical Core Node. Please ensure the correct adapter
+ // logic is in place in the corresponding importJSON method
+ // to accomodate these changes.
+ expect(node.exportJSON()).toStrictEqual({
+ type: 'linebreak',
+ version: 1,
+ });
+ });
+ });
+
+ test('LineBreakNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const lineBreakNode = $createLineBreakNode();
+ const element = lineBreakNode.createDOM();
+
+ expect(element.outerHTML).toBe('<br>');
+ });
+ });
+
+ test('LineBreakNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const lineBreakNode = $createLineBreakNode();
+
+ expect(lineBreakNode.updateDOM()).toBe(false);
+ });
+ });
+
+ test('LineBreakNode.$isLineBreakNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const lineBreakNode = $createLineBreakNode();
+
+ expect($isLineBreakNode(lineBreakNode)).toBe(true);
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $getRoot,
+ $isParagraphNode,
+ ParagraphNode,
+ RangeSelection,
+} from 'lexical';
+
+import {initializeUnitTest} from '../../../__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ paragraph: 'my-paragraph-class',
+ },
+});
+
+describe('LexicalParagraphNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('ParagraphNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = new ParagraphNode();
+
+ expect(paragraphNode.getType()).toBe('paragraph');
+ expect(paragraphNode.getTextContent()).toBe('');
+ });
+ expect(() => new ParagraphNode()).toThrow();
+ });
+
+ test('ParagraphNode.exportJSON() should return and object conforming to the expected schema', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = $createParagraphNode();
+
+ // If you broke this test, you changed the public interface of a
+ // serialized Lexical Core Node. Please ensure the correct adapter
+ // logic is in place in the corresponding importJSON method
+ // to accomodate these changes.
+ expect(node.exportJSON()).toStrictEqual({
+ children: [],
+ direction: null,
+ format: '',
+ indent: 0,
+ textFormat: 0,
+ textStyle: '',
+ type: 'paragraph',
+ version: 1,
+ });
+ });
+ });
+
+ test('ParagraphNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = new ParagraphNode();
+
+ expect(paragraphNode.createDOM(editorConfig).outerHTML).toBe(
+ '<p class="my-paragraph-class"></p>',
+ );
+ expect(
+ paragraphNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<p></p>');
+ });
+ });
+
+ test('ParagraphNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = new ParagraphNode();
+ const domElement = paragraphNode.createDOM(editorConfig);
+
+ expect(domElement.outerHTML).toBe('<p class="my-paragraph-class"></p>');
+
+ const newParagraphNode = new ParagraphNode();
+ const result = newParagraphNode.updateDOM(
+ paragraphNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe('<p class="my-paragraph-class"></p>');
+ });
+ });
+
+ test('ParagraphNode.insertNewAfter()', async () => {
+ const {editor} = testEnv;
+ let paragraphNode: ParagraphNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ paragraphNode = new ParagraphNode();
+ root.append(paragraphNode);
+ });
+
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
+ );
+
+ await editor.update(() => {
+ const selection = paragraphNode.select();
+ const result = paragraphNode.insertNewAfter(
+ selection as RangeSelection,
+ false,
+ );
+ expect(result).toBeInstanceOf(ParagraphNode);
+ expect(result.getDirection()).toEqual(paragraphNode.getDirection());
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
+ );
+ });
+ });
+
+ test('$createParagraphNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = new ParagraphNode();
+ const createdParagraphNode = $createParagraphNode();
+
+ expect(paragraphNode.__type).toEqual(createdParagraphNode.__type);
+ expect(paragraphNode.__parent).toEqual(createdParagraphNode.__parent);
+ expect(paragraphNode.__key).not.toEqual(createdParagraphNode.__key);
+ });
+ });
+
+ test('$isParagraphNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = new ParagraphNode();
+
+ expect($isParagraphNode(paragraphNode)).toBe(true);
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ $isRootNode,
+ ElementNode,
+ RootNode,
+ TextNode,
+} from 'lexical';
+
+import {
+ $createTestDecoratorNode,
+ $createTestElementNode,
+ $createTestInlineElementNode,
+ initializeUnitTest,
+} from '../../../__tests__/utils';
+import {$createRootNode} from '../../LexicalRootNode';
+
+describe('LexicalRootNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ let rootNode: RootNode;
+
+ function expectRootTextContentToBe(text: string): void {
+ const {editor} = testEnv;
+ editor.getEditorState().read(() => {
+ const root = $getRoot();
+
+ expect(root.__cachedText).toBe(text);
+
+ // Copy root to remove __cachedText because it's frozen
+ const rootCopy = Object.assign({}, root);
+ rootCopy.__cachedText = null;
+ Object.setPrototypeOf(rootCopy, Object.getPrototypeOf(root));
+
+ expect(rootCopy.getTextContent()).toBe(text);
+ });
+ }
+
+ beforeEach(async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ rootNode = $createRootNode();
+ });
+ });
+
+ test('RootNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ expect(rootNode).toStrictEqual($createRootNode());
+ expect(rootNode.getType()).toBe('root');
+ expect(rootNode.getTextContent()).toBe('');
+ });
+ });
+
+ test('RootNode.exportJSON() should return and object conforming to the expected schema', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const node = $createRootNode();
+
+ // If you broke this test, you changed the public interface of a
+ // serialized Lexical Core Node. Please ensure the correct adapter
+ // logic is in place in the corresponding importJSON method
+ // to accomodate these changes.
+ expect(node.exportJSON()).toStrictEqual({
+ children: [],
+ direction: null,
+ format: '',
+ indent: 0,
+ type: 'root',
+ version: 1,
+ });
+ });
+ });
+
+ test('RootNode.clone()', async () => {
+ const rootNodeClone = (rootNode.constructor as typeof RootNode).clone();
+
+ expect(rootNodeClone).not.toBe(rootNode);
+ expect(rootNodeClone).toStrictEqual(rootNode);
+ });
+
+ test('RootNode.createDOM()', async () => {
+ // @ts-expect-error
+ expect(() => rootNode.createDOM()).toThrow();
+ });
+
+ test('RootNode.updateDOM()', async () => {
+ // @ts-expect-error
+ expect(rootNode.updateDOM()).toBe(false);
+ });
+
+ test('RootNode.isAttached()', async () => {
+ expect(rootNode.isAttached()).toBe(true);
+ });
+
+ test('RootNode.isRootNode()', () => {
+ expect($isRootNode(rootNode)).toBe(true);
+ });
+
+ test('Cached getTextContent with decorators', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.append($createTestDecoratorNode());
+ });
+
+ expect(
+ editor.getEditorState().read(() => {
+ return $getRoot().getTextContent();
+ }),
+ ).toBe('Hello world');
+ });
+
+ test('RootNode.clear() to handle selection update', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ const text = $createTextNode('Hello');
+ paragraph.append(text);
+ text.select();
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ root.clear();
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.getNode()).toBe(root);
+ expect(selection.focus.getNode()).toBe(root);
+ });
+ });
+
+ test('RootNode is selected when its only child removed', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ const text = $createTextNode('Hello');
+ paragraph.append(text);
+ text.select();
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ root.getFirstChild()!.remove();
+ });
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.getNode()).toBe(root);
+ expect(selection.focus.getNode()).toBe(root);
+ });
+ });
+
+ test('RootNode __cachedText', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ $getRoot().append($createParagraphNode());
+ });
+
+ expectRootTextContentToBe('');
+
+ await editor.update(() => {
+ const firstParagraph = $getRoot().getFirstChild<ElementNode>()!;
+
+ firstParagraph.append($createTextNode('first line'));
+ });
+
+ expectRootTextContentToBe('first line');
+
+ await editor.update(() => {
+ $getRoot().append($createParagraphNode());
+ });
+
+ expectRootTextContentToBe('first line\n\n');
+
+ await editor.update(() => {
+ const secondParagraph = $getRoot().getLastChild<ElementNode>()!;
+
+ secondParagraph.append($createTextNode('second line'));
+ });
+
+ expectRootTextContentToBe('first line\n\nsecond line');
+
+ await editor.update(() => {
+ $getRoot().append($createParagraphNode());
+ });
+
+ expectRootTextContentToBe('first line\n\nsecond line\n\n');
+
+ await editor.update(() => {
+ const thirdParagraph = $getRoot().getLastChild<ElementNode>()!;
+ thirdParagraph.append($createTextNode('third line'));
+ });
+
+ expectRootTextContentToBe('first line\n\nsecond line\n\nthird line');
+
+ await editor.update(() => {
+ const secondParagraph = $getRoot().getChildAtIndex<ElementNode>(1)!;
+ const secondParagraphText = secondParagraph.getFirstChild<TextNode>()!;
+ secondParagraphText.setTextContent('second line!');
+ });
+
+ expectRootTextContentToBe('first line\n\nsecond line!\n\nthird line');
+ });
+
+ test('RootNode __cachedText (empty paragraph)', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ $getRoot().append($createParagraphNode(), $createParagraphNode());
+ });
+
+ expectRootTextContentToBe('\n\n');
+ });
+
+ test('RootNode __cachedText (inlines)', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraph = $createParagraphNode();
+ $getRoot().append(paragraph);
+ paragraph.append(
+ $createTextNode('a'),
+ $createTestElementNode(),
+ $createTextNode('b'),
+ $createTestInlineElementNode(),
+ $createTextNode('c'),
+ );
+ });
+
+ expectRootTextContentToBe('a\n\nbc');
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $insertDataTransferForPlainText,
+ $insertDataTransferForRichText,
+} from '@lexical/clipboard';
+import {$createListItemNode, $createListNode} from '@lexical/list';
+import {registerTabIndentation} from '@lexical/react/LexicalTabIndentationPlugin';
+import {$createHeadingNode, registerRichText} from '@lexical/rich-text';
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTabNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $insertNodes,
+ $isElementNode,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+ KEY_TAB_COMMAND,
+} from 'lexical';
+
+import {
+ DataTransferMock,
+ initializeUnitTest,
+ invariant,
+} from '../../../__tests__/utils';
+
+describe('LexicalTabNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ beforeEach(async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.select();
+ });
+ });
+
+ test('can paste plain text with tabs and newlines in plain text', async () => {
+ const {editor} = testEnv;
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData('text/plain', 'hello\tworld\nhello\tworld');
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
+ $insertDataTransferForPlainText(dataTransfer, selection);
+ });
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span><br><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
+ );
+ });
+
+ test('can paste plain text with tabs and newlines in rich text', async () => {
+ const {editor} = testEnv;
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData('text/plain', 'hello\tworld\nhello\tworld');
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p><p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
+ );
+ });
+
+ // TODO fixme
+ // test('can paste HTML with tabs and new lines #4429', async () => {
+ // const {editor} = testEnv;
+ // const dataTransfer = new DataTransferMock();
+ // // https://codepen.io/zurfyx/pen/bGmrzMR
+ // dataTransfer.setData(
+ // 'text/html',
+ // `<meta charset='utf-8'><span style="color: rgb(0, 0, 0); font-family: Times; font-size: medium; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: pre-wrap; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">hello world
+ // hello world</span>`,
+ // );
+ // await editor.update(() => {
+ // const selection = $getSelection();
+ // invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
+ // $insertDataTransferForRichText(dataTransfer, selection, editor);
+ // });
+ // expect(testEnv.innerHTML).toBe(
+ // '<p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span><br><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
+ // );
+ // });
+
+ test('can paste HTML with tabs and new lines (2)', async () => {
+ const {editor} = testEnv;
+ const dataTransfer = new DataTransferMock();
+ // GDoc 2-liner hello\tworld (like previous test)
+ dataTransfer.setData(
+ 'text/html',
+ `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-123"><p dir="ltr" style="line-height:1.38;margin-left: 36pt;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Hello</span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" style="white-space:pre;"> </span></span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">world</span></p><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Hello</span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" style="white-space:pre;"> </span></span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">world</span></b>`,
+ );
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p><p dir="ltr"><span data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
+ );
+ });
+
+ test('element indents when selection at the start of the block', async () => {
+ const {editor} = testEnv;
+ registerRichText(editor);
+ registerTabIndentation(editor);
+ await editor.update(() => {
+ const selection = $getSelection()!;
+ selection.insertText('foo');
+ $getRoot().selectStart();
+ });
+ await editor.dispatchCommand(
+ KEY_TAB_COMMAND,
+ new KeyboardEvent('keydown'),
+ );
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr" style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">foo</span></p>',
+ );
+ });
+
+ test('elements indent when selection spans across multiple blocks', async () => {
+ const {editor} = testEnv;
+ registerRichText(editor);
+ registerTabIndentation(editor);
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild();
+ invariant($isElementNode(paragraph));
+ const heading = $createHeadingNode('h1');
+ const list = $createListNode('number');
+ const listItem = $createListItemNode();
+ const paragraphText = $createTextNode('foo');
+ const headingText = $createTextNode('bar');
+ const listItemText = $createTextNode('xyz');
+ root.append(heading, list);
+ paragraph.append(paragraphText);
+ heading.append(headingText);
+ list.append(listItem);
+ listItem.append(listItemText);
+ const selection = $createRangeSelection();
+ selection.focus.set(paragraphText.getKey(), 1, 'text');
+ selection.anchor.set(listItemText.getKey(), 1, 'text');
+ $setSelection(selection);
+ });
+ await editor.dispatchCommand(
+ KEY_TAB_COMMAND,
+ new KeyboardEvent('keydown'),
+ );
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr" style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">foo</span></p><h1 dir="ltr" style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">bar</span></h1><ol><li value="1"><ol><li value="1" dir="ltr"><span data-lexical-text="true">xyz</span></li></ol></li></ol>',
+ );
+ });
+
+ test('element tabs when selection is not at the start (1)', async () => {
+ const {editor} = testEnv;
+ registerRichText(editor);
+ registerTabIndentation(editor);
+ await editor.update(() => {
+ $getSelection()!.insertText('foo');
+ });
+ await editor.dispatchCommand(
+ KEY_TAB_COMMAND,
+ new KeyboardEvent('keydown'),
+ );
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">\t</span></p>',
+ );
+ });
+
+ test('element tabs when selection is not at the start (2)', async () => {
+ const {editor} = testEnv;
+ registerRichText(editor);
+ registerTabIndentation(editor);
+ await editor.update(() => {
+ $getSelection()!.insertText('foo');
+ const textNode = $getRoot().getLastDescendant();
+ invariant($isTextNode(textNode));
+ textNode.select(1, 1);
+ });
+ await editor.dispatchCommand(
+ KEY_TAB_COMMAND,
+ new KeyboardEvent('keydown'),
+ );
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">f</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">oo</span></p>',
+ );
+ });
+
+ test('element tabs when selection is not at the start (3)', async () => {
+ const {editor} = testEnv;
+ registerRichText(editor);
+ registerTabIndentation(editor);
+ await editor.update(() => {
+ $getSelection()!.insertText('foo');
+ const textNode = $getRoot().getLastDescendant();
+ invariant($isTextNode(textNode));
+ textNode.select(1, 2);
+ });
+ await editor.dispatchCommand(
+ KEY_TAB_COMMAND,
+ new KeyboardEvent('keydown'),
+ );
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">f</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">o</span></p>',
+ );
+ });
+
+ test('elements tabs when selection is not at the start and overlaps another tab', async () => {
+ const {editor} = testEnv;
+ registerRichText(editor);
+ registerTabIndentation(editor);
+ await editor.update(() => {
+ $getSelection()!.insertRawText('hello\tworld');
+ const root = $getRoot();
+ const firstTextNode = root.getFirstDescendant();
+ const lastTextNode = root.getLastDescendant();
+ const selection = $createRangeSelection();
+ selection.anchor.set(firstTextNode!.getKey(), 'hell'.length, 'text');
+ selection.focus.set(lastTextNode!.getKey(), 'wo'.length, 'text');
+ $setSelection(selection);
+ });
+ await editor.dispatchCommand(
+ KEY_TAB_COMMAND,
+ new KeyboardEvent('keydown'),
+ );
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">hell</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">rld</span></p>',
+ );
+ });
+
+ test('can type between two (leaf nodes) canInsertBeforeAfter false', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const tab1 = $createTabNode();
+ const tab2 = $createTabNode();
+ $insertNodes([tab1, tab2]);
+ tab1.select(1, 1);
+ $getSelection()!.insertText('f');
+ });
+ expect(testEnv.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">\t</span><span data-lexical-text="true">f</span><span data-lexical-text="true">\t</span></p>',
+ );
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createTextNode,
+ $getNodeByKey,
+ $getRoot,
+ $getSelection,
+ $isNodeSelection,
+ $isRangeSelection,
+ ElementNode,
+ LexicalEditor,
+ ParagraphNode,
+ TextFormatType,
+ TextModeType,
+ TextNode,
+} from 'lexical';
+import * as React from 'react';
+import {createRef, useEffect, useMemo} from 'react';
+import {createRoot} from 'react-dom/client';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+import {
+ $createTestSegmentedNode,
+ createTestEditor,
+} from '../../../__tests__/utils';
+import {
+ IS_BOLD,
+ IS_CODE,
+ IS_HIGHLIGHT,
+ IS_ITALIC,
+ IS_STRIKETHROUGH,
+ IS_SUBSCRIPT,
+ IS_SUPERSCRIPT,
+ IS_UNDERLINE,
+} from '../../../LexicalConstants';
+import {
+ $getCompositionKey,
+ $setCompositionKey,
+ getEditorStateTextContent,
+} from '../../../LexicalUtils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ text: {
+ bold: 'my-bold-class',
+ code: 'my-code-class',
+ highlight: 'my-highlight-class',
+ italic: 'my-italic-class',
+ strikethrough: 'my-strikethrough-class',
+ underline: 'my-underline-class',
+ underlineStrikethrough: 'my-underline-strikethrough-class',
+ },
+ },
+});
+
+describe('LexicalTextNode tests', () => {
+ let container: HTMLElement;
+
+ beforeEach(async () => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+
+ await init();
+ });
+ afterEach(() => {
+ document.body.removeChild(container);
+ // @ts-ignore
+ container = null;
+ });
+
+ async function update(fn: () => void) {
+ editor.update(fn);
+ return Promise.resolve().then();
+ }
+
+ function useLexicalEditor(rootElementRef: React.RefObject<HTMLDivElement>) {
+ const editor = useMemo(() => createTestEditor(editorConfig), []);
+
+ useEffect(() => {
+ const rootElement = rootElementRef.current;
+
+ editor.setRootElement(rootElement);
+ }, [rootElementRef, editor]);
+
+ return editor;
+ }
+
+ let editor: LexicalEditor;
+
+ async function init() {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase() {
+ editor = useLexicalEditor(ref);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ createRoot(container).render(<TestBase />);
+ });
+
+ // Insert initial block
+ await update(() => {
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode();
+ text.toggleUnmergeable();
+ paragraph.append(text);
+ $getRoot().append(paragraph);
+ });
+ }
+
+ describe('exportJSON()', () => {
+ test('should return and object conforming to the expected schema', async () => {
+ await update(() => {
+ const node = $createTextNode();
+
+ // If you broke this test, you changed the public interface of a
+ // serialized Lexical Core Node. Please ensure the correct adapter
+ // logic is in place in the corresponding importJSON method
+ // to accomodate these changes.
+
+ expect(node.exportJSON()).toStrictEqual({
+ detail: 0,
+ format: 0,
+ mode: 'normal',
+ style: '',
+ text: '',
+ type: 'text',
+ version: 1,
+ });
+ });
+ });
+ });
+
+ describe('root.getTextContent()', () => {
+ test('writable nodes', async () => {
+ let nodeKey: string;
+
+ await update(() => {
+ const textNode = $createTextNode('Text');
+ nodeKey = textNode.getKey();
+
+ expect(textNode.getTextContent()).toBe('Text');
+ expect(textNode.__text).toBe('Text');
+
+ $getRoot().getFirstChild<ElementNode>()!.append(textNode);
+ });
+
+ expect(
+ editor.getEditorState().read(() => {
+ const root = $getRoot();
+ return root.__cachedText;
+ }),
+ );
+ expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
+
+ // Make sure that the editor content is still set after further reconciliations
+ await update(() => {
+ $getNodeByKey(nodeKey)!.markDirty();
+ });
+ expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
+ });
+
+ test('prepend node', async () => {
+ await update(() => {
+ const textNode = $createTextNode('World').toggleUnmergeable();
+ $getRoot().getFirstChild<ElementNode>()!.append(textNode);
+ });
+
+ await update(() => {
+ const textNode = $createTextNode('Hello ').toggleUnmergeable();
+ const previousTextNode = $getRoot()
+ .getFirstChild<ElementNode>()!
+ .getFirstChild()!;
+ previousTextNode.insertBefore(textNode);
+ });
+
+ expect(getEditorStateTextContent(editor.getEditorState())).toBe(
+ 'Hello World',
+ );
+ });
+ });
+
+ describe('setTextContent()', () => {
+ test('writable nodes', async () => {
+ await update(() => {
+ const textNode = $createTextNode('My new text node');
+ textNode.setTextContent('My newer text node');
+
+ expect(textNode.getTextContent()).toBe('My newer text node');
+ });
+ });
+ });
+
+ describe.each([
+ ['bold', IS_BOLD],
+ ['italic', IS_ITALIC],
+ ['strikethrough', IS_STRIKETHROUGH],
+ ['underline', IS_UNDERLINE],
+ ['code', IS_CODE],
+ ['subscript', IS_SUBSCRIPT],
+ ['superscript', IS_SUPERSCRIPT],
+ ['highlight', IS_HIGHLIGHT],
+ ] as const)('%s flag', (formatFlag: TextFormatType, stateFormat: number) => {
+ const flagPredicate = (node: TextNode) => node.hasFormat(formatFlag);
+ const flagToggle = (node: TextNode) => node.toggleFormat(formatFlag);
+
+ test(`getFormatFlags(${formatFlag})`, async () => {
+ await update(() => {
+ const root = $getRoot();
+ const paragraphNode = root.getFirstChild<ParagraphNode>()!;
+ const textNode = paragraphNode.getFirstChild<TextNode>()!;
+ const newFormat = textNode.getFormatFlags(formatFlag, null);
+
+ expect(newFormat).toBe(stateFormat);
+
+ textNode.setFormat(newFormat);
+ const newFormat2 = textNode.getFormatFlags(formatFlag, null);
+
+ expect(newFormat2).toBe(0);
+ });
+ });
+
+ test(`predicate for ${formatFlag}`, async () => {
+ await update(() => {
+ const root = $getRoot();
+ const paragraphNode = root.getFirstChild<ParagraphNode>()!;
+ const textNode = paragraphNode.getFirstChild<TextNode>()!;
+
+ textNode.setFormat(stateFormat);
+
+ expect(flagPredicate(textNode)).toBe(true);
+ });
+ });
+
+ test(`toggling for ${formatFlag}`, async () => {
+ // Toggle method hasn't been implemented for this flag.
+ if (flagToggle === null) {
+ return;
+ }
+
+ await update(() => {
+ const root = $getRoot();
+ const paragraphNode = root.getFirstChild<ParagraphNode>()!;
+ const textNode = paragraphNode.getFirstChild<TextNode>()!;
+
+ expect(flagPredicate(textNode)).toBe(false);
+
+ flagToggle(textNode);
+
+ expect(flagPredicate(textNode)).toBe(true);
+
+ flagToggle(textNode);
+
+ expect(flagPredicate(textNode)).toBe(false);
+ });
+ });
+ });
+
+ test('setting subscript clears superscript', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ paragraphNode.append(textNode);
+ $getRoot().append(paragraphNode);
+ textNode.toggleFormat('superscript');
+ textNode.toggleFormat('subscript');
+ expect(textNode.hasFormat('subscript')).toBe(true);
+ expect(textNode.hasFormat('superscript')).toBe(false);
+ });
+ });
+
+ test('setting superscript clears subscript', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ paragraphNode.append(textNode);
+ $getRoot().append(paragraphNode);
+ textNode.toggleFormat('subscript');
+ textNode.toggleFormat('superscript');
+ expect(textNode.hasFormat('superscript')).toBe(true);
+ expect(textNode.hasFormat('subscript')).toBe(false);
+ });
+ });
+
+ test('clearing subscript does not set superscript', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ paragraphNode.append(textNode);
+ $getRoot().append(paragraphNode);
+ textNode.toggleFormat('subscript');
+ textNode.toggleFormat('subscript');
+ expect(textNode.hasFormat('subscript')).toBe(false);
+ expect(textNode.hasFormat('superscript')).toBe(false);
+ });
+ });
+
+ test('clearing superscript does not set subscript', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ paragraphNode.append(textNode);
+ $getRoot().append(paragraphNode);
+ textNode.toggleFormat('superscript');
+ textNode.toggleFormat('superscript');
+ expect(textNode.hasFormat('superscript')).toBe(false);
+ expect(textNode.hasFormat('subscript')).toBe(false);
+ });
+ });
+
+ test('selectPrevious()', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ const textNode2 = $createTextNode('Goodbye Earth');
+ paragraphNode.append(textNode, textNode2);
+ $getRoot().append(paragraphNode);
+
+ let selection = textNode2.selectPrevious();
+
+ expect(selection.anchor.getNode()).toBe(textNode);
+ expect(selection.anchor.offset).toBe(11);
+ expect(selection.focus.getNode()).toBe(textNode);
+ expect(selection.focus.offset).toBe(11);
+
+ selection = textNode.selectPrevious();
+
+ expect(selection.anchor.getNode()).toBe(paragraphNode);
+ expect(selection.anchor.offset).toBe(0);
+ });
+ });
+
+ test('selectNext()', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ const textNode2 = $createTextNode('Goodbye Earth');
+ paragraphNode.append(textNode, textNode2);
+ $getRoot().append(paragraphNode);
+ let selection = textNode.selectNext(1, 3);
+
+ if ($isNodeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.getNode()).toBe(textNode2);
+ expect(selection.anchor.offset).toBe(1);
+ expect(selection.focus.getNode()).toBe(textNode2);
+ expect(selection.focus.offset).toBe(3);
+
+ selection = textNode2.selectNext();
+
+ expect(selection.anchor.getNode()).toBe(paragraphNode);
+ expect(selection.anchor.offset).toBe(2);
+ });
+ });
+
+ describe('select()', () => {
+ test.each([
+ [
+ [2, 4],
+ [2, 4],
+ ],
+ [
+ [4, 2],
+ [4, 2],
+ ],
+ [
+ [undefined, 2],
+ [11, 2],
+ ],
+ [
+ [2, undefined],
+ [2, 11],
+ ],
+ [
+ [undefined, undefined],
+ [11, 11],
+ ],
+ ])(
+ 'select(...%p)',
+ async (
+ [anchorOffset, focusOffset],
+ [expectedAnchorOffset, expectedFocusOffset],
+ ) => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('Hello World');
+ paragraphNode.append(textNode);
+ $getRoot().append(paragraphNode);
+
+ const selection = textNode.select(anchorOffset, focusOffset);
+
+ expect(selection.focus.getNode()).toBe(textNode);
+ expect(selection.anchor.offset).toBe(expectedAnchorOffset);
+ expect(selection.focus.getNode()).toBe(textNode);
+ expect(selection.focus.offset).toBe(expectedFocusOffset);
+ });
+ },
+ );
+ });
+
+ describe('splitText()', () => {
+ test('convert segmented node into plain text', async () => {
+ await update(() => {
+ const segmentedNode = $createTestSegmentedNode('Hello World');
+ const paragraphNode = $createParagraphNode();
+ paragraphNode.append(segmentedNode);
+
+ const [middle, next] = segmentedNode.splitText(5);
+
+ const children = paragraphNode.getAllTextNodes();
+ expect(paragraphNode.getTextContent()).toBe('Hello World');
+ expect(children[0].isSimpleText()).toBe(true);
+ expect(children[0].getTextContent()).toBe('Hello');
+ expect(middle).toBe(children[0]);
+ expect(next).toBe(children[1]);
+ });
+ });
+ test.each([
+ ['a', [], ['a']],
+ ['a', [1], ['a']],
+ ['a', [5], ['a']],
+ ['Hello World', [], ['Hello World']],
+ ['Hello World', [3], ['Hel', 'lo World']],
+ ['Hello World', [3, 3], ['Hel', 'lo World']],
+ ['Hello World', [3, 7], ['Hel', 'lo W', 'orld']],
+ ['Hello World', [7, 3], ['Hel', 'lo W', 'orld']],
+ ['Hello World', [3, 7, 99], ['Hel', 'lo W', 'orld']],
+ ])(
+ '"%s" splitText(...%p)',
+ async (initialString, splitOffsets, splitStrings) => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode(initialString);
+ paragraphNode.append(textNode);
+
+ const splitNodes = textNode.splitText(...splitOffsets);
+
+ expect(paragraphNode.getChildren()).toHaveLength(splitStrings.length);
+ expect(splitNodes.map((node) => node.getTextContent())).toEqual(
+ splitStrings,
+ );
+ });
+ },
+ );
+
+ test('splitText moves composition key to last node', async () => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode('12345');
+ paragraphNode.append(textNode);
+ $setCompositionKey(textNode.getKey());
+
+ const [, splitNode2] = textNode.splitText(1);
+ expect($getCompositionKey()).toBe(splitNode2.getKey());
+ });
+ });
+
+ test.each([
+ [
+ 'Hello',
+ [4],
+ [3, 3],
+ {
+ anchorNodeIndex: 0,
+ anchorOffset: 3,
+ focusNodeIndex: 0,
+ focusOffset: 3,
+ },
+ ],
+ [
+ 'Hello',
+ [4],
+ [5, 5],
+ {
+ anchorNodeIndex: 1,
+ anchorOffset: 1,
+ focusNodeIndex: 1,
+ focusOffset: 1,
+ },
+ ],
+ [
+ 'Hello World',
+ [4],
+ [2, 7],
+ {
+ anchorNodeIndex: 0,
+ anchorOffset: 2,
+ focusNodeIndex: 1,
+ focusOffset: 3,
+ },
+ ],
+ [
+ 'Hello World',
+ [4],
+ [2, 4],
+ {
+ anchorNodeIndex: 0,
+ anchorOffset: 2,
+ focusNodeIndex: 0,
+ focusOffset: 4,
+ },
+ ],
+ [
+ 'Hello World',
+ [4],
+ [7, 2],
+ {
+ anchorNodeIndex: 1,
+ anchorOffset: 3,
+ focusNodeIndex: 0,
+ focusOffset: 2,
+ },
+ ],
+ [
+ 'Hello World',
+ [4, 6],
+ [2, 9],
+ {
+ anchorNodeIndex: 0,
+ anchorOffset: 2,
+ focusNodeIndex: 2,
+ focusOffset: 3,
+ },
+ ],
+ [
+ 'Hello World',
+ [4, 6],
+ [9, 2],
+ {
+ anchorNodeIndex: 2,
+ anchorOffset: 3,
+ focusNodeIndex: 0,
+ focusOffset: 2,
+ },
+ ],
+ [
+ 'Hello World',
+ [4, 6],
+ [9, 9],
+ {
+ anchorNodeIndex: 2,
+ anchorOffset: 3,
+ focusNodeIndex: 2,
+ focusOffset: 3,
+ },
+ ],
+ ])(
+ '"%s" splitText(...%p) with select(...%p)',
+ async (
+ initialString,
+ splitOffsets,
+ selectionOffsets,
+ {anchorNodeIndex, anchorOffset, focusNodeIndex, focusOffset},
+ ) => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode(initialString);
+ paragraphNode.append(textNode);
+ $getRoot().append(paragraphNode);
+
+ const selection = textNode.select(...selectionOffsets);
+ const childrenNodes = textNode.splitText(...splitOffsets);
+
+ expect(selection.anchor.getNode()).toBe(
+ childrenNodes[anchorNodeIndex],
+ );
+ expect(selection.anchor.offset).toBe(anchorOffset);
+ expect(selection.focus.getNode()).toBe(childrenNodes[focusNodeIndex]);
+ expect(selection.focus.offset).toBe(focusOffset);
+ });
+ },
+ );
+
+ test('with detached parent', async () => {
+ await update(() => {
+ const textNode = $createTextNode('foo');
+ const splits = textNode.splitText(1, 2);
+ expect(splits.map((split) => split.getTextContent())).toEqual([
+ 'f',
+ 'o',
+ 'o',
+ ]);
+ });
+ });
+ });
+
+ describe('createDOM()', () => {
+ test.each([
+ ['no formatting', 0, 'My text node', '<span>My text node</span>'],
+ [
+ 'bold',
+ IS_BOLD,
+ 'My text node',
+ '<strong class="my-bold-class">My text node</strong>',
+ ],
+ ['bold + empty', IS_BOLD, '', `<strong class="my-bold-class"></strong>`],
+ [
+ 'underline',
+ IS_UNDERLINE,
+ 'My text node',
+ '<span class="my-underline-class">My text node</span>',
+ ],
+ [
+ 'strikethrough',
+ IS_STRIKETHROUGH,
+ 'My text node',
+ '<span class="my-strikethrough-class">My text node</span>',
+ ],
+ [
+ 'highlight',
+ IS_HIGHLIGHT,
+ 'My text node',
+ '<mark><span class="my-highlight-class">My text node</span></mark>',
+ ],
+ [
+ 'italic',
+ IS_ITALIC,
+ 'My text node',
+ '<em class="my-italic-class">My text node</em>',
+ ],
+ [
+ 'code',
+ IS_CODE,
+ 'My text node',
+ '<code spellcheck="false"><span class="my-code-class">My text node</span></code>',
+ ],
+ [
+ 'underline + strikethrough',
+ IS_UNDERLINE | IS_STRIKETHROUGH,
+ 'My text node',
+ '<span class="my-underline-strikethrough-class">' +
+ 'My text node</span>',
+ ],
+ [
+ 'code + italic',
+ IS_CODE | IS_ITALIC,
+ 'My text node',
+ '<code spellcheck="false"><em class="my-code-class my-italic-class">My text node</em></code>',
+ ],
+ [
+ 'code + underline + strikethrough',
+ IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH,
+ 'My text node',
+ '<code spellcheck="false"><span class="my-underline-strikethrough-class my-code-class">' +
+ 'My text node</span></code>',
+ ],
+ [
+ 'highlight + italic',
+ IS_HIGHLIGHT | IS_ITALIC,
+ 'My text node',
+ '<mark><em class="my-highlight-class my-italic-class">My text node</em></mark>',
+ ],
+ [
+ 'code + underline + strikethrough + bold + italic',
+ IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC,
+ 'My text node',
+ '<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-italic-class">My text node</strong></code>',
+ ],
+ [
+ 'code + underline + strikethrough + bold + italic + highlight',
+ IS_CODE |
+ IS_UNDERLINE |
+ IS_STRIKETHROUGH |
+ IS_BOLD |
+ IS_ITALIC |
+ IS_HIGHLIGHT,
+ 'My text node',
+ '<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-highlight-class my-italic-class">My text node</strong></code>',
+ ],
+ ])('%s text format type', async (_type, format, contents, expectedHTML) => {
+ await update(() => {
+ const textNode = $createTextNode(contents);
+ textNode.setFormat(format);
+ const element = textNode.createDOM(editorConfig);
+
+ expect(element.outerHTML).toBe(expectedHTML);
+ });
+ });
+
+ describe('has parent node', () => {
+ test.each([
+ ['no formatting', 0, 'My text node', '<span>My text node</span>'],
+ ['no formatting + empty string', 0, '', `<span></span>`],
+ ])(
+ '%s text format type',
+ async (_type, format, contents, expectedHTML) => {
+ await update(() => {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode(contents);
+ textNode.setFormat(format);
+ paragraphNode.append(textNode);
+ const element = textNode.createDOM(editorConfig);
+
+ expect(element.outerHTML).toBe(expectedHTML);
+ });
+ },
+ );
+ });
+ });
+
+ describe('updateDOM()', () => {
+ test.each([
+ [
+ 'different tags',
+ {
+ format: IS_BOLD,
+ mode: 'normal',
+ text: 'My text node',
+ },
+ {
+ format: IS_ITALIC,
+ mode: 'normal',
+ text: 'My text node',
+ },
+ {
+ expectedHTML: null,
+ result: true,
+ },
+ ],
+ [
+ 'no change in tags',
+ {
+ format: IS_BOLD,
+ mode: 'normal',
+ text: 'My text node',
+ },
+ {
+ format: IS_BOLD,
+ mode: 'normal',
+ text: 'My text node',
+ },
+ {
+ expectedHTML: '<strong class="my-bold-class">My text node</strong>',
+ result: false,
+ },
+ ],
+ [
+ 'change in text',
+ {
+ format: IS_BOLD,
+ mode: 'normal',
+ text: 'My text node',
+ },
+ {
+ format: IS_BOLD,
+ mode: 'normal',
+ text: 'My new text node',
+ },
+ {
+ expectedHTML:
+ '<strong class="my-bold-class">My new text node</strong>',
+ result: false,
+ },
+ ],
+ [
+ 'removing code block',
+ {
+ format: IS_CODE | IS_BOLD,
+ mode: 'normal',
+ text: 'My text node',
+ },
+ {
+ format: IS_BOLD,
+ mode: 'normal',
+ text: 'My new text node',
+ },
+ {
+ expectedHTML: null,
+ result: true,
+ },
+ ],
+ ])(
+ '%s',
+ async (
+ _desc,
+ {text: prevText, mode: prevMode, format: prevFormat},
+ {text: nextText, mode: nextMode, format: nextFormat},
+ {result, expectedHTML},
+ ) => {
+ await update(() => {
+ const prevTextNode = $createTextNode(prevText);
+ prevTextNode.setMode(prevMode as TextModeType);
+ prevTextNode.setFormat(prevFormat);
+ const element = prevTextNode.createDOM(editorConfig);
+ const textNode = $createTextNode(nextText);
+ textNode.setMode(nextMode as TextModeType);
+ textNode.setFormat(nextFormat);
+
+ expect(textNode.updateDOM(prevTextNode, element, editorConfig)).toBe(
+ result,
+ );
+ // Only need to bother about DOM element contents if updateDOM()
+ // returns false.
+ if (!result) {
+ expect(element.outerHTML).toBe(expectedHTML);
+ }
+ });
+ },
+ );
+ });
+
+ test('mergeWithSibling', async () => {
+ await update(() => {
+ const paragraph = $getRoot().getFirstChild<ElementNode>()!;
+ const textNode1 = $createTextNode('1');
+ const textNode2 = $createTextNode('2');
+ const textNode3 = $createTextNode('3');
+ paragraph.append(textNode1, textNode2, textNode3);
+ textNode2.select();
+
+ const selection = $getSelection();
+ textNode2.mergeWithSibling(textNode1);
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.getNode()).toBe(textNode2);
+ expect(selection.anchor.offset).toBe(1);
+ expect(selection.focus.offset).toBe(1);
+
+ textNode2.mergeWithSibling(textNode3);
+
+ expect(selection.anchor.getNode()).toBe(textNode2);
+ expect(selection.anchor.offset).toBe(1);
+ expect(selection.focus.offset).toBe(1);
+ });
+
+ expect(getEditorStateTextContent(editor.getEditorState())).toBe('123');
+ });
+});
--- /dev/null
+/**
+ * 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.
+ *
+ */
+
+// invariant(condition, message) will refine types based on "condition", and
+// if "condition" is false will throw an error. This function is special-cased
+// in flow itself, so we can't name it anything else.
+export default function invariant(
+ cond?: boolean,
+ message?: string,
+ ...args: string[]
+): asserts cond {
+ if (cond) {
+ return;
+ }
+
+ throw new Error(
+ args.reduce((msg, arg) => msg.replace('%s', String(arg)), message || ''),
+ );
+}
--- /dev/null
+/**
+ * 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 const CAN_USE_DOM: boolean =
+ typeof window !== 'undefined' &&
+ typeof window.document !== 'undefined' &&
+ typeof window.document.createElement !== 'undefined';
--- /dev/null
+/**
+ * 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 default function caretFromPoint(
+ x: number,
+ y: number,
+): null | {
+ offset: number;
+ node: Node;
+} {
+ if (typeof document.caretRangeFromPoint !== 'undefined') {
+ const range = document.caretRangeFromPoint(x, y);
+ if (range === null) {
+ return null;
+ }
+ return {
+ node: range.startContainer,
+ offset: range.startOffset,
+ };
+ // @ts-ignore
+ } else if (document.caretPositionFromPoint !== 'undefined') {
+ // @ts-ignore FF - no types
+ const range = document.caretPositionFromPoint(x, y);
+ if (range === null) {
+ return null;
+ }
+ return {
+ node: range.offsetNode,
+ offset: range.offset,
+ };
+ } else {
+ // Gracefully handle IE
+ return null;
+ }
+}
--- /dev/null
+/**
+ * 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 {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
+
+declare global {
+ interface Document {
+ documentMode?: unknown;
+ }
+
+ interface Window {
+ MSStream?: unknown;
+ }
+}
+
+const documentMode =
+ CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
+
+export const IS_APPLE: boolean =
+ CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
+
+export const IS_FIREFOX: boolean =
+ CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
+
+export const CAN_USE_BEFORE_INPUT: boolean =
+ CAN_USE_DOM && 'InputEvent' in window && !documentMode
+ ? 'getTargetRanges' in new window.InputEvent('input')
+ : false;
+
+export const IS_SAFARI: boolean =
+ CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
+
+export const IS_IOS: boolean =
+ CAN_USE_DOM &&
+ /iPad|iPhone|iPod/.test(navigator.userAgent) &&
+ !window.MSStream;
+
+export const IS_ANDROID: boolean =
+ CAN_USE_DOM && /Android/.test(navigator.userAgent);
+
+// Keep these in case we need to use them in the future.
+// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
+export const IS_CHROME: boolean =
+ CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
+// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
+
+export const IS_ANDROID_CHROME: boolean =
+ CAN_USE_DOM && IS_ANDROID && IS_CHROME;
+
+export const IS_APPLE_WEBKIT =
+ CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME;
--- /dev/null
+/**
+ * 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.
+ *
+ */
+
+// invariant(condition, message) will refine types based on "condition", and
+// if "condition" is false will throw an error. This function is special-cased
+// in flow itself, so we can't name it anything else.
+export default function invariant(
+ cond?: boolean,
+ message?: string,
+ ...args: string[]
+): asserts cond {
+ if (cond) {
+ return;
+ }
+
+ throw new Error(
+ 'Internal Lexical error: invariant() is meant to be replaced at compile ' +
+ 'time. There is no runtime version. Error: ' +
+ message,
+ );
+}
--- /dev/null
+/**
+ * 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 default function normalizeClassNames(
+ ...classNames: Array<typeof undefined | boolean | null | string>
+): Array<string> {
+ const rval = [];
+ for (const className of classNames) {
+ if (className && typeof className === 'string') {
+ for (const [s] of className.matchAll(/\S+/g)) {
+ rval.push(s);
+ }
+ }
+ }
+ return rval;
+}
--- /dev/null
+/**
+ * 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 * as React from 'react';
+import * as ReactTestUtils from 'react-dom/test-utils';
+
+/**
+ * React 19 moved act from react-dom/test-utils to react
+ * https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-react-dom-test-utils
+ */
+export const act =
+ 'act' in React
+ ? (React.act as typeof ReactTestUtils.act)
+ : ReactTestUtils.act;
--- /dev/null
+/**
+ * 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 React from 'react';
+
+// Webpack + React 17 fails to compile on the usage of `React.startTransition` or
+// `React["startTransition"]` even if it's behind a feature detection of
+// `"startTransition" in React`. Moving this to a constant avoids the issue :/
+const START_TRANSITION = 'startTransition';
+
+export function startTransition(callback: () => void) {
+ if (START_TRANSITION in React) {
+ React[START_TRANSITION](callback);
+ } else {
+ callback();
+ }
+}
--- /dev/null
+/**
+ * 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 default function simpleDiffWithCursor(
+ a: string,
+ b: string,
+ cursor: number,
+): {index: number; insert: string; remove: number} {
+ const aLength = a.length;
+ const bLength = b.length;
+ let left = 0; // number of same characters counting from left
+ let right = 0; // number of same characters counting from right
+ // Iterate left to the right until we find a changed character
+ // First iteration considers the current cursor position
+ while (
+ left < aLength &&
+ left < bLength &&
+ a[left] === b[left] &&
+ left < cursor
+ ) {
+ left++;
+ }
+ // Iterate right to the left until we find a changed character
+ while (
+ right + left < aLength &&
+ right + left < bLength &&
+ a[aLength - right - 1] === b[bLength - right - 1]
+ ) {
+ right++;
+ }
+ // Try to iterate left further to the right without caring about the current cursor position
+ while (
+ right + left < aLength &&
+ right + left < bLength &&
+ a[left] === b[left]
+ ) {
+ left++;
+ }
+ return {
+ index: left,
+ insert: b.slice(left, bLength - right),
+ remove: aLength - left - right,
+ };
+}
--- /dev/null
+/**
+ * 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 {useEffect, useLayoutEffect} from 'react';
+import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
+
+// This workaround is no longer necessary in React 19,
+// but we currently support React >=17.x
+// https://github.com/facebook/react/pull/26395
+const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM
+ ? useLayoutEffect
+ : useEffect;
+
+export default useLayoutEffectImpl;
--- /dev/null
+/**
+ * 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 default function warnOnlyOnce(message: string) {
+ if (!__DEV__) {
+ return;
+ }
+ let run = false;
+ return () => {
+ if (!run) {
+ console.warn(message);
+ }
+ run = true;
+ };
+}
--- /dev/null
+/**
+ * @jest-environment node
+ */
+
+// Jest environment should be at the very top of the file. overriding environment for this test
+// to ensure that headless editor works within node environment
+// https://jestjs.io/docs/configuration#testenvironment-string
+
+/* eslint-disable header/header */
+
+/**
+ * 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 {EditorState, LexicalEditor, RangeSelection} from 'lexical';
+
+import {$generateHtmlFromNodes} from '@lexical/html';
+import {JSDOM} from 'jsdom';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ COMMAND_PRIORITY_NORMAL,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ ParagraphNode,
+} from 'lexical';
+
+import {createHeadlessEditor} from '../..';
+
+describe('LexicalHeadlessEditor', () => {
+ let editor: LexicalEditor;
+
+ async function update(updateFn: () => void) {
+ editor.update(updateFn);
+ await Promise.resolve();
+ }
+
+ function assertEditorState(
+ editorState: EditorState,
+ nodes: Record<string, unknown>[],
+ ) {
+ const nodesFromState = Array.from(editorState._nodeMap.values());
+ expect(nodesFromState).toEqual(
+ nodes.map((node) => expect.objectContaining(node)),
+ );
+ }
+
+ beforeEach(() => {
+ editor = createHeadlessEditor({
+ namespace: '',
+ onError: (error) => {
+ throw error;
+ },
+ });
+ });
+
+ it('should be headless environment', async () => {
+ expect(typeof window === 'undefined').toBe(true);
+ expect(typeof document === 'undefined').toBe(true);
+ expect(typeof navigator === 'undefined').toBe(true);
+ });
+
+ it('can update editor', async () => {
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('Hello').toggleFormat('bold'),
+ $createTextNode('world'),
+ ),
+ );
+ });
+
+ assertEditorState(editor.getEditorState(), [
+ {
+ __key: 'root',
+ },
+ {
+ __type: 'paragraph',
+ },
+ {
+ __format: 1,
+ __text: 'Hello',
+ __type: 'text',
+ },
+ {
+ __format: 0,
+ __text: 'world',
+ __type: 'text',
+ },
+ ]);
+ });
+
+ it('can set editor state from json', async () => {
+ editor.setEditorState(
+ editor.parseEditorState(
+ '{"root":{"children":[{"children":[{"detail":0,"format":1,"mode":"normal","style":"","text":"Hello","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}',
+ ),
+ );
+
+ assertEditorState(editor.getEditorState(), [
+ {
+ __key: 'root',
+ },
+ {
+ __type: 'paragraph',
+ },
+ {
+ __format: 1,
+ __text: 'Hello',
+ __type: 'text',
+ },
+ {
+ __format: 0,
+ __text: 'world',
+ __type: 'text',
+ },
+ ]);
+ });
+
+ it('can register listeners', async () => {
+ const onUpdate = jest.fn();
+ const onCommand = jest.fn();
+ const onTransform = jest.fn();
+ const onTextContent = jest.fn();
+
+ editor.registerUpdateListener(onUpdate);
+ editor.registerCommand(
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ onCommand,
+ COMMAND_PRIORITY_NORMAL,
+ );
+ editor.registerNodeTransform(ParagraphNode, onTransform);
+ editor.registerTextContentListener(onTextContent);
+
+ await update(() => {
+ $getRoot().append(
+ $createParagraphNode().append(
+ $createTextNode('Hello').toggleFormat('bold'),
+ $createTextNode('world'),
+ ),
+ );
+ editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'foo');
+ });
+
+ expect(onUpdate).toBeCalled();
+ expect(onCommand).toBeCalledWith('foo', expect.anything());
+ expect(onTransform).toBeCalledWith(
+ expect.objectContaining({__type: 'paragraph'}),
+ );
+ expect(onTextContent).toBeCalledWith('Helloworld');
+ });
+
+ it('can preserve selection for pending editor state (within update loop)', async () => {
+ await update(() => {
+ const textNode = $createTextNode('Hello world');
+ $getRoot().append($createParagraphNode().append(textNode));
+ textNode.select(1, 2);
+ });
+
+ await update(() => {
+ const selection = $getSelection() as RangeSelection;
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({offset: 1, type: 'text'}),
+ );
+ expect(selection.focus).toEqual(
+ expect.objectContaining({offset: 2, type: 'text'}),
+ );
+ });
+ });
+
+ function setupDom() {
+ const jsdom = new JSDOM();
+
+ const _window = global.window;
+ const _document = global.document;
+
+ // @ts-expect-error
+ global.window = jsdom.window;
+ global.document = jsdom.window.document;
+
+ return () => {
+ global.window = _window;
+ global.document = _document;
+ };
+ }
+
+ it('can generate html from the nodes when dom is set', async () => {
+ editor.setEditorState(
+ // "hello world"
+ editor.parseEditorState(
+ `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`,
+ ),
+ );
+
+ const cleanup = setupDom();
+
+ const html = editor
+ .getEditorState()
+ .read(() => $generateHtmlFromNodes(editor, null));
+
+ cleanup();
+
+ expect(html).toBe(
+ '<p dir="ltr"><span style="white-space: pre-wrap;">hello world</span></p>',
+ );
+ });
+});
--- /dev/null
+/**
+ * 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 {CreateEditorArgs, LexicalEditor} from 'lexical';
+
+import {createEditor} from 'lexical';
+
+/**
+ * Generates a headless editor that allows lexical to be used without the need for a DOM, eg in Node.js.
+ * Throws an error when unsupported methods are used.
+ * @param editorConfig - The optional lexical editor configuration.
+ * @returns - The configured headless editor.
+ */
+export function createHeadlessEditor(
+ editorConfig?: CreateEditorArgs,
+): LexicalEditor {
+ const editor = createEditor(editorConfig);
+ editor._headless = true;
+
+ const unsupportedMethods = [
+ 'registerDecoratorListener',
+ 'registerRootListener',
+ 'registerMutationListener',
+ 'getRootElement',
+ 'setRootElement',
+ 'getElementByKey',
+ 'focus',
+ 'blur',
+ ] as const;
+
+ unsupportedMethods.forEach((method: typeof unsupportedMethods[number]) => {
+ editor[method] = () => {
+ throw new Error(`${method} is not supported in headless mode`);
+ };
+ });
+
+ return editor;
+}
--- /dev/null
+/**
+ * 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 {EditorState, LexicalEditor, LexicalNode, NodeKey} from 'lexical';
+
+import {mergeRegister} from '@lexical/utils';
+import {
+ $isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ CAN_REDO_COMMAND,
+ CAN_UNDO_COMMAND,
+ CLEAR_EDITOR_COMMAND,
+ CLEAR_HISTORY_COMMAND,
+ COMMAND_PRIORITY_EDITOR,
+ REDO_COMMAND,
+ UNDO_COMMAND,
+} from 'lexical';
+
+type MergeAction = 0 | 1 | 2;
+const HISTORY_MERGE = 0;
+const HISTORY_PUSH = 1;
+const DISCARD_HISTORY_CANDIDATE = 2;
+
+type ChangeType = 0 | 1 | 2 | 3 | 4;
+const OTHER = 0;
+const COMPOSING_CHARACTER = 1;
+const INSERT_CHARACTER_AFTER_SELECTION = 2;
+const DELETE_CHARACTER_BEFORE_SELECTION = 3;
+const DELETE_CHARACTER_AFTER_SELECTION = 4;
+
+export type HistoryStateEntry = {
+ editor: LexicalEditor;
+ editorState: EditorState;
+};
+export type HistoryState = {
+ current: null | HistoryStateEntry;
+ redoStack: Array<HistoryStateEntry>;
+ undoStack: Array<HistoryStateEntry>;
+};
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+function getDirtyNodes(
+ editorState: EditorState,
+ dirtyLeaves: Set<NodeKey>,
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+): Array<LexicalNode> {
+ const nodeMap = editorState._nodeMap;
+ const nodes = [];
+
+ for (const dirtyLeafKey of dirtyLeaves) {
+ const dirtyLeaf = nodeMap.get(dirtyLeafKey);
+
+ if (dirtyLeaf !== undefined) {
+ nodes.push(dirtyLeaf);
+ }
+ }
+
+ for (const [dirtyElementKey, intentionallyMarkedAsDirty] of dirtyElements) {
+ if (!intentionallyMarkedAsDirty) {
+ continue;
+ }
+
+ const dirtyElement = nodeMap.get(dirtyElementKey);
+
+ if (dirtyElement !== undefined && !$isRootNode(dirtyElement)) {
+ nodes.push(dirtyElement);
+ }
+ }
+
+ return nodes;
+}
+
+function getChangeType(
+ prevEditorState: null | EditorState,
+ nextEditorState: EditorState,
+ dirtyLeavesSet: Set<NodeKey>,
+ dirtyElementsSet: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+ isComposing: boolean,
+): ChangeType {
+ if (
+ prevEditorState === null ||
+ (dirtyLeavesSet.size === 0 && dirtyElementsSet.size === 0 && !isComposing)
+ ) {
+ return OTHER;
+ }
+
+ const nextSelection = nextEditorState._selection;
+ const prevSelection = prevEditorState._selection;
+
+ if (isComposing) {
+ return COMPOSING_CHARACTER;
+ }
+
+ if (
+ !$isRangeSelection(nextSelection) ||
+ !$isRangeSelection(prevSelection) ||
+ !prevSelection.isCollapsed() ||
+ !nextSelection.isCollapsed()
+ ) {
+ return OTHER;
+ }
+
+ const dirtyNodes = getDirtyNodes(
+ nextEditorState,
+ dirtyLeavesSet,
+ dirtyElementsSet,
+ );
+
+ if (dirtyNodes.length === 0) {
+ return OTHER;
+ }
+
+ // Catching the case when inserting new text node into an element (e.g. first char in paragraph/list),
+ // or after existing node.
+ if (dirtyNodes.length > 1) {
+ const nextNodeMap = nextEditorState._nodeMap;
+ const nextAnchorNode = nextNodeMap.get(nextSelection.anchor.key);
+ const prevAnchorNode = nextNodeMap.get(prevSelection.anchor.key);
+
+ if (
+ nextAnchorNode &&
+ prevAnchorNode &&
+ !prevEditorState._nodeMap.has(nextAnchorNode.__key) &&
+ $isTextNode(nextAnchorNode) &&
+ nextAnchorNode.__text.length === 1 &&
+ nextSelection.anchor.offset === 1
+ ) {
+ return INSERT_CHARACTER_AFTER_SELECTION;
+ }
+
+ return OTHER;
+ }
+
+ const nextDirtyNode = dirtyNodes[0];
+
+ const prevDirtyNode = prevEditorState._nodeMap.get(nextDirtyNode.__key);
+
+ if (
+ !$isTextNode(prevDirtyNode) ||
+ !$isTextNode(nextDirtyNode) ||
+ prevDirtyNode.__mode !== nextDirtyNode.__mode
+ ) {
+ return OTHER;
+ }
+
+ const prevText = prevDirtyNode.__text;
+ const nextText = nextDirtyNode.__text;
+
+ if (prevText === nextText) {
+ return OTHER;
+ }
+
+ const nextAnchor = nextSelection.anchor;
+ const prevAnchor = prevSelection.anchor;
+
+ if (nextAnchor.key !== prevAnchor.key || nextAnchor.type !== 'text') {
+ return OTHER;
+ }
+
+ const nextAnchorOffset = nextAnchor.offset;
+ const prevAnchorOffset = prevAnchor.offset;
+ const textDiff = nextText.length - prevText.length;
+
+ if (textDiff === 1 && prevAnchorOffset === nextAnchorOffset - 1) {
+ return INSERT_CHARACTER_AFTER_SELECTION;
+ }
+
+ if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset + 1) {
+ return DELETE_CHARACTER_BEFORE_SELECTION;
+ }
+
+ if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset) {
+ return DELETE_CHARACTER_AFTER_SELECTION;
+ }
+
+ return OTHER;
+}
+
+function isTextNodeUnchanged(
+ key: NodeKey,
+ prevEditorState: EditorState,
+ nextEditorState: EditorState,
+): boolean {
+ const prevNode = prevEditorState._nodeMap.get(key);
+ const nextNode = nextEditorState._nodeMap.get(key);
+
+ const prevSelection = prevEditorState._selection;
+ const nextSelection = nextEditorState._selection;
+ const isDeletingLine =
+ $isRangeSelection(prevSelection) &&
+ $isRangeSelection(nextSelection) &&
+ prevSelection.anchor.type === 'element' &&
+ prevSelection.focus.type === 'element' &&
+ nextSelection.anchor.type === 'text' &&
+ nextSelection.focus.type === 'text';
+
+ if (
+ !isDeletingLine &&
+ $isTextNode(prevNode) &&
+ $isTextNode(nextNode) &&
+ prevNode.__parent === nextNode.__parent
+ ) {
+ // This has the assumption that object key order won't change if the
+ // content did not change, which should normally be safe given
+ // the manner in which nodes and exportJSON are typically implemented.
+ return (
+ JSON.stringify(prevEditorState.read(() => prevNode.exportJSON())) ===
+ JSON.stringify(nextEditorState.read(() => nextNode.exportJSON()))
+ );
+ }
+ return false;
+}
+
+function createMergeActionGetter(
+ editor: LexicalEditor,
+ delay: number,
+): (
+ prevEditorState: null | EditorState,
+ nextEditorState: EditorState,
+ currentHistoryEntry: null | HistoryStateEntry,
+ dirtyLeaves: Set<NodeKey>,
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+ tags: Set<string>,
+) => MergeAction {
+ let prevChangeTime = Date.now();
+ let prevChangeType = OTHER;
+
+ return (
+ prevEditorState,
+ nextEditorState,
+ currentHistoryEntry,
+ dirtyLeaves,
+ dirtyElements,
+ tags,
+ ) => {
+ const changeTime = Date.now();
+
+ // If applying changes from history stack there's no need
+ // to run history logic again, as history entries already calculated
+ if (tags.has('historic')) {
+ prevChangeType = OTHER;
+ prevChangeTime = changeTime;
+ return DISCARD_HISTORY_CANDIDATE;
+ }
+
+ const changeType = getChangeType(
+ prevEditorState,
+ nextEditorState,
+ dirtyLeaves,
+ dirtyElements,
+ editor.isComposing(),
+ );
+
+ const mergeAction = (() => {
+ const isSameEditor =
+ currentHistoryEntry === null || currentHistoryEntry.editor === editor;
+ const shouldPushHistory = tags.has('history-push');
+ const shouldMergeHistory =
+ !shouldPushHistory && isSameEditor && tags.has('history-merge');
+
+ if (shouldMergeHistory) {
+ return HISTORY_MERGE;
+ }
+
+ if (prevEditorState === null) {
+ return HISTORY_PUSH;
+ }
+
+ const selection = nextEditorState._selection;
+ const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0;
+
+ if (!hasDirtyNodes) {
+ if (selection !== null) {
+ return HISTORY_MERGE;
+ }
+
+ return DISCARD_HISTORY_CANDIDATE;
+ }
+
+ if (
+ shouldPushHistory === false &&
+ changeType !== OTHER &&
+ changeType === prevChangeType &&
+ changeTime < prevChangeTime + delay &&
+ isSameEditor
+ ) {
+ return HISTORY_MERGE;
+ }
+
+ // A single node might have been marked as dirty, but not have changed
+ // due to some node transform reverting the change.
+ if (dirtyLeaves.size === 1) {
+ const dirtyLeafKey = Array.from(dirtyLeaves)[0];
+ if (
+ isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState)
+ ) {
+ return HISTORY_MERGE;
+ }
+ }
+
+ return HISTORY_PUSH;
+ })();
+
+ prevChangeTime = changeTime;
+ prevChangeType = changeType;
+
+ return mergeAction;
+ };
+}
+
+function redo(editor: LexicalEditor, historyState: HistoryState): void {
+ const redoStack = historyState.redoStack;
+ const undoStack = historyState.undoStack;
+
+ if (redoStack.length !== 0) {
+ const current = historyState.current;
+
+ if (current !== null) {
+ undoStack.push(current);
+ editor.dispatchCommand(CAN_UNDO_COMMAND, true);
+ }
+
+ const historyStateEntry = redoStack.pop();
+
+ if (redoStack.length === 0) {
+ editor.dispatchCommand(CAN_REDO_COMMAND, false);
+ }
+
+ historyState.current = historyStateEntry || null;
+
+ if (historyStateEntry) {
+ historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
+ tag: 'historic',
+ });
+ }
+ }
+}
+
+function undo(editor: LexicalEditor, historyState: HistoryState): void {
+ const redoStack = historyState.redoStack;
+ const undoStack = historyState.undoStack;
+ const undoStackLength = undoStack.length;
+
+ if (undoStackLength !== 0) {
+ const current = historyState.current;
+ const historyStateEntry = undoStack.pop();
+
+ if (current !== null) {
+ redoStack.push(current);
+ editor.dispatchCommand(CAN_REDO_COMMAND, true);
+ }
+
+ if (undoStack.length === 0) {
+ editor.dispatchCommand(CAN_UNDO_COMMAND, false);
+ }
+
+ historyState.current = historyStateEntry || null;
+
+ if (historyStateEntry) {
+ historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
+ tag: 'historic',
+ });
+ }
+ }
+}
+
+function clearHistory(historyState: HistoryState) {
+ historyState.undoStack = [];
+ historyState.redoStack = [];
+ historyState.current = null;
+}
+
+/**
+ * Registers necessary listeners to manage undo/redo history stack and related editor commands.
+ * It returns `unregister` callback that cleans up all listeners and should be called on editor unmount.
+ * @param editor - The lexical editor.
+ * @param historyState - The history state, containing the current state and the undo/redo stack.
+ * @param delay - The time (in milliseconds) the editor should delay generating a new history stack,
+ * instead of merging the current changes with the current stack.
+ * @returns The listeners cleanup callback function.
+ */
+export function registerHistory(
+ editor: LexicalEditor,
+ historyState: HistoryState,
+ delay: number,
+): () => void {
+ const getMergeAction = createMergeActionGetter(editor, delay);
+
+ const applyChange = ({
+ editorState,
+ prevEditorState,
+ dirtyLeaves,
+ dirtyElements,
+ tags,
+ }: {
+ editorState: EditorState;
+ prevEditorState: EditorState;
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
+ dirtyLeaves: Set<NodeKey>;
+ tags: Set<string>;
+ }): void => {
+ const current = historyState.current;
+ const redoStack = historyState.redoStack;
+ const undoStack = historyState.undoStack;
+ const currentEditorState = current === null ? null : current.editorState;
+
+ if (current !== null && editorState === currentEditorState) {
+ return;
+ }
+
+ const mergeAction = getMergeAction(
+ prevEditorState,
+ editorState,
+ current,
+ dirtyLeaves,
+ dirtyElements,
+ tags,
+ );
+
+ if (mergeAction === HISTORY_PUSH) {
+ if (redoStack.length !== 0) {
+ historyState.redoStack = [];
+ editor.dispatchCommand(CAN_REDO_COMMAND, false);
+ }
+
+ if (current !== null) {
+ undoStack.push({
+ ...current,
+ });
+ editor.dispatchCommand(CAN_UNDO_COMMAND, true);
+ }
+ } else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
+ return;
+ }
+
+ // Else we merge
+ historyState.current = {
+ editor,
+ editorState,
+ };
+ };
+
+ const unregister = mergeRegister(
+ editor.registerCommand(
+ UNDO_COMMAND,
+ () => {
+ undo(editor, historyState);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ REDO_COMMAND,
+ () => {
+ redo(editor, historyState);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ CLEAR_EDITOR_COMMAND,
+ () => {
+ clearHistory(historyState);
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ CLEAR_HISTORY_COMMAND,
+ () => {
+ clearHistory(historyState);
+ editor.dispatchCommand(CAN_REDO_COMMAND, false);
+ editor.dispatchCommand(CAN_UNDO_COMMAND, false);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerUpdateListener(applyChange),
+ );
+
+ return unregister;
+}
+
+/**
+ * Creates an empty history state.
+ * @returns - The empty history state, as an object.
+ */
+export function createEmptyHistoryState(): HistoryState {
+ return {
+ current: null,
+ redoStack: [],
+ undoStack: [],
+ };
+}
--- /dev/null
+/**
+ * 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.
+ *
+ */
+
+//@ts-ignore-next-line
+import type {RangeSelection} from 'lexical';
+
+import {createHeadlessEditor} from '@lexical/headless';
+import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
+import {LinkNode} from '@lexical/link';
+import {ListItemNode, ListNode} from '@lexical/list';
+import {HeadingNode, QuoteNode} from '@lexical/rich-text';
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+} from 'lexical';
+
+describe('HTML', () => {
+ type Input = Array<{
+ name: string;
+ html: string;
+ initializeEditorState: () => void;
+ }>;
+
+ const HTML_SERIALIZE: Input = [
+ {
+ html: '<p><br></p>',
+ initializeEditorState: () => {
+ $getRoot().append($createParagraphNode());
+ },
+ name: 'Empty editor state',
+ },
+ ];
+ for (const {name, html, initializeEditorState} of HTML_SERIALIZE) {
+ test(`[Lexical -> HTML]: ${name}`, () => {
+ const editor = createHeadlessEditor({
+ nodes: [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ LinkNode,
+ ],
+ });
+
+ editor.update(initializeEditorState, {
+ discrete: true,
+ });
+
+ expect(
+ editor.getEditorState().read(() => $generateHtmlFromNodes(editor)),
+ ).toBe(html);
+ });
+ }
+
+ test(`[Lexical -> HTML]: Use provided selection`, () => {
+ const editor = createHeadlessEditor({
+ nodes: [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ LinkNode,
+ ],
+ });
+
+ let selection: RangeSelection | null = null;
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const p1 = $createParagraphNode();
+ const text1 = $createTextNode('Hello');
+ p1.append(text1);
+ const p2 = $createParagraphNode();
+ const text2 = $createTextNode('World');
+ p2.append(text2);
+ root.append(p1).append(p2);
+ // Root
+ // - ParagraphNode
+ // -- TextNode "Hello"
+ // - ParagraphNode
+ // -- TextNode "World"
+ p1.select(0, text1.getTextContentSize());
+ selection = $createRangeSelection();
+ selection.setTextNodeRange(text2, 0, text2, text2.getTextContentSize());
+ },
+ {
+ discrete: true,
+ },
+ );
+
+ let html = '';
+
+ editor.update(() => {
+ html = $generateHtmlFromNodes(editor, selection);
+ });
+
+ expect(html).toBe('<span style="white-space: pre-wrap;">World</span>');
+ });
+
+ test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => {
+ const editor = createHeadlessEditor({
+ nodes: [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ CodeNode,
+ LinkNode,
+ ],
+ });
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const p1 = $createParagraphNode();
+ const text1 = $createTextNode('Hello');
+ p1.append(text1);
+ const p2 = $createParagraphNode();
+ const text2 = $createTextNode('World');
+ p2.append(text2);
+ root.append(p1).append(p2);
+ // Root
+ // - ParagraphNode
+ // -- TextNode "Hello"
+ // - ParagraphNode
+ // -- TextNode "World"
+ p1.select(0, text1.getTextContentSize());
+ },
+ {
+ discrete: true,
+ },
+ );
+
+ let html = '';
+
+ editor.update(() => {
+ html = $generateHtmlFromNodes(editor);
+ });
+
+ expect(html).toBe(
+ '<p><span style="white-space: pre-wrap;">Hello</span></p><p><span style="white-space: pre-wrap;">World</span></p>',
+ );
+ });
+
+ test(`If alignment is set on the paragraph, don't overwrite from parent empty format`, () => {
+ const editor = createHeadlessEditor();
+ const parser = new DOMParser();
+ const rightAlignedParagraphInDiv =
+ '<div><p style="text-align: center;">Hello world!</p></div>';
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const dom = parser.parseFromString(
+ rightAlignedParagraphInDiv,
+ 'text/html',
+ );
+ const nodes = $generateNodesFromDOM(editor, dom);
+ root.append(...nodes);
+ },
+ {discrete: true},
+ );
+
+ let html = '';
+
+ editor.update(() => {
+ html = $generateHtmlFromNodes(editor);
+ });
+
+ expect(html).toBe(
+ '<p style="text-align: center;"><span style="white-space: pre-wrap;">Hello world!</span></p>',
+ );
+ });
+
+ test(`If alignment is set on the paragraph, it should take precedence over its parent block alignment`, () => {
+ const editor = createHeadlessEditor();
+ const parser = new DOMParser();
+ const rightAlignedParagraphInDiv =
+ '<div style="text-align: right;"><p style="text-align: center;">Hello world!</p></div>';
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const dom = parser.parseFromString(
+ rightAlignedParagraphInDiv,
+ 'text/html',
+ );
+ const nodes = $generateNodesFromDOM(editor, dom);
+ root.append(...nodes);
+ },
+ {discrete: true},
+ );
+
+ let html = '';
+
+ editor.update(() => {
+ html = $generateHtmlFromNodes(editor);
+ });
+
+ expect(html).toBe(
+ '<p style="text-align: center;"><span style="white-space: pre-wrap;">Hello world!</span></p>',
+ );
+ });
+});
--- /dev/null
+/**
+ * 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,
+ DOMChildConversion,
+ DOMConversion,
+ DOMConversionFn,
+ ElementFormatType,
+ LexicalEditor,
+ LexicalNode,
+} from 'lexical';
+
+import {$sliceSelectedTextNodeContent} from '@lexical/selection';
+import {isBlockDomNode, isHTMLElement} from '@lexical/utils';
+import {
+ $cloneWithProperties,
+ $createLineBreakNode,
+ $createParagraphNode,
+ $getRoot,
+ $isBlockElementNode,
+ $isElementNode,
+ $isRootOrShadowRoot,
+ $isTextNode,
+ ArtificialNode__DO_NOT_USE,
+ ElementNode,
+ isInlineDomNode,
+} from 'lexical';
+
+/**
+ * How you parse your html string to get a document is left up to you. In the browser you can use the native
+ * DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom
+ * or an equivalent library and pass in the document here.
+ */
+export function $generateNodesFromDOM(
+ editor: LexicalEditor,
+ dom: Document,
+): Array<LexicalNode> {
+ const elements = dom.body ? dom.body.childNodes : [];
+ let lexicalNodes: Array<LexicalNode> = [];
+ const allArtificialNodes: Array<ArtificialNode__DO_NOT_USE> = [];
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ if (!IGNORE_TAGS.has(element.nodeName)) {
+ const lexicalNode = $createNodesFromDOM(
+ element,
+ editor,
+ allArtificialNodes,
+ false,
+ );
+ if (lexicalNode !== null) {
+ lexicalNodes = lexicalNodes.concat(lexicalNode);
+ }
+ }
+ }
+ $unwrapArtificalNodes(allArtificialNodes);
+
+ return lexicalNodes;
+}
+
+export function $generateHtmlFromNodes(
+ editor: LexicalEditor,
+ selection?: BaseSelection | null,
+): string {
+ if (
+ typeof document === 'undefined' ||
+ (typeof window === 'undefined' && typeof global.window === 'undefined')
+ ) {
+ throw new Error(
+ 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.',
+ );
+ }
+
+ const container = document.createElement('div');
+ const root = $getRoot();
+ const topLevelChildren = root.getChildren();
+
+ for (let i = 0; i < topLevelChildren.length; i++) {
+ const topLevelNode = topLevelChildren[i];
+ $appendNodesToHTML(editor, topLevelNode, container, selection);
+ }
+
+ return container.innerHTML;
+}
+
+function $appendNodesToHTML(
+ editor: LexicalEditor,
+ currentNode: LexicalNode,
+ parentElement: HTMLElement | DocumentFragment,
+ selection: BaseSelection | null = null,
+): boolean {
+ let shouldInclude =
+ selection !== null ? currentNode.isSelected(selection) : true;
+ const shouldExclude =
+ $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
+ let target = currentNode;
+
+ if (selection !== null) {
+ let clone = $cloneWithProperties(currentNode);
+ clone =
+ $isTextNode(clone) && selection !== null
+ ? $sliceSelectedTextNodeContent(selection, clone)
+ : clone;
+ target = clone;
+ }
+ const children = $isElementNode(target) ? target.getChildren() : [];
+ const registeredNode = editor._nodes.get(target.getType());
+ let exportOutput;
+
+ // Use HTMLConfig overrides, if available.
+ if (registeredNode && registeredNode.exportDOM !== undefined) {
+ exportOutput = registeredNode.exportDOM(editor, target);
+ } else {
+ exportOutput = target.exportDOM(editor);
+ }
+
+ const {element, after} = exportOutput;
+
+ if (!element) {
+ return false;
+ }
+
+ const fragment = document.createDocumentFragment();
+
+ for (let i = 0; i < children.length; i++) {
+ const childNode = children[i];
+ const shouldIncludeChild = $appendNodesToHTML(
+ editor,
+ childNode,
+ fragment,
+ selection,
+ );
+
+ if (
+ !shouldInclude &&
+ $isElementNode(currentNode) &&
+ shouldIncludeChild &&
+ currentNode.extractWithChild(childNode, selection, 'html')
+ ) {
+ shouldInclude = true;
+ }
+ }
+
+ if (shouldInclude && !shouldExclude) {
+ if (isHTMLElement(element)) {
+ element.append(fragment);
+ }
+ parentElement.append(element);
+
+ if (after) {
+ const newElement = after.call(target, element);
+ if (newElement) {
+ element.replaceWith(newElement);
+ }
+ }
+ } else {
+ parentElement.append(fragment);
+ }
+
+ return shouldInclude;
+}
+
+function getConversionFunction(
+ domNode: Node,
+ editor: LexicalEditor,
+): DOMConversionFn | null {
+ const {nodeName} = domNode;
+
+ const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase());
+
+ let currentConversion: DOMConversion | null = null;
+
+ if (cachedConversions !== undefined) {
+ for (const cachedConversion of cachedConversions) {
+ const domConversion = cachedConversion(domNode);
+ if (
+ domConversion !== null &&
+ (currentConversion === null ||
+ (currentConversion.priority || 0) < (domConversion.priority || 0))
+ ) {
+ currentConversion = domConversion;
+ }
+ }
+ }
+
+ return currentConversion !== null ? currentConversion.conversion : null;
+}
+
+const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']);
+
+function $createNodesFromDOM(
+ node: Node,
+ editor: LexicalEditor,
+ allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
+ hasBlockAncestorLexicalNode: boolean,
+ forChildMap: Map<string, DOMChildConversion> = new Map(),
+ parentLexicalNode?: LexicalNode | null | undefined,
+): Array<LexicalNode> {
+ let lexicalNodes: Array<LexicalNode> = [];
+
+ if (IGNORE_TAGS.has(node.nodeName)) {
+ return lexicalNodes;
+ }
+
+ let currentLexicalNode = null;
+ const transformFunction = getConversionFunction(node, editor);
+ const transformOutput = transformFunction
+ ? transformFunction(node as HTMLElement)
+ : null;
+ let postTransform = null;
+
+ if (transformOutput !== null) {
+ postTransform = transformOutput.after;
+ const transformNodes = transformOutput.node;
+ currentLexicalNode = Array.isArray(transformNodes)
+ ? transformNodes[transformNodes.length - 1]
+ : transformNodes;
+
+ if (currentLexicalNode !== null) {
+ for (const [, forChildFunction] of forChildMap) {
+ currentLexicalNode = forChildFunction(
+ currentLexicalNode,
+ parentLexicalNode,
+ );
+
+ if (!currentLexicalNode) {
+ break;
+ }
+ }
+
+ if (currentLexicalNode) {
+ lexicalNodes.push(
+ ...(Array.isArray(transformNodes)
+ ? transformNodes
+ : [currentLexicalNode]),
+ );
+ }
+ }
+
+ if (transformOutput.forChild != null) {
+ forChildMap.set(node.nodeName, transformOutput.forChild);
+ }
+ }
+
+ // If the DOM node doesn't have a transformer, we don't know what
+ // to do with it but we still need to process any childNodes.
+ const children = node.childNodes;
+ let childLexicalNodes = [];
+
+ const hasBlockAncestorLexicalNodeForChildren =
+ currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode)
+ ? false
+ : (currentLexicalNode != null &&
+ $isBlockElementNode(currentLexicalNode)) ||
+ hasBlockAncestorLexicalNode;
+
+ for (let i = 0; i < children.length; i++) {
+ childLexicalNodes.push(
+ ...$createNodesFromDOM(
+ children[i],
+ editor,
+ allArtificialNodes,
+ hasBlockAncestorLexicalNodeForChildren,
+ new Map(forChildMap),
+ currentLexicalNode,
+ ),
+ );
+ }
+
+ if (postTransform != null) {
+ childLexicalNodes = postTransform(childLexicalNodes);
+ }
+
+ if (isBlockDomNode(node)) {
+ if (!hasBlockAncestorLexicalNodeForChildren) {
+ childLexicalNodes = wrapContinuousInlines(
+ node,
+ childLexicalNodes,
+ $createParagraphNode,
+ );
+ } else {
+ childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => {
+ const artificialNode = new ArtificialNode__DO_NOT_USE();
+ allArtificialNodes.push(artificialNode);
+ return artificialNode;
+ });
+ }
+ }
+
+ if (currentLexicalNode == null) {
+ if (childLexicalNodes.length > 0) {
+ // If it hasn't been converted to a LexicalNode, we hoist its children
+ // up to the same level as it.
+ lexicalNodes = lexicalNodes.concat(childLexicalNodes);
+ } else {
+ if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) {
+ // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes
+ lexicalNodes = lexicalNodes.concat($createLineBreakNode());
+ }
+ }
+ } else {
+ if ($isElementNode(currentLexicalNode)) {
+ // If the current node is a ElementNode after conversion,
+ // we can append all the children to it.
+ currentLexicalNode.append(...childLexicalNodes);
+ }
+ }
+
+ return lexicalNodes;
+}
+
+function wrapContinuousInlines(
+ domNode: Node,
+ nodes: Array<LexicalNode>,
+ createWrapperFn: () => ElementNode,
+): Array<LexicalNode> {
+ const textAlign = (domNode as HTMLElement).style
+ .textAlign as ElementFormatType;
+ const out: Array<LexicalNode> = [];
+ let continuousInlines: Array<LexicalNode> = [];
+ // wrap contiguous inline child nodes in para
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ if ($isBlockElementNode(node)) {
+ if (textAlign && !node.getFormat()) {
+ node.setFormat(textAlign);
+ }
+ out.push(node);
+ } else {
+ continuousInlines.push(node);
+ if (
+ i === nodes.length - 1 ||
+ (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1]))
+ ) {
+ const wrapper = createWrapperFn();
+ wrapper.setFormat(textAlign);
+ wrapper.append(...continuousInlines);
+ out.push(wrapper);
+ continuousInlines = [];
+ }
+ }
+ }
+ return out;
+}
+
+function $unwrapArtificalNodes(
+ allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
+) {
+ for (const node of allArtificialNodes) {
+ if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) {
+ node.insertAfter($createLineBreakNode());
+ }
+ }
+ // Replace artificial node with it's children
+ for (const node of allArtificialNodes) {
+ const children = node.getChildren();
+ for (const child of children) {
+ node.insertBefore(child);
+ }
+ node.remove();
+ }
+}
+
+function isDomNodeBetweenTwoInlineNodes(node: Node): boolean {
+ if (node.nextSibling == null || node.previousSibling == null) {
+ return false;
+ }
+ return (
+ isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling)
+ );
+}
--- /dev/null
+/**
+ * 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 {
+ $createAutoLinkNode,
+ $isAutoLinkNode,
+ $toggleLink,
+ AutoLinkNode,
+ SerializedAutoLinkNode,
+} from '@lexical/link';
+import {
+ $getRoot,
+ $selectAll,
+ ParagraphNode,
+ SerializedParagraphNode,
+ TextNode,
+} from 'lexical/src';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ link: 'my-autolink-class',
+ text: {
+ bold: 'my-bold-class',
+ code: 'my-code-class',
+ hashtag: 'my-hashtag-class',
+ italic: 'my-italic-class',
+ strikethrough: 'my-strikethrough-class',
+ underline: 'my-underline-class',
+ underlineStrikethrough: 'my-underline-strikethrough-class',
+ },
+ },
+});
+
+describe('LexicalAutoAutoLinkNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('AutoAutoLinkNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const actutoLinkNode = new AutoLinkNode('/');
+
+ expect(actutoLinkNode.__type).toBe('autolink');
+ expect(actutoLinkNode.__url).toBe('/');
+ expect(actutoLinkNode.__isUnlinked).toBe(false);
+ });
+
+ expect(() => new AutoLinkNode('')).toThrow();
+ });
+
+ test('AutoAutoLinkNode.constructor with isUnlinked param set to true', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const actutoLinkNode = new AutoLinkNode('/', {
+ isUnlinked: true,
+ });
+
+ expect(actutoLinkNode.__type).toBe('autolink');
+ expect(actutoLinkNode.__url).toBe('/');
+ expect(actutoLinkNode.__isUnlinked).toBe(true);
+ });
+
+ expect(() => new AutoLinkNode('')).toThrow();
+ });
+
+ ///
+
+ test('LineBreakNode.clone()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('/');
+
+ const clone = AutoLinkNode.clone(autoLinkNode);
+
+ expect(clone).not.toBe(autoLinkNode);
+ expect(clone).toStrictEqual(autoLinkNode);
+ });
+ });
+
+ test('AutoLinkNode.getURL()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+
+ expect(autoLinkNode.getURL()).toBe('https://example.com/foo');
+ });
+ });
+
+ test('AutoLinkNode.setURL()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+
+ expect(autoLinkNode.getURL()).toBe('https://example.com/foo');
+
+ autoLinkNode.setURL('https://example.com/bar');
+
+ expect(autoLinkNode.getURL()).toBe('https://example.com/bar');
+ });
+ });
+
+ test('AutoLinkNode.getTarget()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ target: '_blank',
+ });
+
+ expect(autoLinkNode.getTarget()).toBe('_blank');
+ });
+ });
+
+ test('AutoLinkNode.setTarget()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ target: '_blank',
+ });
+
+ expect(autoLinkNode.getTarget()).toBe('_blank');
+
+ autoLinkNode.setTarget('_self');
+
+ expect(autoLinkNode.getTarget()).toBe('_self');
+ });
+ });
+
+ test('AutoLinkNode.getRel()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ });
+
+ expect(autoLinkNode.getRel()).toBe('noopener noreferrer');
+ });
+ });
+
+ test('AutoLinkNode.setRel()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ rel: 'noopener',
+ target: '_blank',
+ });
+
+ expect(autoLinkNode.getRel()).toBe('noopener');
+
+ autoLinkNode.setRel('noopener noreferrer');
+
+ expect(autoLinkNode.getRel()).toBe('noopener noreferrer');
+ });
+ });
+
+ test('AutoLinkNode.getTitle()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ title: 'Hello world',
+ });
+
+ expect(autoLinkNode.getTitle()).toBe('Hello world');
+ });
+ });
+
+ test('AutoLinkNode.setTitle()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ title: 'Hello world',
+ });
+
+ expect(autoLinkNode.getTitle()).toBe('Hello world');
+
+ autoLinkNode.setTitle('World hello');
+
+ expect(autoLinkNode.getTitle()).toBe('World hello');
+ });
+ });
+
+ test('AutoLinkNode.getIsUnlinked()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('/', {
+ isUnlinked: true,
+ });
+ expect(autoLinkNode.getIsUnlinked()).toBe(true);
+ });
+ });
+
+ test('AutoLinkNode.setIsUnlinked()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('/');
+ expect(autoLinkNode.getIsUnlinked()).toBe(false);
+ autoLinkNode.setIsUnlinked(true);
+ expect(autoLinkNode.getIsUnlinked()).toBe(true);
+ });
+ });
+
+ test('AutoLinkNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" class="my-autolink-class"></a>',
+ );
+ expect(
+ autoLinkNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<a href="https://example.com/foo"></a>');
+ });
+ });
+
+ test('AutoLinkNode.createDOM() for unlinked', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ isUnlinked: true,
+ });
+
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ `<span>${autoLinkNode.getTextContent()}</span>`,
+ );
+ });
+ });
+
+ test('AutoLinkNode.createDOM() with target, rel and title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
+ );
+ expect(
+ autoLinkNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world"></a>',
+ );
+ });
+ });
+
+ test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ // eslint-disable-next-line no-script-url
+ const autoLinkNode = new AutoLinkNode('javascript:alert(0)');
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="about:blank" class="my-autolink-class"></a>',
+ );
+ });
+ });
+
+ test('AutoLinkNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+
+ const domElement = autoLinkNode.createDOM(editorConfig);
+
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" class="my-autolink-class"></a>',
+ );
+
+ const newAutoLinkNode = new AutoLinkNode('https://example.com/bar');
+ const result = newAutoLinkNode.updateDOM(
+ autoLinkNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/bar" class="my-autolink-class"></a>',
+ );
+ });
+ });
+
+ test('AutoLinkNode.updateDOM() with target, rel and title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ const domElement = autoLinkNode.createDOM(editorConfig);
+
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
+ );
+
+ const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', {
+ rel: 'noopener',
+ target: '_self',
+ title: 'World hello',
+ });
+ const result = newAutoLinkNode.updateDOM(
+ autoLinkNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/bar" target="_self" rel="noopener" title="World hello" class="my-autolink-class"></a>',
+ );
+ });
+ });
+
+ test('AutoLinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ const domElement = autoLinkNode.createDOM(editorConfig);
+
+ expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
+ );
+
+ const newNode = new AutoLinkNode('https://example.com/bar');
+ const result = newNode.updateDOM(
+ autoLinkNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/bar" class="my-autolink-class"></a>',
+ );
+ });
+ });
+
+ test('AutoLinkNode.updateDOM() with isUnlinked "true"', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ isUnlinked: false,
+ });
+
+ const domElement = autoLinkNode.createDOM(editorConfig);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/foo" class="my-autolink-class"></a>',
+ );
+
+ const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', {
+ isUnlinked: true,
+ });
+ const newDomElement = newAutoLinkNode.createDOM(editorConfig);
+ expect(newDomElement.outerHTML).toBe(
+ `<span>${newAutoLinkNode.getTextContent()}</span>`,
+ );
+
+ const result = newAutoLinkNode.updateDOM(
+ autoLinkNode,
+ domElement,
+ editorConfig,
+ );
+ expect(result).toBe(true);
+ });
+ });
+
+ test('AutoLinkNode.canInsertTextBefore()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+
+ expect(autoLinkNode.canInsertTextBefore()).toBe(false);
+ });
+ });
+
+ test('AutoLinkNode.canInsertTextAfter()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+ expect(autoLinkNode.canInsertTextAfter()).toBe(false);
+ });
+ });
+
+ test('$createAutoLinkNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo');
+ const createdAutoLinkNode = $createAutoLinkNode(
+ 'https://example.com/foo',
+ );
+
+ expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type);
+ expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent);
+ expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url);
+ expect(autoLinkNode.__isUnlinked).toEqual(
+ createdAutoLinkNode.__isUnlinked,
+ );
+ expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key);
+ });
+ });
+
+ test('$createAutoLinkNode() with target, rel, isUnlinked and title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ const createdAutoLinkNode = $createAutoLinkNode(
+ 'https://example.com/foo',
+ {
+ isUnlinked: true,
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ },
+ );
+
+ expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type);
+ expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent);
+ expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url);
+ expect(autoLinkNode.__target).toEqual(createdAutoLinkNode.__target);
+ expect(autoLinkNode.__rel).toEqual(createdAutoLinkNode.__rel);
+ expect(autoLinkNode.__title).toEqual(createdAutoLinkNode.__title);
+ expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key);
+ expect(autoLinkNode.__isUnlinked).not.toEqual(
+ createdAutoLinkNode.__isUnlinked,
+ );
+ });
+ });
+
+ test('$isAutoLinkNode()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const autoLinkNode = new AutoLinkNode('');
+ expect($isAutoLinkNode(autoLinkNode)).toBe(true);
+ });
+ });
+
+ test('$toggleLink applies the title attribute when creating', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const p = new ParagraphNode();
+ p.append(new TextNode('Some text'));
+ $getRoot().append(p);
+ });
+
+ await editor.update(() => {
+ $selectAll();
+ $toggleLink('https://lexical.dev/', {title: 'Lexical Website'});
+ });
+
+ const paragraph = editor!.getEditorState().toJSON().root
+ .children[0] as SerializedParagraphNode;
+ const link = paragraph.children[0] as SerializedAutoLinkNode;
+ expect(link.title).toBe('Lexical Website');
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createLinkNode,
+ $isLinkNode,
+ $toggleLink,
+ LinkNode,
+ SerializedLinkNode,
+} from '@lexical/link';
+import {
+ $getRoot,
+ $selectAll,
+ ParagraphNode,
+ SerializedParagraphNode,
+ TextNode,
+} from 'lexical/src';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ link: 'my-link-class',
+ text: {
+ bold: 'my-bold-class',
+ code: 'my-code-class',
+ hashtag: 'my-hashtag-class',
+ italic: 'my-italic-class',
+ strikethrough: 'my-strikethrough-class',
+ underline: 'my-underline-class',
+ underlineStrikethrough: 'my-underline-strikethrough-class',
+ },
+ },
+});
+
+describe('LexicalLinkNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('LinkNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('/');
+
+ expect(linkNode.__type).toBe('link');
+ expect(linkNode.__url).toBe('/');
+ });
+
+ expect(() => new LinkNode('')).toThrow();
+ });
+
+ test('LineBreakNode.clone()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('/');
+
+ const linkNodeClone = LinkNode.clone(linkNode);
+
+ expect(linkNodeClone).not.toBe(linkNode);
+ expect(linkNodeClone).toStrictEqual(linkNode);
+ });
+ });
+
+ test('LinkNode.getURL()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ expect(linkNode.getURL()).toBe('https://example.com/foo');
+ });
+ });
+
+ test('LinkNode.setURL()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ expect(linkNode.getURL()).toBe('https://example.com/foo');
+
+ linkNode.setURL('https://example.com/bar');
+
+ expect(linkNode.getURL()).toBe('https://example.com/bar');
+ });
+ });
+
+ test('LinkNode.getTarget()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ target: '_blank',
+ });
+
+ expect(linkNode.getTarget()).toBe('_blank');
+ });
+ });
+
+ test('LinkNode.setTarget()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ target: '_blank',
+ });
+
+ expect(linkNode.getTarget()).toBe('_blank');
+
+ linkNode.setTarget('_self');
+
+ expect(linkNode.getTarget()).toBe('_self');
+ });
+ });
+
+ test('LinkNode.getRel()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ });
+
+ expect(linkNode.getRel()).toBe('noopener noreferrer');
+ });
+ });
+
+ test('LinkNode.setRel()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ rel: 'noopener',
+ target: '_blank',
+ });
+
+ expect(linkNode.getRel()).toBe('noopener');
+
+ linkNode.setRel('noopener noreferrer');
+
+ expect(linkNode.getRel()).toBe('noopener noreferrer');
+ });
+ });
+
+ test('LinkNode.getTitle()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ title: 'Hello world',
+ });
+
+ expect(linkNode.getTitle()).toBe('Hello world');
+ });
+ });
+
+ test('LinkNode.setTitle()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ title: 'Hello world',
+ });
+
+ expect(linkNode.getTitle()).toBe('Hello world');
+
+ linkNode.setTitle('World hello');
+
+ expect(linkNode.getTitle()).toBe('World hello');
+ });
+ });
+
+ test('LinkNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" class="my-link-class"></a>',
+ );
+ expect(
+ linkNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<a href="https://example.com/foo"></a>');
+ });
+ });
+
+ test('LinkNode.createDOM() with target, rel and title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
+ );
+ expect(
+ linkNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world"></a>',
+ );
+ });
+ });
+
+ test('LinkNode.createDOM() sanitizes javascript: URLs', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ // eslint-disable-next-line no-script-url
+ const linkNode = new LinkNode('javascript:alert(0)');
+ expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="about:blank" class="my-link-class"></a>',
+ );
+ });
+ });
+
+ test('LinkNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ const domElement = linkNode.createDOM(editorConfig);
+
+ expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" class="my-link-class"></a>',
+ );
+
+ const newLinkNode = new LinkNode('https://example.com/bar');
+ const result = newLinkNode.updateDOM(
+ linkNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/bar" class="my-link-class"></a>',
+ );
+ });
+ });
+
+ test('LinkNode.updateDOM() with target, rel and title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ const domElement = linkNode.createDOM(editorConfig);
+
+ expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
+ );
+
+ const newLinkNode = new LinkNode('https://example.com/bar', {
+ rel: 'noopener',
+ target: '_self',
+ title: 'World hello',
+ });
+ const result = newLinkNode.updateDOM(
+ linkNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/bar" target="_self" rel="noopener" title="World hello" class="my-link-class"></a>',
+ );
+ });
+ });
+
+ test('LinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ const domElement = linkNode.createDOM(editorConfig);
+
+ expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
+ '<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
+ );
+
+ const newLinkNode = new LinkNode('https://example.com/bar');
+ const result = newLinkNode.updateDOM(
+ linkNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<a href="https://example.com/bar" class="my-link-class"></a>',
+ );
+ });
+ });
+
+ test('LinkNode.canInsertTextBefore()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ expect(linkNode.canInsertTextBefore()).toBe(false);
+ });
+ });
+
+ test('LinkNode.canInsertTextAfter()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ expect(linkNode.canInsertTextAfter()).toBe(false);
+ });
+ });
+
+ test('$createLinkNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo');
+
+ const createdLinkNode = $createLinkNode('https://example.com/foo');
+
+ expect(linkNode.__type).toEqual(createdLinkNode.__type);
+ expect(linkNode.__parent).toEqual(createdLinkNode.__parent);
+ expect(linkNode.__url).toEqual(createdLinkNode.__url);
+ expect(linkNode.__key).not.toEqual(createdLinkNode.__key);
+ });
+ });
+
+ test('$createLinkNode() with target, rel and title', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ const createdLinkNode = $createLinkNode('https://example.com/foo', {
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ title: 'Hello world',
+ });
+
+ expect(linkNode.__type).toEqual(createdLinkNode.__type);
+ expect(linkNode.__parent).toEqual(createdLinkNode.__parent);
+ expect(linkNode.__url).toEqual(createdLinkNode.__url);
+ expect(linkNode.__target).toEqual(createdLinkNode.__target);
+ expect(linkNode.__rel).toEqual(createdLinkNode.__rel);
+ expect(linkNode.__title).toEqual(createdLinkNode.__title);
+ expect(linkNode.__key).not.toEqual(createdLinkNode.__key);
+ });
+ });
+
+ test('$isLinkNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const linkNode = new LinkNode('');
+
+ expect($isLinkNode(linkNode)).toBe(true);
+ });
+ });
+
+ test('$toggleLink applies the title attribute when creating', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const p = new ParagraphNode();
+ p.append(new TextNode('Some text'));
+ $getRoot().append(p);
+ });
+
+ await editor.update(() => {
+ $selectAll();
+ $toggleLink('https://lexical.dev/', {title: 'Lexical Website'});
+ });
+
+ const paragraph = editor!.getEditorState().toJSON().root
+ .children[0] as SerializedParagraphNode;
+ const link = paragraph.children[0] as SerializedLinkNode;
+ expect(link.title).toBe('Lexical Website');
+ });
+ });
+});
--- /dev/null
+/**
+ * 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,
+ DOMConversionMap,
+ DOMConversionOutput,
+ EditorConfig,
+ LexicalCommand,
+ LexicalNode,
+ NodeKey,
+ RangeSelection,
+ SerializedElementNode,
+} from 'lexical';
+
+import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ $getSelection,
+ $isElementNode,
+ $isRangeSelection,
+ createCommand,
+ ElementNode,
+ Spread,
+} from 'lexical';
+
+export type LinkAttributes = {
+ rel?: null | string;
+ target?: null | string;
+ title?: null | string;
+};
+
+export type AutoLinkAttributes = Partial<
+ Spread<LinkAttributes, {isUnlinked?: boolean}>
+>;
+
+export type SerializedLinkNode = Spread<
+ {
+ url: string;
+ },
+ Spread<LinkAttributes, SerializedElementNode>
+>;
+
+type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
+
+const SUPPORTED_URL_PROTOCOLS = new Set([
+ 'http:',
+ 'https:',
+ 'mailto:',
+ 'sms:',
+ 'tel:',
+]);
+
+/** @noInheritDoc */
+export class LinkNode extends ElementNode {
+ /** @internal */
+ __url: string;
+ /** @internal */
+ __target: null | string;
+ /** @internal */
+ __rel: null | string;
+ /** @internal */
+ __title: null | string;
+
+ static getType(): string {
+ return 'link';
+ }
+
+ static clone(node: LinkNode): LinkNode {
+ return new LinkNode(
+ node.__url,
+ {rel: node.__rel, target: node.__target, title: node.__title},
+ node.__key,
+ );
+ }
+
+ constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
+ super(key);
+ const {target = null, rel = null, title = null} = attributes;
+ this.__url = url;
+ this.__target = target;
+ this.__rel = rel;
+ this.__title = title;
+ }
+
+ createDOM(config: EditorConfig): LinkHTMLElementType {
+ const element = document.createElement('a');
+ element.href = this.sanitizeUrl(this.__url);
+ if (this.__target !== null) {
+ element.target = this.__target;
+ }
+ if (this.__rel !== null) {
+ element.rel = this.__rel;
+ }
+ if (this.__title !== null) {
+ element.title = this.__title;
+ }
+ addClassNamesToElement(element, config.theme.link);
+ return element;
+ }
+
+ updateDOM(
+ prevNode: LinkNode,
+ anchor: LinkHTMLElementType,
+ config: EditorConfig,
+ ): boolean {
+ if (anchor instanceof HTMLAnchorElement) {
+ const url = this.__url;
+ const target = this.__target;
+ const rel = this.__rel;
+ const title = this.__title;
+ if (url !== prevNode.__url) {
+ anchor.href = url;
+ }
+
+ if (target !== prevNode.__target) {
+ if (target) {
+ anchor.target = target;
+ } else {
+ anchor.removeAttribute('target');
+ }
+ }
+
+ if (rel !== prevNode.__rel) {
+ if (rel) {
+ anchor.rel = rel;
+ } else {
+ anchor.removeAttribute('rel');
+ }
+ }
+
+ if (title !== prevNode.__title) {
+ if (title) {
+ anchor.title = title;
+ } else {
+ anchor.removeAttribute('title');
+ }
+ }
+ }
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ a: (node: Node) => ({
+ conversion: $convertAnchorElement,
+ priority: 1,
+ }),
+ };
+ }
+
+ static importJSON(
+ serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
+ ): LinkNode {
+ const node = $createLinkNode(serializedNode.url, {
+ rel: serializedNode.rel,
+ target: serializedNode.target,
+ title: serializedNode.title,
+ });
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ sanitizeUrl(url: string): string {
+ try {
+ const parsedUrl = new URL(url);
+ // eslint-disable-next-line no-script-url
+ if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
+ return 'about:blank';
+ }
+ } catch {
+ return url;
+ }
+ return url;
+ }
+
+ exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
+ return {
+ ...super.exportJSON(),
+ rel: this.getRel(),
+ target: this.getTarget(),
+ title: this.getTitle(),
+ type: 'link',
+ url: this.getURL(),
+ version: 1,
+ };
+ }
+
+ getURL(): string {
+ return this.getLatest().__url;
+ }
+
+ setURL(url: string): void {
+ const writable = this.getWritable();
+ writable.__url = url;
+ }
+
+ getTarget(): null | string {
+ return this.getLatest().__target;
+ }
+
+ setTarget(target: null | string): void {
+ const writable = this.getWritable();
+ writable.__target = target;
+ }
+
+ getRel(): null | string {
+ return this.getLatest().__rel;
+ }
+
+ setRel(rel: null | string): void {
+ const writable = this.getWritable();
+ writable.__rel = rel;
+ }
+
+ getTitle(): null | string {
+ return this.getLatest().__title;
+ }
+
+ setTitle(title: null | string): void {
+ const writable = this.getWritable();
+ writable.__title = title;
+ }
+
+ insertNewAfter(
+ _: RangeSelection,
+ restoreSelection = true,
+ ): null | ElementNode {
+ const linkNode = $createLinkNode(this.__url, {
+ rel: this.__rel,
+ target: this.__target,
+ title: this.__title,
+ });
+ this.insertAfter(linkNode, restoreSelection);
+ return linkNode;
+ }
+
+ canInsertTextBefore(): false {
+ return false;
+ }
+
+ canInsertTextAfter(): false {
+ return false;
+ }
+
+ canBeEmpty(): false {
+ return false;
+ }
+
+ isInline(): true {
+ return true;
+ }
+
+ extractWithChild(
+ child: LexicalNode,
+ selection: BaseSelection,
+ destination: 'clone' | 'html',
+ ): boolean {
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ const focusNode = selection.focus.getNode();
+
+ return (
+ this.isParentOf(anchorNode) &&
+ this.isParentOf(focusNode) &&
+ selection.getTextContent().length > 0
+ );
+ }
+
+ isEmailURI(): boolean {
+ return this.__url.startsWith('mailto:');
+ }
+
+ isWebSiteURI(): boolean {
+ return (
+ this.__url.startsWith('https://') || this.__url.startsWith('http://')
+ );
+ }
+}
+
+function $convertAnchorElement(domNode: Node): DOMConversionOutput {
+ let node = null;
+ if (isHTMLAnchorElement(domNode)) {
+ const content = domNode.textContent;
+ if ((content !== null && content !== '') || domNode.children.length > 0) {
+ node = $createLinkNode(domNode.getAttribute('href') || '', {
+ rel: domNode.getAttribute('rel'),
+ target: domNode.getAttribute('target'),
+ title: domNode.getAttribute('title'),
+ });
+ }
+ }
+ return {node};
+}
+
+/**
+ * Takes a URL and creates a LinkNode.
+ * @param url - The URL the LinkNode should direct to.
+ * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
+ * @returns The LinkNode.
+ */
+export function $createLinkNode(
+ url: string,
+ attributes?: LinkAttributes,
+): LinkNode {
+ return $applyNodeReplacement(new LinkNode(url, attributes));
+}
+
+/**
+ * Determines if node is a LinkNode.
+ * @param node - The node to be checked.
+ * @returns true if node is a LinkNode, false otherwise.
+ */
+export function $isLinkNode(
+ node: LexicalNode | null | undefined,
+): node is LinkNode {
+ return node instanceof LinkNode;
+}
+
+export type SerializedAutoLinkNode = Spread<
+ {
+ isUnlinked: boolean;
+ },
+ SerializedLinkNode
+>;
+
+// Custom node type to override `canInsertTextAfter` that will
+// allow typing within the link
+export class AutoLinkNode extends LinkNode {
+ /** @internal */
+ /** Indicates whether the autolink was ever unlinked. **/
+ __isUnlinked: boolean;
+
+ constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
+ super(url, attributes, key);
+ this.__isUnlinked =
+ attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
+ ? attributes.isUnlinked
+ : false;
+ }
+
+ static getType(): string {
+ return 'autolink';
+ }
+
+ static clone(node: AutoLinkNode): AutoLinkNode {
+ return new AutoLinkNode(
+ node.__url,
+ {
+ isUnlinked: node.__isUnlinked,
+ rel: node.__rel,
+ target: node.__target,
+ title: node.__title,
+ },
+ node.__key,
+ );
+ }
+
+ getIsUnlinked(): boolean {
+ return this.__isUnlinked;
+ }
+
+ setIsUnlinked(value: boolean) {
+ const self = this.getWritable();
+ self.__isUnlinked = value;
+ return self;
+ }
+
+ createDOM(config: EditorConfig): LinkHTMLElementType {
+ if (this.__isUnlinked) {
+ return document.createElement('span');
+ } else {
+ return super.createDOM(config);
+ }
+ }
+
+ updateDOM(
+ prevNode: AutoLinkNode,
+ anchor: LinkHTMLElementType,
+ config: EditorConfig,
+ ): boolean {
+ return (
+ super.updateDOM(prevNode, anchor, config) ||
+ prevNode.__isUnlinked !== this.__isUnlinked
+ );
+ }
+
+ static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
+ const node = $createAutoLinkNode(serializedNode.url, {
+ isUnlinked: serializedNode.isUnlinked,
+ rel: serializedNode.rel,
+ target: serializedNode.target,
+ title: serializedNode.title,
+ });
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ static importDOM(): null {
+ // TODO: Should link node should handle the import over autolink?
+ return null;
+ }
+
+ exportJSON(): SerializedAutoLinkNode {
+ return {
+ ...super.exportJSON(),
+ isUnlinked: this.__isUnlinked,
+ type: 'autolink',
+ version: 1,
+ };
+ }
+
+ insertNewAfter(
+ selection: RangeSelection,
+ restoreSelection = true,
+ ): null | ElementNode {
+ const element = this.getParentOrThrow().insertNewAfter(
+ selection,
+ restoreSelection,
+ );
+ if ($isElementNode(element)) {
+ const linkNode = $createAutoLinkNode(this.__url, {
+ isUnlinked: this.__isUnlinked,
+ rel: this.__rel,
+ target: this.__target,
+ title: this.__title,
+ });
+ element.append(linkNode);
+ return linkNode;
+ }
+ return null;
+ }
+}
+
+/**
+ * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
+ * during typing, which is especially useful when a button to generate a LinkNode is not practical.
+ * @param url - The URL the LinkNode should direct to.
+ * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
+ * @returns The LinkNode.
+ */
+export function $createAutoLinkNode(
+ url: string,
+ attributes?: AutoLinkAttributes,
+): AutoLinkNode {
+ return $applyNodeReplacement(new AutoLinkNode(url, attributes));
+}
+
+/**
+ * Determines if node is an AutoLinkNode.
+ * @param node - The node to be checked.
+ * @returns true if node is an AutoLinkNode, false otherwise.
+ */
+export function $isAutoLinkNode(
+ node: LexicalNode | null | undefined,
+): node is AutoLinkNode {
+ return node instanceof AutoLinkNode;
+}
+
+export const TOGGLE_LINK_COMMAND: LexicalCommand<
+ string | ({url: string} & LinkAttributes) | null
+> = createCommand('TOGGLE_LINK_COMMAND');
+
+/**
+ * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
+ * but saves any children and brings them up to the parent node.
+ * @param url - The URL the link directs to.
+ * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
+ */
+export function $toggleLink(
+ url: null | string,
+ attributes: LinkAttributes = {},
+): void {
+ const {target, title} = attributes;
+ const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ const nodes = selection.extract();
+
+ if (url === null) {
+ // Remove LinkNodes
+ nodes.forEach((node) => {
+ const parent = node.getParent();
+
+ if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
+ const children = parent.getChildren();
+
+ for (let i = 0; i < children.length; i++) {
+ parent.insertBefore(children[i]);
+ }
+
+ parent.remove();
+ }
+ });
+ } else {
+ // Add or merge LinkNodes
+ if (nodes.length === 1) {
+ const firstNode = nodes[0];
+ // if the first node is a LinkNode or if its
+ // parent is a LinkNode, we update the URL, target and rel.
+ const linkNode = $getAncestor(firstNode, $isLinkNode);
+ if (linkNode !== null) {
+ linkNode.setURL(url);
+ if (target !== undefined) {
+ linkNode.setTarget(target);
+ }
+ if (rel !== null) {
+ linkNode.setRel(rel);
+ }
+ if (title !== undefined) {
+ linkNode.setTitle(title);
+ }
+ return;
+ }
+ }
+
+ let prevParent: ElementNode | LinkNode | null = null;
+ let linkNode: LinkNode | null = null;
+
+ nodes.forEach((node) => {
+ const parent = node.getParent();
+
+ if (
+ parent === linkNode ||
+ parent === null ||
+ ($isElementNode(node) && !node.isInline())
+ ) {
+ return;
+ }
+
+ if ($isLinkNode(parent)) {
+ linkNode = parent;
+ parent.setURL(url);
+ if (target !== undefined) {
+ parent.setTarget(target);
+ }
+ if (rel !== null) {
+ linkNode.setRel(rel);
+ }
+ if (title !== undefined) {
+ linkNode.setTitle(title);
+ }
+ return;
+ }
+
+ if (!parent.is(prevParent)) {
+ prevParent = parent;
+ linkNode = $createLinkNode(url, {rel, target, title});
+
+ if ($isLinkNode(parent)) {
+ if (node.getPreviousSibling() === null) {
+ parent.insertBefore(linkNode);
+ } else {
+ parent.insertAfter(linkNode);
+ }
+ } else {
+ node.insertBefore(linkNode);
+ }
+ }
+
+ if ($isLinkNode(node)) {
+ if (node.is(linkNode)) {
+ return;
+ }
+ if (linkNode !== null) {
+ const children = node.getChildren();
+
+ for (let i = 0; i < children.length; i++) {
+ linkNode.append(children[i]);
+ }
+ }
+
+ node.remove();
+ return;
+ }
+
+ if (linkNode !== null) {
+ linkNode.append(node);
+ }
+ });
+ }
+}
+/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
+export const toggleLink = $toggleLink;
+
+function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
+ node: LexicalNode,
+ predicate: (ancestor: LexicalNode) => ancestor is NodeType,
+) {
+ let parent = node;
+ while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
+ parent = parent.getParentOrThrow();
+ }
+ return predicate(parent) ? parent : null;
+}
--- /dev/null
+/**
+ * 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 {ListNode, ListType} from './';
+import type {
+ BaseSelection,
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ EditorThemeClasses,
+ LexicalNode,
+ NodeKey,
+ ParagraphNode,
+ RangeSelection,
+ SerializedElementNode,
+ Spread,
+} from 'lexical';
+
+import {
+ addClassNamesToElement,
+ removeClassNamesFromElement,
+} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $isElementNode,
+ $isParagraphNode,
+ $isRangeSelection,
+ ElementNode,
+ LexicalEditor,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import normalizeClassNames from 'lexical/shared/normalizeClassNames';
+
+import {$createListNode, $isListNode} from './';
+import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
+import {isNestedListNode} from './utils';
+
+export type SerializedListItemNode = Spread<
+ {
+ checked: boolean | undefined;
+ value: number;
+ },
+ SerializedElementNode
+>;
+
+/** @noInheritDoc */
+export class ListItemNode extends ElementNode {
+ /** @internal */
+ __value: number;
+ /** @internal */
+ __checked?: boolean;
+
+ static getType(): string {
+ return 'listitem';
+ }
+
+ static clone(node: ListItemNode): ListItemNode {
+ return new ListItemNode(node.__value, node.__checked, node.__key);
+ }
+
+ constructor(value?: number, checked?: boolean, key?: NodeKey) {
+ super(key);
+ this.__value = value === undefined ? 1 : value;
+ this.__checked = checked;
+ }
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const element = document.createElement('li');
+ const parent = this.getParent();
+ if ($isListNode(parent) && parent.getListType() === 'check') {
+ updateListItemChecked(element, this, null, parent);
+ }
+ element.value = this.__value;
+ $setListItemThemeClassNames(element, config.theme, this);
+ return element;
+ }
+
+ updateDOM(
+ prevNode: ListItemNode,
+ dom: HTMLElement,
+ config: EditorConfig,
+ ): boolean {
+ const parent = this.getParent();
+ if ($isListNode(parent) && parent.getListType() === 'check') {
+ updateListItemChecked(dom, this, prevNode, parent);
+ }
+ // @ts-expect-error - this is always HTMLListItemElement
+ dom.value = this.__value;
+ $setListItemThemeClassNames(dom, config.theme, this);
+
+ return false;
+ }
+
+ static transform(): (node: LexicalNode) => void {
+ return (node: LexicalNode) => {
+ invariant($isListItemNode(node), 'node is not a ListItemNode');
+ if (node.__checked == null) {
+ return;
+ }
+ const parent = node.getParent();
+ if ($isListNode(parent)) {
+ if (parent.getListType() !== 'check' && node.getChecked() != null) {
+ node.setChecked(undefined);
+ }
+ }
+ };
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ li: () => ({
+ conversion: $convertListItemElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
+ const node = $createListItemNode();
+ node.setChecked(serializedNode.checked);
+ node.setValue(serializedNode.value);
+ node.setFormat(serializedNode.format);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const element = this.createDOM(editor._config);
+ element.style.textAlign = this.getFormatType();
+ return {
+ element,
+ };
+ }
+
+ exportJSON(): SerializedListItemNode {
+ return {
+ ...super.exportJSON(),
+ checked: this.getChecked(),
+ type: 'listitem',
+ value: this.getValue(),
+ version: 1,
+ };
+ }
+
+ append(...nodes: LexicalNode[]): this {
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+
+ if ($isElementNode(node) && this.canMergeWith(node)) {
+ const children = node.getChildren();
+ this.append(...children);
+ node.remove();
+ } else {
+ super.append(node);
+ }
+ }
+
+ return this;
+ }
+
+ replace<N extends LexicalNode>(
+ replaceWithNode: N,
+ includeChildren?: boolean,
+ ): N {
+ if ($isListItemNode(replaceWithNode)) {
+ return super.replace(replaceWithNode);
+ }
+ this.setIndent(0);
+ const list = this.getParentOrThrow();
+ if (!$isListNode(list)) {
+ return replaceWithNode;
+ }
+ if (list.__first === this.getKey()) {
+ list.insertBefore(replaceWithNode);
+ } else if (list.__last === this.getKey()) {
+ list.insertAfter(replaceWithNode);
+ } else {
+ // Split the list
+ const newList = $createListNode(list.getListType());
+ let nextSibling = this.getNextSibling();
+ while (nextSibling) {
+ const nodeToAppend = nextSibling;
+ nextSibling = nextSibling.getNextSibling();
+ newList.append(nodeToAppend);
+ }
+ list.insertAfter(replaceWithNode);
+ replaceWithNode.insertAfter(newList);
+ }
+ if (includeChildren) {
+ invariant(
+ $isElementNode(replaceWithNode),
+ 'includeChildren should only be true for ElementNodes',
+ );
+ this.getChildren().forEach((child: LexicalNode) => {
+ replaceWithNode.append(child);
+ });
+ }
+ this.remove();
+ if (list.getChildrenSize() === 0) {
+ list.remove();
+ }
+ return replaceWithNode;
+ }
+
+ insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
+ const listNode = this.getParentOrThrow();
+
+ if (!$isListNode(listNode)) {
+ invariant(
+ false,
+ 'insertAfter: list node is not parent of list item node',
+ );
+ }
+
+ if ($isListItemNode(node)) {
+ return super.insertAfter(node, restoreSelection);
+ }
+
+ const siblings = this.getNextSiblings();
+
+ // Split the lists and insert the node in between them
+ listNode.insertAfter(node, restoreSelection);
+
+ if (siblings.length !== 0) {
+ const newListNode = $createListNode(listNode.getListType());
+
+ siblings.forEach((sibling) => newListNode.append(sibling));
+
+ node.insertAfter(newListNode, restoreSelection);
+ }
+
+ return node;
+ }
+
+ remove(preserveEmptyParent?: boolean): void {
+ const prevSibling = this.getPreviousSibling();
+ const nextSibling = this.getNextSibling();
+ super.remove(preserveEmptyParent);
+
+ if (
+ prevSibling &&
+ nextSibling &&
+ isNestedListNode(prevSibling) &&
+ isNestedListNode(nextSibling)
+ ) {
+ mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
+ nextSibling.remove();
+ }
+ }
+
+ insertNewAfter(
+ _: RangeSelection,
+ restoreSelection = true,
+ ): ListItemNode | ParagraphNode {
+ const newElement = $createListItemNode(
+ this.__checked == null ? undefined : false,
+ );
+ this.insertAfter(newElement, restoreSelection);
+
+ return newElement;
+ }
+
+ collapseAtStart(selection: RangeSelection): true {
+ const paragraph = $createParagraphNode();
+ const children = this.getChildren();
+ children.forEach((child) => paragraph.append(child));
+ const listNode = this.getParentOrThrow();
+ const listNodeParent = listNode.getParentOrThrow();
+ const isIndented = $isListItemNode(listNodeParent);
+
+ if (listNode.getChildrenSize() === 1) {
+ if (isIndented) {
+ // if the list node is nested, we just want to remove it,
+ // effectively unindenting it.
+ listNode.remove();
+ listNodeParent.select();
+ } else {
+ listNode.insertBefore(paragraph);
+ listNode.remove();
+ // If we have selection on the list item, we'll need to move it
+ // to the paragraph
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const key = paragraph.getKey();
+
+ if (anchor.type === 'element' && anchor.getNode().is(this)) {
+ anchor.set(key, anchor.offset, 'element');
+ }
+
+ if (focus.type === 'element' && focus.getNode().is(this)) {
+ focus.set(key, focus.offset, 'element');
+ }
+ }
+ } else {
+ listNode.insertBefore(paragraph);
+ this.remove();
+ }
+
+ return true;
+ }
+
+ getValue(): number {
+ const self = this.getLatest();
+
+ return self.__value;
+ }
+
+ setValue(value: number): void {
+ const self = this.getWritable();
+ self.__value = value;
+ }
+
+ getChecked(): boolean | undefined {
+ const self = this.getLatest();
+
+ let listType: ListType | undefined;
+
+ const parent = this.getParent();
+ if ($isListNode(parent)) {
+ listType = parent.getListType();
+ }
+
+ return listType === 'check' ? Boolean(self.__checked) : undefined;
+ }
+
+ setChecked(checked?: boolean): void {
+ const self = this.getWritable();
+ self.__checked = checked;
+ }
+
+ toggleChecked(): void {
+ this.setChecked(!this.__checked);
+ }
+
+ getIndent(): number {
+ // If we don't have a parent, we are likely serializing
+ const parent = this.getParent();
+ if (parent === null) {
+ return this.getLatest().__indent;
+ }
+ // ListItemNode should always have a ListNode for a parent.
+ let listNodeParent = parent.getParentOrThrow();
+ let indentLevel = 0;
+ while ($isListItemNode(listNodeParent)) {
+ listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
+ indentLevel++;
+ }
+
+ return indentLevel;
+ }
+
+ setIndent(indent: number): this {
+ invariant(typeof indent === 'number', 'Invalid indent value.');
+ indent = Math.floor(indent);
+ invariant(indent >= 0, 'Indent value must be non-negative.');
+ let currentIndent = this.getIndent();
+ while (currentIndent !== indent) {
+ if (currentIndent < indent) {
+ $handleIndent(this);
+ currentIndent++;
+ } else {
+ $handleOutdent(this);
+ currentIndent--;
+ }
+ }
+
+ return this;
+ }
+
+ /** @deprecated @internal */
+ canInsertAfter(node: LexicalNode): boolean {
+ return $isListItemNode(node);
+ }
+
+ /** @deprecated @internal */
+ canReplaceWith(replacement: LexicalNode): boolean {
+ return $isListItemNode(replacement);
+ }
+
+ canMergeWith(node: LexicalNode): boolean {
+ return $isParagraphNode(node) || $isListItemNode(node);
+ }
+
+ extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ const focusNode = selection.focus.getNode();
+
+ return (
+ this.isParentOf(anchorNode) &&
+ this.isParentOf(focusNode) &&
+ this.getTextContent().length === selection.getTextContent().length
+ );
+ }
+
+ isParentRequired(): true {
+ return true;
+ }
+
+ createParentElementNode(): ElementNode {
+ return $createListNode('bullet');
+ }
+
+ canMergeWhenEmpty(): true {
+ return true;
+ }
+}
+
+function $setListItemThemeClassNames(
+ dom: HTMLElement,
+ editorThemeClasses: EditorThemeClasses,
+ node: ListItemNode,
+): void {
+ const classesToAdd = [];
+ const classesToRemove = [];
+ const listTheme = editorThemeClasses.list;
+ const listItemClassName = listTheme ? listTheme.listitem : undefined;
+ let nestedListItemClassName;
+
+ if (listTheme && listTheme.nested) {
+ nestedListItemClassName = listTheme.nested.listitem;
+ }
+
+ if (listItemClassName !== undefined) {
+ classesToAdd.push(...normalizeClassNames(listItemClassName));
+ }
+
+ if (listTheme) {
+ const parentNode = node.getParent();
+ const isCheckList =
+ $isListNode(parentNode) && parentNode.getListType() === 'check';
+ const checked = node.getChecked();
+
+ if (!isCheckList || checked) {
+ classesToRemove.push(listTheme.listitemUnchecked);
+ }
+
+ if (!isCheckList || !checked) {
+ classesToRemove.push(listTheme.listitemChecked);
+ }
+
+ if (isCheckList) {
+ classesToAdd.push(
+ checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
+ );
+ }
+ }
+
+ if (nestedListItemClassName !== undefined) {
+ const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
+
+ if (node.getChildren().some((child) => $isListNode(child))) {
+ classesToAdd.push(...nestedListItemClasses);
+ } else {
+ classesToRemove.push(...nestedListItemClasses);
+ }
+ }
+
+ if (classesToRemove.length > 0) {
+ removeClassNamesFromElement(dom, ...classesToRemove);
+ }
+
+ if (classesToAdd.length > 0) {
+ addClassNamesToElement(dom, ...classesToAdd);
+ }
+}
+
+function updateListItemChecked(
+ dom: HTMLElement,
+ listItemNode: ListItemNode,
+ prevListItemNode: ListItemNode | null,
+ listNode: ListNode,
+): void {
+ // Only add attributes for leaf list items
+ if ($isListNode(listItemNode.getFirstChild())) {
+ dom.removeAttribute('role');
+ dom.removeAttribute('tabIndex');
+ dom.removeAttribute('aria-checked');
+ } else {
+ dom.setAttribute('role', 'checkbox');
+ dom.setAttribute('tabIndex', '-1');
+
+ if (
+ !prevListItemNode ||
+ listItemNode.__checked !== prevListItemNode.__checked
+ ) {
+ dom.setAttribute(
+ 'aria-checked',
+ listItemNode.getChecked() ? 'true' : 'false',
+ );
+ }
+ }
+}
+
+function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
+ const isGitHubCheckList = domNode.classList.contains('task-list-item');
+ if (isGitHubCheckList) {
+ for (const child of domNode.children) {
+ if (child.tagName === 'INPUT') {
+ return $convertCheckboxInput(child);
+ }
+ }
+ }
+
+ const ariaCheckedAttr = domNode.getAttribute('aria-checked');
+ const checked =
+ ariaCheckedAttr === 'true'
+ ? true
+ : ariaCheckedAttr === 'false'
+ ? false
+ : undefined;
+ return {node: $createListItemNode(checked)};
+}
+
+function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
+ const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
+ if (!isCheckboxInput) {
+ return {node: null};
+ }
+ const checked = domNode.hasAttribute('checked');
+ return {node: $createListItemNode(checked)};
+}
+
+/**
+ * Creates a new List Item node, passing true/false will convert it to a checkbox input.
+ * @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively.
+ * @returns The new List Item.
+ */
+export function $createListItemNode(checked?: boolean): ListItemNode {
+ return $applyNodeReplacement(new ListItemNode(undefined, checked));
+}
+
+/**
+ * Checks to see if the node is a ListItemNode.
+ * @param node - The node to be checked.
+ * @returns true if the node is a ListItemNode, false otherwise.
+ */
+export function $isListItemNode(
+ node: LexicalNode | null | undefined,
+): node is ListItemNode {
+ return node instanceof ListItemNode;
+}
--- /dev/null
+/**
+ * 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 {
+ addClassNamesToElement,
+ isHTMLElement,
+ removeClassNamesFromElement,
+} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ $createTextNode,
+ $isElementNode,
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ EditorThemeClasses,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ SerializedElementNode,
+ Spread,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import normalizeClassNames from 'lexical/shared/normalizeClassNames';
+
+import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
+import {
+ mergeNextSiblingListIfSameType,
+ updateChildrenListItemValue,
+} from './formatList';
+import {$getListDepth, $wrapInListItem} from './utils';
+
+export type SerializedListNode = Spread<
+ {
+ listType: ListType;
+ start: number;
+ tag: ListNodeTagType;
+ },
+ SerializedElementNode
+>;
+
+export type ListType = 'number' | 'bullet' | 'check';
+
+export type ListNodeTagType = 'ul' | 'ol';
+
+/** @noInheritDoc */
+export class ListNode extends ElementNode {
+ /** @internal */
+ __tag: ListNodeTagType;
+ /** @internal */
+ __start: number;
+ /** @internal */
+ __listType: ListType;
+
+ static getType(): string {
+ return 'list';
+ }
+
+ static clone(node: ListNode): ListNode {
+ const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
+
+ return new ListNode(listType, node.__start, node.__key);
+ }
+
+ constructor(listType: ListType, start: number, key?: NodeKey) {
+ super(key);
+ const _listType = TAG_TO_LIST_TYPE[listType] || listType;
+ this.__listType = _listType;
+ this.__tag = _listType === 'number' ? 'ol' : 'ul';
+ this.__start = start;
+ }
+
+ getTag(): ListNodeTagType {
+ return this.__tag;
+ }
+
+ setListType(type: ListType): void {
+ const writable = this.getWritable();
+ writable.__listType = type;
+ writable.__tag = type === 'number' ? 'ol' : 'ul';
+ }
+
+ getListType(): ListType {
+ return this.__listType;
+ }
+
+ getStart(): number {
+ return this.__start;
+ }
+
+ // View
+
+ createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
+ const tag = this.__tag;
+ const dom = document.createElement(tag);
+
+ if (this.__start !== 1) {
+ dom.setAttribute('start', String(this.__start));
+ }
+ // @ts-expect-error Internal field.
+ dom.__lexicalListType = this.__listType;
+ $setListThemeClassNames(dom, config.theme, this);
+
+ return dom;
+ }
+
+ updateDOM(
+ prevNode: ListNode,
+ dom: HTMLElement,
+ config: EditorConfig,
+ ): boolean {
+ if (prevNode.__tag !== this.__tag) {
+ return true;
+ }
+
+ $setListThemeClassNames(dom, config.theme, this);
+
+ return false;
+ }
+
+ static transform(): (node: LexicalNode) => void {
+ return (node: LexicalNode) => {
+ invariant($isListNode(node), 'node is not a ListNode');
+ mergeNextSiblingListIfSameType(node);
+ updateChildrenListItemValue(node);
+ };
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ ol: () => ({
+ conversion: $convertListNode,
+ priority: 0,
+ }),
+ ul: () => ({
+ conversion: $convertListNode,
+ priority: 0,
+ }),
+ };
+ }
+
+ static importJSON(serializedNode: SerializedListNode): ListNode {
+ const node = $createListNode(serializedNode.listType, serializedNode.start);
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const {element} = super.exportDOM(editor);
+ if (element && isHTMLElement(element)) {
+ if (this.__start !== 1) {
+ element.setAttribute('start', String(this.__start));
+ }
+ if (this.__listType === 'check') {
+ element.setAttribute('__lexicalListType', 'check');
+ }
+ }
+ return {
+ element,
+ };
+ }
+
+ exportJSON(): SerializedListNode {
+ return {
+ ...super.exportJSON(),
+ listType: this.getListType(),
+ start: this.getStart(),
+ tag: this.getTag(),
+ type: 'list',
+ version: 1,
+ };
+ }
+
+ canBeEmpty(): false {
+ return false;
+ }
+
+ canIndent(): false {
+ return false;
+ }
+
+ append(...nodesToAppend: LexicalNode[]): this {
+ for (let i = 0; i < nodesToAppend.length; i++) {
+ const currentNode = nodesToAppend[i];
+
+ if ($isListItemNode(currentNode)) {
+ super.append(currentNode);
+ } else {
+ const listItemNode = $createListItemNode();
+
+ if ($isListNode(currentNode)) {
+ listItemNode.append(currentNode);
+ } else if ($isElementNode(currentNode)) {
+ const textNode = $createTextNode(currentNode.getTextContent());
+ listItemNode.append(textNode);
+ } else {
+ listItemNode.append(currentNode);
+ }
+ super.append(listItemNode);
+ }
+ }
+ return this;
+ }
+
+ extractWithChild(child: LexicalNode): boolean {
+ return $isListItemNode(child);
+ }
+}
+
+function $setListThemeClassNames(
+ dom: HTMLElement,
+ editorThemeClasses: EditorThemeClasses,
+ node: ListNode,
+): void {
+ const classesToAdd = [];
+ const classesToRemove = [];
+ const listTheme = editorThemeClasses.list;
+
+ if (listTheme !== undefined) {
+ const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
+ const listDepth = $getListDepth(node) - 1;
+ const normalizedListDepth = listDepth % listLevelsClassNames.length;
+ const listLevelClassName = listLevelsClassNames[normalizedListDepth];
+ const listClassName = listTheme[node.__tag];
+ let nestedListClassName;
+ const nestedListTheme = listTheme.nested;
+ const checklistClassName = listTheme.checklist;
+
+ if (nestedListTheme !== undefined && nestedListTheme.list) {
+ nestedListClassName = nestedListTheme.list;
+ }
+
+ if (listClassName !== undefined) {
+ classesToAdd.push(listClassName);
+ }
+
+ if (checklistClassName !== undefined && node.__listType === 'check') {
+ classesToAdd.push(checklistClassName);
+ }
+
+ if (listLevelClassName !== undefined) {
+ classesToAdd.push(...normalizeClassNames(listLevelClassName));
+ for (let i = 0; i < listLevelsClassNames.length; i++) {
+ if (i !== normalizedListDepth) {
+ classesToRemove.push(node.__tag + i);
+ }
+ }
+ }
+
+ if (nestedListClassName !== undefined) {
+ const nestedListItemClasses = normalizeClassNames(nestedListClassName);
+
+ if (listDepth > 1) {
+ classesToAdd.push(...nestedListItemClasses);
+ } else {
+ classesToRemove.push(...nestedListItemClasses);
+ }
+ }
+ }
+
+ if (classesToRemove.length > 0) {
+ removeClassNamesFromElement(dom, ...classesToRemove);
+ }
+
+ if (classesToAdd.length > 0) {
+ addClassNamesToElement(dom, ...classesToAdd);
+ }
+}
+
+/*
+ * This function normalizes the children of a ListNode after the conversion from HTML,
+ * ensuring that they are all ListItemNodes and contain either a single nested ListNode
+ * or some other inline content.
+ */
+function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
+ const normalizedListItems: Array<ListItemNode> = [];
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ if ($isListItemNode(node)) {
+ normalizedListItems.push(node);
+ const children = node.getChildren();
+ if (children.length > 1) {
+ children.forEach((child) => {
+ if ($isListNode(child)) {
+ normalizedListItems.push($wrapInListItem(child));
+ }
+ });
+ }
+ } else {
+ normalizedListItems.push($wrapInListItem(node));
+ }
+ }
+ return normalizedListItems;
+}
+
+function isDomChecklist(domNode: HTMLElement) {
+ if (
+ domNode.getAttribute('__lexicallisttype') === 'check' ||
+ // is github checklist
+ domNode.classList.contains('contains-task-list')
+ ) {
+ return true;
+ }
+ // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
+ for (const child of domNode.childNodes) {
+ if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
+ const nodeName = domNode.nodeName.toLowerCase();
+ let node = null;
+ if (nodeName === 'ol') {
+ // @ts-ignore
+ const start = domNode.start;
+ node = $createListNode('number', start);
+ } else if (nodeName === 'ul') {
+ if (isDomChecklist(domNode)) {
+ node = $createListNode('check');
+ } else {
+ node = $createListNode('bullet');
+ }
+ }
+
+ return {
+ after: $normalizeChildren,
+ node,
+ };
+}
+
+const TAG_TO_LIST_TYPE: Record<string, ListType> = {
+ ol: 'number',
+ ul: 'bullet',
+};
+
+/**
+ * Creates a ListNode of listType.
+ * @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
+ * @param start - Where an ordered list starts its count, start = 1 if left undefined.
+ * @returns The new ListNode
+ */
+export function $createListNode(listType: ListType, start = 1): ListNode {
+ return $applyNodeReplacement(new ListNode(listType, start));
+}
+
+/**
+ * Checks to see if the node is a ListNode.
+ * @param node - The node to be checked.
+ * @returns true if the node is a ListNode, false otherwise.
+ */
+export function $isListNode(
+ node: LexicalNode | null | undefined,
+): node is ListNode {
+ return node instanceof ListNode;
+}
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createRangeSelection,
+ $getRoot,
+ TextNode,
+} from 'lexical';
+import {
+ expectHtmlToBeEqual,
+ html,
+ initializeUnitTest,
+} from 'lexical/src/__tests__/utils';
+
+import {
+ $createListItemNode,
+ $isListItemNode,
+ ListItemNode,
+ ListNode,
+} from '../..';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ list: {
+ listitem: 'my-listItem-item-class',
+ nested: {
+ listitem: 'my-nested-list-listItem-class',
+ },
+ },
+ },
+});
+
+describe('LexicalListItemNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('ListItemNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listItemNode = new ListItemNode();
+
+ expect(listItemNode.getType()).toBe('listitem');
+
+ expect(listItemNode.getTextContent()).toBe('');
+ });
+
+ expect(() => new ListItemNode()).toThrow();
+ });
+
+ test('ListItemNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listItemNode = new ListItemNode();
+
+ expectHtmlToBeEqual(
+ listItemNode.createDOM(editorConfig).outerHTML,
+ html`
+ <li class="my-listItem-item-class" value="1"></li>
+ `,
+ );
+
+ expectHtmlToBeEqual(
+ listItemNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ html`
+ <li value="1"></li>
+ `,
+ );
+ });
+ });
+
+ describe('ListItemNode.updateDOM()', () => {
+ test('base', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listItemNode = new ListItemNode();
+
+ const domElement = listItemNode.createDOM(editorConfig);
+
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+ <li class="my-listItem-item-class" value="1"></li>
+ `,
+ );
+ const newListItemNode = new ListItemNode();
+
+ const result = newListItemNode.updateDOM(
+ listItemNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+ <li class="my-listItem-item-class" value="1"></li>
+ `,
+ );
+ });
+ });
+
+ test('nested list', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const parentListNode = new ListNode('bullet', 1);
+ const parentlistItemNode = new ListItemNode();
+
+ parentListNode.append(parentlistItemNode);
+ const domElement = parentlistItemNode.createDOM(editorConfig);
+
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+ <li class="my-listItem-item-class" value="1"></li>
+ `,
+ );
+ const nestedListNode = new ListNode('bullet', 1);
+ nestedListNode.append(new ListItemNode());
+ parentlistItemNode.append(nestedListNode);
+ const result = parentlistItemNode.updateDOM(
+ parentlistItemNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(false);
+
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+ <li
+ class="my-listItem-item-class my-nested-list-listItem-class"
+ value="1"></li>
+ `,
+ );
+ });
+ });
+ });
+
+ describe('ListItemNode.replace()', () => {
+ let listNode: ListNode;
+ let listItemNode1: ListItemNode;
+ let listItemNode2: ListItemNode;
+ let listItemNode3: ListItemNode;
+
+ beforeEach(async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ listNode = new ListNode('bullet', 1);
+ listItemNode1 = new ListItemNode();
+
+ listItemNode1.append(new TextNode('one'));
+ listItemNode2 = new ListItemNode();
+
+ listItemNode2.append(new TextNode('two'));
+ listItemNode3 = new ListItemNode();
+
+ listItemNode3.append(new TextNode('three'));
+ root.append(listNode);
+ listNode.append(listItemNode1, listItemNode2, listItemNode3);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('another list item node', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const newListItemNode = new ListItemNode();
+
+ newListItemNode.append(new TextNode('bar'));
+ listItemNode1.replace(newListItemNode);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">bar</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('first list item with a non list item node', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ return;
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => {
+ const paragraphNode = $createParagraphNode();
+ listItemNode1.replace(paragraphNode);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <p><br /></p>
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('last list item with a non list item node', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = $createParagraphNode();
+ listItemNode3.replace(paragraphNode);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ </ul>
+ <p><br /></p>
+ </div>
+ `,
+ );
+ });
+
+ test('middle list item with a non list item node', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const paragraphNode = $createParagraphNode();
+ listItemNode2.replace(paragraphNode);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ </ul>
+ <p><br /></p>
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('the only list item with a non list item node', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode2.remove();
+ listItemNode3.remove();
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => {
+ const paragraphNode = $createParagraphNode();
+ listItemNode1.replace(paragraphNode);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <p><br /></p>
+ </div>
+ `,
+ );
+ });
+ });
+
+ describe('ListItemNode.remove()', () => {
+ // - A
+ // - x
+ // - B
+ test('siblings are not nested', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ A_listItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ B_listItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B
+ test('the previous sibling is nested', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem);
+ A_nestedListItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ B_listItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B
+ test('the next sibling is nested', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ A_listItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem);
+ B_nestedListItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li value="3">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B
+ test('both siblings are nested', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem);
+ A_nestedListItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem);
+ B_nestedListItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ // - A1
+ // - A2
+ // - x
+ // - B
+ test('the previous sibling is nested deeper than the next sibling', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem1 = new ListItemNode();
+ const A_nestedListItem2 = new ListItemNode();
+ const A_deeplyNestedList = new ListNode('bullet', 1);
+ const A_deeplyNestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem1);
+ A_nestedList.append(A_nestedListItem2);
+ A_nestedListItem1.append(new TextNode('A1'));
+ A_nestedListItem2.append(A_deeplyNestedList);
+ A_deeplyNestedList.append(A_deeplyNestedListItem);
+ A_deeplyNestedListItem.append(new TextNode('A2'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedlistItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedlistItem);
+ B_nestedlistItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A1</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A2</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A1</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A2</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B1
+ // - B2
+ test('the next sibling is nested deeper than the previous sibling', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem);
+ A_nestedListItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem1 = new ListItemNode();
+ const B_nestedListItem2 = new ListItemNode();
+ const B_deeplyNestedList = new ListNode('bullet', 1);
+ const B_deeplyNestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem1);
+ B_nestedList.append(B_nestedListItem2);
+ B_nestedListItem1.append(B_deeplyNestedList);
+ B_nestedListItem2.append(new TextNode('B2'));
+ B_deeplyNestedList.append(B_deeplyNestedListItem);
+ B_deeplyNestedListItem.append(new TextNode('B1'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B1</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B2</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B1</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B2</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ // - A1
+ // - A2
+ // - x
+ // - B1
+ // - B2
+ test('both siblings are deeply nested', async () => {
+ const {editor} = testEnv;
+ let x: ListItemNode;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem1 = new ListItemNode();
+ const A_nestedListItem2 = new ListItemNode();
+ const A_deeplyNestedList = new ListNode('bullet', 1);
+ const A_deeplyNestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem1);
+ A_nestedList.append(A_nestedListItem2);
+ A_nestedListItem1.append(new TextNode('A1'));
+ A_nestedListItem2.append(A_deeplyNestedList);
+ A_deeplyNestedList.append(A_deeplyNestedListItem);
+ A_deeplyNestedListItem.append(new TextNode('A2'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem1 = new ListItemNode();
+ const B_nestedListItem2 = new ListItemNode();
+ const B_deeplyNestedList = new ListNode('bullet', 1);
+ const B_deeplyNestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem1);
+ B_nestedList.append(B_nestedListItem2);
+ B_nestedListItem1.append(B_deeplyNestedList);
+ B_nestedListItem2.append(new TextNode('B2'));
+ B_deeplyNestedList.append(B_deeplyNestedListItem);
+ B_deeplyNestedListItem.append(new TextNode('B1'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A1</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A2</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">x</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B1</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">B2</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A1</span>
+ </li>
+ <li value="2">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">A2</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B1</span>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">B2</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+ });
+
+ describe('ListItemNode.insertNewAfter(): non-empty list items', () => {
+ let listNode: ListNode;
+ let listItemNode1: ListItemNode;
+ let listItemNode2: ListItemNode;
+ let listItemNode3: ListItemNode;
+
+ beforeEach(async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ listNode = new ListNode('bullet', 1);
+ listItemNode1 = new ListItemNode();
+
+ listItemNode2 = new ListItemNode();
+
+ listItemNode3 = new ListItemNode();
+
+ root.append(listNode);
+ listNode.append(listItemNode1, listItemNode2, listItemNode3);
+ listItemNode1.append(new TextNode('one'));
+ listItemNode2.append(new TextNode('two'));
+ listItemNode3.append(new TextNode('three'));
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('first list item', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode1.insertNewAfter($createRangeSelection());
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li value="2"><br /></li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="4">
+ <span data-lexical-text="true">three</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('last list item', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode3.insertNewAfter($createRangeSelection());
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">three</span>
+ </li>
+ <li value="4"><br /></li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('middle list item', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode3.insertNewAfter($createRangeSelection());
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ <li dir="ltr" value="3">
+ <span data-lexical-text="true">three</span>
+ </li>
+ <li value="4"><br /></li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+
+ test('the only list item', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode2.remove();
+ listItemNode3.remove();
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ </ul>
+ </div>
+ `,
+ );
+
+ await editor.update(() => {
+ listItemNode1.insertNewAfter($createRangeSelection());
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+ <div
+ contenteditable="true"
+ style="user-select: text; white-space: pre-wrap; word-break: break-word;"
+ data-lexical-editor="true">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li value="2"><br /></li>
+ </ul>
+ </div>
+ `,
+ );
+ });
+ });
+
+ test('$createListItemNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listItemNode = new ListItemNode();
+
+ const createdListItemNode = $createListItemNode();
+
+ expect(listItemNode.__type).toEqual(createdListItemNode.__type);
+ expect(listItemNode.__parent).toEqual(createdListItemNode.__parent);
+ expect(listItemNode.__key).not.toEqual(createdListItemNode.__key);
+ });
+ });
+
+ test('$isListItemNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listItemNode = new ListItemNode();
+
+ expect($isListItemNode(listItemNode)).toBe(true);
+ });
+ });
+
+ describe('ListItemNode.setIndent()', () => {
+ let listNode: ListNode;
+ let listItemNode1: ListItemNode;
+ let listItemNode2: ListItemNode;
+
+ beforeEach(async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ listNode = new ListNode('bullet', 1);
+ listItemNode1 = new ListItemNode();
+
+ listItemNode2 = new ListItemNode();
+
+ root.append(listNode);
+ listNode.append(listItemNode1, listItemNode2);
+ listItemNode1.append(new TextNode('one'));
+ listItemNode2.append(new TextNode('two'));
+ });
+ });
+ it('indents and outdents list item', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode1.setIndent(3);
+ });
+
+ await editor.update(() => {
+ expect(listItemNode1.getIndent()).toBe(3);
+ });
+
+ expectHtmlToBeEqual(
+ editor.getRootElement()!.innerHTML,
+ html`
+ <ul>
+ <li value="1">
+ <ul>
+ <li value="1">
+ <ul>
+ <li value="1">
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">two</span>
+ </li>
+ </ul>
+ `,
+ );
+
+ await editor.update(() => {
+ listItemNode1.setIndent(0);
+ });
+
+ await editor.update(() => {
+ expect(listItemNode1.getIndent()).toBe(0);
+ });
+
+ expectHtmlToBeEqual(
+ editor.getRootElement()!.innerHTML,
+ html`
+ <ul>
+ <li dir="ltr" value="1">
+ <span data-lexical-text="true">one</span>
+ </li>
+ <li dir="ltr" value="2">
+ <span data-lexical-text="true">two</span>
+ </li>
+ </ul>
+ `,
+ );
+ });
+
+ it('handles fractional indent values', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ listItemNode1.setIndent(0.5);
+ });
+
+ await editor.update(() => {
+ expect(listItemNode1.getIndent()).toBe(0);
+ });
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {ParagraphNode, TextNode} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+import {
+ $createListItemNode,
+ $createListNode,
+ $isListItemNode,
+ $isListNode,
+ ListItemNode,
+ ListNode,
+} from '../..';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ list: {
+ ol: 'my-ol-list-class',
+ olDepth: [
+ 'my-ol-list-class-1',
+ 'my-ol-list-class-2',
+ 'my-ol-list-class-3',
+ 'my-ol-list-class-4',
+ 'my-ol-list-class-5',
+ 'my-ol-list-class-6',
+ 'my-ol-list-class-7',
+ ],
+ ul: 'my-ul-list-class',
+ ulDepth: [
+ 'my-ul-list-class-1',
+ 'my-ul-list-class-2',
+ 'my-ul-list-class-3',
+ 'my-ul-list-class-4',
+ 'my-ul-list-class-5',
+ 'my-ul-list-class-6',
+ 'my-ul-list-class-7',
+ ],
+ },
+ },
+});
+
+describe('LexicalListNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('ListNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = $createListNode('bullet', 1);
+ expect(listNode.getType()).toBe('list');
+ expect(listNode.getTag()).toBe('ul');
+ expect(listNode.getTextContent()).toBe('');
+ });
+
+ // @ts-expect-error
+ expect(() => $createListNode()).toThrow();
+ });
+
+ test('ListNode.getTag()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const ulListNode = $createListNode('bullet', 1);
+ expect(ulListNode.getTag()).toBe('ul');
+ const olListNode = $createListNode('number', 1);
+ expect(olListNode.getTag()).toBe('ol');
+ const checkListNode = $createListNode('check', 1);
+ expect(checkListNode.getTag()).toBe('ul');
+ });
+ });
+
+ test('ListNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = $createListNode('bullet', 1);
+ expect(listNode.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
+ );
+ expect(
+ listNode.createDOM({
+ namespace: '',
+ theme: {
+ list: {},
+ },
+ }).outerHTML,
+ ).toBe('<ul></ul>');
+ expect(
+ listNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<ul></ul>');
+ });
+ });
+
+ test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode1 = $createListNode('bullet');
+ const listNode2 = $createListNode('bullet');
+ const listNode3 = $createListNode('bullet');
+ const listNode4 = $createListNode('bullet');
+ const listNode5 = $createListNode('bullet');
+ const listNode6 = $createListNode('bullet');
+ const listNode7 = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+ const listItem4 = $createListItemNode();
+
+ listNode1.append(listItem1);
+ listItem1.append(listNode2);
+ listNode2.append(listItem2);
+ listItem2.append(listNode3);
+ listNode3.append(listItem3);
+ listItem3.append(listNode4);
+ listNode4.append(listItem4);
+ listNode4.append(listNode5);
+ listNode5.append(listNode6);
+ listNode6.append(listNode7);
+
+ expect(listNode1.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
+ );
+ expect(
+ listNode1.createDOM({
+ namespace: '',
+ theme: {
+ list: {},
+ },
+ }).outerHTML,
+ ).toBe('<ul></ul>');
+ expect(
+ listNode1.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<ul></ul>');
+ expect(listNode2.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-2"></ul>',
+ );
+ expect(listNode3.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-3"></ul>',
+ );
+ expect(listNode4.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-4"></ul>',
+ );
+ expect(listNode5.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-5"></ul>',
+ );
+ expect(listNode6.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-6"></ul>',
+ );
+ expect(listNode7.createDOM(editorConfig).outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-7"></ul>',
+ );
+ expect(
+ listNode5.createDOM({
+ namespace: '',
+ theme: {
+ list: {
+ ...editorConfig.theme.list,
+ ulDepth: [
+ 'my-ul-list-class-1',
+ 'my-ul-list-class-2',
+ 'my-ul-list-class-3',
+ ],
+ },
+ },
+ }).outerHTML,
+ ).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>');
+ });
+ });
+
+ test('ListNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = $createListNode('bullet', 1);
+ const domElement = listNode.createDOM(editorConfig);
+
+ expect(domElement.outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
+ );
+
+ const newListNode = $createListNode('number', 1);
+ const result = newListNode.updateDOM(
+ listNode,
+ domElement,
+ editorConfig,
+ );
+
+ expect(result).toBe(true);
+ expect(domElement.outerHTML).toBe(
+ '<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
+ );
+ });
+ });
+
+ test('ListNode.append() should properly transform a ListItemNode', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = new ListNode('bullet', 1);
+ const listItemNode = new ListItemNode();
+ const textNode = new TextNode('Hello');
+
+ listItemNode.append(textNode);
+ const nodesToAppend = [listItemNode];
+
+ expect(listNode.append(...nodesToAppend)).toBe(listNode);
+ expect(listNode.getFirstChild()).toBe(listItemNode);
+ expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
+ });
+ });
+
+ test('ListNode.append() should properly transform a ListNode', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = new ListNode('bullet', 1);
+ const nestedListNode = new ListNode('bullet', 1);
+ const listItemNode = new ListItemNode();
+ const textNode = new TextNode('Hello');
+
+ listItemNode.append(textNode);
+ nestedListNode.append(listItemNode);
+
+ const nodesToAppend = [nestedListNode];
+
+ expect(listNode.append(...nodesToAppend)).toBe(listNode);
+ expect($isListItemNode(listNode.getFirstChild())).toBe(true);
+ expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe(
+ nestedListNode,
+ );
+ });
+ });
+
+ test('ListNode.append() should properly transform a ParagraphNode', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = new ListNode('bullet', 1);
+ const paragraph = new ParagraphNode();
+ const textNode = new TextNode('Hello');
+ paragraph.append(textNode);
+ const nodesToAppend = [paragraph];
+
+ expect(listNode.append(...nodesToAppend)).toBe(listNode);
+ expect($isListItemNode(listNode.getFirstChild())).toBe(true);
+ expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
+ });
+ });
+
+ test('$createListNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = $createListNode('bullet', 1);
+ const createdListNode = $createListNode('bullet');
+
+ expect(listNode.__type).toEqual(createdListNode.__type);
+ expect(listNode.__parent).toEqual(createdListNode.__parent);
+ expect(listNode.__tag).toEqual(createdListNode.__tag);
+ expect(listNode.__key).not.toEqual(createdListNode.__key);
+ });
+ });
+
+ test('$isListNode()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const listNode = $createListNode('bullet', 1);
+
+ expect($isListNode(listNode)).toBe(true);
+ });
+ });
+
+ test('$createListNode() with tag name (backward compatibility)', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const numberList = $createListNode('number', 1);
+ const bulletList = $createListNode('bullet', 1);
+ expect(numberList.__listType).toBe('number');
+ expect(bulletList.__listType).toBe('bullet');
+ });
+ });
+
+ test('ListNode.clone() without list type (backward compatibility)', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const olNode = ListNode.clone({
+ __key: '1',
+ __start: 1,
+ __tag: 'ol',
+ } as unknown as ListNode);
+ const ulNode = ListNode.clone({
+ __key: '1',
+ __start: 1,
+ __tag: 'ul',
+ } as unknown as ListNode);
+ expect(olNode.__listType).toBe('number');
+ expect(ulNode.__listType).toBe('bullet');
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$createParagraphNode, $getRoot} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+import {$createListItemNode, $createListNode} from '../..';
+import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils';
+
+describe('Lexical List Utils tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('getListDepth should return the 1-based depth of a list with one levels', async () => {
+ const editor = testEnv.editor;
+
+ editor.update(() => {
+ // Root
+ // |- ListNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+
+ root.append(topListNode);
+
+ const result = $getListDepth(topListNode);
+
+ expect(result).toEqual(1);
+ });
+ });
+
+ test('getListDepth should return the 1-based depth of a list with two levels', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+ const secondLevelListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+ topListNode.append(listItem2);
+ topListNode.append(secondLevelListNode);
+
+ secondLevelListNode.append(listItem3);
+
+ const result = $getListDepth(secondLevelListNode);
+
+ expect(result).toEqual(2);
+ });
+ });
+
+ test('getListDepth should return the 1-based depth of a list with five levels', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+ const listNode2 = $createListNode('bullet');
+ const listNode3 = $createListNode('bullet');
+ const listNode4 = $createListNode('bullet');
+ const listNode5 = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+ const listItem4 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+
+ listItem1.append(listNode2);
+ listNode2.append(listItem2);
+ listItem2.append(listNode3);
+ listNode3.append(listItem3);
+ listItem3.append(listNode4);
+ listNode4.append(listItem4);
+ listItem4.append(listNode5);
+
+ const result = $getListDepth(listNode5);
+
+ expect(result).toEqual(5);
+ });
+ });
+
+ test('getTopListNode should return the top list node when the list is a direct child of the RootNode', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+ const secondLevelListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+ topListNode.append(listItem2);
+ topListNode.append(secondLevelListNode);
+ secondLevelListNode.append(listItem3);
+
+ const result = $getTopListNode(listItem3);
+ expect(result.getKey()).toEqual(topListNode.getKey());
+ });
+ });
+
+ test('getTopListNode should return the top list node when the list is not a direct child of the RootNode', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ParagraphNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const paragraphNode = $createParagraphNode();
+ const topListNode = $createListNode('bullet');
+ const secondLevelListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+ root.append(paragraphNode);
+ paragraphNode.append(topListNode);
+ topListNode.append(listItem1);
+ topListNode.append(listItem2);
+ topListNode.append(secondLevelListNode);
+ secondLevelListNode.append(listItem3);
+
+ const result = $getTopListNode(listItem3);
+ expect(result.getKey()).toEqual(topListNode.getKey());
+ });
+ });
+
+ test('getTopListNode should return the top list node when the list item is deeply nested.', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ParagraphNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const paragraphNode = $createParagraphNode();
+ const topListNode = $createListNode('bullet');
+ const secondLevelListNode = $createListNode('bullet');
+ const thirdLevelListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+ const listItem4 = $createListItemNode();
+ root.append(paragraphNode);
+ paragraphNode.append(topListNode);
+ topListNode.append(listItem1);
+ listItem1.append(secondLevelListNode);
+ secondLevelListNode.append(listItem2);
+ listItem2.append(thirdLevelListNode);
+ thirdLevelListNode.append(listItem3);
+ topListNode.append(listItem4);
+
+ const result = $getTopListNode(listItem4);
+ expect(result.getKey()).toEqual(topListNode.getKey());
+ });
+ });
+
+ test('isLastItemInList should return true if the listItem is the last in a nested list.', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+ const secondLevelListNode = $createListNode('bullet');
+ const thirdLevelListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+ listItem1.append(secondLevelListNode);
+ secondLevelListNode.append(listItem2);
+ listItem2.append(thirdLevelListNode);
+ thirdLevelListNode.append(listItem3);
+
+ const result = $isLastItemInList(listItem3);
+
+ expect(result).toEqual(true);
+ });
+ });
+
+ test('isLastItemInList should return true if the listItem is the last in a non-nested list.', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+ topListNode.append(listItem2);
+
+ const result = $isLastItemInList(listItem2);
+
+ expect(result).toEqual(true);
+ });
+ });
+
+ test('isLastItemInList should return false if the listItem is not the last in a nested list.', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+ const secondLevelListNode = $createListNode('bullet');
+ const thirdLevelListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+ const listItem3 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+ listItem1.append(secondLevelListNode);
+ secondLevelListNode.append(listItem2);
+ listItem2.append(thirdLevelListNode);
+ thirdLevelListNode.append(listItem3);
+
+ const result = $isLastItemInList(listItem2);
+
+ expect(result).toEqual(false);
+ });
+ });
+
+ test('isLastItemInList should return true if the listItem is not the last in a non-nested list.', async () => {
+ const editor = testEnv.editor;
+
+ await editor.update(() => {
+ // Root
+ // |- ListNode
+ // |- ListItemNode
+ // |- ListItemNode
+ const root = $getRoot();
+
+ const topListNode = $createListNode('bullet');
+
+ const listItem1 = $createListItemNode();
+ const listItem2 = $createListItemNode();
+
+ root.append(topListNode);
+
+ topListNode.append(listItem1);
+ topListNode.append(listItem2);
+
+ const result = $isLastItemInList(listItem1);
+
+ expect(result).toEqual(false);
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {expect} from '@playwright/test';
+import prettier from 'prettier';
+
+// This tag function is just used to trigger prettier auto-formatting.
+// (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
+export function html(
+ partials: TemplateStringsArray,
+ ...params: string[]
+): string {
+ let output = '';
+ for (let i = 0; i < partials.length; i++) {
+ output += partials[i];
+ if (i < partials.length - 1) {
+ output += params[i];
+ }
+ }
+ return output;
+}
+
+export function expectHtmlToBeEqual(expected: string, actual: string): void {
+ expect(prettifyHtml(expected)).toBe(prettifyHtml(actual));
+}
+
+export function prettifyHtml(s: string): string {
+ return prettier.format(s.replace(/\n/g, ''), {parser: 'html'});
+}
--- /dev/null
+/**
+ * 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 {$getNearestNodeOfType} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $getSelection,
+ $isElementNode,
+ $isLeafNode,
+ $isParagraphNode,
+ $isRangeSelection,
+ $isRootOrShadowRoot,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ ParagraphNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $createListItemNode,
+ $createListNode,
+ $isListItemNode,
+ $isListNode,
+ ListItemNode,
+ ListNode,
+} from './';
+import {ListType} from './LexicalListNode';
+import {
+ $getAllListItems,
+ $getTopListNode,
+ $removeHighestEmptyListParent,
+ isNestedListNode,
+} from './utils';
+
+function $isSelectingEmptyListItem(
+ anchorNode: ListItemNode | LexicalNode,
+ nodes: Array<LexicalNode>,
+): boolean {
+ return (
+ $isListItemNode(anchorNode) &&
+ (nodes.length === 0 ||
+ (nodes.length === 1 &&
+ anchorNode.is(nodes[0]) &&
+ anchorNode.getChildrenSize() === 0))
+ );
+}
+
+/**
+ * Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of
+ * the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode.
+ * Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children.
+ * If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode,
+ * unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with
+ * a new ListNode, or create a new ListNode at the nearest root/shadow root.
+ * @param editor - The lexical editor.
+ * @param listType - The type of list, "number" | "bullet" | "check".
+ */
+export function insertList(editor: LexicalEditor, listType: ListType): void {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if (selection !== null) {
+ const nodes = selection.getNodes();
+ if ($isRangeSelection(selection)) {
+ const anchorAndFocus = selection.getStartEndPoints();
+ invariant(
+ anchorAndFocus !== null,
+ 'insertList: anchor should be defined',
+ );
+ const [anchor] = anchorAndFocus;
+ const anchorNode = anchor.getNode();
+ const anchorNodeParent = anchorNode.getParent();
+
+ if ($isSelectingEmptyListItem(anchorNode, nodes)) {
+ const list = $createListNode(listType);
+
+ if ($isRootOrShadowRoot(anchorNodeParent)) {
+ anchorNode.replace(list);
+ const listItem = $createListItemNode();
+ if ($isElementNode(anchorNode)) {
+ listItem.setFormat(anchorNode.getFormatType());
+ listItem.setIndent(anchorNode.getIndent());
+ }
+ list.append(listItem);
+ } else if ($isListItemNode(anchorNode)) {
+ const parent = anchorNode.getParentOrThrow();
+ append(list, parent.getChildren());
+ parent.replace(list);
+ }
+
+ return;
+ }
+ }
+
+ const handled = new Set();
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+
+ if (
+ $isElementNode(node) &&
+ node.isEmpty() &&
+ !$isListItemNode(node) &&
+ !handled.has(node.getKey())
+ ) {
+ $createListOrMerge(node, listType);
+ continue;
+ }
+
+ if ($isLeafNode(node)) {
+ let parent = node.getParent();
+ while (parent != null) {
+ const parentKey = parent.getKey();
+
+ if ($isListNode(parent)) {
+ if (!handled.has(parentKey)) {
+ const newListNode = $createListNode(listType);
+ append(newListNode, parent.getChildren());
+ parent.replace(newListNode);
+ handled.add(parentKey);
+ }
+
+ break;
+ } else {
+ const nextParent = parent.getParent();
+
+ if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
+ handled.add(parentKey);
+ $createListOrMerge(parent, listType);
+ break;
+ }
+
+ parent = nextParent;
+ }
+ }
+ }
+ }
+ }
+ });
+}
+
+function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
+ node.splice(node.getChildrenSize(), 0, nodesToAppend);
+}
+
+function $createListOrMerge(node: ElementNode, listType: ListType): ListNode {
+ if ($isListNode(node)) {
+ return node;
+ }
+
+ const previousSibling = node.getPreviousSibling();
+ const nextSibling = node.getNextSibling();
+ const listItem = $createListItemNode();
+ listItem.setFormat(node.getFormatType());
+ listItem.setIndent(node.getIndent());
+ append(listItem, node.getChildren());
+
+ if (
+ $isListNode(previousSibling) &&
+ listType === previousSibling.getListType()
+ ) {
+ previousSibling.append(listItem);
+ node.remove();
+ // if the same type of list is on both sides, merge them.
+
+ if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
+ append(previousSibling, nextSibling.getChildren());
+ nextSibling.remove();
+ }
+ return previousSibling;
+ } else if (
+ $isListNode(nextSibling) &&
+ listType === nextSibling.getListType()
+ ) {
+ nextSibling.getFirstChildOrThrow().insertBefore(listItem);
+ node.remove();
+ return nextSibling;
+ } else {
+ const list = $createListNode(listType);
+ list.append(listItem);
+ node.replace(list);
+ return list;
+ }
+}
+
+/**
+ * A recursive function that goes through each list and their children, including nested lists,
+ * appending list2 children after list1 children and updating ListItemNode values.
+ * @param list1 - The first list to be merged.
+ * @param list2 - The second list to be merged.
+ */
+export function mergeLists(list1: ListNode, list2: ListNode): void {
+ const listItem1 = list1.getLastChild();
+ const listItem2 = list2.getFirstChild();
+
+ if (
+ listItem1 &&
+ listItem2 &&
+ isNestedListNode(listItem1) &&
+ isNestedListNode(listItem2)
+ ) {
+ mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
+ listItem2.remove();
+ }
+
+ const toMerge = list2.getChildren();
+ if (toMerge.length > 0) {
+ list1.append(...toMerge);
+ }
+
+ list2.remove();
+}
+
+/**
+ * Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode
+ * it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
+ * removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
+ * inside a ListItemNode will be appended to the new ParagraphNodes.
+ * @param editor - The lexical editor.
+ */
+export function removeList(editor: LexicalEditor): void {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ const listNodes = new Set<ListNode>();
+ const nodes = selection.getNodes();
+ const anchorNode = selection.anchor.getNode();
+
+ if ($isSelectingEmptyListItem(anchorNode, nodes)) {
+ listNodes.add($getTopListNode(anchorNode));
+ } else {
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+
+ if ($isLeafNode(node)) {
+ const listItemNode = $getNearestNodeOfType(node, ListItemNode);
+
+ if (listItemNode != null) {
+ listNodes.add($getTopListNode(listItemNode));
+ }
+ }
+ }
+ }
+
+ for (const listNode of listNodes) {
+ let insertionPoint: ListNode | ParagraphNode = listNode;
+
+ const listItems = $getAllListItems(listNode);
+
+ for (const listItemNode of listItems) {
+ const paragraph = $createParagraphNode();
+
+ append(paragraph, listItemNode.getChildren());
+
+ insertionPoint.insertAfter(paragraph);
+ insertionPoint = paragraph;
+
+ // When the anchor and focus fall on the textNode
+ // we don't have to change the selection because the textNode will be appended to
+ // the newly generated paragraph.
+ // When selection is in empty nested list item, selection is actually on the listItemNode.
+ // When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
+ // we should manually set the selection's focus and anchor to the newly generated paragraph.
+ if (listItemNode.__key === selection.anchor.key) {
+ selection.anchor.set(paragraph.getKey(), 0, 'element');
+ }
+ if (listItemNode.__key === selection.focus.key) {
+ selection.focus.set(paragraph.getKey(), 0, 'element');
+ }
+
+ listItemNode.remove();
+ }
+ listNode.remove();
+ }
+ }
+ });
+}
+
+/**
+ * Takes the value of a child ListItemNode and makes it the value the ListItemNode
+ * should be if it isn't already. Also ensures that checked is undefined if the
+ * parent does not have a list type of 'check'.
+ * @param list - The list whose children are updated.
+ */
+export function updateChildrenListItemValue(list: ListNode): void {
+ const isNotChecklist = list.getListType() !== 'check';
+ let value = list.getStart();
+ for (const child of list.getChildren()) {
+ if ($isListItemNode(child)) {
+ if (child.getValue() !== value) {
+ child.setValue(value);
+ }
+ if (isNotChecklist && child.getLatest().__checked != null) {
+ child.setChecked(undefined);
+ }
+ if (!$isListNode(child.getFirstChild())) {
+ value++;
+ }
+ }
+ }
+}
+
+/**
+ * Merge the next sibling list if same type.
+ * <ul> will merge with <ul>, but NOT <ul> with <ol>.
+ * @param list - The list whose next sibling should be potentially merged
+ */
+export function mergeNextSiblingListIfSameType(list: ListNode): void {
+ const nextSibling = list.getNextSibling();
+ if (
+ $isListNode(nextSibling) &&
+ list.getListType() === nextSibling.getListType()
+ ) {
+ mergeLists(list, nextSibling);
+ }
+}
+
+/**
+ * Adds an empty ListNode/ListItemNode chain at listItemNode, so as to
+ * create an indent effect. Won't indent ListItemNodes that have a ListNode as
+ * a child, but does merge sibling ListItemNodes if one has a nested ListNode.
+ * @param listItemNode - The ListItemNode to be indented.
+ */
+export function $handleIndent(listItemNode: ListItemNode): void {
+ // go through each node and decide where to move it.
+ const removed = new Set<NodeKey>();
+
+ if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) {
+ return;
+ }
+
+ const parent = listItemNode.getParent();
+
+ // We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards
+ const nextSibling =
+ listItemNode.getNextSibling<ListItemNode>() as ListItemNode;
+ const previousSibling =
+ listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode;
+ // if there are nested lists on either side, merge them all together.
+
+ if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) {
+ const innerList = previousSibling.getFirstChild();
+
+ if ($isListNode(innerList)) {
+ innerList.append(listItemNode);
+ const nextInnerList = nextSibling.getFirstChild();
+
+ if ($isListNode(nextInnerList)) {
+ const children = nextInnerList.getChildren();
+ append(innerList, children);
+ nextSibling.remove();
+ removed.add(nextSibling.getKey());
+ }
+ }
+ } else if (isNestedListNode(nextSibling)) {
+ // if the ListItemNode is next to a nested ListNode, merge them
+ const innerList = nextSibling.getFirstChild();
+
+ if ($isListNode(innerList)) {
+ const firstChild = innerList.getFirstChild();
+
+ if (firstChild !== null) {
+ firstChild.insertBefore(listItemNode);
+ }
+ }
+ } else if (isNestedListNode(previousSibling)) {
+ const innerList = previousSibling.getFirstChild();
+
+ if ($isListNode(innerList)) {
+ innerList.append(listItemNode);
+ }
+ } else {
+ // otherwise, we need to create a new nested ListNode
+
+ if ($isListNode(parent)) {
+ const newListItem = $createListItemNode();
+ const newList = $createListNode(parent.getListType());
+ newListItem.append(newList);
+ newList.append(listItemNode);
+
+ if (previousSibling) {
+ previousSibling.insertAfter(newListItem);
+ } else if (nextSibling) {
+ nextSibling.insertBefore(newListItem);
+ } else {
+ parent.append(newListItem);
+ }
+ }
+ }
+}
+
+/**
+ * Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode
+ * has a great grandparent node of type ListNode, which is where the ListItemNode will reside
+ * within as a child.
+ * @param listItemNode - The ListItemNode to remove the indent (outdent).
+ */
+export function $handleOutdent(listItemNode: ListItemNode): void {
+ // go through each node and decide where to move it.
+
+ if (isNestedListNode(listItemNode)) {
+ return;
+ }
+ const parentList = listItemNode.getParent();
+ const grandparentListItem = parentList ? parentList.getParent() : undefined;
+ const greatGrandparentList = grandparentListItem
+ ? grandparentListItem.getParent()
+ : undefined;
+ // If it doesn't have these ancestors, it's not indented.
+
+ if (
+ $isListNode(greatGrandparentList) &&
+ $isListItemNode(grandparentListItem) &&
+ $isListNode(parentList)
+ ) {
+ // if it's the first child in it's parent list, insert it into the
+ // great grandparent list before the grandparent
+ const firstChild = parentList ? parentList.getFirstChild() : undefined;
+ const lastChild = parentList ? parentList.getLastChild() : undefined;
+
+ if (listItemNode.is(firstChild)) {
+ grandparentListItem.insertBefore(listItemNode);
+
+ if (parentList.isEmpty()) {
+ grandparentListItem.remove();
+ }
+ // if it's the last child in it's parent list, insert it into the
+ // great grandparent list after the grandparent.
+ } else if (listItemNode.is(lastChild)) {
+ grandparentListItem.insertAfter(listItemNode);
+
+ if (parentList.isEmpty()) {
+ grandparentListItem.remove();
+ }
+ } else {
+ // otherwise, we need to split the siblings into two new nested lists
+ const listType = parentList.getListType();
+ const previousSiblingsListItem = $createListItemNode();
+ const previousSiblingsList = $createListNode(listType);
+ previousSiblingsListItem.append(previousSiblingsList);
+ listItemNode
+ .getPreviousSiblings()
+ .forEach((sibling) => previousSiblingsList.append(sibling));
+ const nextSiblingsListItem = $createListItemNode();
+ const nextSiblingsList = $createListNode(listType);
+ nextSiblingsListItem.append(nextSiblingsList);
+ append(nextSiblingsList, listItemNode.getNextSiblings());
+ // put the sibling nested lists on either side of the grandparent list item in the great grandparent.
+ grandparentListItem.insertBefore(previousSiblingsListItem);
+ grandparentListItem.insertAfter(nextSiblingsListItem);
+ // replace the grandparent list item (now between the siblings) with the outdented list item.
+ grandparentListItem.replace(listItemNode);
+ }
+ }
+}
+
+/**
+ * Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode
+ * or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode
+ * (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is
+ * nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode.
+ * Throws an invariant if the selection is not a child of a ListNode.
+ * @returns true if a ParagraphNode was inserted succesfully, false if there is no selection
+ * or the selection does not contain a ListItemNode or the node already holds text.
+ */
+export function $handleListInsertParagraph(): boolean {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+ return false;
+ }
+ // Only run this code on empty list items
+ const anchor = selection.anchor.getNode();
+
+ if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) {
+ return false;
+ }
+ const topListNode = $getTopListNode(anchor);
+ const parent = anchor.getParent();
+
+ invariant(
+ $isListNode(parent),
+ 'A ListItemNode must have a ListNode for a parent.',
+ );
+
+ const grandparent = parent.getParent();
+
+ let replacementNode;
+
+ if ($isRootOrShadowRoot(grandparent)) {
+ replacementNode = $createParagraphNode();
+ topListNode.insertAfter(replacementNode);
+ } else if ($isListItemNode(grandparent)) {
+ replacementNode = $createListItemNode();
+ grandparent.insertAfter(replacementNode);
+ } else {
+ return false;
+ }
+ replacementNode.select();
+
+ const nextSiblings = anchor.getNextSiblings();
+
+ if (nextSiblings.length > 0) {
+ const newList = $createListNode(parent.getListType());
+
+ if ($isParagraphNode(replacementNode)) {
+ replacementNode.insertAfter(newList);
+ } else {
+ const newListItem = $createListItemNode();
+ newListItem.append(newList);
+ replacementNode.insertAfter(newListItem);
+ }
+ nextSiblings.forEach((sibling) => {
+ sibling.remove();
+ newList.append(sibling);
+ });
+ }
+
+ // Don't leave hanging nested empty lists
+ $removeHighestEmptyListParent(anchor);
+
+ return true;
+}
--- /dev/null
+/**
+ * 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 {SerializedListItemNode} from './LexicalListItemNode';
+import type {ListType, SerializedListNode} from './LexicalListNode';
+import type {LexicalCommand} from 'lexical';
+
+import {createCommand} from 'lexical';
+
+import {$handleListInsertParagraph, insertList, removeList} from './formatList';
+import {
+ $createListItemNode,
+ $isListItemNode,
+ ListItemNode,
+} from './LexicalListItemNode';
+import {$createListNode, $isListNode, ListNode} from './LexicalListNode';
+import {$getListDepth} from './utils';
+
+export {
+ $createListItemNode,
+ $createListNode,
+ $getListDepth,
+ $handleListInsertParagraph,
+ $isListItemNode,
+ $isListNode,
+ insertList,
+ ListItemNode,
+ ListNode,
+ ListType,
+ removeList,
+ SerializedListItemNode,
+ SerializedListNode,
+};
+
+export const INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void> =
+ createCommand('INSERT_UNORDERED_LIST_COMMAND');
+export const INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void> = createCommand(
+ 'INSERT_ORDERED_LIST_COMMAND',
+);
+export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
+ 'INSERT_CHECK_LIST_COMMAND',
+);
+export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand(
+ 'REMOVE_LIST_COMMAND',
+);
--- /dev/null
+/**
+ * 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 {LexicalNode, Spread} from 'lexical';
+
+import {$findMatchingParent} from '@lexical/utils';
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $createListItemNode,
+ $isListItemNode,
+ $isListNode,
+ ListItemNode,
+ ListNode,
+} from './';
+
+/**
+ * Checks the depth of listNode from the root node.
+ * @param listNode - The ListNode to be checked.
+ * @returns The depth of the ListNode.
+ */
+export function $getListDepth(listNode: ListNode): number {
+ let depth = 1;
+ let parent = listNode.getParent();
+
+ while (parent != null) {
+ if ($isListItemNode(parent)) {
+ const parentList = parent.getParent();
+
+ if ($isListNode(parentList)) {
+ depth++;
+ parent = parentList.getParent();
+ continue;
+ }
+ invariant(false, 'A ListItemNode must have a ListNode for a parent.');
+ }
+
+ return depth;
+ }
+
+ return depth;
+}
+
+/**
+ * Finds the nearest ancestral ListNode and returns it, throws an invariant if listItem is not a ListItemNode.
+ * @param listItem - The node to be checked.
+ * @returns The ListNode found.
+ */
+export function $getTopListNode(listItem: LexicalNode): ListNode {
+ let list = listItem.getParent<ListNode>();
+
+ if (!$isListNode(list)) {
+ invariant(false, 'A ListItemNode must have a ListNode for a parent.');
+ }
+
+ let parent: ListNode | null = list;
+
+ while (parent !== null) {
+ parent = parent.getParent();
+
+ if ($isListNode(parent)) {
+ list = parent;
+ }
+ }
+
+ return list;
+}
+
+/**
+ * Checks if listItem has no child ListNodes and has no ListItemNode ancestors with siblings.
+ * @param listItem - the ListItemNode to be checked.
+ * @returns true if listItem has no child ListNode and no ListItemNode ancestors with siblings, false otherwise.
+ */
+export function $isLastItemInList(listItem: ListItemNode): boolean {
+ let isLast = true;
+ const firstChild = listItem.getFirstChild();
+
+ if ($isListNode(firstChild)) {
+ return false;
+ }
+ let parent: ListItemNode | null = listItem;
+
+ while (parent !== null) {
+ if ($isListItemNode(parent)) {
+ if (parent.getNextSiblings().length > 0) {
+ isLast = false;
+ }
+ }
+
+ parent = parent.getParent();
+ }
+
+ return isLast;
+}
+
+/**
+ * A recursive Depth-First Search (Postorder Traversal) that finds all of a node's children
+ * that are of type ListItemNode and returns them in an array.
+ * @param node - The ListNode to start the search.
+ * @returns An array containing all nodes of type ListItemNode found.
+ */
+// This should probably be $getAllChildrenOfType
+export function $getAllListItems(node: ListNode): Array<ListItemNode> {
+ let listItemNodes: Array<ListItemNode> = [];
+ const listChildren: Array<ListItemNode> = node
+ .getChildren()
+ .filter($isListItemNode);
+
+ for (let i = 0; i < listChildren.length; i++) {
+ const listItemNode = listChildren[i];
+ const firstChild = listItemNode.getFirstChild();
+
+ if ($isListNode(firstChild)) {
+ listItemNodes = listItemNodes.concat($getAllListItems(firstChild));
+ } else {
+ listItemNodes.push(listItemNode);
+ }
+ }
+
+ return listItemNodes;
+}
+
+const NestedListNodeBrand: unique symbol = Symbol.for(
+ '@lexical/NestedListNodeBrand',
+);
+
+/**
+ * Checks to see if the passed node is a ListItemNode and has a ListNode as a child.
+ * @param node - The node to be checked.
+ * @returns true if the node is a ListItemNode and has a ListNode child, false otherwise.
+ */
+export function isNestedListNode(
+ node: LexicalNode | null | undefined,
+): node is Spread<
+ {getFirstChild(): ListNode; [NestedListNodeBrand]: never},
+ ListItemNode
+> {
+ return $isListItemNode(node) && $isListNode(node.getFirstChild());
+}
+
+/**
+ * Traverses up the tree and returns the first ListItemNode found.
+ * @param node - Node to start the search.
+ * @returns The first ListItemNode found, or null if none exist.
+ */
+export function $findNearestListItemNode(
+ node: LexicalNode,
+): ListItemNode | null {
+ const matchingParent = $findMatchingParent(node, (parent) =>
+ $isListItemNode(parent),
+ );
+ return matchingParent as ListItemNode | null;
+}
+
+/**
+ * Takes a deeply nested ListNode or ListItemNode and traverses up the branch to delete the first
+ * ancestral ListNode (which could be the root ListNode) or ListItemNode with siblings, essentially
+ * bringing the deeply nested node up the branch once. Would remove sublist if it has siblings.
+ * Should not break ListItem -> List -> ListItem chain as empty List/ItemNodes should be removed on .remove().
+ * @param sublist - The nested ListNode or ListItemNode to be brought up the branch.
+ */
+export function $removeHighestEmptyListParent(
+ sublist: ListItemNode | ListNode,
+) {
+ // Nodes may be repeatedly indented, to create deeply nested lists that each
+ // contain just one bullet.
+ // Our goal is to remove these (empty) deeply nested lists. The easiest
+ // way to do that is crawl back up the tree until we find a node that has siblings
+ // (e.g. is actually part of the list contents) and delete that, or delete
+ // the root of the list (if no list nodes have siblings.)
+ let emptyListPtr = sublist;
+
+ while (
+ emptyListPtr.getNextSibling() == null &&
+ emptyListPtr.getPreviousSibling() == null
+ ) {
+ const parent = emptyListPtr.getParent<ListItemNode | ListNode>();
+
+ if (
+ parent == null ||
+ !($isListItemNode(emptyListPtr) || $isListNode(emptyListPtr))
+ ) {
+ break;
+ }
+
+ emptyListPtr = parent;
+ }
+
+ emptyListPtr.remove();
+}
+
+/**
+ * Wraps a node into a ListItemNode.
+ * @param node - The node to be wrapped into a ListItemNode
+ * @returns The ListItemNode which the passed node is wrapped in.
+ */
+export function $wrapInListItem(node: LexicalNode): ListItemNode {
+ const listItemWrapper = $createListItemNode();
+ return listItemWrapper.append(node);
+}
--- /dev/null
+# Lexical Editor Framework
+
+This is a fork and import of [the Lexical editor](https://lexical.dev/) at the version of v0.17.1 for direct use and modification in BookStack. This was done due to fighting many of the opinionated defaults in Lexical during editor development.
+
+Only components used, or intended to be used, were copied in at this point.
+
+#### License
+
+The original work built upon in this directory and below is under the copyright of Meta Platforms, Inc. and affiliates.
+The original license can be seen in the [ORIGINAL-LEXICAL-LICENSE](./ORIGINAL-LEXICAL-LICENSE) file.
+
+Files may have since been modified with modifications being under the license and copyright of the BookStack project as a whole.
\ No newline at end of file
--- /dev/null
+/**
+ * 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 {
+ $createHeadingNode,
+ $isHeadingNode,
+ HeadingNode,
+} from '@lexical/rich-text';
+import {
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ ParagraphNode,
+ RangeSelection,
+} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ heading: {
+ h1: 'my-h1-class',
+ h2: 'my-h2-class',
+ h3: 'my-h3-class',
+ h4: 'my-h4-class',
+ h5: 'my-h5-class',
+ h6: 'my-h6-class',
+ },
+ },
+});
+
+describe('LexicalHeadingNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('HeadingNode.constructor', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const headingNode = new HeadingNode('h1');
+ expect(headingNode.getType()).toBe('heading');
+ expect(headingNode.getTag()).toBe('h1');
+ expect(headingNode.getTextContent()).toBe('');
+ });
+ expect(() => new HeadingNode('h1')).toThrow();
+ });
+
+ test('HeadingNode.createDOM()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const headingNode = new HeadingNode('h1');
+ expect(headingNode.createDOM(editorConfig).outerHTML).toBe(
+ '<h1 class="my-h1-class"></h1>',
+ );
+ expect(
+ headingNode.createDOM({
+ namespace: '',
+ theme: {
+ heading: {},
+ },
+ }).outerHTML,
+ ).toBe('<h1></h1>');
+ expect(
+ headingNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<h1></h1>');
+ });
+ });
+
+ test('HeadingNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const headingNode = new HeadingNode('h1');
+ const domElement = headingNode.createDOM(editorConfig);
+ expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
+ const newHeadingNode = new HeadingNode('h2');
+ const result = newHeadingNode.updateDOM(headingNode, domElement);
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
+ });
+ });
+
+ test('HeadingNode.insertNewAfter() empty', async () => {
+ const {editor} = testEnv;
+ let headingNode: HeadingNode;
+ await editor.update(() => {
+ const root = $getRoot();
+ headingNode = new HeadingNode('h1');
+ root.append(headingNode);
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1><br></h1></div>',
+ );
+ await editor.update(() => {
+ const selection = $getSelection() as RangeSelection;
+ const result = headingNode.insertNewAfter(selection);
+ expect(result).toBeInstanceOf(ParagraphNode);
+ expect(result.getDirection()).toEqual(headingNode.getDirection());
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1><br></h1><p><br></p></div>',
+ );
+ });
+
+ test('HeadingNode.insertNewAfter() middle', async () => {
+ const {editor} = testEnv;
+ let headingNode: HeadingNode;
+ await editor.update(() => {
+ const root = $getRoot();
+ headingNode = new HeadingNode('h1');
+ const headingTextNode = $createTextNode('hello world');
+ root.append(headingNode.append(headingTextNode));
+ headingTextNode.select(5, 5);
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello world</span></h1></div>',
+ );
+ await editor.update(() => {
+ const selection = $getSelection() as RangeSelection;
+ const result = headingNode.insertNewAfter(selection);
+ expect(result).toBeInstanceOf(HeadingNode);
+ expect(result.getDirection()).toEqual(headingNode.getDirection());
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello world</span></h1><h1><br></h1></div>',
+ );
+ });
+
+ test('HeadingNode.insertNewAfter() end', async () => {
+ const {editor} = testEnv;
+ let headingNode: HeadingNode;
+ await editor.update(() => {
+ const root = $getRoot();
+ headingNode = new HeadingNode('h1');
+ const headingTextNode1 = $createTextNode('hello');
+ const headingTextNode2 = $createTextNode(' world');
+ headingTextNode2.setFormat('bold');
+ root.append(headingNode.append(headingTextNode1, headingTextNode2));
+ headingTextNode2.selectEnd();
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello</span><strong data-lexical-text="true"> world</strong></h1></div>',
+ );
+ await editor.update(() => {
+ const selection = $getSelection() as RangeSelection;
+ const result = headingNode.insertNewAfter(selection);
+ expect(result).toBeInstanceOf(ParagraphNode);
+ expect(result.getDirection()).toEqual(headingNode.getDirection());
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello</span><strong data-lexical-text="true"> world</strong></h1><p><br></p></div>',
+ );
+ });
+
+ test('$createHeadingNode()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const headingNode = new HeadingNode('h1');
+ const createdHeadingNode = $createHeadingNode('h1');
+ expect(headingNode.__type).toEqual(createdHeadingNode.__type);
+ expect(headingNode.__parent).toEqual(createdHeadingNode.__parent);
+ expect(headingNode.__key).not.toEqual(createdHeadingNode.__key);
+ });
+ });
+
+ test('$isHeadingNode()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const headingNode = new HeadingNode('h1');
+ expect($isHeadingNode(headingNode)).toBe(true);
+ });
+ });
+
+ test('creates a h2 with text and can insert a new paragraph after', async () => {
+ const {editor} = testEnv;
+ let headingNode: HeadingNode;
+ const text = 'hello world';
+ await editor.update(() => {
+ const root = $getRoot();
+ headingNode = new HeadingNode('h2');
+ root.append(headingNode);
+ const textNode = $createTextNode(text);
+ headingNode.append(textNode);
+ });
+ expect(testEnv.outerHTML).toBe(
+ `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h2 dir="ltr"><span data-lexical-text="true">${text}</span></h2></div>`,
+ );
+ await editor.update(() => {
+ const result = headingNode.insertNewAfter();
+ expect(result).toBeInstanceOf(ParagraphNode);
+ expect(result.getDirection()).toEqual(headingNode.getDirection());
+ });
+ expect(testEnv.outerHTML).toBe(
+ `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h2 dir="ltr"><span data-lexical-text="true">${text}</span></h2><p><br></p></div>`,
+ );
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$createQuoteNode, QuoteNode} from '@lexical/rich-text';
+import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ quote: 'my-quote-class',
+ },
+});
+
+describe('LexicalQuoteNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('QuoteNode.constructor', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const quoteNode = $createQuoteNode();
+ expect(quoteNode.getType()).toBe('quote');
+ expect(quoteNode.getTextContent()).toBe('');
+ });
+ expect(() => $createQuoteNode()).toThrow();
+ });
+
+ test('QuoteNode.createDOM()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const quoteNode = $createQuoteNode();
+ expect(quoteNode.createDOM(editorConfig).outerHTML).toBe(
+ '<blockquote class="my-quote-class"></blockquote>',
+ );
+ expect(
+ quoteNode.createDOM({
+ namespace: '',
+ theme: {},
+ }).outerHTML,
+ ).toBe('<blockquote></blockquote>');
+ });
+ });
+
+ test('QuoteNode.updateDOM()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const quoteNode = $createQuoteNode();
+ const domElement = quoteNode.createDOM(editorConfig);
+ expect(domElement.outerHTML).toBe(
+ '<blockquote class="my-quote-class"></blockquote>',
+ );
+ const newQuoteNode = $createQuoteNode();
+ const result = newQuoteNode.updateDOM(quoteNode, domElement);
+ expect(result).toBe(false);
+ expect(domElement.outerHTML).toBe(
+ '<blockquote class="my-quote-class"></blockquote>',
+ );
+ });
+ });
+
+ test('QuoteNode.insertNewAfter()', async () => {
+ const {editor} = testEnv;
+ let quoteNode: QuoteNode;
+ await editor.update(() => {
+ const root = $getRoot();
+ quoteNode = $createQuoteNode();
+ root.append(quoteNode);
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><blockquote><br></blockquote></div>',
+ );
+ await editor.update(() => {
+ const result = quoteNode.insertNewAfter($createRangeSelection());
+ expect(result).toBeInstanceOf(ParagraphNode);
+ expect(result.getDirection()).toEqual(quoteNode.getDirection());
+ });
+ expect(testEnv.outerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><blockquote><br></blockquote><p><br></p></div>',
+ );
+ });
+
+ test('$createQuoteNode()', async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const quoteNode = $createQuoteNode();
+ const createdQuoteNode = $createQuoteNode();
+ expect(quoteNode.__type).toEqual(createdQuoteNode.__type);
+ expect(quoteNode.__parent).toEqual(createdQuoteNode.__parent);
+ expect(quoteNode.__key).not.toEqual(createdQuoteNode.__key);
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ CommandPayloadType,
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ ElementFormatType,
+ LexicalCommand,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ ParagraphNode,
+ PasteCommandType,
+ RangeSelection,
+ SerializedElementNode,
+ Spread,
+ TextFormatType,
+} from 'lexical';
+
+import {
+ $insertDataTransferForRichText,
+ copyToClipboard,
+} from '@lexical/clipboard';
+import {
+ $moveCharacter,
+ $shouldOverrideDefaultCharacterSelection,
+} from '@lexical/selection';
+import {
+ $findMatchingParent,
+ $getNearestBlockElementAncestorOrThrow,
+ addClassNamesToElement,
+ isHTMLElement,
+ mergeRegister,
+ objectKlassEquals,
+} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTabNode,
+ $getAdjacentNode,
+ $getNearestNodeFromDOMNode,
+ $getRoot,
+ $getSelection,
+ $insertNodes,
+ $isDecoratorNode,
+ $isElementNode,
+ $isNodeSelection,
+ $isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ $normalizeSelection__EXPERIMENTAL,
+ $selectAll,
+ $setSelection,
+ CLICK_COMMAND,
+ COMMAND_PRIORITY_EDITOR,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ COPY_COMMAND,
+ createCommand,
+ CUT_COMMAND,
+ DELETE_CHARACTER_COMMAND,
+ DELETE_LINE_COMMAND,
+ DELETE_WORD_COMMAND,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND,
+ ElementNode,
+ FORMAT_ELEMENT_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INDENT_CONTENT_COMMAND,
+ INSERT_LINE_BREAK_COMMAND,
+ INSERT_PARAGRAPH_COMMAND,
+ INSERT_TAB_COMMAND,
+ isSelectionCapturedInDecoratorInput,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ OUTDENT_CONTENT_COMMAND,
+ PASTE_COMMAND,
+ REMOVE_TEXT_COMMAND,
+ SELECT_ALL_COMMAND,
+} from 'lexical';
+import caretFromPoint from 'lexical/shared/caretFromPoint';
+import {
+ CAN_USE_BEFORE_INPUT,
+ IS_APPLE_WEBKIT,
+ IS_IOS,
+ IS_SAFARI,
+} from 'lexical/shared/environment';
+
+export type SerializedHeadingNode = Spread<
+ {
+ tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+ },
+ SerializedElementNode
+>;
+
+export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
+ 'DRAG_DROP_PASTE_FILE',
+);
+
+export type SerializedQuoteNode = SerializedElementNode;
+
+/** @noInheritDoc */
+export class QuoteNode extends ElementNode {
+ static getType(): string {
+ return 'quote';
+ }
+
+ static clone(node: QuoteNode): QuoteNode {
+ return new QuoteNode(node.__key);
+ }
+
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ // View
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const element = document.createElement('blockquote');
+ addClassNamesToElement(element, config.theme.quote);
+ return element;
+ }
+ updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean {
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ blockquote: (node: Node) => ({
+ conversion: $convertBlockquoteElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const {element} = super.exportDOM(editor);
+
+ if (element && isHTMLElement(element)) {
+ if (this.isEmpty()) {
+ element.append(document.createElement('br'));
+ }
+
+ const formatType = this.getFormatType();
+ element.style.textAlign = formatType;
+
+ const direction = this.getDirection();
+ if (direction) {
+ element.dir = direction;
+ }
+ }
+
+ return {
+ element,
+ };
+ }
+
+ static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
+ const node = $createQuoteNode();
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedElementNode {
+ return {
+ ...super.exportJSON(),
+ type: 'quote',
+ };
+ }
+
+ // Mutation
+
+ insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
+ const newBlock = $createParagraphNode();
+ const direction = this.getDirection();
+ newBlock.setDirection(direction);
+ this.insertAfter(newBlock, restoreSelection);
+ return newBlock;
+ }
+
+ collapseAtStart(): true {
+ const paragraph = $createParagraphNode();
+ const children = this.getChildren();
+ children.forEach((child) => paragraph.append(child));
+ this.replace(paragraph);
+ return true;
+ }
+
+ canMergeWhenEmpty(): true {
+ return true;
+ }
+}
+
+export function $createQuoteNode(): QuoteNode {
+ return $applyNodeReplacement(new QuoteNode());
+}
+
+export function $isQuoteNode(
+ node: LexicalNode | null | undefined,
+): node is QuoteNode {
+ return node instanceof QuoteNode;
+}
+
+export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+
+/** @noInheritDoc */
+export class HeadingNode extends ElementNode {
+ /** @internal */
+ __tag: HeadingTagType;
+
+ static getType(): string {
+ return 'heading';
+ }
+
+ static clone(node: HeadingNode): HeadingNode {
+ return new HeadingNode(node.__tag, node.__key);
+ }
+
+ constructor(tag: HeadingTagType, key?: NodeKey) {
+ super(key);
+ this.__tag = tag;
+ }
+
+ getTag(): HeadingTagType {
+ return this.__tag;
+ }
+
+ // View
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const tag = this.__tag;
+ const element = document.createElement(tag);
+ const theme = config.theme;
+ const classNames = theme.heading;
+ if (classNames !== undefined) {
+ const className = classNames[tag];
+ addClassNamesToElement(element, className);
+ }
+ return element;
+ }
+
+ updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ h1: (node: Node) => ({
+ conversion: $convertHeadingElement,
+ priority: 0,
+ }),
+ h2: (node: Node) => ({
+ conversion: $convertHeadingElement,
+ priority: 0,
+ }),
+ h3: (node: Node) => ({
+ conversion: $convertHeadingElement,
+ priority: 0,
+ }),
+ h4: (node: Node) => ({
+ conversion: $convertHeadingElement,
+ priority: 0,
+ }),
+ h5: (node: Node) => ({
+ conversion: $convertHeadingElement,
+ priority: 0,
+ }),
+ h6: (node: Node) => ({
+ conversion: $convertHeadingElement,
+ priority: 0,
+ }),
+ p: (node: Node) => {
+ // domNode is a <p> since we matched it by nodeName
+ const paragraph = node as HTMLParagraphElement;
+ const firstChild = paragraph.firstChild;
+ if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
+ return {
+ conversion: () => ({node: null}),
+ priority: 3,
+ };
+ }
+ return null;
+ },
+ span: (node: Node) => {
+ if (isGoogleDocsTitle(node)) {
+ return {
+ conversion: (domNode: Node) => {
+ return {
+ node: $createHeadingNode('h1'),
+ };
+ },
+ priority: 3,
+ };
+ }
+ return null;
+ },
+ };
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const {element} = super.exportDOM(editor);
+
+ if (element && isHTMLElement(element)) {
+ if (this.isEmpty()) {
+ element.append(document.createElement('br'));
+ }
+
+ const formatType = this.getFormatType();
+ element.style.textAlign = formatType;
+
+ const direction = this.getDirection();
+ if (direction) {
+ element.dir = direction;
+ }
+ }
+
+ return {
+ element,
+ };
+ }
+
+ static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
+ const node = $createHeadingNode(serializedNode.tag);
+ node.setFormat(serializedNode.format);
+ node.setIndent(serializedNode.indent);
+ node.setDirection(serializedNode.direction);
+ return node;
+ }
+
+ exportJSON(): SerializedHeadingNode {
+ return {
+ ...super.exportJSON(),
+ tag: this.getTag(),
+ type: 'heading',
+ version: 1,
+ };
+ }
+
+ // Mutation
+ insertNewAfter(
+ selection?: RangeSelection,
+ restoreSelection = true,
+ ): ParagraphNode | HeadingNode {
+ const anchorOffet = selection ? selection.anchor.offset : 0;
+ const lastDesc = this.getLastDescendant();
+ const isAtEnd =
+ !lastDesc ||
+ (selection &&
+ selection.anchor.key === lastDesc.getKey() &&
+ anchorOffet === lastDesc.getTextContentSize());
+ const newElement =
+ isAtEnd || !selection
+ ? $createParagraphNode()
+ : $createHeadingNode(this.getTag());
+ const direction = this.getDirection();
+ newElement.setDirection(direction);
+ this.insertAfter(newElement, restoreSelection);
+ if (anchorOffet === 0 && !this.isEmpty() && selection) {
+ const paragraph = $createParagraphNode();
+ paragraph.select();
+ this.replace(paragraph, true);
+ }
+ return newElement;
+ }
+
+ collapseAtStart(): true {
+ const newElement = !this.isEmpty()
+ ? $createHeadingNode(this.getTag())
+ : $createParagraphNode();
+ const children = this.getChildren();
+ children.forEach((child) => newElement.append(child));
+ this.replace(newElement);
+ return true;
+ }
+
+ extractWithChild(): boolean {
+ return true;
+ }
+}
+
+function isGoogleDocsTitle(domNode: Node): boolean {
+ if (domNode.nodeName.toLowerCase() === 'span') {
+ return (domNode as HTMLSpanElement).style.fontSize === '26pt';
+ }
+ return false;
+}
+
+function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
+ const nodeName = element.nodeName.toLowerCase();
+ let node = null;
+ if (
+ nodeName === 'h1' ||
+ nodeName === 'h2' ||
+ nodeName === 'h3' ||
+ nodeName === 'h4' ||
+ nodeName === 'h5' ||
+ nodeName === 'h6'
+ ) {
+ node = $createHeadingNode(nodeName);
+ if (element.style !== null) {
+ node.setFormat(element.style.textAlign as ElementFormatType);
+ }
+ }
+ return {node};
+}
+
+function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
+ const node = $createQuoteNode();
+ if (element.style !== null) {
+ node.setFormat(element.style.textAlign as ElementFormatType);
+ }
+ return {node};
+}
+
+export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
+ return $applyNodeReplacement(new HeadingNode(headingTag));
+}
+
+export function $isHeadingNode(
+ node: LexicalNode | null | undefined,
+): node is HeadingNode {
+ return node instanceof HeadingNode;
+}
+
+function onPasteForRichText(
+ event: CommandPayloadType<typeof PASTE_COMMAND>,
+ editor: LexicalEditor,
+): void {
+ event.preventDefault();
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ const clipboardData =
+ objectKlassEquals(event, InputEvent) ||
+ objectKlassEquals(event, KeyboardEvent)
+ ? null
+ : (event as ClipboardEvent).clipboardData;
+ if (clipboardData != null && selection !== null) {
+ $insertDataTransferForRichText(clipboardData, selection, editor);
+ }
+ },
+ {
+ tag: 'paste',
+ },
+ );
+}
+
+async function onCutForRichText(
+ event: CommandPayloadType<typeof CUT_COMMAND>,
+ editor: LexicalEditor,
+): Promise<void> {
+ await copyToClipboard(
+ editor,
+ objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
+ );
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ selection.removeText();
+ } else if ($isNodeSelection(selection)) {
+ selection.getNodes().forEach((node) => node.remove());
+ }
+ });
+}
+
+// Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
+// in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
+// control this with the first boolean flag.
+export function eventFiles(
+ event: DragEvent | PasteCommandType,
+): [boolean, Array<File>, boolean] {
+ let dataTransfer: null | DataTransfer = null;
+ if (objectKlassEquals(event, DragEvent)) {
+ dataTransfer = (event as DragEvent).dataTransfer;
+ } else if (objectKlassEquals(event, ClipboardEvent)) {
+ dataTransfer = (event as ClipboardEvent).clipboardData;
+ }
+
+ if (dataTransfer === null) {
+ return [false, [], false];
+ }
+
+ const types = dataTransfer.types;
+ const hasFiles = types.includes('Files');
+ const hasContent =
+ types.includes('text/html') || types.includes('text/plain');
+ return [hasFiles, Array.from(dataTransfer.files), hasContent];
+}
+
+function $handleIndentAndOutdent(
+ indentOrOutdent: (block: ElementNode) => void,
+): boolean {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ const alreadyHandled = new Set();
+ const nodes = selection.getNodes();
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ const key = node.getKey();
+ if (alreadyHandled.has(key)) {
+ continue;
+ }
+ const parentBlock = $findMatchingParent(
+ node,
+ (parentNode): parentNode is ElementNode =>
+ $isElementNode(parentNode) && !parentNode.isInline(),
+ );
+ if (parentBlock === null) {
+ continue;
+ }
+ const parentKey = parentBlock.getKey();
+ if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
+ alreadyHandled.add(parentKey);
+ indentOrOutdent(parentBlock);
+ }
+ }
+ return alreadyHandled.size > 0;
+}
+
+function $isTargetWithinDecorator(target: HTMLElement): boolean {
+ const node = $getNearestNodeFromDOMNode(target);
+ return $isDecoratorNode(node);
+}
+
+function $isSelectionAtEndOfRoot(selection: RangeSelection) {
+ const focus = selection.focus;
+ return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
+}
+
+export function registerRichText(editor: LexicalEditor): () => void {
+ const removeListener = mergeRegister(
+ editor.registerCommand(
+ CLICK_COMMAND,
+ (payload) => {
+ const selection = $getSelection();
+ if ($isNodeSelection(selection)) {
+ selection.clear();
+ return true;
+ }
+ return false;
+ },
+ 0,
+ ),
+ editor.registerCommand<boolean>(
+ DELETE_CHARACTER_COMMAND,
+ (isBackward) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.deleteCharacter(isBackward);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<boolean>(
+ DELETE_WORD_COMMAND,
+ (isBackward) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.deleteWord(isBackward);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<boolean>(
+ DELETE_LINE_COMMAND,
+ (isBackward) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.deleteLine(isBackward);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ (eventOrText) => {
+ const selection = $getSelection();
+
+ if (typeof eventOrText === 'string') {
+ if (selection !== null) {
+ selection.insertText(eventOrText);
+ }
+ } else {
+ if (selection === null) {
+ return false;
+ }
+
+ const dataTransfer = eventOrText.dataTransfer;
+ if (dataTransfer != null) {
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ } else if ($isRangeSelection(selection)) {
+ const data = eventOrText.data;
+ if (data) {
+ selection.insertText(data);
+ }
+ return true;
+ }
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ REMOVE_TEXT_COMMAND,
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.removeText();
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<TextFormatType>(
+ FORMAT_TEXT_COMMAND,
+ (format) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.formatText(format);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<ElementFormatType>(
+ FORMAT_ELEMENT_COMMAND,
+ (format) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
+ return false;
+ }
+ const nodes = selection.getNodes();
+ for (const node of nodes) {
+ const element = $findMatchingParent(
+ node,
+ (parentNode): parentNode is ElementNode =>
+ $isElementNode(parentNode) && !parentNode.isInline(),
+ );
+ if (element !== null) {
+ element.setFormat(format);
+ }
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<boolean>(
+ INSERT_LINE_BREAK_COMMAND,
+ (selectStart) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.insertLineBreak(selectStart);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ INSERT_PARAGRAPH_COMMAND,
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ selection.insertParagraph();
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ INSERT_TAB_COMMAND,
+ () => {
+ $insertNodes([$createTabNode()]);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ INDENT_CONTENT_COMMAND,
+ () => {
+ return $handleIndentAndOutdent((block) => {
+ const indent = block.getIndent();
+ block.setIndent(indent + 1);
+ });
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ OUTDENT_CONTENT_COMMAND,
+ () => {
+ return $handleIndentAndOutdent((block) => {
+ const indent = block.getIndent();
+ if (indent > 0) {
+ block.setIndent(indent - 1);
+ }
+ });
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_UP_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if (
+ $isNodeSelection(selection) &&
+ !$isTargetWithinDecorator(event.target as HTMLElement)
+ ) {
+ // If selection is on a node, let's try and move selection
+ // back to being a range selection.
+ const nodes = selection.getNodes();
+ if (nodes.length > 0) {
+ nodes[0].selectPrevious();
+ return true;
+ }
+ } else if ($isRangeSelection(selection)) {
+ const possibleNode = $getAdjacentNode(selection.focus, true);
+ if (
+ !event.shiftKey &&
+ $isDecoratorNode(possibleNode) &&
+ !possibleNode.isIsolated() &&
+ !possibleNode.isInline()
+ ) {
+ possibleNode.selectPrevious();
+ event.preventDefault();
+ return true;
+ }
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_DOWN_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if ($isNodeSelection(selection)) {
+ // If selection is on a node, let's try and move selection
+ // back to being a range selection.
+ const nodes = selection.getNodes();
+ if (nodes.length > 0) {
+ nodes[0].selectNext(0, 0);
+ return true;
+ }
+ } else if ($isRangeSelection(selection)) {
+ if ($isSelectionAtEndOfRoot(selection)) {
+ event.preventDefault();
+ return true;
+ }
+ const possibleNode = $getAdjacentNode(selection.focus, false);
+ if (
+ !event.shiftKey &&
+ $isDecoratorNode(possibleNode) &&
+ !possibleNode.isIsolated() &&
+ !possibleNode.isInline()
+ ) {
+ possibleNode.selectNext();
+ event.preventDefault();
+ return true;
+ }
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_LEFT_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if ($isNodeSelection(selection)) {
+ // If selection is on a node, let's try and move selection
+ // back to being a range selection.
+ const nodes = selection.getNodes();
+ if (nodes.length > 0) {
+ event.preventDefault();
+ nodes[0].selectPrevious();
+ return true;
+ }
+ }
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
+ const isHoldingShift = event.shiftKey;
+ event.preventDefault();
+ $moveCharacter(selection, isHoldingShift, true);
+ return true;
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_RIGHT_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if (
+ $isNodeSelection(selection) &&
+ !$isTargetWithinDecorator(event.target as HTMLElement)
+ ) {
+ // If selection is on a node, let's try and move selection
+ // back to being a range selection.
+ const nodes = selection.getNodes();
+ if (nodes.length > 0) {
+ event.preventDefault();
+ nodes[0].selectNext(0, 0);
+ return true;
+ }
+ }
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ const isHoldingShift = event.shiftKey;
+ if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
+ event.preventDefault();
+ $moveCharacter(selection, isHoldingShift, false);
+ return true;
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent>(
+ KEY_BACKSPACE_COMMAND,
+ (event) => {
+ if ($isTargetWithinDecorator(event.target as HTMLElement)) {
+ return false;
+ }
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ event.preventDefault();
+ const {anchor} = selection;
+ const anchorNode = anchor.getNode();
+
+ if (
+ selection.isCollapsed() &&
+ anchor.offset === 0 &&
+ !$isRootNode(anchorNode)
+ ) {
+ const element = $getNearestBlockElementAncestorOrThrow(anchorNode);
+ if (element.getIndent() > 0) {
+ return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
+ }
+ }
+ return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent>(
+ KEY_DELETE_COMMAND,
+ (event) => {
+ if ($isTargetWithinDecorator(event.target as HTMLElement)) {
+ return false;
+ }
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ event.preventDefault();
+ return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<KeyboardEvent | null>(
+ KEY_ENTER_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ if (event !== null) {
+ // If we have beforeinput, then we can avoid blocking
+ // the default behavior. This ensures that the iOS can
+ // intercept that we're actually inserting a paragraph,
+ // and autocomplete, autocapitalize etc work as intended.
+ // This can also cause a strange performance issue in
+ // Safari, where there is a noticeable pause due to
+ // preventing the key down of enter.
+ if (
+ (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
+ CAN_USE_BEFORE_INPUT
+ ) {
+ return false;
+ }
+ event.preventDefault();
+ if (event.shiftKey) {
+ return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
+ }
+ }
+ return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ editor.blur();
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<DragEvent>(
+ DROP_COMMAND,
+ (event) => {
+ const [, files] = eventFiles(event);
+ if (files.length > 0) {
+ const x = event.clientX;
+ const y = event.clientY;
+ const eventRange = caretFromPoint(x, y);
+ if (eventRange !== null) {
+ const {offset: domOffset, node: domNode} = eventRange;
+ const node = $getNearestNodeFromDOMNode(domNode);
+ if (node !== null) {
+ const selection = $createRangeSelection();
+ if ($isTextNode(node)) {
+ selection.anchor.set(node.getKey(), domOffset, 'text');
+ selection.focus.set(node.getKey(), domOffset, 'text');
+ } else {
+ const parentKey = node.getParentOrThrow().getKey();
+ const offset = node.getIndexWithinParent() + 1;
+ selection.anchor.set(parentKey, offset, 'element');
+ selection.focus.set(parentKey, offset, 'element');
+ }
+ const normalizedSelection =
+ $normalizeSelection__EXPERIMENTAL(selection);
+ $setSelection(normalizedSelection);
+ }
+ editor.dispatchCommand(DRAG_DROP_PASTE, files);
+ }
+ event.preventDefault();
+ return true;
+ }
+
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ return true;
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<DragEvent>(
+ DRAGSTART_COMMAND,
+ (event) => {
+ const [isFileTransfer] = eventFiles(event);
+ const selection = $getSelection();
+ if (isFileTransfer && !$isRangeSelection(selection)) {
+ return false;
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand<DragEvent>(
+ DRAGOVER_COMMAND,
+ (event) => {
+ const [isFileTransfer] = eventFiles(event);
+ const selection = $getSelection();
+ if (isFileTransfer && !$isRangeSelection(selection)) {
+ return false;
+ }
+ const x = event.clientX;
+ const y = event.clientY;
+ const eventRange = caretFromPoint(x, y);
+ if (eventRange !== null) {
+ const node = $getNearestNodeFromDOMNode(eventRange.node);
+ if ($isDecoratorNode(node)) {
+ // Show browser caret as the user is dragging the media across the screen. Won't work
+ // for DecoratorNode nor it's relevant.
+ event.preventDefault();
+ }
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ SELECT_ALL_COMMAND,
+ () => {
+ $selectAll();
+
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ COPY_COMMAND,
+ (event) => {
+ copyToClipboard(
+ editor,
+ objectKlassEquals(event, ClipboardEvent)
+ ? (event as ClipboardEvent)
+ : null,
+ );
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ CUT_COMMAND,
+ (event) => {
+ onCutForRichText(event, editor);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ PASTE_COMMAND,
+ (event) => {
+ const [, files, hasTextContent] = eventFiles(event);
+ if (files.length > 0 && !hasTextContent) {
+ editor.dispatchCommand(DRAG_DROP_PASTE, files);
+ return true;
+ }
+
+ // if inputs then paste within the input ignore creating a new node on paste event
+ if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
+ return false;
+ }
+
+ const selection = $getSelection();
+ if (selection !== null) {
+ onPasteForRichText(event, editor);
+ return true;
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ );
+ return removeListener;
+}
--- /dev/null
+/**
+ * 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 {$createLinkNode} from '@lexical/link';
+import {$createListItemNode, $createListNode} from '@lexical/list';
+import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {ContentEditable} from '@lexical/react/LexicalContentEditable';
+import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
+import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
+import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
+import {$createHeadingNode} from '@lexical/rich-text';
+import {
+ $addNodeStyle,
+ $getSelectionStyleValueForProperty,
+ $patchStyleText,
+ $setBlocksType,
+} from '@lexical/selection';
+import {$createTableNodeWithDimensions} from '@lexical/table';
+import {
+ $createLineBreakNode,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isElementNode,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+ DecoratorNode,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ ParagraphNode,
+ PointType,
+ type RangeSelection,
+ TextNode,
+} from 'lexical';
+import {
+ $assertRangeSelection,
+ $createTestDecoratorNode,
+ $createTestElementNode,
+ createTestEditor,
+ initializeClipboard,
+ invariant,
+ TestComposer,
+} from 'lexical/src/__tests__/utils';
+import {createRoot, Root} from 'react-dom/client';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+import {
+ $setAnchorPoint,
+ $setFocusPoint,
+ applySelectionInputs,
+ convertToSegmentedNode,
+ convertToTokenNode,
+ deleteBackward,
+ deleteWordBackward,
+ deleteWordForward,
+ formatBold,
+ formatItalic,
+ formatStrikeThrough,
+ formatUnderline,
+ getNodeFromPath,
+ insertParagraph,
+ insertSegmentedNode,
+ insertText,
+ insertTokenNode,
+ moveBackward,
+ moveEnd,
+ moveNativeSelection,
+ pastePlain,
+ printWhitespace,
+ redo,
+ setNativeSelectionWithPaths,
+ undo,
+} from '../utils';
+
+interface ExpectedSelection {
+ anchorPath: number[];
+ anchorOffset: number;
+ focusPath: number[];
+ focusOffset: number;
+}
+
+initializeClipboard();
+
+jest.mock('lexical/shared/environment', () => {
+ const originalModule = jest.requireActual('lexical/shared/environment');
+
+ return {...originalModule, IS_FIREFOX: true};
+});
+
+Range.prototype.getBoundingClientRect = function (): DOMRect {
+ const rect = {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+ return {
+ ...rect,
+ toJSON() {
+ return rect;
+ },
+ };
+};
+
+describe('LexicalSelection tests', () => {
+ let container: HTMLElement;
+ let reactRoot: Root;
+ let editor: LexicalEditor | null = null;
+
+ beforeEach(async () => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ reactRoot = createRoot(container);
+ await init();
+ });
+
+ afterEach(async () => {
+ // Ensure we are clearing out any React state and running effects with
+ // act
+ await ReactTestUtils.act(async () => {
+ reactRoot.unmount();
+ await Promise.resolve().then();
+ });
+ document.body.removeChild(container);
+ });
+
+ async function init() {
+ function TestBase() {
+ function TestPlugin() {
+ [editor] = useLexicalComposerContext();
+
+ return null;
+ }
+
+ return (
+ <TestComposer
+ config={{
+ nodes: [],
+ theme: {
+ code: 'editor-code',
+ heading: {
+ h1: 'editor-heading-h1',
+ h2: 'editor-heading-h2',
+ h3: 'editor-heading-h3',
+ h4: 'editor-heading-h4',
+ h5: 'editor-heading-h5',
+ h6: 'editor-heading-h6',
+ },
+ image: 'editor-image',
+ list: {
+ ol: 'editor-list-ol',
+ ul: 'editor-list-ul',
+ },
+ listitem: 'editor-listitem',
+ paragraph: 'editor-paragraph',
+ quote: 'editor-quote',
+ text: {
+ bold: 'editor-text-bold',
+ code: 'editor-text-code',
+ hashtag: 'editor-text-hashtag',
+ italic: 'editor-text-italic',
+ link: 'editor-text-link',
+ strikethrough: 'editor-text-strikethrough',
+ underline: 'editor-text-underline',
+ underlineStrikethrough: 'editor-text-underlineStrikethrough',
+ },
+ },
+ }}>
+ <RichTextPlugin
+ contentEditable={
+ // eslint-disable-next-line jsx-a11y/aria-role, @typescript-eslint/no-explicit-any
+ <ContentEditable role={null as any} spellCheck={null as any} />
+ }
+ placeholder={null}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ <HistoryPlugin />
+ <TestPlugin />
+ <AutoFocusPlugin />
+ </TestComposer>
+ );
+ }
+
+ await ReactTestUtils.act(async () => {
+ reactRoot.render(<TestBase />);
+ await Promise.resolve().then();
+ });
+
+ await Promise.resolve().then();
+ // Focus first element
+ setNativeSelectionWithPaths(
+ editor!.getRootElement()!,
+ [0, 0],
+ 0,
+ [0, 0],
+ 0,
+ );
+ }
+
+ async function update(fn: () => void) {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(fn);
+ });
+ }
+
+ test('Expect initial output to be a block with no text.', () => {
+ expect(container!.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><br></p></div>',
+ );
+ });
+
+ function assertSelection(
+ rootElement: HTMLElement,
+ expectedSelection: ExpectedSelection,
+ ) {
+ const actualSelection = window.getSelection()!;
+
+ expect(actualSelection.anchorNode).toBe(
+ getNodeFromPath(expectedSelection.anchorPath, rootElement),
+ );
+ expect(actualSelection.anchorOffset).toBe(expectedSelection.anchorOffset);
+ expect(actualSelection.focusNode).toBe(
+ getNodeFromPath(expectedSelection.focusPath, rootElement),
+ );
+ expect(actualSelection.focusOffset).toBe(expectedSelection.focusOffset);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const GRAPHEME_SCENARIOS = [
+ {
+ description: 'grapheme cluster',
+ // Hangul grapheme cluster.
+ // https://www.compart.com/en/unicode/U+AC01
+ grapheme: '\u1100\u1161\u11A8',
+ },
+ {
+ description: 'extended grapheme cluster',
+ // Tamil 'ni' grapheme cluster.
+ // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
+ grapheme: '\u0BA8\u0BBF',
+ },
+ {
+ description: 'tailored grapheme cluster',
+ // Devangari 'kshi' tailored grapheme cluster.
+ // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
+ grapheme: '\u0915\u094D\u0937\u093F',
+ },
+ {
+ description: 'Emoji sequence combined using zero-width joiners',
+ // https://emojipedia.org/family-woman-woman-girl-boy/
+ grapheme:
+ '\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66',
+ },
+ {
+ description: 'Emoji sequence with skin-tone modifier',
+ // https://emojipedia.org/clapping-hands-medium-skin-tone/
+ grapheme: '\uD83D\uDC4F\uD83C\uDFFD',
+ },
+ ];
+
+ const suite = [
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ formatBold(),
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing in bold',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<em class="editor-text-italic" data-lexical-text="true">Hello</em></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ formatItalic(),
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing in italic',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">Hello</strong></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ formatItalic(),
+ formatBold(),
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing in italic + bold',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span class="editor-text-underline" data-lexical-text="true">Hello</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ formatUnderline(),
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing in underline',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span class="editor-text-strikethrough" data-lexical-text="true">Hello</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ formatStrikeThrough(),
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing in strikethrough',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span class="editor-text-underlineStrikethrough" data-lexical-text="true">Hello</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ formatUnderline(),
+ formatStrikeThrough(),
+ insertText('H'),
+ insertText('e'),
+ insertText('l'),
+ insertText('l'),
+ insertText('o'),
+ ],
+ name: 'Simple typing in underline + strikethrough',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">1246</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 4,
+ anchorPath: [0, 0, 0],
+ focusOffset: 4,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText('1'),
+ insertText('2'),
+ insertText('3'),
+ deleteBackward(1),
+ insertText('4'),
+ insertText('5'),
+ deleteBackward(1),
+ insertText('6'),
+ ],
+ name: 'Deletion',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">Dominic Gannaway</span>' +
+ '</p></div>',
+ expectedSelection: {
+ anchorOffset: 16,
+ anchorPath: [0, 0, 0],
+ focusOffset: 16,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [insertTokenNode('Dominic Gannaway')],
+ name: 'Creation of an token node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">Dominic Gannaway</span>' +
+ '</p></div>',
+ expectedSelection: {
+ anchorOffset: 1,
+ anchorPath: [0],
+ focusOffset: 1,
+ focusPath: [0],
+ },
+ inputs: [
+ insertText('Dominic Gannaway'),
+ moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
+ convertToTokenNode(),
+ ],
+ name: 'Convert text to an token node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">Dominic Gannaway</span>' +
+ '</p></div>',
+ expectedSelection: {
+ anchorOffset: 1,
+ anchorPath: [0],
+ focusOffset: 1,
+ focusPath: [0],
+ },
+ inputs: [insertSegmentedNode('Dominic Gannaway')],
+ name: 'Creation of a segmented node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">Dominic Gannaway</span>' +
+ '</p></div>',
+ expectedSelection: {
+ anchorOffset: 1,
+ anchorPath: [0],
+ focusOffset: 1,
+ focusPath: [0],
+ },
+ inputs: [
+ insertText('Dominic Gannaway'),
+ moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
+ convertToSegmentedNode(),
+ ],
+ name: 'Convert text to a segmented node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello world</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0],
+ focusOffset: 0,
+ focusPath: [2],
+ },
+ inputs: [
+ insertParagraph(),
+ insertText('Hello world'),
+ insertParagraph(),
+ moveNativeSelection([0], 0, [2], 0),
+ formatBold(),
+ ],
+ name: 'Format selection that starts and ends on element and retain selection',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0],
+ focusOffset: 0,
+ focusPath: [3],
+ },
+ inputs: [
+ insertParagraph(),
+ insertText('Hello'),
+ insertParagraph(),
+ insertText('world'),
+ insertParagraph(),
+ moveNativeSelection([0], 0, [3], 0),
+ formatBold(),
+ ],
+ name: 'Format multiline text selection that starts and ends on element and retain selection',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">He</span>' +
+ '<strong class="editor-text-bold" data-lexical-text="true">llo</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">wo</strong>' +
+ '<span data-lexical-text="true">rld</span>' +
+ '</p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0, 1, 0],
+ focusOffset: 2,
+ focusPath: [1, 0, 0],
+ },
+ inputs: [
+ insertText('Hello'),
+ insertParagraph(),
+ insertText('world'),
+ moveNativeSelection([0, 0, 0], 2, [1, 0, 0], 2),
+ formatBold(),
+ ],
+ name: 'Format multiline text selection that starts and ends within text',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">Hello </span>' +
+ '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [1, 1, 0],
+ focusOffset: 0,
+ focusPath: [2],
+ },
+ inputs: [
+ insertParagraph(),
+ insertText('Hello world'),
+ insertParagraph(),
+ moveNativeSelection([1, 0, 0], 6, [2], 0),
+ formatBold(),
+ ],
+ name: 'Format selection that starts on text and ends on element and retain selection',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
+ '<span data-lexical-text="true"> world</span>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0],
+ focusOffset: 5,
+ focusPath: [1, 0, 0],
+ },
+ inputs: [
+ insertParagraph(),
+ insertText('Hello world'),
+ insertParagraph(),
+ moveNativeSelection([0], 0, [1, 0, 0], 5),
+ formatBold(),
+ ],
+ name: 'Format selection that starts on element and ends on text and retain selection',
+ },
+
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 2,
+ anchorPath: [1, 0, 0],
+ focusOffset: 0,
+ focusPath: [2],
+ },
+ inputs: [
+ insertParagraph(),
+ insertTokenNode('Hello'),
+ insertText(' world'),
+ insertParagraph(),
+ moveNativeSelection([1, 0, 0], 2, [2], 0),
+ formatBold(),
+ ],
+ name: 'Format selection that starts on middle of token node should format complete node',
+ },
+
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0],
+ focusOffset: 2,
+ focusPath: [1, 1, 0],
+ },
+ inputs: [
+ insertParagraph(),
+ insertText('Hello '),
+ insertTokenNode('world'),
+ insertParagraph(),
+ moveNativeSelection([0], 0, [1, 1, 0], 2),
+ formatBold(),
+ ],
+ name: 'Format selection that ends on middle of token node should format complete node',
+ },
+
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><span data-lexical-text="true"> world</span>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 2,
+ anchorPath: [1, 0, 0],
+ focusOffset: 3,
+ focusPath: [1, 0, 0],
+ },
+ inputs: [
+ insertParagraph(),
+ insertTokenNode('Hello'),
+ insertText(' world'),
+ insertParagraph(),
+ moveNativeSelection([1, 0, 0], 2, [1, 0, 0], 3),
+ formatBold(),
+ ],
+ name: 'Format token node if it is the single one selected',
+ },
+
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
+ '<p class="editor-paragraph"><br></p>' +
+ '<p class="editor-paragraph" dir="ltr">' +
+ '<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">beautiful</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
+ '</p>' +
+ '<p class="editor-paragraph"><br></p>' +
+ '</div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0],
+ focusOffset: 0,
+ focusPath: [2],
+ },
+ inputs: [
+ insertParagraph(),
+ insertText('Hello '),
+ insertTokenNode('beautiful'),
+ insertText(' world'),
+ insertParagraph(),
+ moveNativeSelection([0], 0, [2], 0),
+ formatBold(),
+ ],
+ name: 'Format selection that contains a token node in the middle should format the token node',
+ },
+
+ // Tests need fixing:
+ // ...GRAPHEME_SCENARIOS.flatMap(({description, grapheme}) => [
+ // {
+ // name: `Delete backward eliminates entire ${description} (${grapheme})`,
+ // inputs: [insertText(grapheme + grapheme), deleteBackward(1)],
+ // expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}</span></p></div>`,
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: grapheme.length,
+ // focusPath: [0, 0, 0],
+ // focusOffset: grapheme.length,
+ // },
+ // setup: emptySetup,
+ // },
+ // {
+ // name: `Delete forward eliminates entire ${description} (${grapheme})`,
+ // inputs: [
+ // insertText(grapheme + grapheme),
+ // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
+ // deleteForward(),
+ // ],
+ // expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}</span></p></div>`,
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 0,
+ // },
+ // setup: emptySetup,
+ // },
+ // {
+ // name: `Move backward skips over grapheme cluster (${grapheme})`,
+ // inputs: [insertText(grapheme + grapheme), moveBackward(1)],
+ // expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}${grapheme}</span></p></div>`,
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: grapheme.length,
+ // focusPath: [0, 0, 0],
+ // focusOffset: grapheme.length,
+ // },
+ // setup: emptySetup,
+ // },
+ // {
+ // name: `Move forward skips over grapheme cluster (${grapheme})`,
+ // inputs: [
+ // insertText(grapheme + grapheme),
+ // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
+ // moveForward(),
+ // ],
+ // expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}${grapheme}</span></p></div>`,
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: grapheme.length,
+ // focusPath: [0, 0, 0],
+ // focusOffset: grapheme.length,
+ // },
+ // setup: emptySetup,
+ // },
+ // ]),
+ // {
+ // name: 'Jump to beginning and insert',
+ // inputs: [
+ // insertText('1'),
+ // insertText('1'),
+ // insertText('2'),
+ // insertText('3'),
+ // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
+ // insertText('a'),
+ // insertText('b'),
+ // insertText('c'),
+ // deleteForward(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">abc123</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 3,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 3,
+ // },
+ // },
+ // {
+ // name: 'Select and replace',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // insertText('lexical'),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello lexical!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 13,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 13,
+ // },
+ // },
+ // {
+ // name: 'Select and bold',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // formatBold(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
+ // '<strong class="editor-text-bold" data-lexical-text="true">draft</strong><span data-lexical-text="true">!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 1, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 1, 0],
+ // focusOffset: 5,
+ // },
+ // },
+ // {
+ // name: 'Select and italic',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // formatItalic(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
+ // '<em class="editor-text-italic" data-lexical-text="true">draft</em><span data-lexical-text="true">!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 1, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 1, 0],
+ // focusOffset: 5,
+ // },
+ // },
+ // {
+ // name: 'Select and bold + italic',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // formatBold(),
+ // formatItalic(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
+ // '<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">draft</strong><span data-lexical-text="true">!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 1, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 1, 0],
+ // focusOffset: 5,
+ // },
+ // },
+ // {
+ // name: 'Select and underline',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // formatUnderline(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
+ // '<span class="editor-text-underline" data-lexical-text="true">draft</span><span data-lexical-text="true">!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 1, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 1, 0],
+ // focusOffset: 5,
+ // },
+ // },
+ // {
+ // name: 'Select and strikethrough',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // formatStrikeThrough(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
+ // '<span class="editor-text-strikethrough" data-lexical-text="true">draft</span><span data-lexical-text="true">!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 1, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 1, 0],
+ // focusOffset: 5,
+ // },
+ // },
+ // {
+ // name: 'Select and underline + strikethrough',
+ // inputs: [
+ // insertText('Hello draft!'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
+ // formatUnderline(),
+ // formatStrikeThrough(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
+ // '<span class="editor-text-underlineStrikethrough" data-lexical-text="true">draft</span><span data-lexical-text="true">!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 1, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 1, 0],
+ // focusOffset: 5,
+ // },
+ // },
+ // {
+ // name: 'Select and replace all',
+ // inputs: [
+ // insertText('This is broken.'),
+ // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 15),
+ // insertText('This works!'),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 11,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 11,
+ // },
+ // },
+ // {
+ // name: 'Select and delete',
+ // inputs: [
+ // insertText('A lion.'),
+ // moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6),
+ // deleteForward(),
+ // insertText('duck'),
+ // moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">A duck.</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 2,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 6,
+ // },
+ // },
+ // {
+ // name: 'Inserting a paragraph',
+ // inputs: [insertParagraph()],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true"><br></span></p>' +
+ // '<p class="editor-paragraph"><span data-lexical-text="true"><br></span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [1, 0, 0],
+ // anchorOffset: 0,
+ // focusPath: [1, 0, 0],
+ // focusOffset: 0,
+ // },
+ // },
+ // {
+ // name: 'Inserting a paragraph and then removing it',
+ // inputs: [insertParagraph(), deleteBackward(1)],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true"><br></span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 0,
+ // },
+ // },
+ // {
+ // name: 'Inserting a paragraph part way through text',
+ // inputs: [
+ // insertText('Hello world'),
+ // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
+ // insertParagraph(),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span></p>' +
+ // '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">world</span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [1, 0, 0],
+ // anchorOffset: 0,
+ // focusPath: [1, 0, 0],
+ // focusOffset: 0,
+ // },
+ // },
+ // {
+ // name: 'Inserting two paragraphs and then deleting via selection',
+ // inputs: [
+ // insertText('123'),
+ // insertParagraph(),
+ // insertText('456'),
+ // moveNativeSelection([0, 0, 0], 0, [1, 0, 0], 3),
+ // deleteBackward(1),
+ // ],
+ // expectedHTML:
+ // '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true"><br></span></p></div>',
+ // expectedSelection: {
+ // anchorPath: [0, 0, 0],
+ // anchorOffset: 0,
+ // focusPath: [0, 0, 0],
+ // focusOffset: 0,
+ // },
+ // },
+ ...[
+ {
+ whitespaceCharacter: ' ',
+ whitespaceName: 'space',
+ },
+ {
+ whitespaceCharacter: '\u00a0',
+ whitespaceName: 'non-breaking space',
+ },
+ {
+ whitespaceCharacter: '\u2000',
+ whitespaceName: 'en quad',
+ },
+ {
+ whitespaceCharacter: '\u2001',
+ whitespaceName: 'em quad',
+ },
+ {
+ whitespaceCharacter: '\u2002',
+ whitespaceName: 'en space',
+ },
+ {
+ whitespaceCharacter: '\u2003',
+ whitespaceName: 'em space',
+ },
+ {
+ whitespaceCharacter: '\u2004',
+ whitespaceName: 'three-per-em space',
+ },
+ {
+ whitespaceCharacter: '\u2005',
+ whitespaceName: 'four-per-em space',
+ },
+ {
+ whitespaceCharacter: '\u2006',
+ whitespaceName: 'six-per-em space',
+ },
+ {
+ whitespaceCharacter: '\u2007',
+ whitespaceName: 'figure space',
+ },
+ {
+ whitespaceCharacter: '\u2008',
+ whitespaceName: 'punctuation space',
+ },
+ {
+ whitespaceCharacter: '\u2009',
+ whitespaceName: 'thin space',
+ },
+ {
+ whitespaceCharacter: '\u200A',
+ whitespaceName: 'hair space',
+ },
+ ].flatMap(({whitespaceCharacter, whitespaceName}) => [
+ {
+ expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello${printWhitespace(
+ whitespaceCharacter,
+ )}</span></p></div>`,
+ expectedSelection: {
+ anchorOffset: 6,
+ anchorPath: [0, 0, 0],
+ focusOffset: 6,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText(`Hello${whitespaceCharacter}world`),
+ deleteWordBackward(1),
+ ],
+ name: `Type two words separated by a ${whitespaceName}, delete word backward from end`,
+ },
+ {
+ expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">${printWhitespace(
+ whitespaceCharacter,
+ )}world</span></p></div>`,
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0, 0, 0],
+ focusOffset: 0,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText(`Hello${whitespaceCharacter}world`),
+ moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
+ deleteWordForward(1),
+ ],
+ name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning`,
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText(`Hello${whitespaceCharacter}world`),
+ moveNativeSelection([0, 0, 0], 5, [0, 0, 0], 5),
+ deleteWordForward(1),
+ ],
+ name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning of preceding whitespace`,
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">world</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0, 0, 0],
+ focusOffset: 0,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText(`Hello${whitespaceCharacter}world`),
+ moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
+ deleteWordBackward(1),
+ ],
+ name: `Type two words separated by a ${whitespaceName}, delete word backward from end of trailing whitespace`,
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello world</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 11,
+ anchorPath: [0, 0, 0],
+ focusOffset: 11,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [insertText('Hello world'), deleteWordBackward(1), undo(1)],
+ name: `Type a word, delete it and undo the deletion`,
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span></p></div>',
+ expectedSelection: {
+ anchorOffset: 6,
+ anchorPath: [0, 0, 0],
+ focusOffset: 6,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText('Hello world'),
+ deleteWordBackward(1),
+ undo(1),
+ redo(1),
+ ],
+ name: `Type a word, delete it and undo the deletion`,
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">this is weird test</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 0,
+ anchorPath: [0, 0, 0],
+ focusOffset: 0,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ insertText('this is weird test'),
+ moveNativeSelection([0, 0, 0], 14, [0, 0, 0], 14),
+ moveBackward(14),
+ ],
+ name: 'Type a sentence, move the caret to the middle and move with the arrows to the start',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
+ '<span data-lexical-text="true">Hello </span>' +
+ '<span data-lexical-text="true">Bob</span>' +
+ '</p></div>',
+ expectedSelection: {
+ anchorOffset: 3,
+ anchorPath: [0, 1, 0],
+ focusOffset: 3,
+ focusPath: [0, 1, 0],
+ },
+ inputs: [
+ insertText('Hello '),
+ insertTokenNode('Bob'),
+ moveBackward(1),
+ moveBackward(1),
+ moveEnd(),
+ ],
+ name: 'Type a text and token text, move the caret to the end of the first text',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">ABD</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">EFG</span></p></div>',
+ expectedSelection: {
+ anchorOffset: 3,
+ anchorPath: [0, 0, 0],
+ focusOffset: 3,
+ focusPath: [0, 0, 0],
+ },
+ inputs: [
+ pastePlain('ABD\tEFG'),
+ moveBackward(5),
+ insertText('C'),
+ moveBackward(1),
+ deleteWordForward(1),
+ ],
+ name: 'Paste text, move selection and delete word forward',
+ },
+ ]),
+ ];
+
+ suite.forEach((testUnit, i) => {
+ const name = testUnit.name || 'Test case';
+
+ test(name + ` (#${i + 1})`, async () => {
+ await applySelectionInputs(testUnit.inputs, update, editor!);
+
+ // Validate HTML matches
+ expect(container.innerHTML).toBe(testUnit.expectedHTML);
+
+ // Validate selection matches
+ const rootElement = editor!.getRootElement()!;
+ const expectedSelection = testUnit.expectedSelection;
+
+ assertSelection(rootElement, expectedSelection);
+ });
+ });
+
+ test('insert text one selected node element selection', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const paragraph = root.getFirstChild<ParagraphNode>()!;
+
+ const elementNode = $createTestElementNode();
+ const text = $createTextNode('foo');
+
+ paragraph.append(elementNode);
+ elementNode.append(text);
+
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.__key, 0, 'text');
+ selection.focus.set(paragraph.__key, 1, 'element');
+
+ selection.insertText('');
+
+ expect(root.getTextContent()).toBe('');
+ });
+ });
+ });
+
+ test('getNodes resolves nested block nodes', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const paragraph = root.getFirstChild<ParagraphNode>()!;
+
+ const elementNode = $createTestElementNode();
+ const text = $createTextNode();
+
+ paragraph.append(elementNode);
+ elementNode.append(text);
+
+ const selectedNodes = $getSelection()!.getNodes();
+
+ expect(selectedNodes.length).toBe(1);
+ expect(selectedNodes[0].getKey()).toBe(text.getKey());
+ });
+ });
+ });
+
+ describe('Block selection moves when new nodes are inserted', () => {
+ const baseCases: {
+ name: string;
+ anchorOffset: number;
+ focusOffset: number;
+ fn: (
+ paragraph: ElementNode,
+ text: TextNode,
+ ) => {
+ expectedAnchor: LexicalNode;
+ expectedAnchorOffset: number;
+ expectedFocus: LexicalNode;
+ expectedFocusOffset: number;
+ };
+ fnBefore?: (paragraph: ElementNode, text: TextNode) => void;
+ invertSelection?: true;
+ only?: true;
+ }[] = [
+ // Collapsed selection on end; add/remove/replace beginning
+ {
+ anchorOffset: 2,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('2');
+ text.insertBefore(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 3,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 2,
+ name: 'insertBefore - Collapsed selection on end; add beginning',
+ },
+ {
+ anchorOffset: 2,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('2');
+ text.insertAfter(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 3,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 2,
+ name: 'insertAfter - Collapsed selection on end; add beginning',
+ },
+ {
+ anchorOffset: 2,
+ fn: (paragraph, text) => {
+ text.splitText(1);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 3,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 2,
+ name: 'splitText - Collapsed selection on end; add beginning',
+ },
+ {
+ anchorOffset: 1,
+ fn: (paragraph, text) => {
+ text.remove();
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 0,
+ };
+ },
+ focusOffset: 1,
+ name: 'remove - Collapsed selection on end; add beginning',
+ },
+ {
+ anchorOffset: 1,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('replacement');
+ text.replace(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 1,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 1,
+ };
+ },
+ focusOffset: 1,
+ name: 'replace - Collapsed selection on end; replace beginning',
+ },
+ // All selected; add/remove/replace on beginning
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('2');
+ text.insertBefore(newText);
+
+ return {
+ expectedAnchor: text,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 2,
+ name: 'insertBefore - All selected; add on beginning',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText) => {
+ const [, text] = originalText.splitText(1);
+
+ return {
+ expectedAnchor: text,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 2,
+ name: 'splitNodes - All selected; add on beginning',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ text.remove();
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 0,
+ };
+ },
+ focusOffset: 1,
+ name: 'remove - All selected; remove on beginning',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('replacement');
+ text.replace(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 1,
+ };
+ },
+ focusOffset: 1,
+ name: 'replace - All selected; replace on beginning',
+ },
+ // Selection beginning; add/remove/replace on end
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const lastChild = paragraph.getLastChild()!;
+ const newText = $createTextNode('2');
+ lastChild.insertBefore(newText);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: originalText1,
+ expectedFocusOffset: 0,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 1,
+ name: 'insertBefore - Selection beginning; add on end',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const lastChild = paragraph.getLastChild()!;
+ const newText = $createTextNode('2');
+ lastChild.insertAfter(newText);
+
+ return {
+ expectedAnchor: text,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 1,
+ };
+ },
+ focusOffset: 1,
+ name: 'insertAfter - Selection beginning; add on end',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const [, text] = originalText1.splitText(1);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: text,
+ expectedFocusOffset: 0,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 1,
+ name: 'splitText - Selection beginning; add on end',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const lastChild = paragraph.getLastChild()!;
+ lastChild.remove();
+
+ return {
+ expectedAnchor: text,
+ expectedAnchorOffset: 0,
+ expectedFocus: text,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 1,
+ name: 'remove - Selection beginning; remove on end',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('replacement');
+ const lastChild = paragraph.getLastChild()!;
+ lastChild.replace(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 1,
+ };
+ },
+ focusOffset: 1,
+ name: 'replace - Selection beginning; replace on end',
+ },
+ // All selected; add/remove/replace in end offset [1, 2] -> [1, N, 2]
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const lastChild = paragraph.getLastChild()!;
+ const newText = $createTextNode('2');
+ lastChild.insertBefore(newText);
+
+ return {
+ expectedAnchor: text,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 2,
+ };
+ },
+ focusOffset: 1,
+ name: 'insertBefore - All selected; add in end offset',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, text) => {
+ const newText = $createTextNode('2');
+ text.insertAfter(newText);
+
+ return {
+ expectedAnchor: text,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 2,
+ };
+ },
+ focusOffset: 1,
+ name: 'insertAfter - All selected; add in end offset',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const [, text] = originalText1.splitText(1);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: text,
+ expectedFocusOffset: 0,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 1,
+ name: 'splitText - All selected; add in end offset',
+ },
+ {
+ anchorOffset: 1,
+ fn: (paragraph, originalText1) => {
+ const lastChild = paragraph.getLastChild()!;
+ lastChild.remove();
+
+ return {
+ expectedAnchor: originalText1,
+ expectedAnchorOffset: 0,
+ expectedFocus: originalText1,
+ expectedFocusOffset: 3,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'remove - All selected; remove in end offset',
+ },
+ {
+ anchorOffset: 1,
+ fn: (paragraph, originalText1) => {
+ const newText = $createTextNode('replacement');
+ const lastChild = paragraph.getLastChild()!;
+ lastChild.replace(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 1,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 2,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'replace - All selected; replace in end offset',
+ },
+ // All selected; add/remove/replace in middle [1, 2, 3] -> [1, 2, N, 3]
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const lastChild = paragraph.getLastChild()!;
+ const newText = $createTextNode('2');
+ lastChild.insertBefore(newText);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'insertBefore - All selected; add in middle',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const newText = $createTextNode('2');
+ originalText1.insertAfter(newText);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'insertAfter - All selected; add in middle',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ originalText1.splitText(1);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 3,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'splitText - All selected; add in middle',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = originalText1.getPreviousSibling()!;
+ originalText1.remove();
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 1,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'remove - All selected; remove in middle',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const newText = $createTextNode('replacement');
+ originalText1.replace(newText);
+
+ return {
+ expectedAnchor: paragraph,
+ expectedAnchorOffset: 0,
+ expectedFocus: paragraph,
+ expectedFocusOffset: 2,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ originalText1.insertBefore(originalText2);
+ },
+ focusOffset: 2,
+ name: 'replace - All selected; replace in middle',
+ },
+ // Edge cases
+ {
+ anchorOffset: 3,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = paragraph.getLastChild()!;
+ const newText = $createTextNode('new');
+ originalText1.insertBefore(newText);
+
+ return {
+ expectedAnchor: originalText2,
+ expectedAnchorOffset: 'bar'.length,
+ expectedFocus: originalText2,
+ expectedFocusOffset: 'bar'.length,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ paragraph.append(originalText2);
+ },
+ focusOffset: 3,
+ name: "Selection resolves to the end of text node when it's at the end (1)",
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ const originalText2 = paragraph.getLastChild()!;
+ const newText = $createTextNode('new');
+ originalText1.insertBefore(newText);
+
+ return {
+ expectedAnchor: originalText1,
+ expectedAnchorOffset: 0,
+ expectedFocus: originalText2,
+ expectedFocusOffset: 'bar'.length,
+ };
+ },
+ fnBefore: (paragraph, originalText1) => {
+ const originalText2 = $createTextNode('bar');
+ paragraph.append(originalText2);
+ },
+ focusOffset: 3,
+ name: "Selection resolves to the end of text node when it's at the end (2)",
+ },
+ {
+ anchorOffset: 1,
+ fn: (paragraph, originalText1) => {
+ originalText1.getNextSibling()!.remove();
+
+ return {
+ expectedAnchor: originalText1,
+ expectedAnchorOffset: 3,
+ expectedFocus: originalText1,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 1,
+ name: 'remove - Remove with collapsed selection at offset #4221',
+ },
+ {
+ anchorOffset: 0,
+ fn: (paragraph, originalText1) => {
+ originalText1.getNextSibling()!.remove();
+
+ return {
+ expectedAnchor: originalText1,
+ expectedAnchorOffset: 0,
+ expectedFocus: originalText1,
+ expectedFocusOffset: 3,
+ };
+ },
+ focusOffset: 1,
+ name: 'remove - Remove with non-collapsed selection at offset',
+ },
+ ];
+ baseCases
+ .flatMap((testCase) => {
+ // Test inverse selection
+ const inverse = {
+ ...testCase,
+ anchorOffset: testCase.focusOffset,
+ focusOffset: testCase.anchorOffset,
+ invertSelection: true,
+ name: testCase.name + ' (inverse selection)',
+ };
+ return [testCase, inverse];
+ })
+ .forEach(
+ ({
+ name,
+ fn,
+ fnBefore = () => {
+ return;
+ },
+ anchorOffset,
+ focusOffset,
+ invertSelection,
+ only,
+ }) => {
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ const test_ = only === true ? test.only : test;
+ test_(name, async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const paragraph = root.getFirstChild<ParagraphNode>()!;
+ const textNode = $createTextNode('foo');
+ // Note: line break can't be selected by the DOM
+ const linebreak = $createLineBreakNode();
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+
+ paragraph.append(textNode, linebreak);
+
+ fnBefore(paragraph, textNode);
+
+ anchor.set(paragraph.getKey(), anchorOffset, 'element');
+ focus.set(paragraph.getKey(), focusOffset, 'element');
+
+ const {
+ expectedAnchor,
+ expectedAnchorOffset,
+ expectedFocus,
+ expectedFocusOffset,
+ } = fn(paragraph, textNode);
+
+ if (invertSelection !== true) {
+ expect(selection.anchor.key).toBe(expectedAnchor.__key);
+ expect(selection.anchor.offset).toBe(expectedAnchorOffset);
+ expect(selection.focus.key).toBe(expectedFocus.__key);
+ expect(selection.focus.offset).toBe(expectedFocusOffset);
+ } else {
+ expect(selection.anchor.key).toBe(expectedFocus.__key);
+ expect(selection.anchor.offset).toBe(expectedFocusOffset);
+ expect(selection.focus.key).toBe(expectedAnchor.__key);
+ expect(selection.focus.offset).toBe(expectedAnchorOffset);
+ }
+ });
+ });
+ });
+ },
+ );
+ });
+
+ describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => {
+ test('', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const listNode = $createListNode('bullet');
+ const listItemNode = $createListItemNode();
+ const paragraph = $createParagraphNode();
+
+ root.append(listNode);
+
+ listNode.append(listItemNode);
+ listItemNode.select();
+ listNode.insertAfter(paragraph);
+ listItemNode.remove();
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.getNode().__type).toBe('paragraph');
+ expect(selection.focus.getNode().__type).toBe('paragraph');
+ });
+ });
+ });
+ });
+
+ describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => {
+ test('', async () => {
+ await ReactTestUtils.act(async () => {
+ let paragraphNodeKey: string;
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const paragraphNode = $createParagraphNode();
+ paragraphNodeKey = paragraphNode.__key;
+ const listNode = $createListNode('number');
+ const listItemNode1 = $createListItemNode();
+ const textNode1 = $createTextNode('foo');
+ const listItemNode2 = $createListItemNode();
+ const listNode2 = $createListNode('number');
+ const listItemNode2x1 = $createListItemNode();
+
+ listNode.append(listItemNode1, listItemNode2);
+ listItemNode1.append(textNode1);
+ listItemNode2.append(listNode2);
+ listNode2.append(listItemNode2x1);
+ root.append(paragraphNode, listNode);
+
+ listItemNode2.select();
+
+ listNode.remove();
+ });
+ await editor!.getEditorState().read(() => {
+ const selection = $assertRangeSelection($getSelection());
+ expect(selection.anchor.key).toBe(paragraphNodeKey);
+ expect(selection.focus.key).toBe(paragraphNodeKey);
+ });
+ });
+ });
+ });
+
+ describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => {
+ test('', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ // Arrange
+ // Root
+ // |- Paragraph
+ // |- Link
+ // |- Text
+ // |- LineBreak
+ // |- Text
+ // |- Text
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const link = $createLinkNode('bullet');
+ const textOne = $createTextNode('Hello');
+ const br = $createLineBreakNode();
+ const textTwo = $createTextNode('world');
+ const textThree = $createTextNode(' ');
+
+ root.append(paragraph);
+ link.append(textOne);
+ link.append(br);
+ link.append(textTwo);
+
+ paragraph.append(link);
+ paragraph.append(textThree);
+
+ textThree.select();
+ // Act
+ textThree.remove();
+ // Assert
+ const expectedKey = link.getKey();
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const {anchor, focus} = selection;
+
+ expect(anchor.getNode().getKey()).toBe(expectedKey);
+ expect(focus.getNode().getKey()).toBe(expectedKey);
+ expect(anchor.offset).toBe(3);
+ expect(focus.offset).toBe(3);
+ });
+ });
+ });
+ });
+
+ test('isBackward', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const paragraph = root.getFirstChild<ParagraphNode>()!;
+ const paragraphKey = paragraph.getKey();
+ const textNode = $createTextNode('foo');
+ const textNodeKey = textNode.getKey();
+ // Note: line break can't be selected by the DOM
+ const linebreak = $createLineBreakNode();
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ paragraph.append(textNode, linebreak);
+ anchor.set(textNodeKey, 0, 'text');
+ focus.set(textNodeKey, 0, 'text');
+
+ expect(selection.isBackward()).toBe(false);
+
+ anchor.set(paragraphKey, 1, 'element');
+ focus.set(paragraphKey, 1, 'element');
+
+ expect(selection.isBackward()).toBe(false);
+
+ anchor.set(paragraphKey, 0, 'element');
+ focus.set(paragraphKey, 1, 'element');
+
+ expect(selection.isBackward()).toBe(false);
+
+ anchor.set(paragraphKey, 1, 'element');
+ focus.set(paragraphKey, 0, 'element');
+
+ expect(selection.isBackward()).toBe(true);
+ });
+ });
+ });
+
+ describe('Decorator text content for selection', () => {
+ const baseCases: {
+ name: string;
+ fn: (opts: {
+ textNode1: TextNode;
+ textNode2: TextNode;
+ decorator: DecoratorNode<unknown>;
+ paragraph: ParagraphNode;
+ anchor: PointType;
+ focus: PointType;
+ }) => string;
+ invertSelection?: true;
+ }[] = [
+ {
+ fn: ({textNode1, anchor, focus}) => {
+ anchor.set(textNode1.getKey(), 1, 'text');
+ focus.set(textNode1.getKey(), 1, 'text');
+
+ return '';
+ },
+ name: 'Not included if cursor right before it',
+ },
+ {
+ fn: ({textNode2, anchor, focus}) => {
+ anchor.set(textNode2.getKey(), 0, 'text');
+ focus.set(textNode2.getKey(), 0, 'text');
+
+ return '';
+ },
+ name: 'Not included if cursor right after it',
+ },
+ {
+ fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
+ anchor.set(textNode1.getKey(), 1, 'text');
+ focus.set(textNode2.getKey(), 0, 'text');
+
+ return decorator.getTextContent();
+ },
+ name: 'Included if decorator is selected within text',
+ },
+ {
+ fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
+ anchor.set(textNode1.getKey(), 0, 'text');
+ focus.set(textNode2.getKey(), 0, 'text');
+
+ return textNode1.getTextContent() + decorator.getTextContent();
+ },
+ name: 'Included if decorator is selected with another node before it',
+ },
+ {
+ fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
+ anchor.set(textNode1.getKey(), 1, 'text');
+ focus.set(textNode2.getKey(), 1, 'text');
+
+ return decorator.getTextContent() + textNode2.getTextContent();
+ },
+ name: 'Included if decorator is selected with another node after it',
+ },
+ {
+ fn: ({paragraph, textNode1, textNode2, decorator, anchor, focus}) => {
+ textNode1.remove();
+ textNode2.remove();
+ anchor.set(paragraph.getKey(), 0, 'element');
+ focus.set(paragraph.getKey(), 1, 'element');
+
+ return decorator.getTextContent();
+ },
+ name: 'Included if decorator is selected as the only node',
+ },
+ ];
+ baseCases
+ .flatMap((testCase) => {
+ const inverse = {
+ ...testCase,
+ invertSelection: true,
+ name: testCase.name + ' (inverse selection)',
+ };
+
+ return [testCase, inverse];
+ })
+ .forEach(({name, fn, invertSelection}) => {
+ it(name, async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const paragraph = root.getFirstChild<ParagraphNode>()!;
+ const textNode1 = $createTextNode('1');
+ const textNode2 = $createTextNode('2');
+ const decorator = $createTestDecoratorNode();
+
+ paragraph.append(textNode1, decorator, textNode2);
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const expectedTextContent = fn({
+ anchor: invertSelection ? selection.focus : selection.anchor,
+ decorator,
+ focus: invertSelection ? selection.anchor : selection.focus,
+ paragraph,
+ textNode1,
+ textNode2,
+ });
+
+ expect(selection.getTextContent()).toBe(expectedTextContent);
+ });
+ });
+ });
+ });
+ });
+
+ describe('insertParagraph', () => {
+ test('three text nodes at offset 0 on third node', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Hello ');
+ const text2 = $createTextNode('awesome');
+
+ text2.toggleFormat('bold');
+
+ const text3 = $createTextNode(' world');
+
+ paragraph.append(text, text2, text3);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text3.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text3.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertParagraph();
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Hello </span><strong data-lexical-text="true">awesome</strong></p><p dir="ltr"><span data-lexical-text="true"> world</span></p>',
+ );
+ });
+
+ test('four text nodes at offset 0 on third node', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Hello ');
+ const text2 = $createTextNode('awesome ');
+
+ text2.toggleFormat('bold');
+
+ const text3 = $createTextNode('beautiful');
+ const text4 = $createTextNode(' world');
+
+ text4.toggleFormat('bold');
+
+ paragraph.append(text, text2, text3, text4);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text3.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text3.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertParagraph();
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Hello </span><strong data-lexical-text="true">awesome </strong></p><p dir="ltr"><span data-lexical-text="true">beautiful</span><strong data-lexical-text="true"> world</strong></p>',
+ );
+ });
+
+ it('adjust offset for inline elements text formatting', async () => {
+ await init();
+
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+
+ const text1 = $createTextNode('--');
+ const text2 = $createTextNode('abc');
+ const text3 = $createTextNode('--');
+
+ root.append(
+ $createParagraphNode().append(
+ text1,
+ $createLinkNode('https://lexical.dev').append(text2),
+ text3,
+ ),
+ );
+
+ $setAnchorPoint({
+ key: text1.getKey(),
+ offset: 2,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text3.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.formatText('bold');
+
+ expect(text2.hasFormat('bold')).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('Node.replace', () => {
+ let text1: TextNode,
+ text2: TextNode,
+ text3: TextNode,
+ paragraph: ParagraphNode,
+ testEditor: LexicalEditor;
+
+ beforeEach(async () => {
+ testEditor = createTestEditor();
+
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+
+ paragraph = $createParagraphNode();
+ text1 = $createTextNode('Hello ');
+ text2 = $createTextNode('awesome');
+
+ text2.toggleFormat('bold');
+
+ text3 = $createTextNode(' world');
+
+ paragraph.append(text1, text2, text3);
+ root.append(paragraph);
+ });
+ });
+ [
+ {
+ fn: () => {
+ text2.select(1, 1);
+ text2.replace($createTestDecoratorNode());
+
+ return {
+ key: text3.__key,
+ offset: 0,
+ };
+ },
+ name: 'moves selection to to next text node if replacing with decorator',
+ },
+ {
+ fn: () => {
+ text3.replace($createTestDecoratorNode());
+ text2.select(1, 1);
+ text2.replace($createTestDecoratorNode());
+
+ return {
+ key: paragraph.__key,
+ offset: 2,
+ };
+ },
+ name: 'moves selection to parent if next sibling is not a text node',
+ },
+ ].forEach((testCase) => {
+ test(testCase.name, async () => {
+ await testEditor.update(() => {
+ const {key, offset} = testCase.fn();
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor.key).toBe(key);
+ expect(selection.anchor.offset).toBe(offset);
+ expect(selection.focus.key).toBe(key);
+ expect(selection.focus.offset).toBe(offset);
+ });
+ });
+ });
+ });
+
+ describe('Testing that $getStyleObjectFromRawCSS handles unformatted css text ', () => {
+ test('', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('Hello, World!');
+ textNode.setStyle(
+ ' font-family : Arial ; color : red ;top : 50px',
+ );
+ $addNodeStyle(textNode);
+ paragraph.append(textNode);
+ root.append(paragraph);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ selection.insertParagraph();
+ $setAnchorPoint({
+ key: textNode.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: textNode.getKey(),
+ offset: 10,
+ type: 'text',
+ });
+
+ const cssFontFamilyValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'font-family',
+ '',
+ );
+ expect(cssFontFamilyValue).toBe('Arial');
+
+ const cssColorValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'color',
+ '',
+ );
+ expect(cssColorValue).toBe('red');
+
+ const cssTopValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'top',
+ '',
+ );
+ expect(cssTopValue).toBe('50px');
+ });
+ });
+ });
+
+ describe('Testing that getStyleObjectFromRawCSS handles values with colons', () => {
+ test('', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('Hello, World!');
+ textNode.setStyle(
+ 'font-family: double:prefix:Arial; color: color:white; font-size: 30px',
+ );
+ $addNodeStyle(textNode);
+ paragraph.append(textNode);
+ root.append(paragraph);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ selection.insertParagraph();
+ $setAnchorPoint({
+ key: textNode.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: textNode.getKey(),
+ offset: 10,
+ type: 'text',
+ });
+
+ const cssFontFamilyValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'font-family',
+ '',
+ );
+ expect(cssFontFamilyValue).toBe('double:prefix:Arial');
+
+ const cssColorValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'color',
+ '',
+ );
+ expect(cssColorValue).toBe('color:white');
+
+ const cssFontSizeValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'font-size',
+ '',
+ );
+ expect(cssFontSizeValue).toBe('30px');
+ });
+ });
+ });
+
+ describe('$patchStyle', () => {
+ it('should patch the style with the new style object', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('Hello, World!');
+ textNode.setStyle('font-family: serif; color: red;');
+ $addNodeStyle(textNode);
+ paragraph.append(textNode);
+ root.append(paragraph);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ selection.insertParagraph();
+ $setAnchorPoint({
+ key: textNode.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: textNode.getKey(),
+ offset: 10,
+ type: 'text',
+ });
+
+ const newStyle = {
+ color: 'blue',
+ 'font-family': 'Arial',
+ };
+
+ $patchStyleText(selection, newStyle);
+
+ const cssFontFamilyValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'font-family',
+ '',
+ );
+ expect(cssFontFamilyValue).toBe('Arial');
+
+ const cssColorValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'color',
+ '',
+ );
+ expect(cssColorValue).toBe('blue');
+ });
+ });
+ });
+
+ it('should patch the style with property function', async () => {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(() => {
+ const currentColor = 'red';
+ const nextColor = 'blue';
+
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const textNode = $createTextNode('Hello, World!');
+ textNode.setStyle(`color: ${currentColor};`);
+ $addNodeStyle(textNode);
+ paragraph.append(textNode);
+ root.append(paragraph);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ selection.insertParagraph();
+ $setAnchorPoint({
+ key: textNode.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: textNode.getKey(),
+ offset: 10,
+ type: 'text',
+ });
+
+ const newStyle = {
+ color: jest.fn(
+ (current: string | null, target: LexicalNode | RangeSelection) =>
+ nextColor,
+ ),
+ };
+
+ $patchStyleText(selection, newStyle);
+
+ const cssColorValue = $getSelectionStyleValueForProperty(
+ selection,
+ 'color',
+ '',
+ );
+
+ expect(cssColorValue).toBe(nextColor);
+ expect(newStyle.color).toHaveBeenCalledTimes(1);
+
+ const lastCall = newStyle.color.mock.lastCall!;
+ expect(lastCall[0]).toBe(currentColor);
+ // @ts-ignore - It expected to be a LexicalNode
+ expect($isTextNode(lastCall[1])).toBeTruthy();
+ });
+ });
+ });
+ });
+
+ describe('$setBlocksType', () => {
+ test('Collapsed selection in text', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode();
+ const text1 = $createTextNode('text 1');
+ const paragraph2 = $createParagraphNode();
+ const text2 = $createTextNode('text 2');
+ root.append(paragraph1, paragraph2);
+ paragraph1.append(text1);
+ paragraph2.append(text2);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: text1.__key,
+ offset: text1.__text.length,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text1.__key,
+ offset: text1.__text.length,
+ type: 'text',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const rootChildren = root.getChildren();
+ expect(rootChildren[0].__type).toBe('heading');
+ expect(rootChildren[1].__type).toBe('paragraph');
+ expect(rootChildren.length).toBe(2);
+ });
+ });
+
+ test('Collapsed selection in element', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ root.append(paragraph1, paragraph2);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: 'root',
+ offset: 0,
+ type: 'element',
+ });
+ $setFocusPoint({
+ key: 'root',
+ offset: 0,
+ type: 'element',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const rootChildren = root.getChildren();
+ expect(rootChildren[0].__type).toBe('heading');
+ expect(rootChildren[1].__type).toBe('paragraph');
+ expect(rootChildren.length).toBe(2);
+ });
+ });
+
+ test('Two elements, same top-element', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode();
+ const text1 = $createTextNode('text 1');
+ const paragraph2 = $createParagraphNode();
+ const text2 = $createTextNode('text 2');
+ root.append(paragraph1, paragraph2);
+ paragraph1.append(text1);
+ paragraph2.append(text2);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: text1.__key,
+ offset: 0,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text2.__key,
+ offset: text1.__text.length,
+ type: 'text',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const rootChildren = root.getChildren();
+ expect(rootChildren[0].__type).toBe('heading');
+ expect(rootChildren[1].__type).toBe('heading');
+ expect(rootChildren.length).toBe(2);
+ });
+ });
+
+ test('Two empty elements, same top-element', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ root.append(paragraph1, paragraph2);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: paragraph1.__key,
+ offset: 0,
+ type: 'element',
+ });
+ $setFocusPoint({
+ key: paragraph2.__key,
+ offset: 0,
+ type: 'element',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const rootChildren = root.getChildren();
+ expect(rootChildren[0].__type).toBe('heading');
+ expect(rootChildren[1].__type).toBe('heading');
+ expect(rootChildren.length).toBe(2);
+ const sel = $getSelection()!;
+ expect(sel.getNodes().length).toBe(2);
+ });
+ });
+
+ test('Two elements, same top-element', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph1 = $createParagraphNode();
+ const text1 = $createTextNode('text 1');
+ const paragraph2 = $createParagraphNode();
+ const text2 = $createTextNode('text 2');
+ root.append(paragraph1, paragraph2);
+ paragraph1.append(text1);
+ paragraph2.append(text2);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: text1.__key,
+ offset: 0,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text2.__key,
+ offset: text1.__text.length,
+ type: 'text',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const rootChildren = root.getChildren();
+ expect(rootChildren[0].__type).toBe('heading');
+ expect(rootChildren[1].__type).toBe('heading');
+ expect(rootChildren.length).toBe(2);
+ });
+ });
+
+ test('Collapsed in element inside top-element', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const table = $createTableNodeWithDimensions(1, 1);
+ const row = table.getFirstChild();
+ invariant($isElementNode(row));
+ const column = row.getFirstChild();
+ invariant($isElementNode(column));
+ const paragraph = column.getFirstChild();
+ invariant($isElementNode(paragraph));
+ if (paragraph.getFirstChild()) {
+ paragraph.getFirstChild()!.remove();
+ }
+ root.append(table);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: paragraph.__key,
+ offset: 0,
+ type: 'element',
+ });
+ $setFocusPoint({
+ key: paragraph.__key,
+ offset: 0,
+ type: 'element',
+ });
+
+ const columnChildrenPrev = column.getChildren();
+ expect(columnChildrenPrev[0].__type).toBe('paragraph');
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const columnChildrenAfter = column.getChildren();
+ expect(columnChildrenAfter[0].__type).toBe('heading');
+ expect(columnChildrenAfter.length).toBe(1);
+ });
+ });
+
+ test('Collapsed in text inside top-element', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const table = $createTableNodeWithDimensions(1, 1);
+ const row = table.getFirstChild();
+ invariant($isElementNode(row));
+ const column = row.getFirstChild();
+ invariant($isElementNode(column));
+ const paragraph = column.getFirstChild();
+ invariant($isElementNode(paragraph));
+ const text = $createTextNode('foo');
+ root.append(table);
+ paragraph.append(text);
+
+ const selectionz = $createRangeSelection();
+ $setSelection(selectionz);
+ $setAnchorPoint({
+ key: text.__key,
+ offset: text.__text.length,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text.__key,
+ offset: text.__text.length,
+ type: 'text',
+ });
+ const selection = $getSelection() as RangeSelection;
+
+ const columnChildrenPrev = column.getChildren();
+ expect(columnChildrenPrev[0].__type).toBe('paragraph');
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const columnChildrenAfter = column.getChildren();
+ expect(columnChildrenAfter[0].__type).toBe('heading');
+ expect(columnChildrenAfter.length).toBe(1);
+ });
+ });
+
+ test('Full editor selection with a mix of top-elements', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+
+ const paragraph1 = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ const text1 = $createTextNode();
+ const text2 = $createTextNode();
+ paragraph1.append(text1);
+ paragraph2.append(text2);
+ root.append(paragraph1, paragraph2);
+
+ const table = $createTableNodeWithDimensions(1, 2);
+ const row = table.getFirstChild();
+ invariant($isElementNode(row));
+ const columns = row.getChildren();
+ root.append(table);
+
+ const column1 = columns[0];
+ const paragraph3 = $createParagraphNode();
+ const paragraph4 = $createParagraphNode();
+ const text3 = $createTextNode();
+ const text4 = $createTextNode();
+ paragraph1.append(text3);
+ paragraph2.append(text4);
+ invariant($isElementNode(column1));
+ column1.append(paragraph3, paragraph4);
+
+ const column2 = columns[1];
+ const paragraph5 = $createParagraphNode();
+ const paragraph6 = $createParagraphNode();
+ invariant($isElementNode(column2));
+ column2.append(paragraph5, paragraph6);
+
+ const paragraph7 = $createParagraphNode();
+ root.append(paragraph7);
+
+ const selectionz = $createRangeSelection();
+ $setSelection(selectionz);
+ $setAnchorPoint({
+ key: paragraph1.__key,
+ offset: 0,
+ type: 'element',
+ });
+ $setFocusPoint({
+ key: paragraph7.__key,
+ offset: 0,
+ type: 'element',
+ });
+ const selection = $getSelection() as RangeSelection;
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+ expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe(
+ '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
+ );
+ });
+ });
+
+ test('Paragraph with links to heading with links', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text1 = $createTextNode('Links: ');
+ const text2 = $createTextNode('link1');
+ const text3 = $createTextNode('link2');
+ root.append(
+ paragraph.append(
+ text1,
+ $createLinkNode('https://lexical.dev').append(text2),
+ $createTextNode(' '),
+ $createLinkNode('https://playground.lexical.dev').append(text3),
+ ),
+ );
+
+ const paragraphChildrenKeys = [...paragraph.getChildrenKeys()];
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: text1.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text3.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+
+ const rootChildren = root.getChildren();
+ expect(rootChildren.length).toBe(1);
+ invariant($isElementNode(rootChildren[0]));
+ expect(rootChildren[0].getType()).toBe('heading');
+ expect(rootChildren[0].getChildrenKeys()).toEqual(
+ paragraphChildrenKeys,
+ );
+ });
+ });
+
+ test('Nested list', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const ul1 = $createListNode('bullet');
+ const text1 = $createTextNode('1');
+ const li1 = $createListItemNode().append(text1);
+ const li1_wrapper = $createListItemNode();
+ const ul2 = $createListNode('bullet');
+ const text1_1 = $createTextNode('1.1');
+ const li1_1 = $createListItemNode().append(text1_1);
+ ul1.append(li1, li1_wrapper);
+ li1_wrapper.append(ul2);
+ ul2.append(li1_1);
+ root.append(ul1);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: text1.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text1_1.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+ });
+ expect(element.innerHTML).toStrictEqual(
+ `<h1><span data-lexical-text="true">1</span></h1><h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,
+ );
+ });
+
+ test('Nested list with listItem twice indented from his father', async () => {
+ const testEditor = createTestEditor();
+ const element = document.createElement('div');
+ testEditor.setRootElement(element);
+
+ await testEditor.update(() => {
+ const root = $getRoot();
+ const ul1 = $createListNode('bullet');
+ const li1_wrapper = $createListItemNode();
+ const ul2 = $createListNode('bullet');
+ const text1_1 = $createTextNode('1.1');
+ const li1_1 = $createListItemNode().append(text1_1);
+ ul1.append(li1_wrapper);
+ li1_wrapper.append(ul2);
+ ul2.append(li1_1);
+ root.append(ul1);
+
+ const selection = $createRangeSelection();
+ $setSelection(selection);
+ $setAnchorPoint({
+ key: text1_1.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text1_1.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+
+ $setBlocksType(selection, () => {
+ return $createHeadingNode('h1');
+ });
+ });
+ expect(element.innerHTML).toStrictEqual(
+ `<h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,
+ );
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$createLinkNode} from '@lexical/link';
+import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text';
+import {
+ $getSelectionStyleValueForProperty,
+ $patchStyleText,
+} from '@lexical/selection';
+import {
+ $createLineBreakNode,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getNodeByKey,
+ $getRoot,
+ $getSelection,
+ $insertNodes,
+ $isElementNode,
+ $isParagraphNode,
+ $isRangeSelection,
+ $setSelection,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ ParagraphNode,
+ RangeSelection,
+ TextModeType,
+ TextNode,
+} from 'lexical';
+import {
+ $createTestDecoratorNode,
+ $createTestElementNode,
+ $createTestShadowRootNode,
+ createTestEditor,
+ createTestHeadlessEditor,
+ invariant,
+ TestDecoratorNode,
+} from 'lexical/src/__tests__/utils';
+
+import {$setAnchorPoint, $setFocusPoint} from '../utils';
+
+Range.prototype.getBoundingClientRect = function (): DOMRect {
+ const rect = {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+ return {
+ ...rect,
+ toJSON() {
+ return rect;
+ },
+ };
+};
+
+function $createParagraphWithNodes(
+ editor: LexicalEditor,
+ nodes: {text: string; key: string; mergeable?: boolean}[],
+) {
+ const paragraph = $createParagraphNode();
+ const nodeMap = editor._pendingEditorState!._nodeMap;
+
+ for (let i = 0; i < nodes.length; i++) {
+ const {text, key, mergeable} = nodes[i];
+ const textNode = new TextNode(text, key);
+ nodeMap.set(key, textNode);
+
+ if (!mergeable) {
+ textNode.toggleUnmergeable();
+ }
+
+ paragraph.append(textNode);
+ }
+
+ return paragraph;
+}
+
+describe('LexicalSelectionHelpers tests', () => {
+ describe('Collapsed', () => {
+ test('Can handle a text point', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, node: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: false,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+ const selection = $getSelection();
+ cb(selection as RangeSelection, element);
+ });
+ };
+
+ // getNodes
+ setupTestCase((selection, state) => {
+ expect(selection.getNodes()).toEqual([$getNodeByKey('a')]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('');
+ });
+
+ // insertText
+ setupTestCase((selection, state) => {
+ selection.insertText('Test');
+
+ expect($getNodeByKey('a')!.getTextContent()).toBe('Testa');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertNodes
+ setupTestCase((selection, element) => {
+ selection.insertNodes([$createTextNode('foo')]);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 3,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 3,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection) => {
+ selection.insertParagraph();
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak(true);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+
+ expect(element.getFirstChild()!.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(
+ element.getFirstChild()!.getNextSibling()!.getTextContent(),
+ ).toBe('a');
+ });
+
+ // Extract selection
+ setupTestCase((selection, state) => {
+ expect(selection.extract()).toEqual([$getNodeByKey('a')]);
+ });
+ });
+
+ test('Has correct text point after removal after merge', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: 'a',
+ },
+ {
+ key: 'bb',
+ mergeable: true,
+ text: 'bb',
+ },
+ {
+ key: 'empty',
+ mergeable: true,
+ text: '',
+ },
+ {
+ key: 'cc',
+ mergeable: true,
+ text: 'cc',
+ },
+ {
+ key: 'd',
+ mergeable: true,
+ text: 'd',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: 'bb',
+ offset: 1,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: 'cc',
+ offset: 1,
+ type: 'text',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+ });
+
+ test('Has correct text point after removal after merge (2)', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: 'a',
+ },
+ {
+ key: 'empty',
+ mergeable: true,
+ text: '',
+ },
+ {
+ key: 'b',
+ mergeable: true,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: true,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: 'c',
+ offset: 1,
+ type: 'text',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 3,
+ type: 'text',
+ }),
+ );
+ });
+ });
+
+ test('Has correct text point adjust to element point after removal of a single empty text node', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element: ParagraphNode;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: '',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+ });
+
+ test('Has correct element point after removal of an empty text node in a group #1', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: '',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 2,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 2,
+ type: 'element',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'b',
+ offset: 1,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'b',
+ offset: 1,
+ type: 'text',
+ }),
+ );
+ });
+ });
+
+ test('Has correct element point after removal of an empty text node in a group #2', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: '',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: true,
+ text: 'c',
+ },
+ {
+ key: 'd',
+ mergeable: true,
+ text: 'd',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 4,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 4,
+ type: 'element',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'c',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'c',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+ });
+ });
+
+ test('Has correct text point after removal of an empty text node in a group #3', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: '',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: true,
+ text: 'c',
+ },
+ {
+ key: 'd',
+ mergeable: true,
+ text: 'd',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: 'd',
+ offset: 1,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: 'd',
+ offset: 1,
+ type: 'text',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'c',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'c',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+ });
+ });
+
+ test('Can handle an element point on empty element', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, []);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+ const selection = $getSelection();
+ cb(selection as RangeSelection, element);
+ });
+ };
+
+ // getNodes
+ setupTestCase((selection, element) => {
+ expect(selection.getNodes()).toEqual([element]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('');
+ });
+
+ // insertText
+ setupTestCase((selection, element) => {
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection, element) => {
+ selection.insertParagraph();
+ const nextElement = element.getNextSibling()!;
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: nextElement.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: nextElement.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak(true);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // Extract selection
+ setupTestCase((selection, element) => {
+ expect(selection.extract()).toEqual([element]);
+ });
+ });
+
+ test('Can handle a start element point', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: false,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+ const selection = $getSelection();
+ cb(selection as RangeSelection, element);
+ });
+ };
+
+ // getNodes
+ setupTestCase((selection, state) => {
+ expect(selection.getNodes()).toEqual([$getNodeByKey('a')]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('');
+ });
+
+ // insertText
+ setupTestCase((selection, element) => {
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection, element) => {
+ selection.insertParagraph();
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak(true);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // Extract selection
+ setupTestCase((selection, element) => {
+ expect(selection.extract()).toEqual([$getNodeByKey('a')]);
+ });
+ });
+
+ test('Can handle an end element point', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: false,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 3,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 3,
+ type: 'element',
+ });
+ const selection = $getSelection();
+ cb(selection as RangeSelection, element);
+ });
+ };
+
+ // getNodes
+ setupTestCase((selection, state) => {
+ expect(selection.getNodes()).toEqual([$getNodeByKey('c')]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('');
+ });
+
+ // insertText
+ setupTestCase((selection, element) => {
+ selection.insertText('Test');
+ const lastChild = element.getLastChild()!;
+
+ expect(lastChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: lastChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: lastChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection, element) => {
+ selection.insertParagraph();
+ const nextSibling = element.getNextSibling()!;
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: nextSibling.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: nextSibling.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak();
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 4,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 4,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+ const lastChild = element.getLastChild()!;
+
+ expect(lastChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: lastChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: lastChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // Extract selection
+ setupTestCase((selection, element) => {
+ expect(selection.extract()).toEqual([$getNodeByKey('c')]);
+ });
+ });
+
+ test('Has correct element point after merge from middle', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: true,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: true,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 2,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 2,
+ type: 'element',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 2,
+ type: 'text',
+ }),
+ );
+ });
+ });
+
+ test('Has correct element point after merge from end', async () => {
+ const editor = createTestEditor();
+
+ const domElement = document.createElement('div');
+ let element;
+
+ editor.setRootElement(domElement);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: true,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: true,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: true,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 3,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 3,
+ type: 'element',
+ });
+ });
+
+ await Promise.resolve().then();
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 3,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 3,
+ type: 'text',
+ }),
+ );
+ });
+ });
+ });
+
+ describe('Simple range', () => {
+ test('Can handle multiple text points', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: false,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: 'b',
+ offset: 0,
+ type: 'text',
+ });
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ cb(selection, element);
+ });
+ };
+
+ // getNodes
+ setupTestCase((selection, state) => {
+ expect(selection.getNodes()).toEqual([
+ $getNodeByKey('a'),
+ $getNodeByKey('b'),
+ ]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('a');
+ });
+
+ // insertText
+ setupTestCase((selection, state) => {
+ selection.insertText('Test');
+
+ expect($getNodeByKey('a')!.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'a',
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertNodes
+ setupTestCase((selection, element) => {
+ selection.insertNodes([$createTextNode('foo')]);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 3,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 3,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection) => {
+ selection.insertParagraph();
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'b',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'b',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak(true);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+
+ expect(element.getFirstChild()!.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getFirstChild()!.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // Extract selection
+ setupTestCase((selection, state) => {
+ expect(selection.extract()).toEqual([{...$getNodeByKey('a')}]);
+ });
+ });
+
+ test('Can handle multiple element points', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: false,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: element.getKey(),
+ offset: 1,
+ type: 'element',
+ });
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ cb(selection, element);
+ });
+ };
+
+ // getNodes
+ setupTestCase((selection) => {
+ expect(selection.getNodes()).toEqual([$getNodeByKey('a')]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('a');
+ });
+
+ // insertText
+ setupTestCase((selection, element) => {
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection, element) => {
+ selection.insertParagraph();
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: 'b',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: 'b',
+ offset: 0,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak(true);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // Extract selection
+ setupTestCase((selection, element) => {
+ const firstChild = element.getFirstChild();
+
+ expect(selection.extract()).toEqual([firstChild]);
+ });
+ });
+
+ test('Can handle a mix of text and element points', () => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
+ const editor = createTestEditor();
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const element = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ {
+ key: 'c',
+ mergeable: false,
+ text: 'c',
+ },
+ ]);
+
+ root.append(element);
+
+ $setAnchorPoint({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: 'c',
+ offset: 1,
+ type: 'text',
+ });
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ cb(selection, element);
+ });
+ };
+
+ // isBefore
+ setupTestCase((selection, state) => {
+ expect(selection.anchor.isBefore(selection.focus)).toEqual(true);
+ });
+
+ // getNodes
+ setupTestCase((selection, state) => {
+ expect(selection.getNodes()).toEqual([
+ $getNodeByKey('a'),
+ $getNodeByKey('b'),
+ $getNodeByKey('c'),
+ ]);
+ });
+
+ // getTextContent
+ setupTestCase((selection) => {
+ expect(selection.getTextContent()).toEqual('abc');
+ });
+
+ // insertText
+ setupTestCase((selection, element) => {
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // insertParagraph
+ setupTestCase((selection, element) => {
+ selection.insertParagraph();
+ const nextElement = element.getNextSibling()!;
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: nextElement.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: nextElement.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // insertLineBreak
+ setupTestCase((selection, element) => {
+ selection.insertLineBreak(true);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: element.getKey(),
+ offset: 0,
+ type: 'element',
+ }),
+ );
+ });
+
+ // Format text
+ setupTestCase((selection, element) => {
+ selection.formatText('bold');
+ selection.insertText('Test');
+ const firstChild = element.getFirstChild()!;
+
+ expect(firstChild.getTextContent()).toBe('Test');
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: firstChild.getKey(),
+ offset: 4,
+ type: 'text',
+ }),
+ );
+ });
+
+ // Extract selection
+ setupTestCase((selection, element) => {
+ expect(selection.extract()).toEqual([
+ $getNodeByKey('a'),
+ $getNodeByKey('b'),
+ $getNodeByKey('c'),
+ ]);
+ });
+ });
+ });
+
+ describe('can insert non-element nodes correctly', () => {
+ describe('with an empty paragraph node selected', () => {
+ test('a single text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([$createTextNode('foo')]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foo</span></p>',
+ );
+ });
+
+ test('two text nodes', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([
+ $createTextNode('foo'),
+ $createTextNode('bar'),
+ ]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foobar</span></p>',
+ );
+ });
+
+ test('link insertion without parent element', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('ello worl'));
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([
+ $createTextNode('h'),
+ link,
+ $createTextNode('d'),
+ ]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">h</span><a href="https://" dir="ltr"><span data-lexical-text="true">ello worl</span></a><span data-lexical-text="true">d</span></p>',
+ );
+ });
+
+ test('a single heading node with a child text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ $setFocusPoint({
+ key: paragraph.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+
+ const heading = $createHeadingNode('h1');
+ const child = $createTextNode('foo');
+
+ heading.append(child);
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([heading]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<h1 dir="ltr"><span data-lexical-text="true">foo</span></h1>',
+ );
+ });
+ });
+
+ describe('with a paragraph node selected on some existing text', () => {
+ test('a single text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Existing text...');
+
+ paragraph.append(text);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([$createTextNode('foo')]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Existing text...foo</span></p>',
+ );
+ });
+
+ test('two text nodes', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Existing text...');
+
+ paragraph.append(text);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([
+ $createTextNode('foo'),
+ $createTextNode('bar'),
+ ]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Existing text...foobar</span></p>',
+ );
+ });
+
+ test('a single heading node with a child text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Existing text...');
+
+ paragraph.append(text);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ const heading = $createHeadingNode('h1');
+ const child = $createTextNode('foo');
+
+ heading.append(child);
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([heading]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Existing text...foo</span></p>',
+ );
+ });
+
+ test('a paragraph with a child text and a child italic text and a child text', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('AE');
+
+ paragraph.append(text);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 1,
+ type: 'text',
+ });
+
+ const insertedParagraph = $createParagraphNode();
+ const insertedTextB = $createTextNode('B');
+ const insertedTextC = $createTextNode('C');
+ const insertedTextD = $createTextNode('D');
+
+ insertedTextC.toggleFormat('italic');
+
+ insertedParagraph.append(insertedTextB, insertedTextC, insertedTextD);
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([insertedParagraph]);
+
+ expect(selection.anchor).toEqual(
+ expect.objectContaining({
+ key: paragraph
+ .getChildAtIndex(paragraph.getChildrenSize() - 2)!
+ .getKey(),
+ offset: 1,
+ type: 'text',
+ }),
+ );
+
+ expect(selection.focus).toEqual(
+ expect.objectContaining({
+ key: paragraph
+ .getChildAtIndex(paragraph.getChildrenSize() - 2)!
+ .getKey(),
+ offset: 1,
+ type: 'text',
+ }),
+ );
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">AB</span><em data-lexical-text="true">C</em><span data-lexical-text="true">DE</span></p>',
+ );
+ });
+ });
+
+ describe('with a fully-selected text node', () => {
+ test('a single text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('Existing text...');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 'Existing text...'.length,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([$createTextNode('foo')]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foo</span></p>',
+ );
+ });
+ });
+
+ describe('with a fully-selected text node followed by an inline element', () => {
+ test('a single text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('Existing text...');
+ paragraph.append(text);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+ paragraph.append(link);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 'Existing text...'.length,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([$createTextNode('foo')]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foo</span><a href="https://" dir="ltr"><span data-lexical-text="true">link</span></a></p>',
+ );
+ });
+ });
+
+ describe('with a fully-selected text node preceded by an inline element', () => {
+ test('a single text node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+ paragraph.append(link);
+
+ const text = $createTextNode('Existing text...');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 'Existing text...'.length,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([$createTextNode('foo')]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><a href="https://" dir="ltr"><span data-lexical-text="true">link</span></a><span data-lexical-text="true">foo</span></p>',
+ );
+ });
+ });
+
+ test.skip('can insert a linebreak node before an inline element node', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ const link = $createLinkNode('https://lexical.dev/');
+ paragraph.append(link);
+ const text = $createTextNode('Lexical');
+ link.append(text);
+ text.select(0, 0);
+
+ $insertNodes([$createLineBreakNode()]);
+ });
+
+ // TODO #5109 ElementNode should have a way to control when other nodes can be inserted inside
+ expect(element.innerHTML).toBe(
+ '<p><a href="https://lexical.dev/" dir="ltr"><br><span data-lexical-text="true">Lexical</span></a></p>',
+ );
+ });
+ });
+
+ describe('can insert block element nodes correctly', () => {
+ describe('with a fully-selected text node', () => {
+ test('a paragraph node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('Existing text...');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 'Existing text...'.length,
+ type: 'text',
+ });
+
+ const paragraphToInsert = $createParagraphNode();
+ paragraphToInsert.append($createTextNode('foo'));
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([paragraphToInsert]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foo</span></p>',
+ );
+ });
+ });
+
+ describe('with a fully-selected text node followed by an inline element', () => {
+ test('a paragraph node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('Existing text...');
+ paragraph.append(text);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+ paragraph.append(link);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 'Existing text...'.length,
+ type: 'text',
+ });
+
+ const paragraphToInsert = $createParagraphNode();
+ paragraphToInsert.append($createTextNode('foo'));
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([paragraphToInsert]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">foo</span><a href="https://" dir="ltr"><span data-lexical-text="true">link</span></a></p>',
+ );
+ });
+ });
+
+ describe('with a fully-selected text node preceded by an inline element', () => {
+ test('a paragraph node', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+ paragraph.append(link);
+
+ const text = $createTextNode('Existing text...');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 'Existing text...'.length,
+ type: 'text',
+ });
+
+ const paragraphToInsert = $createParagraphNode();
+ paragraphToInsert.append($createTextNode('foo'));
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ selection.insertNodes([paragraphToInsert]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><a href="https://" dir="ltr"><span data-lexical-text="true">link</span></a><span data-lexical-text="true">foo</span></p>',
+ );
+ });
+ });
+
+ test('Can insert link into empty paragraph', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ const linkNode = $createLinkNode('https://lexical.dev');
+ const linkTextNode = $createTextNode('Lexical');
+ linkNode.append(linkTextNode);
+ $insertNodes([linkNode]);
+ });
+ expect(element.innerHTML).toBe(
+ '<p><a href="https://lexical.dev" dir="ltr"><span data-lexical-text="true">Lexical</span></a></p>',
+ );
+ });
+
+ test('Can insert link into empty paragraph (2)', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ const linkNode = $createLinkNode('https://lexical.dev');
+ const linkTextNode = $createTextNode('Lexical');
+ linkNode.append(linkTextNode);
+ const textNode2 = $createTextNode('...');
+ $insertNodes([linkNode, textNode2]);
+ });
+ expect(element.innerHTML).toBe(
+ '<p><a href="https://lexical.dev" dir="ltr"><span data-lexical-text="true">Lexical</span></a><span data-lexical-text="true">...</span></p>',
+ );
+ });
+
+ test('Can insert an ElementNode after ShadowRoot', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.selectStart();
+ const element1 = $createTestShadowRootNode();
+ const element2 = $createTestElementNode();
+ $insertNodes([element1, element2]);
+ });
+ expect([
+ '<div><br></div><div><br></div>',
+ '<div><br></div><p><br></p>',
+ ]).toContain(element.innerHTML);
+ });
+ });
+});
+
+describe('extract', () => {
+ test('Should return the selected node when collapsed on a TextNode', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Existing text...');
+
+ paragraph.append(text);
+ root.append(paragraph);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 16,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ expect($isRangeSelection(selection)).toBeTruthy();
+
+ expect(selection!.extract()).toEqual([text]);
+ });
+ });
+});
+
+describe('insertNodes', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('can insert element next to top level decorator node', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ jest.spyOn(TestDecoratorNode.prototype, 'isInline').mockReturnValue(false);
+
+ await editor.update(() => {
+ $getRoot().append(
+ $createParagraphNode(),
+ $createTestDecoratorNode(),
+ $createParagraphNode().append($createTextNode('Text after')),
+ );
+ });
+
+ await editor.update(() => {
+ const selectionNode = $getRoot().getFirstChild();
+ invariant($isElementNode(selectionNode));
+ const selection = selectionNode.select();
+ selection.insertNodes([
+ $createParagraphNode().append($createTextNode('Text before')),
+ ]);
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">Text before</span></p>' +
+ '<span data-lexical-decorator="true" contenteditable="false"></span>' +
+ '<p dir="ltr"><span data-lexical-text="true">Text after</span></p>',
+ );
+ });
+
+ it('can insert when previous selection was null', async () => {
+ const editor = createTestHeadlessEditor();
+ await editor.update(() => {
+ const selection = $createRangeSelection();
+ selection.anchor.set('root', 0, 'element');
+ selection.focus.set('root', 0, 'element');
+
+ selection.insertNodes([
+ $createParagraphNode().append($createTextNode('Text')),
+ ]);
+
+ expect($getRoot().getTextContent()).toBe('Text');
+
+ $setSelection(null);
+ });
+ await editor.update(() => {
+ const selection = $createRangeSelection();
+ const text = $getRoot().getLastDescendant()!;
+ selection.anchor.set(text.getKey(), 0, 'text');
+ selection.focus.set(text.getKey(), 0, 'text');
+
+ selection.insertNodes([
+ $createParagraphNode().append($createTextNode('Before ')),
+ ]);
+
+ expect($getRoot().getTextContent()).toBe('Before Text');
+ });
+ });
+
+ it('can insert when before empty text node', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ // Empty text node to test empty text split
+ const emptyTextNode = $createTextNode('');
+ $getRoot().append(
+ $createParagraphNode().append(emptyTextNode, $createTextNode('text')),
+ );
+ emptyTextNode.select(0, 0);
+ const selection = $getSelection()!;
+ expect($isRangeSelection(selection)).toBeTruthy();
+ selection.insertNodes([$createTextNode('foo')]);
+
+ expect($getRoot().getTextContent()).toBe('footext');
+ });
+ });
+
+ it('last node is LineBreakNode', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ // Empty text node to test empty text split
+ const paragraph = $createParagraphNode();
+ $getRoot().append(paragraph);
+ const selection = paragraph.select();
+ expect($isRangeSelection(selection)).toBeTruthy();
+
+ const newHeading = $createHeadingNode('h1').append(
+ $createTextNode('heading'),
+ );
+ selection.insertNodes([newHeading, $createLineBreakNode()]);
+ });
+ editor.getEditorState().read(() => {
+ expect(element.innerHTML).toBe(
+ '<h1 dir="ltr"><span data-lexical-text="true">heading</span></h1><p><br></p>',
+ );
+ const selectedNode = ($getSelection() as RangeSelection).anchor.getNode();
+ expect($isParagraphNode(selectedNode)).toBeTruthy();
+ expect($isHeadingNode(selectedNode.getPreviousSibling())).toBeTruthy();
+ });
+ });
+});
+
+describe('$patchStyleText', () => {
+ test('can patch a selection anchored to the end of a TextNode before an inline element', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ ]);
+
+ root.append(paragraph);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+
+ const a = $getNodeByKey('a')!;
+ a.insertAfter(link);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 1,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: 'b',
+ offset: 1,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">a</span>' +
+ '<a href="https://" dir="ltr">' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
+ '</a>' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">b</span></p>',
+ );
+ });
+
+ test('can patch a selection anchored to the end of a TextNode at the end of a paragraph', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph1 = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ ]);
+ const paragraph2 = $createParagraphWithNodes(editor, [
+ {
+ key: 'b',
+ mergeable: false,
+ text: 'b',
+ },
+ ]);
+
+ root.append(paragraph1);
+ root.append(paragraph2);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 1,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: 'b',
+ offset: 1,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">a</span></p>' +
+ '<p dir="ltr"><span style="text-emphasis: filled;" data-lexical-text="true">b</span></p>',
+ );
+ });
+
+ test('can patch a selection that ends on an element', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ ]);
+
+ root.append(paragraph);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+
+ const a = $getNodeByKey('a')!;
+ a.insertAfter(link);
+
+ $setAnchorPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+ // Select to end of the link _element_
+ $setFocusPoint({
+ key: link.getKey(),
+ offset: 1,
+ type: 'element',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr">' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">a</span>' +
+ '<a href="https://" dir="ltr">' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
+ '</a>' +
+ '</p>',
+ );
+ });
+
+ test('can patch a reversed selection that ends on an element', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphWithNodes(editor, [
+ {
+ key: 'a',
+ mergeable: false,
+ text: 'a',
+ },
+ ]);
+
+ root.append(paragraph);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+
+ const a = $getNodeByKey('a')!;
+ a.insertAfter(link);
+
+ // Select from the end of the link _element_
+ $setAnchorPoint({
+ key: link.getKey(),
+ offset: 1,
+ type: 'element',
+ });
+ $setFocusPoint({
+ key: 'a',
+ offset: 0,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr">' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">a</span>' +
+ '<a href="https://" dir="ltr">' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
+ '</a>' +
+ '</p>',
+ );
+ });
+
+ test('can patch a selection that starts and ends on an element', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const link = $createLinkNode('https://');
+ link.append($createTextNode('link'));
+ paragraph.append(link);
+
+ $setAnchorPoint({
+ key: link.getKey(),
+ offset: 0,
+ type: 'element',
+ });
+ $setFocusPoint({
+ key: link.getKey(),
+ offset: 1,
+ type: 'element',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p>' +
+ '<a href="https://" dir="ltr">' +
+ '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
+ '</a>' +
+ '</p>',
+ );
+ });
+
+ test('can clear a style', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('text');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: text.getTextContentSize(),
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+ $patchStyleText(selection, {'text-emphasis': null});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr"><span data-lexical-text="true">text</span></p>',
+ );
+ });
+
+ test('can toggle a style on a collapsed selection', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('text');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+
+ expect(
+ $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''),
+ ).toEqual('filled');
+
+ $patchStyleText(selection, {'text-emphasis': null});
+
+ expect(
+ $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''),
+ ).toEqual('');
+
+ $patchStyleText(selection, {'text-emphasis': 'filled'});
+
+ expect(
+ $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''),
+ ).toEqual('filled');
+ });
+ });
+
+ test('updates cached styles when setting on a collapsed selection', async () => {
+ const editor = createTestEditor();
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('text');
+ paragraph.append(text);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+ $setFocusPoint({
+ key: text.getKey(),
+ offset: 0,
+ type: 'text',
+ });
+
+ // First fetch the initial style -- this will cause the CSS cache to be
+ // populated with an empty string pointing to an empty style object.
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ $getSelectionStyleValueForProperty(selection, 'color', '');
+
+ // Now when we set the style, we should _not_ touch the previously created
+ // empty style object, but create a new one instead.
+ $patchStyleText(selection, {color: 'red'});
+
+ // We can check that result by clearing the style and re-querying it.
+ ($getSelection() as RangeSelection).setStyle('');
+
+ const color = $getSelectionStyleValueForProperty(
+ $getSelection() as RangeSelection,
+ 'color',
+ '',
+ );
+ expect(color).toEqual('');
+ });
+ });
+
+ test.each<TextModeType>(['token', 'segmented'])(
+ 'can update style of text node that is in %s mode',
+ async (mode) => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+ editor.setRootElement(element);
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const text = $createTextNode('first').setFormat('bold');
+ paragraph.append(text);
+
+ const textInMode = $createTextNode('second').setMode(mode);
+ paragraph.append(textInMode);
+
+ $setAnchorPoint({
+ key: text.getKey(),
+ offset: 'fir'.length,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: textInMode.getKey(),
+ offset: 'sec'.length,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+ $patchStyleText(selection!, {'font-size': '15px'});
+ });
+
+ expect(element.innerHTML).toBe(
+ '<p dir="ltr">' +
+ '<strong data-lexical-text="true">fir</strong>' +
+ '<strong style="font-size: 15px;" data-lexical-text="true">st</strong>' +
+ '<span style="font-size: 15px;" data-lexical-text="true">second</span>' +
+ '</p>',
+ );
+ },
+ );
+
+ test('preserve backward selection when changing style of 2 different text nodes', async () => {
+ const editor = createTestEditor();
+
+ const element = document.createElement('div');
+
+ editor.setRootElement(element);
+
+ editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+
+ const firstText = $createTextNode('first ').setFormat('bold');
+ paragraph.append(firstText);
+
+ const secondText = $createTextNode('second').setFormat('italic');
+ paragraph.append(secondText);
+
+ $setAnchorPoint({
+ key: secondText.getKey(),
+ offset: 'sec'.length,
+ type: 'text',
+ });
+
+ $setFocusPoint({
+ key: firstText.getKey(),
+ offset: 'fir'.length,
+ type: 'text',
+ });
+
+ const selection = $getSelection();
+
+ $patchStyleText(selection!, {'font-size': '11px'});
+
+ const [newAnchor, newFocus] = selection!.getStartEndPoints()!;
+
+ const newAnchorNode: LexicalNode = newAnchor.getNode();
+ expect(newAnchorNode.getTextContent()).toBe('sec');
+ expect(newAnchor.offset).toBe('sec'.length);
+
+ const newFocusNode: LexicalNode = newFocus.getNode();
+ expect(newFocusNode.getTextContent()).toBe('st ');
+ expect(newFocus.offset).toBe(0);
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createTextNode,
+ $getSelection,
+ $isNodeSelection,
+ $isRangeSelection,
+ $isTextNode,
+ LexicalEditor,
+ PointType,
+} from 'lexical';
+
+Object.defineProperty(HTMLElement.prototype, 'contentEditable', {
+ get() {
+ return this.getAttribute('contenteditable');
+ },
+
+ set(value) {
+ this.setAttribute('contenteditable', value);
+ },
+});
+
+type Segment = {
+ index: number;
+ isWordLike: boolean;
+ segment: string;
+};
+
+if (!Selection.prototype.modify) {
+ const wordBreakPolyfillRegex =
+ /[\s.,\\/#!$%^&*;:{}=\-`~()\uD800-\uDBFF\uDC00-\uDFFF\u3000-\u303F]/u;
+
+ const pushSegment = function (
+ segments: Array<Segment>,
+ index: number,
+ str: string,
+ isWordLike: boolean,
+ ): void {
+ segments.push({
+ index: index - str.length,
+ isWordLike,
+ segment: str,
+ });
+ };
+
+ const getWordsFromString = function (string: string): Array<Segment> {
+ const segments: Segment[] = [];
+ let wordString = '';
+ let nonWordString = '';
+ let i;
+
+ for (i = 0; i < string.length; i++) {
+ const char = string[i];
+
+ if (wordBreakPolyfillRegex.test(char)) {
+ if (wordString !== '') {
+ pushSegment(segments, i, wordString, true);
+ wordString = '';
+ }
+
+ nonWordString += char;
+ } else {
+ if (nonWordString !== '') {
+ pushSegment(segments, i, nonWordString, false);
+ nonWordString = '';
+ }
+
+ wordString += char;
+ }
+ }
+
+ if (wordString !== '') {
+ pushSegment(segments, i, wordString, true);
+ }
+
+ if (nonWordString !== '') {
+ pushSegment(segments, i, nonWordString, false);
+ }
+
+ return segments;
+ };
+
+ Selection.prototype.modify = function (alter, direction, granularity) {
+ // This is not a thorough implementation, it was more to get tests working
+ // given the refactor to use this selection method.
+ const symbol = Object.getOwnPropertySymbols(this)[0];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const impl = (this as any)[symbol];
+ const focus = impl._focus;
+ const anchor = impl._anchor;
+
+ if (granularity === 'character') {
+ let anchorNode = anchor.node;
+ let anchorOffset = anchor.offset;
+ let _$isTextNode = false;
+
+ if (anchorNode.nodeType === 3) {
+ _$isTextNode = true;
+ anchorNode = anchorNode.parentElement;
+ } else if (anchorNode.nodeName === 'BR') {
+ const parentNode = anchorNode.parentElement;
+ const childNodes = Array.from(parentNode.childNodes);
+ anchorOffset = childNodes.indexOf(anchorNode);
+ anchorNode = parentNode;
+ }
+
+ if (direction === 'backward') {
+ if (anchorOffset === 0) {
+ let prevSibling = anchorNode.previousSibling;
+
+ if (prevSibling === null) {
+ prevSibling = anchorNode.parentElement.previousSibling.lastChild;
+ }
+
+ if (prevSibling.nodeName === 'P') {
+ prevSibling = prevSibling.firstChild;
+ }
+
+ if (prevSibling.nodeName === 'BR') {
+ anchor.node = prevSibling;
+ anchor.offset = 0;
+ } else {
+ anchor.node = prevSibling.firstChild;
+ anchor.offset = anchor.node.nodeValue.length - 1;
+ }
+ } else if (!_$isTextNode) {
+ anchor.node = anchorNode.childNodes[anchorOffset - 1];
+ anchor.offset = anchor.node.nodeValue.length - 1;
+ } else {
+ anchor.offset--;
+ }
+ } else {
+ if (
+ (_$isTextNode && anchorOffset === anchorNode.textContent.length) ||
+ (!_$isTextNode &&
+ (anchorNode.childNodes.length === anchorOffset ||
+ (anchorNode.childNodes.length === 1 &&
+ anchorNode.firstChild.nodeName === 'BR')))
+ ) {
+ let nextSibling = anchorNode.nextSibling;
+
+ if (nextSibling === null) {
+ nextSibling = anchorNode.parentElement.nextSibling.lastChild;
+ }
+
+ if (nextSibling.nodeName === 'P') {
+ nextSibling = nextSibling.lastChild;
+ }
+
+ if (nextSibling.nodeName === 'BR') {
+ anchor.node = nextSibling;
+ anchor.offset = 0;
+ } else {
+ anchor.node = nextSibling.firstChild;
+ anchor.offset = 0;
+ }
+ } else {
+ anchor.offset++;
+ }
+ }
+ } else if (granularity === 'word') {
+ const anchorNode = this.anchorNode!;
+ const targetTextContent =
+ direction === 'backward'
+ ? anchorNode.textContent!.slice(0, this.anchorOffset)
+ : anchorNode.textContent!.slice(this.anchorOffset);
+ const segments = getWordsFromString(targetTextContent);
+ const segmentsLength = segments.length;
+ let index = anchor.offset;
+ let foundWordNode = false;
+
+ if (direction === 'backward') {
+ for (let i = segmentsLength - 1; i >= 0; i--) {
+ const segment = segments[i];
+ const nextIndex = segment.index;
+
+ if (segment.isWordLike) {
+ index = nextIndex;
+ foundWordNode = true;
+ } else if (foundWordNode) {
+ break;
+ } else {
+ index = nextIndex;
+ }
+ }
+ } else {
+ for (let i = 0; i < segmentsLength; i++) {
+ const segment = segments[i];
+ const nextIndex = segment.index + segment.segment.length;
+
+ if (segment.isWordLike) {
+ index = nextIndex;
+ foundWordNode = true;
+ } else if (foundWordNode) {
+ break;
+ } else {
+ index = nextIndex;
+ }
+ }
+ }
+
+ if (direction === 'forward') {
+ index += anchor.offset;
+ }
+
+ anchor.offset = index;
+ }
+
+ if (alter === 'move') {
+ focus.offset = anchor.offset;
+ focus.node = anchor.node;
+ }
+ };
+}
+
+export function printWhitespace(whitespaceCharacter: string) {
+ return whitespaceCharacter.charCodeAt(0) === 160
+ ? ' '
+ : whitespaceCharacter;
+}
+
+export function insertText(text: string) {
+ return {
+ text,
+ type: 'insert_text',
+ };
+}
+
+export function insertTokenNode(text: string) {
+ return {
+ text,
+ type: 'insert_token_node',
+ };
+}
+
+export function insertSegmentedNode(text: string) {
+ return {
+ text,
+ type: 'insert_segmented_node',
+ };
+}
+
+export function convertToTokenNode() {
+ return {
+ text: null,
+ type: 'convert_to_token_node',
+ };
+}
+
+export function convertToSegmentedNode() {
+ return {
+ text: null,
+ type: 'convert_to_segmented_node',
+ };
+}
+
+export function insertParagraph() {
+ return {
+ type: 'insert_paragraph',
+ };
+}
+
+export function deleteWordBackward(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'delete_word_backward',
+ };
+}
+
+export function deleteWordForward(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'delete_word_forward',
+ };
+}
+
+export function moveBackward(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'move_backward',
+ };
+}
+
+export function moveForward(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'move_forward',
+ };
+}
+
+export function moveEnd() {
+ return {
+ type: 'move_end',
+ };
+}
+
+export function deleteBackward(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'delete_backward',
+ };
+}
+
+export function deleteForward(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'delete_forward',
+ };
+}
+
+export function formatBold() {
+ return {
+ format: 'bold',
+ type: 'format_text',
+ };
+}
+
+export function formatItalic() {
+ return {
+ format: 'italic',
+ type: 'format_text',
+ };
+}
+
+export function formatStrikeThrough() {
+ return {
+ format: 'strikethrough',
+ type: 'format_text',
+ };
+}
+
+export function formatUnderline() {
+ return {
+ format: 'underline',
+ type: 'format_text',
+ };
+}
+
+export function redo(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'redo',
+ };
+}
+
+export function undo(n: number | null | undefined) {
+ return {
+ text: null,
+ times: n,
+ type: 'undo',
+ };
+}
+
+export function pastePlain(text: string) {
+ return {
+ text: text,
+ type: 'paste_plain',
+ };
+}
+
+export function pasteLexical(text: string) {
+ return {
+ text: text,
+ type: 'paste_lexical',
+ };
+}
+
+export function pasteHTML(text: string) {
+ return {
+ text: text,
+ type: 'paste_html',
+ };
+}
+
+export function moveNativeSelection(
+ anchorPath: number[],
+ anchorOffset: number,
+ focusPath: number[],
+ focusOffset: number,
+) {
+ return {
+ anchorOffset,
+ anchorPath,
+ focusOffset,
+ focusPath,
+ type: 'move_native_selection',
+ };
+}
+
+export function getNodeFromPath(path: number[], rootElement: Node) {
+ let node = rootElement;
+
+ for (let i = 0; i < path.length; i++) {
+ node = node.childNodes[path[i]];
+ }
+
+ return node;
+}
+
+export function setNativeSelection(
+ anchorNode: Node,
+ anchorOffset: number,
+ focusNode: Node,
+ focusOffset: number,
+) {
+ const domSelection = window.getSelection()!;
+ const range = document.createRange();
+ range.setStart(anchorNode, anchorOffset);
+ range.setEnd(focusNode, focusOffset);
+ domSelection.removeAllRanges();
+ domSelection.addRange(range);
+ Promise.resolve().then(() => {
+ document.dispatchEvent(new Event('selectionchange'));
+ });
+}
+
+export function setNativeSelectionWithPaths(
+ rootElement: Node,
+ anchorPath: number[],
+ anchorOffset: number,
+ focusPath: number[],
+ focusOffset: number,
+) {
+ const anchorNode = getNodeFromPath(anchorPath, rootElement);
+ const focusNode = getNodeFromPath(focusPath, rootElement);
+ setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset);
+}
+
+function getLastTextNode(startingNode: Node) {
+ let node = startingNode;
+
+ mainLoop: while (node !== null) {
+ if (node !== startingNode && node.nodeType === 3) {
+ return node;
+ }
+
+ const child = node.lastChild;
+
+ if (child !== null) {
+ node = child;
+ continue;
+ }
+
+ const previousSibling = node.previousSibling;
+
+ if (previousSibling !== null) {
+ node = previousSibling;
+ continue;
+ }
+
+ let parent = node.parentNode;
+
+ while (parent !== null) {
+ const parentSibling = parent.previousSibling;
+
+ if (parentSibling !== null) {
+ node = parentSibling;
+ continue mainLoop;
+ }
+
+ parent = parent.parentNode;
+ }
+ }
+
+ return null;
+}
+
+function getNextTextNode(startingNode: Node) {
+ let node = startingNode;
+
+ mainLoop: while (node !== null) {
+ if (node !== startingNode && node.nodeType === 3) {
+ return node;
+ }
+
+ const child = node.firstChild;
+
+ if (child !== null) {
+ node = child;
+ continue;
+ }
+
+ const nextSibling = node.nextSibling;
+
+ if (nextSibling !== null) {
+ node = nextSibling;
+ continue;
+ }
+
+ let parent = node.parentNode;
+
+ while (parent !== null) {
+ const parentSibling = parent.nextSibling;
+
+ if (parentSibling !== null) {
+ node = parentSibling;
+ continue mainLoop;
+ }
+
+ parent = parent.parentNode;
+ }
+ }
+
+ return null;
+}
+
+function moveNativeSelectionBackward() {
+ const domSelection = window.getSelection()!;
+ let anchorNode = domSelection.anchorNode!;
+ let anchorOffset = domSelection.anchorOffset!;
+
+ if (domSelection.isCollapsed) {
+ const target = (
+ anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
+ )!;
+ const keyDownEvent = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: 'ArrowLeft',
+ keyCode: 37,
+ });
+ target.dispatchEvent(keyDownEvent);
+
+ if (!keyDownEvent.defaultPrevented) {
+ if (anchorNode.nodeType === 3) {
+ if (anchorOffset === 0) {
+ const lastTextNode = getLastTextNode(anchorNode);
+
+ if (lastTextNode === null) {
+ throw new Error('moveNativeSelectionBackward: TODO');
+ } else {
+ const textLength = lastTextNode.nodeValue!.length;
+ setNativeSelection(
+ lastTextNode,
+ textLength,
+ lastTextNode,
+ textLength,
+ );
+ }
+ } else {
+ setNativeSelection(
+ anchorNode,
+ anchorOffset - 1,
+ anchorNode,
+ anchorOffset - 1,
+ );
+ }
+ } else if (anchorNode.nodeType === 1) {
+ if (anchorNode.nodeName === 'BR') {
+ const parentNode = anchorNode.parentNode!;
+ const childNodes = Array.from(parentNode.childNodes);
+ anchorOffset = childNodes.indexOf(anchorNode as ChildNode);
+ anchorNode = parentNode;
+ } else {
+ anchorOffset--;
+ }
+
+ setNativeSelection(anchorNode, anchorOffset, anchorNode, anchorOffset);
+ } else {
+ throw new Error('moveNativeSelectionBackward: TODO');
+ }
+ }
+
+ const keyUpEvent = new KeyboardEvent('keyup', {
+ bubbles: true,
+ cancelable: true,
+ key: 'ArrowLeft',
+ keyCode: 37,
+ });
+ target.dispatchEvent(keyUpEvent);
+ } else {
+ throw new Error('moveNativeSelectionBackward: TODO');
+ }
+}
+
+function moveNativeSelectionForward() {
+ const domSelection = window.getSelection()!;
+ const anchorNode = domSelection.anchorNode!;
+ const anchorOffset = domSelection.anchorOffset!;
+
+ if (domSelection.isCollapsed) {
+ const target = (
+ anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
+ )!;
+ const keyDownEvent = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: 'ArrowRight',
+ keyCode: 39,
+ });
+ target.dispatchEvent(keyDownEvent);
+
+ if (!keyDownEvent.defaultPrevented) {
+ if (anchorNode.nodeType === 3) {
+ const text = anchorNode.nodeValue!;
+
+ if (text.length === anchorOffset) {
+ const nextTextNode = getNextTextNode(anchorNode);
+
+ if (nextTextNode === null) {
+ throw new Error('moveNativeSelectionForward: TODO');
+ } else {
+ setNativeSelection(nextTextNode, 0, nextTextNode, 0);
+ }
+ } else {
+ setNativeSelection(
+ anchorNode,
+ anchorOffset + 1,
+ anchorNode,
+ anchorOffset + 1,
+ );
+ }
+ } else {
+ throw new Error('moveNativeSelectionForward: TODO');
+ }
+ }
+
+ const keyUpEvent = new KeyboardEvent('keyup', {
+ bubbles: true,
+ cancelable: true,
+ key: 'ArrowRight',
+ keyCode: 39,
+ });
+ target.dispatchEvent(keyUpEvent);
+ } else {
+ throw new Error('moveNativeSelectionForward: TODO');
+ }
+}
+
+export async function applySelectionInputs(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ inputs: Record<string, any>[],
+ update: (fn: () => void) => Promise<void>,
+ editor: LexicalEditor,
+) {
+ const rootElement = editor.getRootElement()!;
+
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i];
+ const times = input?.times ?? 1;
+
+ for (let j = 0; j < times; j++) {
+ await update(() => {
+ const selection = $getSelection()!;
+
+ switch (input.type) {
+ case 'insert_text': {
+ selection.insertText(input.text);
+ break;
+ }
+
+ case 'insert_paragraph': {
+ if ($isRangeSelection(selection)) {
+ selection.insertParagraph();
+ }
+ break;
+ }
+
+ case 'move_backward': {
+ moveNativeSelectionBackward();
+ break;
+ }
+
+ case 'move_forward': {
+ moveNativeSelectionForward();
+ break;
+ }
+
+ case 'move_end': {
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+ if ($isTextNode(anchorNode)) {
+ anchorNode.select();
+ }
+ }
+ break;
+ }
+
+ case 'delete_backward': {
+ if ($isRangeSelection(selection)) {
+ selection.deleteCharacter(true);
+ }
+ break;
+ }
+
+ case 'delete_forward': {
+ if ($isRangeSelection(selection)) {
+ selection.deleteCharacter(false);
+ }
+ break;
+ }
+
+ case 'delete_word_backward': {
+ if ($isRangeSelection(selection)) {
+ selection.deleteWord(true);
+ }
+ break;
+ }
+
+ case 'delete_word_forward': {
+ if ($isRangeSelection(selection)) {
+ selection.deleteWord(false);
+ }
+ break;
+ }
+
+ case 'format_text': {
+ if ($isRangeSelection(selection)) {
+ selection.formatText(input.format);
+ }
+ break;
+ }
+
+ case 'move_native_selection': {
+ setNativeSelectionWithPaths(
+ rootElement,
+ input.anchorPath,
+ input.anchorOffset,
+ input.focusPath,
+ input.focusOffset,
+ );
+ break;
+ }
+
+ case 'insert_token_node': {
+ const text = $createTextNode(input.text);
+ text.setMode('token');
+ if ($isRangeSelection(selection)) {
+ selection.insertNodes([text]);
+ }
+ break;
+ }
+
+ case 'insert_segmented_node': {
+ const text = $createTextNode(input.text);
+ text.setMode('segmented');
+ if ($isRangeSelection(selection)) {
+ selection.insertNodes([text]);
+ }
+ text.selectNext();
+ break;
+ }
+
+ case 'convert_to_token_node': {
+ const text = $createTextNode(selection.getTextContent());
+ text.setMode('token');
+ if ($isRangeSelection(selection)) {
+ selection.insertNodes([text]);
+ }
+ text.selectNext();
+ break;
+ }
+
+ case 'convert_to_segmented_node': {
+ const text = $createTextNode(selection.getTextContent());
+ text.setMode('segmented');
+ if ($isRangeSelection(selection)) {
+ selection.insertNodes([text]);
+ }
+ text.selectNext();
+ break;
+ }
+
+ case 'undo': {
+ rootElement.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ ctrlKey: true,
+ key: 'z',
+ keyCode: 90,
+ }),
+ );
+ break;
+ }
+
+ case 'redo': {
+ rootElement.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ ctrlKey: true,
+ key: 'z',
+ keyCode: 90,
+ shiftKey: true,
+ }),
+ );
+ break;
+ }
+
+ case 'paste_plain': {
+ rootElement.dispatchEvent(
+ Object.assign(
+ new Event('paste', {
+ bubbles: true,
+ cancelable: true,
+ }),
+ {
+ clipboardData: {
+ getData: (type: string) => {
+ if (type === 'text/plain') {
+ return input.text;
+ }
+
+ return '';
+ },
+ },
+ },
+ ),
+ );
+ break;
+ }
+
+ case 'paste_lexical': {
+ rootElement.dispatchEvent(
+ Object.assign(
+ new Event('paste', {
+ bubbles: true,
+ cancelable: true,
+ }),
+ {
+ clipboardData: {
+ getData: (type: string) => {
+ if (type === 'application/x-lexical-editor') {
+ return input.text;
+ }
+
+ return '';
+ },
+ },
+ },
+ ),
+ );
+ break;
+ }
+
+ case 'paste_html': {
+ rootElement.dispatchEvent(
+ Object.assign(
+ new Event('paste', {
+ bubbles: true,
+ cancelable: true,
+ }),
+ {
+ clipboardData: {
+ getData: (type: string) => {
+ if (type === 'text/html') {
+ return input.text;
+ }
+
+ return '';
+ },
+ },
+ },
+ ),
+ );
+ break;
+ }
+ }
+ });
+ }
+ }
+}
+
+export function $setAnchorPoint(
+ point: Pick<PointType, 'type' | 'offset' | 'key'>,
+) {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ const dummyTextNode = $createTextNode();
+ dummyTextNode.select();
+ return $setAnchorPoint(point);
+ }
+
+ if ($isNodeSelection(selection)) {
+ return;
+ }
+
+ const anchor = selection.anchor;
+ anchor.type = point.type;
+ anchor.offset = point.offset;
+ anchor.key = point.key;
+}
+
+export function $setFocusPoint(
+ point: Pick<PointType, 'type' | 'offset' | 'key'>,
+) {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ const dummyTextNode = $createTextNode();
+ dummyTextNode.select();
+ return $setFocusPoint(point);
+ }
+
+ if ($isNodeSelection(selection)) {
+ return;
+ }
+
+ const focus = selection.focus;
+ focus.type = point.type;
+ focus.offset = point.offset;
+ focus.key = point.key;
+}
--- /dev/null
+/**
+ * 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 const CSS_TO_STYLES: Map<string, Record<string, string>> = new Map();
--- /dev/null
+/**
+ * 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 {
+ $addNodeStyle,
+ $isAtNodeEnd,
+ $patchStyleText,
+ $sliceSelectedTextNodeContent,
+ $trimTextContentFromAnchor,
+} from './lexical-node';
+import {
+ $getSelectionStyleValueForProperty,
+ $isParentElementRTL,
+ $moveCaretSelection,
+ $moveCharacter,
+ $selectAll,
+ $setBlocksType,
+ $shouldOverrideDefaultCharacterSelection,
+ $wrapNodes,
+} from './range-selection';
+import {
+ createDOMRange,
+ createRectsFromDOMRange,
+ getStyleObjectFromCSS,
+} from './utils';
+
+export {
+ /** @deprecated moved to the lexical package */ $cloneWithProperties,
+} from 'lexical';
+export {
+ $addNodeStyle,
+ $isAtNodeEnd,
+ $patchStyleText,
+ $sliceSelectedTextNodeContent,
+ $trimTextContentFromAnchor,
+};
+/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */
+export const trimTextContentFromAnchor = $trimTextContentFromAnchor;
+
+export {
+ $getSelectionStyleValueForProperty,
+ $isParentElementRTL,
+ $moveCaretSelection,
+ $moveCharacter,
+ $selectAll,
+ $setBlocksType,
+ $shouldOverrideDefaultCharacterSelection,
+ $wrapNodes,
+};
+
+export {createDOMRange, createRectsFromDOMRange, getStyleObjectFromCSS};
--- /dev/null
+/**
+ * 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 {
+ $createTextNode,
+ $getCharacterOffsets,
+ $getNodeByKey,
+ $getPreviousSelection,
+ $isElementNode,
+ $isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ $isTokenOrSegmented,
+ BaseSelection,
+ LexicalEditor,
+ LexicalNode,
+ Point,
+ RangeSelection,
+ TextNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {CSS_TO_STYLES} from './constants';
+import {
+ getCSSFromStyleObject,
+ getStyleObjectFromCSS,
+ getStyleObjectFromRawCSS,
+} from './utils';
+
+/**
+ * Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
+ * it to be generated into the new TextNode.
+ * @param selection - The selection containing the node whose TextNode is to be edited.
+ * @param textNode - The TextNode to be edited.
+ * @returns The updated TextNode.
+ */
+export function $sliceSelectedTextNodeContent(
+ selection: BaseSelection,
+ textNode: TextNode,
+): LexicalNode {
+ const anchorAndFocus = selection.getStartEndPoints();
+ if (
+ textNode.isSelected(selection) &&
+ !textNode.isSegmented() &&
+ !textNode.isToken() &&
+ anchorAndFocus !== null
+ ) {
+ const [anchor, focus] = anchorAndFocus;
+ const isBackward = selection.isBackward();
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+ const isAnchor = textNode.is(anchorNode);
+ const isFocus = textNode.is(focusNode);
+
+ if (isAnchor || isFocus) {
+ const [anchorOffset, focusOffset] = $getCharacterOffsets(selection);
+ const isSame = anchorNode.is(focusNode);
+ const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
+ const isLast = textNode.is(isBackward ? anchorNode : focusNode);
+ let startOffset = 0;
+ let endOffset = undefined;
+
+ if (isSame) {
+ startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
+ endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
+ } else if (isFirst) {
+ const offset = isBackward ? focusOffset : anchorOffset;
+ startOffset = offset;
+ endOffset = undefined;
+ } else if (isLast) {
+ const offset = isBackward ? anchorOffset : focusOffset;
+ startOffset = 0;
+ endOffset = offset;
+ }
+
+ textNode.__text = textNode.__text.slice(startOffset, endOffset);
+ return textNode;
+ }
+ }
+ return textNode;
+}
+
+/**
+ * Determines if the current selection is at the end of the node.
+ * @param point - The point of the selection to test.
+ * @returns true if the provided point offset is in the last possible position, false otherwise.
+ */
+export function $isAtNodeEnd(point: Point): boolean {
+ if (point.type === 'text') {
+ return point.offset === point.getNode().getTextContentSize();
+ }
+ const node = point.getNode();
+ invariant(
+ $isElementNode(node),
+ 'isAtNodeEnd: node must be a TextNode or ElementNode',
+ );
+
+ return point.offset === node.getChildrenSize();
+}
+
+/**
+ * Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
+ * that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
+ * the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
+ * @param editor - The lexical editor.
+ * @param anchor - The anchor of the current selection, where the selection should be pointing.
+ * @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
+ */
+export function $trimTextContentFromAnchor(
+ editor: LexicalEditor,
+ anchor: Point,
+ delCount: number,
+): void {
+ // Work from the current selection anchor point
+ let currentNode: LexicalNode | null = anchor.getNode();
+ let remaining: number = delCount;
+
+ if ($isElementNode(currentNode)) {
+ const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
+ if (descendantNode !== null) {
+ currentNode = descendantNode;
+ }
+ }
+
+ while (remaining > 0 && currentNode !== null) {
+ if ($isElementNode(currentNode)) {
+ const lastDescendant: null | LexicalNode =
+ currentNode.getLastDescendant<LexicalNode>();
+ if (lastDescendant !== null) {
+ currentNode = lastDescendant;
+ }
+ }
+ let nextNode: LexicalNode | null = currentNode.getPreviousSibling();
+ let additionalElementWhitespace = 0;
+ if (nextNode === null) {
+ let parent: LexicalNode | null = currentNode.getParentOrThrow();
+ let parentSibling: LexicalNode | null = parent.getPreviousSibling();
+
+ while (parentSibling === null) {
+ parent = parent.getParent();
+ if (parent === null) {
+ nextNode = null;
+ break;
+ }
+ parentSibling = parent.getPreviousSibling();
+ }
+ if (parent !== null) {
+ additionalElementWhitespace = parent.isInline() ? 0 : 2;
+ nextNode = parentSibling;
+ }
+ }
+ let text = currentNode.getTextContent();
+ // If the text is empty, we need to consider adding in two line breaks to match
+ // the content if we were to get it from its parent.
+ if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) {
+ // TODO: should this be handled in core?
+ text = '\n\n';
+ }
+ const currentNodeSize = text.length;
+
+ if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
+ const parent = currentNode.getParent();
+ currentNode.remove();
+ if (
+ parent != null &&
+ parent.getChildrenSize() === 0 &&
+ !$isRootNode(parent)
+ ) {
+ parent.remove();
+ }
+ remaining -= currentNodeSize + additionalElementWhitespace;
+ currentNode = nextNode;
+ } else {
+ const key = currentNode.getKey();
+ // See if we can just revert it to what was in the last editor state
+ const prevTextContent: string | null = editor
+ .getEditorState()
+ .read(() => {
+ const prevNode = $getNodeByKey(key);
+ if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
+ return prevNode.getTextContent();
+ }
+ return null;
+ });
+ const offset = currentNodeSize - remaining;
+ const slicedText = text.slice(0, offset);
+ if (prevTextContent !== null && prevTextContent !== text) {
+ const prevSelection = $getPreviousSelection();
+ let target = currentNode;
+ if (!currentNode.isSimpleText()) {
+ const textNode = $createTextNode(prevTextContent);
+ currentNode.replace(textNode);
+ target = textNode;
+ } else {
+ currentNode.setTextContent(prevTextContent);
+ }
+ if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
+ const prevOffset = prevSelection.anchor.offset;
+ target.select(prevOffset, prevOffset);
+ }
+ } else if (currentNode.isSimpleText()) {
+ // Split text
+ const isSelected = anchor.key === key;
+ let anchorOffset = anchor.offset;
+ // Move offset to end if it's less than the remaining number, otherwise
+ // we'll have a negative splitStart.
+ if (anchorOffset < remaining) {
+ anchorOffset = currentNodeSize;
+ }
+ const splitStart = isSelected ? anchorOffset - remaining : 0;
+ const splitEnd = isSelected ? anchorOffset : offset;
+ if (isSelected && splitStart === 0) {
+ const [excessNode] = currentNode.splitText(splitStart, splitEnd);
+ excessNode.remove();
+ } else {
+ const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
+ excessNode.remove();
+ }
+ } else {
+ const textNode = $createTextNode(slicedText);
+ currentNode.replace(textNode);
+ }
+ remaining = 0;
+ }
+ }
+}
+
+/**
+ * Gets the TextNode's style object and adds the styles to the CSS.
+ * @param node - The TextNode to add styles to.
+ */
+export function $addNodeStyle(node: TextNode): void {
+ const CSSText = node.getStyle();
+ const styles = getStyleObjectFromRawCSS(CSSText);
+ CSS_TO_STYLES.set(CSSText, styles);
+}
+
+function $patchStyle(
+ target: TextNode | RangeSelection,
+ patch: Record<
+ string,
+ | string
+ | null
+ | ((currentStyleValue: string | null, _target: typeof target) => string)
+ >,
+): void {
+ const prevStyles = getStyleObjectFromCSS(
+ 'getStyle' in target ? target.getStyle() : target.style,
+ );
+ const newStyles = Object.entries(patch).reduce<Record<string, string>>(
+ (styles, [key, value]) => {
+ if (typeof value === 'function') {
+ styles[key] = value(prevStyles[key], target);
+ } else if (value === null) {
+ delete styles[key];
+ } else {
+ styles[key] = value;
+ }
+ return styles;
+ },
+ {...prevStyles} || {},
+ );
+ const newCSSText = getCSSFromStyleObject(newStyles);
+ target.setStyle(newCSSText);
+ CSS_TO_STYLES.set(newCSSText, newStyles);
+}
+
+/**
+ * Applies the provided styles to the TextNodes in the provided Selection.
+ * Will update partially selected TextNodes by splitting the TextNode and applying
+ * the styles to the appropriate one.
+ * @param selection - The selected node(s) to update.
+ * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
+ */
+export function $patchStyleText(
+ selection: BaseSelection,
+ patch: Record<
+ string,
+ | string
+ | null
+ | ((
+ currentStyleValue: string | null,
+ target: TextNode | RangeSelection,
+ ) => string)
+ >,
+): void {
+ const selectedNodes = selection.getNodes();
+ const selectedNodesLength = selectedNodes.length;
+ const anchorAndFocus = selection.getStartEndPoints();
+ if (anchorAndFocus === null) {
+ return;
+ }
+ const [anchor, focus] = anchorAndFocus;
+
+ const lastIndex = selectedNodesLength - 1;
+ let firstNode = selectedNodes[0];
+ let lastNode = selectedNodes[lastIndex];
+
+ if (selection.isCollapsed() && $isRangeSelection(selection)) {
+ $patchStyle(selection, patch);
+ return;
+ }
+
+ const firstNodeText = firstNode.getTextContent();
+ const firstNodeTextLength = firstNodeText.length;
+ const focusOffset = focus.offset;
+ let anchorOffset = anchor.offset;
+ const isBefore = anchor.isBefore(focus);
+ let startOffset = isBefore ? anchorOffset : focusOffset;
+ let endOffset = isBefore ? focusOffset : anchorOffset;
+ const startType = isBefore ? anchor.type : focus.type;
+ const endType = isBefore ? focus.type : anchor.type;
+ const endKey = isBefore ? focus.key : anchor.key;
+
+ // This is the case where the user only selected the very end of the
+ // first node so we don't want to include it in the formatting change.
+ if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
+ const nextSibling = firstNode.getNextSibling();
+
+ if ($isTextNode(nextSibling)) {
+ // we basically make the second node the firstNode, changing offsets accordingly
+ anchorOffset = 0;
+ startOffset = 0;
+ firstNode = nextSibling;
+ }
+ }
+
+ // This is the case where we only selected a single node
+ if (selectedNodes.length === 1) {
+ if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
+ startOffset =
+ startType === 'element'
+ ? 0
+ : anchorOffset > focusOffset
+ ? focusOffset
+ : anchorOffset;
+ endOffset =
+ endType === 'element'
+ ? firstNodeTextLength
+ : anchorOffset > focusOffset
+ ? anchorOffset
+ : focusOffset;
+
+ // No actual text is selected, so do nothing.
+ if (startOffset === endOffset) {
+ return;
+ }
+
+ // The entire node is selected or a token/segment, so just format it
+ if (
+ $isTokenOrSegmented(firstNode) ||
+ (startOffset === 0 && endOffset === firstNodeTextLength)
+ ) {
+ $patchStyle(firstNode, patch);
+ firstNode.select(startOffset, endOffset);
+ } else {
+ // The node is partially selected, so split it into two nodes
+ // and style the selected one.
+ const splitNodes = firstNode.splitText(startOffset, endOffset);
+ const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
+ $patchStyle(replacement, patch);
+ replacement.select(0, endOffset - startOffset);
+ }
+ } // multiple nodes selected.
+ } else {
+ if (
+ $isTextNode(firstNode) &&
+ startOffset < firstNode.getTextContentSize() &&
+ firstNode.canHaveFormat()
+ ) {
+ if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
+ // the entire first node isn't selected and it isn't a token or segmented, so split it
+ firstNode = firstNode.splitText(startOffset)[1];
+ startOffset = 0;
+ if (isBefore) {
+ anchor.set(firstNode.getKey(), startOffset, 'text');
+ } else {
+ focus.set(firstNode.getKey(), startOffset, 'text');
+ }
+ }
+
+ $patchStyle(firstNode as TextNode, patch);
+ }
+
+ if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
+ const lastNodeText = lastNode.getTextContent();
+ const lastNodeTextLength = lastNodeText.length;
+
+ // The last node might not actually be the end node
+ //
+ // If not, assume the last node is fully-selected unless the end offset is
+ // zero.
+ if (lastNode.__key !== endKey && endOffset !== 0) {
+ endOffset = lastNodeTextLength;
+ }
+
+ // if the entire last node isn't selected and it isn't a token or segmented, split it
+ if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
+ [lastNode] = lastNode.splitText(endOffset);
+ }
+
+ if (endOffset !== 0 || endType === 'element') {
+ $patchStyle(lastNode as TextNode, patch);
+ }
+ }
+
+ // style all the text nodes in between
+ for (let i = 1; i < lastIndex; i++) {
+ const selectedNode = selectedNodes[i];
+ const selectedNodeKey = selectedNode.getKey();
+
+ if (
+ $isTextNode(selectedNode) &&
+ selectedNode.canHaveFormat() &&
+ selectedNodeKey !== firstNode.getKey() &&
+ selectedNodeKey !== lastNode.getKey() &&
+ !selectedNode.isToken()
+ ) {
+ $patchStyle(selectedNode, patch);
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * 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,
+ LexicalNode,
+ NodeKey,
+ Point,
+ RangeSelection,
+ TextNode,
+} from 'lexical';
+
+import {TableSelection} from '@lexical/table';
+import {
+ $getAdjacentNode,
+ $getPreviousSelection,
+ $getRoot,
+ $hasAncestor,
+ $isDecoratorNode,
+ $isElementNode,
+ $isLeafNode,
+ $isLineBreakNode,
+ $isRangeSelection,
+ $isRootNode,
+ $isRootOrShadowRoot,
+ $isTextNode,
+ $setSelection,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {getStyleObjectFromCSS} from './utils';
+
+/**
+ * Converts all nodes in the selection that are of one block type to another.
+ * @param selection - The selected blocks to be converted.
+ * @param createElement - The function that creates the node. eg. $createParagraphNode.
+ */
+export function $setBlocksType(
+ selection: BaseSelection | null,
+ createElement: () => ElementNode,
+): void {
+ if (selection === null) {
+ return;
+ }
+ const anchorAndFocus = selection.getStartEndPoints();
+ const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
+
+ if (anchor !== null && anchor.key === 'root') {
+ const element = createElement();
+ const root = $getRoot();
+ const firstChild = root.getFirstChild();
+
+ if (firstChild) {
+ firstChild.replace(element, true);
+ } else {
+ root.append(element);
+ }
+
+ return;
+ }
+
+ const nodes = selection.getNodes();
+ const firstSelectedBlock =
+ anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false;
+ if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) {
+ nodes.push(firstSelectedBlock);
+ }
+
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+
+ if (!INTERNAL_$isBlock(node)) {
+ continue;
+ }
+ invariant($isElementNode(node), 'Expected block node to be an ElementNode');
+
+ const targetElement = createElement();
+ targetElement.setFormat(node.getFormatType());
+ targetElement.setIndent(node.getIndent());
+ node.replace(targetElement, true);
+ }
+}
+
+function isPointAttached(point: Point): boolean {
+ return point.getNode().isAttached();
+}
+
+function $removeParentEmptyElements(startingNode: ElementNode): void {
+ let node: ElementNode | null = startingNode;
+
+ while (node !== null && !$isRootOrShadowRoot(node)) {
+ const latest = node.getLatest();
+ const parentNode: ElementNode | null = node.getParent<ElementNode>();
+
+ if (latest.getChildrenSize() === 0) {
+ node.remove(true);
+ }
+
+ node = parentNode;
+ }
+}
+
+/**
+ * @deprecated
+ * Wraps all nodes in the selection into another node of the type returned by createElement.
+ * @param selection - The selection of nodes to be wrapped.
+ * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
+ * @param wrappingElement - An element to append the wrapped selection and its children to.
+ */
+export function $wrapNodes(
+ selection: BaseSelection,
+ createElement: () => ElementNode,
+ wrappingElement: null | ElementNode = null,
+): void {
+ const anchorAndFocus = selection.getStartEndPoints();
+ const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
+ const nodes = selection.getNodes();
+ const nodesLength = nodes.length;
+
+ if (
+ anchor !== null &&
+ (nodesLength === 0 ||
+ (nodesLength === 1 &&
+ anchor.type === 'element' &&
+ anchor.getNode().getChildrenSize() === 0))
+ ) {
+ const target =
+ anchor.type === 'text'
+ ? anchor.getNode().getParentOrThrow()
+ : anchor.getNode();
+ const children = target.getChildren();
+ let element = createElement();
+ element.setFormat(target.getFormatType());
+ element.setIndent(target.getIndent());
+ children.forEach((child) => element.append(child));
+
+ if (wrappingElement) {
+ element = wrappingElement.append(element);
+ }
+
+ target.replace(element);
+
+ return;
+ }
+
+ let topLevelNode = null;
+ let descendants: LexicalNode[] = [];
+ for (let i = 0; i < nodesLength; i++) {
+ const node = nodes[i];
+ // Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
+ // user selected multiple Root-like nodes that have to be treated separately as if they are
+ // their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
+ // of each of the cell nodes.
+ if ($isRootOrShadowRoot(node)) {
+ $wrapNodesImpl(
+ selection,
+ descendants,
+ descendants.length,
+ createElement,
+ wrappingElement,
+ );
+ descendants = [];
+ topLevelNode = node;
+ } else if (
+ topLevelNode === null ||
+ (topLevelNode !== null && $hasAncestor(node, topLevelNode))
+ ) {
+ descendants.push(node);
+ } else {
+ $wrapNodesImpl(
+ selection,
+ descendants,
+ descendants.length,
+ createElement,
+ wrappingElement,
+ );
+ descendants = [node];
+ }
+ }
+ $wrapNodesImpl(
+ selection,
+ descendants,
+ descendants.length,
+ createElement,
+ wrappingElement,
+ );
+}
+
+/**
+ * Wraps each node into a new ElementNode.
+ * @param selection - The selection of nodes to wrap.
+ * @param nodes - An array of nodes, generally the descendants of the selection.
+ * @param nodesLength - The length of nodes.
+ * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
+ * @param wrappingElement - An element to wrap all the nodes into.
+ * @returns
+ */
+export function $wrapNodesImpl(
+ selection: BaseSelection,
+ nodes: LexicalNode[],
+ nodesLength: number,
+ createElement: () => ElementNode,
+ wrappingElement: null | ElementNode = null,
+): void {
+ if (nodes.length === 0) {
+ return;
+ }
+
+ const firstNode = nodes[0];
+ const elementMapping: Map<NodeKey, ElementNode> = new Map();
+ const elements = [];
+ // The below logic is to find the right target for us to
+ // either insertAfter/insertBefore/append the corresponding
+ // elements to. This is made more complicated due to nested
+ // structures.
+ let target = $isElementNode(firstNode)
+ ? firstNode
+ : firstNode.getParentOrThrow();
+
+ if (target.isInline()) {
+ target = target.getParentOrThrow();
+ }
+
+ let targetIsPrevSibling = false;
+ while (target !== null) {
+ const prevSibling = target.getPreviousSibling<ElementNode>();
+
+ if (prevSibling !== null) {
+ target = prevSibling;
+ targetIsPrevSibling = true;
+ break;
+ }
+
+ target = target.getParentOrThrow();
+
+ if ($isRootOrShadowRoot(target)) {
+ break;
+ }
+ }
+
+ const emptyElements = new Set();
+
+ // Find any top level empty elements
+ for (let i = 0; i < nodesLength; i++) {
+ const node = nodes[i];
+
+ if ($isElementNode(node) && node.getChildrenSize() === 0) {
+ emptyElements.add(node.getKey());
+ }
+ }
+
+ const movedNodes: Set<NodeKey> = new Set();
+
+ // Move out all leaf nodes into our elements array.
+ // If we find a top level empty element, also move make
+ // an element for that.
+ for (let i = 0; i < nodesLength; i++) {
+ const node = nodes[i];
+ let parent = node.getParent();
+
+ if (parent !== null && parent.isInline()) {
+ parent = parent.getParent();
+ }
+
+ if (
+ parent !== null &&
+ $isLeafNode(node) &&
+ !movedNodes.has(node.getKey())
+ ) {
+ const parentKey = parent.getKey();
+
+ if (elementMapping.get(parentKey) === undefined) {
+ const targetElement = createElement();
+ targetElement.setFormat(parent.getFormatType());
+ targetElement.setIndent(parent.getIndent());
+ elements.push(targetElement);
+ elementMapping.set(parentKey, targetElement);
+ // Move node and its siblings to the new
+ // element.
+ parent.getChildren().forEach((child) => {
+ targetElement.append(child);
+ movedNodes.add(child.getKey());
+ if ($isElementNode(child)) {
+ // Skip nested leaf nodes if the parent has already been moved
+ child.getChildrenKeys().forEach((key) => movedNodes.add(key));
+ }
+ });
+ $removeParentEmptyElements(parent);
+ }
+ } else if (emptyElements.has(node.getKey())) {
+ invariant(
+ $isElementNode(node),
+ 'Expected node in emptyElements to be an ElementNode',
+ );
+ const targetElement = createElement();
+ targetElement.setFormat(node.getFormatType());
+ targetElement.setIndent(node.getIndent());
+ elements.push(targetElement);
+ node.remove(true);
+ }
+ }
+
+ if (wrappingElement !== null) {
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ wrappingElement.append(element);
+ }
+ }
+ let lastElement = null;
+
+ // If our target is Root-like, let's see if we can re-adjust
+ // so that the target is the first child instead.
+ if ($isRootOrShadowRoot(target)) {
+ if (targetIsPrevSibling) {
+ if (wrappingElement !== null) {
+ target.insertAfter(wrappingElement);
+ } else {
+ for (let i = elements.length - 1; i >= 0; i--) {
+ const element = elements[i];
+ target.insertAfter(element);
+ }
+ }
+ } else {
+ const firstChild = target.getFirstChild();
+
+ if ($isElementNode(firstChild)) {
+ target = firstChild;
+ }
+
+ if (firstChild === null) {
+ if (wrappingElement) {
+ target.append(wrappingElement);
+ } else {
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ target.append(element);
+ lastElement = element;
+ }
+ }
+ } else {
+ if (wrappingElement !== null) {
+ firstChild.insertBefore(wrappingElement);
+ } else {
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ firstChild.insertBefore(element);
+ lastElement = element;
+ }
+ }
+ }
+ }
+ } else {
+ if (wrappingElement) {
+ target.insertAfter(wrappingElement);
+ } else {
+ for (let i = elements.length - 1; i >= 0; i--) {
+ const element = elements[i];
+ target.insertAfter(element);
+ lastElement = element;
+ }
+ }
+ }
+
+ const prevSelection = $getPreviousSelection();
+
+ if (
+ $isRangeSelection(prevSelection) &&
+ isPointAttached(prevSelection.anchor) &&
+ isPointAttached(prevSelection.focus)
+ ) {
+ $setSelection(prevSelection.clone());
+ } else if (lastElement !== null) {
+ lastElement.selectEnd();
+ } else {
+ selection.dirty = true;
+ }
+}
+
+/**
+ * Determines if the default character selection should be overridden. Used with DecoratorNodes
+ * @param selection - The selection whose default character selection may need to be overridden.
+ * @param isBackward - Is the selection backwards (the focus comes before the anchor)?
+ * @returns true if it should be overridden, false if not.
+ */
+export function $shouldOverrideDefaultCharacterSelection(
+ selection: RangeSelection,
+ isBackward: boolean,
+): boolean {
+ const possibleNode = $getAdjacentNode(selection.focus, isBackward);
+
+ return (
+ ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
+ ($isElementNode(possibleNode) &&
+ !possibleNode.isInline() &&
+ !possibleNode.canBeEmpty())
+ );
+}
+
+/**
+ * Moves the selection according to the arguments.
+ * @param selection - The selected text or nodes.
+ * @param isHoldingShift - Is the shift key being held down during the operation.
+ * @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
+ * @param granularity - The distance to adjust the current selection.
+ */
+export function $moveCaretSelection(
+ selection: RangeSelection,
+ isHoldingShift: boolean,
+ isBackward: boolean,
+ granularity: 'character' | 'word' | 'lineboundary',
+): void {
+ selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
+}
+
+/**
+ * Tests a parent element for right to left direction.
+ * @param selection - The selection whose parent is to be tested.
+ * @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
+ */
+export function $isParentElementRTL(selection: RangeSelection): boolean {
+ const anchorNode = selection.anchor.getNode();
+ const parent = $isRootNode(anchorNode)
+ ? anchorNode
+ : anchorNode.getParentOrThrow();
+
+ return parent.getDirection() === 'rtl';
+}
+
+/**
+ * Moves selection by character according to arguments.
+ * @param selection - The selection of the characters to move.
+ * @param isHoldingShift - Is the shift key being held down during the operation.
+ * @param isBackward - Is the selection backward (the focus comes before the anchor)?
+ */
+export function $moveCharacter(
+ selection: RangeSelection,
+ isHoldingShift: boolean,
+ isBackward: boolean,
+): void {
+ const isRTL = $isParentElementRTL(selection);
+ $moveCaretSelection(
+ selection,
+ isHoldingShift,
+ isBackward ? !isRTL : isRTL,
+ 'character',
+ );
+}
+
+/**
+ * Expands the current Selection to cover all of the content in the editor.
+ * @param selection - The current selection.
+ */
+export function $selectAll(selection: RangeSelection): void {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const topParent = anchorNode.getTopLevelElementOrThrow();
+ const root = topParent.getParentOrThrow();
+ let firstNode = root.getFirstDescendant();
+ let lastNode = root.getLastDescendant();
+ let firstType: 'element' | 'text' = 'element';
+ let lastType: 'element' | 'text' = 'element';
+ let lastOffset = 0;
+
+ if ($isTextNode(firstNode)) {
+ firstType = 'text';
+ } else if (!$isElementNode(firstNode) && firstNode !== null) {
+ firstNode = firstNode.getParentOrThrow();
+ }
+
+ if ($isTextNode(lastNode)) {
+ lastType = 'text';
+ lastOffset = lastNode.getTextContentSize();
+ } else if (!$isElementNode(lastNode) && lastNode !== null) {
+ lastNode = lastNode.getParentOrThrow();
+ }
+
+ if (firstNode && lastNode) {
+ anchor.set(firstNode.getKey(), 0, firstType);
+ focus.set(lastNode.getKey(), lastOffset, lastType);
+ }
+}
+
+/**
+ * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
+ * @param node - The node whose style value to get.
+ * @param styleProperty - The CSS style property.
+ * @param defaultValue - The default value for the property.
+ * @returns The value of the property for node.
+ */
+function $getNodeStyleValueForProperty(
+ node: TextNode,
+ styleProperty: string,
+ defaultValue: string,
+): string {
+ const css = node.getStyle();
+ const styleObject = getStyleObjectFromCSS(css);
+
+ if (styleObject !== null) {
+ return styleObject[styleProperty] || defaultValue;
+ }
+
+ return defaultValue;
+}
+
+/**
+ * Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
+ * If all TextNodes do not have the same value, it returns an empty string.
+ * @param selection - The selection of TextNodes whose value to find.
+ * @param styleProperty - The CSS style property.
+ * @param defaultValue - The default value for the property, defaults to an empty string.
+ * @returns The value of the property for the selected TextNodes.
+ */
+export function $getSelectionStyleValueForProperty(
+ selection: RangeSelection | TableSelection,
+ styleProperty: string,
+ defaultValue = '',
+): string {
+ let styleValue: string | null = null;
+ const nodes = selection.getNodes();
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const isBackward = selection.isBackward();
+ const endOffset = isBackward ? focus.offset : anchor.offset;
+ const endNode = isBackward ? focus.getNode() : anchor.getNode();
+
+ if (
+ $isRangeSelection(selection) &&
+ selection.isCollapsed() &&
+ selection.style !== ''
+ ) {
+ const css = selection.style;
+ const styleObject = getStyleObjectFromCSS(css);
+
+ if (styleObject !== null && styleProperty in styleObject) {
+ return styleObject[styleProperty];
+ }
+ }
+
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+
+ // if no actual characters in the end node are selected, we don't
+ // include it in the selection for purposes of determining style
+ // value
+ if (i !== 0 && endOffset === 0 && node.is(endNode)) {
+ continue;
+ }
+
+ if ($isTextNode(node)) {
+ const nodeStyleValue = $getNodeStyleValueForProperty(
+ node,
+ styleProperty,
+ defaultValue,
+ );
+
+ if (styleValue === null) {
+ styleValue = nodeStyleValue;
+ } else if (styleValue !== nodeStyleValue) {
+ // multiple text nodes are in the selection and they don't all
+ // have the same style.
+ styleValue = '';
+ break;
+ }
+ }
+ }
+
+ return styleValue === null ? defaultValue : styleValue;
+}
+
+/**
+ * This function is for internal use of the library.
+ * Please do not use it as it may change in the future.
+ */
+export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode {
+ if ($isDecoratorNode(node)) {
+ return false;
+ }
+ if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
+ return false;
+ }
+
+ const firstChild = node.getFirstChild();
+ const isLeafElement =
+ firstChild === null ||
+ $isLineBreakNode(firstChild) ||
+ $isTextNode(firstChild) ||
+ firstChild.isInline();
+
+ return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
+}
+
+export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
+ node: LexicalNode,
+ predicate: (ancestor: LexicalNode) => ancestor is NodeType,
+) {
+ let parent = node;
+ while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
+ parent = parent.getParentOrThrow();
+ }
+ return predicate(parent) ? parent : null;
+}
--- /dev/null
+/**
+ * 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, LexicalNode} from 'lexical';
+
+import {$isTextNode} from 'lexical';
+
+import {CSS_TO_STYLES} from './constants';
+
+function getDOMTextNode(element: Node | null): Text | null {
+ let node = element;
+
+ while (node != null) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node as Text;
+ }
+
+ node = node.firstChild;
+ }
+
+ return null;
+}
+
+function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] {
+ const parent = node.parentNode;
+
+ if (parent == null) {
+ throw new Error('Should never happen');
+ }
+
+ return [parent, Array.from(parent.childNodes).indexOf(node)];
+}
+
+/**
+ * Creates a selection range for the DOM.
+ * @param editor - The lexical editor.
+ * @param anchorNode - The anchor node of a selection.
+ * @param _anchorOffset - The amount of space offset from the anchor to the focus.
+ * @param focusNode - The current focus.
+ * @param _focusOffset - The amount of space offset from the focus to the anchor.
+ * @returns The range of selection for the DOM that was created.
+ */
+export function createDOMRange(
+ editor: LexicalEditor,
+ anchorNode: LexicalNode,
+ _anchorOffset: number,
+ focusNode: LexicalNode,
+ _focusOffset: number,
+): Range | null {
+ const anchorKey = anchorNode.getKey();
+ const focusKey = focusNode.getKey();
+ const range = document.createRange();
+ let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey);
+ let focusDOM: Node | Text | null = editor.getElementByKey(focusKey);
+ let anchorOffset = _anchorOffset;
+ let focusOffset = _focusOffset;
+
+ if ($isTextNode(anchorNode)) {
+ anchorDOM = getDOMTextNode(anchorDOM);
+ }
+
+ if ($isTextNode(focusNode)) {
+ focusDOM = getDOMTextNode(focusDOM);
+ }
+
+ if (
+ anchorNode === undefined ||
+ focusNode === undefined ||
+ anchorDOM === null ||
+ focusDOM === null
+ ) {
+ return null;
+ }
+
+ if (anchorDOM.nodeName === 'BR') {
+ [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode);
+ }
+
+ if (focusDOM.nodeName === 'BR') {
+ [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode);
+ }
+
+ const firstChild = anchorDOM.firstChild;
+
+ if (
+ anchorDOM === focusDOM &&
+ firstChild != null &&
+ firstChild.nodeName === 'BR' &&
+ anchorOffset === 0 &&
+ focusOffset === 0
+ ) {
+ focusOffset = 1;
+ }
+
+ try {
+ range.setStart(anchorDOM, anchorOffset);
+ range.setEnd(focusDOM, focusOffset);
+ } catch (e) {
+ return null;
+ }
+
+ if (
+ range.collapsed &&
+ (anchorOffset !== focusOffset || anchorKey !== focusKey)
+ ) {
+ // Range is backwards, we need to reverse it
+ range.setStart(focusDOM, focusOffset);
+ range.setEnd(anchorDOM, anchorOffset);
+ }
+
+ return range;
+}
+
+/**
+ * Creates DOMRects, generally used to help the editor find a specific location on the screen.
+ * @param editor - The lexical editor
+ * @param range - A fragment of a document that can contain nodes and parts of text nodes.
+ * @returns The selectionRects as an array.
+ */
+export function createRectsFromDOMRange(
+ editor: LexicalEditor,
+ range: Range,
+): Array<ClientRect> {
+ const rootElement = editor.getRootElement();
+
+ if (rootElement === null) {
+ return [];
+ }
+ const rootRect = rootElement.getBoundingClientRect();
+ const computedStyle = getComputedStyle(rootElement);
+ const rootPadding =
+ parseFloat(computedStyle.paddingLeft) +
+ parseFloat(computedStyle.paddingRight);
+ const selectionRects = Array.from(range.getClientRects());
+ let selectionRectsLength = selectionRects.length;
+ //sort rects from top left to bottom right.
+ selectionRects.sort((a, b) => {
+ const top = a.top - b.top;
+ // Some rects match position closely, but not perfectly,
+ // so we give a 3px tolerance.
+ if (Math.abs(top) <= 3) {
+ return a.left - b.left;
+ }
+ return top;
+ });
+ let prevRect;
+ for (let i = 0; i < selectionRectsLength; i++) {
+ const selectionRect = selectionRects[i];
+ // Exclude rects that overlap preceding Rects in the sorted list.
+ const isOverlappingRect =
+ prevRect &&
+ prevRect.top <= selectionRect.top &&
+ prevRect.top + prevRect.height > selectionRect.top &&
+ prevRect.left + prevRect.width > selectionRect.left;
+ // Exclude selections that span the entire element
+ const selectionSpansElement =
+ selectionRect.width + rootPadding === rootRect.width;
+ if (isOverlappingRect || selectionSpansElement) {
+ selectionRects.splice(i--, 1);
+ selectionRectsLength--;
+ continue;
+ }
+ prevRect = selectionRect;
+ }
+ return selectionRects;
+}
+
+/**
+ * Creates an object containing all the styles and their values provided in the CSS string.
+ * @param css - The CSS string of styles and their values.
+ * @returns The styleObject containing all the styles and their values.
+ */
+export function getStyleObjectFromRawCSS(css: string): Record<string, string> {
+ const styleObject: Record<string, string> = {};
+ const styles = css.split(';');
+
+ for (const style of styles) {
+ if (style !== '') {
+ const [key, value] = style.split(/:([^]+)/); // split on first colon
+ if (key && value) {
+ styleObject[key.trim()] = value.trim();
+ }
+ }
+ }
+
+ return styleObject;
+}
+
+/**
+ * Given a CSS string, returns an object from the style cache.
+ * @param css - The CSS property as a string.
+ * @returns The value of the given CSS property.
+ */
+export function getStyleObjectFromCSS(css: string): Record<string, string> {
+ let value = CSS_TO_STYLES.get(css);
+ if (value === undefined) {
+ value = getStyleObjectFromRawCSS(css);
+ CSS_TO_STYLES.set(css, value);
+ }
+
+ if (__DEV__) {
+ // Freeze the value in DEV to prevent accidental mutations
+ Object.freeze(value);
+ }
+
+ return value;
+}
+
+/**
+ * Gets the CSS styles from the style object.
+ * @param styles - The style object containing the styles to get.
+ * @returns A string containing the CSS styles and their values.
+ */
+export function getCSSFromStyleObject(styles: Record<string, string>): string {
+ let css = '';
+
+ for (const style in styles) {
+ if (style) {
+ css += `${style}: ${styles[style]};`;
+ }
+ }
+
+ return css;
+}
--- /dev/null
+/**
+ * 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 {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ SerializedElementNode,
+ Spread,
+} from 'lexical';
+
+import {addClassNamesToElement} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $isElementNode,
+ $isLineBreakNode,
+ $isTextNode,
+ ElementNode,
+} from 'lexical';
+
+import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants';
+
+export const TableCellHeaderStates = {
+ BOTH: 3,
+ COLUMN: 2,
+ NO_STATUS: 0,
+ ROW: 1,
+};
+
+export type TableCellHeaderState =
+ typeof TableCellHeaderStates[keyof typeof TableCellHeaderStates];
+
+export type SerializedTableCellNode = Spread<
+ {
+ colSpan?: number;
+ rowSpan?: number;
+ headerState: TableCellHeaderState;
+ width?: number;
+ backgroundColor?: null | string;
+ },
+ SerializedElementNode
+>;
+
+/** @noInheritDoc */
+export class TableCellNode extends ElementNode {
+ /** @internal */
+ __colSpan: number;
+ /** @internal */
+ __rowSpan: number;
+ /** @internal */
+ __headerState: TableCellHeaderState;
+ /** @internal */
+ __width?: number;
+ /** @internal */
+ __backgroundColor: null | string;
+
+ static getType(): string {
+ return 'tablecell';
+ }
+
+ static clone(node: TableCellNode): TableCellNode {
+ const cellNode = new TableCellNode(
+ node.__headerState,
+ node.__colSpan,
+ node.__width,
+ node.__key,
+ );
+ cellNode.__rowSpan = node.__rowSpan;
+ cellNode.__backgroundColor = node.__backgroundColor;
+ return cellNode;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ td: (node: Node) => ({
+ conversion: $convertTableCellNodeElement,
+ priority: 0,
+ }),
+ th: (node: Node) => ({
+ conversion: $convertTableCellNodeElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ static importJSON(serializedNode: SerializedTableCellNode): TableCellNode {
+ const colSpan = serializedNode.colSpan || 1;
+ const rowSpan = serializedNode.rowSpan || 1;
+ const cellNode = $createTableCellNode(
+ serializedNode.headerState,
+ colSpan,
+ serializedNode.width || undefined,
+ );
+ cellNode.__rowSpan = rowSpan;
+ cellNode.__backgroundColor = serializedNode.backgroundColor || null;
+ return cellNode;
+ }
+
+ constructor(
+ headerState = TableCellHeaderStates.NO_STATUS,
+ colSpan = 1,
+ width?: number,
+ key?: NodeKey,
+ ) {
+ super(key);
+ this.__colSpan = colSpan;
+ this.__rowSpan = 1;
+ this.__headerState = headerState;
+ this.__width = width;
+ this.__backgroundColor = null;
+ }
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const element = document.createElement(
+ this.getTag(),
+ ) as HTMLTableCellElement;
+
+ if (this.__width) {
+ element.style.width = `${this.__width}px`;
+ }
+ if (this.__colSpan > 1) {
+ element.colSpan = this.__colSpan;
+ }
+ if (this.__rowSpan > 1) {
+ element.rowSpan = this.__rowSpan;
+ }
+ if (this.__backgroundColor !== null) {
+ element.style.backgroundColor = this.__backgroundColor;
+ }
+
+ addClassNamesToElement(
+ element,
+ config.theme.tableCell,
+ this.hasHeader() && config.theme.tableCellHeader,
+ );
+
+ return element;
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const {element} = super.exportDOM(editor);
+
+ if (element) {
+ const element_ = element as HTMLTableCellElement;
+ element_.style.border = '1px solid black';
+ if (this.__colSpan > 1) {
+ element_.colSpan = this.__colSpan;
+ }
+ if (this.__rowSpan > 1) {
+ element_.rowSpan = this.__rowSpan;
+ }
+ element_.style.width = `${this.getWidth() || COLUMN_WIDTH}px`;
+
+ element_.style.verticalAlign = 'top';
+ element_.style.textAlign = 'start';
+
+ const backgroundColor = this.getBackgroundColor();
+ if (backgroundColor !== null) {
+ element_.style.backgroundColor = backgroundColor;
+ } else if (this.hasHeader()) {
+ element_.style.backgroundColor = '#f2f3f5';
+ }
+ }
+
+ return {
+ element,
+ };
+ }
+
+ exportJSON(): SerializedTableCellNode {
+ return {
+ ...super.exportJSON(),
+ backgroundColor: this.getBackgroundColor(),
+ colSpan: this.__colSpan,
+ headerState: this.__headerState,
+ rowSpan: this.__rowSpan,
+ type: 'tablecell',
+ width: this.getWidth(),
+ };
+ }
+
+ getColSpan(): number {
+ return this.__colSpan;
+ }
+
+ setColSpan(colSpan: number): this {
+ this.getWritable().__colSpan = colSpan;
+ return this;
+ }
+
+ getRowSpan(): number {
+ return this.__rowSpan;
+ }
+
+ setRowSpan(rowSpan: number): this {
+ this.getWritable().__rowSpan = rowSpan;
+ return this;
+ }
+
+ getTag(): string {
+ return this.hasHeader() ? 'th' : 'td';
+ }
+
+ setHeaderStyles(headerState: TableCellHeaderState): TableCellHeaderState {
+ const self = this.getWritable();
+ self.__headerState = headerState;
+ return this.__headerState;
+ }
+
+ getHeaderStyles(): TableCellHeaderState {
+ return this.getLatest().__headerState;
+ }
+
+ setWidth(width: number): number | null | undefined {
+ const self = this.getWritable();
+ self.__width = width;
+ return this.__width;
+ }
+
+ getWidth(): number | undefined {
+ return this.getLatest().__width;
+ }
+
+ getBackgroundColor(): null | string {
+ return this.getLatest().__backgroundColor;
+ }
+
+ setBackgroundColor(newBackgroundColor: null | string): void {
+ this.getWritable().__backgroundColor = newBackgroundColor;
+ }
+
+ toggleHeaderStyle(headerStateToToggle: TableCellHeaderState): TableCellNode {
+ const self = this.getWritable();
+
+ if ((self.__headerState & headerStateToToggle) === headerStateToToggle) {
+ self.__headerState -= headerStateToToggle;
+ } else {
+ self.__headerState += headerStateToToggle;
+ }
+
+ return self;
+ }
+
+ hasHeaderState(headerState: TableCellHeaderState): boolean {
+ return (this.getHeaderStyles() & headerState) === headerState;
+ }
+
+ hasHeader(): boolean {
+ return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS;
+ }
+
+ updateDOM(prevNode: TableCellNode): boolean {
+ return (
+ prevNode.__headerState !== this.__headerState ||
+ prevNode.__width !== this.__width ||
+ prevNode.__colSpan !== this.__colSpan ||
+ prevNode.__rowSpan !== this.__rowSpan ||
+ prevNode.__backgroundColor !== this.__backgroundColor
+ );
+ }
+
+ isShadowRoot(): boolean {
+ return true;
+ }
+
+ collapseAtStart(): true {
+ return true;
+ }
+
+ canBeEmpty(): false {
+ return false;
+ }
+
+ canIndent(): false {
+ return false;
+ }
+}
+
+export function $convertTableCellNodeElement(
+ domNode: Node,
+): DOMConversionOutput {
+ const domNode_ = domNode as HTMLTableCellElement;
+ const nodeName = domNode.nodeName.toLowerCase();
+
+ let width: number | undefined = undefined;
+
+ if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) {
+ width = parseFloat(domNode_.style.width);
+ }
+
+ const tableCellNode = $createTableCellNode(
+ nodeName === 'th'
+ ? TableCellHeaderStates.ROW
+ : TableCellHeaderStates.NO_STATUS,
+ domNode_.colSpan,
+ width,
+ );
+
+ tableCellNode.__rowSpan = domNode_.rowSpan;
+ const backgroundColor = domNode_.style.backgroundColor;
+ if (backgroundColor !== '') {
+ tableCellNode.__backgroundColor = backgroundColor;
+ }
+
+ const style = domNode_.style;
+ const textDecoration = style.textDecoration.split(' ');
+ const hasBoldFontWeight =
+ style.fontWeight === '700' || style.fontWeight === 'bold';
+ const hasLinethroughTextDecoration = textDecoration.includes('line-through');
+ const hasItalicFontStyle = style.fontStyle === 'italic';
+ const hasUnderlineTextDecoration = textDecoration.includes('underline');
+ return {
+ after: (childLexicalNodes) => {
+ if (childLexicalNodes.length === 0) {
+ childLexicalNodes.push($createParagraphNode());
+ }
+ return childLexicalNodes;
+ },
+ forChild: (lexicalNode, parentLexicalNode) => {
+ if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) {
+ const paragraphNode = $createParagraphNode();
+ if (
+ $isLineBreakNode(lexicalNode) &&
+ lexicalNode.getTextContent() === '\n'
+ ) {
+ return null;
+ }
+ if ($isTextNode(lexicalNode)) {
+ if (hasBoldFontWeight) {
+ lexicalNode.toggleFormat('bold');
+ }
+ if (hasLinethroughTextDecoration) {
+ lexicalNode.toggleFormat('strikethrough');
+ }
+ if (hasItalicFontStyle) {
+ lexicalNode.toggleFormat('italic');
+ }
+ if (hasUnderlineTextDecoration) {
+ lexicalNode.toggleFormat('underline');
+ }
+ }
+ paragraphNode.append(lexicalNode);
+ return paragraphNode;
+ }
+
+ return lexicalNode;
+ },
+ node: tableCellNode,
+ };
+}
+
+export function $createTableCellNode(
+ headerState: TableCellHeaderState,
+ colSpan = 1,
+ width?: number,
+): TableCellNode {
+ return $applyNodeReplacement(new TableCellNode(headerState, colSpan, width));
+}
+
+export function $isTableCellNode(
+ node: LexicalNode | null | undefined,
+): node is TableCellNode {
+ return node instanceof TableCellNode;
+}
--- /dev/null
+/**
+ * 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 {LexicalCommand} from 'lexical';
+
+import {createCommand} from 'lexical';
+
+export type InsertTableCommandPayloadHeaders =
+ | Readonly<{
+ rows: boolean;
+ columns: boolean;
+ }>
+ | boolean;
+
+export type InsertTableCommandPayload = Readonly<{
+ columns: string;
+ rows: string;
+ includeHeaders?: InsertTableCommandPayloadHeaders;
+}>;
+
+export const INSERT_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
+ createCommand('INSERT_TABLE_COMMAND');
--- /dev/null
+/**
+ * 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 {TableCellNode} from './LexicalTableCellNode';
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ SerializedElementNode,
+} from 'lexical';
+
+import {addClassNamesToElement, isHTMLElement} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ $getNearestNodeFromDOMNode,
+ ElementNode,
+} from 'lexical';
+
+import {$isTableCellNode} from './LexicalTableCellNode';
+import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
+import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode';
+import {getTable} from './LexicalTableSelectionHelpers';
+
+export type SerializedTableNode = SerializedElementNode;
+
+/** @noInheritDoc */
+export class TableNode extends ElementNode {
+ static getType(): string {
+ return 'table';
+ }
+
+ static clone(node: TableNode): TableNode {
+ return new TableNode(node.__key);
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ table: (_node: Node) => ({
+ conversion: $convertTableElement,
+ priority: 1,
+ }),
+ };
+ }
+
+ static importJSON(_serializedNode: SerializedTableNode): TableNode {
+ return $createTableNode();
+ }
+
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ exportJSON(): SerializedElementNode {
+ return {
+ ...super.exportJSON(),
+ type: 'table',
+ version: 1,
+ };
+ }
+
+ createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
+ const tableElement = document.createElement('table');
+
+ addClassNamesToElement(tableElement, config.theme.table);
+
+ return tableElement;
+ }
+
+ updateDOM(): boolean {
+ return false;
+ }
+
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ return {
+ ...super.exportDOM(editor),
+ after: (tableElement) => {
+ if (tableElement) {
+ const newElement = tableElement.cloneNode() as ParentNode;
+ const colGroup = document.createElement('colgroup');
+ const tBody = document.createElement('tbody');
+ if (isHTMLElement(tableElement)) {
+ tBody.append(...tableElement.children);
+ }
+ const firstRow = this.getFirstChildOrThrow<TableRowNode>();
+
+ if (!$isTableRowNode(firstRow)) {
+ throw new Error('Expected to find row node.');
+ }
+
+ const colCount = firstRow.getChildrenSize();
+
+ for (let i = 0; i < colCount; i++) {
+ const col = document.createElement('col');
+ colGroup.append(col);
+ }
+
+ newElement.replaceChildren(colGroup, tBody);
+
+ return newElement as HTMLElement;
+ }
+ },
+ };
+ }
+
+ canBeEmpty(): false {
+ return false;
+ }
+
+ isShadowRoot(): boolean {
+ return true;
+ }
+
+ getCordsFromCellNode(
+ tableCellNode: TableCellNode,
+ table: TableDOMTable,
+ ): {x: number; y: number} {
+ const {rows, domRows} = table;
+
+ for (let y = 0; y < rows; y++) {
+ const row = domRows[y];
+
+ if (row == null) {
+ continue;
+ }
+
+ const x = row.findIndex((cell) => {
+ if (!cell) {
+ return;
+ }
+ const {elem} = cell;
+ const cellNode = $getNearestNodeFromDOMNode(elem);
+ return cellNode === tableCellNode;
+ });
+
+ if (x !== -1) {
+ return {x, y};
+ }
+ }
+
+ throw new Error('Cell not found in table.');
+ }
+
+ getDOMCellFromCords(
+ x: number,
+ y: number,
+ table: TableDOMTable,
+ ): null | TableDOMCell {
+ const {domRows} = table;
+
+ const row = domRows[y];
+
+ if (row == null) {
+ return null;
+ }
+
+ const index = x < row.length ? x : row.length - 1;
+
+ const cell = row[index];
+
+ if (cell == null) {
+ return null;
+ }
+
+ return cell;
+ }
+
+ getDOMCellFromCordsOrThrow(
+ x: number,
+ y: number,
+ table: TableDOMTable,
+ ): TableDOMCell {
+ const cell = this.getDOMCellFromCords(x, y, table);
+
+ if (!cell) {
+ throw new Error('Cell not found at cords.');
+ }
+
+ return cell;
+ }
+
+ getCellNodeFromCords(
+ x: number,
+ y: number,
+ table: TableDOMTable,
+ ): null | TableCellNode {
+ const cell = this.getDOMCellFromCords(x, y, table);
+
+ if (cell == null) {
+ return null;
+ }
+
+ const node = $getNearestNodeFromDOMNode(cell.elem);
+
+ if ($isTableCellNode(node)) {
+ return node;
+ }
+
+ return null;
+ }
+
+ getCellNodeFromCordsOrThrow(
+ x: number,
+ y: number,
+ table: TableDOMTable,
+ ): TableCellNode {
+ const node = this.getCellNodeFromCords(x, y, table);
+
+ if (!node) {
+ throw new Error('Node at cords not TableCellNode.');
+ }
+
+ return node;
+ }
+
+ canSelectBefore(): true {
+ return true;
+ }
+
+ canIndent(): false {
+ return false;
+ }
+}
+
+export function $getElementForTableNode(
+ editor: LexicalEditor,
+ tableNode: TableNode,
+): TableDOMTable {
+ const tableElement = editor.getElementByKey(tableNode.getKey());
+
+ if (tableElement == null) {
+ throw new Error('Table Element Not Found');
+ }
+
+ return getTable(tableElement);
+}
+
+export function $convertTableElement(_domNode: Node): DOMConversionOutput {
+ return {node: $createTableNode()};
+}
+
+export function $createTableNode(): TableNode {
+ return $applyNodeReplacement(new TableNode());
+}
+
+export function $isTableNode(
+ node: LexicalNode | null | undefined,
+): node is TableNode {
+ return node instanceof TableNode;
+}
--- /dev/null
+/**
+ * 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, NodeKey, TextFormatType} from 'lexical';
+
+import {
+ addClassNamesToElement,
+ removeClassNamesFromElement,
+} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getNearestNodeFromDOMNode,
+ $getNodeByKey,
+ $getRoot,
+ $getSelection,
+ $isElementNode,
+ $setSelection,
+ SELECTION_CHANGE_COMMAND,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {$isTableCellNode} from './LexicalTableCellNode';
+import {$isTableNode} from './LexicalTableNode';
+import {
+ $createTableSelection,
+ $isTableSelection,
+ type TableSelection,
+} from './LexicalTableSelection';
+import {
+ $findTableNode,
+ $updateDOMForSelection,
+ getDOMSelection,
+ getTable,
+} from './LexicalTableSelectionHelpers';
+
+export type TableDOMCell = {
+ elem: HTMLElement;
+ highlighted: boolean;
+ hasBackgroundColor: boolean;
+ x: number;
+ y: number;
+};
+
+export type TableDOMRows = Array<Array<TableDOMCell | undefined> | undefined>;
+
+export type TableDOMTable = {
+ domRows: TableDOMRows;
+ columns: number;
+ rows: number;
+};
+
+export class TableObserver {
+ focusX: number;
+ focusY: number;
+ listenersToRemove: Set<() => void>;
+ table: TableDOMTable;
+ isHighlightingCells: boolean;
+ anchorX: number;
+ anchorY: number;
+ tableNodeKey: NodeKey;
+ anchorCell: TableDOMCell | null;
+ focusCell: TableDOMCell | null;
+ anchorCellNodeKey: NodeKey | null;
+ focusCellNodeKey: NodeKey | null;
+ editor: LexicalEditor;
+ tableSelection: TableSelection | null;
+ hasHijackedSelectionStyles: boolean;
+ isSelecting: boolean;
+
+ constructor(editor: LexicalEditor, tableNodeKey: string) {
+ this.isHighlightingCells = false;
+ this.anchorX = -1;
+ this.anchorY = -1;
+ this.focusX = -1;
+ this.focusY = -1;
+ this.listenersToRemove = new Set();
+ this.tableNodeKey = tableNodeKey;
+ this.editor = editor;
+ this.table = {
+ columns: 0,
+ domRows: [],
+ rows: 0,
+ };
+ this.tableSelection = null;
+ this.anchorCellNodeKey = null;
+ this.focusCellNodeKey = null;
+ this.anchorCell = null;
+ this.focusCell = null;
+ this.hasHijackedSelectionStyles = false;
+ this.trackTable();
+ this.isSelecting = false;
+ }
+
+ getTable(): TableDOMTable {
+ return this.table;
+ }
+
+ removeListeners() {
+ Array.from(this.listenersToRemove).forEach((removeListener) =>
+ removeListener(),
+ );
+ }
+
+ trackTable() {
+ const observer = new MutationObserver((records) => {
+ this.editor.update(() => {
+ let gridNeedsRedraw = false;
+
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i];
+ const target = record.target;
+ const nodeName = target.nodeName;
+
+ if (
+ nodeName === 'TABLE' ||
+ nodeName === 'TBODY' ||
+ nodeName === 'THEAD' ||
+ nodeName === 'TR'
+ ) {
+ gridNeedsRedraw = true;
+ break;
+ }
+ }
+
+ if (!gridNeedsRedraw) {
+ return;
+ }
+
+ const tableElement = this.editor.getElementByKey(this.tableNodeKey);
+
+ if (!tableElement) {
+ throw new Error('Expected to find TableElement in DOM');
+ }
+
+ this.table = getTable(tableElement);
+ });
+ });
+ this.editor.update(() => {
+ const tableElement = this.editor.getElementByKey(this.tableNodeKey);
+
+ if (!tableElement) {
+ throw new Error('Expected to find TableElement in DOM');
+ }
+
+ this.table = getTable(tableElement);
+ observer.observe(tableElement, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ });
+ }
+
+ clearHighlight() {
+ const editor = this.editor;
+ this.isHighlightingCells = false;
+ this.anchorX = -1;
+ this.anchorY = -1;
+ this.focusX = -1;
+ this.focusY = -1;
+ this.tableSelection = null;
+ this.anchorCellNodeKey = null;
+ this.focusCellNodeKey = null;
+ this.anchorCell = null;
+ this.focusCell = null;
+ this.hasHijackedSelectionStyles = false;
+
+ this.enableHighlightStyle();
+
+ editor.update(() => {
+ const tableNode = $getNodeByKey(this.tableNodeKey);
+
+ if (!$isTableNode(tableNode)) {
+ throw new Error('Expected TableNode.');
+ }
+
+ const tableElement = editor.getElementByKey(this.tableNodeKey);
+
+ if (!tableElement) {
+ throw new Error('Expected to find TableElement in DOM');
+ }
+
+ const grid = getTable(tableElement);
+ $updateDOMForSelection(editor, grid, null);
+ $setSelection(null);
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
+ });
+ }
+
+ enableHighlightStyle() {
+ const editor = this.editor;
+ editor.update(() => {
+ const tableElement = editor.getElementByKey(this.tableNodeKey);
+
+ if (!tableElement) {
+ throw new Error('Expected to find TableElement in DOM');
+ }
+
+ removeClassNamesFromElement(
+ tableElement,
+ editor._config.theme.tableSelection,
+ );
+ tableElement.classList.remove('disable-selection');
+ this.hasHijackedSelectionStyles = false;
+ });
+ }
+
+ disableHighlightStyle() {
+ const editor = this.editor;
+ editor.update(() => {
+ const tableElement = editor.getElementByKey(this.tableNodeKey);
+
+ if (!tableElement) {
+ throw new Error('Expected to find TableElement in DOM');
+ }
+
+ addClassNamesToElement(tableElement, editor._config.theme.tableSelection);
+ this.hasHijackedSelectionStyles = true;
+ });
+ }
+
+ updateTableTableSelection(selection: TableSelection | null): void {
+ if (selection !== null && selection.tableKey === this.tableNodeKey) {
+ const editor = this.editor;
+ this.tableSelection = selection;
+ this.isHighlightingCells = true;
+ this.disableHighlightStyle();
+ $updateDOMForSelection(editor, this.table, this.tableSelection);
+ } else if (selection == null) {
+ this.clearHighlight();
+ } else {
+ this.tableNodeKey = selection.tableKey;
+ this.updateTableTableSelection(selection);
+ }
+ }
+
+ setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) {
+ const editor = this.editor;
+ editor.update(() => {
+ const tableNode = $getNodeByKey(this.tableNodeKey);
+
+ if (!$isTableNode(tableNode)) {
+ throw new Error('Expected TableNode.');
+ }
+
+ const tableElement = editor.getElementByKey(this.tableNodeKey);
+
+ if (!tableElement) {
+ throw new Error('Expected to find TableElement in DOM');
+ }
+
+ const cellX = cell.x;
+ const cellY = cell.y;
+ this.focusCell = cell;
+
+ if (this.anchorCell !== null) {
+ const domSelection = getDOMSelection(editor._window);
+ // Collapse the selection
+ if (domSelection) {
+ domSelection.setBaseAndExtent(
+ this.anchorCell.elem,
+ 0,
+ this.focusCell.elem,
+ 0,
+ );
+ }
+ }
+
+ if (
+ !this.isHighlightingCells &&
+ (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart)
+ ) {
+ this.isHighlightingCells = true;
+ this.disableHighlightStyle();
+ } else if (cellX === this.focusX && cellY === this.focusY) {
+ return;
+ }
+
+ this.focusX = cellX;
+ this.focusY = cellY;
+
+ if (this.isHighlightingCells) {
+ const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
+
+ if (
+ this.tableSelection != null &&
+ this.anchorCellNodeKey != null &&
+ $isTableCellNode(focusTableCellNode) &&
+ tableNode.is($findTableNode(focusTableCellNode))
+ ) {
+ const focusNodeKey = focusTableCellNode.getKey();
+
+ this.tableSelection =
+ this.tableSelection.clone() || $createTableSelection();
+
+ this.focusCellNodeKey = focusNodeKey;
+ this.tableSelection.set(
+ this.tableNodeKey,
+ this.anchorCellNodeKey,
+ this.focusCellNodeKey,
+ );
+
+ $setSelection(this.tableSelection);
+
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
+
+ $updateDOMForSelection(editor, this.table, this.tableSelection);
+ }
+ }
+ });
+ }
+
+ setAnchorCellForSelection(cell: TableDOMCell) {
+ this.isHighlightingCells = false;
+ this.anchorCell = cell;
+ this.anchorX = cell.x;
+ this.anchorY = cell.y;
+
+ this.editor.update(() => {
+ const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
+
+ if ($isTableCellNode(anchorTableCellNode)) {
+ const anchorNodeKey = anchorTableCellNode.getKey();
+ this.tableSelection =
+ this.tableSelection != null
+ ? this.tableSelection.clone()
+ : $createTableSelection();
+ this.anchorCellNodeKey = anchorNodeKey;
+ }
+ });
+ }
+
+ formatCells(type: TextFormatType) {
+ this.editor.update(() => {
+ const selection = $getSelection();
+
+ if (!$isTableSelection(selection)) {
+ invariant(false, 'Expected grid selection');
+ }
+
+ const formatSelection = $createRangeSelection();
+
+ const anchor = formatSelection.anchor;
+ const focus = formatSelection.focus;
+
+ selection.getNodes().forEach((cellNode) => {
+ if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) {
+ anchor.set(cellNode.getKey(), 0, 'element');
+ focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element');
+ formatSelection.formatText(type);
+ }
+ });
+
+ $setSelection(selection);
+
+ this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
+ });
+ }
+
+ clearText() {
+ const editor = this.editor;
+ editor.update(() => {
+ const tableNode = $getNodeByKey(this.tableNodeKey);
+
+ if (!$isTableNode(tableNode)) {
+ throw new Error('Expected TableNode.');
+ }
+
+ const selection = $getSelection();
+
+ if (!$isTableSelection(selection)) {
+ invariant(false, 'Expected grid selection');
+ }
+
+ const selectedNodes = selection.getNodes().filter($isTableCellNode);
+
+ if (selectedNodes.length === this.table.columns * this.table.rows) {
+ tableNode.selectPrevious();
+ // Delete entire table
+ tableNode.remove();
+ const rootNode = $getRoot();
+ rootNode.selectStart();
+ return;
+ }
+
+ selectedNodes.forEach((cellNode) => {
+ if ($isElementNode(cellNode)) {
+ const paragraphNode = $createParagraphNode();
+ const textNode = $createTextNode();
+ paragraphNode.append(textNode);
+ cellNode.append(paragraphNode);
+ cellNode.getChildren().forEach((child) => {
+ if (child !== paragraphNode) {
+ child.remove();
+ }
+ });
+ }
+ });
+
+ $updateDOMForSelection(editor, this.table, null);
+
+ $setSelection(null);
+
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
+ });
+ }
+}
--- /dev/null
+/**
+ * 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 {Spread} from 'lexical';
+
+import {addClassNamesToElement} from '@lexical/utils';
+import {
+ $applyNodeReplacement,
+ DOMConversionMap,
+ DOMConversionOutput,
+ EditorConfig,
+ ElementNode,
+ LexicalNode,
+ NodeKey,
+ SerializedElementNode,
+} from 'lexical';
+
+import {PIXEL_VALUE_REG_EXP} from './constants';
+
+export type SerializedTableRowNode = Spread<
+ {
+ height?: number;
+ },
+ SerializedElementNode
+>;
+
+/** @noInheritDoc */
+export class TableRowNode extends ElementNode {
+ /** @internal */
+ __height?: number;
+
+ static getType(): string {
+ return 'tablerow';
+ }
+
+ static clone(node: TableRowNode): TableRowNode {
+ return new TableRowNode(node.__height, node.__key);
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ tr: (node: Node) => ({
+ conversion: $convertTableRowElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ static importJSON(serializedNode: SerializedTableRowNode): TableRowNode {
+ return $createTableRowNode(serializedNode.height);
+ }
+
+ constructor(height?: number, key?: NodeKey) {
+ super(key);
+ this.__height = height;
+ }
+
+ exportJSON(): SerializedTableRowNode {
+ return {
+ ...super.exportJSON(),
+ ...(this.getHeight() && {height: this.getHeight()}),
+ type: 'tablerow',
+ version: 1,
+ };
+ }
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const element = document.createElement('tr');
+
+ if (this.__height) {
+ element.style.height = `${this.__height}px`;
+ }
+
+ addClassNamesToElement(element, config.theme.tableRow);
+
+ return element;
+ }
+
+ isShadowRoot(): boolean {
+ return true;
+ }
+
+ setHeight(height: number): number | null | undefined {
+ const self = this.getWritable();
+ self.__height = height;
+ return this.__height;
+ }
+
+ getHeight(): number | undefined {
+ return this.getLatest().__height;
+ }
+
+ updateDOM(prevNode: TableRowNode): boolean {
+ return prevNode.__height !== this.__height;
+ }
+
+ canBeEmpty(): false {
+ return false;
+ }
+
+ canIndent(): false {
+ return false;
+ }
+}
+
+export function $convertTableRowElement(domNode: Node): DOMConversionOutput {
+ const domNode_ = domNode as HTMLTableCellElement;
+ let height: number | undefined = undefined;
+
+ if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) {
+ height = parseFloat(domNode_.style.height);
+ }
+
+ return {node: $createTableRowNode(height)};
+}
+
+export function $createTableRowNode(height?: number): TableRowNode {
+ return $applyNodeReplacement(new TableRowNode(height));
+}
+
+export function $isTableRowNode(
+ node: LexicalNode | null | undefined,
+): node is TableRowNode {
+ return node instanceof TableRowNode;
+}
--- /dev/null
+/**
+ * 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 {$findMatchingParent} from '@lexical/utils';
+import {
+ $createPoint,
+ $getNodeByKey,
+ $isElementNode,
+ $normalizeSelection__EXPERIMENTAL,
+ BaseSelection,
+ isCurrentlyReadOnlyMode,
+ LexicalNode,
+ NodeKey,
+ PointType,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
+import {$isTableNode} from './LexicalTableNode';
+import {$isTableRowNode} from './LexicalTableRowNode';
+import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils';
+
+export type TableSelectionShape = {
+ fromX: number;
+ fromY: number;
+ toX: number;
+ toY: number;
+};
+
+export type TableMapValueType = {
+ cell: TableCellNode;
+ startRow: number;
+ startColumn: number;
+};
+export type TableMapType = Array<Array<TableMapValueType>>;
+
+export class TableSelection implements BaseSelection {
+ tableKey: NodeKey;
+ anchor: PointType;
+ focus: PointType;
+ _cachedNodes: Array<LexicalNode> | null;
+ dirty: boolean;
+
+ constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) {
+ this.anchor = anchor;
+ this.focus = focus;
+ anchor._selection = this;
+ focus._selection = this;
+ this._cachedNodes = null;
+ this.dirty = false;
+ this.tableKey = tableKey;
+ }
+
+ getStartEndPoints(): [PointType, PointType] {
+ return [this.anchor, this.focus];
+ }
+
+ /**
+ * Returns whether the Selection is "backwards", meaning the focus
+ * logically precedes the anchor in the EditorState.
+ * @returns true if the Selection is backwards, false otherwise.
+ */
+ isBackward(): boolean {
+ return this.focus.isBefore(this.anchor);
+ }
+
+ getCachedNodes(): LexicalNode[] | null {
+ return this._cachedNodes;
+ }
+
+ setCachedNodes(nodes: LexicalNode[] | null): void {
+ this._cachedNodes = nodes;
+ }
+
+ is(selection: null | BaseSelection): boolean {
+ if (!$isTableSelection(selection)) {
+ return false;
+ }
+ return (
+ this.tableKey === selection.tableKey &&
+ this.anchor.is(selection.anchor) &&
+ this.focus.is(selection.focus)
+ );
+ }
+
+ set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void {
+ this.dirty = true;
+ this.tableKey = tableKey;
+ this.anchor.key = anchorCellKey;
+ this.focus.key = focusCellKey;
+ this._cachedNodes = null;
+ }
+
+ clone(): TableSelection {
+ return new TableSelection(this.tableKey, this.anchor, this.focus);
+ }
+
+ isCollapsed(): boolean {
+ return false;
+ }
+
+ extract(): Array<LexicalNode> {
+ return this.getNodes();
+ }
+
+ insertRawText(text: string): void {
+ // Do nothing?
+ }
+
+ insertText(): void {
+ // Do nothing?
+ }
+
+ insertNodes(nodes: Array<LexicalNode>) {
+ const focusNode = this.focus.getNode();
+ invariant(
+ $isElementNode(focusNode),
+ 'Expected TableSelection focus to be an ElementNode',
+ );
+ const selection = $normalizeSelection__EXPERIMENTAL(
+ focusNode.select(0, focusNode.getChildrenSize()),
+ );
+ selection.insertNodes(nodes);
+ }
+
+ // TODO Deprecate this method. It's confusing when used with colspan|rowspan
+ getShape(): TableSelectionShape {
+ const anchorCellNode = $getNodeByKey(this.anchor.key);
+ invariant(
+ $isTableCellNode(anchorCellNode),
+ 'Expected TableSelection anchor to be (or a child of) TableCellNode',
+ );
+ const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode);
+ invariant(
+ anchorCellNodeRect !== null,
+ 'getCellRect: expected to find AnchorNode',
+ );
+
+ const focusCellNode = $getNodeByKey(this.focus.key);
+ invariant(
+ $isTableCellNode(focusCellNode),
+ 'Expected TableSelection focus to be (or a child of) TableCellNode',
+ );
+ const focusCellNodeRect = $getTableCellNodeRect(focusCellNode);
+ invariant(
+ focusCellNodeRect !== null,
+ 'getCellRect: expected to find focusCellNode',
+ );
+
+ const startX = Math.min(
+ anchorCellNodeRect.columnIndex,
+ focusCellNodeRect.columnIndex,
+ );
+ const stopX = Math.max(
+ anchorCellNodeRect.columnIndex,
+ focusCellNodeRect.columnIndex,
+ );
+
+ const startY = Math.min(
+ anchorCellNodeRect.rowIndex,
+ focusCellNodeRect.rowIndex,
+ );
+ const stopY = Math.max(
+ anchorCellNodeRect.rowIndex,
+ focusCellNodeRect.rowIndex,
+ );
+
+ return {
+ fromX: Math.min(startX, stopX),
+ fromY: Math.min(startY, stopY),
+ toX: Math.max(startX, stopX),
+ toY: Math.max(startY, stopY),
+ };
+ }
+
+ getNodes(): Array<LexicalNode> {
+ const cachedNodes = this._cachedNodes;
+ if (cachedNodes !== null) {
+ return cachedNodes;
+ }
+
+ const anchorNode = this.anchor.getNode();
+ const focusNode = this.focus.getNode();
+ const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode);
+ // todo replace with triplet
+ const focusCell = $findMatchingParent(focusNode, $isTableCellNode);
+ invariant(
+ $isTableCellNode(anchorCell),
+ 'Expected TableSelection anchor to be (or a child of) TableCellNode',
+ );
+ invariant(
+ $isTableCellNode(focusCell),
+ 'Expected TableSelection focus to be (or a child of) TableCellNode',
+ );
+ const anchorRow = anchorCell.getParent();
+ invariant(
+ $isTableRowNode(anchorRow),
+ 'Expected anchorCell to have a parent TableRowNode',
+ );
+ const tableNode = anchorRow.getParent();
+ invariant(
+ $isTableNode(tableNode),
+ 'Expected tableNode to have a parent TableNode',
+ );
+
+ const focusCellGrid = focusCell.getParents()[1];
+ if (focusCellGrid !== tableNode) {
+ if (!tableNode.isParentOf(focusCell)) {
+ // focus is on higher Grid level than anchor
+ const gridParent = tableNode.getParent();
+ invariant(gridParent != null, 'Expected gridParent to have a parent');
+ this.set(this.tableKey, gridParent.getKey(), focusCell.getKey());
+ } else {
+ // anchor is on higher Grid level than focus
+ const focusCellParent = focusCellGrid.getParent();
+ invariant(
+ focusCellParent != null,
+ 'Expected focusCellParent to have a parent',
+ );
+ this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey());
+ }
+ return this.getNodes();
+ }
+
+ // TODO Mapping the whole Grid every time not efficient. We need to compute the entire state only
+ // once (on load) and iterate on it as updates occur. However, to do this we need to have the
+ // ability to store a state. Killing TableSelection and moving the logic to the plugin would make
+ // this possible.
+ const [map, cellAMap, cellBMap] = $computeTableMap(
+ tableNode,
+ anchorCell,
+ focusCell,
+ );
+
+ let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn);
+ let minRow = Math.min(cellAMap.startRow, cellBMap.startRow);
+ let maxColumn = Math.max(
+ cellAMap.startColumn + cellAMap.cell.__colSpan - 1,
+ cellBMap.startColumn + cellBMap.cell.__colSpan - 1,
+ );
+ let maxRow = Math.max(
+ cellAMap.startRow + cellAMap.cell.__rowSpan - 1,
+ cellBMap.startRow + cellBMap.cell.__rowSpan - 1,
+ );
+ let exploredMinColumn = minColumn;
+ let exploredMinRow = minRow;
+ let exploredMaxColumn = minColumn;
+ let exploredMaxRow = minRow;
+ function expandBoundary(mapValue: TableMapValueType): void {
+ const {
+ cell,
+ startColumn: cellStartColumn,
+ startRow: cellStartRow,
+ } = mapValue;
+ minColumn = Math.min(minColumn, cellStartColumn);
+ minRow = Math.min(minRow, cellStartRow);
+ maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1);
+ maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1);
+ }
+ while (
+ minColumn < exploredMinColumn ||
+ minRow < exploredMinRow ||
+ maxColumn > exploredMaxColumn ||
+ maxRow > exploredMaxRow
+ ) {
+ if (minColumn < exploredMinColumn) {
+ // Expand on the left
+ const rowDiff = exploredMaxRow - exploredMinRow;
+ const previousColumn = exploredMinColumn - 1;
+ for (let i = 0; i <= rowDiff; i++) {
+ expandBoundary(map[exploredMinRow + i][previousColumn]);
+ }
+ exploredMinColumn = previousColumn;
+ }
+ if (minRow < exploredMinRow) {
+ // Expand on top
+ const columnDiff = exploredMaxColumn - exploredMinColumn;
+ const previousRow = exploredMinRow - 1;
+ for (let i = 0; i <= columnDiff; i++) {
+ expandBoundary(map[previousRow][exploredMinColumn + i]);
+ }
+ exploredMinRow = previousRow;
+ }
+ if (maxColumn > exploredMaxColumn) {
+ // Expand on the right
+ const rowDiff = exploredMaxRow - exploredMinRow;
+ const nextColumn = exploredMaxColumn + 1;
+ for (let i = 0; i <= rowDiff; i++) {
+ expandBoundary(map[exploredMinRow + i][nextColumn]);
+ }
+ exploredMaxColumn = nextColumn;
+ }
+ if (maxRow > exploredMaxRow) {
+ // Expand on the bottom
+ const columnDiff = exploredMaxColumn - exploredMinColumn;
+ const nextRow = exploredMaxRow + 1;
+ for (let i = 0; i <= columnDiff; i++) {
+ expandBoundary(map[nextRow][exploredMinColumn + i]);
+ }
+ exploredMaxRow = nextRow;
+ }
+ }
+
+ const nodes: Array<LexicalNode> = [tableNode];
+ let lastRow = null;
+ for (let i = minRow; i <= maxRow; i++) {
+ for (let j = minColumn; j <= maxColumn; j++) {
+ const {cell} = map[i][j];
+ const currentRow = cell.getParent();
+ invariant(
+ $isTableRowNode(currentRow),
+ 'Expected TableCellNode parent to be a TableRowNode',
+ );
+ if (currentRow !== lastRow) {
+ nodes.push(currentRow);
+ }
+ nodes.push(cell, ...$getChildrenRecursively(cell));
+ lastRow = currentRow;
+ }
+ }
+
+ if (!isCurrentlyReadOnlyMode()) {
+ this._cachedNodes = nodes;
+ }
+ return nodes;
+ }
+
+ getTextContent(): string {
+ const nodes = this.getNodes().filter((node) => $isTableCellNode(node));
+ let textContent = '';
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ const row = node.__parent;
+ const nextRow = (nodes[i + 1] || {}).__parent;
+ textContent += node.getTextContent() + (nextRow !== row ? '\n' : '\t');
+ }
+ return textContent;
+ }
+}
+
+export function $isTableSelection(x: unknown): x is TableSelection {
+ return x instanceof TableSelection;
+}
+
+export function $createTableSelection(): TableSelection {
+ const anchor = $createPoint('root', 0, 'element');
+ const focus = $createPoint('root', 0, 'element');
+ return new TableSelection('root', anchor, focus);
+}
+
+export function $getChildrenRecursively(node: LexicalNode): Array<LexicalNode> {
+ const nodes = [];
+ const stack = [node];
+ while (stack.length > 0) {
+ const currentNode = stack.pop();
+ invariant(
+ currentNode !== undefined,
+ "Stack.length > 0; can't be undefined",
+ );
+ if ($isElementNode(currentNode)) {
+ stack.unshift(...currentNode.getChildren());
+ }
+ if (currentNode !== node) {
+ nodes.push(currentNode);
+ }
+ }
+ return nodes;
+}
--- /dev/null
+/**
+ * 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 {TableCellNode} from './LexicalTableCellNode';
+import type {TableNode} from './LexicalTableNode';
+import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver';
+import type {
+ TableMapType,
+ TableMapValueType,
+ TableSelection,
+} from './LexicalTableSelection';
+import type {
+ BaseSelection,
+ ElementFormatType,
+ LexicalCommand,
+ LexicalEditor,
+ LexicalNode,
+ RangeSelection,
+ TextFormatType,
+} from 'lexical';
+
+import {
+ $getClipboardDataFromSelection,
+ copyToClipboard,
+} from '@lexical/clipboard';
+import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createRangeSelectionFromDom,
+ $createTextNode,
+ $getNearestNodeFromDOMNode,
+ $getPreviousSelection,
+ $getSelection,
+ $isDecoratorNode,
+ $isElementNode,
+ $isRangeSelection,
+ $isRootOrShadowRoot,
+ $isTextNode,
+ $setSelection,
+ COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_HIGH,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ CUT_COMMAND,
+ DELETE_CHARACTER_COMMAND,
+ DELETE_LINE_COMMAND,
+ DELETE_WORD_COMMAND,
+ FOCUS_COMMAND,
+ FORMAT_ELEMENT_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INSERT_PARAGRAPH_COMMAND,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ KEY_TAB_COMMAND,
+ SELECTION_CHANGE_COMMAND,
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
+} from 'lexical';
+import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
+import invariant from 'lexical/shared/invariant';
+
+import {$isTableCellNode} from './LexicalTableCellNode';
+import {$isTableNode} from './LexicalTableNode';
+import {TableDOMTable, TableObserver} from './LexicalTableObserver';
+import {$isTableRowNode} from './LexicalTableRowNode';
+import {$isTableSelection} from './LexicalTableSelection';
+import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
+
+const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
+
+export const getDOMSelection = (
+ targetWindow: Window | null,
+): Selection | null =>
+ CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
+
+const isMouseDownOnEvent = (event: MouseEvent) => {
+ return (event.buttons & 1) === 1;
+};
+
+export function applyTableHandlers(
+ tableNode: TableNode,
+ tableElement: HTMLTableElementWithWithTableSelectionState,
+ editor: LexicalEditor,
+ hasTabHandler: boolean,
+): TableObserver {
+ const rootElement = editor.getRootElement();
+
+ if (rootElement === null) {
+ throw new Error('No root element.');
+ }
+
+ const tableObserver = new TableObserver(editor, tableNode.getKey());
+ const editorWindow = editor._window || window;
+
+ attachTableObserverToTableElement(tableElement, tableObserver);
+
+ const createMouseHandlers = () => {
+ const onMouseUp = () => {
+ tableObserver.isSelecting = false;
+ editorWindow.removeEventListener('mouseup', onMouseUp);
+ editorWindow.removeEventListener('mousemove', onMouseMove);
+ };
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
+ setTimeout(() => {
+ if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
+ tableObserver.isSelecting = false;
+ editorWindow.removeEventListener('mouseup', onMouseUp);
+ editorWindow.removeEventListener('mousemove', onMouseMove);
+ return;
+ }
+ const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
+ if (
+ focusCell !== null &&
+ (tableObserver.anchorX !== focusCell.x ||
+ tableObserver.anchorY !== focusCell.y)
+ ) {
+ moveEvent.preventDefault();
+ tableObserver.setFocusCellForSelection(focusCell);
+ }
+ }, 0);
+ };
+ return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
+ };
+
+ tableElement.addEventListener('mousedown', (event: MouseEvent) => {
+ setTimeout(() => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ if (!editorWindow) {
+ return;
+ }
+
+ const anchorCell = getDOMCellFromTarget(event.target as Node);
+ if (anchorCell !== null) {
+ stopEvent(event);
+ tableObserver.setAnchorCellForSelection(anchorCell);
+ }
+
+ const {onMouseUp, onMouseMove} = createMouseHandlers();
+ tableObserver.isSelecting = true;
+ editorWindow.addEventListener('mouseup', onMouseUp);
+ editorWindow.addEventListener('mousemove', onMouseMove);
+ }, 0);
+ });
+
+ // Clear selection when clicking outside of dom.
+ const mouseDownCallback = (event: MouseEvent) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ editor.update(() => {
+ const selection = $getSelection();
+ const target = event.target as Node;
+ if (
+ $isTableSelection(selection) &&
+ selection.tableKey === tableObserver.tableNodeKey &&
+ rootElement.contains(target)
+ ) {
+ tableObserver.clearHighlight();
+ }
+ });
+ };
+
+ editorWindow.addEventListener('mousedown', mouseDownCallback);
+
+ tableObserver.listenersToRemove.add(() =>
+ editorWindow.removeEventListener('mousedown', mouseDownCallback),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_DOWN_COMMAND,
+ (event) =>
+ $handleArrowKey(editor, event, 'down', tableNode, tableObserver),
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_UP_COMMAND,
+ (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver),
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_LEFT_COMMAND,
+ (event) =>
+ $handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ARROW_RIGHT_COMMAND,
+ (event) =>
+ $handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_ESCAPE_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if ($isTableSelection(selection)) {
+ const focusCellNode = $findMatchingParent(
+ selection.focus.getNode(),
+ $isTableCellNode,
+ );
+ if ($isTableCellNode(focusCellNode)) {
+ stopEvent(event);
+ focusCellNode.selectEnd();
+ return true;
+ }
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+
+ const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
+ const selection = $getSelection();
+
+ if (!$isSelectionInTable(selection, tableNode)) {
+ return false;
+ }
+
+ if ($isTableSelection(selection)) {
+ tableObserver.clearText();
+
+ return true;
+ } else if ($isRangeSelection(selection)) {
+ const tableCellNode = $findMatchingParent(
+ selection.anchor.getNode(),
+ (n) => $isTableCellNode(n),
+ );
+
+ if (!$isTableCellNode(tableCellNode)) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ const focusNode = selection.focus.getNode();
+ const isAnchorInside = tableNode.isParentOf(anchorNode);
+ const isFocusInside = tableNode.isParentOf(focusNode);
+
+ const selectionContainsPartialTable =
+ (isAnchorInside && !isFocusInside) ||
+ (isFocusInside && !isAnchorInside);
+
+ if (selectionContainsPartialTable) {
+ tableObserver.clearText();
+ return true;
+ }
+
+ const nearestElementNode = $findMatchingParent(
+ selection.anchor.getNode(),
+ (n) => $isElementNode(n),
+ );
+
+ const topLevelCellElementNode =
+ nearestElementNode &&
+ $findMatchingParent(
+ nearestElementNode,
+ (n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
+ );
+
+ if (
+ !$isElementNode(topLevelCellElementNode) ||
+ !$isElementNode(nearestElementNode)
+ ) {
+ return false;
+ }
+
+ if (
+ command === DELETE_LINE_COMMAND &&
+ topLevelCellElementNode.getPreviousSibling() === null
+ ) {
+ // TODO: Fix Delete Line in Table Cells.
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
+ (command) => {
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand(
+ command,
+ deleteTextHandler(command),
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+ },
+ );
+
+ const $deleteCellHandler = (
+ event: KeyboardEvent | ClipboardEvent | null,
+ ): boolean => {
+ const selection = $getSelection();
+
+ if (!$isSelectionInTable(selection, tableNode)) {
+ const nodes = selection ? selection.getNodes() : null;
+ if (nodes) {
+ const table = nodes.find(
+ (node) =>
+ $isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
+ );
+ if ($isTableNode(table)) {
+ const parentNode = table.getParent();
+ if (!parentNode) {
+ return false;
+ }
+ table.remove();
+ }
+ }
+ return false;
+ }
+
+ if ($isTableSelection(selection)) {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ tableObserver.clearText();
+
+ return true;
+ } else if ($isRangeSelection(selection)) {
+ const tableCellNode = $findMatchingParent(
+ selection.anchor.getNode(),
+ (n) => $isTableCellNode(n),
+ );
+
+ if (!$isTableCellNode(tableCellNode)) {
+ return false;
+ }
+ }
+
+ return false;
+ };
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_BACKSPACE_COMMAND,
+ $deleteCellHandler,
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_DELETE_COMMAND,
+ $deleteCellHandler,
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
+ CUT_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if (selection) {
+ if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
+ return false;
+ }
+ // Copying to the clipboard is async so we must capture the data
+ // before we delete it
+ void copyToClipboard(
+ editor,
+ objectKlassEquals(event, ClipboardEvent)
+ ? (event as ClipboardEvent)
+ : null,
+ $getClipboardDataFromSelection(selection),
+ );
+ const intercepted = $deleteCellHandler(event);
+ if ($isRangeSelection(selection)) {
+ selection.removeText();
+ }
+ return intercepted;
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<TextFormatType>(
+ FORMAT_TEXT_COMMAND,
+ (payload) => {
+ const selection = $getSelection();
+
+ if (!$isSelectionInTable(selection, tableNode)) {
+ return false;
+ }
+
+ if ($isTableSelection(selection)) {
+ tableObserver.formatCells(payload);
+
+ return true;
+ } else if ($isRangeSelection(selection)) {
+ const tableCellNode = $findMatchingParent(
+ selection.anchor.getNode(),
+ (n) => $isTableCellNode(n),
+ );
+
+ if (!$isTableCellNode(tableCellNode)) {
+ return false;
+ }
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<ElementFormatType>(
+ FORMAT_ELEMENT_COMMAND,
+ (formatType) => {
+ const selection = $getSelection();
+ if (
+ !$isTableSelection(selection) ||
+ !$isSelectionInTable(selection, tableNode)
+ ) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ const focusNode = selection.focus.getNode();
+ if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) {
+ return false;
+ }
+
+ const [tableMap, anchorCell, focusCell] = $computeTableMap(
+ tableNode,
+ anchorNode,
+ focusNode,
+ );
+ const maxRow = Math.max(anchorCell.startRow, focusCell.startRow);
+ const maxColumn = Math.max(
+ anchorCell.startColumn,
+ focusCell.startColumn,
+ );
+ const minRow = Math.min(anchorCell.startRow, focusCell.startRow);
+ const minColumn = Math.min(
+ anchorCell.startColumn,
+ focusCell.startColumn,
+ );
+ for (let i = minRow; i <= maxRow; i++) {
+ for (let j = minColumn; j <= maxColumn; j++) {
+ const cell = tableMap[i][j].cell;
+ cell.setFormat(formatType);
+
+ const cellChildren = cell.getChildren();
+ for (let k = 0; k < cellChildren.length; k++) {
+ const child = cellChildren[k];
+ if ($isElementNode(child) && !child.isInline()) {
+ child.setFormat(formatType);
+ }
+ }
+ }
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand(
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ (payload) => {
+ const selection = $getSelection();
+
+ if (!$isSelectionInTable(selection, tableNode)) {
+ return false;
+ }
+
+ if ($isTableSelection(selection)) {
+ tableObserver.clearHighlight();
+
+ return false;
+ } else if ($isRangeSelection(selection)) {
+ const tableCellNode = $findMatchingParent(
+ selection.anchor.getNode(),
+ (n) => $isTableCellNode(n),
+ );
+
+ if (!$isTableCellNode(tableCellNode)) {
+ return false;
+ }
+
+ if (typeof payload === 'string') {
+ const edgePosition = $getTableEdgeCursorPosition(
+ editor,
+ selection,
+ tableNode,
+ );
+ if (edgePosition) {
+ $insertParagraphAtTableEdge(edgePosition, tableNode, [
+ $createTextNode(payload),
+ ]);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ if (hasTabHandler) {
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand<KeyboardEvent>(
+ KEY_TAB_COMMAND,
+ (event) => {
+ const selection = $getSelection();
+ if (
+ !$isRangeSelection(selection) ||
+ !selection.isCollapsed() ||
+ !$isSelectionInTable(selection, tableNode)
+ ) {
+ return false;
+ }
+
+ const tableCellNode = $findCellNode(selection.anchor.getNode());
+ if (tableCellNode === null) {
+ return false;
+ }
+
+ stopEvent(event);
+
+ const currentCords = tableNode.getCordsFromCellNode(
+ tableCellNode,
+ tableObserver.table,
+ );
+
+ selectTableNodeInDirection(
+ tableObserver,
+ tableNode,
+ currentCords.x,
+ currentCords.y,
+ !event.shiftKey ? 'forward' : 'backward',
+ );
+
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+ }
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand(
+ FOCUS_COMMAND,
+ (payload) => {
+ return tableNode.isSelected();
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+
+ function getObserverCellFromCellNode(
+ tableCellNode: TableCellNode,
+ ): TableDOMCell {
+ const currentCords = tableNode.getCordsFromCellNode(
+ tableCellNode,
+ tableObserver.table,
+ );
+ return tableNode.getDOMCellFromCordsOrThrow(
+ currentCords.x,
+ currentCords.y,
+ tableObserver.table,
+ );
+ }
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand(
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
+ (selectionPayload) => {
+ const {nodes, selection} = selectionPayload;
+ const anchorAndFocus = selection.getStartEndPoints();
+ const isTableSelection = $isTableSelection(selection);
+ const isRangeSelection = $isRangeSelection(selection);
+ const isSelectionInsideOfGrid =
+ (isRangeSelection &&
+ $findMatchingParent(selection.anchor.getNode(), (n) =>
+ $isTableCellNode(n),
+ ) !== null &&
+ $findMatchingParent(selection.focus.getNode(), (n) =>
+ $isTableCellNode(n),
+ ) !== null) ||
+ isTableSelection;
+
+ if (
+ nodes.length !== 1 ||
+ !$isTableNode(nodes[0]) ||
+ !isSelectionInsideOfGrid ||
+ anchorAndFocus === null
+ ) {
+ return false;
+ }
+ const [anchor] = anchorAndFocus;
+
+ const newGrid = nodes[0];
+ const newGridRows = newGrid.getChildren();
+ const newColumnCount = newGrid
+ .getFirstChildOrThrow<TableNode>()
+ .getChildrenSize();
+ const newRowCount = newGrid.getChildrenSize();
+ const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
+ $isTableCellNode(n),
+ );
+ const gridRowNode =
+ gridCellNode &&
+ $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
+ const gridNode =
+ gridRowNode &&
+ $findMatchingParent(gridRowNode, (n) => $isTableNode(n));
+
+ if (
+ !$isTableCellNode(gridCellNode) ||
+ !$isTableRowNode(gridRowNode) ||
+ !$isTableNode(gridNode)
+ ) {
+ return false;
+ }
+
+ const startY = gridRowNode.getIndexWithinParent();
+ const stopY = Math.min(
+ gridNode.getChildrenSize() - 1,
+ startY + newRowCount - 1,
+ );
+ const startX = gridCellNode.getIndexWithinParent();
+ const stopX = Math.min(
+ gridRowNode.getChildrenSize() - 1,
+ startX + newColumnCount - 1,
+ );
+ const fromX = Math.min(startX, stopX);
+ const fromY = Math.min(startY, stopY);
+ const toX = Math.max(startX, stopX);
+ const toY = Math.max(startY, stopY);
+ const gridRowNodes = gridNode.getChildren();
+ let newRowIdx = 0;
+
+ for (let r = fromY; r <= toY; r++) {
+ const currentGridRowNode = gridRowNodes[r];
+
+ if (!$isTableRowNode(currentGridRowNode)) {
+ return false;
+ }
+
+ const newGridRowNode = newGridRows[newRowIdx];
+
+ if (!$isTableRowNode(newGridRowNode)) {
+ return false;
+ }
+
+ const gridCellNodes = currentGridRowNode.getChildren();
+ const newGridCellNodes = newGridRowNode.getChildren();
+ let newColumnIdx = 0;
+
+ for (let c = fromX; c <= toX; c++) {
+ const currentGridCellNode = gridCellNodes[c];
+
+ if (!$isTableCellNode(currentGridCellNode)) {
+ return false;
+ }
+
+ const newGridCellNode = newGridCellNodes[newColumnIdx];
+
+ if (!$isTableCellNode(newGridCellNode)) {
+ return false;
+ }
+
+ const originalChildren = currentGridCellNode.getChildren();
+ newGridCellNode.getChildren().forEach((child) => {
+ if ($isTextNode(child)) {
+ const paragraphNode = $createParagraphNode();
+ paragraphNode.append(child);
+ currentGridCellNode.append(child);
+ } else {
+ currentGridCellNode.append(child);
+ }
+ });
+ originalChildren.forEach((n) => n.remove());
+ newColumnIdx++;
+ }
+
+ newRowIdx++;
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ () => {
+ const selection = $getSelection();
+ const prevSelection = $getPreviousSelection();
+
+ if ($isRangeSelection(selection)) {
+ const {anchor, focus} = selection;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+ // Using explicit comparison with table node to ensure it's not a nested table
+ // as in that case we'll leave selection resolving to that table
+ const anchorCellNode = $findCellNode(anchorNode);
+ const focusCellNode = $findCellNode(focusNode);
+ const isAnchorInside = !!(
+ anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
+ );
+ const isFocusInside = !!(
+ focusCellNode && tableNode.is($findTableNode(focusCellNode))
+ );
+ const isPartialyWithinTable = isAnchorInside !== isFocusInside;
+ const isWithinTable = isAnchorInside && isFocusInside;
+ const isBackward = selection.isBackward();
+
+ if (isPartialyWithinTable) {
+ const newSelection = selection.clone();
+ if (isFocusInside) {
+ const [tableMap] = $computeTableMap(
+ tableNode,
+ focusCellNode,
+ focusCellNode,
+ );
+ const firstCell = tableMap[0][0].cell;
+ const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
+ newSelection.focus.set(
+ isBackward ? firstCell.getKey() : lastCell.getKey(),
+ isBackward
+ ? firstCell.getChildrenSize()
+ : lastCell.getChildrenSize(),
+ 'element',
+ );
+ }
+ $setSelection(newSelection);
+ $addHighlightStyleToTable(editor, tableObserver);
+ } else if (isWithinTable) {
+ // Handle case when selection spans across multiple cells but still
+ // has range selection, then we convert it into grid selection
+ if (!anchorCellNode.is(focusCellNode)) {
+ tableObserver.setAnchorCellForSelection(
+ getObserverCellFromCellNode(anchorCellNode),
+ );
+ tableObserver.setFocusCellForSelection(
+ getObserverCellFromCellNode(focusCellNode),
+ true,
+ );
+ if (!tableObserver.isSelecting) {
+ setTimeout(() => {
+ const {onMouseUp, onMouseMove} = createMouseHandlers();
+ tableObserver.isSelecting = true;
+ editorWindow.addEventListener('mouseup', onMouseUp);
+ editorWindow.addEventListener('mousemove', onMouseMove);
+ }, 0);
+ }
+ }
+ }
+ } else if (
+ selection &&
+ $isTableSelection(selection) &&
+ selection.is(prevSelection) &&
+ selection.tableKey === tableNode.getKey()
+ ) {
+ // if selection goes outside of the table we need to change it to Range selection
+ const domSelection = getDOMSelection(editor._window);
+ if (
+ domSelection &&
+ domSelection.anchorNode &&
+ domSelection.focusNode
+ ) {
+ const focusNode = $getNearestNodeFromDOMNode(
+ domSelection.focusNode,
+ );
+ const isFocusOutside =
+ focusNode && !tableNode.is($findTableNode(focusNode));
+
+ const anchorNode = $getNearestNodeFromDOMNode(
+ domSelection.anchorNode,
+ );
+ const isAnchorInside =
+ anchorNode && tableNode.is($findTableNode(anchorNode));
+
+ if (
+ isFocusOutside &&
+ isAnchorInside &&
+ domSelection.rangeCount > 0
+ ) {
+ const newSelection = $createRangeSelectionFromDom(
+ domSelection,
+ editor,
+ );
+ if (newSelection) {
+ newSelection.anchor.set(
+ tableNode.getKey(),
+ selection.isBackward() ? tableNode.getChildrenSize() : 0,
+ 'element',
+ );
+ domSelection.removeAllRanges();
+ $setSelection(newSelection);
+ }
+ }
+ }
+ }
+
+ if (
+ selection &&
+ !selection.is(prevSelection) &&
+ ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
+ tableObserver.tableSelection &&
+ !tableObserver.tableSelection.is(prevSelection)
+ ) {
+ if (
+ $isTableSelection(selection) &&
+ selection.tableKey === tableObserver.tableNodeKey
+ ) {
+ tableObserver.updateTableTableSelection(selection);
+ } else if (
+ !$isTableSelection(selection) &&
+ $isTableSelection(prevSelection) &&
+ prevSelection.tableKey === tableObserver.tableNodeKey
+ ) {
+ tableObserver.updateTableTableSelection(null);
+ }
+ return false;
+ }
+
+ if (
+ tableObserver.hasHijackedSelectionStyles &&
+ !tableNode.isSelected()
+ ) {
+ $removeHighlightStyleToTable(editor, tableObserver);
+ } else if (
+ !tableObserver.hasHijackedSelectionStyles &&
+ tableNode.isSelected()
+ ) {
+ $addHighlightStyleToTable(editor, tableObserver);
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ tableObserver.listenersToRemove.add(
+ editor.registerCommand(
+ INSERT_PARAGRAPH_COMMAND,
+ () => {
+ const selection = $getSelection();
+ if (
+ !$isRangeSelection(selection) ||
+ !selection.isCollapsed() ||
+ !$isSelectionInTable(selection, tableNode)
+ ) {
+ return false;
+ }
+ const edgePosition = $getTableEdgeCursorPosition(
+ editor,
+ selection,
+ tableNode,
+ );
+ if (edgePosition) {
+ $insertParagraphAtTableEdge(edgePosition, tableNode);
+ return true;
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+
+ return tableObserver;
+}
+
+export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
+ Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
+
+export function attachTableObserverToTableElement(
+ tableElement: HTMLTableElementWithWithTableSelectionState,
+ tableObserver: TableObserver,
+) {
+ tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
+}
+
+export function getTableObserverFromTableElement(
+ tableElement: HTMLTableElementWithWithTableSelectionState,
+): TableObserver | null {
+ return tableElement[LEXICAL_ELEMENT_KEY];
+}
+
+export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
+ let currentNode: ParentNode | Node | null = node;
+
+ while (currentNode != null) {
+ const nodeName = currentNode.nodeName;
+
+ if (nodeName === 'TD' || nodeName === 'TH') {
+ // @ts-expect-error: internal field
+ const cell = currentNode._cell;
+
+ if (cell === undefined) {
+ return null;
+ }
+
+ return cell;
+ }
+
+ currentNode = currentNode.parentNode;
+ }
+
+ return null;
+}
+
+export function doesTargetContainText(node: Node): boolean {
+ const currentNode: ParentNode | Node | null = node;
+
+ if (currentNode !== null) {
+ const nodeName = currentNode.nodeName;
+
+ if (nodeName === 'SPAN') {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function getTable(tableElement: HTMLElement): TableDOMTable {
+ const domRows: TableDOMRows = [];
+ const grid = {
+ columns: 0,
+ domRows,
+ rows: 0,
+ };
+ let currentNode = tableElement.firstChild;
+ let x = 0;
+ let y = 0;
+ domRows.length = 0;
+
+ while (currentNode != null) {
+ const nodeMame = currentNode.nodeName;
+
+ if (nodeMame === 'TD' || nodeMame === 'TH') {
+ const elem = currentNode as HTMLElement;
+ const cell = {
+ elem,
+ hasBackgroundColor: elem.style.backgroundColor !== '',
+ highlighted: false,
+ x,
+ y,
+ };
+
+ // @ts-expect-error: internal field
+ currentNode._cell = cell;
+
+ let row = domRows[y];
+ if (row === undefined) {
+ row = domRows[y] = [];
+ }
+
+ row[x] = cell;
+ } else {
+ const child = currentNode.firstChild;
+
+ if (child != null) {
+ currentNode = child;
+ continue;
+ }
+ }
+
+ const sibling = currentNode.nextSibling;
+
+ if (sibling != null) {
+ x++;
+ currentNode = sibling;
+ continue;
+ }
+
+ const parent = currentNode.parentNode;
+
+ if (parent != null) {
+ const parentSibling = parent.nextSibling;
+
+ if (parentSibling == null) {
+ break;
+ }
+
+ y++;
+ x = 0;
+ currentNode = parentSibling;
+ }
+ }
+
+ grid.columns = x + 1;
+ grid.rows = y + 1;
+
+ return grid;
+}
+
+export function $updateDOMForSelection(
+ editor: LexicalEditor,
+ table: TableDOMTable,
+ selection: TableSelection | RangeSelection | null,
+) {
+ const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
+ $forEachTableCell(table, (cell, lexicalNode) => {
+ const elem = cell.elem;
+
+ if (selectedCellNodes.has(lexicalNode)) {
+ cell.highlighted = true;
+ $addHighlightToDOM(editor, cell);
+ } else {
+ cell.highlighted = false;
+ $removeHighlightFromDOM(editor, cell);
+ if (!elem.getAttribute('style')) {
+ elem.removeAttribute('style');
+ }
+ }
+ });
+}
+
+export function $forEachTableCell(
+ grid: TableDOMTable,
+ cb: (
+ cell: TableDOMCell,
+ lexicalNode: LexicalNode,
+ cords: {
+ x: number;
+ y: number;
+ },
+ ) => void,
+) {
+ const {domRows} = grid;
+
+ for (let y = 0; y < domRows.length; y++) {
+ const row = domRows[y];
+ if (!row) {
+ continue;
+ }
+
+ for (let x = 0; x < row.length; x++) {
+ const cell = row[x];
+ if (!cell) {
+ continue;
+ }
+ const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
+
+ if (lexicalNode !== null) {
+ cb(cell, lexicalNode, {
+ x,
+ y,
+ });
+ }
+ }
+ }
+}
+
+export function $addHighlightStyleToTable(
+ editor: LexicalEditor,
+ tableSelection: TableObserver,
+) {
+ tableSelection.disableHighlightStyle();
+ $forEachTableCell(tableSelection.table, (cell) => {
+ cell.highlighted = true;
+ $addHighlightToDOM(editor, cell);
+ });
+}
+
+export function $removeHighlightStyleToTable(
+ editor: LexicalEditor,
+ tableObserver: TableObserver,
+) {
+ tableObserver.enableHighlightStyle();
+ $forEachTableCell(tableObserver.table, (cell) => {
+ const elem = cell.elem;
+ cell.highlighted = false;
+ $removeHighlightFromDOM(editor, cell);
+
+ if (!elem.getAttribute('style')) {
+ elem.removeAttribute('style');
+ }
+ });
+}
+
+type Direction = 'backward' | 'forward' | 'up' | 'down';
+
+const selectTableNodeInDirection = (
+ tableObserver: TableObserver,
+ tableNode: TableNode,
+ x: number,
+ y: number,
+ direction: Direction,
+): boolean => {
+ const isForward = direction === 'forward';
+
+ switch (direction) {
+ case 'backward':
+ case 'forward':
+ if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
+ selectTableCellNode(
+ tableNode.getCellNodeFromCordsOrThrow(
+ x + (isForward ? 1 : -1),
+ y,
+ tableObserver.table,
+ ),
+ isForward,
+ );
+ } else {
+ if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
+ selectTableCellNode(
+ tableNode.getCellNodeFromCordsOrThrow(
+ isForward ? 0 : tableObserver.table.columns - 1,
+ y + (isForward ? 1 : -1),
+ tableObserver.table,
+ ),
+ isForward,
+ );
+ } else if (!isForward) {
+ tableNode.selectPrevious();
+ } else {
+ tableNode.selectNext();
+ }
+ }
+
+ return true;
+
+ case 'up':
+ if (y !== 0) {
+ selectTableCellNode(
+ tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
+ false,
+ );
+ } else {
+ tableNode.selectPrevious();
+ }
+
+ return true;
+
+ case 'down':
+ if (y !== tableObserver.table.rows - 1) {
+ selectTableCellNode(
+ tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
+ true,
+ );
+ } else {
+ tableNode.selectNext();
+ }
+
+ return true;
+ default:
+ return false;
+ }
+};
+
+const adjustFocusNodeInDirection = (
+ tableObserver: TableObserver,
+ tableNode: TableNode,
+ x: number,
+ y: number,
+ direction: Direction,
+): boolean => {
+ const isForward = direction === 'forward';
+
+ switch (direction) {
+ case 'backward':
+ case 'forward':
+ if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
+ tableObserver.setFocusCellForSelection(
+ tableNode.getDOMCellFromCordsOrThrow(
+ x + (isForward ? 1 : -1),
+ y,
+ tableObserver.table,
+ ),
+ );
+ }
+
+ return true;
+ case 'up':
+ if (y !== 0) {
+ tableObserver.setFocusCellForSelection(
+ tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
+ );
+
+ return true;
+ } else {
+ return false;
+ }
+ case 'down':
+ if (y !== tableObserver.table.rows - 1) {
+ tableObserver.setFocusCellForSelection(
+ tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
+ );
+
+ return true;
+ } else {
+ return false;
+ }
+ default:
+ return false;
+ }
+};
+
+function $isSelectionInTable(
+ selection: null | BaseSelection,
+ tableNode: TableNode,
+): boolean {
+ if ($isRangeSelection(selection) || $isTableSelection(selection)) {
+ const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
+ const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
+
+ return isAnchorInside && isFocusInside;
+ }
+
+ return false;
+}
+
+function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
+ if (fromStart) {
+ tableCell.selectStart();
+ } else {
+ tableCell.selectEnd();
+ }
+}
+
+const BROWSER_BLUE_RGB = '172,206,247';
+function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
+ const element = cell.elem;
+ const node = $getNearestNodeFromDOMNode(element);
+ invariant(
+ $isTableCellNode(node),
+ 'Expected to find LexicalNode from Table Cell DOMNode',
+ );
+ const backgroundColor = node.getBackgroundColor();
+ if (backgroundColor === null) {
+ element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
+ } else {
+ element.style.setProperty(
+ 'background-image',
+ `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
+ );
+ }
+ element.style.setProperty('caret-color', 'transparent');
+}
+
+function $removeHighlightFromDOM(
+ editor: LexicalEditor,
+ cell: TableDOMCell,
+): void {
+ const element = cell.elem;
+ const node = $getNearestNodeFromDOMNode(element);
+ invariant(
+ $isTableCellNode(node),
+ 'Expected to find LexicalNode from Table Cell DOMNode',
+ );
+ const backgroundColor = node.getBackgroundColor();
+ if (backgroundColor === null) {
+ element.style.removeProperty('background-color');
+ }
+ element.style.removeProperty('background-image');
+ element.style.removeProperty('caret-color');
+}
+
+export function $findCellNode(node: LexicalNode): null | TableCellNode {
+ const cellNode = $findMatchingParent(node, $isTableCellNode);
+ return $isTableCellNode(cellNode) ? cellNode : null;
+}
+
+export function $findTableNode(node: LexicalNode): null | TableNode {
+ const tableNode = $findMatchingParent(node, $isTableNode);
+ return $isTableNode(tableNode) ? tableNode : null;
+}
+
+function $handleArrowKey(
+ editor: LexicalEditor,
+ event: KeyboardEvent,
+ direction: Direction,
+ tableNode: TableNode,
+ tableObserver: TableObserver,
+): boolean {
+ if (
+ (direction === 'up' || direction === 'down') &&
+ isTypeaheadMenuInView(editor)
+ ) {
+ return false;
+ }
+
+ const selection = $getSelection();
+
+ if (!$isSelectionInTable(selection, tableNode)) {
+ if ($isRangeSelection(selection)) {
+ if (selection.isCollapsed() && direction === 'backward') {
+ const anchorType = selection.anchor.type;
+ const anchorOffset = selection.anchor.offset;
+ if (
+ anchorType !== 'element' &&
+ !(anchorType === 'text' && anchorOffset === 0)
+ ) {
+ return false;
+ }
+ const anchorNode = selection.anchor.getNode();
+ if (!anchorNode) {
+ return false;
+ }
+ const parentNode = $findMatchingParent(
+ anchorNode,
+ (n) => $isElementNode(n) && !n.isInline(),
+ );
+ if (!parentNode) {
+ return false;
+ }
+ const siblingNode = parentNode.getPreviousSibling();
+ if (!siblingNode || !$isTableNode(siblingNode)) {
+ return false;
+ }
+ stopEvent(event);
+ siblingNode.selectEnd();
+ return true;
+ } else if (
+ event.shiftKey &&
+ (direction === 'up' || direction === 'down')
+ ) {
+ const focusNode = selection.focus.getNode();
+ if ($isRootOrShadowRoot(focusNode)) {
+ const selectedNode = selection.getNodes()[0];
+ if (selectedNode) {
+ const tableCellNode = $findMatchingParent(
+ selectedNode,
+ $isTableCellNode,
+ );
+ if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
+ const firstDescendant = tableNode.getFirstDescendant();
+ const lastDescendant = tableNode.getLastDescendant();
+ if (!firstDescendant || !lastDescendant) {
+ return false;
+ }
+ const [firstCellNode] = $getNodeTriplet(firstDescendant);
+ const [lastCellNode] = $getNodeTriplet(lastDescendant);
+ const firstCellCoords = tableNode.getCordsFromCellNode(
+ firstCellNode,
+ tableObserver.table,
+ );
+ const lastCellCoords = tableNode.getCordsFromCellNode(
+ lastCellNode,
+ tableObserver.table,
+ );
+ const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
+ firstCellCoords.x,
+ firstCellCoords.y,
+ tableObserver.table,
+ );
+ const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
+ lastCellCoords.x,
+ lastCellCoords.y,
+ tableObserver.table,
+ );
+ tableObserver.setAnchorCellForSelection(firstCellDOM);
+ tableObserver.setFocusCellForSelection(lastCellDOM, true);
+ return true;
+ }
+ }
+ return false;
+ } else {
+ const focusParentNode = $findMatchingParent(
+ focusNode,
+ (n) => $isElementNode(n) && !n.isInline(),
+ );
+ if (!focusParentNode) {
+ return false;
+ }
+ const sibling =
+ direction === 'down'
+ ? focusParentNode.getNextSibling()
+ : focusParentNode.getPreviousSibling();
+ if (
+ $isTableNode(sibling) &&
+ tableObserver.tableNodeKey === sibling.getKey()
+ ) {
+ const firstDescendant = sibling.getFirstDescendant();
+ const lastDescendant = sibling.getLastDescendant();
+ if (!firstDescendant || !lastDescendant) {
+ return false;
+ }
+ const [firstCellNode] = $getNodeTriplet(firstDescendant);
+ const [lastCellNode] = $getNodeTriplet(lastDescendant);
+ const newSelection = selection.clone();
+ newSelection.focus.set(
+ (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
+ direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
+ 'element',
+ );
+ $setSelection(newSelection);
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
+ const {anchor, focus} = selection;
+ const anchorCellNode = $findMatchingParent(
+ anchor.getNode(),
+ $isTableCellNode,
+ );
+ const focusCellNode = $findMatchingParent(
+ focus.getNode(),
+ $isTableCellNode,
+ );
+ if (
+ !$isTableCellNode(anchorCellNode) ||
+ !anchorCellNode.is(focusCellNode)
+ ) {
+ return false;
+ }
+ const anchorCellTable = $findTableNode(anchorCellNode);
+ if (anchorCellTable !== tableNode && anchorCellTable != null) {
+ const anchorCellTableElement = editor.getElementByKey(
+ anchorCellTable.getKey(),
+ );
+ if (anchorCellTableElement != null) {
+ tableObserver.table = getTable(anchorCellTableElement);
+ return $handleArrowKey(
+ editor,
+ event,
+ direction,
+ anchorCellTable,
+ tableObserver,
+ );
+ }
+ }
+
+ if (direction === 'backward' || direction === 'forward') {
+ const anchorType = anchor.type;
+ const anchorOffset = anchor.offset;
+ const anchorNode = anchor.getNode();
+ if (!anchorNode) {
+ return false;
+ }
+
+ const selectedNodes = selection.getNodes();
+ if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
+ return false;
+ }
+
+ if (
+ isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
+ ) {
+ return $handleTableExit(event, anchorNode, tableNode, direction);
+ }
+
+ return false;
+ }
+
+ const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
+ const anchorDOM = editor.getElementByKey(anchor.key);
+ if (anchorDOM == null || anchorCellDom == null) {
+ return false;
+ }
+
+ let edgeSelectionRect;
+ if (anchor.type === 'element') {
+ edgeSelectionRect = anchorDOM.getBoundingClientRect();
+ } else {
+ const domSelection = window.getSelection();
+ if (domSelection === null || domSelection.rangeCount === 0) {
+ return false;
+ }
+
+ const range = domSelection.getRangeAt(0);
+ edgeSelectionRect = range.getBoundingClientRect();
+ }
+
+ const edgeChild =
+ direction === 'up'
+ ? anchorCellNode.getFirstChild()
+ : anchorCellNode.getLastChild();
+ if (edgeChild == null) {
+ return false;
+ }
+
+ const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
+
+ if (edgeChildDOM == null) {
+ return false;
+ }
+
+ const edgeRect = edgeChildDOM.getBoundingClientRect();
+ const isExiting =
+ direction === 'up'
+ ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
+ : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
+
+ if (isExiting) {
+ stopEvent(event);
+
+ const cords = tableNode.getCordsFromCellNode(
+ anchorCellNode,
+ tableObserver.table,
+ );
+
+ if (event.shiftKey) {
+ const cell = tableNode.getDOMCellFromCordsOrThrow(
+ cords.x,
+ cords.y,
+ tableObserver.table,
+ );
+ tableObserver.setAnchorCellForSelection(cell);
+ tableObserver.setFocusCellForSelection(cell, true);
+ } else {
+ return selectTableNodeInDirection(
+ tableObserver,
+ tableNode,
+ cords.x,
+ cords.y,
+ direction,
+ );
+ }
+
+ return true;
+ }
+ } else if ($isTableSelection(selection)) {
+ const {anchor, focus} = selection;
+ const anchorCellNode = $findMatchingParent(
+ anchor.getNode(),
+ $isTableCellNode,
+ );
+ const focusCellNode = $findMatchingParent(
+ focus.getNode(),
+ $isTableCellNode,
+ );
+
+ const [tableNodeFromSelection] = selection.getNodes();
+ const tableElement = editor.getElementByKey(
+ tableNodeFromSelection.getKey(),
+ );
+ if (
+ !$isTableCellNode(anchorCellNode) ||
+ !$isTableCellNode(focusCellNode) ||
+ !$isTableNode(tableNodeFromSelection) ||
+ tableElement == null
+ ) {
+ return false;
+ }
+ tableObserver.updateTableTableSelection(selection);
+
+ const grid = getTable(tableElement);
+ const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
+ const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
+ cordsAnchor.x,
+ cordsAnchor.y,
+ grid,
+ );
+ tableObserver.setAnchorCellForSelection(anchorCell);
+
+ stopEvent(event);
+
+ if (event.shiftKey) {
+ const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
+ return adjustFocusNodeInDirection(
+ tableObserver,
+ tableNodeFromSelection,
+ cords.x,
+ cords.y,
+ direction,
+ );
+ } else {
+ focusCellNode.selectEnd();
+ }
+
+ return true;
+ }
+
+ return false;
+}
+
+function stopEvent(event: Event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ event.stopPropagation();
+}
+
+function isTypeaheadMenuInView(editor: LexicalEditor) {
+ // There is no inbuilt way to check if the component picker is in view
+ // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
+ const root = editor.getRootElement();
+ if (!root) {
+ return false;
+ }
+ return (
+ root.hasAttribute('aria-controls') &&
+ root.getAttribute('aria-controls') === 'typeahead-menu'
+ );
+}
+
+function isExitingTableAnchor(
+ type: string,
+ offset: number,
+ anchorNode: LexicalNode,
+ direction: 'backward' | 'forward',
+) {
+ return (
+ isExitingTableElementAnchor(type, anchorNode, direction) ||
+ $isExitingTableTextAnchor(type, offset, anchorNode, direction)
+ );
+}
+
+function isExitingTableElementAnchor(
+ type: string,
+ anchorNode: LexicalNode,
+ direction: 'backward' | 'forward',
+) {
+ return (
+ type === 'element' &&
+ (direction === 'backward'
+ ? anchorNode.getPreviousSibling() === null
+ : anchorNode.getNextSibling() === null)
+ );
+}
+
+function $isExitingTableTextAnchor(
+ type: string,
+ offset: number,
+ anchorNode: LexicalNode,
+ direction: 'backward' | 'forward',
+) {
+ const parentNode = $findMatchingParent(
+ anchorNode,
+ (n) => $isElementNode(n) && !n.isInline(),
+ );
+ if (!parentNode) {
+ return false;
+ }
+ const hasValidOffset =
+ direction === 'backward'
+ ? offset === 0
+ : offset === anchorNode.getTextContentSize();
+ return (
+ type === 'text' &&
+ hasValidOffset &&
+ (direction === 'backward'
+ ? parentNode.getPreviousSibling() === null
+ : parentNode.getNextSibling() === null)
+ );
+}
+
+function $handleTableExit(
+ event: KeyboardEvent,
+ anchorNode: LexicalNode,
+ tableNode: TableNode,
+ direction: 'backward' | 'forward',
+) {
+ const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
+ if (!$isTableCellNode(anchorCellNode)) {
+ return false;
+ }
+ const [tableMap, cellValue] = $computeTableMap(
+ tableNode,
+ anchorCellNode,
+ anchorCellNode,
+ );
+ if (!isExitingCell(tableMap, cellValue, direction)) {
+ return false;
+ }
+
+ const toNode = $getExitingToNode(anchorNode, direction, tableNode);
+ if (!toNode || $isTableNode(toNode)) {
+ return false;
+ }
+
+ stopEvent(event);
+ if (direction === 'backward') {
+ toNode.selectEnd();
+ } else {
+ toNode.selectStart();
+ }
+ return true;
+}
+
+function isExitingCell(
+ tableMap: TableMapType,
+ cellValue: TableMapValueType,
+ direction: 'backward' | 'forward',
+) {
+ const firstCell = tableMap[0][0];
+ const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
+ const {startColumn, startRow} = cellValue;
+ return direction === 'backward'
+ ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
+ : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
+}
+
+function $getExitingToNode(
+ anchorNode: LexicalNode,
+ direction: 'backward' | 'forward',
+ tableNode: TableNode,
+) {
+ const parentNode = $findMatchingParent(
+ anchorNode,
+ (n) => $isElementNode(n) && !n.isInline(),
+ );
+ if (!parentNode) {
+ return undefined;
+ }
+ const anchorSibling =
+ direction === 'backward'
+ ? parentNode.getPreviousSibling()
+ : parentNode.getNextSibling();
+ return anchorSibling && $isTableNode(anchorSibling)
+ ? anchorSibling
+ : direction === 'backward'
+ ? tableNode.getPreviousSibling()
+ : tableNode.getNextSibling();
+}
+
+function $insertParagraphAtTableEdge(
+ edgePosition: 'first' | 'last',
+ tableNode: TableNode,
+ children?: LexicalNode[],
+) {
+ const paragraphNode = $createParagraphNode();
+ if (edgePosition === 'first') {
+ tableNode.insertBefore(paragraphNode);
+ } else {
+ tableNode.insertAfter(paragraphNode);
+ }
+ paragraphNode.append(...(children || []));
+ paragraphNode.selectEnd();
+}
+
+function $getTableEdgeCursorPosition(
+ editor: LexicalEditor,
+ selection: RangeSelection,
+ tableNode: TableNode,
+) {
+ const tableNodeParent = tableNode.getParent();
+ if (!tableNodeParent) {
+ return undefined;
+ }
+
+ const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
+ if (!tableNodeParentDOM) {
+ return undefined;
+ }
+
+ // TODO: Add support for nested tables
+ const domSelection = window.getSelection();
+ if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
+ return undefined;
+ }
+
+ const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
+ $isTableCellNode(n),
+ ) as TableCellNode | null;
+ if (!anchorCellNode) {
+ return undefined;
+ }
+
+ const parentTable = $findMatchingParent(anchorCellNode, (n) =>
+ $isTableNode(n),
+ );
+ if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
+ return undefined;
+ }
+
+ const [tableMap, cellValue] = $computeTableMap(
+ tableNode,
+ anchorCellNode,
+ anchorCellNode,
+ );
+ const firstCell = tableMap[0][0];
+ const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
+ const {startRow, startColumn} = cellValue;
+
+ const isAtFirstCell =
+ startRow === firstCell.startRow && startColumn === firstCell.startColumn;
+ const isAtLastCell =
+ startRow === lastCell.startRow && startColumn === lastCell.startColumn;
+
+ if (isAtFirstCell) {
+ return 'first';
+ } else if (isAtLastCell) {
+ return 'last';
+ } else {
+ return undefined;
+ }
+}
--- /dev/null
+/**
+ * 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 {TableMapType, TableMapValueType} from './LexicalTableSelection';
+import type {ElementNode, PointType} from 'lexical';
+
+import {$findMatchingParent} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getSelection,
+ $isRangeSelection,
+ LexicalNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {InsertTableCommandPayloadHeaders} from '.';
+import {
+ $createTableCellNode,
+ $isTableCellNode,
+ TableCellHeaderState,
+ TableCellHeaderStates,
+ TableCellNode,
+} from './LexicalTableCellNode';
+import {$createTableNode, $isTableNode, TableNode} from './LexicalTableNode';
+import {TableDOMTable} from './LexicalTableObserver';
+import {
+ $createTableRowNode,
+ $isTableRowNode,
+ TableRowNode,
+} from './LexicalTableRowNode';
+import {$isTableSelection} from './LexicalTableSelection';
+
+export function $createTableNodeWithDimensions(
+ rowCount: number,
+ columnCount: number,
+ includeHeaders: InsertTableCommandPayloadHeaders = true,
+): TableNode {
+ const tableNode = $createTableNode();
+
+ for (let iRow = 0; iRow < rowCount; iRow++) {
+ const tableRowNode = $createTableRowNode();
+
+ for (let iColumn = 0; iColumn < columnCount; iColumn++) {
+ let headerState = TableCellHeaderStates.NO_STATUS;
+
+ if (typeof includeHeaders === 'object') {
+ if (iRow === 0 && includeHeaders.rows) {
+ headerState |= TableCellHeaderStates.ROW;
+ }
+ if (iColumn === 0 && includeHeaders.columns) {
+ headerState |= TableCellHeaderStates.COLUMN;
+ }
+ } else if (includeHeaders) {
+ if (iRow === 0) {
+ headerState |= TableCellHeaderStates.ROW;
+ }
+ if (iColumn === 0) {
+ headerState |= TableCellHeaderStates.COLUMN;
+ }
+ }
+
+ const tableCellNode = $createTableCellNode(headerState);
+ const paragraphNode = $createParagraphNode();
+ paragraphNode.append($createTextNode());
+ tableCellNode.append(paragraphNode);
+ tableRowNode.append(tableCellNode);
+ }
+
+ tableNode.append(tableRowNode);
+ }
+
+ return tableNode;
+}
+
+export function $getTableCellNodeFromLexicalNode(
+ startingNode: LexicalNode,
+): TableCellNode | null {
+ const node = $findMatchingParent(startingNode, (n) => $isTableCellNode(n));
+
+ if ($isTableCellNode(node)) {
+ return node;
+ }
+
+ return null;
+}
+
+export function $getTableRowNodeFromTableCellNodeOrThrow(
+ startingNode: LexicalNode,
+): TableRowNode {
+ const node = $findMatchingParent(startingNode, (n) => $isTableRowNode(n));
+
+ if ($isTableRowNode(node)) {
+ return node;
+ }
+
+ throw new Error('Expected table cell to be inside of table row.');
+}
+
+export function $getTableNodeFromLexicalNodeOrThrow(
+ startingNode: LexicalNode,
+): TableNode {
+ const node = $findMatchingParent(startingNode, (n) => $isTableNode(n));
+
+ if ($isTableNode(node)) {
+ return node;
+ }
+
+ throw new Error('Expected table cell to be inside of table.');
+}
+
+export function $getTableRowIndexFromTableCellNode(
+ tableCellNode: TableCellNode,
+): number {
+ const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode);
+ const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableRowNode);
+ return tableNode.getChildren().findIndex((n) => n.is(tableRowNode));
+}
+
+export function $getTableColumnIndexFromTableCellNode(
+ tableCellNode: TableCellNode,
+): number {
+ const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode);
+ return tableRowNode.getChildren().findIndex((n) => n.is(tableCellNode));
+}
+
+export type TableCellSiblings = {
+ above: TableCellNode | null | undefined;
+ below: TableCellNode | null | undefined;
+ left: TableCellNode | null | undefined;
+ right: TableCellNode | null | undefined;
+};
+
+export function $getTableCellSiblingsFromTableCellNode(
+ tableCellNode: TableCellNode,
+ table: TableDOMTable,
+): TableCellSiblings {
+ const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
+ const {x, y} = tableNode.getCordsFromCellNode(tableCellNode, table);
+ return {
+ above: tableNode.getCellNodeFromCords(x, y - 1, table),
+ below: tableNode.getCellNodeFromCords(x, y + 1, table),
+ left: tableNode.getCellNodeFromCords(x - 1, y, table),
+ right: tableNode.getCellNodeFromCords(x + 1, y, table),
+ };
+}
+
+export function $removeTableRowAtIndex(
+ tableNode: TableNode,
+ indexToDelete: number,
+): TableNode {
+ const tableRows = tableNode.getChildren();
+
+ if (indexToDelete >= tableRows.length || indexToDelete < 0) {
+ throw new Error('Expected table cell to be inside of table row.');
+ }
+
+ const targetRowNode = tableRows[indexToDelete];
+ targetRowNode.remove();
+ return tableNode;
+}
+
+export function $insertTableRow(
+ tableNode: TableNode,
+ targetIndex: number,
+ shouldInsertAfter = true,
+ rowCount: number,
+ table: TableDOMTable,
+): TableNode {
+ const tableRows = tableNode.getChildren();
+
+ if (targetIndex >= tableRows.length || targetIndex < 0) {
+ throw new Error('Table row target index out of range');
+ }
+
+ const targetRowNode = tableRows[targetIndex];
+
+ if ($isTableRowNode(targetRowNode)) {
+ for (let r = 0; r < rowCount; r++) {
+ const tableRowCells = targetRowNode.getChildren<TableCellNode>();
+ const tableColumnCount = tableRowCells.length;
+ const newTableRowNode = $createTableRowNode();
+
+ for (let c = 0; c < tableColumnCount; c++) {
+ const tableCellFromTargetRow = tableRowCells[c];
+
+ invariant(
+ $isTableCellNode(tableCellFromTargetRow),
+ 'Expected table cell',
+ );
+
+ const {above, below} = $getTableCellSiblingsFromTableCellNode(
+ tableCellFromTargetRow,
+ table,
+ );
+
+ let headerState = TableCellHeaderStates.NO_STATUS;
+ const width =
+ (above && above.getWidth()) ||
+ (below && below.getWidth()) ||
+ undefined;
+
+ if (
+ (above && above.hasHeaderState(TableCellHeaderStates.COLUMN)) ||
+ (below && below.hasHeaderState(TableCellHeaderStates.COLUMN))
+ ) {
+ headerState |= TableCellHeaderStates.COLUMN;
+ }
+
+ const tableCellNode = $createTableCellNode(headerState, 1, width);
+
+ tableCellNode.append($createParagraphNode());
+
+ newTableRowNode.append(tableCellNode);
+ }
+
+ if (shouldInsertAfter) {
+ targetRowNode.insertAfter(newTableRowNode);
+ } else {
+ targetRowNode.insertBefore(newTableRowNode);
+ }
+ }
+ } else {
+ throw new Error('Row before insertion index does not exist.');
+ }
+
+ return tableNode;
+}
+
+const getHeaderState = (
+ currentState: TableCellHeaderState,
+ possibleState: TableCellHeaderState,
+): TableCellHeaderState => {
+ if (
+ currentState === TableCellHeaderStates.BOTH ||
+ currentState === possibleState
+ ) {
+ return possibleState;
+ }
+ return TableCellHeaderStates.NO_STATUS;
+};
+
+export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection) || $isTableSelection(selection),
+ 'Expected a RangeSelection or TableSelection',
+ );
+ const focus = selection.focus.getNode();
+ const [focusCell, , grid] = $getNodeTriplet(focus);
+ const [gridMap, focusCellMap] = $computeTableMap(grid, focusCell, focusCell);
+ const columnCount = gridMap[0].length;
+ const {startRow: focusStartRow} = focusCellMap;
+ if (insertAfter) {
+ const focusEndRow = focusStartRow + focusCell.__rowSpan - 1;
+ const focusEndRowMap = gridMap[focusEndRow];
+ const newRow = $createTableRowNode();
+ for (let i = 0; i < columnCount; i++) {
+ const {cell, startRow} = focusEndRowMap[i];
+ if (startRow + cell.__rowSpan - 1 <= focusEndRow) {
+ const currentCell = focusEndRowMap[i].cell as TableCellNode;
+ const currentCellHeaderState = currentCell.__headerState;
+
+ const headerState = getHeaderState(
+ currentCellHeaderState,
+ TableCellHeaderStates.COLUMN,
+ );
+
+ newRow.append(
+ $createTableCellNode(headerState).append($createParagraphNode()),
+ );
+ } else {
+ cell.setRowSpan(cell.__rowSpan + 1);
+ }
+ }
+ const focusEndRowNode = grid.getChildAtIndex(focusEndRow);
+ invariant(
+ $isTableRowNode(focusEndRowNode),
+ 'focusEndRow is not a TableRowNode',
+ );
+ focusEndRowNode.insertAfter(newRow);
+ } else {
+ const focusStartRowMap = gridMap[focusStartRow];
+ const newRow = $createTableRowNode();
+ for (let i = 0; i < columnCount; i++) {
+ const {cell, startRow} = focusStartRowMap[i];
+ if (startRow === focusStartRow) {
+ const currentCell = focusStartRowMap[i].cell as TableCellNode;
+ const currentCellHeaderState = currentCell.__headerState;
+
+ const headerState = getHeaderState(
+ currentCellHeaderState,
+ TableCellHeaderStates.COLUMN,
+ );
+
+ newRow.append(
+ $createTableCellNode(headerState).append($createParagraphNode()),
+ );
+ } else {
+ cell.setRowSpan(cell.__rowSpan + 1);
+ }
+ }
+ const focusStartRowNode = grid.getChildAtIndex(focusStartRow);
+ invariant(
+ $isTableRowNode(focusStartRowNode),
+ 'focusEndRow is not a TableRowNode',
+ );
+ focusStartRowNode.insertBefore(newRow);
+ }
+}
+
+export function $insertTableColumn(
+ tableNode: TableNode,
+ targetIndex: number,
+ shouldInsertAfter = true,
+ columnCount: number,
+ table: TableDOMTable,
+): TableNode {
+ const tableRows = tableNode.getChildren();
+
+ const tableCellsToBeInserted = [];
+ for (let r = 0; r < tableRows.length; r++) {
+ const currentTableRowNode = tableRows[r];
+
+ if ($isTableRowNode(currentTableRowNode)) {
+ for (let c = 0; c < columnCount; c++) {
+ const tableRowChildren = currentTableRowNode.getChildren();
+ if (targetIndex >= tableRowChildren.length || targetIndex < 0) {
+ throw new Error('Table column target index out of range');
+ }
+
+ const targetCell = tableRowChildren[targetIndex];
+
+ invariant($isTableCellNode(targetCell), 'Expected table cell');
+
+ const {left, right} = $getTableCellSiblingsFromTableCellNode(
+ targetCell,
+ table,
+ );
+
+ let headerState = TableCellHeaderStates.NO_STATUS;
+
+ if (
+ (left && left.hasHeaderState(TableCellHeaderStates.ROW)) ||
+ (right && right.hasHeaderState(TableCellHeaderStates.ROW))
+ ) {
+ headerState |= TableCellHeaderStates.ROW;
+ }
+
+ const newTableCell = $createTableCellNode(headerState);
+
+ newTableCell.append($createParagraphNode());
+ tableCellsToBeInserted.push({
+ newTableCell,
+ targetCell,
+ });
+ }
+ }
+ }
+ tableCellsToBeInserted.forEach(({newTableCell, targetCell}) => {
+ if (shouldInsertAfter) {
+ targetCell.insertAfter(newTableCell);
+ } else {
+ targetCell.insertBefore(newTableCell);
+ }
+ });
+
+ return tableNode;
+}
+
+export function $insertTableColumn__EXPERIMENTAL(insertAfter = true): void {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection) || $isTableSelection(selection),
+ 'Expected a RangeSelection or TableSelection',
+ );
+ const anchor = selection.anchor.getNode();
+ const focus = selection.focus.getNode();
+ const [anchorCell] = $getNodeTriplet(anchor);
+ const [focusCell, , grid] = $getNodeTriplet(focus);
+ const [gridMap, focusCellMap, anchorCellMap] = $computeTableMap(
+ grid,
+ focusCell,
+ anchorCell,
+ );
+ const rowCount = gridMap.length;
+ const startColumn = insertAfter
+ ? Math.max(focusCellMap.startColumn, anchorCellMap.startColumn)
+ : Math.min(focusCellMap.startColumn, anchorCellMap.startColumn);
+ const insertAfterColumn = insertAfter
+ ? startColumn + focusCell.__colSpan - 1
+ : startColumn - 1;
+ const gridFirstChild = grid.getFirstChild();
+ invariant(
+ $isTableRowNode(gridFirstChild),
+ 'Expected firstTable child to be a row',
+ );
+ let firstInsertedCell: null | TableCellNode = null;
+ function $createTableCellNodeForInsertTableColumn(
+ headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
+ ) {
+ const cell = $createTableCellNode(headerState).append(
+ $createParagraphNode(),
+ );
+ if (firstInsertedCell === null) {
+ firstInsertedCell = cell;
+ }
+ return cell;
+ }
+ let loopRow: TableRowNode = gridFirstChild;
+ rowLoop: for (let i = 0; i < rowCount; i++) {
+ if (i !== 0) {
+ const currentRow = loopRow.getNextSibling();
+ invariant(
+ $isTableRowNode(currentRow),
+ 'Expected row nextSibling to be a row',
+ );
+ loopRow = currentRow;
+ }
+ const rowMap = gridMap[i];
+
+ const currentCellHeaderState = (
+ rowMap[insertAfterColumn < 0 ? 0 : insertAfterColumn]
+ .cell as TableCellNode
+ ).__headerState;
+
+ const headerState = getHeaderState(
+ currentCellHeaderState,
+ TableCellHeaderStates.ROW,
+ );
+
+ if (insertAfterColumn < 0) {
+ $insertFirst(
+ loopRow,
+ $createTableCellNodeForInsertTableColumn(headerState),
+ );
+ continue;
+ }
+ const {
+ cell: currentCell,
+ startColumn: currentStartColumn,
+ startRow: currentStartRow,
+ } = rowMap[insertAfterColumn];
+ if (currentStartColumn + currentCell.__colSpan - 1 <= insertAfterColumn) {
+ let insertAfterCell: TableCellNode = currentCell;
+ let insertAfterCellRowStart = currentStartRow;
+ let prevCellIndex = insertAfterColumn;
+ while (insertAfterCellRowStart !== i && insertAfterCell.__rowSpan > 1) {
+ prevCellIndex -= currentCell.__colSpan;
+ if (prevCellIndex >= 0) {
+ const {cell: cell_, startRow: startRow_} = rowMap[prevCellIndex];
+ insertAfterCell = cell_;
+ insertAfterCellRowStart = startRow_;
+ } else {
+ loopRow.append($createTableCellNodeForInsertTableColumn(headerState));
+ continue rowLoop;
+ }
+ }
+ insertAfterCell.insertAfter(
+ $createTableCellNodeForInsertTableColumn(headerState),
+ );
+ } else {
+ currentCell.setColSpan(currentCell.__colSpan + 1);
+ }
+ }
+ if (firstInsertedCell !== null) {
+ $moveSelectionToCell(firstInsertedCell);
+ }
+}
+
+export function $deleteTableColumn(
+ tableNode: TableNode,
+ targetIndex: number,
+): TableNode {
+ const tableRows = tableNode.getChildren();
+
+ for (let i = 0; i < tableRows.length; i++) {
+ const currentTableRowNode = tableRows[i];
+
+ if ($isTableRowNode(currentTableRowNode)) {
+ const tableRowChildren = currentTableRowNode.getChildren();
+
+ if (targetIndex >= tableRowChildren.length || targetIndex < 0) {
+ throw new Error('Table column target index out of range');
+ }
+
+ tableRowChildren[targetIndex].remove();
+ }
+ }
+
+ return tableNode;
+}
+
+export function $deleteTableRow__EXPERIMENTAL(): void {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection) || $isTableSelection(selection),
+ 'Expected a RangeSelection or TableSelection',
+ );
+ const anchor = selection.anchor.getNode();
+ const focus = selection.focus.getNode();
+ const [anchorCell, , grid] = $getNodeTriplet(anchor);
+ const [focusCell] = $getNodeTriplet(focus);
+ const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap(
+ grid,
+ anchorCell,
+ focusCell,
+ );
+ const {startRow: anchorStartRow} = anchorCellMap;
+ const {startRow: focusStartRow} = focusCellMap;
+ const focusEndRow = focusStartRow + focusCell.__rowSpan - 1;
+ if (gridMap.length === focusEndRow - anchorStartRow + 1) {
+ // Empty grid
+ grid.remove();
+ return;
+ }
+ const columnCount = gridMap[0].length;
+ const nextRow = gridMap[focusEndRow + 1];
+ const nextRowNode: null | TableRowNode = grid.getChildAtIndex(
+ focusEndRow + 1,
+ );
+ for (let row = focusEndRow; row >= anchorStartRow; row--) {
+ for (let column = columnCount - 1; column >= 0; column--) {
+ const {
+ cell,
+ startRow: cellStartRow,
+ startColumn: cellStartColumn,
+ } = gridMap[row][column];
+ if (cellStartColumn !== column) {
+ // Don't repeat work for the same Cell
+ continue;
+ }
+ // Rows overflowing top have to be trimmed
+ if (row === anchorStartRow && cellStartRow < anchorStartRow) {
+ cell.setRowSpan(cell.__rowSpan - (cellStartRow - anchorStartRow));
+ }
+ // Rows overflowing bottom have to be trimmed and moved to the next row
+ if (
+ cellStartRow >= anchorStartRow &&
+ cellStartRow + cell.__rowSpan - 1 > focusEndRow
+ ) {
+ cell.setRowSpan(cell.__rowSpan - (focusEndRow - cellStartRow + 1));
+ invariant(nextRowNode !== null, 'Expected nextRowNode not to be null');
+ if (column === 0) {
+ $insertFirst(nextRowNode, cell);
+ } else {
+ const {cell: previousCell} = nextRow[column - 1];
+ previousCell.insertAfter(cell);
+ }
+ }
+ }
+ const rowNode = grid.getChildAtIndex(row);
+ invariant(
+ $isTableRowNode(rowNode),
+ 'Expected GridNode childAtIndex(%s) to be RowNode',
+ String(row),
+ );
+ rowNode.remove();
+ }
+ if (nextRow !== undefined) {
+ const {cell} = nextRow[0];
+ $moveSelectionToCell(cell);
+ } else {
+ const previousRow = gridMap[anchorStartRow - 1];
+ const {cell} = previousRow[0];
+ $moveSelectionToCell(cell);
+ }
+}
+
+export function $deleteTableColumn__EXPERIMENTAL(): void {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection) || $isTableSelection(selection),
+ 'Expected a RangeSelection or TableSelection',
+ );
+ const anchor = selection.anchor.getNode();
+ const focus = selection.focus.getNode();
+ const [anchorCell, , grid] = $getNodeTriplet(anchor);
+ const [focusCell] = $getNodeTriplet(focus);
+ const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap(
+ grid,
+ anchorCell,
+ focusCell,
+ );
+ const {startColumn: anchorStartColumn} = anchorCellMap;
+ const {startRow: focusStartRow, startColumn: focusStartColumn} = focusCellMap;
+ const startColumn = Math.min(anchorStartColumn, focusStartColumn);
+ const endColumn = Math.max(
+ anchorStartColumn + anchorCell.__colSpan - 1,
+ focusStartColumn + focusCell.__colSpan - 1,
+ );
+ const selectedColumnCount = endColumn - startColumn + 1;
+ const columnCount = gridMap[0].length;
+ if (columnCount === endColumn - startColumn + 1) {
+ // Empty grid
+ grid.selectPrevious();
+ grid.remove();
+ return;
+ }
+ const rowCount = gridMap.length;
+ for (let row = 0; row < rowCount; row++) {
+ for (let column = startColumn; column <= endColumn; column++) {
+ const {cell, startColumn: cellStartColumn} = gridMap[row][column];
+ if (cellStartColumn < startColumn) {
+ if (column === startColumn) {
+ const overflowLeft = startColumn - cellStartColumn;
+ // Overflowing left
+ cell.setColSpan(
+ cell.__colSpan -
+ // Possible overflow right too
+ Math.min(selectedColumnCount, cell.__colSpan - overflowLeft),
+ );
+ }
+ } else if (cellStartColumn + cell.__colSpan - 1 > endColumn) {
+ if (column === endColumn) {
+ // Overflowing right
+ const inSelectedArea = endColumn - cellStartColumn + 1;
+ cell.setColSpan(cell.__colSpan - inSelectedArea);
+ }
+ } else {
+ cell.remove();
+ }
+ }
+ }
+ const focusRowMap = gridMap[focusStartRow];
+ const nextColumn =
+ anchorStartColumn > focusStartColumn
+ ? focusRowMap[anchorStartColumn + anchorCell.__colSpan]
+ : focusRowMap[focusStartColumn + focusCell.__colSpan];
+ if (nextColumn !== undefined) {
+ const {cell} = nextColumn;
+ $moveSelectionToCell(cell);
+ } else {
+ const previousRow =
+ focusStartColumn < anchorStartColumn
+ ? focusRowMap[focusStartColumn - 1]
+ : focusRowMap[anchorStartColumn - 1];
+ const {cell} = previousRow;
+ $moveSelectionToCell(cell);
+ }
+}
+
+function $moveSelectionToCell(cell: TableCellNode): void {
+ const firstDescendant = cell.getFirstDescendant();
+ if (firstDescendant == null) {
+ cell.selectStart();
+ } else {
+ firstDescendant.getParentOrThrow().selectStart();
+ }
+}
+
+function $insertFirst(parent: ElementNode, node: LexicalNode): void {
+ const firstChild = parent.getFirstChild();
+ if (firstChild !== null) {
+ firstChild.insertBefore(node);
+ } else {
+ parent.append(node);
+ }
+}
+
+export function $unmergeCell(): void {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection) || $isTableSelection(selection),
+ 'Expected a RangeSelection or TableSelection',
+ );
+ const anchor = selection.anchor.getNode();
+ const [cell, row, grid] = $getNodeTriplet(anchor);
+ const colSpan = cell.__colSpan;
+ const rowSpan = cell.__rowSpan;
+ if (colSpan > 1) {
+ for (let i = 1; i < colSpan; i++) {
+ cell.insertAfter(
+ $createTableCellNode(TableCellHeaderStates.NO_STATUS).append(
+ $createParagraphNode(),
+ ),
+ );
+ }
+ cell.setColSpan(1);
+ }
+ if (rowSpan > 1) {
+ const [map, cellMap] = $computeTableMap(grid, cell, cell);
+ const {startColumn, startRow} = cellMap;
+ let currentRowNode;
+ for (let i = 1; i < rowSpan; i++) {
+ const currentRow = startRow + i;
+ const currentRowMap = map[currentRow];
+ currentRowNode = (currentRowNode || row).getNextSibling();
+ invariant(
+ $isTableRowNode(currentRowNode),
+ 'Expected row next sibling to be a row',
+ );
+ let insertAfterCell: null | TableCellNode = null;
+ for (let column = 0; column < startColumn; column++) {
+ const currentCellMap = currentRowMap[column];
+ const currentCell = currentCellMap.cell;
+ if (currentCellMap.startRow === currentRow) {
+ insertAfterCell = currentCell;
+ }
+ if (currentCell.__colSpan > 1) {
+ column += currentCell.__colSpan - 1;
+ }
+ }
+ if (insertAfterCell === null) {
+ for (let j = 0; j < colSpan; j++) {
+ $insertFirst(
+ currentRowNode,
+ $createTableCellNode(TableCellHeaderStates.NO_STATUS).append(
+ $createParagraphNode(),
+ ),
+ );
+ }
+ } else {
+ for (let j = 0; j < colSpan; j++) {
+ insertAfterCell.insertAfter(
+ $createTableCellNode(TableCellHeaderStates.NO_STATUS).append(
+ $createParagraphNode(),
+ ),
+ );
+ }
+ }
+ }
+ cell.setRowSpan(1);
+ }
+}
+
+export function $computeTableMap(
+ grid: TableNode,
+ cellA: TableCellNode,
+ cellB: TableCellNode,
+): [TableMapType, TableMapValueType, TableMapValueType] {
+ const [tableMap, cellAValue, cellBValue] = $computeTableMapSkipCellCheck(
+ grid,
+ cellA,
+ cellB,
+ );
+ invariant(cellAValue !== null, 'Anchor not found in Grid');
+ invariant(cellBValue !== null, 'Focus not found in Grid');
+ return [tableMap, cellAValue, cellBValue];
+}
+
+export function $computeTableMapSkipCellCheck(
+ grid: TableNode,
+ cellA: null | TableCellNode,
+ cellB: null | TableCellNode,
+): [TableMapType, TableMapValueType | null, TableMapValueType | null] {
+ const tableMap: TableMapType = [];
+ let cellAValue: null | TableMapValueType = null;
+ let cellBValue: null | TableMapValueType = null;
+ function write(startRow: number, startColumn: number, cell: TableCellNode) {
+ const value = {
+ cell,
+ startColumn,
+ startRow,
+ };
+ const rowSpan = cell.__rowSpan;
+ const colSpan = cell.__colSpan;
+ for (let i = 0; i < rowSpan; i++) {
+ if (tableMap[startRow + i] === undefined) {
+ tableMap[startRow + i] = [];
+ }
+ for (let j = 0; j < colSpan; j++) {
+ tableMap[startRow + i][startColumn + j] = value;
+ }
+ }
+ if (cellA !== null && cellA.is(cell)) {
+ cellAValue = value;
+ }
+ if (cellB !== null && cellB.is(cell)) {
+ cellBValue = value;
+ }
+ }
+ function isEmpty(row: number, column: number) {
+ return tableMap[row] === undefined || tableMap[row][column] === undefined;
+ }
+
+ const gridChildren = grid.getChildren();
+ for (let i = 0; i < gridChildren.length; i++) {
+ const row = gridChildren[i];
+ invariant(
+ $isTableRowNode(row),
+ 'Expected GridNode children to be TableRowNode',
+ );
+ const rowChildren = row.getChildren();
+ let j = 0;
+ for (const cell of rowChildren) {
+ invariant(
+ $isTableCellNode(cell),
+ 'Expected TableRowNode children to be TableCellNode',
+ );
+ while (!isEmpty(i, j)) {
+ j++;
+ }
+ write(i, j, cell);
+ j += cell.__colSpan;
+ }
+ }
+ return [tableMap, cellAValue, cellBValue];
+}
+
+export function $getNodeTriplet(
+ source: PointType | LexicalNode | TableCellNode,
+): [TableCellNode, TableRowNode, TableNode] {
+ let cell: TableCellNode;
+ if (source instanceof TableCellNode) {
+ cell = source;
+ } else if ('__type' in source) {
+ const cell_ = $findMatchingParent(source, $isTableCellNode);
+ invariant(
+ $isTableCellNode(cell_),
+ 'Expected to find a parent TableCellNode',
+ );
+ cell = cell_;
+ } else {
+ const cell_ = $findMatchingParent(source.getNode(), $isTableCellNode);
+ invariant(
+ $isTableCellNode(cell_),
+ 'Expected to find a parent TableCellNode',
+ );
+ cell = cell_;
+ }
+ const row = cell.getParent();
+ invariant(
+ $isTableRowNode(row),
+ 'Expected TableCellNode to have a parent TableRowNode',
+ );
+ const grid = row.getParent();
+ invariant(
+ $isTableNode(grid),
+ 'Expected TableRowNode to have a parent GridNode',
+ );
+ return [cell, row, grid];
+}
+
+export function $getTableCellNodeRect(tableCellNode: TableCellNode): {
+ rowIndex: number;
+ columnIndex: number;
+ rowSpan: number;
+ colSpan: number;
+} | null {
+ const [cellNode, , gridNode] = $getNodeTriplet(tableCellNode);
+ const rows = gridNode.getChildren<TableRowNode>();
+ const rowCount = rows.length;
+ const columnCount = rows[0].getChildren().length;
+
+ // Create a matrix of the same size as the table to track the position of each cell
+ const cellMatrix = new Array(rowCount);
+ for (let i = 0; i < rowCount; i++) {
+ cellMatrix[i] = new Array(columnCount);
+ }
+
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ const row = rows[rowIndex];
+ const cells = row.getChildren<TableCellNode>();
+ let columnIndex = 0;
+
+ for (let cellIndex = 0; cellIndex < cells.length; cellIndex++) {
+ // Find the next available position in the matrix, skip the position of merged cells
+ while (cellMatrix[rowIndex][columnIndex]) {
+ columnIndex++;
+ }
+
+ const cell = cells[cellIndex];
+ const rowSpan = cell.__rowSpan || 1;
+ const colSpan = cell.__colSpan || 1;
+
+ // Put the cell into the corresponding position in the matrix
+ for (let i = 0; i < rowSpan; i++) {
+ for (let j = 0; j < colSpan; j++) {
+ cellMatrix[rowIndex + i][columnIndex + j] = cell;
+ }
+ }
+
+ // Return to the original index, row span and column span of the cell.
+ if (cellNode === cell) {
+ return {
+ colSpan,
+ columnIndex,
+ rowIndex,
+ rowSpan,
+ };
+ }
+
+ columnIndex += colSpan;
+ }
+ }
+
+ return null;
+}
--- /dev/null
+/**
+ * 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 {$createTableCellNode, TableCellHeaderStates} from '@lexical/table';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ tableCell: 'test-table-cell-class',
+ },
+});
+
+describe('LexicalTableCellNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('TableCellNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const cellNode = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
+
+ expect(cellNode).not.toBe(null);
+ });
+
+ expect(() =>
+ $createTableCellNode(TableCellHeaderStates.NO_STATUS),
+ ).toThrow();
+ });
+
+ test('TableCellNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const cellNode = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
+ expect(cellNode.createDOM(editorConfig).outerHTML).toBe(
+ `<td class="${editorConfig.theme.tableCell}"></td>`,
+ );
+
+ const headerCellNode = $createTableCellNode(TableCellHeaderStates.ROW);
+ expect(headerCellNode.createDOM(editorConfig).outerHTML).toBe(
+ `<th class="${editorConfig.theme.tableCell}"></th>`,
+ );
+
+ const colSpan = 2;
+ const cellWithRowSpanNode = $createTableCellNode(
+ TableCellHeaderStates.NO_STATUS,
+ colSpan,
+ );
+ expect(cellWithRowSpanNode.createDOM(editorConfig).outerHTML).toBe(
+ `<td colspan="${colSpan}" class="${editorConfig.theme.tableCell}"></td>`,
+ );
+
+ const cellWidth = 200;
+ const cellWithCustomWidthNode = $createTableCellNode(
+ TableCellHeaderStates.NO_STATUS,
+ undefined,
+ cellWidth,
+ );
+ expect(cellWithCustomWidthNode.createDOM(editorConfig).outerHTML).toBe(
+ `<td style="width: ${cellWidth}px;" class="${editorConfig.theme.tableCell}"></td>`,
+ );
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$insertDataTransferForRichText} from '@lexical/clipboard';
+import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
+import {
+ $createTableNode,
+ $createTableNodeWithDimensions,
+ $createTableSelection,
+} from '@lexical/table';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ $selectAll,
+ $setSelection,
+ CUT_COMMAND,
+ ParagraphNode,
+} from 'lexical';
+import {
+ DataTransferMock,
+ initializeUnitTest,
+ invariant,
+} from 'lexical/src/__tests__/utils';
+
+import {$getElementForTableNode, TableNode} from '../../LexicalTableNode';
+
+export class ClipboardDataMock {
+ getData: jest.Mock<string, [string]>;
+ setData: jest.Mock<void, [string, string]>;
+
+ constructor() {
+ this.getData = jest.fn();
+ this.setData = jest.fn();
+ }
+}
+
+export class ClipboardEventMock extends Event {
+ clipboardData: ClipboardDataMock;
+
+ constructor(type: string, options?: EventInit) {
+ super(type, options);
+ this.clipboardData = new ClipboardDataMock();
+ }
+}
+
+global.document.execCommand = function execCommandMock(
+ commandId: string,
+ showUI?: boolean,
+ value?: string,
+): boolean {
+ return true;
+};
+Object.defineProperty(window, 'ClipboardEvent', {
+ value: new ClipboardEventMock('cut'),
+});
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ table: 'test-table-class',
+ },
+});
+
+describe('LexicalTableNode tests', () => {
+ initializeUnitTest(
+ (testEnv) => {
+ beforeEach(async () => {
+ const {editor} = testEnv;
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ root.append(paragraph);
+ paragraph.select();
+ });
+ });
+
+ test('TableNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const tableNode = $createTableNode();
+
+ expect(tableNode).not.toBe(null);
+ });
+
+ expect(() => $createTableNode()).toThrow();
+ });
+
+ test('TableNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const tableNode = $createTableNode();
+
+ expect(tableNode.createDOM(editorConfig).outerHTML).toBe(
+ `<table class="${editorConfig.theme.table}"></table>`,
+ );
+ });
+ });
+
+ test('Copy table from an external source', async () => {
+ const {editor} = testEnv;
+
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData(
+ 'text/html',
+ '<html><body><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-16a69100-7fff-6cb9-b829-cb1def16a58d"><div dir="ltr" style="margin-left:0pt;" align="left"><table style="border:none;border-collapse:collapse;table-layout:fixed;width:468pt"><colgroup><col /><col /></colgroup><tbody><tr style="height:22.015pt"><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Hello there</span></p></td><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">General Kenobi!</span></p></td></tr><tr style="height:22.015pt"><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Lexical is nice</span></p></td><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><br /></td></tr></tbody></table></div></b><!--EndFragment--></body></html>',
+ );
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'isRangeSelection(selection)',
+ );
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ // Make sure paragraph is inserted inside empty cells
+ const emptyCell = '<td><p><br></p></td>';
+ expect(testEnv.innerHTML).toBe(
+ `<table><tr><td><p dir="ltr"><span data-lexical-text="true">Hello there</span></p></td><td><p dir="ltr"><span data-lexical-text="true">General Kenobi!</span></p></td></tr><tr><td><p dir="ltr"><span data-lexical-text="true">Lexical is nice</span></p></td>${emptyCell}</tr></table>`,
+ );
+ });
+
+ test('Copy table from an external source like gdoc with formatting', async () => {
+ const {editor} = testEnv;
+
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData(
+ 'text/html',
+ '<google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none" data-sheets-root="1"><colgroup><col width="100"/><col width="189"/><col width="171"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-weight:bold;" data-sheets-value="{"1":2,"2":"Surface"}">Surface</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-style:italic;" data-sheets-value="{"1":2,"2":"MWP_WORK_LS_COMPOSER"}">MWP_WORK_LS_COMPOSER</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:underline;text-align:right;" data-sheets-value="{"1":3,"3":77349}">77349</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"Lexical"}">Lexical</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:line-through;" data-sheets-value="{"1":2,"2":"XDS_RICH_TEXT_AREA"}">XDS_RICH_TEXT_AREA</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"sdvd sdfvsfs"}" data-sheets-textstyleruns="{"1":0}{"1":5,"2":{"5":1}}"><span style="font-size:10pt;font-family:Arial;font-style:normal;">sdvd </span><span style="font-size:10pt;font-family:Arial;font-weight:bold;font-style:normal;">sdfvsfs</span></td></tr></tbody></table>',
+ );
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'isRangeSelection(selection)',
+ );
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ expect(testEnv.innerHTML).toBe(
+ `<table><tr style="height: 21px;"><td><p dir="ltr"><strong data-lexical-text="true">Surface</strong></p></td><td><p dir="ltr"><em data-lexical-text="true">MWP_WORK_LS_COMPOSER</em></p></td><td><p style="text-align: right;"><span data-lexical-text="true">77349</span></p></td></tr><tr style="height: 21px;"><td><p dir="ltr"><span data-lexical-text="true">Lexical</span></p></td><td><p dir="ltr"><span data-lexical-text="true">XDS_RICH_TEXT_AREA</span></p></td><td><p dir="ltr"><span data-lexical-text="true">sdvd </span><strong data-lexical-text="true">sdfvsfs</strong></p></td></tr></table>`,
+ );
+ });
+
+ test('Cut table in the middle of a range selection', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild<ParagraphNode>();
+ const beforeText = $createTextNode('text before the table');
+ const table = $createTableNodeWithDimensions(4, 4, true);
+ const afterText = $createTextNode('text after the table');
+
+ paragraph?.append(beforeText);
+ paragraph?.append(table);
+ paragraph?.append(afterText);
+ });
+ await editor.update(() => {
+ editor.focus();
+ $selectAll();
+ });
+ await editor.update(() => {
+ editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent);
+ });
+
+ expect(testEnv.innerHTML).toBe(`<p><br></p>`);
+ });
+
+ test('Cut table as last node in range selection ', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild<ParagraphNode>();
+ const beforeText = $createTextNode('text before the table');
+ const table = $createTableNodeWithDimensions(4, 4, true);
+
+ paragraph?.append(beforeText);
+ paragraph?.append(table);
+ });
+ await editor.update(() => {
+ editor.focus();
+ $selectAll();
+ });
+ await editor.update(() => {
+ editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent);
+ });
+
+ expect(testEnv.innerHTML).toBe(`<p><br></p>`);
+ });
+
+ test('Cut table as first node in range selection ', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = root.getFirstChild<ParagraphNode>();
+ const table = $createTableNodeWithDimensions(4, 4, true);
+ const afterText = $createTextNode('text after the table');
+
+ paragraph?.append(table);
+ paragraph?.append(afterText);
+ });
+ await editor.update(() => {
+ editor.focus();
+ $selectAll();
+ });
+ await editor.update(() => {
+ editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent);
+ });
+
+ expect(testEnv.innerHTML).toBe(`<p><br></p>`);
+ });
+
+ test('Cut table is whole selection, should remove it', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const table = $createTableNodeWithDimensions(4, 4, true);
+ root.append(table);
+ });
+ await editor.update(() => {
+ const root = $getRoot();
+ const table = root.getLastChild<TableNode>();
+ if (table) {
+ const DOMTable = $getElementForTableNode(editor, table);
+ if (DOMTable) {
+ table
+ ?.getCellNodeFromCords(0, 0, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode('some text'));
+ const selection = $createTableSelection();
+ selection.set(
+ table.__key,
+ table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '',
+ table?.getCellNodeFromCords(3, 3, DOMTable)?.__key || '',
+ );
+ $setSelection(selection);
+ editor.dispatchCommand(CUT_COMMAND, {
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as ClipboardEvent);
+ }
+ }
+ });
+
+ expect(testEnv.innerHTML).toBe(`<p><br></p>`);
+ });
+
+ test('Cut subsection of table cells, should just clear contents', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const table = $createTableNodeWithDimensions(4, 4, true);
+ root.append(table);
+ });
+ await editor.update(() => {
+ const root = $getRoot();
+ const table = root.getLastChild<TableNode>();
+ if (table) {
+ const DOMTable = $getElementForTableNode(editor, table);
+ if (DOMTable) {
+ table
+ ?.getCellNodeFromCords(0, 0, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode('some text'));
+ const selection = $createTableSelection();
+ selection.set(
+ table.__key,
+ table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '',
+ table?.getCellNodeFromCords(2, 2, DOMTable)?.__key || '',
+ );
+ $setSelection(selection);
+ editor.dispatchCommand(CUT_COMMAND, {
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as ClipboardEvent);
+ }
+ }
+ });
+
+ expect(testEnv.innerHTML).toBe(
+ `<p><br></p><table><tr><th><p><br></p></th><th><p><br></p></th><th><p><br></p></th><th><p><br></p></th></tr><tr><th><p><br></p></th><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr><tr><th><p><br></p></th><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr><tr><th><p><br></p></th><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr></table>`,
+ );
+ });
+
+ test('Table plain text output validation', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const table = $createTableNodeWithDimensions(4, 4, true);
+ root.append(table);
+ });
+ await editor.update(() => {
+ const root = $getRoot();
+ const table = root.getLastChild<TableNode>();
+ if (table) {
+ const DOMTable = $getElementForTableNode(editor, table);
+ if (DOMTable) {
+ table
+ ?.getCellNodeFromCords(0, 0, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode('1'));
+ table
+ ?.getCellNodeFromCords(1, 0, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode(''));
+ table
+ ?.getCellNodeFromCords(2, 0, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode('2'));
+ table
+ ?.getCellNodeFromCords(0, 1, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode('3'));
+ table
+ ?.getCellNodeFromCords(1, 1, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode('4'));
+ table
+ ?.getCellNodeFromCords(2, 1, DOMTable)
+ ?.getLastChild<ParagraphNode>()
+ ?.append($createTextNode(''));
+ const selection = $createTableSelection();
+ selection.set(
+ table.__key,
+ table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '',
+ table?.getCellNodeFromCords(2, 1, DOMTable)?.__key || '',
+ );
+ expect(selection.getTextContent()).toBe(`1\t\t2\n3\t4\t\n`);
+ }
+ }
+ });
+ });
+ },
+ undefined,
+ <TablePlugin />,
+ );
+});
--- /dev/null
+/**
+ * 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 {$createTableRowNode} from '@lexical/table';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+const editorConfig = Object.freeze({
+ namespace: '',
+ theme: {
+ tableRow: 'test-table-row-class',
+ },
+});
+
+describe('LexicalTableRowNode tests', () => {
+ initializeUnitTest((testEnv) => {
+ test('TableRowNode.constructor', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const rowNode = $createTableRowNode();
+
+ expect(rowNode).not.toBe(null);
+ });
+
+ expect(() => $createTableRowNode()).toThrow();
+ });
+
+ test('TableRowNode.createDOM()', async () => {
+ const {editor} = testEnv;
+
+ await editor.update(() => {
+ const rowNode = $createTableRowNode();
+ expect(rowNode.createDOM(editorConfig).outerHTML).toBe(
+ `<tr class="${editorConfig.theme.tableRow}"></tr>`,
+ );
+
+ const rowHeight = 36;
+ const rowWithCustomHeightNode = $createTableRowNode(36);
+ expect(rowWithCustomHeightNode.createDOM(editorConfig).outerHTML).toBe(
+ `<tr style="height: ${rowHeight}px;" class="${editorConfig.theme.tableRow}"></tr>`,
+ );
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {$createTableSelection} from '@lexical/table';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $setSelection,
+ EditorState,
+ type LexicalEditor,
+ ParagraphNode,
+ RootNode,
+ TextNode,
+} from 'lexical';
+import {createTestEditor} from 'lexical/src/__tests__/utils';
+import {createRef, useEffect, useMemo} from 'react';
+import {createRoot, Root} from 'react-dom/client';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+describe('table selection', () => {
+ let originalText: TextNode;
+ let parsedParagraph: ParagraphNode;
+ let parsedRoot: RootNode;
+ let parsedText: TextNode;
+ let paragraphKey: string;
+ let textKey: string;
+ let parsedEditorState: EditorState;
+ let reactRoot: Root;
+ let container: HTMLDivElement | null = null;
+ let editor: LexicalEditor | null = null;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ reactRoot = createRoot(container);
+ document.body.appendChild(container);
+ });
+
+ function useLexicalEditor(
+ rootElementRef: React.RefObject<HTMLDivElement>,
+ onError?: () => void,
+ ) {
+ const editorInHook = useMemo(
+ () =>
+ createTestEditor({
+ nodes: [],
+ onError: onError || jest.fn(),
+ theme: {
+ text: {
+ bold: 'editor-text-bold',
+ italic: 'editor-text-italic',
+ underline: 'editor-text-underline',
+ },
+ },
+ }),
+ [onError],
+ );
+
+ useEffect(() => {
+ const rootElement = rootElementRef.current;
+
+ editorInHook.setRootElement(rootElement);
+ }, [rootElementRef, editorInHook]);
+
+ return editorInHook;
+ }
+
+ function init(onError?: () => void) {
+ const ref = createRef<HTMLDivElement>();
+
+ function TestBase() {
+ editor = useLexicalEditor(ref, onError);
+
+ return <div ref={ref} contentEditable={true} />;
+ }
+
+ ReactTestUtils.act(() => {
+ reactRoot.render(<TestBase />);
+ });
+ }
+
+ async function update(fn: () => void) {
+ editor!.update(fn);
+
+ return Promise.resolve().then();
+ }
+
+ beforeEach(async () => {
+ init();
+
+ await update(() => {
+ const paragraph = $createParagraphNode();
+ originalText = $createTextNode('Hello world');
+ const selection = $createTableSelection();
+ selection.set(
+ originalText.getKey(),
+ originalText.getKey(),
+ originalText.getKey(),
+ );
+ $setSelection(selection);
+ paragraph.append(originalText);
+ $getRoot().append(paragraph);
+ });
+
+ const stringifiedEditorState = JSON.stringify(
+ editor!.getEditorState().toJSON(),
+ );
+
+ parsedEditorState = editor!.parseEditorState(stringifiedEditorState);
+ parsedEditorState.read(() => {
+ parsedRoot = $getRoot();
+ parsedParagraph = parsedRoot.getFirstChild()!;
+ paragraphKey = parsedParagraph.getKey();
+ parsedText = parsedParagraph.getFirstChild()!;
+ textKey = parsedText.getKey();
+ });
+ });
+
+ it('Parses the nodes of a stringified editor state', async () => {
+ expect(parsedRoot).toEqual({
+ __cachedText: null,
+ __dir: 'ltr',
+ __first: paragraphKey,
+ __format: 0,
+ __indent: 0,
+ __key: 'root',
+ __last: paragraphKey,
+ __next: null,
+ __parent: null,
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __type: 'root',
+ });
+ expect(parsedParagraph).toEqual({
+ __dir: 'ltr',
+ __first: textKey,
+ __format: 0,
+ __indent: 0,
+ __key: paragraphKey,
+ __last: textKey,
+ __next: null,
+ __parent: 'root',
+ __prev: null,
+ __size: 1,
+ __style: '',
+ __textFormat: 0,
+ __textStyle: '',
+ __type: 'paragraph',
+ });
+ expect(parsedText).toEqual({
+ __detail: 0,
+ __format: 0,
+ __key: textKey,
+ __mode: 0,
+ __next: null,
+ __parent: paragraphKey,
+ __prev: null,
+ __style: '',
+ __text: 'Hello world',
+ __type: 'text',
+ });
+ });
+
+ it('Parses the text content of the editor state', async () => {
+ expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(null);
+ expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
+ 'Hello world',
+ );
+ });
+});
--- /dev/null
+/**
+ * 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 const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/;
+
+// .PlaygroundEditorTheme__tableCell width value from
+// packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
+export const COLUMN_WIDTH = 75;
--- /dev/null
+/**
+ * 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 type {SerializedTableCellNode} from './LexicalTableCellNode';
+export {
+ $createTableCellNode,
+ $isTableCellNode,
+ TableCellHeaderStates,
+ TableCellNode,
+} from './LexicalTableCellNode';
+export type {
+ InsertTableCommandPayload,
+ InsertTableCommandPayloadHeaders,
+} from './LexicalTableCommands';
+export {INSERT_TABLE_COMMAND} from './LexicalTableCommands';
+export type {SerializedTableNode} from './LexicalTableNode';
+export {
+ $createTableNode,
+ $getElementForTableNode,
+ $isTableNode,
+ TableNode,
+} from './LexicalTableNode';
+export type {TableDOMCell} from './LexicalTableObserver';
+export {TableObserver} from './LexicalTableObserver';
+export type {SerializedTableRowNode} from './LexicalTableRowNode';
+export {
+ $createTableRowNode,
+ $isTableRowNode,
+ TableRowNode,
+} from './LexicalTableRowNode';
+export type {
+ TableMapType,
+ TableMapValueType,
+ TableSelection,
+ TableSelectionShape,
+} from './LexicalTableSelection';
+export {
+ $createTableSelection,
+ $isTableSelection,
+} from './LexicalTableSelection';
+export type {HTMLTableElementWithWithTableSelectionState} from './LexicalTableSelectionHelpers';
+export {
+ $findCellNode,
+ $findTableNode,
+ applyTableHandlers,
+ getDOMCellFromTarget,
+ getTableObserverFromTableElement,
+} from './LexicalTableSelectionHelpers';
+export {
+ $computeTableMap,
+ $computeTableMapSkipCellCheck,
+ $createTableNodeWithDimensions,
+ $deleteTableColumn,
+ $deleteTableColumn__EXPERIMENTAL,
+ $deleteTableRow__EXPERIMENTAL,
+ $getNodeTriplet,
+ $getTableCellNodeFromLexicalNode,
+ $getTableCellNodeRect,
+ $getTableColumnIndexFromTableCellNode,
+ $getTableNodeFromLexicalNodeOrThrow,
+ $getTableRowIndexFromTableCellNode,
+ $getTableRowNodeFromTableCellNodeOrThrow,
+ $insertTableColumn,
+ $insertTableColumn__EXPERIMENTAL,
+ $insertTableRow,
+ $insertTableRow__EXPERIMENTAL,
+ $removeTableRowAtIndex,
+ $unmergeCell,
+} from './LexicalTableUtils';
--- /dev/null
+/**
+ * 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 {
+ addClassNamesToElement,
+ removeClassNamesFromElement,
+} from '@lexical/utils';
+
+describe('LexicalElementHelpers tests', () => {
+ describe('addClassNamesToElement() and removeClassNamesFromElement()', () => {
+ test('basic', async () => {
+ const element = document.createElement('div');
+ addClassNamesToElement(element, 'test-class');
+
+ expect(element.className).toEqual('test-class');
+
+ removeClassNamesFromElement(element, 'test-class');
+
+ expect(element.className).toEqual('');
+ });
+
+ test('empty', async () => {
+ const element = document.createElement('div');
+ addClassNamesToElement(
+ element,
+ null,
+ undefined,
+ false,
+ true,
+ '',
+ ' ',
+ ' \t\n',
+ );
+
+ expect(element.className).toEqual('');
+ });
+
+ test('multiple', async () => {
+ const element = document.createElement('div');
+ addClassNamesToElement(element, 'a', 'b', 'c');
+
+ expect(element.className).toEqual('a b c');
+
+ removeClassNamesFromElement(element, 'a', 'b', 'c');
+
+ expect(element.className).toEqual('');
+ });
+
+ test('space separated', async () => {
+ const element = document.createElement('div');
+ addClassNamesToElement(element, 'a b c');
+
+ expect(element.className).toEqual('a b c');
+
+ removeClassNamesFromElement(element, 'a b c');
+
+ expect(element.className).toEqual('');
+ });
+ });
+
+ test('multiple spaces', async () => {
+ const classNames = ' a b c \t\n ';
+ const element = document.createElement('div');
+ addClassNamesToElement(element, classNames);
+
+ expect(element.className).toEqual('a b c');
+
+ removeClassNamesFromElement(element, classNames);
+
+ expect(element.className).toEqual('');
+ });
+});
--- /dev/null
+/**
+ * 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 {CodeHighlightNode, CodeNode} from '@lexical/code';
+import {HashtagNode} from '@lexical/hashtag';
+import {AutoLinkNode, LinkNode} from '@lexical/link';
+import {ListItemNode, ListNode} from '@lexical/list';
+import {OverflowNode} from '@lexical/overflow';
+import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {ContentEditable} from '@lexical/react/LexicalContentEditable';
+import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
+import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
+import {HeadingNode, QuoteNode} from '@lexical/rich-text';
+import {
+ applySelectionInputs,
+ pasteHTML,
+} from '@lexical/selection/src/__tests__/utils';
+import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
+import {LexicalEditor} from 'lexical';
+import {initializeClipboard, TestComposer} from 'lexical/src/__tests__/utils';
+import {createRoot} from 'react-dom/client';
+import * as ReactTestUtils from 'lexical/shared/react-test-utils';
+
+jest.mock('lexical/shared/environment', () => {
+ const originalModule = jest.requireActual('lexical/shared/environment');
+ return {...originalModule, IS_FIREFOX: true};
+});
+
+Range.prototype.getBoundingClientRect = function (): DOMRect {
+ const rect = {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+ return {
+ ...rect,
+ toJSON() {
+ return rect;
+ },
+ };
+};
+
+initializeClipboard();
+
+Range.prototype.getBoundingClientRect = function (): DOMRect {
+ const rect = {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+ return {
+ ...rect,
+ toJSON() {
+ return rect;
+ },
+ };
+};
+
+describe('LexicalEventHelpers', () => {
+ let container: HTMLDivElement | null = null;
+
+ beforeEach(async () => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ await init();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container!);
+ container = null;
+ });
+
+ let editor: LexicalEditor | null = null;
+
+ async function init() {
+ function TestBase() {
+ function TestPlugin(): null {
+ [editor] = useLexicalComposerContext();
+
+ return null;
+ }
+
+ return (
+ <TestComposer
+ config={{
+ nodes: [
+ LinkNode,
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ CodeNode,
+ TableNode,
+ TableCellNode,
+ TableRowNode,
+ HashtagNode,
+ CodeHighlightNode,
+ AutoLinkNode,
+ OverflowNode,
+ ],
+ theme: {
+ code: 'editor-code',
+ heading: {
+ h1: 'editor-heading-h1',
+ h2: 'editor-heading-h2',
+ h3: 'editor-heading-h3',
+ h4: 'editor-heading-h4',
+ h5: 'editor-heading-h5',
+ h6: 'editor-heading-h6',
+ },
+ image: 'editor-image',
+ list: {
+ listitem: 'editor-listitem',
+ olDepth: ['editor-list-ol'],
+ ulDepth: ['editor-list-ul'],
+ },
+ paragraph: 'editor-paragraph',
+ placeholder: 'editor-placeholder',
+ quote: 'editor-quote',
+ text: {
+ bold: 'editor-text-bold',
+ code: 'editor-text-code',
+ hashtag: 'editor-text-hashtag',
+ italic: 'editor-text-italic',
+ link: 'editor-text-link',
+ strikethrough: 'editor-text-strikethrough',
+ underline: 'editor-text-underline',
+ underlineStrikethrough: 'editor-text-underlineStrikethrough',
+ },
+ },
+ }}>
+ <RichTextPlugin
+ contentEditable={
+ // eslint-disable-next-line jsx-a11y/aria-role, @typescript-eslint/no-explicit-any
+ <ContentEditable role={null as any} spellCheck={null as any} />
+ }
+ placeholder={null}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ <AutoFocusPlugin />
+ <TestPlugin />
+ </TestComposer>
+ );
+ }
+
+ ReactTestUtils.act(() => {
+ createRoot(container!).render(<TestBase />);
+ });
+ }
+
+ async function update(fn: () => void) {
+ await ReactTestUtils.act(async () => {
+ await editor!.update(fn);
+ });
+
+ return Promise.resolve().then();
+ }
+
+ test('Expect initial output to be a block with no text', () => {
+ expect(container!.innerHTML).toBe(
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><br></p></div>',
+ );
+ });
+
+ describe('onPasteForRichText', () => {
+ describe('baseline', () => {
+ const suite = [
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 class="editor-heading-h1" dir="ltr"><span data-lexical-text="true">Hello</span></h1></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><h1>Hello</h1>`)],
+ name: 'should produce the correct editor state from a pasted HTML h1 element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h2 class="editor-heading-h2" dir="ltr"><span data-lexical-text="true">From</span></h2></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><h2>From</h2>`)],
+ name: 'should produce the correct editor state from a pasted HTML h2 element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h3 class="editor-heading-h3" dir="ltr"><span data-lexical-text="true">The</span></h3></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><h3>The</h3>`)],
+ name: 'should produce the correct editor state from a pasted HTML h3 element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><ul class="editor-list-ul"><li value="1" class="editor-listitem" dir="ltr"><span data-lexical-text="true">Other side</span></li><li value="2" class="editor-listitem" dir="ltr"><span data-lexical-text="true">I must have called</span></li></ul></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'><ul><li>Other side</li><li>I must have called</li></ul>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from a pasted HTML ul element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><ol class="editor-list-ol"><li value="1" class="editor-listitem" dir="ltr"><span data-lexical-text="true">To tell you</span></li><li value="2" class="editor-listitem" dir="ltr"><span data-lexical-text="true">I’m sorry</span></li></ol></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'><ol><li>To tell you</li><li>I’m sorry</li></ol>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from pasted HTML ol element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">A thousand times</span></p></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'>A thousand times`)],
+ name: 'should produce the correct editor state from pasted DOM Text Node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">Bold</strong></p></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><b>Bold</b>`)],
+ name: 'should produce the correct editor state from a pasted HTML b element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><em class="editor-text-italic" data-lexical-text="true">Italic</em></p></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><i>Italic</i>`)],
+ name: 'should produce the correct editor state from a pasted HTML i element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><em class="editor-text-italic" data-lexical-text="true">Italic</em></p></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><em>Italic</em>`)],
+ name: 'should produce the correct editor state from a pasted HTML em element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span class="editor-text-underline" data-lexical-text="true">Underline</span></p></div>',
+ inputs: [pasteHTML(`<meta charset='utf-8'><u>Underline</u>`)],
+ name: 'should produce the correct editor state from a pasted HTML u element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 class="editor-heading-h1" dir="ltr"><span data-lexical-text="true">Lyrics to Hello by Adele</span></h1><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">A thousand times</span></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'><h1>Lyrics to Hello by Adele</h1>A thousand times`,
+ ),
+ ],
+ name: 'should produce the correct editor state from pasted heading node followed by a DOM Text Node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><a href="https://facebook.com" dir="ltr"><span data-lexical-text="true">Facebook</span></a></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'><a href="https://facebook.com">Facebook</a>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from a pasted HTML anchor element',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Welcome to</span><a href="https://facebook.com" dir="ltr"><span data-lexical-text="true">Facebook!</span></a></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'>Welcome to<a href="https://facebook.com">Facebook!</a>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from a pasted combination of an HTML text node followed by an anchor node',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Welcome to</span><a href="https://facebook.com" dir="ltr"><span data-lexical-text="true">Facebook!</span></a><span data-lexical-text="true">We hope you like it here.</span></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'>Welcome to<a href="https://facebook.com">Facebook!</a>We hope you like it here.`,
+ ),
+ ],
+ name: 'should produce the correct editor state from a pasted combination of HTML anchor elements and text nodes',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><ul class="editor-list-ul"><li value="1" class="editor-listitem" dir="ltr"><span data-lexical-text="true">Hello</span></li><li value="2" class="editor-listitem" dir="ltr"><span data-lexical-text="true">from the other</span></li><li value="3" class="editor-listitem" dir="ltr"><span data-lexical-text="true">side</span></li></ul></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'><doesnotexist><ul><li>Hello</li><li>from the other</li><li>side</li></ul></doesnotexist>`,
+ ),
+ ],
+ name: 'should ignore DOM node types that do not have transformers, but still process their children.',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><ul class="editor-list-ul"><li value="1" class="editor-listitem" dir="ltr"><span data-lexical-text="true">Hello</span></li><li value="2" class="editor-listitem" dir="ltr"><span data-lexical-text="true">from the other</span></li><li value="3" class="editor-listitem" dir="ltr"><span data-lexical-text="true">side</span></li></ul></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'><doesnotexist><doesnotexist><ul><li>Hello</li><li>from the other</li><li>side</li></ul></doesnotexist></doesnotexist>`,
+ ),
+ ],
+ name: 'should ignore multiple levels of DOM node types that do not have transformers, but still process their children.',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Welcome to</span><a href="https://facebook.com" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">Facebook!</strong></a><span data-lexical-text="true">We hope you like it here.</span></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'>Welcome to<b><a href="https://facebook.com">Facebook!</a></b>We hope you like it here.`,
+ ),
+ ],
+ name: 'should preserve formatting from HTML tags on deeply nested text nodes.',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Welcome to</span><a href="https://facebook.com" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">Facebook!</strong></a><strong class="editor-text-bold" data-lexical-text="true">We hope you like it here.</strong></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'>Welcome to<b><a href="https://facebook.com">Facebook!</a>We hope you like it here.</b>`,
+ ),
+ ],
+ name: 'should preserve formatting from HTML tags on deeply nested and top level text nodes.',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Welcome to</span><a href="https://facebook.com" dir="ltr"><strong class="editor-text-bold editor-text-italic" data-lexical-text="true">Facebook!</strong></a><strong class="editor-text-bold editor-text-italic" data-lexical-text="true">We hope you like it here.</strong></p></div>',
+ inputs: [
+ pasteHTML(
+ `<meta charset='utf-8'>Welcome to<b><i><a href="https://facebook.com">Facebook!</a>We hope you like it here.</i></b>`,
+ ),
+ ],
+ name: 'should preserve multiple types of formatting on deeply nested text nodes and top level text nodes',
+ },
+ ];
+
+ suite.forEach((testUnit, i) => {
+ const name = testUnit.name || 'Test case';
+
+ test(name + ` (#${i + 1})`, async () => {
+ await applySelectionInputs(testUnit.inputs, update, editor!);
+
+ // Validate HTML matches
+ expect(container!.innerHTML).toBe(testUnit.expectedHTML);
+ });
+ });
+ });
+
+ describe('Google Docs', () => {
+ const suite = [
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Get schwifty!</span></p></div>',
+ inputs: [
+ pasteHTML(
+ `<b style="font-weight:normal;" id="docs-internal-guid-2c706577-7fff-f54a-fe65-12f480020fac"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Get schwifty!</span></b>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from Normal text',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">Get schwifty!</strong></p></div>',
+ inputs: [
+ pasteHTML(
+ `<b style="font-weight:normal;" id="docs-internal-guid-9db03964-7fff-c26c-8b1e-9484fb3b54a4"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Get schwifty!</span></b>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from bold text',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><em class="editor-text-italic" data-lexical-text="true">Get schwifty!</em></p></div>',
+ inputs: [
+ pasteHTML(
+ `<b style="font-weight:normal;" id="docs-internal-guid-9db03964-7fff-c26c-8b1e-9484fb3b54a4"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Get schwifty!</span></b>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from italic text',
+ },
+ {
+ expectedHTML:
+ '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span class="editor-text-strikethrough" data-lexical-text="true">Get schwifty!</span></p></div>',
+ inputs: [
+ pasteHTML(
+ `<b style="font-weight:normal;" id="docs-internal-guid-9db03964-7fff-c26c-8b1e-9484fb3b54a4"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:line-through;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Get schwifty!</span></b>`,
+ ),
+ ],
+ name: 'should produce the correct editor state from strikethrough text',
+ },
+ ];
+
+ suite.forEach((testUnit, i) => {
+ const name = testUnit.name || 'Test case';
+
+ test(name + ` (#${i + 1})`, async () => {
+ await applySelectionInputs(testUnit.inputs, update, editor!);
+
+ // Validate HTML matches
+ expect(container!.innerHTML).toBe(testUnit.expectedHTML);
+ });
+ });
+ });
+
+ describe('W3 spacing', () => {
+ const suite = [
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">hello world</span></p>',
+ inputs: [pasteHTML('<span>hello world</span>')],
+ name: 'inline hello world',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">hello world</span></p>',
+ inputs: [pasteHTML('<span> hello </span>world ')],
+ name: 'inline hello world (2)',
+ },
+ {
+ // MS Office got it right
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true"> hello world</span></p>',
+ inputs: [
+ pasteHTML(' <span style="white-space: pre"> hello </span> world '),
+ ],
+ name: 'pre + inline (inline collapses with pre)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true"> a b</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">c </span></p>',
+ inputs: [pasteHTML('<p style="white-space: pre"> a b\tc </p>')],
+ name: 'white-space: pre (1) (no touchy)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a b c</span></p>',
+ inputs: [pasteHTML('<p>\ta\tb <span>c\t</span>\t</p>')],
+ name: 'tabs are collapsed',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">hello world</span></p>',
+ inputs: [
+ pasteHTML(`
+ <div>
+ hello
+ world
+ </div>
+ `),
+ ],
+ name: 'remove beginning + end spaces on the block',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">hello world</strong></p>',
+ inputs: [
+ pasteHTML(`
+ <div>
+ <strong>
+ hello
+ world
+ </strong>
+ </div>
+ `),
+ ],
+ name: 'remove beginning + end spaces on the block (2)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a </span><strong class="editor-text-bold" data-lexical-text="true">b</strong><span data-lexical-text="true"> c</span></p>',
+ inputs: [
+ pasteHTML(`
+ <div>
+ a
+ <strong>b</strong>
+ c
+ </div>
+ `),
+ ],
+ name: 'remove beginning + end spaces on the block + anonymous inlines collapsible rules',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">a </strong><span data-lexical-text="true">b</span></p>',
+ inputs: [pasteHTML('<div><strong>a </strong>b</div>')],
+ name: 'collapsibles and neighbors (1)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span><strong class="editor-text-bold" data-lexical-text="true"> b</strong></p>',
+ inputs: [pasteHTML('<div>a<strong> b</strong></div>')],
+ name: 'collapsibles and neighbors (2)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">a </strong><span data-lexical-text="true">b</span></p>',
+ inputs: [pasteHTML('<div><strong>a </strong><span></span>b</div>')],
+ name: 'collapsibles and neighbors (3)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span><strong class="editor-text-bold" data-lexical-text="true"> b</strong></p>',
+ inputs: [pasteHTML('<div>a<span></span><strong> b</strong></div>')],
+ name: 'collapsibles and neighbors (4)',
+ },
+ {
+ expectedHTML: '<p class="editor-paragraph"><br></p>',
+ inputs: [
+ pasteHTML(`
+ <p>
+ </p>
+ `),
+ ],
+ name: 'empty block',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span></p>',
+ inputs: [pasteHTML('<span> </span><span>a</span>')],
+ name: 'redundant inline at start',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span></p>',
+ inputs: [pasteHTML('<span>a</span><span> </span>')],
+ name: 'redundant inline at end',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">b</span></p>',
+ inputs: [
+ pasteHTML(`
+ <div>
+ <p>
+ a
+ </p>
+ <p>
+ b
+ </p>
+ </div>
+ `),
+ ],
+ name: 'collapsible spaces with nested structures',
+ },
+ // TODO no proper support for divs #4465
+ // {
+ // expectedHTML:
+ // '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">b</span></p>',
+ // inputs: [
+ // pasteHTML(`
+ // <div>
+ // <div>
+ // a
+ // </div>
+ // <div>
+ // b
+ // </div>
+ // </div>
+ // `),
+ // ],
+ // name: 'collapsible spaces with nested structures (2)',
+ // },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">a b</strong></p>',
+ inputs: [
+ pasteHTML(`
+ <div>
+ <strong>
+ a
+ </strong>
+ <strong>
+ b
+ </strong>
+ </div>
+ `),
+ ],
+ name: 'collapsible spaces with nested structures (3)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span></p>',
+ inputs: [
+ pasteHTML(`
+ <p>
+ a
+ <br>
+ b
+ </p>
+ `),
+ ],
+ name: 'forced line break should remain',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span></p>',
+ inputs: [
+ pasteHTML(`
+ <p>
+ a
+ \t<br>\t
+ b
+ </p>
+ `),
+ ],
+ name: 'forced line break with tabs',
+ },
+ // The 3 below are not correct, they're missing the first \n -> <br> but that's a fault with
+ // the implementation of DOMParser, it works correctly in Safari
+ {
+ expectedHTML:
+ '<code class="editor-code" spellcheck="false" dir="ltr"><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span><br><br></code>',
+ inputs: [pasteHTML(`<pre>\na\r\nb\r\n</pre>`)],
+ name: 'pre (no touchy) (1)',
+ },
+ {
+ expectedHTML:
+ '<code class="editor-code" spellcheck="false" dir="ltr"><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span><br><br></code>',
+ inputs: [
+ pasteHTML(`
+ <pre>\na\r\nb\r\n</pre>
+ `),
+ ],
+ name: 'pre (no touchy) (2)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><br><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span><br><br></p>',
+ inputs: [
+ pasteHTML(`<span style="white-space: pre">\na\r\nb\r\n</span>`),
+ ],
+ name: 'white-space: pre (no touchy) (2)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">paragraph1</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">paragraph2</span></p>',
+ inputs: [
+ pasteHTML(
+ '\n<p class="p1">paragraph1</p>\n<p class="p1">paragraph2</p>\n',
+ ),
+ ],
+ name: 'two Apple Notes paragraphs',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">line 1</span><br><span data-lexical-text="true">line 2</span></p><p class="editor-paragraph"><br></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">paragraph 1</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">paragraph 2</span></p>',
+ inputs: [
+ pasteHTML(
+ '\n<p class="p1">line 1<br>\nline 2</p>\n<p class="p2"><br></p>\n<p class="p1">paragraph 1</p>\n<p class="p1">paragraph 2</p>\n',
+ ),
+ ],
+ name: 'two Apple Notes lines + two paragraphs separated by an empty paragraph',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">line 1</span><br><span data-lexical-text="true">line 2</span></p><p class="editor-paragraph"><br></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">paragraph 1</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">paragraph 2</span></p>',
+ inputs: [
+ pasteHTML(
+ '\n<p class="p1">line 1<br>\nline 2</p>\n<p class="p2">\n<br>\n</p>\n<p class="p1">paragraph 1</p>\n<p class="p1">paragraph 2</p>\n',
+ ),
+ ],
+ name: 'two lines + two paragraphs separated by an empty paragraph (2)',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">line 1</span><br><span data-lexical-text="true">line 2</span></p>',
+ inputs: [
+ pasteHTML(
+ '<p class="p1"><span>line 1</span><span><br></span><span>line 2</span></p>',
+ ),
+ ],
+ name: 'two lines and br in spans',
+ },
+ {
+ expectedHTML:
+ '<ol class="editor-list-ol"><li value="1" class="editor-listitem"><span data-lexical-text="true">1</span><br><span data-lexical-text="true">2</span></li><li value="2" class="editor-listitem"><br></li><li value="3" class="editor-listitem"><span data-lexical-text="true">3</span></li></ol>',
+ inputs: [
+ pasteHTML('<ol><li>1<div></div>2</li><li></li><li>3</li></ol>'),
+ ],
+ name: 'empty block node in li behaves like a line break',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph"><span data-lexical-text="true">1</span><br><span data-lexical-text="true">2</span></p>',
+ inputs: [pasteHTML('<div>1<div></div>2</div>')],
+ name: 'empty block node in div behaves like a line break',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph"><span data-lexical-text="true">12</span></p>',
+ inputs: [pasteHTML('<div>1<text></text>2</div>')],
+ name: 'empty inline node does not behave like a line break',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph"><span data-lexical-text="true">1</span></p><p class="editor-paragraph"><span data-lexical-text="true">2</span></p>',
+ inputs: [pasteHTML('<div><div>1</div><div></div><div>2</div></div>')],
+ name: 'empty block node between non inline siblings does not behave like a line break',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">b b</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">c</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">z</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">d e</span></p><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">fg</span></p>',
+ inputs: [
+ pasteHTML(
+ `<div>a<div>b b<div>c<div><div></div>z</div></div>d e</div>fg</div>`,
+ ),
+ ],
+ name: 'nested divs',
+ },
+ {
+ expectedHTML:
+ '<ol class="editor-list-ol"><li value="1" class="editor-listitem"><span data-lexical-text="true">1</span></li><li value="2" class="editor-listitem"><br></li><li value="3" class="editor-listitem"><span data-lexical-text="true">3</span></li></ol>',
+ inputs: [pasteHTML('<ol><li>1</li><li><br /></li><li>3</li></ol>')],
+ name: 'only br in a li',
+ },
+ {
+ expectedHTML:
+ '<p class="editor-paragraph"><span data-lexical-text="true">1</span></p><p class="editor-paragraph"><span data-lexical-text="true">2</span></p><p class="editor-paragraph"><span data-lexical-text="true">3</span></p>',
+ inputs: [pasteHTML('1<p>2<br /></p>3')],
+ name: 'last br in a block node is ignored',
+ },
+ ];
+
+ suite.forEach((testUnit, i) => {
+ const name = testUnit.name || 'Test case';
+
+ // eslint-disable-next-line no-only-tests/no-only-tests, dot-notation
+ const test_ = 'only' in testUnit && testUnit['only'] ? test.only : test;
+ test_(name + ` (#${i + 1})`, async () => {
+ await applySelectionInputs(testUnit.inputs, update, editor!);
+
+ // Validate HTML matches
+ expect((container!.firstChild as HTMLElement).innerHTML).toBe(
+ testUnit.expectedHTML,
+ );
+ });
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $createParagraphNode,
+ $createTextNode,
+ $getNodeByKey,
+ $getRoot,
+ $isElementNode,
+ LexicalEditor,
+ NodeKey,
+} from 'lexical';
+import {
+ $createTestElementNode,
+ initializeUnitTest,
+ invariant,
+} from 'lexical/src/__tests__/utils';
+
+import {$dfs} from '../..';
+
+describe('LexicalNodeHelpers tests', () => {
+ initializeUnitTest((testEnv) => {
+ /**
+ * R
+ * P1 P2
+ * B1 B2 T4 T5 B3
+ * T1 T2 T3 T6
+ *
+ * DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6
+ */
+ test('DFS node order', async () => {
+ const editor: LexicalEditor = testEnv.editor;
+
+ let expectedKeys: Array<{
+ depth: number;
+ node: NodeKey;
+ }> = [];
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph1 = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+
+ const block1 = $createTestElementNode();
+ const block2 = $createTestElementNode();
+ const block3 = $createTestElementNode();
+
+ const text1 = $createTextNode('text1');
+ const text2 = $createTextNode('text2');
+ const text3 = $createTextNode('text3');
+ const text4 = $createTextNode('text4');
+ const text5 = $createTextNode('text5');
+ const text6 = $createTextNode('text6');
+
+ root.append(paragraph1, paragraph2);
+ paragraph1.append(block1, block2);
+ paragraph2.append(text4, text5);
+
+ text5.toggleFormat('bold'); // Prevent from merging with text 4
+
+ paragraph2.append(block3);
+ block1.append(text1);
+ block2.append(text2, text3);
+
+ text3.toggleFormat('bold'); // Prevent from merging with text2
+
+ block3.append(text6);
+
+ expectedKeys = [
+ {
+ depth: 0,
+ node: root.getKey(),
+ },
+ {
+ depth: 1,
+ node: paragraph1.getKey(),
+ },
+ {
+ depth: 2,
+ node: block1.getKey(),
+ },
+ {
+ depth: 3,
+ node: text1.getKey(),
+ },
+ {
+ depth: 2,
+ node: block2.getKey(),
+ },
+ {
+ depth: 3,
+ node: text2.getKey(),
+ },
+ {
+ depth: 3,
+ node: text3.getKey(),
+ },
+ {
+ depth: 1,
+ node: paragraph2.getKey(),
+ },
+ {
+ depth: 2,
+ node: text4.getKey(),
+ },
+ {
+ depth: 2,
+ node: text5.getKey(),
+ },
+ {
+ depth: 2,
+ node: block3.getKey(),
+ },
+ {
+ depth: 3,
+ node: text6.getKey(),
+ },
+ ];
+ });
+
+ editor.getEditorState().read(() => {
+ const expectedNodes = expectedKeys.map(({depth, node: nodeKey}) => ({
+ depth,
+ node: $getNodeByKey(nodeKey)!.getLatest(),
+ }));
+
+ const first = expectedNodes[0];
+ const second = expectedNodes[1];
+ const last = expectedNodes[expectedNodes.length - 1];
+ const secondToLast = expectedNodes[expectedNodes.length - 2];
+
+ expect($dfs(first.node, last.node)).toEqual(expectedNodes);
+ expect($dfs(second.node, secondToLast.node)).toEqual(
+ expectedNodes.slice(1, expectedNodes.length - 1),
+ );
+ expect($dfs()).toEqual(expectedNodes);
+ expect($dfs($getRoot())).toEqual(expectedNodes);
+ });
+ });
+
+ test('DFS triggers getLatest()', async () => {
+ const editor: LexicalEditor = testEnv.editor;
+
+ let rootKey: string;
+ let paragraphKey: string;
+ let block1Key: string;
+ let block2Key: string;
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const block1 = $createTestElementNode();
+ const block2 = $createTestElementNode();
+
+ rootKey = root.getKey();
+ paragraphKey = paragraph.getKey();
+ block1Key = block1.getKey();
+ block2Key = block2.getKey();
+
+ root.append(paragraph);
+ paragraph.append(block1, block2);
+ });
+
+ await editor.update(() => {
+ const root = $getNodeByKey(rootKey);
+ const paragraph = $getNodeByKey(paragraphKey);
+ const block1 = $getNodeByKey(block1Key);
+ const block2 = $getNodeByKey(block2Key);
+
+ const block3 = $createTestElementNode();
+ invariant($isElementNode(block1));
+
+ block1.append(block3);
+
+ expect($dfs(root!)).toEqual([
+ {
+ depth: 0,
+ node: root!.getLatest(),
+ },
+ {
+ depth: 1,
+ node: paragraph!.getLatest(),
+ },
+ {
+ depth: 2,
+ node: block1.getLatest(),
+ },
+ {
+ depth: 3,
+ node: block3.getLatest(),
+ },
+ {
+ depth: 2,
+ node: block2!.getLatest(),
+ },
+ ]);
+ });
+ });
+
+ test('DFS of empty ParagraphNode returns only itself', async () => {
+ const editor: LexicalEditor = testEnv.editor;
+
+ let paragraphKey: string;
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ const text = $createTextNode('test');
+
+ paragraphKey = paragraph.getKey();
+
+ paragraph2.append(text);
+ root.append(paragraph, paragraph2);
+ });
+ await editor.update(() => {
+ const paragraph = $getNodeByKey(paragraphKey)!;
+
+ expect($dfs(paragraph ?? undefined)).toEqual([
+ {
+ depth: 1,
+ node: paragraph?.getLatest(),
+ },
+ ]);
+ });
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $isRootTextContentEmpty,
+ $isRootTextContentEmptyCurry,
+ $rootTextContent,
+} from '@lexical/text';
+import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+describe('LexicalRootHelpers tests', () => {
+ initializeUnitTest((testEnv) => {
+ it('textContent', async () => {
+ const editor = testEnv.editor;
+
+ expect(editor.getEditorState().read($rootTextContent)).toBe('');
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('foo');
+ root.append(paragraph);
+ paragraph.append(text);
+
+ expect($rootTextContent()).toBe('foo');
+ });
+
+ expect(editor.getEditorState().read($rootTextContent)).toBe('foo');
+ });
+
+ it('isBlank', async () => {
+ const editor = testEnv.editor;
+
+ expect(
+ editor
+ .getEditorState()
+ .read($isRootTextContentEmptyCurry(editor.isComposing())),
+ ).toBe(true);
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('foo');
+ root.append(paragraph);
+ paragraph.append(text);
+
+ expect($isRootTextContentEmpty(editor.isComposing())).toBe(false);
+ });
+
+ expect(
+ editor
+ .getEditorState()
+ .read($isRootTextContentEmptyCurry(editor.isComposing())),
+ ).toBe(false);
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {objectKlassEquals} from '@lexical/utils';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+
+class MyEvent extends Event {}
+
+class MyEvent2 extends Event {}
+
+let MyEventShadow: typeof Event = MyEvent;
+
+{
+ // eslint-disable-next-line no-shadow
+ class MyEvent extends Event {}
+ MyEventShadow = MyEvent;
+}
+
+describe('LexicalUtilsKlassEqual tests', () => {
+ initializeUnitTest((testEnv) => {
+ it('objectKlassEquals', async () => {
+ const eventInstance = new MyEvent('');
+ expect(eventInstance instanceof MyEvent).toBeTruthy();
+ expect(objectKlassEquals(eventInstance, MyEvent)).toBeTruthy();
+ expect(eventInstance instanceof MyEvent2).toBeFalsy();
+ expect(objectKlassEquals(eventInstance, MyEvent2)).toBeFalsy();
+ expect(eventInstance instanceof MyEventShadow).toBeFalsy();
+ expect(objectKlassEquals(eventInstance, MyEventShadow)).toBeTruthy();
+ });
+ });
+});
--- /dev/null
+/**
+ * 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 {ElementNode, LexicalEditor} from 'lexical';
+
+import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
+import {$getRoot, $isElementNode} from 'lexical';
+import {createTestEditor} from 'lexical/src/__tests__/utils';
+
+import {$splitNode} from '../../index';
+
+describe('LexicalUtils#splitNode', () => {
+ let editor: LexicalEditor;
+
+ const update = async (updateFn: () => void) => {
+ editor.update(updateFn);
+ await Promise.resolve();
+ };
+
+ beforeEach(async () => {
+ editor = createTestEditor();
+ editor._headless = true;
+ });
+
+ const testCases: Array<{
+ _: string;
+ expectedHtml: string;
+ initialHtml: string;
+ splitPath: Array<number>;
+ splitOffset: number;
+ only?: boolean;
+ }> = [
+ {
+ _: 'split paragraph in between two text nodes',
+ expectedHtml:
+ '<p><span style="white-space: pre-wrap;">Hello</span></p><p><span style="white-space: pre-wrap;">world</span></p>',
+ initialHtml: '<p><span>Hello</span><span>world</span></p>',
+ splitOffset: 1,
+ splitPath: [0],
+ },
+ {
+ _: 'split paragraph before the first text node',
+ expectedHtml:
+ '<p><br></p><p><span style="white-space: pre-wrap;">Hello</span><span style="white-space: pre-wrap;">world</span></p>',
+ initialHtml: '<p><span>Hello</span><span>world</span></p>',
+ splitOffset: 0,
+ splitPath: [0],
+ },
+ {
+ _: 'split paragraph after the last text node',
+ expectedHtml:
+ '<p><span style="white-space: pre-wrap;">Hello</span><span style="white-space: pre-wrap;">world</span></p><p><br></p>',
+ initialHtml: '<p><span>Hello</span><span>world</span></p>',
+ splitOffset: 2, // Any offset that is higher than children size
+ splitPath: [0],
+ },
+ {
+ _: 'split list items between two text nodes',
+ expectedHtml:
+ '<ul><li><span style="white-space: pre-wrap;">Hello</span></li></ul>' +
+ '<ul><li><span style="white-space: pre-wrap;">world</span></li></ul>',
+ initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
+ splitOffset: 1, // Any offset that is higher than children size
+ splitPath: [0, 0],
+ },
+ {
+ _: 'split list items before the first text node',
+ expectedHtml:
+ '<ul><li></li></ul>' +
+ '<ul><li><span style="white-space: pre-wrap;">Hello</span><span style="white-space: pre-wrap;">world</span></li></ul>',
+ initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
+ splitOffset: 0, // Any offset that is higher than children size
+ splitPath: [0, 0],
+ },
+ {
+ _: 'split nested list items',
+ expectedHtml:
+ '<ul>' +
+ '<li><span style="white-space: pre-wrap;">Before</span></li>' +
+ '<li><ul><li><span style="white-space: pre-wrap;">Hello</span></li></ul></li>' +
+ '</ul>' +
+ '<ul>' +
+ '<li><ul><li><span style="white-space: pre-wrap;">world</span></li></ul></li>' +
+ '<li><span style="white-space: pre-wrap;">After</span></li>' +
+ '</ul>',
+ initialHtml:
+ '<ul>' +
+ '<li><span>Before</span></li>' +
+ '<ul><li><span>Hello</span><span>world</span></li></ul>' +
+ '<li><span>After</span></li>' +
+ '</ul>',
+ splitOffset: 1, // Any offset that is higher than children size
+ splitPath: [0, 1, 0, 0],
+ },
+ ];
+
+ for (const testCase of testCases) {
+ it(testCase._, async () => {
+ await update(() => {
+ // Running init, update, assert in the same update loop
+ // to skip text nodes normalization (then separate text
+ // nodes will still be separate and represented by its own
+ // spans in html output) and make assertions more precise
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(testCase.initialHtml, 'text/html');
+ const nodesToInsert = $generateNodesFromDOM(editor, dom);
+ $getRoot()
+ .clear()
+ .append(...nodesToInsert);
+
+ let nodeToSplit: ElementNode = $getRoot();
+ for (const index of testCase.splitPath) {
+ nodeToSplit = nodeToSplit.getChildAtIndex(index)!;
+ if (!$isElementNode(nodeToSplit)) {
+ throw new Error('Expected node to be element');
+ }
+ }
+
+ $splitNode(nodeToSplit, testCase.splitOffset);
+
+ // Cleaning up list value attributes as it's not really needed in this test
+ // and it clutters expected output
+ const actualHtml = $generateHtmlFromNodes(editor).replace(
+ /\svalue="\d{1,}"/g,
+ '',
+ );
+ expect(actualHtml).toEqual(testCase.expectedHtml);
+ });
+ });
+ }
+
+ it('throws when splitting root', async () => {
+ await update(() => {
+ expect(() => $splitNode($getRoot(), 0)).toThrow();
+ });
+ });
+});
--- /dev/null
+/**
+ * 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, LexicalNode} from 'lexical';
+
+import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
+import {
+ $createRangeSelection,
+ $getRoot,
+ $isElementNode,
+ $setSelection,
+} from 'lexical';
+import {
+ $createTestDecoratorNode,
+ createTestEditor,
+} from 'lexical/src/__tests__/utils';
+
+import {$insertNodeToNearestRoot} from '../..';
+
+describe('LexicalUtils#insertNodeToNearestRoot', () => {
+ let editor: LexicalEditor;
+
+ const update = async (updateFn: () => void) => {
+ editor.update(updateFn);
+ await Promise.resolve();
+ };
+
+ beforeEach(async () => {
+ editor = createTestEditor();
+ editor._headless = true;
+ });
+
+ const testCases: Array<{
+ _: string;
+ expectedHtml: string;
+ initialHtml: string;
+ selectionPath: Array<number>;
+ selectionOffset: number;
+ only?: boolean;
+ }> = [
+ {
+ _: 'insert into paragraph in between two text nodes',
+ expectedHtml:
+ '<p><span style="white-space: pre-wrap;">Hello</span></p><test-decorator></test-decorator><p><span style="white-space: pre-wrap;">world</span></p>',
+ initialHtml: '<p><span>Helloworld</span></p>',
+ selectionOffset: 5, // Selection on text node after "Hello" world
+ selectionPath: [0, 0],
+ },
+ {
+ _: 'insert into nested list items',
+ expectedHtml:
+ '<ul>' +
+ '<li><span style="white-space: pre-wrap;">Before</span></li>' +
+ '<li><ul><li><span style="white-space: pre-wrap;">Hello</span></li></ul></li>' +
+ '</ul>' +
+ '<test-decorator></test-decorator>' +
+ '<ul>' +
+ '<li><ul><li><span style="white-space: pre-wrap;">world</span></li></ul></li>' +
+ '<li><span style="white-space: pre-wrap;">After</span></li>' +
+ '</ul>',
+ initialHtml:
+ '<ul>' +
+ '<li><span>Before</span></li>' +
+ '<ul><li><span>Helloworld</span></li></ul>' +
+ '<li><span>After</span></li>' +
+ '</ul>',
+ selectionOffset: 5, // Selection on text node after "Hello" world
+ selectionPath: [0, 1, 0, 0, 0],
+ },
+ {
+ _: 'insert into empty paragraph',
+ expectedHtml: '<p><br></p><test-decorator></test-decorator><p><br></p>',
+ initialHtml: '<p></p>',
+ selectionOffset: 0, // Selection on text node after "Hello" world
+ selectionPath: [0],
+ },
+ {
+ _: 'insert in the end of paragraph',
+ expectedHtml:
+ '<p><span style="white-space: pre-wrap;">Hello world</span></p>' +
+ '<test-decorator></test-decorator>' +
+ '<p><br></p>',
+ initialHtml: '<p>Hello world</p>',
+ selectionOffset: 12, // Selection on text node after "Hello" world
+ selectionPath: [0, 0],
+ },
+ {
+ _: 'insert in the beginning of paragraph',
+ expectedHtml:
+ '<p><br></p>' +
+ '<test-decorator></test-decorator>' +
+ '<p><span style="white-space: pre-wrap;">Hello world</span></p>',
+ initialHtml: '<p>Hello world</p>',
+ selectionOffset: 0, // Selection on text node after "Hello" world
+ selectionPath: [0, 0],
+ },
+ {
+ _: 'insert with selection on root start',
+ expectedHtml:
+ '<test-decorator></test-decorator>' +
+ '<test-decorator></test-decorator>' +
+ '<p><span style="white-space: pre-wrap;">Before</span></p>' +
+ '<p><span style="white-space: pre-wrap;">After</span></p>',
+ initialHtml:
+ '<test-decorator></test-decorator>' +
+ '<p><span>Before</span></p>' +
+ '<p><span>After</span></p>',
+ selectionOffset: 0,
+ selectionPath: [],
+ },
+ {
+ _: 'insert with selection on root child',
+ expectedHtml:
+ '<p><span style="white-space: pre-wrap;">Before</span></p>' +
+ '<test-decorator></test-decorator>' +
+ '<p><span style="white-space: pre-wrap;">After</span></p>',
+ initialHtml: '<p>Before</p><p>After</p>',
+ selectionOffset: 1,
+ selectionPath: [],
+ },
+ {
+ _: 'insert with selection on root end',
+ expectedHtml:
+ '<p><span style="white-space: pre-wrap;">Before</span></p>' +
+ '<test-decorator></test-decorator>',
+ initialHtml: '<p>Before</p>',
+ selectionOffset: 1,
+ selectionPath: [],
+ },
+ ];
+
+ for (const testCase of testCases) {
+ it(testCase._, async () => {
+ await update(() => {
+ // Running init, update, assert in the same update loop
+ // to skip text nodes normalization (then separate text
+ // nodes will still be separate and represented by its own
+ // spans in html output) and make assertions more precise
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(testCase.initialHtml, 'text/html');
+ const nodesToInsert = $generateNodesFromDOM(editor, dom);
+ $getRoot()
+ .clear()
+ .append(...nodesToInsert);
+
+ let selectionNode: LexicalNode = $getRoot();
+ for (const index of testCase.selectionPath) {
+ if (!$isElementNode(selectionNode)) {
+ throw new Error(
+ 'Expected node to be element (to traverse the tree)',
+ );
+ }
+ selectionNode = selectionNode.getChildAtIndex(index)!;
+ }
+
+ // Calling selectionNode.select() would "normalize" selection and move it
+ // to text node (if available), while for the purpose of the test we'd want
+ // to use whatever was passed (e.g. keep selection on root node)
+ const selection = $createRangeSelection();
+ const type = $isElementNode(selectionNode) ? 'element' : 'text';
+ selection.anchor.key = selection.focus.key = selectionNode.getKey();
+ selection.anchor.offset = selection.focus.offset =
+ testCase.selectionOffset;
+ selection.anchor.type = selection.focus.type = type;
+ $setSelection(selection);
+
+ $insertNodeToNearestRoot($createTestDecoratorNode());
+
+ // Cleaning up list value attributes as it's not really needed in this test
+ // and it clutters expected output
+ const actualHtml = $generateHtmlFromNodes(editor).replace(
+ /\svalue="\d{1,}"/g,
+ '',
+ );
+ expect(actualHtml).toEqual(testCase.expectedHtml);
+ });
+ });
+ }
+});
--- /dev/null
+/**
+ * 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 {mergeRegister} from '@lexical/utils';
+
+describe('mergeRegister', () => {
+ it('calls all of the clean-up functions', () => {
+ const cleanup = jest.fn();
+ mergeRegister(cleanup, cleanup)();
+ expect(cleanup).toHaveBeenCalledTimes(2);
+ });
+ it('calls the clean-up functions in reverse order', () => {
+ const cleanup = jest.fn();
+ mergeRegister(cleanup.bind(null, 1), cleanup.bind(null, 2))();
+ expect(cleanup.mock.calls.map(([v]) => v)).toEqual([2, 1]);
+ });
+});
--- /dev/null
+/**
+ * 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 {
+ $cloneWithProperties,
+ $createParagraphNode,
+ $getPreviousSelection,
+ $getRoot,
+ $getSelection,
+ $isElementNode,
+ $isRangeSelection,
+ $isRootOrShadowRoot,
+ $isTextNode,
+ $setSelection,
+ $splitNode,
+ EditorState,
+ ElementNode,
+ Klass,
+ LexicalEditor,
+ LexicalNode,
+} from 'lexical';
+// This underscore postfixing is used as a hotfix so we do not
+// export shared types from this module #5918
+import {CAN_USE_DOM as CAN_USE_DOM_} from 'lexical/shared/canUseDOM';
+import {
+ CAN_USE_BEFORE_INPUT as CAN_USE_BEFORE_INPUT_,
+ IS_ANDROID as IS_ANDROID_,
+ IS_ANDROID_CHROME as IS_ANDROID_CHROME_,
+ IS_APPLE as IS_APPLE_,
+ IS_APPLE_WEBKIT as IS_APPLE_WEBKIT_,
+ IS_CHROME as IS_CHROME_,
+ IS_FIREFOX as IS_FIREFOX_,
+ IS_IOS as IS_IOS_,
+ IS_SAFARI as IS_SAFARI_,
+} from 'lexical/shared/environment';
+import invariant from 'lexical/shared/invariant';
+import normalizeClassNames from 'lexical/shared/normalizeClassNames';
+
+export {default as markSelection} from './markSelection';
+export {default as mergeRegister} from './mergeRegister';
+export {default as positionNodeOnRange} from './positionNodeOnRange';
+export {
+ $splitNode,
+ isBlockDomNode,
+ isHTMLAnchorElement,
+ isHTMLElement,
+ isInlineDomNode,
+} from 'lexical';
+// Hotfix to export these with inlined types #5918
+export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_;
+export const CAN_USE_DOM: boolean = CAN_USE_DOM_;
+export const IS_ANDROID: boolean = IS_ANDROID_;
+export const IS_ANDROID_CHROME: boolean = IS_ANDROID_CHROME_;
+export const IS_APPLE: boolean = IS_APPLE_;
+export const IS_APPLE_WEBKIT: boolean = IS_APPLE_WEBKIT_;
+export const IS_CHROME: boolean = IS_CHROME_;
+export const IS_FIREFOX: boolean = IS_FIREFOX_;
+export const IS_IOS: boolean = IS_IOS_;
+export const IS_SAFARI: boolean = IS_SAFARI_;
+
+export type DFSNode = Readonly<{
+ depth: number;
+ node: LexicalNode;
+}>;
+
+/**
+ * Takes an HTML element and adds the classNames passed within an array,
+ * ignoring any non-string types. A space can be used to add multiple classes
+ * eg. addClassNamesToElement(element, ['element-inner active', true, null])
+ * will add both 'element-inner' and 'active' as classes to that element.
+ * @param element - The element in which the classes are added
+ * @param classNames - An array defining the class names to add to the element
+ */
+export function addClassNamesToElement(
+ element: HTMLElement,
+ ...classNames: Array<typeof undefined | boolean | null | string>
+): void {
+ const classesToAdd = normalizeClassNames(...classNames);
+ if (classesToAdd.length > 0) {
+ element.classList.add(...classesToAdd);
+ }
+}
+
+/**
+ * Takes an HTML element and removes the classNames passed within an array,
+ * ignoring any non-string types. A space can be used to remove multiple classes
+ * eg. removeClassNamesFromElement(element, ['active small', true, null])
+ * will remove both the 'active' and 'small' classes from that element.
+ * @param element - The element in which the classes are removed
+ * @param classNames - An array defining the class names to remove from the element
+ */
+export function removeClassNamesFromElement(
+ element: HTMLElement,
+ ...classNames: Array<typeof undefined | boolean | null | string>
+): void {
+ const classesToRemove = normalizeClassNames(...classNames);
+ if (classesToRemove.length > 0) {
+ element.classList.remove(...classesToRemove);
+ }
+}
+
+/**
+ * Returns true if the file type matches the types passed within the acceptableMimeTypes array, false otherwise.
+ * The types passed must be strings and are CASE-SENSITIVE.
+ * eg. if file is of type 'text' and acceptableMimeTypes = ['TEXT', 'IMAGE'] the function will return false.
+ * @param file - The file you want to type check.
+ * @param acceptableMimeTypes - An array of strings of types which the file is checked against.
+ * @returns true if the file is an acceptable mime type, false otherwise.
+ */
+export function isMimeType(
+ file: File,
+ acceptableMimeTypes: Array<string>,
+): boolean {
+ for (const acceptableType of acceptableMimeTypes) {
+ if (file.type.startsWith(acceptableType)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Lexical File Reader with:
+ * 1. MIME type support
+ * 2. batched results (HistoryPlugin compatibility)
+ * 3. Order aware (respects the order when multiple Files are passed)
+ *
+ * const filesResult = await mediaFileReader(files, ['image/']);
+ * filesResult.forEach(file => editor.dispatchCommand('INSERT_IMAGE', \\{
+ * src: file.result,
+ * \\}));
+ */
+export function mediaFileReader(
+ files: Array<File>,
+ acceptableMimeTypes: Array<string>,
+): Promise<Array<{file: File; result: string}>> {
+ const filesIterator = files[Symbol.iterator]();
+ return new Promise((resolve, reject) => {
+ const processed: Array<{file: File; result: string}> = [];
+ const handleNextFile = () => {
+ const {done, value: file} = filesIterator.next();
+ if (done) {
+ return resolve(processed);
+ }
+ const fileReader = new FileReader();
+ fileReader.addEventListener('error', reject);
+ fileReader.addEventListener('load', () => {
+ const result = fileReader.result;
+ if (typeof result === 'string') {
+ processed.push({file, result});
+ }
+ handleNextFile();
+ });
+ if (isMimeType(file, acceptableMimeTypes)) {
+ fileReader.readAsDataURL(file);
+ } else {
+ handleNextFile();
+ }
+ };
+ handleNextFile();
+ });
+}
+
+/**
+ * "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end
+ * before backtracking and finding a new path. Consider solving a maze by hugging either wall, moving down a
+ * branch until you hit a dead-end (leaf) and backtracking to find the nearest branching path and repeat.
+ * It will then return all the nodes found in the search in an array of objects.
+ * @param startingNode - The node to start the search, if ommitted, it will start at the root node.
+ * @param endingNode - The node to end the search, if ommitted, it will find all descendants of the startingNode.
+ * @returns An array of objects of all the nodes found by the search, including their depth into the tree.
+ * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the ending node) so long as it exists
+ */
+export function $dfs(
+ startingNode?: LexicalNode,
+ endingNode?: LexicalNode,
+): Array<DFSNode> {
+ const nodes = [];
+ const start = (startingNode || $getRoot()).getLatest();
+ const end =
+ endingNode ||
+ ($isElementNode(start) ? start.getLastDescendant() || start : start);
+ let node: LexicalNode | null = start;
+ let depth = $getDepth(node);
+
+ while (node !== null && !node.is(end)) {
+ nodes.push({depth, node});
+
+ if ($isElementNode(node) && node.getChildrenSize() > 0) {
+ node = node.getFirstChild();
+ depth++;
+ } else {
+ // Find immediate sibling or nearest parent sibling
+ let sibling = null;
+
+ while (sibling === null && node !== null) {
+ sibling = node.getNextSibling();
+
+ if (sibling === null) {
+ node = node.getParent();
+ depth--;
+ } else {
+ node = sibling;
+ }
+ }
+ }
+ }
+
+ if (node !== null && node.is(end)) {
+ nodes.push({depth, node});
+ }
+
+ return nodes;
+}
+
+function $getDepth(node: LexicalNode): number {
+ let innerNode: LexicalNode | null = node;
+ let depth = 0;
+
+ while ((innerNode = innerNode.getParent()) !== null) {
+ depth++;
+ }
+
+ return depth;
+}
+
+/**
+ * Performs a right-to-left preorder tree traversal.
+ * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path.
+ * It will return the next node in traversal sequence after the startingNode.
+ * The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right.
+ * @param startingNode - The node to start the search.
+ * @returns The next node in pre-order right to left traversal sequence or `null`, if the node does not exist
+ */
+export function $getNextRightPreorderNode(
+ startingNode: LexicalNode,
+): LexicalNode | null {
+ let node: LexicalNode | null = startingNode;
+
+ if ($isElementNode(node) && node.getChildrenSize() > 0) {
+ node = node.getLastChild();
+ } else {
+ let sibling = null;
+
+ while (sibling === null && node !== null) {
+ sibling = node.getPreviousSibling();
+
+ if (sibling === null) {
+ node = node.getParent();
+ } else {
+ node = sibling;
+ }
+ }
+ }
+ return node;
+}
+
+/**
+ * Takes a node and traverses up its ancestors (toward the root node)
+ * in order to find a specific type of node.
+ * @param node - the node to begin searching.
+ * @param klass - an instance of the type of node to look for.
+ * @returns the node of type klass that was passed, or null if none exist.
+ */
+export function $getNearestNodeOfType<T extends ElementNode>(
+ node: LexicalNode,
+ klass: Klass<T>,
+): T | null {
+ let parent: ElementNode | LexicalNode | null = node;
+
+ while (parent != null) {
+ if (parent instanceof klass) {
+ return parent as T;
+ }
+
+ parent = parent.getParent();
+ }
+
+ return null;
+}
+
+/**
+ * Returns the element node of the nearest ancestor, otherwise throws an error.
+ * @param startNode - The starting node of the search
+ * @returns The ancestor node found
+ */
+export function $getNearestBlockElementAncestorOrThrow(
+ startNode: LexicalNode,
+): ElementNode {
+ const blockNode = $findMatchingParent(
+ startNode,
+ (node) => $isElementNode(node) && !node.isInline(),
+ );
+ if (!$isElementNode(blockNode)) {
+ invariant(
+ false,
+ 'Expected node %s to have closest block element node.',
+ startNode.__key,
+ );
+ }
+ return blockNode;
+}
+
+export type DOMNodeToLexicalConversion = (element: Node) => LexicalNode;
+
+export type DOMNodeToLexicalConversionMap = Record<
+ string,
+ DOMNodeToLexicalConversion
+>;
+
+/**
+ * Starts with a node and moves up the tree (toward the root node) to find a matching node based on
+ * the search parameters of the findFn. (Consider JavaScripts' .find() function where a testing function must be
+ * passed as an argument. eg. if( (node) => node.__type === 'div') ) return true; otherwise return false
+ * @param startingNode - The node where the search starts.
+ * @param findFn - A testing function that returns true if the current node satisfies the testing parameters.
+ * @returns A parent node that matches the findFn parameters, or null if one wasn't found.
+ */
+export const $findMatchingParent: {
+ <T extends LexicalNode>(
+ startingNode: LexicalNode,
+ findFn: (node: LexicalNode) => node is T,
+ ): T | null;
+ (
+ startingNode: LexicalNode,
+ findFn: (node: LexicalNode) => boolean,
+ ): LexicalNode | null;
+} = (
+ startingNode: LexicalNode,
+ findFn: (node: LexicalNode) => boolean,
+): LexicalNode | null => {
+ let curr: ElementNode | LexicalNode | null = startingNode;
+
+ while (curr !== $getRoot() && curr != null) {
+ if (findFn(curr)) {
+ return curr;
+ }
+
+ curr = curr.getParent();
+ }
+
+ return null;
+};
+
+/**
+ * Attempts to resolve nested element nodes of the same type into a single node of that type.
+ * It is generally used for marks/commenting
+ * @param editor - The lexical editor
+ * @param targetNode - The target for the nested element to be extracted from.
+ * @param cloneNode - See {@link $createMarkNode}
+ * @param handleOverlap - Handles any overlap between the node to extract and the targetNode
+ * @returns The lexical editor
+ */
+export function registerNestedElementResolver<N extends ElementNode>(
+ editor: LexicalEditor,
+ targetNode: Klass<N>,
+ cloneNode: (from: N) => N,
+ handleOverlap: (from: N, to: N) => void,
+): () => void {
+ const $isTargetNode = (node: LexicalNode | null | undefined): node is N => {
+ return node instanceof targetNode;
+ };
+
+ const $findMatch = (node: N): {child: ElementNode; parent: N} | null => {
+ // First validate we don't have any children that are of the target,
+ // as we need to handle them first.
+ const children = node.getChildren();
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+
+ if ($isTargetNode(child)) {
+ return null;
+ }
+ }
+
+ let parentNode: N | null = node;
+ let childNode = node;
+
+ while (parentNode !== null) {
+ childNode = parentNode;
+ parentNode = parentNode.getParent();
+
+ if ($isTargetNode(parentNode)) {
+ return {child: childNode, parent: parentNode};
+ }
+ }
+
+ return null;
+ };
+
+ const $elementNodeTransform = (node: N) => {
+ const match = $findMatch(node);
+
+ if (match !== null) {
+ const {child, parent} = match;
+
+ // Simple path, we can move child out and siblings into a new parent.
+
+ if (child.is(node)) {
+ handleOverlap(parent, node);
+ const nextSiblings = child.getNextSiblings();
+ const nextSiblingsLength = nextSiblings.length;
+ parent.insertAfter(child);
+
+ if (nextSiblingsLength !== 0) {
+ const newParent = cloneNode(parent);
+ child.insertAfter(newParent);
+
+ for (let i = 0; i < nextSiblingsLength; i++) {
+ newParent.append(nextSiblings[i]);
+ }
+ }
+
+ if (!parent.canBeEmpty() && parent.getChildrenSize() === 0) {
+ parent.remove();
+ }
+ } else {
+ // Complex path, we have a deep node that isn't a child of the
+ // target parent.
+ // TODO: implement this functionality
+ }
+ }
+ };
+
+ return editor.registerNodeTransform(targetNode, $elementNodeTransform);
+}
+
+/**
+ * Clones the editor and marks it as dirty to be reconciled. If there was a selection,
+ * it would be set back to its previous state, or null otherwise.
+ * @param editor - The lexical editor
+ * @param editorState - The editor's state
+ */
+export function $restoreEditorState(
+ editor: LexicalEditor,
+ editorState: EditorState,
+): void {
+ const FULL_RECONCILE = 2;
+ const nodeMap = new Map();
+ const activeEditorState = editor._pendingEditorState;
+
+ for (const [key, node] of editorState._nodeMap) {
+ nodeMap.set(key, $cloneWithProperties(node));
+ }
+
+ if (activeEditorState) {
+ activeEditorState._nodeMap = nodeMap;
+ }
+
+ editor._dirtyType = FULL_RECONCILE;
+ const selection = editorState._selection;
+ $setSelection(selection === null ? null : selection.clone());
+}
+
+/**
+ * If the selected insertion area is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}),
+ * the node will be appended there, otherwise, it will be inserted before the insertion area.
+ * If there is no selection where the node is to be inserted, it will be appended after any current nodes
+ * within the tree, as a child of the root node. A paragraph node will then be added after the inserted node and selected.
+ * @param node - The node to be inserted
+ * @returns The node after its insertion
+ */
+export function $insertNodeToNearestRoot<T extends LexicalNode>(node: T): T {
+ const selection = $getSelection() || $getPreviousSelection();
+
+ if ($isRangeSelection(selection)) {
+ const {focus} = selection;
+ const focusNode = focus.getNode();
+ const focusOffset = focus.offset;
+
+ if ($isRootOrShadowRoot(focusNode)) {
+ const focusChild = focusNode.getChildAtIndex(focusOffset);
+ if (focusChild == null) {
+ focusNode.append(node);
+ } else {
+ focusChild.insertBefore(node);
+ }
+ node.selectNext();
+ } else {
+ let splitNode: ElementNode;
+ let splitOffset: number;
+ if ($isTextNode(focusNode)) {
+ splitNode = focusNode.getParentOrThrow();
+ splitOffset = focusNode.getIndexWithinParent();
+ if (focusOffset > 0) {
+ splitOffset += 1;
+ focusNode.splitText(focusOffset);
+ }
+ } else {
+ splitNode = focusNode;
+ splitOffset = focusOffset;
+ }
+ const [, rightTree] = $splitNode(splitNode, splitOffset);
+ rightTree.insertBefore(node);
+ rightTree.selectStart();
+ }
+ } else {
+ if (selection != null) {
+ const nodes = selection.getNodes();
+ nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node);
+ } else {
+ const root = $getRoot();
+ root.append(node);
+ }
+ const paragraphNode = $createParagraphNode();
+ node.insertAfter(paragraphNode);
+ paragraphNode.select();
+ }
+ return node.getLatest();
+}
+
+/**
+ * Wraps the node into another node created from a createElementNode function, eg. $createParagraphNode
+ * @param node - Node to be wrapped.
+ * @param createElementNode - Creates a new lexical element to wrap the to-be-wrapped node and returns it.
+ * @returns A new lexical element with the previous node appended within (as a child, including its children).
+ */
+export function $wrapNodeInElement(
+ node: LexicalNode,
+ createElementNode: () => ElementNode,
+): ElementNode {
+ const elementNode = createElementNode();
+ node.replace(elementNode);
+ elementNode.append(node);
+ return elementNode;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type ObjectKlass<T> = new (...args: any[]) => T;
+
+/**
+ * @param object = The instance of the type
+ * @param objectClass = The class of the type
+ * @returns Whether the object is has the same Klass of the objectClass, ignoring the difference across window (e.g. different iframs)
+ */
+export function objectKlassEquals<T>(
+ object: unknown,
+ objectClass: ObjectKlass<T>,
+): boolean {
+ return object !== null
+ ? Object.getPrototypeOf(object).constructor.name === objectClass.name
+ : false;
+}
+
+/**
+ * Filter the nodes
+ * @param nodes Array of nodes that needs to be filtered
+ * @param filterFn A filter function that returns node if the current node satisfies the condition otherwise null
+ * @returns Array of filtered nodes
+ */
+
+export function $filter<T>(
+ nodes: Array<LexicalNode>,
+ filterFn: (node: LexicalNode) => null | T,
+): Array<T> {
+ const result: T[] = [];
+ for (let i = 0; i < nodes.length; i++) {
+ const node = filterFn(nodes[i]);
+ if (node !== null) {
+ result.push(node);
+ }
+ }
+ return result;
+}
+/**
+ * Appends the node before the first child of the parent node
+ * @param parent A parent node
+ * @param node Node that needs to be appended
+ */
+export function $insertFirst(parent: ElementNode, node: LexicalNode): void {
+ const firstChild = parent.getFirstChild();
+ if (firstChild !== null) {
+ firstChild.insertBefore(node);
+ } else {
+ parent.append(node);
+ }
+}
+
+/**
+ * Calculates the zoom level of an element as a result of using
+ * css zoom property.
+ * @param element
+ */
+export function calculateZoomLevel(element: Element | null): number {
+ if (IS_FIREFOX) {
+ return 1;
+ }
+ let zoom = 1;
+ while (element) {
+ zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom'));
+ element = element.parentElement;
+ }
+ return zoom;
+}
+
+/**
+ * Checks if the editor is a nested editor created by LexicalNestedComposer
+ */
+export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean {
+ return editor._parentEditor !== null;
+}
--- /dev/null
+/**
+ * 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 {
+ $getSelection,
+ $isRangeSelection,
+ type EditorState,
+ ElementNode,
+ type LexicalEditor,
+ TextNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import mergeRegister from './mergeRegister';
+import positionNodeOnRange from './positionNodeOnRange';
+import px from './px';
+
+export default function markSelection(
+ editor: LexicalEditor,
+ onReposition?: (node: Array<HTMLElement>) => void,
+): () => void {
+ let previousAnchorNode: null | TextNode | ElementNode = null;
+ let previousAnchorOffset: null | number = null;
+ let previousFocusNode: null | TextNode | ElementNode = null;
+ let previousFocusOffset: null | number = null;
+ let removeRangeListener: () => void = () => {};
+ function compute(editorState: EditorState) {
+ editorState.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ // TODO
+ previousAnchorNode = null;
+ previousAnchorOffset = null;
+ previousFocusNode = null;
+ previousFocusOffset = null;
+ removeRangeListener();
+ removeRangeListener = () => {};
+ return;
+ }
+ const {anchor, focus} = selection;
+ const currentAnchorNode = anchor.getNode();
+ const currentAnchorNodeKey = currentAnchorNode.getKey();
+ const currentAnchorOffset = anchor.offset;
+ const currentFocusNode = focus.getNode();
+ const currentFocusNodeKey = currentFocusNode.getKey();
+ const currentFocusOffset = focus.offset;
+ const currentAnchorNodeDOM = editor.getElementByKey(currentAnchorNodeKey);
+ const currentFocusNodeDOM = editor.getElementByKey(currentFocusNodeKey);
+ const differentAnchorDOM =
+ previousAnchorNode === null ||
+ currentAnchorNodeDOM === null ||
+ currentAnchorOffset !== previousAnchorOffset ||
+ currentAnchorNodeKey !== previousAnchorNode.getKey() ||
+ (currentAnchorNode !== previousAnchorNode &&
+ (!(previousAnchorNode instanceof TextNode) ||
+ currentAnchorNode.updateDOM(
+ previousAnchorNode,
+ currentAnchorNodeDOM,
+ editor._config,
+ )));
+ const differentFocusDOM =
+ previousFocusNode === null ||
+ currentFocusNodeDOM === null ||
+ currentFocusOffset !== previousFocusOffset ||
+ currentFocusNodeKey !== previousFocusNode.getKey() ||
+ (currentFocusNode !== previousFocusNode &&
+ (!(previousFocusNode instanceof TextNode) ||
+ currentFocusNode.updateDOM(
+ previousFocusNode,
+ currentFocusNodeDOM,
+ editor._config,
+ )));
+ if (differentAnchorDOM || differentFocusDOM) {
+ const anchorHTMLElement = editor.getElementByKey(
+ anchor.getNode().getKey(),
+ );
+ const focusHTMLElement = editor.getElementByKey(
+ focus.getNode().getKey(),
+ );
+ // TODO handle selection beyond the common TextNode
+ if (
+ anchorHTMLElement !== null &&
+ focusHTMLElement !== null &&
+ anchorHTMLElement.tagName === 'SPAN' &&
+ focusHTMLElement.tagName === 'SPAN'
+ ) {
+ const range = document.createRange();
+ let firstHTMLElement;
+ let firstOffset;
+ let lastHTMLElement;
+ let lastOffset;
+ if (focus.isBefore(anchor)) {
+ firstHTMLElement = focusHTMLElement;
+ firstOffset = focus.offset;
+ lastHTMLElement = anchorHTMLElement;
+ lastOffset = anchor.offset;
+ } else {
+ firstHTMLElement = anchorHTMLElement;
+ firstOffset = anchor.offset;
+ lastHTMLElement = focusHTMLElement;
+ lastOffset = focus.offset;
+ }
+ const firstTextNode = firstHTMLElement.firstChild;
+ invariant(
+ firstTextNode !== null,
+ 'Expected text node to be first child of span',
+ );
+ const lastTextNode = lastHTMLElement.firstChild;
+ invariant(
+ lastTextNode !== null,
+ 'Expected text node to be first child of span',
+ );
+ range.setStart(firstTextNode, firstOffset);
+ range.setEnd(lastTextNode, lastOffset);
+ removeRangeListener();
+ removeRangeListener = positionNodeOnRange(
+ editor,
+ range,
+ (domNodes) => {
+ for (const domNode of domNodes) {
+ const domNodeStyle = domNode.style;
+ if (domNodeStyle.background !== 'Highlight') {
+ domNodeStyle.background = 'Highlight';
+ }
+ if (domNodeStyle.color !== 'HighlightText') {
+ domNodeStyle.color = 'HighlightText';
+ }
+ if (domNodeStyle.zIndex !== '-1') {
+ domNodeStyle.zIndex = '-1';
+ }
+ if (domNodeStyle.pointerEvents !== 'none') {
+ domNodeStyle.pointerEvents = 'none';
+ }
+ if (domNodeStyle.marginTop !== px(-1.5)) {
+ domNodeStyle.marginTop = px(-1.5);
+ }
+ if (domNodeStyle.paddingTop !== px(4)) {
+ domNodeStyle.paddingTop = px(4);
+ }
+ if (domNodeStyle.paddingBottom !== px(0)) {
+ domNodeStyle.paddingBottom = px(0);
+ }
+ }
+ if (onReposition !== undefined) {
+ onReposition(domNodes);
+ }
+ },
+ );
+ }
+ }
+ previousAnchorNode = currentAnchorNode;
+ previousAnchorOffset = currentAnchorOffset;
+ previousFocusNode = currentFocusNode;
+ previousFocusOffset = currentFocusOffset;
+ });
+ }
+ compute(editor.getEditorState());
+ return mergeRegister(
+ editor.registerUpdateListener(({editorState}) => compute(editorState)),
+ removeRangeListener,
+ () => {
+ removeRangeListener();
+ },
+ );
+}
--- /dev/null
+/**
+ * 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.
+ *
+ */
+
+type Func = () => void;
+
+/**
+ * Returns a function that will execute all functions passed when called. It is generally used
+ * to register multiple lexical listeners and then tear them down with a single function call, such
+ * as React's useEffect hook.
+ * @example
+ * ```ts
+ * useEffect(() => {
+ * return mergeRegister(
+ * editor.registerCommand(...registerCommand1 logic),
+ * editor.registerCommand(...registerCommand2 logic),
+ * editor.registerCommand(...registerCommand3 logic)
+ * )
+ * }, [editor])
+ * ```
+ * In this case, useEffect is returning the function returned by mergeRegister as a cleanup
+ * function to be executed after either the useEffect runs again (due to one of its dependencies
+ * updating) or the component it resides in unmounts.
+ * Note the functions don't neccesarily need to be in an array as all arguments
+ * are considered to be the func argument and spread from there.
+ * The order of cleanup is the reverse of the argument order. Generally it is
+ * expected that the first "acquire" will be "released" last (LIFO order),
+ * because a later step may have some dependency on an earlier one.
+ * @param func - An array of cleanup functions meant to be executed by the returned function.
+ * @returns the function which executes all the passed cleanup functions.
+ */
+export default function mergeRegister(...func: Array<Func>): () => void {
+ return () => {
+ for (let i = func.length - 1; i >= 0; i--) {
+ func[i]();
+ }
+ // Clean up the references and make future calls a no-op
+ func.length = 0;
+ };
+}
--- /dev/null
+/**
+ * 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 {createRectsFromDOMRange} from '@lexical/selection';
+import invariant from 'lexical/shared/invariant';
+
+import px from './px';
+
+const mutationObserverConfig = {
+ attributes: true,
+ characterData: true,
+ childList: true,
+ subtree: true,
+};
+
+export default function positionNodeOnRange(
+ editor: LexicalEditor,
+ range: Range,
+ onReposition: (node: Array<HTMLElement>) => void,
+): () => void {
+ let rootDOMNode: null | HTMLElement = null;
+ let parentDOMNode: null | HTMLElement = null;
+ let observer: null | MutationObserver = null;
+ let lastNodes: Array<HTMLElement> = [];
+ const wrapperNode = document.createElement('div');
+
+ function position(): void {
+ invariant(rootDOMNode !== null, 'Unexpected null rootDOMNode');
+ invariant(parentDOMNode !== null, 'Unexpected null parentDOMNode');
+ const {left: rootLeft, top: rootTop} = rootDOMNode.getBoundingClientRect();
+ const parentDOMNode_ = parentDOMNode;
+ const rects = createRectsFromDOMRange(editor, range);
+ if (!wrapperNode.isConnected) {
+ parentDOMNode_.append(wrapperNode);
+ }
+ let hasRepositioned = false;
+ for (let i = 0; i < rects.length; i++) {
+ const rect = rects[i];
+ // Try to reuse the previously created Node when possible, no need to
+ // remove/create on the most common case reposition case
+ const rectNode = lastNodes[i] || document.createElement('div');
+ const rectNodeStyle = rectNode.style;
+ if (rectNodeStyle.position !== 'absolute') {
+ rectNodeStyle.position = 'absolute';
+ hasRepositioned = true;
+ }
+ const left = px(rect.left - rootLeft);
+ if (rectNodeStyle.left !== left) {
+ rectNodeStyle.left = left;
+ hasRepositioned = true;
+ }
+ const top = px(rect.top - rootTop);
+ if (rectNodeStyle.top !== top) {
+ rectNode.style.top = top;
+ hasRepositioned = true;
+ }
+ const width = px(rect.width);
+ if (rectNodeStyle.width !== width) {
+ rectNode.style.width = width;
+ hasRepositioned = true;
+ }
+ const height = px(rect.height);
+ if (rectNodeStyle.height !== height) {
+ rectNode.style.height = height;
+ hasRepositioned = true;
+ }
+ if (rectNode.parentNode !== wrapperNode) {
+ wrapperNode.append(rectNode);
+ hasRepositioned = true;
+ }
+ lastNodes[i] = rectNode;
+ }
+ while (lastNodes.length > rects.length) {
+ lastNodes.pop();
+ }
+ if (hasRepositioned) {
+ onReposition(lastNodes);
+ }
+ }
+
+ function stop(): void {
+ parentDOMNode = null;
+ rootDOMNode = null;
+ if (observer !== null) {
+ observer.disconnect();
+ }
+ observer = null;
+ wrapperNode.remove();
+ for (const node of lastNodes) {
+ node.remove();
+ }
+ lastNodes = [];
+ }
+
+ function restart(): void {
+ const currentRootDOMNode = editor.getRootElement();
+ if (currentRootDOMNode === null) {
+ return stop();
+ }
+ const currentParentDOMNode = currentRootDOMNode.parentElement;
+ if (!(currentParentDOMNode instanceof HTMLElement)) {
+ return stop();
+ }
+ stop();
+ rootDOMNode = currentRootDOMNode;
+ parentDOMNode = currentParentDOMNode;
+ observer = new MutationObserver((mutations) => {
+ const nextRootDOMNode = editor.getRootElement();
+ const nextParentDOMNode =
+ nextRootDOMNode && nextRootDOMNode.parentElement;
+ if (
+ nextRootDOMNode !== rootDOMNode ||
+ nextParentDOMNode !== parentDOMNode
+ ) {
+ return restart();
+ }
+ for (const mutation of mutations) {
+ if (!wrapperNode.contains(mutation.target)) {
+ // TODO throttle
+ return position();
+ }
+ }
+ });
+ observer.observe(currentParentDOMNode, mutationObserverConfig);
+ position();
+ }
+
+ const removeRootListener = editor.registerRootListener(restart);
+
+ return () => {
+ removeRootListener();
+ stop();
+ };
+}
--- /dev/null
+/**
+ * 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 default function px(value: number) {
+ return `${value}px`;
+}
--- /dev/null
+/**
+ * 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 {CollabDecoratorNode} from './CollabDecoratorNode';
+import type {CollabElementNode} from './CollabElementNode';
+import type {CollabLineBreakNode} from './CollabLineBreakNode';
+import type {CollabTextNode} from './CollabTextNode';
+import type {Cursor} from './SyncCursors';
+import type {LexicalEditor, NodeKey} from 'lexical';
+import type {Doc} from 'yjs';
+
+import {Klass, LexicalNode} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import {XmlText} from 'yjs';
+
+import {Provider} from '.';
+import {$createCollabElementNode} from './CollabElementNode';
+
+export type ClientID = number;
+export type Binding = {
+ clientID: number;
+ collabNodeMap: Map<
+ NodeKey,
+ | CollabElementNode
+ | CollabTextNode
+ | CollabDecoratorNode
+ | CollabLineBreakNode
+ >;
+ cursors: Map<ClientID, Cursor>;
+ cursorsContainer: null | HTMLElement;
+ doc: Doc;
+ docMap: Map<string, Doc>;
+ editor: LexicalEditor;
+ id: string;
+ nodeProperties: Map<string, Array<string>>;
+ root: CollabElementNode;
+ excludedProperties: ExcludedProperties;
+};
+export type ExcludedProperties = Map<Klass<LexicalNode>, Set<string>>;
+
+export function createBinding(
+ editor: LexicalEditor,
+ provider: Provider,
+ id: string,
+ doc: Doc | null | undefined,
+ docMap: Map<string, Doc>,
+ excludedProperties?: ExcludedProperties,
+): Binding {
+ invariant(
+ doc !== undefined && doc !== null,
+ 'createBinding: doc is null or undefined',
+ );
+ const rootXmlText = doc.get('root', XmlText) as XmlText;
+ const root: CollabElementNode = $createCollabElementNode(
+ rootXmlText,
+ null,
+ 'root',
+ );
+ root._key = 'root';
+ return {
+ clientID: doc.clientID,
+ collabNodeMap: new Map(),
+ cursors: new Map(),
+ cursorsContainer: null,
+ doc,
+ docMap,
+ editor,
+ excludedProperties: excludedProperties || new Map(),
+ id,
+ nodeProperties: new Map(),
+ root,
+ };
+}
--- /dev/null
+/**
+ * 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 {Binding} from '.';
+import type {CollabElementNode} from './CollabElementNode';
+import type {DecoratorNode, NodeKey, NodeMap} from 'lexical';
+import type {XmlElement} from 'yjs';
+
+import {$getNodeByKey, $isDecoratorNode} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {syncPropertiesFromLexical, syncPropertiesFromYjs} from './Utils';
+
+export class CollabDecoratorNode {
+ _xmlElem: XmlElement;
+ _key: NodeKey;
+ _parent: CollabElementNode;
+ _type: string;
+
+ constructor(xmlElem: XmlElement, parent: CollabElementNode, type: string) {
+ this._key = '';
+ this._xmlElem = xmlElem;
+ this._parent = parent;
+ this._type = type;
+ }
+
+ getPrevNode(nodeMap: null | NodeMap): null | DecoratorNode<unknown> {
+ if (nodeMap === null) {
+ return null;
+ }
+
+ const node = nodeMap.get(this._key);
+ return $isDecoratorNode(node) ? node : null;
+ }
+
+ getNode(): null | DecoratorNode<unknown> {
+ const node = $getNodeByKey(this._key);
+ return $isDecoratorNode(node) ? node : null;
+ }
+
+ getSharedType(): XmlElement {
+ return this._xmlElem;
+ }
+
+ getType(): string {
+ return this._type;
+ }
+
+ getKey(): NodeKey {
+ return this._key;
+ }
+
+ getSize(): number {
+ return 1;
+ }
+
+ getOffset(): number {
+ const collabElementNode = this._parent;
+ return collabElementNode.getChildOffset(this);
+ }
+
+ syncPropertiesFromLexical(
+ binding: Binding,
+ nextLexicalNode: DecoratorNode<unknown>,
+ prevNodeMap: null | NodeMap,
+ ): void {
+ const prevLexicalNode = this.getPrevNode(prevNodeMap);
+ const xmlElem = this._xmlElem;
+
+ syncPropertiesFromLexical(
+ binding,
+ xmlElem,
+ prevLexicalNode,
+ nextLexicalNode,
+ );
+ }
+
+ syncPropertiesFromYjs(
+ binding: Binding,
+ keysChanged: null | Set<string>,
+ ): void {
+ const lexicalNode = this.getNode();
+ invariant(
+ lexicalNode !== null,
+ 'syncPropertiesFromYjs: could not find decorator node',
+ );
+ const xmlElem = this._xmlElem;
+ syncPropertiesFromYjs(binding, xmlElem, lexicalNode, keysChanged);
+ }
+
+ destroy(binding: Binding): void {
+ const collabNodeMap = binding.collabNodeMap;
+ collabNodeMap.delete(this._key);
+ }
+}
+
+export function $createCollabDecoratorNode(
+ xmlElem: XmlElement,
+ parent: CollabElementNode,
+ type: string,
+): CollabDecoratorNode {
+ const collabNode = new CollabDecoratorNode(xmlElem, parent, type);
+ xmlElem._collabNode = collabNode;
+ return collabNode;
+}
--- /dev/null
+/**
+ * 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 {Binding} from '.';
+import type {ElementNode, NodeKey, NodeMap} from 'lexical';
+import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs';
+
+import {$createChildrenArray} from '@lexical/offset';
+import {
+ $getNodeByKey,
+ $isDecoratorNode,
+ $isElementNode,
+ $isTextNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+
+import {CollabDecoratorNode} from './CollabDecoratorNode';
+import {CollabLineBreakNode} from './CollabLineBreakNode';
+import {CollabTextNode} from './CollabTextNode';
+import {
+ $createCollabNodeFromLexicalNode,
+ $getNodeByKeyOrThrow,
+ $getOrInitCollabNodeFromSharedType,
+ createLexicalNodeFromCollabNode,
+ getPositionFromElementAndOffset,
+ removeFromParent,
+ spliceString,
+ syncPropertiesFromLexical,
+ syncPropertiesFromYjs,
+} from './Utils';
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+export class CollabElementNode {
+ _key: NodeKey;
+ _children: Array<
+ | CollabElementNode
+ | CollabTextNode
+ | CollabDecoratorNode
+ | CollabLineBreakNode
+ >;
+ _xmlText: XmlText;
+ _type: string;
+ _parent: null | CollabElementNode;
+
+ constructor(
+ xmlText: XmlText,
+ parent: null | CollabElementNode,
+ type: string,
+ ) {
+ this._key = '';
+ this._children = [];
+ this._xmlText = xmlText;
+ this._type = type;
+ this._parent = parent;
+ }
+
+ getPrevNode(nodeMap: null | NodeMap): null | ElementNode {
+ if (nodeMap === null) {
+ return null;
+ }
+
+ const node = nodeMap.get(this._key);
+ return $isElementNode(node) ? node : null;
+ }
+
+ getNode(): null | ElementNode {
+ const node = $getNodeByKey(this._key);
+ return $isElementNode(node) ? node : null;
+ }
+
+ getSharedType(): XmlText {
+ return this._xmlText;
+ }
+
+ getType(): string {
+ return this._type;
+ }
+
+ getKey(): NodeKey {
+ return this._key;
+ }
+
+ isEmpty(): boolean {
+ return this._children.length === 0;
+ }
+
+ getSize(): number {
+ return 1;
+ }
+
+ getOffset(): number {
+ const collabElementNode = this._parent;
+ invariant(
+ collabElementNode !== null,
+ 'getOffset: could not find collab element node',
+ );
+
+ return collabElementNode.getChildOffset(this);
+ }
+
+ syncPropertiesFromYjs(
+ binding: Binding,
+ keysChanged: null | Set<string>,
+ ): void {
+ const lexicalNode = this.getNode();
+ invariant(
+ lexicalNode !== null,
+ 'syncPropertiesFromYjs: could not find element node',
+ );
+ syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged);
+ }
+
+ applyChildrenYjsDelta(
+ binding: Binding,
+ deltas: Array<{
+ insert?: string | object | AbstractType<unknown>;
+ delete?: number;
+ retain?: number;
+ attributes?: {
+ [x: string]: unknown;
+ };
+ }>,
+ ): void {
+ const children = this._children;
+ let currIndex = 0;
+
+ for (let i = 0; i < deltas.length; i++) {
+ const delta = deltas[i];
+ const insertDelta = delta.insert;
+ const deleteDelta = delta.delete;
+
+ if (delta.retain != null) {
+ currIndex += delta.retain;
+ } else if (typeof deleteDelta === 'number') {
+ let deletionSize = deleteDelta;
+
+ while (deletionSize > 0) {
+ const {node, nodeIndex, offset, length} =
+ getPositionFromElementAndOffset(this, currIndex, false);
+
+ if (
+ node instanceof CollabElementNode ||
+ node instanceof CollabLineBreakNode ||
+ node instanceof CollabDecoratorNode
+ ) {
+ children.splice(nodeIndex, 1);
+ deletionSize -= 1;
+ } else if (node instanceof CollabTextNode) {
+ const delCount = Math.min(deletionSize, length);
+ const prevCollabNode =
+ nodeIndex !== 0 ? children[nodeIndex - 1] : null;
+ const nodeSize = node.getSize();
+
+ if (
+ offset === 0 &&
+ delCount === 1 &&
+ nodeIndex > 0 &&
+ prevCollabNode instanceof CollabTextNode &&
+ length === nodeSize &&
+ // If the node has no keys, it's been deleted
+ Array.from(node._map.keys()).length === 0
+ ) {
+ // Merge the text node with previous.
+ prevCollabNode._text += node._text;
+ children.splice(nodeIndex, 1);
+ } else if (offset === 0 && delCount === nodeSize) {
+ // The entire thing needs removing
+ children.splice(nodeIndex, 1);
+ } else {
+ node._text = spliceString(node._text, offset, delCount, '');
+ }
+
+ deletionSize -= delCount;
+ } else {
+ // Can occur due to the deletion from the dangling text heuristic below.
+ break;
+ }
+ }
+ } else if (insertDelta != null) {
+ if (typeof insertDelta === 'string') {
+ const {node, offset} = getPositionFromElementAndOffset(
+ this,
+ currIndex,
+ true,
+ );
+
+ if (node instanceof CollabTextNode) {
+ node._text = spliceString(node._text, offset, 0, insertDelta);
+ } else {
+ // TODO: maybe we can improve this by keeping around a redundant
+ // text node map, rather than removing all the text nodes, so there
+ // never can be dangling text.
+
+ // We have a conflict where there was likely a CollabTextNode and
+ // an Lexical TextNode too, but they were removed in a merge. So
+ // let's just ignore the text and trigger a removal for it from our
+ // shared type.
+ this._xmlText.delete(offset, insertDelta.length);
+ }
+
+ currIndex += insertDelta.length;
+ } else {
+ const sharedType = insertDelta;
+ const {nodeIndex} = getPositionFromElementAndOffset(
+ this,
+ currIndex,
+ false,
+ );
+ const collabNode = $getOrInitCollabNodeFromSharedType(
+ binding,
+ sharedType as XmlText | YMap<unknown> | XmlElement,
+ this,
+ );
+ children.splice(nodeIndex, 0, collabNode);
+ currIndex += 1;
+ }
+ } else {
+ throw new Error('Unexpected delta format');
+ }
+ }
+ }
+
+ syncChildrenFromYjs(binding: Binding): void {
+ // Now diff the children of the collab node with that of our existing Lexical node.
+ const lexicalNode = this.getNode();
+ invariant(
+ lexicalNode !== null,
+ 'syncChildrenFromYjs: could not find element node',
+ );
+
+ const key = lexicalNode.__key;
+ const prevLexicalChildrenKeys = $createChildrenArray(lexicalNode, null);
+ const nextLexicalChildrenKeys: Array<NodeKey> = [];
+ const lexicalChildrenKeysLength = prevLexicalChildrenKeys.length;
+ const collabChildren = this._children;
+ const collabChildrenLength = collabChildren.length;
+ const collabNodeMap = binding.collabNodeMap;
+ const visitedKeys = new Set();
+ let collabKeys;
+ let writableLexicalNode;
+ let prevIndex = 0;
+ let prevChildNode = null;
+
+ if (collabChildrenLength !== lexicalChildrenKeysLength) {
+ writableLexicalNode = lexicalNode.getWritable();
+ }
+
+ for (let i = 0; i < collabChildrenLength; i++) {
+ const lexicalChildKey = prevLexicalChildrenKeys[prevIndex];
+ const childCollabNode = collabChildren[i];
+ const collabLexicalChildNode = childCollabNode.getNode();
+ const collabKey = childCollabNode._key;
+
+ if (collabLexicalChildNode !== null && lexicalChildKey === collabKey) {
+ const childNeedsUpdating = $isTextNode(collabLexicalChildNode);
+ // Update
+ visitedKeys.add(lexicalChildKey);
+
+ if (childNeedsUpdating) {
+ childCollabNode._key = lexicalChildKey;
+
+ if (childCollabNode instanceof CollabElementNode) {
+ const xmlText = childCollabNode._xmlText;
+ childCollabNode.syncPropertiesFromYjs(binding, null);
+ childCollabNode.applyChildrenYjsDelta(binding, xmlText.toDelta());
+ childCollabNode.syncChildrenFromYjs(binding);
+ } else if (childCollabNode instanceof CollabTextNode) {
+ childCollabNode.syncPropertiesAndTextFromYjs(binding, null);
+ } else if (childCollabNode instanceof CollabDecoratorNode) {
+ childCollabNode.syncPropertiesFromYjs(binding, null);
+ } else if (!(childCollabNode instanceof CollabLineBreakNode)) {
+ invariant(
+ false,
+ 'syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node',
+ );
+ }
+ }
+
+ nextLexicalChildrenKeys[i] = lexicalChildKey;
+ prevChildNode = collabLexicalChildNode;
+ prevIndex++;
+ } else {
+ if (collabKeys === undefined) {
+ collabKeys = new Set();
+
+ for (let s = 0; s < collabChildrenLength; s++) {
+ const child = collabChildren[s];
+ const childKey = child._key;
+
+ if (childKey !== '') {
+ collabKeys.add(childKey);
+ }
+ }
+ }
+
+ if (
+ collabLexicalChildNode !== null &&
+ lexicalChildKey !== undefined &&
+ !collabKeys.has(lexicalChildKey)
+ ) {
+ const nodeToRemove = $getNodeByKeyOrThrow(lexicalChildKey);
+ removeFromParent(nodeToRemove);
+ i--;
+ prevIndex++;
+ continue;
+ }
+
+ writableLexicalNode = lexicalNode.getWritable();
+ // Create/Replace
+ const lexicalChildNode = createLexicalNodeFromCollabNode(
+ binding,
+ childCollabNode,
+ key,
+ );
+ const childKey = lexicalChildNode.__key;
+ collabNodeMap.set(childKey, childCollabNode);
+ nextLexicalChildrenKeys[i] = childKey;
+ if (prevChildNode === null) {
+ const nextSibling = writableLexicalNode.getFirstChild();
+ writableLexicalNode.__first = childKey;
+ if (nextSibling !== null) {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = childKey;
+ lexicalChildNode.__next = writableNextSibling.__key;
+ }
+ } else {
+ const writablePrevChildNode = prevChildNode.getWritable();
+ const nextSibling = prevChildNode.getNextSibling();
+ writablePrevChildNode.__next = childKey;
+ lexicalChildNode.__prev = prevChildNode.__key;
+ if (nextSibling !== null) {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = childKey;
+ lexicalChildNode.__next = writableNextSibling.__key;
+ }
+ }
+ if (i === collabChildrenLength - 1) {
+ writableLexicalNode.__last = childKey;
+ }
+ writableLexicalNode.__size++;
+ prevChildNode = lexicalChildNode;
+ }
+ }
+
+ for (let i = 0; i < lexicalChildrenKeysLength; i++) {
+ const lexicalChildKey = prevLexicalChildrenKeys[i];
+
+ if (!visitedKeys.has(lexicalChildKey)) {
+ // Remove
+ const lexicalChildNode = $getNodeByKeyOrThrow(lexicalChildKey);
+ const collabNode = binding.collabNodeMap.get(lexicalChildKey);
+
+ if (collabNode !== undefined) {
+ collabNode.destroy(binding);
+ }
+ removeFromParent(lexicalChildNode);
+ }
+ }
+ }
+
+ syncPropertiesFromLexical(
+ binding: Binding,
+ nextLexicalNode: ElementNode,
+ prevNodeMap: null | NodeMap,
+ ): void {
+ syncPropertiesFromLexical(
+ binding,
+ this._xmlText,
+ this.getPrevNode(prevNodeMap),
+ nextLexicalNode,
+ );
+ }
+
+ _syncChildFromLexical(
+ binding: Binding,
+ index: number,
+ key: NodeKey,
+ prevNodeMap: null | NodeMap,
+ dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+ dirtyLeaves: null | Set<NodeKey>,
+ ): void {
+ const childCollabNode = this._children[index];
+ // Update
+ const nextChildNode = $getNodeByKeyOrThrow(key);
+
+ if (
+ childCollabNode instanceof CollabElementNode &&
+ $isElementNode(nextChildNode)
+ ) {
+ childCollabNode.syncPropertiesFromLexical(
+ binding,
+ nextChildNode,
+ prevNodeMap,
+ );
+ childCollabNode.syncChildrenFromLexical(
+ binding,
+ nextChildNode,
+ prevNodeMap,
+ dirtyElements,
+ dirtyLeaves,
+ );
+ } else if (
+ childCollabNode instanceof CollabTextNode &&
+ $isTextNode(nextChildNode)
+ ) {
+ childCollabNode.syncPropertiesAndTextFromLexical(
+ binding,
+ nextChildNode,
+ prevNodeMap,
+ );
+ } else if (
+ childCollabNode instanceof CollabDecoratorNode &&
+ $isDecoratorNode(nextChildNode)
+ ) {
+ childCollabNode.syncPropertiesFromLexical(
+ binding,
+ nextChildNode,
+ prevNodeMap,
+ );
+ }
+ }
+
+ syncChildrenFromLexical(
+ binding: Binding,
+ nextLexicalNode: ElementNode,
+ prevNodeMap: null | NodeMap,
+ dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+ dirtyLeaves: null | Set<NodeKey>,
+ ): void {
+ const prevLexicalNode = this.getPrevNode(prevNodeMap);
+ const prevChildren =
+ prevLexicalNode === null
+ ? []
+ : $createChildrenArray(prevLexicalNode, prevNodeMap);
+ const nextChildren = $createChildrenArray(nextLexicalNode, null);
+ const prevEndIndex = prevChildren.length - 1;
+ const nextEndIndex = nextChildren.length - 1;
+ const collabNodeMap = binding.collabNodeMap;
+ let prevChildrenSet: Set<NodeKey> | undefined;
+ let nextChildrenSet: Set<NodeKey> | undefined;
+ let prevIndex = 0;
+ let nextIndex = 0;
+
+ while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
+ const prevKey = prevChildren[prevIndex];
+ const nextKey = nextChildren[nextIndex];
+
+ if (prevKey === nextKey) {
+ // Nove move, create or remove
+ this._syncChildFromLexical(
+ binding,
+ nextIndex,
+ nextKey,
+ prevNodeMap,
+ dirtyElements,
+ dirtyLeaves,
+ );
+
+ prevIndex++;
+ nextIndex++;
+ } else {
+ if (prevChildrenSet === undefined) {
+ prevChildrenSet = new Set(prevChildren);
+ }
+
+ if (nextChildrenSet === undefined) {
+ nextChildrenSet = new Set(nextChildren);
+ }
+
+ const nextHasPrevKey = nextChildrenSet.has(prevKey);
+ const prevHasNextKey = prevChildrenSet.has(nextKey);
+
+ if (!nextHasPrevKey) {
+ // Remove
+ this.splice(binding, nextIndex, 1);
+ prevIndex++;
+ } else {
+ // Create or replace
+ const nextChildNode = $getNodeByKeyOrThrow(nextKey);
+ const collabNode = $createCollabNodeFromLexicalNode(
+ binding,
+ nextChildNode,
+ this,
+ );
+ collabNodeMap.set(nextKey, collabNode);
+
+ if (prevHasNextKey) {
+ this.splice(binding, nextIndex, 1, collabNode);
+ prevIndex++;
+ nextIndex++;
+ } else {
+ this.splice(binding, nextIndex, 0, collabNode);
+ nextIndex++;
+ }
+ }
+ }
+ }
+
+ const appendNewChildren = prevIndex > prevEndIndex;
+ const removeOldChildren = nextIndex > nextEndIndex;
+
+ if (appendNewChildren && !removeOldChildren) {
+ for (; nextIndex <= nextEndIndex; ++nextIndex) {
+ const key = nextChildren[nextIndex];
+ const nextChildNode = $getNodeByKeyOrThrow(key);
+ const collabNode = $createCollabNodeFromLexicalNode(
+ binding,
+ nextChildNode,
+ this,
+ );
+ this.append(collabNode);
+ collabNodeMap.set(key, collabNode);
+ }
+ } else if (removeOldChildren && !appendNewChildren) {
+ for (let i = this._children.length - 1; i >= nextIndex; i--) {
+ this.splice(binding, i, 1);
+ }
+ }
+ }
+
+ append(
+ collabNode:
+ | CollabElementNode
+ | CollabDecoratorNode
+ | CollabTextNode
+ | CollabLineBreakNode,
+ ): void {
+ const xmlText = this._xmlText;
+ const children = this._children;
+ const lastChild = children[children.length - 1];
+ const offset =
+ lastChild !== undefined ? lastChild.getOffset() + lastChild.getSize() : 0;
+
+ if (collabNode instanceof CollabElementNode) {
+ xmlText.insertEmbed(offset, collabNode._xmlText);
+ } else if (collabNode instanceof CollabTextNode) {
+ const map = collabNode._map;
+
+ if (map.parent === null) {
+ xmlText.insertEmbed(offset, map);
+ }
+
+ xmlText.insert(offset + 1, collabNode._text);
+ } else if (collabNode instanceof CollabLineBreakNode) {
+ xmlText.insertEmbed(offset, collabNode._map);
+ } else if (collabNode instanceof CollabDecoratorNode) {
+ xmlText.insertEmbed(offset, collabNode._xmlElem);
+ }
+
+ this._children.push(collabNode);
+ }
+
+ splice(
+ binding: Binding,
+ index: number,
+ delCount: number,
+ collabNode?:
+ | CollabElementNode
+ | CollabDecoratorNode
+ | CollabTextNode
+ | CollabLineBreakNode,
+ ): void {
+ const children = this._children;
+ const child = children[index];
+
+ if (child === undefined) {
+ invariant(
+ collabNode !== undefined,
+ 'splice: could not find collab element node',
+ );
+ this.append(collabNode);
+ return;
+ }
+
+ const offset = child.getOffset();
+ invariant(offset !== -1, 'splice: expected offset to be greater than zero');
+
+ const xmlText = this._xmlText;
+
+ if (delCount !== 0) {
+ // What if we delete many nodes, don't we need to get all their
+ // sizes?
+ xmlText.delete(offset, child.getSize());
+ }
+
+ if (collabNode instanceof CollabElementNode) {
+ xmlText.insertEmbed(offset, collabNode._xmlText);
+ } else if (collabNode instanceof CollabTextNode) {
+ const map = collabNode._map;
+
+ if (map.parent === null) {
+ xmlText.insertEmbed(offset, map);
+ }
+
+ xmlText.insert(offset + 1, collabNode._text);
+ } else if (collabNode instanceof CollabLineBreakNode) {
+ xmlText.insertEmbed(offset, collabNode._map);
+ } else if (collabNode instanceof CollabDecoratorNode) {
+ xmlText.insertEmbed(offset, collabNode._xmlElem);
+ }
+
+ if (delCount !== 0) {
+ const childrenToDelete = children.slice(index, index + delCount);
+
+ for (let i = 0; i < childrenToDelete.length; i++) {
+ childrenToDelete[i].destroy(binding);
+ }
+ }
+
+ if (collabNode !== undefined) {
+ children.splice(index, delCount, collabNode);
+ } else {
+ children.splice(index, delCount);
+ }
+ }
+
+ getChildOffset(
+ collabNode:
+ | CollabElementNode
+ | CollabTextNode
+ | CollabDecoratorNode
+ | CollabLineBreakNode,
+ ): number {
+ let offset = 0;
+ const children = this._children;
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+
+ if (child === collabNode) {
+ return offset;
+ }
+
+ offset += child.getSize();
+ }
+
+ return -1;
+ }
+
+ destroy(binding: Binding): void {
+ const collabNodeMap = binding.collabNodeMap;
+ const children = this._children;
+
+ for (let i = 0; i < children.length; i++) {
+ children[i].destroy(binding);
+ }
+
+ collabNodeMap.delete(this._key);
+ }
+}
+
+export function $createCollabElementNode(
+ xmlText: XmlText,
+ parent: null | CollabElementNode,
+ type: string,
+): CollabElementNode {
+ const collabNode = new CollabElementNode(xmlText, parent, type);
+ xmlText._collabNode = collabNode;
+ return collabNode;
+}
--- /dev/null
+/**
+ * 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 {Binding} from '.';
+import type {CollabElementNode} from './CollabElementNode';
+import type {LineBreakNode, NodeKey} from 'lexical';
+import type {Map as YMap} from 'yjs';
+
+import {$getNodeByKey, $isLineBreakNode} from 'lexical';
+
+export class CollabLineBreakNode {
+ _map: YMap<unknown>;
+ _key: NodeKey;
+ _parent: CollabElementNode;
+ _type: 'linebreak';
+
+ constructor(map: YMap<unknown>, parent: CollabElementNode) {
+ this._key = '';
+ this._map = map;
+ this._parent = parent;
+ this._type = 'linebreak';
+ }
+
+ getNode(): null | LineBreakNode {
+ const node = $getNodeByKey(this._key);
+ return $isLineBreakNode(node) ? node : null;
+ }
+
+ getKey(): NodeKey {
+ return this._key;
+ }
+
+ getSharedType(): YMap<unknown> {
+ return this._map;
+ }
+
+ getType(): string {
+ return this._type;
+ }
+
+ getSize(): number {
+ return 1;
+ }
+
+ getOffset(): number {
+ const collabElementNode = this._parent;
+ return collabElementNode.getChildOffset(this);
+ }
+
+ destroy(binding: Binding): void {
+ const collabNodeMap = binding.collabNodeMap;
+ collabNodeMap.delete(this._key);
+ }
+}
+
+export function $createCollabLineBreakNode(
+ map: YMap<unknown>,
+ parent: CollabElementNode,
+): CollabLineBreakNode {
+ const collabNode = new CollabLineBreakNode(map, parent);
+ map._collabNode = collabNode;
+ return collabNode;
+}
--- /dev/null
+/**
+ * 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 {Binding} from '.';
+import type {CollabElementNode} from './CollabElementNode';
+import type {NodeKey, NodeMap, TextNode} from 'lexical';
+import type {Map as YMap} from 'yjs';
+
+import {
+ $getNodeByKey,
+ $getSelection,
+ $isRangeSelection,
+ $isTextNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import simpleDiffWithCursor from 'lexical/shared/simpleDiffWithCursor';
+
+import {syncPropertiesFromLexical, syncPropertiesFromYjs} from './Utils';
+
+function $diffTextContentAndApplyDelta(
+ collabNode: CollabTextNode,
+ key: NodeKey,
+ prevText: string,
+ nextText: string,
+): void {
+ const selection = $getSelection();
+ let cursorOffset = nextText.length;
+
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
+ const anchor = selection.anchor;
+
+ if (anchor.key === key) {
+ cursorOffset = anchor.offset;
+ }
+ }
+
+ const diff = simpleDiffWithCursor(prevText, nextText, cursorOffset);
+ collabNode.spliceText(diff.index, diff.remove, diff.insert);
+}
+
+export class CollabTextNode {
+ _map: YMap<unknown>;
+ _key: NodeKey;
+ _parent: CollabElementNode;
+ _text: string;
+ _type: string;
+ _normalized: boolean;
+
+ constructor(
+ map: YMap<unknown>,
+ text: string,
+ parent: CollabElementNode,
+ type: string,
+ ) {
+ this._key = '';
+ this._map = map;
+ this._parent = parent;
+ this._text = text;
+ this._type = type;
+ this._normalized = false;
+ }
+
+ getPrevNode(nodeMap: null | NodeMap): null | TextNode {
+ if (nodeMap === null) {
+ return null;
+ }
+
+ const node = nodeMap.get(this._key);
+ return $isTextNode(node) ? node : null;
+ }
+
+ getNode(): null | TextNode {
+ const node = $getNodeByKey(this._key);
+ return $isTextNode(node) ? node : null;
+ }
+
+ getSharedType(): YMap<unknown> {
+ return this._map;
+ }
+
+ getType(): string {
+ return this._type;
+ }
+
+ getKey(): NodeKey {
+ return this._key;
+ }
+
+ getSize(): number {
+ return this._text.length + (this._normalized ? 0 : 1);
+ }
+
+ getOffset(): number {
+ const collabElementNode = this._parent;
+ return collabElementNode.getChildOffset(this);
+ }
+
+ spliceText(index: number, delCount: number, newText: string): void {
+ const collabElementNode = this._parent;
+ const xmlText = collabElementNode._xmlText;
+ const offset = this.getOffset() + 1 + index;
+
+ if (delCount !== 0) {
+ xmlText.delete(offset, delCount);
+ }
+
+ if (newText !== '') {
+ xmlText.insert(offset, newText);
+ }
+ }
+
+ syncPropertiesAndTextFromLexical(
+ binding: Binding,
+ nextLexicalNode: TextNode,
+ prevNodeMap: null | NodeMap,
+ ): void {
+ const prevLexicalNode = this.getPrevNode(prevNodeMap);
+ const nextText = nextLexicalNode.__text;
+
+ syncPropertiesFromLexical(
+ binding,
+ this._map,
+ prevLexicalNode,
+ nextLexicalNode,
+ );
+
+ if (prevLexicalNode !== null) {
+ const prevText = prevLexicalNode.__text;
+
+ if (prevText !== nextText) {
+ const key = nextLexicalNode.__key;
+ $diffTextContentAndApplyDelta(this, key, prevText, nextText);
+ this._text = nextText;
+ }
+ }
+ }
+
+ syncPropertiesAndTextFromYjs(
+ binding: Binding,
+ keysChanged: null | Set<string>,
+ ): void {
+ const lexicalNode = this.getNode();
+ invariant(
+ lexicalNode !== null,
+ 'syncPropertiesAndTextFromYjs: could not find decorator node',
+ );
+
+ syncPropertiesFromYjs(binding, this._map, lexicalNode, keysChanged);
+
+ const collabText = this._text;
+
+ if (lexicalNode.__text !== collabText) {
+ const writable = lexicalNode.getWritable();
+ writable.__text = collabText;
+ }
+ }
+
+ destroy(binding: Binding): void {
+ const collabNodeMap = binding.collabNodeMap;
+ collabNodeMap.delete(this._key);
+ }
+}
+
+export function $createCollabTextNode(
+ map: YMap<unknown>,
+ text: string,
+ parent: CollabElementNode,
+ type: string,
+): CollabTextNode {
+ const collabNode = new CollabTextNode(map, text, parent, type);
+ map._collabNode = collabNode;
+ return collabNode;
+}
--- /dev/null
+/**
+ * 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 {Binding} from './Bindings';
+import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical';
+import type {AbsolutePosition, RelativePosition} from 'yjs';
+
+import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
+import {
+ $getNodeByKey,
+ $getSelection,
+ $isElementNode,
+ $isLineBreakNode,
+ $isRangeSelection,
+ $isTextNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import {
+ compareRelativePositions,
+ createAbsolutePositionFromRelativePosition,
+ createRelativePositionFromTypeIndex,
+} from 'yjs';
+
+import {Provider} from '.';
+import {CollabDecoratorNode} from './CollabDecoratorNode';
+import {CollabElementNode} from './CollabElementNode';
+import {CollabLineBreakNode} from './CollabLineBreakNode';
+import {CollabTextNode} from './CollabTextNode';
+import {getPositionFromElementAndOffset} from './Utils';
+
+export type CursorSelection = {
+ anchor: {
+ key: NodeKey;
+ offset: number;
+ };
+ caret: HTMLElement;
+ color: string;
+ focus: {
+ key: NodeKey;
+ offset: number;
+ };
+ name: HTMLSpanElement;
+ selections: Array<HTMLElement>;
+};
+export type Cursor = {
+ color: string;
+ name: string;
+ selection: null | CursorSelection;
+};
+
+function createRelativePosition(
+ point: Point,
+ binding: Binding,
+): null | RelativePosition {
+ const collabNodeMap = binding.collabNodeMap;
+ const collabNode = collabNodeMap.get(point.key);
+
+ if (collabNode === undefined) {
+ return null;
+ }
+
+ let offset = point.offset;
+ let sharedType = collabNode.getSharedType();
+
+ if (collabNode instanceof CollabTextNode) {
+ sharedType = collabNode._parent._xmlText;
+ const currentOffset = collabNode.getOffset();
+
+ if (currentOffset === -1) {
+ return null;
+ }
+
+ offset = currentOffset + 1 + offset;
+ } else if (
+ collabNode instanceof CollabElementNode &&
+ point.type === 'element'
+ ) {
+ const parent = point.getNode();
+ invariant($isElementNode(parent), 'Element point must be an element node');
+ let accumulatedOffset = 0;
+ let i = 0;
+ let node = parent.getFirstChild();
+ while (node !== null && i++ < offset) {
+ if ($isTextNode(node)) {
+ accumulatedOffset += node.getTextContentSize() + 1;
+ } else {
+ accumulatedOffset++;
+ }
+ node = node.getNextSibling();
+ }
+ offset = accumulatedOffset;
+ }
+
+ return createRelativePositionFromTypeIndex(sharedType, offset);
+}
+
+function createAbsolutePosition(
+ relativePosition: RelativePosition,
+ binding: Binding,
+): AbsolutePosition | null {
+ return createAbsolutePositionFromRelativePosition(
+ relativePosition,
+ binding.doc,
+ );
+}
+
+function shouldUpdatePosition(
+ currentPos: RelativePosition | null | undefined,
+ pos: RelativePosition | null | undefined,
+): boolean {
+ if (currentPos == null) {
+ if (pos != null) {
+ return true;
+ }
+ } else if (pos == null || !compareRelativePositions(currentPos, pos)) {
+ return true;
+ }
+
+ return false;
+}
+
+function createCursor(name: string, color: string): Cursor {
+ return {
+ color: color,
+ name: name,
+ selection: null,
+ };
+}
+
+function destroySelection(binding: Binding, selection: CursorSelection) {
+ const cursorsContainer = binding.cursorsContainer;
+
+ if (cursorsContainer !== null) {
+ const selections = selection.selections;
+ const selectionsLength = selections.length;
+
+ for (let i = 0; i < selectionsLength; i++) {
+ cursorsContainer.removeChild(selections[i]);
+ }
+ }
+}
+
+function destroyCursor(binding: Binding, cursor: Cursor) {
+ const selection = cursor.selection;
+
+ if (selection !== null) {
+ destroySelection(binding, selection);
+ }
+}
+
+function createCursorSelection(
+ cursor: Cursor,
+ anchorKey: NodeKey,
+ anchorOffset: number,
+ focusKey: NodeKey,
+ focusOffset: number,
+): CursorSelection {
+ const color = cursor.color;
+ const caret = document.createElement('span');
+ caret.style.cssText = `position:absolute;top:0;bottom:0;right:-1px;width:1px;background-color:${color};z-index:10;`;
+ const name = document.createElement('span');
+ name.textContent = cursor.name;
+ name.style.cssText = `position:absolute;left:-2px;top:-16px;background-color:${color};color:#fff;line-height:12px;font-size:12px;padding:2px;font-family:Arial;font-weight:bold;white-space:nowrap;`;
+ caret.appendChild(name);
+ return {
+ anchor: {
+ key: anchorKey,
+ offset: anchorOffset,
+ },
+ caret,
+ color,
+ focus: {
+ key: focusKey,
+ offset: focusOffset,
+ },
+ name,
+ selections: [],
+ };
+}
+
+function updateCursor(
+ binding: Binding,
+ cursor: Cursor,
+ nextSelection: null | CursorSelection,
+ nodeMap: NodeMap,
+): void {
+ const editor = binding.editor;
+ const rootElement = editor.getRootElement();
+ const cursorsContainer = binding.cursorsContainer;
+
+ if (cursorsContainer === null || rootElement === null) {
+ return;
+ }
+
+ const cursorsContainerOffsetParent = cursorsContainer.offsetParent;
+ if (cursorsContainerOffsetParent === null) {
+ return;
+ }
+
+ const containerRect = cursorsContainerOffsetParent.getBoundingClientRect();
+ const prevSelection = cursor.selection;
+
+ if (nextSelection === null) {
+ if (prevSelection === null) {
+ return;
+ } else {
+ cursor.selection = null;
+ destroySelection(binding, prevSelection);
+ return;
+ }
+ } else {
+ cursor.selection = nextSelection;
+ }
+
+ const caret = nextSelection.caret;
+ const color = nextSelection.color;
+ const selections = nextSelection.selections;
+ const anchor = nextSelection.anchor;
+ const focus = nextSelection.focus;
+ const anchorKey = anchor.key;
+ const focusKey = focus.key;
+ const anchorNode = nodeMap.get(anchorKey);
+ const focusNode = nodeMap.get(focusKey);
+
+ if (anchorNode == null || focusNode == null) {
+ return;
+ }
+ let selectionRects: Array<DOMRect>;
+
+ // In the case of a collapsed selection on a linebreak, we need
+ // to improvise as the browser will return nothing here as <br>
+ // apparantly take up no visual space :/
+ // This won't work in all cases, but it's better than just showing
+ // nothing all the time.
+ if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) {
+ const brRect = (
+ editor.getElementByKey(anchorKey) as HTMLElement
+ ).getBoundingClientRect();
+ selectionRects = [brRect];
+ } else {
+ const range = createDOMRange(
+ editor,
+ anchorNode,
+ anchor.offset,
+ focusNode,
+ focus.offset,
+ );
+
+ if (range === null) {
+ return;
+ }
+ selectionRects = createRectsFromDOMRange(editor, range);
+ }
+
+ const selectionsLength = selections.length;
+ const selectionRectsLength = selectionRects.length;
+
+ for (let i = 0; i < selectionRectsLength; i++) {
+ const selectionRect = selectionRects[i];
+ let selection = selections[i];
+
+ if (selection === undefined) {
+ selection = document.createElement('span');
+ selections[i] = selection;
+ const selectionBg = document.createElement('span');
+ selection.appendChild(selectionBg);
+ cursorsContainer.appendChild(selection);
+ }
+
+ const top = selectionRect.top - containerRect.top;
+ const left = selectionRect.left - containerRect.left;
+ const style = `position:absolute;top:${top}px;left:${left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;pointer-events:none;z-index:5;`;
+ selection.style.cssText = style;
+
+ (
+ selection.firstChild as HTMLSpanElement
+ ).style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`;
+
+ if (i === selectionRectsLength - 1) {
+ if (caret.parentNode !== selection) {
+ selection.appendChild(caret);
+ }
+ }
+ }
+
+ for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) {
+ const selection = selections[i];
+ cursorsContainer.removeChild(selection);
+ selections.pop();
+ }
+}
+
+export function $syncLocalCursorPosition(
+ binding: Binding,
+ provider: Provider,
+): void {
+ const awareness = provider.awareness;
+ const localState = awareness.getLocalState();
+
+ if (localState === null) {
+ return;
+ }
+
+ const anchorPos = localState.anchorPos;
+ const focusPos = localState.focusPos;
+
+ if (anchorPos !== null && focusPos !== null) {
+ const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
+ const focusAbsPos = createAbsolutePosition(focusPos, binding);
+
+ if (anchorAbsPos !== null && focusAbsPos !== null) {
+ const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
+ anchorAbsPos.type,
+ anchorAbsPos.index,
+ );
+ const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
+ focusAbsPos.type,
+ focusAbsPos.index,
+ );
+
+ if (anchorCollabNode !== null && focusCollabNode !== null) {
+ const anchorKey = anchorCollabNode.getKey();
+ const focusKey = focusCollabNode.getKey();
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+
+ $setPoint(anchor, anchorKey, anchorOffset);
+ $setPoint(focus, focusKey, focusOffset);
+ }
+ }
+ }
+}
+
+function $setPoint(point: Point, key: NodeKey, offset: number): void {
+ if (point.key !== key || point.offset !== offset) {
+ let anchorNode = $getNodeByKey(key);
+ if (
+ anchorNode !== null &&
+ !$isElementNode(anchorNode) &&
+ !$isTextNode(anchorNode)
+ ) {
+ const parent = anchorNode.getParentOrThrow();
+ key = parent.getKey();
+ offset = anchorNode.getIndexWithinParent();
+ anchorNode = parent;
+ }
+ point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text');
+ }
+}
+
+function getCollabNodeAndOffset(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sharedType: any,
+ offset: number,
+): [
+ (
+ | null
+ | CollabDecoratorNode
+ | CollabElementNode
+ | CollabTextNode
+ | CollabLineBreakNode
+ ),
+ number,
+] {
+ const collabNode = sharedType._collabNode;
+
+ if (collabNode === undefined) {
+ return [null, 0];
+ }
+
+ if (collabNode instanceof CollabElementNode) {
+ const {node, offset: collabNodeOffset} = getPositionFromElementAndOffset(
+ collabNode,
+ offset,
+ true,
+ );
+
+ if (node === null) {
+ return [collabNode, 0];
+ } else {
+ return [node, collabNodeOffset];
+ }
+ }
+
+ return [null, 0];
+}
+
+export function syncCursorPositions(
+ binding: Binding,
+ provider: Provider,
+): void {
+ const awarenessStates = Array.from(provider.awareness.getStates());
+ const localClientID = binding.clientID;
+ const cursors = binding.cursors;
+ const editor = binding.editor;
+ const nodeMap = editor._editorState._nodeMap;
+ const visitedClientIDs = new Set();
+
+ for (let i = 0; i < awarenessStates.length; i++) {
+ const awarenessState = awarenessStates[i];
+ const [clientID, awareness] = awarenessState;
+
+ if (clientID !== localClientID) {
+ visitedClientIDs.add(clientID);
+ const {anchorPos, focusPos, name, color, focusing} = awareness;
+ let selection = null;
+
+ let cursor = cursors.get(clientID);
+
+ if (cursor === undefined) {
+ cursor = createCursor(name, color);
+ cursors.set(clientID, cursor);
+ }
+
+ if (anchorPos !== null && focusPos !== null && focusing) {
+ const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
+ const focusAbsPos = createAbsolutePosition(focusPos, binding);
+
+ if (anchorAbsPos !== null && focusAbsPos !== null) {
+ const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
+ anchorAbsPos.type,
+ anchorAbsPos.index,
+ );
+ const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
+ focusAbsPos.type,
+ focusAbsPos.index,
+ );
+
+ if (anchorCollabNode !== null && focusCollabNode !== null) {
+ const anchorKey = anchorCollabNode.getKey();
+ const focusKey = focusCollabNode.getKey();
+ selection = cursor.selection;
+
+ if (selection === null) {
+ selection = createCursorSelection(
+ cursor,
+ anchorKey,
+ anchorOffset,
+ focusKey,
+ focusOffset,
+ );
+ } else {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ anchor.key = anchorKey;
+ anchor.offset = anchorOffset;
+ focus.key = focusKey;
+ focus.offset = focusOffset;
+ }
+ }
+ }
+ }
+
+ updateCursor(binding, cursor, selection, nodeMap);
+ }
+ }
+
+ const allClientIDs = Array.from(cursors.keys());
+
+ for (let i = 0; i < allClientIDs.length; i++) {
+ const clientID = allClientIDs[i];
+
+ if (!visitedClientIDs.has(clientID)) {
+ const cursor = cursors.get(clientID);
+
+ if (cursor !== undefined) {
+ destroyCursor(binding, cursor);
+ cursors.delete(clientID);
+ }
+ }
+ }
+}
+
+export function syncLexicalSelectionToYjs(
+ binding: Binding,
+ provider: Provider,
+ prevSelection: null | BaseSelection,
+ nextSelection: null | BaseSelection,
+): void {
+ const awareness = provider.awareness;
+ const localState = awareness.getLocalState();
+
+ if (localState === null) {
+ return;
+ }
+
+ const {
+ anchorPos: currentAnchorPos,
+ focusPos: currentFocusPos,
+ name,
+ color,
+ focusing,
+ awarenessData,
+ } = localState;
+ let anchorPos = null;
+ let focusPos = null;
+
+ if (
+ nextSelection === null ||
+ (currentAnchorPos !== null && !nextSelection.is(prevSelection))
+ ) {
+ if (prevSelection === null) {
+ return;
+ }
+ }
+
+ if ($isRangeSelection(nextSelection)) {
+ anchorPos = createRelativePosition(nextSelection.anchor, binding);
+ focusPos = createRelativePosition(nextSelection.focus, binding);
+ }
+
+ if (
+ shouldUpdatePosition(currentAnchorPos, anchorPos) ||
+ shouldUpdatePosition(currentFocusPos, focusPos)
+ ) {
+ awareness.setLocalState({
+ anchorPos,
+ awarenessData,
+ color,
+ focusPos,
+ focusing,
+ name,
+ });
+ }
+}
--- /dev/null
+/**
+ * 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 {EditorState, NodeKey} from 'lexical';
+
+import {
+ $createParagraphNode,
+ $getNodeByKey,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ $isTextNode,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs';
+
+import {Binding, Provider} from '.';
+import {CollabDecoratorNode} from './CollabDecoratorNode';
+import {CollabElementNode} from './CollabElementNode';
+import {CollabTextNode} from './CollabTextNode';
+import {
+ $syncLocalCursorPosition,
+ syncCursorPositions,
+ syncLexicalSelectionToYjs,
+} from './SyncCursors';
+import {
+ $getOrInitCollabNodeFromSharedType,
+ $moveSelectionToPreviousNode,
+ doesSelectionNeedRecovering,
+ syncWithTransaction,
+} from './Utils';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function $syncEvent(binding: Binding, event: any): void {
+ const {target} = event;
+ const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
+
+ if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
+ // @ts-expect-error We need to access the private property of the class
+ const {keysChanged, childListChanged, delta} = event;
+
+ // Update
+ if (keysChanged.size > 0) {
+ collabNode.syncPropertiesFromYjs(binding, keysChanged);
+ }
+
+ if (childListChanged) {
+ collabNode.applyChildrenYjsDelta(binding, delta);
+ collabNode.syncChildrenFromYjs(binding);
+ }
+ } else if (
+ collabNode instanceof CollabTextNode &&
+ event instanceof YMapEvent
+ ) {
+ const {keysChanged} = event;
+
+ // Update
+ if (keysChanged.size > 0) {
+ collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
+ }
+ } else if (
+ collabNode instanceof CollabDecoratorNode &&
+ event instanceof YXmlEvent
+ ) {
+ const {attributesChanged} = event;
+
+ // Update
+ if (attributesChanged.size > 0) {
+ collabNode.syncPropertiesFromYjs(binding, attributesChanged);
+ }
+ } else {
+ invariant(false, 'Expected text, element, or decorator event');
+ }
+}
+
+export function syncYjsChangesToLexical(
+ binding: Binding,
+ provider: Provider,
+ events: Array<YEvent<YText>>,
+ isFromUndoManger: boolean,
+): void {
+ const editor = binding.editor;
+ const currentEditorState = editor._editorState;
+
+ // This line precompute the delta before editor update. The reason is
+ // delta is computed when it is accessed. Note that this can only be
+ // safely computed during the event call. If it is accessed after event
+ // call it might result in unexpected behavior.
+ // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
+ events.forEach((event) => event.delta);
+
+ editor.update(
+ () => {
+ for (let i = 0; i < events.length; i++) {
+ const event = events[i];
+ $syncEvent(binding, event);
+ }
+
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ if (doesSelectionNeedRecovering(selection)) {
+ const prevSelection = currentEditorState._selection;
+
+ if ($isRangeSelection(prevSelection)) {
+ $syncLocalCursorPosition(binding, provider);
+ if (doesSelectionNeedRecovering(selection)) {
+ // If the selected node is deleted, move the selection to the previous or parent node.
+ const anchorNodeKey = selection.anchor.key;
+ $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState);
+ }
+ }
+
+ syncLexicalSelectionToYjs(
+ binding,
+ provider,
+ prevSelection,
+ $getSelection(),
+ );
+ } else {
+ $syncLocalCursorPosition(binding, provider);
+ }
+ }
+ },
+ {
+ onUpdate: () => {
+ syncCursorPositions(binding, provider);
+ // If there was a collision on the top level paragraph
+ // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
+ // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
+ editor.update(() => {
+ if ($getRoot().getChildrenSize() === 0) {
+ $getRoot().append($createParagraphNode());
+ }
+ });
+ },
+ skipTransforms: true,
+ tag: isFromUndoManger ? 'historic' : 'collaboration',
+ },
+ );
+}
+
+function $handleNormalizationMergeConflicts(
+ binding: Binding,
+ normalizedNodes: Set<NodeKey>,
+): void {
+ // We handle the merge operations here
+ const normalizedNodesKeys = Array.from(normalizedNodes);
+ const collabNodeMap = binding.collabNodeMap;
+ const mergedNodes = [];
+
+ for (let i = 0; i < normalizedNodesKeys.length; i++) {
+ const nodeKey = normalizedNodesKeys[i];
+ const lexicalNode = $getNodeByKey(nodeKey);
+ const collabNode = collabNodeMap.get(nodeKey);
+
+ if (collabNode instanceof CollabTextNode) {
+ if ($isTextNode(lexicalNode)) {
+ // We mutate the text collab nodes after removing
+ // all the dead nodes first, otherwise offsets break.
+ mergedNodes.push([collabNode, lexicalNode.__text]);
+ } else {
+ const offset = collabNode.getOffset();
+
+ if (offset === -1) {
+ continue;
+ }
+
+ const parent = collabNode._parent;
+ collabNode._normalized = true;
+
+ parent._xmlText.delete(offset, 1);
+
+ collabNodeMap.delete(nodeKey);
+ const parentChildren = parent._children;
+ const index = parentChildren.indexOf(collabNode);
+ parentChildren.splice(index, 1);
+ }
+ }
+ }
+
+ for (let i = 0; i < mergedNodes.length; i++) {
+ const [collabNode, text] = mergedNodes[i];
+ if (collabNode instanceof CollabTextNode && typeof text === 'string') {
+ collabNode._text = text;
+ }
+ }
+}
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+export function syncLexicalUpdateToYjs(
+ binding: Binding,
+ provider: Provider,
+ prevEditorState: EditorState,
+ currEditorState: EditorState,
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
+ dirtyLeaves: Set<NodeKey>,
+ normalizedNodes: Set<NodeKey>,
+ tags: Set<string>,
+): void {
+ syncWithTransaction(binding, () => {
+ currEditorState.read(() => {
+ // We check if the update has come from a origin where the origin
+ // was the collaboration binding previously. This can help us
+ // prevent unnecessarily re-diffing and possible re-applying
+ // the same change editor state again. For example, if a user
+ // types a character and we get it, we don't want to then insert
+ // the same character again. The exception to this heuristic is
+ // when we need to handle normalization merge conflicts.
+ if (tags.has('collaboration') || tags.has('historic')) {
+ if (normalizedNodes.size > 0) {
+ $handleNormalizationMergeConflicts(binding, normalizedNodes);
+ }
+
+ return;
+ }
+
+ if (dirtyElements.has('root')) {
+ const prevNodeMap = prevEditorState._nodeMap;
+ const nextLexicalRoot = $getRoot();
+ const collabRoot = binding.root;
+ collabRoot.syncPropertiesFromLexical(
+ binding,
+ nextLexicalRoot,
+ prevNodeMap,
+ );
+ collabRoot.syncChildrenFromLexical(
+ binding,
+ nextLexicalRoot,
+ prevNodeMap,
+ dirtyElements,
+ dirtyLeaves,
+ );
+ }
+
+ const selection = $getSelection();
+ const prevSelection = prevEditorState._selection;
+ syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
+ });
+ });
+}
--- /dev/null
+/**
+ * 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 {Binding, YjsNode} from '.';
+import type {
+ DecoratorNode,
+ EditorState,
+ ElementNode,
+ LexicalNode,
+ RangeSelection,
+ TextNode,
+} from 'lexical';
+
+import {
+ $getNodeByKey,
+ $getRoot,
+ $isDecoratorNode,
+ $isElementNode,
+ $isLineBreakNode,
+ $isRootNode,
+ $isTextNode,
+ createEditor,
+ NodeKey,
+} from 'lexical';
+import invariant from 'lexical/shared/invariant';
+import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs';
+
+import {
+ $createCollabDecoratorNode,
+ CollabDecoratorNode,
+} from './CollabDecoratorNode';
+import {$createCollabElementNode, CollabElementNode} from './CollabElementNode';
+import {
+ $createCollabLineBreakNode,
+ CollabLineBreakNode,
+} from './CollabLineBreakNode';
+import {$createCollabTextNode, CollabTextNode} from './CollabTextNode';
+
+const baseExcludedProperties = new Set<string>([
+ '__key',
+ '__parent',
+ '__next',
+ '__prev',
+]);
+const elementExcludedProperties = new Set<string>([
+ '__first',
+ '__last',
+ '__size',
+]);
+const rootExcludedProperties = new Set<string>(['__cachedText']);
+const textExcludedProperties = new Set<string>(['__text']);
+
+function isExcludedProperty(
+ name: string,
+ node: LexicalNode,
+ binding: Binding,
+): boolean {
+ if (baseExcludedProperties.has(name)) {
+ return true;
+ }
+
+ if ($isTextNode(node)) {
+ if (textExcludedProperties.has(name)) {
+ return true;
+ }
+ } else if ($isElementNode(node)) {
+ if (
+ elementExcludedProperties.has(name) ||
+ ($isRootNode(node) && rootExcludedProperties.has(name))
+ ) {
+ return true;
+ }
+ }
+
+ const nodeKlass = node.constructor;
+ const excludedProperties = binding.excludedProperties.get(nodeKlass);
+ return excludedProperties != null && excludedProperties.has(name);
+}
+
+export function getIndexOfYjsNode(
+ yjsParentNode: YjsNode,
+ yjsNode: YjsNode,
+): number {
+ let node = yjsParentNode.firstChild;
+ let i = -1;
+
+ if (node === null) {
+ return -1;
+ }
+
+ do {
+ i++;
+
+ if (node === yjsNode) {
+ return i;
+ }
+
+ // @ts-expect-error Sibling exists but type is not available from YJS.
+ node = node.nextSibling;
+
+ if (node === null) {
+ return -1;
+ }
+ } while (node !== null);
+
+ return i;
+}
+
+export function $getNodeByKeyOrThrow(key: NodeKey): LexicalNode {
+ const node = $getNodeByKey(key);
+ invariant(node !== null, 'could not find node by key');
+ return node;
+}
+
+export function $createCollabNodeFromLexicalNode(
+ binding: Binding,
+ lexicalNode: LexicalNode,
+ parent: CollabElementNode,
+):
+ | CollabElementNode
+ | CollabTextNode
+ | CollabLineBreakNode
+ | CollabDecoratorNode {
+ const nodeType = lexicalNode.__type;
+ let collabNode;
+
+ if ($isElementNode(lexicalNode)) {
+ const xmlText = new XmlText();
+ collabNode = $createCollabElementNode(xmlText, parent, nodeType);
+ collabNode.syncPropertiesFromLexical(binding, lexicalNode, null);
+ collabNode.syncChildrenFromLexical(binding, lexicalNode, null, null, null);
+ } else if ($isTextNode(lexicalNode)) {
+ // TODO create a token text node for token, segmented nodes.
+ const map = new YMap();
+ collabNode = $createCollabTextNode(
+ map,
+ lexicalNode.__text,
+ parent,
+ nodeType,
+ );
+ collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null);
+ } else if ($isLineBreakNode(lexicalNode)) {
+ const map = new YMap();
+ map.set('__type', 'linebreak');
+ collabNode = $createCollabLineBreakNode(map, parent);
+ } else if ($isDecoratorNode(lexicalNode)) {
+ const xmlElem = new XmlElement();
+ collabNode = $createCollabDecoratorNode(xmlElem, parent, nodeType);
+ collabNode.syncPropertiesFromLexical(binding, lexicalNode, null);
+ } else {
+ invariant(false, 'Expected text, element, decorator, or linebreak node');
+ }
+
+ collabNode._key = lexicalNode.__key;
+ return collabNode;
+}
+
+function getNodeTypeFromSharedType(
+ sharedType: XmlText | YMap<unknown> | XmlElement,
+): string {
+ const type =
+ sharedType instanceof YMap
+ ? sharedType.get('__type')
+ : sharedType.getAttribute('__type');
+ invariant(type != null, 'Expected shared type to include type attribute');
+ return type;
+}
+
+export function $getOrInitCollabNodeFromSharedType(
+ binding: Binding,
+ sharedType: XmlText | YMap<unknown> | XmlElement,
+ parent?: CollabElementNode,
+):
+ | CollabElementNode
+ | CollabTextNode
+ | CollabLineBreakNode
+ | CollabDecoratorNode {
+ const collabNode = sharedType._collabNode;
+
+ if (collabNode === undefined) {
+ const registeredNodes = binding.editor._nodes;
+ const type = getNodeTypeFromSharedType(sharedType);
+ const nodeInfo = registeredNodes.get(type);
+ invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
+
+ const sharedParent = sharedType.parent;
+ const targetParent =
+ parent === undefined && sharedParent !== null
+ ? $getOrInitCollabNodeFromSharedType(
+ binding,
+ sharedParent as XmlText | YMap<unknown> | XmlElement,
+ )
+ : parent || null;
+
+ invariant(
+ targetParent instanceof CollabElementNode,
+ 'Expected parent to be a collab element node',
+ );
+
+ if (sharedType instanceof XmlText) {
+ return $createCollabElementNode(sharedType, targetParent, type);
+ } else if (sharedType instanceof YMap) {
+ if (type === 'linebreak') {
+ return $createCollabLineBreakNode(sharedType, targetParent);
+ }
+ return $createCollabTextNode(sharedType, '', targetParent, type);
+ } else if (sharedType instanceof XmlElement) {
+ return $createCollabDecoratorNode(sharedType, targetParent, type);
+ }
+ }
+
+ return collabNode;
+}
+
+export function createLexicalNodeFromCollabNode(
+ binding: Binding,
+ collabNode:
+ | CollabElementNode
+ | CollabTextNode
+ | CollabDecoratorNode
+ | CollabLineBreakNode,
+ parentKey: NodeKey,
+): LexicalNode {
+ const type = collabNode.getType();
+ const registeredNodes = binding.editor._nodes;
+ const nodeInfo = registeredNodes.get(type);
+ invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
+ const lexicalNode:
+ | DecoratorNode<unknown>
+ | TextNode
+ | ElementNode
+ | LexicalNode = new nodeInfo.klass();
+ lexicalNode.__parent = parentKey;
+ collabNode._key = lexicalNode.__key;
+
+ if (collabNode instanceof CollabElementNode) {
+ const xmlText = collabNode._xmlText;
+ collabNode.syncPropertiesFromYjs(binding, null);
+ collabNode.applyChildrenYjsDelta(binding, xmlText.toDelta());
+ collabNode.syncChildrenFromYjs(binding);
+ } else if (collabNode instanceof CollabTextNode) {
+ collabNode.syncPropertiesAndTextFromYjs(binding, null);
+ } else if (collabNode instanceof CollabDecoratorNode) {
+ collabNode.syncPropertiesFromYjs(binding, null);
+ }
+
+ binding.collabNodeMap.set(lexicalNode.__key, collabNode);
+ return lexicalNode;
+}
+
+export function syncPropertiesFromYjs(
+ binding: Binding,
+ sharedType: XmlText | YMap<unknown> | XmlElement,
+ lexicalNode: LexicalNode,
+ keysChanged: null | Set<string>,
+): void {
+ const properties =
+ keysChanged === null
+ ? sharedType instanceof YMap
+ ? Array.from(sharedType.keys())
+ : Object.keys(sharedType.getAttributes())
+ : Array.from(keysChanged);
+ let writableNode;
+
+ for (let i = 0; i < properties.length; i++) {
+ const property = properties[i];
+ if (isExcludedProperty(property, lexicalNode, binding)) {
+ continue;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const prevValue = (lexicalNode as any)[property];
+ let nextValue =
+ sharedType instanceof YMap
+ ? sharedType.get(property)
+ : sharedType.getAttribute(property);
+
+ if (prevValue !== nextValue) {
+ if (nextValue instanceof Doc) {
+ const yjsDocMap = binding.docMap;
+
+ if (prevValue instanceof Doc) {
+ yjsDocMap.delete(prevValue.guid);
+ }
+
+ const nestedEditor = createEditor();
+ const key = nextValue.guid;
+ nestedEditor._key = key;
+ yjsDocMap.set(key, nextValue);
+
+ nextValue = nestedEditor;
+ }
+
+ if (writableNode === undefined) {
+ writableNode = lexicalNode.getWritable();
+ }
+
+ writableNode[property as keyof typeof writableNode] = nextValue;
+ }
+ }
+}
+
+export function syncPropertiesFromLexical(
+ binding: Binding,
+ sharedType: XmlText | YMap<unknown> | XmlElement,
+ prevLexicalNode: null | LexicalNode,
+ nextLexicalNode: LexicalNode,
+): void {
+ const type = nextLexicalNode.__type;
+ const nodeProperties = binding.nodeProperties;
+ let properties = nodeProperties.get(type);
+ if (properties === undefined) {
+ properties = Object.keys(nextLexicalNode).filter((property) => {
+ return !isExcludedProperty(property, nextLexicalNode, binding);
+ });
+ nodeProperties.set(type, properties);
+ }
+
+ const EditorClass = binding.editor.constructor;
+
+ for (let i = 0; i < properties.length; i++) {
+ const property = properties[i];
+ const prevValue =
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ prevLexicalNode === null ? undefined : (prevLexicalNode as any)[property];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let nextValue = (nextLexicalNode as any)[property];
+
+ if (prevValue !== nextValue) {
+ if (nextValue instanceof EditorClass) {
+ const yjsDocMap = binding.docMap;
+ let prevDoc;
+
+ if (prevValue instanceof EditorClass) {
+ const prevKey = prevValue._key;
+ prevDoc = yjsDocMap.get(prevKey);
+ yjsDocMap.delete(prevKey);
+ }
+
+ // If we already have a document, use it.
+ const doc = prevDoc || new Doc();
+ const key = doc.guid;
+ nextValue._key = key;
+ yjsDocMap.set(key, doc);
+ nextValue = doc;
+ // Mark the node dirty as we've assigned a new key to it
+ binding.editor.update(() => {
+ nextLexicalNode.markDirty();
+ });
+ }
+
+ if (sharedType instanceof YMap) {
+ sharedType.set(property, nextValue);
+ } else {
+ sharedType.setAttribute(property, nextValue);
+ }
+ }
+ }
+}
+
+export function spliceString(
+ str: string,
+ index: number,
+ delCount: number,
+ newText: string,
+): string {
+ return str.slice(0, index) + newText + str.slice(index + delCount);
+}
+
+export function getPositionFromElementAndOffset(
+ node: CollabElementNode,
+ offset: number,
+ boundaryIsEdge: boolean,
+): {
+ length: number;
+ node:
+ | CollabElementNode
+ | CollabTextNode
+ | CollabDecoratorNode
+ | CollabLineBreakNode
+ | null;
+ nodeIndex: number;
+ offset: number;
+} {
+ let index = 0;
+ let i = 0;
+ const children = node._children;
+ const childrenLength = children.length;
+
+ for (; i < childrenLength; i++) {
+ const child = children[i];
+ const childOffset = index;
+ const size = child.getSize();
+ index += size;
+ const exceedsBoundary = boundaryIsEdge ? index >= offset : index > offset;
+
+ if (exceedsBoundary && child instanceof CollabTextNode) {
+ let textOffset = offset - childOffset - 1;
+
+ if (textOffset < 0) {
+ textOffset = 0;
+ }
+
+ const diffLength = index - offset;
+ return {
+ length: diffLength,
+ node: child,
+ nodeIndex: i,
+ offset: textOffset,
+ };
+ }
+
+ if (index > offset) {
+ return {
+ length: 0,
+ node: child,
+ nodeIndex: i,
+ offset: childOffset,
+ };
+ } else if (i === childrenLength - 1) {
+ return {
+ length: 0,
+ node: null,
+ nodeIndex: i + 1,
+ offset: childOffset + 1,
+ };
+ }
+ }
+
+ return {
+ length: 0,
+ node: null,
+ nodeIndex: 0,
+ offset: 0,
+ };
+}
+
+export function doesSelectionNeedRecovering(
+ selection: RangeSelection,
+): boolean {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ let recoveryNeeded = false;
+
+ try {
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+
+ if (
+ // We might have removed a node that no longer exists
+ !anchorNode.isAttached() ||
+ !focusNode.isAttached() ||
+ // If we've split a node, then the offset might not be right
+ ($isTextNode(anchorNode) &&
+ anchor.offset > anchorNode.getTextContentSize()) ||
+ ($isTextNode(focusNode) && focus.offset > focusNode.getTextContentSize())
+ ) {
+ recoveryNeeded = true;
+ }
+ } catch (e) {
+ // Sometimes checking nor a node via getNode might trigger
+ // an error, so we need recovery then too.
+ recoveryNeeded = true;
+ }
+
+ return recoveryNeeded;
+}
+
+export function syncWithTransaction(binding: Binding, fn: () => void): void {
+ binding.doc.transact(fn, binding);
+}
+
+export function removeFromParent(node: LexicalNode): void {
+ const oldParent = node.getParent();
+ if (oldParent !== null) {
+ const writableNode = node.getWritable();
+ const writableParent = oldParent.getWritable();
+ const prevSibling = node.getPreviousSibling();
+ const nextSibling = node.getNextSibling();
+ // TODO: this function duplicates a bunch of operations, can be simplified.
+ if (prevSibling === null) {
+ if (nextSibling !== null) {
+ const writableNextSibling = nextSibling.getWritable();
+ writableParent.__first = nextSibling.__key;
+ writableNextSibling.__prev = null;
+ } else {
+ writableParent.__first = null;
+ }
+ } else {
+ const writablePrevSibling = prevSibling.getWritable();
+ if (nextSibling !== null) {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = writablePrevSibling.__key;
+ writablePrevSibling.__next = writableNextSibling.__key;
+ } else {
+ writablePrevSibling.__next = null;
+ }
+ writableNode.__prev = null;
+ }
+ if (nextSibling === null) {
+ if (prevSibling !== null) {
+ const writablePrevSibling = prevSibling.getWritable();
+ writableParent.__last = prevSibling.__key;
+ writablePrevSibling.__next = null;
+ } else {
+ writableParent.__last = null;
+ }
+ } else {
+ const writableNextSibling = nextSibling.getWritable();
+ if (prevSibling !== null) {
+ const writablePrevSibling = prevSibling.getWritable();
+ writablePrevSibling.__next = writableNextSibling.__key;
+ writableNextSibling.__prev = writablePrevSibling.__key;
+ } else {
+ writableNextSibling.__prev = null;
+ }
+ writableNode.__next = null;
+ }
+ writableParent.__size--;
+ writableNode.__parent = null;
+ }
+}
+
+export function $moveSelectionToPreviousNode(
+ anchorNodeKey: string,
+ currentEditorState: EditorState,
+) {
+ const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey);
+ if (!anchorNode) {
+ $getRoot().selectStart();
+ return;
+ }
+ // Get previous node
+ const prevNodeKey = anchorNode.__prev;
+ let prevNode: ElementNode | null = null;
+ if (prevNodeKey) {
+ prevNode = $getNodeByKey(prevNodeKey);
+ }
+
+ // If previous node not found, get parent node
+ if (prevNode === null && anchorNode.__parent !== null) {
+ prevNode = $getNodeByKey(anchorNode.__parent);
+ }
+ if (prevNode === null) {
+ $getRoot().selectStart();
+ return;
+ }
+
+ if (prevNode !== null && prevNode.isAttached()) {
+ prevNode.selectEnd();
+ return;
+ } else {
+ // If the found node is also deleted, select the next one
+ $moveSelectionToPreviousNode(prevNode.__key, currentEditorState);
+ }
+}
--- /dev/null
+/**
+ * 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 {Binding} from './Bindings';
+import type {LexicalCommand} from 'lexical';
+import type {Doc, RelativePosition, UndoManager, XmlText} from 'yjs';
+
+import {createCommand} from 'lexical';
+import {UndoManager as YjsUndoManager} from 'yjs';
+
+export type UserState = {
+ anchorPos: null | RelativePosition;
+ color: string;
+ focusing: boolean;
+ focusPos: null | RelativePosition;
+ name: string;
+ awarenessData: object;
+};
+export const CONNECTED_COMMAND: LexicalCommand<boolean> =
+ createCommand('CONNECTED_COMMAND');
+export const TOGGLE_CONNECT_COMMAND: LexicalCommand<boolean> = createCommand(
+ 'TOGGLE_CONNECT_COMMAND',
+);
+export type ProviderAwareness = {
+ getLocalState: () => UserState | null;
+ getStates: () => Map<number, UserState>;
+ off: (type: 'update', cb: () => void) => void;
+ on: (type: 'update', cb: () => void) => void;
+ setLocalState: (arg0: UserState) => void;
+};
+declare interface Provider {
+ awareness: ProviderAwareness;
+ connect(): void | Promise<void>;
+ disconnect(): void;
+ off(type: 'sync', cb: (isSynced: boolean) => void): void;
+ off(type: 'update', cb: (arg0: unknown) => void): void;
+ off(type: 'status', cb: (arg0: {status: string}) => void): void;
+ off(type: 'reload', cb: (doc: Doc) => void): void;
+ on(type: 'sync', cb: (isSynced: boolean) => void): void;
+ on(type: 'status', cb: (arg0: {status: string}) => void): void;
+ on(type: 'update', cb: (arg0: unknown) => void): void;
+ on(type: 'reload', cb: (doc: Doc) => void): void;
+}
+export type Operation = {
+ attributes: {
+ __type: string;
+ };
+ insert: string | Record<string, unknown>;
+};
+export type Delta = Array<Operation>;
+export type YjsNode = Record<string, unknown>;
+export type YjsEvent = Record<string, unknown>;
+export type {Provider};
+export type {Binding, ClientID, ExcludedProperties} from './Bindings';
+export {createBinding} from './Bindings';
+
+export function createUndoManager(
+ binding: Binding,
+ root: XmlText,
+): UndoManager {
+ return new YjsUndoManager(root, {
+ trackedOrigins: new Set([binding, null]),
+ });
+}
+
+export function initLocalState(
+ provider: Provider,
+ name: string,
+ color: string,
+ focusing: boolean,
+ awarenessData: object,
+): void {
+ provider.awareness.setLocalState({
+ anchorPos: null,
+ awarenessData,
+ color,
+ focusPos: null,
+ focusing: focusing,
+ name,
+ });
+}
+
+export function setLocalStateFocus(
+ provider: Provider,
+ name: string,
+ color: string,
+ focusing: boolean,
+ awarenessData: object,
+): void {
+ const {awareness} = provider;
+ let localState = awareness.getLocalState();
+
+ if (localState === null) {
+ localState = {
+ anchorPos: null,
+ awarenessData,
+ color,
+ focusPos: null,
+ focusing: focusing,
+ name,
+ };
+ }
+
+ localState.focusing = focusing;
+ awareness.setLocalState(localState);
+}
+export {syncCursorPositions} from './SyncCursors';
+export {
+ syncLexicalUpdateToYjs,
+ syncYjsChangesToLexical,
+} from './SyncEditorStates';
--- /dev/null
+/**
+ * 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 {CollabDecoratorNode} from './src/CollabDecoratorNode';
+import {CollabElementNode} from './src/CollabElementNode';
+import {CollabLineBreakNode} from './src/CollabLineBreakNode';
+import {CollabTextNode} from './src/CollabTextNode';
+
+declare module 'yjs' {
+ interface XmlElement {
+ _collabNode: CollabDecoratorNode;
+ }
+
+ interface XmlText {
+ _collabNode: CollabElementNode;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ interface Map<MapType> {
+ _collabNode: CollabLineBreakNode | CollabTextNode;
+ }
+}
{
"include": ["resources/js/**/*"],
"compilerOptions": {
- /* Visit https://aka.ms/tsconfig to read more about this file */
-
- /* Projects */
- // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
- // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
- // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
- // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
- // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
- // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
-
- /* Language and Environment */
- "target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
- // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
- // "jsx": "preserve", /* Specify what JSX code is generated. */
- // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
- // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
- // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
- // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
- // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
- // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
- // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
- // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
- // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
-
- /* Modules */
- "module": "commonjs", /* Specify what module code is generated. */
- "rootDir": "./resources/js/", /* Specify the root folder within your source files. */
- // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
- // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
- "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
- "@icons/*": ["./resources/icons/*"]
+ "target": "es2019",
+ "module": "commonjs",
+ "rootDir": "./resources/js/",
+ "baseUrl": "./",
+ "paths": {
+ "@icons/*": ["resources/icons/*"],
+ "lexical": ["resources/js/wysiwyg/lexical/core/index.ts"],
+ "lexical/*": ["resources/js/wysiwyg/lexical/core/*"],
+ "@lexical/*": ["resources/js/wysiwyg/lexical/*"]
},
- // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
- // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
- // "types": [], /* Specify type package names to be included without being referenced in a source file. */
- // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
- // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
- // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
- // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
- // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
- // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
- // "resolveJsonModule": true, /* Enable importing .json files. */
- // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
- // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
-
- /* JavaScript Support */
- "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
- // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
- // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
-
- /* Emit */
- // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
- // "declarationMap": true, /* Create sourcemaps for d.ts files. */
- // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
- // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
- // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
- // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
- // "outDir": "./", /* Specify an output folder for all emitted files. */
- // "removeComments": true, /* Disable emitting comments. */
- // "noEmit": true, /* Disable emitting files from a compilation. */
- // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
- // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
- // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
- // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
- // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
- // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
- // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
- // "newLine": "crlf", /* Set the newline character for emitting files. */
- // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
- // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
- // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
- // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
- // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
- // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
-
- /* Interop Constraints */
- // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
- // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
- // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
- "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
- // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
- "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
-
- /* Type Checking */
- "strict": true, /* Enable all strict type-checking options. */
- // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
- // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
- // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
- // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
- // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
- // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
- // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
- // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
- // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
- // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
- // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
- // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
- // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
- // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
- // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
- // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
- // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
- // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
-
- /* Completeness */
- // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
- "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true
}
}