]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Imported core lexical libs
authorDan Brown <redacted>
Wed, 18 Sep 2024 12:43:39 +0000 (13:43 +0100)
committerDan Brown <redacted>
Wed, 18 Sep 2024 12:43:39 +0000 (13:43 +0100)
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.

128 files changed:
.gitignore
dev/build/esbuild.js
jest.config.ts [new file with mode: 0644]
package-lock.json
package.json
resources/js/app.js
resources/js/global.d.ts
resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE [new file with mode: 0644]
resources/js/wysiwyg/lexical/clipboard/clipboard.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/clipboard/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalCommands.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalConstants.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalEditor.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalEditorState.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalEvents.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalGC.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalMutations.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalNormalization.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalReconciler.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalSelection.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalUpdates.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/LexicalUtils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/environment.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/invariant.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/reactPatches.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/headless/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/history/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/html/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/link/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/LexicalListNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/__tests__/utils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/formatList.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/list/utils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/readme.md [new file with mode: 0644]
resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/rich-text/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/constants.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/lexical-node.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/range-selection.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/selection/utils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/constants.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/table/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/markSelection.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/mergeRegister.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/utils/px.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/Bindings.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/SyncCursors.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/Utils.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/index.ts [new file with mode: 0644]
resources/js/wysiwyg/lexical/yjs/types.ts [new file with mode: 0644]
tsconfig.json

index 55cc0557b8cae37a6d43840238b9eb06cb7b9cc0..3582c410221e9b3c7b532f5f0277f4a5d4121093 100644 (file)
@@ -2,6 +2,7 @@
 /node_modules
 /.vscode
 /composer
+/coverage
 Homestead.yaml
 .env
 .idea
