diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 0391ce5b5..9aceea2a2 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -13,9 +13,9 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install NPM deps run: npm ci diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml new file mode 100644 index 000000000..13f9a8a98 --- /dev/null +++ b/.github/workflows/test-js.yml @@ -0,0 +1,29 @@ +name: test-js + +on: + push: + paths: + - '**.js' + - '**.ts' + - '**.json' + pull_request: + paths: + - '**.js' + - '**.ts' + - '**.json' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install NPM deps + run: npm ci + + - name: Run TypeScript type checking + run: npm run ts:lint + + - name: Run JavaScript tests + run: npm run test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 55cc0557b..3582c4102 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /node_modules /.vscode /composer +/coverage Homestead.yaml .env .idea diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index 3a433338b..499ef4d72 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -3,6 +3,7 @@ namespace BookStack\Entities\Models; use BookStack\Entities\Tools\PageContent; +use BookStack\Entities\Tools\PageEditorType; use BookStack\Permissions\PermissionApplicator; use BookStack\Uploads\Attachment; use Illuminate\Database\Eloquent\Builder; diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index be139b050..ce7e34ae1 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditorData; +use BookStack\Entities\Tools\PageEditorType; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; @@ -127,7 +128,9 @@ class PageRepo } $pageContent = new PageContent($page); - $currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor(); + $defaultEditor = PageEditorType::getSystemDefault(); + $currentEditor = PageEditorType::forPage($page) ?: $defaultEditor; + $inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor; $newEditor = $currentEditor; $haveInput = isset($input['markdown']) || isset($input['html']); @@ -136,15 +139,15 @@ class PageRepo if ($haveInput && $inputEmpty) { $pageContent->setNewHTML('', user()); } elseif (!empty($input['markdown']) && is_string($input['markdown'])) { - $newEditor = 'markdown'; + $newEditor = PageEditorType::Markdown; $pageContent->setNewMarkdown($input['markdown'], user()); } elseif (isset($input['html'])) { - $newEditor = 'wysiwyg'; + $newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce; $pageContent->setNewHTML($input['html'], user()); } if ($newEditor !== $currentEditor && userCan('editor-change')) { - $page->editor = $newEditor; + $page->editor = $newEditor->value; } } diff --git a/app/Entities/Tools/PageEditorData.php b/app/Entities/Tools/PageEditorData.php index f0bd23589..e4fe2fd25 100644 --- a/app/Entities/Tools/PageEditorData.php +++ b/app/Entities/Tools/PageEditorData.php @@ -74,17 +74,17 @@ class PageEditorData ]; } - protected function updateContentForEditor(Page $page, string $editorType): void + protected function updateContentForEditor(Page $page, PageEditorType $editorType): void { $isHtml = !empty($page->html) && empty($page->markdown); // HTML to markdown-clean conversion - if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') { + if ($editorType === PageEditorType::Markdown && $isHtml && $this->requestedEditor === 'markdown-clean') { $page->markdown = (new HtmlToMarkdown($page->html))->convert(); } // Markdown to HTML conversion if we don't have HTML - if ($editorType === 'wysiwyg' && !$isHtml) { + if ($editorType->isHtmlBased() && !$isHtml) { $page->html = (new MarkdownToHtml($page->markdown))->convert(); } } @@ -94,24 +94,16 @@ class PageEditorData * Defaults based upon the current content of the page otherwise will fall back * to system default but will take a requested type (if provided) if permissions allow. */ - protected function getEditorType(Page $page): string + protected function getEditorType(Page $page): PageEditorType { - $editorType = $page->editor ?: self::getSystemDefaultEditor(); + $editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault(); // Use requested editor if valid and if we have permission - $requestedType = explode('-', $this->requestedEditor)[0]; - if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) { + $requestedType = PageEditorType::fromRequestValue($this->requestedEditor); + if ($requestedType && userCan('editor-change')) { $editorType = $requestedType; } return $editorType; } - - /** - * Get the configured system default editor. - */ - public static function getSystemDefaultEditor(): string - { - return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg'; - } } diff --git a/app/Entities/Tools/PageEditorType.php b/app/Entities/Tools/PageEditorType.php new file mode 100644 index 000000000..1c1d430e4 --- /dev/null +++ b/app/Entities/Tools/PageEditorType.php @@ -0,0 +1,37 @@ + true, + self::Markdown => false, + }; + } + + public static function fromRequestValue(string $value): static|null + { + $editor = explode('-', $value)[0]; + return static::tryFrom($editor); + } + + public static function forPage(Page $page): static|null + { + return static::tryFrom($page->editor); + } + + public static function getSystemDefault(): static + { + $setting = setting('app-editor'); + return static::tryFrom($setting) ?? static::WysiwygTinymce; + } +} diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index c5b3c9ef3..fea8c01e3 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -14,6 +14,7 @@ const entryPoints = { code: path.join(__dirname, '../../resources/js/code/index.mjs'), 'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'), markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'), + wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'), }; // Locate our output directory @@ -31,6 +32,15 @@ esbuild.build({ format: 'esm', minify: isProd, logLevel: 'info', + loader: { + '.svg': 'text', + }, + 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', css: '/* See the "/licenses" URI for full package license details */', diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 000000000..3c04f05b2 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,209 @@ +/** + * 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: false, + + // 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: ['./'], + + // 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: { + 'lexical/shared/invariant': 'resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant', + ...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__/**/*.test.[jt]s", + ], + + // 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; diff --git a/lang/en/entities.php b/lang/en/entities.php index 9e620b24e..35e6f050b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -224,6 +224,8 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', + 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', 'pages_edit_set_changelog' => 'Set Changelog', 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', 'pages_edit_enter_changelog' => 'Enter Changelog', diff --git a/package-lock.json b/package-lock.json index 0f007659a..cdc1f3ee0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@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", "markdown-it": "^14.1.0", @@ -30,16 +31,597 @@ }, "devDependencies": { "@lezer/generator": "^1.5.1", + "babel-jest": "^29.7.0", "chokidar-cli": "^3.0", "esbuild": "^0.23.0", "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" + "sass": "^1.69.5", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" } }, + "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": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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": { + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@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": { + "@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/@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/@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": { + "@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/@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": { + "@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/@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": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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": { + "@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/@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/@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": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@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/@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/@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": { + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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": { + "@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/@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": ">=4" + } + }, + "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": ">=4" + } + }, + "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/@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": ">=0.8.0" + } + }, + "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": ">=4" + } + }, + "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": ">=4" + } + }, + "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, + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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": ">=6.9.0" + } + }, + "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": { + "@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": ">=6.9.0" + } + }, + "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": ">=4" + } + }, + "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": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@codemirror/autocomplete": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", @@ -58,9 +640,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", - "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz", + "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -69,15 +651,15 @@ } }, "node_modules/@codemirror/lang-css": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz", - "integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==", + "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.0.0" + "@lezer/css": "^1.1.7" } }, "node_modules/@codemirror/lang-html": { @@ -225,260 +807,26 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", - "cpu": [ - "ppc64" - ], + "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, - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", - "cpu": [ - "arm" - ], + "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, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@esbuild/linux-x64": { @@ -497,118 +845,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "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", @@ -700,6 +936,437 @@ "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", @@ -775,9 +1442,9 @@ } }, "node_modules/@lezer/markdown": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.0.tgz", - "integrity": "sha512-ErbEQ15eowmJUyT095e9NJc3BI9yZ894fjSDtHftD0InkfUBGgnKSU6dvan9jqsZuNHg2+ag/1oyDRxNsENupQ==", + "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" @@ -838,6 +1505,35 @@ "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", @@ -853,18 +1549,181 @@ "@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", @@ -877,6 +1736,16 @@ "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", @@ -886,6 +1755,30 @@ "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", @@ -902,6 +1795,33 @@ "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", @@ -915,7 +1835,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -939,6 +1858,12 @@ "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", @@ -1058,6 +1983,18 @@ "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", @@ -1073,6 +2010,116 @@ "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", @@ -1105,7 +2152,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -1113,6 +2159,65 @@ "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", @@ -1150,11 +2255,30 @@ "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==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1166,6 +2290,15 @@ "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", @@ -1208,6 +2341,26 @@ "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", @@ -1240,6 +2393,16 @@ "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", @@ -1254,11 +2417,16 @@ "@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==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1269,8 +2437,19 @@ "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 + "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", @@ -1284,6 +2463,39 @@ "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/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", @@ -1303,6 +2515,44 @@ "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", @@ -1355,12 +2605,12 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "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.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1380,12 +2630,41 @@ "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", @@ -1420,6 +2699,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -1432,6 +2746,52 @@ "node": ">=6.0.0" } }, + "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": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.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/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -1630,6 +2990,15 @@ "@esbuild/win32-x64": "0.23.1" } }, + "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, + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1642,6 +3011,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", @@ -1737,9 +3127,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.2.tgz", - "integrity": "sha512-3XnC5fDyc8M4J2E8pt8pmSVRX2M+5yWMCfI/kDZwauQeFgzQOuhcRBFKjTeJagqgk4sFKxe1mvNVnaWwImx/Tg==", + "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": { "debug": "^3.2.7" @@ -1763,26 +3153,27 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "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": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", + "@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.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", + "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.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", "semver": "^6.3.1", "tsconfig-paths": "^3.15.0" }, @@ -1871,6 +3262,19 @@ "url": "https://opencollective.com/eslint" } }, + "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, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -1913,6 +3317,53 @@ "node": ">=0.10.0" } }, + "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": { + "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/sindresorhus/execa?sponsor=1" + } + }, + "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, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dependencies": { + "@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": "^14.15.0 || ^16.10.0 || >=18.0.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", @@ -1940,6 +3391,15 @@ "reusify": "^1.0.4" } }, + "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", @@ -1952,11 +3412,40 @@ "node": "^10.12.0 || >=12.0.0" } }, + "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/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/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": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "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==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2009,26 +3498,26 @@ "is-callable": "^1.1.3" } }, + "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": ">= 6" + } + }, "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==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "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", @@ -2065,6 +3554,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2093,6 +3591,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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, + "engines": { + "node": ">=8.0.0" + } + }, + "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": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -2189,8 +3708,7 @@ "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==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -2211,7 +3729,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2285,6 +3802,72 @@ "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": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "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": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "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, + "engines": { + "node": ">=10.17.0" + } + }, + "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": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -2321,6 +3904,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2498,6 +4100,15 @@ "node": ">=4" } }, + "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, + "engines": { + "node": ">=6" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2526,7 +4137,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -2555,6 +4165,12 @@ "node": ">=8" } }, + "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", @@ -2586,6 +4202,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -2655,6 +4283,819 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "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, + "engines": { + "node": ">=8" + } + }, + "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": ">=10" + } + }, + "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": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "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": ">=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" + }, + "engines": { + "node": ">=8" + } + }, + "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": ">=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" + }, + "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/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": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "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" + }, + "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/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": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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/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": ">=8" + } + }, + "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/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": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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": ">=10" + } + }, + "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": { + "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/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": { + "@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": "^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/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": { + "@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": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "@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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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": { + "@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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "@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": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "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": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "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": "^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" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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/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": { + "@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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "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" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "@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": "^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/sindresorhus" + } + }, + "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": { + "@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": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "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", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2667,6 +5108,84 @@ "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", @@ -2679,6 +5198,12 @@ "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", @@ -2712,6 +5237,24 @@ "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", @@ -2725,6 +5268,12 @@ "node": ">= 0.8.0" } }, + "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", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2793,6 +5342,12 @@ "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", @@ -2805,6 +5360,57 @@ "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", @@ -2840,6 +5446,54 @@ "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", @@ -2862,9 +5516,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, "node_modules/natural-compare": { @@ -2879,6 +5533,18 @@ "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", @@ -3072,6 +5738,24 @@ "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", @@ -3183,6 +5867,21 @@ "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", @@ -3270,6 +5969,18 @@ "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", @@ -3315,11 +6026,15 @@ "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" }, @@ -3348,6 +6063,79 @@ "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", @@ -3366,6 +6154,49 @@ "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", @@ -3383,6 +6214,28 @@ "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", @@ -3403,6 +6256,11 @@ } ] }, + "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", @@ -3462,6 +6320,12 @@ "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", @@ -3479,6 +6343,27 @@ "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", @@ -3488,6 +6373,15 @@ "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", @@ -3572,10 +6466,16 @@ "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.77.8", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", - "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", + "integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -3589,6 +6489,18 @@ "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", @@ -3684,6 +6596,26 @@ "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", @@ -3693,9 +6625,18 @@ } }, "node_modules/sortablejs": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", - "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + "version": "1.15.3", + "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", @@ -3706,6 +6647,16 @@ "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", @@ -3738,6 +6689,44 @@ "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", @@ -3861,6 +6850,15 @@ "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", @@ -3882,7 +6880,6 @@ "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" }, @@ -3902,17 +6899,51 @@ "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" }, @@ -3920,6 +6951,157 @@ "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", @@ -3944,6 +7126,15 @@ "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", @@ -4029,6 +7220,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -4049,6 +7253,50 @@ "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", @@ -4058,6 +7306,36 @@ "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", @@ -4073,6 +7351,70 @@ "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", @@ -4206,6 +7548,19 @@ "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", @@ -4227,12 +7582,33 @@ } } }, + "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", @@ -4322,6 +7698,15 @@ "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", diff --git a/package.json b/package.json index aa826b416..46a412183 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources", "build:css:production": "sass ./resources/sass:./public/dist -s compressed", "build:js:dev": "node dev/build/esbuild.js", - "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" -c \"npm run build:js:dev\"", + "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" \"./resources/**/*.ts\" -c \"npm run build:js:dev\"", "build:js:production": "node dev/build/esbuild.js production", "build": "npm-run-all --parallel build:*:dev", "production": "npm-run-all --parallel build:*:production", @@ -14,18 +14,26 @@ "livereload": "livereload ./public/dist/", "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads", "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", - "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"" + "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"", + "ts:lint": "tsc --noEmit", + "test": "jest" }, "devDependencies": { "@lezer/generator": "^1.5.1", + "babel-jest": "^29.7.0", "chokidar-cli": "^3.0", "esbuild": "^0.23.0", "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" + "sass": "^1.69.5", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" }, "dependencies": { "@codemirror/commands": "^6.3.2", @@ -44,6 +52,7 @@ "@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", "markdown-it": "^14.1.0", @@ -59,7 +68,8 @@ }, "extends": "airbnb-base", "ignorePatterns": [ - "resources/**/*-stub.js" + "resources/**/*-stub.js", + "resources/**/*.ts" ], "overrides": [], "parserOptions": { diff --git a/readme.md b/readme.md index b3768a59a..857e2051a 100644 --- a/readme.md +++ b/readme.md @@ -152,6 +152,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be * [Laravel](http://laravel.com/) - _[MIT](https://github.com/laravel/framework/blob/v8.82.0/LICENSE.md)_ * [TinyMCE](https://www.tinymce.com/) - _[MIT](https://github.com/tinymce/tinymce/blob/develop/LICENSE.TXT)_ +* [Lexical](https://lexical.dev/) - _[MIT](https://github.com/facebook/lexical/blob/main/LICENSE)_ * [CodeMirror](https://codemirror.net) - _[MIT](https://github.com/codemirror/CodeMirror/blob/master/LICENSE)_ * [Sortable](https://github.com/SortableJS/Sortable) - _[MIT](https://github.com/SortableJS/Sortable/blob/master/LICENSE)_ * [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_ diff --git a/resources/icons/caret-down-large.svg b/resources/icons/caret-down-large.svg new file mode 100644 index 000000000..a15ec42c8 --- /dev/null +++ b/resources/icons/caret-down-large.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/close.svg b/resources/icons/close.svg index c2ef46510..afd3f4671 100644 --- a/resources/icons/close.svg +++ b/resources/icons/close.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/icons/editor/align-center.svg b/resources/icons/editor/align-center.svg new file mode 100644 index 000000000..495ae000c --- /dev/null +++ b/resources/icons/editor/align-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/align-justify.svg b/resources/icons/editor/align-justify.svg new file mode 100644 index 000000000..bf8f61abb --- /dev/null +++ b/resources/icons/editor/align-justify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/align-left.svg b/resources/icons/editor/align-left.svg new file mode 100644 index 000000000..811212755 --- /dev/null +++ b/resources/icons/editor/align-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/align-right.svg b/resources/icons/editor/align-right.svg new file mode 100644 index 000000000..839110c42 --- /dev/null +++ b/resources/icons/editor/align-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/bold.svg b/resources/icons/editor/bold.svg new file mode 100644 index 000000000..93cc44a3f --- /dev/null +++ b/resources/icons/editor/bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/code-block.svg b/resources/icons/editor/code-block.svg new file mode 100644 index 000000000..308db53b4 --- /dev/null +++ b/resources/icons/editor/code-block.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/code.svg b/resources/icons/editor/code.svg new file mode 100644 index 000000000..d8434b761 --- /dev/null +++ b/resources/icons/editor/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/color-clear.svg b/resources/icons/editor/color-clear.svg new file mode 100644 index 000000000..5d0850282 --- /dev/null +++ b/resources/icons/editor/color-clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/details.svg b/resources/icons/editor/details.svg new file mode 100644 index 000000000..d86e8c423 --- /dev/null +++ b/resources/icons/editor/details.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/diagram.svg b/resources/icons/editor/diagram.svg new file mode 100644 index 000000000..6ac78f56e --- /dev/null +++ b/resources/icons/editor/diagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/direction-ltr.svg b/resources/icons/editor/direction-ltr.svg new file mode 100644 index 000000000..16befc75c --- /dev/null +++ b/resources/icons/editor/direction-ltr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/direction-rtl.svg b/resources/icons/editor/direction-rtl.svg new file mode 100644 index 000000000..5125472a0 --- /dev/null +++ b/resources/icons/editor/direction-rtl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/format-clear.svg b/resources/icons/editor/format-clear.svg new file mode 100644 index 000000000..b6483fb56 --- /dev/null +++ b/resources/icons/editor/format-clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/fullscreen.svg b/resources/icons/editor/fullscreen.svg new file mode 100644 index 000000000..3cca3097a --- /dev/null +++ b/resources/icons/editor/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/help.svg b/resources/icons/editor/help.svg new file mode 100644 index 000000000..8c3410b84 --- /dev/null +++ b/resources/icons/editor/help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/highlighter.svg b/resources/icons/editor/highlighter.svg new file mode 100644 index 000000000..b2eaacdb2 --- /dev/null +++ b/resources/icons/editor/highlighter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/horizontal-rule.svg b/resources/icons/editor/horizontal-rule.svg new file mode 100644 index 000000000..c70df0d6e --- /dev/null +++ b/resources/icons/editor/horizontal-rule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/image-search.svg b/resources/icons/editor/image-search.svg new file mode 100644 index 000000000..b8cb2cfc8 --- /dev/null +++ b/resources/icons/editor/image-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/image.svg b/resources/icons/editor/image.svg new file mode 100644 index 000000000..81d04cea7 --- /dev/null +++ b/resources/icons/editor/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/indent-decrease.svg b/resources/icons/editor/indent-decrease.svg new file mode 100644 index 000000000..af0caa862 --- /dev/null +++ b/resources/icons/editor/indent-decrease.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/indent-increase.svg b/resources/icons/editor/indent-increase.svg new file mode 100644 index 000000000..aa6b4cb36 --- /dev/null +++ b/resources/icons/editor/indent-increase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/italic.svg b/resources/icons/editor/italic.svg new file mode 100644 index 000000000..a98819427 --- /dev/null +++ b/resources/icons/editor/italic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/link.svg b/resources/icons/editor/link.svg new file mode 100644 index 000000000..b29800dc3 --- /dev/null +++ b/resources/icons/editor/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-bullet.svg b/resources/icons/editor/list-bullet.svg new file mode 100644 index 000000000..c073c6ff0 --- /dev/null +++ b/resources/icons/editor/list-bullet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-check.svg b/resources/icons/editor/list-check.svg new file mode 100644 index 000000000..4517d0d53 --- /dev/null +++ b/resources/icons/editor/list-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-numbered.svg b/resources/icons/editor/list-numbered.svg new file mode 100644 index 000000000..4bc0fc1ba --- /dev/null +++ b/resources/icons/editor/list-numbered.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/media.svg b/resources/icons/editor/media.svg new file mode 100644 index 000000000..0c4feea4c --- /dev/null +++ b/resources/icons/editor/media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/more-horizontal.svg b/resources/icons/editor/more-horizontal.svg new file mode 100644 index 000000000..ce09d9855 --- /dev/null +++ b/resources/icons/editor/more-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/redo.svg b/resources/icons/editor/redo.svg new file mode 100644 index 000000000..d542296c5 --- /dev/null +++ b/resources/icons/editor/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/source-view.svg b/resources/icons/editor/source-view.svg new file mode 100644 index 000000000..5314c39da --- /dev/null +++ b/resources/icons/editor/source-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/strikethrough.svg b/resources/icons/editor/strikethrough.svg new file mode 100644 index 000000000..92d14aa76 --- /dev/null +++ b/resources/icons/editor/strikethrough.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/subscript.svg b/resources/icons/editor/subscript.svg new file mode 100644 index 000000000..e877b3359 --- /dev/null +++ b/resources/icons/editor/subscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/superscript.svg b/resources/icons/editor/superscript.svg new file mode 100644 index 000000000..897ceddc2 --- /dev/null +++ b/resources/icons/editor/superscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-delete-column.svg b/resources/icons/editor/table-delete-column.svg new file mode 100644 index 000000000..428fc29a6 --- /dev/null +++ b/resources/icons/editor/table-delete-column.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-delete-row.svg b/resources/icons/editor/table-delete-row.svg new file mode 100644 index 000000000..ee2f8a00d --- /dev/null +++ b/resources/icons/editor/table-delete-row.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-delete.svg b/resources/icons/editor/table-delete.svg new file mode 100644 index 000000000..412cf0732 --- /dev/null +++ b/resources/icons/editor/table-delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-column-after.svg b/resources/icons/editor/table-insert-column-after.svg new file mode 100644 index 000000000..75abd9a85 --- /dev/null +++ b/resources/icons/editor/table-insert-column-after.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-column-before.svg b/resources/icons/editor/table-insert-column-before.svg new file mode 100644 index 000000000..5bb38cd29 --- /dev/null +++ b/resources/icons/editor/table-insert-column-before.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-row-above.svg b/resources/icons/editor/table-insert-row-above.svg new file mode 100644 index 000000000..df951485a --- /dev/null +++ b/resources/icons/editor/table-insert-row-above.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-row-below.svg b/resources/icons/editor/table-insert-row-below.svg new file mode 100644 index 000000000..b2af77592 --- /dev/null +++ b/resources/icons/editor/table-insert-row-below.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table.svg b/resources/icons/editor/table.svg new file mode 100644 index 000000000..15425063c --- /dev/null +++ b/resources/icons/editor/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/text-color.svg b/resources/icons/editor/text-color.svg new file mode 100644 index 000000000..a862e1962 --- /dev/null +++ b/resources/icons/editor/text-color.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/editor/underlined.svg b/resources/icons/editor/underlined.svg new file mode 100644 index 000000000..5d17ef6ef --- /dev/null +++ b/resources/icons/editor/underlined.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/undo.svg b/resources/icons/editor/undo.svg new file mode 100644 index 000000000..4b9f22675 --- /dev/null +++ b/resources/icons/editor/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/unlink.svg b/resources/icons/editor/unlink.svg new file mode 100644 index 000000000..28f47fd24 --- /dev/null +++ b/resources/icons/editor/unlink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index 5b822e900..7f4bbe54d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,9 +1,11 @@ -import * as events from './services/events'; -import * as httpInstance from './services/http'; +import {EventManager} from './services/events.ts'; +import {HttpManager} from './services/http.ts'; import Translations from './services/translations'; - -import * as components from './services/components'; 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) { @@ -21,8 +23,8 @@ window.importVersioned = function importVersioned(moduleName) { }; // Set events and http services on window -window.$http = httpInstance; -window.$events = events; +window.$http = new HttpManager(); +window.$events = new EventManager(); // Translation setup // Creates a global function with name 'trans' to be used in the same way as the Laravel translation system @@ -32,6 +34,6 @@ window.trans_choice = translator.getPlural.bind(translator); window.trans_plural = translator.parsePlural.bind(translator); // Load & initialise components -components.register(componentMap); -window.$components = components; -components.init(); +window.$components = new ComponentStore(); +window.$components.register(componentMap); +window.$components.init(); diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 3a66079d7..8ad5e14cb 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -58,4 +58,5 @@ export {TriLayout} from './tri-layout'; export {UserSelect} from './user-select'; export {WebhookEvents} from './webhook-events'; export {WysiwygEditor} from './wysiwyg-editor'; +export {WysiwygEditorTinymce} from './wysiwyg-editor-tinymce'; export {WysiwygInput} from './wysiwyg-input'; diff --git a/resources/js/components/markdown-editor.js b/resources/js/components/markdown-editor.js index cd928de9f..ad5bcf090 100644 --- a/resources/js/components/markdown-editor.js +++ b/resources/js/components/markdown-editor.js @@ -133,9 +133,9 @@ export class MarkdownEditor extends Component { /** * Get the content of this editor. * Used by the parent page editor component. - * @return {{html: String, markdown: String}} + * @return {Promise<{html: String, markdown: String}>} */ - getContent() { + async getContent() { return this.editor.actions.getContent(); } diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js index 79c9d3c2c..cac20c9fb 100644 --- a/resources/js/components/page-comment.js +++ b/resources/js/components/page-comment.js @@ -1,6 +1,6 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; -import {buildForInput} from '../wysiwyg/config'; +import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComment extends Component { diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index cfb0634a9..bd6dd3c82 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -1,6 +1,6 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; -import {buildForInput} from '../wysiwyg/config'; +import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComments extends Component { diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 963c21008..ecfc3546f 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -118,7 +118,7 @@ export class PageEditor extends Component { async saveDraft() { const data = {name: this.titleElem.value.trim()}; - const editorContent = this.getEditorComponent().getContent(); + const editorContent = await this.getEditorComponent().getContent(); Object.assign(data, editorContent); let didSave = false; @@ -235,10 +235,12 @@ export class PageEditor extends Component { } /** - * @return MarkdownEditor|WysiwygEditor + * @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce} */ getEditorComponent() { - return window.$components.first('markdown-editor') || window.$components.first('wysiwyg-editor'); + return window.$components.first('markdown-editor') + || window.$components.first('wysiwyg-editor') + || window.$components.first('wysiwyg-editor-tinymce'); } } diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js index b22c46731..8bf26fbb5 100644 --- a/resources/js/components/shortcuts.js +++ b/resources/js/components/shortcuts.js @@ -25,7 +25,7 @@ export class Shortcuts extends Component { setupListeners() { window.addEventListener('keydown', event => { - if (event.target.closest('input, select, textarea, .cm-editor')) { + if (event.target.closest('input, select, textarea, .cm-editor, .editor-container')) { return; } diff --git a/resources/js/components/wysiwyg-editor-tinymce.js b/resources/js/components/wysiwyg-editor-tinymce.js new file mode 100644 index 000000000..46ae6ecf4 --- /dev/null +++ b/resources/js/components/wysiwyg-editor-tinymce.js @@ -0,0 +1,48 @@ +import {buildForEditor as buildEditorConfig} from '../wysiwyg-tinymce/config'; +import {Component} from './component'; + +export class WysiwygEditorTinymce extends Component { + + setup() { + this.elem = this.$el; + + this.tinyMceConfig = buildEditorConfig({ + language: this.$opts.language, + containerElement: this.elem, + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.$opts.textDirection, + drawioUrl: this.getDrawIoUrl(), + pageId: Number(this.$opts.pageId), + translations: { + imageUploadErrorText: this.$opts.imageUploadErrorText, + serverUploadLimitText: this.$opts.serverUploadLimitText, + }, + translationMap: window.editor_translations, + }); + + window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig}); + window.tinymce.init(this.tinyMceConfig).then(editors => { + this.editor = editors[0]; + }); + } + + getDrawIoUrl() { + const drawioUrlElem = document.querySelector('[drawio-url]'); + if (drawioUrlElem) { + return drawioUrlElem.getAttribute('drawio-url'); + } + return ''; + } + + /** + * Get the content of this editor. + * Used by the parent page editor component. + * @return {Promise<{html: String}>} + */ + async getContent() { + return { + html: this.editor.getContent(), + }; + } + +} diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 82f60827d..56dbe8d7c 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -1,28 +1,48 @@ -import {buildForEditor as buildEditorConfig} from '../wysiwyg/config'; import {Component} from './component'; export class WysiwygEditor extends Component { setup() { this.elem = this.$el; + this.editContainer = this.$refs.editContainer; + this.input = this.$refs.input; - this.tinyMceConfig = buildEditorConfig({ - language: this.$opts.language, - containerElement: this.elem, - darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.$opts.textDirection, - drawioUrl: this.getDrawIoUrl(), - pageId: Number(this.$opts.pageId), - translations: { - imageUploadErrorText: this.$opts.imageUploadErrorText, - serverUploadLimitText: this.$opts.serverUploadLimitText, - }, - translationMap: window.editor_translations, + /** @var {SimpleWysiwygEditorInterface|null} */ + this.editor = null; + + const translations = { + ...window.editor_translations, + imageUploadErrorText: this.$opts.imageUploadErrorText, + serverUploadLimitText: this.$opts.serverUploadLimitText, + }; + + window.importVersioned('wysiwyg').then(wysiwyg => { + const editorContent = this.input.value; + this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent, { + drawioUrl: this.getDrawIoUrl(), + pageId: Number(this.$opts.pageId), + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.$opts.textDirection, + translations, + }); }); - window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig}); - window.tinymce.init(this.tinyMceConfig).then(editors => { - this.editor = editors[0]; + let handlingFormSubmit = false; + this.input.form.addEventListener('submit', event => { + if (!this.editor) { + return; + } + + if (!handlingFormSubmit) { + event.preventDefault(); + handlingFormSubmit = true; + this.editor.getContentAsHtml().then(html => { + this.input.value = html; + this.input.form.submit(); + }); + } else { + handlingFormSubmit = false; + } }); } @@ -37,11 +57,11 @@ export class WysiwygEditor extends Component { /** * Get the content of this editor. * Used by the parent page editor component. - * @return {{html: String}} + * @return {Promise<{html: String}>} */ - getContent() { + async getContent() { return { - html: this.editor.getContent(), + html: await this.editor.getContentAsHtml(), }; } diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js index ad964aed2..aa21a6371 100644 --- a/resources/js/components/wysiwyg-input.js +++ b/resources/js/components/wysiwyg-input.js @@ -1,5 +1,5 @@ import {Component} from './component'; -import {buildForInput} from '../wysiwyg/config'; +import {buildForInput} from '../wysiwyg-tinymce/config'; export class WysiwygInput extends Component { diff --git a/resources/js/custom.d.ts b/resources/js/custom.d.ts new file mode 100644 index 000000000..c5aba8ee2 --- /dev/null +++ b/resources/js/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts new file mode 100644 index 000000000..0d7efc4d4 --- /dev/null +++ b/resources/js/global.d.ts @@ -0,0 +1,14 @@ +import {ComponentStore} from "./services/components"; +import {EventManager} from "./services/events"; +import {HttpManager} from "./services/http"; + +declare global { + const __DEV__: boolean; + + interface Window { + $components: ComponentStore; + $events: EventManager; + $http: HttpManager; + baseUrl: (path: string) => string; + } +} \ No newline at end of file diff --git a/resources/js/services/components.js b/resources/js/services/components.js deleted file mode 100644 index beb0ce92f..000000000 --- a/resources/js/services/components.js +++ /dev/null @@ -1,165 +0,0 @@ -import {kebabToCamel, camelToKebab} from './text'; - -/** - * A mapping of active components keyed by name, with values being arrays of component - * instances since there can be multiple components of the same type. - * @type {Object} - */ -const components = {}; - -/** - * A mapping of component class models, keyed by name. - * @type {Object>} - */ -const componentModelMap = {}; - -/** - * A mapping of active component maps, keyed by the element components are assigned to. - * @type {WeakMap>} - */ -const elementComponentMap = new WeakMap(); - -/** - * Parse out the element references within the given element - * for the given component name. - * @param {String} name - * @param {Element} element - */ -function parseRefs(name, element) { - const refs = {}; - const manyRefs = {}; - - const prefix = `${name}@`; - const selector = `[refs*="${prefix}"]`; - const refElems = [...element.querySelectorAll(selector)]; - if (element.matches(selector)) { - refElems.push(element); - } - - for (const el of refElems) { - const refNames = el.getAttribute('refs') - .split(' ') - .filter(str => str.startsWith(prefix)) - .map(str => str.replace(prefix, '')) - .map(kebabToCamel); - for (const ref of refNames) { - refs[ref] = el; - if (typeof manyRefs[ref] === 'undefined') { - manyRefs[ref] = []; - } - manyRefs[ref].push(el); - } - } - return {refs, manyRefs}; -} - -/** - * Parse out the element component options. - * @param {String} componentName - * @param {Element} element - * @return {Object} - */ -function parseOpts(componentName, element) { - const opts = {}; - const prefix = `option:${componentName}:`; - for (const {name, value} of element.attributes) { - if (name.startsWith(prefix)) { - const optName = name.replace(prefix, ''); - opts[kebabToCamel(optName)] = value || ''; - } - } - return opts; -} - -/** - * Initialize a component instance on the given dom element. - * @param {String} name - * @param {Element} element - */ -function initComponent(name, element) { - /** @type {Function|undefined} * */ - const ComponentModel = componentModelMap[name]; - if (ComponentModel === undefined) return; - - // Create our component instance - /** @type {Component} * */ - let instance; - try { - instance = new ComponentModel(); - instance.$name = name; - instance.$el = element; - const allRefs = parseRefs(name, element); - instance.$refs = allRefs.refs; - instance.$manyRefs = allRefs.manyRefs; - instance.$opts = parseOpts(name, element); - instance.setup(); - } catch (e) { - console.error('Failed to create component', e, name, element); - } - - // Add to global listing - if (typeof components[name] === 'undefined') { - components[name] = []; - } - components[name].push(instance); - - // Add to element mapping - const elComponents = elementComponentMap.get(element) || {}; - elComponents[name] = instance; - elementComponentMap.set(element, elComponents); -} - -/** - * Initialize all components found within the given element. - * @param {Element|Document} parentElement - */ -export function init(parentElement = document) { - const componentElems = parentElement.querySelectorAll('[component],[components]'); - - for (const el of componentElems) { - const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean); - for (const name of componentNames) { - initComponent(name, el); - } - } -} - -/** - * Register the given component mapping into the component system. - * @param {Object>} mapping - */ -export function register(mapping) { - const keys = Object.keys(mapping); - for (const key of keys) { - componentModelMap[camelToKebab(key)] = mapping[key]; - } -} - -/** - * Get the first component of the given name. - * @param {String} name - * @returns {Component|null} - */ -export function first(name) { - return (components[name] || [null])[0]; -} - -/** - * Get all the components of the given name. - * @param {String} name - * @returns {Component[]} - */ -export function get(name) { - return components[name] || []; -} - -/** - * Get the first component, of the given name, that's assigned to the given element. - * @param {Element} element - * @param {String} name - * @returns {Component|null} - */ -export function firstOnElement(element, name) { - const elComponents = elementComponentMap.get(element) || {}; - return elComponents[name] || null; -} diff --git a/resources/js/services/components.ts b/resources/js/services/components.ts new file mode 100644 index 000000000..c19939e92 --- /dev/null +++ b/resources/js/services/components.ts @@ -0,0 +1,153 @@ +import {kebabToCamel, camelToKebab} from './text'; +import {Component} from "../components/component"; + +/** + * Parse out the element references within the given element + * for the given component name. + */ +function parseRefs(name: string, element: HTMLElement): + {refs: Record, manyRefs: Record} { + const refs: Record = {}; + const manyRefs: Record = {}; + + const prefix = `${name}@`; + const selector = `[refs*="${prefix}"]`; + const refElems = [...element.querySelectorAll(selector)]; + if (element.matches(selector)) { + refElems.push(element); + } + + for (const el of refElems as HTMLElement[]) { + const refNames = (el.getAttribute('refs') || '') + .split(' ') + .filter(str => str.startsWith(prefix)) + .map(str => str.replace(prefix, '')) + .map(kebabToCamel); + for (const ref of refNames) { + refs[ref] = el; + if (typeof manyRefs[ref] === 'undefined') { + manyRefs[ref] = []; + } + manyRefs[ref].push(el); + } + } + return {refs, manyRefs}; +} + +/** + * Parse out the element component options. + */ +function parseOpts(componentName: string, element: HTMLElement): Record { + const opts: Record = {}; + const prefix = `option:${componentName}:`; + for (const {name, value} of element.attributes) { + if (name.startsWith(prefix)) { + const optName = name.replace(prefix, ''); + opts[kebabToCamel(optName)] = value || ''; + } + } + return opts; +} + +export class ComponentStore { + /** + * A mapping of active components keyed by name, with values being arrays of component + * instances since there can be multiple components of the same type. + */ + protected components: Record = {}; + + /** + * A mapping of component class models, keyed by name. + */ + protected componentModelMap: Record = {}; + + /** + * A mapping of active component maps, keyed by the element components are assigned to. + */ + protected elementComponentMap: WeakMap> = new WeakMap(); + + /** + * Initialize a component instance on the given dom element. + */ + protected initComponent(name: string, element: HTMLElement): void { + const ComponentModel = this.componentModelMap[name]; + if (ComponentModel === undefined) return; + + // Create our component instance + let instance: Component|null = null; + try { + instance = new ComponentModel(); + instance.$name = name; + instance.$el = element; + const allRefs = parseRefs(name, element); + instance.$refs = allRefs.refs; + instance.$manyRefs = allRefs.manyRefs; + instance.$opts = parseOpts(name, element); + instance.setup(); + } catch (e) { + console.error('Failed to create component', e, name, element); + } + + if (!instance) { + return; + } + + // Add to global listing + if (typeof this.components[name] === 'undefined') { + this.components[name] = []; + } + this.components[name].push(instance); + + // Add to element mapping + const elComponents = this.elementComponentMap.get(element) || {}; + elComponents[name] = instance; + this.elementComponentMap.set(element, elComponents); + } + + /** + * Initialize all components found within the given element. + */ + public init(parentElement: Document|HTMLElement = document) { + const componentElems = parentElement.querySelectorAll('[component],[components]'); + + for (const el of componentElems) { + const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean); + for (const name of componentNames) { + this.initComponent(name, el as HTMLElement); + } + } + } + + /** + * Register the given component mapping into the component system. + * @param {Object>} mapping + */ + public register(mapping: Record) { + const keys = Object.keys(mapping); + for (const key of keys) { + this.componentModelMap[camelToKebab(key)] = mapping[key]; + } + } + + /** + * Get the first component of the given name. + */ + public first(name: string): Component|null { + return (this.components[name] || [null])[0]; + } + + /** + * Get all the components of the given name. + */ + public get(name: string): Component[] { + return this.components[name] || []; + } + + /** + * Get the first component, of the given name, that's assigned to the given element. + */ + public firstOnElement(element: HTMLElement, name: string): Component|null { + const elComponents = this.elementComponentMap.get(element) || {}; + return elComponents[name] || null; + } +} diff --git a/resources/js/services/drawio.js b/resources/js/services/drawio.ts similarity index 65% rename from resources/js/services/drawio.js rename to resources/js/services/drawio.ts index 46e10327a..4d7d88f1f 100644 --- a/resources/js/services/drawio.js +++ b/resources/js/services/drawio.ts @@ -1,17 +1,32 @@ // Docs: https://www.diagrams.net/doc/faq/embed-mode import * as store from './store'; +import {ConfirmDialog} from "../components"; +import {HttpError} from "./http"; -let iFrame = null; -let lastApprovedOrigin; -let onInit; -let onSave; +type DrawioExportEventResponse = { + action: 'export', + format: string, + message: string, + data: string, + xml: string, +}; + +type DrawioSaveEventResponse = { + action: 'save', + xml: string, +}; + +let iFrame: HTMLIFrameElement|null = null; +let lastApprovedOrigin: string; +let onInit: () => Promise; +let onSave: (data: string) => Promise; const saveBackupKey = 'last-drawing-save'; -function drawPostMessage(data) { - iFrame.contentWindow.postMessage(JSON.stringify(data), lastApprovedOrigin); +function drawPostMessage(data: Record): void { + iFrame?.contentWindow?.postMessage(JSON.stringify(data), lastApprovedOrigin); } -function drawEventExport(message) { +function drawEventExport(message: DrawioExportEventResponse) { store.set(saveBackupKey, message.data); if (onSave) { onSave(message.data).then(() => { @@ -20,7 +35,7 @@ function drawEventExport(message) { } } -function drawEventSave(message) { +function drawEventSave(message: DrawioSaveEventResponse) { drawPostMessage({ action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing', }); @@ -35,8 +50,10 @@ function drawEventInit() { function drawEventConfigure() { const config = {}; - window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config}); - drawPostMessage({action: 'configure', config}); + if (iFrame) { + window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config}); + drawPostMessage({action: 'configure', config}); + } } function drawEventClose() { @@ -47,9 +64,8 @@ function drawEventClose() { /** * Receive and handle a message event from the draw.io window. - * @param {MessageEvent} event */ -function drawReceive(event) { +function drawReceive(event: MessageEvent) { if (!event.data || event.data.length < 1) return; if (event.origin !== lastApprovedOrigin) return; @@ -59,9 +75,9 @@ function drawReceive(event) { } else if (message.event === 'exit') { drawEventClose(); } else if (message.event === 'save') { - drawEventSave(message); + drawEventSave(message as DrawioSaveEventResponse); } else if (message.event === 'export') { - drawEventExport(message); + drawEventExport(message as DrawioExportEventResponse); } else if (message.event === 'configure') { drawEventConfigure(); } @@ -79,9 +95,8 @@ async function attemptRestoreIfExists() { console.error('Missing expected unsaved-drawing dialog'); } - if (backupVal) { - /** @var {ConfirmDialog} */ - const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog'); + if (backupVal && dialogEl) { + const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog') as ConfirmDialog; const restore = await dialog.show(); if (restore) { onInit = async () => backupVal; @@ -94,11 +109,9 @@ async function attemptRestoreIfExists() { * onSaveCallback must return a promise that resolves on successful save and errors on failure. * onInitCallback must return a promise with the xml to load for the editor. * Will attempt to provide an option to restore unsaved changes if found to exist. - * @param {String} drawioUrl - * @param {Function>} onInitCallback - * @param {Function} onSaveCallback - Is called with the drawing data on save. + * onSaveCallback Is called with the drawing data on save. */ -export async function show(drawioUrl, onInitCallback, onSaveCallback) { +export async function show(drawioUrl: string, onInitCallback: () => Promise, onSaveCallback: (data: string) => Promise): Promise { onInit = onInitCallback; onSave = onSaveCallback; @@ -114,13 +127,13 @@ export async function show(drawioUrl, onInitCallback, onSaveCallback) { lastApprovedOrigin = (new URL(drawioUrl)).origin; } -export async function upload(imageData, pageUploadedToId) { +export async function upload(imageData: string, pageUploadedToId: string): Promise<{id: number, url: string}> { const data = { image: imageData, uploaded_to: pageUploadedToId, }; const resp = await window.$http.post(window.baseUrl('/images/drawio'), data); - return resp.data; + return resp.data as {id: number, url: string}; } export function close() { @@ -129,15 +142,14 @@ export function close() { /** * Load an existing image, by fetching it as Base64 from the system. - * @param drawingId - * @returns {Promise} */ -export async function load(drawingId) { +export async function load(drawingId: string): Promise { try { const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`)); - return `data:image/png;base64,${resp.data.content}`; + const data = resp.data as {content: string}; + return `data:image/png;base64,${data.content}`; } catch (error) { - if (error instanceof window.$http.HttpError) { + if (error instanceof HttpError) { window.$events.showResponseError(error); } close(); diff --git a/resources/js/services/events.js b/resources/js/services/events.js deleted file mode 100644 index 761305793..000000000 --- a/resources/js/services/events.js +++ /dev/null @@ -1,81 +0,0 @@ -const listeners = {}; -const stack = []; - -/** - * Emit a custom event for any handlers to pick-up. - * @param {String} eventName - * @param {*} eventData - */ -export function emit(eventName, eventData) { - stack.push({name: eventName, data: eventData}); - - const listenersToRun = listeners[eventName] || []; - for (const listener of listenersToRun) { - listener(eventData); - } -} - -/** - * Listen to a custom event and run the given callback when that event occurs. - * @param {String} eventName - * @param {Function} callback - * @returns {Events} - */ -export function listen(eventName, callback) { - if (typeof listeners[eventName] === 'undefined') listeners[eventName] = []; - listeners[eventName].push(callback); -} - -/** - * Emit an event for public use. - * Sends the event via the native DOM event handling system. - * @param {Element} targetElement - * @param {String} eventName - * @param {Object} eventData - */ -export function emitPublic(targetElement, eventName, eventData) { - const event = new CustomEvent(eventName, { - detail: eventData, - bubbles: true, - }); - targetElement.dispatchEvent(event); -} - -/** - * Emit a success event with the provided message. - * @param {String} message - */ -export function success(message) { - emit('success', message); -} - -/** - * Emit an error event with the provided message. - * @param {String} message - */ -export function error(message) { - emit('error', message); -} - -/** - * Notify of standard server-provided validation errors. - * @param {Object} responseErr - */ -export function showValidationErrors(responseErr) { - if (!responseErr.status) return; - if (responseErr.status === 422 && responseErr.data) { - const message = Object.values(responseErr.data).flat().join('\n'); - error(message); - } -} - -/** - * Notify standard server-provided error messages. - * @param {Object} responseErr - */ -export function showResponseError(responseErr) { - if (!responseErr.status) return; - if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) { - error(responseErr.data.message); - } -} diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts new file mode 100644 index 000000000..be9fba7ec --- /dev/null +++ b/resources/js/services/events.ts @@ -0,0 +1,73 @@ +import {HttpError} from "./http"; + +export class EventManager { + protected listeners: Record void)[]> = {}; + protected stack: {name: string, data: {}}[] = []; + + /** + * Emit a custom event for any handlers to pick-up. + */ + emit(eventName: string, eventData: {} = {}): void { + this.stack.push({name: eventName, data: eventData}); + + const listenersToRun = this.listeners[eventName] || []; + for (const listener of listenersToRun) { + listener(eventData); + } + } + + /** + * Listen to a custom event and run the given callback when that event occurs. + */ + listen(eventName: string, callback: (data: T) => void): void { + if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = []; + this.listeners[eventName].push(callback); + } + + /** + * Emit an event for public use. + * Sends the event via the native DOM event handling system. + */ + emitPublic(targetElement: Element, eventName: string, eventData: {}): void { + const event = new CustomEvent(eventName, { + detail: eventData, + bubbles: true, + }); + targetElement.dispatchEvent(event); + } + + /** + * Emit a success event with the provided message. + */ + success(message: string): void { + this.emit('success', message); + } + + /** + * Emit an error event with the provided message. + */ + error(message: string): void { + this.emit('error', message); + } + + /** + * Notify of standard server-provided validation errors. + */ + showValidationErrors(responseErr: {status?: number, data?: object}): void { + if (!responseErr.status) return; + if (responseErr.status === 422 && responseErr.data) { + const message = Object.values(responseErr.data).flat().join('\n'); + this.error(message); + } + } + + /** + * Notify standard server-provided error messages. + */ + showResponseError(responseErr: {status?: number, data?: Record}|HttpError): void { + if (!responseErr.status) return; + if (responseErr.status >= 400 && typeof responseErr.data === 'object' && responseErr.data.message) { + this.error(responseErr.data.message); + } + } +} diff --git a/resources/js/services/http.js b/resources/js/services/http.js deleted file mode 100644 index d95e4a59a..000000000 --- a/resources/js/services/http.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * @typedef FormattedResponse - * @property {Headers} headers - * @property {Response} original - * @property {Object|String} data - * @property {Boolean} redirected - * @property {Number} status - * @property {string} statusText - * @property {string} url - */ - -/** - * Get the content from a fetch response. - * Checks the content-type header to determine the format. - * @param {Response} response - * @returns {Promise} - */ -async function getResponseContent(response) { - if (response.status === 204) { - return null; - } - - const responseContentType = response.headers.get('Content-Type') || ''; - const subType = responseContentType.split(';')[0].split('/').pop(); - - if (subType === 'javascript' || subType === 'json') { - return response.json(); - } - - return response.text(); -} - -export class HttpError extends Error { - - constructor(response, content) { - super(response.statusText); - this.data = content; - this.headers = response.headers; - this.redirected = response.redirected; - this.status = response.status; - this.statusText = response.statusText; - this.url = response.url; - this.original = response; - } - -} - -/** - * @param {String} method - * @param {String} url - * @param {Object} events - * @return {XMLHttpRequest} - */ -export function createXMLHttpRequest(method, url, events = {}) { - const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); - const req = new XMLHttpRequest(); - - for (const [eventName, callback] of Object.entries(events)) { - req.addEventListener(eventName, callback.bind(req)); - } - - req.open(method, url); - req.withCredentials = true; - req.setRequestHeader('X-CSRF-TOKEN', csrfToken); - - return req; -} - -/** - * Create a new HTTP request, setting the required CSRF information - * to communicate with the back-end. Parses & formats the response. - * @param {String} url - * @param {Object} options - * @returns {Promise} - */ -async function request(url, options = {}) { - let requestUrl = url; - - if (!requestUrl.startsWith('http')) { - requestUrl = window.baseUrl(requestUrl); - } - - if (options.params) { - const urlObj = new URL(requestUrl); - for (const paramName of Object.keys(options.params)) { - const value = options.params[paramName]; - if (typeof value !== 'undefined' && value !== null) { - urlObj.searchParams.set(paramName, value); - } - } - requestUrl = urlObj.toString(); - } - - const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); - const requestOptions = {...options, credentials: 'same-origin'}; - requestOptions.headers = { - ...requestOptions.headers || {}, - baseURL: window.baseUrl(''), - 'X-CSRF-TOKEN': csrfToken, - }; - - const response = await fetch(requestUrl, requestOptions); - const content = await getResponseContent(response); - const returnData = { - data: content, - headers: response.headers, - redirected: response.redirected, - status: response.status, - statusText: response.statusText, - url: response.url, - original: response, - }; - - if (!response.ok) { - throw new HttpError(response, content); - } - - return returnData; -} - -/** - * Perform a HTTP request to the back-end that includes data in the body. - * Parses the body to JSON if an object, setting the correct headers. - * @param {String} method - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -async function dataRequest(method, url, data = null) { - const options = { - method, - body: data, - }; - - // Send data as JSON if a plain object - if (typeof data === 'object' && !(data instanceof FormData)) { - options.headers = { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }; - options.body = JSON.stringify(data); - } - - // Ensure FormData instances are sent over POST - // Since Laravel does not read multipart/form-data from other types - // of request. Hence the addition of the magic _method value. - if (data instanceof FormData && method !== 'post') { - data.append('_method', method); - options.method = 'post'; - } - - return request(url, options); -} - -/** - * Perform a HTTP GET request. - * Can easily pass query parameters as the second parameter. - * @param {String} url - * @param {Object} params - * @returns {Promise} - */ -export async function get(url, params = {}) { - return request(url, { - method: 'GET', - params, - }); -} - -/** - * Perform a HTTP POST request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function post(url, data = null) { - return dataRequest('POST', url, data); -} - -/** - * Perform a HTTP PUT request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function put(url, data = null) { - return dataRequest('PUT', url, data); -} - -/** - * Perform a HTTP PATCH request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function patch(url, data = null) { - return dataRequest('PATCH', url, data); -} - -/** - * Perform a HTTP DELETE request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -async function performDelete(url, data = null) { - return dataRequest('DELETE', url, data); -} - -export {performDelete as delete}; - -/** - * Parse the response text for an error response to a user - * presentable string. Handles a range of errors responses including - * validation responses & server response text. - * @param {String} text - * @returns {String} - */ -export function formatErrorResponseText(text) { - const data = text.startsWith('{') ? JSON.parse(text) : {message: text}; - if (!data) { - return text; - } - - if (data.message || data.error) { - return data.message || data.error; - } - - const values = Object.values(data); - const isValidation = values.every(val => { - return Array.isArray(val) || val.every(x => typeof x === 'string'); - }); - - if (isValidation) { - return values.flat().join(' '); - } - - return text; -} diff --git a/resources/js/services/http.ts b/resources/js/services/http.ts new file mode 100644 index 000000000..f9eaafc39 --- /dev/null +++ b/resources/js/services/http.ts @@ -0,0 +1,221 @@ +type ResponseData = Record|string; + +type RequestOptions = { + params?: Record, + headers?: Record +}; + +type FormattedResponse = { + headers: Headers; + original: Response; + data: ResponseData; + redirected: boolean; + status: number; + statusText: string; + url: string; +}; + +export class HttpError extends Error implements FormattedResponse { + + data: ResponseData; + headers: Headers; + original: Response; + redirected: boolean; + status: number; + statusText: string; + url: string; + + constructor(response: Response, content: ResponseData) { + super(response.statusText); + this.data = content; + this.headers = response.headers; + this.redirected = response.redirected; + this.status = response.status; + this.statusText = response.statusText; + this.url = response.url; + this.original = response; + } +} + +export class HttpManager { + + /** + * Get the content from a fetch response. + * Checks the content-type header to determine the format. + */ + protected async getResponseContent(response: Response): Promise { + if (response.status === 204) { + return null; + } + + const responseContentType = response.headers.get('Content-Type') || ''; + const subType = responseContentType.split(';')[0].split('/').pop(); + + if (subType === 'javascript' || subType === 'json') { + return response.json(); + } + + return response.text(); + } + + createXMLHttpRequest(method: string, url: string, events: Record void> = {}): XMLHttpRequest { + const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content'); + const req = new XMLHttpRequest(); + + for (const [eventName, callback] of Object.entries(events)) { + req.addEventListener(eventName, callback.bind(req)); + } + + req.open(method, url); + req.withCredentials = true; + req.setRequestHeader('X-CSRF-TOKEN', csrfToken || ''); + + return req; + } + + /** + * Create a new HTTP request, setting the required CSRF information + * to communicate with the back-end. Parses & formats the response. + */ + protected async request(url: string, options: RequestOptions & RequestInit = {}): Promise { + let requestUrl = url; + + if (!requestUrl.startsWith('http')) { + requestUrl = window.baseUrl(requestUrl); + } + + if (options.params) { + const urlObj = new URL(requestUrl); + for (const paramName of Object.keys(options.params)) { + const value = options.params[paramName]; + if (typeof value !== 'undefined' && value !== null) { + urlObj.searchParams.set(paramName, value); + } + } + requestUrl = urlObj.toString(); + } + + const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || ''; + const requestOptions: RequestInit = {...options, credentials: 'same-origin'}; + requestOptions.headers = { + ...requestOptions.headers || {}, + baseURL: window.baseUrl(''), + 'X-CSRF-TOKEN': csrfToken, + }; + + const response = await fetch(requestUrl, requestOptions); + const content = await this.getResponseContent(response) || ''; + const returnData: FormattedResponse = { + data: content, + headers: response.headers, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + url: response.url, + original: response, + }; + + if (!response.ok) { + throw new HttpError(response, content); + } + + return returnData; + } + + /** + * Perform a HTTP request to the back-end that includes data in the body. + * Parses the body to JSON if an object, setting the correct headers. + */ + protected async dataRequest(method: string, url: string, data: Record|null): Promise { + const options: RequestInit & RequestOptions = { + method, + body: data as BodyInit, + }; + + // Send data as JSON if a plain object + if (typeof data === 'object' && !(data instanceof FormData)) { + options.headers = { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }; + options.body = JSON.stringify(data); + } + + // Ensure FormData instances are sent over POST + // Since Laravel does not read multipart/form-data from other types + // of request, hence the addition of the magic _method value. + if (data instanceof FormData && method !== 'post') { + data.append('_method', method); + options.method = 'post'; + } + + return this.request(url, options); + } + + /** + * Perform a HTTP GET request. + * Can easily pass query parameters as the second parameter. + */ + async get(url: string, params: {} = {}): Promise { + return this.request(url, { + method: 'GET', + params, + }); + } + + /** + * Perform a HTTP POST request. + */ + async post(url: string, data: null|Record = null): Promise { + return this.dataRequest('POST', url, data); + } + + /** + * Perform a HTTP PUT request. + */ + async put(url: string, data: null|Record = null): Promise { + return this.dataRequest('PUT', url, data); + } + + /** + * Perform a HTTP PATCH request. + */ + async patch(url: string, data: null|Record = null): Promise { + return this.dataRequest('PATCH', url, data); + } + + /** + * Perform a HTTP DELETE request. + */ + async delete(url: string, data: null|Record = null): Promise { + return this.dataRequest('DELETE', url, data); + } + + /** + * Parse the response text for an error response to a user + * presentable string. Handles a range of errors responses including + * validation responses & server response text. + */ + protected formatErrorResponseText(text: string): string { + const data = text.startsWith('{') ? JSON.parse(text) : {message: text}; + if (!data) { + return text; + } + + if (data.message || data.error) { + return data.message || data.error; + } + + const values = Object.values(data); + const isValidation = values.every(val => { + return Array.isArray(val) && val.every(x => typeof x === 'string'); + }); + + if (isValidation) { + return values.flat().join(' '); + } + + return text; + } + +} diff --git a/resources/js/services/text.js b/resources/js/services/text.ts similarity index 55% rename from resources/js/services/text.js rename to resources/js/services/text.ts index d5e6fa798..351e80167 100644 --- a/resources/js/services/text.js +++ b/resources/js/services/text.ts @@ -1,19 +1,15 @@ /** * Convert a kebab-case string to camelCase - * @param {String} kebab - * @returns {string} */ -export function kebabToCamel(kebab) { - const ucFirst = word => word.slice(0, 1).toUpperCase() + word.slice(1); +export function kebabToCamel(kebab: string): string { + const ucFirst = (word: string) => word.slice(0, 1).toUpperCase() + word.slice(1); const words = kebab.split('-'); return words[0] + words.slice(1).map(ucFirst).join(''); } /** * Convert a camelCase string to a kebab-case string. - * @param {String} camelStr - * @returns {String} */ -export function camelToKebab(camelStr) { +export function camelToKebab(camelStr: string): string { return camelStr.replace(/[A-Z]/g, (str, offset) => (offset > 0 ? '-' : '') + str.toLowerCase()); } diff --git a/resources/js/services/util.js b/resources/js/services/util.js index 942456d9d..1264d1058 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -84,6 +84,17 @@ export function uniqueId() { return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); } +/** + * Generate a random smaller unique ID. + * + * @returns {string} + */ +export function uniqueIdSmall() { + // eslint-disable-next-line no-bitwise + const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + return S4(); +} + /** * Create a promise that resolves after the given time. * @param {int} timeMs diff --git a/resources/js/wysiwyg/common-events.js b/resources/js/wysiwyg-tinymce/common-events.js similarity index 100% rename from resources/js/wysiwyg/common-events.js rename to resources/js/wysiwyg-tinymce/common-events.js diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg-tinymce/config.js similarity index 100% rename from resources/js/wysiwyg/config.js rename to resources/js/wysiwyg-tinymce/config.js diff --git a/resources/js/wysiwyg/drop-paste-handling.js b/resources/js/wysiwyg-tinymce/drop-paste-handling.js similarity index 100% rename from resources/js/wysiwyg/drop-paste-handling.js rename to resources/js/wysiwyg-tinymce/drop-paste-handling.js diff --git a/resources/js/wysiwyg/filters.js b/resources/js/wysiwyg-tinymce/filters.js similarity index 100% rename from resources/js/wysiwyg/filters.js rename to resources/js/wysiwyg-tinymce/filters.js diff --git a/resources/js/wysiwyg/fixes.js b/resources/js/wysiwyg-tinymce/fixes.js similarity index 100% rename from resources/js/wysiwyg/fixes.js rename to resources/js/wysiwyg-tinymce/fixes.js diff --git a/resources/js/wysiwyg/icons.js b/resources/js/wysiwyg-tinymce/icons.js similarity index 100% rename from resources/js/wysiwyg/icons.js rename to resources/js/wysiwyg-tinymce/icons.js diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg-tinymce/plugin-codeeditor.js similarity index 100% rename from resources/js/wysiwyg/plugin-codeeditor.js rename to resources/js/wysiwyg-tinymce/plugin-codeeditor.js diff --git a/resources/js/wysiwyg/plugin-drawio.js b/resources/js/wysiwyg-tinymce/plugin-drawio.js similarity index 99% rename from resources/js/wysiwyg/plugin-drawio.js rename to resources/js/wysiwyg-tinymce/plugin-drawio.js index 3b343a958..342cac0af 100644 --- a/resources/js/wysiwyg/plugin-drawio.js +++ b/resources/js/wysiwyg-tinymce/plugin-drawio.js @@ -1,4 +1,4 @@ -import * as DrawIO from '../services/drawio'; +import * as DrawIO from '../services/drawio.ts'; import {wait} from '../services/util'; let pageEditor = null; diff --git a/resources/js/wysiwyg/plugins-about.js b/resources/js/wysiwyg-tinymce/plugins-about.js similarity index 100% rename from resources/js/wysiwyg/plugins-about.js rename to resources/js/wysiwyg-tinymce/plugins-about.js diff --git a/resources/js/wysiwyg/plugins-customhr.js b/resources/js/wysiwyg-tinymce/plugins-customhr.js similarity index 100% rename from resources/js/wysiwyg/plugins-customhr.js rename to resources/js/wysiwyg-tinymce/plugins-customhr.js diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg-tinymce/plugins-details.js similarity index 100% rename from resources/js/wysiwyg/plugins-details.js rename to resources/js/wysiwyg-tinymce/plugins-details.js diff --git a/resources/js/wysiwyg/plugins-imagemanager.js b/resources/js/wysiwyg-tinymce/plugins-imagemanager.js similarity index 100% rename from resources/js/wysiwyg/plugins-imagemanager.js rename to resources/js/wysiwyg-tinymce/plugins-imagemanager.js diff --git a/resources/js/wysiwyg/plugins-stub.js b/resources/js/wysiwyg-tinymce/plugins-stub.js similarity index 100% rename from resources/js/wysiwyg/plugins-stub.js rename to resources/js/wysiwyg-tinymce/plugins-stub.js diff --git a/resources/js/wysiwyg/plugins-table-additions.js b/resources/js/wysiwyg-tinymce/plugins-table-additions.js similarity index 100% rename from resources/js/wysiwyg/plugins-table-additions.js rename to resources/js/wysiwyg-tinymce/plugins-table-additions.js diff --git a/resources/js/wysiwyg/plugins-tasklist.js b/resources/js/wysiwyg-tinymce/plugins-tasklist.js similarity index 100% rename from resources/js/wysiwyg/plugins-tasklist.js rename to resources/js/wysiwyg-tinymce/plugins-tasklist.js diff --git a/resources/js/wysiwyg/scrolling.js b/resources/js/wysiwyg-tinymce/scrolling.js similarity index 100% rename from resources/js/wysiwyg/scrolling.js rename to resources/js/wysiwyg-tinymce/scrolling.js diff --git a/resources/js/wysiwyg/shortcuts.js b/resources/js/wysiwyg-tinymce/shortcuts.js similarity index 100% rename from resources/js/wysiwyg/shortcuts.js rename to resources/js/wysiwyg-tinymce/shortcuts.js diff --git a/resources/js/wysiwyg/toolbars.js b/resources/js/wysiwyg-tinymce/toolbars.js similarity index 100% rename from resources/js/wysiwyg/toolbars.js rename to resources/js/wysiwyg-tinymce/toolbars.js diff --git a/resources/js/wysiwyg/util.js b/resources/js/wysiwyg-tinymce/util.js similarity index 100% rename from resources/js/wysiwyg/util.js rename to resources/js/wysiwyg-tinymce/util.js diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts new file mode 100644 index 000000000..c4403773b --- /dev/null +++ b/resources/js/wysiwyg/index.ts @@ -0,0 +1,129 @@ +import {$getSelection, createEditor, CreateEditorArgs, isCurrentlyReadOnlyMode, LexicalEditor} from 'lexical'; +import {createEmptyHistoryState, registerHistory} from '@lexical/history'; +import {registerRichText} from '@lexical/rich-text'; +import {mergeRegister} from '@lexical/utils'; +import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; +import {buildEditorUI} from "./ui"; +import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; +import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; +import {EditorUiContext} from "./ui/framework/core"; +import {listen as listenToCommonEvents} from "./services/common-events"; +import {registerDropPasteHandling} from "./services/drop-paste-handling"; +import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; +import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; +import {el} from "./utils/dom"; +import {registerShortcuts} from "./services/shortcuts"; +import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; +import {registerKeyboardHandling} from "./services/keyboard-handling"; + +export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { + const config: CreateEditorArgs = { + namespace: 'BookStackPageEditor', + nodes: getNodesForPageEditor(), + onError: console.error, + theme: { + text: { + bold: 'editor-theme-bold', + code: 'editor-theme-code', + italic: 'editor-theme-italic', + strikethrough: 'editor-theme-strikethrough', + subscript: 'editor-theme-subscript', + superscript: 'editor-theme-superscript', + underline: 'editor-theme-underline', + underlineStrikethrough: 'editor-theme-underline-strikethrough', + } + } + }; + + const editArea = el('div', { + contenteditable: 'true', + class: 'editor-content-area page-content', + }); + const editWrap = el('div', { + class: 'editor-content-wrap', + }, [editArea]); + + container.append(editWrap); + container.classList.add('editor-container'); + container.setAttribute('dir', options.textDirection); + if (options.darkMode) { + container.classList.add('editor-dark'); + } + + const editor = createEditor(config); + editor.setRootElement(editArea); + const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); + + mergeRegister( + registerRichText(editor), + registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(context), + registerKeyboardHandling(context), + registerTableResizer(editor, editWrap), + registerTableSelectionHandler(editor), + registerTaskListHandler(editor, editArea), + registerDropPasteHandling(context), + registerNodeResizer(context), + ); + + listenToCommonEvents(editor); + + setEditorContentFromHtml(editor, htmlContent); + + const debugView = document.getElementById('lexical-debug'); + if (debugView) { + debugView.hidden = true; + } + + let changeFromLoading = true; + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Watch for selection changes to update the UI on change + // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit + // for all selection changes, so this proved more reliable. + const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false); + if (selectionChange) { + editor.update(() => { + const selection = $getSelection(); + context.manager.triggerStateUpdate({ + editor, selection, + }); + }); + } + + // Emit change event to component system (for draft detection) on actual user content change + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { + if (changeFromLoading) { + changeFromLoading = false; + } else { + window.$events.emit('editor-html-change', ''); + } + } + + // Debug logic + // console.log('editorState', editorState.toJSON()); + if (debugView) { + debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); + } + }); + + // @ts-ignore + window.debugEditorState = () => { + console.log(editor.getEditorState().toJSON()); + }; + + registerCommonNodeMutationListeners(context); + + return new SimpleWysiwygEditorInterface(editor); +} + +export class SimpleWysiwygEditorInterface { + protected editor: LexicalEditor; + + constructor(editor: LexicalEditor) { + this.editor = editor; + } + + async getContentAsHtml(): Promise { + return await getEditorContentAsHtml(this.editor); + } +} \ 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 index 000000000..b93be9051 --- /dev/null +++ b/resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE @@ -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 index 000000000..1d79c2d7b --- /dev/null +++ b/resources/js/wysiwyg/lexical/clipboard/clipboard.ts @@ -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, + selection: BaseSelection, +): void { + if ( + !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, { + nodes, + selection, + }) + ) { + selection.insertNodes(nodes); + } + return; +} + +export interface BaseSerializedNode { + children?: Array; + type: string; + version: number; +} + +function exportNodeToJSON(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 = [], +): 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; +} { + const nodes: Array = []; + 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, +): Array { + 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 { + 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 index 000000000..ffa1f19f6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/clipboard/index.ts @@ -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 index 000000000..0f1c0a5d3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -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(type?: string): LexicalCommand { + return __DEV__ ? {type} : {}; +} + +export const SELECTION_CHANGE_COMMAND: LexicalCommand = createCommand( + 'SELECTION_CHANGE_COMMAND', +); +export const SELECTION_INSERT_CLIPBOARD_NODES_COMMAND: LexicalCommand<{ + nodes: Array; + selection: BaseSelection; +}> = createCommand('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND'); +export const CLICK_COMMAND: LexicalCommand = + createCommand('CLICK_COMMAND'); +export const DELETE_CHARACTER_COMMAND: LexicalCommand = createCommand( + 'DELETE_CHARACTER_COMMAND', +); +export const INSERT_LINE_BREAK_COMMAND: LexicalCommand = createCommand( + 'INSERT_LINE_BREAK_COMMAND', +); +export const INSERT_PARAGRAPH_COMMAND: LexicalCommand = createCommand( + 'INSERT_PARAGRAPH_COMMAND', +); +export const CONTROLLED_TEXT_INSERTION_COMMAND: LexicalCommand< + InputEvent | string +> = createCommand('CONTROLLED_TEXT_INSERTION_COMMAND'); +export const PASTE_COMMAND: LexicalCommand = + createCommand('PASTE_COMMAND'); +export const REMOVE_TEXT_COMMAND: LexicalCommand = + createCommand('REMOVE_TEXT_COMMAND'); +export const DELETE_WORD_COMMAND: LexicalCommand = createCommand( + 'DELETE_WORD_COMMAND', +); +export const DELETE_LINE_COMMAND: LexicalCommand = createCommand( + 'DELETE_LINE_COMMAND', +); +export const FORMAT_TEXT_COMMAND: LexicalCommand = + createCommand('FORMAT_TEXT_COMMAND'); +export const UNDO_COMMAND: LexicalCommand = createCommand('UNDO_COMMAND'); +export const REDO_COMMAND: LexicalCommand = createCommand('REDO_COMMAND'); +export const KEY_DOWN_COMMAND: LexicalCommand = + createCommand('KEYDOWN_COMMAND'); +export const KEY_ARROW_RIGHT_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_RIGHT_COMMAND'); +export const MOVE_TO_END: LexicalCommand = + createCommand('MOVE_TO_END'); +export const KEY_ARROW_LEFT_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_LEFT_COMMAND'); +export const MOVE_TO_START: LexicalCommand = + createCommand('MOVE_TO_START'); +export const KEY_ARROW_UP_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_UP_COMMAND'); +export const KEY_ARROW_DOWN_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_DOWN_COMMAND'); +export const KEY_ENTER_COMMAND: LexicalCommand = + createCommand('KEY_ENTER_COMMAND'); +export const KEY_SPACE_COMMAND: LexicalCommand = + createCommand('KEY_SPACE_COMMAND'); +export const KEY_BACKSPACE_COMMAND: LexicalCommand = + createCommand('KEY_BACKSPACE_COMMAND'); +export const KEY_ESCAPE_COMMAND: LexicalCommand = + createCommand('KEY_ESCAPE_COMMAND'); +export const KEY_DELETE_COMMAND: LexicalCommand = + createCommand('KEY_DELETE_COMMAND'); +export const KEY_TAB_COMMAND: LexicalCommand = + createCommand('KEY_TAB_COMMAND'); +export const INSERT_TAB_COMMAND: LexicalCommand = + createCommand('INSERT_TAB_COMMAND'); +export const INDENT_CONTENT_COMMAND: LexicalCommand = createCommand( + 'INDENT_CONTENT_COMMAND', +); +export const OUTDENT_CONTENT_COMMAND: LexicalCommand = createCommand( + 'OUTDENT_CONTENT_COMMAND', +); +export const DROP_COMMAND: LexicalCommand = + createCommand('DROP_COMMAND'); +export const FORMAT_ELEMENT_COMMAND: LexicalCommand = + createCommand('FORMAT_ELEMENT_COMMAND'); +export const DRAGSTART_COMMAND: LexicalCommand = + createCommand('DRAGSTART_COMMAND'); +export const DRAGOVER_COMMAND: LexicalCommand = + createCommand('DRAGOVER_COMMAND'); +export const DRAGEND_COMMAND: LexicalCommand = + 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 = + createCommand('SELECT_ALL_COMMAND'); +export const CLEAR_EDITOR_COMMAND: LexicalCommand = createCommand( + 'CLEAR_EDITOR_COMMAND', +); +export const CLEAR_HISTORY_COMMAND: LexicalCommand = createCommand( + 'CLEAR_HISTORY_COMMAND', +); +export const CAN_REDO_COMMAND: LexicalCommand = + createCommand('CAN_REDO_COMMAND'); +export const CAN_UNDO_COMMAND: LexicalCommand = + createCommand('CAN_UNDO_COMMAND'); +export const FOCUS_COMMAND: LexicalCommand = + createCommand('FOCUS_COMMAND'); +export const BLUR_COMMAND: LexicalCommand = + createCommand('BLUR_COMMAND'); +export const KEY_MODIFIER_COMMAND: LexicalCommand = + 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 index 000000000..82461e74d --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts @@ -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 = { + 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 = { + directionless: IS_DIRECTIONLESS, + unmergeable: IS_UNMERGEABLE, +}; + +export const ELEMENT_TYPE_TO_FORMAT: Record< + Exclude, + 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 = { + [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 = { + normal: IS_NORMAL, + segmented: IS_SEGMENTED, + token: IS_TOKEN, +}; + +export const TEXT_TYPE_TO_MODE: Record = { + [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 index 000000000..092429156 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -0,0 +1,1296 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 = Omit & T1; + +// https://github.com/microsoft/TypeScript/issues/3841 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type KlassConstructor> = + GenericConstructor> & {[k in keyof Cls]: Cls[k]}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericConstructor = new (...args: any[]) => T; + +export type Klass = InstanceType< + T['constructor'] +> extends T + ? T['constructor'] + : GenericConstructor & 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; + 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; + ol?: EditorThemeClassName; + olDepth?: Array; + 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + with: ( + node: InstanceType, + ) => LexicalNode; + withKlass?: Klass; +}; + +export type HTMLConfig = { + export?: DOMExportOutputMap; + import?: DOMConversionMap; +}; + +export type CreateEditorArgs = { + disableEvents?: boolean; + editorState?: EditorState; + namespace?: string; + nodes?: ReadonlyArray | LexicalNodeReplacement>; + onError?: ErrorHandler; + parentEditor?: LexicalEditor; + editable?: boolean; + theme?: EditorThemeClasses; + html?: HTMLConfig; +}; + +export type RegisteredNodes = Map; + +export type RegisteredNode = { + klass: Klass; + transforms: Set>; + replace: null | ((node: LexicalNode) => LexicalNode); + replaceWithKlass: null | Klass; + exportDOM?: ( + editor: LexicalEditor, + targetNode: LexicalNode, + ) => DOMExportOutput; +}; + +export type Transform = (node: T) => void; + +export type ErrorHandler = (error: Error) => void; + +export type MutationListeners = Map>; + +export type MutatedNodes = Map, Map>; + +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; + dirtyLeaves: Set; + editorState: EditorState; + normalizedNodes: Set; + prevEditorState: EditorState; + tags: Set; +}) => void; + +export type DecoratorListener = ( + decorator: Record, +) => void; + +export type RootListener = ( + rootElement: null | HTMLElement, + prevRootElement: null | HTMLElement, +) => void; + +export type TextContentListener = (text: string) => void; + +export type MutationListener = ( + nodes: Map, + payload: { + updateTags: Set; + dirtyLeaves: Set; + prevEditorState: EditorState; + }, +) => void; + +export type CommandListener

= (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 = { + type?: string; +}; + +/** + * Type helper for extracting the payload type from a command. + * + * @example + * ```ts + * const MY_COMMAND = createCommand(); + * + * // ... + * + * 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) { + * // `payload` is of type `SomeType`, extracted from the command. + * } + * ``` + */ +export type CommandPayloadType> = + TCommand extends LexicalCommand ? TPayload : never; + +type Commands = Map< + LexicalCommand, + Array>> +>; +type Listeners = { + decorator: Set; + mutation: MutationListeners; + editable: Set; + root: Set; + textcontent: Set; + update: Set; +}; + +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; + + 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>(); + 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; + + /** 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; + /** @internal */ + _updates: Array<[() => void, EditorUpdateOptions | undefined]>; + /** @internal */ + _updating: boolean; + /** @internal */ + _listeners: Listeners; + /** @internal */ + _commands: Commands; + /** @internal */ + _nodes: RegisteredNodes; + /** @internal */ + _decorators: Record; + /** @internal */ + _pendingDecorators: null | Record; + /** @internal */ + _config: EditorConfig; + /** @internal */ + _dirtyType: 0 | 1 | 2; + /** @internal */ + _cloneNotNeeded: Set; + /** @internal */ + _dirtyLeaves: Set; + /** @internal */ + _dirtyElements: Map; + /** @internal */ + _normalizedNodes: Set; + /** @internal */ + _updateTags: Set; + /** @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(listener: DecoratorListener): () => 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

( + command: LexicalCommand

, + listener: CommandListener

, + 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); + return () => { + listeners.delete(listener as CommandListener); + + 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, + 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): 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, + ): void { + const prevEditorState = this._editorState; + const nodeMap = getCachedTypeToNodeMap(prevEditorState).get( + klass.getType(), + ); + if (!nodeMap) { + return; + } + const nodeMutationMap = new Map(); + 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( + klass: Klass, + listener: Transform, + ): RegisteredNode { + const registeredNode = this.getRegisteredNode(klass); + registeredNode.transforms.add(listener as Transform); + + 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( + klass: Klass, + listener: Transform, + ): () => 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, + ); + registeredNodes.push(registeredReplaceWithNode); + } + + markAllNodesAsDirty(this, klass.getType()); + return () => { + registeredNodes.forEach((node) => + node.transforms.delete(listener as Transform), + ); + }; + } + + /** + * 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>(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>(nodes: Array): 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>( + type: TCommand, + payload: CommandPayloadType, + ): 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(): Record { + return this._decorators as Record; + } + + /** + * + * @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(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'); + } + } + } + + /** + * Commits any currently pending updates scheduled for the editor. + */ + commitUpdates(): void { + $commitPendingUpdates(this); + } + + /** + * 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 index 000000000..f84d2e40a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts @@ -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; +} + +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( + 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(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 index 000000000..5fd671a76 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -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 | ((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(); +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 = 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 index 000000000..9405ae6cf --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalGC.ts @@ -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, + dirtyNodes: Map, +): 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, + dirtyElements: Map, +): 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 = []; + + 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 index 000000000..56f364501 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts @@ -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, + 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
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, 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 index 000000000..c6bc2e642 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -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; + +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 = { + conversion: DOMConversionFn; + priority?: 0 | 1 | 2 | 3 | 4; +}; + +export type DOMConversionFn = ( + element: T, +) => DOMConversionOutput | null; + +export type DOMChildConversion = ( + lexicalNode: LexicalNode, + parentLexicalNode: LexicalNode | null | undefined, +) => LexicalNode | null | undefined; + +export type DOMConversionMap = Record< + NodeName, + (node: T) => DOMConversion | null +>; +type NodeName = string; + +export type DOMConversionOutput = { + after?: (childLexicalNodes: Array) => Array; + forChild?: DOMChildConversion; + node: null | LexicalNode | Array; +}; + +export type DOMExportOutputMap = Map< + Klass, + (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; + /** @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(); + * 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): 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 { + * 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 | 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 | null { + const parent = this.getLatest().__parent; + if (parent === null) { + return null; + } + return $getNodeByKey(parent); + } + + /** + * Returns the parent of this node, or throws if none is found. + */ + getParentOrThrow(): T { + const parent = this.getParent(); + 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 | 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 { + 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 { + const parents: Array = []; + 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 { + 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 | null { + const self = this.getLatest(); + const prevKey = self.__prev; + return prevKey === null ? null : $getNodeByKey(prevKey); + } + + /** + * Returns the "previous" siblings - that is, the nodes that come between + * this one and the first child of it's parent, inclusive. + * + */ + getPreviousSiblings(): Array { + const siblings: Array = []; + 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 | null { + const self = this.getLatest(); + const nextKey = self.__next; + return nextKey === null ? null : $getNodeByKey(nextKey); + } + + /** + * Returns all "next" siblings - that is, the nodes that come between this + * one and the last child of it's parent, inclusive. + * + */ + getNextSiblings(): Array { + const siblings: Array = []; + 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( + 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 { + 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.__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(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, +): 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 index 000000000..59a7be644 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts @@ -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 index 000000000..09d01bffd --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -0,0 +1,830 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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, + setMutatedNode, +} from './LexicalUtils'; + +type IntentionallyMarkedAsDirtyElement = boolean; + +let subTreeTextContent = ''; +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 activeDirtyElements: Map; +let activeDirtyLeaves: Set; +let activePrevNodeMap: NodeMap; +let activeNextNodeMap: NodeMap; +let activePrevKeyToDOMMap: Map; +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, + _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); + $createChildren(children, node, 0, endIndex, dom, null); + } + 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'; + } + 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 $createChildren( + children: Array, + 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
+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 $reconcileChildrenWithDirection( + prevElement: ElementNode, + nextElement: ElementNode, + dom: HTMLElement, +): void { + subTreeTextFormat = null; + subTreeTextStyle = ''; + $reconcileChildren(prevElement, nextElement, dom); + reconcileParagraphFormat(nextElement); + reconcileParagraphStyle(nextElement); +} + +function createChildrenArray( + element: ElementNode, + nodeMap: NodeMap, +): Array { + 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; + } + } else { + const text = prevNode.getTextContent(); + + 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); + } + } + + 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, + nextChildren: Array, + prevChildrenLength: number, + nextChildrenLength: number, + dom: HTMLElement, +): void { + const prevEndIndex = prevChildrenLength - 1; + const nextEndIndex = nextChildrenLength - 1; + let prevChildrenSet: Set | undefined; + let nextChildrenSet: Set | 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, + dirtyLeaves: Set, +): MutatedNodes { + // We cache text content to make retrieval more efficient. + // The cache must be rebuilt during reconciliation to account for any changes. + subTreeTextContent = ''; + editorTextContent = ''; + // 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; + 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 index 000000000..db18cfc4a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -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(aOffset); + aNode = aNodeDescendant != null ? aNodeDescendant : aNode; + } + if ($isElementNode(bNode)) { + const bNodeDescendant = bNode.getDescendantByIndex(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 | null; + dirty: boolean; + + clone(): BaseSelection; + extract(): Array; + getNodes(): Array; + getTextContent(): string; + insertText(text: string): void; + insertRawText(text: string): void; + is(selection: null | BaseSelection): boolean; + insertNodes(nodes: Array): void; + getStartEndPoints(): null | [PointType, PointType]; + isCollapsed(): boolean; + isBackward(): boolean; + getCachedNodes(): LexicalNode[] | null; + setCachedNodes(nodes: LexicalNode[] | null): void; +} + +export class NodeSelection implements BaseSelection { + _nodes: Set; + _cachedNodes: Array | null; + dirty: boolean; + + constructor(objects: Set) { + 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 = this._nodes; + const b: Set = 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 { + return this.getNodes(); + } + + insertRawText(text: string): void { + // Do nothing? + } + + insertText(): void { + // Do nothing? + } + + insertNodes(nodes: Array) { + 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 { + 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 | 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 { + 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(startOffset); + firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode; + } + if ($isElementNode(lastNode)) { + let lastNodeDescendant = + lastNode.getDescendantByIndex(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; + + 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(); + 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(); + 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 = []; + 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): 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 { + 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, + 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) { + 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 index 000000000..86ed2740f --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalUpdates.ts @@ -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(); + 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 + ).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>>, +) { + 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; + 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( + 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, + dirtyLeaves: Set, + 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(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, +>( + editor: LexicalEditor, + type: TCommand, + payload: CommandPayloadType, +): 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 index 000000000..71096b19d --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -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 { + 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, +): 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( + 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 { + 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 { + 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 { + 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(klass: Klass): Array { + const klassType = klass.getType(); + const editorState = getActiveEditorState(); + if (editorState._readOnly) { + const nodes = getCachedTypeToNodeMap(editorState).get(klassType) as + | undefined + | Map; + return nodes ? Array.from(nodes.values()) : []; + } + const nodes = editorState._nodeMap; + const nodesOfType: Array = []; + 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>( + editor: LexicalEditor, + command: TCommand, + payload: CommandPayloadType, +): 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(node: T): T { + const copy = node.constructor.clone(node) as T; + $setNodeKey(copy, null); + return copy; +} + +export function $applyNodeReplacement( + 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(key: NodeKey): N { + const node = $getNodeByKey(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 = ( + 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 { + 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( + 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; +/** + * @internal + * Compute a cached Map of node type to nodes for a frozen EditorState + */ +const cachedNodeMaps = new WeakMap(); +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(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/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts new file mode 100644 index 000000000..534663a54 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -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/__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: `

Hello!

`, + name: 'plain DOM text node', + pastedHTML: `Hello!`, + }, + { + expectedHTML: `

Hello!


`, + name: 'a paragraph element', + pastedHTML: `

Hello!

`, + }, + { + expectedHTML: `

123

456

`, + name: 'a single div', + pastedHTML: `123 +
+ 456 +
`, + }, + { + expectedHTML: `

a b c d e

f g h

`, + name: 'multiple nested spans and divs', + pastedHTML: `
+ a b + + c d + e + +
+ f + g h +
+
`, + }, + { + expectedHTML: `

123

456

`, + name: 'nested span in a div', + pastedHTML: `
+ + 123 +
456
+
+
`, + }, + { + expectedHTML: `

123

456

`, + name: 'nested div in a span', + pastedHTML: ` 123
456
`, + }, + { + expectedHTML: `
  • done
  • todo
    • done
    • todo
  • todo
`, + name: 'google doc checklist', + pastedHTML: `
  • checked

    done

  • unchecked

    todo

    • checked

      done

    • unchecked

      todo

  • unchecked

    todo

`, + }, + { + expectedHTML: `

checklist

  • done
  • todo
`, + name: 'github checklist', + pastedHTML: `

checklist

  • done
  • todo
`, + }, + ]; + + 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 index 000000000..f3c6f7105 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -0,0 +1,2652 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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; + function setContainerChild(el: HTMLElement) { + container.innerHTML = ''; + container.append(el); + } + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + // @ts-ignore + container = null; + + jest.restoreAllMocks(); + }); + + function useLexicalEditor( + rootElement: HTMLDivElement, + onError?: (error: Error) => void, + nodes?: ReadonlyArray | LexicalNodeReplacement>, + ) { + const editor = createTestEditor({ + nodes: nodes ?? [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }); + editor.setRootElement(rootElement); + return editor; + } + + let editor: LexicalEditor; + + function init(onError?: (error: Error) => void) { + const edContainer = document.createElement('div'); + edContainer.setAttribute('contenteditable', 'true'); + + setContainerChild(edContainer); + editor = useLexicalEditor(edContainer, onError); + } + + 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( + '

This works!

', + ); + + const initialEditorState = initialEditor.getEditorState(); + initialEditor.setRootElement(null); + + expect(container.innerHTML).toBe( + '
', + ); + + editor = createTestEditor({ + editorState: initialEditorState, + onError: jest.fn(), + }); + editor.setRootElement(rootElement); + + expect(editor.getEditorState()).toEqual(initialEditorState); + expect(container.innerHTML).toBe( + '

This works!

', + ); + }); + + it('Should handle nested updates in the correct sequence', async () => { + init(); + const onUpdate = jest.fn(); + + let log: Array = []; + + 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( + '

foo

', + ); + }); + + 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( + '

foo

', + ); + }); + + it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => { + const hasRun = [false, false, false]; + init(); + + // 1. [Foo] into [,Fo,o,,!,] + 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 [,Fo,o!,,!,] + 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 [,Fo,o!!,] + 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( + '

Foo!!

', + ); + }); + + 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 = []; + + 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 element = document.createElement('div'); + element.setAttribute('contenteditable', 'true'); + setContainerChild(element); + + editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + }); + + expect(container.innerHTML).toBe('
'); + + editor.setRootElement(element); + + expect(container.innerHTML).toBe( + '

This works!

', + ); + }); + + 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( + '

This works!

', + ); + expect(errorListener).toHaveBeenCalledTimes(0); + + editor.update(() => { + const root = $getRoot(); + root + .getFirstChild()! + .getFirstChild()! + .getFirstChild()! + .setTextContent('Foo'); + }); + + expect(errorListener).toHaveBeenCalledTimes(1); + expect(container.innerHTML).toBe( + '

This works!

', + ); + }); + + it('Should be able to handle a change in root element', async () => { + const rootListener = jest.fn(); + const updateListener = jest.fn(); + + let editorInstance = createTestEditor(); + editorInstance.registerRootListener(rootListener); + editorInstance.registerUpdateListener(updateListener); + + let edContainer: HTMLElement = document.createElement('div'); + edContainer.setAttribute('contenteditable', 'true'); + setContainerChild(edContainer); + editorInstance.setRootElement(edContainer); + + function runUpdate(changeElement: boolean) { + editorInstance.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); + } + }); + } + + setContainerChild(edContainer); + editorInstance.setRootElement(edContainer); + runUpdate(false); + editorInstance.commitUpdates(); + + expect(container.innerHTML).toBe( + '

Not changed

', + ); + + edContainer = document.createElement('span'); + edContainer.setAttribute('contenteditable', 'true'); + runUpdate(true); + editorInstance.setRootElement(edContainer); + setContainerChild(edContainer); + editorInstance.commitUpdates(); + + expect(rootListener).toHaveBeenCalledTimes(3); + expect(updateListener).toHaveBeenCalledTimes(3); + expect(container.innerHTML).toBe( + '

Change successful

', + ); + }); + + 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('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: null, + __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: null, + __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: null, + __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: null, + __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( + '

A
B

', + ); + }); + + 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( + '

B
A

', + ); + }); + + 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( + '

A
C
B

', + ); + }); + }); + + 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 () => { + + function TestBase() { + const edContainer = document.createElement('div'); + edContainer.contentEditable = 'true'; + + editor = useLexicalEditor(edContainer, undefined, [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + withKlass: TestTextNode, + }, + ]); + + return edContainer; + } + + setContainerChild(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 () => { + + function TestBase() { + const edContainer = document.createElement('div'); + edContainer.contentEditable = 'true'; + + editor = useLexicalEditor(edContainer, undefined, [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + withKlass: TestTextNode, + }, + ]); + + return edContainer; + } + + setContainerChild(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(asymmetricMatch: (x: T) => boolean) { + return {asymmetricMatch}; + } + + expect(mutationListenerA).toHaveBeenCalledTimes(1); + expect(mutationListenerA).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + updateTags: asymmetricMatcher( + (s: Set) => !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) => + 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) => !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( + '

Hello

', + ); + }); + + 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":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"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'); + }); + + newEditor.commitUpdates(); + + 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(''); + + text.toggleFormat('bold'); + + const htmlBold = $generateHtmlFromNodes(newEditor, selection); + expect(htmlBold).toBe(''); + }); + + 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 = '
'; + + 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 index 000000000..38ecf03bc --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts @@ -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: null, + __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: null, + __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":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"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/LexicalNode.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts new file mode 100644 index 000000000..fcf666213 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts @@ -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 { + 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; + __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; + 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[] = []; + 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( + '

foo

', + ); + + 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( + '

foobar

', + ); + + 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( + '

foobarbaz

', + ); + + 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( + '

foobar

', + ); + + 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( + '

foobarbaz

', + ); + + 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( + '

fooqux

bar

baz

', + ); + + 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( + '

foobarbaz

', + ); + + 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( + '

foobarbaz

qux

', + ); + + 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( + '

footoken

', + ); + + 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( + '

foosegmented

', + ); + + 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( + '

foodirectionless

', + ); + + 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( + '

foo

', + ); + + 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( + '


', + ); + 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( + '

foo

', + ); + 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( + '

foo

bar

', + ); + + await editor.update(() => { + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

bar


', + ); + }); + + test('LexicalNode.replace(): text', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

bar

', + ); + }); + + test('LexicalNode.replace(): token', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

bar

', + ); + }); + + test('LexicalNode.replace(): segmented', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('segmented'); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

bar

', + ); + }); + + test('LexicalNode.replace(): directionless', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode(`bar`).toggleDirectionless(); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

bar

', + ); + // 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( + '', + ); + }); + + 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( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

foobar

', + ); + }); + + test('LexicalNode.insertAfter(): token', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

foobar

', + ); + }); + + test('LexicalNode.insertAfter(): segmented', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

foobar

', + ); + }); + + test('LexicalNode.insertAfter(): directionless', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode(`bar`).toggleDirectionless(); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

foobar

', + ); + // 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( + '

A

B

C

', + ); + + await editor.update(() => { + text1.insertAfter(block2); + }); + + expect(testEnv.outerHTML).toBe( + '

A

B

C

', + ); + }); + + 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( + '

A

B

C

', + ); + + await editor.update(() => { + text3.insertAfter(text1); + text3.insertAfter(text2); + }); + + expect(testEnv.outerHTML).toBe( + '



CBA

', + ); + }); + + 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( + '

foo

', + ); + 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( + '

foo

bar

', + ); + }); + + test('LexicalNode.insertBefore(): text', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

barfoo

', + ); + }); + + test('LexicalNode.insertBefore(): token', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

barfoo

', + ); + }); + + test('LexicalNode.insertBefore(): segmented', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('segmented'); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

barfoo

', + ); + }); + + test('LexicalNode.insertBefore(): directionless', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

foo

', + ); + + await editor.update(() => { + const barTextNode = new TextNode(`bar`).toggleDirectionless(); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

barfoo

', + ); + }); + + 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.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.ts new file mode 100644 index 000000000..ecfbe6bf7 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.ts @@ -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 index 000000000..ac0ec15e5 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts @@ -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' + ? '

ab

' + : mode === 'mid-paragraph' + ? '

abc

' + : '

ab

'; + + 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( + '

xab

', + ); + }; + + 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( + '

axbc

', + ); + }; + + 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( + '

axb

', + ); + }; + + 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( + '

axb

', + ); + }; + + 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( + '

abxc

', + ); + }; + + 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( + '

abx

', + ); + }; + + 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 index 000000000..5599604c0 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 {$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 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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":null,"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":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"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":null,"format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":null,"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":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"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":null,"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 index 000000000..0026cf5d6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts @@ -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 index 000000000..f7230595a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -0,0 +1,727 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 { + 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 editorEl = document.createElement('div'); + editorEl.setAttribute('contenteditable', 'true'); + testEnv.container.append(editorEl); + + const lexicalEditor = createTestEditor(editorConfig); + lexicalEditor.setRootElement(editorEl); + testEnv.editor = lexicalEditor; + }); + + 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 { + 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() { + const decorator = document.createElement('span'); + decorator.textContent = 'Hello world'; + return decorator; + } +} + +export function $createTestDecoratorNode(): TestDecoratorNode { + return new TestDecoratorNode(); +} + +const DEFAULT_NODES: NonNullable | LexicalNodeReplacement>> = [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + LinkNode, + TestElementNode, + TestSegmentedNode, + TestExcludeFromCopyElementNode, + TestDecoratorNode, + TestInlineElementNode, + TestShadowRootNode, + TestTextNode, +]; + +export function createTestEditor( + config: { + namespace?: string; + editorState?: EditorState; + theme?: EditorThemeClasses; + parentEditor?: LexicalEditor; + nodes?: ReadonlyArray | 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; + setData: jest.Mock; + + constructor() { + this.getData = jest.fn(); + this.setData = jest.fn(); + } +} + +export class DataTransferMock implements DataTransfer { + _data: Map = 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 { + 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( + 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; +} + +export function expectHtmlToBeEqual(expected: string, actual: string): void { + expect(formatHtml(expected)).toBe(formatHtml(actual)); +} + +function formatHtml(s: string): string { + return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/index.ts b/resources/js/wysiwyg/lexical/core/index.ts new file mode 100644 index 000000000..5ef926b5a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/index.ts @@ -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 index 000000000..0f01d2c34 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts @@ -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 index 000000000..99d2669d9 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts @@ -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 { + getTopLevelElement(): ElementNode | this | null; + getTopLevelElementOrThrow(): ElementNode | this; +} + +/** @noInheritDoc */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class DecoratorNode extends LexicalNode { + ['constructor']!: KlassConstructor>; + 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( + node: LexicalNode | null | undefined, +): node is DecoratorNode { + 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 index 000000000..88c6d5678 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -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; + 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; + /** @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(): Array { + const children: Array = []; + let child: T | null = this.getFirstChild(); + while (child !== null) { + children.push(child); + child = child.getNextSibling(); + } + return children; + } + getChildrenKeys(): Array { + const children: Array = []; + 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 { + 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(): null | T { + let node = this.getFirstChild(); + while ($isElementNode(node)) { + const child = node.getFirstChild(); + if (child === null) { + break; + } + node = child; + } + return node; + } + getLastDescendant(): null | T { + let node = this.getLastChild(); + while ($isElementNode(node)) { + const child = node.getLastChild(); + if (child === null) { + break; + } + node = child; + } + return node; + } + getDescendantByIndex(index: number): null | T { + const children = this.getChildren(); + 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(): null | T { + const self = this.getLatest(); + const firstKey = self.__first; + return firstKey === null ? null : $getNodeByKey(firstKey); + } + getFirstChildOrThrow(): T { + const firstChild = this.getFirstChild(); + if (firstChild === null) { + invariant(false, 'Expected node %s to have a first child.', this.__key); + } + return firstChild; + } + getLastChild(): null | T { + const self = this.getLatest(); + const lastKey = self.__last; + return lastKey === null ? null : $getNodeByKey(lastKey); + } + getLastChildOrThrow(): T { + const lastChild = this.getLastChild(); + if (lastChild === null) { + invariant(false, 'Expected node %s to have a last child.', this.__key); + } + return lastChild; + } + getChildAtIndex(index: number): null | T { + const size = this.getChildrenSize(); + let node: null | T; + let i; + if (index < size / 2) { + node = this.getFirstChild(); + i = 0; + while (node !== null && i <= index) { + if (i === index) { + return node; + } + node = node.getNextSibling(); + i++; + } + return null; + } + node = this.getLastChild(); + 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, + ): 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, + nodesToInsertKeySet: Set, +): 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 index 000000000..2d28db08c --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts @@ -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; + 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 index 000000000..4e69dc21c --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -0,0 +1,231 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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; + /** @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 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.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 index 000000000..74c8d5a7f --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts @@ -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; + +/** @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(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 index 000000000..5fa3623d4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts @@ -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 index 000000000..4a3a48950 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -0,0 +1,1401 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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; + +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; + __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', + ); + + // Wrap up to retain space if head/tail whitespace exists + const text = this.getTextContent(); + if (/^\s|\s$/.test(text)) { + element.style.whiteSpace = 'pre-wrap'; + } + + // Strip editor theme classes + for (const className of Array.from(element.classList.values())) { + if (className.startsWith('editor-theme-')) { + element.classList.remove(className); + } + } + if (element.classList.length === 0) { + element.removeAttribute('class'); + } + + // Remove placeholder tag if redundant + if (element.nodeName === 'SPAN' && !element.getAttribute('style')) { + element = document.createTextNode(text); + } + + // This is the only way to properly add support for most clients, + // even if it's semantically incorrect to have to resort to using + // , , , elements. + if (this.hasFormat('bold')) { + element = wrapElementWith(element, 'b'); + } + if (this.hasFormat('italic')) { + element = wrapElementWith(element, 'em'); + } + 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): Array { + 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 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 since we matched it by nodeName + const b = domNode; + // Google Docs wraps all copied HTML in a with font-weight normal + const hasNormalFontWeight = b.style.fontWeight === 'normal'; + + return { + forChild: applyTextFormatFromStyle( + b.style, + hasNormalFontWeight ? undefined : 'bold', + ), + node: null, + }; +} + +const preParentCache = new WeakMap(); + +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 = []; + 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 = { + 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; + + // Styles to copy to node + const color = style.color; + const backgroundColor = style.backgroundColor; + + 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'); + } + + // Apply styles + let style = lexicalNode.getStyle(); + if (color) { + style += `color: ${color};`; + } + if (backgroundColor && backgroundColor !== 'transparent') { + style += `background-color: ${backgroundColor};`; + } + if (style) { + lexicalNode.setStyle(style); + } + + if (shouldApply && !lexicalNode.hasFormat(shouldApply)) { + lexicalNode.toggleFormat(shouldApply); + } + + return lexicalNode; + }; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts new file mode 100644 index 000000000..fb5c98f8a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts @@ -0,0 +1,617 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 { + $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); + editor.commitUpdates(); + return Promise.resolve().then(); + } + + let editor: LexicalEditor; + + async function init() { + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.innerHTML = ''; + container.appendChild(root); + + editor = createTestEditor(); + editor.setRootElement(root); + + // 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()!.getChildren(); + expect(children).toHaveLength(3); + }); + }); + }); + + describe('getAllTextNodes()', () => { + test('basic', async () => { + await update(() => { + const textNodes = $getRoot() + .getFirstChild()! + .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()! + .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()! + .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 = {}; + + 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()!.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.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.ts new file mode 100644 index 000000000..2c7e978a1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.ts @@ -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( + ['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 index 000000000..110086ac8 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts @@ -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('
'); + }); + }); + + 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 index 000000000..1f7c4cfc3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts @@ -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( + '

', + ); + expect( + paragraphNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('

'); + }); + }); + + test('ParagraphNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = new ParagraphNode(); + const domElement = paragraphNode.createDOM(editorConfig); + + expect(domElement.outerHTML).toBe('

'); + + const newParagraphNode = new ParagraphNode(); + const result = newParagraphNode.updateDOM( + paragraphNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe('

'); + }); + }); + + 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( + '


', + ); + + 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( + '


', + ); + }); + }); + + 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 index 000000000..123cb3375 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts @@ -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()!; + + 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()!; + + 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()!; + thirdParagraph.append($createTextNode('third line')); + }); + + expectRootTextContentToBe('first line\n\nsecond line\n\nthird line'); + + await editor.update(() => { + const secondParagraph = $getRoot().getChildAtIndex(1)!; + const secondParagraphText = secondParagraph.getFirstChild()!; + 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.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts new file mode 100644 index 000000000..d8525fb36 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 {$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( + '

hello\tworld
hello\tworld

', + ); + }); + + 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( + '

hello\tworld

hello\tworld

', + ); + }); + + // 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', + // `hello world + // hello world`, + // ); + // await editor.update(() => { + // const selection = $getSelection(); + // invariant($isRangeSelection(selection), 'isRangeSelection(selection)'); + // $insertDataTransferForRichText(dataTransfer, selection, editor); + // }); + // expect(testEnv.innerHTML).toBe( + // '

hello\tworld
hello\tworld

', + // ); + // }); + + 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', + `

Hello world

Hello world
`, + ); + await editor.update(() => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'isRangeSelection(selection)'); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe( + '

Hello\tworld

Hello\tworld

', + ); + }); + + 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( + '

\tf\t

', + ); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts new file mode 100644 index 000000000..b1ea099ac --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts @@ -0,0 +1,879 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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, + $getNodeByKey, + $getRoot, + $getSelection, + $isNodeSelection, + $isRangeSelection, + ElementNode, + LexicalEditor, + ParagraphNode, + TextFormatType, + TextModeType, + TextNode, +} from 'lexical'; + +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'; +import {Text} from "@codemirror/state"; +import {$generateHtmlFromNodes} from "@lexical/html"; +import {formatBold} from "@lexical/selection/__tests__/utils"; + +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); + editor.commitUpdates(); + return Promise.resolve().then(); + } + + let editor: LexicalEditor; + + async function init() { + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.innerHTML = ''; + container.appendChild(root); + + editor = createTestEditor(); + editor.setRootElement(root); + + // 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()!.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()!.append(textNode); + }); + + await update(() => { + const textNode = $createTextNode('Hello ').toggleUnmergeable(); + const previousTextNode = $getRoot() + .getFirstChild()! + .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()!; + const textNode = paragraphNode.getFirstChild()!; + 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()!; + const textNode = paragraphNode.getFirstChild()!; + + 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()!; + const textNode = paragraphNode.getFirstChild()!; + + 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', 'My text node'], + [ + 'bold', + IS_BOLD, + 'My text node', + 'My text node', + ], + ['bold + empty', IS_BOLD, '', ``], + [ + 'underline', + IS_UNDERLINE, + 'My text node', + 'My text node', + ], + [ + 'strikethrough', + IS_STRIKETHROUGH, + 'My text node', + 'My text node', + ], + [ + 'highlight', + IS_HIGHLIGHT, + 'My text node', + 'My text node', + ], + [ + 'italic', + IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code', + IS_CODE, + 'My text node', + 'My text node', + ], + [ + 'underline + strikethrough', + IS_UNDERLINE | IS_STRIKETHROUGH, + 'My text node', + '' + + 'My text node', + ], + [ + 'code + italic', + IS_CODE | IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code + underline + strikethrough', + IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH, + 'My text node', + '' + + 'My text node', + ], + [ + 'highlight + italic', + IS_HIGHLIGHT | IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code + underline + strikethrough + bold + italic', + IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code + underline + strikethrough + bold + italic + highlight', + IS_CODE | + IS_UNDERLINE | + IS_STRIKETHROUGH | + IS_BOLD | + IS_ITALIC | + IS_HIGHLIGHT, + 'My text node', + 'My text node', + ], + ])('%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', 'My text node'], + ['no formatting + empty string', 0, '', ``], + ])( + '%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: 'My text node', + 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: + 'My new text node', + 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); + } + }); + }, + ); + }); + + describe('exportDOM()', () => { + + test('simple text exports as a text node', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + const textNode = $createTextNode('hello'); + paragraph.append(textNode); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe('

hello

'); + }); + }); + + test('simple text wrapped in span if leading or ending spacing', async () => { + + const textByExpectedHtml = { + 'hello ': '

hello

', + ' hello': '

hello

', + ' hello ': '

hello

', + } + + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + for (const [text, expectedHtml] of Object.entries(textByExpectedHtml)) { + paragraph.getChildren().forEach(c => c.remove(true)); + const textNode = $createTextNode(text); + paragraph.append(textNode); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe(expectedHtml); + } + }); + }); + + test('text with formats exports using format elements instead of classes', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + const textNode = $createTextNode('hello'); + textNode.toggleFormat('bold'); + textNode.toggleFormat('subscript'); + textNode.toggleFormat('italic'); + textNode.toggleFormat('underline'); + textNode.toggleFormat('code'); + paragraph.append(textNode); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe('

hello

'); + }); + }); + + }); + + test('mergeWithSibling', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + 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 index 000000000..ff3b7cbf1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts @@ -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 index 000000000..78db6aafd --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts @@ -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 index 000000000..642e070e1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts @@ -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 index 000000000..c05d33221 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/environment.ts @@ -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 index 000000000..0e73848ba --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/invariant.ts @@ -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 index 000000000..22ea3a940 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts @@ -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 +): Array { + 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/simpleDiffWithCursor.ts b/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts new file mode 100644 index 000000000..39f3d3b33 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts @@ -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/warnOnlyOnce.ts b/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts new file mode 100644 index 000000000..d29e99e02 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts @@ -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 index 000000000..c4dedd47d --- /dev/null +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -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[], + ) { + 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( + '

hello world

', + ); + }); +}); diff --git a/resources/js/wysiwyg/lexical/headless/index.ts b/resources/js/wysiwyg/lexical/headless/index.ts new file mode 100644 index 000000000..2b8eddb8e --- /dev/null +++ b/resources/js/wysiwyg/lexical/headless/index.ts @@ -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 index 000000000..8c731d3aa --- /dev/null +++ b/resources/js/wysiwyg/lexical/history/index.ts @@ -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; + undoStack: Array; +}; + +type IntentionallyMarkedAsDirtyElement = boolean; + +function getDirtyNodes( + editorState: EditorState, + dirtyLeaves: Set, + dirtyElements: Map, +): Array { + 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, + dirtyElementsSet: Map, + 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, + dirtyElements: Map, + tags: Set, +) => 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; + dirtyLeaves: Set; + tags: Set; + }): 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 index 000000000..947e591b4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -0,0 +1,211 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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: '


', + 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('World'); + }); + + test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => { + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + 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( + '

Hello

World

', + ); + }); + + test(`If alignment is set on the paragraph, don't overwrite from parent empty format`, () => { + const editor = createHeadlessEditor(); + const parser = new DOMParser(); + const rightAlignedParagraphInDiv = + '

Hello world!

'; + + 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( + '

Hello world!

', + ); + }); + + 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 = + '

Hello world!

'; + + 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( + '

Hello world!

', + ); + }); +}); diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts new file mode 100644 index 000000000..2975315cc --- /dev/null +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -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 { + const elements = dom.body ? dom.body.childNodes : []; + let lexicalNodes: Array = []; + const allArtificialNodes: Array = []; + 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, + hasBlockAncestorLexicalNode: boolean, + forChildMap: Map = new Map(), + parentLexicalNode?: LexicalNode | null | undefined, +): Array { + let lexicalNodes: Array = []; + + 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, + createWrapperFn: () => ElementNode, +): Array { + const textAlign = (domNode as HTMLElement).style + .textAlign as ElementFormatType; + const out: Array = []; + let continuousInlines: Array = []; + // 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, +) { + 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 index 000000000..0f3513682 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -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'; +import {initializeUnitTest} from 'lexical/__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( + '', + ); + expect( + autoLinkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe(''); + }); + }); + + 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( + `${autoLinkNode.getTextContent()}`, + ); + }); + }); + + 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( + '', + ); + expect( + autoLinkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe( + '', + ); + }); + }); + + 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( + '', + ); + }); + }); + + 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( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar'); + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + 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( + '', + ); + + 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( + '', + ); + }); + }); + + 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( + '', + ); + + const newNode = new AutoLinkNode('https://example.com/bar'); + const result = newNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + 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( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', { + isUnlinked: true, + }); + const newDomElement = newAutoLinkNode.createDOM(editorConfig); + expect(newDomElement.outerHTML).toBe( + `${newAutoLinkNode.getTextContent()}`, + ); + + 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 index 000000000..1aff91863 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts @@ -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'; +import {initializeUnitTest} from 'lexical/__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( + '', + ); + expect( + linkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe(''); + }); + }); + + 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( + '', + ); + expect( + linkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe( + '', + ); + }); + }); + + 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( + '', + ); + }); + }); + + 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( + '', + ); + + const newLinkNode = new LinkNode('https://example.com/bar'); + const result = newLinkNode.updateDOM( + linkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + 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( + '', + ); + + 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( + '', + ); + }); + }); + + 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( + '', + ); + + const newLinkNode = new LinkNode('https://example.com/bar'); + const result = newLinkNode.updateDOM( + linkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + 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 index 000000000..fe2b97570 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -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 +>; + +export type SerializedLinkNode = Spread< + { + url: string; + }, + Spread +>; + +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( + 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 index 000000000..5026a0129 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -0,0 +1,564 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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( + 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 { + + if (this.getTextContent().trim() === '' && this.isLastChild()) { + const list = this.getParentOrThrow(); + if (!$isListItemNode(list.getParent())) { + const paragraph = $createParagraphNode(); + list.insertAfter(paragraph, restoreSelection); + this.remove(); + return paragraph; + } + } + + 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 index 000000000..e22fbf771 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -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): Array { + const normalizedListItems: Array = []; + 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 = { + 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 index 000000000..581db0294 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -0,0 +1,1363 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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/__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` +
  • + `, + ); + + expectHtmlToBeEqual( + listItemNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + html` +
  • + `, + ); + }); + }); + + 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` +
  • + `, + ); + const newListItemNode = new ListItemNode(); + + const result = newListItemNode.updateDOM( + listItemNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + }); + }); + + 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` +
  • + `, + ); + 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` +
  • + `, + ); + }); + }); + }); + + 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` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + 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` +
    +
      +
    • + bar +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('first list item with a non list item node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + return; + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +


    +
      +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + 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` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    +


    +
    + `, + ); + }); + + 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` +
    +
      +
    • + one +
    • +
    +


    +
      +
    • + three +
    • +
    +
    + `, + ); + }); + + 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` +
    +
      +
    • + one +
    • +
    +
    + `, + ); + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +


    +
    + `, + ); + }); + }); + + 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` +
    +
      +
    • + A +
    • +
    • + x +
    • +
    • + B +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + A +
    • +
    • + B +
    • +
    +
    + `, + ); + }); + + // - 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` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • + B +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + B +
    • +
    +
    + `, + ); + }); + + // - 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` +
    +
      +
    • + A +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + A +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - 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` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - 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` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - 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` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - 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` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + }); + }); + + 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` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('first list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +

    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('last list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +

    • +
    +
    + `, + ); + }); + + test('middle list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +

    • +
    +
    + `, + ); + }); + + test('the only list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +
    + `, + ); + + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +

    • +
    +
    + `, + ); + }); + }); + + 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` +
      +
    • +
        +
      • +
          +
        • +
            +
          • + one +
          • +
          +
        • +
        +
      • +
      +
    • +
    • + two +
    • +
    + `, + ); + + await editor.update(() => { + listItemNode1.setIndent(0); + }); + + await editor.update(() => { + expect(listItemNode1.getIndent()).toBe(0); + }); + + expectHtmlToBeEqual( + editor.getRootElement()!.innerHTML, + html` +
      +
    • + one +
    • +
    • + two +
    • +
    + `, + ); + }); + + 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 index 000000000..497e096b1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts @@ -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/__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( + '
      ', + ); + expect( + listNode.createDOM({ + namespace: '', + theme: { + list: {}, + }, + }).outerHTML, + ).toBe('
        '); + expect( + listNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
          '); + }); + }); + + 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( + '
            ', + ); + expect( + listNode1.createDOM({ + namespace: '', + theme: { + list: {}, + }, + }).outerHTML, + ).toBe('
              '); + expect( + listNode1.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
                '); + expect(listNode2.createDOM(editorConfig).outerHTML).toBe( + '
                  ', + ); + expect(listNode3.createDOM(editorConfig).outerHTML).toBe( + '
                    ', + ); + expect(listNode4.createDOM(editorConfig).outerHTML).toBe( + '
                      ', + ); + expect(listNode5.createDOM(editorConfig).outerHTML).toBe( + '
                        ', + ); + expect(listNode6.createDOM(editorConfig).outerHTML).toBe( + '
                          ', + ); + expect(listNode7.createDOM(editorConfig).outerHTML).toBe( + '
                            ', + ); + 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('
                              '); + }); + }); + + test('ListNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + const domElement = listNode.createDOM(editorConfig); + + expect(domElement.outerHTML).toBe( + '
                                ', + ); + + const newListNode = $createListNode('number', 1); + const result = newListNode.updateDOM( + listNode, + domElement, + editorConfig, + ); + + expect(result).toBe(true); + expect(domElement.outerHTML).toBe( + '
                                  ', + ); + }); + }); + + 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()!.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 index 000000000..ba3971289 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts @@ -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/__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/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts new file mode 100644 index 000000000..b9ca01169 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/formatList.ts @@ -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, +): 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) { + 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(); + 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. + *
                                    will merge with
                                      , but NOT
                                        with
                                          . + * @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(); + + 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() as ListItemNode; + const previousSibling = + listItemNode.getPreviousSibling() 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 index 000000000..157fe79de --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/index.ts @@ -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 = + createCommand('INSERT_UNORDERED_LIST_COMMAND'); +export const INSERT_ORDERED_LIST_COMMAND: LexicalCommand = createCommand( + 'INSERT_ORDERED_LIST_COMMAND', +); +export const INSERT_CHECK_LIST_COMMAND: LexicalCommand = createCommand( + 'INSERT_CHECK_LIST_COMMAND', +); +export const REMOVE_LIST_COMMAND: LexicalCommand = 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 index 000000000..c451a4508 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/utils.ts @@ -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(); + + 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 { + let listItemNodes: Array = []; + const listChildren: Array = 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(); + + 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 index 000000000..31db8fab1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/readme.md @@ -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 index 000000000..a94f9ee0b --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts @@ -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/__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( + '

                                          ', + ); + expect( + headingNode.createDOM({ + namespace: '', + theme: { + heading: {}, + }, + }).outerHTML, + ).toBe('

                                          '); + expect( + headingNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('

                                          '); + }); + }); + + test('HeadingNode.updateDOM()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const headingNode = new HeadingNode('h1'); + const domElement = headingNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe('

                                          '); + const newHeadingNode = new HeadingNode('h2'); + const result = newHeadingNode.updateDOM(headingNode, domElement); + expect(result).toBe(false); + expect(domElement.outerHTML).toBe('

                                          '); + }); + }); + + 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( + '


                                          ', + ); + 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( + '



                                          ', + ); + }); + + 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( + '

                                          hello world

                                          ', + ); + 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( + '

                                          hello world


                                          ', + ); + }); + + 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( + '

                                          hello world

                                          ', + ); + 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( + '

                                          hello world


                                          ', + ); + }); + + 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( + `

                                          ${text}

                                          `, + ); + await editor.update(() => { + const result = headingNode.insertNewAfter(); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(headingNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + `

                                          ${text}


                                          `, + ); + }); + }); +}); 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 index 000000000..66374bf5f --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts @@ -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/__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( + '
                                          ', + ); + expect( + quoteNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
                                          '); + }); + }); + + test('QuoteNode.updateDOM()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const quoteNode = $createQuoteNode(); + const domElement = quoteNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( + '
                                          ', + ); + const newQuoteNode = $createQuoteNode(); + const result = newQuoteNode.updateDOM(quoteNode, domElement); + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '
                                          ', + ); + }); + }); + + 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( + '

                                          ', + ); + await editor.update(() => { + const result = quoteNode.insertNewAfter($createRangeSelection()); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(quoteNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + '


                                          ', + ); + }); + + 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 index 000000000..d937060c6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -0,0 +1,1055 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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> = 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; + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { + const node = $createQuoteNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + 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

                                          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; + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { + const node = $createHeadingNode(serializedNode.tag); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + 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, + 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, + editor: LexicalEditor, +): Promise { + 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, 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( + DELETE_CHARACTER_COMMAND, + (isBackward) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.deleteCharacter(isBackward); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_WORD_COMMAND, + (isBackward) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.deleteWord(isBackward); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + 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( + FORMAT_TEXT_COMMAND, + (format) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.formatText(format); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + DRAGSTART_COMMAND, + (event) => { + const [isFileTransfer] = eventFiles(event); + const selection = $getSelection(); + if (isFileTransfer && !$isRangeSelection(selection)) { + return false; + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + 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.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts new file mode 100644 index 000000000..5f2d9dcc0 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -0,0 +1,2740 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 {$createHeadingNode, registerRichText} from '@lexical/rich-text'; +import { + $addNodeStyle, + $getSelectionStyleValueForProperty, + $patchStyleText, + $setBlocksType, +} from '@lexical/selection'; +import {$createTableNodeWithDimensions} from '@lexical/table'; +import { + $createLineBreakNode, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $getSelection, $insertNodes, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setSelection, + DecoratorNode, + ElementNode, + LexicalEditor, + LexicalNode, + ParagraphNode, + PointType, + type RangeSelection, + TextNode, +} from 'lexical'; +import { + $assertRangeSelection, + $createTestDecoratorNode, + $createTestElementNode, + createTestEditor, + initializeClipboard, + invariant, +} from 'lexical/__tests__/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'; +import {createEmptyHistoryState, registerHistory} from "@lexical/history"; +import {mergeRegister} from "@lexical/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 root: HTMLDivElement; + let editor: LexicalEditor | null = null; + + beforeEach(async () => { + container = document.createElement('div'); + document.body.appendChild(container); + + root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.append(root); + + await init(); + }); + + afterEach(async () => { + document.body.removeChild(container); + }); + + async function init() { + + editor = createTestEditor({ + 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', + }, + } + }); + + mergeRegister( + registerHistory(editor, createEmptyHistoryState(), 300), + registerRichText(editor), + ); + + editor.setRootElement(root); + editor.update(() => { + const p = $createParagraphNode(); + $insertNodes([p]); + }); + editor.commitUpdates(); + editor.focus(); + + // Focus first element + setNativeSelectionWithPaths( + editor!.getRootElement()!, + [0, 0], + 0, + [0, 0], + 0, + ); + } + + async function update(fn: () => void) { + editor!.update(fn); + editor!.commitUpdates(); + } + + test('Expect initial output to be a block with no text.', () => { + expect(container!.innerHTML).toBe( + '


                                          ', + ); + }); + + 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: + '

                                          Hello

                                          ', + 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: + '

                                          ' + + 'Hello

                                          ', + 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: + '

                                          ' + + 'Hello

                                          ', + 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: + '

                                          ' + + 'Hello

                                          ', + 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: + '

                                          ' + + 'Hello

                                          ', + 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: + '

                                          ' + + 'Hello

                                          ', + 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: + '

                                          ' + + 'Hello

                                          ', + 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: + '

                                          1246

                                          ', + 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: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + expectedSelection: { + anchorOffset: 16, + anchorPath: [0, 0, 0], + focusOffset: 16, + focusPath: [0, 0, 0], + }, + inputs: [insertTokenNode('Dominic Gannaway')], + name: 'Creation of an token node', + }, + { + expectedHTML: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + 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: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + expectedSelection: { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0], + }, + inputs: [insertSegmentedNode('Dominic Gannaway')], + name: 'Creation of a segmented node', + }, + { + expectedHTML: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello' + + '

                                          ' + + '

                                          ' + + 'world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '

                                          ' + + 'He' + + 'llo' + + '

                                          ' + + '

                                          ' + + 'wo' + + 'rld' + + '

                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello ' + + 'world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello' + + ' world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello beautiful world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + 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', + }, + + ...[ + { + 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: `

                                          Hello${printWhitespace( + whitespaceCharacter, + )}

                                          `, + 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: `

                                          ${printWhitespace( + whitespaceCharacter, + )}world

                                          `, + 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: + '

                                          Hello

                                          ', + 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: + '

                                          world

                                          ', + 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: + '

                                          Hello world

                                          ', + 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: + '

                                          Hello

                                          ', + 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: + '

                                          ' + + 'this is weird test

                                          ', + 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: + '

                                          ' + + 'Hello ' + + 'Bob' + + '

                                          ', + 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: + '

                                          ABD\tEFG

                                          ', + 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 editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + + 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 editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + + 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 editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + 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 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 () => { + 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 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 editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + 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; + 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 editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + 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( + '

                                          Hello awesome

                                          world

                                          ', + ); + }); + + 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( + '

                                          Hello awesome

                                          beautiful world

                                          ', + ); + }); + + it('adjust offset for inline elements text formatting', async () => { + await init(); + + 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 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 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( + `

                                          1

                                          1.1

                                          `, + ); + }); + + 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( + `

                                          1.1

                                          `, + ); + }); + }); +}); 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 index 000000000..4d88bde0e --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -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/__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( + '

                                          foo

                                          ', + ); + }); + + 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( + '

                                          foobar

                                          ', + ); + }); + + 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( + '

                                          hello world

                                          ', + ); + }); + + 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( + '

                                          foo

                                          ', + ); + }); + }); + + 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( + '

                                          Existing text...foo

                                          ', + ); + }); + + 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( + '

                                          Existing text...foobar

                                          ', + ); + }); + + 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( + '

                                          Existing text...foo

                                          ', + ); + }); + + 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( + '

                                          ABCDE

                                          ', + ); + }); + }); + + 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( + '

                                          foo

                                          ', + ); + }); + }); + + 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( + '

                                          foolink

                                          ', + ); + }); + }); + + 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( + '

                                          linkfoo

                                          ', + ); + }); + }); + + 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( + '


                                          Lexical

                                          ', + ); + }); + }); + + 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( + '

                                          foo

                                          ', + ); + }); + }); + + 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( + '

                                          foolink

                                          ', + ); + }); + }); + + 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( + '

                                          linkfoo

                                          ', + ); + }); + }); + + 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( + '

                                          Lexical

                                          ', + ); + }); + + 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( + '

                                          Lexical...

                                          ', + ); + }); + + 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([ + '


                                          ', + '


                                          ', + ]).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( + '

                                          Text before

                                          ' + + '' + + '

                                          Text after

                                          ', + ); + }); + + 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( + '

                                          heading


                                          ', + ); + 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( + '

                                          a' + + '' + + 'link' + + '' + + 'b

                                          ', + ); + }); + + 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( + '

                                          a

                                          ' + + '

                                          b

                                          ', + ); + }); + + 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( + '

                                          ' + + 'a' + + '' + + 'link' + + '' + + '

                                          ', + ); + }); + + 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( + '

                                          ' + + 'a' + + '' + + 'link' + + '' + + '

                                          ', + ); + }); + + 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( + '

                                          ' + + '' + + 'link' + + '' + + '

                                          ', + ); + }); + + 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( + '

                                          text

                                          ', + ); + }); + + 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(['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( + '

                                          ' + + 'fir' + + 'st' + + 'second' + + '

                                          ', + ); + }, + ); + + 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 index 000000000..84c82edec --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts @@ -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, + index: number, + str: string, + isWordLike: boolean, + ): void { + segments.push({ + index: index - str.length, + isWordLike, + segment: str, + }); + }; + + const getWordsFromString = function (string: string): Array { + const segments: Segment[] = []; + let wordString = ''; + let nonWordString = ''; + let i; + + for (i = 0; i < string.length; i++) { + const char = string[i]; + + if (wordBreakPolyfillRegex.test(char)) { + if (wordString !== '') { + pushSegment(segments, i, wordString, true); + wordString = ''; + } + + nonWordString += char; + } else { + if (nonWordString !== '') { + pushSegment(segments, i, nonWordString, false); + nonWordString = ''; + } + + wordString += char; + } + } + + if (wordString !== '') { + pushSegment(segments, i, wordString, true); + } + + if (nonWordString !== '') { + pushSegment(segments, i, nonWordString, false); + } + + return segments; + }; + + Selection.prototype.modify = function (alter, direction, granularity) { + // This is not a thorough implementation, it was more to get tests working + // given the refactor to use this selection method. + const symbol = Object.getOwnPropertySymbols(this)[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const impl = (this as any)[symbol]; + const focus = impl._focus; + const anchor = impl._anchor; + + if (granularity === 'character') { + let anchorNode = anchor.node; + let anchorOffset = anchor.offset; + let _$isTextNode = false; + + if (anchorNode.nodeType === 3) { + _$isTextNode = true; + anchorNode = anchorNode.parentElement; + } else if (anchorNode.nodeName === 'BR') { + const parentNode = anchorNode.parentElement; + const childNodes = Array.from(parentNode.childNodes); + anchorOffset = childNodes.indexOf(anchorNode); + anchorNode = parentNode; + } + + if (direction === 'backward') { + if (anchorOffset === 0) { + let prevSibling = anchorNode.previousSibling; + + if (prevSibling === null) { + prevSibling = anchorNode.parentElement.previousSibling.lastChild; + } + + if (prevSibling.nodeName === 'P') { + prevSibling = prevSibling.firstChild; + } + + if (prevSibling.nodeName === 'BR') { + anchor.node = prevSibling; + anchor.offset = 0; + } else { + anchor.node = prevSibling.firstChild; + anchor.offset = anchor.node.nodeValue.length - 1; + } + } else if (!_$isTextNode) { + anchor.node = anchorNode.childNodes[anchorOffset - 1]; + anchor.offset = anchor.node.nodeValue.length - 1; + } else { + anchor.offset--; + } + } else { + if ( + (_$isTextNode && anchorOffset === anchorNode.textContent.length) || + (!_$isTextNode && + (anchorNode.childNodes.length === anchorOffset || + (anchorNode.childNodes.length === 1 && + anchorNode.firstChild.nodeName === 'BR'))) + ) { + let nextSibling = anchorNode.nextSibling; + + if (nextSibling === null) { + nextSibling = anchorNode.parentElement.nextSibling.lastChild; + } + + if (nextSibling.nodeName === 'P') { + nextSibling = nextSibling.lastChild; + } + + if (nextSibling.nodeName === 'BR') { + anchor.node = nextSibling; + anchor.offset = 0; + } else { + anchor.node = nextSibling.firstChild; + anchor.offset = 0; + } + } else { + anchor.offset++; + } + } + } else if (granularity === 'word') { + const anchorNode = this.anchorNode!; + const targetTextContent = + direction === 'backward' + ? anchorNode.textContent!.slice(0, this.anchorOffset) + : anchorNode.textContent!.slice(this.anchorOffset); + const segments = getWordsFromString(targetTextContent); + const segmentsLength = segments.length; + let index = anchor.offset; + let foundWordNode = false; + + if (direction === 'backward') { + for (let i = segmentsLength - 1; i >= 0; i--) { + const segment = segments[i]; + const nextIndex = segment.index; + + if (segment.isWordLike) { + index = nextIndex; + foundWordNode = true; + } else if (foundWordNode) { + break; + } else { + index = nextIndex; + } + } + } else { + for (let i = 0; i < segmentsLength; i++) { + const segment = segments[i]; + const nextIndex = segment.index + segment.segment.length; + + if (segment.isWordLike) { + index = nextIndex; + foundWordNode = true; + } else if (foundWordNode) { + break; + } else { + index = nextIndex; + } + } + } + + if (direction === 'forward') { + index += anchor.offset; + } + + anchor.offset = index; + } + + if (alter === 'move') { + focus.offset = anchor.offset; + focus.node = anchor.node; + } + }; +} + +export function printWhitespace(whitespaceCharacter: string) { + return whitespaceCharacter.charCodeAt(0) === 160 + ? ' ' + : whitespaceCharacter; +} + +export function insertText(text: string) { + return { + text, + type: 'insert_text', + }; +} + +export function insertTokenNode(text: string) { + return { + text, + type: 'insert_token_node', + }; +} + +export function insertSegmentedNode(text: string) { + return { + text, + type: 'insert_segmented_node', + }; +} + +export function convertToTokenNode() { + return { + text: null, + type: 'convert_to_token_node', + }; +} + +export function convertToSegmentedNode() { + return { + text: null, + type: 'convert_to_segmented_node', + }; +} + +export function insertParagraph() { + return { + type: 'insert_paragraph', + }; +} + +export function deleteWordBackward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_word_backward', + }; +} + +export function deleteWordForward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_word_forward', + }; +} + +export function moveBackward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'move_backward', + }; +} + +export function moveForward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'move_forward', + }; +} + +export function moveEnd() { + return { + type: 'move_end', + }; +} + +export function deleteBackward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_backward', + }; +} + +export function deleteForward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_forward', + }; +} + +export function formatBold() { + return { + format: 'bold', + type: 'format_text', + }; +} + +export function formatItalic() { + return { + format: 'italic', + type: 'format_text', + }; +} + +export function formatStrikeThrough() { + return { + format: 'strikethrough', + type: 'format_text', + }; +} + +export function formatUnderline() { + return { + format: 'underline', + type: 'format_text', + }; +} + +export function redo(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'redo', + }; +} + +export function undo(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'undo', + }; +} + +export function pastePlain(text: string) { + return { + text: text, + type: 'paste_plain', + }; +} + +export function pasteLexical(text: string) { + return { + text: text, + type: 'paste_lexical', + }; +} + +export function pasteHTML(text: string) { + return { + text: text, + type: 'paste_html', + }; +} + +export function moveNativeSelection( + anchorPath: number[], + anchorOffset: number, + focusPath: number[], + focusOffset: number, +) { + return { + anchorOffset, + anchorPath, + focusOffset, + focusPath, + type: 'move_native_selection', + }; +} + +export function getNodeFromPath(path: number[], rootElement: Node) { + let node = rootElement; + + for (let i = 0; i < path.length; i++) { + node = node.childNodes[path[i]]; + } + + return node; +} + +export function setNativeSelection( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, +) { + const domSelection = window.getSelection()!; + const range = document.createRange(); + range.setStart(anchorNode, anchorOffset); + range.setEnd(focusNode, focusOffset); + domSelection.removeAllRanges(); + domSelection.addRange(range); + Promise.resolve().then(() => { + document.dispatchEvent(new Event('selectionchange')); + }); +} + +export function setNativeSelectionWithPaths( + rootElement: Node, + anchorPath: number[], + anchorOffset: number, + focusPath: number[], + focusOffset: number, +) { + const anchorNode = getNodeFromPath(anchorPath, rootElement); + const focusNode = getNodeFromPath(focusPath, rootElement); + setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset); +} + +function getLastTextNode(startingNode: Node) { + let node = startingNode; + + mainLoop: while (node !== null) { + if (node !== startingNode && node.nodeType === 3) { + return node; + } + + const child = node.lastChild; + + if (child !== null) { + node = child; + continue; + } + + const previousSibling = node.previousSibling; + + if (previousSibling !== null) { + node = previousSibling; + continue; + } + + let parent = node.parentNode; + + while (parent !== null) { + const parentSibling = parent.previousSibling; + + if (parentSibling !== null) { + node = parentSibling; + continue mainLoop; + } + + parent = parent.parentNode; + } + } + + return null; +} + +function getNextTextNode(startingNode: Node) { + let node = startingNode; + + mainLoop: while (node !== null) { + if (node !== startingNode && node.nodeType === 3) { + return node; + } + + const child = node.firstChild; + + if (child !== null) { + node = child; + continue; + } + + const nextSibling = node.nextSibling; + + if (nextSibling !== null) { + node = nextSibling; + continue; + } + + let parent = node.parentNode; + + while (parent !== null) { + const parentSibling = parent.nextSibling; + + if (parentSibling !== null) { + node = parentSibling; + continue mainLoop; + } + + parent = parent.parentNode; + } + } + + return null; +} + +function moveNativeSelectionBackward() { + const domSelection = window.getSelection()!; + let anchorNode = domSelection.anchorNode!; + let anchorOffset = domSelection.anchorOffset!; + + if (domSelection.isCollapsed) { + const target = ( + anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode + )!; + const keyDownEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowLeft', + keyCode: 37, + }); + target.dispatchEvent(keyDownEvent); + + if (!keyDownEvent.defaultPrevented) { + if (anchorNode.nodeType === 3) { + if (anchorOffset === 0) { + const lastTextNode = getLastTextNode(anchorNode); + + if (lastTextNode === null) { + throw new Error('moveNativeSelectionBackward: TODO'); + } else { + const textLength = lastTextNode.nodeValue!.length; + setNativeSelection( + lastTextNode, + textLength, + lastTextNode, + textLength, + ); + } + } else { + setNativeSelection( + anchorNode, + anchorOffset - 1, + anchorNode, + anchorOffset - 1, + ); + } + } else if (anchorNode.nodeType === 1) { + if (anchorNode.nodeName === 'BR') { + const parentNode = anchorNode.parentNode!; + const childNodes = Array.from(parentNode.childNodes); + anchorOffset = childNodes.indexOf(anchorNode as ChildNode); + anchorNode = parentNode; + } else { + anchorOffset--; + } + + setNativeSelection(anchorNode, anchorOffset, anchorNode, anchorOffset); + } else { + throw new Error('moveNativeSelectionBackward: TODO'); + } + } + + const keyUpEvent = new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + key: 'ArrowLeft', + keyCode: 37, + }); + target.dispatchEvent(keyUpEvent); + } else { + throw new Error('moveNativeSelectionBackward: TODO'); + } +} + +function moveNativeSelectionForward() { + const domSelection = window.getSelection()!; + const anchorNode = domSelection.anchorNode!; + const anchorOffset = domSelection.anchorOffset!; + + if (domSelection.isCollapsed) { + const target = ( + anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode + )!; + const keyDownEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowRight', + keyCode: 39, + }); + target.dispatchEvent(keyDownEvent); + + if (!keyDownEvent.defaultPrevented) { + if (anchorNode.nodeType === 3) { + const text = anchorNode.nodeValue!; + + if (text.length === anchorOffset) { + const nextTextNode = getNextTextNode(anchorNode); + + if (nextTextNode === null) { + throw new Error('moveNativeSelectionForward: TODO'); + } else { + setNativeSelection(nextTextNode, 0, nextTextNode, 0); + } + } else { + setNativeSelection( + anchorNode, + anchorOffset + 1, + anchorNode, + anchorOffset + 1, + ); + } + } else { + throw new Error('moveNativeSelectionForward: TODO'); + } + } + + const keyUpEvent = new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + key: 'ArrowRight', + keyCode: 39, + }); + target.dispatchEvent(keyUpEvent); + } else { + throw new Error('moveNativeSelectionForward: TODO'); + } +} + +export async function applySelectionInputs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: Record[], + update: (fn: () => void) => Promise, + 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, +) { + 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, +) { + 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 index 000000000..104f57df5 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/constants.ts @@ -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> = new Map(); diff --git a/resources/js/wysiwyg/lexical/selection/index.ts b/resources/js/wysiwyg/lexical/selection/index.ts new file mode 100644 index 000000000..b2d18b164 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/index.ts @@ -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 index 000000000..82f7d330e --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/lexical-node.ts @@ -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(); + 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>( + (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 index 000000000..dbadaf346 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/range-selection.ts @@ -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(); + + 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 = 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(); + + 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 = 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( + 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 index 000000000..0608706ea --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/utils.ts @@ -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 { + 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 { + const styleObject: Record = {}; + 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 { + 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 { + 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 index 000000000..455d39bf6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -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 index 000000000..8fb542383 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts @@ -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 = + 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 index 000000000..3e695eaa4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -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(); + + 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 index 000000000..0d40d0699 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts @@ -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 | 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 index 000000000..eddea69a2 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts @@ -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 index 000000000..4564ace7f --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts @@ -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>; + +export class TableSelection implements BaseSelection { + tableKey: NodeKey; + anchor: PointType; + focus: PointType; + _cachedNodes: Array | 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 { + return this.getNodes(); + } + + insertRawText(text: string): void { + // Do nothing? + } + + insertText(): void { + // Do nothing? + } + + insertNodes(nodes: Array) { + 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 { + 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 = [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 { + 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 index 000000000..812cccc0d --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -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( + KEY_ARROW_DOWN_COMMAND, + (event) => + $handleArrowKey(editor, event, 'down', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + (event) => + $handleArrowKey(editor, event, 'backward', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (event) => + $handleArrowKey(editor, event, 'forward', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + 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) => () => { + 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( + KEY_BACKSPACE_COMMAND, + $deleteCellHandler, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_DELETE_COMMAND, + $deleteCellHandler, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + 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( + 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( + 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( + 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() + .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; + +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 index 000000000..cdbc84658 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts @@ -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(); + 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(); + 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(); + 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 index 000000000..70b327866 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts @@ -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/__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( + ``, + ); + + const headerCellNode = $createTableCellNode(TableCellHeaderStates.ROW); + expect(headerCellNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + + const colSpan = 2; + const cellWithRowSpanNode = $createTableCellNode( + TableCellHeaderStates.NO_STATUS, + colSpan, + ); + expect(cellWithRowSpanNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + + const cellWidth = 200; + const cellWithCustomWidthNode = $createTableCellNode( + TableCellHeaderStates.NO_STATUS, + undefined, + cellWidth, + ); + expect(cellWithCustomWidthNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts new file mode 100644 index 000000000..6848e5532 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -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 {$insertDataTransferForRichText} from '@lexical/clipboard'; +import { + $createTableNode, +} from '@lexical/table'; +import { + $createParagraphNode, + $getRoot, + $getSelection, + $isRangeSelection, +} from 'lexical'; +import { + DataTransferMock, + initializeUnitTest, + invariant, +} from 'lexical/__tests__/utils'; + +export class ClipboardDataMock { + getData: jest.Mock; + setData: jest.Mock; + + 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( + `
                                          `, + ); + }); + }); + + test('Copy table from an external source', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '

                                          Hello there

                                          General Kenobi!

                                          Lexical is nice


                                          ', + ); + 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 = '


                                          '; + expect(testEnv.innerHTML).toBe( + `${emptyCell}

                                          Hello there

                                          General Kenobi!

                                          Lexical is nice

                                          `, + ); + }); + + test('Copy table from an external source like gdoc with formatting', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '
                                          SurfaceMWP_WORK_LS_COMPOSER77349
                                          LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
                                          ', + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe( + `

                                          Surface

                                          MWP_WORK_LS_COMPOSER

                                          77349

                                          Lexical

                                          XDS_RICH_TEXT_AREA

                                          sdvd sdfvsfs

                                          `, + ); + }); + }, + undefined, + ); +}); 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 index 000000000..285d587bf --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts @@ -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/__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( + ``, + ); + + const rowHeight = 36; + const rowWithCustomHeightNode = $createTableRowNode(36); + expect(rowWithCustomHeightNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts new file mode 100644 index 000000000..d5b85ccaa --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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/__tests__/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 root: HTMLDivElement; + let container: HTMLDivElement | null = null; + let editor: LexicalEditor | null = null; + + beforeEach(() => { + container = document.createElement('div'); + root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + document.body.appendChild(container); + }); + + afterEach(() => { + container?.remove(); + }); + + function init(onError?: () => void) { + editor = createTestEditor({ + nodes: [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }) + + editor.setRootElement(root); + } + + 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: null, + __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: null, + __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 index 000000000..ffa6ba1c3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/constants.ts @@ -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 index 000000000..2429eb608 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/index.ts @@ -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 index 000000000..0bca8a9ea --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts @@ -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.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts new file mode 100644 index 000000000..fd7731f90 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -0,0 +1,676 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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 {AutoLinkNode, LinkNode} from '@lexical/link'; +import {ListItemNode, ListNode} from '@lexical/list'; +import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text'; +import { + applySelectionInputs, + pasteHTML, +} from '@lexical/selection/__tests__/utils'; +import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; +import {$createParagraphNode, $insertNodes, LexicalEditor} from 'lexical'; +import {createTestEditor, initializeClipboard} from 'lexical/__tests__/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() { + + const config = { + nodes: [ + LinkNode, + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + ], + 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', + }, + }, + }; + + editor = createTestEditor(config); + registerRichText(editor); + + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container?.append(root); + + editor.setRootElement(root); + + editor.update(() => { + $insertNodes([$createParagraphNode()]) + }); + editor.commitUpdates(); + } + + async function update(fn: () => void) { + await editor!.update(fn); + editor?.commitUpdates(); + + return Promise.resolve().then(); + } + + test('Expect initial output to be a block with no text', () => { + expect(container!.innerHTML).toBe( + '


                                          ', + ); + }); + + describe('onPasteForRichText', () => { + describe('baseline', () => { + const suite = [ + { + expectedHTML: + '

                                          Hello

                                          ', + inputs: [pasteHTML(`

                                          Hello

                                          `)], + name: 'should produce the correct editor state from a pasted HTML h1 element', + }, + { + expectedHTML: + '

                                          From

                                          ', + inputs: [pasteHTML(`

                                          From

                                          `)], + name: 'should produce the correct editor state from a pasted HTML h2 element', + }, + { + expectedHTML: + '

                                          The

                                          ', + inputs: [pasteHTML(`

                                          The

                                          `)], + name: 'should produce the correct editor state from a pasted HTML h3 element', + }, + { + expectedHTML: + '
                                          • Other side
                                          • I must have called
                                          ', + inputs: [ + pasteHTML( + `
                                          • Other side
                                          • I must have called
                                          `, + ), + ], + name: 'should produce the correct editor state from a pasted HTML ul element', + }, + { + expectedHTML: + '
                                          1. To tell you
                                          2. I’m sorry
                                          ', + inputs: [ + pasteHTML( + `
                                          1. To tell you
                                          2. I’m sorry
                                          `, + ), + ], + name: 'should produce the correct editor state from pasted HTML ol element', + }, + { + expectedHTML: + '

                                          A thousand times

                                          ', + inputs: [pasteHTML(`A thousand times`)], + name: 'should produce the correct editor state from pasted DOM Text Node', + }, + { + expectedHTML: + '

                                          Bold

                                          ', + inputs: [pasteHTML(`Bold`)], + name: 'should produce the correct editor state from a pasted HTML b element', + }, + { + expectedHTML: + '

                                          Italic

                                          ', + inputs: [pasteHTML(`Italic`)], + name: 'should produce the correct editor state from a pasted HTML i element', + }, + { + expectedHTML: + '

                                          Italic

                                          ', + inputs: [pasteHTML(`Italic`)], + name: 'should produce the correct editor state from a pasted HTML em element', + }, + { + expectedHTML: + '

                                          Underline

                                          ', + inputs: [pasteHTML(`Underline`)], + name: 'should produce the correct editor state from a pasted HTML u element', + }, + { + expectedHTML: + '

                                          Lyrics to Hello by Adele

                                          A thousand times

                                          ', + inputs: [ + pasteHTML( + `

                                          Lyrics to Hello by Adele

                                          A thousand times`, + ), + ], + name: 'should produce the correct editor state from pasted heading node followed by a DOM Text Node', + }, + { + expectedHTML: + '', + inputs: [ + pasteHTML( + `Facebook`, + ), + ], + name: 'should produce the correct editor state from a pasted HTML anchor element', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!`, + ), + ], + name: 'should produce the correct editor state from a pasted combination of an HTML text node followed by an anchor node', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!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: + '
                                          • Hello
                                          • from the other
                                          • side
                                          ', + inputs: [ + pasteHTML( + `
                                          • Hello
                                          • from the other
                                          • side
                                          `, + ), + ], + name: 'should ignore DOM node types that do not have transformers, but still process their children.', + }, + { + expectedHTML: + '
                                          • Hello
                                          • from the other
                                          • side
                                          ', + inputs: [ + pasteHTML( + `
                                          • Hello
                                          • from the other
                                          • side
                                          `, + ), + ], + name: 'should ignore multiple levels of DOM node types that do not have transformers, but still process their children.', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + name: 'should preserve formatting from HTML tags on deeply nested text nodes.', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + name: 'should preserve formatting from HTML tags on deeply nested and top level text nodes.', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + 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: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from Normal text', + }, + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from bold text', + }, + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from italic text', + }, + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + 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: + '

                                          hello world

                                          ', + inputs: [pasteHTML('hello world')], + name: 'inline hello world', + }, + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [pasteHTML(' hello world ')], + name: 'inline hello world (2)', + }, + { + // MS Office got it right + expectedHTML: + '

                                          hello world

                                          ', + inputs: [ + pasteHTML(' hello world '), + ], + name: 'pre + inline (inline collapses with pre)', + }, + { + expectedHTML: + '

                                          a b\tc

                                          ', + inputs: [pasteHTML('

                                          a b\tc

                                          ')], + name: 'white-space: pre (1) (no touchy)', + }, + { + expectedHTML: + '

                                          a b c

                                          ', + inputs: [pasteHTML('

                                          \ta\tb c\t\t

                                          ')], + name: 'tabs are collapsed', + }, + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [ + pasteHTML(` +
                                          + hello + world +
                                          + `), + ], + name: 'remove beginning + end spaces on the block', + }, + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [ + pasteHTML(` +
                                          + + hello + world + +
                                          + `), + ], + name: 'remove beginning + end spaces on the block (2)', + }, + { + expectedHTML: + '

                                          a b c

                                          ', + inputs: [ + pasteHTML(` +
                                          + a + b + c +
                                          + `), + ], + name: 'remove beginning + end spaces on the block + anonymous inlines collapsible rules', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (1)', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (2)', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (3)', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (4)', + }, + { + expectedHTML: '


                                          ', + inputs: [ + pasteHTML(` +

                                          +

                                          + `), + ], + name: 'empty block', + }, + { + expectedHTML: + '

                                          a

                                          ', + inputs: [pasteHTML(' a')], + name: 'redundant inline at start', + }, + { + expectedHTML: + '

                                          a

                                          ', + inputs: [pasteHTML('a ')], + name: 'redundant inline at end', + }, + { + expectedHTML: + '

                                          a

                                          b

                                          ', + inputs: [ + pasteHTML(` +
                                          +

                                          + a +

                                          +

                                          + b +

                                          +
                                          + `), + ], + name: 'collapsible spaces with nested structures', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [ + pasteHTML(` +
                                          + + a + + + b + +
                                          + `), + ], + name: 'collapsible spaces with nested structures (3)', + }, + { + expectedHTML: + '

                                          a
                                          b

                                          ', + inputs: [ + pasteHTML(` +

                                          + a +
                                          + b +

                                          + `), + ], + name: 'forced line break should remain', + }, + { + expectedHTML: + '

                                          a
                                          b

                                          ', + inputs: [ + pasteHTML(` +

                                          + a + \t
                                          \t + b +

                                          + `), + ], + name: 'forced line break with tabs', + }, + { + expectedHTML: + '

                                          paragraph1

                                          paragraph2

                                          ', + inputs: [ + pasteHTML( + '\n

                                          paragraph1

                                          \n

                                          paragraph2

                                          \n', + ), + ], + name: 'two Apple Notes paragraphs', + }, + { + expectedHTML: + '

                                          line 1
                                          line 2


                                          paragraph 1

                                          paragraph 2

                                          ', + inputs: [ + pasteHTML( + '\n

                                          line 1
                                          \nline 2

                                          \n


                                          \n

                                          paragraph 1

                                          \n

                                          paragraph 2

                                          \n', + ), + ], + name: 'two Apple Notes lines + two paragraphs separated by an empty paragraph', + }, + { + expectedHTML: + '

                                          line 1
                                          line 2


                                          paragraph 1

                                          paragraph 2

                                          ', + inputs: [ + pasteHTML( + '\n

                                          line 1
                                          \nline 2

                                          \n

                                          \n
                                          \n

                                          \n

                                          paragraph 1

                                          \n

                                          paragraph 2

                                          \n', + ), + ], + name: 'two lines + two paragraphs separated by an empty paragraph (2)', + }, + { + expectedHTML: + '

                                          line 1
                                          line 2

                                          ', + inputs: [ + pasteHTML( + '

                                          line 1
                                          line 2

                                          ', + ), + ], + name: 'two lines and br in spans', + }, + { + expectedHTML: + '
                                          1. 1
                                            2

                                          2. 3
                                          ', + inputs: [ + pasteHTML('
                                          1. 1
                                            2
                                          2. 3
                                          '), + ], + name: 'empty block node in li behaves like a line break', + }, + { + expectedHTML: + '

                                          1
                                          2

                                          ', + inputs: [pasteHTML('
                                          1
                                          2
                                          ')], + name: 'empty block node in div behaves like a line break', + }, + { + expectedHTML: + '

                                          12

                                          ', + inputs: [pasteHTML('
                                          12
                                          ')], + name: 'empty inline node does not behave like a line break', + }, + { + expectedHTML: + '

                                          1

                                          2

                                          ', + inputs: [pasteHTML('
                                          1
                                          2
                                          ')], + name: 'empty block node between non inline siblings does not behave like a line break', + }, + { + expectedHTML: + '

                                          a

                                          b b

                                          c

                                          z

                                          d e

                                          fg

                                          ', + inputs: [ + pasteHTML( + `
                                          a
                                          b b
                                          c
                                          z
                                          d e
                                          fg
                                          `, + ), + ], + name: 'nested divs', + }, + { + expectedHTML: + '
                                          1. 1

                                          2. 3
                                          ', + inputs: [pasteHTML('
                                          1. 1

                                          2. 3
                                          ')], + name: 'only br in a li', + }, + { + expectedHTML: + '

                                          1

                                          2

                                          3

                                          ', + inputs: [pasteHTML('1

                                          2

                                          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 index 000000000..1d994e140 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts @@ -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/__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 index 000000000..1322b482b --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts @@ -0,0 +1,88 @@ +/** + * Copyright (c) Meta Platforms, Inc. 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} from 'lexical'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; + +export function $rootTextContent(): string { + const root = $getRoot(); + + return root.getTextContent(); +} + +export function $isRootTextContentEmpty( + isEditorComposing: boolean, + trim = true, +): boolean { + if (isEditorComposing) { + return false; + } + + let text = $rootTextContent(); + + if (trim) { + text = text.trim(); + } + + return text === ''; +} + +export function $isRootTextContentEmptyCurry( + isEditorComposing: boolean, + trim?: boolean, +): () => boolean { + return () => $isRootTextContentEmpty(isEditorComposing, trim); +} + +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 index 000000000..a62b7bae1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts @@ -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/__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.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts new file mode 100644 index 000000000..a70200d63 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts @@ -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/__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; + splitOffset: number; + only?: boolean; + }> = [ + { + _: 'split paragraph in between two text nodes', + expectedHtml: + '

                                          Hello

                                          world

                                          ', + initialHtml: '

                                          Helloworld

                                          ', + splitOffset: 1, + splitPath: [0], + }, + { + _: 'split paragraph before the first text node', + expectedHtml: + '


                                          Helloworld

                                          ', + initialHtml: '

                                          Helloworld

                                          ', + splitOffset: 0, + splitPath: [0], + }, + { + _: 'split paragraph after the last text node', + expectedHtml: + '

                                          Helloworld


                                          ', + initialHtml: '

                                          Helloworld

                                          ', + splitOffset: 2, // Any offset that is higher than children size + splitPath: [0], + }, + { + _: 'split list items between two text nodes', + expectedHtml: + '
                                          • Hello
                                          ' + + '
                                          • world
                                          ', + initialHtml: '
                                          • Helloworld
                                          ', + splitOffset: 1, // Any offset that is higher than children size + splitPath: [0, 0], + }, + { + _: 'split list items before the first text node', + expectedHtml: + '
                                          ' + + '
                                          • Helloworld
                                          ', + initialHtml: '
                                          • Helloworld
                                          ', + splitOffset: 0, // Any offset that is higher than children size + splitPath: [0, 0], + }, + { + _: 'split nested list items', + expectedHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Hello
                                          • ' + + '
                                          ' + + '
                                            ' + + '
                                            • world
                                          • ' + + '
                                          • After
                                          • ' + + '
                                          ', + initialHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Helloworld
                                            ' + + '
                                          • After
                                          • ' + + '
                                          ', + 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.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts new file mode 100644 index 000000000..fb04e6284 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts @@ -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/__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; + selectionOffset: number; + only?: boolean; + }> = [ + { + _: 'insert into paragraph in between two text nodes', + expectedHtml: + '

                                          Hello

                                          world

                                          ', + initialHtml: '

                                          Helloworld

                                          ', + selectionOffset: 5, // Selection on text node after "Hello" world + selectionPath: [0, 0], + }, + { + _: 'insert into nested list items', + expectedHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Hello
                                          • ' + + '
                                          ' + + '' + + '
                                            ' + + '
                                            • world
                                          • ' + + '
                                          • After
                                          • ' + + '
                                          ', + initialHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Helloworld
                                            ' + + '
                                          • After
                                          • ' + + '
                                          ', + selectionOffset: 5, // Selection on text node after "Hello" world + selectionPath: [0, 1, 0, 0, 0], + }, + { + _: 'insert into empty paragraph', + expectedHtml: '



                                          ', + initialHtml: '

                                          ', + selectionOffset: 0, // Selection on text node after "Hello" world + selectionPath: [0], + }, + { + _: 'insert in the end of paragraph', + expectedHtml: + '

                                          Hello world

                                          ' + + '' + + '


                                          ', + initialHtml: '

                                          Hello world

                                          ', + selectionOffset: 12, // Selection on text node after "Hello" world + selectionPath: [0, 0], + }, + { + _: 'insert in the beginning of paragraph', + expectedHtml: + '


                                          ' + + '' + + '

                                          Hello world

                                          ', + initialHtml: '

                                          Hello world

                                          ', + selectionOffset: 0, // Selection on text node after "Hello" world + selectionPath: [0, 0], + }, + { + _: 'insert with selection on root start', + expectedHtml: + '' + + '' + + '

                                          Before

                                          ' + + '

                                          After

                                          ', + initialHtml: + '' + + '

                                          Before

                                          ' + + '

                                          After

                                          ', + selectionOffset: 0, + selectionPath: [], + }, + { + _: 'insert with selection on root child', + expectedHtml: + '

                                          Before

                                          ' + + '' + + '

                                          After

                                          ', + initialHtml: '

                                          Before

                                          After

                                          ', + selectionOffset: 1, + selectionPath: [], + }, + { + _: 'insert with selection on root end', + expectedHtml: + '

                                          Before

                                          ' + + '', + initialHtml: '

                                          Before

                                          ', + 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 index 000000000..01228f629 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts @@ -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 index 000000000..7984126e3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/index.ts @@ -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 +): 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 +): 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, +): 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, + acceptableMimeTypes: Array, +): Promise> { + 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 { + 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( + node: LexicalNode, + klass: Klass, +): 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: { + ( + 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( + editor: LexicalEditor, + targetNode: Klass, + 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(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 = 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( + object: unknown, + objectClass: ObjectKlass, +): 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( + nodes: Array, + filterFn: (node: LexicalNode) => null | T, +): Array { + 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 index 000000000..b1359c6df --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/markSelection.ts @@ -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) => 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 index 000000000..0d1a19255 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/mergeRegister.ts @@ -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): () => 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 index 000000000..468d25c08 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts @@ -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) => void, +): () => void { + let rootDOMNode: null | HTMLElement = null; + let parentDOMNode: null | HTMLElement = null; + let observer: null | MutationObserver = null; + let lastNodes: Array = []; + 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 index 000000000..c306cc7d6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/px.ts @@ -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 index 000000000..4d3ac01f4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/Bindings.ts @@ -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; + cursorsContainer: null | HTMLElement; + doc: Doc; + docMap: Map; + editor: LexicalEditor; + id: string; + nodeProperties: Map>; + root: CollabElementNode; + excludedProperties: ExcludedProperties; +}; +export type ExcludedProperties = Map, Set>; + +export function createBinding( + editor: LexicalEditor, + provider: Provider, + id: string, + doc: Doc | null | undefined, + docMap: Map, + 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 index 000000000..3578ed7f5 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts @@ -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 { + if (nodeMap === null) { + return null; + } + + const node = nodeMap.get(this._key); + return $isDecoratorNode(node) ? node : null; + } + + getNode(): null | DecoratorNode { + 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, + prevNodeMap: null | NodeMap, + ): void { + const prevLexicalNode = this.getPrevNode(prevNodeMap); + const xmlElem = this._xmlElem; + + syncPropertiesFromLexical( + binding, + xmlElem, + prevLexicalNode, + nextLexicalNode, + ); + } + + syncPropertiesFromYjs( + binding: Binding, + keysChanged: null | Set, + ): 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 index 000000000..b3866043d --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts @@ -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, + ): 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; + 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 | 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 = []; + 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, + dirtyLeaves: null | Set, + ): 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, + dirtyLeaves: null | Set, + ): 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 | undefined; + let nextChildrenSet: Set | 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 index 000000000..6d1267f8e --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts @@ -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; + _key: NodeKey; + _parent: CollabElementNode; + _type: 'linebreak'; + + constructor(map: YMap, 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 { + 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, + 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 index 000000000..86caf91f2 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts @@ -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; + _key: NodeKey; + _parent: CollabElementNode; + _text: string; + _type: string; + _normalized: boolean; + + constructor( + map: YMap, + 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 { + 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, + ): 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, + 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 index 000000000..721fbb68f --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/SyncCursors.ts @@ -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; +}; +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; + + // In the case of a collapsed selection on a linebreak, we need + // to improvise as the browser will return nothing here as
                                          + // 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 index 000000000..c2dd07748 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts @@ -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>, + 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, +): 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, + dirtyLeaves: Set, + normalizedNodes: Set, + tags: Set, +): 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 index 000000000..c0e6bc96d --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/Utils.ts @@ -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([ + '__key', + '__parent', + '__next', + '__prev', +]); +const elementExcludedProperties = new Set([ + '__first', + '__last', + '__size', +]); +const rootExcludedProperties = new Set(['__cachedText']); +const textExcludedProperties = new Set(['__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 | 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 | 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 | 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 + | 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 | XmlElement, + lexicalNode: LexicalNode, + keysChanged: null | Set, +): 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 | 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 index 000000000..248e34426 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/index.ts @@ -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 = + createCommand('CONNECTED_COMMAND'); +export const TOGGLE_CONNECT_COMMAND: LexicalCommand = createCommand( + 'TOGGLE_CONNECT_COMMAND', +); +export type ProviderAwareness = { + getLocalState: () => UserState | null; + getStates: () => Map; + off: (type: 'update', cb: () => void) => void; + on: (type: 'update', cb: () => void) => void; + setLocalState: (arg0: UserState) => void; +}; +declare interface Provider { + awareness: ProviderAwareness; + connect(): void | Promise; + 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; +}; +export type Delta = Array; +export type YjsNode = Record; +export type YjsEvent = Record; +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 index 000000000..d8807a288 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/types.ts @@ -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 { + _collabNode: CollabLineBreakNode | CollabTextNode; + } +} diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts new file mode 100644 index 000000000..71849bb45 --- /dev/null +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -0,0 +1,122 @@ +import {LexicalNode, Spread} from "lexical"; +import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; +import {el, sizeToPixels} from "../utils/dom"; + +export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; +const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; + +type EditorNodeDirection = 'ltr' | 'rtl' | null; + +export type SerializedCommonBlockNode = Spread<{ + id: string; + alignment: CommonBlockAlignment; + inset: number; +}, SerializedElementNode> + +export interface NodeHasAlignment { + readonly __alignment: CommonBlockAlignment; + setAlignment(alignment: CommonBlockAlignment): void; + getAlignment(): CommonBlockAlignment; +} + +export interface NodeHasId { + readonly __id: string; + setId(id: string): void; + getId(): string; +} + +export interface NodeHasInset { + readonly __inset: number; + setInset(inset: number): void; + getInset(): number; +} + +export interface NodeHasDirection { + readonly __dir: EditorNodeDirection; + setDirection(direction: EditorNodeDirection): void; + getDirection(): EditorNodeDirection; +} + +interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset, NodeHasDirection {} + +export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment { + const textAlignStyle: string = element.style.textAlign || ''; + if (validAlignments.includes(textAlignStyle as CommonBlockAlignment)) { + return textAlignStyle as CommonBlockAlignment; + } + + if (element.classList.contains('align-left')) { + return 'left'; + } else if (element.classList.contains('align-right')) { + return 'right' + } else if (element.classList.contains('align-center')) { + return 'center' + } else if (element.classList.contains('align-justify')) { + return 'justify' + } + + return ''; +} + +export function extractInsetFromElement(element: HTMLElement): number { + const elemPadding: string = element.style.paddingLeft || '0'; + return sizeToPixels(elemPadding); +} + +export function extractDirectionFromElement(element: HTMLElement): EditorNodeDirection { + const elemDir = (element.dir || '').toLowerCase(); + if (elemDir === 'rtl' || elemDir === 'ltr') { + return elemDir; + } + + return null; +} + +export function setCommonBlockPropsFromElement(element: HTMLElement, node: CommonBlockInterface): void { + if (element.id) { + node.setId(element.id); + } + + node.setAlignment(extractAlignmentFromElement(element)); + node.setInset(extractInsetFromElement(element)); + node.setDirection(extractDirectionFromElement(element)); +} + +export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: CommonBlockInterface): boolean { + return nodeA.__id !== nodeB.__id || + nodeA.__alignment !== nodeB.__alignment || + nodeA.__inset !== nodeB.__inset || + nodeA.__dir !== nodeB.__dir; +} + +export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void { + if (node.__id) { + element.setAttribute('id', node.__id); + } + + if (node.__alignment) { + element.classList.add('align-' + node.__alignment); + } + + if (node.__inset) { + element.style.paddingLeft = `${node.__inset}px`; + } + + if (node.__dir) { + element.dir = node.__dir; + } +} + +export function deserializeCommonBlockNode(serializedNode: SerializedCommonBlockNode, node: CommonBlockInterface): void { + node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); + node.setInset(serializedNode.inset); + node.setDirection(serializedNode.direction); +} + +export interface NodeHasSize { + setHeight(height: number): void; + setWidth(width: number): void; + getHeight(): number; + getWidth(): number; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts new file mode 100644 index 000000000..cfe32ec85 --- /dev/null +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -0,0 +1,178 @@ +import { + $createParagraphNode, + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + ParagraphNode, Spread +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; +import type {RangeSelection} from "lexical/LexicalSelection"; +import { + CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; + +export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success'; + +export type SerializedCalloutNode = Spread<{ + category: CalloutCategory; +}, SerializedCommonBlockNode> + +export class CalloutNode extends ElementNode { + __id: string = ''; + __category: CalloutCategory = 'info'; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + static getType() { + return 'callout'; + } + + static clone(node: CalloutNode) { + const newNode = new CalloutNode(node.__category, node.__key); + newNode.__id = node.__id; + newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; + return newNode; + } + + constructor(category: CalloutCategory, key?: string) { + super(key); + this.__category = category; + } + + setCategory(category: CalloutCategory) { + const self = this.getWritable(); + self.__category = category; + } + + getCategory(): CalloutCategory { + const self = this.getLatest(); + return self.__category; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const element = document.createElement('p'); + element.classList.add('callout', this.__category || ''); + updateElementWithCommonBlockProps(element, this); + return element; + } + + updateDOM(prevNode: CalloutNode): boolean { + return prevNode.__category !== this.__category || + commonPropertiesDifferent(prevNode, this); + } + + insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): CalloutNode|ParagraphNode { + const anchorOffset = selection ? selection.anchor.offset : 0; + const newElement = anchorOffset === this.getTextContentSize() || !selection + ? $createParagraphNode() : $createCalloutNode(this.__category); + + newElement.setDirection(this.getDirection()); + this.insertAfter(newElement, restoreSelection); + + if (anchorOffset === 0 && !this.isEmpty() && selection) { + const paragraph = $createParagraphNode(); + paragraph.select(); + this.replace(paragraph, true); + } + + return newElement; + } + + static importDOM(): DOMConversionMap|null { + return { + p(node: HTMLElement): DOMConversion|null { + if (node.classList.contains('callout')) { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + let category: CalloutCategory = 'info'; + const categories: CalloutCategory[] = ['info', 'success', 'warning', 'danger']; + + for (const c of categories) { + if (element.classList.contains(c)) { + category = c; + break; + } + } + + const node = new CalloutNode(category); + setCommonBlockPropsFromElement(element, node); + + return { + node, + }; + }, + priority: 3, + }; + } + return null; + }, + }; + } + + exportJSON(): SerializedCalloutNode { + return { + ...super.exportJSON(), + type: 'callout', + version: 1, + category: this.__category, + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + }; + } + + static importJSON(serializedNode: SerializedCalloutNode): CalloutNode { + const node = $createCalloutNode(serializedNode.category); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + +} + +export function $createCalloutNode(category: CalloutCategory = 'info') { + return new CalloutNode(category); +} + +export function $isCalloutNode(node: LexicalNode | null | undefined): node is CalloutNode { + return node instanceof CalloutNode; +} + +export function $isCalloutNodeOfCategory(node: LexicalNode | null | undefined, category: CalloutCategory = 'info') { + return node instanceof CalloutNode && (node as CalloutNode).getCategory() === category; +} diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts new file mode 100644 index 000000000..76c171971 --- /dev/null +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -0,0 +1,197 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, + DOMConversionOutput, DOMExportOutput, + LexicalEditor, LexicalNode, + SerializedLexicalNode, + Spread +} from "lexical"; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {EditorDecoratorAdapter} from "../ui/framework/decorator"; +import {CodeEditor} from "../../components"; +import {el} from "../utils/dom"; + +export type SerializedCodeBlockNode = Spread<{ + language: string; + id: string; + code: string; +}, SerializedLexicalNode> + +const getLanguageFromClassList = (classes: string) => { + const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-')); + return (langClasses[0] || '').replace('language-', ''); +}; + +export class CodeBlockNode extends DecoratorNode { + __id: string = ''; + __language: string = ''; + __code: string = ''; + + static getType(): string { + return 'code-block'; + } + + static clone(node: CodeBlockNode): CodeBlockNode { + const newNode = new CodeBlockNode(node.__language, node.__code, node.__key); + newNode.__id = node.__id; + return newNode; + } + + constructor(language: string = '', code: string = '', key?: string) { + super(key); + this.__language = language; + this.__code = code; + } + + setLanguage(language: string): void { + const self = this.getWritable(); + self.__language = language; + } + + getLanguage(): string { + const self = this.getLatest(); + return self.__language; + } + + setCode(code: string): void { + const self = this.getWritable(); + self.__code = code; + } + + getCode(): string { + const self = this.getLatest(); + return self.__code; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'code', + getNode: () => this, + }; + } + + isInline(): boolean { + return false; + } + + isIsolated() { + return true; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const codeBlock = el('pre', { + id: this.__id || null, + }, [ + el('code', { + class: this.__language ? `language-${this.__language}` : null, + }, [this.__code]), + ]); + + return el('div', {class: 'editor-code-block-wrap'}, [codeBlock]); + } + + updateDOM(prevNode: CodeBlockNode, dom: HTMLElement) { + const code = dom.querySelector('code'); + if (!code) return false; + + if (prevNode.__language !== this.__language) { + code.className = this.__language ? `language-${this.__language}` : ''; + } + + if (prevNode.__id !== this.__id) { + dom.setAttribute('id', this.__id); + } + + if (prevNode.__code !== this.__code) { + code.textContent = this.__code; + } + + return false; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const dom = this.createDOM(editor._config, editor); + return { + element: dom.querySelector('pre') as HTMLElement, + }; + } + + static importDOM(): DOMConversionMap|null { + return { + pre(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + + const codeEl = element.querySelector('code'); + const language = getLanguageFromClassList(element.className) + || (codeEl && getLanguageFromClassList(codeEl.className)) + || ''; + + const code = codeEl ? (codeEl.textContent || '').trim() : (element.textContent || '').trim(); + const node = $createCodeBlockNode(language, code); + + if (element.id) { + node.setId(element.id); + } + + return { node }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedCodeBlockNode { + return { + type: 'code-block', + version: 1, + id: this.__id, + language: this.__language, + code: this.__code, + }; + } + + static importJSON(serializedNode: SerializedCodeBlockNode): CodeBlockNode { + const node = $createCodeBlockNode(serializedNode.language, serializedNode.code); + node.setId(serializedNode.id || ''); + return node; + } +} + +export function $createCodeBlockNode(language: string = '', code: string = ''): CodeBlockNode { + return new CodeBlockNode(language, code); +} + +export function $isCodeBlockNode(node: LexicalNode | null | undefined) { + return node instanceof CodeBlockNode; +} + +export function $openCodeEditorForNode(editor: LexicalEditor, node: CodeBlockNode): void { + const code = node.getCode(); + const language = node.getLanguage(); + + // @ts-ignore + const codeEditor = window.$components.first('code-editor') as CodeEditor; + // TODO - Handle direction + codeEditor.open(code, language, 'ltr', (newCode: string, newLang: string) => { + editor.update(() => { + node.setCode(newCode); + node.setLanguage(newLang); + }); + // TODO - Re-focus + }, () => { + // TODO - Re-focus + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts new file mode 100644 index 000000000..5df6245f5 --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-heading.ts @@ -0,0 +1,146 @@ +import { + DOMConversionMap, + DOMConversionOutput, + LexicalNode, + Spread +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text"; +import { + CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; + + +export type SerializedCustomHeadingNode = Spread + +export class CustomHeadingNode extends HeadingNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + static getType() { + return 'custom-heading'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + static clone(node: CustomHeadingNode) { + const newNode = new CustomHeadingNode(node.__tag, node.__key); + newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + updateElementWithCommonBlockProps(dom, this); + return dom; + } + + updateDOM(prevNode: CustomHeadingNode, dom: HTMLElement): boolean { + return super.updateDOM(prevNode, dom) + || commonPropertiesDifferent(prevNode, this); + } + + exportJSON(): SerializedCustomHeadingNode { + return { + ...super.exportJSON(), + type: 'custom-heading', + version: 1, + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + }; + } + + static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode { + const node = $createCustomHeadingNode(serializedNode.tag); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + + 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, + }), + }; + } +} + +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 = $createCustomHeadingNode(nodeName); + setCommonBlockPropsFromElement(element, node); + } + return {node}; +} + +export function $createCustomHeadingNode(tag: HeadingTagType) { + return new CustomHeadingNode(tag); +} + +export function $isCustomHeadingNode(node: LexicalNode | null | undefined): node is CustomHeadingNode { + return node instanceof CustomHeadingNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts new file mode 100644 index 000000000..659a55a15 --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -0,0 +1,118 @@ +import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; + +import {el} from "../utils/dom"; +import {$isCustomListNode} from "./custom-list"; + +function updateListItemChecked( + dom: HTMLElement, + listItemNode: ListItemNode, +): void { + // Only set task list attrs for leaf list items + const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild()); + dom.classList.toggle('task-list-item', shouldBeTaskItem); + if (listItemNode.__checked) { + dom.setAttribute('checked', 'checked'); + } else { + dom.removeAttribute('checked'); + } +} + + +export class CustomListItemNode extends ListItemNode { + static getType(): string { + return 'custom-list-item'; + } + + static clone(node: CustomListItemNode): CustomListItemNode { + return new CustomListItemNode(node.__value, node.__checked, node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('li'); + const parent = this.getParent(); + + if ($isListNode(parent) && parent.getListType() === 'check') { + updateListItemChecked(element, this); + } + + element.value = this.__value; + + if ($hasNestedListWithoutLabel(this)) { + element.style.listStyle = 'none'; + } + + return element; + } + + updateDOM( + prevNode: ListItemNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + const parent = this.getParent(); + if ($isListNode(parent) && parent.getListType() === 'check') { + updateListItemChecked(dom, this); + } + // @ts-expect-error - this is always HTMLListItemElement + dom.value = this.__value; + + return false; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config); + element.style.textAlign = this.getFormatType(); + + if (element.classList.contains('task-list-item')) { + const input = el('input', { + type: 'checkbox', + disabled: 'disabled', + }); + if (element.hasAttribute('checked')) { + input.setAttribute('checked', 'checked'); + element.removeAttribute('checked'); + } + + element.prepend(input); + } + + return { + element, + }; + } + + exportJSON(): SerializedListItemNode { + return { + ...super.exportJSON(), + type: 'custom-list-item', + }; + } +} + +function $hasNestedListWithoutLabel(node: CustomListItemNode): boolean { + const children = node.getChildren(); + let hasLabel = false; + let hasNestedList = false; + + for (const child of children) { + if ($isCustomListNode(child)) { + hasNestedList = true; + } else if (child.getTextContent().trim().length > 0) { + hasLabel = true; + } + } + + return hasNestedList && !hasLabel; +} + +export function $isCustomListItemNode( + node: LexicalNode | null | undefined, +): node is CustomListItemNode { + return node instanceof CustomListItemNode; +} + +export function $createCustomListItemNode(): CustomListItemNode { + return new CustomListItemNode(); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-list.ts b/resources/js/wysiwyg/nodes/custom-list.ts new file mode 100644 index 000000000..4b05fa62e --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-list.ts @@ -0,0 +1,139 @@ +import { + DOMConversionFn, + DOMConversionMap, EditorConfig, + LexicalNode, + Spread +} from "lexical"; +import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list"; +import {$createCustomListItemNode} from "./custom-list-item"; +import {extractDirectionFromElement} from "./_common"; + + +export type SerializedCustomListNode = Spread<{ + id: string; +}, SerializedListNode> + +export class CustomListNode extends ListNode { + __id: string = ''; + + static getType() { + return 'custom-list'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: CustomListNode) { + const newNode = new CustomListNode(node.__listType, node.__start, node.__key); + newNode.__id = node.__id; + newNode.__dir = node.__dir; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + if (this.__id) { + dom.setAttribute('id', this.__id); + } + + if (this.__dir) { + dom.setAttribute('dir', this.__dir); + } + + return dom; + } + + updateDOM(prevNode: ListNode, dom: HTMLElement, config: EditorConfig): boolean { + return super.updateDOM(prevNode, dom, config) || + prevNode.__dir !== this.__dir; + } + + exportJSON(): SerializedCustomListNode { + return { + ...super.exportJSON(), + type: 'custom-list', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedCustomListNode): CustomListNode { + const node = $createCustomListNode(serializedNode.listType); + node.setId(serializedNode.id); + node.setDirection(serializedNode.direction); + return node; + } + + static importDOM(): DOMConversionMap | null { + // @ts-ignore + const converter = super.importDOM().ol().conversion as DOMConversionFn; + const customConvertFunction = (element: HTMLElement) => { + const baseResult = converter(element); + if (element.id && baseResult?.node) { + (baseResult.node as CustomListNode).setId(element.id); + } + + if (element.dir && baseResult?.node) { + (baseResult.node as CustomListNode).setDirection(extractDirectionFromElement(element)); + } + + if (baseResult) { + baseResult.after = $normalizeChildren; + } + + return baseResult; + }; + + return { + ol: () => ({ + conversion: customConvertFunction, + priority: 0, + }), + ul: () => ({ + conversion: customConvertFunction, + priority: 0, + }), + }; + } +} + +/* + * This function is a custom normalization function to allow nested lists within list item elements. + * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303 + * With modifications made. + * Copyright (c) Meta Platforms, Inc. and affiliates. + * MIT license + */ +function $normalizeChildren(nodes: Array): Array { + const normalizedListItems: Array = []; + + for (const node of nodes) { + if ($isListItemNode(node)) { + normalizedListItems.push(node); + } else { + normalizedListItems.push($wrapInListItem(node)); + } + } + + return normalizedListItems; +} + +function $wrapInListItem(node: LexicalNode): ListItemNode { + const listItemWrapper = $createCustomListItemNode(); + return listItemWrapper.append(node); +} + +export function $createCustomListNode(type: ListType): CustomListNode { + return new CustomListNode(type, 1); +} + +export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode { + return node instanceof CustomListNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts new file mode 100644 index 000000000..3adc10d0e --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-paragraph.ts @@ -0,0 +1,123 @@ +import { + DOMConversion, + DOMConversionMap, + DOMConversionOutput, + LexicalNode, + ParagraphNode, SerializedParagraphNode, Spread, +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import { + CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; + +export type SerializedCustomParagraphNode = Spread + +export class CustomParagraphNode extends ParagraphNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + static getType() { + return 'custom-paragraph'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + static clone(node: CustomParagraphNode): CustomParagraphNode { + const newNode = new CustomParagraphNode(node.__key); + newNode.__id = node.__id; + newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + updateElementWithCommonBlockProps(dom, this); + return dom; + } + + updateDOM(prevNode: CustomParagraphNode, dom: HTMLElement, config: EditorConfig): boolean { + return super.updateDOM(prevNode, dom, config) + || commonPropertiesDifferent(prevNode, this); + } + + exportJSON(): SerializedCustomParagraphNode { + return { + ...super.exportJSON(), + type: 'custom-paragraph', + version: 1, + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + }; + } + + static importJSON(serializedNode: SerializedCustomParagraphNode): CustomParagraphNode { + const node = $createCustomParagraphNode(); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + + static importDOM(): DOMConversionMap|null { + return { + p(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = $createCustomParagraphNode(); + if (element.style.textIndent) { + const indent = parseInt(element.style.textIndent, 10) / 20; + if (indent > 0) { + node.setIndent(indent); + } + } + + setCommonBlockPropsFromElement(element, node); + + return {node}; + }, + priority: 1, + }; + }, + }; + } +} + +export function $createCustomParagraphNode(): CustomParagraphNode { + return new CustomParagraphNode(); +} + +export function $isCustomParagraphNode(node: LexicalNode | null | undefined): node is CustomParagraphNode { + return node instanceof CustomParagraphNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-quote.ts b/resources/js/wysiwyg/nodes/custom-quote.ts new file mode 100644 index 000000000..39ae7bf8a --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-quote.ts @@ -0,0 +1,115 @@ +import { + DOMConversionMap, + DOMConversionOutput, + LexicalNode, + Spread +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text"; +import { + CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; + + +export type SerializedCustomQuoteNode = Spread + +export class CustomQuoteNode extends QuoteNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + static getType() { + return 'custom-quote'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + static clone(node: CustomQuoteNode) { + const newNode = new CustomQuoteNode(node.__key); + newNode.__id = node.__id; + newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + updateElementWithCommonBlockProps(dom, this); + return dom; + } + + updateDOM(prevNode: CustomQuoteNode): boolean { + return commonPropertiesDifferent(prevNode, this); + } + + exportJSON(): SerializedCustomQuoteNode { + return { + ...super.exportJSON(), + type: 'custom-quote', + version: 1, + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + }; + } + + static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode { + const node = $createCustomQuoteNode(); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + + static importDOM(): DOMConversionMap | null { + return { + blockquote: (node: Node) => ({ + conversion: $convertBlockquoteElement, + priority: 0, + }), + }; + } +} + +function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { + const node = $createCustomQuoteNode(); + setCommonBlockPropsFromElement(element, node); + return {node}; +} + +export function $createCustomQuoteNode() { + return new CustomQuoteNode(); +} + +export function $isCustomQuoteNode(node: LexicalNode | null | undefined): node is CustomQuoteNode { + return node instanceof CustomQuoteNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts new file mode 100644 index 000000000..793302cfe --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-table-cell.ts @@ -0,0 +1,247 @@ +import { + $createParagraphNode, + $isElementNode, + $isLineBreakNode, + $isTextNode, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + Spread +} from "lexical"; + +import { + $createTableCellNode, + $isTableCellNode, + SerializedTableCellNode, + TableCellHeaderStates, + TableCellNode +} from "@lexical/table"; +import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; +import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; + +export type SerializedCustomTableCellNode = Spread<{ + styles: Record; + alignment: CommonBlockAlignment; +}, SerializedTableCellNode> + +export class CustomTableCellNode extends TableCellNode { + __styles: StyleMap = new Map; + __alignment: CommonBlockAlignment = ''; + + static getType(): string { + return 'custom-table-cell'; + } + + static clone(node: CustomTableCellNode): CustomTableCellNode { + const cellNode = new CustomTableCellNode( + node.__headerState, + node.__colSpan, + node.__width, + node.__key, + ); + cellNode.__rowSpan = node.__rowSpan; + cellNode.__styles = new Map(node.__styles); + cellNode.__alignment = node.__alignment; + return cellNode; + } + + clearWidth(): void { + const self = this.getWritable(); + self.__width = undefined; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + updateTag(tag: string): void { + const isHeader = tag.toLowerCase() === 'th'; + const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS; + const self = this.getWritable(); + self.__headerState = state; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = super.createDOM(config); + + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + + return element; + } + + updateDOM(prevNode: CustomTableCellNode): boolean { + return super.updateDOM(prevNode) + || this.__styles !== prevNode.__styles + || this.__alignment !== prevNode.__alignment; + } + + static importDOM(): DOMConversionMap | null { + return { + td: (node: Node) => ({ + conversion: $convertCustomTableCellNodeElement, + priority: 0, + }), + th: (node: Node) => ({ + conversion: $convertCustomTableCellNodeElement, + priority: 0, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config); + return { + element + }; + } + + static importJSON(serializedNode: SerializedCustomTableCellNode): CustomTableCellNode { + const node = $createCustomTableCellNode( + serializedNode.headerState, + serializedNode.colSpan, + serializedNode.width, + ); + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + node.setAlignment(serializedNode.alignment); + + return node; + } + + exportJSON(): SerializedCustomTableCellNode { + return { + ...super.exportJSON(), + type: 'custom-table-cell', + styles: Object.fromEntries(this.__styles), + alignment: this.__alignment, + }; + } +} + +function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput { + const output = $convertTableCellNodeElement(domNode); + + if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { + output.node.setStyles(extractStyleMapFromElement(domNode)); + output.node.setAlignment(extractAlignmentFromElement(domNode)); + } + + return output; +} + +/** + * Function taken from: + * https://github.com/facebook/lexical/blob/e1881a6e409e1541c10dd0b5378f3a38c9dc8c9e/packages/lexical-table/src/LexicalTableCellNode.ts#L289 + * Copyright (c) Meta Platforms, Inc. and affiliates. + * MIT LICENSE + * Modified since copy. + */ +export function $convertTableCellNodeElement( + domNode: Node, +): DOMConversionOutput { + const domNode_ = domNode as HTMLTableCellElement; + const nodeName = domNode.nodeName.toLowerCase(); + + let width: number | undefined = undefined; + + + const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/; + 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 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 $createCustomTableCellNode( + headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, + colSpan = 1, + width?: number, +): CustomTableCellNode { + return new CustomTableCellNode(headerState, colSpan, width); +} + +export function $isCustomTableCellNode(node: LexicalNode | null | undefined): node is CustomTableCellNode { + return node instanceof CustomTableCellNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-table-row.ts b/resources/js/wysiwyg/nodes/custom-table-row.ts new file mode 100644 index 000000000..f4702f36d --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-table-row.ts @@ -0,0 +1,106 @@ +import { + DOMConversionMap, + DOMConversionOutput, + EditorConfig, + LexicalNode, + Spread +} from "lexical"; + +import { + SerializedTableRowNode, + TableRowNode +} from "@lexical/table"; +import {NodeKey} from "lexical/LexicalNode"; +import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; + +export type SerializedCustomTableRowNode = Spread<{ + styles: Record, +}, SerializedTableRowNode> + +export class CustomTableRowNode extends TableRowNode { + __styles: StyleMap = new Map(); + + constructor(key?: NodeKey) { + super(0, key); + } + + static getType(): string { + return 'custom-table-row'; + } + + static clone(node: CustomTableRowNode): CustomTableRowNode { + const cellNode = new CustomTableRowNode(node.__key); + + cellNode.__styles = new Map(node.__styles); + return cellNode; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = super.createDOM(config); + + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + + return element; + } + + updateDOM(prevNode: CustomTableRowNode): boolean { + return super.updateDOM(prevNode) + || this.__styles !== prevNode.__styles; + } + + static importDOM(): DOMConversionMap | null { + return { + tr: (node: Node) => ({ + conversion: $convertTableRowElement, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedCustomTableRowNode): CustomTableRowNode { + const node = $createCustomTableRowNode(); + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + + return node; + } + + exportJSON(): SerializedCustomTableRowNode { + return { + ...super.exportJSON(), + height: 0, + type: 'custom-table-row', + styles: Object.fromEntries(this.__styles), + }; + } +} + +export function $convertTableRowElement(domNode: Node): DOMConversionOutput { + const rowNode = $createCustomTableRowNode(); + + if (domNode instanceof HTMLElement) { + rowNode.setStyles(extractStyleMapFromElement(domNode)); + } + + return {node: rowNode}; +} + +export function $createCustomTableRowNode(): CustomTableRowNode { + return new CustomTableRowNode(); +} + +export function $isCustomTableRowNode(node: LexicalNode | null | undefined): node is CustomTableRowNode { + return node instanceof CustomTableRowNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts new file mode 100644 index 000000000..c25c06c65 --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -0,0 +1,166 @@ +import {SerializedTableNode, TableNode} from "@lexical/table"; +import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; + +import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom"; +import {getTableColumnWidths} from "../utils/tables"; +import { + CommonBlockAlignment, deserializeCommonBlockNode, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; + +export type SerializedCustomTableNode = Spread, +}, SerializedTableNode>, SerializedCommonBlockNode> + +export class CustomTableNode extends TableNode { + __id: string = ''; + __colWidths: string[] = []; + __styles: StyleMap = new Map; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + static getType() { + return 'custom-table'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + setColWidths(widths: string[]) { + const self = this.getWritable(); + self.__colWidths = widths; + } + + getColWidths(): string[] { + const self = this.getLatest(); + return self.__colWidths; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + + static clone(node: CustomTableNode) { + const newNode = new CustomTableNode(node.__key); + newNode.__id = node.__id; + newNode.__colWidths = node.__colWidths; + newNode.__styles = new Map(node.__styles); + newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + updateElementWithCommonBlockProps(dom, this); + + const colWidths = this.getColWidths(); + if (colWidths.length > 0) { + const colgroup = el('colgroup'); + for (const width of colWidths) { + const col = el('col'); + if (width) { + col.style.width = width; + } + colgroup.append(col); + } + dom.append(colgroup); + } + + for (const [name, value] of this.__styles.entries()) { + dom.style.setProperty(name, value); + } + + return dom; + } + + updateDOM(): boolean { + return true; + } + + exportJSON(): SerializedCustomTableNode { + return { + ...super.exportJSON(), + type: 'custom-table', + version: 1, + id: this.__id, + colWidths: this.__colWidths, + styles: Object.fromEntries(this.__styles), + alignment: this.__alignment, + inset: this.__inset, + }; + } + + static importJSON(serializedNode: SerializedCustomTableNode): CustomTableNode { + const node = $createCustomTableNode(); + deserializeCommonBlockNode(serializedNode, node); + node.setColWidths(serializedNode.colWidths); + node.setStyles(new Map(Object.entries(serializedNode.styles))); + return node; + } + + static importDOM(): DOMConversionMap|null { + return { + table(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = $createCustomTableNode(); + setCommonBlockPropsFromElement(element, node); + + const colWidths = getTableColumnWidths(element as HTMLTableElement); + node.setColWidths(colWidths); + node.setStyles(extractStyleMapFromElement(element)); + + return {node}; + }, + priority: 1, + }; + }, + }; + } +} + +export function $createCustomTableNode(): CustomTableNode { + return new CustomTableNode(); +} + +export function $isCustomTableNode(node: LexicalNode | null | undefined): node is CustomTableNode { + return node instanceof CustomTableNode; +} diff --git a/resources/js/wysiwyg/nodes/details.ts b/resources/js/wysiwyg/nodes/details.ts new file mode 100644 index 000000000..de87696f3 --- /dev/null +++ b/resources/js/wysiwyg/nodes/details.ts @@ -0,0 +1,161 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode, Spread, + EditorConfig, +} from 'lexical'; + +import {el} from "../utils/dom"; +import {extractDirectionFromElement} from "./_common"; + +export type SerializedDetailsNode = Spread<{ + id: string; +}, SerializedElementNode> + +export class DetailsNode extends ElementNode { + __id: string = ''; + + static getType() { + return 'details'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: DetailsNode): DetailsNode { + const newNode = new DetailsNode(node.__key); + newNode.__id = node.__id; + newNode.__dir = node.__dir; + return newNode; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const el = document.createElement('details'); + if (this.__id) { + el.setAttribute('id', this.__id); + } + + if (this.__dir) { + el.setAttribute('dir', this.__dir); + } + + return el; + } + + updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + return prevNode.__id !== this.__id + || prevNode.__dir !== this.__dir; + } + + static importDOM(): DOMConversionMap|null { + return { + details(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = new DetailsNode(); + if (element.id) { + node.setId(element.id); + } + + if (element.dir) { + node.setDirection(extractDirectionFromElement(element)); + } + + return {node}; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedDetailsNode { + return { + ...super.exportJSON(), + type: 'details', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedDetailsNode): DetailsNode { + const node = $createDetailsNode(); + node.setId(serializedNode.id); + node.setDirection(serializedNode.direction); + return node; + } + +} + +export function $createDetailsNode() { + return new DetailsNode(); +} + +export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode { + return node instanceof DetailsNode; +} + +export class SummaryNode extends ElementNode { + + static getType() { + return 'summary'; + } + + static clone(node: SummaryNode) { + return new SummaryNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + return el('summary'); + } + + updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + summary(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return { + node: new SummaryNode(), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'summary', + version: 1, + }; + } + + static importJSON(serializedNode: SerializedElementNode): SummaryNode { + return $createSummaryNode(); + } + +} + +export function $createSummaryNode(): SummaryNode { + return new SummaryNode(); +} + +export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode { + return node instanceof SummaryNode; +} diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts new file mode 100644 index 000000000..bd37b200c --- /dev/null +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -0,0 +1,155 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, + DOMConversionOutput, + LexicalEditor, + SerializedLexicalNode, + Spread +} from "lexical"; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {EditorDecoratorAdapter} from "../ui/framework/decorator"; +import {el} from "../utils/dom"; + +export type SerializedDiagramNode = Spread<{ + id: string; + drawingId: string; + drawingUrl: string; +}, SerializedLexicalNode> + +export class DiagramNode extends DecoratorNode { + __id: string = ''; + __drawingId: string = ''; + __drawingUrl: string = ''; + + static getType(): string { + return 'diagram'; + } + + static clone(node: DiagramNode): DiagramNode { + const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl); + newNode.__id = node.__id; + return newNode; + } + + constructor(drawingId: string, drawingUrl: string, key?: string) { + super(key); + this.__drawingId = drawingId; + this.__drawingUrl = drawingUrl; + } + + setDrawingIdAndUrl(drawingId: string, drawingUrl: string): void { + const self = this.getWritable(); + self.__drawingUrl = drawingUrl; + self.__drawingId = drawingId; + } + + getDrawingIdAndUrl(): { id: string, url: string } { + const self = this.getLatest(); + return { + id: self.__drawingId, + url: self.__drawingUrl, + }; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'diagram', + getNode: () => this, + }; + } + + isInline(): boolean { + return false; + } + + isIsolated() { + return true; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + return el('div', { + id: this.__id || null, + 'drawio-diagram': this.__drawingId, + }, [ + el('img', {src: this.__drawingUrl}), + ]); + } + + updateDOM(prevNode: DiagramNode, dom: HTMLElement) { + const img = dom.querySelector('img'); + if (!img) return false; + + if (prevNode.__id !== this.__id) { + dom.setAttribute('id', this.__id); + } + + if (prevNode.__drawingUrl !== this.__drawingUrl) { + img.setAttribute('src', this.__drawingUrl); + } + + if (prevNode.__drawingId !== this.__drawingId) { + dom.setAttribute('drawio-diagram', this.__drawingId); + } + + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + div(node: HTMLElement): DOMConversion | null { + + if (!node.hasAttribute('drawio-diagram')) { + return null; + } + + return { + conversion: (element: HTMLElement): DOMConversionOutput | null => { + + const img = element.querySelector('img'); + const drawingUrl = img?.getAttribute('src') || ''; + const drawingId = element.getAttribute('drawio-diagram') || ''; + const node = $createDiagramNode(drawingId, drawingUrl); + + if (element.id) { + node.setId(element.id); + } + + return { node }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedDiagramNode { + return { + type: 'diagram', + version: 1, + id: this.__id, + drawingId: this.__drawingId, + drawingUrl: this.__drawingUrl, + }; + } + + static importJSON(serializedNode: SerializedDiagramNode): DiagramNode { + const node = $createDiagramNode(serializedNode.drawingId, serializedNode.drawingUrl); + node.setId(serializedNode.id || ''); + return node; + } +} + +export function $createDiagramNode(drawingId: string = '', drawingUrl: string = ''): DiagramNode { + return new DiagramNode(drawingId, drawingUrl); +} diff --git a/resources/js/wysiwyg/nodes/horizontal-rule.ts b/resources/js/wysiwyg/nodes/horizontal-rule.ts new file mode 100644 index 000000000..e881d4688 --- /dev/null +++ b/resources/js/wysiwyg/nodes/horizontal-rule.ts @@ -0,0 +1,92 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode, Spread, +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; + +export type SerializedHorizontalRuleNode = Spread<{ + id: string; +}, SerializedElementNode> + +export class HorizontalRuleNode extends ElementNode { + __id: string = ''; + + static getType() { + return 'horizontal-rule'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: HorizontalRuleNode): HorizontalRuleNode { + const newNode = new HorizontalRuleNode(node.__key); + newNode.__id = node.__id; + return newNode; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + const el = document.createElement('hr'); + if (this.__id) { + el.setAttribute('id', this.__id); + } + + return el; + } + + updateDOM(prevNode: HorizontalRuleNode, dom: HTMLElement) { + return prevNode.__id !== this.__id; + } + + static importDOM(): DOMConversionMap|null { + return { + hr(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = new HorizontalRuleNode(); + if (element.id) { + node.setId(element.id); + } + + return {node}; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedHorizontalRuleNode { + return { + ...super.exportJSON(), + type: 'horizontal-rule', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedHorizontalRuleNode): HorizontalRuleNode { + const node = $createHorizontalRuleNode(); + node.setId(serializedNode.id); + return node; + } + +} + +export function $createHorizontalRuleNode(): HorizontalRuleNode { + return new HorizontalRuleNode(); +} + +export function $isHorizontalRuleNode(node: LexicalNode | null | undefined): node is HorizontalRuleNode { + return node instanceof HorizontalRuleNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts new file mode 100644 index 000000000..b6d362b62 --- /dev/null +++ b/resources/js/wysiwyg/nodes/image.ts @@ -0,0 +1,238 @@ +import { + DOMConversion, + DOMConversionMap, + DOMConversionOutput, ElementNode, + LexicalEditor, LexicalNode, + Spread +} from "lexical"; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; +import {$selectSingleNode} from "../utils/selection"; +import {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; + +export interface ImageNodeOptions { + alt?: string; + width?: number; + height?: number; +} + +export type SerializedImageNode = Spread<{ + src: string; + alt: string; + width: number; + height: number; + alignment: CommonBlockAlignment; +}, SerializedElementNode> + +export class ImageNode extends ElementNode { + __src: string = ''; + __alt: string = ''; + __width: number = 0; + __height: number = 0; + __alignment: CommonBlockAlignment = ''; + + static getType(): string { + return 'image'; + } + + static clone(node: ImageNode): ImageNode { + const newNode = new ImageNode(node.__src, { + alt: node.__alt, + width: node.__width, + height: node.__height, + }, node.__key); + newNode.__alignment = node.__alignment; + return newNode; + } + + constructor(src: string, options: ImageNodeOptions, key?: string) { + super(key); + this.__src = src; + if (options.alt) { + this.__alt = options.alt; + } + if (options.width) { + this.__width = options.width; + } + if (options.height) { + this.__height = options.height; + } + } + + setSrc(src: string): void { + const self = this.getWritable(); + self.__src = src; + } + + getSrc(): string { + const self = this.getLatest(); + return self.__src; + } + + setAltText(altText: string): void { + const self = this.getWritable(); + self.__alt = altText; + } + + getAltText(): string { + const self = this.getLatest(); + return self.__alt; + } + + setHeight(height: number): void { + const self = this.getWritable(); + self.__height = height; + } + + getHeight(): number { + const self = this.getLatest(); + return self.__height; + } + + setWidth(width: number): void { + const self = this.getWritable(); + self.__width = width; + } + + getWidth(): number { + const self = this.getLatest(); + return self.__width; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + isInline(): boolean { + return true; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const element = document.createElement('img'); + element.setAttribute('src', this.__src); + + if (this.__width) { + element.setAttribute('width', String(this.__width)); + } + if (this.__height) { + element.setAttribute('height', String(this.__height)); + } + if (this.__alt) { + element.setAttribute('alt', this.__alt); + } + + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + + element.addEventListener('click', e => { + _editor.update(() => { + $selectSingleNode(this); + }); + }); + + return element; + } + + updateDOM(prevNode: ImageNode, dom: HTMLElement) { + if (prevNode.__src !== this.__src) { + dom.setAttribute('src', this.__src); + } + + if (prevNode.__width !== this.__width) { + if (this.__width) { + dom.setAttribute('width', String(this.__width)); + } else { + dom.removeAttribute('width'); + } + } + + if (prevNode.__height !== this.__height) { + if (this.__height) { + dom.setAttribute('height', String(this.__height)); + } else { + dom.removeAttribute('height'); + } + } + + if (prevNode.__alt !== this.__alt) { + if (this.__alt) { + dom.setAttribute('alt', String(this.__alt)); + } else { + dom.removeAttribute('alt'); + } + } + + if (prevNode.__alignment !== this.__alignment) { + if (prevNode.__alignment) { + dom.classList.remove('align-' + prevNode.__alignment); + } + if (this.__alignment) { + dom.classList.add('align-' + this.__alignment); + } + } + + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + img(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + + const src = element.getAttribute('src') || ''; + const options: ImageNodeOptions = { + alt: element.getAttribute('alt') || '', + height: Number.parseInt(element.getAttribute('height') || '0'), + width: Number.parseInt(element.getAttribute('width') || '0'), + } + + const node = new ImageNode(src, options); + node.setAlignment(extractAlignmentFromElement(element)); + + return { node }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedImageNode { + return { + ...super.exportJSON(), + type: 'image', + version: 1, + src: this.__src, + alt: this.__alt, + height: this.__height, + width: this.__width, + alignment: this.__alignment, + }; + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const node = $createImageNode(serializedNode.src, { + alt: serializedNode.alt, + width: serializedNode.width, + height: serializedNode.height, + }); + node.setAlignment(serializedNode.alignment); + return node; + } +} + +export function $createImageNode(src: string, options: ImageNodeOptions = {}): ImageNode { + return new ImageNode(src, options); +} + +export function $isImageNode(node: LexicalNode | null | undefined) { + return node instanceof ImageNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts new file mode 100644 index 000000000..b5483c500 --- /dev/null +++ b/resources/js/wysiwyg/nodes/index.ts @@ -0,0 +1,128 @@ +import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import {CalloutNode} from './callout'; +import { + ElementNode, + KlassConstructor, + LexicalNode, + LexicalNodeReplacement, NodeMutation, + ParagraphNode +} from "lexical"; +import {CustomParagraphNode} from "./custom-paragraph"; +import {LinkNode} from "@lexical/link"; +import {ImageNode} from "./image"; +import {DetailsNode, SummaryNode} from "./details"; +import {ListItemNode, ListNode} from "@lexical/list"; +import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; +import {CustomTableNode} from "./custom-table"; +import {HorizontalRuleNode} from "./horizontal-rule"; +import {CodeBlockNode} from "./code-block"; +import {DiagramNode} from "./diagram"; +import {EditorUiContext} from "../ui/framework/core"; +import {MediaNode} from "./media"; +import {CustomListItemNode} from "./custom-list-item"; +import {CustomTableCellNode} from "./custom-table-cell"; +import {CustomTableRowNode} from "./custom-table-row"; +import {CustomHeadingNode} from "./custom-heading"; +import {CustomQuoteNode} from "./custom-quote"; +import {CustomListNode} from "./custom-list"; + +/** + * Load the nodes for lexical. + */ +export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { + return [ + CalloutNode, + CustomHeadingNode, + CustomQuoteNode, + CustomListNode, + CustomListItemNode, // TODO - Alignment? + CustomTableNode, + CustomTableRowNode, + CustomTableCellNode, + ImageNode, // TODO - Alignment + HorizontalRuleNode, + DetailsNode, SummaryNode, + CodeBlockNode, + DiagramNode, + MediaNode, // TODO - Alignment + CustomParagraphNode, + LinkNode, + { + replace: ParagraphNode, + with: (node: ParagraphNode) => { + return new CustomParagraphNode(); + } + }, + { + replace: HeadingNode, + with: (node: HeadingNode) => { + return new CustomHeadingNode(node.__tag); + } + }, + { + replace: QuoteNode, + with: (node: QuoteNode) => { + return new CustomQuoteNode(); + } + }, + { + replace: ListNode, + with: (node: ListNode) => { + return new CustomListNode(node.getListType(), node.getStart()); + } + }, + { + replace: ListItemNode, + with: (node: ListItemNode) => { + return new CustomListItemNode(node.__value, node.__checked); + } + }, + { + replace: TableNode, + with(node: TableNode) { + return new CustomTableNode(); + } + }, + { + replace: TableRowNode, + with(node: TableRowNode) { + return new CustomTableRowNode(); + } + }, + { + replace: TableCellNode, + with: (node: TableCellNode) => { + const cell = new CustomTableCellNode( + node.__headerState, + node.__colSpan, + node.__width, + ); + cell.__rowSpan = node.__rowSpan; + return cell; + } + }, + ]; +} + +export function registerCommonNodeMutationListeners(context: EditorUiContext): void { + const decorated = [ImageNode, CodeBlockNode, DiagramNode]; + + const decorationDestroyListener = (mutations: Map): void => { + for (let [nodeKey, mutation] of mutations) { + if (mutation === "destroyed") { + const decorator = context.manager.getDecoratorByNodeKey(nodeKey); + if (decorator) { + decorator.destroy(context); + } + } + } + }; + + for (let decoratedNode of decorated) { + // Have to pass a unique function here since they are stored by lexical keyed on listener function. + context.editor.registerMutationListener(decoratedNode, (mutations) => decorationDestroyListener(mutations)); + } +} + +export type LexicalNodeMatcher = (node: LexicalNode|null|undefined) => boolean; +export type LexicalElementNodeCreator = () => ElementNode; \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts new file mode 100644 index 000000000..64fe8f77b --- /dev/null +++ b/resources/js/wysiwyg/nodes/media.ts @@ -0,0 +1,372 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, DOMExportOutput, + ElementNode, + LexicalEditor, + LexicalNode, + Spread +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; + +import {el, setOrRemoveAttribute, sizeToPixels} from "../utils/dom"; +import { + CommonBlockAlignment, deserializeCommonBlockNode, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; +import {$selectSingleNode} from "../utils/selection"; + +export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; +export type MediaNodeSource = { + src: string; + type: string; +}; + +export type SerializedMediaNode = Spread<{ + tag: MediaNodeTag; + attributes: Record; + sources: MediaNodeSource[]; +}, SerializedCommonBlockNode> + +const attributeAllowList = [ + 'width', 'height', 'style', 'title', 'name', + 'src', 'allow', 'allowfullscreen', 'loading', 'sandbox', + 'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop', + 'muted', 'playsinline', 'poster', 'preload' +]; + +function filterAttributes(attributes: Record): Record { + const filtered: Record = {}; + for (const key of Object.keys(attributes)) { + if (attributeAllowList.includes(key)) { + filtered[key] = attributes[key]; + } + } + return filtered; +} + +function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode { + const node = $createMediaNode(tag); + + const attributes: Record = {}; + for (const attribute of element.attributes) { + attributes[attribute.name] = attribute.value; + } + node.setAttributes(attributes); + + const sources: MediaNodeSource[] = []; + if (tag === 'video' || tag === 'audio') { + for (const child of element.children) { + if (child.tagName === 'SOURCE') { + const src = child.getAttribute('src'); + const type = child.getAttribute('type'); + if (src && type) { + sources.push({ src, type }); + } + } + } + node.setSources(sources); + } + + setCommonBlockPropsFromElement(element, node); + + return node; +} + +export class MediaNode extends ElementNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; + __tag: MediaNodeTag; + __attributes: Record = {}; + __sources: MediaNodeSource[] = []; + __inset: number = 0; + + static getType() { + return 'media'; + } + + static clone(node: MediaNode) { + const newNode = new MediaNode(node.__tag, node.__key); + newNode.__attributes = Object.assign({}, node.__attributes); + newNode.__sources = node.__sources.map(s => Object.assign({}, s)); + newNode.__id = node.__id; + newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; + return newNode; + } + + constructor(tag: MediaNodeTag, key?: string) { + super(key); + this.__tag = tag; + } + + setTag(tag: MediaNodeTag) { + const self = this.getWritable(); + self.__tag = tag; + } + + getTag(): MediaNodeTag { + const self = this.getLatest(); + return self.__tag; + } + + setAttributes(attributes: Record) { + const self = this.getWritable(); + self.__attributes = filterAttributes(attributes); + } + + getAttributes(): Record { + const self = this.getLatest(); + return self.__attributes; + } + + setSources(sources: MediaNodeSource[]) { + const self = this.getWritable(); + self.__sources = sources; + } + + getSources(): MediaNodeSource[] { + const self = this.getLatest(); + return self.__sources; + } + + setSrc(src: string): void { + const attrs = Object.assign({}, this.getAttributes()); + if (this.__tag ==='object') { + attrs.data = src; + } else { + attrs.src = src; + } + this.setAttributes(attrs); + } + + setWidthAndHeight(width: string, height: string): void { + const attrs = Object.assign( + {}, + this.getAttributes(), + {width, height}, + ); + this.setAttributes(attrs); + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + setHeight(height: number): void { + if (!height) { + return; + } + + const attrs = Object.assign({}, this.getAttributes(), {height}); + this.setAttributes(attrs); + } + + getHeight(): number { + const self = this.getLatest(); + return sizeToPixels(self.__attributes.height || '0'); + } + + setWidth(width: number): void { + const attrs = Object.assign({}, this.getAttributes(), {width}); + this.setAttributes(attrs); + } + + getWidth(): number { + const self = this.getLatest(); + return sizeToPixels(self.__attributes.width || '0'); + } + + isInline(): boolean { + return true; + } + + isParentRequired(): boolean { + return true; + } + + createInnerDOM() { + const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; + const sourceEls = sources.map(source => el('source', source)); + const element = el(this.__tag, this.__attributes, sourceEls); + updateElementWithCommonBlockProps(element, this); + return element; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const media = this.createInnerDOM(); + const wrap = el('span', { + class: media.className + ' editor-media-wrap', + }, [media]); + + wrap.addEventListener('click', e => { + _editor.update(() => $selectSingleNode(this)); + }); + + return wrap; + } + + updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean { + if (prevNode.__tag !== this.__tag) { + return true; + } + + if (JSON.stringify(prevNode.__sources) !== JSON.stringify(this.__sources)) { + return true; + } + + if (JSON.stringify(prevNode.__attributes) !== JSON.stringify(this.__attributes)) { + return true; + } + + const mediaEl = dom.firstElementChild as HTMLElement; + + if (prevNode.__id !== this.__id) { + setOrRemoveAttribute(mediaEl, 'id', this.__id); + } + + if (prevNode.__alignment !== this.__alignment) { + if (prevNode.__alignment) { + dom.classList.remove(`align-${prevNode.__alignment}`); + mediaEl.classList.remove(`align-${prevNode.__alignment}`); + } + if (this.__alignment) { + dom.classList.add(`align-${this.__alignment}`); + mediaEl.classList.add(`align-${this.__alignment}`); + } + } + + if (prevNode.__inset !== this.__inset) { + dom.style.paddingLeft = `${this.__inset}px`; + } + + return false; + } + + static importDOM(): DOMConversionMap|null { + + const buildConverter = (tag: MediaNodeTag) => { + return (node: HTMLElement): DOMConversion|null => { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return { + node: domElementToNode(tag, element), + }; + }, + priority: 3, + }; + }; + }; + + return { + iframe: buildConverter('iframe'), + embed: buildConverter('embed'), + object: buildConverter('object'), + video: buildConverter('video'), + audio: buildConverter('audio'), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createInnerDOM(); + return { element }; + } + + exportJSON(): SerializedMediaNode { + return { + ...super.exportJSON(), + type: 'media', + version: 1, + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + tag: this.__tag, + attributes: this.__attributes, + sources: this.__sources, + }; + } + + static importJSON(serializedNode: SerializedMediaNode): MediaNode { + const node = $createMediaNode(serializedNode.tag); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + +} + +export function $createMediaNode(tag: MediaNodeTag) { + return new MediaNode(tag); +} + +export function $createMediaNodeFromHtml(html: string): MediaNode | null { + const parser = new DOMParser(); + const doc = parser.parseFromString(`${html}`, 'text/html'); + + const el = doc.body.children[0]; + if (!(el instanceof HTMLElement)) { + return null; + } + + const tag = el.tagName.toLowerCase(); + const validTypes = ['embed', 'iframe', 'video', 'audio', 'object']; + if (!validTypes.includes(tag)) { + return null; + } + + return domElementToNode(tag as MediaNodeTag, el); +} + +const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov']; +const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm']; +const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', '']; + +export function $createMediaNodeFromSrc(src: string): MediaNode { + let nodeTag: MediaNodeTag = 'iframe'; + const srcEnd = src.split('?')[0].split('/').pop() || ''; + const srcEndSplit = srcEnd.split('.'); + const extension = (srcEndSplit.length > 1 ? srcEndSplit[srcEndSplit.length - 1] : '').toLowerCase(); + if (videoExtensions.includes(extension)) { + nodeTag = 'video'; + } else if (audioExtensions.includes(extension)) { + nodeTag = 'audio'; + } else if (extension && !iframeExtensions.includes(extension)) { + nodeTag = 'embed'; + } + + return new MediaNode(nodeTag); +} + +export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode { + return node instanceof MediaNode; +} + +export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean { + return node instanceof MediaNode && (node as MediaNode).getTag() === tag; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts new file mode 100644 index 000000000..16522d66b --- /dev/null +++ b/resources/js/wysiwyg/services/common-events.ts @@ -0,0 +1,43 @@ +import {LexicalEditor} from "lexical"; +import { + appendHtmlToEditor, + focusEditor, + insertHtmlIntoEditor, + prependHtmlToEditor, + setEditorContentFromHtml +} from "../utils/actions"; + +type EditorEventContent = { + html: string; + markdown: string; +}; + +function getContentToInsert(eventContent: EditorEventContent): string { + return eventContent.html || ''; +} + +export function listen(editor: LexicalEditor): void { + window.$events.listen('editor::replace', eventContent => { + const html = getContentToInsert(eventContent); + setEditorContentFromHtml(editor, html); + }); + + window.$events.listen('editor::append', eventContent => { + const html = getContentToInsert(eventContent); + appendHtmlToEditor(editor, html); + }); + + window.$events.listen('editor::prepend', eventContent => { + const html = getContentToInsert(eventContent); + prependHtmlToEditor(editor, html); + }); + + window.$events.listen('editor::insert', eventContent => { + const html = getContentToInsert(eventContent); + insertHtmlIntoEditor(editor, html); + }); + + window.$events.listen('editor::focus', () => { + focusEditor(editor); + }); +} diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts new file mode 100644 index 000000000..07e35d443 --- /dev/null +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -0,0 +1,163 @@ +import { + $insertNodes, + $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND, + LexicalEditor, + LexicalNode, PASTE_COMMAND +} from "lexical"; +import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; +import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; +import {Clipboard} from "../../services/clipboard"; +import {$createImageNode} from "../nodes/image"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$createLinkNode} from "@lexical/link"; +import {EditorImageData, uploadImageFile} from "../utils/images"; +import {EditorUiContext} from "../ui/framework/core"; + +function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null { + const x = event.clientX; + const y = event.clientY; + const dom = document.elementFromPoint(x, y); + if (!dom) { + return null; + } + + return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY); +} + +function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) { + const positionNode = $getNodeFromMouseEvent(event, editor); + + if (positionNode) { + $selectSingleNode(positionNode); + } + + $insertNewBlockNodesAtSelection(nodes, true); + + if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) { + positionNode?.remove(); + } +} + +async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, event: DragEvent) { + const resp = await window.$http.get(`/templates/${templateId}`); + const data = (resp.data || {html: ''}) as {html: string} + const html: string = data.html || ''; + + editor.update(() => { + const newNodes = $htmlToBlockNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); +} + +function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean { + const clipboard = new Clipboard(data); + let handled = false; + + // Don't handle the event ourselves if no items exist of contains table-looking data + if (!clipboard.hasItems() || clipboard.containsTabularData()) { + return handled; + } + + const images = clipboard.getImages(); + if (images.length > 0) { + handled = true; + } + + context.editor.update(async () => { + for (const imageFile of images) { + const loadingImage = window.baseUrl('/loading.gif'); + const loadingNode = $createImageNode(loadingImage); + const imageWrap = $createCustomParagraphNode(); + imageWrap.append(loadingNode); + $insertNodes([imageWrap]); + + try { + const respData: EditorImageData = await uploadImageFile(imageFile, context.options.pageId); + const safeName = respData.name.replace(/"/g, ''); + context.editor.update(() => { + const finalImage = $createImageNode(respData.thumbs?.display || '', { + alt: safeName, + }); + const imageLink = $createLinkNode(respData.url, {target: '_blank'}); + imageLink.append(finalImage); + loadingNode.replace(imageLink); + }); + } catch (err: any) { + context.editor.update(() => { + loadingNode.remove(false); + }); + window.$events.error(err?.data?.message || context.options.translations.imageUploadErrorText); + console.error(err); + } + } + }); + + return handled; +} + +function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean { + const editor = context.editor; + return (event: DragEvent): boolean => { + // Template handling + const templateId = event.dataTransfer?.getData('bookstack/template') || ''; + if (templateId) { + insertTemplateToEditor(editor, templateId, event); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + // HTML contents drop + const html = event.dataTransfer?.getData('text/html') || ''; + if (html) { + editor.update(() => { + const newNodes = $htmlToBlockNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + if (event.dataTransfer) { + const handled = handleMediaInsert(event.dataTransfer, context); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + } + + return false; + }; +} + +function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) => boolean { + return (event: ClipboardEvent) => { + if (!event.clipboardData) { + return false; + } + + const handled = handleMediaInsert(event.clipboardData, context); + if (handled) { + event.preventDefault(); + } + + return handled; + }; +} + +export function registerDropPasteHandling(context: EditorUiContext): () => void { + const dropListener = createDropListener(context); + const pasteListener = createPasteListener(context); + + const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH); + const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH); + context.scrollDOM.addEventListener('drop', dropListener); + + return () => { + unregisterDrop(); + unregisterPaste(); + context.scrollDOM.removeEventListener('drop', dropListener); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts new file mode 100644 index 000000000..2c7bfdbba --- /dev/null +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -0,0 +1,101 @@ +import {EditorUiContext} from "../ui/framework/core"; +import { + $getSelection, + $isDecoratorNode, + COMMAND_PRIORITY_LOW, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, KEY_TAB_COMMAND, + LexicalEditor, + LexicalNode +} from "lexical"; +import {$isImageNode} from "../nodes/image"; +import {$isMediaNode} from "../nodes/media"; +import {getLastSelection} from "../utils/selection"; +import {$getNearestNodeBlockParent} from "../utils/nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$isCustomListItemNode} from "../nodes/custom-list-item"; +import {$setInsetForSelection} from "../utils/lists"; + +function isSingleSelectedNode(nodes: LexicalNode[]): boolean { + if (nodes.length === 1) { + const node = nodes[0]; + if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) { + return true; + } + } + + return false; +} + +function deleteSingleSelectedNode(editor: LexicalEditor) { + const selectionNodes = getLastSelection(editor)?.getNodes() || []; + if (isSingleSelectedNode(selectionNodes)) { + editor.update(() => { + selectionNodes[0].remove(); + }); + } +} + +function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const selectionNodes = getLastSelection(editor)?.getNodes() || []; + if (isSingleSelectedNode(selectionNodes)) { + const node = selectionNodes[0]; + const nearestBlock = $getNearestNodeBlockParent(node) || node; + if (nearestBlock) { + requestAnimationFrame(() => { + editor.update(() => { + const newParagraph = $createCustomParagraphNode(); + nearestBlock.insertAfter(newParagraph); + newParagraph.select(); + }); + }); + event?.preventDefault(); + return true; + } + } + + return false; +} + +function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const change = event?.shiftKey ? -40 : 40; + const selection = $getSelection(); + const nodes = selection?.getNodes() || []; + if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) { + editor.update(() => { + $setInsetForSelection(editor, change); + }); + event?.preventDefault(); + return true; + } + + return false; +} + +export function registerKeyboardHandling(context: EditorUiContext): () => void { + const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => { + deleteSingleSelectedNode(context.editor); + return false; + }, COMMAND_PRIORITY_LOW); + + const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => { + deleteSingleSelectedNode(context.editor); + return false; + }, COMMAND_PRIORITY_LOW); + + const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { + return insertAfterSingleSelectedNode(context.editor, event); + }, COMMAND_PRIORITY_LOW); + + const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { + return handleInsetOnTab(context.editor, event); + }, COMMAND_PRIORITY_LOW); + + return () => { + unregisterBackspace(); + unregisterDelete(); + unregisterEnter(); + unregisterTab(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts new file mode 100644 index 000000000..b17ec1bf7 --- /dev/null +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -0,0 +1,112 @@ +import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; +import { + cycleSelectionCalloutFormats, + formatCodeBlock, insertOrUpdateLink, + toggleSelectionAsBlockquote, + toggleSelectionAsHeading, toggleSelectionAsList, + toggleSelectionAsParagraph +} from "../utils/formats"; +import {HeadingTagType} from "@lexical/rich-text"; +import {EditorUiContext} from "../ui/framework/core"; +import {$getNodeFromSelection} from "../utils/selection"; +import {$isLinkNode, LinkNode} from "@lexical/link"; +import {$showLinkForm} from "../ui/defaults/forms/objects"; +import {showLinkSelector} from "../utils/links"; + +function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { + toggleSelectionAsHeading(editor, tag); + return true; +} + +function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction { + return (editor: LexicalEditor) => { + formatAction(editor); + return true; + }; +} + +function toggleInlineCode(editor: LexicalEditor): boolean { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); + return true; +} + +type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boolean; + +const actionsByKeys: Record = { + 'ctrl+s': () => { + window.$events.emit('editor-save-draft'); + return true; + }, + 'ctrl+enter': () => { + window.$events.emit('editor-save-page'); + return true; + }, + 'ctrl+1': (editor) => headerHandler(editor, 'h1'), + 'ctrl+2': (editor) => headerHandler(editor, 'h2'), + 'ctrl+3': (editor) => headerHandler(editor, 'h3'), + 'ctrl+4': (editor) => headerHandler(editor, 'h4'), + 'ctrl+5': wrapFormatAction(toggleSelectionAsParagraph), + 'ctrl+d': wrapFormatAction(toggleSelectionAsParagraph), + 'ctrl+6': wrapFormatAction(toggleSelectionAsBlockquote), + 'ctrl+q': wrapFormatAction(toggleSelectionAsBlockquote), + 'ctrl+7': wrapFormatAction(formatCodeBlock), + 'ctrl+e': wrapFormatAction(formatCodeBlock), + 'ctrl+8': toggleInlineCode, + 'ctrl+shift+e': toggleInlineCode, + 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats), + + 'ctrl+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), + 'ctrl+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), + 'ctrl+k': (editor, context) => { + editor.getEditorState().read(() => { + const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; + $showLinkForm(selectedLink, context); + }); + return true; + }, + 'ctrl+shift+k': (editor, context) => { + showLinkSelector(entity => { + insertOrUpdateLink(editor, { + text: entity.name, + title: entity.link, + target: '', + url: entity.link, + }); + }); + return true; + }, +}; + +function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void { + return (event: KeyboardEvent) => { + // TODO - Mac Cmd support + const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase(); + // console.log(`pressed: ${combo}`); + if (actionsByKeys[combo]) { + const handled = actionsByKeys[combo](context.editor, context); + if (handled) { + event.stopPropagation(); + event.preventDefault(); + } + } + }; +} + +function overrideDefaultCommands(editor: LexicalEditor) { + // Prevent default ctrl+enter command + editor.registerCommand(KEY_ENTER_COMMAND, (event) => { + return event?.ctrlKey ? true : false + }, COMMAND_PRIORITY_HIGH); +} + +export function registerShortcuts(context: EditorUiContext) { + const listener = createKeyDownListener(context); + overrideDefaultCommands(context.editor); + + return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { + // add the listener to the current root element + rootElement?.addEventListener('keydown', listener); + // remove the listener from the old root element + prevRootElement?.removeEventListener('keydown', listener); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md new file mode 100644 index 000000000..bcd4851e8 --- /dev/null +++ b/resources/js/wysiwyg/todo.md @@ -0,0 +1,23 @@ +# Lexical based editor todo + +## In progress + +// + +## Main Todo + +- Mac: Shortcut support via command. + +## Secondary Todo + +- Color picker support in table form color fields +- Color picker for color controls +- Table caption text support +- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) +- Deep check of translation coverage +- About button & view +- Mobile display and handling + +## Bugs + +- List selection can get lost on nesting/unnesting \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts new file mode 100644 index 000000000..37d3df588 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -0,0 +1,99 @@ +import {EditorDecorator} from "../framework/decorator"; +import {EditorUiContext} from "../framework/core"; +import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; +import {$isDecoratorNode, BaseSelection} from "lexical"; +import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; + + +export class CodeBlockDecorator extends EditorDecorator { + + protected completedSetup: boolean = false; + protected latestCode: string = ''; + protected latestLanguage: string = ''; + + // @ts-ignore + protected editor: any = null; + + setup(context: EditorUiContext, element: HTMLElement) { + const codeNode = this.getNode() as CodeBlockNode; + const preEl = element.querySelector('pre'); + if (!preEl) { + return; + } + + if (preEl) { + preEl.hidden = true; + } + + this.latestCode = codeNode.__code; + this.latestLanguage = codeNode.__language; + const lines = this.latestCode.split('\n').length; + const height = (lines * 19.2) + 18 + 24; + element.style.height = `${height}px`; + + const startTime = Date.now(); + + element.addEventListener('click', event => { + requestAnimationFrame(() => { + context.editor.update(() => { + $selectSingleNode(this.getNode()); + }); + }); + }); + + element.addEventListener('dblclick', event => { + context.editor.getEditorState().read(() => { + $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); + }); + }); + + const selectionChange = (selection: BaseSelection|null): void => { + element.classList.toggle('selected', $selectionContainsNode(selection, codeNode)); + }; + context.manager.onSelectionChange(selectionChange); + this.onDestroy(() => { + context.manager.offSelectionChange(selectionChange); + }); + + // @ts-ignore + const renderEditor = (Code) => { + this.editor = Code.wysiwygView(element, document, this.latestCode, this.latestLanguage); + setTimeout(() => { + element.style.height = ''; + }, 12); + }; + + // @ts-ignore + window.importVersioned('code').then((Code) => { + const timeout = (Date.now() - startTime < 20) ? 20 : 0; + setTimeout(() => renderEditor(Code), timeout); + }); + + this.completedSetup = true; + } + + update() { + const codeNode = this.getNode() as CodeBlockNode; + const code = codeNode.getCode(); + const language = codeNode.getLanguage(); + + if (this.latestCode === code && this.latestLanguage === language) { + return; + } + this.latestLanguage = language; + this.latestCode = code; + + if (this.editor) { + this.editor.setContent(code); + this.editor.setMode(language, code); + } + } + + render(context: EditorUiContext, element: HTMLElement): void { + if (this.completedSetup) { + this.update(); + } else { + this.setup(context, element); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts new file mode 100644 index 000000000..44d332939 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -0,0 +1,49 @@ +import {EditorDecorator} from "../framework/decorator"; +import {EditorUiContext} from "../framework/core"; +import {BaseSelection} from "lexical"; +import {DiagramNode} from "../../nodes/diagram"; +import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; +import {$openDrawingEditorForNode} from "../../utils/diagrams"; + + +export class DiagramDecorator extends EditorDecorator { + protected completedSetup: boolean = false; + + setup(context: EditorUiContext, element: HTMLElement) { + const diagramNode = this.getNode(); + element.classList.add('editor-diagram'); + element.addEventListener('click', event => { + context.editor.update(() => { + $selectSingleNode(this.getNode()); + }) + }); + + element.addEventListener('dblclick', event => { + context.editor.getEditorState().read(() => { + $openDrawingEditorForNode(context, (this.getNode() as DiagramNode)); + }); + }); + + const selectionChange = (selection: BaseSelection|null): void => { + element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode)); + }; + context.manager.onSelectionChange(selectionChange); + this.onDestroy(() => { + context.manager.offSelectionChange(selectionChange); + }); + + this.completedSetup = true; + } + + update() { + // + } + + render(context: EditorUiContext, element: HTMLElement): void { + if (this.completedSetup) { + this.update(); + } else { + this.setup(context, element); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts new file mode 100644 index 000000000..f0f46ddc6 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -0,0 +1,117 @@ +import {$isElementNode, BaseSelection} from "lexical"; +import {EditorButtonDefinition} from "../../framework/buttons"; +import alignLeftIcon from "@icons/editor/align-left.svg"; +import {EditorUiContext} from "../../framework/core"; +import alignCenterIcon from "@icons/editor/align-center.svg"; +import alignRightIcon from "@icons/editor/align-right.svg"; +import alignJustifyIcon from "@icons/editor/align-justify.svg"; +import ltrIcon from "@icons/editor/direction-ltr.svg"; +import rtlIcon from "@icons/editor/direction-rtl.svg"; +import { + $getBlockElementNodesInSelection, + $selectionContainsAlignment, $selectionContainsDirection, $selectSingleNode, $toggleSelection, getLastSelection +} from "../../../utils/selection"; +import {CommonBlockAlignment} from "../../../nodes/_common"; +import {nodeHasAlignment} from "../../../utils/nodes"; + + +function setAlignmentForSelection(context: EditorUiContext, alignment: CommonBlockAlignment): void { + const selection = getLastSelection(context.editor); + const selectionNodes = selection?.getNodes() || []; + + // Handle inline node selection alignment + if (selectionNodes.length === 1 && $isElementNode(selectionNodes[0]) && selectionNodes[0].isInline() && nodeHasAlignment(selectionNodes[0])) { + selectionNodes[0].setAlignment(alignment); + $selectSingleNode(selectionNodes[0]); + context.manager.triggerFutureStateRefresh(); + return; + } + + // Handle normal block/range alignment + const elements = $getBlockElementNodesInSelection(selection); + const alignmentNodes = elements.filter(n => nodeHasAlignment(n)); + const allAlreadyAligned = alignmentNodes.every(n => n.getAlignment() === alignment); + const newAlignment = allAlreadyAligned ? '' : alignment; + for (const node of alignmentNodes) { + node.setAlignment(newAlignment); + } + + context.manager.triggerFutureStateRefresh(); +} + +function setDirectionForSelection(context: EditorUiContext, direction: 'ltr' | 'rtl'): void { + const selection = getLastSelection(context.editor); + + const elements = $getBlockElementNodesInSelection(selection); + for (const node of elements) { + node.setDirection(direction); + } + + context.manager.triggerFutureStateRefresh(); +} + +export const alignLeft: EditorButtonDefinition = { + label: 'Align left', + icon: alignLeftIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSelection(context, 'left')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsAlignment(selection, 'left'); + } +}; + +export const alignCenter: EditorButtonDefinition = { + label: 'Align center', + icon: alignCenterIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSelection(context, 'center')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsAlignment(selection, 'center'); + } +}; + +export const alignRight: EditorButtonDefinition = { + label: 'Align right', + icon: alignRightIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSelection(context, 'right')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsAlignment(selection, 'right'); + } +}; + +export const alignJustify: EditorButtonDefinition = { + label: 'Justify', + icon: alignJustifyIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSelection(context, 'justify')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsAlignment(selection, 'justify'); + } +}; + +export const directionLTR: EditorButtonDefinition = { + label: 'Left to right', + icon: ltrIcon, + action(context: EditorUiContext) { + context.editor.update(() => setDirectionForSelection(context, 'ltr')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsDirection(selection, 'ltr'); + } +}; + +export const directionRTL: EditorButtonDefinition = { + label: 'Right to left', + icon: rtlIcon, + action(context: EditorUiContext) { + context.editor.update(() => setDirectionForSelection(context, 'rtl')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsDirection(selection, 'rtl'); + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts new file mode 100644 index 000000000..f86e33c31 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -0,0 +1,79 @@ +import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; +import {EditorButtonDefinition} from "../../framework/buttons"; +import {EditorUiContext} from "../../framework/core"; +import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; +import { + $isHeadingNode, + $isQuoteNode, + HeadingNode, + HeadingTagType +} from "@lexical/rich-text"; +import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection"; +import { + toggleSelectionAsBlockquote, + toggleSelectionAsHeading, + toggleSelectionAsParagraph +} from "../../../utils/formats"; + +function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { + return { + label: name, + action(context: EditorUiContext) { + context.editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => $isCalloutNodeOfCategory(node, category), + () => $createCalloutNode(category), + ) + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category)); + } + }; +} + +export const infoCallout: EditorButtonDefinition = buildCalloutButton('info', 'Info'); +export const dangerCallout: EditorButtonDefinition = buildCalloutButton('danger', 'Danger'); +export const warningCallout: EditorButtonDefinition = buildCalloutButton('warning', 'Warning'); +export const successCallout: EditorButtonDefinition = buildCalloutButton('success', 'Success'); + +const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { + return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag; +}; + +function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { + return { + label: name, + action(context: EditorUiContext) { + toggleSelectionAsHeading(context.editor, tag); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); + } + }; +} + +export const h2: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header'); +export const h3: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header'); +export const h4: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header'); +export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header'); + +export const blockquote: EditorButtonDefinition = { + label: 'Blockquote', + action(context: EditorUiContext) { + toggleSelectionAsBlockquote(context.editor); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isQuoteNode); + } +}; + +export const paragraph: EditorButtonDefinition = { + label: 'Paragraph', + action(context: EditorUiContext) { + toggleSelectionAsParagraph(context.editor); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isParagraphNode); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts new file mode 100644 index 000000000..77223dac3 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -0,0 +1,83 @@ +import {EditorButton, EditorButtonDefinition} from "../../framework/buttons"; +import undoIcon from "@icons/editor/undo.svg"; +import {EditorUiContext} from "../../framework/core"; +import { + BaseSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + REDO_COMMAND, + UNDO_COMMAND +} from "lexical"; +import redoIcon from "@icons/editor/redo.svg"; +import sourceIcon from "@icons/editor/source-view.svg"; +import {getEditorContentAsHtml} from "../../../utils/actions"; +import fullscreenIcon from "@icons/editor/fullscreen.svg"; + +export const undo: EditorButtonDefinition = { + label: 'Undo', + icon: undoIcon, + action(context: EditorUiContext) { + context.editor.dispatchCommand(UNDO_COMMAND, undefined); + context.manager.triggerFutureStateRefresh(); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + }, + setup(context: EditorUiContext, button: EditorButton) { + button.toggleDisabled(true); + + context.editor.registerCommand(CAN_UNDO_COMMAND, (payload: boolean): boolean => { + button.toggleDisabled(!payload) + return false; + }, COMMAND_PRIORITY_LOW); + } +} + +export const redo: EditorButtonDefinition = { + label: 'Redo', + icon: redoIcon, + action(context: EditorUiContext) { + context.editor.dispatchCommand(REDO_COMMAND, undefined); + context.manager.triggerFutureStateRefresh(); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + }, + setup(context: EditorUiContext, button: EditorButton) { + button.toggleDisabled(true); + + context.editor.registerCommand(CAN_REDO_COMMAND, (payload: boolean): boolean => { + button.toggleDisabled(!payload) + return false; + }, COMMAND_PRIORITY_LOW); + } +} + + +export const source: EditorButtonDefinition = { + label: 'Source', + icon: sourceIcon, + async action(context: EditorUiContext) { + const modal = context.manager.createModal('source'); + const source = await getEditorContentAsHtml(context.editor); + modal.show({source}); + }, + isActive() { + return false; + } +}; + +export const fullscreen: EditorButtonDefinition = { + label: 'Fullscreen', + icon: fullscreenIcon, + async action(context: EditorUiContext, button: EditorButton) { + const isFullScreen = context.containerDOM.classList.contains('fullscreen'); + context.containerDOM.classList.toggle('fullscreen', !isFullScreen); + (context.containerDOM.closest('body') as HTMLElement).classList.toggle('editor-is-fullscreen', !isFullScreen); + button.setActiveState(!isFullScreen); + }, + isActive(selection, context: EditorUiContext) { + return context.containerDOM.classList.contains('fullscreen'); + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts new file mode 100644 index 000000000..c3726acf0 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -0,0 +1,56 @@ +import {$getSelection, $isTextNode, BaseSelection, FORMAT_TEXT_COMMAND, TextFormatType} from "lexical"; +import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../../framework/buttons"; +import {EditorUiContext} from "../../framework/core"; +import boldIcon from "@icons/editor/bold.svg"; +import italicIcon from "@icons/editor/italic.svg"; +import underlinedIcon from "@icons/editor/underlined.svg"; +import textColorIcon from "@icons/editor/text-color.svg"; +import highlightIcon from "@icons/editor/highlighter.svg"; +import strikethroughIcon from "@icons/editor/strikethrough.svg"; +import superscriptIcon from "@icons/editor/superscript.svg"; +import subscriptIcon from "@icons/editor/subscript.svg"; +import codeIcon from "@icons/editor/code.svg"; +import formatClearIcon from "@icons/editor/format-clear.svg"; +import {$selectionContainsTextFormat} from "../../../utils/selection"; + +function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { + return { + label: label, + icon, + action(context: EditorUiContext) { + context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsTextFormat(selection, format); + } + }; +} + +export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon); +export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); +export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); +export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; + +export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); +export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); +export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); +export const code: EditorButtonDefinition = buildFormatButton('Inline code', 'code', codeIcon); +export const clearFormating: EditorButtonDefinition = { + label: 'Clear formatting', + icon: formatClearIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + for (const node of selection?.getNodes() || []) { + if ($isTextNode(node)) { + node.setFormat(0); + node.setStyle(''); + } + } + }); + }, + isActive() { + return false; + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts new file mode 100644 index 000000000..9cfa8168e --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -0,0 +1,63 @@ +import {$isListNode, ListNode, ListType} from "@lexical/list"; +import {EditorButtonDefinition} from "../../framework/buttons"; +import {EditorUiContext} from "../../framework/core"; +import { + BaseSelection, + LexicalNode, +} from "lexical"; +import listBulletIcon from "@icons/editor/list-bullet.svg"; +import listNumberedIcon from "@icons/editor/list-numbered.svg"; +import listCheckIcon from "@icons/editor/list-check.svg"; +import indentIncreaseIcon from "@icons/editor/indent-increase.svg"; +import indentDecreaseIcon from "@icons/editor/indent-decrease.svg"; +import { + $selectionContainsNodeType, +} from "../../../utils/selection"; +import {toggleSelectionAsList} from "../../../utils/formats"; +import {$setInsetForSelection} from "../../../utils/lists"; + + +function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { + return { + label, + icon, + action(context: EditorUiContext) { + toggleSelectionAsList(context.editor, type); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $isListNode(node) && (node as ListNode).getListType() === type; + }); + } + }; +} + +export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon); +export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); +export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); + +export const indentIncrease: EditorButtonDefinition = { + label: 'Increase indent', + icon: indentIncreaseIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $setInsetForSelection(context.editor, 40); + }); + }, + isActive() { + return false; + } +}; + +export const indentDecrease: EditorButtonDefinition = { + label: 'Decrease indent', + icon: indentDecreaseIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $setInsetForSelection(context.editor, -40); + }); + }, + isActive() { + return false; + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts new file mode 100644 index 000000000..fd95f9f35 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -0,0 +1,220 @@ +import {EditorButtonDefinition} from "../../framework/buttons"; +import linkIcon from "@icons/editor/link.svg"; +import {EditorUiContext} from "../../framework/core"; +import { + $createTextNode, + $getRoot, + $getSelection, $insertNodes, + BaseSelection, + ElementNode, isCurrentlyReadOnlyMode +} from "lexical"; +import {$isLinkNode, LinkNode} from "@lexical/link"; +import unlinkIcon from "@icons/editor/unlink.svg"; +import imageIcon from "@icons/editor/image.svg"; +import {$isImageNode, ImageNode} from "../../../nodes/image"; +import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; +import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; +import codeBlockIcon from "@icons/editor/code-block.svg"; +import {$isCodeBlockNode} from "../../../nodes/code-block"; +import editIcon from "@icons/edit.svg"; +import diagramIcon from "@icons/editor/diagram.svg"; +import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; +import detailsIcon from "@icons/editor/details.svg"; +import mediaIcon from "@icons/editor/media.svg"; +import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details"; +import {$isMediaNode, MediaNode} from "../../../nodes/media"; +import { + $getNodeFromSelection, + $insertNewBlockNodeAtSelection, + $selectionContainsNodeType, getLastSelection +} from "../../../utils/selection"; +import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; +import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; +import {$showImageForm, $showLinkForm} from "../forms/objects"; +import {formatCodeBlock} from "../../../utils/formats"; + +export const link: EditorButtonDefinition = { + label: 'Insert/edit link', + icon: linkIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; + $showLinkForm(selectedLink, context); + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isLinkNode); + } +}; + +export const unlink: EditorButtonDefinition = { + label: 'Remove link', + icon: unlinkIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = getLastSelection(context.editor); + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; + + if (selectedLink) { + const contents = selectedLink.getChildren().reverse(); + for (const child of contents) { + selectedLink.insertAfter(child); + } + selectedLink.remove(); + + contents[contents.length - 1].selectStart(); + + context.manager.triggerFutureStateRefresh(); + } + }); + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +}; + + +export const image: EditorButtonDefinition = { + label: 'Insert/Edit Image', + icon: imageIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = getLastSelection(context.editor); + const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null; + if (selectedImage) { + $showImageForm(selectedImage, context); + return; + } + + showImageManager((image) => { + context.editor.update(() => { + const link = $createLinkedImageNodeFromImageData(image); + $insertNodes([link]); + }); + }) + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isImageNode); + } +}; + +export const horizontalRule: EditorButtonDefinition = { + label: 'Insert horizontal line', + icon: horizontalRuleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isHorizontalRuleNode); + } +}; + +export const codeBlock: EditorButtonDefinition = { + label: 'Insert code block', + icon: codeBlockIcon, + action(context: EditorUiContext) { + formatCodeBlock(context.editor); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isCodeBlockNode); + } +}; + +export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, { + label: 'Edit code block', + icon: editIcon, +}); + +export const diagram: EditorButtonDefinition = { + label: 'Insert/edit drawing', + icon: diagramIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = getLastSelection(context.editor); + const diagramNode = $getNodeFromSelection(selection, $isDiagramNode) as (DiagramNode | null); + if (diagramNode === null) { + context.editor.update(() => { + const diagram = $createDiagramNode(); + $insertNewBlockNodeAtSelection(diagram, true); + $openDrawingEditorForNode(context, diagram); + diagram.selectStart(); + }); + } else { + $openDrawingEditorForNode(context, diagramNode); + } + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isDiagramNode); + } +}; + +export const diagramManager: EditorButtonDefinition = { + label: 'Drawing manager', + action(context: EditorUiContext) { + showDiagramManagerForInsert(context); + }, + isActive(): boolean { + return false; + } +}; + +export const media: EditorButtonDefinition = { + label: 'Insert/edit Media', + icon: mediaIcon, + action(context: EditorUiContext) { + const mediaModal = context.manager.createModal('media'); + + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null; + + let formDefaults = {}; + if (selectedNode) { + const nodeAttrs = selectedNode.getAttributes(); + formDefaults = { + src: nodeAttrs.src || nodeAttrs.data || '', + width: nodeAttrs.width, + height: nodeAttrs.height, + embed: '', + } + } + + mediaModal.show(formDefaults); + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isMediaNode); + } +}; + +export const details: EditorButtonDefinition = { + label: 'Insert collapsible block', + icon: detailsIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + const detailsNode = $createDetailsNode(); + const selectionNodes = selection?.getNodes() || []; + const topLevels = selectionNodes.map(n => n.getTopLevelElement()) + .filter(n => n !== null) as ElementNode[]; + const uniqueTopLevels = [...new Set(topLevels)]; + + if (uniqueTopLevels.length > 0) { + uniqueTopLevels[0].insertAfter(detailsNode); + } else { + $getRoot().append(detailsNode); + } + + for (const node of uniqueTopLevels) { + detailsNode.append(node); + } + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isDetailsNode); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts new file mode 100644 index 000000000..fc4196f0a --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -0,0 +1,398 @@ +import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../../framework/buttons"; +import tableIcon from "@icons/editor/table.svg"; +import deleteIcon from "@icons/editor/table-delete.svg"; +import deleteColumnIcon from "@icons/editor/table-delete-column.svg"; +import deleteRowIcon from "@icons/editor/table-delete-row.svg"; +import insertColumnAfterIcon from "@icons/editor/table-insert-column-after.svg"; +import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg"; +import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; +import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; +import {EditorUiContext} from "../../framework/core"; +import {$getSelection, BaseSelection} from "lexical"; +import {$isCustomTableNode} from "../../../nodes/custom-table"; +import { + $deleteTableColumn__EXPERIMENTAL, + $deleteTableRow__EXPERIMENTAL, + $insertTableColumn__EXPERIMENTAL, + $insertTableRow__EXPERIMENTAL, + $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, +} from "@lexical/table"; +import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; +import {$getParentOfType} from "../../../utils/nodes"; +import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; +import {$showCellPropertiesForm, $showRowPropertiesForm, $showTablePropertiesForm} from "../forms/tables"; +import { + $clearTableFormatting, + $clearTableSizes, $getTableFromSelection, + $getTableRowsFromSelection, + $mergeTableCellsInSelection +} from "../../../utils/tables"; +import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; +import { + $copySelectedColumnsToClipboard, + $copySelectedRowsToClipboard, + $cutSelectedColumnsToClipboard, + $cutSelectedRowsToClipboard, + $pasteClipboardRowsBefore, + $pasteClipboardRowsAfter, + isColumnClipboardEmpty, + isRowClipboardEmpty, + $pasteClipboardColumnsBefore, $pasteClipboardColumnsAfter +} from "../../../utils/table-copy-paste"; + +const neverActive = (): boolean => false; +const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); + +export const table: EditorBasicButtonDefinition = { + label: 'Table', + icon: tableIcon, +}; + +export const tableProperties: EditorButtonDefinition = { + label: 'Table properties', + icon: tableIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const table = $getTableFromSelection($getSelection()); + if ($isCustomTableNode(table)) { + $showTablePropertiesForm(table, context); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const clearTableFormatting: EditorButtonDefinition = { + label: 'Clear table formatting', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if (!$isCustomTableCellNode(cell)) { + return; + } + + const table = $getParentOfType(cell, $isTableNode); + if ($isCustomTableNode(table)) { + $clearTableFormatting(table); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const resizeTableToContents: EditorButtonDefinition = { + label: 'Resize to contents', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if (!$isCustomTableCellNode(cell)) { + return; + } + + const table = $getParentOfType(cell, $isCustomTableNode); + if ($isCustomTableNode(table)) { + $clearTableSizes(table); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const deleteTable: EditorButtonDefinition = { + label: 'Delete table', + icon: deleteIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const table = $getNodeFromSelection($getSelection(), $isCustomTableNode); + if (table) { + table.remove(); + } + }); + }, + isActive() { + return false; + } +}; + +export const deleteTableMenuAction: EditorButtonDefinition = { + ...deleteTable, + format: 'long', + isDisabled(selection) { + return !$selectionContainsNodeType(selection, $isTableNode); + }, +}; + +export const insertRowAbove: EditorButtonDefinition = { + label: 'Insert row before', + icon: insertRowAboveIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableRow__EXPERIMENTAL(false); + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const insertRowBelow: EditorButtonDefinition = { + label: 'Insert row after', + icon: insertRowBelowIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableRow__EXPERIMENTAL(true); + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const deleteRow: EditorButtonDefinition = { + label: 'Delete row', + icon: deleteRowIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $deleteTableRow__EXPERIMENTAL(); + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const rowProperties: EditorButtonDefinition = { + label: 'Row properties', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const rows = $getTableRowsFromSelection($getSelection()); + if ($isCustomTableRowNode(rows[0])) { + $showRowPropertiesForm(rows[0], context); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const cutRow: EditorButtonDefinition = { + label: 'Cut row', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + try { + $cutSelectedRowsToClipboard(); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const copyRow: EditorButtonDefinition = { + label: 'Copy row', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + try { + $copySelectedRowsToClipboard(); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const pasteRowBefore: EditorButtonDefinition = { + label: 'Paste row before', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + try { + $pasteClipboardRowsBefore(context.editor); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(), +}; + +export const pasteRowAfter: EditorButtonDefinition = { + label: 'Paste row after', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + try { + $pasteClipboardRowsAfter(context.editor); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(), +}; + +export const cutColumn: EditorButtonDefinition = { + label: 'Cut column', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + try { + $cutSelectedColumnsToClipboard(); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const copyColumn: EditorButtonDefinition = { + label: 'Copy column', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + try { + $copySelectedColumnsToClipboard(); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const pasteColumnBefore: EditorButtonDefinition = { + label: 'Paste column before', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + try { + $pasteClipboardColumnsBefore(context.editor); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(), +}; + +export const pasteColumnAfter: EditorButtonDefinition = { + label: 'Paste column after', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + try { + $pasteClipboardColumnsAfter(context.editor); + } catch (e: any) { + context.error(e); + } + }); + }, + isActive: neverActive, + isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(), +}; + +export const insertColumnBefore: EditorButtonDefinition = { + label: 'Insert column before', + icon: insertColumnBeforeIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableColumn__EXPERIMENTAL(false); + }); + }, + isActive() { + return false; + } +}; + +export const insertColumnAfter: EditorButtonDefinition = { + label: 'Insert column after', + icon: insertColumnAfterIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableColumn__EXPERIMENTAL(true); + }); + }, + isActive() { + return false; + } +}; + +export const deleteColumn: EditorButtonDefinition = { + label: 'Delete column', + icon: deleteColumnIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $deleteTableColumn__EXPERIMENTAL(); + }); + }, + isActive() { + return false; + } +}; + +export const cellProperties: EditorButtonDefinition = { + label: 'Cell properties', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if ($isCustomTableCellNode(cell)) { + $showCellPropertiesForm(cell, context); + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const mergeCells: EditorButtonDefinition = { + label: 'Merge cells', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + if ($isTableSelection(selection)) { + $mergeTableCellsInSelection(selection); + } + }); + }, + isActive: neverActive, + isDisabled(selection) { + return !$isTableSelection(selection); + } +}; + +export const splitCell: EditorButtonDefinition = { + label: 'Split cell', + format: 'long', + action(context: EditorUiContext) { + context.editor.update(() => { + $unmergeCell(); + }); + }, + isActive: neverActive, + isDisabled(selection) { + const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as TableCellNode|null; + if (cell) { + const merged = cell.getRowSpan() > 1 || cell.getColSpan() > 1; + return !merged; + } + + return true; + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts new file mode 100644 index 000000000..fc461f662 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -0,0 +1,18 @@ +import {EditorFormDefinition} from "../../framework/forms"; +import {EditorUiContext} from "../../framework/core"; +import {setEditorContentFromHtml} from "../../../utils/actions"; + +export const source: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); + return true; + }, + fields: [ + { + label: 'Source', + name: 'source', + type: 'textarea', + }, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts new file mode 100644 index 000000000..228566d44 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -0,0 +1,266 @@ +import { + EditorFormDefinition, + EditorFormField, + EditorFormTabs, + EditorSelectFormFieldDefinition +} from "../../framework/forms"; +import {EditorUiContext} from "../../framework/core"; +import {$createNodeSelection, $createTextNode, $getSelection, $insertNodes, $setSelection} from "lexical"; +import {$isImageNode, ImageNode} from "../../../nodes/image"; +import {$createLinkNode, $isLinkNode, LinkNode} from "@lexical/link"; +import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; +import {$insertNodeToNearestRoot} from "@lexical/utils"; +import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; +import {EditorFormModal} from "../../framework/modals"; +import {EditorActionField} from "../../framework/blocks/action-field"; +import {EditorButton} from "../../framework/buttons"; +import {showImageManager} from "../../../utils/images"; +import searchImageIcon from "@icons/editor/image-search.svg"; +import searchIcon from "@icons/search.svg"; +import {showLinkSelector} from "../../../utils/links"; +import {LinkField} from "../../framework/blocks/link-field"; +import {insertOrUpdateLink} from "../../../utils/formats"; + +export function $showImageForm(image: ImageNode, context: EditorUiContext) { + const imageModal: EditorFormModal = context.manager.createModal('image'); + const height = image.getHeight(); + const width = image.getWidth(); + + const formData = { + src: image.getSrc(), + alt: image.getAltText(), + height: height === 0 ? '' : String(height), + width: width === 0 ? '' : String(width), + }; + + imageModal.show(formData); +} + +export const image: EditorFormDefinition = { + submitText: 'Apply', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const selection = getLastSelection(context.editor); + const selectedImage = $getNodeFromSelection(selection, $isImageNode); + if ($isImageNode(selectedImage)) { + selectedImage.setSrc(formData.get('src')?.toString() || ''); + selectedImage.setAltText(formData.get('alt')?.toString() || ''); + + selectedImage.setWidth(Number(formData.get('width')?.toString() || '0')); + selectedImage.setHeight(Number(formData.get('height')?.toString() || '0')); + } + }); + return true; + }, + fields: [ + { + build() { + return new EditorActionField( + new EditorFormField({ + label: 'Source', + name: 'src', + type: 'text', + }), + new EditorButton({ + label: 'Browse files', + icon: searchImageIcon, + action(context: EditorUiContext) { + showImageManager((image) => { + const modal = context.manager.getActiveModal('image'); + if (modal) { + modal.getForm().setValues({ + src: image.thumbs?.display || image.url, + alt: image.name, + }); + } + }); + } + }), + ); + }, + }, + { + label: 'Alternative description', + name: 'alt', + type: 'text', + }, + { + label: 'Width', + name: 'width', + type: 'text', + }, + { + label: 'Height', + name: 'height', + type: 'text', + }, + ], +}; + +export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('link'); + + if (link) { + const formDefaults: Record = { + url: link.getURL(), + text: link.getTextContent(), + title: link.getTitle() || '', + target: link.getTarget() || '', + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(link.getKey()); + $setSelection(selection); + }); + + linkModal.show(formDefaults); + } else { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const text = selection?.getTextContent() || ''; + const formDefaults = {text}; + linkModal.show(formDefaults); + }); + } +} + +export const link: EditorFormDefinition = { + submitText: 'Apply', + async action(formData, context: EditorUiContext) { + insertOrUpdateLink(context.editor, { + url: formData.get('url')?.toString() || '', + title: formData.get('title')?.toString() || '', + target: formData.get('target')?.toString() || '', + text: formData.get('text')?.toString() || '', + }); + return true; + }, + fields: [ + { + build() { + return new EditorActionField( + new LinkField(new EditorFormField({ + label: 'URL', + name: 'url', + type: 'text', + })), + new EditorButton({ + label: 'Browse links', + icon: searchIcon, + action(context: EditorUiContext) { + showLinkSelector(entity => { + const modal = context.manager.getActiveModal('link'); + if (modal) { + modal.getForm().setValues({ + url: entity.link, + text: entity.name, + title: entity.name, + }); + } + }); + } + }), + ); + }, + }, + { + label: 'Text to display', + name: 'text', + type: 'text', + }, + { + label: 'Title', + name: 'title', + type: 'text', + }, + { + label: 'Open link in...', + name: 'target', + type: 'select', + valuesByLabel: { + 'Current window': '', + 'New window': '_blank', + } + } as EditorSelectFormFieldDefinition, + ], +}; + +export const media: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + const selectedNode: MediaNode|null = await (new Promise((res, rej) => { + context.editor.getEditorState().read(() => { + const node = $getNodeFromSelection($getSelection(), $isMediaNode); + res(node as MediaNode|null); + }); + })); + + const embedCode = (formData.get('embed') || '').toString().trim(); + if (embedCode) { + context.editor.update(() => { + const node = $createMediaNodeFromHtml(embedCode); + if (selectedNode && node) { + selectedNode.replace(node) + } else if (node) { + $insertNodes([node]); + } + }); + + return true; + } + + context.editor.update(() => { + const src = (formData.get('src') || '').toString().trim(); + const height = (formData.get('height') || '').toString().trim(); + const width = (formData.get('width') || '').toString().trim(); + + const updateNode = selectedNode || $createMediaNodeFromSrc(src); + updateNode.setSrc(src); + updateNode.setWidthAndHeight(width, height); + if (!selectedNode) { + $insertNodes([updateNode]); + } + }); + + return true; + }, + fields: [ + { + build() { + return new EditorFormTabs([ + { + label: 'General', + contents: [ + { + label: 'Source', + name: 'src', + type: 'text', + }, + { + label: 'Width', + name: 'width', + type: 'text', + }, + { + label: 'Height', + name: 'height', + type: 'text', + }, + ], + }, + { + label: 'Embed', + contents: [ + { + label: 'Paste your embed code below:', + name: 'embed', + type: 'textarea', + }, + ], + } + ]) + } + }, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts new file mode 100644 index 000000000..5a41c85b3 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -0,0 +1,328 @@ +import { + EditorFormDefinition, + EditorFormFieldDefinition, + EditorFormTabs, + EditorSelectFormFieldDefinition +} from "../../framework/forms"; +import {EditorUiContext} from "../../framework/core"; +import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; +import {EditorFormModal} from "../../framework/modals"; +import {$getSelection, ElementFormatType} from "lexical"; +import { + $forEachTableCell, $getCellPaddingForTable, + $getTableCellColumnWidth, + $getTableCellsFromSelection, $getTableFromSelection, + $getTableRowsFromSelection, + $setTableCellColumnWidth +} from "../../../utils/tables"; +import {formatSizeValue} from "../../../utils/dom"; +import {CustomTableRowNode} from "../../../nodes/custom-table-row"; +import {CustomTableNode} from "../../../nodes/custom-table"; + +const borderStyleInput: EditorSelectFormFieldDefinition = { + label: 'Border style', + name: 'border_style', + type: 'select', + valuesByLabel: { + 'Select...': '', + "Solid": 'solid', + "Dotted": 'dotted', + "Dashed": 'dashed', + "Double": 'double', + "Groove": 'groove', + "Ridge": 'ridge', + "Inset": 'inset', + "Outset": 'outset', + "None": 'none', + "Hidden": 'hidden', + } +}; + +const borderColorInput: EditorFormFieldDefinition = { + label: 'Border color', + name: 'border_color', + type: 'text', +}; + +const backgroundColorInput: EditorFormFieldDefinition = { + label: 'Background color', + name: 'background_color', + type: 'text', +}; + +const alignmentInput: EditorSelectFormFieldDefinition = { + label: 'Alignment', + name: 'align', + type: 'select', + valuesByLabel: { + 'None': '', + 'Left': 'left', + 'Center': 'center', + 'Right': 'right', + } +}; + +export function $showCellPropertiesForm(cell: CustomTableCellNode, context: EditorUiContext): EditorFormModal { + const styles = cell.getStyles(); + const modalForm = context.manager.createModal('cell_properties'); + modalForm.show({ + width: $getTableCellColumnWidth(context.editor, cell), + height: styles.get('height') || '', + type: cell.getTag(), + h_align: cell.getFormatType(), + v_align: styles.get('vertical-align') || '', + border_width: styles.get('border-width') || '', + border_style: styles.get('border-style') || '', + border_color: styles.get('border-color') || '', + background_color: styles.get('background-color') || '', + }); + return modalForm; +} + +export const cellProperties: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const cells = $getTableCellsFromSelection($getSelection()); + for (const cell of cells) { + const width = formData.get('width')?.toString() || ''; + + $setTableCellColumnWidth(cell, width); + cell.updateTag(formData.get('type')?.toString() || ''); + cell.setFormat((formData.get('h_align')?.toString() || '') as ElementFormatType); + + const styles = cell.getStyles(); + styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); + styles.set('vertical-align', formData.get('v_align')?.toString() || ''); + styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || '')); + styles.set('border-style', formData.get('border_style')?.toString() || ''); + styles.set('border-color', formData.get('border_color')?.toString() || ''); + styles.set('background-color', formData.get('background_color')?.toString() || ''); + + cell.setStyles(styles); + } + }); + + return true; + }, + fields: [ + { + build() { + const generalFields: EditorFormFieldDefinition[] = [ + { + label: 'Width', // Colgroup width + name: 'width', + type: 'text', + }, + { + label: 'Height', // inline-style: height + name: 'height', + type: 'text', + }, + { + label: 'Cell type', // element + name: 'type', + type: 'select', + valuesByLabel: { + 'Cell': 'td', + 'Header cell': 'th', + } + } as EditorSelectFormFieldDefinition, + { + ...alignmentInput, // class: 'align-right/left/center' + label: 'Horizontal align', + name: 'h_align', + }, + { + label: 'Vertical align', // inline-style: vertical-align + name: 'v_align', + type: 'select', + valuesByLabel: { + 'None': '', + 'Top': 'top', + 'Middle': 'middle', + 'Bottom': 'bottom', + } + } as EditorSelectFormFieldDefinition, + ]; + + const advancedFields: EditorFormFieldDefinition[] = [ + { + label: 'Border width', // inline-style: border-width + name: 'border_width', + type: 'text', + }, + borderStyleInput, // inline-style: border-style + borderColorInput, // inline-style: border-color + backgroundColorInput, // inline-style: background-color + ]; + + return new EditorFormTabs([ + { + label: 'General', + contents: generalFields, + }, + { + label: 'Advanced', + contents: advancedFields, + } + ]) + } + }, + ], +}; + +export function $showRowPropertiesForm(row: CustomTableRowNode, context: EditorUiContext): EditorFormModal { + const styles = row.getStyles(); + const modalForm = context.manager.createModal('row_properties'); + modalForm.show({ + height: styles.get('height') || '', + border_style: styles.get('border-style') || '', + border_color: styles.get('border-color') || '', + background_color: styles.get('background-color') || '', + }); + return modalForm; +} + +export const rowProperties: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const rows = $getTableRowsFromSelection($getSelection()); + for (const row of rows) { + const styles = row.getStyles(); + styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); + styles.set('border-style', formData.get('border_style')?.toString() || ''); + styles.set('border-color', formData.get('border_color')?.toString() || ''); + styles.set('background-color', formData.get('background_color')?.toString() || ''); + row.setStyles(styles); + } + }); + return true; + }, + fields: [ + // Removed fields: + // Removed 'Row Type' as we don't currently support thead/tfoot elements + // TinyMCE would move rows up/down into these parents when set + // Removed 'Alignment' since this was broken in our editor (applied alignment class to whole parent table) + { + label: 'Height', // style on tr: height + name: 'height', + type: 'text', + }, + borderStyleInput, // style on tr: height + borderColorInput, // style on tr: height + backgroundColorInput, // style on tr: height + ], +}; + +export function $showTablePropertiesForm(table: CustomTableNode, context: EditorUiContext): EditorFormModal { + const styles = table.getStyles(); + const modalForm = context.manager.createModal('table_properties'); + modalForm.show({ + width: styles.get('width') || '', + height: styles.get('height') || '', + cell_spacing: styles.get('cell-spacing') || '', + cell_padding: $getCellPaddingForTable(table), + border_width: styles.get('border-width') || '', + border_style: styles.get('border-style') || '', + border_color: styles.get('border-color') || '', + background_color: styles.get('background-color') || '', + // caption: '', TODO + align: table.getFormatType(), + }); + return modalForm; +} + +export const tableProperties: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const table = $getTableFromSelection($getSelection()); + if (!table) { + return; + } + + const styles = table.getStyles(); + styles.set('width', formatSizeValue(formData.get('width')?.toString() || '')); + styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); + styles.set('cell-spacing', formatSizeValue(formData.get('cell_spacing')?.toString() || '')); + styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || '')); + styles.set('border-style', formData.get('border_style')?.toString() || ''); + styles.set('border-color', formData.get('border_color')?.toString() || ''); + styles.set('background-color', formData.get('background_color')?.toString() || ''); + table.setStyles(styles); + + table.setFormat(formData.get('align') as ElementFormatType); + + const cellPadding = (formData.get('cell_padding')?.toString() || ''); + if (cellPadding) { + const cellPaddingFormatted = formatSizeValue(cellPadding); + $forEachTableCell(table, (cell: CustomTableCellNode) => { + const styles = cell.getStyles(); + styles.set('padding', cellPaddingFormatted); + cell.setStyles(styles); + }); + } + + // TODO - cell caption + }); + return true; + }, + fields: [ + { + build() { + const generalFields: EditorFormFieldDefinition[] = [ + { + label: 'Width', // Style - width + name: 'width', + type: 'text', + }, + { + label: 'Height', // Style - height + name: 'height', + type: 'text', + }, + { + label: 'Cell spacing', // Style - border-spacing + name: 'cell_spacing', + type: 'text', + }, + { + label: 'Cell padding', // Style - padding on child cells? + name: 'cell_padding', + type: 'text', + }, + { + label: 'Border width', // Style - border-width + name: 'border_width', + type: 'text', + }, + { + label: 'caption', // Caption element + name: 'caption', + type: 'text', // TODO - + }, + alignmentInput, // alignment class + ]; + + const advancedFields: EditorFormFieldDefinition[] = [ + borderStyleInput, // Style - border-style + borderColorInput, // Style - border-color + backgroundColorInput, // Style - background-color + ]; + + return new EditorFormTabs([ + { + label: 'General', + contents: generalFields, + }, + { + label: 'Advanced', + contents: advancedFields, + } + ]) + } + }, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts new file mode 100644 index 000000000..c43923778 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -0,0 +1,35 @@ +import {EditorFormModalDefinition} from "../framework/modals"; +import {image, link, media} from "./forms/objects"; +import {source} from "./forms/controls"; +import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; + +export const modals: Record = { + link: { + title: 'Insert/Edit Link', + form: link, + }, + image: { + title: 'Insert/Edit Image', + form: image, + }, + media: { + title: 'Insert/Edit Media', + form: media, + }, + source: { + title: 'Source code', + form: source, + }, + cell_properties: { + title: 'Cell Properties', + form: cellProperties, + }, + row_properties: { + title: 'Row Properties', + form: rowProperties, + }, + table_properties: { + title: 'Table Properties', + form: tableProperties, + }, +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts new file mode 100644 index 000000000..b7741321b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts @@ -0,0 +1,25 @@ +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorButton} from "../buttons"; + + +export class EditorActionField extends EditorContainerUiElement { + protected input: EditorUiElement; + protected action: EditorButton; + + constructor(input: EditorUiElement, action: EditorButton) { + super([input, action]); + + this.input = input; + this.action = action; + } + + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-action-input-container', + }, [ + this.input.getDOMElement(), + this.action.getDOMElement(), + ]); + } +} diff --git a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts new file mode 100644 index 000000000..30dd237f6 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts @@ -0,0 +1,31 @@ +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorButton} from "../buttons"; +import {EditorDropdownButton} from "./dropdown-button"; +import caretDownIcon from "@icons/caret-down-large.svg"; + +export class EditorButtonWithMenu extends EditorContainerUiElement { + protected button: EditorButton; + protected dropdownButton: EditorDropdownButton; + + constructor(button: EditorButton, menuItems: EditorUiElement[]) { + super([button]); + + this.button = button; + this.dropdownButton = new EditorDropdownButton({ + button: {label: 'Menu', icon: caretDownIcon}, + showOnHover: false, + direction: 'vertical', + }, menuItems); + this.addChildren(this.dropdownButton); + } + + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-button-with-menu-container', + }, [ + this.button.getDOMElement(), + this.dropdownButton.getDOMElement() + ]); + } +} diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-button.ts b/resources/js/wysiwyg/ui/framework/blocks/color-button.ts new file mode 100644 index 000000000..e81521a26 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-button.ts @@ -0,0 +1,35 @@ +import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; +import {EditorUiStateUpdate} from "../core"; +import {$isRangeSelection} from "lexical"; +import {$getSelectionStyleValueForProperty} from "@lexical/selection"; + +export class EditorColorButton extends EditorButton { + protected style: string; + + constructor(definition: EditorBasicButtonDefinition, style: string) { + super(definition); + + this.style = style; + } + + getColorBar(): HTMLElement { + const colorBar = this.getDOMElement().querySelector('svg .editor-icon-color-bar'); + + if (!colorBar) { + throw new Error(`Could not find expected color bar in the icon for this ${this.definition.label} button`); + } + + return (colorBar as HTMLElement); + } + + updateState(state: EditorUiStateUpdate): void { + super.updateState(state); + + if ($isRangeSelection(state.selection)) { + const value = $getSelectionStyleValueForProperty(state.selection, this.style); + const colorBar = this.getColorBar(); + colorBar.setAttribute('fill', value); + } + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts new file mode 100644 index 000000000..b068fb4f0 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -0,0 +1,94 @@ +import {EditorUiElement} from "../core"; +import {$getSelection} from "lexical"; +import {$patchStyleText} from "@lexical/selection"; +import {el} from "../../../utils/dom"; + +import removeIcon from "@icons/editor/color-clear.svg"; + +const colorChoices = [ + '#000000', + '#ffffff', + + '#BFEDD2', + '#FBEEB8', + '#F8CAC6', + '#ECCAFA', + '#C2E0F4', + + '#2DC26B', + '#F1C40F', + '#E03E2D', + '#B96AD9', + '#3598DB', + + '#169179', + '#E67E23', + '#BA372A', + '#843FA1', + '#236FA1', + + '#ECF0F1', + '#CED4D9', + '#95A5A6', + '#7E8C8D', + '#34495E', +]; + +export class EditorColorPicker extends EditorUiElement { + + protected styleProperty: string; + + constructor(styleProperty: string) { + super(); + this.styleProperty = styleProperty; + } + + buildDOM(): HTMLElement { + + const colorOptions = colorChoices.map(choice => { + return el('div', { + class: 'editor-color-select-option', + style: `background-color: ${choice}`, + 'data-color': choice, + 'aria-label': choice, + }); + }); + + const removeButton = el('div', { + class: 'editor-color-select-option', + 'data-color': '', + title: 'Clear color', + }, []); + removeButton.innerHTML = removeIcon; + colorOptions.push(removeButton); + + const colorRows = []; + for (let i = 0; i < colorOptions.length; i+=5) { + const options = colorOptions.slice(i, i + 5); + colorRows.push(el('div', { + class: 'editor-color-select-row', + }, options)); + } + + const wrapper = el('div', { + class: 'editor-color-select', + }, colorRows); + + wrapper.addEventListener('click', this.onClick.bind(this)); + + return wrapper; + } + + onClick(event: MouseEvent) { + const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement; + if (!colorEl) return; + + const color = colorEl.dataset.color as string; + this.getContext().editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[this.styleProperty]: color || null}); + } + }); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts new file mode 100644 index 000000000..cba141f6c --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -0,0 +1,78 @@ +import {handleDropdown} from "../helpers/dropdowns"; +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; +import {el} from "../../../utils/dom"; +import {EditorMenuButton} from "./menu-button"; + +export type EditorDropdownButtonOptions = { + showOnHover?: boolean; + direction?: 'vertical'|'horizontal'; + button: EditorBasicButtonDefinition|EditorButton; +}; + +const defaultOptions: EditorDropdownButtonOptions = { + showOnHover: false, + direction: 'horizontal', + button: {label: 'Menu'}, +} + +export class EditorDropdownButton extends EditorContainerUiElement { + protected button: EditorButton; + protected childItems: EditorUiElement[]; + protected open: boolean = false; + protected options: EditorDropdownButtonOptions; + + constructor(options: EditorDropdownButtonOptions, children: EditorUiElement[]) { + super(children); + this.childItems = children; + this.options = Object.assign({}, defaultOptions, options); + + if (options.button instanceof EditorButton) { + this.button = options.button; + } else { + const type = options.button.format === 'long' ? EditorMenuButton : EditorButton; + this.button = new type({ + ...options.button, + action() { + return false; + }, + isActive: () => { + return this.open; + } + }); + } + + this.addChildren(this.button); + } + + insertItems(...items: EditorUiElement[]) { + this.addChildren(...items); + this.childItems.push(...items); + } + + protected buildDOM(): HTMLElement { + const button = this.button.getDOMElement(); + + const childElements: HTMLElement[] = this.childItems.map(child => child.getDOMElement()); + const menu = el('div', { + class: `editor-dropdown-menu editor-dropdown-menu-${this.options.direction}`, + hidden: 'true', + }, childElements); + + const wrapper = el('div', { + class: 'editor-dropdown-menu-container', + }, [button, menu]); + + handleDropdown({toggle: button, menu : menu, + showOnHover: this.options.showOnHover, + onOpen : () => { + this.open = true; + this.getContext().manager.triggerStateUpdateForElement(this.button); + }, onClose : () => { + this.open = false; + this.getContext().manager.triggerStateUpdateForElement(this.button); + }}); + + return wrapper; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts new file mode 100644 index 000000000..d666954bf --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -0,0 +1,56 @@ +import {EditorUiStateUpdate, EditorContainerUiElement} from "../core"; +import {EditorButton} from "../buttons"; +import {handleDropdown} from "../helpers/dropdowns"; +import {el} from "../../../utils/dom"; + +export class EditorFormatMenu extends EditorContainerUiElement { + buildDOM(): HTMLElement { + const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); + const menu = el('div', { + class: 'editor-format-menu-dropdown editor-dropdown-menu editor-dropdown-menu-vertical', + hidden: 'true', + }, childElements); + + const toggle = el('button', { + class: 'editor-format-menu-toggle editor-button', + type: 'button', + }, [this.trans('Formats')]); + + const wrapper = el('div', { + class: 'editor-format-menu editor-dropdown-menu-container', + }, [toggle, menu]); + + handleDropdown({toggle : toggle, menu : menu}); + + return wrapper; + } + + updateState(state: EditorUiStateUpdate) { + super.updateState(state); + + for (const child of this.children) { + if (child instanceof EditorButton && child.isActive()) { + this.updateToggleLabel(child.getLabel()); + return; + } + + if (child instanceof EditorContainerUiElement) { + for (const grandchild of child.getChildren()) { + if (grandchild instanceof EditorButton && grandchild.isActive()) { + this.updateToggleLabel(grandchild.getLabel()); + return; + } + } + } + } + + this.updateToggleLabel(this.trans('Formats')); + } + + protected updateToggleLabel(text: string): void { + const button = this.getDOMElement().querySelector('button'); + if (button) { + button.innerText = text; + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts new file mode 100644 index 000000000..2371983dd --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts @@ -0,0 +1,47 @@ +import {EditorButton, EditorButtonDefinition} from "../buttons"; +import {el} from "../../../utils/dom"; + +export class FormatPreviewButton extends EditorButton { + protected previewSampleElement: HTMLElement; + + constructor(previewSampleElement: HTMLElement,definition: EditorButtonDefinition) { + super(definition); + this.previewSampleElement = previewSampleElement; + } + + protected buildDOM(): HTMLButtonElement { + const button = super.buildDOM(); + button.innerHTML = ''; + + const preview = el('span', { + class: 'editor-button-format-preview' + }, [this.getLabel()]); + + const stylesToApply = this.getStylesFromPreview(); + for (const style of Object.keys(stylesToApply)) { + preview.style.setProperty(style, stylesToApply[style]); + } + + button.append(preview); + return button; + } + + protected getStylesFromPreview(): Record { + const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); + const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; + sampleClone.textContent = this.getLabel(); + wrap.append(sampleClone); + document.body.append(wrap); + + const propertiesToFetch = ['color', 'font-size', 'background-color', 'border-inline-start']; + const propertiesToReturn: Record = {}; + + const computed = window.getComputedStyle(sampleClone); + for (const property of propertiesToFetch) { + propertiesToReturn[property] = computed.getPropertyValue(property); + } + wrap.remove(); + + return propertiesToReturn; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts new file mode 100644 index 000000000..5a64cdc30 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -0,0 +1,96 @@ +import {EditorContainerUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorFormField} from "../forms"; +import {CustomHeadingNode} from "../../../nodes/custom-heading"; +import {$getAllNodesOfType} from "../../../utils/nodes"; +import {$isHeadingNode} from "@lexical/rich-text"; +import {uniqueIdSmall} from "../../../../services/util"; + +export class LinkField extends EditorContainerUiElement { + protected input: EditorFormField; + protected headerMap = new Map(); + + constructor(input: EditorFormField) { + super([input]); + + this.input = input; + } + + buildDOM(): HTMLElement { + const listId = 'editor-form-datalist-' + this.input.getName() + '-' + Date.now(); + const inputOuterDOM = this.input.getDOMElement(); + const inputFieldDOM = inputOuterDOM.querySelector('input'); + inputFieldDOM?.setAttribute('list', listId); + inputFieldDOM?.setAttribute('autocomplete', 'off'); + const datalist = el('datalist', {id: listId}); + + const container = el('div', { + class: 'editor-link-field-container', + }, [inputOuterDOM, datalist]); + + inputFieldDOM?.addEventListener('focusin', () => { + this.updateDataList(datalist); + }); + + inputFieldDOM?.addEventListener('input', () => { + const value = inputFieldDOM.value; + const header = this.headerMap.get(value); + if (header) { + this.updateFormFromHeader(header); + } + }); + + return container; + } + + updateFormFromHeader(header: CustomHeadingNode) { + this.getHeaderIdAndText(header).then(({id, text}) => { + console.log('updating form', id, text); + const modal = this.getContext().manager.getActiveModal('link'); + if (modal) { + modal.getForm().setValues({ + url: `#${id}`, + text: text, + title: text, + }); + } + }); + } + + getHeaderIdAndText(header: CustomHeadingNode): Promise<{id: string, text: string}> { + return new Promise((res) => { + this.getContext().editor.update(() => { + let id = header.getId(); + console.log('header', id, header.__id); + if (!id) { + id = 'header-' + uniqueIdSmall(); + header.setId(id); + } + + const text = header.getTextContent(); + res({id, text}); + }); + }); + } + + updateDataList(listEl: HTMLElement) { + this.getContext().editor.getEditorState().read(() => { + const headers = $getAllNodesOfType($isHeadingNode) as CustomHeadingNode[]; + + this.headerMap.clear(); + const listEls: HTMLElement[] = []; + + for (const header of headers) { + const key = 'header-' + header.getKey(); + this.headerMap.set(key, header); + listEls.push(el('option', { + value: key, + label: header.getTextContent().substring(0, 54), + })); + } + + listEl.innerHTML = ''; + listEl.append(...listEls); + }); + } +} diff --git a/resources/js/wysiwyg/ui/framework/blocks/menu-button.ts b/resources/js/wysiwyg/ui/framework/blocks/menu-button.ts new file mode 100644 index 000000000..6f6c8cf1b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/menu-button.ts @@ -0,0 +1,15 @@ +import {EditorButton} from "../buttons"; +import {el} from "../../../utils/dom"; +import arrowIcon from "@icons/chevron-right.svg" + +export class EditorMenuButton extends EditorButton { + protected buildDOM(): HTMLButtonElement { + const dom = super.buildDOM(); + + const icon = el('div', {class: 'editor-menu-button-icon'}); + icon.innerHTML = arrowIcon; + dom.append(icon); + + return dom; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts new file mode 100644 index 000000000..cd0780534 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -0,0 +1,44 @@ +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {EditorDropdownButton} from "./dropdown-button"; +import moreHorizontal from "@icons/editor/more-horizontal.svg" +import {el} from "../../../utils/dom"; + + +export class EditorOverflowContainer extends EditorContainerUiElement { + + protected size: number; + protected overflowButton: EditorDropdownButton; + protected content: EditorUiElement[]; + + constructor(size: number, children: EditorUiElement[]) { + super(children); + this.size = size; + this.content = children; + this.overflowButton = new EditorDropdownButton({ + button: { + label: 'More', + icon: moreHorizontal, + }, + }, []); + this.addChildren(this.overflowButton); + } + + protected buildDOM(): HTMLElement { + const slicePosition = this.content.length > this.size ? this.size - 1 : this.size; + const visibleChildren = this.content.slice(0, slicePosition); + const invisibleChildren = this.content.slice(slicePosition); + + const visibleElements = visibleChildren.map(child => child.getDOMElement()); + if (invisibleChildren.length > 0) { + this.removeChildren(...invisibleChildren); + this.overflowButton.insertItems(...invisibleChildren); + visibleElements.push(this.overflowButton.getDOMElement()); + } + + return el('div', { + class: 'editor-overflow-container', + }, visibleElements); + } + + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/separator.ts b/resources/js/wysiwyg/ui/framework/blocks/separator.ts new file mode 100644 index 000000000..c0ef353e6 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/separator.ts @@ -0,0 +1,10 @@ +import {EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; + +export class EditorSeparator extends EditorUiElement { + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-separator', + }); + } +} diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts new file mode 100644 index 000000000..a8a142df5 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -0,0 +1,82 @@ +import {EditorUiElement} from "../core"; +import {$createTableNodeWithDimensions} from "@lexical/table"; +import {CustomTableNode} from "../../../nodes/custom-table"; +import {$insertNewBlockNodeAtSelection} from "../../../utils/selection"; +import {el} from "../../../utils/dom"; + + +export class EditorTableCreator extends EditorUiElement { + + buildDOM(): HTMLElement { + const size = 10; + const rows: HTMLElement[] = []; + const cells: HTMLElement[] = []; + + for (let row = 1; row < size + 1; row++) { + const rowCells = []; + for (let column = 1; column < size + 1; column++) { + const cell = el('div', { + class: 'editor-table-creator-cell', + 'data-rows': String(row), + 'data-columns': String(column), + }); + rowCells.push(cell); + cells.push(cell); + } + rows.push(el('div', { + class: 'editor-table-creator-row' + }, rowCells)); + } + + const display = el('div', {class: 'editor-table-creator-display'}, ['0 x 0']); + const grid = el('div', {class: 'editor-table-creator-grid'}, rows); + grid.addEventListener('mousemove', event => { + const cell = (event.target as HTMLElement).closest('.editor-table-creator-cell') as HTMLElement|null; + if (cell) { + const row = Number(cell.dataset.rows || 0); + const column = Number(cell.dataset.columns || 0); + this.updateGridSelection(row, column, cells, display) + } + }); + + grid.addEventListener('click', event => { + const cell = (event.target as HTMLElement).closest('.editor-table-creator-cell'); + if (cell) { + this.onCellClick(cell as HTMLElement); + } + }); + + grid.addEventListener('mouseleave', event => { + this.updateGridSelection(0, 0, cells, display); + }); + + return el('div', { + class: 'editor-table-creator', + }, [ + grid, + display, + ]); + } + + updateGridSelection(rows: number, columns: number, cells: HTMLElement[], display: HTMLElement) { + for (const cell of cells) { + const active = Number(cell.dataset.rows) <= rows && Number(cell.dataset.columns) <= columns; + cell.classList.toggle('active', active); + } + + display.textContent = `${rows} x ${columns}`; + } + + onCellClick(cell: HTMLElement) { + const rows = Number(cell.dataset.rows || 0); + const columns = Number(cell.dataset.columns || 0); + if (rows < 1 || columns < 1) { + return; + } + + this.getContext().editor.update(() => { + const table = $createTableNodeWithDimensions(rows, columns, false) as CustomTableNode; + $insertNewBlockNodeAtSelection(table); + }); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts new file mode 100644 index 000000000..cf114aa02 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -0,0 +1,122 @@ +import {BaseSelection} from "lexical"; +import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; + +import {el} from "../../utils/dom"; + +export interface EditorBasicButtonDefinition { + label: string; + icon?: string|undefined; + format?: 'small' | 'long'; +} + +export interface EditorButtonDefinition extends EditorBasicButtonDefinition { + action: (context: EditorUiContext, button: EditorButton) => void; + isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean; + isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean; + setup?: (context: EditorUiContext, button: EditorButton) => void; +} + +export class EditorButton extends EditorUiElement { + protected definition: EditorButtonDefinition; + protected active: boolean = false; + protected completedSetup: boolean = false; + protected disabled: boolean = false; + + constructor(definition: EditorButtonDefinition|EditorBasicButtonDefinition) { + super(); + + if ((definition as EditorButtonDefinition).action !== undefined) { + this.definition = definition as EditorButtonDefinition; + } else { + this.definition = { + ...definition, + action() { + return false; + }, + isActive: () => { + return false; + } + }; + } + } + + setContext(context: EditorUiContext) { + super.setContext(context); + + if (this.definition.setup && !this.completedSetup) { + this.definition.setup(context, this); + this.completedSetup = true; + } + } + + protected buildDOM(): HTMLButtonElement { + const label = this.getLabel(); + const format = this.definition.format || 'small'; + const children: (string|HTMLElement)[] = []; + + if (this.definition.icon || format === 'long') { + const icon = el('div', {class: 'editor-button-icon'}); + icon.innerHTML = this.definition.icon || ''; + children.push(icon); + } + + if (!this.definition.icon ||format === 'long') { + const text = el('div', {class: 'editor-button-text'}, [label]); + children.push(text); + } + + const button = el('button', { + type: 'button', + class: `editor-button editor-button-${format}`, + title: this.definition.icon ? label : null, + disabled: this.disabled ? 'true' : null, + }, children) as HTMLButtonElement; + + button.addEventListener('click', this.onClick.bind(this)); + + return button; + } + + protected onClick() { + this.definition.action(this.getContext(), this); + } + + protected updateActiveState(selection: BaseSelection|null) { + const isActive = this.definition.isActive(selection, this.getContext()); + this.setActiveState(isActive); + } + + protected updateDisabledState(selection: BaseSelection|null) { + if (this.definition.isDisabled) { + const isDisabled = this.definition.isDisabled(selection, this.getContext()); + this.toggleDisabled(isDisabled); + } + } + + setActiveState(active: boolean) { + this.active = active; + this.dom?.classList.toggle('editor-button-active', this.active); + } + + updateState(state: EditorUiStateUpdate): void { + this.updateActiveState(state.selection); + this.updateDisabledState(state.selection); + } + + isActive(): boolean { + return this.active; + } + + getLabel(): string { + return this.trans(this.definition.label); + } + + toggleDisabled(disabled: boolean) { + this.disabled = disabled; + if (disabled) { + this.dom?.setAttribute('disabled', 'true'); + } else { + this.dom?.removeAttribute('disabled'); + } + } +} diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts new file mode 100644 index 000000000..3433b96e8 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -0,0 +1,126 @@ +import {BaseSelection, LexicalEditor} from "lexical"; +import {EditorUIManager} from "./manager"; + +import {el} from "../../utils/dom"; + +export type EditorUiStateUpdate = { + editor: LexicalEditor; + selection: BaseSelection|null; +}; + +export type EditorUiContext = { + editor: LexicalEditor; // Lexical editor instance + editorDOM: HTMLElement; // DOM element the editor is bound to + containerDOM: HTMLElement; // DOM element which contains all editor elements + scrollDOM: HTMLElement; // DOM element which is the main content scroll container + translate: (text: string) => string; // Translate function + error: (text: string|Error) => void; // Error reporting function + manager: EditorUIManager; // UI Manager instance for this editor + options: Record; // General user options which may be used by sub elements +}; + +export interface EditorUiBuilderDefinition { + build: () => EditorUiElement; +} + +export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDefinition { + return 'build' in object; +} + +export abstract class EditorUiElement { + protected dom: HTMLElement|null = null; + private context: EditorUiContext|null = null; + + protected abstract buildDOM(): HTMLElement; + + setContext(context: EditorUiContext): void { + this.context = context; + } + + getContext(): EditorUiContext { + if (this.context === null) { + throw new Error('Attempted to use EditorUIContext before it has been set'); + } + + return this.context; + } + + getDOMElement(): HTMLElement { + if (!this.dom) { + this.dom = this.buildDOM(); + } + + return this.dom; + } + + trans(text: string) { + return this.getContext().translate(text); + } + + updateState(state: EditorUiStateUpdate): void { + return; + } +} + +export class EditorContainerUiElement extends EditorUiElement { + protected children : EditorUiElement[] = []; + + constructor(children: EditorUiElement[]) { + super(); + this.children.push(...children); + } + + protected buildDOM(): HTMLElement { + return el('div', {}, this.getChildren().map(child => child.getDOMElement())); + } + + getChildren(): EditorUiElement[] { + return this.children; + } + + protected addChildren(...children: EditorUiElement[]): void { + this.children.push(...children); + } + + protected removeChildren(...children: EditorUiElement[]): void { + for (const child of children) { + this.removeChild(child); + } + } + + protected removeChild(child: EditorUiElement) { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + } + } + + updateState(state: EditorUiStateUpdate): void { + for (const child of this.children) { + child.updateState(state); + } + } + + setContext(context: EditorUiContext) { + super.setContext(context); + for (const child of this.getChildren()) { + child.setContext(context); + } + } +} + +export class EditorSimpleClassContainer extends EditorContainerUiElement { + protected className; + + constructor(className: string, children: EditorUiElement[]) { + super(children); + this.className = className; + } + + protected buildDOM(): HTMLElement { + return el('div', { + class: this.className, + }, this.getChildren().map(child => child.getDOMElement())); + } +} + diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts new file mode 100644 index 000000000..570b8222b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -0,0 +1,57 @@ +import {EditorUiContext} from "./core"; +import {LexicalNode} from "lexical"; + +export interface EditorDecoratorAdapter { + type: string; + getNode(): LexicalNode; +} + +export abstract class EditorDecorator { + + protected node: LexicalNode | null = null; + protected context: EditorUiContext; + + private onDestroyCallbacks: (() => void)[] = []; + + constructor(context: EditorUiContext) { + this.context = context; + } + + protected getNode(): LexicalNode { + if (!this.node) { + throw new Error('Attempted to get use node without it being set'); + } + + return this.node; + } + + setNode(node: LexicalNode) { + this.node = node; + } + + /** + * Register a callback to be ran on destroy of this decorator's node. + */ + protected onDestroy(callback: () => void) { + this.onDestroyCallbacks.push(callback); + } + + /** + * Render the decorator. + * Can run on both creation and update for a node decorator. + * If an element is returned, this will be appended to the element + * that is being decorated. + */ + abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; + + /** + * Destroy this decorator. Used for tear-down operations upon destruction + * of the underlying node this decorator is attached to. + */ + destroy(context: EditorUiContext): void { + for (const callback of this.onDestroyCallbacks) { + callback(); + } + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts new file mode 100644 index 000000000..36371e302 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -0,0 +1,240 @@ +import { + EditorUiContext, + EditorUiElement, + EditorContainerUiElement, + EditorUiBuilderDefinition, + isUiBuilderDefinition +} from "./core"; +import {uniqueId} from "../../../services/util"; +import {el} from "../../utils/dom"; + +export interface EditorFormFieldDefinition { + label: string; + name: string; + type: 'text' | 'select' | 'textarea'; +} + +export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { + type: 'select', + valuesByLabel: Record +} + +interface EditorFormTabDefinition { + label: string; + contents: EditorFormFieldDefinition[]; +} + +export interface EditorFormDefinition { + submitText: string; + action: (formData: FormData, context: EditorUiContext) => Promise; + fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; +} + +export class EditorFormField extends EditorUiElement { + protected definition: EditorFormFieldDefinition; + + constructor(definition: EditorFormFieldDefinition) { + super(); + this.definition = definition; + } + + setValue(value: string) { + const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; + input.value = value; + } + + getName(): string { + return this.definition.name; + } + + protected buildDOM(): HTMLElement { + const id = `editor-form-field-${this.definition.name}-${Date.now()}`; + let input: HTMLElement; + + if (this.definition.type === 'select') { + const options = (this.definition as EditorSelectFormFieldDefinition).valuesByLabel + const labels = Object.keys(options); + const optionElems = labels.map(label => el('option', {value: options[label]}, [this.trans(label)])); + input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); + } else if (this.definition.type === 'textarea') { + input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'}); + } else { + input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); + } + + return el('div', {class: 'editor-form-field-wrapper'}, [ + el('label', {class: 'editor-form-field-label', for: id}, [this.trans(this.definition.label)]), + input, + ]); + } +} + +export class EditorForm extends EditorContainerUiElement { + protected definition: EditorFormDefinition; + protected onCancel: null|(() => void) = null; + protected onSuccessfulSubmit: null|(() => void) = null; + + constructor(definition: EditorFormDefinition) { + let children: (EditorFormField|EditorUiElement)[] = definition.fields.map(fieldDefinition => { + if (isUiBuilderDefinition(fieldDefinition)) { + return fieldDefinition.build(); + } + return new EditorFormField(fieldDefinition) + }); + + super(children); + this.definition = definition; + } + + setValues(values: Record) { + for (const name of Object.keys(values)) { + const field = this.getFieldByName(name); + if (field) { + field.setValue(values[name]); + } + } + } + + setOnCancel(callback: () => void) { + this.onCancel = callback; + } + + setOnSuccessfulSubmit(callback: () => void) { + this.onSuccessfulSubmit = callback; + } + + protected getFieldByName(name: string): EditorFormField|null { + + const search = (children: EditorUiElement[]): EditorFormField|null => { + for (const child of children) { + if (child instanceof EditorFormField && child.getName() === name) { + return child; + } else if (child instanceof EditorContainerUiElement) { + const matchingChild = search(child.getChildren()); + if (matchingChild) { + return matchingChild; + } + } + } + + return null; + }; + + return search(this.getChildren()); + } + + protected buildDOM(): HTMLElement { + const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans('Cancel')]); + const form = el('form', {}, [ + ...this.children.map(child => child.getDOMElement()), + el('div', {class: 'editor-form-actions'}, [ + cancelButton, + el('button', {type: 'submit', class: 'editor-form-action-primary'}, [this.trans(this.definition.submitText)]), + ]) + ]); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const formData = new FormData(form as HTMLFormElement); + const result = await this.definition.action(formData, this.getContext()); + if (result && this.onSuccessfulSubmit) { + this.onSuccessfulSubmit(); + } + }); + + cancelButton.addEventListener('click', (event) => { + if (this.onCancel) { + this.onCancel(); + } + }); + + return form; + } +} + +export class EditorFormTab extends EditorContainerUiElement { + + protected definition: EditorFormTabDefinition; + protected fields: EditorFormField[]; + protected id: string; + + constructor(definition: EditorFormTabDefinition) { + const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef)); + super(fields); + + this.definition = definition; + this.fields = fields; + this.id = uniqueId(); + } + + public getLabel(): string { + return this.getContext().translate(this.definition.label); + } + + public getId(): string { + return this.id; + } + + protected buildDOM(): HTMLElement { + return el( + 'div', + { + class: 'editor-form-tab-content', + role: 'tabpanel', + id: `editor-tabpanel-${this.id}`, + 'aria-labelledby': `editor-tab-${this.id}`, + }, + this.fields.map(f => f.getDOMElement()) + ); + } +} +export class EditorFormTabs extends EditorContainerUiElement { + + protected definitions: EditorFormTabDefinition[] = []; + protected tabs: EditorFormTab[] = []; + + constructor(definitions: EditorFormTabDefinition[]) { + const tabs: EditorFormTab[] = definitions.map(d => new EditorFormTab(d)); + super(tabs); + + this.definitions = definitions; + this.tabs = tabs; + } + + protected buildDOM(): HTMLElement { + const controls: HTMLElement[] = []; + const contents: HTMLElement[] = []; + + const selectTab = (tabIndex: number) => { + for (let i = 0; i < controls.length; i++) { + controls[i].setAttribute('aria-selected', (i === tabIndex) ? 'true' : 'false'); + } + for (let i = 0; i < contents.length; i++) { + contents[i].hidden = !(i === tabIndex); + } + }; + + for (const tab of this.tabs) { + const button = el('button', { + class: 'editor-form-tab-control', + type: 'button', + role: 'tab', + id: `editor-tab-${tab.getId()}`, + 'aria-controls': `editor-tabpanel-${tab.getId()}` + }, [tab.getLabel()]); + contents.push(tab.getDOMElement()); + controls.push(button); + + button.addEventListener('click', event => { + selectTab(controls.indexOf(button)); + }); + } + + selectTab(0); + + return el('div', {class: 'editor-form-tab-container'}, [ + el('div', {class: 'editor-form-tab-controls'}, controls), + el('div', {class: 'editor-form-tab-contents'}, contents), + ]); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts new file mode 100644 index 000000000..e8cef3c8d --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -0,0 +1,48 @@ + + + +interface HandleDropdownParams { + toggle: HTMLElement; + menu: HTMLElement; + showOnHover?: boolean, + onOpen?: Function | undefined; + onClose?: Function | undefined; +} + +export function handleDropdown(options: HandleDropdownParams) { + const {menu, toggle, onClose, onOpen, showOnHover} = options; + let clickListener: Function|null = null; + + const hide = () => { + menu.hidden = true; + if (clickListener) { + window.removeEventListener('click', clickListener as EventListener); + } + if (onClose) { + onClose(); + } + }; + + const show = () => { + menu.hidden = false + clickListener = (event: MouseEvent) => { + if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { + hide(); + } + } + window.addEventListener('click', clickListener as EventListener); + if (onOpen) { + onOpen(); + } + }; + + const toggleShowing = (event: MouseEvent) => { + menu.hasAttribute('hidden') ? show() : hide(); + }; + toggle.addEventListener('click', toggleShowing); + if (showOnHover) { + toggle.addEventListener('mouseenter', toggleShowing); + } + + menu.parentElement?.addEventListener('mouseleave', hide); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts b/resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts new file mode 100644 index 000000000..141f9b20f --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts @@ -0,0 +1,76 @@ + +export type MouseDragTrackerDistance = { + x: number; + y: number; +} + +export type MouseDragTrackerOptions = { + down?: (event: MouseEvent, element: HTMLElement) => any; + move?: (event: MouseEvent, element: HTMLElement, distance: MouseDragTrackerDistance) => any; + up?: (event: MouseEvent, element: HTMLElement, distance: MouseDragTrackerDistance) => any; +} + +export class MouseDragTracker { + protected container: HTMLElement; + protected dragTargetSelector: string; + protected options: MouseDragTrackerOptions; + + protected startX: number = 0; + protected startY: number = 0; + protected target: HTMLElement|null = null; + + constructor(container: HTMLElement, dragTargetSelector: string, options: MouseDragTrackerOptions) { + this.container = container; + this.dragTargetSelector = dragTargetSelector; + this.options = options; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.container.addEventListener('mousedown', this.onMouseDown); + } + + teardown() { + this.container.removeEventListener('mousedown', this.onMouseDown); + this.container.removeEventListener('mouseup', this.onMouseUp); + this.container.removeEventListener('mousemove', this.onMouseMove); + } + + protected onMouseDown(event: MouseEvent) { + this.target = (event.target as HTMLElement).closest(this.dragTargetSelector); + if (!this.target) { + return; + } + + this.startX = event.screenX; + this.startY = event.screenY; + + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + if (this.options.down) { + this.options.down(event, this.target); + } + } + + protected onMouseMove(event: MouseEvent) { + if (this.options.move && this.target) { + this.options.move(event, this.target, { + x: event.screenX - this.startX, + y: event.screenY - this.startY, + }); + } + } + + protected onMouseUp(event: MouseEvent) { + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); + + if (this.options.up && this.target) { + this.options.up(event, this.target, { + x: event.screenX - this.startX, + y: event.screenY - this.startY, + }); + } + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts new file mode 100644 index 000000000..2e4f2939c --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts @@ -0,0 +1,184 @@ +import {BaseSelection, LexicalNode,} from "lexical"; +import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; +import {el} from "../../../utils/dom"; +import {$isImageNode} from "../../../nodes/image"; +import {EditorUiContext} from "../core"; +import {NodeHasSize} from "../../../nodes/_common"; +import {$isMediaNode} from "../../../nodes/media"; + +function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode { + return $isImageNode(node) || $isMediaNode(node); +} + +class NodeResizer { + protected context: EditorUiContext; + protected dom: HTMLElement|null = null; + protected scrollContainer: HTMLElement; + + protected mouseTracker: MouseDragTracker|null = null; + protected activeSelection: string = ''; + + constructor(context: EditorUiContext) { + this.context = context; + this.scrollContainer = context.scrollDOM; + + this.onSelectionChange = this.onSelectionChange.bind(this); + context.manager.onSelectionChange(this.onSelectionChange); + } + + onSelectionChange(selection: BaseSelection|null) { + const nodes = selection?.getNodes() || []; + if (this.activeSelection) { + this.hide(); + } + + if (nodes.length === 1 && isNodeWithSize(nodes[0])) { + const node = nodes[0]; + const nodeKey = node.getKey(); + let nodeDOM = this.context.editor.getElementByKey(nodeKey); + + if (nodeDOM && nodeDOM.nodeName === 'SPAN') { + nodeDOM = nodeDOM.firstElementChild as HTMLElement; + } + + if (nodeDOM) { + this.showForNode(node, nodeDOM); + } + } + } + + teardown() { + this.context.manager.offSelectionChange(this.onSelectionChange); + this.hide(); + } + + protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) { + this.dom = this.buildDOM(); + + let ghost = el('span', {class: 'editor-node-resizer-ghost'}); + if ($isImageNode(node)) { + ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'}); + } + this.dom.append(ghost); + + this.context.scrollDOM.append(this.dom); + this.updateDOMPosition(dom); + + this.mouseTracker = this.setupTracker(this.dom, node, dom); + this.activeSelection = node.getKey(); + } + + protected updateDOMPosition(nodeDOM: HTMLElement) { + if (!this.dom) { + return; + } + + const scrollAreaRect = this.scrollContainer.getBoundingClientRect(); + const nodeRect = nodeDOM.getBoundingClientRect(); + const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop); + const left = nodeRect.left - scrollAreaRect.left; + + this.dom.style.top = `${top}px`; + this.dom.style.left = `${left}px`; + this.dom.style.width = nodeRect.width + 'px'; + this.dom.style.height = nodeRect.height + 'px'; + } + + protected updateDOMSize(width: number, height: number): void { + if (!this.dom) { + return; + } + + this.dom.style.width = width + 'px'; + this.dom.style.height = height + 'px'; + } + + protected hide() { + this.mouseTracker?.teardown(); + this.dom?.remove(); + this.activeSelection = ''; + } + + protected buildDOM() { + const handleClasses = ['nw', 'ne', 'se', 'sw']; + const handleElems = handleClasses.map(c => { + return el('div', {class: `editor-node-resizer-handle ${c}`}); + }); + + return el('div', { + class: 'editor-node-resizer', + }, handleElems); + } + + setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker { + let startingWidth: number = 0; + let startingHeight: number = 0; + let startingRatio: number = 0; + let hasHeight = false; + let _this = this; + let flipXChange: boolean = false; + let flipYChange: boolean = false; + + const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => { + let xChange = distance.x; + if (flipXChange) { + xChange = 0 - xChange; + } + let yChange = distance.y; + if (flipYChange) { + yChange = 0 - yChange; + } + + const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); + const increase = xChange + yChange > 0; + const directedChange = increase ? balancedChange : 0-balancedChange; + const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); + const newHeight = Math.round(newWidth * startingRatio); + + return {width: newWidth, height: newHeight}; + }; + + return new MouseDragTracker(container, '.editor-node-resizer-handle', { + down(event: MouseEvent, handle: HTMLElement) { + _this.dom?.classList.add('active'); + _this.context.editor.getEditorState().read(() => { + const domRect = nodeDOM.getBoundingClientRect(); + startingWidth = node.getWidth() || domRect.width; + startingHeight = node.getHeight() || domRect.height; + if (node.getHeight()) { + hasHeight = true; + } + startingRatio = startingHeight / startingWidth; + }); + + flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); + flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne'); + }, + move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + const size = calculateSize(distance); + _this.updateDOMSize(size.width, size.height); + }, + up(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + const size = calculateSize(distance); + _this.context.editor.update(() => { + node.setWidth(size.width); + node.setHeight(hasHeight ? size.height : 0); + _this.context.manager.triggerLayoutUpdate(); + requestAnimationFrame(() => { + _this.updateDOMPosition(nodeDOM); + }) + }); + _this.dom?.classList.remove('active'); + } + }); + } +} + + +export function registerNodeResizer(context: EditorUiContext): (() => void) { + const resizer = new NodeResizer(context); + + return () => { + resizer.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts new file mode 100644 index 000000000..37f1b6f01 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -0,0 +1,218 @@ +import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; +import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; +import {CustomTableNode} from "../../../nodes/custom-table"; +import {TableRowNode} from "@lexical/table"; +import {el} from "../../../utils/dom"; +import {$getTableColumnWidth, $setTableColumnWidth} from "../../../utils/tables"; + +type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; + +class TableResizer { + protected editor: LexicalEditor; + protected editScrollContainer: HTMLElement; + protected markerDom: MarkerDomRecord|null = null; + protected mouseTracker: MouseDragTracker|null = null; + protected dragging: boolean = false; + protected targetCell: HTMLElement|null = null; + protected xMarkerAtStart : boolean = false; + protected yMarkerAtStart : boolean = false; + + constructor(editor: LexicalEditor, editScrollContainer: HTMLElement) { + this.editor = editor; + this.editScrollContainer = editScrollContainer; + + this.setupListeners(); + } + + teardown() { + this.editScrollContainer.removeEventListener('mousemove', this.onCellMouseMove); + window.removeEventListener('scroll', this.onScrollOrResize, {capture: true}); + window.removeEventListener('resize', this.onScrollOrResize); + if (this.mouseTracker) { + this.mouseTracker.teardown(); + } + } + + protected setupListeners() { + this.onCellMouseMove = this.onCellMouseMove.bind(this); + this.onScrollOrResize = this.onScrollOrResize.bind(this); + this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove); + window.addEventListener('scroll', this.onScrollOrResize, {capture: true, passive: true}); + window.addEventListener('resize', this.onScrollOrResize, {passive: true}); + } + + protected onScrollOrResize(): void { + this.updateCurrentMarkerTargetPosition(); + } + + protected onCellMouseMove(event: MouseEvent) { + const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement; + if (!cell || this.dragging) { + return; + } + + const rect = cell.getBoundingClientRect(); + const midX = rect.left + (rect.width / 2); + const midY = rect.top + (rect.height / 2); + + this.targetCell = cell; + this.xMarkerAtStart = event.clientX <= midX; + this.yMarkerAtStart = event.clientY <= midY; + + const xMarkerPos = this.xMarkerAtStart ? rect.left : rect.right; + const yMarkerPos = this.yMarkerAtStart ? rect.top : rect.bottom; + this.updateMarkersTo(cell, xMarkerPos, yMarkerPos); + } + + protected updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) { + const markers: MarkerDomRecord = this.getMarkers(); + const table = cell.closest('table') as HTMLElement; + const tableRect = table.getBoundingClientRect(); + const editBounds = this.editScrollContainer.getBoundingClientRect(); + + const maxTop = Math.max(tableRect.top, editBounds.top); + const maxBottom = Math.min(tableRect.bottom, editBounds.bottom); + const maxHeight = maxBottom - maxTop; + markers.x.style.left = xPos + 'px'; + markers.x.style.top = maxTop + 'px'; + markers.x.style.height = maxHeight + 'px'; + + markers.y.style.top = yPos + 'px'; + markers.y.style.left = tableRect.left + 'px'; + markers.y.style.width = tableRect.width + 'px'; + + // Hide markers when out of bounds + markers.y.hidden = yPos < editBounds.top || yPos > editBounds.bottom; + markers.x.hidden = tableRect.top > editBounds.bottom || tableRect.bottom < editBounds.top; + } + + protected updateCurrentMarkerTargetPosition(): void { + if (!this.targetCell) { + return; + } + + const rect = this.targetCell.getBoundingClientRect(); + const xMarkerPos = this.xMarkerAtStart ? rect.left : rect.right; + const yMarkerPos = this.yMarkerAtStart ? rect.top : rect.bottom; + this.updateMarkersTo(this.targetCell, xMarkerPos, yMarkerPos); + } + + protected getMarkers(): MarkerDomRecord { + if (!this.markerDom) { + this.markerDom = { + x: el('div', {class: 'editor-table-marker editor-table-marker-column'}), + y: el('div', {class: 'editor-table-marker editor-table-marker-row'}), + } + const wrapper = el('div', { + class: 'editor-table-marker-wrap', + }, [this.markerDom.x, this.markerDom.y]); + this.editScrollContainer.after(wrapper); + this.watchMarkerMouseDrags(wrapper); + } + + return this.markerDom; + } + + protected watchMarkerMouseDrags(wrapper: HTMLElement) { + const _this = this; + let markerStart: number = 0; + let markerProp: 'left' | 'top' = 'left'; + + this.mouseTracker = new MouseDragTracker(wrapper, '.editor-table-marker', { + down(event: MouseEvent, marker: HTMLElement) { + marker.classList.add('active'); + _this.dragging = true; + + markerProp = marker.classList.contains('editor-table-marker-column') ? 'left' : 'top'; + markerStart = Number(marker.style[markerProp].replace('px', '')); + }, + move(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) { + marker.style[markerProp] = (markerStart + distance[markerProp === 'left' ? 'x' : 'y']) + 'px'; + }, + up(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) { + marker.classList.remove('active'); + marker.style.left = '0'; + marker.style.top = '0'; + + _this.dragging = false; + const parentTable = _this.targetCell?.closest('table'); + + if (markerProp === 'left' && _this.targetCell && parentTable) { + let cellIndex = _this.getTargetCellColumnIndex(); + let change = distance.x; + if (_this.xMarkerAtStart && cellIndex > 0) { + cellIndex -= 1; + } else if (_this.xMarkerAtStart && cellIndex === 0) { + change = -change; + } + + _this.editor.update(() => { + const table = $getNearestNodeFromDOMNode(parentTable); + if (table instanceof CustomTableNode) { + const originalWidth = $getTableColumnWidth(_this.editor, table, cellIndex); + const newWidth = Math.max(originalWidth + change, 10); + $setTableColumnWidth(table, cellIndex, newWidth); + } + }); + } + + if (markerProp === 'top' && _this.targetCell) { + const cellElement = _this.targetCell; + + _this.editor.update(() => { + const cellNode = $getNearestNodeFromDOMNode(cellElement); + const rowNode = cellNode?.getParent(); + let rowIndex = rowNode?.getIndexWithinParent() || 0; + + let change = distance.y; + if (_this.yMarkerAtStart && rowIndex > 0) { + rowIndex -= 1; + } else if (_this.yMarkerAtStart && rowIndex === 0) { + change = -change; + } + + const targetRow = rowNode?.getParent()?.getChildren()[rowIndex]; + if (targetRow instanceof TableRowNode) { + const height = targetRow.getHeight() || 0; + const newHeight = Math.max(height + change, 10); + targetRow.setHeight(newHeight); + } + }); + } + } + }); + } + + protected getTargetCellColumnIndex(): number { + const cell = this.targetCell; + if (cell === null) { + return -1; + } + + let index = 0; + const row = cell.parentElement; + for (const rowCell of row?.children || []) { + let size = Number(rowCell.getAttribute('colspan')); + if (Number.isNaN(size) || size < 1) { + size = 1; + } + + index += size; + + if (rowCell === cell) { + return index - 1; + } + } + + return -1; + } +} + + +export function registerTableResizer(editor: LexicalEditor, editScrollContainer: HTMLElement): (() => void) { + const resizer = new TableResizer(editor, editScrollContainer); + + return () => { + resizer.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts new file mode 100644 index 000000000..f631fb804 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -0,0 +1,79 @@ +import {$getNodeByKey, LexicalEditor} from "lexical"; +import {NodeKey} from "lexical/LexicalNode"; +import { + applyTableHandlers, + HTMLTableElementWithWithTableSelectionState, + TableNode, + TableObserver +} from "@lexical/table"; +import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table"; + +// File adapted from logic in: +// https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-react/src/LexicalTablePlugin.ts#L49 +// Copyright (c) Meta Platforms, Inc. and affiliates. +// License: MIT + +class TableSelectionHandler { + + protected editor: LexicalEditor + protected tableSelections = new Map(); + protected unregisterMutationListener = () => {}; + + constructor(editor: LexicalEditor) { + this.editor = editor; + this.init(); + } + + protected init() { + this.unregisterMutationListener = this.editor.registerMutationListener(CustomTableNode, (mutations) => { + for (const [nodeKey, mutation] of mutations) { + if (mutation === 'created') { + this.editor.getEditorState().read(() => { + const tableNode = $getNodeByKey(nodeKey); + if ($isCustomTableNode(tableNode)) { + this.initializeTableNode(tableNode); + } + }); + } else if (mutation === 'destroyed') { + const tableSelection = this.tableSelections.get(nodeKey); + + if (tableSelection !== undefined) { + tableSelection.removeListeners(); + this.tableSelections.delete(nodeKey); + } + } + } + }); + } + + protected initializeTableNode(tableNode: TableNode) { + const nodeKey = tableNode.getKey(); + const tableElement = this.editor.getElementByKey( + nodeKey, + ) as HTMLTableElementWithWithTableSelectionState; + if (tableElement && !this.tableSelections.has(nodeKey)) { + const tableSelection = applyTableHandlers( + tableNode, + tableElement, + this.editor, + false, + ); + this.tableSelections.set(nodeKey, tableSelection); + } + }; + + teardown() { + this.unregisterMutationListener(); + for (const [, tableSelection] of this.tableSelections) { + tableSelection.removeListeners(); + } + } +} + +export function registerTableSelectionHandler(editor: LexicalEditor): (() => void) { + const resizer = new TableSelectionHandler(editor); + + return () => { + resizer.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts new file mode 100644 index 000000000..da8c0eae3 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts @@ -0,0 +1,59 @@ +import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; +import {$isCustomListItemNode} from "../../../nodes/custom-list-item"; + +class TaskListHandler { + protected editorContainer: HTMLElement; + protected editor: LexicalEditor; + + constructor(editor: LexicalEditor, editorContainer: HTMLElement) { + this.editor = editor; + this.editorContainer = editorContainer; + this.setupListeners(); + } + + protected setupListeners() { + this.handleClick = this.handleClick.bind(this); + this.editorContainer.addEventListener('click', this.handleClick); + } + + handleClick(event: MouseEvent) { + const target = event.target; + if (target instanceof HTMLElement && target.nodeName === 'LI' && target.classList.contains('task-list-item')) { + this.handleTaskListItemClick(target, event); + event.preventDefault(); + } + } + + handleTaskListItemClick(listItem: HTMLElement, event: MouseEvent) { + const bounds = listItem.getBoundingClientRect(); + const withinBounds = event.clientX <= bounds.right + && event.clientX >= bounds.left + && event.clientY >= bounds.top + && event.clientY <= bounds.bottom; + + // Outside task list item bounds means we're probably clicking the pseudo-element + if (withinBounds) { + return; + } + + this.editor.update(() => { + const node = $getNearestNodeFromDOMNode(listItem); + if ($isCustomListItemNode(node)) { + node.setChecked(!node.getChecked()); + } + }); + } + + teardown() { + this.editorContainer.removeEventListener('click', this.handleClick); + } +} + + +export function registerTaskListHandler(editor: LexicalEditor, editorContainer: HTMLElement): (() => void) { + const handler = new TaskListHandler(editor, editorContainer); + + return () => { + handler.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts new file mode 100644 index 000000000..7c0975da7 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -0,0 +1,241 @@ +import {EditorFormModal, EditorFormModalDefinition} from "./modals"; +import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; +import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; +import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; +import {DecoratorListener} from "lexical/LexicalEditor"; +import type {NodeKey} from "lexical/LexicalNode"; +import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; +import {getLastSelection, setLastSelection} from "../../utils/selection"; + +export type SelectionChangeHandler = (selection: BaseSelection|null) => void; + +export class EditorUIManager { + + protected modalDefinitionsByKey: Record = {}; + protected activeModalsByKey: Record = {}; + protected decoratorConstructorsByType: Record = {}; + protected decoratorInstancesByNodeKey: Record = {}; + protected context: EditorUiContext|null = null; + protected toolbar: EditorContainerUiElement|null = null; + protected contextToolbarDefinitionsByKey: Record = {}; + protected activeContextToolbars: EditorContextToolbar[] = []; + protected selectionChangeHandlers: Set = new Set(); + + setContext(context: EditorUiContext) { + this.context = context; + this.setupEventListeners(context); + this.setupEditor(context.editor); + } + + getContext(): EditorUiContext { + if (this.context === null) { + throw new Error(`Context attempted to be used without being set`); + } + + return this.context; + } + + triggerStateUpdateForElement(element: EditorUiElement) { + element.updateState({ + selection: null, + editor: this.getContext().editor + }); + } + + registerModal(key: string, modalDefinition: EditorFormModalDefinition) { + this.modalDefinitionsByKey[key] = modalDefinition; + } + + createModal(key: string): EditorFormModal { + const modalDefinition = this.modalDefinitionsByKey[key]; + if (!modalDefinition) { + throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`); + } + + const modal = new EditorFormModal(modalDefinition, key); + modal.setContext(this.getContext()); + + return modal; + } + + setModalActive(key: string, modal: EditorFormModal): void { + this.activeModalsByKey[key] = modal; + } + + setModalInactive(key: string): void { + delete this.activeModalsByKey[key]; + } + + getActiveModal(key: string): EditorFormModal|null { + return this.activeModalsByKey[key]; + } + + registerDecoratorType(type: string, decorator: typeof EditorDecorator) { + this.decoratorConstructorsByType[type] = decorator; + } + + protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator { + if (this.decoratorInstancesByNodeKey[nodeKey]) { + return this.decoratorInstancesByNodeKey[nodeKey]; + } + + const decoratorClass = this.decoratorConstructorsByType[decoratorType]; + if (!decoratorClass) { + throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`); + } + + // @ts-ignore + const decorator = new decoratorClass(nodeKey); + this.decoratorInstancesByNodeKey[nodeKey] = decorator; + return decorator; + } + + getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null { + return this.decoratorInstancesByNodeKey[nodeKey] || null; + } + + setToolbar(toolbar: EditorContainerUiElement) { + if (this.toolbar) { + this.toolbar.getDOMElement().remove(); + } + + this.toolbar = toolbar; + toolbar.setContext(this.getContext()); + this.getContext().containerDOM.prepend(toolbar.getDOMElement()); + } + + registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) { + this.contextToolbarDefinitionsByKey[key] = definition; + } + + triggerStateUpdate(update: EditorUiStateUpdate): void { + setLastSelection(update.editor, update.selection); + this.toolbar?.updateState(update); + this.updateContextToolbars(update); + for (const toolbar of this.activeContextToolbars) { + toolbar.updateState(update); + } + this.triggerSelectionChange(update.selection); + } + + triggerStateRefresh(): void { + const editor = this.getContext().editor; + const update = { + editor, + selection: getLastSelection(editor), + }; + + this.triggerStateUpdate(update); + this.updateContextToolbars(update); + } + + triggerFutureStateRefresh(): void { + requestAnimationFrame(() => { + this.getContext().editor.getEditorState().read(() => { + this.triggerStateRefresh(); + }); + }); + } + + protected triggerSelectionChange(selection: BaseSelection|null): void { + if (!selection) { + return; + } + + for (const handler of this.selectionChangeHandlers) { + handler(selection); + } + } + + onSelectionChange(handler: SelectionChangeHandler): void { + this.selectionChangeHandlers.add(handler); + } + + offSelectionChange(handler: SelectionChangeHandler): void { + this.selectionChangeHandlers.delete(handler); + } + + triggerLayoutUpdate(): void { + window.requestAnimationFrame(() => { + for (const toolbar of this.activeContextToolbars) { + toolbar.updatePosition(); + } + }); + } + + getDefaultDirection(): 'rtl' | 'ltr' { + return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr'; + } + + protected updateContextToolbars(update: EditorUiStateUpdate): void { + for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { + const toolbar = this.activeContextToolbars[i]; + toolbar.destroy(); + this.activeContextToolbars.splice(i, 1); + } + + const node = (update.selection?.getNodes() || [])[0] || null; + if (!node) { + return; + } + + const element = update.editor.getElementByKey(node.getKey()); + if (!element) { + return; + } + + const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey); + const contentByTarget = new Map(); + for (const key of toolbarKeys) { + const definition = this.contextToolbarDefinitionsByKey[key]; + const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null; + if (matchingElem) { + const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem; + if (!contentByTarget.has(targetEl)) { + contentByTarget.set(targetEl, []) + } + // @ts-ignore + contentByTarget.get(targetEl).push(...definition.content); + } + } + + for (const [target, contents] of contentByTarget) { + const toolbar = new EditorContextToolbar(target, contents); + toolbar.setContext(this.getContext()); + this.activeContextToolbars.push(toolbar); + + this.getContext().containerDOM.append(toolbar.getDOMElement()); + toolbar.updatePosition(); + } + } + + protected setupEditor(editor: LexicalEditor) { + // Register our DOM decorate listener with the editor + const domDecorateListener: DecoratorListener = (decorators: Record) => { + editor.getEditorState().read(() => { + const keys = Object.keys(decorators); + for (const key of keys) { + const decoratedEl = editor.getElementByKey(key); + if (!decoratedEl) { + continue; + } + + const adapter = decorators[key]; + const decorator = this.getDecorator(adapter.type, key); + decorator.setNode(adapter.getNode()); + const decoratorEl = decorator.render(this.getContext(), decoratedEl); + if (decoratorEl) { + decoratedEl.append(decoratorEl); + } + } + }); + } + editor.registerDecoratorListener(domDecorateListener); + } + + protected setupEventListeners(context: EditorUiContext) { + const layoutUpdate = this.triggerLayoutUpdate.bind(this); + window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true}); + window.addEventListener('resize', layoutUpdate, {passive: true}); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts new file mode 100644 index 000000000..3eea62ebb --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -0,0 +1,74 @@ +import {EditorForm, EditorFormDefinition} from "./forms"; +import {EditorContainerUiElement} from "./core"; +import closeIcon from "@icons/close.svg"; +import {el} from "../../utils/dom"; + +export interface EditorModalDefinition { + title: string; +} + +export interface EditorFormModalDefinition extends EditorModalDefinition { + form: EditorFormDefinition; +} + +export class EditorFormModal extends EditorContainerUiElement { + protected definition: EditorFormModalDefinition; + protected key: string; + + constructor(definition: EditorFormModalDefinition, key: string) { + super([new EditorForm(definition.form)]); + this.definition = definition; + this.key = key; + } + + show(defaultValues: Record) { + const dom = this.getDOMElement(); + document.body.append(dom); + + const form = this.getForm(); + form.setValues(defaultValues); + form.setOnCancel(this.hide.bind(this)); + form.setOnSuccessfulSubmit(this.hide.bind(this)); + + this.getContext().manager.setModalActive(this.key, this); + } + + hide() { + this.getDOMElement().remove(); + this.getContext().manager.setModalInactive(this.key); + } + + getForm(): EditorForm { + return this.children[0] as EditorForm; + } + + protected buildDOM(): HTMLElement { + const closeButton = el('button', { + class: 'editor-modal-close', + type: 'button', + title: this.trans('Close'), + }); + closeButton.innerHTML = closeIcon; + closeButton.addEventListener('click', this.hide.bind(this)); + + const modal = el('div', {class: 'editor-modal editor-form-modal'}, [ + el('div', {class: 'editor-modal-header'}, [ + el('div', {class: 'editor-modal-title'}, [this.trans(this.definition.title)]), + closeButton, + ]), + el('div', {class: 'editor-modal-body'}, [ + this.getForm().getDOMElement(), + ]), + ]); + + const wrapper = el('div', {class: 'editor-modal-wrapper'}, [modal]); + + wrapper.addEventListener('click', event => { + if (event.target && !modal.contains(event.target as HTMLElement)) { + this.hide(); + } + }); + + return wrapper; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts new file mode 100644 index 000000000..b4e49af95 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -0,0 +1,72 @@ +import {EditorContainerUiElement, EditorUiElement} from "./core"; + +import {el} from "../../utils/dom"; + +export type EditorContextToolbarDefinition = { + selector: string; + content: EditorUiElement[], + displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement; +}; + +export class EditorContextToolbar extends EditorContainerUiElement { + + protected target: HTMLElement; + + constructor(target: HTMLElement, children: EditorUiElement[]) { + super(children); + this.target = target; + } + + protected buildDOM(): HTMLElement { + return el('div', { + class: 'editor-context-toolbar', + }, this.getChildren().map(child => child.getDOMElement())); + } + + updatePosition() { + const editorBounds = this.getContext().scrollDOM.getBoundingClientRect(); + const targetBounds = this.target.getBoundingClientRect(); + const dom = this.getDOMElement(); + const domBounds = dom.getBoundingClientRect(); + + const showing = targetBounds.bottom > editorBounds.top + && targetBounds.top < editorBounds.bottom; + + dom.hidden = !showing; + + if (!showing) { + return; + } + + const showAbove: boolean = targetBounds.bottom + 6 + domBounds.height > editorBounds.bottom; + dom.classList.toggle('is-above', showAbove); + + const targetMid = targetBounds.left + (targetBounds.width / 2); + const targetLeft = targetMid - (domBounds.width / 2); + if (showAbove) { + dom.style.top = (targetBounds.top - 6 - domBounds.height) + 'px'; + } else { + dom.style.top = (targetBounds.bottom + 6) + 'px'; + } + dom.style.left = targetLeft + 'px'; + } + + insert(children: EditorUiElement[]) { + this.addChildren(...children); + const dom = this.getDOMElement(); + dom.append(...children.map(child => child.getDOMElement())); + } + + protected empty() { + const children = this.getChildren(); + for (const child of children) { + child.getDOMElement().remove(); + } + this.removeChildren(...children); + } + + destroy() { + this.empty(); + this.getDOMElement().remove(); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts new file mode 100644 index 000000000..3811f44b9 --- /dev/null +++ b/resources/js/wysiwyg/ui/index.ts @@ -0,0 +1,73 @@ +import {LexicalEditor} from "lexical"; +import { + getCodeToolbarContent, + getImageToolbarContent, + getLinkToolbarContent, + getMainEditorFullToolbar, getTableToolbarContent +} from "./toolbars"; +import {EditorUIManager} from "./framework/manager"; +import {EditorUiContext} from "./framework/core"; +import {CodeBlockDecorator} from "./decorators/code-block"; +import {DiagramDecorator} from "./decorators/diagram"; +import {modals} from "./defaults/modals"; + +export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { + const manager = new EditorUIManager(); + const context: EditorUiContext = { + editor, + containerDOM: container, + editorDOM: element, + scrollDOM: scrollContainer, + manager, + translate(text: string): string { + const translations = options.translations; + return translations[text] || text; + }, + error(error: string|Error): void { + const message = error instanceof Error ? error.message : error; + window.$events.error(message); // TODO - Translate + }, + options, + }; + manager.setContext(context); + + // Create primary toolbar + manager.setToolbar(getMainEditorFullToolbar(context)); + + // Register modals + for (const key of Object.keys(modals)) { + manager.registerModal(key, modals[key]); + } + + // Register context toolbars + manager.registerContextToolbar('image', { + selector: 'img:not([drawio-diagram] img)', + content: getImageToolbarContent(), + }); + manager.registerContextToolbar('link', { + selector: 'a', + content: getLinkToolbarContent(), + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + const image = originalTarget.querySelector('img'); + return image || originalTarget; + } + }); + manager.registerContextToolbar('code', { + selector: '.editor-code-block-wrap', + content: getCodeToolbarContent(), + }); + + manager.registerContextToolbar('table', { + selector: 'td,th', + content: getTableToolbarContent(), + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + return originalTarget.closest('table') as HTMLTableElement; + } + }); + + // Register image decorator listener + manager.registerDecoratorType('code', CodeBlockDecorator); + manager.registerDecoratorType('diagram', DiagramDecorator); + + return context; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts new file mode 100644 index 000000000..35146e5a4 --- /dev/null +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -0,0 +1,256 @@ +import {EditorButton} from "./framework/buttons"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; +import {EditorFormatMenu} from "./framework/blocks/format-menu"; +import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; +import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; +import {EditorColorPicker} from "./framework/blocks/color-picker"; +import {EditorTableCreator} from "./framework/blocks/table-creator"; +import {EditorColorButton} from "./framework/blocks/color-button"; +import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; +import { + cellProperties, clearTableFormatting, + copyColumn, + copyRow, + cutColumn, + cutRow, + deleteColumn, + deleteRow, + deleteTable, + deleteTableMenuAction, + insertColumnAfter, + insertColumnBefore, + insertRowAbove, + insertRowBelow, + mergeCells, + pasteColumnAfter, + pasteColumnBefore, + pasteRowAfter, + pasteRowBefore, resizeTableToContents, + rowProperties, + splitCell, + table, tableProperties +} from "./defaults/buttons/tables"; +import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; +import { + blockquote, dangerCallout, + h2, + h3, + h4, + h5, + infoCallout, + paragraph, + successCallout, + warningCallout +} from "./defaults/buttons/block-formats"; +import { + bold, clearFormating, code, + highlightColor, + italic, + strikethrough, subscript, + superscript, + textColor, + underline +} from "./defaults/buttons/inline-formats"; +import { + alignCenter, + alignJustify, + alignLeft, + alignRight, + directionLTR, + directionRTL +} from "./defaults/buttons/alignments"; +import { + bulletList, + indentDecrease, + indentIncrease, + numberList, + taskList +} from "./defaults/buttons/lists"; +import { + codeBlock, + details, + diagram, diagramManager, + editCodeBlock, + horizontalRule, + image, + link, media, + unlink +} from "./defaults/buttons/objects"; +import {el} from "../utils/dom"; +import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; +import {EditorSeparator} from "./framework/blocks/separator"; + +export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { + + const inRtlMode = context.manager.getDefaultDirection() === 'rtl'; + + return new EditorSimpleClassContainer('editor-toolbar-main', [ + + // History state + new EditorOverflowContainer(2, [ + new EditorButton(undo), + new EditorButton(redo), + ]), + + // Block formats + new EditorFormatMenu([ + new FormatPreviewButton(el('h2'), h2), + new FormatPreviewButton(el('h3'), h3), + new FormatPreviewButton(el('h4'), h4), + new FormatPreviewButton(el('h5'), h5), + new FormatPreviewButton(el('blockquote'), blockquote), + new FormatPreviewButton(el('p'), paragraph), + new EditorDropdownButton({button: {label: 'Callouts', format: 'long'}, showOnHover: true, direction: 'vertical'}, [ + new FormatPreviewButton(el('p', {class: 'callout info'}), infoCallout), + new FormatPreviewButton(el('p', {class: 'callout success'}), successCallout), + new FormatPreviewButton(el('p', {class: 'callout warning'}), warningCallout), + new FormatPreviewButton(el('p', {class: 'callout danger'}), dangerCallout), + ]), + ]), + + // Inline formats + new EditorOverflowContainer(6, [ + new EditorButton(bold), + new EditorButton(italic), + new EditorButton(underline), + new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [ + new EditorColorPicker('color'), + ]), + new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [ + new EditorColorPicker('background-color'), + ]), + new EditorButton(strikethrough), + new EditorButton(superscript), + new EditorButton(subscript), + new EditorButton(code), + new EditorButton(clearFormating), + ]), + + // Alignment + new EditorOverflowContainer(6, [ + new EditorButton(alignLeft), + new EditorButton(alignCenter), + new EditorButton(alignRight), + new EditorButton(alignJustify), + inRtlMode ? new EditorButton(directionLTR) : null, + inRtlMode ? new EditorButton(directionRTL) : null, + ].filter(x => x !== null)), + + // Lists + new EditorOverflowContainer(3, [ + new EditorButton(bulletList), + new EditorButton(numberList), + new EditorButton(taskList), + new EditorButton(indentDecrease), + new EditorButton(indentIncrease), + ]), + + // Insert types + new EditorOverflowContainer(4, [ + new EditorButton(link), + + new EditorDropdownButton({button: table, direction: 'vertical'}, [ + new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [ + new EditorTableCreator(), + ]), + new EditorSeparator(), + new EditorDropdownButton({button: {label: 'Cell', format: 'long'}, direction: 'vertical', showOnHover: true}, [ + new EditorButton(cellProperties), + new EditorButton(mergeCells), + new EditorButton(splitCell), + ]), + new EditorDropdownButton({button: {label: 'Row', format: 'long'}, direction: 'vertical', showOnHover: true}, [ + new EditorButton({...insertRowAbove, format: 'long'}), + new EditorButton({...insertRowBelow, format: 'long'}), + new EditorButton({...deleteRow, format: 'long'}), + new EditorButton(rowProperties), + new EditorSeparator(), + new EditorButton(cutRow), + new EditorButton(copyRow), + new EditorButton(pasteRowBefore), + new EditorButton(pasteRowAfter), + ]), + new EditorDropdownButton({button: {label: 'Column', format: 'long'}, direction: 'vertical', showOnHover: true}, [ + new EditorButton({...insertColumnBefore, format: 'long'}), + new EditorButton({...insertColumnAfter, format: 'long'}), + new EditorButton({...deleteColumn, format: 'long'}), + new EditorSeparator(), + new EditorButton(cutColumn), + new EditorButton(copyColumn), + new EditorButton(pasteColumnBefore), + new EditorButton(pasteColumnAfter), + ]), + new EditorSeparator(), + new EditorButton({...tableProperties, format: 'long'}), + new EditorButton(clearTableFormatting), + new EditorButton(resizeTableToContents), + new EditorButton(deleteTableMenuAction), + ]), + + new EditorButton(image), + new EditorButton(horizontalRule), + new EditorButton(codeBlock), + new EditorButtonWithMenu( + new EditorButton(diagram), + [new EditorButton(diagramManager)], + ), + new EditorButton(media), + new EditorButton(details), + ]), + + // Meta elements + new EditorOverflowContainer(3, [ + new EditorButton(source), + new EditorButton(fullscreen), + + // Test + // new EditorButton({ + // label: 'Test button', + // action(context: EditorUiContext) { + // context.editor.update(() => { + // // Do stuff + // }); + // }, + // isActive() { + // return false; + // } + // }) + ]), + ]); +} + +export function getImageToolbarContent(): EditorUiElement[] { + return [new EditorButton(image)]; +} + +export function getLinkToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(link), + new EditorButton(unlink), + ]; +} + +export function getCodeToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(editCodeBlock), + ]; +} + +export function getTableToolbarContent(): EditorUiElement[] { + return [ + new EditorOverflowContainer(2, [ + new EditorButton(tableProperties), + new EditorButton(deleteTable), + ]), + new EditorOverflowContainer(3, [ + new EditorButton(insertRowAbove), + new EditorButton(insertRowBelow), + new EditorButton(deleteRow), + ]), + new EditorOverflowContainer(3, [ + new EditorButton(insertColumnBefore), + new EditorButton(insertColumnAfter), + new EditorButton(deleteColumn), + ]), + ]; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts new file mode 100644 index 000000000..ae829bae3 --- /dev/null +++ b/resources/js/wysiwyg/utils/actions.ts @@ -0,0 +1,69 @@ +import {$getRoot, $getSelection, LexicalEditor} from "lexical"; +import {$generateHtmlFromNodes} from "@lexical/html"; +import {$htmlToBlockNodes} from "./nodes"; + +export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { + editor.update(() => { + // Empty existing + const root = $getRoot(); + for (const child of root.getChildren()) { + child.remove(true); + } + + const nodes = $htmlToBlockNodes(editor, html); + root.append(...nodes); + }); +} + +export function appendHtmlToEditor(editor: LexicalEditor, html: string) { + editor.update(() => { + const root = $getRoot(); + const nodes = $htmlToBlockNodes(editor, html); + root.append(...nodes); + }); +} + +export function prependHtmlToEditor(editor: LexicalEditor, html: string) { + editor.update(() => { + const root = $getRoot(); + const nodes = $htmlToBlockNodes(editor, html); + let reference = root.getChildren()[0]; + for (let i = nodes.length - 1; i >= 0; i--) { + if (reference) { + reference.insertBefore(nodes[i]); + } else { + root.append(nodes[i]) + } + reference = nodes[i]; + } + }); +} + +export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) { + editor.update(() => { + const selection = $getSelection(); + const nodes = $htmlToBlockNodes(editor, html); + + const reference = selection?.getNodes()[0]; + const referencesParents = reference?.getParents() || []; + const topLevel = referencesParents[referencesParents.length - 1]; + if (topLevel && reference) { + for (let i = nodes.length - 1; i >= 0; i--) { + reference.insertAfter(nodes[i]); + } + } + }); +} + +export function getEditorContentAsHtml(editor: LexicalEditor): Promise { + return new Promise((resolve, reject) => { + editor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(editor); + resolve(html); + }); + }); +} + +export function focusEditor(editor: LexicalEditor) { + editor.focus(() => {}, {defaultSelection: "rootStart"}); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts new file mode 100644 index 000000000..fb5543005 --- /dev/null +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -0,0 +1,95 @@ +import {$getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; +import {HttpError} from "../../services/http"; +import {EditorUiContext} from "../ui/framework/core"; +import * as DrawIO from "../../services/drawio"; +import {$createDiagramNode, DiagramNode} from "../nodes/diagram"; +import {ImageManager} from "../../components"; +import {EditorImageData} from "./images"; +import {$getNodeFromSelection, getLastSelection} from "./selection"; + +export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { + return node instanceof DiagramNode; +} + +function handleUploadError(error: HttpError, context: EditorUiContext): void { + if (error.status === 413) { + window.$events.emit('error', context.options.translations.serverUploadLimitText || ''); + } else { + window.$events.emit('error', context.options.translations.imageUploadErrorText || ''); + } + console.error(error); +} + +async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise { + const drawingId = await new Promise((res, rej) => { + editor.getEditorState().read(() => { + const {id: drawingId} = node.getDrawingIdAndUrl(); + res(drawingId); + }); + }); + + return drawingId || ''; +} + +async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise { + DrawIO.close(); + + if (isNew) { + const loadingImage: string = window.baseUrl('/loading.gif'); + context.editor.update(() => { + node.setDrawingIdAndUrl('', loadingImage); + }); + } + + try { + const img = await DrawIO.upload(pngData, context.options.pageId); + context.editor.update(() => { + node.setDrawingIdAndUrl(String(img.id), img.url); + }); + } catch (err) { + if (err instanceof HttpError) { + handleUploadError(err, context); + } + + if (isNew) { + context.editor.update(() => { + node.remove(); + }); + } + + throw new Error(`Failed to save image with error: ${err}`); + } +} + +export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void { + let isNew = false; + DrawIO.show(context.options.drawioUrl, async () => { + const drawingId = await loadDiagramIdFromNode(context.editor, node); + isNew = !drawingId; + return isNew ? '' : DrawIO.load(drawingId); + }, async (pngData: string) => { + return updateDrawingNodeFromData(context, node, pngData, isNew); + }); +} + +export function showDiagramManager(callback: (image: EditorImageData) => any) { + const imageManager: ImageManager = window.$components.first('image-manager') as ImageManager; + imageManager.show((image: EditorImageData) => { + callback(image); + }, 'drawio'); +} + +export function showDiagramManagerForInsert(context: EditorUiContext) { + const selection = getLastSelection(context.editor); + showDiagramManager((image: EditorImageData) => { + context.editor.update(() => { + const diagramNode = $createDiagramNode(image.id, image.url); + const selectedDiagram = $getNodeFromSelection(selection, $isDiagramNode); + if ($isDiagramNode(selectedDiagram)) { + selectedDiagram.replace(diagramNode); + } else { + $insertNodes([diagramNode]); + } + }); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts new file mode 100644 index 000000000..bbb07cb41 --- /dev/null +++ b/resources/js/wysiwyg/utils/dom.ts @@ -0,0 +1,81 @@ +export function el(tag: string, attrs: Record = {}, children: (string | HTMLElement)[] = []): HTMLElement { + const el = document.createElement(tag); + const attrKeys = Object.keys(attrs); + for (const attr of attrKeys) { + if (attrs[attr] !== null) { + el.setAttribute(attr, attrs[attr] as string); + } + } + + for (const child of children) { + if (typeof child === 'string') { + el.append(document.createTextNode(child)); + } else { + el.append(child); + } + } + + return el; +} + +export function htmlToDom(html: string): Document { + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +} + +export function formatSizeValue(size: number | string, defaultSuffix: string = 'px'): string { + if (typeof size === 'number' || /^-?\d+$/.test(size)) { + return `${size}${defaultSuffix}`; + } + + return size; +} + +export function sizeToPixels(size: string): number { + if (/^-?\d+$/.test(size)) { + return Number(size); + } + + if (/^-?\d+\.\d+$/.test(size)) { + return Math.round(Number(size)); + } + + if (/^-?\d+px\s*$/.test(size)) { + return Number(size.trim().replace('px', '')); + } + + return 0; +} + +export type StyleMap = Map; + +/** + * Creates a map from an element's styles. + * Uses direct attribute value string handling since attempting to iterate + * over .style will expand out any shorthand properties (like 'padding') making + * rather than being representative of the actual properties set. + */ +export function extractStyleMapFromElement(element: HTMLElement): StyleMap { + const map: StyleMap = new Map(); + const styleText= element.getAttribute('style') || ''; + + const rules = styleText.split(';'); + for (const rule of rules) { + const [name, value] = rule.split(':'); + if (!name || !value) { + continue; + } + + map.set(name.trim().toLowerCase(), value.trim()); + } + + return map; +} + +export function setOrRemoveAttribute(element: HTMLElement, name: string, value: string|null|undefined) { + if (value) { + element.setAttribute(name, value); + } else { + element.removeAttribute(name); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts new file mode 100644 index 000000000..97038f07b --- /dev/null +++ b/resources/js/wysiwyg/utils/formats.ts @@ -0,0 +1,132 @@ +import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; +import {$createTextNode, $getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; +import { + $getBlockElementNodesInSelection, + $getNodeFromSelection, + $insertNewBlockNodeAtSelection, $selectionContainsNodeType, + $toggleSelectionBlockNodeType, + getLastSelection +} from "./selection"; +import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading"; +import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$createCustomQuoteNode} from "../nodes/custom-quote"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; +import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; +import {insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isCustomListNode} from "../nodes/custom-list"; +import {$createLinkNode, $isLinkNode} from "@lexical/link"; + +const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { + return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; +}; + +export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagType) { + editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => $isHeaderNodeOfTag(node, tag), + () => $createCustomHeadingNode(tag), + ) + }); +} + +export function toggleSelectionAsParagraph(editor: LexicalEditor) { + editor.update(() => { + $toggleSelectionBlockNodeType($isCustomParagraphNode, $createCustomParagraphNode); + }); +} + +export function toggleSelectionAsBlockquote(editor: LexicalEditor) { + editor.update(() => { + $toggleSelectionBlockNodeType($isQuoteNode, $createCustomQuoteNode); + }); +} + +export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $isCustomListNode(node) && (node as ListNode).getListType() === type; + }); + + if (listSelected) { + removeList(editor); + } else { + insertList(editor, type); + } + }); +} + +export function formatCodeBlock(editor: LexicalEditor) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const lastSelection = getLastSelection(editor); + const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); + if (codeBlock === null) { + editor.update(() => { + const codeBlock = $createCodeBlockNode(); + codeBlock.setCode(selection?.getTextContent() || ''); + $insertNewBlockNodeAtSelection(codeBlock, true); + $openCodeEditorForNode(editor, codeBlock); + codeBlock.selectStart(); + }); + } else { + $openCodeEditorForNode(editor, codeBlock); + } + }); +} + +export function cycleSelectionCalloutFormats(editor: LexicalEditor) { + editor.update(() => { + const selection = $getSelection(); + const blocks = $getBlockElementNodesInSelection(selection); + + let created = false; + for (const block of blocks) { + if (!$isCalloutNode(block)) { + block.replace($createCalloutNode('info'), true); + created = true; + } + } + + if (created) { + return; + } + + const types: CalloutCategory[] = ['info', 'warning', 'danger', 'success']; + for (const block of blocks) { + if ($isCalloutNode(block)) { + const type = block.getCategory(); + const typeIndex = types.indexOf(type); + const newIndex = (typeIndex + 1) % types.length; + const newType = types[newIndex]; + block.setCategory(newType); + } + } + }); +} + +export function insertOrUpdateLink(editor: LexicalEditor, linkDetails: {text: string, title: string, target: string, url: string}) { + editor.update(() => { + const selection = $getSelection(); + let link = $getNodeFromSelection(selection, $isLinkNode); + if ($isLinkNode(link)) { + link.setURL(linkDetails.url); + link.setTarget(linkDetails.target); + link.setTitle(linkDetails.title); + } else { + link = $createLinkNode(linkDetails.url, { + title: linkDetails.title, + target: linkDetails.target, + }); + + $insertNodes([link]); + } + + if ($isLinkNode(link)) { + for (const child of link.getChildren()) { + child.remove(true); + } + link.append($createTextNode(linkDetails.text)); + } + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts new file mode 100644 index 000000000..2c13427d9 --- /dev/null +++ b/resources/js/wysiwyg/utils/images.ts @@ -0,0 +1,44 @@ +import {ImageManager} from "../../components"; +import {$createImageNode} from "../nodes/image"; +import {$createLinkNode, LinkNode} from "@lexical/link"; + +export type EditorImageData = { + id: string; + url: string; + thumbs?: {display: string}; + name: string; +}; + +export function showImageManager(callback: (image: EditorImageData) => any) { + const imageManager: ImageManager = window.$components.first('image-manager') as ImageManager; + imageManager.show((image: EditorImageData) => { + callback(image); + }, 'gallery'); +} + +export function $createLinkedImageNodeFromImageData(image: EditorImageData): LinkNode { + const url = image.thumbs?.display || image.url; + const linkNode = $createLinkNode(url, {target: '_blank'}); + const imageNode = $createImageNode(url, { + alt: image.name + }); + linkNode.append(imageNode); + return linkNode; +} + +/** + * Upload an image file to the server + */ +export async function uploadImageFile(file: File, pageId: string): Promise { + if (file === null || file.type.indexOf('image') !== 0) { + throw new Error('Not an image file'); + } + + const remoteFilename = file.name || `image-${Date.now()}.png`; + const formData = new FormData(); + formData.append('file', file, remoteFilename); + formData.append('uploaded_to', pageId); + + const resp = await window.$http.post('/images/gallery', formData); + return resp.data as EditorImageData; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/links.ts b/resources/js/wysiwyg/utils/links.ts new file mode 100644 index 000000000..03c4a5ef0 --- /dev/null +++ b/resources/js/wysiwyg/utils/links.ts @@ -0,0 +1,16 @@ +import {EntitySelectorPopup} from "../../components"; + +type EditorEntityData = { + link: string; + name: string; +}; + +export function showLinkSelector(callback: (entity: EditorEntityData) => any, selectionText?: string) { + const selector: EntitySelectorPopup = window.$components.first('entity-selector-popup') as EntitySelectorPopup; + selector.show((entity: EditorEntityData) => callback(entity), { + initialValue: selectionText, + searchEndpoint: '/search/entity-selector', + entityTypes: 'page,book,chapter,bookshelf', + entityPermission: 'view', + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts new file mode 100644 index 000000000..edde994e5 --- /dev/null +++ b/resources/js/wysiwyg/utils/lists.ts @@ -0,0 +1,123 @@ +import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item"; +import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list"; +import {BaseSelection, LexicalEditor} from "lexical"; +import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection, getLastSelection} from "./selection"; +import {nodeHasInset} from "./nodes"; + + +export function $nestListItem(node: CustomListItemNode) { + const list = node.getParent(); + if (!$isCustomListNode(list)) { + return; + } + + const listItems = list.getChildren() as CustomListItemNode[]; + const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); + const isFirst = nodeIndex === 0; + + const newListItem = $createCustomListItemNode(); + const newList = $createCustomListNode(list.getListType()); + newList.append(newListItem); + newListItem.append(...node.getChildren()); + + if (isFirst) { + node.append(newList); + } else { + const prevListItem = listItems[nodeIndex - 1]; + prevListItem.append(newList); + node.remove(); + } +} + +export function $unnestListItem(node: CustomListItemNode) { + const list = node.getParent(); + const parentListItem = list?.getParent(); + const outerList = parentListItem?.getParent(); + if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) { + return; + } + + parentListItem.insertAfter(node); + if (list.getChildren().length === 0) { + list.remove(); + } + + if (parentListItem.getChildren().length === 0) { + parentListItem.remove(); + } +} + +function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] { + const nodes = selection?.getNodes() || []; + const listItemNodes = []; + + outer: for (const node of nodes) { + if ($isCustomListItemNode(node)) { + listItemNodes.push(node); + continue; + } + + const parents = node.getParents(); + for (const parent of parents) { + if ($isCustomListItemNode(parent)) { + listItemNodes.push(parent); + continue outer; + } + } + + listItemNodes.push(null); + } + + return listItemNodes; +} + +function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] { + const listItemMap: Record = {}; + + for (const item of listItems) { + if (item === null) { + continue; + } + + const key = item.getKey(); + if (typeof listItemMap[key] === 'undefined') { + listItemMap[key] = item; + } + } + + return Object.values(listItemMap); +} + +export function $setInsetForSelection(editor: LexicalEditor, change: number): void { + const selection = getLastSelection(editor); + + const listItemsInSelection = getListItemsForSelection(selection); + const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); + + if (isListSelection) { + const listItems = $reduceDedupeListItems(listItemsInSelection); + if (change > 0) { + for (const listItem of listItems) { + $nestListItem(listItem); + } + } else if (change < 0) { + for (const listItem of [...listItems].reverse()) { + $unnestListItem(listItem); + } + } + + $selectNodes(listItems); + return; + } + + const elements = $getBlockElementNodesInSelection(selection); + for (const node of elements) { + if (nodeHasInset(node)) { + const currentInset = node.getInset(); + const newInset = Math.min(Math.max(currentInset + change, 0), 500); + node.setInset(newInset) + } + } + + $toggleSelection(editor); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/node-clipboard.ts b/resources/js/wysiwyg/utils/node-clipboard.ts new file mode 100644 index 000000000..dd3b4dfbe --- /dev/null +++ b/resources/js/wysiwyg/utils/node-clipboard.ts @@ -0,0 +1,51 @@ +import {$isElementNode, LexicalEditor, LexicalNode, SerializedLexicalNode} from "lexical"; + +type SerializedLexicalNodeWithChildren = { + node: SerializedLexicalNode, + children: SerializedLexicalNodeWithChildren[], +}; + +function serializeNodeRecursive(node: LexicalNode): SerializedLexicalNodeWithChildren { + const childNodes = $isElementNode(node) ? node.getChildren() : []; + return { + node: node.exportJSON(), + children: childNodes.map(n => serializeNodeRecursive(n)), + }; +} + +function unserializeNodeRecursive(editor: LexicalEditor, {node, children}: SerializedLexicalNodeWithChildren): LexicalNode|null { + const instance = editor._nodes.get(node.type)?.klass.importJSON(node); + if (!instance) { + return null; + } + + const childNodes = children.map(child => unserializeNodeRecursive(editor, child)); + for (const child of childNodes) { + if (child && $isElementNode(instance)) { + instance.append(child); + } + } + + return instance; +} + +export class NodeClipboard { + protected store: SerializedLexicalNodeWithChildren[] = []; + + set(...nodes: LexicalNode[]): void { + this.store.splice(0, this.store.length); + for (const node of nodes) { + this.store.push(serializeNodeRecursive(node)); + } + } + + get(editor: LexicalEditor): T[] { + return this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => { + return node !== null; + }) as T[]; + } + + size(): number { + return this.store.length; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts new file mode 100644 index 000000000..48fbe043f --- /dev/null +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -0,0 +1,103 @@ +import { + $getRoot, + $isDecoratorNode, + $isElementNode, + $isTextNode, + ElementNode, + LexicalEditor, + LexicalNode +} from "lexical"; +import {LexicalNodeMatcher} from "../nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$generateNodesFromDOM} from "@lexical/html"; +import {htmlToDom} from "./dom"; +import {NodeHasAlignment, NodeHasInset} from "../nodes/_common"; +import {$findMatchingParent} from "@lexical/utils"; + +function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { + return nodes.map(node => { + if ($isTextNode(node)) { + const paragraph = $createCustomParagraphNode(); + paragraph.append(node); + return paragraph; + } + return node; + }); +} + +export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] { + const dom = htmlToDom(html); + const nodes = $generateNodesFromDOM(editor, dom); + return wrapTextNodes(nodes); +} + +export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null { + for (const parent of node.getParents()) { + if (matcher(parent)) { + return parent; + } + } + + return null; +} + +export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNode): LexicalNode[] { + if (!root) { + root = $getRoot(); + } + + const matches = []; + + for (const child of root.getChildren()) { + if (matcher(child)) { + matches.push(child); + } + + if ($isElementNode(child)) { + matches.push(...$getAllNodesOfType(matcher, child)); + } + } + + return matches; +} + +/** + * Get the nearest root/block level node for the given position. + */ +export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, y: number): LexicalNode | null { + // TODO - Take into account x for floated blocks? + const rootNodes = $getRoot().getChildren(); + for (const node of rootNodes) { + const nodeDom = editor.getElementByKey(node.__key); + if (!nodeDom) { + continue; + } + + const bounds = nodeDom.getBoundingClientRect(); + if (y <= bounds.bottom) { + return node; + } + } + + return null; +} + +export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null { + const isBlockNode = (node: LexicalNode): boolean => { + return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); + }; + + if (isBlockNode(node)) { + return node; + } + + return $findMatchingParent(node, isBlockNode); +} + +export function nodeHasAlignment(node: object): node is NodeHasAlignment { + return '__alignment' in node; +} + +export function nodeHasInset(node: object): node is NodeHasInset { + return '__inset' in node; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts new file mode 100644 index 000000000..f1055d98a --- /dev/null +++ b/resources/js/wysiwyg/utils/selection.ts @@ -0,0 +1,240 @@ +import { + $createNodeSelection, + $createParagraphNode, $createRangeSelection, + $getRoot, + $getSelection, $isBlockElementNode, $isDecoratorNode, + $isElementNode, + $isTextNode, + $setSelection, + BaseSelection, DecoratorNode, + ElementFormatType, + ElementNode, LexicalEditor, + LexicalNode, + TextFormatType, TextNode +} from "lexical"; +import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; +import {$setBlocksType} from "@lexical/selection"; + +import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {CommonBlockAlignment} from "../nodes/_common"; + +const lastSelectionByEditor = new WeakMap; + +export function getLastSelection(editor: LexicalEditor): BaseSelection|null { + return lastSelectionByEditor.get(editor) || null; +} + +export function setLastSelection(editor: LexicalEditor, selection: BaseSelection|null): void { + lastSelectionByEditor.set(editor, selection); +} + +export function $selectionContainsNodeType(selection: BaseSelection | null, matcher: LexicalNodeMatcher): boolean { + return $getNodeFromSelection(selection, matcher) !== null; +} + +export function $getNodeFromSelection(selection: BaseSelection | null, matcher: LexicalNodeMatcher): LexicalNode | null { + if (!selection) { + return null; + } + + for (const node of selection.getNodes()) { + if (matcher(node)) { + return node; + } + + const matchedParent = $getParentOfType(node, matcher); + if (matchedParent) { + return matchedParent; + } + } + + return null; +} + +export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean { + if (!selection) { + return false; + } + + for (const node of selection.getNodes()) { + if ($isTextNode(node) && node.hasFormat(format)) { + return true; + } + } + + return false; +} + +export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { + const selection = $getSelection(); + const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + if (selection && matcher(blockElement)) { + $setBlocksType(selection, $createCustomParagraphNode); + } else { + $setBlocksType(selection, creator); + } +} + +export function $insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: boolean = true) { + $insertNewBlockNodesAtSelection([node], insertAfter); +} + +export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfter: boolean = true) { + const selection = $getSelection(); + const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + + if (blockElement) { + if (insertAfter) { + for (let i = nodes.length - 1; i >= 0; i--) { + blockElement.insertAfter(nodes[i]); + } + } else { + for (const node of nodes) { + blockElement.insertBefore(node); + } + } + } else { + $getRoot().append(...nodes); + } +} + +export function $selectSingleNode(node: LexicalNode) { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(node.getKey()); + $setSelection(nodeSelection); +} + +function getFirstTextNodeInNodes(nodes: LexicalNode[]): TextNode|null { + for (const node of nodes) { + if ($isTextNode(node)) { + return node; + } + + if ($isElementNode(node)) { + const children = node.getChildren(); + const textNode = getFirstTextNodeInNodes(children); + if (textNode !== null) { + return textNode; + } + } + } + + return null; +} + +function getLastTextNodeInNodes(nodes: LexicalNode[]): TextNode|null { + const revNodes = [...nodes].reverse(); + for (const node of revNodes) { + if ($isTextNode(node)) { + return node; + } + + if ($isElementNode(node)) { + const children = [...node.getChildren()].reverse(); + const textNode = getLastTextNodeInNodes(children); + if (textNode !== null) { + return textNode; + } + } + } + + return null; +} + +export function $selectNodes(nodes: LexicalNode[]) { + if (nodes.length === 0) { + return; + } + + const selection = $createRangeSelection(); + const firstText = getFirstTextNodeInNodes(nodes); + const lastText = getLastTextNodeInNodes(nodes); + if (firstText && lastText) { + selection.setTextNodeRange(firstText, 0, lastText, lastText.getTextContentSize() || 0) + $setSelection(selection); + } +} + +export function $toggleSelection(editor: LexicalEditor) { + const lastSelection = getLastSelection(editor); + + if (lastSelection) { + window.requestAnimationFrame(() => { + editor.update(() => { + $setSelection(lastSelection.clone()); + }) + }); + } +} + +export function $selectionContainsNode(selection: BaseSelection | null, node: LexicalNode): boolean { + if (!selection) { + return false; + } + + const key = node.getKey(); + for (const node of selection.getNodes()) { + if (node.getKey() === key) { + return true; + } + } + + return false; +} + +export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean { + + const nodes = [ + ...(selection?.getNodes() || []), + ...$getBlockElementNodesInSelection(selection) + ]; + for (const node of nodes) { + if (nodeHasAlignment(node) && node.getAlignment() === alignment) { + return true; + } + } + + return false; +} + +export function $selectionContainsDirection(selection: BaseSelection | null, direction: 'rtl'|'ltr'): boolean { + + const nodes = [ + ...(selection?.getNodes() || []), + ...$getBlockElementNodesInSelection(selection) + ]; + + for (const node of nodes) { + if ($isBlockElementNode(node) && node.getDirection() === direction) { + return true; + } + } + + return false; +} + +export function $getBlockElementNodesInSelection(selection: BaseSelection | null): ElementNode[] { + if (!selection) { + return []; + } + + const blockNodes: Map = new Map(); + for (const node of selection.getNodes()) { + const blockElement = $getNearestNodeBlockParent(node); + if ($isElementNode(blockElement)) { + blockNodes.set(blockElement.getKey(), blockElement); + } + } + + return Array.from(blockNodes.values()); +} + +export function $getDecoratorNodesInSelection(selection: BaseSelection | null): DecoratorNode[] { + if (!selection) { + return []; + } + + return selection.getNodes().filter(node => $isDecoratorNode(node)); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts new file mode 100644 index 000000000..12c19b0fb --- /dev/null +++ b/resources/js/wysiwyg/utils/table-copy-paste.ts @@ -0,0 +1,272 @@ +import {NodeClipboard} from "./node-clipboard"; +import {CustomTableRowNode} from "../nodes/custom-table-row"; +import {$getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection} from "./tables"; +import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {$createCustomTableCellNode, $isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; +import {CustomTableNode} from "../nodes/custom-table"; +import {TableMap} from "./table-map"; +import {$isTableSelection} from "@lexical/table"; +import {$getNodeFromSelection} from "./selection"; + +const rowClipboard: NodeClipboard = new NodeClipboard(); + +export function isRowClipboardEmpty(): boolean { + return rowClipboard.size() === 0; +} + +export function validateRowsToCopy(rows: CustomTableRowNode[]): void { + let commonRowSize: number|null = null; + + for (const row of rows) { + const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + let rowSize = 0; + for (const cell of cells) { + rowSize += cell.getColSpan() || 1; + if (cell.getRowSpan() > 1) { + throw Error('Cannot copy rows with merged cells'); + } + } + + if (commonRowSize === null) { + commonRowSize = rowSize; + } else if (commonRowSize !== rowSize) { + throw Error('Cannot copy rows with inconsistent sizes'); + } + } +} + +export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: CustomTableNode): void { + const tableColCount = (new TableMap(targetTable)).columnCount; + for (const row of rows) { + const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + let rowSize = 0; + for (const cell of cells) { + rowSize += cell.getColSpan() || 1; + } + + if (rowSize > tableColCount) { + throw Error('Cannot paste rows that are wider than target table'); + } + + while (rowSize < tableColCount) { + row.append($createCustomTableCellNode()); + rowSize++; + } + } +} + +export function $cutSelectedRowsToClipboard(): void { + const rows = $getTableRowsFromSelection($getSelection()); + validateRowsToCopy(rows); + rowClipboard.set(...rows); + for (const row of rows) { + row.remove(); + } +} + +export function $copySelectedRowsToClipboard(): void { + const rows = $getTableRowsFromSelection($getSelection()); + validateRowsToCopy(rows); + rowClipboard.set(...rows); +} + +export function $pasteClipboardRowsBefore(editor: LexicalEditor): void { + const selection = $getSelection(); + const rows = $getTableRowsFromSelection(selection); + const table = $getTableFromSelection(selection); + const lastRow = rows[rows.length - 1]; + if (lastRow && table) { + const clipboardRows = rowClipboard.get(editor); + validateRowsToPaste(clipboardRows, table); + for (const row of clipboardRows) { + lastRow.insertBefore(row); + } + } +} + +export function $pasteClipboardRowsAfter(editor: LexicalEditor): void { + const selection = $getSelection(); + const rows = $getTableRowsFromSelection(selection); + const table = $getTableFromSelection(selection); + const lastRow = rows[rows.length - 1]; + if (lastRow && table) { + const clipboardRows = rowClipboard.get(editor).reverse(); + validateRowsToPaste(clipboardRows, table); + for (const row of clipboardRows) { + lastRow.insertAfter(row); + } + } +} + +const columnClipboard: NodeClipboard[] = []; + +function setColumnClipboard(columns: CustomTableCellNode[][]): void { + const newClipboards = columns.map(cells => { + const clipboard = new NodeClipboard(); + clipboard.set(...cells); + return clipboard; + }); + + columnClipboard.splice(0, columnClipboard.length, ...newClipboards); +} + +type TableRange = {from: number, to: number}; + +export function isColumnClipboardEmpty(): boolean { + return columnClipboard.length === 0; +} + +function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|null { + if ($isTableSelection(selection)) { + const shape = selection.getShape() + return {from: shape.fromX, to: shape.toX}; + } + + const cell = $getNodeFromSelection(selection, $isCustomTableCellNode); + const table = $getTableFromSelection(selection); + if (!$isCustomTableCellNode(cell) || !table) { + return null; + } + + const map = new TableMap(table); + const range = map.getRangeForCell(cell); + if (!range) { + return null; + } + + return {from: range.fromX, to: range.toX}; +} + +function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTableNode): CustomTableCellNode[][] { + const map = new TableMap(table); + const columns = []; + for (let x = range.from; x <= range.to; x++) { + const cells = map.getCellsInColumn(x); + columns.push(cells); + } + + return columns; +} + +function validateColumnsToCopy(columns: CustomTableCellNode[][]): void { + let commonColSize: number|null = null; + + for (const cells of columns) { + let colSize = 0; + for (const cell of cells) { + colSize += cell.getRowSpan() || 1; + if (cell.getColSpan() > 1) { + throw Error('Cannot copy columns with merged cells'); + } + } + + if (commonColSize === null) { + commonColSize = colSize; + } else if (commonColSize !== colSize) { + throw Error('Cannot copy columns with inconsistent sizes'); + } + } +} + +export function $cutSelectedColumnsToClipboard(): void { + const selection = $getSelection(); + const range = $getSelectionColumnRange(selection); + const table = $getTableFromSelection(selection); + if (!range || !table) { + return; + } + + const colWidths = table.getColWidths(); + const columns = $getTableColumnCellsFromSelection(range, table); + validateColumnsToCopy(columns); + setColumnClipboard(columns); + for (const cells of columns) { + for (const cell of cells) { + cell.remove(); + } + } + + const newWidths = [...colWidths].splice(range.from, (range.to - range.from) + 1); + table.setColWidths(newWidths); +} + +export function $copySelectedColumnsToClipboard(): void { + const selection = $getSelection(); + const range = $getSelectionColumnRange(selection); + const table = $getTableFromSelection(selection); + if (!range || !table) { + return; + } + + const columns = $getTableColumnCellsFromSelection(range, table); + validateColumnsToCopy(columns); + setColumnClipboard(columns); +} + +function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: CustomTableNode) { + const tableRowCount = (new TableMap(targetTable)).rowCount; + for (const cells of columns) { + let colSize = 0; + for (const cell of cells) { + colSize += cell.getRowSpan() || 1; + } + + if (colSize > tableRowCount) { + throw Error('Cannot paste columns that are taller than target table'); + } + + while (colSize < tableRowCount) { + cells.push($createCustomTableCellNode()); + colSize++; + } + } +} + +function $pasteClipboardColumns(editor: LexicalEditor, isBefore: boolean): void { + const selection = $getSelection(); + const table = $getTableFromSelection(selection); + const cells = $getTableCellsFromSelection(selection); + const referenceCell = cells[isBefore ? 0 : cells.length - 1]; + if (!table || !referenceCell) { + return; + } + + const clipboardCols = columnClipboard.map(cb => cb.get(editor)); + if (!isBefore) { + clipboardCols.reverse(); + } + + validateColumnsToPaste(clipboardCols, table); + const map = new TableMap(table); + const cellRange = map.getRangeForCell(referenceCell); + if (!cellRange) { + return; + } + + const colIndex = isBefore ? cellRange.fromX : cellRange.toX; + const colWidths = table.getColWidths(); + + for (let y = 0; y < map.rowCount; y++) { + const relCell = map.getCellAtPosition(colIndex, y); + for (const cells of clipboardCols) { + const newCell = cells[y]; + if (isBefore) { + relCell.insertBefore(newCell); + } else { + relCell.insertAfter(newCell); + } + } + } + + const refWidth = colWidths[colIndex]; + const addedWidths = clipboardCols.map(_ => refWidth); + colWidths.splice(isBefore ? colIndex : colIndex + 1, 0, ...addedWidths); +} + +export function $pasteClipboardColumnsBefore(editor: LexicalEditor): void { + $pasteClipboardColumns(editor, true); +} + +export function $pasteClipboardColumnsAfter(editor: LexicalEditor): void { + $pasteClipboardColumns(editor, false); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts new file mode 100644 index 000000000..607deffe1 --- /dev/null +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -0,0 +1,136 @@ +import {CustomTableNode} from "../nodes/custom-table"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; +import {$isTableRowNode} from "@lexical/table"; + +export type CellRange = { + fromX: number; + fromY: number; + toX: number; + toY: number; +} + +export class TableMap { + + rowCount: number = 0; + columnCount: number = 0; + + // Represents an array (rows*columns in length) of cell nodes from top-left to + // bottom right. Cells may repeat where merged and covering multiple spaces. + cells: CustomTableCellNode[] = []; + + constructor(table: CustomTableNode) { + this.buildCellMap(table); + } + + protected buildCellMap(table: CustomTableNode) { + const rowsAndCells: CustomTableCellNode[][] = []; + const setCell = (x: number, y: number, cell: CustomTableCellNode) => { + if (typeof rowsAndCells[y] === 'undefined') { + rowsAndCells[y] = []; + } + + rowsAndCells[y][x] = cell; + }; + const cellFilled = (x: number, y: number): boolean => !!(rowsAndCells[y] && rowsAndCells[y][x]); + + const rowNodes = table.getChildren().filter(r => $isTableRowNode(r)); + for (let rowIndex = 0; rowIndex < rowNodes.length; rowIndex++) { + const rowNode = rowNodes[rowIndex]; + const cellNodes = rowNode.getChildren().filter(c => $isCustomTableCellNode(c)); + let targetColIndex: number = 0; + for (let cellIndex = 0; cellIndex < cellNodes.length; cellIndex++) { + const cellNode = cellNodes[cellIndex]; + const colspan = cellNode.getColSpan() || 1; + const rowSpan = cellNode.getRowSpan() || 1; + for (let x = targetColIndex; x < targetColIndex + colspan; x++) { + for (let y = rowIndex; y < rowIndex + rowSpan; y++) { + while (cellFilled(x, y)) { + targetColIndex += 1; + x += 1; + } + + setCell(x, y, cellNode); + } + } + targetColIndex += colspan; + } + } + + this.rowCount = rowsAndCells.length; + this.columnCount = Math.max(...rowsAndCells.map(r => r.length)); + + const cells = []; + let lastCell: CustomTableCellNode = rowsAndCells[0][0]; + for (let y = 0; y < this.rowCount; y++) { + for (let x = 0; x < this.columnCount; x++) { + if (!rowsAndCells[y] || !rowsAndCells[y][x]) { + cells.push(lastCell); + } else { + cells.push(rowsAndCells[y][x]); + lastCell = rowsAndCells[y][x]; + } + } + } + + this.cells = cells; + } + + public getCellAtPosition(x: number, y: number): CustomTableCellNode { + const position = (y * this.columnCount) + x; + if (position >= this.cells.length) { + throw new Error(`TableMap Error: Attempted to get cell ${position+1} of ${this.cells.length}`); + } + + return this.cells[position]; + } + + public getCellsInRange(range: CellRange): CustomTableCellNode[] { + const minX = Math.max(Math.min(range.fromX, range.toX), 0); + const maxX = Math.min(Math.max(range.fromX, range.toX), this.columnCount - 1); + const minY = Math.max(Math.min(range.fromY, range.toY), 0); + const maxY = Math.min(Math.max(range.fromY, range.toY), this.rowCount - 1); + + const cells = new Set(); + + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + cells.add(this.getCellAtPosition(x, y)); + } + } + + return [...cells.values()]; + } + + public getCellsInColumn(columnIndex: number): CustomTableCellNode[] { + return this.getCellsInRange({ + fromX: columnIndex, + toX: columnIndex, + fromY: 0, + toY: this.rowCount - 1, + }); + } + + public getRangeForCell(cell: CustomTableCellNode): CellRange|null { + let range: CellRange|null = null; + const cellKey = cell.getKey(); + + for (let y = 0; y < this.rowCount; y++) { + for (let x = 0; x < this.columnCount; x++) { + const index = (y * this.columnCount) + x; + const lCell = this.cells[index]; + if (lCell.getKey() === cellKey) { + if (range === null) { + range = {fromX: x, toX: x, fromY: y, toY: y}; + } else { + range.fromX = Math.min(range.fromX, x); + range.toX = Math.max(range.toX, x); + range.fromY = Math.min(range.fromY, y); + range.toY = Math.max(range.toY, y); + } + } + } + } + + return range; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts new file mode 100644 index 000000000..aa8ec89ba --- /dev/null +++ b/resources/js/wysiwyg/utils/tables.ts @@ -0,0 +1,317 @@ +import {BaseSelection, LexicalEditor} from "lexical"; +import {$isTableRowNode, $isTableSelection, TableRowNode, TableSelection, TableSelectionShape} from "@lexical/table"; +import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; +import {$getParentOfType} from "./nodes"; +import {$getNodeFromSelection} from "./selection"; +import {formatSizeValue} from "./dom"; +import {TableMap} from "./table-map"; +import {$isCustomTableRowNode, CustomTableRowNode} from "../nodes/custom-table-row"; + +function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null { + return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null; +} + +export function getTableColumnWidths(table: HTMLTableElement): string[] { + const maxColRow = getMaxColRowFromTable(table); + + const colGroup = table.querySelector('colgroup'); + let widths: string[] = []; + if (colGroup && (colGroup.childElementCount === maxColRow?.childElementCount || !maxColRow)) { + widths = extractWidthsFromRow(colGroup); + } + if (widths.filter(Boolean).length === 0 && maxColRow) { + widths = extractWidthsFromRow(maxColRow); + } + + return widths; +} + +function getMaxColRowFromTable(table: HTMLTableElement): HTMLTableRowElement | null { + const rows = table.querySelectorAll('tr'); + let maxColCount: number = 0; + let maxColRow: HTMLTableRowElement | null = null; + + for (const row of rows) { + if (row.childElementCount > maxColCount) { + maxColRow = row; + maxColCount = row.childElementCount; + } + } + + return maxColRow; +} + +function extractWidthsFromRow(row: HTMLTableRowElement | HTMLTableColElement) { + return [...row.children].map(child => extractWidthFromElement(child as HTMLElement)) +} + +function extractWidthFromElement(element: HTMLElement): string { + let width = element.style.width || element.getAttribute('width'); + if (width && !Number.isNaN(Number(width))) { + width = width + 'px'; + } + + return width || ''; +} + +export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number|string): void { + const rows = node.getChildren() as TableRowNode[]; + let maxCols = 0; + for (const row of rows) { + const cellCount = row.getChildren().length; + if (cellCount > maxCols) { + maxCols = cellCount; + } + } + + let colWidths = node.getColWidths(); + if (colWidths.length === 0 || colWidths.length < maxCols) { + colWidths = Array(maxCols).fill(''); + } + + if (columnIndex + 1 > colWidths.length) { + console.error(`Attempted to set table column width for column [${columnIndex}] but only ${colWidths.length} columns found`); + } + + colWidths[columnIndex] = formatSizeValue(width); + node.setColWidths(colWidths); +} + +export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number { + const colWidths = node.getColWidths(); + if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) { + return Number(colWidths[columnIndex].replace('px', '')); + } + + // Otherwise, get from table element + const table = editor.getElementByKey(node.__key) as HTMLTableElement | null; + if (table) { + const maxColRow = getMaxColRowFromTable(table); + if (maxColRow && maxColRow.children.length > columnIndex) { + const cell = maxColRow.children[columnIndex]; + return cell.clientWidth; + } + } + + return 0; +} + +function $getCellColumnIndex(node: CustomTableCellNode): number { + const row = node.getParent(); + if (!$isTableRowNode(row)) { + return -1; + } + + let index = 0; + const cells = row.getChildren(); + for (const cell of cells) { + let colSpan = cell.getColSpan() || 1; + index += colSpan; + if (cell.getKey() === node.getKey()) { + break; + } + } + + return index - 1; +} + +export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: string): void { + const table = $getTableFromCell(cell) + const index = $getCellColumnIndex(cell); + + if (table && index >= 0) { + $setTableColumnWidth(table, index, width); + } +} + +export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTableCellNode): string { + const table = $getTableFromCell(cell) + const index = $getCellColumnIndex(cell); + if (!table) { + return ''; + } + + const widths = table.getColWidths(); + return (widths.length > index) ? widths[index] : ''; +} + +export function $getTableCellsFromSelection(selection: BaseSelection|null): CustomTableCellNode[] { + if ($isTableSelection(selection)) { + const nodes = selection.getNodes(); + return nodes.filter(n => $isCustomTableCellNode(n)); + } + + const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode; + return cell ? [cell] : []; +} + +export function $mergeTableCellsInSelection(selection: TableSelection): void { + const selectionShape = selection.getShape(); + const cells = $getTableCellsFromSelection(selection); + if (cells.length === 0) { + return; + } + + const table = $getTableFromCell(cells[0]); + if (!table) { + return; + } + + const tableMap = new TableMap(table); + const headCell = tableMap.getCellAtPosition(selectionShape.toX, selectionShape.toY); + if (!headCell) { + return; + } + + // We have to adjust the shape since it won't take into account spans for the head corner position. + const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1); + const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1); + + const mergeCells = tableMap.getCellsInRange({ + fromX: selectionShape.fromX, + fromY: selectionShape.fromY, + toX: fixedToX, + toY: fixedToY, + }); + + if (mergeCells.length === 0) { + return; + } + + const firstCell = mergeCells[0]; + const newWidth = Math.abs(selectionShape.fromX - fixedToX) + 1; + const newHeight = Math.abs(selectionShape.fromY - fixedToY) + 1; + + for (let i = 1; i < mergeCells.length; i++) { + const mergeCell = mergeCells[i]; + firstCell.append(...mergeCell.getChildren()); + mergeCell.remove(); + } + + firstCell.setColSpan(newWidth); + firstCell.setRowSpan(newHeight); +} + +export function $getTableRowsFromSelection(selection: BaseSelection|null): CustomTableRowNode[] { + const cells = $getTableCellsFromSelection(selection); + const rowsByKey: Record = {}; + for (const cell of cells) { + const row = cell.getParent(); + if ($isCustomTableRowNode(row)) { + rowsByKey[row.getKey()] = row; + } + } + + return Object.values(rowsByKey); +} + +export function $getTableFromSelection(selection: BaseSelection|null): CustomTableNode|null { + const cells = $getTableCellsFromSelection(selection); + if (cells.length === 0) { + return null; + } + + const table = $getParentOfType(cells[0], $isCustomTableNode); + if ($isCustomTableNode(table)) { + return table; + } + + return null; +} + +export function $clearTableSizes(table: CustomTableNode): void { + table.setColWidths([]); + + // TODO - Extra form things once table properties and extra things + // are supported + + for (const row of table.getChildren()) { + if (!$isCustomTableRowNode(row)) { + continue; + } + + const rowStyles = row.getStyles(); + rowStyles.delete('height'); + rowStyles.delete('width'); + row.setStyles(rowStyles); + + const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + for (const cell of cells) { + const cellStyles = cell.getStyles(); + cellStyles.delete('height'); + cellStyles.delete('width'); + cell.setStyles(cellStyles); + cell.clearWidth(); + } + } +} + +export function $clearTableFormatting(table: CustomTableNode): void { + table.setColWidths([]); + table.setStyles(new Map); + + for (const row of table.getChildren()) { + if (!$isCustomTableRowNode(row)) { + continue; + } + + row.setStyles(new Map); + row.setFormat(''); + + const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + for (const cell of cells) { + cell.setStyles(new Map); + cell.clearWidth(); + cell.setFormat(''); + } + } +} + +/** + * Perform the given callback for each cell in the given table. + * Returning false from the callback stops the function early. + */ +export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTableCellNode) => void|false): void { + outer: for (const row of table.getChildren()) { + if (!$isCustomTableRowNode(row)) { + continue; + } + const cells = row.getChildren(); + for (const cell of cells) { + if (!$isCustomTableCellNode(cell)) { + return; + } + const result = callback(cell); + if (result === false) { + break outer; + } + } + } +} + +export function $getCellPaddingForTable(table: CustomTableNode): string { + let padding: string|null = null; + + $forEachTableCell(table, (cell: CustomTableCellNode) => { + const cellPadding = cell.getStyles().get('padding') || '' + if (padding === null) { + padding = cellPadding; + } + + if (cellPadding !== padding) { + padding = null; + return false; + } + }); + + return padding || ''; +} + + + + + + + + diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 84fd4eae6..0a1a49151 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -32,6 +32,9 @@ margin-left: auto; margin-right: auto; } + .align-justify { + text-align: justify; + } h1, h2, h3, h4, h5, h6, pre { clear: left; } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss new file mode 100644 index 000000000..b33cb4d05 --- /dev/null +++ b/resources/sass/_editor.scss @@ -0,0 +1,608 @@ +// Common variables +:root { + --editor-color-primary: #206ea7; +} + +// Main UI elements +.editor-container { + background-color: #FFF; + position: relative; + &.fullscreen { + z-index: 500; + } +} +.editor-toolbar-main { + display: flex; + flex-wrap: wrap; + justify-content: center; + border-top: 1px solid #DDD; + border-bottom: 1px solid #DDD; +} + +body.editor-is-fullscreen { + overflow: hidden; + .edit-area { + z-index: 20; + } +} +.editor-content-area { + min-height: 100%; + padding-block: 1rem; + &:focus { + outline: 0; + } +} +.editor-content-wrap { + position: relative; + overflow-y: scroll; + flex: 1; +} + +// Buttons +.editor-button { + font-size: 12px; + padding: 4px; + color: #444; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + margin: 2px; +} +.editor-button:hover { + background-color: #EEE; + cursor: pointer; + color: #000; +} +.editor-button[disabled] { + pointer-events: none; + cursor: not-allowed; + opacity: .6; +} +.editor-button-active, .editor-button-active:hover { + background-color: #ceebff; + color: #000; +} +.editor-button-long { + display: flex !important; + flex-direction: row; + align-items: center; + justify-content: start; + gap: .5rem; +} +.editor-button-text { + font-weight: 400; + color: #000; + font-size: 14px; + flex: 1; + padding-inline-end: 4px; +} +.editor-button-format-preview { + padding: 4px 6px; + display: block; +} +.editor-button-long .editor-button-icon { + width: 24px; + height: 24px; +} +.editor-button-icon svg { + width: 24px; + height: 24px; + color: inherit; + fill: currentColor; + display: block; +} +.editor-menu-button-icon { + width: 24px; + height: 24px; + svg { + fill: #888; + } +} +.editor-container[dir="rtl"] .editor-menu-button-icon { + rotate: 180deg; +} +.editor-button-with-menu-container { + display: flex; + flex-direction: row; + gap: 0; + align-items: stretch; + border-radius: 4px; + .editor-dropdown-menu-container { + display: flex; + } + .editor-dropdown-menu-container > .editor-dropdown-menu { + top: 100%; + } + .editor-dropdown-menu-container > .editor-button { + padding-inline: 4px; + margin-inline-start: -3px; + svg { + width: 12px; + height: 12px; + } + } + &:hover { + outline: 1px solid #DDD; + outline-offset: -3px; + } +} + +// Containers +.editor-dropdown-menu-container { + position: relative; +} +.editor-dropdown-menu { + position: absolute; + background-color: #FFF; + box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); + z-index: 99; + display: flex; + flex-direction: row; +} +.editor-dropdown-menu-vertical { + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 160px; +} +.editor-dropdown-menu-vertical .editor-button { + border-bottom: 0; + text-align: start; + display: block; + width: 100%; +} +.editor-dropdown-menu-vertical > .editor-dropdown-menu-container .editor-dropdown-menu { + inset-inline-start: 100%; + top: 0; +} + +.editor-separator { + display: block; + height: 1px; + background-color: #DDD; + opacity: .8; +} + +.editor-format-menu-toggle { + width: 130px; + height: 32px; + font-size: 13px; + overflow: hidden; + padding-inline: 12px; + justify-content: start; + background-image: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: 98% 50%; + background-size: 28px; +} +.editor-container[dir="rtl"] .editor-format-menu-toggle { + background-position: 2% 50%; +} +.editor-format-menu .editor-dropdown-menu { + min-width: 300px; + .editor-dropdown-menu { + min-width: 220px; + } + .editor-button-icon { + display: none; + } +} +.editor-format-menu .editor-dropdown-menu .editor-dropdown-menu-container > .editor-button { + padding: 8px 10px; +} + +.editor-overflow-container { + display: flex; + border-inline: 1px solid #DDD; + padding-inline: 4px; + &:first-child { + border-inline-start: none; + } + &:last-child { + border-inline-end: none; + } + + .editor-overflow-container { + border-inline-start: none; + } +} + +.editor-context-toolbar { + position: fixed; + background-color: #FFF; + border: 1px solid #DDD; + padding: .2rem; + border-radius: 4px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: row; + &:before { + content: ''; + z-index: -1; + display: block; + width: 8px; + height: 8px; + position: absolute; + background-color: #FFF; + border-top: 1px solid #DDD; + border-left: 1px solid #DDD; + transform: rotate(45deg); + left: 50%; + margin-left: -4px; + top: -5px; + } + &.is-above:before { + top: calc(100% - 5px); + transform: rotate(225deg); + } +} + +// Modals +.editor-modal-wrapper { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + background-color: rgba(0, 0, 0, 0.5); + width: 100%; + height: 100%; +} +.editor-modal { + background-color: #FFF; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); +} +.editor-modal-header { + display: flex; + justify-content: space-between; + align-items: stretch; + background-color: var(--color-primary); + color: #FFF; +} +.editor-modal-title { + padding: 8px $-m; +} +.editor-modal-close { + color: #FFF; + padding: 8px $-m; + align-items: center; + justify-content: center; + cursor: pointer; + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + svg { + width: 1rem; + height: 1rem; + fill: currentColor; + display: block; + } +} +.editor-modal-body { + padding: $-m; +} + +// Specific UI elements +.editor-color-select-row { + display: flex; +} +.editor-color-select-option { + width: 28px; + height: 28px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.editor-color-select-option:hover { + border-radius: 3px; + box-sizing: border-box; + z-index: 3; + box-shadow: 0 0 4px 1px rgba(0, 0, 0, 0.25); +} +.editor-color-select-option[data-color=""] svg { + width: 20px; + height: 20px; + fill: #888; +} +.editor-table-creator-row { + display: flex; +} +.editor-table-creator-cell { + border: 1px solid #DDD; + width: 15px; + height: 15px; + cursor: pointer; + &.active { + background-color: var(--editor-color-primary); + } +} +.editor-table-creator-display { + text-align: center; + padding: 0.2em; +} + +// In-editor elements +.editor-image-wrap { + position: relative; + display: inline-flex; +} +.editor-node-resizer { + position: absolute; + left: 0; + right: auto; + display: inline-block; + outline: 2px dashed var(--editor-color-primary); + direction: ltr; +} +.editor-node-resizer-handle { + position: absolute; + display: block; + width: 10px; + height: 10px; + border: 2px solid var(--editor-color-primary); + z-index: 3; + background-color: #FFF; + user-select: none; + &.nw { + inset-inline-start: -5px; + inset-block-start: -5px; + cursor: nw-resize; + } + &.ne { + inset-inline-end: -5px; + inset-block-start: -5px; + cursor: ne-resize; + } + &.se { + inset-inline-end: -5px; + inset-block-end: -5px; + cursor: se-resize; + } + &.sw { + inset-inline-start: -5px; + inset-block-end: -5px; + cursor: sw-resize; + } +} +.editor-node-resizer-ghost { + opacity: 0.5; + display: none; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 2; + pointer-events: none; + background-color: var(--editor-color-primary); +} +.editor-node-resizer.active .editor-node-resizer-ghost { + display: block; +} + +.editor-table-marker { + position: fixed; + background-color: var(--editor-color-primary); + z-index: 99; + user-select: none; + opacity: 0; + &:hover, &.active { + opacity: 0.4; + } +} +.editor-table-marker-column { + width: 4px; + cursor: col-resize; +} +.editor-table-marker-row { + height: 4px; + cursor: row-resize; +} + +.editor-code-block-wrap { + user-select: none; + > * { + pointer-events: none; + } + &.selected .cm-editor { + border: 1px dashed var(--editor-color-primary); + } +} +.editor-diagram.selected { + outline: 2px dashed var(--editor-color-primary); +} + +.editor-media-wrap { + display: inline-block; + cursor: not-allowed; + iframe { + pointer-events: none; + } + &.align-left { + float: left; + } + &.align-right { + float: right; + } + &.align-center { + display: block; + margin-inline: auto; + } +} + +/** + * Fake task list checkboxes + */ +.editor-content-area .task-list-item { + margin-left: 0; + position: relative; +} +.editor-content-area .task-list-item > input[type="checkbox"] { + display: none; +} +.editor-content-area .task-list-item:before { + content: ''; + display: inline-block; + border: 2px solid #CCC; + width: 12px; + height: 12px; + border-radius: 2px; + margin-right: 8px; + vertical-align: text-top; + cursor: pointer; + position: absolute; + left: -24px; + top: 4px; +} +.editor-content-area .task-list-item[checked]:before { + background-color: #CCC; + background-image: url('data:image/svg+xml;utf8,'); + background-position: 50% 50%; + background-size: 100% 100%; +} + +/** + * Form elements + */ +.editor-form-field-wrapper { + margin-bottom: .5rem; +} +.editor-form-field-input { + display: block; + width: 100%; + min-width: 250px; + border: 1px solid #DDD; + padding: .5rem; + border-radius: 4px; + color: #444; +} +textarea.editor-form-field-input { + font-family: var(--font-code); + width: 350px; + height: 250px; + font-size: 12px; +} +.editor-form-field-label { + color: #444; + font-weight: 700; + font-size: 12px; +} +.editor-form-actions { + display: flex; + justify-content: end; + gap: $-s; + margin-top: $-m; +} +.editor-form-actions > button { + display: block; + font-size: 0.85rem; + line-height: 1.4em; + padding: $-xs*1.3 $-m; + font-weight: 400; + border-radius: 4px; + cursor: pointer; + box-shadow: none; + &:focus { + outline: 1px dotted currentColor; + outline-offset: -$-xs; + box-shadow: none; + filter: brightness(90%); + } +} +.editor-form-action-primary { + background-color: var(--color-primary); + color: #FFF; + border: 1px solid var(--color-primary); + &:hover { + @include lightDark(box-shadow, $bs-light, $bs-dark); + filter: brightness(110%); + } +} +.editor-form-action-secondary { + border: 1px solid; + @include lightDark(border-color, #CCC, #666); + @include lightDark(color, #666, #AAA); + &:hover, &:focus, &:active { + @include lightDark(color, #444, #BBB); + border: 1px solid #CCC; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); + background-color: #F2F2F2; + @include lightDark(background-color, #f8f8f8, #444); + filter: none; + } + &:active { + border-color: #BBB; + background-color: #DDD; + color: #666; + box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1); + } +} +.editor-form-tab-container { + display: flex; + flex-direction: row; + gap: 2rem; +} +.editor-form-tab-controls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: .25rem; +} +.editor-form-tab-control { + font-weight: bold; + font-size: 14px; + color: #444; + border-bottom: 2px solid transparent; + position: relative; + cursor: pointer; + padding: .25rem .5rem; + text-align: start; + &[aria-selected="true"] { + border-color: var(--editor-color-primary); + color: var(--editor-color-primary); + } + &[aria-selected="true"]:after, &:hover:after { + background-color: var(--editor-color-primary); + opacity: .15; + content: ''; + display: block; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + } +} +.editor-form-tab-contents { + width: 360px; +} +.editor-action-input-container { + display: flex; + flex-direction: row; + align-items: end; + justify-content: space-between; + gap: .1rem; + .editor-button { + margin-bottom: 12px; + } +} + +// Editor theme styles +.editor-theme-bold { + font-weight: bold; +} +.editor-theme-italic { + font-style: italic; +} +.editor-theme-strikethrough { + text-decoration-line: line-through; +} +.editor-theme-underline { + text-decoration-line: underline; +} +.editor-theme-underline-strikethrough { + text-decoration: underline line-through; +} \ No newline at end of file diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index ca59c85ca..426f7961c 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -2,13 +2,11 @@ display: flex; flex-direction: column; align-items: stretch; - overflow: hidden; .edit-area { flex: 1; flex-direction: column; z-index: 10; - overflow: hidden; border-radius: 0 0 8px 8px; } @@ -37,12 +35,15 @@ } @include larger-than($xxl) { + .page-editor-wysiwyg2024 .page-edit-toolbar, + .page-editor-wysiwyg2024 .page-editor-page-area, .page-editor-wysiwyg .page-edit-toolbar, .page-editor-wysiwyg .page-editor-page-area { max-width: 1140px; } - .page-editor-wysiwyg .floating-toolbox { + .page-editor-wysiwyg .floating-toolbox, + .page-editor-wysiwyg2024 .floating-toolbox { position: absolute; } } diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index f52b61992..636367e3a 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -15,6 +15,7 @@ @import "forms"; @import "animations"; @import "tinymce"; +@import "editor"; @import "codemirror"; @import "components"; @import "header"; diff --git a/resources/views/help/licenses.blade.php b/resources/views/help/licenses.blade.php index 1eb293523..09126ddad 100644 --- a/resources/views/help/licenses.blade.php +++ b/resources/views/help/licenses.blade.php @@ -54,6 +54,12 @@ License File: https://github.com/tinymce/tinymce/blob/release/6.7/LICENSE.TXT Copyright: Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. Link: https://github.com/tinymce/tinymce + ----------- + BookStack's newer WYSIWYG editor is based upon lexical code: + License: MIT + License File: https://github.com/facebook/lexical/blob/v0.17.1/LICENSE + Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. + Link: https://github.com/facebook/lexical diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php index d25f6a0a4..341fbf67d 100644 --- a/resources/views/pages/parts/editor-toolbar.blade.php +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -55,7 +55,7 @@
                                        1. - @if($editor === 'wysiwyg') + @if($editor !== \BookStack\Entities\Tools\PageEditorType::Markdown) @icon('swap-horizontal')
                                          @@ -72,12 +72,23 @@ {{ trans('entities.pages_edit_switch_to_markdown_stable') }}
                                          - @else + @endif + @if($editor !== \BookStack\Entities\Tools\PageEditorType::WysiwygTinymce) @icon('swap-horizontal')
                                          {{ trans('entities.pages_edit_switch_to_wysiwyg') }}
                                          @endif + @if($editor !== \BookStack\Entities\Tools\PageEditorType::WysiwygLexical) + + @icon('swap-horizontal') +
                                          + {{ trans('entities.pages_edit_switch_to_new_wysiwyg') }} +
                                          + {{ trans('entities.pages_edit_switch_to_new_wysiwyg_desc') }} +
                                          +
                                          + @endif
                                        2. @endif
                                      diff --git a/resources/views/pages/parts/form.blade.php b/resources/views/pages/parts/form.blade.php index e2c839cd2..e1104b406 100644 --- a/resources/views/pages/parts/form.blade.php +++ b/resources/views/pages/parts/form.blade.php @@ -32,14 +32,19 @@
                                      {{--Editors--}}
                                      + - {{--WYSIWYG Editor--}} - @if($editor === 'wysiwyg') + @if($editor === \BookStack\Entities\Tools\PageEditorType::WysiwygLexical) @include('pages.parts.wysiwyg-editor', ['model' => $model]) @endif + {{--WYSIWYG Editor (TinyMCE - Deprecated)--}} + @if($editor === \BookStack\Entities\Tools\PageEditorType::WysiwygTinymce) + @include('pages.parts.wysiwyg-editor-tinymce', ['model' => $model]) + @endif + {{--Markdown Editor--}} - @if($editor === 'markdown') + @if($editor === \BookStack\Entities\Tools\PageEditorType::Markdown) @include('pages.parts.markdown-editor', ['model' => $model]) @endif diff --git a/resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php b/resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php new file mode 100644 index 000000000..33c526a99 --- /dev/null +++ b/resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php @@ -0,0 +1,21 @@ +@push('head') + +@endpush + +
                                      + + +
                                      + +@if($errors->has('html')) +
                                      {{ $errors->first('html') }}
                                      +@endif + +@include('form.editor-translations') \ No newline at end of file diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 84a267b68..73e655752 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -1,17 +1,17 @@ -@push('head') - -@endpush -
                                      + class="flex-container-column flex-fill flex"> - +
                                      +
                                      + +{{--
                                      --}} + +
                                      @if($errors->has('html')) diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 4845e2055..70a490298 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -32,6 +32,7 @@
                                      diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index b2fb85955..934024956 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -4,6 +4,7 @@ namespace Tests\Entity; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; +use BookStack\Entities\Tools\PageEditorType; use Tests\TestCase; class PageEditorTest extends TestCase @@ -25,7 +26,7 @@ class PageEditorTest extends TestCase public function test_markdown_setting_shows_markdown_editor_for_new_pages() { - $this->setSettings(['app-editor' => 'markdown']); + $this->setSettings(['app-editor' => PageEditorType::Markdown->value]); $resp = $this->asAdmin()->get($this->page->book->getUrl('/create-page')); $this->withHtml($this->followRedirects($resp)) @@ -37,7 +38,7 @@ class PageEditorTest extends TestCase { $mdContent = '# hello. This is a test'; $this->page->markdown = $mdContent; - $this->page->editor = 'markdown'; + $this->page->editor = PageEditorType::Markdown; $this->page->save(); $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); @@ -135,6 +136,19 @@ class PageEditorTest extends TestCase $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=wysiwyg')); $resp->assertStatus(200); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor-tinymce"]'); + $resp->assertSee("

                                      A Header

                                      \n

                                      Some content with bold text!

                                      ", true); + } + + public function test_switching_from_markdown_to_wysiwyg2024_works() + { + $page = $this->entities->page(); + $page->html = ''; + $page->markdown = "## A Header\n\nSome content with **bold** text!"; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=wysiwyg2024')); + $resp->assertStatus(200); $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); $resp->assertSee("

                                      A Header

                                      \n

                                      Some content with bold text!

                                      ", true); } @@ -142,7 +156,7 @@ class PageEditorTest extends TestCase public function test_page_editor_changes_with_editor_property() { $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); - $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor-tinymce"]'); $this->page->markdown = "## A Header\n\nSome content with **bold** text!"; $this->page->editor = 'markdown'; @@ -150,6 +164,12 @@ class PageEditorTest extends TestCase $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); $this->withHtml($resp)->assertElementExists('[component="markdown-editor"]'); + + $this->page->editor = 'wysiwyg2024'; + $this->page->save(); + + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); } public function test_editor_type_switch_options_show() @@ -158,6 +178,7 @@ class PageEditorTest extends TestCase $editLink = $this->page->getUrl('/edit') . '?editor='; $this->withHtml($resp)->assertElementContains("a[href=\"${editLink}markdown-clean\"]", '(Clean Content)'); $this->withHtml($resp)->assertElementContains("a[href=\"${editLink}markdown-stable\"]", '(Stable Content)'); + $this->withHtml($resp)->assertElementContains("a[href=\"${editLink}wysiwyg2024\"]", '(In Alpha Testing)'); $resp = $this->asAdmin()->get($this->page->getUrl('/edit?editor=markdown-stable')); $editLink = $this->page->getUrl('/edit') . '?editor='; @@ -179,7 +200,7 @@ class PageEditorTest extends TestCase $resp = $this->asEditor()->get($page->getUrl('/edit?editor=markdown-stable')); $resp->assertStatus(200); - $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor-tinymce"]'); $this->withHtml($resp)->assertElementNotExists('[component="markdown-editor"]'); } @@ -193,4 +214,40 @@ class PageEditorTest extends TestCase $this->asEditor()->put($page->getUrl(), ['name' => $page->name, 'markdown' => '## Updated content abc']); $this->assertEquals('wysiwyg', $page->refresh()->editor); } + + public function test_editor_type_change_to_wysiwyg_infers_type_from_request_or_uses_system_default() + { + $tests = [ + [ + 'setting' => 'wysiwyg', + 'request' => 'wysiwyg2024', + 'expected' => 'wysiwyg2024', + ], + [ + 'setting' => 'wysiwyg2024', + 'request' => 'wysiwyg', + 'expected' => 'wysiwyg', + ], + [ + 'setting' => 'wysiwyg', + 'request' => null, + 'expected' => 'wysiwyg', + ], + [ + 'setting' => 'wysiwyg2024', + 'request' => null, + 'expected' => 'wysiwyg2024', + ] + ]; + + $page = $this->entities->page(); + foreach ($tests as $test) { + $page->editor = 'markdown'; + $page->save(); + + $this->setSettings(['app-editor' => $test['setting']]); + $this->asAdmin()->put($page->getUrl(), ['name' => $page->name, 'html' => '

                                      Hello

                                      ', 'editor' => $test['request']]); + $this->assertEquals($test['expected'], $page->refresh()->editor, "Failed asserting global editor {$test['setting']} with request editor {$test['request']} results in {$test['expected']} set for the page"); + } + } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..8bffc25f8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["resources/js/**/*"], + "exclude": ["resources/js/wysiwyg/lexical/yjs/*"], + "compilerOptions": { + "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/*"] + }, + "resolveJsonModule": true, + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +}