├── .gitignore ├── .npmignore ├── .npmrc ├── .tern-project ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── README.md └── commands.ts └── test └── test-commands.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /dist 4 | /notes.txt 5 | /test/*.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /test 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "libs": ["browser"], 3 | "plugins": { 4 | "node": {}, 5 | "complete_strings": {}, 6 | "es_modules": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.7.1 (2025-04-13) 2 | 3 | ### Bug fixes 4 | 5 | Fix a regression in `splitBlock` that would cause it to crash, rather than return false, when no split is possible. 6 | 7 | ## 1.7.0 (2025-02-20) 8 | 9 | ### New features 10 | 11 | `toggleMark` now accepts an `includeWhitespace` option that controls whether it affects leading/trailing space. 12 | 13 | ## 1.6.2 (2024-10-24) 14 | 15 | ### Bug fixes 16 | 17 | Make `splitBlock` smart enough to split blocks when the cursor is inside a nested inline node. 18 | 19 | ## 1.6.1 (2024-10-11) 20 | 21 | ### Bug fixes 22 | 23 | `joinBackward` will, when moving a node into a block, no longer join that block with the block after when the two have compatible content but aren't of the same type. 24 | 25 | Fix an issue in `splitBlock` that caused it to return true without doing anything when the schema makes splitting at the cursor impossible. 26 | 27 | Support implicit conversion between hard break nodes and newlines in the `joinForward` and `joinBackward` commands. 28 | 29 | ## 1.6.0 (2024-07-26) 30 | 31 | ### Bug fixes 32 | 33 | Fix an issue where `joinBackward` couldn't lift the block with the cursor when the block before it was isolating. 34 | 35 | ### New features 36 | 37 | `toggleMark` now takes an option that controls its behavior when only part of the selection has the mark already. 38 | 39 | The function given to `splitBlockAs` now has access to the split position via a third parameter. 40 | 41 | `toggleMark` now takes an `enterInlineAtoms` option that controls whether it descends into atom nodes. 42 | 43 | ## 1.5.2 (2023-05-17) 44 | 45 | ### Bug fixes 46 | 47 | Include CommonJS type declarations in the package to please new TypeScript resolution settings. 48 | 49 | ## 1.5.1 (2023-03-01) 50 | 51 | ### Bug fixes 52 | 53 | Fix `joinTextblockBackward` not applying when the textblock before was wrapped in another node. 54 | 55 | ## 1.5.0 (2022-12-05) 56 | 57 | ### New features 58 | 59 | The new `splitBlockAs` command-builder allows you to pass in custom logic to determine the type of block that should be split off. 60 | 61 | ## 1.4.0 (2022-12-01) 62 | 63 | ### Bug fixes 64 | 65 | Make `setBlockType` act on all selection ranges in selections that have them. 66 | 67 | ### New features 68 | 69 | The new `joinTextblockForward` and `joinTextblockBackward` commands provide a more primitive command for delete/backspace behavior when you don't want the extra strategies implemented by `joinForward`/`joinBackward`. 70 | 71 | ## 1.3.1 (2022-09-08) 72 | 73 | ### Bug fixes 74 | 75 | Make sure `toggleMark` doesn't add marks to top nodes with non-inline content. 76 | 77 | ## 1.3.0 (2022-05-30) 78 | 79 | ### New features 80 | 81 | Include TypeScript type declarations. 82 | 83 | ## 1.2.2 (2022-03-16) 84 | 85 | ### Bug fixes 86 | 87 | Don't override behavior of Home and End keys in base keymap. 88 | 89 | ## 1.2.1 (2022-01-20) 90 | 91 | ### Bug fixes 92 | 93 | Fix an issue where `joinBackward` and `joinForward` would return true when activated with the cursor in an empty but undeletable block, but not make any change. 94 | 95 | ## 1.2.0 (2022-01-17) 96 | 97 | ### Bug fixes 98 | 99 | Add a workaround for a bug on macOS where Ctrl-a and Ctrl-e getting stuck at the edge of inline nodes. 100 | 101 | ### New features 102 | 103 | The new `selectTextblockEnd` and `selectTextblockStart` commands move the cursor to the start/end of the textblock, when inside one. 104 | 105 | Ctrl-a/e on macOS and Home/End on other platforms are now bound to `selectTextblockEnd` and `selectTextblockStart`. 106 | 107 | ## 1.1.12 (2021-10-29) 108 | 109 | ### Bug fixes 110 | 111 | Fix issue where the default PC keymap was used on recent versions of iPhone or iPad operating systems. 112 | 113 | ## 1.1.11 (2021-10-06) 114 | 115 | ### Bug fixes 116 | 117 | Add a binding for Shift-Backspace to the base keymap, so that shift or caps-lock won't interfere with backspace behavior. 118 | 119 | Fix an issue in `autoJoin` that made it ignore a third argument if it was passed one. 120 | 121 | ## 1.1.10 (2021-07-05) 122 | 123 | ### Bug fixes 124 | 125 | Make `joinBackward` capable of joining textblocks wrapped in parent nodes when the parent nodes themselves can't be joined (for example two list items which allow only a single paragraph). 126 | 127 | ## 1.1.9 (2021-06-07) 128 | 129 | ### Bug fixes 130 | 131 | Fix a regression where `splitBlock` could crash when splitting at the end of a non-default block. 132 | 133 | ## 1.1.8 (2021-05-22) 134 | 135 | ### Bug fixes 136 | 137 | Fix a crash in `splitBlock` that occurred with certain types of schemas. 138 | 139 | ## 1.1.7 (2021-02-22) 140 | 141 | ### Bug fixes 142 | 143 | Fix a regression where `createParagraphNear` no longer fired for gap cursor selections. 144 | 145 | ## 1.1.6 (2021-02-10) 146 | 147 | ### Bug fixes 148 | 149 | Improve behavior of enter when the entire document is selected. 150 | 151 | ## 1.1.5 (2021-01-14) 152 | 153 | ### Bug fixes 154 | 155 | `joinBackward` and `joinForward` will now, when the textblock after the cut can't be moved into the structure before the cut, try to just join the inline content onto the last child in the structure before the cut. 156 | 157 | `toggleMark` will now skip whitespace at the start and end of the selection when adding a mark. 158 | 159 | ## 1.1.4 (2020-04-15) 160 | 161 | ### Bug fixes 162 | 163 | `selectNodeForward` and `selectNodeBackward` will now also select nodes next to a gap cursor (or other custom empty selection type). 164 | 165 | ## 1.1.3 (2020-01-03) 166 | 167 | ### Bug fixes 168 | 169 | Fix an issue where, since version 1.7.4 of prosemirror-model, `splitBlock` fails to create the expected new textblock in some schemas. 170 | 171 | ## 1.1.2 (2019-11-20) 172 | 173 | ### Bug fixes 174 | 175 | Rename ES module files to use a .js extension, since Webpack gets confused by .mjs 176 | 177 | ## 1.1.1 (2019-11-19) 178 | 179 | ### Bug fixes 180 | 181 | The file referred to in the package's `module` field now is compiled down to ES5. 182 | 183 | ## 1.1.0 (2019-11-08) 184 | 185 | ### New features 186 | 187 | Add a `module` field to package json file. 188 | 189 | ## 1.0.8 (2019-05-14) 190 | 191 | ### Bug fixes 192 | 193 | Fix a crash caused by using a position potentially outside the document in [`splitBlock`](https://prosemirror.net/docs/ref/#commands.splitBlock). 194 | 195 | ## 1.0.7 (2018-04-09) 196 | 197 | ### Bug fixes 198 | 199 | Fixes an issue where [`joinBackward`](https://prosemirror.net/docs/ref/#commands.joinBackward) might create a selection pointing into the old document. 200 | 201 | ## 1.0.6 (2018-04-04) 202 | 203 | ### Bug fixes 204 | 205 | The [`setBlockType` command](https://prosemirror.net/docs/ref/#commands.setBlockType) command is now considered applicable when _any_ of the selected textblocks can be changed (it used to only look at the first one). 206 | 207 | Fix crash when calling [`splitBlock`](https://prosemirror.net/docs/ref/#commands.splitBlock) when the selection isn't in a block node (by disabling the command in that case). 208 | 209 | Fixes an issue where [`joinForward`](https://prosemirror.net/docs/ref/#commands.joinForward) might create a selection pointing into the old document. 210 | 211 | ## 1.0.5 (2018-01-30) 212 | 213 | ### Bug fixes 214 | 215 | Fix crash in [`splitBlock`](https://prosemirror.net/docs/ref/#commands.splitBlock) when `defaultContentType` returns null. 216 | 217 | ## 1.0.4 (2018-01-18) 218 | 219 | ### Bug fixes 220 | 221 | Pressing delete in front of an [isolating](https://prosemirror.net/docs/ref/#model.NodeSpec.isolating) node no longer opens it. 222 | 223 | ## 1.0.3 (2017-12-19) 224 | 225 | ### Bug fixes 226 | 227 | Fix issue where [`joinBackward`](https://prosemirror.net/docs/ref/#commands.joinBackward) would sometimes create an invalid selection. 228 | 229 | ## 1.0.2 (2017-11-21) 230 | 231 | ### Bug fixes 232 | 233 | [`splitBlock`](https://prosemirror.net/docs/ref/#commands.splitBlock) no longer crashes when used in a block that's it's parent node's only allowed child. 234 | 235 | ## 1.0.0 (2017-10-13) 236 | 237 | ### New features 238 | 239 | The [`setBlockType` command](https://prosemirror.net/docs/ref/#commands.setBlockType) can now be used to change the types of multiple selected textblocks (rather than only one). 240 | 241 | The platform-dependent versions of the [base keymap](https://prosemirror.net/docs/ref/#commands.baseKeymap) are now exported separately as [`pcBaseKeymap`](https://prosemirror.net/docs/ref/#commands.pcBaseKeymap) and [`macBaseKeymap`](https://prosemirror.net/docs/ref/#commands.macBaseKeymap). 242 | 243 | ## 0.23.0 (2017-09-13) 244 | 245 | ### Breaking changes 246 | 247 | `joinForward` and `joinBackward` no longer fall back to selecting the next node when no other behavior is possible. There are now separate commands `selectNodeForward` and `selectNodeBackward` that do this, which the base keymap binds as fallback behavior. 248 | 249 | [`baseKeymap`](https://prosemirror.net/docs/ref/version/0.23.0.html#commands.baseKeymap) no longer binds keys for `joinUp`, `joinDown`, `lift`, and `selectParentNode`. 250 | 251 | ### New features 252 | 253 | New commands [`selectNodeForward`](https://prosemirror.net/docs/ref/version/0.23.0.html#commands.selectNodeForward) and [`selectNodeBackward`](https://prosemirror.net/docs/ref/version/0.23.0.html#commands.selectNodeBackward) added. 254 | 255 | ## 0.20.0 (2017-04-03) 256 | 257 | ### New features 258 | 259 | The new [`selectAll` command](https://prosemirror.net/docs/ref/version/0.20.0.html#commands.selectAll), bound to Mod-a in the base keymap, sets the selection to an [`AllSelection`](https://prosemirror.net/docs/ref/version/0.20.0.html#state.AllSelection). 260 | 261 | ## 0.19.0 (2017-03-16) 262 | 263 | ### Bug fixes 264 | 265 | Calling `joinBackward` at the start of a node that can't be joined no longer raises an error. 266 | 267 | ## 0.18.0 (2017-02-24) 268 | 269 | ### New features 270 | 271 | New command [`splitBlockKeepMarks`](https://prosemirror.net/docs/ref/version/0.18.0.html#commands.splitBlockKeepMarks) which splits a block but preserves the marks at the cursor. 272 | 273 | ## 0.17.1 (2017-01-16) 274 | 275 | ### Bug fixes 276 | 277 | Make sure [`toggleMark`](https://prosemirror.net/docs/ref/version/0.17.0.html#commands.toggleMark) also works in the top-level node (when it is a textblock). 278 | 279 | ## 0.17.0 (2017-01-05) 280 | 281 | ### Breaking changes 282 | 283 | The `dispatch` function passed to commands is now passed a [`Transaction`](https://prosemirror.net/docs/ref/version/0.17.0.html#state.Transaction), not an action object. 284 | 285 | ## 0.15.0 (2016-12-10) 286 | 287 | ### Breaking changes 288 | 289 | Drops suppport for `delete(Char|Word)(Before|After)` and `move(Back|For)ward`, since we are now letting the browser handle those natively. 290 | 291 | ### Bug fixes 292 | 293 | The [`joinForward`](https://prosemirror.net/docs/ref/version/0.15.0.html#commands.joinForward) and [`joinBackward`](https://prosemirror.net/docs/ref/version/0.15.0.html#commands.joinBackward) commands can now strip out markup and nodes that aren't allowed in the joined node. 294 | 295 | ### New features 296 | 297 | A new command [`exitCode`](https://prosemirror.net/docs/ref/version/0.15.0.html#commands.exitCode) allows a user to exit a code block by creating a new paragraph below it. 298 | 299 | The [`joinForward`](https://prosemirror.net/docs/ref/version/0.15.0.html#commands.joinForward) and [`joinBackward`](https://prosemirror.net/docs/ref/version/0.15.0.html#commands.joinBackward) commands now use a bidirectional-text-aware way to determine whether the cursor is at the proper side of its parent textblock when they are passed a view. 300 | 301 | ## 0.13.0 (2016-11-11) 302 | 303 | ### New features 304 | 305 | The [`autoJoin`](https://prosemirror.net/docs/ref/version/0.13.0.html#commands.autoJoin) function allows you to wrap command functions so that when the command makes nodes of a certain type occur next to each other, they are automatically joined. 306 | 307 | ## 0.12.0 (2016-10-21) 308 | 309 | ### Bug fixes 310 | 311 | Fix crash when backspacing into nodes with complex content 312 | expressions. 313 | 314 | ## 0.11.0 (2016-09-21) 315 | 316 | ### Breaking changes 317 | 318 | Moved into a separate module. 319 | 320 | The interface for command functions was changed to work with the new 321 | [state](https://prosemirror.net/docs/ref/version/0.11.0.html#state.EditorState)/[action](https://prosemirror.net/docs/ref/version/0.11.0.html#state.Action) abstractions. 322 | 323 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | - [Getting help](#getting-help) 4 | - [Submitting bug reports](#submitting-bug-reports) 5 | - [Contributing code](#contributing-code) 6 | 7 | ## Getting help 8 | 9 | Community discussion, questions, and informal bug reporting is done on the 10 | [discuss.ProseMirror forum](http://discuss.prosemirror.net). 11 | 12 | ## Submitting bug reports 13 | 14 | Report bugs on the 15 | [GitHub issue tracker](http://github.com/prosemirror/prosemirror/issues). 16 | Before reporting a bug, please read these pointers. 17 | 18 | - The issue tracker is for *bugs*, not requests for help. Questions 19 | should be asked on the [forum](http://discuss.prosemirror.net). 20 | 21 | - Include information about the version of the code that exhibits the 22 | problem. For browser-related issues, include the browser and browser 23 | version on which the problem occurred. 24 | 25 | - Mention very precisely what went wrong. "X is broken" is not a good 26 | bug report. What did you expect to happen? What happened instead? 27 | Describe the exact steps a maintainer has to take to make the 28 | problem occur. A screencast can be useful, but is no substitute for 29 | a textual description. 30 | 31 | - A great way to make it easy to reproduce your problem, if it can not 32 | be trivially reproduced on the website demos, is to submit a script 33 | that triggers the issue. 34 | 35 | ## Contributing code 36 | 37 | If you want to make a change that involves a significant overhaul of 38 | the code or introduces a user-visible new feature, create an 39 | [RFC](https://github.com/ProseMirror/rfcs/) first with your proposal. 40 | 41 | - Make sure you have a [GitHub Account](https://github.com/signup/free) 42 | 43 | - Fork the relevant repository 44 | ([how to fork a repo](https://help.github.com/articles/fork-a-repo)) 45 | 46 | - Create a local checkout of the code. You can use the 47 | [main repository](https://github.com/prosemirror/prosemirror) to 48 | easily check out all core modules. 49 | 50 | - Make your changes, and commit them 51 | 52 | - Follow the code style of the rest of the project (see below). Run 53 | `npm run lint` (in the main repository checkout) to make sure that 54 | the linter is happy. 55 | 56 | - If your changes are easy to test or likely to regress, add tests in 57 | the relevant `test/` directory. Either put them in an existing 58 | `test-*.js` file, if they fit there, or add a new file. 59 | 60 | - Make sure all tests pass. Run `npm run test` to verify tests pass 61 | (you will need Node.js v6+). 62 | 63 | - Submit a pull request ([how to create a pull request](https://help.github.com/articles/fork-a-repo)). 64 | Don't put more than one feature/fix in a single pull request. 65 | 66 | By contributing code to ProseMirror you 67 | 68 | - Agree to license the contributed code under the project's [MIT 69 | license](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE). 70 | 71 | - Confirm that you have the right to contribute and license the code 72 | in question. (Either you hold all rights on the code, or the rights 73 | holder has explicitly granted the right to use it like this, 74 | through a compatible open source license or through a direct 75 | agreement with you.) 76 | 77 | ### Coding standards 78 | 79 | - ES6 syntax, targeting an ES5 runtime (i.e. don't use library 80 | elements added by ES6, don't use ES7/ES.next syntax). 81 | 82 | - 2 spaces per indentation level, no tabs. 83 | 84 | - No semicolons except when necessary. 85 | 86 | - Follow the surrounding code when it comes to spacing, brace 87 | placement, etc. 88 | 89 | - Brace-less single-statement bodies are encouraged (whenever they 90 | don't impact readability). 91 | 92 | - [getdocs](https://github.com/marijnh/getdocs)-style doc comments 93 | above items that are part of the public API. 94 | 95 | - When documenting non-public items, you can put the type after a 96 | single colon, so that getdocs doesn't pick it up and add it to the 97 | API reference. 98 | 99 | - The linter (`npm run lint`) complains about unused variables and 100 | functions. Prefix their names with an underscore to muffle it. 101 | 102 | - ProseMirror does *not* follow JSHint or JSLint prescribed style. 103 | Patches that try to 'fix' code to pass one of these linters will not 104 | be accepted. 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015-2017 by Marijn Haverbeke and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-commands 2 | 3 | [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**CHANGELOG**](https://github.com/ProseMirror/prosemirror-commands/blob/master/CHANGELOG.md) ] 4 | 5 | This is a [core module](https://prosemirror.net/docs/ref/#commands) of [ProseMirror](https://prosemirror.net). 6 | ProseMirror is a well-behaved rich semantic content editor based on 7 | contentEditable, with support for collaborative editing and custom 8 | document schemas. 9 | 10 | This [module](https://prosemirror.net/docs/ref/#commands) implements a 11 | number of editing commands, which are functions that abstract editing 12 | actions which can be bound to keys. 13 | 14 | The [project page](https://prosemirror.net) has more information, a 15 | number of [examples](https://prosemirror.net/examples/) and the 16 | [documentation](https://prosemirror.net/docs/). 17 | 18 | This code is released under an 19 | [MIT license](https://github.com/prosemirror/prosemirror/tree/master/LICENSE). 20 | There's a [forum](http://discuss.prosemirror.net) for general 21 | discussion and support requests, and the 22 | [Github bug tracker](https://github.com/prosemirror/prosemirror/issues) 23 | is the place to report issues. 24 | 25 | We aim to be an inclusive, welcoming community. To make that explicit, 26 | we have a [code of 27 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 28 | to communication around the project. 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-commands", 3 | "version": "1.7.1", 4 | "description": "Editing commands for ProseMirror", 5 | "type": "module", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "sideEffects": false, 14 | "license": "MIT", 15 | "maintainers": [ 16 | { 17 | "name": "Marijn Haverbeke", 18 | "email": "marijn@haverbeke.berlin", 19 | "web": "http://marijnhaverbeke.nl" 20 | } 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git://github.com/prosemirror/prosemirror-commands.git" 25 | }, 26 | "dependencies": { 27 | "prosemirror-model": "^1.0.0", 28 | "prosemirror-transform": "^1.10.2", 29 | "prosemirror-state": "^1.0.0" 30 | }, 31 | "devDependencies": { 32 | "@prosemirror/buildhelper": "^0.1.5", 33 | "prosemirror-test-builder": "^1.0.0" 34 | }, 35 | "scripts": { 36 | "test": "pm-runtests", 37 | "prepare": "pm-buildhelper src/commands.ts" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | This module exports a number of _commands_, which are building block 2 | functions that encapsulate an editing action. A command function takes 3 | an editor state, _optionally_ a `dispatch` function that it can use 4 | to dispatch a transaction and _optionally_ an `EditorView` instance. 5 | It should return a boolean that indicates whether it could perform any 6 | action. When no `dispatch` callback is passed, the command should do a 7 | 'dry run', determining whether it is applicable, but not actually doing 8 | anything. 9 | 10 | These are mostly used to bind keys and define menu items. 11 | 12 | @chainCommands 13 | @deleteSelection 14 | @joinBackward 15 | @selectNodeBackward 16 | @joinTextblockBackward 17 | @joinForward 18 | @selectNodeForward 19 | @joinTextblockForward 20 | @joinUp 21 | @joinDown 22 | @lift 23 | @newlineInCode 24 | @exitCode 25 | @createParagraphNear 26 | @liftEmptyBlock 27 | @splitBlock 28 | @splitBlockAs 29 | @splitBlockKeepMarks 30 | @selectParentNode 31 | @selectAll 32 | @selectTextblockStart 33 | @selectTextblockEnd 34 | @wrapIn 35 | @setBlockType 36 | @toggleMark 37 | @autoJoin 38 | @baseKeymap 39 | @pcBaseKeymap 40 | @macBaseKeymap 41 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import {joinPoint, canJoin, findWrapping, liftTarget, canSplit, 2 | ReplaceStep, ReplaceAroundStep, replaceStep} from "prosemirror-transform" 3 | import {Slice, Fragment, Node, NodeType, Attrs, MarkType, ResolvedPos, ContentMatch} from "prosemirror-model" 4 | import {Selection, EditorState, Transaction, TextSelection, NodeSelection, 5 | SelectionRange, AllSelection, Command} from "prosemirror-state" 6 | import {EditorView} from "prosemirror-view" 7 | 8 | /// Delete the selection, if there is one. 9 | export const deleteSelection: Command = (state, dispatch) => { 10 | if (state.selection.empty) return false 11 | if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()) 12 | return true 13 | } 14 | 15 | function atBlockStart(state: EditorState, view?: EditorView): ResolvedPos | null { 16 | let {$cursor} = state.selection as TextSelection 17 | if (!$cursor || (view ? !view.endOfTextblock("backward", state) 18 | : $cursor.parentOffset > 0)) 19 | return null 20 | return $cursor 21 | } 22 | 23 | /// If the selection is empty and at the start of a textblock, try to 24 | /// reduce the distance between that block and the one before it—if 25 | /// there's a block directly before it that can be joined, join them. 26 | /// If not, try to move the selected block closer to the next one in 27 | /// the document structure by lifting it out of its parent or moving it 28 | /// into a parent of the previous block. Will use the view for accurate 29 | /// (bidi-aware) start-of-textblock detection if given. 30 | export const joinBackward: Command = (state, dispatch, view) => { 31 | let $cursor = atBlockStart(state, view) 32 | if (!$cursor) return false 33 | 34 | let $cut = findCutBefore($cursor) 35 | 36 | // If there is no node before this, try to lift 37 | if (!$cut) { 38 | let range = $cursor.blockRange(), target = range && liftTarget(range) 39 | if (target == null) return false 40 | if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 41 | return true 42 | } 43 | 44 | let before = $cut.nodeBefore! 45 | // Apply the joining algorithm 46 | if (deleteBarrier(state, $cut, dispatch, -1)) return true 47 | 48 | // If the node below has no content and the node above is 49 | // selectable, delete the node below and select the one above. 50 | if ($cursor.parent.content.size == 0 && 51 | (textblockAt(before, "end") || NodeSelection.isSelectable(before))) { 52 | for (let depth = $cursor.depth;; depth--) { 53 | let delStep = replaceStep(state.doc, $cursor.before(depth), $cursor.after(depth), Slice.empty) 54 | if (delStep && (delStep as ReplaceStep).slice.size < (delStep as ReplaceStep).to - (delStep as ReplaceStep).from) { 55 | if (dispatch) { 56 | let tr = state.tr.step(delStep) 57 | tr.setSelection(textblockAt(before, "end") 58 | ? Selection.findFrom(tr.doc.resolve(tr.mapping.map($cut.pos, -1)), -1)! 59 | : NodeSelection.create(tr.doc, $cut.pos - before.nodeSize)) 60 | dispatch(tr.scrollIntoView()) 61 | } 62 | return true 63 | } 64 | if (depth == 1 || $cursor.node(depth - 1).childCount > 1) break 65 | } 66 | } 67 | 68 | // If the node before is an atom, delete it 69 | if (before.isAtom && $cut.depth == $cursor.depth - 1) { 70 | if (dispatch) dispatch(state.tr.delete($cut.pos - before.nodeSize, $cut.pos).scrollIntoView()) 71 | return true 72 | } 73 | 74 | return false 75 | } 76 | 77 | /// A more limited form of [`joinBackward`](#commands.joinBackward) 78 | /// that only tries to join the current textblock to the one before 79 | /// it, if the cursor is at the start of a textblock. 80 | export const joinTextblockBackward: Command = (state, dispatch, view) => { 81 | let $cursor = atBlockStart(state, view) 82 | if (!$cursor) return false 83 | let $cut = findCutBefore($cursor) 84 | return $cut ? joinTextblocksAround(state, $cut, dispatch) : false 85 | } 86 | 87 | /// A more limited form of [`joinForward`](#commands.joinForward) 88 | /// that only tries to join the current textblock to the one after 89 | /// it, if the cursor is at the end of a textblock. 90 | export const joinTextblockForward: Command = (state, dispatch, view) => { 91 | let $cursor = atBlockEnd(state, view) 92 | if (!$cursor) return false 93 | let $cut = findCutAfter($cursor) 94 | return $cut ? joinTextblocksAround(state, $cut, dispatch) : false 95 | } 96 | 97 | function joinTextblocksAround(state: EditorState, $cut: ResolvedPos, dispatch?: (tr: Transaction) => void) { 98 | let before = $cut.nodeBefore!, beforeText = before, beforePos = $cut.pos - 1 99 | for (; !beforeText.isTextblock; beforePos--) { 100 | if (beforeText.type.spec.isolating) return false 101 | let child = beforeText.lastChild 102 | if (!child) return false 103 | beforeText = child 104 | } 105 | let after = $cut.nodeAfter!, afterText = after, afterPos = $cut.pos + 1 106 | for (; !afterText.isTextblock; afterPos++) { 107 | if (afterText.type.spec.isolating) return false 108 | let child = afterText.firstChild 109 | if (!child) return false 110 | afterText = child 111 | } 112 | let step = replaceStep(state.doc, beforePos, afterPos, Slice.empty) as ReplaceStep | null 113 | if (!step || step.from != beforePos || 114 | step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false 115 | if (dispatch) { 116 | let tr = state.tr.step(step) 117 | tr.setSelection(TextSelection.create(tr.doc, beforePos)) 118 | dispatch(tr.scrollIntoView()) 119 | } 120 | return true 121 | 122 | } 123 | 124 | function textblockAt(node: Node, side: "start" | "end", only = false) { 125 | for (let scan: Node | null = node; scan; scan = (side == "start" ? scan.firstChild : scan.lastChild)) { 126 | if (scan.isTextblock) return true 127 | if (only && scan.childCount != 1) return false 128 | } 129 | return false 130 | } 131 | 132 | /// When the selection is empty and at the start of a textblock, select 133 | /// the node before that textblock, if possible. This is intended to be 134 | /// bound to keys like backspace, after 135 | /// [`joinBackward`](#commands.joinBackward) or other deleting 136 | /// commands, as a fall-back behavior when the schema doesn't allow 137 | /// deletion at the selected point. 138 | export const selectNodeBackward: Command = (state, dispatch, view) => { 139 | let {$head, empty} = state.selection, $cut: ResolvedPos | null = $head 140 | if (!empty) return false 141 | 142 | if ($head.parent.isTextblock) { 143 | if (view ? !view.endOfTextblock("backward", state) : $head.parentOffset > 0) return false 144 | $cut = findCutBefore($head) 145 | } 146 | let node = $cut && $cut.nodeBefore 147 | if (!node || !NodeSelection.isSelectable(node)) return false 148 | if (dispatch) 149 | dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $cut!.pos - node.nodeSize)).scrollIntoView()) 150 | return true 151 | } 152 | 153 | function findCutBefore($pos: ResolvedPos): ResolvedPos | null { 154 | if (!$pos.parent.type.spec.isolating) for (let i = $pos.depth - 1; i >= 0; i--) { 155 | if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1)) 156 | if ($pos.node(i).type.spec.isolating) break 157 | } 158 | return null 159 | } 160 | 161 | function atBlockEnd(state: EditorState, view?: EditorView): ResolvedPos | null { 162 | let {$cursor} = state.selection as TextSelection 163 | if (!$cursor || (view ? !view.endOfTextblock("forward", state) 164 | : $cursor.parentOffset < $cursor.parent.content.size)) 165 | return null 166 | return $cursor 167 | } 168 | 169 | /// If the selection is empty and the cursor is at the end of a 170 | /// textblock, try to reduce or remove the boundary between that block 171 | /// and the one after it, either by joining them or by moving the other 172 | /// block closer to this one in the tree structure. Will use the view 173 | /// for accurate start-of-textblock detection if given. 174 | export const joinForward: Command = (state, dispatch, view) => { 175 | let $cursor = atBlockEnd(state, view) 176 | if (!$cursor) return false 177 | 178 | let $cut = findCutAfter($cursor) 179 | // If there is no node after this, there's nothing to do 180 | if (!$cut) return false 181 | 182 | let after = $cut.nodeAfter! 183 | // Try the joining algorithm 184 | if (deleteBarrier(state, $cut, dispatch, 1)) return true 185 | 186 | // If the node above has no content and the node below is 187 | // selectable, delete the node above and select the one below. 188 | if ($cursor.parent.content.size == 0 && 189 | (textblockAt(after, "start") || NodeSelection.isSelectable(after))) { 190 | let delStep = replaceStep(state.doc, $cursor.before(), $cursor.after(), Slice.empty) 191 | if (delStep && (delStep as ReplaceStep).slice.size < (delStep as ReplaceStep).to - (delStep as ReplaceStep).from) { 192 | if (dispatch) { 193 | let tr = state.tr.step(delStep) 194 | tr.setSelection(textblockAt(after, "start") ? Selection.findFrom(tr.doc.resolve(tr.mapping.map($cut.pos)), 1)! 195 | : NodeSelection.create(tr.doc, tr.mapping.map($cut.pos))) 196 | dispatch(tr.scrollIntoView()) 197 | } 198 | return true 199 | } 200 | } 201 | 202 | // If the next node is an atom, delete it 203 | if (after.isAtom && $cut.depth == $cursor.depth - 1) { 204 | if (dispatch) dispatch(state.tr.delete($cut.pos, $cut.pos + after.nodeSize).scrollIntoView()) 205 | return true 206 | } 207 | 208 | return false 209 | } 210 | 211 | /// When the selection is empty and at the end of a textblock, select 212 | /// the node coming after that textblock, if possible. This is intended 213 | /// to be bound to keys like delete, after 214 | /// [`joinForward`](#commands.joinForward) and similar deleting 215 | /// commands, to provide a fall-back behavior when the schema doesn't 216 | /// allow deletion at the selected point. 217 | export const selectNodeForward: Command = (state, dispatch, view) => { 218 | let {$head, empty} = state.selection, $cut: ResolvedPos | null = $head 219 | if (!empty) return false 220 | if ($head.parent.isTextblock) { 221 | if (view ? !view.endOfTextblock("forward", state) : $head.parentOffset < $head.parent.content.size) 222 | return false 223 | $cut = findCutAfter($head) 224 | } 225 | let node = $cut && $cut.nodeAfter 226 | if (!node || !NodeSelection.isSelectable(node)) return false 227 | if (dispatch) 228 | dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $cut!.pos)).scrollIntoView()) 229 | return true 230 | } 231 | 232 | function findCutAfter($pos: ResolvedPos) { 233 | if (!$pos.parent.type.spec.isolating) for (let i = $pos.depth - 1; i >= 0; i--) { 234 | let parent = $pos.node(i) 235 | if ($pos.index(i) + 1 < parent.childCount) return $pos.doc.resolve($pos.after(i + 1)) 236 | if (parent.type.spec.isolating) break 237 | } 238 | return null 239 | } 240 | 241 | /// Join the selected block or, if there is a text selection, the 242 | /// closest ancestor block of the selection that can be joined, with 243 | /// the sibling above it. 244 | export const joinUp: Command = (state, dispatch) => { 245 | let sel = state.selection, nodeSel = sel instanceof NodeSelection, point 246 | if (nodeSel) { 247 | if ((sel as NodeSelection).node.isTextblock || !canJoin(state.doc, sel.from)) return false 248 | point = sel.from 249 | } else { 250 | point = joinPoint(state.doc, sel.from, -1) 251 | if (point == null) return false 252 | } 253 | if (dispatch) { 254 | let tr = state.tr.join(point) 255 | if (nodeSel) tr.setSelection(NodeSelection.create(tr.doc, point - state.doc.resolve(point).nodeBefore!.nodeSize)) 256 | dispatch(tr.scrollIntoView()) 257 | } 258 | return true 259 | } 260 | 261 | /// Join the selected block, or the closest ancestor of the selection 262 | /// that can be joined, with the sibling after it. 263 | export const joinDown: Command = (state, dispatch) => { 264 | let sel = state.selection, point 265 | if (sel instanceof NodeSelection) { 266 | if (sel.node.isTextblock || !canJoin(state.doc, sel.to)) return false 267 | point = sel.to 268 | } else { 269 | point = joinPoint(state.doc, sel.to, 1) 270 | if (point == null) return false 271 | } 272 | if (dispatch) 273 | dispatch(state.tr.join(point).scrollIntoView()) 274 | return true 275 | } 276 | 277 | /// Lift the selected block, or the closest ancestor block of the 278 | /// selection that can be lifted, out of its parent node. 279 | export const lift: Command = (state, dispatch) => { 280 | let {$from, $to} = state.selection 281 | let range = $from.blockRange($to), target = range && liftTarget(range) 282 | if (target == null) return false 283 | if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 284 | return true 285 | } 286 | 287 | /// If the selection is in a node whose type has a truthy 288 | /// [`code`](#model.NodeSpec.code) property in its spec, replace the 289 | /// selection with a newline character. 290 | export const newlineInCode: Command = (state, dispatch) => { 291 | let {$head, $anchor} = state.selection 292 | if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) return false 293 | if (dispatch) dispatch(state.tr.insertText("\n").scrollIntoView()) 294 | return true 295 | } 296 | 297 | function defaultBlockAt(match: ContentMatch) { 298 | for (let i = 0; i < match.edgeCount; i++) { 299 | let {type} = match.edge(i) 300 | if (type.isTextblock && !type.hasRequiredAttrs()) return type 301 | } 302 | return null 303 | } 304 | 305 | /// When the selection is in a node with a truthy 306 | /// [`code`](#model.NodeSpec.code) property in its spec, create a 307 | /// default block after the code block, and move the cursor there. 308 | export const exitCode: Command = (state, dispatch) => { 309 | let {$head, $anchor} = state.selection 310 | if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) return false 311 | let above = $head.node(-1), after = $head.indexAfter(-1), type = defaultBlockAt(above.contentMatchAt(after)) 312 | if (!type || !above.canReplaceWith(after, after, type)) return false 313 | if (dispatch) { 314 | let pos = $head.after(), tr = state.tr.replaceWith(pos, pos, type.createAndFill()!) 315 | tr.setSelection(Selection.near(tr.doc.resolve(pos), 1)) 316 | dispatch(tr.scrollIntoView()) 317 | } 318 | return true 319 | } 320 | 321 | /// If a block node is selected, create an empty paragraph before (if 322 | /// it is its parent's first child) or after it. 323 | export const createParagraphNear: Command = (state, dispatch) => { 324 | let sel = state.selection, {$from, $to} = sel 325 | if (sel instanceof AllSelection || $from.parent.inlineContent || $to.parent.inlineContent) return false 326 | let type = defaultBlockAt($to.parent.contentMatchAt($to.indexAfter())) 327 | if (!type || !type.isTextblock) return false 328 | if (dispatch) { 329 | let side = (!$from.parentOffset && $to.index() < $to.parent.childCount ? $from : $to).pos 330 | let tr = state.tr.insert(side, type.createAndFill()!) 331 | tr.setSelection(TextSelection.create(tr.doc, side + 1)) 332 | dispatch(tr.scrollIntoView()) 333 | } 334 | return true 335 | } 336 | 337 | /// If the cursor is in an empty textblock that can be lifted, lift the 338 | /// block. 339 | export const liftEmptyBlock: Command = (state, dispatch) => { 340 | let {$cursor} = state.selection as TextSelection 341 | if (!$cursor || $cursor.parent.content.size) return false 342 | if ($cursor.depth > 1 && $cursor.after() != $cursor.end(-1)) { 343 | let before = $cursor.before() 344 | if (canSplit(state.doc, before)) { 345 | if (dispatch) dispatch(state.tr.split(before).scrollIntoView()) 346 | return true 347 | } 348 | } 349 | let range = $cursor.blockRange(), target = range && liftTarget(range) 350 | if (target == null) return false 351 | if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 352 | return true 353 | } 354 | 355 | /// Create a variant of [`splitBlock`](#commands.splitBlock) that uses 356 | /// a custom function to determine the type of the newly split off block. 357 | export function splitBlockAs( 358 | splitNode?: (node: Node, atEnd: boolean, $from: ResolvedPos) => {type: NodeType, attrs?: Attrs} | null 359 | ): Command { 360 | return (state, dispatch) => { 361 | let {$from, $to} = state.selection 362 | if (state.selection instanceof NodeSelection && state.selection.node.isBlock) { 363 | if (!$from.parentOffset || !canSplit(state.doc, $from.pos)) return false 364 | if (dispatch) dispatch(state.tr.split($from.pos).scrollIntoView()) 365 | return true 366 | } 367 | 368 | if (!$from.depth) return false 369 | let types: (null | {type: NodeType, attrs?: Attrs | null})[] = [] 370 | let splitDepth, deflt, atEnd = false, atStart = false 371 | for (let d = $from.depth;; d--) { 372 | let node = $from.node(d) 373 | if (node.isBlock) { 374 | atEnd = $from.end(d) == $from.pos + ($from.depth - d) 375 | atStart = $from.start(d) == $from.pos - ($from.depth - d) 376 | deflt = defaultBlockAt($from.node(d - 1).contentMatchAt($from.indexAfter(d - 1))) 377 | let splitType = splitNode && splitNode($to.parent, atEnd, $from) 378 | types.unshift(splitType || (atEnd && deflt ? {type: deflt} : null)) 379 | splitDepth = d 380 | break 381 | } else { 382 | if (d == 1) return false 383 | types.unshift(null) 384 | } 385 | } 386 | 387 | let tr = state.tr 388 | if (state.selection instanceof TextSelection || state.selection instanceof AllSelection) tr.deleteSelection() 389 | let splitPos = tr.mapping.map($from.pos) 390 | let can = canSplit(tr.doc, splitPos, types.length, types) 391 | if (!can) { 392 | types[0] = deflt ? {type: deflt} : null 393 | can = canSplit(tr.doc, splitPos, types.length, types) 394 | } 395 | if (!can) return false 396 | tr.split(splitPos, types.length, types) 397 | if (!atEnd && atStart && $from.node(splitDepth).type != deflt) { 398 | let first = tr.mapping.map($from.before(splitDepth)), $first = tr.doc.resolve(first) 399 | if (deflt && $from.node(splitDepth - 1).canReplaceWith($first.index(), $first.index() + 1, deflt)) 400 | tr.setNodeMarkup(tr.mapping.map($from.before(splitDepth)), deflt) 401 | } 402 | if (dispatch) dispatch(tr.scrollIntoView()) 403 | return true 404 | } 405 | } 406 | 407 | /// Split the parent block of the selection. If the selection is a text 408 | /// selection, also delete its content. 409 | export const splitBlock: Command = splitBlockAs() 410 | 411 | /// Acts like [`splitBlock`](#commands.splitBlock), but without 412 | /// resetting the set of active marks at the cursor. 413 | export const splitBlockKeepMarks: Command = (state, dispatch) => { 414 | return splitBlock(state, dispatch && (tr => { 415 | let marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()) 416 | if (marks) tr.ensureMarks(marks) 417 | dispatch(tr) 418 | })) 419 | } 420 | 421 | /// Move the selection to the node wrapping the current selection, if 422 | /// any. (Will not select the document node.) 423 | export const selectParentNode: Command = (state, dispatch) => { 424 | let {$from, to} = state.selection, pos 425 | let same = $from.sharedDepth(to) 426 | if (same == 0) return false 427 | pos = $from.before(same) 428 | if (dispatch) dispatch(state.tr.setSelection(NodeSelection.create(state.doc, pos))) 429 | return true 430 | } 431 | 432 | /// Select the whole document. 433 | export const selectAll: Command = (state, dispatch) => { 434 | if (dispatch) dispatch(state.tr.setSelection(new AllSelection(state.doc))) 435 | return true 436 | } 437 | 438 | function joinMaybeClear(state: EditorState, $pos: ResolvedPos, dispatch: ((tr: Transaction) => void) | undefined) { 439 | let before = $pos.nodeBefore, after = $pos.nodeAfter, index = $pos.index() 440 | if (!before || !after || !before.type.compatibleContent(after.type)) return false 441 | if (!before.content.size && $pos.parent.canReplace(index - 1, index)) { 442 | if (dispatch) dispatch(state.tr.delete($pos.pos - before.nodeSize, $pos.pos).scrollIntoView()) 443 | return true 444 | } 445 | if (!$pos.parent.canReplace(index, index + 1) || !(after.isTextblock || canJoin(state.doc, $pos.pos))) 446 | return false 447 | if (dispatch) 448 | dispatch(state.tr.join($pos.pos).scrollIntoView()) 449 | return true 450 | } 451 | 452 | function deleteBarrier(state: EditorState, $cut: ResolvedPos, dispatch: ((tr: Transaction) => void) | undefined, dir: number) { 453 | let before = $cut.nodeBefore!, after = $cut.nodeAfter!, conn, match 454 | let isolated = before.type.spec.isolating || after.type.spec.isolating 455 | if (!isolated && joinMaybeClear(state, $cut, dispatch)) return true 456 | 457 | let canDelAfter = !isolated && $cut.parent.canReplace($cut.index(), $cut.index() + 1) 458 | if (canDelAfter && 459 | (conn = (match = before.contentMatchAt(before.childCount)).findWrapping(after.type)) && 460 | match.matchType(conn[0] || after.type)!.validEnd) { 461 | if (dispatch) { 462 | let end = $cut.pos + after.nodeSize, wrap = Fragment.empty 463 | for (let i = conn.length - 1; i >= 0; i--) 464 | wrap = Fragment.from(conn[i].create(null, wrap)) 465 | wrap = Fragment.from(before.copy(wrap)) 466 | let tr = state.tr.step(new ReplaceAroundStep($cut.pos - 1, end, $cut.pos, end, new Slice(wrap, 1, 0), conn.length, true)) 467 | let $joinAt = tr.doc.resolve(end + 2 * conn.length) 468 | if ($joinAt.nodeAfter && $joinAt.nodeAfter.type == before.type && 469 | canJoin(tr.doc, $joinAt.pos)) tr.join($joinAt.pos) 470 | dispatch(tr.scrollIntoView()) 471 | } 472 | return true 473 | } 474 | 475 | let selAfter = after.type.spec.isolating || (dir > 0 && isolated) ? null : Selection.findFrom($cut, 1) 476 | let range = selAfter && selAfter.$from.blockRange(selAfter.$to), target = range && liftTarget(range) 477 | if (target != null && target >= $cut.depth) { 478 | if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 479 | return true 480 | } 481 | 482 | if (canDelAfter && textblockAt(after, "start", true) && textblockAt(before, "end")) { 483 | let at = before, wrap = [] 484 | for (;;) { 485 | wrap.push(at) 486 | if (at.isTextblock) break 487 | at = at.lastChild! 488 | } 489 | let afterText = after, afterDepth = 1 490 | for (; !afterText.isTextblock; afterText = afterText.firstChild!) afterDepth++ 491 | if (at.canReplace(at.childCount, at.childCount, afterText.content)) { 492 | if (dispatch) { 493 | let end = Fragment.empty 494 | for (let i = wrap.length - 1; i >= 0; i--) end = Fragment.from(wrap[i].copy(end)) 495 | let tr = state.tr.step(new ReplaceAroundStep($cut.pos - wrap.length, $cut.pos + after.nodeSize, 496 | $cut.pos + afterDepth, $cut.pos + after.nodeSize - afterDepth, 497 | new Slice(end, wrap.length, 0), 0, true)) 498 | dispatch(tr.scrollIntoView()) 499 | } 500 | return true 501 | } 502 | } 503 | 504 | return false 505 | } 506 | 507 | function selectTextblockSide(side: number): Command { 508 | return function(state, dispatch) { 509 | let sel = state.selection, $pos = side < 0 ? sel.$from : sel.$to 510 | let depth = $pos.depth 511 | while ($pos.node(depth).isInline) { 512 | if (!depth) return false 513 | depth-- 514 | } 515 | if (!$pos.node(depth).isTextblock) return false 516 | if (dispatch) 517 | dispatch(state.tr.setSelection(TextSelection.create( 518 | state.doc, side < 0 ? $pos.start(depth) : $pos.end(depth)))) 519 | return true 520 | } 521 | } 522 | 523 | /// Moves the cursor to the start of current text block. 524 | export const selectTextblockStart = selectTextblockSide(-1) 525 | 526 | /// Moves the cursor to the end of current text block. 527 | export const selectTextblockEnd = selectTextblockSide(1) 528 | 529 | // Parameterized commands 530 | 531 | /// Wrap the selection in a node of the given type with the given 532 | /// attributes. 533 | export function wrapIn(nodeType: NodeType, attrs: Attrs | null = null): Command { 534 | return function(state, dispatch) { 535 | let {$from, $to} = state.selection 536 | let range = $from.blockRange($to), wrapping = range && findWrapping(range, nodeType, attrs) 537 | if (!wrapping) return false 538 | if (dispatch) dispatch(state.tr.wrap(range!, wrapping).scrollIntoView()) 539 | return true 540 | } 541 | } 542 | 543 | /// Returns a command that tries to set the selected textblocks to the 544 | /// given node type with the given attributes. 545 | export function setBlockType(nodeType: NodeType, attrs: Attrs | null = null): Command { 546 | return function(state, dispatch) { 547 | let applicable = false 548 | for (let i = 0; i < state.selection.ranges.length && !applicable; i++) { 549 | let {$from: {pos: from}, $to: {pos: to}} = state.selection.ranges[i] 550 | state.doc.nodesBetween(from, to, (node, pos) => { 551 | if (applicable) return false 552 | if (!node.isTextblock || node.hasMarkup(nodeType, attrs)) return 553 | if (node.type == nodeType) { 554 | applicable = true 555 | } else { 556 | let $pos = state.doc.resolve(pos), index = $pos.index() 557 | applicable = $pos.parent.canReplaceWith(index, index + 1, nodeType) 558 | } 559 | }) 560 | } 561 | if (!applicable) return false 562 | if (dispatch) { 563 | let tr = state.tr 564 | for (let i = 0; i < state.selection.ranges.length; i++) { 565 | let {$from: {pos: from}, $to: {pos: to}} = state.selection.ranges[i] 566 | tr.setBlockType(from, to, nodeType, attrs) 567 | } 568 | dispatch(tr.scrollIntoView()) 569 | } 570 | return true 571 | } 572 | } 573 | 574 | function markApplies(doc: Node, ranges: readonly SelectionRange[], type: MarkType, enterAtoms: boolean) { 575 | for (let i = 0; i < ranges.length; i++) { 576 | let {$from, $to} = ranges[i] 577 | let can = $from.depth == 0 ? doc.inlineContent && doc.type.allowsMarkType(type) : false 578 | doc.nodesBetween($from.pos, $to.pos, (node, pos) => { 579 | if (can || !enterAtoms && node.isAtom && node.isInline && pos >= $from.pos && pos + node.nodeSize <= $to.pos) 580 | return false 581 | can = node.inlineContent && node.type.allowsMarkType(type) 582 | }) 583 | if (can) return true 584 | } 585 | return false 586 | } 587 | 588 | function removeInlineAtoms(ranges: readonly SelectionRange[]): readonly SelectionRange[] { 589 | let result = [] 590 | for (let i = 0; i < ranges.length; i++) { 591 | let {$from, $to} = ranges[i] 592 | $from.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { 593 | if (node.isAtom && node.content.size && node.isInline && pos >= $from.pos && pos + node.nodeSize <= $to.pos) { 594 | if (pos + 1 > $from.pos) result.push(new SelectionRange($from, $from.doc.resolve(pos + 1))) 595 | $from = $from.doc.resolve(pos + 1 + node.content.size) 596 | return false 597 | } 598 | }) 599 | if ($from.pos < $to.pos) result.push(new SelectionRange($from, $to)) 600 | } 601 | return result 602 | } 603 | 604 | /// Create a command function that toggles the given mark with the 605 | /// given attributes. Will return `false` when the current selection 606 | /// doesn't support that mark. This will remove the mark if any marks 607 | /// of that type exist in the selection, or add it otherwise. If the 608 | /// selection is empty, this applies to the [stored 609 | /// marks](#state.EditorState.storedMarks) instead of a range of the 610 | /// document. 611 | export function toggleMark(markType: MarkType, attrs: Attrs | null = null, options?: { 612 | /// Controls whether, when part of the selected range has the mark 613 | /// already and part doesn't, the mark is removed (`true`, the 614 | /// default) or added (`false`). 615 | removeWhenPresent?: boolean 616 | /// When set to false, this will prevent the command from acting on 617 | /// the content of inline nodes marked as 618 | /// [atoms](#model.NodeSpec.atom) that are completely covered by a 619 | /// selection range. 620 | enterInlineAtoms?: boolean 621 | /// By default, this command doesn't apply to leading and trailing 622 | /// whitespace in the selection. Set this to `true` to change that. 623 | includeWhitespace?: boolean 624 | }): Command { 625 | let removeWhenPresent = (options && options.removeWhenPresent) !== false 626 | let enterAtoms = (options && options.enterInlineAtoms) !== false 627 | let dropSpace = !(options && options.includeWhitespace) 628 | return function(state, dispatch) { 629 | let {empty, $cursor, ranges} = state.selection as TextSelection 630 | if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType, enterAtoms)) return false 631 | if (dispatch) { 632 | if ($cursor) { 633 | if (markType.isInSet(state.storedMarks || $cursor.marks())) 634 | dispatch(state.tr.removeStoredMark(markType)) 635 | else 636 | dispatch(state.tr.addStoredMark(markType.create(attrs))) 637 | } else { 638 | let add, tr = state.tr 639 | if (!enterAtoms) ranges = removeInlineAtoms(ranges) 640 | if (removeWhenPresent) { 641 | add = !ranges.some(r => state.doc.rangeHasMark(r.$from.pos, r.$to.pos, markType)) 642 | } else { 643 | add = !ranges.every(r => { 644 | let missing = false 645 | tr.doc.nodesBetween(r.$from.pos, r.$to.pos, (node, pos, parent) => { 646 | if (missing) return false 647 | missing = !markType.isInSet(node.marks) && !!parent && parent.type.allowsMarkType(markType) && 648 | !(node.isText && /^\s*$/.test(node.textBetween(Math.max(0, r.$from.pos - pos), 649 | Math.min(node.nodeSize, r.$to.pos - pos)))) 650 | }) 651 | return !missing 652 | }) 653 | } 654 | for (let i = 0; i < ranges.length; i++) { 655 | let {$from, $to} = ranges[i] 656 | if (!add) { 657 | tr.removeMark($from.pos, $to.pos, markType) 658 | } else { 659 | let from = $from.pos, to = $to.pos, start = $from.nodeAfter, end = $to.nodeBefore 660 | let spaceStart = dropSpace && start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0 661 | let spaceEnd = dropSpace && end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0 662 | if (from + spaceStart < to) { from += spaceStart; to -= spaceEnd } 663 | tr.addMark(from, to, markType.create(attrs)) 664 | } 665 | } 666 | dispatch(tr.scrollIntoView()) 667 | } 668 | } 669 | return true 670 | } 671 | } 672 | 673 | function wrapDispatchForJoin(dispatch: (tr: Transaction) => void, isJoinable: (a: Node, b: Node) => boolean) { 674 | return (tr: Transaction) => { 675 | if (!tr.isGeneric) return dispatch(tr) 676 | 677 | let ranges: number[] = [] 678 | for (let i = 0; i < tr.mapping.maps.length; i++) { 679 | let map = tr.mapping.maps[i] 680 | for (let j = 0; j < ranges.length; j++) 681 | ranges[j] = map.map(ranges[j]) 682 | map.forEach((_s, _e, from, to) => ranges.push(from, to)) 683 | } 684 | 685 | // Figure out which joinable points exist inside those ranges, 686 | // by checking all node boundaries in their parent nodes. 687 | let joinable = [] 688 | for (let i = 0; i < ranges.length; i += 2) { 689 | let from = ranges[i], to = ranges[i + 1] 690 | let $from = tr.doc.resolve(from), depth = $from.sharedDepth(to), parent = $from.node(depth) 691 | for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) { 692 | let after = parent.maybeChild(index) 693 | if (!after) break 694 | if (index && joinable.indexOf(pos) == -1) { 695 | let before = parent.child(index - 1) 696 | if (before.type == after.type && isJoinable(before, after)) 697 | joinable.push(pos) 698 | } 699 | pos += after.nodeSize 700 | } 701 | } 702 | // Join the joinable points 703 | joinable.sort((a, b) => a - b) 704 | for (let i = joinable.length - 1; i >= 0; i--) { 705 | if (canJoin(tr.doc, joinable[i])) tr.join(joinable[i]) 706 | } 707 | dispatch(tr) 708 | } 709 | } 710 | 711 | /// Wrap a command so that, when it produces a transform that causes 712 | /// two joinable nodes to end up next to each other, those are joined. 713 | /// Nodes are considered joinable when they are of the same type and 714 | /// when the `isJoinable` predicate returns true for them or, if an 715 | /// array of strings was passed, if their node type name is in that 716 | /// array. 717 | export function autoJoin( 718 | command: Command, 719 | isJoinable: ((before: Node, after: Node) => boolean) | readonly string[] 720 | ): Command { 721 | let canJoin = Array.isArray(isJoinable) ? (node: Node) => isJoinable.indexOf(node.type.name) > -1 722 | : isJoinable as (a: Node, b: Node) => boolean 723 | return (state, dispatch, view) => command(state, dispatch && wrapDispatchForJoin(dispatch, canJoin), view) 724 | } 725 | 726 | /// Combine a number of command functions into a single function (which 727 | /// calls them one by one until one returns true). 728 | export function chainCommands(...commands: readonly Command[]): Command { 729 | return function(state, dispatch, view) { 730 | for (let i = 0; i < commands.length; i++) 731 | if (commands[i](state, dispatch, view)) return true 732 | return false 733 | } 734 | } 735 | 736 | let backspace = chainCommands(deleteSelection, joinBackward, selectNodeBackward) 737 | let del = chainCommands(deleteSelection, joinForward, selectNodeForward) 738 | 739 | /// A basic keymap containing bindings not specific to any schema. 740 | /// Binds the following keys (when multiple commands are listed, they 741 | /// are chained with [`chainCommands`](#commands.chainCommands)): 742 | /// 743 | /// * **Enter** to `newlineInCode`, `createParagraphNear`, `liftEmptyBlock`, `splitBlock` 744 | /// * **Mod-Enter** to `exitCode` 745 | /// * **Backspace** and **Mod-Backspace** to `deleteSelection`, `joinBackward`, `selectNodeBackward` 746 | /// * **Delete** and **Mod-Delete** to `deleteSelection`, `joinForward`, `selectNodeForward` 747 | /// * **Mod-Delete** to `deleteSelection`, `joinForward`, `selectNodeForward` 748 | /// * **Mod-a** to `selectAll` 749 | export const pcBaseKeymap: {[key: string]: Command} = { 750 | "Enter": chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock), 751 | "Mod-Enter": exitCode, 752 | "Backspace": backspace, 753 | "Mod-Backspace": backspace, 754 | "Shift-Backspace": backspace, 755 | "Delete": del, 756 | "Mod-Delete": del, 757 | "Mod-a": selectAll 758 | } 759 | 760 | /// A copy of `pcBaseKeymap` that also binds **Ctrl-h** like Backspace, 761 | /// **Ctrl-d** like Delete, **Alt-Backspace** like Ctrl-Backspace, and 762 | /// **Ctrl-Alt-Backspace**, **Alt-Delete**, and **Alt-d** like 763 | /// Ctrl-Delete. 764 | export const macBaseKeymap: {[key: string]: Command} = { 765 | "Ctrl-h": pcBaseKeymap["Backspace"], 766 | "Alt-Backspace": pcBaseKeymap["Mod-Backspace"], 767 | "Ctrl-d": pcBaseKeymap["Delete"], 768 | "Ctrl-Alt-Backspace": pcBaseKeymap["Mod-Delete"], 769 | "Alt-Delete": pcBaseKeymap["Mod-Delete"], 770 | "Alt-d": pcBaseKeymap["Mod-Delete"], 771 | "Ctrl-a": selectTextblockStart, 772 | "Ctrl-e": selectTextblockEnd 773 | } 774 | for (let key in pcBaseKeymap) (macBaseKeymap as any)[key] = pcBaseKeymap[key] 775 | 776 | const mac = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) 777 | // @ts-ignore 778 | : typeof os != "undefined" && os.platform ? os.platform() == "darwin" : false 779 | 780 | /// Depending on the detected platform, this will hold 781 | /// [`pcBasekeymap`](#commands.pcBaseKeymap) or 782 | /// [`macBaseKeymap`](#commands.macBaseKeymap). 783 | export const baseKeymap: {[key: string]: Command} = mac ? macBaseKeymap : pcBaseKeymap 784 | -------------------------------------------------------------------------------- /test/test-commands.ts: -------------------------------------------------------------------------------- 1 | import {Schema, Node} from "prosemirror-model" 2 | import {EditorState, Selection, TextSelection, NodeSelection, Command} from "prosemirror-state" 3 | import {schema, builders, eq, doc, blockquote, pre, h1, p, li, ol, ul, em, strong, hr, img} from "prosemirror-test-builder" 4 | import ist from "ist" 5 | 6 | import {joinBackward, joinTextblockBackward, selectNodeBackward, joinForward, joinTextblockForward, selectNodeForward, 7 | deleteSelection, joinUp, joinDown, lift, 8 | wrapIn, splitBlock, splitBlockAs, splitBlockKeepMarks, liftEmptyBlock, createParagraphNear, setBlockType, 9 | selectTextblockStart, selectTextblockEnd, 10 | selectParentNode, autoJoin, toggleMark} from "prosemirror-commands" 11 | 12 | function t(node: Node): {[name: string]: number} { 13 | return (node as any).tag 14 | } 15 | 16 | function selFor(doc: Node) { 17 | let a = t(doc).a 18 | if (a != null) { 19 | let $a = doc.resolve(a) 20 | if ($a.parent.inlineContent) return new TextSelection($a, t(doc).b != null ? doc.resolve(t(doc).b) : undefined) 21 | else return new NodeSelection($a) 22 | } 23 | return Selection.atStart(doc) 24 | } 25 | 26 | function mkState(doc: Node) { 27 | return EditorState.create({doc, selection: selFor(doc)}) 28 | } 29 | 30 | function apply(doc: Node, command: Command, result: Node | null) { 31 | let state = mkState(doc) 32 | command(state, tr => state = state.apply(tr)) 33 | ist(state.doc, result || doc, eq) 34 | if (result && t(result).a != null) ist(state.selection, selFor(result), eq) 35 | } 36 | 37 | describe("joinBackward", () => { 38 | it("can join paragraphs", () => 39 | apply(doc(p("hi"), p("there")), joinBackward, doc(p("hithere")))) 40 | 41 | it("can join out of a nested node", () => 42 | apply(doc(p("hi"), blockquote(p("there"))), joinBackward, 43 | doc(p("hi"), p("there")))) 44 | 45 | it("moves a block into an adjacent wrapper", () => 46 | apply(doc(blockquote(p("hi")), p("there")), joinBackward, 47 | doc(blockquote(p("hi"), p("there"))))) 48 | 49 | it("moves a block into an adjacent wrapper from another wrapper", () => 50 | apply(doc(blockquote(p("hi")), blockquote(p("there"))), joinBackward, 51 | doc(blockquote(p("hi"), p("there"))))) 52 | 53 | it("joins the wrapper to a subsequent one if applicable", () => 54 | apply(doc(blockquote(p("hi")), p("there"), blockquote(p("x"))), joinBackward, 55 | doc(blockquote(p("hi"), p("there"), p("x"))))) 56 | 57 | it("moves a block into a list item", () => 58 | apply(doc(ul(li(p("hi"))), p("there")), joinBackward, 59 | doc(ul(li(p("hi")), li(p("there")))))) 60 | 61 | it("joins lists", () => 62 | apply(doc(ul(li(p("hi"))), ul(li(p("there")))), joinBackward, 63 | doc(ul(li(p("hi")), li(p("there")))))) 64 | 65 | it("joins list items", () => 66 | apply(doc(ul(li(p("hi")), li(p("there")))), joinBackward, 67 | doc(ul(li(p("hi"), p("there")))))) 68 | 69 | it("lifts out of a list at the start", () => 70 | apply(doc(ul(li(p("there")))), joinBackward, doc(p("there")))) 71 | 72 | it("joins lists before and after", () => 73 | apply(doc(ul(li(p("hi"))), p("there"), ul(li(p("x")))), joinBackward, 74 | doc(ul(li(p("hi")), li(p("there")), li(p("x")))))) 75 | 76 | it("deletes leaf nodes before", () => 77 | apply(doc(hr, p("there")), joinBackward, doc(p("there")))) 78 | 79 | it("lifts before it deletes", () => 80 | apply(doc(hr, blockquote(p("there"))), joinBackward, doc(hr, p("there")))) 81 | 82 | it("does nothing at start of doc", () => 83 | apply(doc(p("foo")), joinBackward, null)) 84 | 85 | it("can join single-textblock-child nodes", () => { 86 | let s = new Schema({ 87 | nodes: { 88 | text: {inline: true}, 89 | doc: {content: "block+"}, 90 | block: {content: "para"}, 91 | para: {content: "text*"} 92 | } 93 | }) 94 | let doc = s.node("doc", null, [ 95 | s.node("block", null, [s.node("para", null, [s.text("a")])]), 96 | s.node("block", null, [s.node("para", null, [s.text("b")])]) 97 | ]) 98 | let state = EditorState.create({doc, selection: TextSelection.near(doc.resolve(7))}) 99 | ist(joinBackward(state, tr => state = state.apply(tr))) 100 | ist(state.doc.toString(), "doc(block(para(\"ab\")))") 101 | }) 102 | 103 | it("doesn't return true on empty blocks that can't be deleted", () => 104 | apply(doc(p("a"), ul(li(p(""), ul(li("b"))))), joinBackward, null)) 105 | 106 | it("doesn't join surrounding nodes of different types", () => 107 | apply(doc(ul(li(p("a"))), p(""), ol(li(p("b")))), joinBackward, 108 | doc(ul(li(p("a")), li(p(""))), ol(li(p("b")))))) 109 | }) 110 | 111 | describe("joinTextblockBackward", () => { 112 | it("can join paragraphs", () => 113 | apply(doc(p("hi"), p("there")), joinTextblockBackward, doc(p("hithere")))) 114 | 115 | it("can join if second block is wrapped", () => 116 | apply(doc(p("hi"), ul(li(p("there")))), joinTextblockBackward, doc(p("hithere")))) 117 | 118 | it("can join if first block is wrapped", () => 119 | apply(doc(blockquote(p("hi")), p("there")), joinTextblockBackward, doc(blockquote(p("hithere"))))) 120 | 121 | it("does nothing at start of doc", () => 122 | apply(doc(p("foo")), joinTextblockBackward, null)) 123 | 124 | it("can join if inside a nested block", () => 125 | apply(doc(blockquote(blockquote(p("hi")), p("there"))), 126 | joinTextblockBackward, 127 | doc(blockquote(blockquote(p("hithere")))))) 128 | }) 129 | 130 | describe("selectNodeBackward", () => { 131 | it("selects the node before the cut", () => 132 | apply(doc(blockquote(p("a")), blockquote(p("b"))), selectNodeBackward, 133 | doc("", blockquote(p("a")), blockquote(p("b"))))) 134 | 135 | it("does nothing when not at the start of the textblock", () => 136 | apply(doc(p("ab")), selectNodeBackward, null)) 137 | }) 138 | 139 | describe("deleteSelection", () => { 140 | it("deletes part of a text node", () => 141 | apply(doc(p("foo")), deleteSelection, doc(p("fo")))) 142 | 143 | it("can delete across blocks", () => 144 | apply(doc(p("foo"), p("bar")), deleteSelection, doc(p("fr")))) 145 | 146 | it("deletes node selections", () => 147 | apply(doc(p("foo"), "", hr()), deleteSelection, doc(p("foo")))) 148 | 149 | it("moves selection after deleted node", () => 150 | apply(doc(p("a"), "", p("b"), blockquote(p("c"))), deleteSelection, 151 | doc(p("a"), blockquote(p("c"))))) 152 | 153 | it("moves selection before deleted node at end", () => 154 | apply(doc(p("a"), "", p("b")), deleteSelection, 155 | doc(p("a")))) 156 | }) 157 | 158 | describe("joinForward", () => { 159 | it("joins two textblocks", () => 160 | apply(doc(p("foo"), p("bar")), joinForward, doc(p("foobar")))) 161 | 162 | it("keeps type of second node when first is empty", () => 163 | apply(doc(p("x"), p(""), h1("hi")), joinForward, doc(p("x"), h1("hi")))) 164 | 165 | it("clears nodes from joined node that wouldn't be allowed in target node", () => 166 | apply(doc(pre("foo"), p("bar", img())), joinForward, doc(pre("foobar")))) 167 | 168 | it("does nothing at the end of the document", () => 169 | apply(doc(p("foo")), joinForward, null)) 170 | 171 | it("deletes a leaf node after the current block", () => 172 | apply(doc(p("foo"), hr(), p("bar")), joinForward, doc(p("foo"), p("bar")))) 173 | 174 | it("pulls the next block into the current list item", () => 175 | apply(doc(ul(li(p("a")), li(p("b")))), joinForward, 176 | doc(ul(li(p("a"), p("b")))))) 177 | 178 | it("joins two blocks inside of a list item", () => 179 | apply(doc(ul(li(p("a"), p("b")))), joinForward, 180 | doc(ul(li(p("ab")))))) 181 | 182 | it("pulls the next block into a blockquote", () => 183 | apply(doc(blockquote(p("foo")), p("bar")), joinForward, 184 | doc(blockquote(p("foo"), p("bar"))))) 185 | 186 | it("joins two blockquotes", () => 187 | apply(doc(blockquote(p("hi")), blockquote(p("there"))), joinForward, 188 | doc(blockquote(p("hi"), p("there"))))) 189 | 190 | it("pulls the next block outside of a wrapping blockquote", () => 191 | apply(doc(p("foo"), blockquote(p("bar"))), joinForward, 192 | doc(p("foo"), p("bar")))) 193 | 194 | it("joins two lists", () => 195 | apply(doc(ul(li(p("hi"))), ul(li(p("there")))), joinForward, 196 | doc(ul(li(p("hi")), li(p("there")))))) 197 | 198 | it("does nothing in a nested node at the end of the document", () => 199 | apply(doc(ul(li(p("there")))), joinForward, 200 | null)) 201 | 202 | it("deletes a leaf node at the end of the document", () => 203 | apply(doc(p("there"), hr()), joinForward, 204 | doc(p("there")))) 205 | 206 | it("moves before it deletes a leaf node", () => 207 | apply(doc(blockquote(p("there")), hr()), joinForward, 208 | doc(blockquote(p("there"), hr())))) 209 | 210 | it("does nothing when it can't join", () => 211 | apply(doc(p("foo"), ul(li(p("bar"), ul(li(p("baz")))))), joinForward, 212 | null)) 213 | }) 214 | 215 | describe("joinTextblockForward", () => { 216 | it("can join paragraphs", () => 217 | apply(doc(p("hi"), p("there")), joinTextblockForward, doc(p("hithere")))) 218 | 219 | it("can join if second block is wrapped", () => 220 | apply(doc(p("hi"), ul(li(p("there")))), joinTextblockForward, doc(p("hithere")))) 221 | 222 | it("can join if first block is wrapped", () => 223 | apply(doc(blockquote(p("hi")), p("there")), joinTextblockForward, doc(blockquote(p("hithere"))))) 224 | 225 | it("does nothing at end of doc", () => 226 | apply(doc(p("foo")), joinTextblockForward, null)) 227 | }) 228 | 229 | describe("selectNodeForward", () => { 230 | it("selects the next node", () => 231 | apply(doc(p("foo"), ul(li(p("bar"), ul(li(p("baz")))))), selectNodeForward, 232 | doc(p("foo"), "", ul(li(p("bar"), ul(li(p("baz")))))))) 233 | 234 | it("does nothing at end of document", () => 235 | apply(doc(p("foo")), selectNodeForward, null)) 236 | }) 237 | 238 | describe("joinUp", () => { 239 | it("joins identical parent blocks", () => 240 | apply(doc(blockquote(p("foo")), blockquote(p("bar"))), joinUp, 241 | doc(blockquote(p("foo"), p("bar"))))) 242 | 243 | it("does nothing in the first block", () => 244 | apply(doc(blockquote(p("foo")), blockquote(p("bar"))), joinUp, null)) 245 | 246 | it("joins lists", () => 247 | apply(doc(ul(li(p("foo"))), ul(li(p("bar")))), joinUp, 248 | doc(ul(li(p("foo")), li(p("bar")))))) 249 | 250 | it("joins list items", () => 251 | apply(doc(ul(li(p("foo")), li(p("bar")))), joinUp, 252 | doc(ul(li(p("foo"), p("bar")))))) 253 | 254 | it("doesn't look at ancestors when a block is selected", () => 255 | apply(doc(ul(li(p("foo")), li("", p("bar")))), joinUp, null)) 256 | 257 | it("can join selected block nodes", () => 258 | apply(doc(ul(li(p("foo")), "", li(p("bar")))), joinUp, 259 | doc(ul("", li(p("foo"), p("bar")))))) 260 | }) 261 | 262 | describe("joinDown", () => { 263 | it("joins parent blocks", () => 264 | apply(doc(blockquote(p("foo")), blockquote(p("bar"))), joinDown, 265 | doc(blockquote(p("foo"), p("bar"))))) 266 | 267 | it("doesn't join with the block before", () => 268 | apply(doc(blockquote(p("foo")), blockquote(p("bar"))), joinDown, null)) 269 | 270 | it("joins lists", () => 271 | apply(doc(ul(li(p("foo"))), ul(li(p("bar")))), joinDown, 272 | doc(ul(li(p("foo")), li(p("bar")))))) 273 | 274 | it("joins list items", () => 275 | apply(doc(ul(li(p("foo")), li(p("bar")))), joinDown, 276 | doc(ul(li(p("foo"), p("bar")))))) 277 | 278 | it("doesn't look at parent nodes of a selected node", () => 279 | apply(doc(ul(li("", p("foo")), li(p("bar")))), joinDown, null)) 280 | 281 | it("can join selected nodes", () => 282 | apply(doc(ul("", li(p("foo")), li(p("bar")))), joinDown, 283 | doc(ul("", li(p("foo"), p("bar")))))) 284 | }) 285 | 286 | describe("lift", () => { 287 | it("lifts out of a parent block", () => 288 | apply(doc(blockquote(p("foo"))), lift, doc(p("foo")))) 289 | 290 | it("splits the parent block when necessary", () => 291 | apply(doc(blockquote(p("foo"), p("bar"), p("baz"))), lift, 292 | doc(blockquote(p("foo")), p("bar"), blockquote(p("baz"))))) 293 | 294 | it("can lift out of a list", () => 295 | apply(doc(ul(li(p("foo")))), lift, doc(p("foo")))) 296 | 297 | it("does nothing for a top-level block", () => 298 | apply(doc(p("foo")), lift, null)) 299 | 300 | it("lifts out of the innermost parent", () => 301 | apply(doc(blockquote(ul(li(p("foo"))))), lift, 302 | doc(blockquote(p("foo"))))) 303 | 304 | it("can lift a node selection", () => 305 | apply(doc(blockquote("", ul(li(p("foo"))))), lift, 306 | doc("", ul(li(p("foo")))))) 307 | 308 | it("lifts out of a nested list", () => 309 | apply(doc(ul(li(p("one"), ul(li(p("sub1")), li(p("sub2")))), li(p("two")))), lift, 310 | doc(ul(li(p("one"), p("sub1"), ul(li(p("sub2")))), li(p("two")))))) 311 | }) 312 | 313 | describe("wrapIn", () => { 314 | let wrap = wrapIn(schema.nodes.blockquote) 315 | 316 | it("can wrap a paragraph", () => 317 | apply(doc(p("foo")), wrap, doc(blockquote(p("foo"))))) 318 | 319 | it("wraps multiple pragraphs", () => 320 | apply(doc(p("foo"), p("bar"), p("baz"), p("quux")), wrap, 321 | doc(blockquote(p("foo"), p("bar"), p("baz")), p("quux")))) 322 | 323 | it("wraps an already wrapped node", () => 324 | apply(doc(blockquote(p("foo"))), wrap, 325 | doc(blockquote(blockquote(p("foo")))))) 326 | 327 | it("can wrap a node selection", () => 328 | apply(doc("", ul(li(p("foo")))), wrap, 329 | doc(blockquote(ul(li(p("foo"))))))) 330 | }) 331 | 332 | describe("splitBlock", () => { 333 | it("splits a paragraph at the end", () => 334 | apply(doc(p("foo")), splitBlock, doc(p("foo"), p()))) 335 | 336 | it("split a pragraph in the middle", () => 337 | apply(doc(p("foobar")), splitBlock, doc(p("foo"), p("bar")))) 338 | 339 | it("splits a paragraph from a heading", () => 340 | apply(doc(h1("foo")), splitBlock, doc(h1("foo"), p()))) 341 | 342 | it("splits a heading in two when in the middle", () => 343 | apply(doc(h1("foobar")), splitBlock, doc(h1("foo"), h1("bar")))) 344 | 345 | it("deletes selected content", () => 346 | apply(doc(p("foobar")), splitBlock, doc(p("fo"), p("ar")))) 347 | 348 | it("splits a parent block when a node is selected", () => 349 | apply(doc(ol(li(p("a")), "", li(p("b")), li(p("c")))), splitBlock, 350 | doc(ol(li(p("a"))), ol(li(p("b")), li(p("c")))))) 351 | 352 | it("doesn't split the parent block when at the start", () => 353 | apply(doc(ol("", li(p("a")), li(p("b")), li(p("c")))), splitBlock, null)) 354 | 355 | it("splits off a normal paragraph when splitting at the start of a textblock", () => 356 | apply(doc(h1("foo")), splitBlock, doc(p(), h1("foo")))) 357 | 358 | const hSchema = new Schema({ 359 | nodes: schema.spec.nodes.update("heading", { 360 | content: "inline*" 361 | }).update("doc", { 362 | content: "heading block*" 363 | }).addToEnd("span", { 364 | inline: true, 365 | group: "inline", 366 | content: "inline*" 367 | }) 368 | }) 369 | function hDoc(a: number) { 370 | const hDoc = hSchema.node("doc", null, [ 371 | hSchema.node("heading", {level: 1}, hSchema.text("foobar")) 372 | ]) 373 | ;(hDoc as any).tag = {a} 374 | return hDoc 375 | } 376 | 377 | it("splits a paragraph from a heading when a double heading isn't allowed", () => 378 | apply(hDoc(4), splitBlock, 379 | hSchema.node("doc", null, [ 380 | hSchema.node("heading", {level: 1}, hSchema.text("foo")), 381 | hSchema.node("paragraph", null, hSchema.text("bar")) 382 | ]))) 383 | 384 | it("won't try to reset the type of an empty leftover when the schema forbids it", () => 385 | apply(hDoc(1), splitBlock, 386 | hSchema.node("doc", null, [ 387 | hSchema.node("heading", {level: 1}), 388 | hSchema.node("paragraph", null, hSchema.text("foobar")) 389 | ]))) 390 | 391 | it("can split an inline node", () => { 392 | let d = hSchema.node("doc", null, [ 393 | hSchema.node("heading", {level: 1}, [ 394 | hSchema.node("span", null, hSchema.text("abcd"))])]) 395 | ;(d as any).tag = {a: 4} 396 | apply(d, splitBlock, hSchema.node("doc", null, [ 397 | hSchema.node("heading", {level: 1}, hSchema.node("span", null, hSchema.text("ab"))), 398 | hSchema.node("paragraph", null, hSchema.node("span", null, hSchema.text("cd"))) 399 | ])) 400 | }) 401 | 402 | it("prefers textblocks", () => { 403 | let s = new Schema({nodes: { 404 | text: {}, 405 | para: {content: "text*", toDOM() { return ["p", 0] }}, 406 | section: {content: "para+", toDOM() { return ["section", 0] }}, 407 | doc: {content: "para* section*"} 408 | }}) 409 | let doc = s.node("doc", null, [s.node("para", null, [s.text("hello")])]) 410 | ;(doc as any).tag = {a: 3} 411 | apply(doc, splitBlock, 412 | s.node("doc", null, [s.node("para", null, [s.text("he")]), 413 | s.node("para", null, [s.text("llo")])])) 414 | }) 415 | }) 416 | 417 | describe("splitBlockAs", () => { 418 | it("splits to the appropriate type", () => 419 | apply(doc(p("one")), splitBlockAs(n => ({type: n.type.schema.nodes.heading, attrs: {level: 1}})), 420 | doc(p("on"), h1("e")))) 421 | 422 | it("passes an end-of-block flag", () => 423 | apply(doc(p("one")), 424 | splitBlockAs((n, e) => e ? {type: n.type.schema.nodes.code_block} : null), 425 | doc(p("one"), pre("")))) 426 | }) 427 | 428 | describe("splitBlockKeepMarks", () => { 429 | it("keeps marks when used after marked text", () => { 430 | let state = mkState(doc(p(strong("foo"), "bar"))) 431 | splitBlockKeepMarks(state, tr => state = state.apply(tr)) 432 | ist(state.storedMarks!.length, 1) 433 | }) 434 | 435 | it("preserves the stored marks", () => { 436 | let state = mkState(doc(p(em("foo")))) 437 | toggleMark(schema.marks.strong)(state, tr => state = state.apply(tr)) 438 | splitBlockKeepMarks(state, tr => state = state.apply(tr)) 439 | ist(state.storedMarks!.length, 2) 440 | }) 441 | }) 442 | 443 | describe("liftEmptyBlock", () => { 444 | it("splits the parent block when there are sibling before", () => 445 | apply(doc(blockquote(p("foo"), p(""), p("bar"))), liftEmptyBlock, 446 | doc(blockquote(p("foo")), blockquote(p(), p("bar"))))) 447 | 448 | it("lifts the last child out of its parent", () => 449 | apply(doc(blockquote(p("foo"), p(""))), liftEmptyBlock, 450 | doc(blockquote(p("foo")), p()))) 451 | 452 | it("lifts an only child", () => 453 | apply(doc(blockquote(p("foo")), blockquote(p(""))), liftEmptyBlock, 454 | doc(blockquote(p("foo")), p("")))) 455 | 456 | it("does not violate schema constraints", () => 457 | apply(doc(ul(li(p("foo"), blockquote(p("bar"))))), liftEmptyBlock, null)) 458 | 459 | it("lifts out of a list", () => 460 | apply(doc(ul(li(p("hi")), li(p("")))), liftEmptyBlock, 461 | doc(ul(li(p("hi"))), p()))) 462 | }) 463 | 464 | describe("createParagraphNear", () => { 465 | it("creates a paragraph before a selected node at the start of the doc", () => 466 | apply(doc("", hr(), hr()), createParagraphNear, doc(p(), hr(), hr()))) 467 | 468 | it("creates a paragraph after a lone selected node", () => 469 | apply(doc("", hr()), createParagraphNear, doc(hr(), p()))) 470 | 471 | it("creates a paragraph after selected nodes not at the start of the doc", () => 472 | apply(doc(p(), "", hr()), createParagraphNear, doc(p(), hr(), p()))) 473 | }) 474 | 475 | describe("setBlockType", () => { 476 | let setHeading = setBlockType(schema.nodes.heading, {level: 1}) 477 | let setPara = setBlockType(schema.nodes.paragraph) 478 | let setCode = setBlockType(schema.nodes.code_block) 479 | 480 | it("can change the type of a paragraph", () => 481 | apply(doc(p("foo")), setHeading, doc(h1("foo")))) 482 | 483 | it("can change the type of a code block", () => 484 | apply(doc(pre("foo")), setHeading, doc(h1("foo")))) 485 | 486 | it("can make a heading into a paragraph", () => 487 | apply(doc(h1("foo")), setPara, doc(p("foo")))) 488 | 489 | it("preserves marks", () => 490 | apply(doc(h1("foo", em("bar"))), setPara, doc(p("foo", em("bar"))))) 491 | 492 | it("acts on node selections", () => 493 | apply(doc("", h1("foo")), setPara, doc(p("foo")))) 494 | 495 | it("can make a block a code block", () => 496 | apply(doc(h1("foo")), setCode, doc(pre("foo")))) 497 | 498 | it("clears marks when necessary", () => 499 | apply(doc(p("foo", em("bar"))), setCode, doc(pre("foobar")))) 500 | 501 | it("acts on multiple blocks when possible", () => 502 | apply(doc(p("abc"), p("def"), ul(li(p("ghi"), p("jkl")))), setCode, 503 | doc(pre("abc"), pre("def"), ul(li(p("ghi"), pre("jkl")))))) 504 | 505 | it("returns false when all textblocks in the selection are already this type", () => 506 | apply(doc(pre("abc"), pre("def")), setCode, null)) 507 | 508 | it("returns false when the selected blocks can't be changed", () => 509 | apply(doc(ul(p("abc"), p("def"))), setCode, null)) 510 | }) 511 | 512 | describe("selectParentNode", () => { 513 | it("selects the whole textblock", () => 514 | apply(doc(ul(li(p("foo"), p("bar")), li(p("baz")))), selectParentNode, 515 | doc(ul(li(p("foo"), "", p("bar")), li(p("baz")))))) 516 | 517 | it("goes one level up when on a block", () => 518 | apply(doc(ul(li(p("foo"), "", p("bar")), li(p("baz")))), selectParentNode, 519 | doc(ul("", li(p("foo"), p("bar")), li(p("baz")))))) 520 | 521 | it("goes further up", () => 522 | apply(doc(ul("", li(p("foo"), p("bar")), li(p("baz")))), selectParentNode, 523 | doc("", ul(li(p("foo"), p("bar")), li(p("baz")))))) 524 | 525 | it("stops at the top level", () => 526 | apply(doc("", ul(li(p("foo"), p("bar")), li(p("baz")))), selectParentNode, 527 | doc("", ul(li(p("foo"), p("bar")), li(p("baz")))))) 528 | }) 529 | 530 | describe("autoJoin", () => { 531 | it("joins lists when deleting a paragraph between them", () => 532 | apply(doc(ul(li(p("a"))), "", p("b"), ul(li(p("c")))), 533 | autoJoin(deleteSelection, ["bullet_list"]), 534 | doc(ul(li(p("a")), li(p("c")))))) 535 | 536 | it("doesn't join lists when deleting an item inside of them", () => 537 | apply(doc(ul(li(p("a")), "", li(p("b"))), ul(li(p("c")))), 538 | autoJoin(deleteSelection, ["bullet_list"]), 539 | doc(ul(li(p("a"))), ul(li(p("c")))))) 540 | 541 | it("joins lists when wrapping a paragraph after them in a list", () => 542 | apply(doc(ul(li(p("a"))), p("b")), 543 | autoJoin(wrapIn(schema.nodes.bullet_list), ["bullet_list"]), 544 | doc(ul(li(p("a")), li(p("b")))))) 545 | 546 | it("joins lists when wrapping a paragraph between them in a list", () => 547 | apply(doc(ul(li(p("a"))), p("b"), ul(li(p("c")))), 548 | autoJoin(wrapIn(schema.nodes.bullet_list), ["bullet_list"]), 549 | doc(ul(li(p("a")), li(p("b")), li(p("c")))))) 550 | 551 | it("joins lists when lifting a list between them", () => 552 | apply(doc(ul(li(p("a"))), blockquote("", ul(li(p("b")))), ul(li(p("c")))), 553 | autoJoin(lift, ["bullet_list"]), 554 | doc(ul(li(p("a")), li(p("b")), li(p("c")))))) 555 | }) 556 | 557 | describe("toggleMark", () => { 558 | let toggleEm = toggleMark(schema.marks.em), toggleStrong = toggleMark(schema.marks.strong) 559 | let toggleEm2 = toggleMark(schema.marks.em, null, {removeWhenPresent: false}) 560 | 561 | it("can add a mark", () => { 562 | apply(doc(p("one two")), toggleEm, 563 | doc(p("one ", em("two")))) 564 | }) 565 | 566 | it("can stack marks", () => { 567 | apply(doc(p("one tw", strong("o"))), toggleEm, 568 | doc(p("one ", em("tw", strong("o"))))) 569 | }) 570 | 571 | it("can remove marks", () => { 572 | apply(doc(p(em("one two"))), toggleEm, 573 | doc(p(em("one "), "two"))) 574 | }) 575 | 576 | it("can toggle pending marks", () => { 577 | let state = mkState(doc(p("hello"))) 578 | toggleEm(state, tr => state = state.apply(tr)) 579 | ist(state.storedMarks!.length, 1) 580 | toggleStrong(state, tr => state = state.apply(tr)) 581 | ist(state.storedMarks!.length, 2) 582 | toggleEm(state, tr => state = state.apply(tr)) 583 | ist(state.storedMarks!.length, 1) 584 | }) 585 | 586 | it("skips whitespace at selection ends when adding marks", () => { 587 | apply(doc(p("one two three")), toggleEm, 588 | doc(p("one ", em("two"), " three"))) 589 | }) 590 | 591 | it("doesn't skip whitespace-only selections", () => { 592 | apply(doc(p("one two")), toggleEm, 593 | doc(p("one", em(" "), "two"))) 594 | }) 595 | 596 | it("includes whitespace when asked", () => { 597 | apply(doc(p("one two three")), toggleMark(schema.marks.em, null, {includeWhitespace: true}), 598 | doc(p("one", em(" two "), "three"))) 599 | }) 600 | 601 | it("can add marks with remove-when-present off", () => { 602 | apply(doc(p("", em("one"), " two")), toggleEm2, 603 | doc(p(em("one two")))) 604 | apply(doc(p("three")), toggleEm2, 605 | doc(p(em("three")))) 606 | }) 607 | 608 | it("can remove marks with remove-when-present off", () => { 609 | apply(doc(p(em("one two"))), toggleEm2, 610 | doc(p(em("o"), "ne two"))) 611 | }) 612 | 613 | it("can remove marks with trailing space when remove-when-present is off", () => { 614 | apply(doc(p(em("one two"), " three")), toggleEm2, 615 | doc(p(em("o"), "ne two three"))) 616 | }) 617 | 618 | function footnoteSchema() { 619 | let schema = new Schema({ 620 | nodes: { 621 | text: {inline: true}, 622 | doc: {content: "para+"}, 623 | footnote: {content: "text*", atom: true, inline: true}, 624 | para: {content: "(text | footnote)*"}, 625 | }, 626 | marks: { 627 | em: {} 628 | } 629 | }) 630 | return builders(schema) 631 | } 632 | 633 | it("enters inline atoms by default", () => { 634 | let {doc, para, footnote, em, schema} = footnoteSchema() 635 | apply(doc(para("hello", footnote("okay"), "")), 636 | toggleMark(schema.marks.em), 637 | doc(para("h", em("ello", footnote(em("okay")))))) 638 | }) 639 | 640 | it("doesn't enter inline atoms to add a mark when told not to", () => { 641 | let {doc, para, footnote, em, schema} = footnoteSchema() 642 | apply(doc(para("hello", footnote("okay"), "")), 643 | toggleMark(schema.marks.em, null, {enterInlineAtoms: false}), 644 | doc(para("h", em("ello", footnote("okay"))))) 645 | }) 646 | 647 | it("can apply styles inside inline atoms", () => { 648 | let {doc, para, footnote, em, schema} = footnoteSchema() 649 | apply(doc(para("hello", footnote("okay"))), 650 | toggleMark(schema.marks.em, null, {enterInlineAtoms: false}), 651 | doc(para("hello", footnote("o", em("kay"))))) 652 | }) 653 | 654 | it("can add a mark even if already active inside an inline atom", () => { 655 | let {doc, para, footnote, em, schema} = footnoteSchema() 656 | apply(doc(para("hello", footnote(em("okay")), "")), 657 | toggleMark(schema.marks.em, null, {enterInlineAtoms: false}), 658 | doc(para("h", em("ello", footnote(em("okay")))))) 659 | }) 660 | 661 | it("doesn't enter inline atoms to remove a mark when told not to", () => { 662 | let {doc, para, footnote, em, schema} = footnoteSchema() 663 | apply(doc(para(em("hello", footnote(em("okay")), ""))), 664 | toggleMark(schema.marks.em, null, {enterInlineAtoms: false}), 665 | doc(para(em("h"), "ello", footnote(em("okay"))))) 666 | }) 667 | }) 668 | 669 | describe('selectTextblockStart and selectTextblockEnd', () => { 670 | it("can move the cursor when the selection is empty", () => { 671 | apply(doc(p("one two")), selectTextblockStart, 672 | doc(p("one two"))) 673 | 674 | apply(doc(p("one two")), selectTextblockEnd, 675 | doc(p("one two"))) 676 | }) 677 | 678 | it("can move the cursor when the selection is not empty", () => { 679 | apply(doc(p("one two")), selectTextblockStart, 680 | doc(p("one two"))) 681 | 682 | apply(doc(p("one two")), selectTextblockEnd, 683 | doc(p("one two"))) 684 | }) 685 | 686 | it("can move the cursor when the selection crosses multiple text blocks", () => { 687 | apply(doc(p("one two"), p('three four')), selectTextblockStart, 688 | doc(p("one two"), p('three four'))) 689 | 690 | apply(doc(p("one two"), p('three four')), selectTextblockEnd, 691 | doc(p("one two"), p('three four'))) 692 | }) 693 | }) 694 | --------------------------------------------------------------------------------