index 0680f4ac3da48ec0f19985e6f656ffdeaac0ad97..fea8c01e353887d2bb169d330fec1bc51b997a0a 100644 (file)
@@ -38,6 +38,8 @@ esbuild.build({
     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',
diff --git a/jest.config.ts b/jest.config.ts
new file mode 100644 (file)
index 0000000..0243b39
--- /dev/null
@@ -0,0 +1,207 @@
+/**
+ * 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;
index 1d252766152b45b7b8f3a238bf784b0149fe600e..0b6c970806c3dc7d79623e1b76189b07e6b66ac4 100644 (file)
         "@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",
index d39bf5a2cb2fd378dc2052011de0be74243cb1ca..163df34ed223cc1cd654b382677ce8a9ad94e950 100644 (file)
     "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",
index e08b90ba1e46d741b3584e6f7f5f9cf81168440a..7f4bbe54d639e58db5811a4f3a0b3c84265517b9 100644 (file)
@@ -4,6 +4,9 @@ import Translations from './services/translations';
 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;
index 1f216b7a53c0238c15783df339bf16cd28deec3c..0d7efc4d43c8673e4e3a7def39d1082a5dfeaba5 100644 (file)
@@ -3,10 +3,12 @@ import {EventManager} from "./services/events";
 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
diff --git a/resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE b/resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE
new file mode 100644 (file)
index 0000000..b93be90
--- /dev/null
@@ -0,0 +1,21 @@
+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.
diff --git a/resources/js/wysiwyg/lexical/clipboard/clipboard.ts b/resources/js/wysiwyg/lexical/clipboard/clipboard.ts
new file mode 100644 (file)
index 0000000..1d79c2d
--- /dev/null
@@ -0,0 +1,542 @@
+/**
+ * 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);
+    }
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/clipboard/index.ts b/resources/js/wysiwyg/lexical/clipboard/index.ts
new file mode 100644 (file)
index 0000000..ffa1f19
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * 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';
diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts
new file mode 100644 (file)
index 0000000..0f1c0a5
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * 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');
diff --git a/resources/js/wysiwyg/lexical/core/LexicalConstants.ts b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts
new file mode 100644 (file)
index 0000000..82461e7
--- /dev/null
@@ -0,0 +1,145 @@
+/**
+ * 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',
+};
diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts
new file mode 100644 (file)
index 0000000..b0b9000
--- /dev/null
@@ -0,0 +1,1289 @@
+/**
+ * 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';
diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts b/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts
new file mode 100644 (file)
index 0000000..f84d2e4
--- /dev/null
@@ -0,0 +1,137 @@
+/**
+ * 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()),
+    }));
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts
new file mode 100644 (file)
index 0000000..5fd671a
--- /dev/null
@@ -0,0 +1,1385 @@
+/**
+ * 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];
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalGC.ts b/resources/js/wysiwyg/lexical/core/LexicalGC.ts
new file mode 100644 (file)
index 0000000..9405ae6
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * 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);
+    }
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts
new file mode 100644 (file)
index 0000000..56f3645
--- /dev/null
@@ -0,0 +1,322 @@
+/**
+ * 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);
+    },
+  );
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
new file mode 100644 (file)
index 0000000..c6bc2e6
--- /dev/null
@@ -0,0 +1,1221 @@
+/**
+ * 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);
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts b/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts
new file mode 100644 (file)
index 0000000..59a7be6
--- /dev/null
@@ -0,0 +1,124 @@
+/**
+ * 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',
+    );
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts
new file mode 100644 (file)
index 0000000..0162d22
--- /dev/null
@@ -0,0 +1,943 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts
new file mode 100644 (file)
index 0000000..db18cfc
--- /dev/null
@@ -0,0 +1,2835 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalUpdates.ts b/resources/js/wysiwyg/lexical/core/LexicalUpdates.ts
new file mode 100644 (file)
index 0000000..86ed274
--- /dev/null
@@ -0,0 +1,1035 @@
+/**
+ * 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);
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts
new file mode 100644 (file)
index 0000000..71096b1
--- /dev/null
@@ -0,0 +1,1788 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts
new file mode 100644 (file)
index 0000000..5d6a931
--- /dev/null
@@ -0,0 +1,144 @@
+/**
+ * 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&#32;</span><span style="color:#ffc66d;">run</span>()&#32;{<br>&#32;&#32;<span style="color:#cc7832;">return&#32;</span>[<span style="color:#cc7832;">null,&#32;undefined,&#32;</span><span style="color:#6897bb;">2</span><span style="color:#cc7832;">,&#32;</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, &quot;Courier New&quot;, 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, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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',
+        },
+      },
+    },
+  );
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts
new file mode 100644 (file)
index 0000000..b146548
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * 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, &quot;system-ui&quot;, &quot;Segoe UI&quot;, &quot;Noto Sans&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; 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, &quot;system-ui&quot;, &quot;Segoe UI&quot;, &quot;Noto Sans&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; 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',
+        },
+      },
+    },
+  );
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts
new file mode 100644 (file)
index 0000000..4ca6b77
--- /dev/null
@@ -0,0 +1,2856 @@
+/**
+ * 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();
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts
new file mode 100644 (file)
index 0000000..09b49b7
--- /dev/null
@@ -0,0 +1,159 @@
+/**
+ * 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',
+            },
+          ],
+        ]),
+      );
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx
new file mode 100644 (file)
index 0000000..a2968c2
--- /dev/null
@@ -0,0 +1,212 @@
+/**
+ * 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>
+      `,
+    );
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts
new file mode 100644 (file)
index 0000000..7373f89
--- /dev/null
@@ -0,0 +1,1517 @@
+/**
+ * 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: {},
+    },
+  );
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx
new file mode 100644 (file)
index 0000000..ecfbe6b
--- /dev/null
@@ -0,0 +1,176 @@
+/**
+ * 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);
+          });
+        });
+      }
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts
new file mode 100644 (file)
index 0000000..7055f36
--- /dev/null
@@ -0,0 +1,342 @@
+/**
+ * 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'});
+          // });
+        });
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts
new file mode 100644 (file)
index 0000000..9237bc9
--- /dev/null
@@ -0,0 +1,126 @@
+/**
+ * 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}}`,
+      );
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts
new file mode 100644 (file)
index 0000000..0026cf5
--- /dev/null
@@ -0,0 +1,293 @@
+/**
+ * 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']));
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
new file mode 100644 (file)
index 0000000..b7ccfab
--- /dev/null
@@ -0,0 +1,751 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/index.ts b/resources/js/wysiwyg/lexical/core/index.ts
new file mode 100644 (file)
index 0000000..5ef926b
--- /dev/null
@@ -0,0 +1,208 @@
+/**
+ * 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';
diff --git a/resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts b/resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts
new file mode 100644 (file)
index 0000000..0f01d2c
--- /dev/null
@@ -0,0 +1,23 @@
+/**
+ * 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;
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts
new file mode 100644 (file)
index 0000000..99d2669
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
new file mode 100644 (file)
index 0000000..88c6d56
--- /dev/null
@@ -0,0 +1,635 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts
new file mode 100644 (file)
index 0000000..2d28db0
--- /dev/null
@@ -0,0 +1,142 @@
+/**
+ * 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 || '')
+  );
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts
new file mode 100644 (file)
index 0000000..deab3a2
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts
new file mode 100644 (file)
index 0000000..74c8d5a
--- /dev/null
@@ -0,0 +1,132 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts
new file mode 100644 (file)
index 0000000..5fa3623
--- /dev/null
@@ -0,0 +1,94 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
new file mode 100644 (file)
index 0000000..43bef7e
--- /dev/null
@@ -0,0 +1,1364 @@
+/**
+ * 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;
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx
new file mode 100644 (file)
index 0000000..e165df7
--- /dev/null
@@ -0,0 +1,635 @@
+/**
+ * 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);
+        });
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx
new file mode 100644 (file)
index 0000000..2c7e978
--- /dev/null
@@ -0,0 +1,119 @@
+/**
+ * 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);
+      });
+    }
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts
new file mode 100644 (file)
index 0000000..110086a
--- /dev/null
@@ -0,0 +1,74 @@
+/**
+ * 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);
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts
new file mode 100644 (file)
index 0000000..1f7c4cf
--- /dev/null
@@ -0,0 +1,153 @@
+/**
+ * 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);
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts
new file mode 100644 (file)
index 0000000..123cb33
--- /dev/null
@@ -0,0 +1,271 @@
+/**
+ * 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');
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx
new file mode 100644 (file)
index 0000000..0c06273
--- /dev/null
@@ -0,0 +1,257 @@
+/**
+ * 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>',
+      );
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx
new file mode 100644 (file)
index 0000000..7fc509d
--- /dev/null
@@ -0,0 +1,843 @@
+/**
+ * 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');
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts b/resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts
new file mode 100644 (file)
index 0000000..ff3b7cb
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * 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 || ''),
+  );
+}
diff --git a/resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts b/resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts
new file mode 100644 (file)
index 0000000..78db6aa
--- /dev/null
@@ -0,0 +1,12 @@
+/**
+ * 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';
diff --git a/resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts b/resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts
new file mode 100644 (file)
index 0000000..642e070
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * 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;
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/shared/environment.ts b/resources/js/wysiwyg/lexical/core/shared/environment.ts
new file mode 100644 (file)
index 0000000..c05d332
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * 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;
diff --git a/resources/js/wysiwyg/lexical/core/shared/invariant.ts b/resources/js/wysiwyg/lexical/core/shared/invariant.ts
new file mode 100644 (file)
index 0000000..0e73848
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+// 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,
+  );
+}
diff --git a/resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts b/resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts
new file mode 100644 (file)
index 0000000..22ea3a9
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts b/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts
new file mode 100644 (file)
index 0000000..8e08674
--- /dev/null
@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import * 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;
diff --git a/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts b/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts
new file mode 100644 (file)
index 0000000..9685cd8
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * 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();
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts b/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts
new file mode 100644 (file)
index 0000000..39f3d3b
--- /dev/null
@@ -0,0 +1,49 @@
+/**
+ * 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,
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts b/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts
new file mode 100644 (file)
index 0000000..6149879
--- /dev/null
@@ -0,0 +1,19 @@
+/**
+ * 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;
diff --git a/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts b/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts
new file mode 100644 (file)
index 0000000..d29e99e
--- /dev/null
@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export default function warnOnlyOnce(message: string) {
+  if (!__DEV__) {
+    return;
+  }
+  let run = false;
+  return () => {
+    if (!run) {
+      console.warn(message);
+    }
+    run = true;
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts
new file mode 100644 (file)
index 0000000..afa6570
--- /dev/null
@@ -0,0 +1,212 @@
+/**
+ * @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>',
+    );
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/headless/index.ts b/resources/js/wysiwyg/lexical/headless/index.ts
new file mode 100644 (file)
index 0000000..2b8eddb
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/history/index.ts b/resources/js/wysiwyg/lexical/history/index.ts
new file mode 100644 (file)
index 0000000..8c731d3
--- /dev/null
@@ -0,0 +1,501 @@
+/**
+ * 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: [],
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts
new file mode 100644 (file)
index 0000000..55d120b
--- /dev/null
@@ -0,0 +1,212 @@
+/**
+ * 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>',
+    );
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts
new file mode 100644 (file)
index 0000000..2975315
--- /dev/null
@@ -0,0 +1,376 @@
+/**
+ * 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)
+  );
+}
diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts
new file mode 100644 (file)
index 0000000..8ef2aa0
--- /dev/null
@@ -0,0 +1,506 @@
+/**
+ * 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');
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts
new file mode 100644 (file)
index 0000000..3ad6cba
--- /dev/null
@@ -0,0 +1,413 @@
+/**
+ * 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');
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts
new file mode 100644 (file)
index 0000000..fe2b975
--- /dev/null
@@ -0,0 +1,610 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
new file mode 100644 (file)
index 0000000..7d12b5b
--- /dev/null
@@ -0,0 +1,552 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts
new file mode 100644 (file)
index 0000000..e22fbf7
--- /dev/null
@@ -0,0 +1,367 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts
new file mode 100644 (file)
index 0000000..d36b8f1
--- /dev/null
@@ -0,0 +1,1365 @@
+/**
+ * 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);
+        });
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts
new file mode 100644 (file)
index 0000000..6abcbbd
--- /dev/null
@@ -0,0 +1,317 @@
+/**
+ * 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');
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts
new file mode 100644 (file)
index 0000000..1fa3273
--- /dev/null
@@ -0,0 +1,335 @@
+/**
+ * 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);
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/list/__tests__/utils.ts b/resources/js/wysiwyg/lexical/list/__tests__/utils.ts
new file mode 100644 (file)
index 0000000..aa95a7a
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * 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'});
+}
diff --git a/resources/js/wysiwyg/lexical/list/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts
new file mode 100644 (file)
index 0000000..b9ca011
--- /dev/null
@@ -0,0 +1,530 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/list/index.ts b/resources/js/wysiwyg/lexical/list/index.ts
new file mode 100644 (file)
index 0000000..157fe79
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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',
+);
diff --git a/resources/js/wysiwyg/lexical/list/utils.ts b/resources/js/wysiwyg/lexical/list/utils.ts
new file mode 100644 (file)
index 0000000..c451a45
--- /dev/null
@@ -0,0 +1,205 @@
+/**
+ * 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);
+}
diff --git a/resources/js/wysiwyg/lexical/readme.md b/resources/js/wysiwyg/lexical/readme.md
new file mode 100644 (file)
index 0000000..31db8fa
--- /dev/null
@@ -0,0 +1,12 @@
+# 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
diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts
new file mode 100644 (file)
index 0000000..057999b
--- /dev/null
@@ -0,0 +1,202 @@
+/**
+ * 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>`,
+      );
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts
new file mode 100644 (file)
index 0000000..e64c418
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * 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);
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts
new file mode 100644 (file)
index 0000000..fd91625
--- /dev/null
@@ -0,0 +1,1067 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx
new file mode 100644 (file)
index 0000000..e608678
--- /dev/null
@@ -0,0 +1,3082 @@
+/**
+ * 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>`,
+      );
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts
new file mode 100644 (file)
index 0000000..01390ed
--- /dev/null
@@ -0,0 +1,3173 @@
+/**
+ * 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);
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts
new file mode 100644 (file)
index 0000000..84c82ed
--- /dev/null
@@ -0,0 +1,918 @@
+/**
+ * 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
+    ? '&nbsp;'
+    : 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;
+}
diff --git a/resources/js/wysiwyg/lexical/selection/constants.ts b/resources/js/wysiwyg/lexical/selection/constants.ts
new file mode 100644 (file)
index 0000000..104f57d
--- /dev/null
@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+export const CSS_TO_STYLES: Map<string, Record<string, string>> = new Map();
diff --git a/resources/js/wysiwyg/lexical/selection/index.ts b/resources/js/wysiwyg/lexical/selection/index.ts
new file mode 100644 (file)
index 0000000..b2d18b1
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * 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};
diff --git a/resources/js/wysiwyg/lexical/selection/lexical-node.ts b/resources/js/wysiwyg/lexical/selection/lexical-node.ts
new file mode 100644 (file)
index 0000000..82f7d33
--- /dev/null
@@ -0,0 +1,427 @@
+/**
+ * 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);
+      }
+    }
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/selection/range-selection.ts b/resources/js/wysiwyg/lexical/selection/range-selection.ts
new file mode 100644 (file)
index 0000000..dbadaf3
--- /dev/null
@@ -0,0 +1,608 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/selection/utils.ts b/resources/js/wysiwyg/lexical/selection/utils.ts
new file mode 100644 (file)
index 0000000..0608706
--- /dev/null
@@ -0,0 +1,228 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts
new file mode 100644 (file)
index 0000000..455d39b
--- /dev/null
@@ -0,0 +1,374 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts
new file mode 100644 (file)
index 0000000..8fb5423
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * 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');
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
new file mode 100644 (file)
index 0000000..3e695ea
--- /dev/null
@@ -0,0 +1,258 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts b/resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts
new file mode 100644 (file)
index 0000000..0d40d06
--- /dev/null
@@ -0,0 +1,414 @@
+/**
+ * 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);
+    });
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts
new file mode 100644 (file)
index 0000000..eddea69
--- /dev/null
@@ -0,0 +1,130 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts
new file mode 100644 (file)
index 0000000..4564ace
--- /dev/null
@@ -0,0 +1,373 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
new file mode 100644 (file)
index 0000000..812cccc
--- /dev/null
@@ -0,0 +1,1819 @@
+/**
+ * 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;
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts
new file mode 100644 (file)
index 0000000..cdbc846
--- /dev/null
@@ -0,0 +1,894 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts
new file mode 100644 (file)
index 0000000..9c56db6
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * 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>`,
+        );
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx
new file mode 100644 (file)
index 0000000..b11b994
--- /dev/null
@@ -0,0 +1,351 @@
+/**
+ * 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="{&quot;1&quot;:2,&quot;2&quot;:&quot;Surface&quot;}">Surface</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-style:italic;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;MWP_WORK_LS_COMPOSER&quot;}">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="{&quot;1&quot;:3,&quot;3&quot;:77349}">77349</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Lexical&quot;}">Lexical</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:line-through;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;XDS_RICH_TEXT_AREA&quot;}">XDS_RICH_TEXT_AREA</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;sdvd sdfvsfs&quot;}" data-sheets-textstyleruns="{&quot;1&quot;:0}{&quot;1&quot;:5,&quot;2&quot;:{&quot;5&quot;: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 />,
+  );
+});
diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts
new file mode 100644 (file)
index 0000000..cf11063
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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>`,
+        );
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx
new file mode 100644 (file)
index 0000000..5eb631c
--- /dev/null
@@ -0,0 +1,176 @@
+/**
+ * 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',
+    );
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/table/constants.ts b/resources/js/wysiwyg/lexical/table/constants.ts
new file mode 100644 (file)
index 0000000..ffa6ba1
--- /dev/null
@@ -0,0 +1,13 @@
+/**
+ * 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;
diff --git a/resources/js/wysiwyg/lexical/table/index.ts b/resources/js/wysiwyg/lexical/table/index.ts
new file mode 100644 (file)
index 0000000..2429eb6
--- /dev/null
@@ -0,0 +1,74 @@
+/**
+ * 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';
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts
new file mode 100644 (file)
index 0000000..0bca8a9
--- /dev/null
@@ -0,0 +1,77 @@
+/**
+ * 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('');
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx
new file mode 100644 (file)
index 0000000..2b49e3b
--- /dev/null
@@ -0,0 +1,747 @@
+/**
+ * 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,
+          );
+        });
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts
new file mode 100644 (file)
index 0000000..82d2ddd
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * 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(),
+          },
+        ]);
+      });
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts
new file mode 100644 (file)
index 0000000..0701075
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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);
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts
new file mode 100644 (file)
index 0000000..b4b18ef
--- /dev/null
@@ -0,0 +1,36 @@
+/**
+ * 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();
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx
new file mode 100644 (file)
index 0000000..f3db393
--- /dev/null
@@ -0,0 +1,142 @@
+/**
+ * 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();
+    });
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx
new file mode 100644 (file)
index 0000000..0e46573
--- /dev/null
@@ -0,0 +1,184 @@
+/**
+ * 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);
+      });
+    });
+  }
+});
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts
new file mode 100644 (file)
index 0000000..01228f6
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * 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]);
+  });
+});
diff --git a/resources/js/wysiwyg/lexical/utils/index.ts b/resources/js/wysiwyg/lexical/utils/index.ts
new file mode 100644 (file)
index 0000000..7984126
--- /dev/null
@@ -0,0 +1,607 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/utils/markSelection.ts b/resources/js/wysiwyg/lexical/utils/markSelection.ts
new file mode 100644 (file)
index 0000000..b1359c6
--- /dev/null
@@ -0,0 +1,170 @@
+/**
+ * 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();
+    },
+  );
+}
diff --git a/resources/js/wysiwyg/lexical/utils/mergeRegister.ts b/resources/js/wysiwyg/lexical/utils/mergeRegister.ts
new file mode 100644 (file)
index 0000000..0d1a192
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * 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;
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts b/resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts
new file mode 100644 (file)
index 0000000..468d25c
--- /dev/null
@@ -0,0 +1,141 @@
+/**
+ * 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();
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/utils/px.ts b/resources/js/wysiwyg/lexical/utils/px.ts
new file mode 100644 (file)
index 0000000..c306cc7
--- /dev/null
@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export default function px(value: number) {
+  return `${value}px`;
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/Bindings.ts b/resources/js/wysiwyg/lexical/yjs/Bindings.ts
new file mode 100644 (file)
index 0000000..4d3ac01
--- /dev/null
@@ -0,0 +1,78 @@
+/**
+ * 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,
+  };
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts
new file mode 100644 (file)
index 0000000..3578ed7
--- /dev/null
@@ -0,0 +1,110 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts
new file mode 100644 (file)
index 0000000..b386604
--- /dev/null
@@ -0,0 +1,666 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts
new file mode 100644 (file)
index 0000000..6d1267f
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts
new file mode 100644 (file)
index 0000000..86caf91
--- /dev/null
@@ -0,0 +1,178 @@
+/**
+ * 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;
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/SyncCursors.ts b/resources/js/wysiwyg/lexical/yjs/SyncCursors.ts
new file mode 100644 (file)
index 0000000..721fbb6
--- /dev/null
@@ -0,0 +1,536 @@
+/**
+ * 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,
+    });
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts b/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts
new file mode 100644 (file)
index 0000000..c2dd077
--- /dev/null
@@ -0,0 +1,247 @@
+/**
+ * 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);
+    });
+  });
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/Utils.ts b/resources/js/wysiwyg/lexical/yjs/Utils.ts
new file mode 100644 (file)
index 0000000..c0e6bc9
--- /dev/null
@@ -0,0 +1,560 @@
+/**
+ * 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);
+  }
+}
diff --git a/resources/js/wysiwyg/lexical/yjs/index.ts b/resources/js/wysiwyg/lexical/yjs/index.ts
new file mode 100644 (file)
index 0000000..248e344
--- /dev/null
@@ -0,0 +1,116 @@
+/**
+ * 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';
diff --git a/resources/js/wysiwyg/lexical/yjs/types.ts b/resources/js/wysiwyg/lexical/yjs/types.ts
new file mode 100644 (file)
index 0000000..d8807a2
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * 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;
+  }
+}
index 0be5421c7c8d9661b86a2a355a6315d4f33c48c1..4026872ac9a1f28efd967416c87d46d4769b9b3b 100644 (file)
 {
   "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
   }
 }