Repository: ProseMirror/prosemirror-transform Branch: master Commit: 662b7a937baf Files: 26 Total size: 167.3 KB Directory structure: gitextract_zrcfeuwv/ ├── .gitignore ├── .npmignore ├── .npmrc ├── .tern-project ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src/ │ ├── README.md │ ├── attr_step.ts │ ├── index.ts │ ├── map.ts │ ├── mark.ts │ ├── mark_step.ts │ ├── replace.ts │ ├── replace_step.ts │ ├── step.ts │ ├── structure.ts │ └── transform.ts └── test/ ├── test-mapping.ts ├── test-replace_step.ts ├── test-step.ts ├── test-structure.ts ├── test-trans.ts └── trans.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /node_modules .tern-port /dist /notes.txt /test/*.js ================================================ FILE: .npmignore ================================================ /node_modules .tern-port /test ================================================ FILE: .npmrc ================================================ package-lock=false ================================================ FILE: .tern-project ================================================ { "libs": ["browser"], "plugins": { "node": {}, "complete_strings": {}, "es_modules": {} } } ================================================ FILE: CHANGELOG.md ================================================ ## 1.12.0 (2026-03-30) ### Bug fixes Handle deletions that go from the beginning of one textblock to the beginning of another specially, deleting the selected textblocks entirely. ### New features `ReplaceStep.MAP_BIAS` can now be adjusted to change the way insertion steps are mapped over other insertions at the same place. The default behavior remains the same for the sake of backwards compatibility. ## 1.11.0 (2026-01-21) ### New features The new `Tranform.changedRange` method gives you a document range that includes all changes made by the transform/transaction. ## 1.10.5 (2025-11-11) ### Bug fixes Fix a bug in `liftTarget` that caused it to fail to properly check content constraints when a lift would split a node. ## 1.10.4 (2025-04-22) ### Bug fixes Fix a bug that caused mapping `ReplaceStep`s to reset their `structure` flag. Align `removeNodeMark`'s behavior to that of `removeMark`. When passing in a node type, it now removes all occurrences of that type. ## 1.10.3 (2025-03-04) ### Bug fixes Make sure `Mapping.appendMap` doesn't mutate shared arrays passed to its constructor. ## 1.10.2 (2024-10-11) ### Bug fixes Allow `Transform.join` to clear incompatible inline content from the node after the join. ## 1.10.1 (2024-10-10) ### Bug fixes Fix an issue where a `deleteRange` heuristic could produce unexpected deletion shapes. Make `Transform.join` convert between newlines and line break replacement nodes when necessary. ## 1.10.0 (2024-08-13) ### New features `setBlockType` can now take a function that computes attributes for the new nodes, instead of a static attribute object. ## 1.9.0 (2024-05-06) ### Bug fixes Fix an issue in `ReplaceAroundStep.map` that broke mapping steps that wrapped content over steps that inserted content at the start of the step. ### New features `setBlockMarkup` now uses the linebreak equivalent node defined in the schema. ## 1.8.0 (2023-10-01) ### New features The new `DocAttrStep` can be used to set attributes on the document's top node. `Transform.setDocAttribute` can be used to create a `DocAttrStep` in a transform. ## 1.7.5 (2023-08-22) ### Bug fixes Fix a failure in `replaceRange` to drop wrapper nodes when the same wrapper is already present. ## 1.7.4 (2023-07-28) ### Bug fixes When using `setBlockType` to convert a code block to a type of node that doesn't contain code, replace newlines with spaces. ## 1.7.3 (2023-06-01) ### Bug fixes Fix a bug in `canSplit` that made it interpret the `typesAfter` argument incorrectly on splits of depth greater than 1. ## 1.7.2 (2023-05-17) ### Bug fixes Include CommonJS type declarations in the package to please new TypeScript resolution settings. ## 1.7.1 (2023-01-20) ### Bug fixes Keep content in isolating nodes inside their parent when fitting a replace step. `Transform.setNodeMarkup` will no longer clear the node's marks when it isn't given an array of marks. ## 1.7.0 (2022-08-16) ### New features The new `AttrStep` (and `Transform.setNodeAttribute`) can be used to set individual attributes on a node. `AddNodeMarkStep` and `RemoveNodeMarkStep` can now be used to add and remove marks on individual nodes. `Transform.addNodeMark`/`removeNodeMark` provide an interface to these in transform objects. ## 1.6.0 (2022-06-01) ### Bug fixes Allow replace steps to be mapped through changes that delete content next to their start and end points, as long as no delete spans across those points. ### New features `MapResult` objects now provide information about whether the tokens before, after, and around the position were deleted. ## 1.5.0 (2022-05-30) ### New features Include TypeScript type declarations. ## 1.4.2 (2022-04-05) ### Bug fixes Make replacements that span to the end of a textblock more consistent with those ending in the middle of the block. ## 1.4.1 (2022-03-31) ### Bug fixes `replaceRange` will now close multiple defining parent nodes when appropriate. ## 1.4.0 (2022-03-21) ### New features Node specs can now use the `definingForContent` and `definingAsContext` properties to opt in to specific parts of the existing `defining` behavior. ## 1.3.4 (2022-02-04) ### Bug fixes Make sure that constructing an empty `StepMap` returns `StepMap.empty`. ## 1.3.3 (2021-09-29) ### Bug fixes Fix an inconsistency in `deleteRange` where it would delete the parent node for ranges covering all textblocks in a given parent. ## 1.3.2 (2021-04-06) ### Bug fixes Fix a regression in `dropPoint` that caused it to dereference undefined in some circumstances. ## 1.3.1 (2021-04-01) ### Bug fixes Fix a crash in `Transform.replaceRange` when called with under specific circumstances. Fix an issue where `dropPoint` could return an invalid drop point. ## 1.3.0 (2021-03-31) ### New features The various properties of subclasses of `Step` (`ReplaceStep`, `ReplaceAroundStep`, `AddMarkStep`, and `RemoveMarkStep`) are now part of the public interface. ## 1.2.12 (2021-02-20) ### Bug fixes Fix a bug where merging replace steps with the `structure` flag could create steps that couldn't be applied. ## 1.2.11 (2021-02-06) ### Bug fixes Fix an issue in `Transform.removeMark` where the mark type was passed through to be removed instead of the mark itself. ## 1.2.10 (2021-02-05) ### Bug fixes Fix an issue where `Transform.removeMark`, when given a mark type, would only remove the first instance from nodes that had multiple marks of the type. ## 1.2.9 (2021-01-19) ### Bug fixes Fix an issue where `AddMarkStep` would mark inline nodes with content. ## 1.2.8 (2020-08-11) ### Bug fixes Fix an issue where fitting a slice at the top level of the document would, in some circumstances, crash. ## 1.2.7 (2020-07-09) ### Bug fixes Fix an issue where in some cases replace fitting would insert an additional bogus node when fitting content into nodes with strict content restrictions. ## 1.2.6 (2020-06-10) ### Bug fixes Fix an issue where creating a replace step would sometimes fail due to unmatchable close tokens after the replaced range. ## 1.2.5 (2020-04-15) ### Bug fixes Rewrite the slice-fitting code used by `replaceStep` to handle a few more corner cases. ## 1.2.4 (2020-03-10) ### Bug fixes Fix `joinPoint` to return check whether the parent node allows a given join. ## 1.2.3 (2019-12-03) ### Bug fixes Fix a crash in `deleteRange` that occurred when deleting a region that spans to the ends of two nodes at different depths. ## 1.2.2 (2019-11-20) ### Bug fixes Rename ES module files to use a .js extension, since Webpack gets confused by .mjs ## 1.2.1 (2019-11-19) ### Bug fixes The file referred to in the package's `module` field now is compiled down to ES5. ## 1.2.0 (2019-11-08) ### New features Add a `module` field to package json file. ## 1.1.6 (2019-11-01) ### Bug fixes Fixes an issue where deleting a range from the start of block A to the end of block B would leave you with an empty block of type B. ## 1.1.5 (2019-10-02) ### Bug fixes Fix crash in building replace steps for open-ended slices with complicated node content expressions. ## 1.1.4 (2019-08-26) ### Bug fixes [`Mapping.invert`](https://prosemirror.net/docs/ref/#transform.Mapping.invert) is now part of the documented API (it was intented to be public from the start, but a typo prevented it from showing up in the docs). Fix an issue where a replace could needlessly drop content when the first node of the slice didn't fit the target context. `replaceRange` now more aggressively expands the replaced region if `replace` fails to place the slice. ## 1.1.3 (2018-07-03) ### Bug fixes Replacing from a code block into a paragraph that has marks, or similar scenarios that would join content with the wrong marks into a node, no longer crashes. ## 1.1.2 (2018-06-29) ### Bug fixes Fix issue where [`replaceRange`](https://prosemirror.net/docs/ref/#transform.Transform.replaceRange) might create invalid nodes. ## 1.1.1 (2018-06-26) ### Bug fixes Fix issues in the new [`dropPoint`](https://prosemirror.net/docs/ref/#transform.dropPoint) function. ## 1.1.0 (2018-06-20) ### New features [`Transform.getMirror`](https://prosemirror.net/docs/ref/#transform.Transform.getMirror), usable in obscure circumstances for inspecting the mirroring structure or a transform, is now a public method. New utility function [`dropPoint`](https://prosemirror.net/docs/ref/#transform.dropPoint), which tries to find a valid position for dropping a slice near a given document position. ## 1.0.10 (2018-04-15) ### Bug fixes [`Transform.setBlockType`](https://prosemirror.net/docs/ref/#transform.Transform.setBlockType) no longer drops marks on the nodes it updates. ## 1.0.9 (2018-04-05) ### Bug fixes Fix a bug that made [`replaceStep`](https://prosemirror.net/docs/ref/#transform.replaceStep) unable to generate wrapper nodes in some circumstances. ## 1.0.8 (2018-04-04) ### Bug fixes Fixes an issue where [`replaceStep`](https://prosemirror.net/docs/ref/#transform.replaceStep) could generate slices that internally violated the schema. ## 1.0.7 (2018-03-21) ### Bug fixes [`Transform.deleteRange`](https://prosemirror.net/docs/ref/#transform.Transform.deleteRange) will cover unmatched opening at the start of the deleted range. ## 1.0.6 (2018-03-15) ### Bug fixes Throw errors, rather than constructing invalid objects, when deserializing from invalid JSON data. ## 1.0.5 (2018-03-14) ### Bug fixes [`replaceStep`](https://prosemirror.net/docs/ref/#transform.replaceStep) will now return null rather than an empty step when it fails to place the slice. Avoid duplicating defining parent nodes in [`replaceRange`](https://prosemirror.net/docs/ref/#tranform.Transform.replaceRange). ## 1.0.4 (2018-02-23) ### Bug fixes Fix overeager closing of destination nodes when fitting a slice during replacing. ## 1.0.3 (2018-02-23) ### Bug fixes Fix a problem where slice-placing didn't handle content matches correctly and might generate invalid steps or fail to generate steps though a valid one exists. Allows adjacent nodes from an inserted slice to be placed in different parent nodes, allowing `Transform.replace` to create fits that weren't previously found. ## 1.0.2 (2018-01-24) ### Bug fixes Fixes a crash in [`replace`](https://prosemirror.net/docs/ref/#transform.Transform.replace). ## 1.0.1 (2017-11-10) ### Bug fixes The errors raised by [`Transform.step`](https://prosemirror.net/docs/ref/#transform.Transform.step) now properly inherit from Error. ## 1.0.0 (2017-10-13) ### Bug fixes When [`setBlockType`](https://prosemirror.net/docs/ref/#transform.Transform.setBlockType) comes across a textblock that can't be changed due to schema constraints, it skips it instead of failing. [`canSplit`](https://prosemirror.net/docs/ref/#transform.canSplit) now returns false when the requested split goes through isolating nodes. ## 0.24.0 (2017-09-25) ### Breaking changes The `setNodeType` method on transforms is now more descriptively called [`setNodeMarkup`](https://prosemirror.net/docs/ref/version/0.24.0.html#transform.Transform.setNodeMarkup). The old name will continue to work with a warning until the next release. ## 0.23.0 (2017-09-13) ### Breaking changes [`Step.toJSON`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.Step.toJSON) no longer has a default implementation. Steps no longer have an `offset` method. Map them through a map created with [`StepMap.offset`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.StepMap^offset) instead. The `clearMarkup` method on [`Transform`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.Transform) is no longer supported (you probably needed [`clearIncompatible`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.Transform.clearIncompatible) anyway). ### Bug fixes Pasting a list item at the start of a non-empty textblock now wraps the textblock in a list. Marks on open nodes at the left of a slice are no longer dropped by [`Transform.replace`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.Transform.replace). ### New features `StepMap` now has a static method [`offset`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.StepMap^offset), which can be used to create a map that offsets all positions by a given distance. Transform objects now have a [`clearIncompatible`](https://prosemirror.net/docs/ref/version/0.23.0.html#transform.Transform.clearIncompatible) method that can help make sure a node's content matches another node type. ## 0.22.2 (2017-07-06) ### Bug fixes Fix another bug in the way `canSplit` interpreted its `typesAfter` argument. ## 0.22.1 (2017-07-03) ### Bug fixes Fix crash in [`canSplit`](https://prosemirror.net/docs/ref/version/0.22.0.html#transform.canSplit) when an array containing null fields is passed as fourth argument. ## 0.22.0 (2017-06-29) ### Bug fixes [`canSplit`](https://prosemirror.net/docs/ref/version/0.22.0.html#transform.canSplit) now returns false when given custom after-split node types that don't match the content at that point. Fixes [`canLift`](https://prosemirror.net/docs/ref/version/0.22.0.html#transform.canLift) incorrectly returning null when lifting into an isolating node. ## 0.21.1 (2017-05-16) ### Bug fixes [`addMark`](https://prosemirror.net/docs/ref/version/0.21.0.html#transform.Transform.addMark) no longer assumes marks always [exclude](https://prosemirror.net/docs/ref/version/0.21.0.html#model.MarkSpec.excludes) only themselves. `replaceRange`](https://prosemirror.net/docs/ref/version/0.21.0.html#transform.Transform.replaceRange) and [`deleteRange`](https://prosemirror.net/docs/ref/version/0.21.0.html#transform.Transform.deleteRange) will no longer expand the range across isolating node boundaries. ## 0.20.0 (2017-04-03) ### Bug fixes Fixes issue where replacing would sometimes unexpectedly split nodes. ## 0.18.0 (2017-02-24) ### New features [`Transform.setNodeType`](https://prosemirror.net/docs/ref/version/0.18.0.html#transform.Transform.setNodeType) now takes an optional argument to set the new node's attributes. Steps now provide an [`offset`](https://prosemirror.net/docs/ref/version/0.18.0.html#transform.Step.offset) method, which makes it possible to create a copy the step with its position offset by a given amount. [`docChanged`](https://prosemirror.net/docs/ref/version/0.18.0.html#transform.Transform.docChanged) is now a property on the `Transform` class, rather than its `Transaction` subclass. `Mapping` instances now have [`invert`](https://prosemirror.net/docs/ref/version/0.18.0.html#transform.Mapping.invert) and [`appendMappingInverted`](https://prosemirror.net/docs/ref/version/0.18.0.html#transform.Mapping.appendMappingInverted) methods to make mapping through them in reverse easier. ## 0.15.0 (2016-12-10) ### Bug fixes Fix bug where pasted/inserted content would sometimes get incorrectly closed at the right side. ## 0.13.0 (2016-11-11) ### Bug fixes Fix issue where [`Transform.replace`](https://prosemirror.net/docs/ref/version/0.13.0.html#transform.Transform.replace) would, in specific circumstances, unneccessarily drop content. ### New features The new [`Transform`](https://prosemirror.net/docs/ref/version/0.13.0.html#transform.Transform) method [`replaceRange`](https://prosemirror.net/docs/ref/version/0.13.0.html#transform.Transform.replaceRange), [`replaceRangeWith`](https://prosemirror.net/docs/ref/version/0.13.0.html#transform.Transform.replaceRangeWith), and [`deleteRange`](https://prosemirror.net/docs/ref/version/0.13.0.html#transform.Transform.deleteRange) provide a way to replace and delete content in a 'do what I mean' way, automatically expanding the replaced region over empty parent nodes and including the parent nodes in the inserted content when appropriate. ## 0.12.1 (2016-11-01) ### Bug fixes Fix bug in `Transform.setBlockType` when used in a transform that already has steps. ## 0.12.0 (2016-10-21) ### Breaking changes Mapped positions now count as deleted when the token to the side specified by the `assoc` parameter is deleted, rather than when both tokens around them are deleted. (This is usually what you already wanted anyway.) ## 0.11.0 (2016-09-21) ### Breaking changes Moved into a separate module. The `Remapping` class was renamed to `Mapping` and works differently (simpler, grows in only one direction, and has provision for mapping through only a part of it). [`Transform`](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.Transform) objects now build up a `Mapping` instead of an array of maps. `PosMap` was renamed to [`StepMap`](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.StepMap) to make it clearer that this applies only to a single step (as opposed to [`Mapping`](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.Mapping). The arguments to [`canSplit`](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.canSplit) and [`split`](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.Transform.split) were changed to make it possible to specify multiple split-off node types for splits with a depth greater than 1. Rename `joinable` to [`canJoin`](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.canJoin). ### New features Steps can now be [merged](https://prosemirror.net/docs/ref/version/0.11.0.html#transform.Step.merge) in some circumstances, which can be useful when storing a lot of them. ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute - [Getting help](#getting-help) - [Submitting bug reports](#submitting-bug-reports) - [Contributing code](#contributing-code) ## Getting help Community discussion, questions, and informal bug reporting is done on the [discuss.ProseMirror forum](http://discuss.prosemirror.net). ## Submitting bug reports Report bugs on the [issue tracker](https://code.haverbeke.berlin/prosemirror/prosemirror/issues). Before reporting a bug, please read these pointers. - The issue tracker is for *bugs*, not requests for help. Questions should be asked on the [forum](http://discuss.prosemirror.net). - Include information about the version of the code that exhibits the problem. For browser-related issues, include the browser and browser version on which the problem occurred. - Mention very precisely what went wrong. "X is broken" is not a good bug report. What did you expect to happen? What happened instead? Describe the exact steps a maintainer has to take to make the problem occur. A screencast can be useful, but is no substitute for a textual description. - A great way to make it easy to reproduce your problem, if it can not be trivially reproduced on the website demos, is to submit a script that triggers the issue. ## Contributing code Code generated by a language model is not welcome in this project. Please don't waste my time with it. If you want to make a change that involves a significant overhaul of the code or introduces a user-visible new feature, create an [issue](https://code.haverbeke.berlin/prosemirror/prosemirror/issues/) first with your proposal. - Make sure you have a [Codeberg](https://codeberg.org/user/sign_up) or [GitHub](https://github.com/signup/free) account. - Use that to create a [code.haverbeke.berlin account](https://code.haverbeke.berlin/user/login). - Fork the relevant repository. - Create a local checkout of the code. You can use the [main repository](https://code.haverbeke.berlin/prosemirror/prosemirror) to easily check out all core modules. - Make your changes, and commit them - If your changes are easy to test or likely to regress, add tests in the relevant `test/` directory. Either put them in an existing `test-*.js` file, if they fit there, or add a new file. - Make sure all tests pass. Run `npm run test` to verify tests pass (you will need Node.js v6+). - Submit a pull request. Don't put more than one feature/fix in a single pull request. By contributing code to ProseMirror you - Agree to license the contributed code under the project's [MIT license](https://code.haverbeke.berlin/prosemirror/prosemirror/blob/main/LICENSE). - Confirm that you have the right to contribute and license the code in question. (Either you hold all rights on the code, or the rights holder has explicitly granted the right to use it like this, through a compatible open source license or through a direct agreement with you.) ### Coding standards - ES6 syntax, targeting an ES5 runtime (i.e. don't use library elements added by ES6, don't use ES7/ES.next syntax). - 2 spaces per indentation level, no tabs. - No semicolons except when necessary. - Follow the surrounding code when it comes to spacing, brace placement, etc. - Brace-less single-statement bodies are encouraged whenever they don't impact readability. - [getdocs-ts](https://code.haverbeke.berlin/marijn/getdocs-ts)-style doc comments above items that are part of the public API. - When documenting non-public items, you can put the type after a single colon, so that getdocs doesn't pick it up and add it to the API reference. - ProseMirror does *not* follow JSHint or JSLint prescribed style. Patches that try to 'fix' code to pass one of these linters will not be accepted. ================================================ FILE: LICENSE ================================================ Copyright (C) 2015-2017 by Marijn Haverbeke and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ **This repository has moved to https://code.haverbeke.berlin/prosemirror/prosemirror-transform** # prosemirror-transform [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://code.haverbeke.berlin/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**CHANGELOG**](https://code.haverbeke.berlin/prosemirror/prosemirror-transform/src/branch/main/CHANGELOG.md) ] This is a [core module](https://prosemirror.net/docs/ref/#transform) of [ProseMirror](https://prosemirror.net). ProseMirror is a well-behaved rich semantic content editor based on contentEditable, with support for collaborative editing and custom document schemas. This [module](https://prosemirror.net/docs/ref/#transform) implements document [transforms](https://prosemirror.net/docs/guide/#transform), which are used by the editor to treat changes as first-class values, which can be saved, shared, and reasoned about. The [project page](https://prosemirror.net) has more information, a number of [examples](https://prosemirror.net/examples/) and the [documentation](https://prosemirror.net/docs/). This code is released under an [MIT license](https://code.haverbeke.berlin/prosemirror/prosemirror/src/branch/main/LICENSE). There's a [forum](http://discuss.prosemirror.net) for general discussion and support requests, and the [Github bug tracker](https://code.haverbeke.berlin/prosemirror/prosemirror/issues) is the place to report issues. We aim to be an inclusive, welcoming community. To make that explicit, we have a [code of conduct](http://contributor-covenant.org/version/1/1/0/) that applies to communication around the project. ================================================ FILE: package.json ================================================ { "name": "prosemirror-transform", "version": "1.12.0", "description": "ProseMirror document transformations", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", "exports": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "sideEffects": false, "license": "MIT", "maintainers": [ { "name": "Marijn Haverbeke", "email": "marijn@haverbeke.berlin", "web": "http://marijnhaverbeke.nl" } ], "repository": { "type": "git", "url": "git+https://code.haverbeke.berlin/prosemirror/prosemirror-transform.git" }, "dependencies": { "prosemirror-model": "^1.21.0" }, "devDependencies": { "@prosemirror/buildhelper": "^0.1.5", "prosemirror-test-builder": "^1.0.0" }, "scripts": { "test": "pm-runtests", "prepare": "pm-buildhelper src/index.ts" } } ================================================ FILE: src/README.md ================================================ This module defines a way of modifying documents that allows changes to be recorded, replayed, and reordered. You can read more about transformations in [the guide](/docs/guide/#transform). ### Steps Transforming happens in `Step`s, which are atomic, well-defined modifications to a document. [Applying](#transform.Step.apply) a step produces a new document. Each step provides a [change map](#transform.StepMap) that maps positions in the old document to position in the transformed document. Steps can be [inverted](#transform.Step.invert) to create a step that undoes their effect, and chained together in a convenience object called a [`Transform`](#transform.Transform). @Step @StepResult @ReplaceStep @ReplaceAroundStep @AddMarkStep @RemoveMarkStep @AddNodeMarkStep @RemoveNodeMarkStep @AttrStep @DocAttrStep ### Position Mapping Mapping positions from one document to another by running through the [step maps](#transform.StepMap) produced by steps is an important operation in ProseMirror. It is used, for example, for updating the selection when the document changes. @Mappable @MapResult @StepMap @Mapping ### Document transforms Because you often need to collect a number of steps together to effect a composite change, ProseMirror provides an abstraction to make this easy. [State transactions](#state.Transaction) are a subclass of transforms. @Transform The following helper functions can be useful when creating transformations or determining whether they are even possible. @replaceStep @liftTarget @findWrapping @canSplit @canJoin @joinPoint @insertPoint @dropPoint ================================================ FILE: src/attr_step.ts ================================================ import {Fragment, Slice, Node, Schema} from "prosemirror-model" import {Step, StepResult} from "./step" import {StepMap, Mappable} from "./map" /// Update an attribute in a specific node. export class AttrStep extends Step { /// Construct an attribute step. constructor( /// The position of the target node. readonly pos: number, /// The attribute to set. readonly attr: string, // The attribute's new value. readonly value: any ) { super() } apply(doc: Node) { let node = doc.nodeAt(this.pos) if (!node) return StepResult.fail("No node at attribute step's position") let attrs = Object.create(null) for (let name in node.attrs) attrs[name] = node.attrs[name] attrs[this.attr] = this.value let updated = node.type.create(attrs, null, node.marks) return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1)) } getMap() { return StepMap.empty } invert(doc: Node) { return new AttrStep(this.pos, this.attr, doc.nodeAt(this.pos)!.attrs[this.attr]) } map(mapping: Mappable) { let pos = mapping.mapResult(this.pos, 1) return pos.deletedAfter ? null : new AttrStep(pos.pos, this.attr, this.value) } toJSON(): any { return {stepType: "attr", pos: this.pos, attr: this.attr, value: this.value} } static fromJSON(schema: Schema, json: any) { if (typeof json.pos != "number" || typeof json.attr != "string") throw new RangeError("Invalid input for AttrStep.fromJSON") return new AttrStep(json.pos, json.attr, json.value) } } Step.jsonID("attr", AttrStep) /// Update an attribute in the doc node. export class DocAttrStep extends Step { /// Construct an attribute step. constructor( /// The attribute to set. readonly attr: string, // The attribute's new value. readonly value: any ) { super() } apply(doc: Node) { let attrs = Object.create(null) for (let name in doc.attrs) attrs[name] = doc.attrs[name] attrs[this.attr] = this.value let updated = doc.type.create(attrs, doc.content, doc.marks) return StepResult.ok(updated) } getMap() { return StepMap.empty } invert(doc: Node) { return new DocAttrStep(this.attr, doc.attrs[this.attr]) } map(mapping: Mappable) { return this } toJSON(): any { return {stepType: "docAttr", attr: this.attr, value: this.value} } static fromJSON(schema: Schema, json: any) { if (typeof json.attr != "string") throw new RangeError("Invalid input for DocAttrStep.fromJSON") return new DocAttrStep(json.attr, json.value) } } Step.jsonID("docAttr", DocAttrStep) ================================================ FILE: src/index.ts ================================================ export {Transform} from "./transform" /// @internal export {TransformError} from "./transform" export {Step, StepResult} from "./step" export {joinPoint, canJoin, canSplit, insertPoint, dropPoint, liftTarget, findWrapping} from "./structure" export {StepMap, MapResult, Mapping, Mappable} from "./map" export {AddMarkStep, RemoveMarkStep, AddNodeMarkStep, RemoveNodeMarkStep} from "./mark_step" export {ReplaceStep, ReplaceAroundStep} from "./replace_step" export {AttrStep, DocAttrStep} from "./attr_step" import "./mark" export {replaceStep} from "./replace" ================================================ FILE: src/map.ts ================================================ /// There are several things that positions can be mapped through. /// Such objects conform to this interface. export interface Mappable { /// Map a position through this object. When given, `assoc` (should /// be -1 or 1, defaults to 1) determines with which side the /// position is associated, which determines in which direction to /// move when a chunk of content is inserted at the mapped position. map: (pos: number, assoc?: number) => number /// Map a position, and return an object containing additional /// information about the mapping. The result's `deleted` field tells /// you whether the position was deleted (completely enclosed in a /// replaced range) during the mapping. When content on only one side /// is deleted, the position itself is only considered deleted when /// `assoc` points in the direction of the deleted content. mapResult: (pos: number, assoc?: number) => MapResult } // Recovery values encode a range index and an offset. They are // represented as numbers, because tons of them will be created when // mapping, for example, a large number of decorations. The number's // lower 16 bits provide the index, the remaining bits the offset. // // Note: We intentionally don't use bit shift operators to en- and // decode these, since those clip to 32 bits, which we might in rare // cases want to overflow. A 64-bit float can represent 48-bit // integers precisely. const lower16 = 0xffff const factor16 = Math.pow(2, 16) function makeRecover(index: number, offset: number) { return index + offset * factor16 } function recoverIndex(value: number) { return value & lower16 } function recoverOffset(value: number) { return (value - (value & lower16)) / factor16 } const DEL_BEFORE = 1, DEL_AFTER = 2, DEL_ACROSS = 4, DEL_SIDE = 8 /// An object representing a mapped position with extra /// information. export class MapResult { /// @internal constructor( /// The mapped version of the position. readonly pos: number, /// @internal readonly delInfo: number, /// @internal readonly recover: number | null ) {} /// Tells you whether the position was deleted, that is, whether the /// step removed the token on the side queried (via the `assoc`) /// argument from the document. get deleted() { return (this.delInfo & DEL_SIDE) > 0 } /// Tells you whether the token before the mapped position was deleted. get deletedBefore() { return (this.delInfo & (DEL_BEFORE | DEL_ACROSS)) > 0 } /// True when the token after the mapped position was deleted. get deletedAfter() { return (this.delInfo & (DEL_AFTER | DEL_ACROSS)) > 0 } /// Tells whether any of the steps mapped through deletes across the /// position (including both the token before and after the /// position). get deletedAcross() { return (this.delInfo & DEL_ACROSS) > 0 } } /// A map describing the deletions and insertions made by a step, which /// can be used to find the correspondence between positions in the /// pre-step version of a document and the same position in the /// post-step version. export class StepMap implements Mappable { /// Create a position map. The modifications to the document are /// represented as an array of numbers, in which each group of three /// represents a modified chunk as `[start, oldSize, newSize]`. constructor( /// @internal readonly ranges: readonly number[], /// @internal readonly inverted = false ) { if (!ranges.length && StepMap.empty) return StepMap.empty } /// @internal recover(value: number) { let diff = 0, index = recoverIndex(value) if (!this.inverted) for (let i = 0; i < index; i++) diff += this.ranges[i * 3 + 2] - this.ranges[i * 3 + 1] return this.ranges[index * 3] + diff + recoverOffset(value) } mapResult(pos: number, assoc = 1): MapResult { return this._map(pos, assoc, false) as MapResult } map(pos: number, assoc = 1): number { return this._map(pos, assoc, true) as number } /// @internal _map(pos: number, assoc: number, simple: boolean) { let diff = 0, oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2 for (let i = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i] - (this.inverted ? diff : 0) if (start > pos) break let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex], end = start + oldSize if (pos <= end) { let side = !oldSize ? assoc : pos == start ? -1 : pos == end ? 1 : assoc let result = start + diff + (side < 0 ? 0 : newSize) if (simple) return result let recover = pos == (assoc < 0 ? start : end) ? null : makeRecover(i / 3, pos - start) let del = pos == start ? DEL_AFTER : pos == end ? DEL_BEFORE : DEL_ACROSS if (assoc < 0 ? pos != start : pos != end) del |= DEL_SIDE return new MapResult(result, del, recover) } diff += newSize - oldSize } return simple ? pos + diff : new MapResult(pos + diff, 0, null) } /// @internal touches(pos: number, recover: number) { let diff = 0, index = recoverIndex(recover) let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2 for (let i = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i] - (this.inverted ? diff : 0) if (start > pos) break let oldSize = this.ranges[i + oldIndex], end = start + oldSize if (pos <= end && i == index * 3) return true diff += this.ranges[i + newIndex] - oldSize } return false } /// Calls the given function on each of the changed ranges included in /// this map. forEach(f: (oldStart: number, oldEnd: number, newStart: number, newEnd: number) => void) { let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2 for (let i = 0, diff = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i], oldStart = start - (this.inverted ? diff : 0), newStart = start + (this.inverted ? 0 : diff) let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex] f(oldStart, oldStart + oldSize, newStart, newStart + newSize) diff += newSize - oldSize } } /// Create an inverted version of this map. The result can be used to /// map positions in the post-step document to the pre-step document. invert() { return new StepMap(this.ranges, !this.inverted) } /// @internal toString() { return (this.inverted ? "-" : "") + JSON.stringify(this.ranges) } /// Create a map that moves all positions by offset `n` (which may be /// negative). This can be useful when applying steps meant for a /// sub-document to a larger document, or vice-versa. static offset(n: number) { return n == 0 ? StepMap.empty : new StepMap(n < 0 ? [0, -n, 0] : [0, 0, n]) } /// A StepMap that contains no changed ranges. static empty = new StepMap([]) } /// A mapping represents a pipeline of zero or more [step /// maps](#transform.StepMap). It has special provisions for losslessly /// handling mapping positions through a series of steps in which some /// steps are inverted versions of earlier steps. (This comes up when /// ‘[rebasing](/docs/guide/#transform.rebasing)’ steps for /// collaboration or history management.) export class Mapping implements Mappable { /// Create a new mapping with the given position maps. constructor( maps?: readonly StepMap[], /// @internal public mirror?: number[], /// The starting position in the `maps` array, used when `map` or /// `mapResult` is called. public from = 0, /// The end position in the `maps` array. public to = maps ? maps.length : 0 ) { this._maps = (maps as StepMap[]) || [] this.ownData = !(maps || mirror) } /// The step maps in this mapping. get maps(): readonly StepMap[] { return this._maps } private _maps: StepMap[] // False if maps/mirror are shared arrays that we shouldn't mutate private ownData: boolean /// Create a mapping that maps only through a part of this one. slice(from = 0, to = this.maps.length) { return new Mapping(this._maps, this.mirror, from, to) } /// Add a step map to the end of this mapping. If `mirrors` is /// given, it should be the index of the step map that is the mirror /// image of this one. appendMap(map: StepMap, mirrors?: number) { if (!this.ownData) { this._maps = this._maps.slice() this.mirror = this.mirror && this.mirror.slice() this.ownData = true } this.to = this._maps.push(map) if (mirrors != null) this.setMirror(this._maps.length - 1, mirrors) } /// Add all the step maps in a given mapping to this one (preserving /// mirroring information). appendMapping(mapping: Mapping) { for (let i = 0, startSize = this._maps.length; i < mapping._maps.length; i++) { let mirr = mapping.getMirror(i) this.appendMap(mapping._maps[i], mirr != null && mirr < i ? startSize + mirr : undefined) } } /// Finds the offset of the step map that mirrors the map at the /// given offset, in this mapping (as per the second argument to /// `appendMap`). getMirror(n: number): number | undefined { if (this.mirror) for (let i = 0; i < this.mirror.length; i++) if (this.mirror[i] == n) return this.mirror[i + (i % 2 ? -1 : 1)] } /// @internal setMirror(n: number, m: number) { if (!this.mirror) this.mirror = [] this.mirror.push(n, m) } /// Append the inverse of the given mapping to this one. appendMappingInverted(mapping: Mapping) { for (let i = mapping.maps.length - 1, totalSize = this._maps.length + mapping._maps.length; i >= 0; i--) { let mirr = mapping.getMirror(i) this.appendMap(mapping._maps[i].invert(), mirr != null && mirr > i ? totalSize - mirr - 1 : undefined) } } /// Create an inverted version of this mapping. invert() { let inverse = new Mapping inverse.appendMappingInverted(this) return inverse } /// Map a position through this mapping. map(pos: number, assoc = 1) { if (this.mirror) return this._map(pos, assoc, true) as number for (let i = this.from; i < this.to; i++) pos = this._maps[i].map(pos, assoc) return pos } /// Map a position through this mapping, returning a mapping /// result. mapResult(pos: number, assoc = 1) { return this._map(pos, assoc, false) as MapResult } /// @internal _map(pos: number, assoc: number, simple: boolean) { let delInfo = 0 for (let i = this.from; i < this.to; i++) { let map = this._maps[i], result = map.mapResult(pos, assoc) if (result.recover != null) { let corr = this.getMirror(i) if (corr != null && corr > i && corr < this.to) { i = corr pos = this._maps[corr].recover(result.recover) continue } } delInfo |= result.delInfo pos = result.pos } return simple ? pos : new MapResult(pos, delInfo, null) } } ================================================ FILE: src/mark.ts ================================================ import {Mark, MarkType, Slice, Fragment, NodeType} from "prosemirror-model" import {Step} from "./step" import {Transform} from "./transform" import {AddMarkStep, RemoveMarkStep} from "./mark_step" import {ReplaceStep} from "./replace_step" export function addMark(tr: Transform, from: number, to: number, mark: Mark) { let removed: Step[] = [], added: Step[] = [] let removing: RemoveMarkStep | undefined, adding: AddMarkStep | undefined tr.doc.nodesBetween(from, to, (node, pos, parent) => { if (!node.isInline) return let marks = node.marks if (!mark.isInSet(marks) && parent!.type.allowsMarkType(mark.type)) { let start = Math.max(pos, from), end = Math.min(pos + node.nodeSize, to) let newSet = mark.addToSet(marks) for (let i = 0; i < marks.length; i++) { if (!marks[i].isInSet(newSet)) { if (removing && removing.to == start && removing.mark.eq(marks[i])) (removing as any).to = end else removed.push(removing = new RemoveMarkStep(start, end, marks[i])) } } if (adding && adding.to == start) (adding as any).to = end else added.push(adding = new AddMarkStep(start, end, mark)) } }) removed.forEach(s => tr.step(s)) added.forEach(s => tr.step(s)) } export function removeMark(tr: Transform, from: number, to: number, mark?: Mark | MarkType | null) { let matched: {style: Mark, from: number, to: number, step: number}[] = [], step = 0 tr.doc.nodesBetween(from, to, (node, pos) => { if (!node.isInline) return step++ let toRemove = null if (mark instanceof MarkType) { let set = node.marks, found while (found = mark.isInSet(set)) { ;(toRemove || (toRemove = [])).push(found) set = found.removeFromSet(set) } } else if (mark) { if (mark.isInSet(node.marks)) toRemove = [mark] } else { toRemove = node.marks } if (toRemove && toRemove.length) { let end = Math.min(pos + node.nodeSize, to) for (let i = 0; i < toRemove.length; i++) { let style = toRemove[i], found for (let j = 0; j < matched.length; j++) { let m = matched[j] if (m.step == step - 1 && style.eq(matched[j].style)) found = m } if (found) { found.to = end found.step = step } else { matched.push({style, from: Math.max(pos, from), to: end, step}) } } } }) matched.forEach(m => tr.step(new RemoveMarkStep(m.from, m.to, m.style))) } export function clearIncompatible(tr: Transform, pos: number, parentType: NodeType, match = parentType.contentMatch, clearNewlines = true) { let node = tr.doc.nodeAt(pos)! let replSteps: Step[] = [], cur = pos + 1 for (let i = 0; i < node.childCount; i++) { let child = node.child(i), end = cur + child.nodeSize let allowed = match.matchType(child.type) if (!allowed) { replSteps.push(new ReplaceStep(cur, end, Slice.empty)) } else { match = allowed for (let j = 0; j < child.marks.length; j++) if (!parentType.allowsMarkType(child.marks[j].type)) tr.step(new RemoveMarkStep(cur, end, child.marks[j])) if (clearNewlines && child.isText && parentType.whitespace != "pre") { let m, newline = /\r?\n|\r/g, slice while (m = newline.exec(child.text!)) { if (!slice) slice = new Slice(Fragment.from(parentType.schema.text(" ", parentType.allowedMarks(child.marks))), 0, 0) replSteps.push(new ReplaceStep(cur + m.index, cur + m.index + m[0].length, slice)) } } } cur = end } if (!match.validEnd) { let fill = match.fillBefore(Fragment.empty, true) tr.replace(cur, cur, new Slice(fill!, 0, 0)) } for (let i = replSteps.length - 1; i >= 0; i--) tr.step(replSteps[i]) } ================================================ FILE: src/mark_step.ts ================================================ import {Fragment, Slice, Node, Mark, Schema} from "prosemirror-model" import {Step, StepResult} from "./step" import {Mappable} from "./map" function mapFragment(fragment: Fragment, f: (child: Node, parent: Node, i: number) => Node, parent: Node): Fragment { let mapped = [] for (let i = 0; i < fragment.childCount; i++) { let child = fragment.child(i) if (child.content.size) child = child.copy(mapFragment(child.content, f, child)) if (child.isInline) child = f(child, parent, i) mapped.push(child) } return Fragment.fromArray(mapped) } /// Add a mark to all inline content between two positions. export class AddMarkStep extends Step { /// Create a mark step. constructor( /// The start of the marked range. readonly from: number, /// The end of the marked range. readonly to: number, /// The mark to add. readonly mark: Mark ) { super() } apply(doc: Node) { let oldSlice = doc.slice(this.from, this.to), $from = doc.resolve(this.from) let parent = $from.node($from.sharedDepth(this.to)) let slice = new Slice(mapFragment(oldSlice.content, (node, parent) => { if (!node.isAtom || !parent.type.allowsMarkType(this.mark.type)) return node return node.mark(this.mark.addToSet(node.marks)) }, parent), oldSlice.openStart, oldSlice.openEnd) return StepResult.fromReplace(doc, this.from, this.to, slice) } invert(): Step { return new RemoveMarkStep(this.from, this.to, this.mark) } map(mapping: Mappable): Step | null { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1) if (from.deleted && to.deleted || from.pos >= to.pos) return null return new AddMarkStep(from.pos, to.pos, this.mark) } merge(other: Step): Step | null { if (other instanceof AddMarkStep && other.mark.eq(this.mark) && this.from <= other.to && this.to >= other.from) return new AddMarkStep(Math.min(this.from, other.from), Math.max(this.to, other.to), this.mark) return null } toJSON(): any { return {stepType: "addMark", mark: this.mark.toJSON(), from: this.from, to: this.to} } /// @internal static fromJSON(schema: Schema, json: any) { if (typeof json.from != "number" || typeof json.to != "number") throw new RangeError("Invalid input for AddMarkStep.fromJSON") return new AddMarkStep(json.from, json.to, schema.markFromJSON(json.mark)) } } Step.jsonID("addMark", AddMarkStep) /// Remove a mark from all inline content between two positions. export class RemoveMarkStep extends Step { /// Create a mark-removing step. constructor( /// The start of the unmarked range. readonly from: number, /// The end of the unmarked range. readonly to: number, /// The mark to remove. readonly mark: Mark ) { super() } apply(doc: Node) { let oldSlice = doc.slice(this.from, this.to) let slice = new Slice(mapFragment(oldSlice.content, node => { return node.mark(this.mark.removeFromSet(node.marks)) }, doc), oldSlice.openStart, oldSlice.openEnd) return StepResult.fromReplace(doc, this.from, this.to, slice) } invert(): Step { return new AddMarkStep(this.from, this.to, this.mark) } map(mapping: Mappable): Step | null { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1) if (from.deleted && to.deleted || from.pos >= to.pos) return null return new RemoveMarkStep(from.pos, to.pos, this.mark) } merge(other: Step): Step | null { if (other instanceof RemoveMarkStep && other.mark.eq(this.mark) && this.from <= other.to && this.to >= other.from) return new RemoveMarkStep(Math.min(this.from, other.from), Math.max(this.to, other.to), this.mark) return null } toJSON(): any { return {stepType: "removeMark", mark: this.mark.toJSON(), from: this.from, to: this.to} } /// @internal static fromJSON(schema: Schema, json: any) { if (typeof json.from != "number" || typeof json.to != "number") throw new RangeError("Invalid input for RemoveMarkStep.fromJSON") return new RemoveMarkStep(json.from, json.to, schema.markFromJSON(json.mark)) } } Step.jsonID("removeMark", RemoveMarkStep) /// Add a mark to a specific node. export class AddNodeMarkStep extends Step { /// Create a node mark step. constructor( /// The position of the target node. readonly pos: number, /// The mark to add. readonly mark: Mark ) { super() } apply(doc: Node) { let node = doc.nodeAt(this.pos) if (!node) return StepResult.fail("No node at mark step's position") let updated = node.type.create(node.attrs, null, this.mark.addToSet(node.marks)) return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1)) } invert(doc: Node): Step { let node = doc.nodeAt(this.pos) if (node) { let newSet = this.mark.addToSet(node.marks) if (newSet.length == node.marks.length) { for (let i = 0; i < node.marks.length; i++) if (!node.marks[i].isInSet(newSet)) return new AddNodeMarkStep(this.pos, node.marks[i]) return new AddNodeMarkStep(this.pos, this.mark) } } return new RemoveNodeMarkStep(this.pos, this.mark) } map(mapping: Mappable): Step | null { let pos = mapping.mapResult(this.pos, 1) return pos.deletedAfter ? null : new AddNodeMarkStep(pos.pos, this.mark) } toJSON(): any { return {stepType: "addNodeMark", pos: this.pos, mark: this.mark.toJSON()} } /// @internal static fromJSON(schema: Schema, json: any) { if (typeof json.pos != "number") throw new RangeError("Invalid input for AddNodeMarkStep.fromJSON") return new AddNodeMarkStep(json.pos, schema.markFromJSON(json.mark)) } } Step.jsonID("addNodeMark", AddNodeMarkStep) /// Remove a mark from a specific node. export class RemoveNodeMarkStep extends Step { /// Create a mark-removing step. constructor( /// The position of the target node. readonly pos: number, /// The mark to remove. readonly mark: Mark ) { super() } apply(doc: Node) { let node = doc.nodeAt(this.pos) if (!node) return StepResult.fail("No node at mark step's position") let updated = node.type.create(node.attrs, null, this.mark.removeFromSet(node.marks)) return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1)) } invert(doc: Node): Step { let node = doc.nodeAt(this.pos) if (!node || !this.mark.isInSet(node.marks)) return this return new AddNodeMarkStep(this.pos, this.mark) } map(mapping: Mappable): Step | null { let pos = mapping.mapResult(this.pos, 1) return pos.deletedAfter ? null : new RemoveNodeMarkStep(pos.pos, this.mark) } toJSON(): any { return {stepType: "removeNodeMark", pos: this.pos, mark: this.mark.toJSON()} } /// @internal static fromJSON(schema: Schema, json: any) { if (typeof json.pos != "number") throw new RangeError("Invalid input for RemoveNodeMarkStep.fromJSON") return new RemoveNodeMarkStep(json.pos, schema.markFromJSON(json.mark)) } } Step.jsonID("removeNodeMark", RemoveNodeMarkStep) ================================================ FILE: src/replace.ts ================================================ import {Fragment, Slice, Node, ResolvedPos, NodeType, ContentMatch, Attrs} from "prosemirror-model" import {Step} from "./step" import {ReplaceStep, ReplaceAroundStep} from "./replace_step" import {Transform} from "./transform" import {insertPoint} from "./structure" /// ‘Fit’ a slice into a given position in the document, producing a /// [step](#transform.Step) that inserts it. Will return null if /// there's no meaningful way to insert the slice here, or inserting it /// would be a no-op (an empty slice over an empty range). export function replaceStep(doc: Node, from: number, to = from, slice = Slice.empty): Step | null { if (from == to && !slice.size) return null let $from = doc.resolve(from), $to = doc.resolve(to) // Optimization -- avoid work if it's obvious that it's not needed. if (fitsTrivially($from, $to, slice)) return new ReplaceStep(from, to, slice) return new Fitter($from, $to, slice).fit() } function fitsTrivially($from: ResolvedPos, $to: ResolvedPos, slice: Slice) { return !slice.openStart && !slice.openEnd && $from.start() == $to.start() && $from.parent.canReplace($from.index(), $to.index(), slice.content) } interface Fittable { sliceDepth: number frontierDepth: number parent: Node | null inject?: Fragment | null wrap?: readonly NodeType[] } // Algorithm for 'placing' the elements of a slice into a gap: // // We consider the content of each node that is open to the left to be // independently placeable. I.e. in , when the // paragraph on the left is open, "foo" can be placed (somewhere on // the left side of the replacement gap) independently from p("bar"). // // This class tracks the state of the placement progress in the // following properties: // // - `frontier` holds a stack of `{type, match}` objects that // represent the open side of the replacement. It starts at // `$from`, then moves forward as content is placed, and is finally // reconciled with `$to`. // // - `unplaced` is a slice that represents the content that hasn't // been placed yet. // // - `placed` is a fragment of placed content. Its open-start value // is implicit in `$from`, and its open-end value in `frontier`. class Fitter { frontier: {type: NodeType, match: ContentMatch}[] = [] placed: Fragment = Fragment.empty constructor( readonly $from: ResolvedPos, readonly $to: ResolvedPos, public unplaced: Slice ) { for (let i = 0; i <= $from.depth; i++) { let node = $from.node(i) this.frontier.push({ type: node.type, match: node.contentMatchAt($from.indexAfter(i)) }) } for (let i = $from.depth; i > 0; i--) this.placed = Fragment.from($from.node(i).copy(this.placed)) } get depth() { return this.frontier.length - 1 } fit() { // As long as there's unplaced content, try to place some of it. // If that fails, either increase the open score of the unplaced // slice, or drop nodes from it, and then try again. while (this.unplaced.size) { let fit = this.findFittable() if (fit) this.placeNodes(fit) else this.openMore() || this.dropNode() } // When there's inline content directly after the frontier _and_ // directly after `this.$to`, we must generate a `ReplaceAround` // step that pulls that content into the node after the frontier. // That means the fitting must be done to the end of the textblock // node after `this.$to`, not `this.$to` itself. let moveInline = this.mustMoveInline(), placedSize = this.placed.size - this.depth - this.$from.depth let $from = this.$from, $to = this.close(moveInline < 0 ? this.$to : $from.doc.resolve(moveInline)) if (!$to) return null // If closing to `$to` succeeded, create a step let content = this.placed, openStart = $from.depth, openEnd = $to.depth while (openStart && openEnd && content.childCount == 1) { // Normalize by dropping open parent nodes content = content.firstChild!.content openStart--; openEnd-- } let slice = new Slice(content, openStart, openEnd) if (moveInline > -1) return new ReplaceAroundStep($from.pos, moveInline, this.$to.pos, this.$to.end(), slice, placedSize) if (slice.size || $from.pos != this.$to.pos) // Don't generate no-op steps return new ReplaceStep($from.pos, $to.pos, slice) return null } // Find a position on the start spine of `this.unplaced` that has // content that can be moved somewhere on the frontier. Returns two // depths, one for the slice and one for the frontier. findFittable(): Fittable | undefined { let startDepth = this.unplaced.openStart for (let cur = this.unplaced.content, d = 0, openEnd = this.unplaced.openEnd; d < startDepth; d++) { let node = cur.firstChild! if (cur.childCount > 1) openEnd = 0 if (node.type.spec.isolating && openEnd <= d) { startDepth = d break } cur = node.content } // Only try wrapping nodes (pass 2) after finding a place without // wrapping failed. for (let pass = 1; pass <= 2; pass++) { for (let sliceDepth = pass == 1 ? startDepth : this.unplaced.openStart; sliceDepth >= 0; sliceDepth--) { let fragment, parent = null if (sliceDepth) { parent = contentAt(this.unplaced.content, sliceDepth - 1).firstChild fragment = parent!.content } else { fragment = this.unplaced.content } let first = fragment.firstChild for (let frontierDepth = this.depth; frontierDepth >= 0; frontierDepth--) { let {type, match} = this.frontier[frontierDepth], wrap, inject: Fragment | null = null // In pass 1, if the next node matches, or there is no next // node but the parents look compatible, we've found a // place. if (pass == 1 && (first ? match.matchType(first.type) || (inject = match.fillBefore(Fragment.from(first), false)) : parent && type.compatibleContent(parent.type))) return {sliceDepth, frontierDepth, parent, inject} // In pass 2, look for a set of wrapping nodes that make // `first` fit here. else if (pass == 2 && first && (wrap = match.findWrapping(first.type))) return {sliceDepth, frontierDepth, parent, wrap} // Don't continue looking further up if the parent node // would fit here. if (parent && match.matchType(parent.type)) break } } } } openMore() { let {content, openStart, openEnd} = this.unplaced let inner = contentAt(content, openStart) if (!inner.childCount || inner.firstChild!.isLeaf) return false this.unplaced = new Slice(content, openStart + 1, Math.max(openEnd, inner.size + openStart >= content.size - openEnd ? openStart + 1 : 0)) return true } dropNode() { let {content, openStart, openEnd} = this.unplaced let inner = contentAt(content, openStart) if (inner.childCount <= 1 && openStart > 0) { let openAtEnd = content.size - openStart <= openStart + inner.size this.unplaced = new Slice(dropFromFragment(content, openStart - 1, 1), openStart - 1, openAtEnd ? openStart - 1 : openEnd) } else { this.unplaced = new Slice(dropFromFragment(content, openStart, 1), openStart, openEnd) } } // Move content from the unplaced slice at `sliceDepth` to the // frontier node at `frontierDepth`. Close that frontier node when // applicable. placeNodes({sliceDepth, frontierDepth, parent, inject, wrap}: Fittable) { while (this.depth > frontierDepth) this.closeFrontierNode() if (wrap) for (let i = 0; i < wrap.length; i++) this.openFrontierNode(wrap[i]) let slice = this.unplaced, fragment = parent ? parent.content : slice.content let openStart = slice.openStart - sliceDepth let taken = 0, add = [] let {match, type} = this.frontier[frontierDepth] if (inject) { for (let i = 0; i < inject.childCount; i++) add.push(inject.child(i)) match = match.matchFragment(inject)! } // Computes the amount of (end) open nodes at the end of the // fragment. When 0, the parent is open, but no more. When // negative, nothing is open. let openEndCount = (fragment.size + sliceDepth) - (slice.content.size - slice.openEnd) // Scan over the fragment, fitting as many child nodes as // possible. while (taken < fragment.childCount) { let next = fragment.child(taken), matches = match.matchType(next.type) if (!matches) break taken++ if (taken > 1 || openStart == 0 || next.content.size) { // Drop empty open nodes match = matches add.push(closeNodeStart(next.mark(type.allowedMarks(next.marks)), taken == 1 ? openStart : 0, taken == fragment.childCount ? openEndCount : -1)) } } let toEnd = taken == fragment.childCount if (!toEnd) openEndCount = -1 this.placed = addToFragment(this.placed, frontierDepth, Fragment.from(add)) this.frontier[frontierDepth].match = match // If the parent types match, and the entire node was moved, and // it's not open, close this frontier node right away. if (toEnd && openEndCount < 0 && parent && parent.type == this.frontier[this.depth].type && this.frontier.length > 1) this.closeFrontierNode() // Add new frontier nodes for any open nodes at the end. for (let i = 0, cur = fragment; i < openEndCount; i++) { let node = cur.lastChild! this.frontier.push({type: node.type, match: node.contentMatchAt(node.childCount)}) cur = node.content } // Update `this.unplaced`. Drop the entire node from which we // placed it we got to its end, otherwise just drop the placed // nodes. this.unplaced = !toEnd ? new Slice(dropFromFragment(slice.content, sliceDepth, taken), slice.openStart, slice.openEnd) : sliceDepth == 0 ? Slice.empty : new Slice(dropFromFragment(slice.content, sliceDepth - 1, 1), sliceDepth - 1, openEndCount < 0 ? slice.openEnd : sliceDepth - 1) } mustMoveInline() { if (!this.$to.parent.isTextblock) return -1 let top = this.frontier[this.depth], level if (!top.type.isTextblock || !contentAfterFits(this.$to, this.$to.depth, top.type, top.match, false) || (this.$to.depth == this.depth && (level = this.findCloseLevel(this.$to)) && level.depth == this.depth)) return -1 let {depth} = this.$to, after = this.$to.after(depth) while (depth > 1 && after == this.$to.end(--depth)) ++after return after } findCloseLevel($to: ResolvedPos) { scan: for (let i = Math.min(this.depth, $to.depth); i >= 0; i--) { let {match, type} = this.frontier[i] let dropInner = i < $to.depth && $to.end(i + 1) == $to.pos + ($to.depth - (i + 1)) let fit = contentAfterFits($to, i, type, match, dropInner) if (!fit) continue for (let d = i - 1; d >= 0; d--) { let {match, type} = this.frontier[d] let matches = contentAfterFits($to, d, type, match, true) if (!matches || matches.childCount) continue scan } return {depth: i, fit, move: dropInner ? $to.doc.resolve($to.after(i + 1)) : $to} } } close($to: ResolvedPos) { let close = this.findCloseLevel($to) if (!close) return null while (this.depth > close.depth) this.closeFrontierNode() if (close.fit.childCount) this.placed = addToFragment(this.placed, close.depth, close.fit) $to = close.move for (let d = close.depth + 1; d <= $to.depth; d++) { let node = $to.node(d), add = node.type.contentMatch.fillBefore(node.content, true, $to.index(d))! this.openFrontierNode(node.type, node.attrs, add) } return $to } openFrontierNode(type: NodeType, attrs: Attrs | null = null, content?: Fragment) { let top = this.frontier[this.depth] top.match = top.match.matchType(type)! this.placed = addToFragment(this.placed, this.depth, Fragment.from(type.create(attrs, content))) this.frontier.push({type, match: type.contentMatch}) } closeFrontierNode() { let open = this.frontier.pop()! let add = open.match.fillBefore(Fragment.empty, true)! if (add.childCount) this.placed = addToFragment(this.placed, this.frontier.length, add) } } function dropFromFragment(fragment: Fragment, depth: number, count: number): Fragment { if (depth == 0) return fragment.cutByIndex(count, fragment.childCount) return fragment.replaceChild(0, fragment.firstChild!.copy(dropFromFragment(fragment.firstChild!.content, depth - 1, count))) } function addToFragment(fragment: Fragment, depth: number, content: Fragment): Fragment { if (depth == 0) return fragment.append(content) return fragment.replaceChild(fragment.childCount - 1, fragment.lastChild!.copy(addToFragment(fragment.lastChild!.content, depth - 1, content))) } function contentAt(fragment: Fragment, depth: number) { for (let i = 0; i < depth; i++) fragment = fragment.firstChild!.content return fragment } function closeNodeStart(node: Node, openStart: number, openEnd: number) { if (openStart <= 0) return node let frag = node.content if (openStart > 1) frag = frag.replaceChild(0, closeNodeStart(frag.firstChild!, openStart - 1, frag.childCount == 1 ? openEnd - 1 : 0)) if (openStart > 0) { frag = node.type.contentMatch.fillBefore(frag)!.append(frag) if (openEnd <= 0) frag = frag.append(node.type.contentMatch.matchFragment(frag)!.fillBefore(Fragment.empty, true)!) } return node.copy(frag) } function contentAfterFits($to: ResolvedPos, depth: number, type: NodeType, match: ContentMatch, open: boolean) { let node = $to.node(depth), index = open ? $to.indexAfter(depth) : $to.index(depth) if (index == node.childCount && !type.compatibleContent(node.type)) return null let fit = match.fillBefore(node.content, true, index) return fit && !invalidMarks(type, node.content, index) ? fit : null } function invalidMarks(type: NodeType, fragment: Fragment, start: number) { for (let i = start; i < fragment.childCount; i++) if (!type.allowsMarks(fragment.child(i).marks)) return true return false } function definesContent(type: NodeType) { return type.spec.defining || type.spec.definingForContent } export function replaceRange(tr: Transform, from: number, to: number, slice: Slice) { if (!slice.size) return tr.deleteRange(from, to) let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to) if (fitsTrivially($from, $to, slice)) return tr.step(new ReplaceStep(from, to, slice)) let targetDepths = coveredDepths($from, $to) // Can't replace the whole document, so remove 0 if it's present if (targetDepths[targetDepths.length - 1] == 0) targetDepths.pop() // Negative numbers represent not expansion over the whole node at // that depth, but replacing from $from.before(-D) to $to.pos. let preferredTarget = -($from.depth + 1) targetDepths.unshift(preferredTarget) // This loop picks a preferred target depth, if one of the covering // depths is not outside of a defining node, and adds negative // depths for any depth that has $from at its start and does not // cross a defining node. for (let d = $from.depth, pos = $from.pos - 1; d > 0; d--, pos--) { let spec = $from.node(d).type.spec if (spec.defining || spec.definingAsContext || spec.isolating) break if (targetDepths.indexOf(d) > -1) preferredTarget = d else if ($from.before(d) == pos) targetDepths.splice(1, 0, -d) } // Try to fit each possible depth of the slice into each possible // target depth, starting with the preferred depths. let preferredTargetIndex = targetDepths.indexOf(preferredTarget) let leftNodes: Node[] = [], preferredDepth = slice.openStart for (let content = slice.content, i = 0;; i++) { let node = content.firstChild! leftNodes.push(node) if (i == slice.openStart) break content = node.content } // Back up preferredDepth to cover defining textblocks directly // above it, possibly skipping a non-defining textblock. for (let d = preferredDepth - 1; d >= 0; d--) { let leftNode = leftNodes[d], def = definesContent(leftNode.type) if (def && !leftNode.sameMarkup($from.node(Math.abs(preferredTarget) - 1))) preferredDepth = d else if (def || !leftNode.type.isTextblock) break } for (let j = slice.openStart; j >= 0; j--) { let openDepth = (j + preferredDepth + 1) % (slice.openStart + 1) let insert = leftNodes[openDepth] if (!insert) continue for (let i = 0; i < targetDepths.length; i++) { // Loop over possible expansion levels, starting with the // preferred one let targetDepth = targetDepths[(i + preferredTargetIndex) % targetDepths.length], expand = true if (targetDepth < 0) { expand = false; targetDepth = -targetDepth } let parent = $from.node(targetDepth - 1), index = $from.index(targetDepth - 1) if (parent.canReplaceWith(index, index, insert.type, insert.marks)) return tr.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to, new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth), openDepth, slice.openEnd)) } } let startSteps = tr.steps.length for (let i = targetDepths.length - 1; i >= 0; i--) { tr.replace(from, to, slice) if (tr.steps.length > startSteps) break let depth = targetDepths[i] if (depth < 0) continue from = $from.before(depth); to = $to.after(depth) } } function closeFragment(fragment: Fragment, depth: number, oldOpen: number, newOpen: number, parent?: Node) { if (depth < oldOpen) { let first = fragment.firstChild! fragment = fragment.replaceChild(0, first.copy(closeFragment(first.content, depth + 1, oldOpen, newOpen, first))) } if (depth > newOpen) { let match = parent!.contentMatchAt(0)! let start = match.fillBefore(fragment)!.append(fragment) fragment = start.append(match.matchFragment(start)!.fillBefore(Fragment.empty, true)!) } return fragment } export function replaceRangeWith(tr: Transform, from: number, to: number, node: Node) { if (!node.isInline && from == to && tr.doc.resolve(from).parent.content.size) { let point = insertPoint(tr.doc, from, node.type) if (point != null) from = to = point } tr.replaceRange(from, to, new Slice(Fragment.from(node), 0, 0)) } export function deleteRange(tr: Transform, from: number, to: number) { let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to) // When the deleted range spans from the start of one textblock to // the start of another one, move out of the start of both blocks. if ($from.parent.isTextblock && $to.parent.isTextblock && $from.start() != $to.start() && $from.parentOffset == 0 && $to.parentOffset == 0) { let shared = $from.sharedDepth(to), isolated = false for (let d = $from.depth; d > shared; d--) if ($from.node(d).type.spec.isolating) isolated = true for (let d = $to.depth; d > shared; d--) if ($to.node(d).type.spec.isolating) isolated = true if (!isolated) { for (let d = $from.depth; d > 0 && from == $from.start(d); d--) from = $from.before(d) for (let d = $to.depth; d > 0 && to == $to.start(d); d--) to = $to.before(d) $from = tr.doc.resolve(from) $to = tr.doc.resolve(to) } } let covered = coveredDepths($from, $to) for (let i = 0; i < covered.length; i++) { let depth = covered[i], last = i == covered.length - 1 if ((last && depth == 0) || $from.node(depth).type.contentMatch.validEnd) return tr.delete($from.start(depth), $to.end(depth)) if (depth > 0 && (last || $from.node(depth - 1).canReplace($from.index(depth - 1), $to.indexAfter(depth - 1)))) return tr.delete($from.before(depth), $to.after(depth)) } for (let d = 1; d <= $from.depth && d <= $to.depth; d++) { if (from - $from.start(d) == $from.depth - d && to > $from.end(d) && $to.end(d) - to != $to.depth - d && $from.start(d - 1) == $to.start(d - 1) && $from.node(d - 1).canReplace($from.index(d - 1), $to.index(d - 1))) return tr.delete($from.before(d), to) } tr.delete(from, to) } // Returns an array of all depths for which $from - $to spans the // whole content of the nodes at that depth. function coveredDepths($from: ResolvedPos, $to: ResolvedPos) { let result: number[] = [], minDepth = Math.min($from.depth, $to.depth) for (let d = minDepth; d >= 0; d--) { let start = $from.start(d) if (start < $from.pos - ($from.depth - d) || $to.end(d) > $to.pos + ($to.depth - d) || $from.node(d).type.spec.isolating || $to.node(d).type.spec.isolating) break if (start == $to.start(d) || (d == $from.depth && d == $to.depth && $from.parent.inlineContent && $to.parent.inlineContent && d && $to.start(d - 1) == start - 1)) result.push(d) } return result } ================================================ FILE: src/replace_step.ts ================================================ import {Slice, Node, Schema} from "prosemirror-model" import {Step, StepResult} from "./step" import {StepMap, Mappable} from "./map" /// Replace a part of the document with a slice of new content. export class ReplaceStep extends Step { /// The given `slice` should fit the 'gap' between `from` and /// `to`—the depths must line up, and the surrounding nodes must be /// able to be joined with the open sides of the slice. When /// `structure` is true, the step will fail if the content between /// from and to is not just a sequence of closing and then opening /// tokens (this is to guard against rebased replace steps /// overwriting something they weren't supposed to). constructor( /// The start position of the replaced range. readonly from: number, /// The end position of the replaced range. readonly to: number, /// The slice to insert. readonly slice: Slice, /// @internal readonly structure = false ) { super() } apply(doc: Node) { if (this.structure && contentBetween(doc, this.from, this.to)) return StepResult.fail("Structure replace would overwrite content") return StepResult.fromReplace(doc, this.from, this.to, this.slice) } getMap() { return new StepMap([this.from, this.to - this.from, this.slice.size]) } invert(doc: Node) { return new ReplaceStep(this.from, this.from + this.slice.size, doc.slice(this.from, this.to)) } map(mapping: Mappable) { let to = mapping.mapResult(this.to, -1) let from = this.from == this.to && ReplaceStep.MAP_BIAS < 0 ? to : mapping.mapResult(this.from, 1) if (from.deletedAcross && to.deletedAcross) return null return new ReplaceStep(from.pos, Math.max(from.pos, to.pos), this.slice, this.structure) } merge(other: Step) { if (!(other instanceof ReplaceStep) || other.structure || this.structure) return null if (this.from + this.slice.size == other.from && !this.slice.openEnd && !other.slice.openStart) { let slice = this.slice.size + other.slice.size == 0 ? Slice.empty : new Slice(this.slice.content.append(other.slice.content), this.slice.openStart, other.slice.openEnd) return new ReplaceStep(this.from, this.to + (other.to - other.from), slice, this.structure) } else if (other.to == this.from && !this.slice.openStart && !other.slice.openEnd) { let slice = this.slice.size + other.slice.size == 0 ? Slice.empty : new Slice(other.slice.content.append(this.slice.content), other.slice.openStart, this.slice.openEnd) return new ReplaceStep(other.from, this.to, slice, this.structure) } else { return null } } toJSON(): any { let json: any = {stepType: "replace", from: this.from, to: this.to} if (this.slice.size) json.slice = this.slice.toJSON() if (this.structure) json.structure = true return json } /// @internal static fromJSON(schema: Schema, json: any) { if (typeof json.from != "number" || typeof json.to != "number") throw new RangeError("Invalid input for ReplaceStep.fromJSON") return new ReplaceStep(json.from, json.to, Slice.fromJSON(schema, json.slice), !!json.structure) } /// By default, for backwards compatibility, an inserting step /// mapped over an insertion at that same position fill move after /// the inserted content. In a collaborative editing situation, that /// can make redone insertions appear in unexpected places. You can /// set this to -1 to make such mapping keep the step before the /// insertion instead. static MAP_BIAS: -1 | 1 = 1 } Step.jsonID("replace", ReplaceStep) /// Replace a part of the document with a slice of content, but /// preserve a range of the replaced content by moving it into the /// slice. export class ReplaceAroundStep extends Step { /// Create a replace-around step with the given range and gap. /// `insert` should be the point in the slice into which the content /// of the gap should be moved. `structure` has the same meaning as /// it has in the [`ReplaceStep`](#transform.ReplaceStep) class. constructor( /// The start position of the replaced range. readonly from: number, /// The end position of the replaced range. readonly to: number, /// The start of preserved range. readonly gapFrom: number, /// The end of preserved range. readonly gapTo: number, /// The slice to insert. readonly slice: Slice, /// The position in the slice where the preserved range should be /// inserted. readonly insert: number, /// @internal readonly structure = false ) { super() } apply(doc: Node) { if (this.structure && (contentBetween(doc, this.from, this.gapFrom) || contentBetween(doc, this.gapTo, this.to))) return StepResult.fail("Structure gap-replace would overwrite content") let gap = doc.slice(this.gapFrom, this.gapTo) if (gap.openStart || gap.openEnd) return StepResult.fail("Gap is not a flat range") let inserted = this.slice.insertAt(this.insert, gap.content) if (!inserted) return StepResult.fail("Content does not fit in gap") return StepResult.fromReplace(doc, this.from, this.to, inserted) } getMap() { return new StepMap([this.from, this.gapFrom - this.from, this.insert, this.gapTo, this.to - this.gapTo, this.slice.size - this.insert]) } invert(doc: Node) { let gap = this.gapTo - this.gapFrom return new ReplaceAroundStep(this.from, this.from + this.slice.size + gap, this.from + this.insert, this.from + this.insert + gap, doc.slice(this.from, this.to).removeBetween(this.gapFrom - this.from, this.gapTo - this.from), this.gapFrom - this.from, this.structure) } map(mapping: Mappable) { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1) let gapFrom = this.from == this.gapFrom ? from.pos : mapping.map(this.gapFrom, -1) let gapTo = this.to == this.gapTo ? to.pos : mapping.map(this.gapTo, 1) if ((from.deletedAcross && to.deletedAcross) || gapFrom < from.pos || gapTo > to.pos) return null return new ReplaceAroundStep(from.pos, to.pos, gapFrom, gapTo, this.slice, this.insert, this.structure) } toJSON(): any { let json: any = {stepType: "replaceAround", from: this.from, to: this.to, gapFrom: this.gapFrom, gapTo: this.gapTo, insert: this.insert} if (this.slice.size) json.slice = this.slice.toJSON() if (this.structure) json.structure = true return json } /// @internal static fromJSON(schema: Schema, json: any) { if (typeof json.from != "number" || typeof json.to != "number" || typeof json.gapFrom != "number" || typeof json.gapTo != "number" || typeof json.insert != "number") throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON") return new ReplaceAroundStep(json.from, json.to, json.gapFrom, json.gapTo, Slice.fromJSON(schema, json.slice), json.insert, !!json.structure) } } Step.jsonID("replaceAround", ReplaceAroundStep) function contentBetween(doc: Node, from: number, to: number) { let $from = doc.resolve(from), dist = to - from, depth = $from.depth while (dist > 0 && depth > 0 && $from.indexAfter(depth) == $from.node(depth).childCount) { depth-- dist-- } if (dist > 0) { let next = $from.node(depth).maybeChild($from.indexAfter(depth)) while (dist > 0) { if (!next || next.isLeaf) return true next = next.firstChild dist-- } } return false } ================================================ FILE: src/step.ts ================================================ import {ReplaceError, Schema, Slice, Node} from "prosemirror-model" import {StepMap, Mappable} from "./map" const stepsByID: {[id: string]: {fromJSON(schema: Schema, json: any): Step}} = Object.create(null) /// A step object represents an atomic change. It generally applies /// only to the document it was created for, since the positions /// stored in it will only make sense for that document. /// /// New steps are defined by creating classes that extend `Step`, /// overriding the `apply`, `invert`, `map`, `getMap` and `fromJSON` /// methods, and registering your class with a unique /// JSON-serialization identifier using /// [`Step.jsonID`](#transform.Step^jsonID). export abstract class Step { /// Applies this step to the given document, returning a result /// object that either indicates failure, if the step can not be /// applied to this document, or indicates success by containing a /// transformed document. abstract apply(doc: Node): StepResult /// Get the step map that represents the changes made by this step, /// and which can be used to transform between positions in the old /// and the new document. getMap(): StepMap { return StepMap.empty } /// Create an inverted version of this step. Needs the document as it /// was before the step as argument. abstract invert(doc: Node): Step /// Map this step through a mappable thing, returning either a /// version of that step with its positions adjusted, or `null` if /// the step was entirely deleted by the mapping. abstract map(mapping: Mappable): Step | null /// Try to merge this step with another one, to be applied directly /// after it. Returns the merged step when possible, null if the /// steps can't be merged. merge(other: Step): Step | null { return null } /// Create a JSON-serializeable representation of this step. When /// defining this for a custom subclass, make sure the result object /// includes the step type's [JSON id](#transform.Step^jsonID) under /// the `stepType` property. abstract toJSON(): any /// Deserialize a step from its JSON representation. Will call /// through to the step class' own implementation of this method. static fromJSON(schema: Schema, json: any): Step { if (!json || !json.stepType) throw new RangeError("Invalid input for Step.fromJSON") let type = stepsByID[json.stepType] if (!type) throw new RangeError(`No step type ${json.stepType} defined`) return type.fromJSON(schema, json) } /// To be able to serialize steps to JSON, each step needs a string /// ID to attach to its JSON representation. Use this method to /// register an ID for your step classes. Try to pick something /// that's unlikely to clash with steps from other modules. static jsonID(id: string, stepClass: {fromJSON(schema: Schema, json: any): Step}) { if (id in stepsByID) throw new RangeError("Duplicate use of step JSON ID " + id) stepsByID[id] = stepClass ;(stepClass as any).prototype.jsonID = id return stepClass } } /// The result of [applying](#transform.Step.apply) a step. Contains either a /// new document or a failure value. export class StepResult { /// @internal constructor( /// The transformed document, if successful. readonly doc: Node | null, /// The failure message, if unsuccessful. readonly failed: string | null ) {} /// Create a successful step result. static ok(doc: Node) { return new StepResult(doc, null) } /// Create a failed step result. static fail(message: string) { return new StepResult(null, message) } /// Call [`Node.replace`](#model.Node.replace) with the given /// arguments. Create a successful result if it succeeds, and a /// failed one if it throws a `ReplaceError`. static fromReplace(doc: Node, from: number, to: number, slice: Slice) { try { return StepResult.ok(doc.replace(from, to, slice)) } catch (e) { if (e instanceof ReplaceError) return StepResult.fail(e.message) throw e } } } ================================================ FILE: src/structure.ts ================================================ import {Slice, Fragment, NodeRange, NodeType, Node, Mark, Attrs, ContentMatch} from "prosemirror-model" import {Transform} from "./transform" import {ReplaceStep, ReplaceAroundStep} from "./replace_step" import {clearIncompatible} from "./mark" function canCut(node: Node, start: number, end: number) { return (start == 0 || node.canReplace(start, node.childCount)) && (end == node.childCount || node.canReplace(0, end)) } /// Try to find a target depth to which the content in the given range /// can be lifted. Will not go across /// [isolating](#model.NodeSpec.isolating) parent nodes. export function liftTarget(range: NodeRange): number | null { let parent = range.parent let content = parent.content.cutByIndex(range.startIndex, range.endIndex) for (let depth = range.depth, contentBefore = 0, contentAfter = 0;; --depth) { let node = range.$from.node(depth) let index = range.$from.index(depth) + contentBefore, endIndex = range.$to.indexAfter(depth) - contentAfter if (depth < range.depth && node.canReplace(index, endIndex, content)) return depth if (depth == 0 || node.type.spec.isolating || !canCut(node, index, endIndex)) break if (index) contentBefore = 1 if (endIndex < node.childCount) contentAfter = 1 } return null } export function lift(tr: Transform, range: NodeRange, target: number) { let {$from, $to, depth} = range let gapStart = $from.before(depth + 1), gapEnd = $to.after(depth + 1) let start = gapStart, end = gapEnd let before = Fragment.empty, openStart = 0 for (let d = depth, splitting = false; d > target; d--) if (splitting || $from.index(d) > 0) { splitting = true before = Fragment.from($from.node(d).copy(before)) openStart++ } else { start-- } let after = Fragment.empty, openEnd = 0 for (let d = depth, splitting = false; d > target; d--) if (splitting || $to.after(d + 1) < $to.end(d)) { splitting = true after = Fragment.from($to.node(d).copy(after)) openEnd++ } else { end++ } tr.step(new ReplaceAroundStep(start, end, gapStart, gapEnd, new Slice(before.append(after), openStart, openEnd), before.size - openStart, true)) } /// Try to find a valid way to wrap the content in the given range in a /// node of the given type. May introduce extra nodes around and inside /// the wrapper node, if necessary. Returns null if no valid wrapping /// could be found. When `innerRange` is given, that range's content is /// used as the content to fit into the wrapping, instead of the /// content of `range`. export function findWrapping( range: NodeRange, nodeType: NodeType, attrs: Attrs | null = null, innerRange = range ): {type: NodeType, attrs: Attrs | null}[] | null { let around = findWrappingOutside(range, nodeType) let inner = around && findWrappingInside(innerRange, nodeType) if (!inner) return null return (around!.map(withAttrs) as {type: NodeType, attrs: Attrs | null}[]) .concat({type: nodeType, attrs}).concat(inner.map(withAttrs)) } function withAttrs(type: NodeType) { return {type, attrs: null} } function findWrappingOutside(range: NodeRange, type: NodeType) { let {parent, startIndex, endIndex} = range let around = parent.contentMatchAt(startIndex).findWrapping(type) if (!around) return null let outer = around.length ? around[0] : type return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null } function findWrappingInside(range: NodeRange, type: NodeType) { let {parent, startIndex, endIndex} = range let inner = parent.child(startIndex) let inside = type.contentMatch.findWrapping(inner.type) if (!inside) return null let lastType = inside.length ? inside[inside.length - 1] : type let innerMatch: ContentMatch | null = lastType.contentMatch for (let i = startIndex; innerMatch && i < endIndex; i++) innerMatch = innerMatch.matchType(parent.child(i).type) if (!innerMatch || !innerMatch.validEnd) return null return inside } export function wrap(tr: Transform, range: NodeRange, wrappers: readonly {type: NodeType, attrs?: Attrs | null}[]) { let content = Fragment.empty for (let i = wrappers.length - 1; i >= 0; i--) { if (content.size) { let match = wrappers[i].type.contentMatch.matchFragment(content) if (!match || !match.validEnd) throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper") } content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)) } let start = range.start, end = range.end tr.step(new ReplaceAroundStep(start, end, start, end, new Slice(content, 0, 0), wrappers.length, true)) } export function setBlockType(tr: Transform, from: number, to: number, type: NodeType, attrs: Attrs | null | ((oldNode: Node) => Attrs)) { if (!type.isTextblock) throw new RangeError("Type given to setBlockType should be a textblock") let mapFrom = tr.steps.length tr.doc.nodesBetween(from, to, (node, pos) => { let attrsHere = typeof attrs == "function" ? attrs(node) : attrs if (node.isTextblock && !node.hasMarkup(type, attrsHere) && canChangeType(tr.doc, tr.mapping.slice(mapFrom).map(pos), type)) { let convertNewlines = null if (type.schema.linebreakReplacement) { let pre = type.whitespace == "pre", supportLinebreak = !!type.contentMatch.matchType(type.schema.linebreakReplacement) if (pre && !supportLinebreak) convertNewlines = false else if (!pre && supportLinebreak) convertNewlines = true } // Ensure all markup that isn't allowed in the new node type is cleared if (convertNewlines === false) replaceLinebreaks(tr, node, pos, mapFrom) clearIncompatible(tr, tr.mapping.slice(mapFrom).map(pos, 1), type, undefined, convertNewlines === null) let mapping = tr.mapping.slice(mapFrom) let startM = mapping.map(pos, 1), endM = mapping.map(pos + node.nodeSize, 1) tr.step(new ReplaceAroundStep(startM, endM, startM + 1, endM - 1, new Slice(Fragment.from(type.create(attrsHere, null, node.marks)), 0, 0), 1, true)) if (convertNewlines === true) replaceNewlines(tr, node, pos, mapFrom) return false } }) } function replaceNewlines(tr: Transform, node: Node, pos: number, mapFrom: number) { node.forEach((child, offset) => { if (child.isText) { let m, newline = /\r?\n|\r/g while (m = newline.exec(child.text!)) { let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset + m.index) tr.replaceWith(start, start + 1, node.type.schema.linebreakReplacement!.create()) } } }) } function replaceLinebreaks(tr: Transform, node: Node, pos: number, mapFrom: number) { node.forEach((child, offset) => { if (child.type == child.type.schema.linebreakReplacement) { let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset) tr.replaceWith(start, start + 1, node.type.schema.text("\n")) } }) } function canChangeType(doc: Node, pos: number, type: NodeType) { let $pos = doc.resolve(pos), index = $pos.index() return $pos.parent.canReplaceWith(index, index + 1, type) } /// Change the type, attributes, and/or marks of the node at `pos`. /// When `type` isn't given, the existing node type is preserved, export function setNodeMarkup(tr: Transform, pos: number, type: NodeType | undefined | null, attrs: Attrs | null, marks: readonly Mark[] | undefined) { let node = tr.doc.nodeAt(pos) if (!node) throw new RangeError("No node at given position") if (!type) type = node.type let newNode = type.create(attrs, null, marks || node.marks) if (node.isLeaf) return tr.replaceWith(pos, pos + node.nodeSize, newNode) if (!type.validContent(node.content)) throw new RangeError("Invalid content for node type " + type.name) tr.step(new ReplaceAroundStep(pos, pos + node.nodeSize, pos + 1, pos + node.nodeSize - 1, new Slice(Fragment.from(newNode), 0, 0), 1, true)) } /// Check whether splitting at the given position is allowed. export function canSplit(doc: Node, pos: number, depth = 1, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]): boolean { let $pos = doc.resolve(pos), base = $pos.depth - depth let innerType = (typesAfter && typesAfter[typesAfter.length - 1]) || $pos.parent if (base < 0 || $pos.parent.type.spec.isolating || !$pos.parent.canReplace($pos.index(), $pos.parent.childCount) || !innerType.type.validContent($pos.parent.content.cutByIndex($pos.index(), $pos.parent.childCount))) return false for (let d = $pos.depth - 1, i = depth - 2; d > base; d--, i--) { let node = $pos.node(d), index = $pos.index(d) if (node.type.spec.isolating) return false let rest = node.content.cutByIndex(index, node.childCount) let overrideChild = typesAfter && typesAfter[i + 1] if (overrideChild) rest = rest.replaceChild(0, overrideChild.type.create(overrideChild.attrs)) let after = (typesAfter && typesAfter[i]) || node if (!node.canReplace(index + 1, node.childCount) || !after.type.validContent(rest)) return false } let index = $pos.indexAfter(base) let baseType = typesAfter && typesAfter[0] return $pos.node(base).canReplaceWith(index, index, baseType ? baseType.type : $pos.node(base + 1).type) } export function split(tr: Transform, pos: number, depth = 1, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]) { let $pos = tr.doc.resolve(pos), before = Fragment.empty, after = Fragment.empty for (let d = $pos.depth, e = $pos.depth - depth, i = depth - 1; d > e; d--, i--) { before = Fragment.from($pos.node(d).copy(before)) let typeAfter = typesAfter && typesAfter[i] after = Fragment.from(typeAfter ? typeAfter.type.create(typeAfter.attrs, after) : $pos.node(d).copy(after)) } tr.step(new ReplaceStep(pos, pos, new Slice(before.append(after), depth, depth), true)) } /// Test whether the blocks before and after a given position can be /// joined. export function canJoin(doc: Node, pos: number): boolean { let $pos = doc.resolve(pos), index = $pos.index() return joinable($pos.nodeBefore, $pos.nodeAfter) && $pos.parent.canReplace(index, index + 1) } function canAppendWithSubstitutedLinebreaks(a: Node, b: Node) { if (!b.content.size) a.type.compatibleContent(b.type) let match: ContentMatch | null = a.contentMatchAt(a.childCount) let {linebreakReplacement} = a.type.schema for (let i = 0; i < b.childCount; i++) { let child = b.child(i) let type = child.type == linebreakReplacement ? a.type.schema.nodes.text : child.type match = match.matchType(type) if (!match) return false if (!a.type.allowsMarks(child.marks)) return false } return match.validEnd } function joinable(a: Node | null, b: Node | null) { return !!(a && b && !a.isLeaf && canAppendWithSubstitutedLinebreaks(a, b)) } /// Find an ancestor of the given position that can be joined to the /// block before (or after if `dir` is positive). Returns the joinable /// point, if any. export function joinPoint(doc: Node, pos: number, dir = -1) { let $pos = doc.resolve(pos) for (let d = $pos.depth;; d--) { let before, after, index = $pos.index(d) if (d == $pos.depth) { before = $pos.nodeBefore after = $pos.nodeAfter } else if (dir > 0) { before = $pos.node(d + 1) index++ after = $pos.node(d).maybeChild(index) } else { before = $pos.node(d).maybeChild(index - 1) after = $pos.node(d + 1) } if (before && !before.isTextblock && joinable(before, after) && $pos.node(d).canReplace(index, index + 1)) return pos if (d == 0) break pos = dir < 0 ? $pos.before(d) : $pos.after(d) } } export function join(tr: Transform, pos: number, depth: number) { let convertNewlines = null let {linebreakReplacement} = tr.doc.type.schema let $before = tr.doc.resolve(pos - depth), beforeType = $before.node().type if (linebreakReplacement && beforeType.inlineContent) { let pre = beforeType.whitespace == "pre" let supportLinebreak = !!beforeType.contentMatch.matchType(linebreakReplacement) if (pre && !supportLinebreak) convertNewlines = false else if (!pre && supportLinebreak) convertNewlines = true } let mapFrom = tr.steps.length if (convertNewlines === false) { let $after = tr.doc.resolve(pos + depth) replaceLinebreaks(tr, $after.node(), $after.before(), mapFrom) } if (beforeType.inlineContent) clearIncompatible(tr, pos + depth - 1, beforeType, $before.node().contentMatchAt($before.index()), convertNewlines == null) let mapping = tr.mapping.slice(mapFrom), start = mapping.map(pos - depth) tr.step(new ReplaceStep(start, mapping.map(pos + depth, - 1), Slice.empty, true)) if (convertNewlines === true) { let $full = tr.doc.resolve(start) replaceNewlines(tr, $full.node(), $full.before(), tr.steps.length) } return tr } /// Try to find a point where a node of the given type can be inserted /// near `pos`, by searching up the node hierarchy when `pos` itself /// isn't a valid place but is at the start or end of a node. Return /// null if no position was found. export function insertPoint(doc: Node, pos: number, nodeType: NodeType): number | null { let $pos = doc.resolve(pos) if ($pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) return pos if ($pos.parentOffset == 0) for (let d = $pos.depth - 1; d >= 0; d--) { let index = $pos.index(d) if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.before(d + 1) if (index > 0) return null } if ($pos.parentOffset == $pos.parent.content.size) for (let d = $pos.depth - 1; d >= 0; d--) { let index = $pos.indexAfter(d) if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.after(d + 1) if (index < $pos.node(d).childCount) return null } return null } /// Finds a position at or around the given position where the given /// slice can be inserted. Will look at parent nodes' nearest boundary /// and try there, even if the original position wasn't directly at the /// start or end of that node. Returns null when no position was found. export function dropPoint(doc: Node, pos: number, slice: Slice): number | null { let $pos = doc.resolve(pos) if (!slice.content.size) return pos let content = slice.content for (let i = 0; i < slice.openStart; i++) content = content.firstChild!.content for (let pass = 1; pass <= (slice.openStart == 0 && slice.size ? 2 : 1); pass++) { for (let d = $pos.depth; d >= 0; d--) { let bias = d == $pos.depth ? 0 : $pos.pos <= ($pos.start(d + 1) + $pos.end(d + 1)) / 2 ? -1 : 1 let insertPos = $pos.index(d) + (bias > 0 ? 1 : 0) let parent = $pos.node(d), fits: boolean | null = false if (pass == 1) { fits = parent.canReplace(insertPos, insertPos, content) } else { let wrapping = parent.contentMatchAt(insertPos).findWrapping(content.firstChild!.type) fits = wrapping && parent.canReplaceWith(insertPos, insertPos, wrapping[0]) } if (fits) return bias == 0 ? $pos.pos : bias < 0 ? $pos.before(d + 1) : $pos.after(d + 1) } } return null } ================================================ FILE: src/transform.ts ================================================ import {Node, NodeType, Mark, MarkType, ContentMatch, Slice, Fragment, NodeRange, Attrs} from "prosemirror-model" import {Mapping} from "./map" import {Step} from "./step" import {addMark, removeMark, clearIncompatible} from "./mark" import {replaceStep, replaceRange, replaceRangeWith, deleteRange} from "./replace" import {lift, wrap, setBlockType, setNodeMarkup, split, join} from "./structure" import {AttrStep, DocAttrStep} from "./attr_step" import {AddNodeMarkStep, RemoveNodeMarkStep} from "./mark_step" /// @internal export let TransformError = class extends Error {} TransformError = function TransformError(this: any, message: string) { let err = Error.call(this, message) ;(err as any).__proto__ = TransformError.prototype return err } as any TransformError.prototype = Object.create(Error.prototype) TransformError.prototype.constructor = TransformError TransformError.prototype.name = "TransformError" /// Abstraction to build up and track an array of /// [steps](#transform.Step) representing a document transformation. /// /// Most transforming methods return the `Transform` object itself, so /// that they can be chained. export class Transform { /// The steps in this transform. readonly steps: Step[] = [] /// The documents before each of the steps. readonly docs: Node[] = [] /// A mapping with the maps for each of the steps in this transform. readonly mapping: Mapping = new Mapping /// Create a transform that starts with the given document. constructor( /// The current document (the result of applying the steps in the /// transform). public doc: Node ) {} /// The starting document. get before() { return this.docs.length ? this.docs[0] : this.doc } /// Apply a new step in this transform, saving the result. Throws an /// error when the step fails. step(step: Step) { let result = this.maybeStep(step) if (result.failed) throw new TransformError(result.failed) return this } /// Try to apply a step in this transformation, ignoring it if it /// fails. Returns the step result. maybeStep(step: Step) { let result = step.apply(this.doc) if (!result.failed) this.addStep(step, result.doc!) return result } /// True when the document has been changed (when there are any /// steps). get docChanged() { return this.steps.length > 0 } /// Return a single range, in post-transform document positions, /// that covers all content changed by this transform. Returns null /// if no replacements are made. Note that this will ignore changes /// that add/remove marks without replacing the underlying content. changedRange() { let from = 1e9, to = -1e9 for (let i = 0; i < this.mapping.maps.length; i++) { let map = this.mapping.maps[i] if (i) { from = map.map(from, 1) to = map.map(to, -1) } map.forEach((_f, _t, fromB, toB) => { from = Math.min(from, fromB) to = Math.max(to, toB) }) } return from == 1e9 ? null : {from, to} } /// @internal addStep(step: Step, doc: Node) { this.docs.push(this.doc) this.steps.push(step) this.mapping.appendMap(step.getMap()) this.doc = doc } /// Replace the part of the document between `from` and `to` with the /// given `slice`. replace(from: number, to = from, slice = Slice.empty): this { let step = replaceStep(this.doc, from, to, slice) if (step) this.step(step) return this } /// Replace the given range with the given content, which may be a /// fragment, node, or array of nodes. replaceWith(from: number, to: number, content: Fragment | Node | readonly Node[]): this { return this.replace(from, to, new Slice(Fragment.from(content), 0, 0)) } /// Delete the content between the given positions. delete(from: number, to: number): this { return this.replace(from, to, Slice.empty) } /// Insert the given content at the given position. insert(pos: number, content: Fragment | Node | readonly Node[]): this { return this.replaceWith(pos, pos, content) } /// Replace a range of the document with a given slice, using /// `from`, `to`, and the slice's /// [`openStart`](#model.Slice.openStart) property as hints, rather /// than fixed start and end points. This method may grow the /// replaced area or close open nodes in the slice in order to get a /// fit that is more in line with WYSIWYG expectations, by dropping /// fully covered parent nodes of the replaced region when they are /// marked [non-defining as /// context](#model.NodeSpec.definingAsContext), or including an /// open parent node from the slice that _is_ marked as [defining /// its content](#model.NodeSpec.definingForContent). /// /// This is the method, for example, to handle paste. The similar /// [`replace`](#transform.Transform.replace) method is a more /// primitive tool which will _not_ move the start and end of its given /// range, and is useful in situations where you need more precise /// control over what happens. replaceRange(from: number, to: number, slice: Slice): this { replaceRange(this, from, to, slice) return this } /// Replace the given range with a node, but use `from` and `to` as /// hints, rather than precise positions. When from and to are the same /// and are at the start or end of a parent node in which the given /// node doesn't fit, this method may _move_ them out towards a parent /// that does allow the given node to be placed. When the given range /// completely covers a parent node, this method may completely replace /// that parent node. replaceRangeWith(from: number, to: number, node: Node): this { replaceRangeWith(this, from, to, node) return this } /// Delete the given range, expanding it to cover fully covered /// parent nodes until a valid replace is found. deleteRange(from: number, to: number): this { deleteRange(this, from, to) return this } /// Split the content in the given range off from its parent, if there /// is sibling content before or after it, and move it up the tree to /// the depth specified by `target`. You'll probably want to use /// [`liftTarget`](#transform.liftTarget) to compute `target`, to make /// sure the lift is valid. lift(range: NodeRange, target: number): this { lift(this, range, target) return this } /// Join the blocks around the given position. If depth is 2, their /// last and first siblings are also joined, and so on. join(pos: number, depth: number = 1): this { join(this, pos, depth) return this } /// Wrap the given [range](#model.NodeRange) in the given set of wrappers. /// The wrappers are assumed to be valid in this position, and should /// probably be computed with [`findWrapping`](#transform.findWrapping). wrap(range: NodeRange, wrappers: readonly {type: NodeType, attrs?: Attrs | null}[]): this { wrap(this, range, wrappers) return this } /// Set the type of all textblocks (partly) between `from` and `to` to /// the given node type with the given attributes. setBlockType(from: number, to = from, type: NodeType, attrs: Attrs | null | ((oldNode: Node) => Attrs) = null): this { setBlockType(this, from, to, type, attrs) return this } /// Change the type, attributes, and/or marks of the node at `pos`. /// When `type` isn't given, the existing node type is preserved, setNodeMarkup(pos: number, type?: NodeType | null, attrs: Attrs | null = null, marks?: readonly Mark[]): this { setNodeMarkup(this, pos, type, attrs, marks) return this } /// Set a single attribute on a given node to a new value. /// The `pos` addresses the document content. Use `setDocAttribute` /// to set attributes on the document itself. setNodeAttribute(pos: number, attr: string, value: any): this { this.step(new AttrStep(pos, attr, value)) return this } /// Set a single attribute on the document to a new value. setDocAttribute(attr: string, value: any): this { this.step(new DocAttrStep(attr, value)) return this } /// Add a mark to the node at position `pos`. addNodeMark(pos: number, mark: Mark): this { this.step(new AddNodeMarkStep(pos, mark)) return this } /// Remove a mark (or all marks of the given type) from the node at /// position `pos`. removeNodeMark(pos: number, mark: Mark | MarkType): this { let node = this.doc.nodeAt(pos) if (!node) throw new RangeError("No node at position " + pos) if (mark instanceof Mark) { if (mark.isInSet(node.marks)) this.step(new RemoveNodeMarkStep(pos, mark)) } else { let set = node.marks, found: Mark | undefined, steps: Step[] = [] while (found = mark.isInSet(set)) { steps.push(new RemoveNodeMarkStep(pos, found)) set = found.removeFromSet(set) } for (let i = steps.length - 1; i >= 0; i--) this.step(steps[i]) } return this } /// Split the node at the given position, and optionally, if `depth` is /// greater than one, any number of nodes above that. By default, the /// parts split off will inherit the node type of the original node. /// This can be changed by passing an array of types and attributes to /// use after the split (with the outermost nodes coming first). split(pos: number, depth = 1, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]) { split(this, pos, depth, typesAfter) return this } /// Add the given mark to the inline content between `from` and `to`. addMark(from: number, to: number, mark: Mark): this { addMark(this, from, to, mark) return this } /// Remove marks from inline nodes between `from` and `to`. When /// `mark` is a single mark, remove precisely that mark. When it is /// a mark type, remove all marks of that type. When it is null, /// remove all marks of any type. removeMark(from: number, to: number, mark?: Mark | MarkType | null) { removeMark(this, from, to, mark) return this } /// Removes all marks and nodes from the content of the node at /// `pos` that don't match the given new parent node type. Accepts /// an optional starting [content match](#model.ContentMatch) as /// third argument. clearIncompatible(pos: number, parentType: NodeType, match?: ContentMatch) { clearIncompatible(this, pos, parentType, match) return this } } ================================================ FILE: test/test-mapping.ts ================================================ import ist from "ist" import {Mapping, StepMap} from "prosemirror-transform" function testMapping(mapping: Mapping, ...cases: [number, number, number?, boolean?][]) { let inverted = mapping.invert() for (let i = 0; i < cases.length; i++) { let [from, to, bias = 1, lossy] = cases[i] ist(mapping.map(from, bias), to) if (!lossy) ist(inverted.map(to, bias), from) } } function testDel(mapping: Mapping, pos: number, side: number, flags: string) { let r = mapping.mapResult(pos, side), found = "" if (r.deleted) found += "d" if (r.deletedBefore) found += "b" if (r.deletedAfter) found += "a" if (r.deletedAcross) found += "x" ist(found, flags) } function mk(...args: (number[] | {[from: number]: number})[]) { let mapping = new Mapping args.forEach(arg => { if (Array.isArray(arg)) mapping.appendMap(new StepMap(arg)) else for (let from in arg) mapping.setMirror(+from, arg[from]) }) return mapping } describe("Mapping", () => { it("can map through a single insertion", () => { testMapping(mk([2, 0, 4]), [0, 0], [2, 6], [2, 2, -1], [3, 7]) }) it("can map through a single deletion", () => { testMapping(mk([2, 4, 0]), [0, 0], [2, 2, -1], [3, 2, 1, true], [6, 2, 1], [6, 2, -1, true], [7, 3]) }) it("can map through a single replace", () => { testMapping(mk([2, 4, 4]), [0, 0], [2, 2, 1], [4, 6, 1, true], [4, 2, -1, true], [6, 6, -1], [8, 8]) }) it("can map through a mirrorred delete-insert", () => { testMapping(mk([2, 4, 0], [2, 0, 4], {0: 1}), [0, 0], [2, 2], [4, 4], [6, 6], [7, 7]) }) it("cap map through a mirrorred insert-delete", () => { testMapping(mk([2, 0, 4], [2, 4, 0], {0: 1}), [0, 0], [2, 2], [3, 3]) }) it("can map through an delete-insert with an insert in between", () => { testMapping(mk([2, 4, 0], [1, 0, 1], [3, 0, 4], {0: 2}), [0, 0], [1, 2], [4, 5], [6, 7], [7, 8]) }) it("assigns the correct deleted flags when deletions happen before", () => { testDel(mk([0, 2, 0]), 2, -1, "db") testDel(mk([0, 2, 0]), 2, 1, "b") testDel(mk([0, 2, 2]), 2, -1, "db") testDel(mk([0, 1, 0], [0, 1, 0]), 2, -1, "db") testDel(mk([0, 1, 0]), 2, -1, "") }) it("assigns the correct deleted flags when deletions happen after", () => { testDel(mk([2, 2, 0]), 2, -1, "a") testDel(mk([2, 2, 0]), 2, 1, "da") testDel(mk([2, 2, 2]), 2, 1, "da") testDel(mk([2, 1, 0], [2, 1, 0]), 2, 1, "da") testDel(mk([3, 2, 0]), 2, -1, "") }) it("assigns the correct deleted flags when deletions happen across", () => { testDel(mk([0, 4, 0]), 2, -1, "dbax") testDel(mk([0, 4, 0]), 2, 1, "dbax") testDel(mk([0, 4, 0]), 2, 1, "dbax") testDel(mk([0, 1, 0], [4, 1, 0], [0, 3, 0]), 2, 1, "dbax") }) it("assigns the correct deleted flags when deletions happen around", () => { testDel(mk([4, 1, 0], [0, 1, 0]), 2, -1, "") testDel(mk([2, 1, 0], [0, 2, 0]), 2, -1, "dba") testDel(mk([2, 1, 0], [0, 1, 0]), 2, -1, "a") testDel(mk([3, 1, 0], [0, 2, 0]), 2, -1, "db") }) }) ================================================ FILE: test/test-replace_step.ts ================================================ import ist from "ist" import {Node} from "prosemirror-model" import {doc, blockquote, p, schema, eq} from "prosemirror-test-builder" import {Transform} from "prosemirror-transform" describe("ReplaceAroundStep.map", () => { function test(doc: Node, change: (tr: Transform) => void, otherChange: (tr: Transform) => void, expected: Node) { let trA = new Transform(doc), trB = new Transform(doc) change(trA) otherChange(trB) let result = new Transform(trB.doc).step(trA.steps[0].map(trB.mapping)!).doc ist(result, expected, eq) } it("doesn't break wrap steps on insertions", () => test(doc(p("a")), tr => tr.wrap(tr.doc.resolve(1).blockRange()!, [{type: schema.nodes.blockquote}]), tr => tr.insert(0, p("b")), doc(p("b"), blockquote(p("a"))))) it("doesn't overwrite content inserted at start of unwrap step", () => test(doc(blockquote(p("a"))), tr => tr.lift(tr.doc.resolve(2).blockRange()!, 0), tr => tr.insert(2, schema.text("x")), doc(p("xa")))) }) ================================================ FILE: test/test-step.ts ================================================ import {Slice, Fragment} from "prosemirror-model" import {ReplaceStep, AddMarkStep, RemoveMarkStep} from "prosemirror-transform" import ist from "ist" import {eq, schema, doc, p} from "prosemirror-test-builder" const testDoc = doc(p("foobar")) function mkStep(from: number, to: number, val: string | null) { if (val == "+em") return new AddMarkStep(from, to, schema.marks.em.create()) else if (val == "-em") return new RemoveMarkStep(from, to, schema.marks.em.create()) else return new ReplaceStep(from, to, val == null ? Slice.empty : new Slice(Fragment.from(schema.text(val)), 0, 0)) } describe("Step", () => { describe("merge", () => { function yes(from1: number, to1: number, val1: string | null, from2: number, to2: number, val2: string | null) { return () => { let step1 = mkStep(from1, to1, val1), step2 = mkStep(from2, to2, val2) let merged = step1.merge(step2) ist(merged) ist(merged!.apply(testDoc).doc, step2.apply(step1.apply(testDoc).doc!).doc, eq) } } function no(from1: number, to1: number, val1: string | null, from2: number, to2: number, val2: string | null) { return () => { let step1 = mkStep(from1, to1, val1), step2 = mkStep(from2, to2, val2) ist(!step1.merge(step2)) } } it("merges typing changes", yes(2, 2, "a", 3, 3, "b")) it("merges inverse typing", yes(2, 2, "a", 2, 2, "b")) it("doesn't merge separated typing", no(2, 2, "a", 4, 4, "b")) it("doesn't merge inverted separated typing", no(3, 3, "a", 2, 2, "b")) it("merges adjacent backspaces", yes(3, 4, null, 2, 3, null)) it("merges adjacent deletes", yes(2, 3, null, 2, 3, null)) it("doesn't merge separate backspaces", no(1, 2, null, 2, 3, null)) it("merges backspace and type", yes(2, 3, null, 2, 2, "x")) it("merges longer adjacent inserts", yes(2, 2, "quux", 6, 6, "baz")) it("merges inverted longer inserts", yes(2, 2, "quux", 2, 2, "baz")) it("merges longer deletes", yes(2, 5, null, 2, 4, null)) it("merges inverted longer deletes", yes(4, 6, null, 2, 4, null)) it("merges overwrites", yes(3, 4, "x", 4, 5, "y")) it("merges adding adjacent styles", yes(1, 2, "+em", 2, 4, "+em")) it("merges adding overlapping styles", yes(1, 3, "+em", 2, 4, "+em")) it("doesn't merge separate styles", no(1, 2, "+em", 3, 4, "+em")) it("merges removing adjacent styles", yes(1, 2, "-em", 2, 4, "-em")) it("merges removing overlapping styles", yes(1, 3, "-em", 2, 4, "-em")) it("doesn't merge removing separate styles", no(1, 2, "-em", 3, 4, "-em")) }) }) ================================================ FILE: test/test-structure.ts ================================================ import {Schema, Slice, Node} from "prosemirror-model" import {canSplit, liftTarget, findWrapping, Transform} from "prosemirror-transform" import {eq, schema as baseSchema} from "prosemirror-test-builder" import ist from "ist" const schema = new Schema({ nodes: { doc: {content: "head? block* sect* closing?"}, para: {content: "text*", group: "block"}, head: {content: "text*", marks: ""}, figure: {content: "caption figureimage", group: "block"}, quote: {content: "block+", group: "block"}, figureimage: {}, caption: {content: "text*", marks: ""}, sect: {content: "head block* sect*"}, closing: {content: "text*"}, text: baseSchema.spec.nodes.get("text")!, fixed: {content: "head para closing", group: "block"} }, marks: { em: {} } }) function n(name: string, ...content: Node[]) { return schema.nodes[name].create(null, content) } function t(str: string, em = false) { return schema.text(str, em ? [schema.mark("em")] : null) } const doc = n("doc", // 0 n("head", t("Head")), // 6 n("para", t("Intro")), // 13 n("sect", // 14 n("head", t("Section head")), // 28 n("sect", // 29 n("head", t("Subsection head")), // 46 n("para", t("Subtext")), // 55 n("figure", // 56 n("caption", t("Figure caption")), // 72 n("figureimage")), // 74 n("quote", n("para", t("!"))))), // 81 n("sect", // 82 n("head", t("S2")), // 86 n("para", t("Yes"))), // 92 n("closing", t("fin"))) // 97 function range(pos: number, end?: number) { return doc.resolve(pos).blockRange(end == null ? undefined : doc.resolve(end)) } describe("canSplit", () => { function yes(pos: number, depth?: number, after?: string) { return () => ist(canSplit(doc, pos, depth, after == null ? undefined : [{type: schema.nodes[after]}])) } function no(pos: number, depth?: number, after?: string) { return () => ist(!canSplit(doc, pos, depth, after == null ? undefined : [{type: schema.nodes[after]}])) } it("can't at start", no(0)) it("can't in head", no(3)) it("can by making head a para", yes(3, 1, "para")) it("can't on top level", no(6)) it("can in regular para", yes(8)) it("can't at start of section", no(14)) it("can't in section head", no(17)) it("can if also splitting the section", yes(17, 2)) it("can if making the remaining head a para", yes(18, 1, "para")) it("can't after the section head", no(46)) it("can in the first section para", yes(48)) it("can't in the figure caption", no(60)) it("can't if it also splits the figure", no(62, 2)) it("can't after the figure caption", no(72)) it("can in the first para in a quote", yes(76)) it("can if it also splits the quote", yes(77, 2)) it("can't at the end of the document", no(97)) it("doesn't return true when the split-off content doesn't fit in the given node type", () => { let s = new Schema({nodes: schema.spec.nodes.addBefore("heading", "title", {content: "text*"}) .addToEnd("chapter", {content: "title scene+"}) .addToEnd("scene", {content: "para+"}) .update("doc", {content: "chapter+"})}) ist(!canSplit(s.node("doc", null, s.node("chapter", null, [ s.node("title", null, s.text("title")), s.node("scene", null, s.node("para", null, s.text("scene"))) ])), 4, 1, [{type: s.nodes.scene}])) }) }) describe("liftTarget", () => { function yes(pos: number) { return () => { let r = range(pos); ist(r && liftTarget(r)) } } function no(pos: number) { return () => { let r = range(pos); ist(!(r && liftTarget(r))) } } it("can't at the start of the doc", no(0)) it("can't in the heading", no(3)) it("can't in a subsection para", no(52)) it("can't in a figure caption", no(70)) it("can from a quote", yes(76)) it("can't in a section head", no(86)) it("notices unliftable content after or before", () => { let s = new Schema({nodes: { doc: {content: "section+"}, section: {content: "heading? p+"}, heading: {content: "p+"}, p: {content: "text*"}, text: {inline: true}, }}) let p = s.node("p", null, [s.text("A")]) let d = s.node("doc", null, [s.node("section", null, [s.node("heading", null, [p, p, p]), p])]) ist(liftTarget(d.resolve(3).blockRange()!), null) ist(liftTarget(d.resolve(6).blockRange()!), null) ist(liftTarget(d.resolve(3).blockRange(d.resolve(6))!), null) ist(liftTarget(d.resolve(9).blockRange()!), 1) }) }) describe("findWrapping", () => { function yes(pos: number, end: number, type: string) { return () => { let r = range(pos, end); ist(r && findWrapping(r, schema.nodes[type])) } } function no(pos: number, end: number, type: string) { return () => { let r = range(pos, end); ist(!r || !findWrapping(r, schema.nodes[type])) } } it("can wrap the whole doc in a section", yes(0, 92, "sect")) it("can't wrap a head before a para in a section", no(4, 4, "sect")) it("can wrap a top paragraph in a quote", yes(8, 8, "quote")) it("can't wrap a section head in a quote", no(18, 18, "quote")) it("can wrap a figure in a quote", yes(55, 74, "quote")) it("can't wrap a head in a figure", no(90, 90, "figure")) }) describe("Transform", () => { describe("replace", () => { function repl(doc: Node, from: number, to: number, content: Node | null, openStart: number, openEnd: number, result: Node) { return () => { let slice = content ? new Slice(content.content, openStart, openEnd) : Slice.empty let tr = new Transform(doc).replace(from, to, slice) ist(tr.doc, result, eq) } } it("automatically adds a heading to a section", repl(n("doc", n("sect", n("head", t("foo")), n("para", t("bar")))), 6, 6, n("doc", n("sect"), n("sect")), 1, 1, n("doc", n("sect", n("head", t("foo"))), n("sect", n("head"), n("para", t("bar")))))) it("suppresses impossible inputs", repl(n("doc", n("para", t("a")), n("para", t("b"))), 3, 3, n("doc", n("closing", t("."))), 0, 0, n("doc", n("para", t("a")), n("para", t("b"))))) it("adds necessary nodes to the left", repl(n("doc", n("sect", n("head", t("foo")), n("para", t("bar")))), 1, 3, n("doc", n("sect"), n("sect", n("head", t("hi")))), 1, 2, n("doc", n("sect", n("head")), n("sect", n("head", t("hioo")), n("para", t("bar")))))) it("adds a caption to a figure", repl(n("doc"), 0, 0, n("doc", n("figure", n("figureimage"))), 1, 0, n("doc", n("figure", n("caption"), n("figureimage"))))) it("adds an image to a figure", repl(n("doc"), 0, 0, n("doc", n("figure", n("caption"))), 0, 1, n("doc", n("figure", n("caption"), n("figureimage"))))) it("can join figures", repl(n("doc", n("figure", n("caption"), n("figureimage")), n("figure", n("caption"), n("figureimage"))), 3, 8, null, 0, 0, n("doc", n("figure", n("caption"), n("figureimage"))))) it("adds necessary nodes to a parent node", repl(n("doc", n("sect", n("head"), n("figure", n("caption"), n("figureimage")))), 7, 9, n("doc", n("para", t("hi"))), 0, 0, n("doc", n("sect", n("head"), n("figure", n("caption"), n("figureimage")), n("para", t("hi")))))) }) }) ================================================ FILE: test/test-trans.ts ================================================ import {schema, doc, blockquote, pre, h1, h2, p, li, ol, ul, em, strong, code, a, img, br, hr, eq, builders} from "prosemirror-test-builder" import {testTransform} from "./trans.js" import {Transform, liftTarget, findWrapping} from "prosemirror-transform" import {Slice, Fragment, Schema, Node, Mark, MarkType, NodeType, Attrs, NodeSpec} from "prosemirror-model" import ist from "ist" function tag$(node: Node, tag: string): number | null { return (node as any).tag[tag] } function tag(node: Node, tag: string): number { let value = tag$(node, tag) if (value == null) throw new Error(`Missing tag ${tag} on ${node}`) return value } describe("Transform", () => { describe("addMark", () => { function add(doc: Node, mark: Mark, expect: Node) { testTransform(new Transform(doc).addMark(tag(doc, "a"), tag(doc, "b"), mark), expect) } it("should add a mark", () => add(doc(p("hello there!")), schema.mark("strong"), doc(p("hello ", strong("there"), "!")))) it("should only add a mark once", () => add(doc(p("hello ", strong("there"), "!")), schema.mark("strong"), doc(p("hello ", strong("there!"))))) it("should join overlapping marks", () => add(doc(p("one two ", em("three four"))), schema.mark("strong"), doc(p("one ", strong("two ", em("three")), em(" four"))))) it("should overwrite marks with different attributes", () => add(doc(p("this is a ", a("link"))), schema.mark("link", {href: "bar"}), doc(p("this is a ", a({href: "bar"}, "link"))))) it("can add a mark in a nested node", () => add(doc(p("before"), blockquote(p("the variable is called i")), p("after")), schema.mark("code"), doc(p("before"), blockquote(p("the variable is called ", code("i"))), p("after")))) it("can add a mark across blocks", () => add(doc(p("hi this"), blockquote(p("is")), p("a document"), p("!")), schema.mark("em"), doc(p("hi ", em("this")), blockquote(p(em("is"))), p(em("a docu"), "ment"), p("!")))) it("does not remove non-excluded marks of the same type", () => { let schema = new Schema({ nodes: {doc: {content: "text*"}, text: {}}, marks: {comment: {excludes: "", attrs: {id: {}}}} }) let tr = new Transform(schema.node("doc", null, schema.text("hi", [schema.mark("comment", {id: 10})]))) tr.addMark(0, 2, schema.mark("comment", {id: 20})) ist(tr.doc.firstChild!.marks.length, 2) }) it("can remove multiple excluded marks", () => { let schema = new Schema({ nodes: {doc: {content: "text*"}, text: {}}, marks: {big: {excludes: "small1 small2"}, small1: {}, small2: {}} }) let tr = new Transform(schema.node("doc", null, schema.text("hi", [schema.mark("small1"), schema.mark("small2")]))) ist(tr.doc.firstChild!.marks.length, 2) tr.addMark(0, 2, schema.mark("big")) ist(tr.doc.firstChild!.marks.length, 1) ist(tr.doc.firstChild!.marks[0].type.name, "big") }) }) describe("removeMark", () => { function rem(doc: Node, mark: Mark | null, expect: Node) { testTransform(new Transform(doc).removeMark(tag(doc, "a"), tag(doc, "b"), mark), expect) } it("can cut a gap", () => rem(doc(p(em("hello world!"))), schema.mark("em"), doc(p(em("hello "), "world", em("!"))))) it("doesn't do anything when there's no mark", () => rem(doc(p(em("hello"), " world!")), schema.mark("em"), doc(p(em("hello"), " world!")))) it("can remove marks from nested nodes", () => rem(doc(p(em("one ", strong("two"), " three"))), schema.mark("strong"), doc(p(em("one two three"))))) it("can remove a link", () => rem(doc(p("hello ", a("link"))), schema.mark("link", {href: "foo"}), doc(p("hello link")))) it("doesn't remove a non-matching link", () => rem(doc(p("hello ", a("link"))), schema.mark("link", {href: "bar"}), doc(p("hello ", a("link"))))) it("can remove across blocks", () => rem(doc(blockquote(p(em("much em")), p(em("here too"))), p("between", em("...")), p(em("end"))), schema.mark("em"), doc(blockquote(p(em("much "), "em"), p("here too")), p("between..."), p("end")))) it("can remove everything", () => rem(doc(p("hello, ", em("this is ", strong("much"), " ", a("markup")))), null, doc(p("hello, this is much markup")))) it("can remove more than one mark of the same type from a block", () => { let schema = new Schema({ nodes: {doc: {content: "text*"}, text: {}}, marks: {comment: {excludes: "", attrs: {id: {}}}} }) let tr = new Transform(schema.node("doc", null, schema.text("hi", [schema.mark("comment", {id: 1}), schema.mark("comment", {id: 2})]))) ist(tr.doc.firstChild!.marks.length, 2) tr.removeMark(0, 2, schema.marks["comment"]) ist(tr.doc.firstChild!.marks.length, 0) }) }) describe("insert", () => { function ins(doc: Node, nodes: Node | Node[], expect: Node) { testTransform(new Transform(doc).insert(tag(doc, "a"), nodes), expect) } it("can insert a break", () => ins(doc(p("hellothere")), schema.node("hard_break"), doc(p("hello", br(), "there")))) it("can insert an empty paragraph at the top", () => ins(doc(p("one"), "", p("two<2>")), schema.node("paragraph"), doc(p("one"), p(), "", p("two<2>")))) it("can insert two block nodes", () => ins(doc(p("one"), "", p("two<2>")), [schema.node("paragraph", null, [schema.text("hi")]), schema.node("horizontal_rule")], doc(p("one"), p("hi"), hr(), "", p("two<2>")))) it("can insert at the end of a blockquote", () => ins(doc(blockquote(p("hey"), ""), p("after")), schema.node("paragraph"), doc(blockquote(p("hey"), p()), p("after")))) it("can insert at the start of a blockquote", () => ins(doc(blockquote("", p("he<1>y")), p("after<2>")), schema.node("paragraph"), doc(blockquote(p(), "", p("he<1>y")), p("after<2>")))) it("will wrap a node with the suitable parent", () => ins(doc(p("foobar")), schema.nodes.list_item.createAndFill()!, doc(p("foo"), ol(li(p())), p("bar")))) }) describe("delete", () => { function del(doc: Node, expect: Node) { testTransform(new Transform(doc).delete(tag(doc, "a"), tag(doc, "b")), expect) } it("can delete a word", () => del(doc(p("<1>one"), "", p("tw<2>o"), "", p("<3>three")), doc(p("<1>one"), "<2>", p("<3>three")))) it("preserves content constraints", () => del(doc(blockquote("", p("hi"), ""), p("x")), doc(blockquote(p()), p("x")))) it("preserves positions after the range", () => del(doc(blockquote(p("a"), "", p("b"), ""), p("c<1>")), doc(blockquote(p("a")), p("c<1>")))) it("doesn't join incompatible nodes", () => del(doc(pre("foo"), p("bar", img())), doc(pre("fo"), p("ar", img())))) it("doesn't join when marks are incompatible", () => del(doc(pre("foo"), p(em("bar"))), doc(pre("fo"), p(em("ar"))))) }) const linebreakSchema = new Schema({ nodes: schema.spec.nodes.update("hard_break", {...schema.spec.nodes.get("hard_break"), linebreakReplacement: true}) }) const lb = builders(linebreakSchema, { p: {nodeType: "paragraph"}, pre: {nodeType: "code_block"}, br: {nodeType: "hard_break"}, h1: {nodeType: "heading", level: 1}, }) describe("join", () => { function join(doc: Node, expect: Node) { testTransform(new Transform(doc).join(tag(doc, "a")), expect) } it("can join blocks", () => join(doc(blockquote(p("a")), "", blockquote(p("b")), p("after")), doc(blockquote(p("a"), "", p("b")), p("after")))) it("can join compatible blocks", () => join(doc(h1("foo"), "", p("bar")), doc(h1("foobar")))) it("can join nested blocks", () => join(doc(blockquote(blockquote(p("a"), p("b")), "", blockquote(p("c"), p("d")))), doc(blockquote(blockquote(p("a"), p("b"), "", p("c"), p("d")))))) it("can join lists", () => join(doc(ol(li(p("one")), li(p("two"))), "", ol(li(p("three")))), doc(ol(li(p("one")), li(p("two")), "", li(p("three")))))) it("can join list items", () => join(doc(ol(li(p("one")), li(p("two")), "", li(p("three")))), doc(ol(li(p("one")), li(p("two"), "", p("three")))))) it("can join textblocks", () => join(doc(p("foo"), "", p("bar")), doc(p("foobar")))) it("converts newlines to line breaks", () => join(lb.doc(lb.p("one"), "", lb.pre("two\nthree")), lb.doc(lb.p("onetwo", lb.br(), "three")))) it("converts line breaks to newlines", () => join(lb.doc(lb.pre("one"), "", lb.p("two", lb.br(), "three")), lb.doc(lb.pre("onetwo\nthree")))) }) describe("split", () => { function split(doc: Node, expect: Node | "fail", depth?: number, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]) { if (expect == "fail") ist.throws(() => new Transform(doc).split(tag(doc, "a"), depth, typesAfter)) else testTransform(new Transform(doc).split(tag(doc, "a"), depth, typesAfter), expect) } it("can split a textblock", () => split(doc(p("foobar")), doc(p("foo"), p("bar")))) it("correctly maps positions", () => split(doc(p("<1>a"), p("<2>foobar<3>"), p("<4>b")), doc(p("<1>a"), p("<2>foo"), p("bar<3>"), p("<4>b")))) it("can split two deep", () => split(doc(blockquote(blockquote(p("foobar"))), p("after<1>")), doc(blockquote(blockquote(p("foo")), blockquote(p("bar"))), p("after<1>")), 2)) it("can split three deep", () => split(doc(blockquote(blockquote(p("foobar"))), p("after<1>")), doc(blockquote(blockquote(p("foo"))), blockquote(blockquote(p("bar"))), p("after<1>")), 3)) it("can split at end", () => split(doc(blockquote(p("hi"))), doc(blockquote(p("hi"), p(""))))) it("can split at start", () => split(doc(blockquote(p("hi"))), doc(blockquote(p(), p("hi"))))) it("can split inside a list item", () => split(doc(ol(li(p("one<1>")), li(p("twothree")), li(p("four<2>")))), doc(ol(li(p("one<1>")), li(p("two"), p("three")), li(p("four<2>")))))) it("can split a list item", () => split(doc(ol(li(p("one<1>")), li(p("twothree")), li(p("four<2>")))), doc(ol(li(p("one<1>")), li(p("two")), li(p("three")), li(p("four<2>")))), 2)) it("respects the type param", () => split(doc(h1("hello!")), doc(h1("hell"), p("o!")), undefined, [{type: schema.nodes.paragraph}])) it("preserves content constraints before", () => split(doc(blockquote("", p("x"))), "fail")) it("preserves content constraints after", () => split(doc(blockquote(p("x"), "")), "fail")) }) describe("lift", () => { function lift(doc: Node, expect: Node) { let range = doc.resolve(tag(doc, "a")).blockRange(doc.resolve(tag$(doc, "b") || tag(doc, "a"))) testTransform(new Transform(doc).lift(range!, liftTarget(range!)!), expect) } it("can lift a block out of the middle of its parent", () => lift(doc(blockquote(p("one"), p("two"), p("three"))), doc(blockquote(p("one")), p("two"), blockquote(p("three"))))) it("can lift a block from the start of its parent", () => lift(doc(blockquote(p("two"), p("three"))), doc(p("two"), blockquote(p("three"))))) it("can lift a block from the end of its parent", () => lift(doc(blockquote(p("one"), p("two"))), doc(blockquote(p("one")), p("two")))) it("can lift a single child", () => lift(doc(blockquote(p("two"))), doc(p("two")))) it("can lift multiple blocks", () => lift(doc(blockquote(blockquote(p("one"), p("two")), p("three"))), doc(blockquote(p("one"), p("two"), p("three"))))) it("finds a valid range from a lopsided selection", () => lift(doc(p("start"), blockquote(blockquote(p("a"), p("b")), p("c"))), doc(p("start"), blockquote(p("a"), p("b")), p("c")))) it("can lift from a nested node", () => lift(doc(blockquote(blockquote(p("<1>one"), p("two"), p("<3>three"), p("four"), p("<5>five")))), doc(blockquote(blockquote(p("<1>one")), p("two"), p("<3>three"), p("four"), blockquote(p("<5>five")))))) it("can lift from a list", () => lift(doc(ul(li(p("one")), li(p("two")), li(p("three")))), doc(ul(li(p("one"))), p("two"), ul(li(p("three")))))) it("can lift from the end of a list", () => lift(doc(ul(li(p("a")), li(p("b")), "<1>")), doc(ul(li(p("a"))), p("b"), "<1>"))) }) describe("wrap", () => { function wrap(doc: Node, expect: Node, type: string, attrs?: Attrs) { let range = doc.resolve(tag(doc, "a")).blockRange(doc.resolve(tag$(doc, "b") || tag(doc, "a"))) testTransform(new Transform(doc).wrap(range!, findWrapping(range!, schema.nodes[type], attrs)!), expect) } it("can wrap in a blockquote", () => wrap(doc(p("one"), p("two"), p("three")), doc(p("one"), blockquote(p("two")), p("three")), "blockquote")) it("can wrap two paragraphs", () => wrap(doc(p("one<1>"), p("two"), p("three"), p("four<4>")), doc(p("one<1>"), blockquote(p("two"), p("three")), p("four<4>")), "blockquote")) it("can wrap in a list", () => wrap(doc(p("one"), p("two")), doc(ol(li(p("one"), p("two")))), "ordered_list")) it("can wrap in a nested list", () => wrap(doc(ol(li(p("<1>one")), li(p("..."), p("two"), p("three")), li(p("<4>four")))), doc(ol(li(p("<1>one")), li(p("..."), ol(li(p("two"), p("three")))), li(p("<4>four")))), "ordered_list")) it("includes half-covered parent nodes", () => wrap(doc(blockquote(p("<1>one"), p("two")), p("three")), doc(blockquote(blockquote(p("<1>one"), p("two")), p("three"))), "blockquote")) }) describe("setBlockType", () => { function type(doc: Node, expect: Node, nodeType: string, attrs?: Attrs | ((oldNode: Node) => Attrs)) { testTransform(new Transform(doc).setBlockType(tag(doc, "a"), tag$(doc, "b") || tag(doc, "a"), doc.type.schema.nodes[nodeType], attrs), expect) } it("can change a single textblock", () => type(doc(p("am i")), doc(h2("am i")), "heading", {level: 2})) it("can change multiple blocks", () => type(doc(h1("hello"), p("there"), p("you"), p("end")), doc(pre("hello"), pre("there"), pre("you"), p("end")), "code_block")) it("can change a wrapped block", () => type(doc(blockquote(p("one"), p("two"))), doc(blockquote(h1("one"), h1("two"))), "heading", {level: 1})) it("clears markup when necessary", () => type(doc(p("hello ", em("world"))), doc(pre("hello world")), "code_block")) it("removes non-allowed nodes", () => type(doc(p("one", img(), "two", img(), "three")), doc(pre("onetwothree")), "code_block")) it("removes newlines in non-code", () => type(doc(pre("one\ntwo\nthree")), doc(p("one two three")), "paragraph")) it("only clears markup when needed", () => type(doc(p("hello ", em("world"))), doc(h1("hello ", em("world"))), "heading", {level: 1})) it("works after another step", () => { let d = doc(p("foobar"), p("baz")) let tr = new Transform(d).delete(tag(d, "x"), tag(d, "y")), pos = tr.mapping.map(tag(d, "a")) tr.setBlockType(pos, pos, schema.nodes.heading, {level: 1}) testTransform(tr, doc(p("far"), h1("baz"))) }) it("skips nodes that can't be changed due to constraints", () => type(doc(p("hello", img()), p("okay"), ul(li(p("foo")))), doc(pre("hello"), pre("okay"), ul(li(p("foo")))), "code_block")) it("converts newlines to linebreak replacements when appropriate", () => { type(lb.doc(lb.pre("one\ntwo\nthree")), lb.doc(lb.p("one", lb.br(), "two", lb.br(), "three")), "paragraph") type(lb.doc(lb.p("one\ntwo")), lb.doc(lb.pre("one\ntwo")), "code_block") }) it("converts linebreak replacements to newlines when appropriate", () => { type(lb.doc(lb.p("one", lb.br(), "two", lb.br(), "three")), lb.doc(lb.pre("one\ntwo\nthree")), "code_block") type(lb.doc(lb.p("one", lb.br(), "two", lb.br(), "three")), lb.doc(lb.h1("one", lb.br(), "two", lb.br(), "three")), "heading", {level: 1}) }) it("can base attributes on previous attributes", () => type(doc("", h1("a"), p("b"), ""), doc(h2("a"), h1("b")), "heading", node => ({level: (node.attrs.level || 0) + 1}))) }) describe("setNodeMarkup", () => { function markup(doc: Node, expect: Node, type: string, attrs?: Attrs) { testTransform(new Transform(doc).setNodeMarkup(tag(doc, "a"), schema.nodes[type], attrs), expect) } it("can change a textblock", () => markup(doc("", p("foo")), doc(h1("foo")), "heading", {level: 1})) it("can change an inline node", () => markup(doc(p("foo", img(), "bar")), doc(p("foo", img({src: "bar", alt: "y"}), "bar")), "image", {src: "bar", alt: "y"})) }) describe("replace", () => { function repl(doc: Node, source: Node | Slice | null, expect: Node) { let slice = !source ? Slice.empty : source instanceof Slice ? source : source.slice(tag(source, "a"), tag(source, "b")) testTransform(new Transform(doc).replace(tag(doc, "a"), tag$(doc, "b") || tag(doc, "a"), slice), expect) } it("can delete text", () => repl(doc(p("hello you")), null, doc(p("hellou")))) it("can join blocks", () => repl(doc(p("hello"), p("you")), null, doc(p("hellou")))) it("can delete right-leaning lopsided regions", () => repl(doc(blockquote(p("abc")), "", p("def")), null, doc(blockquote(p("ab")), "", p("def")))) it("can delete left-leaning lopsided regions", () => repl(doc(p("abc"), "", blockquote(p("def"))), null, doc(p("abc"), "", blockquote(p("ef"))))) it("can overwrite text", () => repl(doc(p("hello you")), doc(p("i k")), doc(p("helli kou")))) it("can insert text", () => repl(doc(p("hello")), doc(p("i k")), doc(p("helli ko")))) it("can add a textblock", () => repl(doc(p("helloyou")), doc("", p("there"), ""), doc(p("hello"), p("there"), p("you")))) it("can insert while joining textblocks", () => repl(doc(h1("hello"), p("arg!")), doc(p("123")), doc(h1("he2!")))) it("will match open list items", () => repl(doc(ol(li(p("one")), li(p("three")))), doc(ol(li(p("half")), li(p("two")), "")), doc(ol(li(p("onehalf")), li(p("two")), li(p("three")))))) it("merges blocks across deleted content", () => repl(doc(p("a"), p("b"), p("c")), null, doc(p("ac")))) it("can merge text down from nested nodes", () => repl(doc(h1("woah"), blockquote(p("ahha"))), null, doc(h1("woha")))) it("can merge text up into nested nodes", () => repl(doc(blockquote(p("foobar")), p("middle"), h1("quuxbaz")), null, doc(blockquote(p("foobaz"))))) it("will join multiple levels when possible", () => repl(doc(blockquote(ul(li(p("a")), li(p("b")), li(p("c")), li(p("d")), li(p("e"))))), null, doc(blockquote(ul(li(p("a")), li(p("bd")), li(p("e"))))))) it("can replace a piece of text", () => repl(doc(p("hello world")), doc(p(" big")), doc(p("hello big world")))) it("respects open empty nodes at the edges", () => repl(doc(p("onetwo")), doc(p("a"), p("hello"), p("b")), doc(p("one"), p("hello"), p("two")))) it("can completely overwrite a paragraph", () => repl(doc(p("one"), p("two"), p("three")), doc(p("a"), p("TWO"), p("b")), doc(p("one"), p("TWO"), p("three")))) it("joins marks", () => repl(doc(p("foo ", em("barbaz"), " quux")), doc(p("foo ", em("xyzzy"), " foo")), doc(p("foo ", em("barzzy"), " foo quux")))) it("can replace text with a break", () => repl(doc(p("foobbbar")), doc(p("", br(), "")), doc(p("foo", br(), "bar")))) it("can join different blocks", () => repl(doc(h1("hello"), p("bye")), null, doc(h1("helle")))) it("can restore a list parent", () => repl(doc(h1("hello"), ""), doc(ol(li(p("one")), li(p("two")))), doc(h1("helle"), ol(li(p("tw")))))) it("can restore a list parent and join text after it", () => repl(doc(h1("hello"), p("you")), doc(ol(li(p("one")), li(p("two")))), doc(h1("helle"), ol(li(p("twu")))))) it("can insert into an empty block", () => repl(doc(p("a"), p(""), p("b")), doc(p("xyz")), doc(p("a"), p("y"), p("b")))) it("doesn't change the nesting of blocks after the selection", () => repl(doc(p("one"), p("two"), p("three")), doc(p("outside"), blockquote(p("inside"))), doc(p("one"), blockquote(p("inside")), p("two"), p("three")))) it("can close a parent node", () => repl(doc(blockquote(p("bc"), p("de"), p("f"))), doc(blockquote(p("xy")), p("after"), ""), doc(blockquote(p("by")), p("after"), blockquote(p("e"), p("f"))))) it("accepts lopsided regions", () => repl(doc(blockquote(p("bc"), p("de"), p("f"))), doc(blockquote(p("xy")), p("z")), doc(blockquote(p("by")), p("ze"), blockquote(p("f"))))) it("can close nested parent nodes", () => repl(doc(blockquote(blockquote(p("one"), p("two"), p("three<3>"), p("four<4>")))), doc(ol(li(p("helloworld")), li(p("bye"))), p("next")), doc(blockquote(blockquote(p("one"), p("twworld"), ol(li(p("bye"))), p("nehree<3>"), p("four<4>")))))) it("will close open nodes to the right", () => repl(doc(p("x"), ""), doc("", ul(li(p("a")), li("", p("b")))), doc(p("x"), ul(li(p("a")), li(p())), ""))) it("can delete the whole document", () => repl(doc("", h1("hi"), p("you"), ""), null, doc(p()))) it("preserves an empty parent to the left", () => repl(doc(blockquote("", p("hi")), p("bx")), doc(p("hi")), doc(blockquote(p("hix"))))) it("drops an empty parent to the right", () => repl(doc(p("xhi"), blockquote(p("yy"), ""), p("c")), doc(p("hi")), doc(p("xhi"), p("c")))) it("drops an empty node at the start of the slice", () => repl(doc(p("x")), doc(blockquote(p("hi"), ""), p("b")), doc(p(), p("bx")))) it("drops an empty node at the end of the slice", () => repl(doc(p("x")), doc(p("b"), blockquote("", p("hi"))), doc(p(), blockquote(p()), p("x")))) it("does nothing when given an unfittable slice", () => repl(p("x"), new Slice(Fragment.from([blockquote(), hr()]), 0, 0), p("x"))) it("doesn't drop content when things only fit at the top level", () => repl(doc(p("foo"), "", p("bar")), ol(li(p("a")), li(p("b"))), doc(p("foo"), p("a"), ol(li(p("b")))))) it("preserves openEnd when top isn't placed", () => repl(doc(ul(li(p("abcd")), li(p("efgh")))), doc(ul(li(p("ABCD")), li(p("EFGH")))).slice(5, 13, true), doc(ul(li(p("abCD")), li(p("EFgh")))))) it("will auto-close a list item when it fits in a list", () => repl(doc(ul(li(p("foo")), "", li(p("bar")))), ul(li(p("abc")), li(p("def"))), doc(ul(li(p("foo")), li(p("bc")), li(p("de")), li(p("bar")))))) it("finds the proper openEnd value when unwrapping a deep slice", () => repl(doc("", p(), ""), doc(blockquote(blockquote(blockquote(p("hi"))))).slice(3, 6, true), doc(p("hi")))) // A schema that allows marks on top-level block nodes let ms = new Schema({ nodes: schema.spec.nodes.update("doc", Object.assign({}, schema.spec.nodes.get("doc"), {marks: "_"})), marks: schema.spec.marks }) it("preserves marks on block nodes", () => { let tr = new Transform(ms.node("doc", null, [ ms.node("paragraph", null, [ms.text("hey")], [ms.mark("em")]), ms.node("paragraph", null, [ms.text("ok")], [ms.mark("strong")]) ])) tr.replace(2, 7, tr.doc.slice(2, 7)) ist(tr.doc, tr.before, eq) }) it("preserves marks on open slice block nodes", () => { let tr = new Transform(ms.node("doc", null, [ms.node("paragraph", null, [ms.text("a")])])) tr.replace(3, 3, ms.node("doc", null, [ ms.node("paragraph", null, [ms.text("b")], [ms.mark("em")]) ]).slice(1, 3)) ist(tr.doc.childCount, 2) ist(tr.doc.lastChild!.marks.length, 1) }) // A schema that enforces a heading and a body at the top level let hbSchema = new Schema({ nodes: schema.spec.nodes.append({ doc: Object.assign({}, schema.spec.nodes.get("doc"), {content: "heading body"}), body: {content: "block+"} }) }) let hb = builders(hbSchema, { p: {nodeType: "paragraph"}, b: {nodeType: "body"}, h: {nodeType: "heading", level: 1}, }) as any it("can unwrap a paragraph when replacing into a strict schema", () => { let tr = new Transform(hb.doc(hb.h("Head"), hb.b(hb.p("Content")))) tr.replace(0, tr.doc.content.size, tr.doc.slice(7, 16)) ist(tr.doc, hb.doc(hb.h("Content"), hb.b(hb.p())), eq) }) it("can unwrap a body after a placed node", () => { let tr = new Transform(hb.doc(hb.h("Head"), hb.b(hb.p("Content")))) tr.replace(7, 7, tr.doc.slice(0, tr.doc.content.size)) ist(tr.doc, hb.doc(hb.h("Head"), hb.b(hb.h("Head"), hb.p("Content"), hb.p("Content"))), eq) }) it("can wrap a paragraph in a body, even when it's not the first node", () => { let tr = new Transform(hb.doc(hb.h("Head"), hb.b(hb.p("One"), hb.p("Two")))) tr.replace(0, tr.doc.content.size, tr.doc.slice(8, 16)) ist(tr.doc, hb.doc(hb.h("One"), hb.b(hb.p("Two"))), eq) }) it("can split a fragment and place its children in different parents", () => { let tr = new Transform(hb.doc(hb.h("Head"), hb.b(hb.h("One"), hb.p("Two")))) tr.replace(0, tr.doc.content.size, tr.doc.slice(7, 17)) ist(tr.doc, hb.doc(hb.h("One"), hb.b(hb.p("Two"))), eq) }) it("will insert filler nodes before a node when necessary", () => { let tr = new Transform(hb.doc(hb.h("Head"), hb.b(hb.p("One")))) tr.replace(0, tr.doc.content.size, tr.doc.slice(6, tr.doc.content.size)) ist(tr.doc, hb.doc(hb.h(), hb.b(hb.p("One"))), eq) }) it("doesn't fail when moving text would solve an unsatisfied content constraint", () => { let s = new Schema({ nodes: schema.spec.nodes.append({ title: {content: "text*"}, doc: {content: "title? block*"} }) }) let tr = new Transform(s.node("doc", null, s.node("title", null, s.text("hi")))) tr.replace(1, 1, s.node("bullet_list", null, [ s.node("list_item", null, s.node("paragraph", null, s.text("one"))), s.node("list_item", null, s.node("paragraph", null, s.text("two"))) ]).slice(2, 12)) ist(tr.steps.length, 0, ">") }) it("doesn't fail when pasting a half-open slice with a title and a code block into an empty title", () => { let s = new Schema({ nodes: schema.spec.nodes.append({ title: {content: "text*"}, doc: {content: "title? block*"} }) }) let tr = new Transform(s.node("doc", null, [s.node("title", null, [])])) tr.replace(1, 1, s.node("doc", null, [ s.node("title", null, s.text("title")), s.node("code_block", null, s.text("two")), ]).slice(1)) ist(tr.steps.length, 0, ">") }) it("doesn't fail when pasting a half-open slice with a heading and a code block into an empty title", () => { let s = new Schema({ nodes: schema.spec.nodes.append({ title: {content: "text*"}, doc: {content: "title? block*"} }) }) let tr = new Transform(s.node("doc", null, [s.node("title")])) tr.replace(1, 1, s.node("doc", null, [ s.node("heading", {level: 1}, [s.text("heading")]), s.node("code_block", null, [s.text("code")]), ]).slice(1)) ist(tr.steps.length, 0, ">") }) it("can handle replacing in nodes with fixed content", () => { let s = new Schema({ nodes: { doc: {content: "block+"}, a: {content: "inline*"}, b: {content: "inline*"}, block: {content: "a b"}, text: {group: "inline"} } }) let doc = s.node("doc", null, [ s.node("block", null, [s.node("a", null, [s.text("aa")]), s.node("b", null, [s.text("bb")])]) ]) let from = 3, to = doc.content.size ist(new Transform(doc).replace(from, to, doc.slice(from, to)).doc, doc, eq) }) it("keeps isolating nodes together", () => { let s = new Schema({ nodes: schema.spec.nodes.append({ iso: { group: "block", content: "block+", isolating: true } }) }) let doc = s.node("doc", null, [s.node("paragraph", null, [s.text("one")])]) let iso = Fragment.from(s.node("iso", null, [s.node("paragraph", null, s.text("two"))])) ist(new Transform(doc).replace(2, 3, new Slice(iso, 2, 0)).doc, s.node("doc", null, [ s.node("paragraph", null, [s.text("o")]), s.node("iso", null, [s.node("paragraph", null, s.text("two"))]), s.node("paragraph", null, [s.text("e")]) ]), eq) ist(new Transform(doc).replace(2, 3, new Slice(iso, 2, 2)).doc, s.node("doc", null, [s.node("paragraph", null, [s.text("otwoe")])]), eq) }) }) describe("replaceRange", () => { function repl(doc: Node, source: Node, expect: Node) { let slice = !source ? Slice.empty : source instanceof Slice ? source : source.slice(tag(source, "a"), tag(source, "b"), true) testTransform(new Transform(doc).replaceRange(tag(doc, "a"), tag$(doc, "b") || tag(doc, "a"), slice), expect) } it("replaces inline content", () => repl(doc(p("foobar")), p("xx"), doc(p("fooxxar")))) it("replaces an empty paragraph with a heading", () => repl(doc(p("")), doc(h1("text")), doc(h1("text")))) it("replaces a fully selected paragraph with a heading", () => repl(doc(p("abc")), doc(h1("text")), doc(h1("text")))) it("recreates a list when overwriting a paragraph", () => repl(doc(p("")), doc(ul(li(p("foobar")))), doc(ul(li(p("foobar")))))) it("drops context when it doesn't fit", () => repl(doc(ul(li(p("")), li(p("b")))), doc(h1("h")), doc(ul(li(p("h")), li(p("b")))))) it("can replace a node when endpoints are in different children", () => repl(doc(p("a"), ul(li(p("b")), li(p("c"), blockquote(p("d")))), p("e")), doc(h1("x")), doc(p("a"), h1("x"), p("e")))) it("keeps defining context when inserting at the start of a textblock", () => repl(doc(p("foo")), doc(ul(li(p("one")), li(p("two")))), doc(ul(li(p("one")), li(p("twofoo")))))) it("keeps defining context when it doesn't matches the parent markup", () => { let spec: NodeSpec = { content: "block+", group: "block", definingForContent: true, definingAsContext: false, attrs: {color: {default: "black"}} } let s = new Schema({ nodes: schema.spec.nodes.update("blockquote", spec), marks: schema.spec.marks, }) let { b1, b2, b3, b4, b5, b6, p, doc } = builders(s, { b1: { nodeType: "blockquote", color: "#100" }, b2: { nodeType: "blockquote", color: "#200" }, b3: { nodeType: "blockquote", color: "#300" }, b4: { nodeType: "blockquote", color: "#400" }, b5: { nodeType: "blockquote", color: "#500" }, b6: { nodeType: "blockquote", color: "#600" }, p: { nodeType: "paragraph" }, doc: { nodeType: "doc" }, }) const source = doc(b1(p("b1")), b2(p("b2"))) const before1 = [b3(p("b3")), b4(p(""))] const before2 = [b5(p("b5"), ...before1)] const before3 = [b6(p("b6"), ...before2)] const expect1 = [b3(p("b3")), b1(p("b1")), b2(p("b2"))] const expect2 = [b5(p("b5"), ...expect1)] const expect3 = [b6(p("b6"), ...expect2)] repl(doc(...before1), source, doc(...expect1)) repl(doc(...before2), source, doc(...expect2)) repl(doc(...before3), source, doc(...expect3)) }) it("drops defining context when it matches the parent structure", () => repl(doc(blockquote(p(""))), doc(blockquote(p("one"))), doc(blockquote(p("one"))))) it("drops defining context when it matches the parent structure in a nested context", () => repl(doc(ul(li(p("list1"), blockquote(p(""))))), doc(blockquote(p("one"))), doc(ul(li(p("list1"), blockquote(p("one"))))))) it("drops defining context when it matches the parent structure in a deep nested context", () => repl(doc(ul(li(p("list1"), ul(li(p("list2"), blockquote(p(""))))))), doc(blockquote(p("one"))), doc(ul(li(p("list1"), ul(li(p("list2"), blockquote(p("one"))))))))) it("closes open nodes at the start", () => repl(doc("", p("abc"), ""), doc(ul(li("")), p("def"), ""), doc(ul(li(p())), p("def")))) }) describe("replaceRangeWith", () => { function repl(doc: Node, node: Node, expect: Node) { testTransform(new Transform(doc).replaceRangeWith(tag(doc, "a"), tag$(doc, "b") || tag(doc, "a"), node), expect) } it("can insert an inline node", () => repl(doc(p("foo")), img(), doc(p("fo", img(), "o")))) it("can replace content with an inline node", () => repl(doc(p("foo")), img(), doc(p("", img(), "o")))) it("can replace a block node with an inline node", () => repl(doc("", blockquote(p("a")), ""), img(), doc(p(img)))) it("can replace a block node with a block node", () => repl(doc("", blockquote(p("a")), ""), hr(), doc(hr()))) it("can insert a block quote in the middle of text", () => repl(doc(p("foobar")), hr(), doc(p("foo"), hr(), p("bar")))) it("can replace empty parents with a block node", () => repl(doc(blockquote(p(""))), hr(), doc(blockquote(hr())))) it("can move an inserted block forward out of parent nodes", () => repl(doc(h1("foo")), hr(), doc(h1("foo"), hr()))) it("can move an inserted block backward out of parent nodes", () => repl(doc(p("a"), blockquote(p("b"))), hr(), doc(p("a"), blockquote(hr, p("b"))))) }) describe("deleteRange", () => { function del(doc: Node, expect: Node) { testTransform(new Transform(doc).deleteRange(tag(doc, "a"), tag$(doc, "b") || tag(doc, "a")), expect) } it("deletes the given range", () => del(doc(p("foo"), p("bar")), doc(p("foar")))) it("deletes empty parent nodes", () => del(doc(blockquote(ul(li("", p("foo"), "")), p("x"))), doc(blockquote("", p("x"))))) it("doesn't delete parent nodes that can be empty", () => del(doc(p("foo")), doc(p("")))) it("is okay with deleting empty ranges", () => del(doc(p("")), doc(p("")))) it("will delete a whole covered node even if selection ends are in different nodes", () => del(doc(ul(li(p("foo")), li(p("bar"))), p("hi")), doc(p("hi")))) it("leaves wrapping textblock when deleting all text in it", () => del(doc(p("a"), p("b")), doc(p("a"), p()))) it("expands to cover the whole parent node", () => del(doc(p("a"), blockquote(blockquote(p("foo")), p("bar")), p("b")), doc(p("a"), p("b")))) it("expands to cover the whole document", () => del(doc(h1("foo"), p("bar"), blockquote(p("baz"))), doc(p()))) it("doesn't expand beyond same-depth textblocks", () => del(doc(h1("foo"), p("bar"), p("baz")), doc(h1()))) it("deletes the open token when deleting from start to past end of block", () => del(doc(h1("foo"), p("bar")), doc(p("ar")))) it("doesn't delete the open token when the range end is at end of its own block", () => del(doc(p("one"), h1("two"), blockquote(p("three")), p("four")), doc(p("one"), h1(), p("four")))) it("doesn't break text-joining by inappropriate expansion", () => del(doc(ol(li(p("One"), ol(li(p("Two")))))), doc(ol(li(p("o")))))) it("will delete entire blocks when deleting from the start of one textblock to another", () => del(doc(blockquote(ol(li(p("a")), li(p("b")), li(p("c")))), p("x"), p("y")), doc(blockquote(ol(li(p("a")))), p("y")))) }) describe("addNodeMark", () => { function add(doc: Node, mark: Mark, expect: Node) { testTransform(new Transform(doc).addNodeMark(tag(doc, "a"), mark), expect) } it("adds a mark", () => add(doc(p("", img())), schema.mark("em"), doc(p("", em(img()))))) it("doesn't duplicate a mark", () => add(doc(p("", em(img()))), schema.mark("em"), doc(p("", em(img()))))) it("replaces a mark", () => add(doc(p("", a(img()))), schema.mark("link", {href: "x"}), doc(p("", a({href: "x"}, img()))))) }) describe("removeNodeMark", () => { function rm(doc: Node, mark: Mark | MarkType, expect: Node) { testTransform(new Transform(doc).removeNodeMark(tag(doc, "a"), mark), expect) } it("removes a mark", () => rm(doc(p("", em(img()))), schema.mark("em"), doc(p("", img())))) it("doesn't do anything when there is no mark", () => rm(doc(p("", img())), schema.mark("em"), doc(p("", img())))) it("can remove a mark from multiple marks", () => rm(doc(p("", em(a(img())))), schema.mark("em"), doc(p("", a(img()))))) it("can remove multiple instances of a mark type", () => { let s = new Schema({ nodes: {doc: {content: "p+", marks: "comment"}, p: {content: "text*"}, text: {}}, marks: {comment: {excludes: "", attrs: {id: {}}}} }) let doc = s.node("doc", null, [s.node("p", null, [s.text("abc")], [s.mark("comment", {id: 1}), s.mark("comment", {id: 2})])]) testTransform(new Transform(doc).removeNodeMark(0, s.marks.comment), s.node("doc", null, [s.node("p", null, [s.text("abc")])])) }) }) describe("setNodeAttribute", () => { function set(doc: Node, attr: string, value: any, expect: Node) { testTransform(new Transform(doc).setNodeAttribute(tag(doc, "a"), attr, value), expect) } it("sets an attribute", () => set(doc("", h1("a")), "level", 2, doc("", h2("a")))) }) describe("setDocAttribute", () => { let schema = new Schema({ nodes: {doc: {content: "text*", attrs: {meta: {default: null}}}, text: {}}, }) let {doc} = builders(schema, { doc: {nodeType: "doc"} }) function set(doc: Node, attr: string, value: any, expect: Node) { testTransform(new Transform(doc).setDocAttribute(attr, value), expect) } it("sets an attribute", () => set(doc(), "meta", "hello", doc({meta: "hello"}))) }) describe("changedRange", () => { function r(tr: Transform) { let ch = tr.changedRange() return ch && ch.from + "-" + ch.to } it("returns null when there are no changes", () => { let tr = new Transform(doc(p("hello"))) ist(r(tr), null) tr.addMark(1, 3, schema.mark("strong")) ist(r(tr), null) }) it("returns a range when something changed", () => { let tr = new Transform(doc(p("ab"))).insert(3, schema.text("c")) ist(r(tr), "3-4") }) it("can handle multiple steps that affect each other's position", () => { let tr = new Transform(doc(p("ab"))).insert(3, schema.text("c")).insert(2, schema.text("d")).insert(1, schema.text("e")) ist(r(tr), "1-6") }) it("properly adjusts for deletions before an earlier step", () => { let tr = new Transform(doc(p("abcde"))).insert(6, schema.text("f")).delete(1, 4) ist(r(tr), "1-4") }) }) }) ================================================ FILE: test/trans.ts ================================================ import {Node, Schema, NodeSpec, MarkSpec} from "prosemirror-model" import {Transform, Mapping, Step} from "prosemirror-transform" import {eq} from "prosemirror-test-builder" import ist from "ist" function invert(transform: Transform) { let out = new Transform(transform.doc) for (let i = transform.steps.length - 1; i >= 0; i--) out.step(transform.steps[i].invert(transform.docs[i])) return out } function testMapping(mapping: Mapping, pos: number, newPos: number) { let mapped = mapping.map(pos, 1) ist(mapped, newPos) let remap = new Mapping(mapping.maps.map(m => m.invert())) for (let i = mapping.maps.length - 1, mapFrom = mapping.maps.length; i >= 0; i--) remap.appendMap(mapping.maps[i], --mapFrom) ist(remap.map(pos, 1), pos) } function testStepJSON(tr: Transform) { let newTR = new Transform(tr.before) tr.steps.forEach(step => newTR.step(Step.fromJSON(tr.doc.type.schema, step.toJSON()))) ist(tr.doc, newTR.doc, eq) } export function testTransform(tr: Transform, expect: Node) { outputTransform(tr, expect) ist(tr.doc, expect, eq) ist(invert(tr).doc, tr.before, eq) testStepJSON(tr) for (let tag in (expect as any).tag) testMapping(tr.mapping, (tr.before as any).tag[tag], (expect as any).tag[tag]) } // Dumping tested transforms as JSON declare const process: any const outputFile = typeof process == "undefined" ? undefined : process.env["EMIT_JSON"] let output: { schemas: Schema[], tests: {schema: number, start: any, steps: any[], result: any, mapping: [number, number][]}[] } | null = outputFile ? {schemas: [], tests: []} : null function outputTransform(tr: Transform, expected: Node) { if (output && tr.steps.length) { let mapping: [number, number][] = [] for (let tag in (expected as any).tag) mapping.push([(tr.before as any).tag[tag], (expected as any).tag[tag]]) output.tests.push({ schema: storeSchema(tr.doc.type.schema), start: tr.before.toJSON(), steps: tr.steps.map(s => s.toJSON()), result: expected.toJSON(), mapping }) } } function storeSchema(schema: Schema) { let known = output!.schemas.indexOf(schema) if (known > -1) return known return output!.schemas.push(schema) - 1 } function schemaToJSON(schema: Schema) { let nodes: {[name: string]: NodeSpec} = {}, marks: {[name: string]: MarkSpec} = {} schema.spec.nodes.forEach((key, value) => nodes[key] = value) schema.spec.marks.forEach((key, value) => marks[key] = value) return {topNode: schema.topNodeType.name, nodes, marks} } if (output) { let writeFileSync: any import("fs" as any).then(fs => ({writeFileSync} = fs)) process.on("exit", () => { writeFileSync(outputFile, JSON.stringify({ schemas: output!.schemas.map(schemaToJSON), tests: output!.tests }, null, 2)) }) }