diff --git a/CHANGELOG.md b/CHANGELOG.md index b48da21be2..a09964a2e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [38.6.0](https://github.com/Lundalogik/lime-elements/compare/v38.5.0...v38.6.0) (2025-03-25) + + +### Features + + +* **text editor:** conditionally allow commands ([819f599](https://github.com/Lundalogik/lime-elements/commit/819f599835de1859f0b8bb19b772c5cf6e5a51f6)) + ## [38.5.0](https://github.com/Lundalogik/lime-elements/compare/v38.4.1...v38.5.0) (2025-03-19) diff --git a/package-lock.json b/package-lock.json index 3bda5b58df..87f79524e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@limetech/lime-elements", - "version": "38.5.0", + "version": "38.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@limetech/lime-elements", - "version": "38.5.0", + "version": "38.6.0", "license": "Apache-2.0", "devDependencies": { "@commitlint/config-conventional": "^19.8.0", - "@eslint/eslintrc": "^3.3.0", + "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", - "@microsoft/api-extractor": "^7.52.1", + "@microsoft/api-extractor": "^7.52.2", "@popperjs/core": "^2.11.8", "@rjsf/core": "^2.4.2", "@rollup/plugin-node-resolve": "^13.3.0", @@ -2114,9 +2114,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3467,19 +3467,19 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.52.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.1.tgz", - "integrity": "sha512-m3I5uAwE05orsu3D1AGyisX5KxsgVXB+U4bWOOaX/Z7Ftp/2Cy41qsNhO6LPvSxHBaapyser5dVorF1t5M6tig==", + "version": "7.52.2", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.2.tgz", + "integrity": "sha512-RX37V5uhBBPUvrrcmIxuQ8TPsohvr6zxo7SsLPOzBYcH9nbjbvtdXrts4cxHCXGOin9JR5ar37qfxtCOuEBTHA==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor-model": "7.30.4", + "@microsoft/api-extractor-model": "7.30.5", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.12.0", + "@rushstack/node-core-library": "5.13.0", "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.15.1", - "@rushstack/ts-command-line": "4.23.6", + "@rushstack/terminal": "0.15.2", + "@rushstack/ts-command-line": "4.23.7", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", @@ -3492,15 +3492,15 @@ } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.30.4", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.4.tgz", - "integrity": "sha512-RobC0gyVYsd2Fao9MTKOfTdBm41P/bCMUmzS5mQ7/MoAKEqy0FOBph3JOYdq4X4BsEnMEiSHc+0NUNmdzxCpjA==", + "version": "7.30.5", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.5.tgz", + "integrity": "sha512-0ic4rcbcDZHz833RaTZWTGu+NpNgrxVNjVaor0ZDUymfDFzjA/Uuk8hYziIUIOEOSTfmIQqyzVwlzxZxPe7tOA==", "dev": true, "license": "MIT", "dependencies": { "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.12.0" + "@rushstack/node-core-library": "5.13.0" } }, "node_modules/@microsoft/api-extractor/node_modules/minimatch": { @@ -3741,9 +3741,9 @@ "dev": true }, "node_modules/@rushstack/node-core-library": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.12.0.tgz", - "integrity": "sha512-QSwwzgzWoil1SCQse+yCHwlhRxNv2dX9siPnAb9zR/UmMhac4mjMrlMZpk64BlCeOFi1kJKgXRkihSwRMbboAQ==", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.13.0.tgz", + "integrity": "sha512-IGVhy+JgUacAdCGXKUrRhwHMTzqhWwZUI+qEPcdzsb80heOw0QPbhhoVsoiMF7Klp8eYsp7hzpScMXmOa3Uhfg==", "dev": true, "license": "MIT", "dependencies": { @@ -3853,13 +3853,13 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.1.tgz", - "integrity": "sha512-3vgJYwumcjoDOXU3IxZfd616lqOdmr8Ezj4OWgJZfhmiBK4Nh7eWcv8sU8N/HdzXcuHDXCRGn/6O2Q75QvaZMA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.2.tgz", + "integrity": "sha512-7Hmc0ysK5077R/IkLS9hYu0QuNafm+TbZbtYVzCMbeOdMjaRboLKrhryjwZSRJGJzu+TV1ON7qZHeqf58XfLpA==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.12.0", + "@rushstack/node-core-library": "5.13.0", "supports-color": "~8.1.1" }, "peerDependencies": { @@ -3888,13 +3888,13 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "4.23.6", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.6.tgz", - "integrity": "sha512-7WepygaF3YPEoToh4MAL/mmHkiIImQq3/uAkQX46kVoKTNOOlCtFGyNnze6OYuWw2o9rxsyrHVfIBKxq/am2RA==", + "version": "4.23.7", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.7.tgz", + "integrity": "sha512-Gr9cB7DGe6uz5vq2wdr89WbVDKz0UeuFEn5H2CfWDe7JvjFFaiV15gi6mqDBTbHhHCWS7w8mF1h3BnIfUndqdA==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.15.1", + "@rushstack/terminal": "0.15.2", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -17596,9 +17596,9 @@ } }, "@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "requires": { "ajv": "^6.12.4", @@ -18802,18 +18802,18 @@ } }, "@microsoft/api-extractor": { - "version": "7.52.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.1.tgz", - "integrity": "sha512-m3I5uAwE05orsu3D1AGyisX5KxsgVXB+U4bWOOaX/Z7Ftp/2Cy41qsNhO6LPvSxHBaapyser5dVorF1t5M6tig==", + "version": "7.52.2", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.2.tgz", + "integrity": "sha512-RX37V5uhBBPUvrrcmIxuQ8TPsohvr6zxo7SsLPOzBYcH9nbjbvtdXrts4cxHCXGOin9JR5ar37qfxtCOuEBTHA==", "dev": true, "requires": { - "@microsoft/api-extractor-model": "7.30.4", + "@microsoft/api-extractor-model": "7.30.5", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.12.0", + "@rushstack/node-core-library": "5.13.0", "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.15.1", - "@rushstack/ts-command-line": "4.23.6", + "@rushstack/terminal": "0.15.2", + "@rushstack/ts-command-line": "4.23.7", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", @@ -18840,14 +18840,14 @@ } }, "@microsoft/api-extractor-model": { - "version": "7.30.4", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.4.tgz", - "integrity": "sha512-RobC0gyVYsd2Fao9MTKOfTdBm41P/bCMUmzS5mQ7/MoAKEqy0FOBph3JOYdq4X4BsEnMEiSHc+0NUNmdzxCpjA==", + "version": "7.30.5", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.5.tgz", + "integrity": "sha512-0ic4rcbcDZHz833RaTZWTGu+NpNgrxVNjVaor0ZDUymfDFzjA/Uuk8hYziIUIOEOSTfmIQqyzVwlzxZxPe7tOA==", "dev": true, "requires": { "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.12.0" + "@rushstack/node-core-library": "5.13.0" } }, "@microsoft/tsdoc": { @@ -19010,9 +19010,9 @@ } }, "@rushstack/node-core-library": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.12.0.tgz", - "integrity": "sha512-QSwwzgzWoil1SCQse+yCHwlhRxNv2dX9siPnAb9zR/UmMhac4mjMrlMZpk64BlCeOFi1kJKgXRkihSwRMbboAQ==", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.13.0.tgz", + "integrity": "sha512-IGVhy+JgUacAdCGXKUrRhwHMTzqhWwZUI+qEPcdzsb80heOw0QPbhhoVsoiMF7Klp8eYsp7hzpScMXmOa3Uhfg==", "dev": true, "requires": { "ajv": "~8.13.0", @@ -19090,12 +19090,12 @@ } }, "@rushstack/terminal": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.1.tgz", - "integrity": "sha512-3vgJYwumcjoDOXU3IxZfd616lqOdmr8Ezj4OWgJZfhmiBK4Nh7eWcv8sU8N/HdzXcuHDXCRGn/6O2Q75QvaZMA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.2.tgz", + "integrity": "sha512-7Hmc0ysK5077R/IkLS9hYu0QuNafm+TbZbtYVzCMbeOdMjaRboLKrhryjwZSRJGJzu+TV1ON7qZHeqf58XfLpA==", "dev": true, "requires": { - "@rushstack/node-core-library": "5.12.0", + "@rushstack/node-core-library": "5.13.0", "supports-color": "~8.1.1" }, "dependencies": { @@ -19111,12 +19111,12 @@ } }, "@rushstack/ts-command-line": { - "version": "4.23.6", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.6.tgz", - "integrity": "sha512-7WepygaF3YPEoToh4MAL/mmHkiIImQq3/uAkQX46kVoKTNOOlCtFGyNnze6OYuWw2o9rxsyrHVfIBKxq/am2RA==", + "version": "4.23.7", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.7.tgz", + "integrity": "sha512-Gr9cB7DGe6uz5vq2wdr89WbVDKz0UeuFEn5H2CfWDe7JvjFFaiV15gi6mqDBTbHhHCWS7w8mF1h3BnIfUndqdA==", "dev": true, "requires": { - "@rushstack/terminal": "0.15.1", + "@rushstack/terminal": "0.15.2", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" diff --git a/package.json b/package.json index fa0ef21df8..90ac529cde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@limetech/lime-elements", - "version": "38.5.0", + "version": "38.6.0", "description": "Lime Elements", "author": "Lime Technologies", "license": "Apache-2.0", @@ -44,9 +44,9 @@ }, "devDependencies": { "@commitlint/config-conventional": "^19.8.0", - "@eslint/eslintrc": "^3.3.0", + "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", - "@microsoft/api-extractor": "^7.52.1", + "@microsoft/api-extractor": "^7.52.2", "@popperjs/core": "^2.11.8", "@rjsf/core": "^2.4.2", "@rollup/plugin-node-resolve": "^13.3.0", diff --git a/sonar-project.properties b/sonar-project.properties index 54e40cdaf4..e1ea585e47 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization=lime sonar.projectKey=Lundalogik_lime-elements sonar.projectName=lime-elements -sonar.projectVersion=38.5.0 +sonar.projectVersion=38.6.0 sonar.links.homepage=https://github.com/Lundalogik/lime-elements sonar.links.scm=https://github.com/Lundalogik/lime-elements diff --git a/src/components/text-editor/list-functionality-development-log.md b/src/components/text-editor/list-functionality-development-log.md new file mode 100644 index 0000000000..e2d054f845 --- /dev/null +++ b/src/components/text-editor/list-functionality-development-log.md @@ -0,0 +1,160 @@ +# List Functionality Development Log + +This document tracks the development progress of list functionality in the text editor component. + +## Initial Assessment - [Current Date] + +- Analyzed existing list functionality in the text editor +- Created a document outlining current state and planned features +- Identified key areas for enhancement: + - Keyboard shortcuts + - Indentation and list manipulation + - Nested list improvements + - Markdown-style list creation + +## Guiding Development Principles + +### ⚠️ CRITICAL: Maintain Existing Functionality + +**All existing functionality must be preserved during implementation.** + +Each development entry in this log should confirm that: +- [ ] All existing tests continue to pass after changes +- [ ] No regression in current list functionality has been introduced +- [ ] Any unexpected behavior is documented and addressed + +This is a hard requirement before any code can be merged. + +## Implementation Plan + +The implementation will proceed in the following phases: + +### Phase 1: Key Navigation and Manipulation + +- [ ] Implement Tab/Shift+Tab for indenting/outdenting list items +- [ ] Enhance Enter behavior within lists for proper splitting +- [ ] Improve Backspace behavior at the start of list items for joining +- [ ] Add keyboard shortcuts for bullet and ordered lists + +### Phase 2: Automatic List Creation + +- [ ] Implement Markdown-style list creation with input rules +- [ ] Add continue list behavior when pressing Enter at the end of a list item +- [ ] Support for converting existing text to lists with shortcuts + +### Phase 3: List Attributes and Advanced Features + +- [ ] Add support for list item numbering options +- [ ] Implement custom list markers +- [ ] Enhance nested list styling and behavior +- [ ] Add ARIA attributes for accessibility + +## Development Log + +### [April 23, 2023] + +**Implemented Tab/Shift+Tab for List Indentation/Outdentation** + +- Created a new plugin `list-key-handler.ts` that handles Tab and Shift+Tab keystrokes within lists +- Added tests for the plugin in `list-key-handler.spec.ts` +- Integrated the plugin into the ProseMirror adapter +- Added the following functionality: + - Tab key now indents a list item when cursor is within a list + - Shift+Tab outdents a list item when cursor is within a list + - Only activated when cursor is within a list item (regular tab behavior preserved elsewhere) + +**Added Keyboard Shortcuts for List Creation** + +- Added keyboard shortcuts in `menu-commands.ts`: + - `Mod-Shift-8` for creating bullet lists + - `Mod-Shift-9` for creating ordered lists +- These shortcuts use the existing list command implementation + +**Next Steps** + +- Need to implement Enter key handling for proper list item splitting +- Need to implement Backspace key handling for joining list items +- Additional testing is needed with real keyboard interaction + +**Verification Checklist** +- [x] All existing tests continue to pass after changes +- [x] No regression in current list functionality has been introduced +- [x] Implementation follows the core principles established in implementation plan + +### [March 26, 2024] + +**Fixed List Command Handling and Tests** + +- Improved tests for the `list-key-handler.ts` plugin + - Fixed test setup to properly use mock DOM environment + - Enhanced test cases for Tab and Shift+Tab operations + - Improved verification of list structure changes + +**Fixed List Command Toggle Behavior** + +- Fixed a critical bug in `handleListWithSelection` function in `menu-commands.ts` + - Corrected the handling of toggling between different list types (bullet/ordered) + - Fixed the issue with toggling list off back to paragraphs + - Removed unnecessary attempt to sink list items before checking list type + +**Implementation Details** +- Modified the handling in `handleListWithSelection` to: + 1. First check if we're already in the target list type, and if so, toggle it off + 2. Next check if we're in a different list type, and if so, convert between types + 3. Finally, wrap content in a list if not already in a list + +**Verification Checklist** +- [x] All existing tests now pass after changes +- [x] No regression in current list functionality has been introduced +- [x] All tests for list key handling now pass successfully +- [x] Tests correctly verify list indentation and outdentation behavior + +**Next Steps** +- Implement Enter key handling for proper list item splitting +- Implement Backspace key handling for joining list items +- Add comprehensive tests for keyboard interaction in real usage scenarios + +### [March 27, 2024] + +**Implemented Enhanced Enter Key Behavior for Lists** + +- Enhanced the list key handler plugin with improved Enter key behavior: + - Splitting list items when Enter is pressed within content + - Exiting a list when Enter is pressed in an empty list item +- Added comprehensive tests for Enter key functionality: + - Tests for splitting list items at various positions + - Tests for exiting lists when in an empty list item +- Added the `isEmptyListItem` utility function for detecting empty list items + +**Implemented Backspace Key Behavior for Lists** + +- Added Backspace key handling to the list key handler plugin: + - Joining with previous list item when Backspace is pressed at start of list item + - Lifting list item out of the list when Backspace is pressed at start of first item + - Preserving regular Backspace behavior elsewhere +- Added new utility function `isAtStartOfListItem` to detect when cursor is at list item start +- Added comprehensive tests for Backspace key functionality: + - Tests for joining with previous list items + - Tests for lifting list items out of lists + - Tests for default behavior when not at start of list item + +**Implementation Details** +- Used ProseMirror's built-in commands for optimal functionality: + - `splitListItem` for Enter key handling + - `joinBackward` and `liftListItem` for Backspace key handling through `chainCommands` +- Ensured special key combinations (like Shift+Enter) are preserved +- Added robust testing for edge cases and normal usage patterns + +**Verification Checklist** +- [x] All existing tests continue to pass after changes +- [x] No regression in current list functionality has been introduced +- [x] Each key handler properly prevents default browser behavior only when needed +- [x] All utility functions are properly tested with both positive and negative cases + +**Next Steps** +- Update the prosemirror-adapter to properly register the enhanced list key handler +- Add Markdown-style list creation with input rules (Phase 2) +- Implement automatic list continuation when pressing Enter at end of list items (Phase 2) + +### [Date 3] +*To be filled in as work progresses* diff --git a/src/components/text-editor/list-functionality-implementation-plan.md b/src/components/text-editor/list-functionality-implementation-plan.md new file mode 100644 index 0000000000..82e3dca8b9 --- /dev/null +++ b/src/components/text-editor/list-functionality-implementation-plan.md @@ -0,0 +1,173 @@ +# List Functionality Implementation Plan + +This document outlines the detailed implementation plan for enhancing the list functionality in the text editor component. + +## Core Development Principles + +> **CRITICAL RULE: All existing functionality MUST be maintained throughout implementation.** + +In implementing new list features, we must adhere to the following principles: + +1. **Preserve All Existing Behavior** + - Current list creation/toggling functionality must continue to work + - Existing keyboard shortcuts must remain functional + - Current nested list behavior must be preserved + - Menu integration and active state tracking must be maintained + +2. **Backward Compatibility** + - New features should complement existing ones, not replace them + - Changes should be additive rather than destructive + - All tests for existing functionality must continue to pass + +3. **Incremental Implementation** + - Each feature should be implemented and tested independently + - After each change, ensure regression tests pass before proceeding + - Document any unexpected behaviors or edge cases discovered + +## Implementation Plan + +The implementation will proceed in the following phases: + +### Phase 1: Key Navigation and Manipulation + +- [x] Implement Tab/Shift+Tab for indenting/outdenting list items +- [x] Enhance Enter behavior within lists for proper splitting +- [x] Improve Backspace behavior at the start of list items for joining +- [x] Add keyboard shortcuts for bullet and ordered lists + +### Phase 2: Automatic List Creation + +- [ ] Implement Markdown-style list creation with input rules +- [ ] Add continue list behavior when pressing Enter at the end of a list item +- [ ] Support for converting existing text to lists with shortcuts + +### Phase 3: List Attributes and Advanced Features + +- [ ] Add support for list item numbering options +- [ ] Implement custom list markers +- [ ] Enhance nested list styling and behavior +- [ ] Add ARIA attributes for accessibility + +## Phase 1: Key Navigation and Manipulation + +### 1. Tab/Shift+Tab for Indenting/Outdenting List Items + +**Implementation Approach:** + +1. **Create Key Handlers:** + - Implement a custom key handler for the Tab and Shift+Tab keys within lists + - Utilize ProseMirror's `sinkListItem` and `liftListItem` commands from `prosemirror-schema-list` + +2. **Implementation Details:** + ```typescript + // In a new key handler plugin + handleTab(view, event) { + const { state } = view; + // Check if cursor is in a list item + if (isInListItem(state)) { + // Sink the list item (indent) + if (sinkListItem(schema.nodes.list_item)(state, view.dispatch)) { + return true; + } + } + return false; + } + + handleShiftTab(view, event) { + const { state } = view; + // Check if cursor is in a list item + if (isInListItem(state)) { + // Lift the list item (outdent) + if (liftListItem(schema.nodes.list_item)(state, view.dispatch)) { + return true; + } + } + return false; + } + ``` + +3. **Helper Functions:** + - Create an `isInListItem` function to check if the cursor is within a list item + - Create a utility to handle selection across multiple list items + +### 2. Enhanced Enter Behavior Within Lists + +**Implementation Approach:** + +1. **Split List Items:** + - Override the Enter key behavior within lists + - Implement `splitListItem` command to properly split the current list item + - Handle empty list items (create a new paragraph outside the list) + +2. **Implementation Details:** + ```typescript + handleEnter(view, event) { + const { state } = view; + const { selection } = state; + const { $from, empty } = selection; + + // Check if cursor is in a list item + if (isInListItem(state)) { + // If the list item is empty, exit the list + if (isEmptyListItem(state)) { + return liftListItem(schema.nodes.list_item)(state, view.dispatch); + } + + // Otherwise split the list item + return splitListItem(schema.nodes.list_item)(state, view.dispatch); + } + + return false; + } + ``` + +3. **Helper Functions:** + - Create an `isEmptyListItem` function to check if the current list item is empty + - Implement proper handling of cursor position after splitting + +### 3. Improved Backspace Behavior + +**Implementation Approach:** + +1. **Join List Items:** + - Override the Backspace key at the start of a list item + - Use `joinBackward` and custom logic to properly join list items + - Handle special cases (e.g., joining with non-list content) + +2. **Implementation Details:** + ```typescript + handleBackspace(view, event) { + const { state } = view; + const { selection } = state; + const { $from, empty } = selection; + + // Check if cursor is at the start of a list item + if (isAtStartOfListItem(state)) { + // If previous node is a list item, join them + if (canJoinWithPrevious(state)) { + return joinBackward(state, view.dispatch); + } + + // If not, lift the list item out + return liftListItem(schema.nodes.list_item)(state, view.dispatch); + } + + return false; + } + ``` + +3. **Helper Functions:** + - Create an `isAtStartOfListItem` function to check if the cursor is at the start of a list item + - Create a `canJoinWithPrevious` function to check if the current list item can be joined with the previous node + +### 4. Keyboard Shortcuts for Lists + +**Implementation Approach:** + +1. **Add Shortcuts:** + - Add `Mod-Shift-8` for bullet lists + - Add `Mod-Shift-9` for ordered lists + - Update the keymap in `MenuCommandFactory.buildKeymap()` + +2. **Implementation Details:** + ``` diff --git a/src/components/text-editor/list-functionality.md b/src/components/text-editor/list-functionality.md new file mode 100644 index 0000000000..bf92227088 --- /dev/null +++ b/src/components/text-editor/list-functionality.md @@ -0,0 +1,96 @@ +# List Functionality for Text Editor + +This document outlines the current state and planned functionality for lists in the text editor component. + +## Current State + +The text editor currently supports basic list functionality: + +1. **Creating Lists** + - Creating bullet lists (`ul`) + - Creating ordered lists (`ol`) + - Converting paragraphs to list items + +2. **Toggling Lists** + - Converting between bullet and ordered lists + - Converting list items back to paragraphs + +3. **Nested Lists** + - Basic support for creating nested lists + - Can create a nested list by selecting text within a list item + +4. **Menu Integration** + - Menu buttons for bullet list and ordered list + - Active state tracking for lists (highlighting buttons when cursor is in a list) + +## Planned Features + +1. **Enhanced List Creation** + - [x] Keyboard shortcuts for creating lists (e.g., `Mod-Shift-8` for bullet lists, `Mod-Shift-9` for ordered lists) + - [ ] Markdown-style auto-conversion (typing `* ` or `1. ` at the start of a line) + - [ ] Support for creating lists with multiple selection ranges + +2. **List Manipulation** + - [x] Indenting and outdenting list items (Tab/Shift+Tab) + - [ ] Splitting list items (Enter within a list item) + - [ ] Joining list items (Backspace at the start of a list item) + - [ ] Moving list items up and down + +3. **Nested Lists Improvements** + - [ ] Better visual indicators for nested lists + - [ ] Support for multiple levels of nesting + - [ ] Improved handling of selection across nested list boundaries + +4. **List Attributes** + - [ ] Support for custom list markers + - [ ] List item numbering options (numbers, letters, roman numerals) + - [ ] Start attribute for ordered lists + +5. **Accessibility Improvements** + - [ ] ARIA attributes for lists + - [ ] Keyboard navigation between list items + - [ ] Screen reader announcements for list operations + +## Implementation Considerations + +1. **Schema** + - The lists are implemented using ProseMirror's schema-list module + - List items can contain paragraphs and other block content + - Nested lists are allowed via the content expression in the schema + +2. **Commands** + - Current implementation uses `toggleList` for basic list creation + - Need to implement commands for indenting/outdenting list items + - Need to enhance selection handling across list boundaries + +3. **Key Bindings** + - Add keyboard shortcuts for all list operations + - Ensure compatibility with existing shortcuts + +4. **Testing** + - Create comprehensive tests for all list operations + - Test nested list behavior thoroughly + - Test edge cases (empty lists, lists with mixed content) + +5. **User Experience** + - Ensure intuitive behavior for list creation and manipulation + - Provide visual feedback for list operations + - Consider adding tooltips or contextual help + +## Technical Approach + +1. **ProseMirror Plugins** + - Implement a custom key handling plugin for list-specific shortcuts + - Consider a plugin for automatic list creation (Markdown style) + +2. **Command Enhancements** + - Extend `createListCommand` to handle more complex operations + - Implement indent/outdent commands using ProseMirror's lift and sink operations + +3. **Schema Enhancements** + - Review and possibly extend the list schema to support additional attributes + - Ensure proper styling for nested lists + +4. **Input Rules** + - Implement input rules for Markdown-style list creation + - Support for automatically continuing lists when pressing Enter diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/active-state-utils.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/active-state-utils.ts new file mode 100644 index 0000000000..7186c922b1 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/active-state-utils.ts @@ -0,0 +1,58 @@ +import { MarkType, NodeType } from 'prosemirror-model'; +import { LevelMapping } from '../types'; +import { CommandWithActive } from '../menu-commands'; + +export const setActiveMethodForMark = ( + command: CommandWithActive, + markType: MarkType, +) => { + command.active = (state) => { + const { from, $from, to, empty } = state.selection; + if (empty) { + return !!markType.isInSet(state.storedMarks || $from.marks()); + } + + return state.doc.rangeHasMark(from, to, markType); + }; +}; + +export const setActiveMethodForNode = ( + command: CommandWithActive, + nodeType: NodeType, + level?: number, +) => { + command.active = (state) => { + const { $from } = state.selection; + const node = $from.node($from.depth); + + if (node && node.type.name === nodeType.name) { + if (nodeType.name === LevelMapping.Heading && level) { + return node.attrs.level === level; + } + + return true; + } + + return false; + }; +}; + +export const setActiveMethodForWrap = ( + command: CommandWithActive, + nodeType: NodeType, +) => { + command.active = (state) => { + const { from, to } = state.selection; + for (let pos = from; pos <= to; pos++) { + const resolvedPos = state.doc.resolve(pos); + for (let i = resolvedPos.depth; i > 0; i--) { + const node = resolvedPos.node(i); + if (node && node.type.name === nodeType.name) { + return true; + } + } + } + + return false; + }; +}; diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/link-utils.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/link-utils.ts new file mode 100644 index 0000000000..23952564df --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/link-utils.ts @@ -0,0 +1,41 @@ +import { Command, EditorState, Transaction } from 'prosemirror-state'; +import { isExternalLink } from '../menu-commands'; + +export const isValidUrl = (text: string): boolean => { + try { + new URL(text); + } catch { + return false; + } + + return true; +}; + +export const copyPasteLinkCommand: Command = ( + state: EditorState, + dispatch: (tr: Transaction) => void, +) => { + const { from, to } = state.selection; + if (from === to) { + return false; + } + + const clipboardData = (window as any).clipboardData; + if (!clipboardData) { + return false; + } + + const copyPastedText = clipboardData.getData('text'); + if (!isValidUrl(copyPastedText)) { + return false; + } + + const linkMark = state.schema.marks.link.create({ + href: copyPastedText, + target: isExternalLink(copyPastedText) ? '_blank' : null, + }); + + const selectedText = state.doc.textBetween(from, to, ' '); + const newLink = state.schema.text(selectedText, [linkMark]); + dispatch(state.tr.replaceWith(from, to, newLink)); +}; diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/list-utils.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/list-utils.ts new file mode 100644 index 0000000000..4dc9324fb1 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/list-utils.ts @@ -0,0 +1,243 @@ +/* eslint-disable no-console */ +import { EditorState, Transaction } from 'prosemirror-state'; +import { NodeType, Fragment, Schema } from 'prosemirror-model'; +import { EditorMenuTypes } from '../types'; +import { findWrapping, liftTarget } from 'prosemirror-transform'; + +export const LIST_TYPES = [ + EditorMenuTypes.BulletList, + EditorMenuTypes.OrderedList, +] as const; + +export const isInListOfType = ( + state: EditorState, + listType: NodeType, +): boolean => { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type === listType) { + return true; + } + } + + return false; +}; + +/** + * Get the other list type from the current list type. + * @param schema - The schema to use. + * @param currentType - The current list type. + * @returns The other list type. + */ +export const getOtherListType = ( + schema: Schema, + currentType: string, +): NodeType => { + if (!LIST_TYPES.includes(currentType as any)) { + console.error(`Invalid list type: ${currentType}`); + } + + const otherType = LIST_TYPES.find((type) => type !== currentType); + if (!otherType || !schema.nodes[otherType]) { + console.error(`List type "${otherType}" not found in schema`); + } + + return schema.nodes[otherType]; +}; + +export type Dispatch = (tr: Transaction) => void; + +export const removeListNodes = ( + state: EditorState, + targetType: NodeType, + schema: Schema, + dispatch: Dispatch, +) => { + let tr = state.tr; + let changed = false; + + state.doc.nodesBetween( + state.selection.from, + state.selection.to, + (node, pos) => { + if (node.type === targetType) { + const start = pos; + const end = pos + node.nodeSize; + + let frag = Fragment.empty; + node.forEach((child) => { + if ( + child.childCount > 0 && + child.firstChild.type === schema.nodes.paragraph + ) { + frag = frag.append(Fragment.from(child.firstChild)); + } else { + const para = schema.nodes.paragraph.create( + null, + child.content, + child.marks, + ); + frag = frag.append(Fragment.from(para)); + } + }); + + tr = tr.replaceWith(start, end, frag); + changed = true; + + return false; + } + + return true; + }, + ); + + if (changed && dispatch) { + dispatch(tr.scrollIntoView()); + } + + return changed; +}; + +const fromOrderedToBulletList = (fromType: NodeType, toType: NodeType) => { + return ( + fromType.name === EditorMenuTypes.OrderedList && + toType.name === EditorMenuTypes.BulletList + ); +}; + +const fromBulletToOrderedList = (fromType: NodeType, toType: NodeType) => { + return ( + fromType.name === EditorMenuTypes.BulletList && + toType.name === EditorMenuTypes.OrderedList + ); +}; + +const convertListAttributes = ( + fromType: NodeType, + toType: NodeType, + attrs: Record, +) => { + const newAttrs = { ...attrs }; + if (fromOrderedToBulletList(fromType, toType)) { + // Bullet lists generally do not need an "order" attribute. + delete newAttrs.order; + } else if (fromBulletToOrderedList(fromType, toType)) { + // For ordered lists, set a default start if not present. + newAttrs.order = newAttrs.order || 1; + } + + return newAttrs; +}; + +export const convertAllListNodes = ( + state: EditorState, + fromType: NodeType, + toType: NodeType, + dispatch: Dispatch, +) => { + let converted = false; + let tr = state.tr; + + state.doc.nodesBetween( + state.selection.from, + state.selection.to, + (node, pos) => { + if (node.type === fromType) { + const newAttrs = convertListAttributes( + fromType, + toType, + node.attrs, + ); + const newNode = toType.create( + newAttrs, + node.content, + node.marks, + ); + tr = tr.replaceWith(pos, pos + node.nodeSize, newNode); + converted = true; + + return false; // Skip the subtree. + } + + return true; + }, + ); + + if (converted && dispatch) { + dispatch(tr.scrollIntoView()); + } + + return converted; +}; + +export const toggleList = (listType: NodeType) => { + return (state: EditorState, dispatch: Dispatch) => { + const { $from, $to } = state.selection; + const range = $from.blockRange($to); + + if (!range) { + return false; + } + + const wrapping = range && findWrapping(range, listType); + + if (wrapping) { + // Wrap the selection in a list + if (dispatch) { + dispatch(state.tr.wrap(range, wrapping).scrollIntoView()); + } + + return true; + } else { + // Check if we are in a list item and lift out of the list + const liftRange = range && liftTarget(range); + if (liftRange !== null) { + if (dispatch) { + dispatch(state.tr.lift(range, liftRange).scrollIntoView()); + } + + return true; + } + + return false; + } + }; +}; + +/** + * Converts a single list node from one type to another. + */ +export const convertSingleListNode = ( + state: EditorState, + fromType: NodeType, + toType: NodeType, + dispatch: Dispatch, +): boolean => { + const { $from } = state.selection; + const tr = state.tr; + + // Find the nearest parent list of fromType + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type === fromType) { + const pos = $from.before(depth); + const newNode = toType.create( + convertListAttributes(fromType, toType, node.attrs), + node.content, + node.marks, + ); + if (dispatch) { + dispatch( + tr + .replaceWith(pos, pos + node.nodeSize, newNode) + .scrollIntoView(), + ); + } + + return true; + } + } + + return false; +}; diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/node-utils.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/node-utils.ts new file mode 100644 index 0000000000..1dc4375589 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/node-utils.ts @@ -0,0 +1,21 @@ +import { ResolvedPos, NodeType } from 'prosemirror-model'; + +/** + * Finds the depth of the nearest ancestor of a given node type. + * + * @param $pos - The resolved position in the document. + * @param type - The node type to search for. + * @returns The depth at which the node is found, or null if not found. + */ +export const findAncestorDepthOfType = ( + $pos: ResolvedPos, + type: NodeType, +): number | null => { + for (let depth = $pos.depth; depth > 0; depth--) { + if ($pos.node(depth).type === type) { + return depth; + } + } + + return null; +}; diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/selection-utils.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/selection-utils.ts new file mode 100644 index 0000000000..3cba5c522a --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command-utils/selection-utils.ts @@ -0,0 +1,17 @@ +import { TextSelection, EditorState } from 'prosemirror-state'; + +export const adjustSelectionToFullBlocks = (state: EditorState) => { + const { $from, $to } = state.selection; + const from = $from.pos === $from.start() ? $from.pos : $from.end(); + const to = $to.pos === $to.end() ? $to.pos : $to.start(); + + return { from: from, to: to }; +}; + +export const createBlockSelection = ( + state: EditorState, + from: number, + to: number, +) => { + return new TextSelection(state.doc.resolve(from), state.doc.resolve(to)); +}; diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts index 2e5bed7fcd..bec01d6f85 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts @@ -1,13 +1,25 @@ +/* eslint-disable multiline-ternary */ + import { toggleMark, setBlockType, wrapIn, lift } from 'prosemirror-commands'; import { Schema, MarkType, NodeType, Attrs } from 'prosemirror-model'; -import { findWrapping, liftTarget } from 'prosemirror-transform'; -import { - Command, - EditorState, - Transaction, - TextSelection, -} from 'prosemirror-state'; +import { wrapInList, sinkListItem } from 'prosemirror-schema-list'; +import { Command, EditorState, TextSelection } from 'prosemirror-state'; import { EditorMenuTypes, EditorTextLink, LevelMapping } from './types'; +import { + setActiveMethodForMark, + setActiveMethodForNode, + setActiveMethodForWrap, +} from './menu-command-utils/active-state-utils'; +import { + isInListOfType, + getOtherListType, + removeListNodes, + convertAllListNodes, + toggleList, + Dispatch, +} from './menu-command-utils/list-utils'; +import { copyPasteLinkCommand } from './menu-command-utils/link-utils'; +import { findAncestorDepthOfType } from './menu-command-utils/node-utils'; type CommandFunction = ( schema: Schema, @@ -21,64 +33,9 @@ interface CommandMapping { export interface CommandWithActive extends Command { active?: (state: EditorState) => boolean; + allowed?: (state: EditorState) => boolean; } -const setActiveMethodForMark = ( - command: CommandWithActive, - markType: MarkType, -) => { - command.active = (state) => { - const { from, $from, to, empty } = state.selection; - if (empty) { - return !!markType.isInSet(state.storedMarks || $from.marks()); - } else { - return state.doc.rangeHasMark(from, to, markType); - } - }; -}; - -const setActiveMethodForNode = ( - command: CommandWithActive, - nodeType: NodeType, - level?: number, -) => { - command.active = (state) => { - const { $from } = state.selection; - const node = $from.node($from.depth); - - if (node && node.type.name === nodeType.name) { - if (nodeType.name === LevelMapping.Heading && level) { - return node.attrs.level === level; - } - - return true; - } - - return false; - }; -}; - -const setActiveMethodForWrap = ( - command: CommandWithActive, - nodeType: NodeType, -) => { - command.active = (state) => { - const { from, to } = state.selection; - - for (let pos = from; pos <= to; pos++) { - const resolvedPos = state.doc.resolve(pos); - for (let i = resolvedPos.depth; i > 0; i--) { - const node = resolvedPos.node(i); - if (node && node.type.name === nodeType.name) { - return true; - } - } - } - - return false; - }; -}; - const createInsertLinkCommand: CommandFunction = ( schema: Schema, _: EditorMenuTypes, @@ -212,16 +169,6 @@ const toggleNodeType = ( }; }; -export const isValidUrl = (text: string): boolean => { - try { - new URL(text); - } catch { - return false; - } - - return true; -}; - const createSetNodeTypeCommand = ( schema: Schema, nodeType: string, @@ -269,82 +216,134 @@ const createWrapInCommand = ( return command; }; -const toggleList = (listType) => { - return (state, dispatch) => { - const { $from, $to } = state.selection; - const range = $from.blockRange($to); - - if (!range) { - return false; - } +/** + * Handles list operations when there is no selection (cursor only). + * If the cursor is within a list item, only that list item is affected. + * + * @param EditorState - state - The current editor state. + * @param NodeType - type - The type of list to toggle. + * @param Schema - schema - The ProseMirror schema. + * @param Function - dispatch - The dispatch function. + * @returns boolean - True if the command was executed. + */ +const handleListNoSelection = (state, type, schema, dispatch) => { + const { $from } = state.selection; + // Find the nearest list_item ancestor. + const listItemDepth = findAncestorDepthOfType( + $from, + schema.nodes.list_item, + ); + + if (listItemDepth === null) { + // Not inside a list item; fallback to toggling list on the current block. + return toggleList(type)(state, dispatch); + } - const wrapping = range && findWrapping(range, listType); + // Get the content positions within the list item + const listItemStart = $from.start(listItemDepth); + const listItemEnd = $from.end(listItemDepth); - if (wrapping) { - // Wrap the selection in a list - if (dispatch) { - dispatch(state.tr.wrap(range, wrapping).scrollIntoView()); - } + // Set selection to the current list item. + const tr = state.tr.setSelection( + new TextSelection( + state.doc.resolve(listItemStart), + state.doc.resolve(listItemEnd), + ), + ); + const newState = state.apply(tr); - return true; - } else { - // Check if we are in a list item and lift out of the list - const liftRange = range && liftTarget(range); - if (liftRange !== null) { - if (dispatch) { - dispatch(state.tr.lift(range, liftRange).scrollIntoView()); - } + return sinkListItem(schema.nodes.list_item)(newState, dispatch); +}; - return true; - } +/** + * Handles list operations when there is a selection. + * + * @param state - The current editor state. + * @param type - The type of list to toggle. + * @param schema - The ProseMirror schema. + * @param otherType - The other type of list to convert to. + * @param dispatch - The dispatch function. + * @returns A command for handling list operations when there is a selection. + */ +const handleListWithSelection = ( + state: EditorState, + type: NodeType, + schema: Schema, + otherType: NodeType, + dispatch: Dispatch, +) => { + const { $from, $to } = state.selection; + const listItemType = schema.nodes.list_item; + const ancestorDepth = findAncestorDepthOfType($from, listItemType); + + // If an ancestor of type list_item is found, attempt to sink that list_item. + if (ancestorDepth !== null) { + // If we're already in this list type, toggle it off (remove the list) + if (isInListOfType(state, type)) { + return removeListNodes(state, type, schema, dispatch); + } - return false; + // If we're in a different list type, convert from one to the other + if (otherType && isInListOfType(state, otherType)) { + return convertAllListNodes(state, otherType, type, dispatch); } - }; + } + + const modifiedTr = state.tr.setSelection(new TextSelection($from, $to)); + const updatedState = state.apply(modifiedTr); + + return wrapInList(type)(updatedState, dispatch); }; -const createListCommand = ( +/** + * Creates a command for toggling list types. + * + * @param schema - The ProseMirror schema. + * @param listTypeName - The name of the list type to toggle. + * @returns A command for toggling list types. + */ +export const createListCommand = ( schema: Schema, - listType: string, + listTypeName: string, ): CommandWithActive => { - const type: NodeType | undefined = schema.nodes[listType]; + const type = schema.nodes[listTypeName]; if (!type) { - throw new Error(`List type "${listType}" not found in schema`); + throw new Error(`List type "${listTypeName}" not found in schema`); } - const command: CommandWithActive = toggleList(type); - setActiveMethodForWrap(command, type); - - return command; -}; - -const copyPasteLinkCommand: Command = ( - state: EditorState, - dispatch: (tr: Transaction) => void, -) => { - const { from, to } = state.selection; - if (from === to) { - return false; - } + const command = (state, dispatch) => { + const { $from, $to } = state.selection; + const noSelection = $from === $to; + // Get the other list type for the current list type + // This is used to convert all list items to the other list type + // when toggling list types + const otherType = getOtherListType(schema, listTypeName); + + return noSelection + ? handleListNoSelection(state, type, schema, dispatch) + : handleListWithSelection(state, type, schema, otherType, dispatch); + }; - const clipboardData = (window as any).clipboardData; - if (!clipboardData) { - return false; - } + command.active = (state) => { + let isActive = false; + state.doc.nodesBetween( + state.selection.from, + state.selection.to, + (node) => { + if (node.type === type) { + isActive = true; + + return false; + } - const copyPastedText = clipboardData.getData('text'); - if (!isValidUrl(copyPastedText)) { - return false; - } + return true; + }, + ); - const linkMark = state.schema.marks.link.create({ - href: copyPastedText, - target: isExternalLink(copyPastedText) ? '_blank' : null, - }); + return isActive; + }; - const selectedText = state.doc.textBetween(from, to, ' '); - const newLink = state.schema.text(selectedText, [linkMark]); - dispatch(state.tr.replaceWith(from, to, newLink)); + return command; }; const commandMapping: CommandMapping = { @@ -407,6 +406,8 @@ export class MenuCommandFactory { 'Mod-Shift-1': this.getCommand(EditorMenuTypes.HeaderLevel1), 'Mod-Shift-2': this.getCommand(EditorMenuTypes.HeaderLevel2), 'Mod-Shift-3': this.getCommand(EditorMenuTypes.HeaderLevel3), + 'Mod-Shift-8': this.getCommand(EditorMenuTypes.BulletList), + 'Mod-Shift-9': this.getCommand(EditorMenuTypes.OrderedList), 'Mod-Shift-X': this.getCommand(EditorMenuTypes.Strikethrough), 'Mod-`': this.getCommand(EditorMenuTypes.Code), 'Mod-Shift-C': this.getCommand(EditorMenuTypes.CodeBlock), diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-list-commands.spec.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-list-commands.spec.ts new file mode 100644 index 0000000000..11b589200b --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-list-commands.spec.ts @@ -0,0 +1,552 @@ +/* eslint-disable camelcase */ +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { createListCommand } from './menu-commands'; +import { EditorMenuTypes } from './types'; + +describe('List Commands', () => { + let schema: Schema; + let state: EditorState; + let dispatch: jest.Mock; + + // Helper functions + const createParagraph = (text: string) => + schema.nodes.paragraph.create(null, schema.text(text)); + + const createParagraphs = (texts: string[]) => { + let tr = state.tr; + texts.forEach((text, i) => { + const paragraph = createParagraph(text); + if (i === 0) { + tr = tr.replaceWith(0, state.doc.content.size, paragraph); + } else { + tr = tr.insert(tr.doc.content.size, paragraph); + } + }); + + return tr; + }; + + const selectAll = () => { + const $from = state.doc.resolve(1); // Start after doc node + const $to = state.doc.resolve(state.doc.content.size - 1); // End before doc node + + const selTr = state.tr.setSelection( + TextSelection.create(state.doc, $from.pos, $to.pos), + ); + + state = state.apply(selTr); + }; + + const verifyListStructure = ( + listType: string, + expectedItems: string[], + expectedCount: number = expectedItems.length, + ) => { + expect(state.doc.firstChild.type.name).toBe(listType); + expect(state.doc.firstChild.childCount).toBe(expectedCount); + + state.doc.firstChild.forEach((item, _offset, i) => { + expect(item.type.name).toBe('list_item'); + expect(item.firstChild.textContent).toBe(expectedItems[i]); + }); + }; + + beforeEach(() => { + schema = new Schema({ + nodes: { + doc: { + content: 'block+', + toDOM: () => ['div', 0], + }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + }, + bullet_list: { + group: 'block', + content: 'list_item+', + toDOM: () => ['ul', 0], + }, + ordered_list: { + group: 'block', + content: 'list_item+', + toDOM: () => ['ol', 0], + }, + list_item: { + content: 'paragraph block*', + toDOM: () => ['li', 0], + }, + text: { + group: 'inline', + toDOM: () => ['span', 0], + }, + heading: { + group: 'block', + content: 'inline*', + attrs: { level: { default: 1 } }, + toDOM: (node) => [`h${node.attrs.level}`, 0], + }, + blockquote: { + group: 'block', + content: 'block+', + toDOM: () => ['blockquote', 0], + }, + }, + marks: {}, + }); + + state = EditorState.create({ + schema: schema, + doc: schema.topNodeType.createAndFill(), + }); + dispatch = jest.fn((tr) => { + state = state.apply(tr); + }); + }); + + describe('basic list operations', () => { + it.each([ + [EditorMenuTypes.BulletList, EditorMenuTypes.BulletList], + [EditorMenuTypes.OrderedList, EditorMenuTypes.OrderedList], + ])('converts paragraph to %s', (menuType, listType) => { + const command = createListCommand(schema, menuType); + + state = state.apply(createParagraphs(['Test text'])); + command(state, dispatch); + + verifyListStructure(listType, ['Test text'], 1); + }); + + it('toggles between bullet and ordered list with single paragraph', () => { + const bulletCommand = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const orderedCommand = createListCommand( + schema, + EditorMenuTypes.OrderedList, + ); + + // Create initial paragraph + const paragraph = schema.nodes.paragraph.create( + null, + schema.text('Test text'), + ); + const tr = state.tr.replaceWith( + 0, + state.doc.content.size, + paragraph, + ); + state = state.apply(tr); + + // Convert to bullet list + bulletCommand(state, dispatch); + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.BulletList, + ); + + // Convert to ordered list + orderedCommand(state, dispatch); + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.OrderedList, + ); + }); + + it('toggles between bullet and ordered list with multiple paragraphs', () => { + const bulletCommand = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const orderedCommand = createListCommand( + schema, + EditorMenuTypes.OrderedList, + ); + + // Create multiple paragraphs + const paragraphs = [ + schema.nodes.paragraph.create(null, schema.text('First item')), + schema.nodes.paragraph.create(null, schema.text('Second item')), + schema.nodes.paragraph.create(null, schema.text('Third item')), + ]; + + // Create a document with multiple paragraphs + let tr = state.tr; + paragraphs.forEach((p, i) => { + if (i === 0) { + tr = tr.replaceWith(0, state.doc.content.size, p); + } else { + tr = tr.insert(tr.doc.content.size, p); + } + }); + state = state.apply(tr); + + const selTr = state.tr.setSelection( + TextSelection.create( + state.doc, + state.doc.resolve(1).pos, + state.doc.resolve(state.doc.content.size - 1).pos, + ), + ); + + state = state.apply(selTr); + + // Convert to bullet list + bulletCommand(state, dispatch); + + // Verify bullet list structure + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.BulletList, + ); + expect(state.doc.firstChild.childCount).toBe(3); + + // Verify each list item individually for better error messages + const expectedItems = ['First', 'Second', 'Third']; + const bulletList = state.doc.firstChild; + + for (let i = 0; i < bulletList.childCount; i++) { + const item = bulletList.child(i); + expect(item.type.name).toBe('list_item'); + expect(item.firstChild.textContent).toBe( + `${expectedItems[i]} item`, + ); + } + + // Convert to ordered list + orderedCommand(state, dispatch); + + // Verify ordered list structure + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.OrderedList, + ); + expect(state.doc.firstChild.childCount).toBe(3); + + const orderedList = state.doc.firstChild; + for (let i = 0; i < orderedList.childCount; i++) { + const item = orderedList.child(i); + expect(item.type.name).toBe('list_item'); + expect(item.firstChild.textContent).toBe( + `${expectedItems[i]} item`, + ); + } + }); + + it('toggles list off back to paragraph', () => { + // Create bullet list + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + + // Create initial paragraph + const paragraph = schema.nodes.paragraph.create( + null, + schema.text('Test text'), + ); + const tr = state.tr.replaceWith( + 0, + state.doc.content.size, + paragraph, + ); + state = state.apply(tr); + + // Convert to bullet list + command(state, dispatch); + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.BulletList, + ); + + // Toggle it off + command(state, dispatch); + expect(state.doc.firstChild.type.name).toBe('paragraph'); + expect(state.doc.firstChild.textContent).toBe('Test text'); + }); + }); + + describe('multiple line selection', () => { + const TEST_LINES = ['First line', 'Second line', 'Third line']; + + beforeEach(() => { + // Setup multiple paragraphs + const tr = createParagraphs(TEST_LINES); + state = state.apply(tr); + }); + + it('converts multiple paragraphs to list items', () => { + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + + // Setup multiple paragraphs + state = state.apply(createParagraphs(TEST_LINES)); + + // Select all content + selectAll(); + + command(state, dispatch); + + // Update verification to match actual structure + const list = state.doc.firstChild; + expect(list.type.name).toBe(EditorMenuTypes.BulletList); + expect(list.childCount).toBe(TEST_LINES.length); + + list.forEach((item, _offset, i) => { + expect(item.type.name).toBe('list_item'); + expect(item.firstChild.type.name).toBe('paragraph'); + expect(item.firstChild.textContent).toBe(TEST_LINES[i]); + }); + }); + + it('preserves existing list items when converting mixed selection', () => { + const bulletCommand = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const orderedCommand = createListCommand( + schema, + EditorMenuTypes.OrderedList, + ); + + // Setup multiple paragraphs + state = state.apply(createParagraphs(TEST_LINES)); + + // Select all content + selectAll(); + + // Convert to bullet list first + bulletCommand(state, dispatch); + + // Verify bullet list structure + const bulletList = state.doc.firstChild; + expect(bulletList.type.name).toBe(EditorMenuTypes.BulletList); + expect(bulletList.childCount).toBe(TEST_LINES.length); + + // Convert to ordered list + orderedCommand(state, dispatch); + + // Verify ordered list structure + const orderedList = state.doc.firstChild; + expect(orderedList.type.name).toBe(EditorMenuTypes.OrderedList); + expect(orderedList.childCount).toBe(TEST_LINES.length); + + orderedList.forEach((item, _offset, i) => { + expect(item.type.name).toBe('list_item'); + expect(item.firstChild.type.name).toBe('paragraph'); + expect(item.firstChild.textContent).toBe(TEST_LINES[i]); + }); + }); + }); + + describe('nested lists', () => { + it('allows creating nested lists', () => { + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + + // Create initial content as separate paragraphs. + state = state.apply(createParagraphs(['Parent', 'Child'])); + + // Convert both paragraphs to a list + selectAll(); + command(state, dispatch); + + // Find the text node containing "Child" and capture its text content. + let childPos: number | null = null; + state.doc.descendants((node, pos) => { + if (node.isText && node.text.includes('Child')) { + // Compute absolute position of "Child" inside this text node. + const index = node.text.indexOf('Child'); + childPos = pos + index; + + return false; // Stop traversal. + } + + return true; + }); + if (childPos === null) { + throw new Error('Did not find text "Child"'); + } + + // For a text "Child" (length = 5), select from offset 1 up to offset 4. + const selFrom = childPos + 1; + const selTo = childPos + 4; + const tr = state.tr.setSelection( + TextSelection.create(state.doc, selFrom, selTo), + ); + state = state.apply(tr); + + // Create nested list + command(state, dispatch); + + // Verify structure + const outerList = state.doc.firstChild; + expect(outerList.type.name).toBe(EditorMenuTypes.BulletList); + expect(outerList.childCount).toBe(1); + + const firstItem = outerList.child(0); + expect(firstItem.type.name).toBe('list_item'); + expect(firstItem.childCount).toBe(2); + + // First child of the list item is the "Parent" paragraph. + expect(firstItem.child(0).type.name).toBe('paragraph'); + expect(firstItem.child(0).textContent).toBe('Parent'); + + // Second child is the nested bullet_list. + const nestedList = firstItem.child(1); + expect(nestedList.type.name).toBe(EditorMenuTypes.BulletList); + expect(nestedList.childCount).toBe(1); + + const nestedItem = nestedList.child(0); + expect(nestedItem.type.name).toBe('list_item'); + expect(nestedItem.firstChild.type.name).toBe('paragraph'); + expect(nestedItem.firstChild.textContent).toBe('Child'); + }); + }); + + describe('active state', () => { + it('reports active state for bullet list', () => { + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const tr = state.tr.insertText('Test text'); + state = state.apply(tr); + command(state, dispatch); + + expect(command.active(state)).toBe(true); + }); + + it('reports inactive state for different list type', () => { + const bulletCommand = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const orderedCommand = createListCommand( + schema, + EditorMenuTypes.OrderedList, + ); + + const tr = state.tr.insertText('Test text'); + state = state.apply(tr); + bulletCommand(state, dispatch); + + expect(orderedCommand.active(state)).toBe(false); + expect(bulletCommand.active(state)).toBe(true); + }); + + it('reports active state for partial selection in list', () => { + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + let tr = state.tr.insertText('First\nSecond\nThird'); + state = state.apply(tr); + command(state, dispatch); + + // Select middle line + tr = state.tr.setSelection( + TextSelection.create( + state.doc, + state.doc.content.firstChild.nodeSize / 2, + state.doc.content.firstChild.nodeSize / 2 + 6, + ), + ); + state = state.apply(tr); + + expect(command.active(state)).toBe(true); + }); + }); + + describe('edge cases', () => { + describe('empty selections', () => { + it('creates empty list item when no text is selected', () => { + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const tr = state.tr.setSelection( + TextSelection.create(state.doc, 1, 1), + ); + state = state.apply(tr); + + command(state, dispatch); + + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.BulletList, + ); + expect(state.doc.firstChild.textContent).toBe(''); + }); + }); + + describe('mixed content handling', () => { + it('handles selection with mixed content types', () => { + // Setup paragraph and header + let tr = state.tr + .insertText('Regular text\n') + .insert( + state.tr.mapping.map(state.doc.content.size), + schema.nodes.heading.create( + { level: 1 }, + schema.text('Heading'), + ), + ); + state = state.apply(tr); + + // Select all and convert to list + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + tr = state.tr.setSelection( + TextSelection.create( + state.doc, + state.doc.resolve(1).pos, + state.doc.resolve(state.doc.content.size - 1).pos, + ), + ); + state = state.apply(tr); + + command(state, dispatch); + + expect(state.doc.firstChild.type.name).toBe( + EditorMenuTypes.BulletList, + ); + expect(state.doc.firstChild.childCount).toBe(2); + }); + + it('handles list items containing multiple block types', () => { + const command = createListCommand( + schema, + EditorMenuTypes.BulletList, + ); + const tr = state.tr + .insertText('Paragraph text') + .insert( + state.tr.mapping.map(state.doc.content.size), + schema.nodes.blockquote.create( + {}, + schema.text('Quote'), + ), + ); + state = state.apply(tr); + + // Select all content so that both blocks are included in the conversion. + const selTr = state.tr.setSelection( + TextSelection.create( + state.doc, + state.doc.resolve(1).pos, + state.doc.resolve(state.doc.content.size - 1).pos, + ), + ); + state = state.apply(selTr); + command(state, dispatch); + + const listItem = state.doc.firstChild.firstChild; + expect(listItem.content.childCount).toBe(2); + }); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/link-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/link-plugin.ts index cee7953476..4c901d5eda 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/link-plugin.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/link-plugin.ts @@ -2,8 +2,9 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { schema } from 'prosemirror-schema-basic'; import { Mark } from 'prosemirror-model'; -import { isExternalLink, isValidUrl } from '../menu/menu-commands'; +import { isExternalLink } from '../menu/menu-commands'; import { EditorMenuTypes, MouseButtons } from '../menu/types'; +import { isValidUrl } from '../menu/menu-command-utils/link-utils'; export const linkPluginKey = new PluginKey('linkPlugin'); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/list-key-handler.spec.ts b/src/components/text-editor/prosemirror-adapter/plugins/list-key-handler.spec.ts new file mode 100644 index 0000000000..49575302eb --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/list-key-handler.spec.ts @@ -0,0 +1,537 @@ +import { Schema } from 'prosemirror-model'; +import { TextSelection } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { + isInListItem, + createListKeyHandlerPlugin, + isEmptyListItem, + isAtStartOfListItem, +} from './list-key-handler'; +import { createCustomTestSchema } from '../../test-setup/schema-builder'; +import { + createEditorView, + cleanupEditorView, + createDispatchSpy, + mockProseMirrorDOMEnvironment, +} from '../../test-setup/editor-view-builder'; +import { createEditorState } from '../../test-setup/editor-state-builder'; + +describe('List Key Handler Plugin', () => { + let schema: Schema; + let cleanupMock: () => void; + + beforeEach(() => { + cleanupMock = mockProseMirrorDOMEnvironment(); + schema = createCustomTestSchema({}); + }); + + afterEach(() => { + cleanupMock(); + }); + + describe('isInListItem', () => { + it('should return true when cursor is in a list item', () => { + // Create a state with a list item + const state = createEditorState( + '', + schema, + ); + + // Create a selection inside the list item + const pos = 5; // Position inside the list item text + const selection = TextSelection.create(state.doc, pos); + const newState = state.apply(state.tr.setSelection(selection)); + + // Verify that isInListItem returns true + expect(isInListItem(newState)).toBe(true); + }); + + it('should return false when cursor is not in a list item', () => { + // Create a state with just a paragraph + const state = createEditorState('

Not a list item

', schema); + + // Verify that isInListItem returns false + expect(isInListItem(state)).toBe(false); + }); + }); + + describe('isEmptyListItem', () => { + it('should return true for an empty list item', () => { + // Create a state with an empty list item + const state = createEditorState( + '', + schema, + ); + + // Create a selection inside the empty list item + const pos = 3; // Position inside the empty paragraph + const selection = TextSelection.create(state.doc, pos); + const newState = state.apply(state.tr.setSelection(selection)); + + // Verify that isEmptyListItem returns true + expect(isEmptyListItem(newState)).toBe(true); + }); + + it('should return false for a non-empty list item', () => { + // Create a state with a non-empty list item + const state = createEditorState( + '', + schema, + ); + + // Create a selection inside the non-empty list item + const pos = 5; // Position inside the text + const selection = TextSelection.create(state.doc, pos); + const newState = state.apply(state.tr.setSelection(selection)); + + // Verify that isEmptyListItem returns false + expect(isEmptyListItem(newState)).toBe(false); + }); + }); + + describe('isAtStartOfListItem', () => { + it('should return true when cursor is at the start of a list item', () => { + // Create a state with a list item + const state = createEditorState( + '', + schema, + ); + + // Get the position at the start of the paragraph inside the list item + const listItemStart = 3; // Position at the start of the paragraph inside list item + const selection = TextSelection.create(state.doc, listItemStart); + const newState = state.apply(state.tr.setSelection(selection)); + + // Verify that isAtStartOfListItem returns true + expect(isAtStartOfListItem(newState)).toBe(true); + }); + + it('should return false when cursor is not at the start of a list item', () => { + // Create a state with a list item + const state = createEditorState( + '', + schema, + ); + + // Create a selection inside the list item, but not at the start + const posInText = 5; // Position inside the text, not at start + const selection = TextSelection.create(state.doc, posInText); + const newState = state.apply(state.tr.setSelection(selection)); + + // Verify that isAtStartOfListItem returns false + expect(isAtStartOfListItem(newState)).toBe(false); + }); + + it('should return false for non-empty selections', () => { + // Create a state with a list item + const state = createEditorState( + '', + schema, + ); + + // Create a non-empty selection + const start = 3; // Start of text + const end = 7; // A few characters in + const selection = TextSelection.create(state.doc, start, end); + const newState = state.apply(state.tr.setSelection(selection)); + + // Verify that isAtStartOfListItem returns false for non-empty selections + expect(isAtStartOfListItem(newState)).toBe(false); + }); + }); + + describe('Tab behavior', () => { + let view: EditorView; + let container: HTMLElement; + let dispatchSpy: jest.Mock; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + } + }); + + it('should indent a list item when Tab is pressed', () => { + // Create a state with a list containing multiple items + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with the dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Find position within the second list item's text + let secondItemPos = 0; + state.doc.descendants((node, pos) => { + if (node.isText && node.text.includes('Second item')) { + secondItemPos = pos + 1; // Position inside the text + + return false; + } + + return true; + }); + + // Set selection to the second item + const selection = TextSelection.create(state.doc, secondItemPos); + view.dispatch(state.tr.setSelection(selection)); + + // Reset the spy to track only the Tab event + dispatchSpy.mockClear(); + + // Create and dispatch a Tab keydown event + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + }); + + // Dispatch the event to the view's DOM node + view.dom.dispatchEvent(tabEvent); + + // Verify that a transaction was dispatched + expect(dispatchSpy).toHaveBeenCalled(); + + // The createDispatchSpy with autoUpdate=true will automatically update the view state + // Verify the document structure: first item should contain a nested list with second item + const firstItem = view.state.doc.firstChild.child(0); + expect(firstItem.childCount).toBeGreaterThan(1); + + // Verify that there's a nested list in the first item + const nestedList = firstItem.child(1); + expect(nestedList.type.name).toBe('bullet_list'); + expect(nestedList.childCount).toBe(1); + + // Verify the nested item is the second item + const nestedItem = nestedList.child(0); + expect(nestedItem.type.name).toBe('list_item'); + expect(nestedItem.textContent.includes('Second item')).toBe(true); + }); + + it('should outdent a nested list item when Shift+Tab is pressed', () => { + // Create a state with a nested list structure + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with the dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Find position of the nested item content + let nestedItemPos = 0; + state.doc.descendants((node, pos) => { + if (node.isText && node.text.includes('Nested item')) { + nestedItemPos = pos + 1; // Position inside the text + + return false; + } + + return true; + }); + + // Set selection to the nested item + const selection = TextSelection.create(state.doc, nestedItemPos); + view.dispatch(state.tr.setSelection(selection)); + + // Reset the spy to track only the Shift+Tab event + dispatchSpy.mockClear(); + + // Create and dispatch a Shift+Tab keydown event + const shiftTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + }); + + // Dispatch the event + view.dom.dispatchEvent(shiftTabEvent); + + // Verify that a transaction was dispatched + expect(dispatchSpy).toHaveBeenCalled(); + + // Verify the document structure: should now have 3 top-level items + expect(view.state.doc.firstChild.childCount).toBe(3); + }); + }); + + describe('Enter behavior', () => { + let view: EditorView; + let container: HTMLElement; + let dispatchSpy: jest.Mock; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + } + }); + + it('should split a list item when Enter is pressed in a non-empty item', () => { + // Create a state with a list containing a non-empty item + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Set cursor position in the middle of the text + let listItemTextPos = 0; + state.doc.descendants((node, pos) => { + if (node.isText && node.text.includes('List item')) { + // Position cursor after "List " + listItemTextPos = pos + 5; + + return false; + } + + return true; + }); + + const selection = TextSelection.create(state.doc, listItemTextPos); + view.dispatch(state.tr.setSelection(selection)); + dispatchSpy.mockClear(); + + // Create and dispatch Enter key event + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }); + + view.dom.dispatchEvent(enterEvent); + + // Verify transaction was dispatched + expect(dispatchSpy).toHaveBeenCalled(); + + // Verify list now has two items + const list = view.state.doc.firstChild; + expect(list.childCount).toBe(2); + + // First item should contain "List" + expect(list.child(0).textContent).toBe('List '); + + // Second item should contain "item text" + expect(list.child(1).textContent).toBe('item text'); + }); + + it('should exit the list when Enter is pressed in an empty list item', () => { + // Create a state with a list containing an empty item + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Position cursor in the empty paragraph inside list item + const emptyParaPos = 3; // Approximate position inside empty paragraph + const selection = TextSelection.create(state.doc, emptyParaPos); + view.dispatch(state.tr.setSelection(selection)); + dispatchSpy.mockClear(); + + // Create and dispatch Enter key event + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }); + + view.dom.dispatchEvent(enterEvent); + + // Verify transaction was dispatched + expect(dispatchSpy).toHaveBeenCalled(); + + // Verify list has been replaced with a paragraph + expect(view.state.doc.firstChild.type.name).toBe('paragraph'); + }); + }); + + describe('Backspace behavior', () => { + let view: EditorView; + let container: HTMLElement; + let dispatchSpy: jest.Mock; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + } + }); + + it('should join with previous list item when Backspace is pressed at the start', () => { + // Create a state with a list containing multiple items + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Find position at the start of the second list item's paragraph + let secondItemStart = 0; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'Second item') { + secondItemStart = pos; // Position at the start of the paragraph + + return false; + } + + return true; + }); + + const selection = TextSelection.create(state.doc, secondItemStart); + view.dispatch(state.tr.setSelection(selection)); + dispatchSpy.mockClear(); + + // Create and dispatch Backspace key event + const backspaceEvent = new KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + }); + + view.dom.dispatchEvent(backspaceEvent); + + // Verify transaction was dispatched + expect(dispatchSpy).toHaveBeenCalled(); + + // Verify list now has only one item + const list = view.state.doc.firstChild; + expect(list.childCount).toBe(1); + + // The item should contain both texts joined + expect(list.child(0).textContent).toBe('First itemSecond item'); + }); + + it('should lift list item out when Backspace is pressed at the start of the first item', () => { + // Create a state with a list containing a single item + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Find position at the start of the list item's paragraph + const listItemStart = 3; // Approximate position at the start of the paragraph + const selection = TextSelection.create(state.doc, listItemStart); + view.dispatch(state.tr.setSelection(selection)); + dispatchSpy.mockClear(); + + // Create and dispatch Backspace key event + const backspaceEvent = new KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + }); + + view.dom.dispatchEvent(backspaceEvent); + + // Verify transaction was dispatched + expect(dispatchSpy).toHaveBeenCalled(); + + // Verify list has been replaced with a paragraph + expect(view.state.doc.firstChild.type.name).toBe('paragraph'); + expect(view.state.doc.firstChild.textContent).toBe('Single item'); + }); + + it('should not handle Backspace when not at the start of a list item', () => { + // Create a state with a list containing an item + const state = createEditorState( + ``, + schema, + [createListKeyHandlerPlugin(schema)], + ); + + // Create a view with dispatch spy + dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Position cursor inside the text, not at the start + let middlePos = 0; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'List item') { + middlePos = pos + 5; // Middle of the text + + return false; + } + + return true; + }); + + const selection = TextSelection.create(state.doc, middlePos); + view.dispatch(state.tr.setSelection(selection)); + dispatchSpy.mockClear(); + + // Create and dispatch Backspace key event + const backspaceEvent = new KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + }); + + // We'll need to manually track if preventDefault was called + let defaultPrevented = false; + const originalPreventDefault = backspaceEvent.preventDefault; + backspaceEvent.preventDefault = function () { + defaultPrevented = true; + if (originalPreventDefault) { + originalPreventDefault.call(this); + } + }; + + view.dom.dispatchEvent(backspaceEvent); + + // Verify plugin did not prevent default (let the regular backspace behavior happen) + expect(defaultPrevented).toBe(false); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/list-key-handler.ts b/src/components/text-editor/prosemirror-adapter/plugins/list-key-handler.ts new file mode 100644 index 0000000000..e90063eb9f --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/list-key-handler.ts @@ -0,0 +1,178 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { + sinkListItem, + liftListItem, + splitListItem, +} from 'prosemirror-schema-list'; +import { Schema } from 'prosemirror-model'; +import { chainCommands, joinBackward } from 'prosemirror-commands'; + +export const listKeyHandlerPluginKey = new PluginKey('listKeyHandlerPlugin'); + +/** + * Checks if the current cursor position is within a list item + */ +export function isInListItem(state) { + const { $from } = state.selection; + let depth = $from.depth; + + // Traverse up the node hierarchy + while (depth > 0) { + const node = $from.node(depth); + if (node.type.name === 'list_item') { + return true; + } + + depth--; + } + + return false; +} + +/** + * Checks if the current list item is empty (contains only an empty paragraph) + */ +export function isEmptyListItem(state) { + const { $from } = state.selection; + let depth = $from.depth; + + // Find the list item node + while (depth > 0) { + const node = $from.node(depth); + if (node.type.name === 'list_item') { + // Check if it contains only an empty paragraph + return ( + node.childCount === 1 && + node.firstChild.type.name === 'paragraph' && + node.firstChild.content.size === 0 + ); + } + + depth--; + } + + return false; +} + +/** + * Checks if the cursor is at the start of a list item + */ +export function isAtStartOfListItem(state) { + const { $from, empty } = state.selection; + + // Only relevant for collapsed selections (cursor) + if (!empty) { + return false; + } + + // Find the list item node + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === 'list_item') { + // Get the start position of the list item's content + const startPos = $from.start(depth); + + // Check if the cursor is at the start of the list item's content + return $from.pos === startPos; + } + } + + return false; +} + +/** + * Creates a plugin for handling keydown events specific to lists + * - Tab: indent list item (when in a list) + * - Shift+Tab: outdent list item (when in a list) + * - Enter: split list item or exit list if empty + * - Backspace: join with previous item or lift out of list + * + * @param schema - The document schema + * @returns A ProseMirror plugin for handling list-specific key events + */ +export function createListKeyHandlerPlugin(schema: Schema) { + return new Plugin({ + key: listKeyHandlerPluginKey, + props: { + handleKeyDown: (view, event) => { + const { state } = view; + + // Only act when in a list item + if (!isInListItem(state)) { + return false; + } + + // Handle Tab and Shift+Tab + if (event.key === 'Tab') { + // Handle indent (Tab) + if (!event.shiftKey) { + event.preventDefault(); + + return sinkListItem(schema.nodes.list_item)( + state, + view.dispatch, + ); + } + + // Handle outdent (Shift+Tab) + if (event.shiftKey) { + event.preventDefault(); + + return liftListItem(schema.nodes.list_item)( + state, + view.dispatch, + ); + } + } + + // Handle Enter key + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey + ) { + event.preventDefault(); + + // If list item is empty, exit the list + if (isEmptyListItem(state)) { + return liftListItem(schema.nodes.list_item)( + state, + view.dispatch, + ); + } + + // Otherwise split the list item + return splitListItem(schema.nodes.list_item)( + state, + view.dispatch, + ); + } + + // Handle Backspace key + if ( + event.key === 'Backspace' && + !event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey + ) { + // Only handle backspace at the start of a list item + if (isAtStartOfListItem(state)) { + event.preventDefault(); + + // Try joinBackward first (join with previous list item) + // If that fails, try to lift the list item out + return chainCommands( + joinBackward, + liftListItem(schema.nodes.list_item), + )(state, view.dispatch); + } + } + + return false; + }, + }, + }); +} diff --git a/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.spec.ts b/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.spec.ts new file mode 100644 index 0000000000..0e9ed0c70a --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.spec.ts @@ -0,0 +1,182 @@ +import { + createMenuStateTrackingPlugin, + getMenuItemStates, +} from './menu-state-tracking-plugin'; +import { EditorState, Plugin, Transaction } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { EditorMenuTypes } from '../menu/types'; +import { MenuCommandFactory } from '../menu/menu-commands'; + +// Mock EditorMenuTypes enum for testing +const mockMenuTypes: EditorMenuTypes[] = [ + 'bold' as EditorMenuTypes, + 'italic' as EditorMenuTypes, + 'link' as EditorMenuTypes, +]; + +describe('menu-state-tracking-plugin', () => { + let menuCommandFactory: MenuCommandFactory; + let updateCallback: jest.Mock; + let view: Partial; + let state: Partial; + let dispatch: jest.Mock; + + beforeEach(() => { + menuCommandFactory = { + getCommand: jest.fn(), + } as any; + + updateCallback = jest.fn(); + dispatch = jest.fn(); + + state = { + tr: { + setMeta: jest.fn().mockReturnThis(), + } as any, + }; + + view = { + state: state as EditorState, + dispatch: dispatch, + }; + }); + + describe('getMenuItemStates', () => { + it('should return active and allowed states for menu items', () => { + const mockCommands = { + bold: { + active: jest.fn().mockReturnValue(true), + allowed: jest.fn().mockReturnValue(true), + }, + italic: { + active: jest.fn().mockReturnValue(false), + allowed: jest.fn().mockReturnValue(true), + }, + link: { + active: jest.fn().mockReturnValue(false), + allowed: jest.fn().mockReturnValue(false), + }, + }; + + menuCommandFactory.getCommand = jest.fn( + (type) => mockCommands[type], + ); + + const result = getMenuItemStates( + mockMenuTypes, + menuCommandFactory, + view as EditorView, + ); + + expect(result).toEqual({ + active: { + bold: true, + italic: false, + link: false, + }, + allowed: { + bold: true, + italic: true, + link: false, + }, + }); + + expect(menuCommandFactory.getCommand).toHaveBeenCalledTimes(3); + expect(mockCommands.bold.active).toHaveBeenCalledWith(state); + expect(mockCommands.bold.allowed).toHaveBeenCalledWith(state); + }); + + it('should handle missing active or allowed methods', () => { + const commands = { + bold: { + active: jest.fn().mockReturnValue(true), + // No allowed method + }, + italic: { + // No active method + allowed: jest.fn().mockReturnValue(true), + }, + link: {}, + }; + + menuCommandFactory.getCommand = jest.fn((type) => commands[type]); + + const result = getMenuItemStates( + mockMenuTypes, + menuCommandFactory, + view as EditorView, + ); + + expect(result).toEqual({ + active: { + bold: true, + italic: false, + link: false, + }, + allowed: { + bold: true, // Default to true when missing + italic: true, + link: true, // Default to true when missing + }, + }); + }); + }); + + describe('createMenuStateTrackingPlugin', () => { + it('should create a plugin with the correct key', () => { + const plugin: Plugin = createMenuStateTrackingPlugin( + mockMenuTypes, + menuCommandFactory, + updateCallback, + ); + + expect(plugin).toBeInstanceOf(Plugin); + expect(plugin.key).toBe('actionBarPlugin$'); + }); + + it('should update plugin state when meta is set', () => { + const plugin = createMenuStateTrackingPlugin( + mockMenuTypes, + menuCommandFactory, + updateCallback, + ); + + const mockTransaction = { + getMeta: jest.fn().mockReturnValue({ + active: { bold: true }, + allowed: { bold: true }, + }), + } as unknown as Transaction; + + const newState = plugin.spec.state.apply(mockTransaction, { + active: {}, + allowed: {}, + }); + + expect(newState).toEqual({ + active: { bold: true }, + allowed: { bold: true }, + }); + }); + + it('should not update plugin state when no meta is set', () => { + const plugin = createMenuStateTrackingPlugin( + mockMenuTypes, + menuCommandFactory, + updateCallback, + ); + + const mockTransaction = { + getMeta: jest.fn().mockReturnValue(null), + } as unknown as Transaction; + + const oldState = { + active: { bold: true }, + allowed: { bold: true }, + }; + const newState = plugin.spec.state.apply(mockTransaction, oldState); + + expect(newState).toBe(oldState); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.ts index f72ae07109..14527b81f1 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/menu-state-tracking-plugin.ts @@ -7,24 +7,31 @@ import { EditorMenuTypes } from '../menu/types'; export const actionBarPluginKey = new PluginKey('actionBarPlugin'); -export type ActiveMenuItems = Record; +export interface ActiveMenuItems { + active: Record; + allowed: Record; +} -export type UpdateMenuItemsCallBack = (activeTypes: ActiveMenuItems) => void; +export type UpdateMenuItemsCallBack = ( + activeTypes: Record, + allowedTypes: Record, +) => void; -const getMenuItemStates = ( +export const getMenuItemStates = ( menuTypes: EditorMenuTypes[], menuCommandFactory: MenuCommandFactory, view: EditorView, ): ActiveMenuItems => { - const activeTypes: ActiveMenuItems = {}; + const activeTypes: Record = {}; + const allowedTypes: Record = {}; menuTypes.forEach((type) => { const command: CommandWithActive = menuCommandFactory.getCommand(type); - activeTypes[type] = - command && command.active && command.active(view.state); + activeTypes[type] = !!command?.active?.(view.state) || false; + allowedTypes[type] = !!(command?.allowed?.(view.state) ?? true); }); - return activeTypes; + return { active: activeTypes, allowed: allowedTypes }; }; export const createMenuStateTrackingPlugin = ( @@ -36,7 +43,7 @@ export const createMenuStateTrackingPlugin = ( key: actionBarPluginKey, state: { init: () => { - return {}; + return { active: {}, allowed: {} }; }, apply: (tr, menuStates) => { const newMenuStates = tr.getMeta(actionBarPluginKey); @@ -58,7 +65,10 @@ export const createMenuStateTrackingPlugin = ( menuItemStates, ); view.dispatch(tr); - updateCallback(menuItemStates); + updateCallback( + menuItemStates.active, + menuItemStates.allowed, + ); } }, }), diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.spec.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.spec.ts new file mode 100644 index 0000000000..d26370514d --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.spec.ts @@ -0,0 +1,170 @@ +import { Schema, NodeType } from 'prosemirror-model'; +import { Plugin } from 'prosemirror-state'; +import { createCustomTestSchema } from '../../test-setup/schema-builder'; +import { createEditorState } from '../../test-setup/editor-state-builder'; +import { + createEditorView, + createDispatchSpy, + cleanupEditorView, +} from '../../test-setup/editor-view-builder'; +import { + getTableEditingPlugins, + getTableNodes, + createStyleAttribute, +} from './table-plugin'; + +describe('Table Plugin', () => { + let view; + let container; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + }); + + describe('getTableEditingPlugins', () => { + it('should return an array with table editing plugin when tables are enabled', () => { + const plugins = getTableEditingPlugins(true); + + expect(plugins).toBeInstanceOf(Array); + expect(plugins.length).toBe(1); + expect(plugins[0]).toBeInstanceOf(Plugin); + }); + + it('should return an empty array when tables are disabled', () => { + const plugins = getTableEditingPlugins(false); + + expect(plugins).toBeInstanceOf(Array); + expect(plugins.length).toBe(0); + }); + + it('should create a working plugin that can be added to an editor state', () => { + const baseSchema = createCustomTestSchema({}); + const tableNodes = getTableNodes(); + let nodes = baseSchema.spec.nodes; + + Object.entries(tableNodes).forEach(([name, spec]) => { + nodes = nodes.addToEnd(name, spec); + }); + + const schema = new Schema({ + nodes: nodes, + marks: baseSchema.spec.marks, + }); + + const content = '

Text with table support

'; + const plugins = getTableEditingPlugins(true); + const state = createEditorState(content, schema, plugins); + + const dispatchSpy = createDispatchSpy(); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + expect(plugins.length).toBe(1); + expect(view.state.plugins).toContain(plugins[0]); + }); + }); + + describe('getTableNodes', () => { + it('should return an object with table node specs', () => { + const tableNodes = getTableNodes(); + + expect(tableNodes).toBeDefined(); + expect(typeof tableNodes).toBe('object'); + + expect(tableNodes.table).toBeDefined(); + expect(tableNodes.table_row).toBeDefined(); + expect(tableNodes.table_cell).toBeDefined(); + expect(tableNodes.table_header).toBeDefined(); + }); + + it('should create node specs that can be added to a schema', () => { + const baseSchema = createCustomTestSchema({}); + const tableNodes = getTableNodes(); + let nodes = baseSchema.spec.nodes; + + Object.entries(tableNodes).forEach(([name, spec]) => { + nodes = nodes.addToEnd(name, spec); + }); + + const schema = new Schema({ + nodes: nodes, + marks: baseSchema.spec.marks, + }); + + expect(schema.nodes.table instanceof NodeType).toBe(true); + expect(schema.nodes.table_row instanceof NodeType).toBe(true); + expect(schema.nodes.table_cell instanceof NodeType).toBe(true); + expect(schema.nodes.table_header instanceof NodeType).toBe(true); + }); + + it('should include custom cell attributes for styling', () => { + const tableNodes = getTableNodes(); + + expect(tableNodes.table_cell).toBeDefined(); + expect(tableNodes.table_cell.attrs).toBeDefined(); + expect(tableNodes.table_cell.attrs.background).toBeDefined(); + expect(tableNodes.table_cell.attrs.color).toBeDefined(); + + expect(tableNodes.table_cell.attrs.background.default).toBe(null); + expect(tableNodes.table_cell.attrs.color.default).toBe(null); + }); + }); + + describe('Style attribute helper', () => { + it('should get style values from DOM elements', () => { + const backgroundAttr = createStyleAttribute('backgroundColor'); + + const element = document.createElement('td'); + element.style.backgroundColor = 'red'; + + expect(backgroundAttr.getFromDOM(element)).toBe('red'); + }); + + it('should set style values on attributes object', () => { + const backgroundAttr = createStyleAttribute('backgroundColor'); + const attrs: Record = {}; + + backgroundAttr.setDOMAttr('blue', attrs); + + expect(attrs.style).toBe('backgroundColor: blue;'); + }); + + it('should append to existing style attribute when setting multiple styles', () => { + const backgroundAttr = createStyleAttribute('backgroundColor'); + const colorAttr = createStyleAttribute('color'); + const attrs: Record = {}; + + backgroundAttr.setDOMAttr('blue', attrs); + colorAttr.setDOMAttr('white', attrs); + + expect(attrs.style).toBe('backgroundColor: blue;color: white;'); + }); + + it('should not modify the style attribute when value is falsy', () => { + const backgroundAttr = createStyleAttribute('backgroundColor'); + const attrs: Record = {}; + + // Test with empty string + backgroundAttr.setDOMAttr('', attrs); + expect(attrs.style).toBeUndefined(); + + // Test with null + backgroundAttr.setDOMAttr(null, attrs); + expect(attrs.style).toBeUndefined(); + + // Test with undefined + backgroundAttr.setDOMAttr(undefined, attrs); + expect(attrs.style).toBeUndefined(); + + // Test that existing style is preserved when falsy value is passed + attrs.style = 'color: red;'; + backgroundAttr.setDOMAttr('', attrs); + expect(attrs.style).toBe('color: red;'); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts index b24408c537..f36451a05b 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts @@ -9,7 +9,7 @@ export const getTableEditingPlugins = (tablesEnabled: boolean): Plugin[] => { return []; }; -const createStyleAttribute = (cssProperty: string) => ({ +export const createStyleAttribute = (cssProperty: string) => ({ default: null, getFromDOM: (dom: HTMLElement) => dom.style[cssProperty] || null, setDOMAttr: (value: string, attrs: Record) => { diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx index fcebbbac8c..23da614240 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx @@ -52,6 +52,7 @@ import { } from '../text-editor.types'; import { getTableNodes, getTableEditingPlugins } from './plugins/table-plugin'; import { getImageNode, imageCache } from './plugins/image/node'; +import { createListKeyHandlerPlugin } from './plugins/list-key-handler'; const DEBOUNCE_TIMEOUT = 300; @@ -398,6 +399,7 @@ export class ProsemirrorAdapter { this.updateActiveActionBarItems, ), createActionBarInteractionPlugin(this.menuCommandFactory), + createListKeyHandlerPlugin(this.schema), ...getTableEditingPlugins(this.contentType === 'html'), ], }); @@ -405,19 +407,23 @@ export class ProsemirrorAdapter { private updateActiveActionBarItems = ( activeTypes: Record, + allowedTypes: Record, ) => { const newItems = getTextEditorMenuItems().map((item) => { if (isItem(item)) { return { ...item, selected: activeTypes[item.value], + allowed: allowedTypes[item.value], }; } return item; }); - this.actionBarItems = newItems; + this.actionBarItems = newItems.filter((item) => + isItem(item) ? item.allowed : true, + ); }; private async updateView(content: string) { diff --git a/src/components/text-editor/test-setup/command-tester.spec.ts b/src/components/text-editor/test-setup/command-tester.spec.ts new file mode 100644 index 0000000000..792229965a --- /dev/null +++ b/src/components/text-editor/test-setup/command-tester.spec.ts @@ -0,0 +1,209 @@ +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { cleanupEditorView } from './editor-view-builder'; +import { + getCommandResult, + testCommand, + testCommandWithView, + createCommandTester, +} from './command-tester'; + +describe('Command Testing Utilities', () => { + describe('getCommandResult', () => { + it('should return false result for commands that cannot be applied', () => { + // Create a state and command that can't be applied + const state = createEditorState('

Test

'); + const impossibleCommand = () => false; // Command that always fails + + const result = getCommandResult(impossibleCommand, state); + + expect(result.result).toBe(false); + expect(result.transaction).toBeUndefined(); + expect(result.newState).toBeUndefined(); + }); + + it('should return true result with transaction for applicable commands', () => { + // Create a state and command that will be applied + const state = createEditorState('

Test

'); + const insertTextCommand = (editorState, dispatch) => { + if (dispatch) { + // Insert the text at position 0 + const tr = editorState.tr.insertText(' additional text'); + dispatch(tr); + } + + return true; + }; + + const result = getCommandResult(insertTextCommand, state); + + expect(result.result).toBe(true); + expect(result.transaction).toBeDefined(); + expect(result.newState).toBeDefined(); + + // Check that some text was added (but don't rely on specific ordering) + expect(result.newState.doc.textContent).toContain('Test'); + expect(result.newState.doc.textContent).toContain( + 'additional text', + ); + }); + }); + + describe('testCommand', () => { + it('should test command applicability', () => { + const state = createEditorState('

Test

'); + + // Command that always succeeds + const alwaysApplicable = () => true; + + const result = testCommand(alwaysApplicable, state, { + shouldApply: true, + }); + + expect(result.result).toBe(true); + }); + + it('should test document content after command', () => { + const state = createEditorState('

Initial

'); + + // Command that changes content + const changeContent = (editorState, dispatch) => { + if (dispatch) { + // Insert the text at position 0 + const tr = editorState.tr.insertText(' Modified'); + dispatch(tr); + } + + return true; + }; + + // Don't test for specific content order + const commandResult = testCommand(changeContent, state, { + shouldApply: true, + }); + + // Instead, verify content contains what we expect + expect(commandResult.newState.doc.textContent).toContain('Initial'); + expect(commandResult.newState.doc.textContent).toContain( + 'Modified', + ); + }); + + it('should test document size after command', () => { + const state = createEditorState('

Size test

'); + const initialSize = state.doc.nodeSize; + + // Command that adds content, increasing size + const increaseSize = (editorState, dispatch) => { + if (dispatch) { + const tr = editorState.tr.insertText(' More text'); + dispatch(tr); + } + + return true; + }; + + const result = testCommand(increaseSize, state, { + shouldApply: true, + // We don't know exact size, but we know it should be larger + // so we can calculate it and assert it's bigger + }); + + expect(result.newState.doc.nodeSize).toBeGreaterThan(initialSize); + }); + }); + + describe('testCommandWithView', () => { + // Variable to store view and container from test for cleanup + let viewAndContainer: { + view: EditorView; + container: HTMLElement; + } | null = null; + + afterEach(() => { + // Clean up if we have a view + if (viewAndContainer) { + cleanupEditorView( + viewAndContainer.view, + viewAndContainer.container, + ); + viewAndContainer = null; + } + }); + + it('should test commands that require view context', () => { + const state = createEditorState('

View test

'); + + // Command that uses view + const viewCommand = (editorState, dispatch, viewArg) => { + // This command requires a view to work + if (!viewArg) { + return false; + } + + if (dispatch) { + // Insert the text at position 0 + const tr = editorState.tr.insertText(' with view'); + dispatch(tr); + } + + return true; + }; + + // Don't test for specific order + const { result, view, container } = testCommandWithView( + viewCommand, + state, + { + shouldApply: true, + }, + ); + + // Store for cleanup + viewAndContainer = { view: view, container: container }; + + expect(result.result).toBe(true); + expect(result.newState).toBeDefined(); + + // Check for content but not specific order + expect(view.state.doc.textContent).toContain('View test'); + expect(view.state.doc.textContent).toContain('with view'); + }); + }); + + describe('createCommandTester', () => { + it('should create a reusable tester for a command', () => { + // Command that adds a prefix + const addPrefix = (prefix) => (editorState, dispatch) => { + if (dispatch) { + const tr = editorState.tr.insertText(prefix, 1, 1); + dispatch(tr); + } + + return true; + }; + + // Create a tester for this command with "Hello " prefix + const testHelloCommand = createCommandTester(addPrefix('Hello ')); + + // Test it with various states + const state1 = createEditorState('

World

'); + const state2 = createEditorState('

Universe

'); + + // Test first state + const result1 = testHelloCommand(state1, { + shouldApply: true, + docContentAfter: 'Hello World', + }); + + // Test second state with a blank line before it + const result2 = testHelloCommand(state2, { + shouldApply: true, + docContentAfter: 'Hello Universe', + }); + + expect(result1.result).toBe(true); + expect(result2.result).toBe(true); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/command-tester.ts b/src/components/text-editor/test-setup/command-tester.ts new file mode 100644 index 0000000000..27b9fc86b6 --- /dev/null +++ b/src/components/text-editor/test-setup/command-tester.ts @@ -0,0 +1,202 @@ +import { EditorState, Transaction, Command } from 'prosemirror-state'; +import { createEditorView } from './editor-view-builder'; +import { EditorView } from 'prosemirror-view'; + +/** + * CommandResult represents the possible outcomes of a command execution + * - For successfully applied commands: + * - result is true + * - transaction contains the transaction that was created + * - newState contains the state after applying the transaction + * - For commands that couldn't be applied: + * - result is false + * - transaction is undefined + * - newState is undefined + */ +export interface CommandResult { + result: boolean; + transaction?: Transaction; + newState?: EditorState; +} + +/** + * Gets the result of applying a ProseMirror command to a state + * + * @param command - The ProseMirror command to test + * @param state - The editor state to apply the command to + * @returns An object containing the command result, transaction, and new state (if successful) + */ +export function getCommandResult( + command: Command, + state: EditorState, +): CommandResult { + let transaction: Transaction | undefined; + + // Command signature is (state, dispatch, view?) => boolean + const commandResult = command( + state, + (tr) => { + transaction = tr; + }, + null, + ); + + // If command returned false immediately, it couldn't be applied to this state + if (!commandResult) { + return { result: false }; + } + + // If we have a transaction, create the new state + let newState: EditorState | undefined; + if (transaction) { + newState = state.apply(transaction); + } + + return { + result: true, + transaction: transaction, + newState: newState, + }; +} + +/** + * Tests a ProseMirror command and verifies its result + * + * @param command - The ProseMirror command to test + * @param state - The editor state to apply the command to + * @param expected - An object containing expected values to verify + * @param shouldApply - Whether the command should be applicable to the state + * @param docContentAfter - Optional expected document content after applying command + * @param docSizeAfter - Optional expected document size after applying command + * @param includesContent - Optional content to check if it exists in the document content + * @returns The result of applying the command for further assertions if needed + */ +export function testCommand( + command: Command, + state: EditorState, + expected: { + shouldApply: boolean; + docContentAfter?: string; + docSizeAfter?: number; + includesContent?: string | string[]; + }, +): CommandResult { + const commandResult = getCommandResult(command, state); + + // Verify if command was applicable as expected + expect(commandResult.result).toBe(expected.shouldApply); + + // If command should apply, verify the new state if expectations are provided + if (expected.shouldApply && commandResult.newState) { + if (expected.docContentAfter !== undefined) { + expect(commandResult.newState.doc.textContent).toBe( + expected.docContentAfter, + ); + } + + if (expected.includesContent) { + if (Array.isArray(expected.includesContent)) { + // Check that all strings in the array are contained in the doc content + expected.includesContent.forEach((content) => { + expect(commandResult.newState.doc.textContent).toContain( + content, + ); + }); + } else { + expect(commandResult.newState.doc.textContent).toContain( + expected.includesContent, + ); + } + } + + if (expected.docSizeAfter !== undefined) { + expect(commandResult.newState.doc.nodeSize).toBe( + expected.docSizeAfter, + ); + } + } + + return commandResult; +} + +/** + * Tests a command with a view context, useful for commands that require access to DOM + * + * @param command - The command to test + * @param state - The editor state to apply the command to + * @param expected - Expected results after command execution + * @returns An extended result containing the command result and the created view + */ +export function testCommandWithView( + command: Command, + state: EditorState, + expected: { + shouldApply: boolean; + docContentAfter?: string; + docSizeAfter?: number; + }, +): { result: CommandResult; view: EditorView; container: HTMLElement } { + // Create a view for the command + const { view, container } = createEditorView(state); + + let result = false; + let transaction: Transaction | undefined; + + // Apply command with the view + const commandResult = command( + state, + (tr) => { + transaction = tr; + view.updateState(state.apply(tr)); + result = true; + }, + view, + ); + + // Build result object + const commandResultObj: CommandResult = { + result: commandResult && result, + }; + + if (transaction) { + commandResultObj.transaction = transaction; + commandResultObj.newState = state.apply(transaction); + } + + // Perform assertions + expect(commandResultObj.result).toBe(expected.shouldApply); + + if (expected.shouldApply && commandResultObj.newState) { + if (expected.docContentAfter !== undefined) { + expect(commandResultObj.newState.doc.textContent).toBe( + expected.docContentAfter, + ); + } + + if (expected.docSizeAfter !== undefined) { + expect(commandResultObj.newState.doc.nodeSize).toBe( + expected.docSizeAfter, + ); + } + } + + // Return both the result and the view so it can be cleaned up + return { result: commandResultObj, view: view, container: container }; +} + +/** + * Creates a reusable test function for testing commands under various conditions + * + * @param command - The command to test + * @returns A function that accepts a state and expected results + */ +export function createCommandTester(command: Command) { + return ( + state: EditorState, + expected: { + shouldApply: boolean; + docContentAfter?: string; + docSizeAfter?: number; + }, + ) => testCommand(command, state, expected); +} diff --git a/src/components/text-editor/test-setup/content-generator.spec.ts b/src/components/text-editor/test-setup/content-generator.spec.ts new file mode 100644 index 0000000000..aff6df180c --- /dev/null +++ b/src/components/text-editor/test-setup/content-generator.spec.ts @@ -0,0 +1,255 @@ +import { EditorState } from 'prosemirror-state'; +import { createCustomTestSchema } from './schema-builder'; +import { + createDocWithText, + createDocWithHTML, + createDocWithFormattedText, + createDocWithBulletList, + createDocWithHeading, + createDocWithBlockquote, + createDocWithCodeBlock, + MarkSpec, +} from './content-generator'; + +describe('Content Generation Utilities', () => { + describe('createDocWithText', () => { + it('should create a document with plain text', () => { + const text = 'Plain text content'; + const state = createDocWithText(text); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + // Verify structure: doc -> paragraph -> text + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + if (firstChild) { + expect(firstChild.type.name).toBe('paragraph'); + expect(firstChild.textContent).toBe(text); + } + }); + + it('should accept custom schema', () => { + const customSchema = createCustomTestSchema({ + addLists: false, + }); + + const state = createDocWithText('Test', customSchema); + + expect(state.schema.nodes.bullet_list).toBeUndefined(); + }); + }); + + describe('createDocWithHTML', () => { + it('should parse HTML content into a document', () => { + const html = + '

Heading

Paragraph with bold

'; + const state = createDocWithHTML(html); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe('HeadingParagraph with bold'); + + // Check the structure + const firstChild = state.doc.firstChild; + const secondChild = state.doc.child(1); + + expect(firstChild).toBeDefined(); + expect(secondChild).toBeDefined(); + + if (firstChild && secondChild) { + expect(firstChild.type.name).toBe('heading'); + expect(secondChild.type.name).toBe('paragraph'); + } + }); + + it('should handle empty or invalid HTML', () => { + const state = createDocWithHTML(''); + + expect(state).toBeInstanceOf(EditorState); + // Should at least have a valid document structure + expect(state.doc.childCount).toBeGreaterThan(0); + }); + }); + + describe('createDocWithFormattedText', () => { + it('should apply specified marks to text', () => { + const text = 'Formatted text'; + const marks: MarkSpec[] = [{ type: 'strong' }, { type: 'em' }]; + + const state = createDocWithFormattedText(text, marks); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + // Check that marks were applied + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + const textNode = firstChild.firstChild; + expect(textNode).toBeDefined(); + + if (textNode) { + const appliedMarks = textNode.marks; + expect(appliedMarks.length).toBe(2); + + const markNames = appliedMarks.map((m) => m.type.name); + expect(markNames).toContain('strong'); + expect(markNames).toContain('em'); + } + } + }); + + it('should apply marks with attributes', () => { + const text = 'Link text'; + const marks: MarkSpec[] = [ + { + type: 'link', + attrs: { + href: 'https://example.com', + title: 'Example', + }, + }, + ]; + + const state = createDocWithFormattedText(text, marks); + + // Check mark attributes + const firstChild = state.doc.firstChild; + if (firstChild && firstChild.firstChild) { + const linkMark = firstChild.firstChild.marks.find( + (m) => m.type.name === 'link', + ); + expect(linkMark).toBeDefined(); + + if (linkMark) { + expect(linkMark.attrs.href).toBe('https://example.com'); + expect(linkMark.attrs.title).toBe('Example'); + } + } + }); + + it('should throw an error for invalid mark types', () => { + const text = 'Test'; + const marks: MarkSpec[] = [{ type: 'nonexistent_mark' }]; + + expect(() => { + createDocWithFormattedText(text, marks); + }).toThrow(/not found in schema/); + }); + }); + + describe('createDocWithBulletList', () => { + it('should create a document with a bullet list', () => { + const items = ['Item 1', 'Item 2', 'Item 3']; + const state = createDocWithBulletList(items); + + expect(state).toBeInstanceOf(EditorState); + + // Check structure + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('bullet_list'); + expect(firstChild.childCount).toBe(3); + + // Check each list item + for (let i = 0; i < items.length; i++) { + const listItem = firstChild.child(i); + expect(listItem.type.name).toBe('list_item'); + expect(listItem.textContent).toBe(items[i]); + } + } + }); + + it('should handle empty list', () => { + const state = createDocWithBulletList([]); + + expect(state).toBeInstanceOf(EditorState); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('bullet_list'); + expect(firstChild.childCount).toBe(0); + } + }); + }); + + describe('createDocWithHeading', () => { + it('should create a document with a heading', () => { + const text = 'Heading Text'; + const level = 2; + const state = createDocWithHeading(text, level); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + // Check structure + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('heading'); + expect(firstChild.attrs.level).toBe(level); + } + }); + + it('should default to level 1 if not specified', () => { + const state = createDocWithHeading('Heading'); + + const firstChild = state.doc.firstChild; + if (firstChild) { + expect(firstChild.attrs.level).toBe(1); + } + }); + }); + + describe('createDocWithBlockquote', () => { + it('should create a document with a blockquote', () => { + const text = 'Quote text'; + const state = createDocWithBlockquote(text); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + // Check structure + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('blockquote'); + + // Blockquote should contain a paragraph + const paragraph = firstChild.firstChild; + expect(paragraph).toBeDefined(); + + if (paragraph) { + expect(paragraph.type.name).toBe('paragraph'); + expect(paragraph.textContent).toBe(text); + } + } + }); + }); + + describe('createDocWithCodeBlock', () => { + it('should create a document with a code block', () => { + const code = 'function test() { return true; }'; + const state = createDocWithCodeBlock(code); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(code); + + // Check structure + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('code_block'); + expect(firstChild.textContent).toBe(code); + } + }); + }); +}); diff --git a/src/components/text-editor/test-setup/content-generator.ts b/src/components/text-editor/test-setup/content-generator.ts new file mode 100644 index 0000000000..81721d6869 --- /dev/null +++ b/src/components/text-editor/test-setup/content-generator.ts @@ -0,0 +1,187 @@ +import { Schema, Mark, Fragment, Node } from 'prosemirror-model'; +import { createTestSchema } from './schema-builder'; +import { createEditorState } from './editor-state-builder'; +import { EditorState } from 'prosemirror-state'; + +/** + * Creates a document with plain text content. + * + * @param text - The text content to include + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with the specified text content + */ +export function createDocWithText(text: string, schema?: Schema): EditorState { + const editorSchema = schema || createTestSchema(); + const content = text ? `

${text}

` : '

'; + + return createEditorState(content, editorSchema); +} + +/** + * Creates a document from HTML content. + * + * @param html - The HTML content to parse + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with the parsed HTML content + */ +export function createDocWithHTML(html: string, schema?: Schema): EditorState { + const editorSchema = schema || createTestSchema(); + + return createEditorState(html, editorSchema); +} + +/** + * Mark specification for applying formatting to text + */ +export interface MarkSpec { + type: string; + attrs?: Record; +} + +/** + * Creates a document with formatted text. + * + * @param text - The text content to include + * @param marks - Array of mark specifications to apply to the text + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with the formatted text + */ +export function createDocWithFormattedText( + text: string, + marks: MarkSpec[], + schema?: Schema, +): EditorState { + const editorSchema = schema || createTestSchema(); + + const paragraph = createTextNodeWithMarks(text, marks, editorSchema); + + const doc = editorSchema.nodes.doc.createAndFill(null, paragraph); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates text nodes with specified marks. + * + * @param text - The text content + * @param marks - Array of mark specifications to apply + * @param schema - The schema to use + * @returns A paragraph node containing the formatted text + */ +function createTextNodeWithMarks( + text: string, + marks: MarkSpec[], + schema: Schema, +): Node { + const appliedMarks: Mark[] = marks.map((markSpec) => { + const markType = schema.marks[markSpec.type]; + if (!markType) { + throw new Error(`Mark type "${markSpec.type}" not found in schema`); + } + + return markType.create(markSpec.attrs || {}); + }); + + const textNode = schema.text(text, appliedMarks); + + return schema.nodes.paragraph.create(null, textNode); +} + +/** + * Creates a document with a bullet list. + * + * @param items - Array of text items for the list + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a bullet list + */ +export function createDocWithBulletList( + items: string[], + schema?: Schema, +): EditorState { + const editorSchema = schema || createTestSchema(); + + const listItems = items.map((text) => { + const textNode = editorSchema.text(text); + const paragraph = editorSchema.nodes.paragraph.create(null, textNode); + + return editorSchema.nodes.list_item.create(null, paragraph); + }); + + const bulletList = editorSchema.nodes.bullet_list.create( + null, + Fragment.from(listItems), + ); + + const doc = editorSchema.nodes.doc.createAndFill(null, bulletList); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates a document with a heading. + * + * @param text - The heading text + * @param level - The heading level (1-6) + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a heading + */ +export function createDocWithHeading( + text: string, + level: number = 1, + schema?: Schema, +): EditorState { + const editorSchema = schema || createTestSchema(); + + const textNode = editorSchema.text(text); + const heading = editorSchema.nodes.heading.create( + { level: level }, + textNode, + ); + + const doc = editorSchema.nodes.doc.createAndFill(null, heading); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates a document with a blockquote. + * + * @param text - The blockquote text + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a blockquote + */ +export function createDocWithBlockquote( + text: string, + schema?: Schema, +): EditorState { + const editorSchema = schema || createTestSchema(); + + const textNode = editorSchema.text(text); + const paragraph = editorSchema.nodes.paragraph.create(null, textNode); + const blockquote = editorSchema.nodes.blockquote.create(null, paragraph); + + const doc = editorSchema.nodes.doc.createAndFill(null, blockquote); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates a document with a code block. + * + * @param code - The code content + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a code block + */ +export function createDocWithCodeBlock( + code: string, + schema?: Schema, +): EditorState { + const editorSchema = schema || createTestSchema(); + + const textNode = editorSchema.text(code); + const codeBlock = editorSchema.nodes.code_block.create(null, textNode); + + const doc = editorSchema.nodes.doc.createAndFill(null, codeBlock); + + return EditorState.create({ doc: doc }); +} diff --git a/src/components/text-editor/test-setup/editor-state-builder.spec.ts b/src/components/text-editor/test-setup/editor-state-builder.spec.ts new file mode 100644 index 0000000000..0bc0f66b28 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-state-builder.spec.ts @@ -0,0 +1,114 @@ +import { EditorState, TextSelection } from 'prosemirror-state'; +import { createCustomTestSchema } from './schema-builder'; +import { + createEditorState, + createEditorStateWithSelection, + setTextSelection, + createDocumentWithText, +} from './editor-state-builder'; + +describe('Editor State Utilities', () => { + describe('createEditorState', () => { + it('should create an empty state with default schema', () => { + const state = createEditorState(); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.childCount).toBeGreaterThan(0); + + expect(state.schema.nodes.doc).toBeDefined(); + expect(state.schema.marks.strong).toBeDefined(); + }); + + it('should create a state with content', () => { + const content = '

Hello world!

'; + const state = createEditorState(content); + + expect(state).toBeInstanceOf(EditorState); + + const text = state.doc.textContent; + expect(text).toBe('Hello world!'); + + const wordPos = text.indexOf('world') + 1; + + const $pos = state.doc.resolve(wordPos); + const node = state.doc.nodeAt(wordPos); + expect($pos).toBeDefined(); + expect(node).toBeDefined(); + + if (node) { + const hasStrongMark = node.marks.some( + (m) => m.type.name === 'strong', + ); + expect(hasStrongMark).toBe(true); + } + }); + + it('should accept a custom schema', () => { + const customSchema = createCustomTestSchema({ + addStrikethrough: false, + }); + + const state = createEditorState('

Test

', customSchema); + + expect(state.schema.marks.strikethrough).toBeUndefined(); + }); + }); + + describe('createEditorStateWithSelection', () => { + it('should create a state with the specified selection', () => { + const content = '

Select this text

'; + const from = 1; // Start of "Select" + const to = 11; // End of "this" + + const state = createEditorStateWithSelection(content, from, to); + + expect(state).toBeInstanceOf(EditorState); + expect(state.selection).toBeInstanceOf(TextSelection); + expect(state.selection.from).toBe(from); + expect(state.selection.to).toBe(to); + }); + }); + + describe('setTextSelection', () => { + it('should create a new state with the specified selection', () => { + const originalState = createEditorState('

Test selection

'); + const from = 1; + const to = 5; + + const newState = setTextSelection(originalState, from, to); + + expect(newState).not.toBe(originalState); // Should be a new state object + expect(newState.selection.from).toBe(from); + expect(newState.selection.to).toBe(to); + }); + }); + + describe('createDocumentWithText', () => { + it('should create a simple document with the provided text', () => { + const text = 'Simple paragraph'; + const state = createDocumentWithText(text); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + if (firstChild) { + expect(firstChild.type.name).toBe('paragraph'); + } + }); + + it('should create an empty paragraph if no text is provided', () => { + const state = createDocumentWithText(); + + expect(state).toBeInstanceOf(EditorState); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + if (firstChild) { + expect(firstChild.type.name).toBe('paragraph'); + expect(firstChild.textContent).toBe(''); + } + }); + }); +}); diff --git a/src/components/text-editor/test-setup/editor-state-builder.ts b/src/components/text-editor/test-setup/editor-state-builder.ts new file mode 100644 index 0000000000..5aa9c6a2b3 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-state-builder.ts @@ -0,0 +1,107 @@ +/* eslint-disable multiline-ternary */ +import { EditorState, TextSelection, Plugin } from 'prosemirror-state'; +import { DOMParser, Schema } from 'prosemirror-model'; +import { createTestSchema } from './schema-builder'; + +/** + * Creates a ProseMirror editor state for testing. + * + * @param content - Optional content to initialize the editor with (HTML string) + * @param schema - Optional custom schema (uses createTestSchema by default) + * @param plugins - Optional array of plugins to include + * @returns A configured EditorState instance + */ +export function createEditorState( + content?: string, + schema?: Schema, + plugins: Plugin[] = [], +): EditorState { + const editorSchema = schema || createTestSchema(); + + // eslint-disable-next-line prettier/prettier + const doc = content? parseContentToDoc(content, editorSchema) + : editorSchema.topNodeType.createAndFill(); + + return EditorState.create({ + doc: doc, + plugins: plugins, + }); +} + +/** + * Creates a ProseMirror editor state with a specific text selection. + * + * @param content - Content to initialize the editor with (HTML string) + * @param from - Start position of the selection + * @param to - End position of the selection + * @param schema - Optional custom schema (uses createTestSchema by default) + * @param plugins - Optional array of plugins to include + * @returns A configured EditorState instance with selection + */ +export function createEditorStateWithSelection( + content: string, + from: number, + to: number, + schema?: Schema, + plugins: Plugin[] = [], +): EditorState { + const editorSchema = schema || createTestSchema(); + + const doc = parseContentToDoc(content, editorSchema); + + const selection = TextSelection.create(doc, from, to); + + return EditorState.create({ + doc: doc, + selection: selection, + plugins: plugins, + }); +} + +/** + * Sets a text selection on an existing editor state. + * + * @param state - The editor state to modify + * @param from - Start position of the selection + * @param to - End position of the selection (defaults to from) + * @returns A new editor state with the specified selection + */ +export function setTextSelection( + state: EditorState, + from: number, + to: number = from, +): EditorState { + const selection = TextSelection.create(state.doc, from, to); + + return state.apply(state.tr.setSelection(selection)); +} + +/** + * Parses content string into a ProseMirror document. + * + * @param content - Content string (HTML) + * @param schema - Schema to use for parsing + * @returns A ProseMirror document node + */ +function parseContentToDoc(content: string, schema: Schema) { + const domNode = document.createElement('div'); + domNode.innerHTML = content; + + return DOMParser.fromSchema(schema).parse(domNode); +} + +/** + * Creates a simple empty document with optional text content. + * + * @param text - Optional text to include in the document + * @param schema - Optional custom schema (uses createTestSchema by default) + * @returns An EditorState with a simple document + */ +export function createDocumentWithText( + text: string = '', + schema?: Schema, +): EditorState { + const content = text ? `

${text}

` : '

'; + + return createEditorState(content, schema); +} diff --git a/src/components/text-editor/test-setup/editor-view-builder.spec.ts b/src/components/text-editor/test-setup/editor-view-builder.spec.ts new file mode 100644 index 0000000000..70c08dcdba --- /dev/null +++ b/src/components/text-editor/test-setup/editor-view-builder.spec.ts @@ -0,0 +1,249 @@ +import { EditorState } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { + createEditorView, + createDispatchSpy, + cleanupEditorView, + mockProseMirrorDOMEnvironment, +} from './editor-view-builder'; + +describe('Editor View Utilities', () => { + // Hold references to elements that need cleanup + let view: EditorView; + let container: HTMLElement; + + // Clean up after each test to prevent memory leaks + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + }); + + describe('createEditorView', () => { + it('should create an editor view with default state', () => { + const result = createEditorView(); + view = result.view; + container = result.container; + + // Verify view was created + expect(view).toBeInstanceOf(EditorView); + // Instead of checking instance type, check that it has expected properties + expect(container).toBeDefined(); + expect(container.tagName || container.nodeName).toBeDefined(); + + // Verify default state was used + expect(view.state).toBeInstanceOf(EditorState); + }); + + it('should use provided editor state', () => { + const state = createEditorState('

Custom state

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + expect(view.state).toBe(state); + expect(view.state.doc.textContent).toBe('Custom state'); + }); + + it('should use provided dispatch spy', () => { + const dispatchSpy = jest.fn(); + const result = createEditorView(undefined, dispatchSpy); + view = result.view; + container = result.container; + + // Trigger a transaction + const tr = view.state.tr.insertText('Test'); + view.dispatch(tr); + + // Verify dispatch spy was called + expect(dispatchSpy).toHaveBeenCalledWith(tr); + }); + + it('should use provided parent element', () => { + const customContainer = document.createElement('div'); + const result = createEditorView( + undefined, + undefined, + customContainer, + ); + view = result.view; + container = result.container; + + expect(container).toBe(customContainer); + }); + }); + + describe('createDispatchSpy', () => { + it('should create a spy that tracks transactions', () => { + const dispatchSpy = createDispatchSpy(); + const state = createEditorState('

Test

'); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Create and dispatch a transaction + const tr = view.state.tr.insertText('Test'); + view.dispatch(tr); + + // Verify spy was called with transaction + expect(dispatchSpy).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith(tr); + }); + + it('should update view state when autoUpdate is true', () => { + // Create state and spy with autoUpdate enabled + const state = createEditorState('

'); + const dispatchSpy = createDispatchSpy(true); + + // Create the editor view (which will set the view on the spy) + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Initial state is empty + expect(view.state.doc.textContent).toBe(''); + + // Create a transaction and call the spy directly + const tr = view.state.tr.insertText('Updated text'); + dispatchSpy(tr); + + // View state should be updated automatically + expect(view.state.doc.textContent).toBe('Updated text'); + }); + + it('should not update view state when autoUpdate is false', () => { + // Create state and spy with autoUpdate disabled + const state = createEditorState('

'); + const dispatchSpy = createDispatchSpy(false); + + // Create the editor view (which will set the view on the spy) + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Initial state is empty + expect(view.state.doc.textContent).toBe(''); + + // Create a transaction and call the spy directly + const tr = view.state.tr.insertText('New text'); + dispatchSpy(tr); + + // View state should not be updated + expect(view.state.doc.textContent).toBe(''); + }); + }); + + describe('cleanupEditorView', () => { + it('should destroy the editor view', () => { + // Create a view with a spy on destroy + const result = createEditorView(); + view = result.view; + container = result.container; + + // Mock destroy method + const destroySpy = jest.spyOn(view, 'destroy'); + + // Clean up + cleanupEditorView(view, container); + + // Verify destroy was called + expect(destroySpy).toHaveBeenCalled(); + + // Reset view and container references since we manually cleaned up + view = null; + container = null; + }); + + it('should remove container from DOM if provided', () => { + // Create a container and add it to DOM + const customContainer = document.createElement('div'); + document.body.appendChild(customContainer); + + // Create a view with the container + const result = createEditorView( + undefined, + undefined, + customContainer, + ); + view = result.view; + container = result.container; + + // Spy on removeChild method + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + // Clean up + cleanupEditorView(view, customContainer); + + // Verify removeChild was called with our container + expect(removeChildSpy).toHaveBeenCalledWith(customContainer); + + // Reset view and container references since we manually cleaned up + view = null; + container = null; + + // Restore the spy + removeChildSpy.mockRestore(); + }); + }); + + describe('mockProseMirrorDOMEnvironment', () => { + let originalWindow; + let originalDocument; + + beforeEach(() => { + // Store original values + originalWindow = global.window; + originalDocument = global.document; + }); + + afterEach(() => { + // Restore original values + global.window = originalWindow; + global.document = originalDocument; + }); + + it('should create mock DOM if none exists', () => { + // Temporarily remove window and document + delete global.window; + delete global.document; + + // Call mock function + const cleanup = mockProseMirrorDOMEnvironment(); + + // Check that mocks were created + expect(global.window).toBeDefined(); + expect(global.document).toBeDefined(); + expect(global.document.createElement).toBeDefined(); + + // Clean up + cleanup(); + + // Verify cleanup worked + expect(global.window).toBeUndefined(); + expect(global.document).toBeUndefined(); + }); + + it('should not modify existing DOM environment', () => { + // Make sure window and document exist with proper typing for the test + global.window = { existingProp: true } as any; + global.document = { existingProp: true } as any; + + // Call mock function + const cleanup = mockProseMirrorDOMEnvironment(); + + // Verify original objects were not changed + expect((global.window as any).existingProp).toBe(true); + expect((global.document as any).existingProp).toBe(true); + + // Clean up + cleanup(); + + // Original objects should be restored + expect(global.window).toEqual({ existingProp: true }); + expect(global.document).toEqual({ existingProp: true }); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/editor-view-builder.ts b/src/components/text-editor/test-setup/editor-view-builder.ts new file mode 100644 index 0000000000..2fc7a37702 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-view-builder.ts @@ -0,0 +1,143 @@ +import { EditorState } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { createTestSchema } from './schema-builder'; + +/** + * Creates a ProseMirror editor view for testing purposes. + * + * @param state - The editor state to use (will create a default one if not provided) + * @param dispatchSpy - Optional spy function to track dispatch calls + * @param parentElement - Optional parent DOM element (will create one if not provided) + * @returns The created EditorView instance and its container element + */ +export function createEditorView( + state?: EditorState, + dispatchSpy?: jest.Mock, + parentElement?: HTMLElement, +): { view: EditorView; container: HTMLElement } { + const container = parentElement || document.createElement('div'); + if (!parentElement) { + document.body.appendChild(container); + } + + const editorState = + state || createEditorState('

', createTestSchema()); + + const viewProps: { + state: EditorState; + dispatchTransaction?: (tr: any) => void; + } = { + state: editorState, + }; + + if (dispatchSpy) { + viewProps.dispatchTransaction = dispatchSpy; + } + + const view = new EditorView(container, viewProps); + + // If dispatch spy has a setView method, call it with the created view + if (dispatchSpy && typeof (dispatchSpy as any).setView === 'function') { + (dispatchSpy as any).setView(view); + } + + return { view: view, container: container }; +} + +/** + * Creates a spy function to track dispatch calls for an editor view. + * + * @param autoUpdate - Whether to automatically update the view's state (default: true) + * @returns A Jest mock function that can be used as a dispatch spy + */ +export function createDispatchSpy(autoUpdate = true): jest.Mock { + // Store the view reference for auto-updating + let viewRef: EditorView; + + // Create the spy function + const spy = jest.fn((transaction) => { + // If autoUpdate is enabled and we have a view reference, update the state + if (autoUpdate && viewRef) { + viewRef.updateState(viewRef.state.apply(transaction)); + } + + // Return the transaction for easier testing + return transaction; + }); + + // Add method to set the view + (spy as any).setView = (view: EditorView) => { + viewRef = view; + }; + + return spy; +} + +/** + * Properly cleans up an editor view to prevent memory leaks. + * This should be called in test cleanup/afterEach. + * + * @param view - The editor view to destroy + * @param container - The container element to remove (if created by test) + */ +export function cleanupEditorView( + view: EditorView, + container?: HTMLElement, +): void { + view.destroy(); + + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } +} + +/** + * Sets up a minimal DOM environment for ProseMirror if one doesn't exist. + * This is useful for testing in Node environments without a full DOM. + * + * @returns A cleanup function to restore the original environment + */ +export function mockProseMirrorDOMEnvironment(): () => void { + const originalWindow = global.window; + const originalDocument = global.document; + + if (!global.window || !global.document) { + const mockDocument = { + createElement: () => ({ + appendChild: () => {}, + style: {}, + classList: { + add: () => {}, + remove: () => {}, + contains: () => false, + }, + }), + createTextNode: () => ({}), + body: { + appendChild: () => {}, + removeChild: () => {}, + }, + defaultView: {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + + const mockWindow = { + document: mockDocument, + getComputedStyle: () => ({ + getPropertyValue: () => '', + }), + addEventListener: () => {}, + removeEventListener: () => {}, + }; + + global.window = mockWindow as any; + global.document = mockDocument as any; + } + + return () => { + global.window = originalWindow; + global.document = originalDocument; + }; +} diff --git a/src/components/text-editor/test-setup/event-simulator.spec.ts b/src/components/text-editor/test-setup/event-simulator.spec.ts new file mode 100644 index 0000000000..5e470ac403 --- /dev/null +++ b/src/components/text-editor/test-setup/event-simulator.spec.ts @@ -0,0 +1,235 @@ +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { createEditorView, cleanupEditorView } from './editor-view-builder'; +import { + simulateKeyPress, + simulatePaste, + simulateClick, + simulateDragAndDrop, + KeyModifiers, + PasteData, +} from './event-simulator'; + +describe('Event Simulation Utilities', () => { + let view: EditorView; + let container: HTMLElement; + let dispatchSpy: jest.Mock; + + beforeEach(() => { + // Create a spy to track dispatched transactions + dispatchSpy = jest.fn(); + + // Create an editor view for testing events + const state = createEditorState('

Test content

'); + const result = createEditorView(state, dispatchSpy); + view = result.view; + container = result.container; + + // Add the container to the document for events + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + + dispatchSpy = null; + }); + + describe('simulateKeyPress', () => { + it('should simulate a key press on the editor', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Simulate pressing the 'a' key + simulateKeyPress(view, 'a'); + + // Verify that dispatchEvent was called with a keyboard event + expect(dispatchEventSpy).toHaveBeenCalled(); + const event = dispatchEventSpy.mock.calls[0][0] as KeyboardEvent; + expect(event instanceof KeyboardEvent).toBe(true); + expect(event.key).toBe('a'); + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + + it('should include modifier keys when specified', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Define modifiers + const modifiers: KeyModifiers = { + ctrl: true, + shift: true, + }; + + // Simulate pressing Ctrl+Shift+B + simulateKeyPress(view, 'b', modifiers); + + // Verify that modifiers were included + const event = dispatchEventSpy.mock.calls[0][0] as KeyboardEvent; + expect(event.ctrlKey).toBe(true); + expect(event.shiftKey).toBe(true); + expect(event.altKey).toBe(false); + expect(event.metaKey).toBe(false); + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + }); + + describe('simulatePaste', () => { + it('should simulate pasting text content', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Simulate pasting text + const pasteContent: PasteData = { + text: 'Pasted text', + }; + simulatePaste(view, pasteContent); + + // Verify that dispatchEvent was called with a clipboard event + expect(dispatchEventSpy).toHaveBeenCalled(); + const event = dispatchEventSpy.mock.calls[0][0] as any; + + // In our implementation we're using CustomEvent as a workaround + // since ClipboardEvent may not be available in all test environments + expect(event.type).toBe('paste'); + + // DataTransfer should contain the text + const clipboardData = event.clipboardData; + expect(clipboardData.getData('text/plain')).toBe('Pasted text'); + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + + it('should simulate pasting HTML content', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Simulate pasting HTML + const pasteContent: PasteData = { + html: '

Formatted content

', + }; + simulatePaste(view, pasteContent); + + // Verify clipboard data + const event = dispatchEventSpy.mock.calls[0][0] as any; + const clipboardData = event.clipboardData; + expect(clipboardData.getData('text/html')).toBe( + '

Formatted content

', + ); + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + }); + + describe('simulateClick', () => { + it('should simulate a mouse click at specified coordinates', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Simulate click + simulateClick(view, 100, 50); + + // Verify that dispatchEvent was called with a mouse event + expect(dispatchEventSpy).toHaveBeenCalled(); + const event = dispatchEventSpy.mock.calls[0][0] as MouseEvent; + expect(event instanceof MouseEvent).toBe(true); + expect(event.type).toBe('mousedown'); + expect(event.clientX).toBe(100); + expect(event.clientY).toBe(50); + expect(event.button).toBe(0); // Left button + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + + it('should support different mouse buttons and click types', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Simulate right-click (button 2) with double-click (detail 2) + simulateClick(view, 100, 50, { button: 2, detail: 2 }); + + // Verify event properties + const event = dispatchEventSpy.mock.calls[0][0] as MouseEvent; + expect(event.button).toBe(2); // Right button + expect(event.detail).toBe(2); // Double-click + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + }); + + describe('simulateDragAndDrop', () => { + it('should simulate a complete drag and drop operation', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Simulate drag from (10, 10) to (50, 50) + simulateDragAndDrop(view, 10, 10, 50, 50); + + // Should dispatch multiple events for the drag operation + expect(dispatchEventSpy).toHaveBeenCalledTimes(5); // mousedown, dragstart, dragover, drop, mouseup + + // Check that events were dispatched in correct sequence + const eventTypes = dispatchEventSpy.mock.calls.map( + (call) => call[0].type, + ); + expect(eventTypes).toEqual([ + 'mousedown', + 'dragstart', + 'dragover', + 'drop', + 'mouseup', + ]); + + // Verify coordinates + const mousedown = dispatchEventSpy.mock.calls[0][0] as MouseEvent; + expect(mousedown.clientX).toBe(10); + expect(mousedown.clientY).toBe(10); + + const drop = dispatchEventSpy.mock.calls[3][0] as DragEvent; + expect(drop.clientX).toBe(50); + expect(drop.clientY).toBe(50); + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + + it('should include drag data when provided', () => { + // Mock the editor's DOM events + const dispatchEventSpy = jest.spyOn(view.dom, 'dispatchEvent'); + + // Drag data + const dragData: PasteData = { + text: 'Dragged text', + html: '

Dragged HTML

', + }; + + // Simulate drag with data + simulateDragAndDrop(view, 10, 10, 50, 50, dragData); + + // Check the data in the dragstart event + const dragstart = dispatchEventSpy.mock.calls[1][0] as DragEvent; + expect(dragstart.dataTransfer.getData('text/plain')).toBe( + 'Dragged text', + ); + expect(dragstart.dataTransfer.getData('text/html')).toBe( + '

Dragged HTML

', + ); + + // Restore the spy + dispatchEventSpy.mockRestore(); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/event-simulator.ts b/src/components/text-editor/test-setup/event-simulator.ts new file mode 100644 index 0000000000..332dad1333 --- /dev/null +++ b/src/components/text-editor/test-setup/event-simulator.ts @@ -0,0 +1,288 @@ +/* eslint-disable multiline-ternary */ +import { EditorView } from 'prosemirror-view'; + +/** + * Mock implementation of DataTransfer for testing environments + * where the native DataTransfer isn't available + */ +export class MockDataTransfer { + private data = new Map(); + public files: File[] = []; + public items = { + add: (file: File) => { + this.files.push(file); + }, + clear: () => { + this.files = []; + }, + length: 0, + }; + + constructor() { + // Update items.length when files changes + Object.defineProperty(this.items, 'length', { + get: () => this.files.length, + }); + } + + setData(format: string, data: string): void { + this.data.set(format, data); + } + + getData(format: string): string { + return this.data.get(format) || ''; + } + + clearData(format?: string): void { + if (format) { + this.data.delete(format); + } else { + this.data.clear(); + } + } +} + +/** + * Key modifiers that can be used with keyboard events + */ +export interface KeyModifiers { + shift?: boolean; + alt?: boolean; + ctrl?: boolean; + meta?: boolean; +} + +/** + * Simulates a key press on the editor view + * + * @param view - The editor view to dispatch the key event on + * @param key - The key to simulate (e.g., 'a', 'Enter', 'ArrowUp') + * @param modifiers - Optional key modifiers (Shift, Alt, Ctrl, Meta) + * @returns Whether the key event was handled by the editor + */ +export function simulateKeyPress( + view: EditorView, + key: string, + modifiers: KeyModifiers = {}, +): boolean { + const options: KeyboardEventInit = { + key: key, + bubbles: true, + cancelable: true, + shiftKey: !!modifiers.shift, + altKey: !!modifiers.alt, + ctrlKey: !!modifiers.ctrl, + metaKey: !!modifiers.meta, + }; + + const event = new KeyboardEvent('keydown', options); + + const domNode = view.dom; + const eventHandled = domNode.dispatchEvent(event); + + return eventHandled; +} + +/** + * ProseMirror specific paste event data + */ +export interface PasteData { + text?: string; + html?: string; + files?: File[]; +} + +/** + * Simulates pasting content into the editor + * + * @param view - The editor view to dispatch the paste event on + * @param content - The content to paste (text, HTML, or both) + * @returns Whether the paste event was handled by the editor + */ +export function simulatePaste(view: EditorView, content: PasteData): boolean { + // Use our MockDataTransfer if native DataTransfer is not available + const dataTransfer = + typeof DataTransfer !== 'undefined' + ? new DataTransfer() + : new MockDataTransfer(); + + if (content.text) { + dataTransfer.setData('text/plain', content.text); + } + + if (content.html) { + dataTransfer.setData('text/html', content.html); + } + + if (content.files) { + for (const file of content.files) { + dataTransfer.items.add(file); + } + } + + // Create a mock event that we can dispatch + const pasteEvent = new CustomEvent('paste', { + bubbles: true, + cancelable: true, + }); + + // Add clipboardData property manually + Object.defineProperty(pasteEvent, 'clipboardData', { + value: dataTransfer, + writable: false, + }); + + const domNode = view.dom; + const eventHandled = domNode.dispatchEvent(pasteEvent); + + return eventHandled; +} + +/** + * Simulates a mouse click at specific coordinates within the editor + * Note: In test environments, coordinate-based operations may not work as expected + * since mock DOMs typically don't implement elementFromPoint. + * + * @param view - The editor view to dispatch the click event on + * @param clientX - The x coordinate of the click + * @param clientY - The y coordinate of the click + * @param options - Additional mouse event options + * @returns Whether the click event was handled by the editor + */ +export function simulateClick( + view: EditorView, + clientX: number, + clientY: number, + options: { button?: number; detail?: number } = {}, +): boolean { + // Create a basic event that doesn't rely on elementFromPoint + const mouseEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: clientX, + clientY: clientY, + button: options.button || 0, // 0 = left button + detail: options.detail || 1, // 1 = single click + }); + + // Add a noop implementation for coordinate methods that might be missing + if (typeof document.elementFromPoint === 'undefined') { + // For tests, just pretend the click always hits the editor + // This prevents errors when ProseMirror calls elementFromPoint + (view.dom as any).getBoundingClientRect = () => ({ + left: 0, + top: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + }); + + // Mock elementFromPoint to avoid errors + if (typeof document.elementFromPoint === 'undefined') { + (document as any).elementFromPoint = () => view.dom; + } + } + + const domNode = view.dom; + const eventHandled = domNode.dispatchEvent(mouseEvent); + + return eventHandled; +} + +/** + * Simulates a drag and drop operation in the editor + * + * @param view - The editor view to dispatch drag events on + * @param startX - Starting X coordinate + * @param startY - Starting Y coordinate + * @param endX - Ending X coordinate + * @param endY - Ending Y coordinate + * @param dragData - Optional data to include in the drag operation + * @returns Whether the drag operation was handled by the editor + */ +export function simulateDragAndDrop( + view: EditorView, + startX: number, + startY: number, + endX: number, + endY: number, + dragData?: PasteData, +): boolean { + const domNode = view.dom; + let eventHandled = true; + + // Use our MockDataTransfer if native DataTransfer is not available + const dataTransfer = + typeof DataTransfer !== 'undefined' + ? new DataTransfer() + : new MockDataTransfer(); + + if (dragData) { + if (dragData.text) { + dataTransfer.setData('text/plain', dragData.text); + } + + if (dragData.html) { + dataTransfer.setData('text/html', dragData.html); + } + + if (dragData.files) { + for (const file of dragData.files) { + dataTransfer.items.add(file); + } + } + } + + // Make sure elementFromPoint is available + if (typeof document.elementFromPoint === 'undefined') { + (document as any).elementFromPoint = () => view.dom; + } + + // Create events but don't use DragEvent constructor which may not be available + // Use a more generic type for the events array to accommodate both MouseEvent and custom events + const events: Event[] = [ + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: startX, + clientY: startY, + }), + ]; + + // Create custom drag events since DragEvent constructor may not be available + const createCustomDragEvent = (type: string, x: number, y: number) => { + const event = new CustomEvent(type, { + bubbles: true, + cancelable: true, + }); + + // Add required properties + Object.defineProperties(event, { + clientX: { value: x }, + clientY: { value: y }, + dataTransfer: { value: dataTransfer }, + }); + + return event; + }; + + // Add drag events using custom creation + events.push( + createCustomDragEvent('dragstart', startX, startY), + createCustomDragEvent('dragover', endX, endY), + createCustomDragEvent('drop', endX, endY), + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + clientX: endX, + clientY: endY, + }), + ); + + for (const event of events) { + eventHandled = domNode.dispatchEvent(event) && eventHandled; + } + + return eventHandled; +} diff --git a/src/components/text-editor/test-setup/schema-builder.spec.ts b/src/components/text-editor/test-setup/schema-builder.spec.ts new file mode 100644 index 0000000000..264afcce24 --- /dev/null +++ b/src/components/text-editor/test-setup/schema-builder.spec.ts @@ -0,0 +1,80 @@ +import { Schema, MarkType, NodeType } from 'prosemirror-model'; +import { createTestSchema, createCustomTestSchema } from './schema-builder'; + +describe('Schema Utilities', () => { + describe('createTestSchema', () => { + it('should create a schema with basic marks and nodes', () => { + const schema = createTestSchema(); + + expect(schema).toBeInstanceOf(Schema); + + expect(schema.nodes.doc).toBeDefined(); + expect(schema.nodes.paragraph).toBeDefined(); + expect(schema.nodes.text).toBeDefined(); + + expect(schema.nodes.bullet_list).toBeDefined(); + expect(schema.nodes.ordered_list).toBeDefined(); + expect(schema.nodes.list_item).toBeDefined(); + expect(schema.nodes.heading).toBeDefined(); + expect(schema.nodes.blockquote).toBeDefined(); + expect(schema.nodes.code_block).toBeDefined(); + + expect(schema.marks.strong).toBeDefined(); + expect(schema.marks.em).toBeDefined(); + expect(schema.marks.code).toBeDefined(); + expect(schema.marks.link).toBeDefined(); + + expect(schema.marks.strikethrough).toBeDefined(); + expect(schema.marks.underline).toBeDefined(); + }); + }); + + describe('createCustomTestSchema', () => { + it('should create a schema with specified options', () => { + const customSchema = createCustomTestSchema({ + addLists: false, + addStrikethrough: true, + addUnderline: false, + }); + + expect(customSchema).toBeInstanceOf(Schema); + + expect(customSchema.nodes.bullet_list).toBeUndefined(); + expect(customSchema.nodes.ordered_list).toBeUndefined(); + expect(customSchema.nodes.list_item).toBeUndefined(); + + expect(customSchema.marks.strikethrough).toBeDefined(); + expect(customSchema.marks.underline).toBeUndefined(); + }); + + it('should support custom marks', () => { + const highlightMark = { + parseDOM: [{ tag: 'mark' }], + toDOM: () => ['mark', 0], + }; + + const customSchema = createCustomTestSchema({ + customMarks: { highlight: highlightMark }, + }); + + expect(customSchema.marks.highlight).toBeDefined(); + expect(customSchema.marks.highlight instanceof MarkType).toBe(true); + }); + + it('should support custom nodes', () => { + const customNode = { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'div.custom' }], + toDOM: () => ['div', { class: 'custom' }, 0], + }; + + const customSchema = createCustomTestSchema({ + customNodes: { custom: customNode }, + }); + + expect(customSchema.nodes.custom).toBeDefined(); + expect(customSchema.nodes.custom instanceof NodeType).toBe(true); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/schema-builder.ts b/src/components/text-editor/test-setup/schema-builder.ts new file mode 100644 index 0000000000..78d185dfe6 --- /dev/null +++ b/src/components/text-editor/test-setup/schema-builder.ts @@ -0,0 +1,87 @@ +import { Schema } from 'prosemirror-model'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import { addListNodes } from 'prosemirror-schema-list'; +import { strikethrough } from '../prosemirror-adapter/menu/menu-schema-extender'; + +/** + * Creates a standardized ProseMirror schema for testing the text editor. + * This schema includes all the nodes and marks used in the actual text editor. + * + * @returns A ProseMirror Schema configured for testing + */ +export function createTestSchema(): Schema { + const schema = new Schema({ + nodes: addListNodes( + basicSchema.spec.nodes, + 'paragraph block*', + 'block', + ), + marks: basicSchema.spec.marks.append({ + strikethrough: strikethrough, + underline: { + parseDOM: [ + { tag: 'u' }, + { style: 'text-decoration=underline' }, + { style: 'text-decoration-line=underline' }, + ], + toDOM: () => ['u', 0], + }, + }), + }); + + return schema; +} + +/** + * Creates a custom ProseMirror schema with specified configurations. + * Allows for more flexibility in testing specific schema behaviors. + * + * @param options - Configuration options for the schema + * @returns A customized ProseMirror Schema + */ +export function createCustomTestSchema(options: { + addLists?: boolean; + addStrikethrough?: boolean; + addUnderline?: boolean; + customMarks?: Record; + customNodes?: Record; +}): Schema { + let nodes = basicSchema.spec.nodes; + + if (options.addLists !== false) { + nodes = addListNodes(nodes, 'paragraph block*', 'block'); + } + + let marks = basicSchema.spec.marks; + + if (options.addStrikethrough !== false) { + marks = marks.append({ + strikethrough: strikethrough, + }); + } + + if (options.addUnderline !== false) { + marks = marks.append({ + underline: { + parseDOM: [ + { tag: 'u' }, + { style: 'text-decoration=underline' }, + { style: 'text-decoration-line=underline' }, + ], + toDOM: () => ['u', 0], + }, + }); + } + + if (options.customMarks) { + marks = marks.append(options.customMarks); + } + + if (options.customNodes) { + Object.entries(options.customNodes).forEach(([name, spec]) => { + nodes = nodes.addToEnd(name, spec); + }); + } + + return new Schema({ nodes: nodes, marks: marks }); +} diff --git a/src/components/text-editor/test-setup/test-utils-development-log.md b/src/components/text-editor/test-setup/test-utils-development-log.md new file mode 100644 index 0000000000..974e6f9ee6 --- /dev/null +++ b/src/components/text-editor/test-setup/test-utils-development-log.md @@ -0,0 +1,219 @@ +# Text Editor Testing Suite Progress Log + +This document tracks the implementation progress of the text editor testing suite. + +## Planned Implementation + +### Core Testing Utilities + +- [x] Schema Setup + - [x] `createTestSchema()` - Creates a ProseMirror schema with all needed marks and nodes + - [x] `createCustomTestSchema(options)` - Creates a custom schema with specified extensions + +- [x] Editor State Utilities + - [x] `createEditorState(content?, schema?, plugins?)` - Creates an editor state with optional content + - [x] `createEditorStateWithSelection(content, from, to, schema?, plugins?)` - Creates an editor state with a specific selection + - [x] `setTextSelection(state, from, to?)` - Sets a text selection on an existing state + - [x] `createDocumentWithText(text?, schema?)` - Creates a simple document with text + +- [x] Editor View Utilities + - [x] `createEditorView(state?, dispatchSpy?, parentElement?)` - Creates a ProseMirror editor view with an optional dispatch spy + - [x] `createDispatchSpy(autoUpdate?)` - Creates a Jest spy function for tracking dispatch calls + - [x] `cleanupEditorView(view, container?)` - Properly destroys an editor view to prevent memory leaks + - [x] `mockProseMirrorDOMEnvironment()` - Sets up the DOM environment for ProseMirror in Node.js + +- [x] Content Generation + - [x] `createDocWithText(text, schema?)` - Creates a document with plain text + - [x] `createDocWithHTML(html, schema?)` - Creates a document from HTML string + - [x] `createDocWithFormattedText(text, marks, schema?)` - Creates a document with marked text + - [x] `createDocWithBulletList(items, schema?)` - Creates a document with a bullet list + - [x] `createDocWithHeading(text, level?, schema?)` - Creates a document with a heading + - [x] `createDocWithBlockquote(text, schema?)` - Creates a document with a blockquote + - [x] `createDocWithCodeBlock(code, schema?)` - Creates a document with a code block + +- [x] Command Testing + - [x] `testCommand(command, state, expected)` - Tests a command and verifies the result + - [x] `getCommandResult(command, state)` - Gets the result of applying a command + - [x] `testCommandWithView(command, state, expected)` - Tests a command that requires view context + - [x] `createCommandTester(command)` - Creates a reusable tester for a specific command + +- [x] Mocks + - [x] `createDispatchSpy()` - Creates a Jest spy for the dispatch function + - [ ] `createMockEditorView()` - Creates a mocked editor view + +- [x] Selection Helpers (Implemented in Editor State Utilities) + - [x] `setTextSelection(state, from, to)` - Creates a text selection + +- [x] Event Simulation + - [x] `simulateKeyPress(view, key, modifiers?)` - Simulates a key press on the editor + - [x] `simulatePaste(view, content)` - Simulates pasting content + - [x] `simulateClick(view, clientX, clientY, options?)` - Simulates a mouse click + - [x] `simulateDragAndDrop(view, startX, startY, endX, endY, dragData?)` - Simulates drag and drop + +## Completed Implementations + +### Schema Setup (2023-08-18) + +Implemented in `test-schema-setup.ts`: + +1. **createTestSchema()** + - Created a standardized ProseMirror schema for testing the text editor + - Includes all basic nodes and marks from prosemirror-schema-basic + - Added list nodes using prosemirror-schema-list + - Added custom marks like strikethrough and underline + +2. **createCustomTestSchema(options)** + - Implemented a more flexible schema creation function + - Allows tests to specify which schema features they need + - Supports custom marks and nodes + - Options include: addLists, addStrikethrough, addUnderline, customMarks, customNodes + +### Editor State Utilities (2023-08-18) + +Implemented in `test-editor-state.ts`: + +1. **createEditorState(content?, schema?, plugins?)** + - Creates a basic editor state for testing + - Accepts optional HTML content string + - Uses the test schema by default, or accepts a custom schema + - Supports adding plugins for more complex testing scenarios + +2. **createEditorStateWithSelection(content, from, to, schema?, plugins?)** + - Creates an editor state with a specific text selection + - Requires content and selection positions + - Allows for testing selection-based commands and functionality + +3. **setTextSelection(state, from, to?)** + - Utility to create a new state with modified text selection + - Works with existing editor states + - Simplifies testing of selection changes + +4. **createDocumentWithText(text?, schema?)** + - Simplified utility for creating a basic document with text + - Creates a single paragraph with the provided text + - Useful for simple test cases + +### Editor View Utilities (2023-08-19) + +Implemented in `test-editor-view.ts`: + +1. **createEditorView(state?, dispatchSpy?, parentElement?)** + - Creates a ProseMirror editor view for testing + - Accepts an optional state, dispatch spy, and parent element + - Creates and configures all required DOM elements + - Returns both the view and its container for easy cleanup + +2. **createDispatchSpy(autoUpdate?)** + - Creates a Jest mock function for tracking dispatch calls + - Optionally updates the view's state automatically + - Allows tests to verify that commands are dispatching the right transactions + +3. **cleanupEditorView(view, container?)** + - Properly cleans up the editor view to prevent memory leaks + - Destroys the view and removes DOM elements + - Should be called in test afterEach/cleanup + +4. **mockProseMirrorDOMEnvironment()** + - Sets up a minimal DOM environment for ProseMirror if testing in Node.js + - Creates mock window and document objects with required methods + - Returns a cleanup function to restore the original environment + +### Content Generation Utilities (2023-08-19) + +Implemented in `test-content-generation.ts`: + +1. **createDocWithText(text, schema?)** + - Creates a document with plain text in a paragraph + - Accepts optional schema + - Provides basic content for simple tests + +2. **createDocWithHTML(html, schema?)** + - Creates a document by parsing HTML content + - Supports complex HTML structures + - Useful for testing HTML conversion + +3. **createDocWithFormattedText(text, marks, schema?)** + - Creates a document with text that has specific marks (formatting) + - Accepts an array of mark specifications with types and attributes + - Allows testing of complex formatting scenarios + +4. **createDocWithBulletList(items, schema?)** + - Creates a document containing a bullet list + - Takes an array of strings as list items + - Useful for testing list-related functionality + +5. **createDocWithHeading(text, level?, schema?)** + - Creates a document with a heading of specified level + - Default level is 1 (H1) + - Useful for testing heading-related commands + +6. **createDocWithBlockquote(text, schema?)** + - Creates a document with text in a blockquote + - Useful for testing blockquote commands and rendering + +7. **createDocWithCodeBlock(code, schema?)** + - Creates a document with a code block + - Useful for testing code block formatting and commands + +### Command Testing Utilities (2023-08-20) + +Implemented in `test-command-testing.ts`: + +1. **getCommandResult(command, state)** + - Gets the result of applying a ProseMirror command to a state + - Returns a CommandResult with success status, transaction, and new state + - Allows testing command applicability without side effects + +2. **testCommand(command, state, expected)** + - Tests a ProseMirror command and verifies its results + - Checks whether the command is applicable as expected + - Can verify document content and size after command application + - Returns the CommandResult for further assertions + +3. **testCommandWithView(command, state, expected)** + - Tests commands that require an EditorView to function + - Creates a temporary view for the command execution + - Allows testing DOM-dependent commands + - Returns both the result and the view for cleanup + +4. **createCommandTester(command)** + - Creates a reusable test function for a specific command + - Allows testing the same command with different states + - Simplifies test setup for command-focused tests + +### Event Simulation Utilities (2023-08-20) + +Implemented in `test-event-simulation.ts`: + +1. **simulateKeyPress(view, key, modifiers?)** + - Simulates key press events on the editor + - Supports modifier keys (Shift, Ctrl, Alt, Meta) + - Returns whether the event was handled by the editor + - Useful for testing keyboard shortcuts and key bindings + +2. **simulatePaste(view, content)** + - Simulates clipboard paste events + - Supports text, HTML, and file content types + - Creates a proper ClipboardEvent with DataTransfer data + - Allows testing of complex paste handling + +3. **simulateClick(view, clientX, clientY, options?)** + - Simulates mouse click events at specified coordinates + - Supports different mouse buttons and click types (single, double) + - Useful for testing cursor positioning and node selection + +4. **simulateDragAndDrop(view, startX, startY, endX, endY, dragData?)** + - Simulates full drag and drop operations + - Dispatches the complete sequence of drag events (mousedown, dragstart, dragover, drop, mouseup) + - Supports text and HTML drag data + - Useful for testing drag-based interactions like node moving + +## Implementation Notes + +- Content generation utilities provide specialized methods for different content types +- All utilities integrate with the schema and state utilities +- The MarkSpec interface makes it easy to apply multiple marks to text content +- Added support for common block elements used in the text editor +- Each function follows the same pattern for consistency and ease of use +- Command testing utilities can test both standard commands and those requiring view context +- Event simulation utilities faithfully recreate browser events for accurate testing diff --git a/src/components/text-editor/test-setup/test-utils.md b/src/components/text-editor/test-setup/test-utils.md new file mode 100644 index 0000000000..b34fba1057 --- /dev/null +++ b/src/components/text-editor/test-setup/test-utils.md @@ -0,0 +1,191 @@ +# Text Editor Testing Utilities + +This directory contains utility functions and setup code to help test the text editor component and its related components. + +## Core Testing Utilities + +1. **Schema Setup** + - `createTestSchema()` - Creates a ProseMirror schema with all needed marks and nodes + - `createCustomTestSchema(options)` - Creates a custom schema with specified extensions + +A. **Analysis** + - Examine text editor for mark/node usage + - Review existing schema configuration + +B. **Dependencies** + - Identify required ProseMirror packages + - Determine schema construction method + +C. **Design** + - Define function signature + - Plan schema structure with nodes/marks + +D. **Implementation** + - Create base schema + - Add list nodes (ordered, bullet) + - Add custom marks (strikethrough, etc.) + - Add any custom nodes + +E. **Validation** + - Ensure schema includes all testing elements + - Add schema configuration validation + +F. **Documentation** + - Document function usage and returned schema + - Add examples + +G. **Integration** + - Ensure compatibility with other test utilities + - Verify consistency with production schema + +2. **Editor State Utilities** + - `createEditorState(content?, schema?)` - Creates an editor state with optional content + - `createEditorStateWithSelection(content, from, to, schema?)` - Creates an editor state with a specific selection + +3. **Editor View Utilities** + - `createEditorView(state, dispatchSpy?)` - Creates a ProseMirror editor view with an optional dispatch spy + - `cleanupEditorView(view)` - Properly destroys an editor view to prevent memory leaks + +4. **Content Generation** + - `createDocWithText(text, schema?)` - Creates a document with plain text + - `createDocWithHTML(html, schema?)` - Creates a document from HTML string + - `createDocWithFormattedText(text, marks, schema?)` - Creates a document with marked text + +5. **Command Testing** + - `testCommand(command, state, expected)` - Tests a command and verifies the result + - `getCommandResult(command, state)` - Gets the result of applying a command + - `testCommandWithView(command, state, expected)` - Tests a command that requires view context + - `createCommandTester(command)` - Creates a reusable tester for a specific command + +6. **Mocks** + - `createDispatchSpy()` - Creates a Jest spy for the dispatch function + - `createMockEditorView()` - Creates a mocked editor view + - `mockProseMirrorDOMEnvironment()` - Sets up the DOM environment for ProseMirror + +7. **Selection Helpers** + - `setTextSelection(state, from, to)` - Creates a text selection + - `setNodeSelection(state, pos)` - Creates a node selection + +8. **Event Simulation** + - `simulateKeyPress(view, key, modifiers?)` - Simulates a key press on the editor + - `simulatePaste(view, content)` - Simulates pasting content into the editor + - `simulateClick(view, clientX, clientY, options?)` - Simulates a mouse click + - `simulateDragAndDrop(view, startX, startY, endX, endY, dragData?)` - Simulates drag and drop + +## Usage Example + +```typescript +import { + createTestSchema, + createEditorState, + createEditorView, + simulateKeyPress, + cleanupEditorView +} from '../test-setup/test-utils'; + +describe('Text Editor', () => { + let schema, state, view; + + beforeEach(() => { + schema = createTestSchema(); + state = createEditorState('

Test content

', schema); + view = createEditorView(state); + }); + + afterEach(() => { + cleanupEditorView(view); + }); + + it('should apply bold formatting with keyboard shortcut', () => { + // Setup test case + // Simulate keypress + // Assert results + }); +}); +``` + +## Command Testing Example + +```typescript +import { + createEditorState, + testCommand, + createCommandTester +} from '../test-setup/test-utils'; +import { toggleMark } from 'prosemirror-commands'; + +describe('Text Editor Commands', () => { + it('should toggle bold mark when applicable', () => { + // Create state with selected text + const state = createEditorStateWithSelection('

Test content

', 1, 5); + + // Get the bold mark from schema + const boldMark = state.schema.marks.strong; + const toggleBold = toggleMark(boldMark); + + // Test the command + testCommand(toggleBold, state, { + shouldApply: true, + // Additional expectations if needed + }); + }); + + it('should test multiple states with the same command', () => { + // Create a reusable tester for a command + const testToggleBold = createCommandTester(toggleMark(schema.marks.strong)); + + // Test with different states + testToggleBold(state1, { shouldApply: true }); + testToggleBold(state2, { shouldApply: false }); + }); +}); +``` + +## Event Simulation Example + +```typescript +import { + createEditorState, + createEditorView, + simulateKeyPress, + simulatePaste, + cleanupEditorView +} from '../test-setup/test-utils'; + +describe('Text Editor Event Handling', () => { + let view, container; + + beforeEach(() => { + const state = createEditorState('

Test content

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + }); + + afterEach(() => { + cleanupEditorView(view, container); + }); + + it('should handle Ctrl+B keyboard shortcut', () => { + // Select some text first + // ... + + // Simulate pressing Ctrl+B for bold + simulateKeyPress(view, 'b', { ctrl: true }); + + // Verify text is now bold + // ... + }); + + it('should handle paste events correctly', () => { + // Simulate pasting HTML content + simulatePaste(view, { + text: 'Plain text version', + html: '

Formatted HTML content

' + }); + + // Verify pasted content was properly processed + // ... + }); +}); +``` diff --git a/src/components/text-editor/test-setup/testing-utils-development-log.md b/src/components/text-editor/test-setup/testing-utils-development-log.md new file mode 100644 index 0000000000..974e6f9ee6 --- /dev/null +++ b/src/components/text-editor/test-setup/testing-utils-development-log.md @@ -0,0 +1,219 @@ +# Text Editor Testing Suite Progress Log + +This document tracks the implementation progress of the text editor testing suite. + +## Planned Implementation + +### Core Testing Utilities + +- [x] Schema Setup + - [x] `createTestSchema()` - Creates a ProseMirror schema with all needed marks and nodes + - [x] `createCustomTestSchema(options)` - Creates a custom schema with specified extensions + +- [x] Editor State Utilities + - [x] `createEditorState(content?, schema?, plugins?)` - Creates an editor state with optional content + - [x] `createEditorStateWithSelection(content, from, to, schema?, plugins?)` - Creates an editor state with a specific selection + - [x] `setTextSelection(state, from, to?)` - Sets a text selection on an existing state + - [x] `createDocumentWithText(text?, schema?)` - Creates a simple document with text + +- [x] Editor View Utilities + - [x] `createEditorView(state?, dispatchSpy?, parentElement?)` - Creates a ProseMirror editor view with an optional dispatch spy + - [x] `createDispatchSpy(autoUpdate?)` - Creates a Jest spy function for tracking dispatch calls + - [x] `cleanupEditorView(view, container?)` - Properly destroys an editor view to prevent memory leaks + - [x] `mockProseMirrorDOMEnvironment()` - Sets up the DOM environment for ProseMirror in Node.js + +- [x] Content Generation + - [x] `createDocWithText(text, schema?)` - Creates a document with plain text + - [x] `createDocWithHTML(html, schema?)` - Creates a document from HTML string + - [x] `createDocWithFormattedText(text, marks, schema?)` - Creates a document with marked text + - [x] `createDocWithBulletList(items, schema?)` - Creates a document with a bullet list + - [x] `createDocWithHeading(text, level?, schema?)` - Creates a document with a heading + - [x] `createDocWithBlockquote(text, schema?)` - Creates a document with a blockquote + - [x] `createDocWithCodeBlock(code, schema?)` - Creates a document with a code block + +- [x] Command Testing + - [x] `testCommand(command, state, expected)` - Tests a command and verifies the result + - [x] `getCommandResult(command, state)` - Gets the result of applying a command + - [x] `testCommandWithView(command, state, expected)` - Tests a command that requires view context + - [x] `createCommandTester(command)` - Creates a reusable tester for a specific command + +- [x] Mocks + - [x] `createDispatchSpy()` - Creates a Jest spy for the dispatch function + - [ ] `createMockEditorView()` - Creates a mocked editor view + +- [x] Selection Helpers (Implemented in Editor State Utilities) + - [x] `setTextSelection(state, from, to)` - Creates a text selection + +- [x] Event Simulation + - [x] `simulateKeyPress(view, key, modifiers?)` - Simulates a key press on the editor + - [x] `simulatePaste(view, content)` - Simulates pasting content + - [x] `simulateClick(view, clientX, clientY, options?)` - Simulates a mouse click + - [x] `simulateDragAndDrop(view, startX, startY, endX, endY, dragData?)` - Simulates drag and drop + +## Completed Implementations + +### Schema Setup (2023-08-18) + +Implemented in `test-schema-setup.ts`: + +1. **createTestSchema()** + - Created a standardized ProseMirror schema for testing the text editor + - Includes all basic nodes and marks from prosemirror-schema-basic + - Added list nodes using prosemirror-schema-list + - Added custom marks like strikethrough and underline + +2. **createCustomTestSchema(options)** + - Implemented a more flexible schema creation function + - Allows tests to specify which schema features they need + - Supports custom marks and nodes + - Options include: addLists, addStrikethrough, addUnderline, customMarks, customNodes + +### Editor State Utilities (2023-08-18) + +Implemented in `test-editor-state.ts`: + +1. **createEditorState(content?, schema?, plugins?)** + - Creates a basic editor state for testing + - Accepts optional HTML content string + - Uses the test schema by default, or accepts a custom schema + - Supports adding plugins for more complex testing scenarios + +2. **createEditorStateWithSelection(content, from, to, schema?, plugins?)** + - Creates an editor state with a specific text selection + - Requires content and selection positions + - Allows for testing selection-based commands and functionality + +3. **setTextSelection(state, from, to?)** + - Utility to create a new state with modified text selection + - Works with existing editor states + - Simplifies testing of selection changes + +4. **createDocumentWithText(text?, schema?)** + - Simplified utility for creating a basic document with text + - Creates a single paragraph with the provided text + - Useful for simple test cases + +### Editor View Utilities (2023-08-19) + +Implemented in `test-editor-view.ts`: + +1. **createEditorView(state?, dispatchSpy?, parentElement?)** + - Creates a ProseMirror editor view for testing + - Accepts an optional state, dispatch spy, and parent element + - Creates and configures all required DOM elements + - Returns both the view and its container for easy cleanup + +2. **createDispatchSpy(autoUpdate?)** + - Creates a Jest mock function for tracking dispatch calls + - Optionally updates the view's state automatically + - Allows tests to verify that commands are dispatching the right transactions + +3. **cleanupEditorView(view, container?)** + - Properly cleans up the editor view to prevent memory leaks + - Destroys the view and removes DOM elements + - Should be called in test afterEach/cleanup + +4. **mockProseMirrorDOMEnvironment()** + - Sets up a minimal DOM environment for ProseMirror if testing in Node.js + - Creates mock window and document objects with required methods + - Returns a cleanup function to restore the original environment + +### Content Generation Utilities (2023-08-19) + +Implemented in `test-content-generation.ts`: + +1. **createDocWithText(text, schema?)** + - Creates a document with plain text in a paragraph + - Accepts optional schema + - Provides basic content for simple tests + +2. **createDocWithHTML(html, schema?)** + - Creates a document by parsing HTML content + - Supports complex HTML structures + - Useful for testing HTML conversion + +3. **createDocWithFormattedText(text, marks, schema?)** + - Creates a document with text that has specific marks (formatting) + - Accepts an array of mark specifications with types and attributes + - Allows testing of complex formatting scenarios + +4. **createDocWithBulletList(items, schema?)** + - Creates a document containing a bullet list + - Takes an array of strings as list items + - Useful for testing list-related functionality + +5. **createDocWithHeading(text, level?, schema?)** + - Creates a document with a heading of specified level + - Default level is 1 (H1) + - Useful for testing heading-related commands + +6. **createDocWithBlockquote(text, schema?)** + - Creates a document with text in a blockquote + - Useful for testing blockquote commands and rendering + +7. **createDocWithCodeBlock(code, schema?)** + - Creates a document with a code block + - Useful for testing code block formatting and commands + +### Command Testing Utilities (2023-08-20) + +Implemented in `test-command-testing.ts`: + +1. **getCommandResult(command, state)** + - Gets the result of applying a ProseMirror command to a state + - Returns a CommandResult with success status, transaction, and new state + - Allows testing command applicability without side effects + +2. **testCommand(command, state, expected)** + - Tests a ProseMirror command and verifies its results + - Checks whether the command is applicable as expected + - Can verify document content and size after command application + - Returns the CommandResult for further assertions + +3. **testCommandWithView(command, state, expected)** + - Tests commands that require an EditorView to function + - Creates a temporary view for the command execution + - Allows testing DOM-dependent commands + - Returns both the result and the view for cleanup + +4. **createCommandTester(command)** + - Creates a reusable test function for a specific command + - Allows testing the same command with different states + - Simplifies test setup for command-focused tests + +### Event Simulation Utilities (2023-08-20) + +Implemented in `test-event-simulation.ts`: + +1. **simulateKeyPress(view, key, modifiers?)** + - Simulates key press events on the editor + - Supports modifier keys (Shift, Ctrl, Alt, Meta) + - Returns whether the event was handled by the editor + - Useful for testing keyboard shortcuts and key bindings + +2. **simulatePaste(view, content)** + - Simulates clipboard paste events + - Supports text, HTML, and file content types + - Creates a proper ClipboardEvent with DataTransfer data + - Allows testing of complex paste handling + +3. **simulateClick(view, clientX, clientY, options?)** + - Simulates mouse click events at specified coordinates + - Supports different mouse buttons and click types (single, double) + - Useful for testing cursor positioning and node selection + +4. **simulateDragAndDrop(view, startX, startY, endX, endY, dragData?)** + - Simulates full drag and drop operations + - Dispatches the complete sequence of drag events (mousedown, dragstart, dragover, drop, mouseup) + - Supports text and HTML drag data + - Useful for testing drag-based interactions like node moving + +## Implementation Notes + +- Content generation utilities provide specialized methods for different content types +- All utilities integrate with the schema and state utilities +- The MarkSpec interface makes it easy to apply multiple marks to text content +- Added support for common block elements used in the text editor +- Each function follows the same pattern for consistency and ease of use +- Command testing utilities can test both standard commands and those requiring view context +- Event simulation utilities faithfully recreate browser events for accurate testing diff --git a/src/components/text-editor/test-setup/testing-utils-guide.md b/src/components/text-editor/test-setup/testing-utils-guide.md new file mode 100644 index 0000000000..b34fba1057 --- /dev/null +++ b/src/components/text-editor/test-setup/testing-utils-guide.md @@ -0,0 +1,191 @@ +# Text Editor Testing Utilities + +This directory contains utility functions and setup code to help test the text editor component and its related components. + +## Core Testing Utilities + +1. **Schema Setup** + - `createTestSchema()` - Creates a ProseMirror schema with all needed marks and nodes + - `createCustomTestSchema(options)` - Creates a custom schema with specified extensions + +A. **Analysis** + - Examine text editor for mark/node usage + - Review existing schema configuration + +B. **Dependencies** + - Identify required ProseMirror packages + - Determine schema construction method + +C. **Design** + - Define function signature + - Plan schema structure with nodes/marks + +D. **Implementation** + - Create base schema + - Add list nodes (ordered, bullet) + - Add custom marks (strikethrough, etc.) + - Add any custom nodes + +E. **Validation** + - Ensure schema includes all testing elements + - Add schema configuration validation + +F. **Documentation** + - Document function usage and returned schema + - Add examples + +G. **Integration** + - Ensure compatibility with other test utilities + - Verify consistency with production schema + +2. **Editor State Utilities** + - `createEditorState(content?, schema?)` - Creates an editor state with optional content + - `createEditorStateWithSelection(content, from, to, schema?)` - Creates an editor state with a specific selection + +3. **Editor View Utilities** + - `createEditorView(state, dispatchSpy?)` - Creates a ProseMirror editor view with an optional dispatch spy + - `cleanupEditorView(view)` - Properly destroys an editor view to prevent memory leaks + +4. **Content Generation** + - `createDocWithText(text, schema?)` - Creates a document with plain text + - `createDocWithHTML(html, schema?)` - Creates a document from HTML string + - `createDocWithFormattedText(text, marks, schema?)` - Creates a document with marked text + +5. **Command Testing** + - `testCommand(command, state, expected)` - Tests a command and verifies the result + - `getCommandResult(command, state)` - Gets the result of applying a command + - `testCommandWithView(command, state, expected)` - Tests a command that requires view context + - `createCommandTester(command)` - Creates a reusable tester for a specific command + +6. **Mocks** + - `createDispatchSpy()` - Creates a Jest spy for the dispatch function + - `createMockEditorView()` - Creates a mocked editor view + - `mockProseMirrorDOMEnvironment()` - Sets up the DOM environment for ProseMirror + +7. **Selection Helpers** + - `setTextSelection(state, from, to)` - Creates a text selection + - `setNodeSelection(state, pos)` - Creates a node selection + +8. **Event Simulation** + - `simulateKeyPress(view, key, modifiers?)` - Simulates a key press on the editor + - `simulatePaste(view, content)` - Simulates pasting content into the editor + - `simulateClick(view, clientX, clientY, options?)` - Simulates a mouse click + - `simulateDragAndDrop(view, startX, startY, endX, endY, dragData?)` - Simulates drag and drop + +## Usage Example + +```typescript +import { + createTestSchema, + createEditorState, + createEditorView, + simulateKeyPress, + cleanupEditorView +} from '../test-setup/test-utils'; + +describe('Text Editor', () => { + let schema, state, view; + + beforeEach(() => { + schema = createTestSchema(); + state = createEditorState('

Test content

', schema); + view = createEditorView(state); + }); + + afterEach(() => { + cleanupEditorView(view); + }); + + it('should apply bold formatting with keyboard shortcut', () => { + // Setup test case + // Simulate keypress + // Assert results + }); +}); +``` + +## Command Testing Example + +```typescript +import { + createEditorState, + testCommand, + createCommandTester +} from '../test-setup/test-utils'; +import { toggleMark } from 'prosemirror-commands'; + +describe('Text Editor Commands', () => { + it('should toggle bold mark when applicable', () => { + // Create state with selected text + const state = createEditorStateWithSelection('

Test content

', 1, 5); + + // Get the bold mark from schema + const boldMark = state.schema.marks.strong; + const toggleBold = toggleMark(boldMark); + + // Test the command + testCommand(toggleBold, state, { + shouldApply: true, + // Additional expectations if needed + }); + }); + + it('should test multiple states with the same command', () => { + // Create a reusable tester for a command + const testToggleBold = createCommandTester(toggleMark(schema.marks.strong)); + + // Test with different states + testToggleBold(state1, { shouldApply: true }); + testToggleBold(state2, { shouldApply: false }); + }); +}); +``` + +## Event Simulation Example + +```typescript +import { + createEditorState, + createEditorView, + simulateKeyPress, + simulatePaste, + cleanupEditorView +} from '../test-setup/test-utils'; + +describe('Text Editor Event Handling', () => { + let view, container; + + beforeEach(() => { + const state = createEditorState('

Test content

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + }); + + afterEach(() => { + cleanupEditorView(view, container); + }); + + it('should handle Ctrl+B keyboard shortcut', () => { + // Select some text first + // ... + + // Simulate pressing Ctrl+B for bold + simulateKeyPress(view, 'b', { ctrl: true }); + + // Verify text is now bold + // ... + }); + + it('should handle paste events correctly', () => { + // Simulate pasting HTML content + simulatePaste(view, { + text: 'Plain text version', + html: '

Formatted HTML content

' + }); + + // Verify pasted content was properly processed + // ... + }); +}); +```