├── .github └── workflows │ └── dispatch.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── README.md ├── commands.ts ├── comment.ts └── history.ts └── test ├── state.ts ├── test-commands.ts ├── test-comment.ts ├── test-history.ts └── webtest-commands.ts /.github/workflows/dispatch.yml: -------------------------------------------------------------------------------- 1 | name: Trigger CI 2 | on: push 3 | 4 | jobs: 5 | build: 6 | name: Dispatch to main repo 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Emit repository_dispatch 10 | uses: mvasigh/dispatch-action@main 11 | with: 12 | # You should create a personal access token and store it in your repository 13 | token: ${{ secrets.DISPATCH_AUTH }} 14 | repo: dev 15 | owner: codemirror 16 | event_type: push 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | /dist 4 | /test/*.js 5 | /test/*.d.ts 6 | /test/*.d.ts.map 7 | .tern-* 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /test 3 | /node_modules 4 | .tern-* 5 | rollup.config.js 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.8.1 (2025-03-31) 2 | 3 | ### Bug fixes 4 | 5 | Fix an issue where creating a comment for a line that starts an inner language would use the comment style from the outer language. 6 | 7 | ## 6.8.0 (2025-01-08) 8 | 9 | ### New features 10 | 11 | The new `cursorGroupForwardWin` and `selectGroupForwardWin` commands implement Windows-style forward motion by group. 12 | 13 | ## 6.7.1 (2024-10-21) 14 | 15 | ### Bug fixes 16 | 17 | Change `toggleBlockCommentByLine` to not affect lines with the selection end right at their start. 18 | 19 | ## 6.7.0 (2024-10-07) 20 | 21 | ### Bug fixes 22 | 23 | Bind Shift-Enter to the same command as Enter in the default keymap, so that it doesn't do nothing when on an EditContext-supporting browser. 24 | 25 | ### New features 26 | 27 | Add commands for by-string-index cursor motion that ignores text direction. 28 | 29 | ## 6.6.2 (2024-09-17) 30 | 31 | ### Bug fixes 32 | 33 | Fix an issue causing `selectParentSyntax` to not select syntax that is a direct child of the top node. 34 | 35 | Make `selectParentSyntax` return false when it doesn't change the selection. 36 | 37 | ## 6.6.1 (2024-08-31) 38 | 39 | ### Bug fixes 40 | 41 | Fix a bug in the undo history that would cause it to incorrectly track inverted effects when adding multiple edits to a single history event. 42 | 43 | ## 6.6.0 (2024-06-04) 44 | 45 | ### New features 46 | 47 | The new `toggleTabFocusMode` and `temporarilySetTabFocusMode` commands provide control over the view's tab-focus mode. 48 | 49 | The default keymap now binds Ctrl-m (Shift-Alt-m on macOS) to `toggleTabFocusMode`. 50 | 51 | ## 6.5.0 (2024-04-19) 52 | 53 | ### New features 54 | 55 | The `insertNewlineKeepIndent` command inserts a newline along with the same indentation as the line before. 56 | 57 | ## 6.4.0 (2024-04-17) 58 | 59 | ### Bug fixes 60 | 61 | Fix an issue where `deleteLine` sometimes leaves the cursor on the wrong line. 62 | 63 | ### New features 64 | 65 | The new `deleteCharBackwardStrict` command just deletes a character, without further smart behavior around indentation. 66 | 67 | ## 6.3.3 (2023-12-28) 68 | 69 | ### Bug fixes 70 | 71 | Fix an issue causing cursor motion commands to not dispatch a transaction when the change only affects cursor associativity. 72 | 73 | ## 6.3.2 (2023-11-28) 74 | 75 | ### Bug fixes 76 | 77 | Fix a regression that caused `deleteCharBackward` to sometimes delete a large chunk of text. 78 | 79 | ## 6.3.1 (2023-11-27) 80 | 81 | ### Bug fixes 82 | 83 | When undoing, store the selection after the undone change with the redo event, so that redoing restores it. 84 | 85 | `deleteCharBackward` will no longer delete variant selector characters as separate characters. 86 | 87 | ## 6.3.0 (2023-09-29) 88 | 89 | ### Bug fixes 90 | 91 | Make it possible for `selectParentSyntax` to jump out of or into a syntax tree overlay. 92 | 93 | Make Cmd-Backspace and Cmd-Delete on macOS delete to the next line wrap point, not the start/end of the line. 94 | 95 | ### New features 96 | 97 | The new `deleteLineBoundaryForward` and `deleteLineBoundaryBackward` commands delete to the start/end of the line or the next line wrapping point. 98 | 99 | ## 6.2.5 (2023-08-26) 100 | 101 | ### Bug fixes 102 | 103 | Make `insertNewlineAndIndent` properly count indentation for tabs when copying over the previous line's indentation. 104 | 105 | The various sub-word motion commands will now use `Intl.Segmenter`, when available, to stop at CJK language word boundaries. 106 | 107 | Fix a bug in `insertNewlineAndIndent` that would delete text between brackets if it had no corresponding AST node. 108 | 109 | ## 6.2.4 (2023-05-03) 110 | 111 | ### Bug fixes 112 | 113 | The by-subword motion commands now properly treat dashes, underscores, and similar as subword separators. 114 | 115 | ## 6.2.3 (2023-04-19) 116 | 117 | ### Bug fixes 118 | 119 | Block commenting the selection no longer includes indentation on the first line. 120 | 121 | ## 6.2.2 (2023-03-10) 122 | 123 | ### Bug fixes 124 | 125 | Fix a bug where line commenting got confused when commenting a range that crossed language boundaries. 126 | 127 | ## 6.2.1 (2023-02-15) 128 | 129 | ### Bug fixes 130 | 131 | Keep cursor position stable in `cursorPageUp`/`cursorPageDown` when there are panels or other scroll margins active. 132 | 133 | Make sure `toggleComment` doesn't get thrown off by local language nesting, by fetching the language data for the start of the selection line. 134 | 135 | ## 6.2.0 (2023-01-18) 136 | 137 | ### New features 138 | 139 | The new `joinToEvent` history configuration option allows you to provide custom logic that determines whether a new transaction is added to an existing history event. 140 | 141 | ## 6.1.3 (2022-12-26) 142 | 143 | ### Bug fixes 144 | 145 | Preserve selection bidi level when extending the selection, to prevent shift-selection from getting stuck in some kinds of bidirectional text. 146 | 147 | ## 6.1.2 (2022-10-13) 148 | 149 | ### Bug fixes 150 | 151 | Fix a bug that caused deletion commands on non-empty ranges to incorrectly return false and do nothing, causing the editor to fall back to native behavior. 152 | 153 | ## 6.1.1 (2022-09-28) 154 | 155 | ### Bug fixes 156 | 157 | Make sure the selection endpoints are moved out of atomic ranges when applying a deletion command to a non-empty selection. 158 | 159 | ## 6.1.0 (2022-08-18) 160 | 161 | ### Bug fixes 162 | 163 | Prevent native behavior on Ctrl/Cmd-ArrowLeft/ArrowRight bindings, so that browsers with odd bidi behavior won't do the wrong thing at start/end of line. 164 | 165 | Cmd-ArrowLeft/Right on macOS now moves the cursor in the direction of the arrow even in right-to-left content. 166 | 167 | ### New features 168 | 169 | The new `cursorLineBoundaryLeft`/`Right` and `selectLineBoundaryLeft`/`Right` commands allow directional motion to line boundaries. 170 | 171 | ## 6.0.1 (2022-06-30) 172 | 173 | ### Bug fixes 174 | 175 | Announce to the screen reader when the selection is deleted. 176 | 177 | Also bind Ctrl-Shift-z to redo on Linux. 178 | 179 | ## 6.0.0 (2022-06-08) 180 | 181 | ### Bug fixes 182 | 183 | Fix a bug where by-page selection commands sometimes moved one line too far. 184 | 185 | ## 0.20.0 (2022-04-20) 186 | 187 | ### Breaking changes 188 | 189 | There is no longer a separate `commentKeymap`. Those bindings are now part of `defaultKeymap`. 190 | 191 | ### Bug fixes 192 | 193 | Make `cursorPageUp` and `cursorPageDown` move by window height when the editor is higher than the window. 194 | 195 | Make sure the default behavior of Home/End is prevented, since it could produce unexpected results on macOS. 196 | 197 | ### New features 198 | 199 | The exports from @codemirror/comment are now available in this package. 200 | 201 | The exports from the @codemirror/history package are now available from this package. 202 | 203 | ## 0.19.8 (2022-01-26) 204 | 205 | ### Bug fixes 206 | 207 | `deleteCharBackward` now removes extending characters one at a time, rather than deleting the entire glyph at once. 208 | 209 | Alt-v is no longer bound in `emacsStyleKeymap` and macOS's `standardKeymap`, because macOS doesn't bind it by default and it conflicts with some keyboard layouts. 210 | 211 | ## 0.19.7 (2022-01-11) 212 | 213 | ### Bug fixes 214 | 215 | Don't bind Alt-\< and Alt-> on macOS by default, since those interfere with some keyboard layouts. Make cursorPageUp/Down scroll the view to keep the cursor in place 216 | 217 | `cursorPageUp` and `cursorPageDown` now scroll the view by the amount that the cursor moved. 218 | 219 | ## 0.19.6 (2021-12-10) 220 | 221 | ### Bug fixes 222 | 223 | The standard keymap no longer overrides Shift-Delete, in order to allow the native behavior of that key to happen on platforms that support it. 224 | 225 | ## 0.19.5 (2021-09-21) 226 | 227 | ### New features 228 | 229 | Adds an `insertBlankLine` command which creates an empty line below the selection, and binds it to Mod-Enter in the default keymap. 230 | 231 | ## 0.19.4 (2021-09-13) 232 | 233 | ### Bug fixes 234 | 235 | Make commands that affect the editor's content check `state.readOnly` and return false when that is true. 236 | 237 | ## 0.19.3 (2021-09-09) 238 | 239 | ### Bug fixes 240 | 241 | Make by-line cursor motion commands move the cursor to the start/end of the document when they hit the first/last line. 242 | 243 | Fix a bug where `deleteCharForward`/`Backward` behaved incorrectly when deleting directly before or after an atomic range. 244 | 245 | ## 0.19.2 (2021-08-24) 246 | 247 | ### New features 248 | 249 | New commands `cursorSubwordForward`, `cursorSubwordBackward`, `selectSubwordForward`, and `selectSubwordBackward` which implement motion by camel case subword. 250 | 251 | ## 0.19.1 (2021-08-11) 252 | 253 | ### Bug fixes 254 | 255 | Fix incorrect versions for @lezer dependencies. 256 | 257 | ## 0.19.0 (2021-08-11) 258 | 259 | ### Breaking changes 260 | 261 | Change default binding for backspace to `deleteCharBackward`, drop `deleteCodePointBackward`/`Forward` from the library. 262 | 263 | `defaultTabBinding` was removed. 264 | 265 | ### Bug fixes 266 | 267 | Drop Alt-d, Alt-f, and Alt-b bindings from `emacsStyleKeymap` (and thus from the default macOS bindings). 268 | 269 | `deleteCharBackward` and `deleteCharForward` now take atomic ranges into account. 270 | 271 | ### New features 272 | 273 | Attach more granular user event strings to transactions. 274 | 275 | The module exports a new binding `indentWithTab` that binds tab and shift-tab to `indentMore` and `indentLess`. 276 | 277 | ## 0.18.3 (2021-06-11) 278 | 279 | ### Bug fixes 280 | 281 | `moveLineDown` will no longer incorrectly grow the selection. 282 | 283 | Line-based commands will no longer include lines where a range selection ends right at the start of the line. 284 | 285 | ## 0.18.2 (2021-05-06) 286 | 287 | ### Bug fixes 288 | 289 | Use Ctrl-l, not Alt-l, to bind `selectLine` on macOS, to avoid conflicting with special-character-insertion bindings. 290 | 291 | Make the macOS Command-ArrowLeft/Right commands behave more like their native versions. 292 | 293 | ## 0.18.1 (2021-04-08) 294 | 295 | ### Bug fixes 296 | 297 | Also bind Shift-Backspace and Shift-Delete in the default keymap (to do the same thing as the Shift-less binding). 298 | 299 | ### New features 300 | 301 | Adds a `deleteToLineStart` command. 302 | 303 | Adds bindings for Cmd-Delete and Cmd-Backspace on macOS. 304 | 305 | ## 0.18.0 (2021-03-03) 306 | 307 | ### Breaking changes 308 | 309 | Update dependencies to 0.18. 310 | 311 | ## 0.17.5 (2021-02-25) 312 | 313 | ### Bug fixes 314 | 315 | Use Alt-l for the default `selectLine` binding, because Mod-l already has an important meaning in the browser. 316 | 317 | Make `deleteGroupBackward`/`deleteGroupForward` delete groups of whitespace when bigger than a single space. 318 | 319 | Don't change lines that have the end of a range selection directly at their start in `indentLess`, `indentMore`, and `indentSelection`. 320 | 321 | ## 0.17.4 (2021-02-18) 322 | 323 | ### Bug fixes 324 | 325 | Fix a bug where `deleteToLineEnd` would delete the rest of the document when at the end of a line. 326 | 327 | ## 0.17.3 (2021-02-16) 328 | 329 | ### Bug fixes 330 | 331 | Fix an issue where `insertNewlineAndIndent` behaved strangely with the cursor between brackets that sat on different lines. 332 | 333 | ## 0.17.2 (2021-01-22) 334 | 335 | ### New features 336 | 337 | The new `insertTab` command inserts a tab when nothing is selected, and defers to `indentMore` otherwise. 338 | 339 | The package now exports a `defaultTabBinding` object that provides a recommended binding for tab (if you must bind tab). 340 | 341 | ## 0.17.1 (2021-01-06) 342 | 343 | ### New features 344 | 345 | The package now also exports a CommonJS module. 346 | 347 | ## 0.17.0 (2020-12-29) 348 | 349 | ### Breaking changes 350 | 351 | First numbered release. 352 | 353 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2018-2021 by Marijn Haverbeke and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @codemirror/commands [![NPM version](https://img.shields.io/npm/v/@codemirror/commands.svg)](https://www.npmjs.org/package/@codemirror/commands) 2 | 3 | [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#commands) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/commands/blob/main/CHANGELOG.md) ] 4 | 5 | This package implements a collection of editing commands for the 6 | [CodeMirror](https://codemirror.net/) code editor. 7 | 8 | The [project page](https://codemirror.net/) has more information, a 9 | number of [examples](https://codemirror.net/examples/) and the 10 | [documentation](https://codemirror.net/docs/). 11 | 12 | This code is released under an 13 | [MIT license](https://github.com/codemirror/commands/tree/main/LICENSE). 14 | 15 | We aim to be an inclusive, welcoming community. To make that explicit, 16 | we have a [code of 17 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 18 | to communication around the project. 19 | 20 | ## Usage 21 | 22 | ```javascript 23 | import {EditorView, keymap} from "@codemirror/view" 24 | import {standardKeymap, selectLine} from "@codemirror/commands" 25 | 26 | const view = new EditorView({ 27 | parent: document.body, 28 | extensions: [ 29 | keymap.of([ 30 | ...standardKeymap, 31 | {key: "Alt-l", mac: "Ctrl-l", run: selectLine} 32 | ]) 33 | ] 34 | }) 35 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codemirror/commands", 3 | "version": "6.8.1", 4 | "description": "Collection of editing commands for the CodeMirror code editor", 5 | "scripts": { 6 | "test": "cm-runtests", 7 | "prepare": "cm-buildhelper src/commands.ts" 8 | }, 9 | "keywords": [ 10 | "editor", 11 | "code" 12 | ], 13 | "author": { 14 | "name": "Marijn Haverbeke", 15 | "email": "marijn@haverbeke.berlin", 16 | "url": "http://marijnhaverbeke.nl" 17 | }, 18 | "type": "module", 19 | "main": "dist/index.cjs", 20 | "exports": { 21 | "import": "./dist/index.js", 22 | "require": "./dist/index.cjs" 23 | }, 24 | "types": "dist/index.d.ts", 25 | "module": "dist/index.js", 26 | "sideEffects": false, 27 | "license": "MIT", 28 | "dependencies": { 29 | "@codemirror/language": "^6.0.0", 30 | "@codemirror/state": "^6.4.0", 31 | "@codemirror/view": "^6.27.0", 32 | "@lezer/common": "^1.1.0" 33 | }, 34 | "devDependencies": { 35 | "@codemirror/buildhelper": "^1.0.0", 36 | "@codemirror/lang-javascript": "^6.0.0" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/codemirror/commands.git" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | This package exports a collection of generic editing commands, along 2 | with key bindings for a lot of them. 3 | 4 | ### Keymaps 5 | 6 | @standardKeymap 7 | 8 | @defaultKeymap 9 | 10 | @emacsStyleKeymap 11 | 12 | @indentWithTab 13 | 14 | ### Selection 15 | 16 | @simplifySelection 17 | 18 | #### By character 19 | 20 | @cursorCharLeft 21 | 22 | @selectCharLeft 23 | 24 | @cursorCharRight 25 | 26 | @selectCharRight 27 | 28 | @cursorCharForward 29 | 30 | @selectCharForward 31 | 32 | @cursorCharBackward 33 | 34 | @selectCharBackward 35 | 36 | @cursorCharForwardLogical 37 | 38 | @selectCharForwardLogical 39 | 40 | @cursorCharBackwardLogical 41 | 42 | @selectCharBackwardLogical 43 | 44 | #### By group 45 | 46 | @cursorGroupLeft 47 | 48 | @selectGroupLeft 49 | 50 | @cursorGroupRight 51 | 52 | @selectGroupRight 53 | 54 | @cursorGroupForward 55 | 56 | @selectGroupForward 57 | 58 | @cursorGroupBackward 59 | 60 | @selectGroupBackward 61 | 62 | @cursorGroupForwardWin 63 | 64 | @selectGroupForwardWin 65 | 66 | @cursorSubwordForward 67 | 68 | @selectSubwordForward 69 | 70 | @cursorSubwordBackward 71 | 72 | @selectSubwordBackward 73 | 74 | #### Vertical motion 75 | 76 | @cursorLineUp 77 | 78 | @selectLineUp 79 | 80 | @cursorLineDown 81 | 82 | @selectLineDown 83 | 84 | @cursorPageUp 85 | 86 | @selectPageUp 87 | 88 | @cursorPageDown 89 | 90 | @selectPageDown 91 | 92 | #### By line boundary 93 | 94 | @cursorLineBoundaryForward 95 | 96 | @selectLineBoundaryForward 97 | 98 | @cursorLineBoundaryBackward 99 | 100 | @selectLineBoundaryBackward 101 | 102 | @cursorLineBoundaryLeft 103 | 104 | @selectLineBoundaryLeft 105 | 106 | @cursorLineBoundaryRight 107 | 108 | @selectLineBoundaryRight 109 | 110 | @cursorLineStart 111 | 112 | @selectLineStart 113 | 114 | @cursorLineEnd 115 | 116 | @selectLineEnd 117 | 118 | @selectLine 119 | 120 | #### By document boundary 121 | 122 | @cursorDocStart 123 | 124 | @selectDocStart 125 | 126 | @cursorDocEnd 127 | 128 | @selectDocEnd 129 | 130 | @selectAll 131 | 132 | #### By syntax 133 | 134 | @cursorSyntaxLeft 135 | 136 | @selectSyntaxLeft 137 | 138 | @cursorSyntaxRight 139 | 140 | @selectSyntaxRight 141 | 142 | @selectParentSyntax 143 | 144 | @cursorMatchingBracket 145 | 146 | @selectMatchingBracket 147 | 148 | ### Deletion 149 | 150 | @deleteCharBackward 151 | 152 | @deleteCharBackwardStrict 153 | 154 | @deleteCharForward 155 | 156 | @deleteGroupBackward 157 | 158 | @deleteGroupForward 159 | 160 | @deleteToLineStart 161 | 162 | @deleteToLineEnd 163 | 164 | @deleteLineBoundaryBackward 165 | 166 | @deleteLineBoundaryForward 167 | 168 | @deleteTrailingWhitespace 169 | 170 | ### Line manipulation 171 | 172 | @splitLine 173 | 174 | @moveLineUp 175 | 176 | @moveLineDown 177 | 178 | @copyLineUp 179 | 180 | @copyLineDown 181 | 182 | @deleteLine 183 | 184 | ### Indentation 185 | 186 | @indentSelection 187 | 188 | @indentMore 189 | 190 | @indentLess 191 | 192 | @insertTab 193 | 194 | ### Character Manipulation 195 | 196 | @transposeChars 197 | 198 | @insertNewline 199 | 200 | @insertNewlineAndIndent 201 | 202 | @insertNewlineKeepIndent 203 | 204 | @insertBlankLine 205 | 206 | ### Undo History 207 | 208 | @history 209 | 210 | @historyKeymap 211 | 212 | @historyField 213 | 214 | @undo 215 | 216 | @redo 217 | 218 | @undoSelection 219 | 220 | @redoSelection 221 | 222 | @undoDepth 223 | 224 | @redoDepth 225 | 226 | @isolateHistory 227 | 228 | @invertedEffects 229 | 230 | ### Commenting and Uncommenting 231 | 232 | @CommentTokens 233 | 234 | @toggleComment 235 | 236 | @toggleLineComment 237 | 238 | @lineComment 239 | 240 | @lineUncomment 241 | 242 | @toggleBlockComment 243 | 244 | @blockComment 245 | 246 | @blockUncomment 247 | 248 | @toggleBlockCommentByLine 249 | 250 | ### Tab Focus Mode 251 | 252 | @toggleTabFocusMode 253 | 254 | @temporarilySetTabFocusMode 255 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import {EditorState, StateCommand, EditorSelection, SelectionRange, 2 | StateEffect, ChangeSpec, Transaction, 3 | findClusterBreak, Text, Line, countColumn, CharCategory} from "@codemirror/state" 4 | import {EditorView, Command, Direction, KeyBinding} from "@codemirror/view" 5 | import {syntaxTree, IndentContext, getIndentUnit, indentUnit, indentString, 6 | getIndentation, matchBrackets} from "@codemirror/language" 7 | import {SyntaxNode, NodeProp} from "@lezer/common" 8 | import {toggleComment, toggleBlockComment} from "./comment" 9 | 10 | export {CommentTokens, toggleComment, toggleLineComment, lineComment, lineUncomment, 11 | toggleBlockComment, blockComment, blockUncomment, toggleBlockCommentByLine} from "./comment" 12 | export {history, historyKeymap, historyField, undo, redo, undoSelection, redoSelection, 13 | undoDepth, redoDepth, isolateHistory, invertedEffects} from "./history" 14 | 15 | function updateSel(sel: EditorSelection, by: (range: SelectionRange) => SelectionRange) { 16 | return EditorSelection.create(sel.ranges.map(by), sel.mainIndex) 17 | } 18 | 19 | function setSel(state: EditorState, selection: EditorSelection | {anchor: number, head?: number}) { 20 | return state.update({selection, scrollIntoView: true, userEvent: "select"}) 21 | } 22 | 23 | type CommandTarget = {state: EditorState, dispatch: (tr: Transaction) => void} 24 | 25 | function moveSel({state, dispatch}: CommandTarget, 26 | how: (range: SelectionRange) => SelectionRange): boolean { 27 | let selection = updateSel(state.selection, how) 28 | if (selection.eq(state.selection, true)) return false 29 | dispatch(setSel(state, selection)) 30 | return true 31 | } 32 | 33 | function rangeEnd(range: SelectionRange, forward: boolean) { 34 | return EditorSelection.cursor(forward ? range.to : range.from) 35 | } 36 | 37 | function cursorByChar(view: EditorView, forward: boolean) { 38 | return moveSel(view, range => range.empty ? view.moveByChar(range, forward) : rangeEnd(range, forward)) 39 | } 40 | 41 | function ltrAtCursor(view: EditorView) { 42 | return view.textDirectionAt(view.state.selection.main.head) == Direction.LTR 43 | } 44 | 45 | /// Move the selection one character to the left (which is backward in 46 | /// left-to-right text, forward in right-to-left text). 47 | export const cursorCharLeft: Command = view => cursorByChar(view, !ltrAtCursor(view)) 48 | /// Move the selection one character to the right. 49 | export const cursorCharRight: Command = view => cursorByChar(view, ltrAtCursor(view)) 50 | 51 | /// Move the selection one character forward. 52 | export const cursorCharForward: Command = view => cursorByChar(view, true) 53 | /// Move the selection one character backward. 54 | export const cursorCharBackward: Command = view => cursorByChar(view, false) 55 | 56 | function byCharLogical(state: EditorState, range: SelectionRange, forward: boolean) { 57 | let pos = range.head, line = state.doc.lineAt(pos) 58 | if (pos == (forward ? line.to : line.from)) 59 | pos = forward ? Math.min(state.doc.length, line.to + 1) : Math.max(0, line.from - 1) 60 | else 61 | pos = line.from + findClusterBreak(line.text, pos - line.from, forward) 62 | return EditorSelection.cursor(pos, forward ? -1 : 1) 63 | } 64 | 65 | function moveByCharLogical(target: CommandTarget, forward: boolean) { 66 | return moveSel(target, range => range.empty ? byCharLogical(target.state, range, forward) : rangeEnd(range, forward)) 67 | } 68 | 69 | /// Move the selection one character forward, in logical 70 | /// (non-text-direction-aware) string index order. 71 | export const cursorCharForwardLogical: StateCommand = target => moveByCharLogical(target, true) 72 | 73 | /// Move the selection one character backward, in logical string index 74 | /// order. 75 | export const cursorCharBackwardLogical: StateCommand = target => moveByCharLogical(target, false) 76 | 77 | function cursorByGroup(view: EditorView, forward: boolean) { 78 | return moveSel(view, range => range.empty ? view.moveByGroup(range, forward) : rangeEnd(range, forward)) 79 | } 80 | 81 | /// Move the selection to the left across one group of word or 82 | /// non-word (but also non-space) characters. 83 | export const cursorGroupLeft: Command = view => cursorByGroup(view, !ltrAtCursor(view)) 84 | /// Move the selection one group to the right. 85 | export const cursorGroupRight: Command = view => cursorByGroup(view, ltrAtCursor(view)) 86 | 87 | /// Move the selection one group forward. 88 | export const cursorGroupForward: Command = view => cursorByGroup(view, true) 89 | /// Move the selection one group backward. 90 | export const cursorGroupBackward: Command = view => cursorByGroup(view, false) 91 | 92 | function toGroupStart(view: EditorView, pos: number, start: string) { 93 | let categorize = view.state.charCategorizer(pos) 94 | let cat = categorize(start), initial = cat != CharCategory.Space 95 | return (next: string) => { 96 | let nextCat = categorize(next) 97 | if (nextCat != CharCategory.Space) return initial && nextCat == cat 98 | initial = false 99 | return true 100 | } 101 | } 102 | 103 | /// Move the cursor one group forward in the default Windows style, 104 | /// where it moves to the start of the next group. 105 | export const cursorGroupForwardWin: Command = view => { 106 | return moveSel(view, range => range.empty 107 | ? view.moveByChar(range, true, start => toGroupStart(view, range.head, start)) 108 | : rangeEnd(range, true)) 109 | } 110 | 111 | const segmenter = typeof Intl != "undefined" && (Intl as any).Segmenter ? 112 | new ((Intl as any).Segmenter)(undefined, {granularity: "word"}) : null 113 | 114 | function moveBySubword(view: EditorView, range: SelectionRange, forward: boolean) { 115 | let categorize = view.state.charCategorizer(range.from) 116 | let cat = CharCategory.Space, pos = range.from, steps = 0 117 | let done = false, sawUpper = false, sawLower = false 118 | let step = (next: string) => { 119 | if (done) return false 120 | pos += forward ? next.length : -next.length 121 | let nextCat = categorize(next), ahead 122 | if (nextCat == CharCategory.Word && next.charCodeAt(0) < 128 && /[\W_]/.test(next)) 123 | nextCat = -1 as any // Treat word punctuation specially 124 | if (cat == CharCategory.Space) cat = nextCat 125 | if (cat != nextCat) return false 126 | if (cat == CharCategory.Word) { 127 | if (next.toLowerCase() == next) { 128 | if (!forward && sawUpper) return false 129 | sawLower = true 130 | } else if (sawLower) { 131 | if (forward) return false 132 | done = true 133 | } else { 134 | if (sawUpper && forward && categorize(ahead = view.state.sliceDoc(pos, pos + 1)) == CharCategory.Word && 135 | ahead.toLowerCase() == ahead) return false 136 | sawUpper = true 137 | } 138 | } 139 | steps++ 140 | return true 141 | } 142 | 143 | let end = view.moveByChar(range, forward, start => { 144 | step(start) 145 | return step 146 | }) 147 | 148 | if (segmenter && cat == CharCategory.Word as any && end.from == range.from + steps * (forward ? 1 : -1)) { 149 | let from = Math.min(range.head, end.head), to = Math.max(range.head, end.head) 150 | let skipped = view.state.sliceDoc(from, to) 151 | if (skipped.length > 1 && /[\u4E00-\uffff]/.test(skipped)) { 152 | let segments = Array.from(segmenter.segment(skipped)) as {index: number}[] 153 | if (segments.length > 1) { 154 | if (forward) return EditorSelection.cursor(range.head + segments[1].index, -1) 155 | return EditorSelection.cursor(end.head + segments[segments.length - 1].index, 1) 156 | } 157 | } 158 | } 159 | return end 160 | } 161 | 162 | function cursorBySubword(view: EditorView, forward: boolean) { 163 | return moveSel(view, range => range.empty ? moveBySubword(view, range, forward) : rangeEnd(range, forward)) 164 | } 165 | 166 | /// Move the selection one group or camel-case subword forward. 167 | export const cursorSubwordForward: Command = view => cursorBySubword(view, true) 168 | /// Move the selection one group or camel-case subword backward. 169 | export const cursorSubwordBackward: Command = view => cursorBySubword(view, false) 170 | 171 | function interestingNode(state: EditorState, node: SyntaxNode, bracketProp: NodeProp) { 172 | if (node.type.prop(bracketProp)) return true 173 | let len = node.to - node.from 174 | return len && (len > 2 || /[^\s,.;:]/.test(state.sliceDoc(node.from, node.to))) || node.firstChild 175 | } 176 | 177 | function moveBySyntax(state: EditorState, start: SelectionRange, forward: boolean) { 178 | let pos = syntaxTree(state).resolveInner(start.head) 179 | let bracketProp = forward ? NodeProp.closedBy : NodeProp.openedBy 180 | // Scan forward through child nodes to see if there's an interesting 181 | // node ahead. 182 | for (let at = start.head;;) { 183 | let next = forward ? pos.childAfter(at) : pos.childBefore(at) 184 | if (!next) break 185 | if (interestingNode(state, next, bracketProp)) pos = next 186 | else at = forward ? next.to : next.from 187 | } 188 | let bracket = pos.type.prop(bracketProp), match, newPos 189 | if (bracket && (match = forward ? matchBrackets(state, pos.from, 1) : matchBrackets(state, pos.to, -1)) && match.matched) 190 | newPos = forward ? match.end!.to : match.end!.from 191 | else 192 | newPos = forward ? pos.to : pos.from 193 | return EditorSelection.cursor(newPos, forward ? -1 : 1) 194 | } 195 | 196 | /// Move the cursor over the next syntactic element to the left. 197 | export const cursorSyntaxLeft: Command = 198 | view => moveSel(view, range => moveBySyntax(view.state, range, !ltrAtCursor(view))) 199 | /// Move the cursor over the next syntactic element to the right. 200 | export const cursorSyntaxRight: Command = 201 | view => moveSel(view, range => moveBySyntax(view.state, range, ltrAtCursor(view))) 202 | 203 | function cursorByLine(view: EditorView, forward: boolean) { 204 | return moveSel(view, range => { 205 | if (!range.empty) return rangeEnd(range, forward) 206 | let moved = view.moveVertically(range, forward) 207 | return moved.head != range.head ? moved : view.moveToLineBoundary(range, forward) 208 | }) 209 | } 210 | 211 | /// Move the selection one line up. 212 | export const cursorLineUp: Command = view => cursorByLine(view, false) 213 | /// Move the selection one line down. 214 | export const cursorLineDown: Command = view => cursorByLine(view, true) 215 | 216 | function pageInfo(view: EditorView) { 217 | let selfScroll = view.scrollDOM.clientHeight < view.scrollDOM.scrollHeight - 2 218 | let marginTop = 0, marginBottom = 0, height 219 | if (selfScroll) { 220 | for (let source of view.state.facet(EditorView.scrollMargins)) { 221 | let margins = source(view) 222 | if (margins?.top) marginTop = Math.max(margins?.top, marginTop) 223 | if (margins?.bottom) marginBottom = Math.max(margins?.bottom, marginBottom) 224 | } 225 | height = view.scrollDOM.clientHeight - marginTop - marginBottom 226 | } else { 227 | height = (view.dom.ownerDocument.defaultView || window).innerHeight 228 | } 229 | return {marginTop, marginBottom, selfScroll, 230 | height: Math.max(view.defaultLineHeight, height - 5)} 231 | } 232 | 233 | function cursorByPage(view: EditorView, forward: boolean) { 234 | let page = pageInfo(view) 235 | let {state} = view, selection = updateSel(state.selection, range => { 236 | return range.empty ? view.moveVertically(range, forward, page.height) 237 | : rangeEnd(range, forward) 238 | }) 239 | if (selection.eq(state.selection)) return false 240 | let effect: StateEffect | undefined 241 | if (page.selfScroll) { 242 | let startPos = view.coordsAtPos(state.selection.main.head) 243 | let scrollRect = view.scrollDOM.getBoundingClientRect() 244 | let scrollTop = scrollRect.top + page.marginTop, scrollBottom = scrollRect.bottom - page.marginBottom 245 | if (startPos && startPos.top > scrollTop && startPos.bottom < scrollBottom) 246 | effect = EditorView.scrollIntoView(selection.main.head, {y: "start", yMargin: startPos.top - scrollTop}) 247 | } 248 | view.dispatch(setSel(state, selection), {effects: effect}) 249 | return true 250 | } 251 | 252 | /// Move the selection one page up. 253 | export const cursorPageUp: Command = view => cursorByPage(view, false) 254 | /// Move the selection one page down. 255 | export const cursorPageDown: Command = view => cursorByPage(view, true) 256 | 257 | function moveByLineBoundary(view: EditorView, start: SelectionRange, forward: boolean) { 258 | let line = view.lineBlockAt(start.head), moved = view.moveToLineBoundary(start, forward) 259 | if (moved.head == start.head && moved.head != (forward ? line.to : line.from)) 260 | moved = view.moveToLineBoundary(start, forward, false) 261 | if (!forward && moved.head == line.from && line.length) { 262 | let space = /^\s*/.exec(view.state.sliceDoc(line.from, Math.min(line.from + 100, line.to)))![0].length 263 | if (space && start.head != line.from + space) moved = EditorSelection.cursor(line.from + space) 264 | } 265 | return moved 266 | } 267 | 268 | /// Move the selection to the next line wrap point, or to the end of 269 | /// the line if there isn't one left on this line. 270 | export const cursorLineBoundaryForward: Command = view => moveSel(view, range => moveByLineBoundary(view, range, true)) 271 | /// Move the selection to previous line wrap point, or failing that to 272 | /// the start of the line. If the line is indented, and the cursor 273 | /// isn't already at the end of the indentation, this will move to the 274 | /// end of the indentation instead of the start of the line. 275 | export const cursorLineBoundaryBackward: Command = view => moveSel(view, range => moveByLineBoundary(view, range, false)) 276 | /// Move the selection one line wrap point to the left. 277 | export const cursorLineBoundaryLeft: Command = view => moveSel(view, range => 278 | moveByLineBoundary(view, range, !ltrAtCursor(view))) 279 | /// Move the selection one line wrap point to the right. 280 | export const cursorLineBoundaryRight: Command = view => moveSel(view, range => 281 | moveByLineBoundary(view, range, ltrAtCursor(view))) 282 | 283 | /// Move the selection to the start of the line. 284 | export const cursorLineStart: Command = view => moveSel(view, range => EditorSelection.cursor(view.lineBlockAt(range.head).from, 1)) 285 | /// Move the selection to the end of the line. 286 | export const cursorLineEnd: Command = view => moveSel(view, range => EditorSelection.cursor(view.lineBlockAt(range.head).to, -1)) 287 | 288 | function toMatchingBracket(state: EditorState, dispatch: (tr: Transaction) => void, extend: boolean) { 289 | let found = false, selection = updateSel(state.selection, range => { 290 | let matching = matchBrackets(state, range.head, -1) 291 | || matchBrackets(state, range.head, 1) 292 | || (range.head > 0 && matchBrackets(state, range.head - 1, 1)) 293 | || (range.head < state.doc.length && matchBrackets(state, range.head + 1, -1)) 294 | if (!matching || !matching.end) return range 295 | found = true 296 | let head = matching.start.from == range.head ? matching.end.to : matching.end.from 297 | return extend ? EditorSelection.range(range.anchor, head) : EditorSelection.cursor(head) 298 | }) 299 | if (!found) return false 300 | dispatch(setSel(state, selection)) 301 | return true 302 | } 303 | 304 | /// Move the selection to the bracket matching the one it is currently 305 | /// on, if any. 306 | export const cursorMatchingBracket: StateCommand = ({state, dispatch}) => toMatchingBracket(state, dispatch, false) 307 | /// Extend the selection to the bracket matching the one the selection 308 | /// head is currently on, if any. 309 | export const selectMatchingBracket: StateCommand = ({state, dispatch}) => toMatchingBracket(state, dispatch, true) 310 | 311 | function extendSel(target: CommandTarget, how: (range: SelectionRange) => SelectionRange): boolean { 312 | let selection = updateSel(target.state.selection, range => { 313 | let head = how(range) 314 | return EditorSelection.range(range.anchor, head.head, head.goalColumn, head.bidiLevel || undefined) 315 | }) 316 | if (selection.eq(target.state.selection)) return false 317 | target.dispatch(setSel(target.state, selection)) 318 | return true 319 | } 320 | 321 | function selectByChar(view: EditorView, forward: boolean) { 322 | return extendSel(view, range => view.moveByChar(range, forward)) 323 | } 324 | 325 | /// Move the selection head one character to the left, while leaving 326 | /// the anchor in place. 327 | export const selectCharLeft: Command = view => selectByChar(view, !ltrAtCursor(view)) 328 | /// Move the selection head one character to the right. 329 | export const selectCharRight: Command = view => selectByChar(view, ltrAtCursor(view)) 330 | 331 | /// Move the selection head one character forward. 332 | export const selectCharForward: Command = view => selectByChar(view, true) 333 | /// Move the selection head one character backward. 334 | export const selectCharBackward: Command = view => selectByChar(view, false) 335 | 336 | /// Move the selection head one character forward by logical 337 | /// (non-direction aware) string index order. 338 | export const selectCharForwardLogical: StateCommand = 339 | target => extendSel(target, range => byCharLogical(target.state, range, true)) 340 | /// Move the selection head one character backward by logical string 341 | /// index order. 342 | export const selectCharBackwardLogical: StateCommand = 343 | target => extendSel(target, range => byCharLogical(target.state, range, false)) 344 | 345 | function selectByGroup(view: EditorView, forward: boolean) { 346 | return extendSel(view, range => view.moveByGroup(range, forward)) 347 | } 348 | 349 | /// Move the selection head one [group](#commands.cursorGroupLeft) to 350 | /// the left. 351 | export const selectGroupLeft: Command = view => selectByGroup(view, !ltrAtCursor(view)) 352 | /// Move the selection head one group to the right. 353 | export const selectGroupRight: Command = view => selectByGroup(view, ltrAtCursor(view)) 354 | 355 | /// Move the selection head one group forward. 356 | export const selectGroupForward: Command = view => selectByGroup(view, true) 357 | /// Move the selection head one group backward. 358 | export const selectGroupBackward: Command = view => selectByGroup(view, false) 359 | 360 | /// Move the selection head one group forward in the default Windows 361 | /// style, skipping to the start of the next group. 362 | export const selectGroupForwardWin: Command = view => { 363 | return extendSel(view, range => view.moveByChar(range, true, start => toGroupStart(view, range.head, start))) 364 | } 365 | 366 | function selectBySubword(view: EditorView, forward: boolean) { 367 | return extendSel(view, range => moveBySubword(view, range, forward)) 368 | } 369 | 370 | /// Move the selection head one group or camel-case subword forward. 371 | export const selectSubwordForward: Command = view => selectBySubword(view, true) 372 | /// Move the selection head one group or subword backward. 373 | export const selectSubwordBackward: Command = view => selectBySubword(view, false) 374 | 375 | /// Move the selection head over the next syntactic element to the left. 376 | export const selectSyntaxLeft: Command = 377 | view => extendSel(view, range => moveBySyntax(view.state, range, !ltrAtCursor(view))) 378 | /// Move the selection head over the next syntactic element to the right. 379 | export const selectSyntaxRight: Command = 380 | view => extendSel(view, range => moveBySyntax(view.state, range, ltrAtCursor(view))) 381 | 382 | function selectByLine(view: EditorView, forward: boolean) { 383 | return extendSel(view, range => view.moveVertically(range, forward)) 384 | } 385 | 386 | /// Move the selection head one line up. 387 | export const selectLineUp: Command = view => selectByLine(view, false) 388 | /// Move the selection head one line down. 389 | export const selectLineDown: Command = view => selectByLine(view, true) 390 | 391 | function selectByPage(view: EditorView, forward: boolean) { 392 | return extendSel(view, range => view.moveVertically(range, forward, pageInfo(view).height)) 393 | } 394 | 395 | /// Move the selection head one page up. 396 | export const selectPageUp: Command = view => selectByPage(view, false) 397 | /// Move the selection head one page down. 398 | export const selectPageDown: Command = view => selectByPage(view, true) 399 | 400 | /// Move the selection head to the next line boundary. 401 | export const selectLineBoundaryForward: Command = view => extendSel(view, range => moveByLineBoundary(view, range, true)) 402 | /// Move the selection head to the previous line boundary. 403 | export const selectLineBoundaryBackward: Command = view => extendSel(view, range => moveByLineBoundary(view, range, false)) 404 | /// Move the selection head one line boundary to the left. 405 | export const selectLineBoundaryLeft: Command = view => extendSel(view, range => 406 | moveByLineBoundary(view, range, !ltrAtCursor(view))) 407 | /// Move the selection head one line boundary to the right. 408 | export const selectLineBoundaryRight: Command = view => extendSel(view, range => 409 | moveByLineBoundary(view, range, ltrAtCursor(view))) 410 | 411 | /// Move the selection head to the start of the line. 412 | export const selectLineStart: Command = view => extendSel(view, range => EditorSelection.cursor(view.lineBlockAt(range.head).from)) 413 | /// Move the selection head to the end of the line. 414 | export const selectLineEnd: Command = view => extendSel(view, range => EditorSelection.cursor(view.lineBlockAt(range.head).to)) 415 | 416 | /// Move the selection to the start of the document. 417 | export const cursorDocStart: StateCommand = ({state, dispatch}) => { 418 | dispatch(setSel(state, {anchor: 0})) 419 | return true 420 | } 421 | 422 | /// Move the selection to the end of the document. 423 | export const cursorDocEnd: StateCommand = ({state, dispatch}) => { 424 | dispatch(setSel(state, {anchor: state.doc.length})) 425 | return true 426 | } 427 | 428 | /// Move the selection head to the start of the document. 429 | export const selectDocStart: StateCommand = ({state, dispatch}) => { 430 | dispatch(setSel(state, {anchor: state.selection.main.anchor, head: 0})) 431 | return true 432 | } 433 | 434 | /// Move the selection head to the end of the document. 435 | export const selectDocEnd: StateCommand = ({state, dispatch}) => { 436 | dispatch(setSel(state, {anchor: state.selection.main.anchor, head: state.doc.length})) 437 | return true 438 | } 439 | 440 | /// Select the entire document. 441 | export const selectAll: StateCommand = ({state, dispatch}) => { 442 | dispatch(state.update({selection: {anchor: 0, head: state.doc.length}, userEvent: "select"})) 443 | return true 444 | } 445 | 446 | /// Expand the selection to cover entire lines. 447 | export const selectLine: StateCommand = ({state, dispatch}) => { 448 | let ranges = selectedLineBlocks(state).map(({from, to}) => EditorSelection.range(from, Math.min(to + 1, state.doc.length))) 449 | dispatch(state.update({selection: EditorSelection.create(ranges), userEvent: "select"})) 450 | return true 451 | } 452 | 453 | /// Select the next syntactic construct that is larger than the 454 | /// selection. Note that this will only work insofar as the language 455 | /// [provider](#language.language) you use builds up a full 456 | /// syntax tree. 457 | export const selectParentSyntax: StateCommand = ({state, dispatch}) => { 458 | let selection = updateSel(state.selection, range => { 459 | let tree = syntaxTree(state), stack = tree.resolveStack(range.from, 1) 460 | if (range.empty) { 461 | let stackBefore = tree.resolveStack(range.from, -1) 462 | if (stackBefore.node.from >= stack.node.from && stackBefore.node.to <= stack.node.to) stack = stackBefore 463 | } 464 | for (let cur: typeof stack | null = stack; cur; cur = cur.next) { 465 | let {node} = cur 466 | if (((node.from < range.from && node.to >= range.to) || 467 | (node.to > range.to && node.from <= range.from)) && 468 | cur.next) 469 | return EditorSelection.range(node.to, node.from) 470 | } 471 | return range 472 | }) 473 | if (selection.eq(state.selection)) return false 474 | dispatch(setSel(state, selection)) 475 | return true 476 | } 477 | 478 | /// Simplify the current selection. When multiple ranges are selected, 479 | /// reduce it to its main range. Otherwise, if the selection is 480 | /// non-empty, convert it to a cursor selection. 481 | export const simplifySelection: StateCommand = ({state, dispatch}) => { 482 | let cur = state.selection, selection = null 483 | if (cur.ranges.length > 1) selection = EditorSelection.create([cur.main]) 484 | else if (!cur.main.empty) selection = EditorSelection.create([EditorSelection.cursor(cur.main.head)]) 485 | if (!selection) return false 486 | dispatch(setSel(state, selection)) 487 | return true 488 | } 489 | 490 | function deleteBy(target: CommandTarget, by: (start: SelectionRange) => number) { 491 | if (target.state.readOnly) return false 492 | let event = "delete.selection", {state} = target 493 | let changes = state.changeByRange(range => { 494 | let {from, to} = range 495 | if (from == to) { 496 | let towards = by(range) 497 | if (towards < from) { 498 | event = "delete.backward" 499 | towards = skipAtomic(target, towards, false) 500 | } else if (towards > from) { 501 | event = "delete.forward" 502 | towards = skipAtomic(target, towards, true) 503 | } 504 | from = Math.min(from, towards) 505 | to = Math.max(to, towards) 506 | } else { 507 | from = skipAtomic(target, from, false) 508 | to = skipAtomic(target, to, true) 509 | } 510 | return from == to ? {range} : {changes: {from, to}, range: EditorSelection.cursor(from, from < range.head ? -1 : 1)} 511 | }) 512 | if (changes.changes.empty) return false 513 | target.dispatch(state.update(changes, { 514 | scrollIntoView: true, 515 | userEvent: event, 516 | effects: event == "delete.selection" ? EditorView.announce.of(state.phrase("Selection deleted")) : undefined 517 | })) 518 | return true 519 | } 520 | 521 | function skipAtomic(target: CommandTarget, pos: number, forward: boolean) { 522 | if (target instanceof EditorView) for (let ranges of target.state.facet(EditorView.atomicRanges).map(f => f(target))) 523 | ranges.between(pos, pos, (from, to) => { 524 | if (from < pos && to > pos) pos = forward ? to : from 525 | }) 526 | return pos 527 | } 528 | 529 | const deleteByChar = (target: CommandTarget, forward: boolean, byIndentUnit: boolean) => deleteBy(target, range => { 530 | let pos = range.from, {state} = target, line = state.doc.lineAt(pos), before, targetPos: number 531 | if (byIndentUnit && !forward && pos > line.from && pos < line.from + 200 && 532 | !/[^ \t]/.test(before = line.text.slice(0, pos - line.from))) { 533 | if (before[before.length - 1] == "\t") return pos - 1 534 | let col = countColumn(before, state.tabSize), drop = col % getIndentUnit(state) || getIndentUnit(state) 535 | for (let i = 0; i < drop && before[before.length - 1 - i] == " "; i++) pos-- 536 | targetPos = pos 537 | } else { 538 | targetPos = findClusterBreak(line.text, pos - line.from, forward, forward) + line.from 539 | if (targetPos == pos && line.number != (forward ? state.doc.lines : 1)) 540 | targetPos += forward ? 1 : -1 541 | else if (!forward && /[\ufe00-\ufe0f]/.test(line.text.slice(targetPos - line.from, pos - line.from))) 542 | targetPos = findClusterBreak(line.text, targetPos - line.from, false, false) + line.from 543 | } 544 | return targetPos 545 | }) 546 | 547 | /// Delete the selection, or, for cursor selections, the character or 548 | /// indentation unit before the cursor. 549 | export const deleteCharBackward: Command = view => deleteByChar(view, false, true) 550 | 551 | /// Delete the selection or the character before the cursor. Does not 552 | /// implement any extended behavior like deleting whole indentation 553 | /// units in one go. 554 | export const deleteCharBackwardStrict: Command = view => deleteByChar(view, false, false) 555 | 556 | /// Delete the selection or the character after the cursor. 557 | export const deleteCharForward: Command = view => deleteByChar(view, true, false) 558 | 559 | const deleteByGroup = (target: CommandTarget, forward: boolean) => deleteBy(target, range => { 560 | let pos = range.head, {state} = target, line = state.doc.lineAt(pos) 561 | let categorize = state.charCategorizer(pos) 562 | for (let cat: CharCategory | null = null;;) { 563 | if (pos == (forward ? line.to : line.from)) { 564 | if (pos == range.head && line.number != (forward ? state.doc.lines : 1)) 565 | pos += forward ? 1 : -1 566 | break 567 | } 568 | let next = findClusterBreak(line.text, pos - line.from, forward) + line.from 569 | let nextChar = line.text.slice(Math.min(pos, next) - line.from, Math.max(pos, next) - line.from) 570 | let nextCat = categorize(nextChar) 571 | if (cat != null && nextCat != cat) break 572 | if (nextChar != " " || pos != range.head) cat = nextCat 573 | pos = next 574 | } 575 | return pos 576 | }) 577 | 578 | /// Delete the selection or backward until the end of the next 579 | /// [group](#view.EditorView.moveByGroup), only skipping groups of 580 | /// whitespace when they consist of a single space. 581 | export const deleteGroupBackward: StateCommand = target => deleteByGroup(target, false) 582 | /// Delete the selection or forward until the end of the next group. 583 | export const deleteGroupForward: StateCommand = target => deleteByGroup(target, true) 584 | 585 | /// Delete the selection, or, if it is a cursor selection, delete to 586 | /// the end of the line. If the cursor is directly at the end of the 587 | /// line, delete the line break after it. 588 | export const deleteToLineEnd: Command = view => deleteBy(view, range => { 589 | let lineEnd = view.lineBlockAt(range.head).to 590 | return range.head < lineEnd ? lineEnd : Math.min(view.state.doc.length, range.head + 1) 591 | }) 592 | 593 | /// Delete the selection, or, if it is a cursor selection, delete to 594 | /// the start of the line. If the cursor is directly at the start of the 595 | /// line, delete the line break before it. 596 | export const deleteToLineStart: Command = view => deleteBy(view, range => { 597 | let lineStart = view.lineBlockAt(range.head).from 598 | return range.head > lineStart ? lineStart : Math.max(0, range.head - 1) 599 | }) 600 | 601 | /// Delete the selection, or, if it is a cursor selection, delete to 602 | /// the start of the line or the next line wrap before the cursor. 603 | export const deleteLineBoundaryBackward: Command = view => deleteBy(view, range => { 604 | let lineStart = view.moveToLineBoundary(range, false).head 605 | return range.head > lineStart ? lineStart : Math.max(0, range.head - 1) 606 | }) 607 | 608 | /// Delete the selection, or, if it is a cursor selection, delete to 609 | /// the end of the line or the next line wrap after the cursor. 610 | export const deleteLineBoundaryForward: Command = view => deleteBy(view, range => { 611 | let lineStart = view.moveToLineBoundary(range, true).head 612 | return range.head < lineStart ? lineStart : Math.min(view.state.doc.length, range.head + 1) 613 | }) 614 | 615 | /// Delete all whitespace directly before a line end from the 616 | /// document. 617 | export const deleteTrailingWhitespace: StateCommand = ({state, dispatch}) => { 618 | if (state.readOnly) return false 619 | let changes = [] 620 | for (let pos = 0, prev = "", iter = state.doc.iter();;) { 621 | iter.next() 622 | if (iter.lineBreak || iter.done) { 623 | let trailing = prev.search(/\s+$/) 624 | if (trailing > -1) changes.push({from: pos - (prev.length - trailing), to: pos}) 625 | if (iter.done) break 626 | prev = "" 627 | } else { 628 | prev = iter.value 629 | } 630 | pos += iter.value.length 631 | } 632 | if (!changes.length) return false 633 | dispatch(state.update({changes, userEvent: "delete"})) 634 | return true 635 | } 636 | 637 | /// Replace each selection range with a line break, leaving the cursor 638 | /// on the line before the break. 639 | export const splitLine: StateCommand = ({state, dispatch}) => { 640 | if (state.readOnly) return false 641 | let changes = state.changeByRange(range => { 642 | return {changes: {from: range.from, to: range.to, insert: Text.of(["", ""])}, 643 | range: EditorSelection.cursor(range.from)} 644 | }) 645 | dispatch(state.update(changes, {scrollIntoView: true, userEvent: "input"})) 646 | return true 647 | } 648 | 649 | /// Flip the characters before and after the cursor(s). 650 | export const transposeChars: StateCommand = ({state, dispatch}) => { 651 | if (state.readOnly) return false 652 | let changes = state.changeByRange(range => { 653 | if (!range.empty || range.from == 0 || range.from == state.doc.length) return {range} 654 | let pos = range.from, line = state.doc.lineAt(pos) 655 | let from = pos == line.from ? pos - 1 : findClusterBreak(line.text, pos - line.from, false) + line.from 656 | let to = pos == line.to ? pos + 1 : findClusterBreak(line.text, pos - line.from, true) + line.from 657 | return {changes: {from, to, insert: state.doc.slice(pos, to).append(state.doc.slice(from, pos))}, 658 | range: EditorSelection.cursor(to)} 659 | }) 660 | if (changes.changes.empty) return false 661 | dispatch(state.update(changes, {scrollIntoView: true, userEvent: "move.character"})) 662 | return true 663 | } 664 | 665 | function selectedLineBlocks(state: EditorState) { 666 | let blocks = [], upto = -1 667 | for (let range of state.selection.ranges) { 668 | let startLine = state.doc.lineAt(range.from), endLine = state.doc.lineAt(range.to) 669 | if (!range.empty && range.to == endLine.from) endLine = state.doc.lineAt(range.to - 1) 670 | if (upto >= startLine.number) { 671 | let prev = blocks[blocks.length - 1] 672 | prev.to = endLine.to 673 | prev.ranges.push(range) 674 | } else { 675 | blocks.push({from: startLine.from, to: endLine.to, ranges: [range]}) 676 | } 677 | upto = endLine.number + 1 678 | } 679 | return blocks 680 | } 681 | 682 | function moveLine(state: EditorState, dispatch: (tr: Transaction) => void, forward: boolean): boolean { 683 | if (state.readOnly) return false 684 | let changes = [], ranges = [] 685 | for (let block of selectedLineBlocks(state)) { 686 | if (forward ? block.to == state.doc.length : block.from == 0) continue 687 | let nextLine = state.doc.lineAt(forward ? block.to + 1 : block.from - 1) 688 | let size = nextLine.length + 1 689 | if (forward) { 690 | changes.push({from: block.to, to: nextLine.to}, 691 | {from: block.from, insert: nextLine.text + state.lineBreak}) 692 | for (let r of block.ranges) 693 | ranges.push(EditorSelection.range(Math.min(state.doc.length, r.anchor + size), Math.min(state.doc.length, r.head + size))) 694 | } else { 695 | changes.push({from: nextLine.from, to: block.from}, 696 | {from: block.to, insert: state.lineBreak + nextLine.text}) 697 | for (let r of block.ranges) 698 | ranges.push(EditorSelection.range(r.anchor - size, r.head - size)) 699 | } 700 | } 701 | if (!changes.length) return false 702 | dispatch(state.update({ 703 | changes, 704 | scrollIntoView: true, 705 | selection: EditorSelection.create(ranges, state.selection.mainIndex), 706 | userEvent: "move.line" 707 | })) 708 | return true 709 | } 710 | 711 | /// Move the selected lines up one line. 712 | export const moveLineUp: StateCommand = ({state, dispatch}) => moveLine(state, dispatch, false) 713 | /// Move the selected lines down one line. 714 | export const moveLineDown: StateCommand = ({state, dispatch}) => moveLine(state, dispatch, true) 715 | 716 | function copyLine(state: EditorState, dispatch: (tr: Transaction) => void, forward: boolean): boolean { 717 | if (state.readOnly) return false 718 | let changes = [] 719 | for (let block of selectedLineBlocks(state)) { 720 | if (forward) 721 | changes.push({from: block.from, insert: state.doc.slice(block.from, block.to) + state.lineBreak}) 722 | else 723 | changes.push({from: block.to, insert: state.lineBreak + state.doc.slice(block.from, block.to)}) 724 | } 725 | dispatch(state.update({changes, scrollIntoView: true, userEvent: "input.copyline"})) 726 | return true 727 | } 728 | 729 | /// Create a copy of the selected lines. Keep the selection in the top copy. 730 | export const copyLineUp: StateCommand = ({state, dispatch}) => copyLine(state, dispatch, false) 731 | /// Create a copy of the selected lines. Keep the selection in the bottom copy. 732 | export const copyLineDown: StateCommand = ({state, dispatch}) => copyLine(state, dispatch, true) 733 | 734 | /// Delete selected lines. 735 | export const deleteLine: Command = view => { 736 | if (view.state.readOnly) return false 737 | let {state} = view, changes = state.changes(selectedLineBlocks(state).map(({from, to}) => { 738 | if (from > 0) from-- 739 | else if (to < state.doc.length) to++ 740 | return {from, to} 741 | })) 742 | let selection = updateSel(state.selection, range => { 743 | let dist: number | undefined = undefined 744 | if (view.lineWrapping) { 745 | let block = view.lineBlockAt(range.head), pos = view.coordsAtPos(range.head, range.assoc || 1) 746 | if (pos) dist = (block.bottom + view.documentTop) - pos.bottom + view.defaultLineHeight / 2 747 | } 748 | return view.moveVertically(range, true, dist) 749 | }).map(changes) 750 | view.dispatch({changes, selection, scrollIntoView: true, userEvent: "delete.line"}) 751 | return true 752 | } 753 | 754 | /// Replace the selection with a newline. 755 | export const insertNewline: StateCommand = ({state, dispatch}) => { 756 | dispatch(state.update(state.replaceSelection(state.lineBreak), {scrollIntoView: true, userEvent: "input"})) 757 | return true 758 | } 759 | 760 | /// Replace the selection with a newline and the same amount of 761 | /// indentation as the line above. 762 | export const insertNewlineKeepIndent: StateCommand = ({state, dispatch}) => { 763 | dispatch(state.update(state.changeByRange(range => { 764 | let indent = /^\s*/.exec(state.doc.lineAt(range.from).text)![0] 765 | return { 766 | changes: {from: range.from, to: range.to, insert: state.lineBreak + indent}, 767 | range: EditorSelection.cursor(range.from + indent.length + 1) 768 | } 769 | }), {scrollIntoView: true, userEvent: "input"})) 770 | return true 771 | } 772 | 773 | function isBetweenBrackets(state: EditorState, pos: number): {from: number, to: number} | null { 774 | if (/\(\)|\[\]|\{\}/.test(state.sliceDoc(pos - 1, pos + 1))) return {from: pos, to: pos} 775 | let context = syntaxTree(state).resolveInner(pos) 776 | let before = context.childBefore(pos), after = context.childAfter(pos), closedBy 777 | if (before && after && before.to <= pos && after.from >= pos && 778 | (closedBy = before.type.prop(NodeProp.closedBy)) && closedBy.indexOf(after.name) > -1 && 779 | state.doc.lineAt(before.to).from == state.doc.lineAt(after.from).from && 780 | !/\S/.test(state.sliceDoc(before.to, after.from))) 781 | return {from: before.to, to: after.from} 782 | return null 783 | } 784 | 785 | /// Replace the selection with a newline and indent the newly created 786 | /// line(s). If the current line consists only of whitespace, this 787 | /// will also delete that whitespace. When the cursor is between 788 | /// matching brackets, an additional newline will be inserted after 789 | /// the cursor. 790 | export const insertNewlineAndIndent = newlineAndIndent(false) 791 | 792 | /// Create a blank, indented line below the current line. 793 | export const insertBlankLine = newlineAndIndent(true) 794 | 795 | function newlineAndIndent(atEof: boolean): StateCommand { 796 | return ({state, dispatch}): boolean => { 797 | if (state.readOnly) return false 798 | let changes = state.changeByRange(range => { 799 | let {from, to} = range, line = state.doc.lineAt(from) 800 | let explode = !atEof && from == to && isBetweenBrackets(state, from) 801 | if (atEof) from = to = (to <= line.to ? line : state.doc.lineAt(to)).to 802 | let cx = new IndentContext(state, {simulateBreak: from, simulateDoubleBreak: !!explode}) 803 | let indent = getIndentation(cx, from) 804 | if (indent == null) 805 | indent = countColumn(/^\s*/.exec(state.doc.lineAt(from).text)![0], state.tabSize) 806 | 807 | while (to < line.to && /\s/.test(line.text[to - line.from])) to++ 808 | if (explode) ({from, to} = explode) 809 | else if (from > line.from && from < line.from + 100 && !/\S/.test(line.text.slice(0, from))) from = line.from 810 | let insert = ["", indentString(state, indent)] 811 | if (explode) insert.push(indentString(state, cx.lineIndent(line.from, -1))) 812 | return {changes: {from, to, insert: Text.of(insert)}, 813 | range: EditorSelection.cursor(from + 1 + insert[1].length)} 814 | }) 815 | dispatch(state.update(changes, {scrollIntoView: true, userEvent: "input"})) 816 | return true 817 | } 818 | } 819 | 820 | function changeBySelectedLine(state: EditorState, f: (line: Line, changes: ChangeSpec[], range: SelectionRange) => void) { 821 | let atLine = -1 822 | return state.changeByRange(range => { 823 | let changes: ChangeSpec[] = [] 824 | for (let pos = range.from; pos <= range.to;) { 825 | let line = state.doc.lineAt(pos) 826 | if (line.number > atLine && (range.empty || range.to > line.from)) { 827 | f(line, changes, range) 828 | atLine = line.number 829 | } 830 | pos = line.to + 1 831 | } 832 | let changeSet = state.changes(changes) 833 | return {changes, 834 | range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1))} 835 | }) 836 | } 837 | 838 | /// Auto-indent the selected lines. This uses the [indentation service 839 | /// facet](#language.indentService) as source for auto-indent 840 | /// information. 841 | export const indentSelection: StateCommand = ({state, dispatch}) => { 842 | if (state.readOnly) return false 843 | let updated: {[lineStart: number]: number} = Object.create(null) 844 | let context = new IndentContext(state, {overrideIndentation: start => { 845 | let found = updated[start] 846 | return found == null ? -1 : found 847 | }}) 848 | let changes = changeBySelectedLine(state, (line, changes, range) => { 849 | let indent = getIndentation(context, line.from) 850 | if (indent == null) return 851 | if (!/\S/.test(line.text)) indent = 0 852 | let cur = /^\s*/.exec(line.text)![0] 853 | let norm = indentString(state, indent) 854 | if (cur != norm || range.from < line.from + cur.length) { 855 | updated[line.from] = indent 856 | changes.push({from: line.from, to: line.from + cur.length, insert: norm}) 857 | } 858 | }) 859 | if (!changes.changes!.empty) dispatch(state.update(changes, {userEvent: "indent"})) 860 | return true 861 | } 862 | 863 | /// Add a [unit](#language.indentUnit) of indentation to all selected 864 | /// lines. 865 | export const indentMore: StateCommand = ({state, dispatch}) => { 866 | if (state.readOnly) return false 867 | dispatch(state.update(changeBySelectedLine(state, (line, changes) => { 868 | changes.push({from: line.from, insert: state.facet(indentUnit)}) 869 | }), {userEvent: "input.indent"})) 870 | return true 871 | } 872 | 873 | /// Remove a [unit](#language.indentUnit) of indentation from all 874 | /// selected lines. 875 | export const indentLess: StateCommand = ({state, dispatch}) => { 876 | if (state.readOnly) return false 877 | dispatch(state.update(changeBySelectedLine(state, (line, changes) => { 878 | let space = /^\s*/.exec(line.text)![0] 879 | if (!space) return 880 | let col = countColumn(space, state.tabSize), keep = 0 881 | let insert = indentString(state, Math.max(0, col - getIndentUnit(state))) 882 | while (keep < space.length && keep < insert.length && space.charCodeAt(keep) == insert.charCodeAt(keep)) keep++ 883 | changes.push({from: line.from + keep, to: line.from + space.length, insert: insert.slice(keep)}) 884 | }), {userEvent: "delete.dedent"})) 885 | return true 886 | } 887 | 888 | /// Enables or disables 889 | /// [tab-focus mode](#view.EditorView.setTabFocusMode). While on, this 890 | /// prevents the editor's key bindings from capturing Tab or 891 | /// Shift-Tab, making it possible for the user to move focus out of 892 | /// the editor with the keyboard. 893 | export const toggleTabFocusMode: Command = view => { 894 | view.setTabFocusMode() 895 | return true 896 | } 897 | 898 | /// Temporarily enables [tab-focus 899 | /// mode](#view.EditorView.setTabFocusMode) for two seconds or until 900 | /// another key is pressed. 901 | export const temporarilySetTabFocusMode: Command = view => { 902 | view.setTabFocusMode(2000) 903 | return true 904 | } 905 | 906 | /// Insert a tab character at the cursor or, if something is selected, 907 | /// use [`indentMore`](#commands.indentMore) to indent the entire 908 | /// selection. 909 | export const insertTab: StateCommand = ({state, dispatch}) => { 910 | if (state.selection.ranges.some(r => !r.empty)) return indentMore({state, dispatch}) 911 | dispatch(state.update(state.replaceSelection("\t"), {scrollIntoView: true, userEvent: "input"})) 912 | return true 913 | } 914 | 915 | /// Array of key bindings containing the Emacs-style bindings that are 916 | /// available on macOS by default. 917 | /// 918 | /// - Ctrl-b: [`cursorCharLeft`](#commands.cursorCharLeft) ([`selectCharLeft`](#commands.selectCharLeft) with Shift) 919 | /// - Ctrl-f: [`cursorCharRight`](#commands.cursorCharRight) ([`selectCharRight`](#commands.selectCharRight) with Shift) 920 | /// - Ctrl-p: [`cursorLineUp`](#commands.cursorLineUp) ([`selectLineUp`](#commands.selectLineUp) with Shift) 921 | /// - Ctrl-n: [`cursorLineDown`](#commands.cursorLineDown) ([`selectLineDown`](#commands.selectLineDown) with Shift) 922 | /// - Ctrl-a: [`cursorLineStart`](#commands.cursorLineStart) ([`selectLineStart`](#commands.selectLineStart) with Shift) 923 | /// - Ctrl-e: [`cursorLineEnd`](#commands.cursorLineEnd) ([`selectLineEnd`](#commands.selectLineEnd) with Shift) 924 | /// - Ctrl-d: [`deleteCharForward`](#commands.deleteCharForward) 925 | /// - Ctrl-h: [`deleteCharBackward`](#commands.deleteCharBackward) 926 | /// - Ctrl-k: [`deleteToLineEnd`](#commands.deleteToLineEnd) 927 | /// - Ctrl-Alt-h: [`deleteGroupBackward`](#commands.deleteGroupBackward) 928 | /// - Ctrl-o: [`splitLine`](#commands.splitLine) 929 | /// - Ctrl-t: [`transposeChars`](#commands.transposeChars) 930 | /// - Ctrl-v: [`cursorPageDown`](#commands.cursorPageDown) 931 | /// - Alt-v: [`cursorPageUp`](#commands.cursorPageUp) 932 | export const emacsStyleKeymap: readonly KeyBinding[] = [ 933 | {key: "Ctrl-b", run: cursorCharLeft, shift: selectCharLeft, preventDefault: true}, 934 | {key: "Ctrl-f", run: cursorCharRight, shift: selectCharRight}, 935 | 936 | {key: "Ctrl-p", run: cursorLineUp, shift: selectLineUp}, 937 | {key: "Ctrl-n", run: cursorLineDown, shift: selectLineDown}, 938 | 939 | {key: "Ctrl-a", run: cursorLineStart, shift: selectLineStart}, 940 | {key: "Ctrl-e", run: cursorLineEnd, shift: selectLineEnd}, 941 | 942 | {key: "Ctrl-d", run: deleteCharForward}, 943 | {key: "Ctrl-h", run: deleteCharBackward}, 944 | {key: "Ctrl-k", run: deleteToLineEnd}, 945 | {key: "Ctrl-Alt-h", run: deleteGroupBackward}, 946 | 947 | {key: "Ctrl-o", run: splitLine}, 948 | {key: "Ctrl-t", run: transposeChars}, 949 | 950 | {key: "Ctrl-v", run: cursorPageDown}, 951 | ] 952 | 953 | /// An array of key bindings closely sticking to platform-standard or 954 | /// widely used bindings. (This includes the bindings from 955 | /// [`emacsStyleKeymap`](#commands.emacsStyleKeymap), with their `key` 956 | /// property changed to `mac`.) 957 | /// 958 | /// - ArrowLeft: [`cursorCharLeft`](#commands.cursorCharLeft) ([`selectCharLeft`](#commands.selectCharLeft) with Shift) 959 | /// - ArrowRight: [`cursorCharRight`](#commands.cursorCharRight) ([`selectCharRight`](#commands.selectCharRight) with Shift) 960 | /// - Ctrl-ArrowLeft (Alt-ArrowLeft on macOS): [`cursorGroupLeft`](#commands.cursorGroupLeft) ([`selectGroupLeft`](#commands.selectGroupLeft) with Shift) 961 | /// - Ctrl-ArrowRight (Alt-ArrowRight on macOS): [`cursorGroupRight`](#commands.cursorGroupRight) ([`selectGroupRight`](#commands.selectGroupRight) with Shift) 962 | /// - Cmd-ArrowLeft (on macOS): [`cursorLineStart`](#commands.cursorLineStart) ([`selectLineStart`](#commands.selectLineStart) with Shift) 963 | /// - Cmd-ArrowRight (on macOS): [`cursorLineEnd`](#commands.cursorLineEnd) ([`selectLineEnd`](#commands.selectLineEnd) with Shift) 964 | /// - ArrowUp: [`cursorLineUp`](#commands.cursorLineUp) ([`selectLineUp`](#commands.selectLineUp) with Shift) 965 | /// - ArrowDown: [`cursorLineDown`](#commands.cursorLineDown) ([`selectLineDown`](#commands.selectLineDown) with Shift) 966 | /// - Cmd-ArrowUp (on macOS): [`cursorDocStart`](#commands.cursorDocStart) ([`selectDocStart`](#commands.selectDocStart) with Shift) 967 | /// - Cmd-ArrowDown (on macOS): [`cursorDocEnd`](#commands.cursorDocEnd) ([`selectDocEnd`](#commands.selectDocEnd) with Shift) 968 | /// - Ctrl-ArrowUp (on macOS): [`cursorPageUp`](#commands.cursorPageUp) ([`selectPageUp`](#commands.selectPageUp) with Shift) 969 | /// - Ctrl-ArrowDown (on macOS): [`cursorPageDown`](#commands.cursorPageDown) ([`selectPageDown`](#commands.selectPageDown) with Shift) 970 | /// - PageUp: [`cursorPageUp`](#commands.cursorPageUp) ([`selectPageUp`](#commands.selectPageUp) with Shift) 971 | /// - PageDown: [`cursorPageDown`](#commands.cursorPageDown) ([`selectPageDown`](#commands.selectPageDown) with Shift) 972 | /// - Home: [`cursorLineBoundaryBackward`](#commands.cursorLineBoundaryBackward) ([`selectLineBoundaryBackward`](#commands.selectLineBoundaryBackward) with Shift) 973 | /// - End: [`cursorLineBoundaryForward`](#commands.cursorLineBoundaryForward) ([`selectLineBoundaryForward`](#commands.selectLineBoundaryForward) with Shift) 974 | /// - Ctrl-Home (Cmd-Home on macOS): [`cursorDocStart`](#commands.cursorDocStart) ([`selectDocStart`](#commands.selectDocStart) with Shift) 975 | /// - Ctrl-End (Cmd-Home on macOS): [`cursorDocEnd`](#commands.cursorDocEnd) ([`selectDocEnd`](#commands.selectDocEnd) with Shift) 976 | /// - Enter and Shift-Enter: [`insertNewlineAndIndent`](#commands.insertNewlineAndIndent) 977 | /// - Ctrl-a (Cmd-a on macOS): [`selectAll`](#commands.selectAll) 978 | /// - Backspace: [`deleteCharBackward`](#commands.deleteCharBackward) 979 | /// - Delete: [`deleteCharForward`](#commands.deleteCharForward) 980 | /// - Ctrl-Backspace (Alt-Backspace on macOS): [`deleteGroupBackward`](#commands.deleteGroupBackward) 981 | /// - Ctrl-Delete (Alt-Delete on macOS): [`deleteGroupForward`](#commands.deleteGroupForward) 982 | /// - Cmd-Backspace (macOS): [`deleteLineBoundaryBackward`](#commands.deleteLineBoundaryBackward). 983 | /// - Cmd-Delete (macOS): [`deleteLineBoundaryForward`](#commands.deleteLineBoundaryForward). 984 | export const standardKeymap: readonly KeyBinding[] = ([ 985 | {key: "ArrowLeft", run: cursorCharLeft, shift: selectCharLeft, preventDefault: true}, 986 | {key: "Mod-ArrowLeft", mac: "Alt-ArrowLeft", run: cursorGroupLeft, shift: selectGroupLeft, preventDefault: true}, 987 | {mac: "Cmd-ArrowLeft", run: cursorLineBoundaryLeft, shift: selectLineBoundaryLeft, preventDefault: true}, 988 | 989 | {key: "ArrowRight", run: cursorCharRight, shift: selectCharRight, preventDefault: true}, 990 | {key: "Mod-ArrowRight", mac: "Alt-ArrowRight", run: cursorGroupRight, shift: selectGroupRight, preventDefault: true}, 991 | {mac: "Cmd-ArrowRight", run: cursorLineBoundaryRight, shift: selectLineBoundaryRight, preventDefault: true}, 992 | 993 | {key: "ArrowUp", run: cursorLineUp, shift: selectLineUp, preventDefault: true}, 994 | {mac: "Cmd-ArrowUp", run: cursorDocStart, shift: selectDocStart}, 995 | {mac: "Ctrl-ArrowUp", run: cursorPageUp, shift: selectPageUp}, 996 | 997 | {key: "ArrowDown", run: cursorLineDown, shift: selectLineDown, preventDefault: true}, 998 | {mac: "Cmd-ArrowDown", run: cursorDocEnd, shift: selectDocEnd}, 999 | {mac: "Ctrl-ArrowDown", run: cursorPageDown, shift: selectPageDown}, 1000 | 1001 | {key: "PageUp", run: cursorPageUp, shift: selectPageUp}, 1002 | {key: "PageDown", run: cursorPageDown, shift: selectPageDown}, 1003 | 1004 | {key: "Home", run: cursorLineBoundaryBackward, shift: selectLineBoundaryBackward, preventDefault: true}, 1005 | {key: "Mod-Home", run: cursorDocStart, shift: selectDocStart}, 1006 | 1007 | {key: "End", run: cursorLineBoundaryForward, shift: selectLineBoundaryForward, preventDefault: true}, 1008 | {key: "Mod-End", run: cursorDocEnd, shift: selectDocEnd}, 1009 | 1010 | {key: "Enter", run: insertNewlineAndIndent, shift: insertNewlineAndIndent}, 1011 | 1012 | {key: "Mod-a", run: selectAll}, 1013 | 1014 | {key: "Backspace", run: deleteCharBackward, shift: deleteCharBackward}, 1015 | {key: "Delete", run: deleteCharForward}, 1016 | {key: "Mod-Backspace", mac: "Alt-Backspace", run: deleteGroupBackward}, 1017 | {key: "Mod-Delete", mac: "Alt-Delete", run: deleteGroupForward}, 1018 | {mac: "Mod-Backspace", run: deleteLineBoundaryBackward}, 1019 | {mac: "Mod-Delete", run: deleteLineBoundaryForward} 1020 | ] as KeyBinding[]).concat(emacsStyleKeymap.map(b => ({mac: b.key, run: b.run, shift: b.shift}))) 1021 | 1022 | /// The default keymap. Includes all bindings from 1023 | /// [`standardKeymap`](#commands.standardKeymap) plus the following: 1024 | /// 1025 | /// - Alt-ArrowLeft (Ctrl-ArrowLeft on macOS): [`cursorSyntaxLeft`](#commands.cursorSyntaxLeft) ([`selectSyntaxLeft`](#commands.selectSyntaxLeft) with Shift) 1026 | /// - Alt-ArrowRight (Ctrl-ArrowRight on macOS): [`cursorSyntaxRight`](#commands.cursorSyntaxRight) ([`selectSyntaxRight`](#commands.selectSyntaxRight) with Shift) 1027 | /// - Alt-ArrowUp: [`moveLineUp`](#commands.moveLineUp) 1028 | /// - Alt-ArrowDown: [`moveLineDown`](#commands.moveLineDown) 1029 | /// - Shift-Alt-ArrowUp: [`copyLineUp`](#commands.copyLineUp) 1030 | /// - Shift-Alt-ArrowDown: [`copyLineDown`](#commands.copyLineDown) 1031 | /// - Escape: [`simplifySelection`](#commands.simplifySelection) 1032 | /// - Ctrl-Enter (Cmd-Enter on macOS): [`insertBlankLine`](#commands.insertBlankLine) 1033 | /// - Alt-l (Ctrl-l on macOS): [`selectLine`](#commands.selectLine) 1034 | /// - Ctrl-i (Cmd-i on macOS): [`selectParentSyntax`](#commands.selectParentSyntax) 1035 | /// - Ctrl-[ (Cmd-[ on macOS): [`indentLess`](#commands.indentLess) 1036 | /// - Ctrl-] (Cmd-] on macOS): [`indentMore`](#commands.indentMore) 1037 | /// - Ctrl-Alt-\\ (Cmd-Alt-\\ on macOS): [`indentSelection`](#commands.indentSelection) 1038 | /// - Shift-Ctrl-k (Shift-Cmd-k on macOS): [`deleteLine`](#commands.deleteLine) 1039 | /// - Shift-Ctrl-\\ (Shift-Cmd-\\ on macOS): [`cursorMatchingBracket`](#commands.cursorMatchingBracket) 1040 | /// - Ctrl-/ (Cmd-/ on macOS): [`toggleComment`](#commands.toggleComment). 1041 | /// - Shift-Alt-a: [`toggleBlockComment`](#commands.toggleBlockComment). 1042 | /// - Ctrl-m (Alt-Shift-m on macOS): [`toggleTabFocusMode`](#commands.toggleTabFocusMode). 1043 | export const defaultKeymap: readonly KeyBinding[] = ([ 1044 | {key: "Alt-ArrowLeft", mac: "Ctrl-ArrowLeft", run: cursorSyntaxLeft, shift: selectSyntaxLeft}, 1045 | {key: "Alt-ArrowRight", mac: "Ctrl-ArrowRight", run: cursorSyntaxRight, shift: selectSyntaxRight}, 1046 | 1047 | {key: "Alt-ArrowUp", run: moveLineUp}, 1048 | {key: "Shift-Alt-ArrowUp", run: copyLineUp}, 1049 | 1050 | {key: "Alt-ArrowDown", run: moveLineDown}, 1051 | {key: "Shift-Alt-ArrowDown", run: copyLineDown}, 1052 | 1053 | {key: "Escape", run: simplifySelection}, 1054 | {key: "Mod-Enter", run: insertBlankLine}, 1055 | 1056 | {key: "Alt-l", mac: "Ctrl-l", run: selectLine}, 1057 | {key: "Mod-i", run: selectParentSyntax, preventDefault: true}, 1058 | 1059 | {key: "Mod-[", run: indentLess}, 1060 | {key: "Mod-]", run: indentMore}, 1061 | {key: "Mod-Alt-\\", run: indentSelection}, 1062 | 1063 | {key: "Shift-Mod-k", run: deleteLine}, 1064 | 1065 | {key: "Shift-Mod-\\", run: cursorMatchingBracket}, 1066 | 1067 | {key: "Mod-/", run: toggleComment}, 1068 | {key: "Alt-A", run: toggleBlockComment}, 1069 | 1070 | {key: "Ctrl-m", mac: "Shift-Alt-m", run: toggleTabFocusMode}, 1071 | ] as readonly KeyBinding[]).concat(standardKeymap) 1072 | 1073 | /// A binding that binds Tab to [`indentMore`](#commands.indentMore) and 1074 | /// Shift-Tab to [`indentLess`](#commands.indentLess). 1075 | /// Please see the [Tab example](../../examples/tab/) before using 1076 | /// this. 1077 | export const indentWithTab: KeyBinding = 1078 | {key: "Tab", run: indentMore, shift: indentLess} 1079 | -------------------------------------------------------------------------------- /src/comment.ts: -------------------------------------------------------------------------------- 1 | import {Line, EditorState, TransactionSpec, StateCommand} from "@codemirror/state" 2 | 3 | /// An object of this type can be provided as [language 4 | /// data](#state.EditorState.languageDataAt) under a `"commentTokens"` 5 | /// property to configure comment syntax for a language. 6 | export interface CommentTokens { 7 | /// The block comment syntax, if any. For example, for HTML 8 | /// you'd provide `{open: ""}`. 9 | block?: {open: string, close: string}, 10 | /// The line comment syntax. For example `"//"`. 11 | line?: string 12 | } 13 | 14 | /// Comment or uncomment the current selection. Will use line comments 15 | /// if available, otherwise falling back to block comments. 16 | export const toggleComment: StateCommand = target => { 17 | let {state} = target, line = state.doc.lineAt(state.selection.main.from), config = getConfig(target.state, line.from) 18 | return config.line ? toggleLineComment(target) : config.block ? toggleBlockCommentByLine(target) : false 19 | } 20 | 21 | const enum CommentOption { Toggle, Comment, Uncomment } 22 | 23 | function command(f: (option: CommentOption, state: EditorState) => TransactionSpec | null, 24 | option: CommentOption): StateCommand { 25 | return ({state, dispatch}) => { 26 | if (state.readOnly) return false 27 | let tr = f(option, state) 28 | if (!tr) return false 29 | dispatch(state.update(tr)) 30 | return true 31 | } 32 | } 33 | 34 | /// Comment or uncomment the current selection using line comments. 35 | /// The line comment syntax is taken from the 36 | /// [`commentTokens`](#commands.CommentTokens) [language 37 | /// data](#state.EditorState.languageDataAt). 38 | export const toggleLineComment = command(changeLineComment, CommentOption.Toggle) 39 | 40 | /// Comment the current selection using line comments. 41 | export const lineComment = command(changeLineComment, CommentOption.Comment) 42 | 43 | /// Uncomment the current selection using line comments. 44 | export const lineUncomment = command(changeLineComment, CommentOption.Uncomment) 45 | 46 | /// Comment or uncomment the current selection using block comments. 47 | /// The block comment syntax is taken from the 48 | /// [`commentTokens`](#commands.CommentTokens) [language 49 | /// data](#state.EditorState.languageDataAt). 50 | export const toggleBlockComment = command(changeBlockComment, CommentOption.Toggle) 51 | 52 | /// Comment the current selection using block comments. 53 | export const blockComment = command(changeBlockComment, CommentOption.Comment) 54 | 55 | /// Uncomment the current selection using block comments. 56 | export const blockUncomment = command(changeBlockComment, CommentOption.Uncomment) 57 | 58 | /// Comment or uncomment the lines around the current selection using 59 | /// block comments. 60 | export const toggleBlockCommentByLine = 61 | command((o, s) => changeBlockComment(o, s, selectedLineRanges(s)), CommentOption.Toggle) 62 | 63 | function getConfig(state: EditorState, pos: number) { 64 | let data = state.languageDataAt("commentTokens", pos, 1) 65 | return data.length ? data[0] : {} 66 | } 67 | 68 | type BlockToken = {open: string, close: string} 69 | 70 | type BlockComment = { 71 | open: {pos: number, margin: number}, 72 | close: {pos: number, margin: number} 73 | } 74 | 75 | const SearchMargin = 50 76 | 77 | /// Determines if the given range is block-commented in the given 78 | /// state. 79 | function findBlockComment( 80 | state: EditorState, {open, close}: BlockToken, from: number, to: number 81 | ): BlockComment | null { 82 | let textBefore = state.sliceDoc(from - SearchMargin, from) 83 | let textAfter = state.sliceDoc(to, to + SearchMargin) 84 | let spaceBefore = /\s*$/.exec(textBefore)![0].length, spaceAfter = /^\s*/.exec(textAfter)![0].length 85 | let beforeOff = textBefore.length - spaceBefore 86 | if (textBefore.slice(beforeOff - open.length, beforeOff) == open && 87 | textAfter.slice(spaceAfter, spaceAfter + close.length) == close) { 88 | return {open: {pos: from - spaceBefore, margin: spaceBefore && 1}, 89 | close: {pos: to + spaceAfter, margin: spaceAfter && 1}} 90 | } 91 | 92 | let startText: string, endText: string 93 | if (to - from <= 2 * SearchMargin) { 94 | startText = endText = state.sliceDoc(from, to) 95 | } else { 96 | startText = state.sliceDoc(from, from + SearchMargin) 97 | endText = state.sliceDoc(to - SearchMargin, to) 98 | } 99 | let startSpace = /^\s*/.exec(startText)![0].length, endSpace = /\s*$/.exec(endText)![0].length 100 | let endOff = endText.length - endSpace - close.length 101 | if (startText.slice(startSpace, startSpace + open.length) == open && 102 | endText.slice(endOff, endOff + close.length) == close) { 103 | return {open: {pos: from + startSpace + open.length, 104 | margin: /\s/.test(startText.charAt(startSpace + open.length)) ? 1 : 0}, 105 | close: {pos: to - endSpace - close.length, 106 | margin: /\s/.test(endText.charAt(endOff - 1)) ? 1 : 0}} 107 | } 108 | return null 109 | } 110 | 111 | function selectedLineRanges(state: EditorState) { 112 | let ranges: {from: number, to: number}[] = [] 113 | for (let r of state.selection.ranges) { 114 | let fromLine = state.doc.lineAt(r.from) 115 | let toLine = r.to <= fromLine.to ? fromLine : state.doc.lineAt(r.to) 116 | if (toLine.from > fromLine.from && toLine.from == r.to) 117 | toLine = r.to == fromLine.to + 1 ? fromLine : state.doc.lineAt(r.to - 1) 118 | let last = ranges.length - 1 119 | if (last >= 0 && ranges[last].to > fromLine.from) ranges[last].to = toLine.to 120 | else ranges.push({from: fromLine.from + /^\s*/.exec(fromLine.text)![0].length, to: toLine.to}) 121 | } 122 | return ranges 123 | } 124 | 125 | // Performs toggle, comment and uncomment of block comments in 126 | // languages that support them. 127 | function changeBlockComment( 128 | option: CommentOption, 129 | state: EditorState, 130 | ranges: readonly {from: number, to: number}[] = state.selection.ranges, 131 | ) { 132 | let tokens = ranges.map(r => getConfig(state, r.from).block) as {open: string, close: string}[] 133 | if (!tokens.every(c => c)) return null 134 | let comments = ranges.map((r, i) => findBlockComment(state, tokens[i], r.from, r.to)) 135 | if (option != CommentOption.Uncomment && !comments.every(c => c)) { 136 | return {changes: state.changes(ranges.map((range, i) => { 137 | if (comments[i]) return [] 138 | return [{from: range.from, insert: tokens[i].open + " "}, {from: range.to, insert: " " + tokens[i].close}] 139 | }))} 140 | } else if (option != CommentOption.Comment && comments.some(c => c)) { 141 | let changes = [] 142 | for (let i = 0, comment; i < comments.length; i++) if (comment = comments[i]) { 143 | let token = tokens[i], {open, close} = comment 144 | changes.push( 145 | {from: open.pos - token.open.length, to: open.pos + open.margin}, 146 | {from: close.pos - close.margin, to: close.pos + token.close.length} 147 | ) 148 | } 149 | return {changes} 150 | } 151 | return null 152 | } 153 | 154 | // Performs toggle, comment and uncomment of line comments. 155 | function changeLineComment( 156 | option: CommentOption, 157 | state: EditorState, 158 | ranges: readonly {from: number, to: number}[] = state.selection.ranges, 159 | ): TransactionSpec | null { 160 | let lines: {line: Line, token: string, comment: number, empty: boolean, indent: number, single: boolean}[] = [] 161 | let prevLine = -1 162 | for (let {from, to} of ranges) { 163 | let startI = lines.length, minIndent = 1e9 164 | let token = getConfig(state, from).line 165 | if (!token) continue 166 | for (let pos = from; pos <= to;) { 167 | let line = state.doc.lineAt(pos) 168 | if (line.from > prevLine && (from == to || to > line.from)) { 169 | prevLine = line.from 170 | let indent = /^\s*/.exec(line.text)![0].length 171 | let empty = indent == line.length 172 | let comment = line.text.slice(indent, indent + token.length) == token ? indent : -1 173 | if (indent < line.text.length && indent < minIndent) minIndent = indent 174 | lines.push({line, comment, token, indent, empty, single: false}) 175 | } 176 | pos = line.to + 1 177 | } 178 | if (minIndent < 1e9) for (let i = startI; i < lines.length; i++) 179 | if (lines[i].indent < lines[i].line.text.length) lines[i].indent = minIndent 180 | if (lines.length == startI + 1) lines[startI].single = true 181 | } 182 | 183 | if (option != CommentOption.Uncomment && lines.some(l => l.comment < 0 && (!l.empty || l.single))) { 184 | let changes = [] 185 | for (let {line, token, indent, empty, single} of lines) if (single || !empty) 186 | changes.push({from: line.from + indent, insert: token + " "}) 187 | let changeSet = state.changes(changes) 188 | return {changes: changeSet, selection: state.selection.map(changeSet, 1)} 189 | } else if (option != CommentOption.Comment && lines.some(l => l.comment >= 0)) { 190 | let changes = [] 191 | for (let {line, comment, token} of lines) if (comment >= 0) { 192 | let from = line.from + comment, to = from + token.length 193 | if (line.text[to - line.from] == " ") to++ 194 | changes.push({from, to}) 195 | } 196 | return {changes} 197 | } 198 | return null 199 | } 200 | -------------------------------------------------------------------------------- /src/history.ts: -------------------------------------------------------------------------------- 1 | import {combineConfig, EditorState, Transaction, StateField, StateCommand, StateEffect, 2 | Facet, Annotation, Extension, ChangeSet, ChangeDesc, EditorSelection} from "@codemirror/state" 3 | import {KeyBinding, EditorView} from "@codemirror/view" 4 | 5 | const enum BranchName { Done, Undone } 6 | 7 | const fromHistory = Annotation.define<{side: BranchName, rest: Branch, selection: EditorSelection}>() 8 | 9 | /// Transaction annotation that will prevent that transaction from 10 | /// being combined with other transactions in the undo history. Given 11 | /// `"before"`, it'll prevent merging with previous transactions. With 12 | /// `"after"`, subsequent transactions won't be combined with this 13 | /// one. With `"full"`, the transaction is isolated on both sides. 14 | export const isolateHistory = Annotation.define<"before" | "after" | "full">() 15 | 16 | /// This facet provides a way to register functions that, given a 17 | /// transaction, provide a set of effects that the history should 18 | /// store when inverting the transaction. This can be used to 19 | /// integrate some kinds of effects in the history, so that they can 20 | /// be undone (and redone again). 21 | export const invertedEffects = Facet.define<(tr: Transaction) => readonly StateEffect[]>() 22 | 23 | interface HistoryConfig { 24 | /// The minimum depth (amount of events) to store. Defaults to 100. 25 | minDepth?: number 26 | /// The maximum time (in milliseconds) that adjacent events can be 27 | /// apart and still be grouped together. Defaults to 500. 28 | newGroupDelay?: number 29 | /// By default, when close enough together in time, changes are 30 | /// joined into an existing undo event if they touch any of the 31 | /// changed ranges from that event. You can pass a custom predicate 32 | /// here to influence that logic. 33 | joinToEvent?: (tr: Transaction, isAdjacent: boolean) => boolean 34 | } 35 | 36 | const historyConfig = Facet.define>({ 37 | combine(configs) { 38 | return combineConfig(configs, { 39 | minDepth: 100, 40 | newGroupDelay: 500, 41 | joinToEvent: (_t, isAdjacent) => isAdjacent, 42 | }, { 43 | minDepth: Math.max, 44 | newGroupDelay: Math.min, 45 | joinToEvent: (a, b) => (tr, adj) => a(tr, adj) || b(tr, adj) 46 | }) 47 | } 48 | }) 49 | 50 | const historyField_ = StateField.define({ 51 | create() { 52 | return HistoryState.empty 53 | }, 54 | 55 | update(state: HistoryState, tr: Transaction): HistoryState { 56 | let config = tr.state.facet(historyConfig) 57 | 58 | let fromHist = tr.annotation(fromHistory) 59 | if (fromHist) { 60 | let item = HistEvent.fromTransaction(tr, fromHist.selection), from = fromHist.side 61 | let other = from == BranchName.Done ? state.undone : state.done 62 | if (item) other = updateBranch(other, other.length, config.minDepth, item) 63 | else other = addSelection(other, tr.startState.selection) 64 | return new HistoryState(from == BranchName.Done ? fromHist.rest : other, 65 | from == BranchName.Done ? other : fromHist.rest) 66 | } 67 | 68 | let isolate = tr.annotation(isolateHistory) 69 | if (isolate == "full" || isolate == "before") state = state.isolate() 70 | 71 | if (tr.annotation(Transaction.addToHistory) === false) 72 | return !tr.changes.empty ? state.addMapping(tr.changes.desc) : state 73 | 74 | let event = HistEvent.fromTransaction(tr) 75 | let time = tr.annotation(Transaction.time)!, userEvent = tr.annotation(Transaction.userEvent) 76 | if (event) 77 | state = state.addChanges(event, time, userEvent, config, tr) 78 | else if (tr.selection) 79 | state = state.addSelection(tr.startState.selection, time, userEvent, config.newGroupDelay) 80 | 81 | if (isolate == "full" || isolate == "after") state = state.isolate() 82 | return state 83 | }, 84 | 85 | toJSON(value) { 86 | return {done: value.done.map(e => e.toJSON()), undone: value.undone.map(e => e.toJSON())} 87 | }, 88 | 89 | fromJSON(json) { 90 | return new HistoryState(json.done.map(HistEvent.fromJSON), json.undone.map(HistEvent.fromJSON)) 91 | } 92 | }) 93 | 94 | /// Create a history extension with the given configuration. 95 | export function history(config: HistoryConfig = {}): Extension { 96 | return [ 97 | historyField_, 98 | historyConfig.of(config), 99 | EditorView.domEventHandlers({ 100 | beforeinput(e, view) { 101 | let command = e.inputType == "historyUndo" ? undo : e.inputType == "historyRedo" ? redo : null 102 | if (!command) return false 103 | e.preventDefault() 104 | return command(view) 105 | } 106 | }) 107 | ] 108 | } 109 | 110 | /// The state field used to store the history data. Should probably 111 | /// only be used when you want to 112 | /// [serialize](#state.EditorState.toJSON) or 113 | /// [deserialize](#state.EditorState^fromJSON) state objects in a way 114 | /// that preserves history. 115 | export const historyField = historyField_ as StateField 116 | 117 | function cmd(side: BranchName, selection: boolean): StateCommand { 118 | return function({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}) { 119 | if (!selection && state.readOnly) return false 120 | let historyState = state.field(historyField_, false) 121 | if (!historyState) return false 122 | let tr = historyState.pop(side, state, selection) 123 | if (!tr) return false 124 | dispatch(tr) 125 | return true 126 | } 127 | } 128 | 129 | /// Undo a single group of history events. Returns false if no group 130 | /// was available. 131 | export const undo = cmd(BranchName.Done, false) 132 | /// Redo a group of history events. Returns false if no group was 133 | /// available. 134 | export const redo = cmd(BranchName.Undone, false) 135 | 136 | /// Undo a change or selection change. 137 | export const undoSelection = cmd(BranchName.Done, true) 138 | 139 | /// Redo a change or selection change. 140 | export const redoSelection = cmd(BranchName.Undone, true) 141 | 142 | function depth(side: BranchName) { 143 | return function(state: EditorState): number { 144 | let histState = state.field(historyField_, false) 145 | if (!histState) return 0 146 | let branch = side == BranchName.Done ? histState.done : histState.undone 147 | return branch.length - (branch.length && !branch[0].changes ? 1 : 0) 148 | } 149 | } 150 | 151 | /// The amount of undoable change events available in a given state. 152 | export const undoDepth = depth(BranchName.Done) 153 | /// The amount of redoable change events available in a given state. 154 | export const redoDepth = depth(BranchName.Undone) 155 | 156 | // History events store groups of changes or effects that need to be 157 | // undone/redone together. 158 | class HistEvent { 159 | constructor( 160 | // The changes in this event. Normal events hold at least one 161 | // change or effect. But it may be necessary to store selection 162 | // events before the first change, in which case a special type of 163 | // instance is created which doesn't hold any changes, with 164 | // changes == startSelection == undefined 165 | readonly changes: ChangeSet | undefined, 166 | // The effects associated with this event 167 | readonly effects: readonly StateEffect[], 168 | // Accumulated mapping (from addToHistory==false) that should be 169 | // applied to events below this one. 170 | readonly mapped: ChangeDesc | undefined, 171 | // The selection before this event 172 | readonly startSelection: EditorSelection | undefined, 173 | // Stores selection changes after this event, to be used for 174 | // selection undo/redo. 175 | readonly selectionsAfter: readonly EditorSelection[] 176 | ) {} 177 | 178 | setSelAfter(after: readonly EditorSelection[]) { 179 | return new HistEvent(this.changes, this.effects, this.mapped, this.startSelection, after) 180 | } 181 | 182 | toJSON() { 183 | return { 184 | changes: this.changes?.toJSON(), 185 | mapped: this.mapped?.toJSON(), 186 | startSelection: this.startSelection?.toJSON(), 187 | selectionsAfter: this.selectionsAfter.map(s => s.toJSON()) 188 | } 189 | } 190 | 191 | static fromJSON(json: any) { 192 | return new HistEvent( 193 | json.changes && ChangeSet.fromJSON(json.changes), 194 | [], 195 | json.mapped && ChangeDesc.fromJSON(json.mapped), 196 | json.startSelection && EditorSelection.fromJSON(json.startSelection), 197 | json.selectionsAfter.map(EditorSelection.fromJSON) 198 | ) 199 | } 200 | 201 | // This does not check `addToHistory` and such, it assumes the 202 | // transaction needs to be converted to an item. Returns null when 203 | // there are no changes or effects in the transaction. 204 | static fromTransaction(tr: Transaction, selection?: EditorSelection) { 205 | let effects: readonly StateEffect[] = none 206 | for (let invert of tr.startState.facet(invertedEffects)) { 207 | let result = invert(tr) 208 | if (result.length) effects = effects.concat(result) 209 | } 210 | if (!effects.length && tr.changes.empty) return null 211 | return new HistEvent(tr.changes.invert(tr.startState.doc), effects, undefined, selection || tr.startState.selection, none) 212 | } 213 | 214 | static selection(selections: readonly EditorSelection[]) { 215 | return new HistEvent(undefined, none, undefined, undefined, selections) 216 | } 217 | } 218 | 219 | type Branch = readonly HistEvent[] 220 | 221 | function updateBranch(branch: Branch, to: number, maxLen: number, newEvent: HistEvent) { 222 | let start = to + 1 > maxLen + 20 ? to - maxLen - 1 : 0 223 | let newBranch = branch.slice(start, to) 224 | newBranch.push(newEvent) 225 | return newBranch 226 | } 227 | 228 | function isAdjacent(a: ChangeDesc, b: ChangeDesc): boolean { 229 | let ranges: number[] = [], isAdjacent = false 230 | a.iterChangedRanges((f, t) => ranges.push(f, t)) 231 | b.iterChangedRanges((_f, _t, f, t) => { 232 | for (let i = 0; i < ranges.length;) { 233 | let from = ranges[i++], to = ranges[i++] 234 | if (t >= from && f <= to) isAdjacent = true 235 | } 236 | }) 237 | return isAdjacent 238 | } 239 | 240 | function eqSelectionShape(a: EditorSelection, b: EditorSelection) { 241 | return a.ranges.length == b.ranges.length && 242 | a.ranges.filter((r, i) => r.empty != b.ranges[i].empty).length === 0 243 | } 244 | 245 | function conc(a: readonly T[], b: readonly T[]) { 246 | return !a.length ? b : !b.length ? a : a.concat(b) 247 | } 248 | 249 | const none: readonly any[] = [] 250 | 251 | const MaxSelectionsPerEvent = 200 252 | 253 | function addSelection(branch: Branch, selection: EditorSelection) { 254 | if (!branch.length) { 255 | return [HistEvent.selection([selection])] 256 | } else { 257 | let lastEvent = branch[branch.length - 1] 258 | let sels = lastEvent.selectionsAfter.slice(Math.max(0, lastEvent.selectionsAfter.length - MaxSelectionsPerEvent)) 259 | if (sels.length && sels[sels.length - 1].eq(selection)) return branch 260 | sels.push(selection) 261 | return updateBranch(branch, branch.length - 1, 1e9, lastEvent.setSelAfter(sels)) 262 | } 263 | } 264 | 265 | // Assumes the top item has one or more selectionAfter values 266 | function popSelection(branch: Branch): Branch { 267 | let last = branch[branch.length - 1] 268 | let newBranch = branch.slice() 269 | newBranch[branch.length - 1] = last.setSelAfter(last.selectionsAfter.slice(0, last.selectionsAfter.length - 1)) 270 | return newBranch 271 | } 272 | 273 | // Add a mapping to the top event in the given branch. If this maps 274 | // away all the changes and effects in that item, drop it and 275 | // propagate the mapping to the next item. 276 | function addMappingToBranch(branch: Branch, mapping: ChangeDesc) { 277 | if (!branch.length) return branch 278 | let length = branch.length, selections = none 279 | while (length) { 280 | let event = mapEvent(branch[length - 1], mapping, selections) 281 | if (event.changes && !event.changes.empty || event.effects.length) { // Event survived mapping 282 | let result = branch.slice(0, length) 283 | result[length - 1] = event 284 | return result 285 | } else { // Drop this event, since there's no changes or effects left 286 | mapping = event.mapped! 287 | length-- 288 | selections = event.selectionsAfter 289 | } 290 | } 291 | return selections.length ? [HistEvent.selection(selections)] : none 292 | } 293 | 294 | function mapEvent(event: HistEvent, mapping: ChangeDesc, 295 | extraSelections: readonly EditorSelection[]) { 296 | let selections = conc(event.selectionsAfter.length ? event.selectionsAfter.map(s => s.map(mapping)) : none, 297 | extraSelections) 298 | // Change-less events don't store mappings (they are always the last event in a branch) 299 | if (!event.changes) return HistEvent.selection(selections) 300 | 301 | let mappedChanges = event.changes.map(mapping), before = mapping.mapDesc(event.changes, true) 302 | let fullMapping = event.mapped ? event.mapped.composeDesc(before) : before 303 | return new HistEvent(mappedChanges, StateEffect.mapEffects(event.effects, mapping), 304 | fullMapping, event.startSelection!.map(before), selections) 305 | } 306 | 307 | const joinableUserEvent = /^(input\.type|delete)($|\.)/ 308 | 309 | class HistoryState { 310 | constructor(public readonly done: Branch, 311 | public readonly undone: Branch, 312 | private readonly prevTime: number = 0, 313 | private readonly prevUserEvent: string | undefined = undefined) {} 314 | 315 | isolate() { 316 | return this.prevTime ? new HistoryState(this.done, this.undone) : this 317 | } 318 | 319 | addChanges(event: HistEvent, time: number, userEvent: string | undefined, 320 | config: Required, tr: Transaction): HistoryState { 321 | let done = this.done, lastEvent = done[done.length - 1] 322 | if (lastEvent && lastEvent.changes && !lastEvent.changes.empty && event.changes && 323 | (!userEvent || joinableUserEvent.test(userEvent)) && 324 | ((!lastEvent.selectionsAfter.length && 325 | time - this.prevTime < config.newGroupDelay && 326 | config.joinToEvent(tr, isAdjacent(lastEvent.changes, event.changes))) || 327 | // For compose (but not compose.start) events, always join with previous event 328 | userEvent == "input.type.compose")) { 329 | done = updateBranch(done, done.length - 1, config.minDepth, 330 | new HistEvent(event.changes.compose(lastEvent.changes), 331 | conc(StateEffect.mapEffects(event.effects, lastEvent.changes), lastEvent.effects), 332 | lastEvent.mapped, lastEvent.startSelection, none)) 333 | } else { 334 | done = updateBranch(done, done.length, config.minDepth, event) 335 | } 336 | return new HistoryState(done, none, time, userEvent) 337 | } 338 | 339 | addSelection(selection: EditorSelection, time: number, userEvent: string | undefined, newGroupDelay: number) { 340 | let last = this.done.length ? this.done[this.done.length - 1].selectionsAfter : none 341 | if (last.length > 0 && 342 | time - this.prevTime < newGroupDelay && 343 | userEvent == this.prevUserEvent && userEvent && /^select($|\.)/.test(userEvent) && 344 | eqSelectionShape(last[last.length - 1], selection)) 345 | return this 346 | return new HistoryState(addSelection(this.done, selection), this.undone, time, userEvent) 347 | } 348 | 349 | addMapping(mapping: ChangeDesc): HistoryState { 350 | return new HistoryState(addMappingToBranch(this.done, mapping), 351 | addMappingToBranch(this.undone, mapping), 352 | this.prevTime, this.prevUserEvent) 353 | } 354 | 355 | pop(side: BranchName, state: EditorState, onlySelection: boolean): Transaction | null { 356 | let branch = side == BranchName.Done ? this.done : this.undone 357 | if (branch.length == 0) return null 358 | let event = branch[branch.length - 1], selection = event.selectionsAfter[0] || state.selection 359 | if (onlySelection && event.selectionsAfter.length) { 360 | return state.update({ 361 | selection: event.selectionsAfter[event.selectionsAfter.length - 1], 362 | annotations: fromHistory.of({side, rest: popSelection(branch), selection}), 363 | userEvent: side == BranchName.Done ? "select.undo" : "select.redo", 364 | scrollIntoView: true 365 | }) 366 | } else if (!event.changes) { 367 | return null 368 | } else { 369 | let rest = branch.length == 1 ? none : branch.slice(0, branch.length - 1) 370 | if (event.mapped) rest = addMappingToBranch(rest, event.mapped!) 371 | return state.update({ 372 | changes: event.changes, 373 | selection: event.startSelection, 374 | effects: event.effects, 375 | annotations: fromHistory.of({side, rest, selection}), 376 | filter: false, 377 | userEvent: side == BranchName.Done ? "undo" : "redo", 378 | scrollIntoView: true 379 | }) 380 | } 381 | } 382 | 383 | static empty: HistoryState = new HistoryState(none, none) 384 | } 385 | 386 | /// Default key bindings for the undo history. 387 | /// 388 | /// - Mod-z: [`undo`](#commands.undo). 389 | /// - Mod-y (Mod-Shift-z on macOS) + Ctrl-Shift-z on Linux: [`redo`](#commands.redo). 390 | /// - Mod-u: [`undoSelection`](#commands.undoSelection). 391 | /// - Alt-u (Mod-Shift-u on macOS): [`redoSelection`](#commands.redoSelection). 392 | export const historyKeymap: readonly KeyBinding[] = [ 393 | {key: "Mod-z", run: undo, preventDefault: true}, 394 | {key: "Mod-y", mac: "Mod-Shift-z", run: redo, preventDefault: true}, 395 | {linux: "Ctrl-Shift-z", run: redo, preventDefault: true}, 396 | {key: "Mod-u", run: undoSelection, preventDefault: true}, 397 | {key: "Alt-u", mac: "Mod-Shift-u", run: redoSelection, preventDefault: true} 398 | ] 399 | -------------------------------------------------------------------------------- /test/state.ts: -------------------------------------------------------------------------------- 1 | import {EditorState, EditorSelection, Extension} from "@codemirror/state" 2 | 3 | export function mkState(doc: string, extensions: Extension = []) { 4 | let range = /\||<([^]*?)>/g, m 5 | let ranges = [] 6 | while (m = range.exec(doc)) { 7 | if (m[1]) { 8 | ranges.push(EditorSelection.range(m.index, m.index + m[1].length)) 9 | doc = doc.slice(0, m.index) + doc.slice(m.index + 1, m.index + 1 + m[1].length) + doc.slice(m.index + m[0].length) 10 | range.lastIndex -= 2 11 | } else { 12 | ranges.push(EditorSelection.cursor(m.index)) 13 | doc = doc.slice(0, m.index) + doc.slice(m.index + 1) 14 | range.lastIndex-- 15 | } 16 | } 17 | return EditorState.create({ 18 | doc, 19 | selection: ranges.length ? EditorSelection.create(ranges) : undefined, 20 | extensions: [extensions, EditorState.allowMultipleSelections.of(true)] 21 | }) 22 | } 23 | 24 | export function stateStr(state: EditorState) { 25 | let doc = state.doc.toString() 26 | for (let i = state.selection.ranges.length - 1; i >= 0; i--) { 27 | let range = state.selection.ranges[i] 28 | if (range.empty) 29 | doc = doc.slice(0, range.from) + "|" + doc.slice(range.from) 30 | else 31 | doc = doc.slice(0, range.from) + "<" + doc.slice(range.from, range.to) + ">" + doc.slice(range.to) 32 | } 33 | return doc 34 | } 35 | -------------------------------------------------------------------------------- /test/test-commands.ts: -------------------------------------------------------------------------------- 1 | import {EditorState, StateCommand, Extension} from "@codemirror/state" 2 | import {indentMore, indentLess, indentSelection, 3 | insertNewlineAndIndent, insertNewlineKeepIndent, 4 | deleteTrailingWhitespace, deleteGroupForward, deleteGroupBackward, 5 | moveLineUp, moveLineDown} from "@codemirror/commands" 6 | import {javascriptLanguage} from "@codemirror/lang-javascript" 7 | import {indentUnit} from "@codemirror/language" 8 | import ist from "ist" 9 | import {mkState, stateStr} from "./state.js" 10 | 11 | function cmd(state: EditorState, command: StateCommand) { 12 | command({state, dispatch(tr) { state = tr.state }}) 13 | return state 14 | } 15 | 16 | describe("commands", () => { 17 | describe("indentMore", () => { 18 | function test(from: string, to: string) { ist(stateStr(cmd(mkState(from), indentMore)), to) } 19 | 20 | it("adds indentation", () => 21 | test("one\ntwo|\nthree", "one\n two|\nthree")) 22 | 23 | it("indents all lines in a range", () => 24 | test("one\n", "one\n ")) 25 | 26 | it("doesn't double-indent a given line", () => 27 | test("on|e|\n", " on|e|\n ")) 28 | 29 | it("ignores lines if a range selection ends directly at their start", () => 30 | test("onthree", " onthree")) 31 | }) 32 | 33 | describe("indentLess", () => { 34 | function test(from: string, to: string) { ist(stateStr(cmd(mkState(from), indentLess)), to) } 35 | 36 | it("removes indentation", () => 37 | test("one\n two|\nthree", "one\ntwo|\nthree")) 38 | 39 | it("removes one unit of indentation", () => 40 | test("one\n two|\n three|", "one\n two|\n three|")) 41 | 42 | it("dedents all lines in a range", () => 43 | test("one\n ", "one\n")) 44 | 45 | it("takes tabs into account", () => 46 | test(" \tone|\n \ttwo|", " one|\n two|")) 47 | 48 | it("can split tabs", () => 49 | test("\tone|", " one|")) 50 | }) 51 | 52 | describe("indentSelection", () => { 53 | function test(from: string, to: string) { 54 | ist(stateStr(cmd(mkState(from, javascriptLanguage), indentSelection)), to) 55 | } 56 | 57 | it("auto-indents the current line", () => 58 | test("if (0)\nfoo()|", "if (0)\n foo()|")) 59 | 60 | it("moves the cursor ahead of the indentation", () => 61 | test("if (0)\n | foo()", "if (0)\n |foo()")) 62 | 63 | it("indents blocks of lines", () => 64 | test("if (0) {\n\n}", "if (0) {\n \n}")) 65 | 66 | it("includes previous indentation changes in relative indentation", () => 67 | test("<{\n{\n{\n{}\n}\n}\n}>", "<{\n {\n {\n {}\n }\n }\n}>")) 68 | }) 69 | 70 | describe("insertNewlineKeepIndent", () => { 71 | function test(from: string, to: string) { 72 | ist(stateStr(cmd(mkState(from), insertNewlineKeepIndent)), to) 73 | } 74 | 75 | it("keeps indentation", () => 76 | test(" one|", " one\n |")) 77 | 78 | it("keeps zero indentation", () => 79 | test("one|two", "one\n|two")) 80 | 81 | it("deletes the selection", () => 82 | test("if x:\n onefour", "if x:\n one\n |four")) 83 | }) 84 | 85 | describe("insertNewlineAndIndent", () => { 86 | function test(from: string, to: string) { 87 | ist(stateStr(cmd(mkState(from, javascriptLanguage), insertNewlineAndIndent)), to) 88 | } 89 | 90 | it("indents the new line", () => 91 | test("{|", "{\n |")) 92 | 93 | it("can handle multiple selections", () => 94 | test("{|\n foo()|", "{\n |\n foo()\n |")) 95 | 96 | it("isn't confused by text after the cursor", () => 97 | test("{|two", "{\n |two")) 98 | 99 | it("clears empty lines before the cursor", () => 100 | test(" |", "\n|")) 101 | 102 | it("deletes selected text", () => 103 | test("{two", "{\n |two")) 104 | 105 | it("can explode brackets", () => 106 | test("let x = [|]", "let x = [\n |\n]")) 107 | 108 | it("can explode in indented positions", () => 109 | test("{\n foo(|)", "{\n foo(\n |\n )")) 110 | 111 | it("can explode brackets with whitespace", () => 112 | test("foo( | )", "foo(\n |\n)")) 113 | 114 | it("doesn't try to explode already-exploded brackets", () => 115 | test("foo(\n |\n)", "foo(\n\n |\n)")) 116 | 117 | function testIndentationFromPrevLine(from: string, to: string, ext: Extension = []) { 118 | ist(stateStr(cmd(mkState(from, ext), insertNewlineAndIndent)), to) 119 | } 120 | 121 | it("doesn't indent when previous line lacks indentation", () => 122 | testIndentationFromPrevLine("foo|", "foo\n|")) 123 | 124 | it("indents when previous line uses two space indentation", () => 125 | testIndentationFromPrevLine(" foo|", " foo\n |")) 126 | 127 | it("indents when previous line uses four space indentation", () => 128 | testIndentationFromPrevLine(" foo|", " foo\n |")) 129 | 130 | it("indents when previous line uses tab indentation", () => 131 | testIndentationFromPrevLine("\tfoo|", "\tfoo\n\t|", indentUnit.of("\t"))) 132 | 133 | it("indents when previous line uses tab indentation and short alignment", () => 134 | testIndentationFromPrevLine("\t foo|", "\t foo\n\t |", indentUnit.of("\t"))) 135 | }) 136 | 137 | describe("deleteTrailingWhitespace", () => { 138 | function test(from: string, to: string) { 139 | ist(cmd(mkState(from), deleteTrailingWhitespace).doc.toString(), to) 140 | } 141 | 142 | it("deletes trailing whitespace", () => 143 | test("foo ", "foo")) 144 | 145 | it("checks multiple lines", () => 146 | test("one\ntwo \nthree \n ", "one\ntwo\nthree\n")) 147 | 148 | it("can handle empty lines", () => 149 | test("one \n\ntwo ", "one\n\ntwo")) 150 | }) 151 | 152 | describe("deleteGroupForward", () => { 153 | function test(from: string, to: string) { 154 | ist(stateStr(cmd(mkState(from), deleteGroupForward)), to) 155 | } 156 | 157 | it("deletes a word", () => 158 | test("one |two three", "one | three")) 159 | 160 | it("deletes a word with leading space", () => 161 | test("one| two three", "one| three")) 162 | 163 | it("deletes a group of punctuation", () => 164 | test("one|...two", "one|two")) 165 | 166 | it("deletes a group of space", () => 167 | test("one| \ttwo", "one|two")) 168 | 169 | it("deletes a newline", () => 170 | test("one|\ntwo", "one|two")) 171 | 172 | it("stops deleting at a newline", () => 173 | test("one| \n two", "one|\n two")) 174 | 175 | it("stops deleting after a newline", () => 176 | test("one|\n two", "one| two")) 177 | 178 | it("deletes up to the end of the doc", () => 179 | test("one|two", "one|")) 180 | 181 | it("does nothing at the end of the doc", () => 182 | test("one|", "one|")) 183 | }) 184 | 185 | describe("deleteGroupBackward", () => { 186 | function test(from: string, to: string) { 187 | ist(stateStr(cmd(mkState(from), deleteGroupBackward)), to) 188 | } 189 | 190 | it("deletes a word", () => 191 | test("one two| three", "one | three")) 192 | 193 | it("deletes a word with trailing space", () => 194 | test("one two |three", "one |three")) 195 | 196 | it("deletes a group of punctuation", () => 197 | test("one...|two", "one|two")) 198 | 199 | it("deletes a group of space", () => 200 | test("one \t |two", "one|two")) 201 | 202 | it("deletes a newline", () => 203 | test("one\n|two", "one|two")) 204 | 205 | it("stops deleting at a newline", () => 206 | test("one \n |two", "one \n|two")) 207 | 208 | it("stops deleting after a newline", () => 209 | test("one \n|two", "one |two")) 210 | 211 | it("deletes up to the start of the doc", () => 212 | test("one|two", "|two")) 213 | }) 214 | 215 | describe("moveLineUp", () => { 216 | function test(from: string, to: string) { 217 | ist(stateStr(cmd(mkState(from), moveLineUp)), to) 218 | } 219 | 220 | it("can move a line up", () => 221 | test("one\ntwo|\nthree", "two|\none\nthree")) 222 | 223 | it("preserves multiple cursors on a single line", () => 224 | test("one\nt|w|o|\n", "t|w|o|\none\n")) 225 | 226 | it("moves selected blocks as one", () => 227 | test("one\ntwo\nthr\n", "one\nthr\ntwo\n")) 228 | 229 | it("moves blocks made of multiple ranges as one", () => 230 | test("one\nree\nfo|u\n", "ree\nfo|u\none\n")) 231 | 232 | it("does not include a trailing line after a range", () => 233 | test("one\nfour", "one\nfour")) 234 | }) 235 | 236 | describe("moveLineDown", () => { 237 | function test(from: string, to: string) { 238 | ist(stateStr(cmd(mkState(from), moveLineDown)), to) 239 | } 240 | 241 | it("can move a line own", () => 242 | test("one\ntwo|\nthree", "one\nthree\ntwo|")) 243 | 244 | it("preserves multiple cursors on a single line", () => 245 | test("one\nt|w|o|\nthree", "one\nthree\nt|w|o|")) 246 | 247 | it("moves selected blocks as one", () => 248 | test("one\ntwo\nthr\nsix", "one\ntwo\nsix\nthr")) 249 | 250 | it("moves blocks made of multiple ranges as one", () => 251 | test("one\nree\nfo|u\nsix\n", "one\nsix\nree\nfo|u\n")) 252 | 253 | it("does not include a trailing line after a range", () => 254 | test("one\nfour\n", "one\nfour\n")) 255 | 256 | it("clips the selection when moving to the end of the doc", () => 257 | test("one\nfour", "one\nfour\n")) 258 | }) 259 | }) 260 | -------------------------------------------------------------------------------- /test/test-comment.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {SelectionRange, EditorState, EditorSelection, Extension, StateCommand} from "@codemirror/state" 3 | import {toggleLineComment, CommentTokens, toggleBlockComment, toggleBlockCommentByLine} from "@codemirror/commands" 4 | import {htmlLanguage} from "@codemirror/lang-html" 5 | 6 | describe("comment", () => { 7 | const defaultConfig: CommentTokens = {line: "//", block: {open: "/*", close: "*/"}} 8 | 9 | /// Creates a new `EditorState` using `doc` as the document text. 10 | /// The selection ranges in the returned state can be specified 11 | /// within the `doc` argument: 12 | /// The character `|` is used a marker to indicate both the 13 | /// start and the end of a `SelectionRange`, *e.g.*, 14 | /// 15 | /// ```typescript 16 | /// s("line 1\nlin|e 2\nline 3") 17 | /// ``` 18 | function s(doc: string, config: CommentTokens = defaultConfig, extensions: readonly Extension[] = []): EditorState { 19 | let markers = [], pos 20 | while ((pos = doc.indexOf("|", 0)) >= 0) { 21 | markers.push(pos) 22 | doc = doc.slice(0, pos) + doc.slice(pos + 1) 23 | } 24 | 25 | const ranges: SelectionRange[] = [] 26 | if (markers.length == 1) { 27 | ranges.push(EditorSelection.cursor(markers[0])) 28 | } else if (markers.length % 2 != 0) { 29 | throw "Markers for multiple selections need to be even."; 30 | } else { 31 | for (let i = 0; i < markers.length; i += 2) 32 | ranges.push(EditorSelection.range(markers[i], markers[i + 1])) 33 | if (ranges.length == 0) ranges.push(EditorSelection.cursor(0)) 34 | } 35 | 36 | return EditorState.create({ 37 | doc, 38 | selection: EditorSelection.create(ranges), 39 | extensions: [EditorState.allowMultipleSelections.of(true), 40 | EditorState.languageData.of(() => [{commentTokens: config}])].concat(extensions) 41 | }) 42 | } 43 | 44 | function same(actualState: EditorState, expectedState: EditorState) { 45 | ist(actualState.doc.toString(), expectedState.doc.toString()) 46 | ist(JSON.stringify(actualState.selection), JSON.stringify(expectedState.selection)) 47 | } 48 | 49 | function checkToggleChain(toggle: StateCommand, config: CommentTokens, docs: string[]) { 50 | let state = s(docs[0], config) 51 | for (let i = 1; i <= docs.length; i++) { 52 | toggle({state, dispatch(tr) { state = tr.state }}) 53 | same(state, s(docs[i == docs.length ? docs.length - 2 : i], config)) 54 | } 55 | } 56 | 57 | // Runs all tests for the given line-comment token, `k`. 58 | function runLineCommentTests(k: string) { 59 | function check(...docs: string[]) { 60 | checkToggleChain(toggleLineComment, {line: k}, docs) 61 | } 62 | 63 | describe(`Line comments ('${k}')`, () => { 64 | it("toggles in an empty single selection", () => { 65 | check(`\nline 1\n ${k} ${k} ${k} ${k}line| 2\nline 3\n`, 66 | `\nline 1\n ${k} ${k} ${k}line| 2\nline 3\n`, 67 | `\nline 1\n ${k} ${k}line| 2\nline 3\n`, 68 | `\nline 1\n ${k}line| 2\nline 3\n`, 69 | `\nline 1\n line| 2\nline 3\n`, 70 | `\nline 1\n ${k} line| 2\nline 3\n`) 71 | 72 | check(`\nline 1\n ${k}line 2|\nline 3\n`, 73 | `\nline 1\n line 2|\nline 3\n`, 74 | `\nline 1\n ${k} line 2|\nline 3\n`) 75 | 76 | check(`\nline 1\n| ${k}line 2\nline 3\n`, 77 | `\nline 1\n| line 2\nline 3\n`, 78 | `\nline 1\n| ${k} line 2\nline 3\n`) 79 | 80 | check(`\nline 1\n|${k}\nline 3\n`, 81 | `\nline 1\n|\nline 3\n`, 82 | `\nline 1\n${k} |\nline 3\n`) 83 | 84 | check(`\nline 1\n line 2\nline 3\n|${k}`, 85 | `\nline 1\n line 2\nline 3\n|`, 86 | `\nline 1\n line 2\nline 3\n${k} |`) 87 | }) 88 | 89 | it("toggles comments in a single line when the cursor is at the beginning", () => { 90 | check(`line 1\n |line 2\nline 3\n`, 91 | `line 1\n ${k} |line 2\nline 3\n`) 92 | }) 93 | 94 | it("toggles comments in a single line selection", () => { 95 | check(`line 1\n ${k}li|ne |2\nline 3\n`, 96 | `line 1\n li|ne |2\nline 3\n`, 97 | `line 1\n ${k} li|ne |2\nline 3\n`) 98 | }) 99 | 100 | it("toggles comments in a multi-line selection", () => { 101 | check(`\n ${k}lin|e 1\n ${k} line 2\n ${k} line |3\n`, 102 | `\n lin|e 1\n line 2\n line |3\n`, 103 | `\n ${k} lin|e 1\n ${k} line 2\n ${k} line |3\n`) 104 | 105 | check(`\n ${k}lin|e 1\n ${k} line 2\n line 3\n ${k} li|ne 4\n`, 106 | `\n ${k} ${k}lin|e 1\n ${k} ${k} line 2\n ${k} line 3\n ${k} ${k} li|ne 4\n`) 107 | 108 | check(`\n ${k} lin|e 1\n\n ${k} line |3\n`, 109 | `\n lin|e 1\n\n line |3\n`) 110 | 111 | check(`\n ${k} lin|e 1\n \n ${k} line |3\n`, 112 | `\n lin|e 1\n \n line |3\n`) 113 | 114 | check(`\n|\n ${k} line 2\n | \n`, 115 | `\n|\n line 2\n | \n`) 116 | 117 | check(`\n|\n\n | \n`, 118 | `\n|\n\n | \n`) 119 | }) 120 | 121 | it("toggles comments in a multi-line multi-range selection", () => { 122 | check(`\n lin|e 1\n line |2\n line 3\n l|ine 4\n line| 5\n`, 123 | `\n ${k} lin|e 1\n ${k} line |2\n line 3\n ${k} l|ine 4\n ${k} line| 5\n`) 124 | }) 125 | 126 | it("can handle multiple selections on one line", () => { 127 | check(`|line| |with| |ranges|`, 128 | `${k} |line| |with| |ranges|`) 129 | }) 130 | 131 | it("doesn't include lines in which a selection range ends", () => { 132 | check(`line| 1\nline 2\n|line 3`, 133 | `${k} line| 1\n${k} line 2\n|line 3`) 134 | }) 135 | 136 | it("leaves empty lines alone", () => { 137 | check(`line| 1\n\nline 3|`, 138 | `${k} line| 1\n\n${k} line 3|`) 139 | }) 140 | 141 | it("does comment empty lines with a cursor", () => { 142 | check(`|\nline 2`, 143 | `${k} |\nline 2`) 144 | }) 145 | }) 146 | } 147 | 148 | /// Runs all tests for the given block-comment tokens. 149 | function runBlockCommentTests(o: string, c: string) { 150 | describe(`Block comments ('${o} ${c}')`, () => { 151 | function check(...docs: string[]) { 152 | checkToggleChain(toggleBlockComment, {block: {open: o, close: c}}, docs) 153 | } 154 | function checkLine(...docs: string[]) { 155 | checkToggleChain(toggleBlockCommentByLine, {block: {open: o, close: c}}, docs) 156 | } 157 | 158 | it("toggles block comment in multi-line selection", () => { 159 | check(`\n lin|e 1\n line 2\n line 3\n line |4\n line 5\n`, 160 | `\n lin${o} |e 1\n line 2\n line 3\n line | ${c}4\n line 5\n`) 161 | }) 162 | 163 | it("toggles block comment in multi-line multi-range selection", () => { 164 | check(`\n lin|e 1\n line |2\n l|ine 3\n line 4\n line |5\n`, 165 | `\n lin${o} |e 1\n line | ${c}2\n l${o} |ine 3\n line 4\n line | ${c}5\n`) 166 | }) 167 | 168 | it("can toggle comments inside the selection", () => { 169 | check(`|${o} one\ntwo ${c}| three`, 170 | `|one\ntwo| three`, 171 | `${o} |one\ntwo| ${c} three`) 172 | }) 173 | 174 | it("comments the entire line", () => { 175 | checkLine(`one|\ntwo`, 176 | `${o} one| ${c}\ntwo`) 177 | }) 178 | 179 | it("comments multiple lines", () => { 180 | checkLine(`on|e\nt|wo`, 181 | `${o} on|e\nt|wo ${c}`) 182 | }) 183 | 184 | it("joins selected blocks of lines", () => { 185 | checkLine(`on|e\nt|w|o\nth|ree`, 186 | `${o} on|e\nt|w|o\nth|ree ${c}`) 187 | }) 188 | 189 | it("doesn't include lines that the selection stops at the start of", () => { 190 | checkLine(`|one\n|two`, `${o} |one ${c}\n|two`) 191 | }) 192 | 193 | it("does include lines with cursor selection at the start", () => { 194 | checkLine(`|one\ntwo`, `|${o} one ${c}\ntwo`) 195 | }) 196 | }) 197 | } 198 | 199 | runLineCommentTests("//") 200 | 201 | runLineCommentTests("#") 202 | 203 | runBlockCommentTests("/*", "*/") 204 | 205 | runBlockCommentTests("") 206 | 207 | it("toggles line comment in multi-language doc", () => { 208 | let state = s(` 212 | `, undefined, [htmlLanguage]) 213 | 214 | toggleLineComment({state, dispatch(tr) { state = tr.state }}) 215 | same(state, s(` 219 | `)) 220 | }) 221 | }) 222 | -------------------------------------------------------------------------------- /test/test-history.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | 3 | import {EditorState, EditorSelection, Transaction, 4 | StateEffect, StateEffectType, StateField, ChangeDesc} from "@codemirror/state" 5 | import {isolateHistory, history, redo, redoDepth, redoSelection, undo, undoDepth, 6 | undoSelection, invertedEffects, historyField} from "@codemirror/commands" 7 | 8 | function mkState(config?: any, doc?: string) { 9 | return EditorState.create({ 10 | extensions: [history(config), EditorState.allowMultipleSelections.of(true)], 11 | doc 12 | }) 13 | } 14 | 15 | function type(state: EditorState, text: string, at = state.doc.length) { 16 | return state.update({changes: {from: at, insert: text}}).state 17 | } 18 | function timedType(state: EditorState, text: string, atTime: number) { 19 | return state.update({changes: {from: state.doc.length, insert: text}, 20 | annotations: Transaction.time.of(atTime)}).state 21 | } 22 | function receive(state: EditorState, text: string, from: number, to = from) { 23 | return state.update({changes: {from, to, insert: text}, 24 | annotations: Transaction.addToHistory.of(false)}).state 25 | } 26 | function command(state: EditorState, cmd: any, success: boolean = true) { 27 | ist(cmd({state, dispatch(tr: Transaction) { state = tr.state }}), success) 28 | return state 29 | } 30 | 31 | describe("history", () => { 32 | it("allows to undo a change", () => { 33 | let state = mkState() 34 | state = type(state, "newtext") 35 | state = command(state, undo) 36 | ist(state.doc.toString(), "") 37 | }) 38 | 39 | it("allows to undo nearby changes in one change", () => { 40 | let state = mkState() 41 | state = type(state, "new") 42 | state = type(state, "text") 43 | state = command(state, undo) 44 | ist(state.doc.toString(), "") 45 | }) 46 | 47 | it("allows to redo a change", () => { 48 | let state = mkState() 49 | state = type(state, "newtext") 50 | state = command(state, undo) 51 | state = command(state, redo) 52 | ist(state.doc.toString(), "newtext") 53 | }) 54 | 55 | it("allows to redo nearby changes in one change", () => { 56 | let state = mkState() 57 | state = type(state, "new") 58 | state = type(state, "text") 59 | state = command(state, undo) 60 | state = command(state, redo) 61 | ist(state.doc.toString(), "newtext") 62 | }) 63 | 64 | it("puts the cursor after the change on redo", () => { 65 | let state = mkState({}, "one\n\ntwo") 66 | state = state.update({changes: {from: 3, insert: "!"}, selection: {anchor: 4}}).state 67 | state = state.update({selection: {anchor: state.doc.length}}).state 68 | state = command(state, undo) 69 | state = command(state, redo) 70 | ist(state.selection.main.head, 4) 71 | }) 72 | 73 | it("tracks multiple levels of history", () => { 74 | let state = mkState({}, "one") 75 | state = type(state, "new") 76 | state = type(state, "text") 77 | state = type(state, "some", 0) 78 | ist(state.doc.toString(), "someonenewtext") 79 | state = command(state, undo) 80 | ist(state.doc.toString(), "onenewtext") 81 | state = command(state, undo) 82 | ist(state.doc.toString(), "one") 83 | state = command(state, redo) 84 | ist(state.doc.toString(), "onenewtext") 85 | state = command(state, redo) 86 | ist(state.doc.toString(), "someonenewtext") 87 | state = command(state, undo) 88 | ist(state.doc.toString(), "onenewtext") 89 | }) 90 | 91 | it("starts a new event when newGroupDelay elapses", () => { 92 | let state = mkState({newGroupDelay: 1000}) 93 | state = timedType(state, "a", 1000) 94 | state = timedType(state, "b", 1600) 95 | ist(undoDepth(state), 1) 96 | state = timedType(state, "c", 2700) 97 | ist(undoDepth(state), 2) 98 | state = command(state, undo) 99 | state = timedType(state, "d", 2800) 100 | ist(undoDepth(state), 2) 101 | }) 102 | 103 | it("supports a custom join predicate", () => { 104 | let state = mkState({joinToEvent: (tr: Transaction, adj: boolean) => { 105 | if (!adj) return false 106 | let space = false 107 | if (adj) tr.changes.iterChanges((fA, tA, fB, tB, text) => { 108 | if (text.length && text.sliceString(0, 1) == " ") space = true 109 | }) 110 | return !space 111 | }}) 112 | for (let ch of "ab cd") state = type(state, ch) 113 | ist(state.sliceDoc(), "ab cd") 114 | state = command(state, undo) 115 | ist(state.sliceDoc(), "ab") 116 | state = command(state, undo) 117 | ist(state.sliceDoc(), "") 118 | }) 119 | 120 | it("allows changes that aren't part of the history", () => { 121 | let state = mkState() 122 | state = type(state, "hello") 123 | state = receive(state, "oops", 0) 124 | state = receive(state, "!", 9) 125 | state = command(state, undo) 126 | ist(state.doc.toString(), "oops!") 127 | }) 128 | 129 | it("doesn't get confused by an undo not adding any redo item", () => { 130 | let state = mkState({}, "ab") 131 | state = type(state, "cd", 1) 132 | state = receive(state, "123", 0, 4) 133 | state = command(state, undo, false) 134 | command(state, redo, false) 135 | }) 136 | 137 | it("accurately maps changes through each other", () => { 138 | let state = mkState({}, "123") 139 | state = state.update({ 140 | changes: [{from: 0, to: 1, insert: "ab"}, {from: 1, to: 2, insert: "cd"}, {from: 2, to: 3, insert: "ef"}] 141 | }).state 142 | state = receive(state, "!!!!!!!!", 2, 2) 143 | state = command(state, undo) 144 | state = command(state, redo) 145 | ist(state.doc.toString(), "ab!!!!!!!!cdef") 146 | }) 147 | 148 | it("can handle complex editing sequences", () => { 149 | let state = mkState() 150 | state = type(state, "hello") 151 | state = state.update({annotations: isolateHistory.of("before")}).state 152 | state = type(state, "!") 153 | state = receive(state, "....", 0) 154 | state = type(state, "\n\n", 2) 155 | ist(state.doc.toString(), "..\n\n..hello!") 156 | state = receive(state, "\n\n", 1) 157 | state = command(state, undo) 158 | state = command(state, undo) 159 | ist(state.doc.toString(), ".\n\n...hello") 160 | state = command(state, undo) 161 | ist(state.doc.toString(), ".\n\n...") 162 | }) 163 | 164 | it("supports overlapping edits", () => { 165 | let state = mkState() 166 | state = type(state, "hello") 167 | state = state.update({annotations: isolateHistory.of("before")}).state 168 | state = state.update({changes: {from: 0, to: 5}}).state 169 | ist(state.doc.toString(), "") 170 | state = command(state, undo) 171 | ist(state.doc.toString(), "hello") 172 | state = command(state, undo) 173 | ist(state.doc.toString(), "") 174 | }) 175 | 176 | it("supports overlapping edits that aren't collapsed", () => { 177 | let state = mkState() 178 | state = receive(state, "h", 0) 179 | state = type(state, "ello") 180 | state = state.update({annotations: isolateHistory.of("before")}).state 181 | state = state.update({changes: {from: 0, to: 5}}).state 182 | ist(state.doc.toString(), "") 183 | state = command(state, undo) 184 | ist(state.doc.toString(), "hello") 185 | state = command(state, undo) 186 | ist(state.doc.toString(), "h") 187 | }) 188 | 189 | it("supports overlapping unsynced deletes", () => { 190 | let state = mkState() 191 | state = type(state, "hi") 192 | state = state.update({annotations: isolateHistory.of("before")}).state 193 | state = type(state, "hello") 194 | state = state.update({changes: {from: 0, to: 7}, annotations: Transaction.addToHistory.of(false)}).state 195 | ist(state.doc.toString(), "") 196 | state = command(state, undo, false) 197 | ist(state.doc.toString(), "") 198 | }) 199 | 200 | it("can go back and forth through history multiple times", () => { 201 | let state = mkState() 202 | state = type(state, "one") 203 | state = type(state, " two") 204 | state = state.update({annotations: isolateHistory.of("before")}).state 205 | state = type(state, " three") 206 | state = type(state, "zero ", 0) 207 | state = state.update({annotations: isolateHistory.of("before")}).state 208 | state = type(state, "\n\n", 0) 209 | state = type(state, "top", 0) 210 | for (let i = 0; i < 6; i++) { 211 | let re = i % 2 212 | for (let j = 0; j < 4; j++) state = command(state, re ? redo : undo) 213 | ist(state.doc.toString(), re ? "top\n\nzero one two three" : "") 214 | } 215 | }) 216 | 217 | it("supports non-tracked changes next to tracked changes", () => { 218 | let state = mkState() 219 | state = type(state, "o") 220 | state = type(state, "\n\n", 0) 221 | state = receive(state, "zzz", 3) 222 | state = command(state, undo) 223 | ist(state.doc.toString(), "zzz") 224 | }) 225 | 226 | it("can go back and forth through history when preserving items", () => { 227 | let state = mkState() 228 | state = type(state, "one") 229 | state = type(state, " two") 230 | state = state.update({annotations: isolateHistory.of("before")}).state 231 | state = receive(state, "xxx", state.doc.length) 232 | state = type(state, " three") 233 | state = type(state, "zero ", 0) 234 | state = state.update({annotations: isolateHistory.of("before")}).state 235 | state = type(state, "\n\n", 0) 236 | state = type(state, "top", 0) 237 | state = receive(state, "yyy", 0) 238 | for (let i = 0; i < 3; i++) { 239 | for (let j = 0; j < 4; j++) state = command(state, undo) 240 | ist(state.doc.toString(), "yyyxxx") 241 | for (let j = 0; j < 4; j++) state = command(state, redo) 242 | ist(state.doc.toString(), "yyytop\n\nzero one twoxxx three") 243 | } 244 | }) 245 | 246 | it("restores selection on undo", () => { 247 | let state = mkState() 248 | state = type(state, "hi") 249 | state = state.update({annotations: isolateHistory.of("before")}).state 250 | state = state.update({selection: {anchor: 0, head: 2}}).state 251 | const selection = state.selection 252 | state = state.update(state.replaceSelection("hello")).state 253 | const selection2 = state.selection 254 | state = command(state, undo) 255 | ist(state.selection.eq(selection)) 256 | state = command(state, redo) 257 | ist(state.selection.eq(selection2)) 258 | }) 259 | 260 | it("restores the selection before the first change in an item (#46)", () => { 261 | let state = mkState() 262 | state = state.update({changes: {from: 0, insert: "a"}, selection: {anchor: 1}}).state 263 | state = state.update({changes: {from: 1, insert: "b"}, selection: {anchor: 2}}).state 264 | state = command(state, undo) 265 | ist(state.doc.toString(), "") 266 | ist(state.selection.main.anchor, 0) 267 | }) 268 | 269 | it("doesn't merge document changes if there's a selection change in between", () => { 270 | let state = mkState() 271 | state = type(state, "hi") 272 | state = state.update({selection: {anchor: 0, head: 2}}).state 273 | state = state.update(state.replaceSelection("hello")).state 274 | ist(undoDepth(state), 2) 275 | }) 276 | 277 | it("rebases selection on undo", () => { 278 | let state = mkState() 279 | state = type(state, "hi") 280 | state = state.update({annotations: isolateHistory.of("before")}).state 281 | state = state.update({selection: {anchor: 0, head: 2}}).state 282 | state = type(state, "hello", 0) 283 | state = receive(state, "---", 0) 284 | state = command(state, undo) 285 | ist(state.selection.ranges[0].head, 5) 286 | }) 287 | 288 | it("supports querying for the undo and redo depth", () => { 289 | let state = mkState() 290 | state = type(state, "a") 291 | ist(undoDepth(state), 1) 292 | ist(redoDepth(state), 0) 293 | state = receive(state, "b", 0) 294 | ist(undoDepth(state), 1) 295 | ist(redoDepth(state), 0) 296 | state = command(state, undo) 297 | ist(undoDepth(state), 0) 298 | ist(redoDepth(state), 1) 299 | state = command(state, redo) 300 | ist(undoDepth(state), 1) 301 | ist(redoDepth(state), 0) 302 | }) 303 | 304 | it("all functions gracefully handle EditorStates without history", () => { 305 | let state = EditorState.create() 306 | ist(undoDepth(state), 0) 307 | ist(redoDepth(state), 0) 308 | command(state, undo, false) 309 | command(state, redo, false) 310 | }) 311 | 312 | it("truncates history", () => { 313 | let state = mkState({minDepth: 10}) 314 | for (let i = 0; i < 40; ++i) { 315 | state = type(state, "a") 316 | state = state.update({annotations: isolateHistory.of("before")}).state 317 | } 318 | ist(undoDepth(state) < 40) 319 | }) 320 | 321 | it("doesn't undo selection-only transactions", () => { 322 | let state = mkState(undefined, "abc") 323 | ist(state.selection.main.head, 0) 324 | state = state.update({selection: {anchor: 2}}).state 325 | state = command(state, undo, false) 326 | ist(state.selection.main.head, 2) 327 | }) 328 | 329 | it("isolates transactions when asked to", () => { 330 | let state = mkState() 331 | state = state.update({changes: {from: 0, insert: "a"}, annotations: isolateHistory.of("after")}).state 332 | state = state.update({changes: {from: 1, insert: "a"}}).state 333 | state = state.update({changes: {from: 2, insert: "c"}, annotations: isolateHistory.of("after")}).state 334 | state = state.update({changes: {from: 3, insert: "d"}}).state 335 | state = state.update({changes: {from: 4, insert: "e"}, annotations: isolateHistory.of("full")}).state 336 | state = state.update({changes: {from: 5, insert: "f"}}).state 337 | ist(undoDepth(state), 5) 338 | }) 339 | 340 | it("can group events around a non-history transaction", () => { 341 | let state = mkState() 342 | state = state.update({changes: {from: 0, insert: "a"}}).state 343 | state = state.update({changes: {from: 1, insert: "b"}, annotations: Transaction.addToHistory.of(false)}).state 344 | state = state.update({changes: {from: 1, insert: "c"}}).state 345 | state = command(state, undo) 346 | ist(state.doc.toString(), "b") 347 | }) 348 | 349 | it("properly maps selections through non-history changes", () => { 350 | let state = mkState({}, "abc") 351 | state = state.update({selection: EditorSelection.create([EditorSelection.cursor(0), 352 | EditorSelection.cursor(1), 353 | EditorSelection.cursor(2)])}).state 354 | state = state.update({changes: {from: 0, to: 3, insert: "d"}}).state 355 | state = state.update({changes: [{from: 0, insert: "x"}, {from: 1, insert: "y"}], 356 | annotations: Transaction.addToHistory.of(false)}).state 357 | state = command(state, undo) 358 | ist(state.doc.toString(), "xabcy") 359 | ist(state.selection.ranges.map(r => r.from).join(","), "0,2,3") 360 | }) 361 | 362 | it("restores selection on redo", () => { 363 | let state = mkState({}, "a\nb\nc\n") 364 | state = state.update({selection: EditorSelection.create([1, 3, 5].map(n => EditorSelection.cursor(n)))}).state 365 | state = state.update(state.replaceSelection("-")).state 366 | state = state.update({selection: {anchor: 0}}).state 367 | state = command(state, undo) 368 | state = state.update({selection: {anchor: 0}}).state 369 | state = command(state, redo) 370 | ist(state.selection.ranges.map(r => r.head).join(","), "2,5,8") 371 | }) 372 | 373 | describe("undoSelection", () => { 374 | it("allows to undo a change", () => { 375 | let state = mkState() 376 | state = type(state, "newtext") 377 | state = command(state, undoSelection) 378 | ist(state.doc.toString(), "") 379 | }) 380 | 381 | it("allows to undo selection-only transactions", () => { 382 | let state = mkState(undefined, "abc") 383 | ist(state.selection.main.head, 0) 384 | state = state.update({selection: {anchor: 2}}).state 385 | state = command(state, undoSelection) 386 | ist(state.selection.main.head, 0) 387 | }) 388 | 389 | it("merges selection-only transactions from keyboard", () => { 390 | let state = mkState(undefined, "abc") 391 | ist(state.selection.main.head, 0) 392 | state = state.update({selection: {anchor: 2}, userEvent: "select"}).state 393 | state = state.update({selection: {anchor: 3}, userEvent: "select"}).state 394 | state = state.update({selection: {anchor: 1}, userEvent: "select"}).state 395 | state = command(state, undoSelection) 396 | ist(state.selection.main.head, 0) 397 | }) 398 | 399 | it("doesn't merge selection-only transactions from other sources", () => { 400 | let state = mkState(undefined, "abc") 401 | ist(state.selection.main.head, 0) 402 | state = state.update({selection: {anchor: 2}}).state 403 | state = state.update({selection: {anchor: 3}}).state 404 | state = state.update({selection: {anchor: 1}}).state 405 | state = command(state, undoSelection) 406 | ist(state.selection.main.head, 3) 407 | state = command(state, undoSelection) 408 | ist(state.selection.main.head, 2) 409 | state = command(state, undoSelection) 410 | ist(state.selection.main.head, 0) 411 | }) 412 | 413 | it("doesn't merge selection-only transactions if they change the number of selections", () => { 414 | let state = mkState(undefined, "abc") 415 | ist(state.selection.main.head, 0) 416 | state = state.update({selection: {anchor: 2}, userEvent: "select"}).state 417 | state = state.update({selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(3)]), 418 | userEvent: "select"}).state 419 | state = state.update({selection: {anchor: 1}, userEvent: "select"}).state 420 | state = command(state, undoSelection) 421 | ist(state.selection.ranges.length, 2) 422 | state = command(state, undoSelection) 423 | ist(state.selection.main.head, 0) 424 | }) 425 | 426 | it("doesn't merge selection-only transactions if a selection changes empty state", () => { 427 | let state = mkState(undefined, "abc") 428 | ist(state.selection.main.head, 0) 429 | state = state.update({selection: {anchor: 2}, userEvent: "select"}).state 430 | state = state.update({selection: {anchor: 2, head: 3}, userEvent: "select"}).state 431 | state = state.update({selection: {anchor: 1}, userEvent: "select"}).state 432 | state = command(state, undoSelection) 433 | ist(state.selection.main.anchor, 2) 434 | ist(state.selection.main.head, 3) 435 | state = command(state, undoSelection) 436 | ist(state.selection.main.head, 0) 437 | }) 438 | 439 | it("allows to redo a change", () => { 440 | let state = mkState() 441 | state = type(state, "newtext") 442 | state = command(state, undoSelection) 443 | state = command(state, redoSelection) 444 | ist(state.doc.toString(), "newtext") 445 | }) 446 | 447 | it("allows to redo selection-only transactions", () => { 448 | let state = mkState(undefined, "abc") 449 | ist(state.selection.main.head, 0) 450 | state = state.update({selection: {anchor: 2}}).state 451 | state = command(state, undoSelection) 452 | state = command(state, redoSelection) 453 | ist(state.selection.main.head, 2) 454 | }) 455 | 456 | it("only changes selection", () => { 457 | let state = mkState() 458 | state = type(state, "hi") 459 | state = state.update({annotations: isolateHistory.of("before")}).state 460 | const selection = state.selection 461 | state = state.update({selection: {anchor: 0, head: 2}}).state 462 | const selection2 = state.selection 463 | state = command(state, undoSelection) 464 | ist(state.selection.eq(selection)) 465 | ist(state.doc.toString(), "hi") 466 | state = command(state, redoSelection) 467 | ist(state.selection.eq(selection2)) 468 | state = state.update(state.replaceSelection("hello")).state 469 | const selection3 = state.selection 470 | state = command(state, undoSelection) 471 | ist(state.selection.eq(selection2)) 472 | state = command(state, redo) 473 | ist(state.selection.eq(selection3)) 474 | }) 475 | 476 | it("can undo a selection through remote changes", () => { 477 | let state = mkState() 478 | state = type(state, "hello") 479 | const selection = state.selection 480 | state = state.update({selection: {anchor: 0, head: 2}}).state 481 | state = receive(state, "oops", 0) 482 | state = receive(state, "!", 9) 483 | ist(state.selection.eq(EditorSelection.single(4, 6))) 484 | state = command(state, undoSelection) 485 | ist(state.doc.toString(), "oopshello!") 486 | ist(state.selection.eq(selection)) 487 | }) 488 | 489 | it("preserves text inserted inside a change", () => { 490 | let state = mkState() 491 | state = type(state, "1234") 492 | state = state.update({changes: {from: 2, insert: "x"}, annotations: Transaction.addToHistory.of(false)}).state 493 | state = command(state, undo) 494 | ist(state.doc.toString(), "x") 495 | }) 496 | }) 497 | 498 | describe("effects", () => { 499 | it("includes inverted effects in the history", () => { 500 | let set = StateEffect.define() 501 | let field = StateField.define({ 502 | create: () => 0, 503 | update(val, tr) { 504 | for (let effect of tr.effects) if (effect.is(set)) val = effect.value 505 | return val 506 | } 507 | }) 508 | let invert = invertedEffects.of(tr => { 509 | for (let e of tr.effects) if (e.is(set)) return [set.of(tr.startState.field(field))] 510 | return [] 511 | }) 512 | let state = EditorState.create({extensions: [history(), field, invert]}) 513 | state = state.update({effects: set.of(10), annotations: isolateHistory.of("before")}).state 514 | state = state.update({effects: set.of(20), annotations: isolateHistory.of("before")}).state 515 | ist(state.field(field), 20) 516 | state = command(state, undo) 517 | ist(state.field(field), 10) 518 | state = command(state, undo) 519 | ist(state.field(field), 0) 520 | state = command(state, redo) 521 | ist(state.field(field), 10) 522 | state = command(state, redo) 523 | ist(state.field(field), 20) 524 | state = command(state, undo) 525 | ist(state.field(field), 10) 526 | state = command(state, redo) 527 | ist(state.field(field), 20) 528 | }) 529 | 530 | class Comment { 531 | constructor(readonly from: number, 532 | readonly to: number, 533 | readonly text: string) {} 534 | 535 | eq(other: Comment) { return this.from == other.from && this.to == other.to && this.text == other.text } 536 | } 537 | function mapComment(comment: Comment, mapping: ChangeDesc) { 538 | let from = mapping.mapPos(comment.from, 1), to = mapping.mapPos(comment.to, -1) 539 | return from >= to ? undefined : new Comment(from, to, comment.text) 540 | } 541 | let addComment: StateEffectType = StateEffect.define({map: mapComment}) 542 | let rmComment: StateEffectType = StateEffect.define({map: mapComment}) 543 | let comments = StateField.define({ 544 | create: () => [], 545 | update(value, tr) { 546 | value = value.map(c => mapComment(c, tr.changes)).filter(x => x) as any 547 | for (let effect of tr.effects) { 548 | if (effect.is(addComment)) value = value.concat(effect.value) 549 | else if (effect.is(rmComment)) value = value.filter(c => !c.eq(effect.value)) 550 | } 551 | return value.sort((a, b) => a.from - b.from) 552 | } 553 | }) 554 | let invertComments = invertedEffects.of(tr => { 555 | let effects = [] 556 | for (let effect of tr.effects) { 557 | if (effect.is(addComment) || effect.is(rmComment)) { 558 | let src = mapComment(effect.value, tr.changes.invertedDesc) 559 | if (src) effects.push((effect.is(addComment) ? rmComment : addComment).of(src)) 560 | } 561 | } 562 | for (let comment of tr.startState.field(comments)) { 563 | if (!mapComment(comment, tr.changes)) effects.push(addComment.of(comment)) 564 | } 565 | return effects 566 | }) 567 | 568 | function commentStr(state: EditorState) { return state.field(comments).map(c => c.text + "@" + c.from).join(",") } 569 | 570 | it("can map effects", () => { 571 | let state = EditorState.create({extensions: [history(), comments, invertComments], 572 | doc: "one two foo"}) 573 | state = state.update({effects: addComment.of(new Comment(0, 3, "c1")), 574 | annotations: isolateHistory.of("full")}).state 575 | ist(commentStr(state), "c1@0") 576 | state = state.update({changes: {from: 3, to: 4, insert: "---"}, 577 | annotations: isolateHistory.of("full"), 578 | effects: addComment.of(new Comment(6, 9, "c2"))}).state 579 | ist(commentStr(state), "c1@0,c2@6") 580 | state = state.update({changes: {from: 0, insert: "---"}, annotations: Transaction.addToHistory.of(false)}).state 581 | ist(commentStr(state), "c1@3,c2@9") 582 | state = command(state, undo) 583 | ist(state.doc.toString(), "---one two foo") 584 | ist(commentStr(state), "c1@3") 585 | state = command(state, undo) 586 | ist(commentStr(state), "") 587 | state = command(state, redo) 588 | ist(commentStr(state), "c1@3") 589 | state = command(state, redo) 590 | ist(commentStr(state), "c1@3,c2@9") 591 | ist(state.doc.toString(), "---one---two foo") 592 | state = command(state, undo).update({changes: {from: 10, to: 11, insert: "---"}, 593 | annotations: Transaction.addToHistory.of(false)}).state 594 | state = state.update({effects: addComment.of(new Comment(13, 16, "c3")), 595 | annotations: isolateHistory.of("full")}).state 596 | ist(commentStr(state), "c1@3,c3@13") 597 | state = command(state, undo) 598 | ist(state.doc.toString(), "---one two---foo") 599 | ist(commentStr(state), "c1@3") 600 | state = command(state, redo) 601 | ist(commentStr(state), "c1@3,c3@13") 602 | }) 603 | 604 | it("can restore comments lost through deletion", () => { 605 | let state = EditorState.create({extensions: [history(), comments, invertComments], 606 | doc: "123456"}) 607 | state = state.update({effects: addComment.of(new Comment(3, 5, "c1")), 608 | annotations: isolateHistory.of("full")}).state 609 | state = state.update({changes: {from: 2, to: 6}}).state 610 | ist(commentStr(state), "") 611 | state = command(state, undo) 612 | ist(commentStr(state), "c1@3") 613 | }) 614 | }) 615 | 616 | describe("JSON", () => { 617 | it("survives serialization", () => { 618 | let state = EditorState.create({doc: "abcd", extensions: history()}) 619 | state = state.update({changes: {from: 3, to: 4}}).state 620 | state = state.update({changes: {from: 0, insert: "d"}}).state 621 | state = command(state, undo) 622 | let jsonConf = {history: historyField} 623 | let json = JSON.stringify(state.toJSON(jsonConf)) 624 | state = EditorState.fromJSON(JSON.parse(json), {extensions: history()}, jsonConf) 625 | ist(state.doc.toString(), "abc") 626 | state = command(state, redo) 627 | ist(state.doc.toString(), "dabc") 628 | state = command(command(state, undo), undo) 629 | ist(state.doc.toString(), "abcd") 630 | }) 631 | }) 632 | }) 633 | -------------------------------------------------------------------------------- /test/webtest-commands.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, Command} from "@codemirror/view" 2 | import {Extension, EditorState} from "@codemirror/state" 3 | import {cursorSubwordForward, cursorSubwordBackward} from "@codemirror/commands" 4 | import ist from "ist" 5 | import {mkState, stateStr} from "./state.js" 6 | 7 | const dashWordChar = EditorState.languageData.of(() => [{wordChars: "-"}]) 8 | 9 | function testCmd(before: string, after: string, command: Command, extensions: Extension = []) { 10 | let state = mkState(before, extensions) 11 | let view = new EditorView({ 12 | state, 13 | parent: document.querySelector("#workspace")! as HTMLElement 14 | }) 15 | try { 16 | command(view) 17 | ist(stateStr(view.state), after) 18 | } finally { 19 | view.destroy() 20 | } 21 | } 22 | 23 | describe("commands", () => { 24 | describe("cursorSubwordForward", () => { 25 | it("stops at first camelcase boundary", () => 26 | testCmd("|CamelCaseWord", "Camel|CaseWord", cursorSubwordForward)) 27 | 28 | it("stops at inner camelcase boundary", () => 29 | testCmd("Camel|CaseWord", "CamelCase|Word", cursorSubwordForward)) 30 | 31 | it("stops at last camelcase boundary", () => 32 | testCmd("CamelCase|Word", "CamelCaseWord|", cursorSubwordForward)) 33 | 34 | it("treats ranges of capitals as a single word", () => 35 | testCmd("Eat|CSSToken", "EatCSS|Token", cursorSubwordForward)) 36 | 37 | it("stops at the end of word", () => 38 | testCmd("o|kay.", "okay|.", cursorSubwordForward)) 39 | 40 | it("stops before underscores", () => 41 | testCmd("|snake_case", "snake|_case", cursorSubwordForward)) 42 | 43 | it("stops after underscores", () => 44 | testCmd("snake|_case", "snake_|case", cursorSubwordForward)) 45 | 46 | it("stops before dashes", () => 47 | testCmd("|kebab-case", "kebab|-case", cursorSubwordForward, dashWordChar)) 48 | 49 | it("stops after dashes", () => 50 | testCmd("kebab|-case", "kebab-|case", cursorSubwordForward, dashWordChar)) 51 | 52 | it("stops on dashes at end of word", () => 53 | testCmd("one|--..", "one--|..", cursorSubwordForward, dashWordChar)) 54 | 55 | if (typeof Intl != "undefined" && (Intl as any).Segmenter) { 56 | it("stops on CJK word boundaries", () => { 57 | testCmd("|马在路上小跑着。", "马|在路上小跑着。", cursorSubwordForward) 58 | testCmd("马|在路上小跑着。", "马在|路上小跑着。", cursorSubwordForward) 59 | testCmd("马在|路上小跑着。", "马在路上|小跑着。", cursorSubwordForward) 60 | }) 61 | } 62 | }) 63 | 64 | describe("cursorSubwordBackward", () => { 65 | it("stops at camelcase boundary", () => 66 | testCmd("CamelCaseWord|", "CamelCase|Word", cursorSubwordBackward)) 67 | 68 | it("stops at last camelcase boundary", () => 69 | testCmd("Camel|CaseWord", "|CamelCaseWord", cursorSubwordBackward)) 70 | 71 | it("treats ranges of capitals as a single word", () => 72 | testCmd("EatCSS|Token", "Eat|CSSToken", cursorSubwordBackward)) 73 | 74 | it("stops at the end of word", () => 75 | testCmd(".o|kay", ".|okay", cursorSubwordBackward)) 76 | 77 | it("stops before underscores", () => 78 | testCmd("snake_case|", "snake_|case", cursorSubwordBackward)) 79 | 80 | it("stops after underscores", () => 81 | testCmd("snake_|case", "snake|_case", cursorSubwordBackward)) 82 | 83 | it("stops before dashes", () => 84 | testCmd("kebab-case|", "kebab-|case", cursorSubwordBackward, dashWordChar)) 85 | 86 | it("stops after dashes", () => 87 | testCmd("kebab--|case", "kebab|--case", cursorSubwordBackward, dashWordChar)) 88 | 89 | it("stops on dashes at end of word", () => 90 | testCmd("..--one|", "..--|one", cursorSubwordBackward, dashWordChar)) 91 | 92 | if (typeof Intl != "undefined" && (Intl as any).Segmenter) { 93 | it("stops on CJK word boundaries", () => { 94 | testCmd("马在路上小跑着|。", "马在路上小跑|着。", cursorSubwordBackward) 95 | testCmd("马在路上小跑|着。", "马在路上|小跑着。", cursorSubwordBackward) 96 | testCmd("马在路上|小跑着。", "马在|路上小跑着。", cursorSubwordBackward) 97 | }) 98 | } 99 | }) 100 | }) 101 | --------------------------------------------------------------------------------