diff --git a/.eslintrc.js b/.eslintrc.js index 24949684e5d..a985cc4c07d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -111,6 +111,7 @@ module.exports = { }, { files: [ + 'scripts/__tests__/**', 'packages/**/src/__tests__/**', 'packages/lexical-playground/**', 'packages/lexical-devtools/**', @@ -166,7 +167,6 @@ module.exports = { // import helps to configure simple-import-sort 'import', - 'jest', 'no-function-declare-after-return', 'react', 'no-only-tests', diff --git a/.github/workflows/call-integration-tests.yml b/.github/workflows/call-integration-tests.yml index 6e2c418ad27..b0eeed6eb35 100644 --- a/.github/workflows/call-integration-tests.yml +++ b/.github/workflows/call-integration-tests.yml @@ -25,3 +25,4 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - run: pnpm run test-integration + - run: pnpm run test-eslint-integration diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 93715f2625b..00000000000 --- a/jest.config.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -'use strict'; - -const common = { - modulePathIgnorePatterns: ['/npm'], -}; - -module.exports = { - projects: [ - { - ...common, - displayName: 'integration', - globalSetup: './scripts/__tests__/integration/setup.js', - testMatch: ['**/scripts/__tests__/integration/**/*.test.js'], - }, - ], -}; diff --git a/package.json b/package.json index dda8995297c..e646d95695f 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,10 @@ "collab": "cross-env HOST=localhost PORT=1234 npx y-websocket", "validation": "npx ts-node --cwdMode packages/lexical-playground/src/server/validation.ts", "vitest": "vitest", - "test-unit": "vitest --no-watch", - "test-unit-watch": "vitest", - "test-integration": "jest --selectProjects integration --testPathPattern", + "test-unit": "vitest --project unit --project scripts-unit --no-watch", + "test-unit-watch": "vitest --project unit --project scripts-unit", + "test-eslint-integration": "node packages/lexical-eslint-plugin/__tests__/integration/integration-test.mjs", + "test-integration": "vitest --project integration --no-watch", "test-e2e-chromium": "cross-env E2E_BROWSER=chromium playwright test --project=\"chromium\"", "test-e2e-firefox": "cross-env E2E_BROWSER=firefox playwright test --project=\"firefox\"", "test-e2e-webkit": "cross-env E2E_BROWSER=webkit playwright test --project=\"webkit\"", @@ -123,7 +124,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", "@types/fs-extra": "^8.1.5", - "@types/jest": "^29.5.12", "@types/jsdom": "^21.1.6", "@types/katex": "^0.16.7", "@types/node": "^17.0.31", @@ -148,7 +148,6 @@ "eslint-plugin-ft-flow": "^3.0.7", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jest": "^28.5.0", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-no-function-declare-after-return": "^1.1.0", "eslint-plugin-no-only-tests": "^3.1.0", @@ -166,8 +165,6 @@ "hermes-parser": "^0.26.0", "hermes-transform": "^0.26.0", "husky": "^7.0.1", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", "jsdom": "^24.0.0", "lint-staged": "^11.1.0", "minimist": "^1.2.5", @@ -182,7 +179,6 @@ "react-test-renderer": "^19.1.1", "rollup": "^4.59.0", "tmp": "^0.2.1", - "ts-jest": "^29.1.2", "ts-morph": "^25.0.1", "ts-node": "^10.9.1", "typedoc": "^0.28.10", diff --git a/packages/lexical-eslint-plugin-internal/package.json b/packages/lexical-eslint-plugin-internal/package.json index e078b616bd9..345a6970607 100644 --- a/packages/lexical-eslint-plugin-internal/package.json +++ b/packages/lexical-eslint-plugin-internal/package.json @@ -5,6 +5,6 @@ "main": "src/index.js", "private": true, "peerDependencies": { - "eslint": "^7.31.0 || ^8.0.0" + "eslint": ">=7.31.0" } } diff --git a/packages/lexical-eslint-plugin-internal/src/index.js b/packages/lexical-eslint-plugin-internal/src/index.js index 793a67b9859..3ee0357164e 100644 --- a/packages/lexical-eslint-plugin-internal/src/index.js +++ b/packages/lexical-eslint-plugin-internal/src/index.js @@ -10,20 +10,41 @@ const rules = require('./rules'); -module.exports = { +// Legacy config format (ESLint 7-8) +const legacyAll = { + rules: { + '@lexical/internal/no-imports-from-self': 'error', + '@lexical/internal/no-optional-chaining': 'error', + }, +}; + +const plugin = { configs: { - all: { - rules: { - '@lexical/internal/no-imports-from-self': 'error', - '@lexical/internal/no-optional-chaining': 'error', - }, - }, - recommended: { - rules: { - '@lexical/internal/no-imports-from-self': 'error', - '@lexical/internal/no-optional-chaining': 'error', - }, - }, + // Legacy configs (ESLint 7-8) - available under multiple names for compatibility + all: legacyAll, + // Flat configs (ESLint 9-10+) - placeholders, will be set below + 'flat/all': /** @type {any} */ (null), + 'flat/recommended': /** @type {any} */ (null), + 'legacy-all': legacyAll, + 'legacy-recommended': legacyAll, + recommended: legacyAll, }, rules, }; + +// Flat config format (ESLint 9-10+) +// Must be created after plugin is defined to avoid circular reference +const flatAll = { + plugins: { + '@lexical/internal': plugin, + }, + rules: { + '@lexical/internal/no-imports-from-self': 'error', + '@lexical/internal/no-optional-chaining': 'error', + }, +}; + +plugin.configs['flat/all'] = flatAll; +plugin.configs['flat/recommended'] = flatAll; + +module.exports = plugin; diff --git a/packages/lexical-eslint-plugin-internal/src/rules/no-optional-chaining.js b/packages/lexical-eslint-plugin-internal/src/rules/no-optional-chaining.js index 4605cb71c45..c99f50d6c5e 100644 --- a/packages/lexical-eslint-plugin-internal/src/rules/no-optional-chaining.js +++ b/packages/lexical-eslint-plugin-internal/src/rules/no-optional-chaining.js @@ -15,7 +15,9 @@ /** @type {import('eslint').Rule.RuleModule} */ module.exports = { create(context) { - const sourceCode = context.getSourceCode(); + // ESLint 9+ provides sourceCode directly on context (required in ESLint 10+) + // ESLint 7-8 requires calling getSourceCode() method + const sourceCode = context.sourceCode || context.getSourceCode(); /** * Checks if the given token is a `?.` token or not. diff --git a/packages/lexical-eslint-plugin/README.md b/packages/lexical-eslint-plugin/README.md index b2267fec79f..d594761b465 100644 --- a/packages/lexical-eslint-plugin/README.md +++ b/packages/lexical-eslint-plugin/README.md @@ -2,6 +2,8 @@ This ESLint plugin enforces the [Lexical $function convention](https://lexical.dev/docs/intro#reading-and-updating-editor-state). +**ESLint Compatibility:** This plugin supports ESLint 7, 8, 9, and 10+. Both legacy (`.eslintrc`) and flat config (`eslint.config.js`) formats are supported. + ## Installation Assuming you already have ESLint installed, run: @@ -10,20 +12,54 @@ Assuming you already have ESLint installed, run: npm install @lexical/eslint-plugin --save-dev ``` -Then extend the recommended eslint config: +### ESLint 9+ (Flat Config) + +If you're using ESLint 9 or later with the new flat config format (required in ESLint 10+), add this to your `eslint.config.js`: + +```js +import lexical from '@lexical/eslint-plugin'; + +export default [ + // ... other configs + lexical.configs['flat/recommended'] +]; +``` + +### ESLint 7-8 (Legacy Config) + +For ESLint 7 or 8 with the legacy `.eslintrc` format, extend the recommended config: ```js { "extends": [ // ... - "plugin:@lexical/recommended" + "plugin:@lexical/legacy-recommended" ] } ``` +> **Note:** The `recommended` and `all` configs are currently aliases to `legacy-recommended` and `legacy-all`. `all` and `recommended` will be migrated to flat config in a future version. + ### Custom Configuration -If you want more fine-grained configuration, you can instead add a snippet like this to your ESLint configuration file: +#### ESLint 9+ (Flat Config) + +```js +import lexical from '@lexical/eslint-plugin'; + +export default [ + { + plugins: { + '@lexical': lexical + }, + rules: { + '@lexical/rules-of-lexical': 'error' + } + } +]; +``` + +#### ESLint 7-8 (Legacy Config) ```js { @@ -53,6 +89,39 @@ If the string begins with a `"^"` or `"("` then it is treated as a RegExp, otherwise it will be an exact match. A string may also be used instead of an array of strings. +#### ESLint 9+ (Flat Config) + +```js +import lexical from '@lexical/eslint-plugin'; + +export default [ + { + plugins: { + '@lexical': lexical + }, + rules: { + '@lexical/rules-of-lexical': [ + 'error', + { + isDollarFunction: ['^\\$[a-z_]'], + isIgnoredFunction: [], + isLexicalProvider: [ + 'parseEditorState', + 'read', + 'registerCommand', + 'registerNodeTransform', + 'update' + ], + isSafeDollarFunction: ['^\\$is'] + } + ] + } + } +]; +``` + +#### ESLint 7-8 (Legacy Config) + ```js { "plugins": [ @@ -111,6 +180,21 @@ These \$functions are considered safe to call from anywhere, generally these functions are runtime type checks that do not depend on any other state. +## Testing + +To verify that the plugin works with different ESLint versions, run the integration tests: + +```bash +node packages/lexical-eslint-plugin/__tests__/integration-test.js +``` + +This will test: +- ✓ ESLint 8 with legacy `.eslintrc.json` configuration +- ✓ ESLint 10 with flat `eslint.config.js` configuration +- ✓ Legacy config name aliases (`recommended` vs `legacy-recommended`) + +The tests use `pnpm dlx` to run different ESLint versions without modifying `package.json` or `pnpm-lock.yaml`. + ## Valid and Invalid Examples ### Valid Examples diff --git a/packages/lexical-eslint-plugin/__tests__/integration/README.md b/packages/lexical-eslint-plugin/__tests__/integration/README.md new file mode 100644 index 00000000000..053168e1a4d --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/README.md @@ -0,0 +1,71 @@ +# ESLint Plugin Integration Tests + +This directory contains integration tests that verify `@lexical/eslint-plugin` works correctly with different ESLint versions. + +## Test Coverage + +The integration tests verify: + +- **ESLint 8.x** with legacy `.eslintrc.json` configuration +- **ESLint 10.x** with flat `eslint.config.js` configuration +- **Config name aliases** (`recommended` vs `legacy-recommended`) + +## Running the Tests + +From the repository root: + +```bash +node packages/lexical-eslint-plugin/__tests__/integration-test.js +``` + +Or from the package directory: + +```bash +cd packages/lexical-eslint-plugin +node __tests__/integration-test.js +``` + +## How It Works + +The test script uses `pnpx` to run different ESLint versions without modifying `package.json` or `pnpm-lock.yaml`. This ensures: + +- No dependency conflicts +- Clean testing environment +- Multiple ESLint versions can be tested in the same run + +## Test Fixtures + +### ESLint 8 (Legacy Config) + +Located in `fixtures/eslint8-legacy/`: +- `.eslintrc.json` - Legacy ESLint configuration +- `valid.js` - Code that should pass linting +- `invalid.js` - Code that should trigger `@lexical/rules-of-lexical` errors + +### ESLint 10 (Flat Config) + +Located in `fixtures/eslint10-flat/`: +- `eslint.config.js` - Flat ESLint configuration +- `valid.js` - Code that should pass linting +- `invalid.js` - Code that should trigger `@lexical/rules-of-lexical` errors + +## Expected Behavior + +### Valid Code Examples + +These should **pass** linting: +- Functions with `$` prefix calling other `$` functions +- Code inside `editor.update()` callbacks calling `$` functions +- Class methods calling `$` functions + +### Invalid Code Examples + +These should **fail** linting with `@lexical/rules-of-lexical` error: +- Functions without `$` prefix calling `$` functions directly + +## Test Output + +The test script provides colored output: +- ✓ Green = Test passed +- ✗ Red = Test failed +- Summary at the end with total/passed/failed counts diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/.gitignore b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/.gitignore new file mode 100644 index 00000000000..2370e461f0f --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/.gitignore @@ -0,0 +1,3 @@ +# Test files copied during integration tests +./*/valid.js +./*/invalid.js diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint10-flat/eslint.config.js b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint10-flat/eslint.config.js new file mode 100644 index 00000000000..20702e66b95 --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint10-flat/eslint.config.js @@ -0,0 +1,20 @@ +const lexical = require('@lexical/eslint-plugin'); + +module.exports = [ + { + files: ['**/*.js'], + ignores: [], + ...lexical.configs['flat/recommended'], + rules: { + '@lexical/rules-of-lexical': 'error', + }, + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + globals: { + module: 'readonly', + require: 'readonly', + }, + }, + }, +]; diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint10-flat/package.json b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint10-flat/package.json new file mode 100644 index 00000000000..7919fd524b4 --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint10-flat/package.json @@ -0,0 +1,8 @@ +{ + "name": "eslint10-flat-fixture", + "private": true, + "type": "commonjs", + "devDependencies": { + "@lexical/eslint-plugin": "file:../../../.." + } +} diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy-deprecated/.eslintrc.json b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy-deprecated/.eslintrc.json new file mode 100644 index 00000000000..345b5bcf749 --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy-deprecated/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "root": true, + "extends": ["plugin:@lexical/recommended"], + "rules": { + "@lexical/rules-of-lexical": "error" + }, + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + } +} diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy-deprecated/package.json b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy-deprecated/package.json new file mode 100644 index 00000000000..9d23f2e31cc --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy-deprecated/package.json @@ -0,0 +1,8 @@ +{ + "name": "eslint8-legacy-fixture", + "private": true, + "type": "commonjs", + "devDependencies": { + "@lexical/eslint-plugin": "file:../../../.." + } +} diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy/.eslintrc.json b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy/.eslintrc.json new file mode 100644 index 00000000000..a7e6eabcb26 --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "root": true, + "extends": ["plugin:@lexical/legacy-recommended"], + "rules": { + "@lexical/rules-of-lexical": "error" + }, + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + } +} diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy/package.json b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy/package.json new file mode 100644 index 00000000000..9d23f2e31cc --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/eslint8-legacy/package.json @@ -0,0 +1,8 @@ +{ + "name": "eslint8-legacy-fixture", + "private": true, + "type": "commonjs", + "devDependencies": { + "@lexical/eslint-plugin": "file:../../../.." + } +} diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/invalid.js b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/invalid.js new file mode 100644 index 00000000000..5daa17c9479 --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/invalid.js @@ -0,0 +1,9 @@ +/** + * Invalid example - should trigger rules-of-lexical error + * This function calls $getRoot() but doesn't have $ prefix + */ +function invalidFunction() { + return $getRoot(); +} + +module.exports = {invalidFunction}; diff --git a/packages/lexical-eslint-plugin/__tests__/integration/fixtures/valid.js b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/valid.js new file mode 100644 index 00000000000..68c085efada --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/fixtures/valid.js @@ -0,0 +1,27 @@ +/** + * Valid example - $function calling another $function + */ +function $createMyNode() { + return $getRoot(); +} + +/** + * Valid example - using editor.update + */ +function validUsesUpdate(editor) { + editor.update(() => { + const root = $getRoot(); + return root; + }); +} + +/** + * Valid example - class method can call $functions + */ +class MyNode { + createChild() { + return $createTextNode('hello'); + } +} + +module.exports = {$createMyNode, validUsesUpdate, MyNode}; diff --git a/packages/lexical-eslint-plugin/__tests__/integration/integration-test.mjs b/packages/lexical-eslint-plugin/__tests__/integration/integration-test.mjs new file mode 100755 index 00000000000..bf6aeb0f9f0 --- /dev/null +++ b/packages/lexical-eslint-plugin/__tests__/integration/integration-test.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Integration test to verify @lexical/eslint-plugin works with: + * - ESLint 8 (legacy .eslintrc config) + * - ESLint 10 (flat eslint.config.js) + * + * This test uses pnpx to run different ESLint versions without + * modifying package.json or pnpm-lock.yaml + */ +/* eslint-disable no-console */ + +import {execSync} from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import {fileURLToPath} from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const FIXTURES_DIR = path.join(__dirname, 'fixtures'); +const ESLINT8_DIR = path.join(FIXTURES_DIR, 'eslint8-legacy'); +const ESLINT8_DEPRECATED_DIR = path.join( + FIXTURES_DIR, + 'eslint8-legacy-deprecated', +); +const ESLINT10_DIR = path.join(FIXTURES_DIR, 'eslint10-flat'); + +// ANSI color codes +const RESET = '\x1b[0m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const BLUE = '\x1b[34m'; +const BOLD = '\x1b[1m'; + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; + +function log(message, color = RESET) { + console.log(`${color}${message}${RESET}`); +} + +function logTest(name, passed, details = '') { + totalTests++; + if (passed) { + passedTests++; + log(` ✓ ${name}`, GREEN); + } else { + failedTests++; + log(` ✗ ${name}`, RED); + if (details) { + log(` ${details}`, YELLOW); + } + } +} + +function runESLint(version, configDir, configFile, file, shouldFail = false) { + const testName = `ESLint ${version} - ${path.basename(file)} (${shouldFail ? 'should fail' : 'should pass'})`; + + // Copy the file into the config directory to ensure consistent behavior + // and avoid parent config file discovery issues + const fileName = path.basename(file); + const copiedFile = path.join(configDir, fileName); + try { + fs.copyFileSync(file, copiedFile); + } catch (error) { + logTest(testName, false, `Failed to copy test file: ${error.message}`); + return false; + } + + try { + // Use -c with relative path (relative to cwd) to explicitly specify config + // Use --no-eslintrc to prevent parent config lookup (ESLint 8 only) + // For ESLint 8, set ESLINT_USE_FLAT_CONFIG=false to avoid flat config detection + const envPrefix = version === '8' ? 'ESLINT_USE_FLAT_CONFIG=false ' : ''; + const noEslintrc = version === '8' ? '--no-eslintrc ' : ''; + const cmd = `${envPrefix}pnpm dlx eslint@${version} ${noEslintrc}--no-ignore -c "${configFile}" "${fileName}"`; + const _output = execSync(cmd, { + cwd: configDir, + encoding: 'utf8', + shell: '/bin/bash', + stdio: 'pipe', + }); + + // Clean up copied file + if (fs.existsSync(copiedFile)) { + fs.unlinkSync(copiedFile); + } + + // If we expected it to fail but it passed + if (shouldFail) { + logTest( + testName, + false, + 'Expected ESLint to report errors but it passed', + ); + return false; + } + + logTest(testName, true); + return true; + } catch (error) { + // Clean up copied file + if (fs.existsSync(copiedFile)) { + fs.unlinkSync(copiedFile); + } + + const output = error.stdout + error.stderr; + + // If we expected it to fail and it did + if (shouldFail) { + // Verify it failed for the right reason (rules-of-lexical) + if ( + output.includes('@lexical/rules-of-lexical') || + output.includes('rules-of-lexical') + ) { + logTest(testName, true); + return true; + } else { + logTest(testName, false, 'Failed but not due to rules-of-lexical rule'); + return false; + } + } + + // If we expected it to pass but it failed + logTest(testName, false, error.message.split('\n')[0]); + return false; + } +} + +function testESLint8(dirName) { + log(`\n${BOLD}${BLUE}Testing ESLint 8 (${path.basename(dirName)})${RESET}`); + log(`Directory: ${dirName}`); + + // Check if config exists + const configPath = path.join(dirName, '.eslintrc.json'); + if (!fs.existsSync(configPath)) { + log(` ✗ Config file not found: ${configPath}`, RED); + return false; + } + + runESLint( + '8', + dirName, + '.eslintrc.json', + path.join(FIXTURES_DIR, 'valid.js'), + false, + ); + runESLint( + '8', + dirName, + '.eslintrc.json', + path.join(FIXTURES_DIR, 'invalid.js'), + true, + ); +} + +function testESLint10Flat() { + log(`\n${BOLD}${BLUE}Testing ESLint 10 (Flat Config)${RESET}`); + log(`Directory: ${ESLINT10_DIR}`); + + // Check if config exists + const configPath = path.join(ESLINT10_DIR, 'eslint.config.js'); + if (!fs.existsSync(configPath)) { + log(` ✗ Config file not found: ${configPath}`, RED); + return false; + } + + runESLint( + '10', + ESLINT10_DIR, + 'eslint.config.js', + path.join(FIXTURES_DIR, 'valid.js'), + false, + ); + runESLint( + '10', + ESLINT10_DIR, + 'eslint.config.js', + path.join(FIXTURES_DIR, 'invalid.js'), + true, + ); +} + +function setupFixtures() { + log(`\n${BOLD}${BLUE}Setting up test fixtures...${RESET}`); + + [ESLINT8_DIR, ESLINT8_DEPRECATED_DIR, ESLINT10_DIR].forEach((cwd) => { + try { + log(` Installing dependencies for ${path.basename(cwd)} fixture...`); + execSync('pnpm install --no-lockfile', { + cwd, + stdio: 'pipe', + }); + log(` ✓ ${path.basename(cwd)} fixture ready`, GREEN); + } catch (error) { + log( + ` ✗ Failed to setup ${path.basename(cwd)} fixture: ${error.message}`, + RED, + ); + throw error; + } + }); +} + +function main() { + log(`${BOLD}${'='.repeat(70)}${RESET}`); + log(`${BOLD}ESLint Plugin Integration Tests${RESET}`); + log(`${BOLD}${'='.repeat(70)}${RESET}`); + log(`\nTesting @lexical/eslint-plugin compatibility with:`); + log(` - ESLint 8.x (legacy deprecated .eslintrc config)`); + log(` - ESLint 8.x (legacy prefixed .eslintrc config)`); + log(` - ESLint 10.x (flat eslint.config.js)`); + + try { + setupFixtures(); + testESLint8(ESLINT8_DIR); + testESLint8(ESLINT8_DEPRECATED_DIR); + testESLint10Flat(); + + log(`\n${BOLD}${'='.repeat(70)}${RESET}`); + log(`${BOLD}Test Summary${RESET}`); + log(`${BOLD}${'='.repeat(70)}${RESET}`); + log(`Total tests: ${totalTests}`); + log(`Passed: ${passedTests}`, GREEN); + + if (failedTests > 0) { + log(`Failed: ${failedTests}`, RED); + log(`\n${RED}${BOLD}✗ Some tests failed${RESET}`); + process.exit(1); + } else { + log(`\n${GREEN}${BOLD}✓ All tests passed!${RESET}`); + process.exit(0); + } + } catch (error) { + log(`\n${RED}${BOLD}Fatal error: ${error.message}${RESET}`, RED); + process.exit(1); + } +} + +main(); diff --git a/packages/lexical-eslint-plugin/package.json b/packages/lexical-eslint-plugin/package.json index 26224efaa24..b5fd6be706b 100644 --- a/packages/lexical-eslint-plugin/package.json +++ b/packages/lexical-eslint-plugin/package.json @@ -23,7 +23,7 @@ "homepage": "https://lexical.dev/docs/packages/lexical-eslint-plugin", "sideEffects": false, "peerDependencies": { - "eslint": ">=7.31.0 || ^8.0.0" + "eslint": ">=7.31.0" }, "exports": { ".": { diff --git a/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js b/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js index 8e6d357a09f..361481666b4 100644 --- a/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js +++ b/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js @@ -11,17 +11,24 @@ const {name, version} = require('../package.json'); const {rulesOfLexical} = require('./rules/rules-of-lexical.js'); -const all = { +// Legacy config format (ESLint 7-8) +const legacyAll = { plugins: ['@lexical'], rules: { - '@lexical/rules-of-lexical': 'warn', + '@lexical/rules-of-lexical': /** @type {'warn'|'error'|'off'}*/ ('warn'), }, }; const plugin = { configs: { - all, - recommended: all, + // Legacy configs (ESLint 7-8) - available under multiple names for compatibility + all: legacyAll, + // Flat configs (ESLint 9-10+) - placeholders, will be set below + 'flat/all': /** @type {any} */ (null), + 'flat/recommended': /** @type {any} */ (null), + 'legacy-all': legacyAll, + 'legacy-recommended': legacyAll, + recommended: legacyAll, }, meta: {name, version}, rules: { @@ -29,4 +36,18 @@ const plugin = { }, }; +// Flat config format (ESLint 9-10+) +// Must be created after plugin is defined to avoid circular reference +const flatAll = { + plugins: { + '@lexical': plugin, + }, + rules: { + '@lexical/rules-of-lexical': 'warn' /** @type {'warn'|'error'|'off'}*/, + }, +}; + +plugin.configs['flat/all'] = flatAll; +plugin.configs['flat/recommended'] = flatAll; + module.exports = plugin; diff --git a/packages/lexical-eslint-plugin/src/index.ts b/packages/lexical-eslint-plugin/src/index.ts index 59bcc3857e8..d2474d0df21 100644 --- a/packages/lexical-eslint-plugin/src/index.ts +++ b/packages/lexical-eslint-plugin/src/index.ts @@ -11,8 +11,51 @@ * compilation is necessary */ -import * as plugin from './LexicalEslintPlugin.js'; +import type {Rule} from 'eslint'; + +import * as jsPlugin from './LexicalEslintPlugin.js'; export type {RulesOfLexicalOptions} from './rules/rules-of-lexical.js'; + +// Legacy config format (ESLint 7-8) +export interface LegacyConfig { + plugins: string[]; + rules: { + '@lexical/rules-of-lexical': 'warn' | 'error' | 'off'; + }; +} + +// Flat config format (ESLint 9-10+) +export interface FlatConfig { + plugins: { + '@lexical': Plugin; + }; + rules: { + '@lexical/rules-of-lexical': 'warn' | 'error' | 'off'; + }; +} + +export interface Plugin { + meta: { + name: string; + version: string; + }; + rules: { + 'rules-of-lexical': Rule.RuleModule; + }; + configs: { + // Legacy configs (ESLint 7-8) - available under multiple names + all: LegacyConfig; + 'legacy-all': LegacyConfig; + 'legacy-recommended': LegacyConfig; + recommended: LegacyConfig; + // Flat configs (ESLint 9-10+) + 'flat/all': FlatConfig; + 'flat/recommended': FlatConfig; + }; +} + +const plugin: Plugin = jsPlugin; + // eslint-disable-next-line no-restricted-exports export default plugin; diff --git a/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js b/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js index b24e2dc3e6e..3c45d465c2b 100644 --- a/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js +++ b/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js @@ -239,8 +239,10 @@ function parseMatcherOption(context, optionName) { /** @param {RuleContext} context */ function getSourceCode(context) { - // Deprecated in 8.x but we are still on 7.x - return context.getSourceCode(); + // ESLint 9+ provides sourceCode directly on context (required in ESLint 10+) + // ESLint 7-8 requires calling getSourceCode() method + // This maintains compatibility across ESLint 7, 8, 9, and 10+ + return context.sourceCode || context.getSourceCode(); } const matcherSchema = { diff --git a/packages/lexical-link/src/LexicalLinkNode.ts b/packages/lexical-link/src/LexicalLinkNode.ts index 40fd5aee4dc..8239cf15fc0 100644 --- a/packages/lexical-link/src/LexicalLinkNode.ts +++ b/packages/lexical-link/src/LexicalLinkNode.ts @@ -111,6 +111,14 @@ export class LinkNode extends ElementNode { this.__title = title; } + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + this.__url = prevNode.__url; + this.__rel = prevNode.__rel; + this.__target = prevNode.__target; + this.__title = prevNode.__target; + } + createDOM(config: EditorConfig): LinkHTMLElementType { const element = document.createElement('a'); this.updateLinkDOM(null, element, config); @@ -240,11 +248,7 @@ export class LinkNode extends ElementNode { _: RangeSelection, restoreSelection = true, ): null | ElementNode { - const linkNode = $createLinkNode(this.__url, { - rel: this.__rel, - target: this.__target, - title: this.__title, - }); + const linkNode = $copyNode(this); this.insertAfter(linkNode, restoreSelection); return linkNode; } @@ -257,7 +261,7 @@ export class LinkNode extends ElementNode { return false; } - canBeEmpty(): false { + canBeEmpty(): boolean { return false; } @@ -326,6 +330,7 @@ export function $linkNodeTransform(link: LinkNode): void { focusPair = $saveCaretPair(selection.focus); } + let transformed = false; for (const caret of $getChildCaret(link, 'next')) { const node = caret.origin; if ($isElementNode(node) && !node.isInline()) { @@ -334,13 +339,17 @@ export function $linkNodeTransform(link: LinkNode): void { const innerLink = $copyNode(link); innerLink.append(...blockChildren); node.append(innerLink); + transformed = true; } $insertNodeToNearestRootAtCaret(node, $rewindSiblingCaret(caret), { $shouldSplit: () => false, }); } } - if (link.isEmpty()) { + if (!transformed) { + return; + } + if (!link.canBeEmpty() && link.isEmpty()) { const parent = link.getParent(); link.remove(); if (parent && parent.isEmpty()) { @@ -420,6 +429,11 @@ export class AutoLinkNode extends LinkNode { : false; } + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + this.__isUnlinked = prevNode.__isUnlinked; + } + static getType(): string { return 'autolink'; } @@ -647,11 +661,7 @@ function $splitLinkAtSelection( const trailingChildren = allChildren.slice(lastExtractedIndex + 1); if (trailingChildren.length > 0) { - const newLink = $createLinkNode(parentLink.getURL(), { - rel: parentLink.getRel(), - target: parentLink.getTarget(), - title: parentLink.getTitle(), - }); + const newLink = $copyNode(parentLink); extractedChildren[extractedChildren.length - 1].insertAfter(newLink); trailingChildren.forEach((child) => newLink.append(child)); diff --git a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts index c8872af3371..fb3ac439dcb 100644 --- a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts +++ b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts @@ -1266,4 +1266,25 @@ describe('LinkNode transform (Regression #8083)', () => { expect(selection.anchor.offset).toBe(7); }); }); + + test('an empty link is not deleted if the transformation did not occur', () => { + const editor = buildEditorFromExtensions(transformExtension); + let linkKey: string; + editor.update( + () => { + const root = $getRoot(); + const link = $createLinkNode('https://lexical.dev'); + linkKey = link.getKey(); + const paragraph = $createParagraphNode(); + paragraph.append(link); + root.clear().append(paragraph); + link.select(); + }, + {discrete: true}, + ); + editor.read(() => { + const linkNode = $getNodeByKey(linkKey); + expect(linkNode).not.toBe(null); + }); + }); }); diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index 6ee0284ccee..f3be1852e0e 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -229,7 +229,7 @@ export class ListItemNode extends ElementNode { list.insertAfter(replaceWithNode); } else { // Split the list - const newList = $createListNode(list.getListType()); + const newList = $copyNode(list); let nextSibling = this.getNextSibling(); while (nextSibling) { const nodeToAppend = nextSibling; @@ -275,7 +275,7 @@ export class ListItemNode extends ElementNode { listNode.insertAfter(node, restoreSelection); if (siblings.length !== 0) { - const newListNode = $createListNode(listNode.getListType()); + const newListNode = $copyNode(listNode); siblings.forEach((sibling) => newListNode.append(sibling)); diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts index 990d4d068f2..1a70de13028 100644 --- a/packages/lexical-list/src/LexicalListNode.ts +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -13,6 +13,7 @@ import { } from '@lexical/utils'; import { $applyNodeReplacement, + $copyNode, $createTextNode, $isElementNode, buildImportMap, @@ -197,6 +198,13 @@ export class ListNode extends ElementNode { deleteCount: number, nodesToInsert: LexicalNode[], ): this { + const exampleListItem = + nodesToInsert.find($isListItemNode) ?? + this.getChildren().find($isListItemNode); + const $newListItem = exampleListItem + ? () => $copyNode(exampleListItem) + : $createListItemNode; + let listItemNodesToInsert = nodesToInsert; for (let i = 0; i < nodesToInsert.length; i++) { const node = nodesToInsert[i]; @@ -204,7 +212,7 @@ export class ListNode extends ElementNode { if (listItemNodesToInsert === nodesToInsert) { listItemNodesToInsert = [...nodesToInsert]; } - listItemNodesToInsert[i] = $createListItemNode().append( + listItemNodesToInsert[i] = $newListItem().append( $isElementNode(node) && !($isListNode(node) || node.isInline()) ? $createTextNode(node.getTextContent()) : node, diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts index eaf9648139a..b8b76e29942 100644 --- a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts +++ b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts @@ -6,15 +6,6 @@ * */ import {$createLinkNode, $isLinkNode, LinkNode} from '@lexical/link'; -import {$createTextNode, $getRoot, ParagraphNode, TextNode} from 'lexical'; -import { - expectHtmlToBeEqual, - html, - initializeUnitTest, -} from 'lexical/src/__tests__/utils'; -import {waitForReact} from 'packages/lexical-react/src/__tests__/unit/utils'; -import {describe, expect, test} from 'vitest'; - import { $createListItemNode, $createListNode, @@ -22,7 +13,15 @@ import { $isListNode, ListItemNode, ListNode, -} from '../..'; +} from '@lexical/list'; +import {$createTextNode, $getRoot, ParagraphNode, TextNode} from 'lexical'; +import { + expectHtmlToBeEqual, + html, + initializeUnitTest, +} from 'lexical/src/__tests__/utils'; +import {waitForReact} from 'packages/lexical-react/src/__tests__/unit/utils'; +import {assert, describe, expect, test} from 'vitest'; const editorConfig = Object.freeze({ namespace: '', @@ -306,6 +305,39 @@ describe('LexicalListNode tests', () => { }); }); + test('ListNode.splice() should wrap multiple non-ListItem nodes in individual ListItem nodes', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const list = $createListNode('bullet').append( + $createListItemNode().append($createTextNode('A')), + $createListItemNode().append($createTextNode('D')), + ); + const root = $getRoot(); + root.append(list); + + const textA = $createTextNode('B'); + const textB = $createTextNode('C'); + + list.splice(1, 0, [textA, textB]); + }); + + await editor.read(() => { + const list = $getRoot().getFirstChild(); + assert($isListNode(list), 'First child must be a ListNode'); + + const children = list.getChildren(); + expect(children).toHaveLength(4); + + // Each child must be its own ListItemNode, not the same instance + expect($isListItemNode(children[1])).toBe(true); + expect($isListItemNode(children[2])).toBe(true); + expect(children[1]).not.toBe(children[2]); + expect(children[1].getTextContent()).toBe('B'); + expect(children[2].getTextContent()).toBe('C'); + }); + }); + test('Should update list children when switching from checklist to bullet', async () => { const {editor} = testEnv; diff --git a/packages/lexical-list/src/__tests__/unit/formatList.test.ts b/packages/lexical-list/src/__tests__/unit/formatList.test.ts index 66ca5eab4a1..ee35026db49 100644 --- a/packages/lexical-list/src/__tests__/unit/formatList.test.ts +++ b/packages/lexical-list/src/__tests__/unit/formatList.test.ts @@ -5,6 +5,17 @@ * LICENSE file in the root directory of this source tree. * */ +import { + $createListItemNode, + $createListNode, + $insertList, + $isListItemNode, + $isListNode, + ListItemNode, + ListNode, + ListType, + registerList, +} from '@lexical/list'; import {registerRichText} from '@lexical/rich-text'; import { $createTableCellNode, @@ -26,6 +37,7 @@ import { $nodesOfType, $selectAll, INSERT_PARAGRAPH_COMMAND, + LexicalNode, } from 'lexical'; import { $createTestDecoratorNode, @@ -33,10 +45,39 @@ import { } from 'lexical/src/__tests__/utils'; import {describe, expect, test} from 'vitest'; -import {registerList} from '../../'; -import {$insertList} from '../../formatList'; -import {$createListItemNode, $isListItemNode} from '../../LexicalListItemNode'; -import {$createListNode, $isListNode, ListNode} from '../../LexicalListNode'; +import {$handleIndent, $handleOutdent} from '../../formatList'; + +class ExtendedTestListNode extends ListNode { + $config() { + return this.config('extended-test-list', {extends: ListNode}); + } +} + +function $createExtendedTestListNode(listType: ListType): ExtendedTestListNode { + return new ExtendedTestListNode(listType); +} + +function $isExtendedTestListNode(node?: LexicalNode | null) { + return node instanceof ExtendedTestListNode; +} + +class ExtendedTestListItemNode extends ListItemNode { + $config() { + return this.config('extended-test-list-item', {extends: ListItemNode}); + } +} + +function $createExtendedTestListItemNode(): ExtendedTestListItemNode { + return new ExtendedTestListItemNode(); +} + +function $isExtendedTestListItemNode(node?: LexicalNode | null) { + return node instanceof ExtendedTestListItemNode; +} + +const initOptions = { + nodes: [ExtendedTestListNode, ExtendedTestListItemNode], +}; describe('insertList', () => { initializeUnitTest((testEnv) => { @@ -298,5 +339,114 @@ describe('$handleListInsertParagraph', () => { expect((children[0] as ListNode).getChildrenSize()).toBe(3); }); }); - }); + + test('splits list when the empty element is not the last one', async () => { + const {editor} = testEnv; + registerList(editor); + + let emptyListItemKey: string; + await editor.update(() => { + const firstListItemWithContent = $createExtendedTestListItemNode(); + const secondListItemWithContent = $createExtendedTestListItemNode(); + const listItemEmpty = $createExtendedTestListItemNode(); + emptyListItemKey = listItemEmpty.getKey(); + const listNode = $createExtendedTestListNode('bullet'); + firstListItemWithContent.append($createTextNode('item1')); + secondListItemWithContent.append($createTextNode('item2')); + listNode.append( + firstListItemWithContent, + listItemEmpty, + secondListItemWithContent, + ); + $getRoot().append(listNode); + listItemEmpty.select(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }); + + editor.read(() => { + const children = $getRoot().getChildren(); + const firstList = children[0] as ExtendedTestListNode; + const secondList = children[2] as ExtendedTestListNode; + + expect(children.length).toBe(3); + expect($isListNode(children[0])).toBe(true); + expect($isParagraphNode(children[1])).toBe(true); + expect($isListNode(children[2])).toBe(true); + expect(firstList.getChildrenSize()).toBe(1); + expect(secondList.getChildrenSize()).toBe(1); + expect($getNodeByKey(emptyListItemKey)).toBeNull(); + // check that the new list is of the same type + expect(secondList).toBeInstanceOf(firstList.constructor); + expect(firstList.getListType()).toBe(secondList.getListType()); + }); + }); + }, initOptions); +}); + +describe('$handleIndent', () => { + initializeUnitTest( + (testEnv) => { + test('creates a new nested sublist', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const listNode = $createExtendedTestListNode('bullet'); + const listItem1 = $createExtendedTestListItemNode(); + const listItem2 = $createExtendedTestListItemNode(); + + listNode.append(listItem1, listItem2); + root.append(listNode); + + $handleIndent(listItem2); + + // new item keeps the same type + const newListItem2 = + listNode.getChildren()[1] as ExtendedTestListItemNode; + expect($isExtendedTestListItemNode(newListItem2)).toBe(true); + expect(newListItem2.getChildren().length).toBe(1); + + // nested list contains the original list item + const nestedList = + newListItem2.getChildren()[0] as ExtendedTestListNode; + expect($isExtendedTestListNode(nestedList)).toBe(true); + expect(nestedList.getChildren().length).toBe(1); + expect(nestedList.getChildren()[0].is(listItem2)).toBe(true); + }); + }); + }, + {nodes: [ExtendedTestListNode, ExtendedTestListItemNode]}, + ); +}); + +describe('$handleOutdent', () => { + initializeUnitTest((testEnv) => { + test('removes the nested list and replaces list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const listNode = $createExtendedTestListNode('bullet'); + const listItem1 = $createExtendedTestListItemNode(); + const listItem2 = $createExtendedTestListItemNode(); + const indentedListItem = $createExtendedTestListItemNode(); + + listNode.append( + listItem1, + listItem2.append( + $createExtendedTestListNode('bullet').append(indentedListItem), + ), + ); + root.append(listNode); + + $handleOutdent(indentedListItem); + + const children = listNode.getChildren(); + // item is outdented and doesn't have nested list + expect(children.length).toBe(2); + expect(children[1].is(indentedListItem)).toBe(true); + expect(indentedListItem.getChildren().length).toBe(0); + }); + }); + }, initOptions); }); diff --git a/packages/lexical-list/src/formatList.ts b/packages/lexical-list/src/formatList.ts index 65a13605c9b..5802e0a8028 100644 --- a/packages/lexical-list/src/formatList.ts +++ b/packages/lexical-list/src/formatList.ts @@ -8,6 +8,7 @@ import {$getNearestNodeOfType} from '@lexical/utils'; import { + $copyNode, $createParagraphNode, $getChildCaret, $getSelection, @@ -415,12 +416,8 @@ export function $handleIndent(listItemNode: ListItemNode): void { // otherwise, we need to create a new nested ListNode if ($isListNode(parent)) { - const newListItem = $createListItemNode() - .setTextFormat(listItemNode.getTextFormat()) - .setTextStyle(listItemNode.getTextStyle()); - const newList = $createListNode(parent.getListType()) - .setTextFormat(parent.getTextFormat()) - .setTextStyle(parent.getTextStyle()); + const newListItem = $copyNode(listItemNode); + const newList = $copyNode(parent); newListItem.append(newList); newList.append(listItemNode); @@ -480,15 +477,14 @@ export function $handleOutdent(listItemNode: ListItemNode): void { } } else { // otherwise, we need to split the siblings into two new nested lists - const listType = parentList.getListType(); - const previousSiblingsListItem = $createListItemNode(); - const previousSiblingsList = $createListNode(listType); + const previousSiblingsListItem = $copyNode(listItemNode); + const previousSiblingsList = $copyNode(parentList); previousSiblingsListItem.append(previousSiblingsList); listItemNode .getPreviousSiblings() .forEach((sibling) => previousSiblingsList.append(sibling)); - const nextSiblingsListItem = $createListItemNode(); - const nextSiblingsList = $createListNode(listType); + const nextSiblingsListItem = $copyNode(listItemNode); + const nextSiblingsList = $copyNode(parentList); nextSiblingsListItem.append(nextSiblingsList); append(nextSiblingsList, listItemNode.getNextSiblings()); // put the sibling nested lists on either side of the grandparent list item in the great grandparent. @@ -560,7 +556,7 @@ export function $handleListInsertParagraph( replacementNode = $createParagraphNode(); topListNode.insertAfter(replacementNode); } else if ($isListItemNode(grandparent)) { - replacementNode = $createListItemNode(); + replacementNode = $copyNode(grandparent); grandparent.insertAfter(replacementNode); } else { return false; @@ -574,10 +570,10 @@ export function $handleListInsertParagraph( if (nextSiblings.length > 0) { const newStart = restoreNumbering ? $getNewListStart(parent, listItem) : 1; - const newList = $createListNode(parent.getListType(), newStart); + const newList = $copyNode(parent).setStart(newStart); if ($isListItemNode(replacementNode)) { - const newListItem = $createListItemNode(); + const newListItem = $copyNode(replacementNode); newListItem.append(newList); replacementNode.insertAfter(newListItem); } else { diff --git a/packages/lexical-playground/__tests__/e2e/EquationNode.spec.mjs b/packages/lexical-playground/__tests__/e2e/EquationNode.spec.mjs new file mode 100644 index 00000000000..5cecd1c901b --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/EquationNode.spec.mjs @@ -0,0 +1,107 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + assertHTML, + click, + focus, + focusEditor, + html, + initialize, + selectFromInsertDropdown, + test, + waitForSelector, +} from '../utils/index.mjs'; + +export async function insertBlockEquation(page, equation) { + await selectFromInsertDropdown(page, '.equation'); + await click(page, 'input[data-test-id="equation-inline-checkbox"]'); + await focus(page, 'textarea[data-test-id="equation-input"]'); + await page.keyboard.type(equation); + await click(page, 'button[data-test-id="equation-submit-btn"]'); +} + +function equationHtml(inline = true) { + const tag = inline ? 'span' : 'div'; + return `<${tag} + class="editor-equation" + contenteditable="false" + data-lexical-decorator="true"> + + + ${inline ? '' : ``} + + + + ${inline ? '' : ``} + + + `; +} + +test.describe('EquationNode', () => { + test.beforeEach(({isCollab, isPlainText, page}) => { + test.skip(isPlainText); + return initialize({ + isCollab, + page, + }); + }); + test('inline EquationNode is wrapped in a paragraph', async ({ + page, + isCollab, + }) => { + await focusEditor(page); + await page.keyboard.type('$1$'); + await waitForSelector(page, '.editor-equation'); + + await assertHTML( + page, + html` +

+ ${equationHtml(true)} +
+

+ `, + ); + }); + test('block EquationNode is a child of the root', async ({ + page, + isCollab, + }) => { + await focusEditor(page); + await insertBlockEquation(page, '1'); + await waitForSelector(page, '.editor-equation'); + + await assertHTML( + page, + html` +

+
+

+ ${equationHtml(false)} +

+
+

+ `, + ); + }); +}); diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 9e18413d342..23a57f94e93 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -1699,6 +1699,10 @@ button.toolbar-item.active i { user-select: none; } +span.editor-equation { + display: inline-block; +} + .editor-equation.focused { outline: 2px solid rgb(60, 132, 244); } diff --git a/packages/lexical-playground/src/nodes/EquationNode.tsx b/packages/lexical-playground/src/nodes/EquationNode.tsx index c32aa5bea3c..5db9e63482d 100644 --- a/packages/lexical-playground/src/nodes/EquationNode.tsx +++ b/packages/lexical-playground/src/nodes/EquationNode.tsx @@ -58,12 +58,18 @@ export class EquationNode extends DecoratorNode { return new EquationNode(node.__equation, node.__inline, node.__key); } - constructor(equation: string, inline?: boolean, key?: NodeKey) { + constructor(equation: string = '', inline?: boolean, key?: NodeKey) { super(key); this.__equation = equation; this.__inline = inline ?? false; } + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + this.__equation = prevNode.__equation; + this.__inline = prevNode.__inline; + } + static importJSON(serializedNode: SerializedEquationNode): EquationNode { return $createEquationNode( serializedNode.equation, @@ -75,7 +81,7 @@ export class EquationNode extends DecoratorNode { return { ...super.exportJSON(), equation: this.getEquation(), - inline: this.__inline, + inline: this.isInline(), }; } @@ -132,16 +138,21 @@ export class EquationNode extends DecoratorNode { } getTextContent(): string { - return this.__equation; + return this.getEquation(); + } + + isInline(): boolean { + return this.getLatest().__inline; } getEquation(): string { - return this.__equation; + return this.getLatest().__equation; } - setEquation(equation: string): void { + setEquation(equation: string): this { const writable = this.getWritable(); writable.__equation = equation; + return this; } decorate(): JSX.Element { diff --git a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx b/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx index 46f1bf7ae1e..16b4ebaa3b7 100644 --- a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx @@ -11,7 +11,7 @@ import type {JSX} from 'react'; import 'katex/dist/katex.css'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {$wrapNodeInElement} from '@lexical/utils'; +import {$insertNodeToNearestRoot, $wrapNodeInElement} from '@lexical/utils'; import { $createParagraphNode, $insertNodes, @@ -69,9 +69,13 @@ export default function EquationsPlugin(): JSX.Element | null { const {equation, inline} = payload; const equationNode = $createEquationNode(equation, inline); - $insertNodes([equationNode]); - if ($isRootOrShadowRoot(equationNode.getParentOrThrow())) { - $wrapNodeInElement(equationNode, $createParagraphNode).selectEnd(); + if (inline) { + $insertNodes([equationNode]); + if ($isRootOrShadowRoot(equationNode.getParentOrThrow())) { + $wrapNodeInElement(equationNode, $createParagraphNode).selectEnd(); + } + } else { + $insertNodeToNearestRoot(equationNode); } return true; diff --git a/packages/lexical-playground/src/ui/KatexEquationAlterer.tsx b/packages/lexical-playground/src/ui/KatexEquationAlterer.tsx index ab3dd38abbc..abe9ead8b92 100644 --- a/packages/lexical-playground/src/ui/KatexEquationAlterer.tsx +++ b/packages/lexical-playground/src/ui/KatexEquationAlterer.tsx @@ -43,7 +43,12 @@ export default function KatexEquationAlterer({ <>
Inline - +
Equation
@@ -54,6 +59,7 @@ export default function KatexEquationAlterer({ }} value={equation} className="KatexEquationAlterer_textArea" + data-test-id="equation-input" /> ) : (