├── .npmrc ├── .npmignore ├── .gitignore ├── src ├── dom.ts ├── index.ts ├── comparedeep.ts ├── README.md ├── diff.ts ├── mark.ts ├── replace.ts ├── to_dom.ts ├── fragment.ts ├── resolvedpos.ts ├── content.ts ├── node.ts └── schema.ts ├── .tern-project ├── package.json ├── LICENSE ├── README.md ├── test ├── test-resolve.ts ├── test-diff.ts ├── test-slice.ts ├── test-replace.ts ├── test-mark.ts ├── test-content.ts ├── test-node.ts └── test-dom.ts ├── CONTRIBUTING.md └── CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /test 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /dist 4 | /test/*.js 5 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | export type DOMNode = InstanceType 2 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "libs": ["browser"], 3 | "plugins": { 4 | "node": {}, 5 | "complete_strings": {}, 6 | "es_modules": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Node} from "./node" 2 | export {ResolvedPos, NodeRange} from "./resolvedpos" 3 | export {Fragment} from "./fragment" 4 | export {Slice, ReplaceError} from "./replace" 5 | export {Mark} from "./mark" 6 | 7 | export {Schema, NodeType, Attrs, MarkType, NodeSpec, MarkSpec, AttributeSpec, SchemaSpec} from "./schema" 8 | export {ContentMatch} from "./content" 9 | 10 | export {DOMParser, GenericParseRule, TagParseRule, StyleParseRule, ParseRule, ParseOptions} from "./from_dom" 11 | export {DOMSerializer, DOMOutputSpec} from "./to_dom" 12 | -------------------------------------------------------------------------------- /src/comparedeep.ts: -------------------------------------------------------------------------------- 1 | export function compareDeep(a: any, b: any) { 2 | if (a === b) return true 3 | if (!(a && typeof a == "object") || 4 | !(b && typeof b == "object")) return false 5 | let array = Array.isArray(a) 6 | if (Array.isArray(b) != array) return false 7 | if (array) { 8 | if (a.length != b.length) return false 9 | for (let i = 0; i < a.length; i++) if (!compareDeep(a[i], b[i])) return false 10 | } else { 11 | for (let p in a) if (!(p in b) || !compareDeep(a[p], b[p])) return false 12 | for (let p in b) if (!(p in a)) return false 13 | } 14 | return true 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-model", 3 | "version": "1.25.4", 4 | "description": "ProseMirror's document model", 5 | "type": "module", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "sideEffects": false, 14 | "license": "MIT", 15 | "maintainers": [ 16 | { 17 | "name": "Marijn Haverbeke", 18 | "email": "marijn@haverbeke.berlin", 19 | "web": "http://marijnhaverbeke.nl" 20 | } 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git://github.com/prosemirror/prosemirror-model.git" 25 | }, 26 | "dependencies": { 27 | "orderedmap": "^2.0.0" 28 | }, 29 | "devDependencies": { 30 | "@prosemirror/buildhelper": "^0.1.5", 31 | "jsdom": "^20.0.0", 32 | "prosemirror-test-builder": "^1.0.0" 33 | }, 34 | "scripts": { 35 | "test": "pm-runtests", 36 | "prepare": "pm-buildhelper src/index.ts" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015-2017 by Marijn Haverbeke and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-model 2 | 3 | [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**CHANGELOG**](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md) ] 4 | 5 | This is a [core module](https://prosemirror.net/docs/ref/#model) of [ProseMirror](https://prosemirror.net). 6 | ProseMirror is a well-behaved rich semantic content editor based on 7 | contentEditable, with support for collaborative editing and custom 8 | document schemas. 9 | 10 | This [module](https://prosemirror.net/docs/ref/#model) implements 11 | ProseMirror's [document model](https://prosemirror.net/docs/guide/#doc), 12 | along with the mechanisms needed to support 13 | [schemas](https://prosemirror.net/docs/guide/#schema). 14 | 15 | The [project page](https://prosemirror.net) has more information, a 16 | number of [examples](https://prosemirror.net/examples/) and the 17 | [documentation](https://prosemirror.net/docs/). 18 | 19 | This code is released under an 20 | [MIT license](https://github.com/prosemirror/prosemirror/tree/master/LICENSE). 21 | There's a [forum](http://discuss.prosemirror.net) for general 22 | discussion and support requests, and the 23 | [Github bug tracker](https://github.com/prosemirror/prosemirror/issues) 24 | is the place to report issues. 25 | 26 | We aim to be an inclusive, welcoming community. To make that explicit, 27 | we have a [code of 28 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 29 | to communication around the project. 30 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | This module defines ProseMirror's content model, the data structures 2 | used to represent and work with documents. 3 | 4 | ### Document Structure 5 | 6 | A ProseMirror document is a tree. At each level, a [node](#model.Node) 7 | describes the type of the content, and holds a 8 | [fragment](#model.Fragment) containing its children. 9 | 10 | @Node 11 | @Fragment 12 | @Mark 13 | @Slice 14 | @Attrs 15 | @ReplaceError 16 | 17 | ### Resolved Positions 18 | 19 | Positions in a document can be represented as integer 20 | [offsets](/docs/guide/#doc.indexing). But you'll often want to use a 21 | more convenient representation. 22 | 23 | @ResolvedPos 24 | @NodeRange 25 | 26 | ### Document Schema 27 | 28 | Every ProseMirror document conforms to a 29 | [schema](/docs/guide/#schema), which describes the set of nodes and 30 | marks that it is made out of, along with the relations between those, 31 | such as which node may occur as a child node of which other nodes. 32 | 33 | @Schema 34 | 35 | @SchemaSpec 36 | @NodeSpec 37 | @MarkSpec 38 | @AttributeSpec 39 | 40 | @NodeType 41 | @MarkType 42 | 43 | @ContentMatch 44 | 45 | ### DOM Representation 46 | 47 | Because representing a document as a tree of DOM nodes is central to 48 | the way ProseMirror operates, DOM [parsing](#model.DOMParser) and 49 | [serializing](#model.DOMSerializer) is integrated with the model. 50 | 51 | (But note that you do _not_ need to have a DOM implementation loaded 52 | to use this module.) 53 | 54 | @DOMParser 55 | @ParseOptions 56 | @GenericParseRule 57 | @TagParseRule 58 | @StyleParseRule 59 | @ParseRule 60 | 61 | @DOMSerializer 62 | @DOMOutputSpec 63 | -------------------------------------------------------------------------------- /src/diff.ts: -------------------------------------------------------------------------------- 1 | import {Fragment} from "./fragment" 2 | 3 | export function findDiffStart(a: Fragment, b: Fragment, pos: number): number | null { 4 | for (let i = 0;; i++) { 5 | if (i == a.childCount || i == b.childCount) 6 | return a.childCount == b.childCount ? null : pos 7 | 8 | let childA = a.child(i), childB = b.child(i) 9 | if (childA == childB) { pos += childA.nodeSize; continue } 10 | 11 | if (!childA.sameMarkup(childB)) return pos 12 | 13 | if (childA.isText && childA.text != childB.text) { 14 | for (let j = 0; childA.text![j] == childB.text![j]; j++) 15 | pos++ 16 | return pos 17 | } 18 | if (childA.content.size || childB.content.size) { 19 | let inner = findDiffStart(childA.content, childB.content, pos + 1) 20 | if (inner != null) return inner 21 | } 22 | pos += childA.nodeSize 23 | } 24 | } 25 | 26 | export function findDiffEnd(a: Fragment, b: Fragment, posA: number, posB: number): {a: number, b: number} | null { 27 | for (let iA = a.childCount, iB = b.childCount;;) { 28 | if (iA == 0 || iB == 0) 29 | return iA == iB ? null : {a: posA, b: posB} 30 | 31 | let childA = a.child(--iA), childB = b.child(--iB), size = childA.nodeSize 32 | if (childA == childB) { 33 | posA -= size; posB -= size 34 | continue 35 | } 36 | 37 | if (!childA.sameMarkup(childB)) return {a: posA, b: posB} 38 | 39 | if (childA.isText && childA.text != childB.text) { 40 | let same = 0, minSize = Math.min(childA.text!.length, childB.text!.length) 41 | while (same < minSize && childA.text![childA.text!.length - same - 1] == childB.text![childB.text!.length - same - 1]) { 42 | same++; posA--; posB-- 43 | } 44 | return {a: posA, b: posB} 45 | } 46 | if (childA.content.size || childB.content.size) { 47 | let inner = findDiffEnd(childA.content, childB.content, posA - 1, posB - 1) 48 | if (inner) return inner 49 | } 50 | posA -= size; posB -= size 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/test-resolve.ts: -------------------------------------------------------------------------------- 1 | import {doc, p, em, blockquote} from "prosemirror-test-builder" 2 | import ist from "ist" 3 | 4 | const testDoc = doc(p("ab"), blockquote(p(em("cd"), "ef"))) 5 | const _doc = {node: testDoc, start: 0, end: 12} 6 | const _p1 = {node: testDoc.child(0), start: 1, end: 3} 7 | const _blk = {node: testDoc.child(1), start: 5, end: 11} 8 | const _p2 = {node: _blk.node.child(0), start: 6, end: 10} 9 | 10 | describe("Node", () => { 11 | describe("resolve", () => { 12 | it("should reflect the document structure", () => { 13 | let expected: {[pos: number]: any} = { 14 | 0: [_doc, 0, null, _p1.node], 15 | 1: [_doc, _p1, 0, null, "ab"], 16 | 2: [_doc, _p1, 1, "a", "b"], 17 | 3: [_doc, _p1, 2, "ab", null], 18 | 4: [_doc, 4, _p1.node, _blk.node], 19 | 5: [_doc, _blk, 0, null, _p2.node], 20 | 6: [_doc, _blk, _p2, 0, null, "cd"], 21 | 7: [_doc, _blk, _p2, 1, "c", "d"], 22 | 8: [_doc, _blk, _p2, 2, "cd", "ef"], 23 | 9: [_doc, _blk, _p2, 3, "e", "f"], 24 | 10: [_doc, _blk, _p2, 4, "ef", null], 25 | 11: [_doc, _blk, 6, _p2.node, null], 26 | 12: [_doc, 12, _blk.node, null] 27 | } 28 | 29 | for (let pos = 0; pos <= testDoc.content.size; pos++) { 30 | let $pos = testDoc.resolve(pos), exp = expected[pos] 31 | ist($pos.depth, exp.length - 4) 32 | for (let i = 0; i < exp.length - 3; i++) { 33 | ist($pos.node(i).eq(exp[i].node)) 34 | ist($pos.start(i), exp[i].start) 35 | ist($pos.end(i), exp[i].end) 36 | if (i) { 37 | ist($pos.before(i), exp[i].start - 1) 38 | ist($pos.after(i), exp[i].end + 1) 39 | } 40 | } 41 | ist($pos.parentOffset, exp[exp.length - 3]) 42 | let before = $pos.nodeBefore!, eBefore = exp[exp.length - 2] 43 | ist(typeof eBefore == "string" ? before.textContent : before, eBefore) 44 | let after = $pos.nodeAfter!, eAfter = exp[exp.length - 1] 45 | ist(typeof eAfter == "string" ? after.textContent : after, eAfter) 46 | } 47 | }) 48 | 49 | it("has a working posAtIndex method", () => { 50 | let d = doc(blockquote(p("one"), blockquote(p("two ", em("three")), p("four")))) 51 | let pThree = d.resolve(12) // Start of em("three") 52 | ist(pThree.posAtIndex(0), 8) 53 | ist(pThree.posAtIndex(1), 12) 54 | ist(pThree.posAtIndex(2), 17) 55 | ist(pThree.posAtIndex(0, 2), 7) 56 | ist(pThree.posAtIndex(1, 2), 18) 57 | ist(pThree.posAtIndex(2, 2), 24) 58 | ist(pThree.posAtIndex(0, 1), 1) 59 | ist(pThree.posAtIndex(1, 1), 6) 60 | ist(pThree.posAtIndex(2, 1), 25) 61 | ist(pThree.posAtIndex(0, 0), 0) 62 | ist(pThree.posAtIndex(1, 0), 26) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/test-diff.ts: -------------------------------------------------------------------------------- 1 | import {doc, blockquote, h1, h2, p, em, strong} from "prosemirror-test-builder" 2 | import {Node} from "prosemirror-model" 3 | import ist from "ist" 4 | 5 | describe("Fragment", () => { 6 | describe("findDiffStart", () => { 7 | function start(a: Node, b: Node) { 8 | ist(a.content.findDiffStart(b.content), (a as any).tag.a) 9 | } 10 | 11 | it("returns null for identical nodes", () => 12 | start(doc(p("a", em("b")), p("hello"), blockquote(h1("bye"))), 13 | doc(p("a", em("b")), p("hello"), blockquote(h1("bye"))))) 14 | 15 | it("notices when one node is longer", () => 16 | start(doc(p("a", em("b")), p("hello"), blockquote(h1("bye")), ""), 17 | doc(p("a", em("b")), p("hello"), blockquote(h1("bye")), p("oops")))) 18 | 19 | it("notices when one node is shorter", () => 20 | start(doc(p("a", em("b")), p("hello"), blockquote(h1("bye")), "", p("oops")), 21 | doc(p("a", em("b")), p("hello"), blockquote(h1("bye"))))) 22 | 23 | it("notices differing marks", () => 24 | start(doc(p("a", em("b"))), 25 | doc(p("a", strong("b"))))) 26 | 27 | it("stops at longer text", () => 28 | start(doc(p("foobar", em("b"))), 29 | doc(p("foo", em("b"))))) 30 | 31 | it("stops at a different character", () => 32 | start(doc(p("foobar")), 33 | doc(p("foocar")))) 34 | 35 | it("stops at a different node type", () => 36 | start(doc(p("a"), "", p("b")), 37 | doc(p("a"), h1("b")))) 38 | 39 | it("works when the difference is at the start", () => 40 | start(doc("", p("b")), 41 | doc(h1("b")))) 42 | 43 | it("notices a different attribute", () => 44 | start(doc(p("a"), "", h1("foo")), 45 | doc(p("a"), h2("foo")))) 46 | }) 47 | 48 | describe("findDiffEnd", () => { 49 | function end(a: Node, b: Node) { 50 | let found = a.content.findDiffEnd(b.content) 51 | ist(found && found.a, (a as any).tag.a) 52 | } 53 | 54 | it("returns null when there is no difference", () => 55 | end(doc(p("a", em("b")), p("hello"), blockquote(h1("bye"))), 56 | doc(p("a", em("b")), p("hello"), blockquote(h1("bye"))))) 57 | 58 | it("notices when the second doc is longer", () => 59 | end(doc("", p("a", em("b")), p("hello"), blockquote(h1("bye"))), 60 | doc(p("oops"), p("a", em("b")), p("hello"), blockquote(h1("bye"))))) 61 | 62 | it("notices when the second doc is shorter", () => 63 | end(doc(p("oops"), "", p("a", em("b")), p("hello"), blockquote(h1("bye"))), 64 | doc(p("a", em("b")), p("hello"), blockquote(h1("bye"))))) 65 | 66 | it("notices different styles", () => 67 | end(doc(p("a", em("b"), "c")), 68 | doc(p("a", strong("b"), "c")))) 69 | 70 | it("spots longer text", () => 71 | end(doc(p("barfoo", em("b"))), 72 | doc(p("foo", em("b"))))) 73 | 74 | it("spots different text", () => 75 | end(doc(p("foobar")), 76 | doc(p("foocar")))) 77 | 78 | it("notices different nodes", () => 79 | end(doc(p("a"), "", p("b")), 80 | doc(h1("a"), p("b")))) 81 | 82 | it("notices a difference at the end", () => 83 | end(doc(p("b"), ""), 84 | doc(h1("b")))) 85 | 86 | it("handles a similar start", () => 87 | end(doc("", p("hello")), 88 | doc(p("hey"), p("hello")))) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/test-slice.ts: -------------------------------------------------------------------------------- 1 | import {doc, p, li, ul, em, a, blockquote} from "prosemirror-test-builder" 2 | import {Node} from "prosemirror-model" 3 | import ist from "ist" 4 | 5 | describe("Node", () => { 6 | describe("slice", () => { 7 | function t(doc: Node, expect: Node, openStart: number, openEnd: number) { 8 | let slice = doc.slice((doc as any).tag.a || 0, (doc as any).tag.b) 9 | ist(slice.content.eq(expect.content)) 10 | ist(slice.openStart, openStart) 11 | ist(slice.openEnd, openEnd) 12 | } 13 | 14 | it("can cut half a paragraph", () => 15 | t(doc(p("hello world")), doc(p("hello")), 0, 1)) 16 | 17 | it("can cut to the end of a pragraph", () => 18 | t(doc(p("hello")), doc(p("hello")), 0, 1)) 19 | 20 | it("leaves off extra content", () => 21 | t(doc(p("hello world"), p("rest")), doc(p("hello")), 0, 1)) 22 | 23 | it("preserves styles", () => 24 | t(doc(p("hello ", em("WORLD"))), doc(p("hello ", em("WOR"))), 0, 1)) 25 | 26 | it("can cut multiple blocks", () => 27 | t(doc(p("a"), p("b")), doc(p("a"), p("b")), 0, 1)) 28 | 29 | it("can cut to a top-level position", () => 30 | t(doc(p("a"), "", p("b")), doc(p("a")), 0, 0)) 31 | 32 | it("can cut to a deep position", () => 33 | t(doc(blockquote(ul(li(p("a")), li(p("b"))))), 34 | doc(blockquote(ul(li(p("a")), li(p("b"))))), 0, 4)) 35 | 36 | it("can cut everything after a position", () => 37 | t(doc(p("hello world")), doc(p(" world")), 1, 0)) 38 | 39 | it("can cut from the start of a textblock", () => 40 | t(doc(p("hello")), doc(p("hello")), 1, 0)) 41 | 42 | it("leaves off extra content before", () => 43 | t(doc(p("foo"), p("barbaz")), doc(p("baz")), 1, 0)) 44 | 45 | it("preserves styles after cut", () => 46 | t(doc(p("a sentence with an ", em("emphasized ", a("link")), " in it")), 47 | doc(p(em(a("nk")), " in it")), 1, 0)) 48 | 49 | it("preserves styles started after cut", () => 50 | t(doc(p("a ", em("sentence"), " with ", em("text"), " in it")), 51 | doc(p("th ", em("text"), " in it")), 1, 0)) 52 | 53 | it("can cut from a top-level position", () => 54 | t(doc(p("a"), "", p("b")), doc(p("b")), 0, 0)) 55 | 56 | it("can cut from a deep position", () => 57 | t(doc(blockquote(ul(li(p("a")), li(p("b"))))), 58 | doc(blockquote(ul(li(p("b"))))), 4, 0)) 59 | 60 | it("can cut part of a text node", () => 61 | t(doc(p("hello world")), p("o wo"), 0, 0)) 62 | 63 | it("can cut across paragraphs", () => 64 | t(doc(p("one"), p("two")), doc(p("e"), p("t")), 1, 1)) 65 | 66 | it("can cut part of marked text", () => 67 | t(doc(p("here's nothing and ", em("here's em"))), 68 | p("ing and ", em("here's e")), 0, 0)) 69 | 70 | it("can cut across different depths", () => 71 | t(doc(ul(li(p("hello")), li(p("world")), li(p("x"))), p(em("boo"))), 72 | doc(ul(li(p("rld")), li(p("x"))), p(em("bo"))), 3, 1)) 73 | 74 | it("can cut between deeply nested nodes", () => 75 | t(doc(blockquote(p("foobar"), ul(li(p("a")), li(p("b"), "", p("c"))), p("d"))), 76 | blockquote(p("bar"), ul(li(p("a")), li(p("b")))), 1, 2)) 77 | 78 | it("can include parents", () => { 79 | let d = doc(blockquote(p("foo"), p("bar"))) 80 | let slice = d.slice((d as any).tag.a, (d as any).tag.b, true) 81 | ist(slice.toString(), '(2,2)') 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/mark.ts: -------------------------------------------------------------------------------- 1 | import {compareDeep} from "./comparedeep" 2 | import {Attrs, MarkType, Schema} from "./schema" 3 | 4 | /// A mark is a piece of information that can be attached to a node, 5 | /// such as it being emphasized, in code font, or a link. It has a 6 | /// type and optionally a set of attributes that provide further 7 | /// information (such as the target of the link). Marks are created 8 | /// through a `Schema`, which controls which types exist and which 9 | /// attributes they have. 10 | export class Mark { 11 | /// @internal 12 | constructor( 13 | /// The type of this mark. 14 | readonly type: MarkType, 15 | /// The attributes associated with this mark. 16 | readonly attrs: Attrs 17 | ) {} 18 | 19 | /// Given a set of marks, create a new set which contains this one as 20 | /// well, in the right position. If this mark is already in the set, 21 | /// the set itself is returned. If any marks that are set to be 22 | /// [exclusive](#model.MarkSpec.excludes) with this mark are present, 23 | /// those are replaced by this one. 24 | addToSet(set: readonly Mark[]): readonly Mark[] { 25 | let copy, placed = false 26 | for (let i = 0; i < set.length; i++) { 27 | let other = set[i] 28 | if (this.eq(other)) return set 29 | if (this.type.excludes(other.type)) { 30 | if (!copy) copy = set.slice(0, i) 31 | } else if (other.type.excludes(this.type)) { 32 | return set 33 | } else { 34 | if (!placed && other.type.rank > this.type.rank) { 35 | if (!copy) copy = set.slice(0, i) 36 | copy.push(this) 37 | placed = true 38 | } 39 | if (copy) copy.push(other) 40 | } 41 | } 42 | if (!copy) copy = set.slice() 43 | if (!placed) copy.push(this) 44 | return copy 45 | } 46 | 47 | /// Remove this mark from the given set, returning a new set. If this 48 | /// mark is not in the set, the set itself is returned. 49 | removeFromSet(set: readonly Mark[]): readonly Mark[] { 50 | for (let i = 0; i < set.length; i++) 51 | if (this.eq(set[i])) 52 | return set.slice(0, i).concat(set.slice(i + 1)) 53 | return set 54 | } 55 | 56 | /// Test whether this mark is in the given set of marks. 57 | isInSet(set: readonly Mark[]) { 58 | for (let i = 0; i < set.length; i++) 59 | if (this.eq(set[i])) return true 60 | return false 61 | } 62 | 63 | /// Test whether this mark has the same type and attributes as 64 | /// another mark. 65 | eq(other: Mark) { 66 | return this == other || 67 | (this.type == other.type && compareDeep(this.attrs, other.attrs)) 68 | } 69 | 70 | /// Convert this mark to a JSON-serializeable representation. 71 | toJSON(): any { 72 | let obj: any = {type: this.type.name} 73 | for (let _ in this.attrs) { 74 | obj.attrs = this.attrs 75 | break 76 | } 77 | return obj 78 | } 79 | 80 | /// Deserialize a mark from JSON. 81 | static fromJSON(schema: Schema, json: any) { 82 | if (!json) throw new RangeError("Invalid input for Mark.fromJSON") 83 | let type = schema.marks[json.type] 84 | if (!type) throw new RangeError(`There is no mark type ${json.type} in this schema`) 85 | let mark = type.create(json.attrs) 86 | type.checkAttrs(mark.attrs) 87 | return mark 88 | } 89 | 90 | /// Test whether two sets of marks are identical. 91 | static sameSet(a: readonly Mark[], b: readonly Mark[]) { 92 | if (a == b) return true 93 | if (a.length != b.length) return false 94 | for (let i = 0; i < a.length; i++) 95 | if (!a[i].eq(b[i])) return false 96 | return true 97 | } 98 | 99 | /// Create a properly sorted mark set from null, a single mark, or an 100 | /// unsorted array of marks. 101 | static setFrom(marks?: Mark | readonly Mark[] | null): readonly Mark[] { 102 | if (!marks || Array.isArray(marks) && marks.length == 0) return Mark.none 103 | if (marks instanceof Mark) return [marks] 104 | let copy = marks.slice() 105 | copy.sort((a, b) => a.type.rank - b.type.rank) 106 | return copy 107 | } 108 | 109 | /// The empty set of marks. 110 | static none: readonly Mark[] = [] 111 | } 112 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | - [Getting help](#getting-help) 4 | - [Submitting bug reports](#submitting-bug-reports) 5 | - [Contributing code](#contributing-code) 6 | 7 | ## Getting help 8 | 9 | Community discussion, questions, and informal bug reporting is done on the 10 | [discuss.ProseMirror forum](http://discuss.prosemirror.net). 11 | 12 | ## Submitting bug reports 13 | 14 | Report bugs on the 15 | [GitHub issue tracker](http://github.com/prosemirror/prosemirror/issues). 16 | Before reporting a bug, please read these pointers. 17 | 18 | - The issue tracker is for *bugs*, not requests for help. Questions 19 | should be asked on the [forum](http://discuss.prosemirror.net). 20 | 21 | - Include information about the version of the code that exhibits the 22 | problem. For browser-related issues, include the browser and browser 23 | version on which the problem occurred. 24 | 25 | - Mention very precisely what went wrong. "X is broken" is not a good 26 | bug report. What did you expect to happen? What happened instead? 27 | Describe the exact steps a maintainer has to take to make the 28 | problem occur. A screencast can be useful, but is no substitute for 29 | a textual description. 30 | 31 | - A great way to make it easy to reproduce your problem, if it can not 32 | be trivially reproduced on the website demos, is to submit a script 33 | that triggers the issue. 34 | 35 | ## Contributing code 36 | 37 | If you want to make a change that involves a significant overhaul of 38 | the code or introduces a user-visible new feature, create an 39 | [RFC](https://github.com/ProseMirror/rfcs/) first with your proposal. 40 | 41 | - Make sure you have a [GitHub Account](https://github.com/signup/free) 42 | 43 | - Fork the relevant repository 44 | ([how to fork a repo](https://help.github.com/articles/fork-a-repo)) 45 | 46 | - Create a local checkout of the code. You can use the 47 | [main repository](https://github.com/prosemirror/prosemirror) to 48 | easily check out all core modules. 49 | 50 | - Make your changes, and commit them 51 | 52 | - Follow the code style of the rest of the project (see below). Run 53 | `npm run lint` (in the main repository checkout) to make sure that 54 | the linter is happy. 55 | 56 | - If your changes are easy to test or likely to regress, add tests in 57 | the relevant `test/` directory. Either put them in an existing 58 | `test-*.js` file, if they fit there, or add a new file. 59 | 60 | - Make sure all tests pass. Run `npm run test` to verify tests pass 61 | (you will need Node.js v6+). 62 | 63 | - Submit a pull request ([how to create a pull request](https://help.github.com/articles/fork-a-repo)). 64 | Don't put more than one feature/fix in a single pull request. 65 | 66 | By contributing code to ProseMirror you 67 | 68 | - Agree to license the contributed code under the project's [MIT 69 | license](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE). 70 | 71 | - Confirm that you have the right to contribute and license the code 72 | in question. (Either you hold all rights on the code, or the rights 73 | holder has explicitly granted the right to use it like this, 74 | through a compatible open source license or through a direct 75 | agreement with you.) 76 | 77 | ### Coding standards 78 | 79 | - ES6 syntax, targeting an ES5 runtime (i.e. don't use library 80 | elements added by ES6, don't use ES7/ES.next syntax). 81 | 82 | - 2 spaces per indentation level, no tabs. 83 | 84 | - No semicolons except when necessary. 85 | 86 | - Follow the surrounding code when it comes to spacing, brace 87 | placement, etc. 88 | 89 | - Brace-less single-statement bodies are encouraged (whenever they 90 | don't impact readability). 91 | 92 | - [getdocs](https://github.com/marijnh/getdocs)-style doc comments 93 | above items that are part of the public API. 94 | 95 | - When documenting non-public items, you can put the type after a 96 | single colon, so that getdocs doesn't pick it up and add it to the 97 | API reference. 98 | 99 | - The linter (`npm run lint`) complains about unused variables and 100 | functions. Prefix their names with an underscore to muffle it. 101 | 102 | - ProseMirror does *not* follow JSHint or JSLint prescribed style. 103 | Patches that try to 'fix' code to pass one of these linters will not 104 | be accepted. 105 | -------------------------------------------------------------------------------- /test/test-replace.ts: -------------------------------------------------------------------------------- 1 | import {Slice, Node} from "prosemirror-model" 2 | import {eq, doc, blockquote, h1, p, ul, li} from "prosemirror-test-builder" 3 | import ist from "ist" 4 | 5 | describe("Node", () => { 6 | describe("replace", () => { 7 | function rpl(doc: Node, insert: Node | null, expected: Node) { 8 | let slice = insert ? insert.slice((insert as any).tag.a, (insert as any).tag.b) : Slice.empty 9 | ist(doc.replace((doc as any).tag.a, (doc as any).tag.b, slice), expected, eq) 10 | } 11 | 12 | it("joins on delete", () => 13 | rpl(doc(p("one"), p("two")), null, doc(p("onwo")))) 14 | 15 | it("merges matching blocks", () => 16 | rpl(doc(p("one"), p("two")), doc(p("xxxx"), p("yyyy")), doc(p("onxx"), p("yywo")))) 17 | 18 | it("merges when adding text", () => 19 | rpl(doc(p("one"), p("two")), 20 | doc(p("H")), 21 | doc(p("onHwo")))) 22 | 23 | it("can insert text", () => 24 | rpl(doc(p("before"), p("one"), p("after")), 25 | doc(p("H")), 26 | doc(p("before"), p("onHe"), p("after")))) 27 | 28 | it("doesn't merge non-matching blocks", () => 29 | rpl(doc(p("one"), p("two")), 30 | doc(h1("H")), 31 | doc(p("onHwo")))) 32 | 33 | it("can merge a nested node", () => 34 | rpl(doc(blockquote(blockquote(p("one"), p("two")))), 35 | doc(p("H")), 36 | doc(blockquote(blockquote(p("onHwo")))))) 37 | 38 | it("can replace within a block", () => 39 | rpl(doc(blockquote(p("abcd"))), 40 | doc(p("xyz")), 41 | doc(blockquote(p("ayd"))))) 42 | 43 | it("can insert a lopsided slice", () => 44 | rpl(doc(blockquote(blockquote(p("one"), p("two"), "", p("three")))), 45 | doc(blockquote(p("aaaa"), p("bb"), p("cc"), "", p("dd"))), 46 | doc(blockquote(blockquote(p("onaa"), p("bb"), p("cc"), p("three")))))) 47 | 48 | it("can insert a deep, lopsided slice", () => 49 | rpl(doc(blockquote(blockquote(p("one"), p("two"), p("three")), "", p("x"))), 50 | doc(blockquote(p("aaaa"), p("bb"), p("cc")), "", p("dd")), 51 | doc(blockquote(blockquote(p("onaa"), p("bb"), p("cc")), p("x"))))) 52 | 53 | it("can merge multiple levels", () => 54 | rpl(doc(blockquote(blockquote(p("hello"))), blockquote(blockquote(p("a")))), 55 | null, 56 | doc(blockquote(blockquote(p("hella")))))) 57 | 58 | it("can merge multiple levels while inserting", () => 59 | rpl(doc(blockquote(blockquote(p("hello"))), blockquote(blockquote(p("a")))), 60 | doc(p("i")), 61 | doc(blockquote(blockquote(p("hellia")))))) 62 | 63 | it("can insert a split", () => 64 | rpl(doc(p("foobar")), 65 | doc(p("x"), p("y")), 66 | doc(p("foox"), p("ybar")))) 67 | 68 | it("can insert a deep split", () => 69 | rpl(doc(blockquote(p("fooxbar"))), 70 | doc(blockquote(p("x")), blockquote(p("y"))), 71 | doc(blockquote(p("foox")), blockquote(p("ybar"))))) 72 | 73 | it("can add a split one level up", () => 74 | rpl(doc(blockquote(p("foou"), p("vbar"))), 75 | doc(blockquote(p("x")), blockquote(p("y"))), 76 | doc(blockquote(p("foox")), blockquote(p("ybar"))))) 77 | 78 | it("keeps the node type of the left node", () => 79 | rpl(doc(h1("foobar"), ""), 80 | doc(p("foobaz"), ""), 81 | doc(h1("foobaz")))) 82 | 83 | it("keeps the node type even when empty", () => 84 | rpl(doc(h1("bar"), ""), 85 | doc(p("foobaz"), ""), 86 | doc(h1("baz")))) 87 | 88 | function bad(doc: Node, insert: Node | null, pattern: string) { 89 | let slice = insert ? insert.slice((insert as any).tag.a, (insert as any).tag.b) : Slice.empty 90 | ist.throws(() => doc.replace((doc as any).tag.a, (doc as any).tag.b, slice), new RegExp(pattern, "i")) 91 | } 92 | 93 | it("doesn't allow the left side to be too deep", () => 94 | bad(doc(p("")), 95 | doc(blockquote(p("")), ""), 96 | "deeper")) 97 | 98 | it("doesn't allow a depth mismatch", () => 99 | bad(doc(p("")), 100 | doc("", p("")), 101 | "inconsistent")) 102 | 103 | it("rejects a bad fit", () => 104 | bad(doc(""), 105 | doc(p("foo")), 106 | "invalid content")) 107 | 108 | it("rejects unjoinable content", () => 109 | bad(doc(ul(li(p("a")), ""), ""), 110 | doc(p("foo", ""), ""), 111 | "cannot join")) 112 | 113 | it("rejects an unjoinable delete", () => 114 | bad(doc(blockquote(p("a"), ""), ul("", li(p("b")))), 115 | null, 116 | "cannot join")) 117 | 118 | it("check content validity", () => 119 | bad(doc(blockquote("", p("hi")), ""), 120 | doc(blockquote("hi", ""), ""), 121 | "invalid content")) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /test/test-mark.ts: -------------------------------------------------------------------------------- 1 | import {Mark, Schema, Node} from "prosemirror-model" 2 | import {schema, doc, p, em, a} from "prosemirror-test-builder" 3 | import ist from "ist" 4 | 5 | let em_ = schema.mark("em") 6 | let strong = schema.mark("strong") 7 | let link = (href: string, title?: string) => schema.mark("link", {href, title}) 8 | let code = schema.mark("code") 9 | 10 | let customSchema = new Schema({ 11 | nodes: {doc: {content: "paragraph+"}, paragraph: {content: "text*"}, text: {}}, 12 | marks: { 13 | remark: {attrs: {id: {}}, excludes: "", inclusive: false}, 14 | user: {attrs: {id: {}}, excludes: "_"}, 15 | strong: {excludes: "em-group"}, 16 | em: {group: "em-group"} 17 | } 18 | }), custom = customSchema.marks 19 | let remark1 = custom.remark.create({id: 1}), remark2 = custom.remark.create({id: 2}), 20 | user1 = custom.user.create({id: 1}), user2 = custom.user.create({id: 2}), 21 | customEm = custom.em.create(), customStrong = custom.strong.create() 22 | 23 | describe("Mark", () => { 24 | describe("sameSet", () => { 25 | it("returns true for two empty sets", () => ist(Mark.sameSet([], []))) 26 | 27 | it("returns true for simple identical sets", () => 28 | ist(Mark.sameSet([em_, strong], [em_, strong]))) 29 | 30 | it("returns false for different sets", () => 31 | ist(!Mark.sameSet([em_, strong], [em_, code]))) 32 | 33 | it("returns false when set size differs", () => 34 | ist(!Mark.sameSet([em_, strong], [em_, strong, code]))) 35 | 36 | it("recognizes identical links in set", () => 37 | ist(Mark.sameSet([link("http://foo"), code], [link("http://foo"), code]))) 38 | 39 | it("recognizes different links in set", () => 40 | ist(!Mark.sameSet([link("http://foo"), code], [link("http://bar"), code]))) 41 | }) 42 | 43 | describe("eq", () => { 44 | it("considers identical links to be the same", () => 45 | ist(link("http://foo").eq(link("http://foo")))) 46 | 47 | it("considers different links to differ", () => 48 | ist(!link("http://foo").eq(link("http://bar")))) 49 | 50 | it("considers links with different titles to differ", () => 51 | ist(!link("http://foo", "A").eq(link("http://foo", "B")))) 52 | }) 53 | 54 | describe("addToSet", () => { 55 | it("can add to the empty set", () => 56 | ist(em_.addToSet([]), [em_], Mark.sameSet)) 57 | 58 | it("is a no-op when the added thing is in set", () => 59 | ist(em_.addToSet([em_]), [em_], Mark.sameSet)) 60 | 61 | it("adds marks with lower rank before others", () => 62 | ist(em_.addToSet([strong]), [em_, strong], Mark.sameSet)) 63 | 64 | it("adds marks with higher rank after others", () => 65 | ist(strong.addToSet([em_]), [em_, strong], Mark.sameSet)) 66 | 67 | it("replaces different marks with new attributes", () => 68 | ist(link("http://bar").addToSet([link("http://foo"), em_]), 69 | [link("http://bar"), em_], Mark.sameSet)) 70 | 71 | it("does nothing when adding an existing link", () => 72 | ist(link("http://foo").addToSet([em_, link("http://foo")]), 73 | [em_, link("http://foo")], Mark.sameSet)) 74 | 75 | it("puts code marks at the end", () => 76 | ist(code.addToSet([em_, strong, link("http://foo")]), 77 | [em_, strong, link("http://foo"), code], Mark.sameSet)) 78 | 79 | it("puts marks with middle rank in the middle", () => 80 | ist(strong.addToSet([em_, code]), [em_, strong, code], Mark.sameSet)) 81 | 82 | it("allows nonexclusive instances of marks with the same type", () => 83 | ist(remark2.addToSet([remark1]), [remark1, remark2], Mark.sameSet)) 84 | 85 | it("doesn't duplicate identical instances of nonexclusive marks", () => 86 | ist(remark1.addToSet([remark1]), [remark1], Mark.sameSet)) 87 | 88 | it("clears all others when adding a globally-excluding mark", () => 89 | ist(user1.addToSet([remark1, customEm]), [user1], Mark.sameSet)) 90 | 91 | it("does not allow adding another mark to a globally-excluding mark", () => 92 | ist(customEm.addToSet([user1]), [user1], Mark.sameSet)) 93 | 94 | it("does overwrite a globally-excluding mark when adding another instance", () => 95 | ist(user2.addToSet([user1]), [user2], Mark.sameSet)) 96 | 97 | it("doesn't add anything when another mark excludes the added mark", () => 98 | ist(customEm.addToSet([remark1, customStrong]), [remark1, customStrong], Mark.sameSet)) 99 | 100 | it("remove excluded marks when adding a mark", () => 101 | ist(customStrong.addToSet([remark1, customEm]), [remark1, customStrong], Mark.sameSet)) 102 | }) 103 | 104 | describe("removeFromSet", () => { 105 | it("is a no-op for the empty set", () => 106 | ist(Mark.sameSet(em_.removeFromSet([]), []))) 107 | 108 | it("can remove the last mark from a set", () => 109 | ist(Mark.sameSet(em_.removeFromSet([em_]), []))) 110 | 111 | it("is a no-op when the mark isn't in the set", () => 112 | ist(Mark.sameSet(strong.removeFromSet([em_]), [em_]))) 113 | 114 | it("can remove a mark with attributes", () => 115 | ist(Mark.sameSet(link("http://foo").removeFromSet([link("http://foo")]), []))) 116 | 117 | it("doesn't remove a mark when its attrs differ", () => 118 | ist(Mark.sameSet(link("http://foo", "title").removeFromSet([link("http://foo")]), 119 | [link("http://foo")]))) 120 | }) 121 | 122 | describe("ResolvedPos.marks", () => { 123 | function isAt(doc: Node, mark: Mark, result: boolean) { 124 | ist(mark.isInSet(doc.resolve((doc as any).tag.a).marks()), result) 125 | } 126 | 127 | it("recognizes a mark exists inside marked text", () => 128 | isAt(doc(p(em("foo"))), em_, true)) 129 | 130 | it("recognizes a mark doesn't exist in non-marked text", () => 131 | isAt(doc(p(em("foo"))), strong, false)) 132 | 133 | it("considers a mark active after the mark", () => 134 | isAt(doc(p(em("hi"), " there")), em_, true)) 135 | 136 | it("considers a mark inactive before the mark", () => 137 | isAt(doc(p("one ", em("two"))), em_, false)) 138 | 139 | it("considers a mark active at the start of the textblock", () => 140 | isAt(doc(p(em("one"))), em_, true)) 141 | 142 | it("notices that attributes differ", () => 143 | isAt(doc(p(a("link"))), link("http://baz"), false)) 144 | 145 | let customDoc = customSchema.node("doc", null, [ 146 | customSchema.node("paragraph", null, [ // pos 1 147 | customSchema.text("one", [remark1, customStrong]), customSchema.text("two") 148 | ]), 149 | customSchema.node("paragraph", null, [ // pos 9 150 | customSchema.text("one"), customSchema.text("two", [remark1]), customSchema.text("three", [remark1]) 151 | ]), // pos 22 152 | customSchema.node("paragraph", null, [ 153 | customSchema.text("one", [remark2]), customSchema.text("two", [remark1]) 154 | ]) 155 | ]) 156 | 157 | it("omits non-inclusive marks at end of mark", () => 158 | ist(Mark.sameSet(customDoc.resolve(4).marks(), [customStrong]))) 159 | 160 | it("includes non-inclusive marks inside a text node", () => 161 | ist(Mark.sameSet(customDoc.resolve(3).marks(), [remark1, customStrong]))) 162 | 163 | it("omits non-inclusive marks at the end of a line", () => 164 | ist(Mark.sameSet(customDoc.resolve(20).marks(), []))) 165 | 166 | it("includes non-inclusive marks between two marked nodes", () => 167 | ist(Mark.sameSet(customDoc.resolve(15).marks(), [remark1]))) 168 | 169 | it("excludes non-inclusive marks at a point where mark attrs change", () => 170 | ist(Mark.sameSet(customDoc.resolve(25).marks(), []))) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /test/test-content.ts: -------------------------------------------------------------------------------- 1 | import {ContentMatch, Node} from "prosemirror-model" 2 | import {schema, eq, doc, p, pre, img, br, h1, hr} from "prosemirror-test-builder" 3 | import ist from "ist" 4 | 5 | function get(expr: string) { return ContentMatch.parse(expr, schema.nodes) } 6 | 7 | function match(expr: string, types: string) { 8 | let m = get(expr), ts = types ? types.split(" ").map(t => schema.nodes[t]) : [] 9 | for (let i = 0; m && i < ts.length; i++) m = m.matchType(ts[i])! 10 | return m && m.validEnd 11 | } 12 | 13 | function valid(expr: string, types: string) { ist(match(expr, types)) } 14 | function invalid(expr: string, types: string) { ist(!match(expr, types)) } 15 | 16 | function fill(expr: string, before: Node, after: Node, result: Node | null) { 17 | let filled = get(expr).matchFragment(before.content)!.fillBefore(after.content, true) 18 | if (result) ist(filled, result.content, eq) 19 | else ist(!filled) 20 | } 21 | 22 | function fill3(expr: string, before: Node, mid: Node, after: Node, left: Node | null, right?: Node) { 23 | let content = get(expr) 24 | let a = content.matchFragment(before.content)!.fillBefore(mid.content) 25 | let b = a && content.matchFragment(before.content.append(a).append(mid.content))!.fillBefore(after.content, true) 26 | if (left) { 27 | ist(a, left.content, eq) 28 | ist(b, right!.content, eq) 29 | } else { 30 | ist(!b) 31 | } 32 | } 33 | 34 | describe("ContentMatch", () => { 35 | describe("matchType", () => { 36 | it("accepts empty content for the empty expr", () => valid("", "")) 37 | it("doesn't accept content in the empty expr", () => invalid("", "image")) 38 | 39 | it("matches nothing to an asterisk", () => valid("image*", "")) 40 | it("matches one element to an asterisk", () => valid("image*", "image")) 41 | it("matches multiple elements to an asterisk", () => valid("image*", "image image image image")) 42 | it("only matches appropriate elements to an asterisk", () => invalid("image*", "image text")) 43 | 44 | it("matches group members to a group", () => valid("inline*", "image text")) 45 | it("doesn't match non-members to a group", () => invalid("inline*", "paragraph")) 46 | it("matches an element to a choice expression", () => valid("(paragraph | heading)", "paragraph")) 47 | it("doesn't match unmentioned elements to a choice expr", () => invalid("(paragraph | heading)", "image")) 48 | 49 | it("matches a simple sequence", () => valid("paragraph horizontal_rule paragraph", "paragraph horizontal_rule paragraph")) 50 | it("fails when a sequence is too long", () => invalid("paragraph horizontal_rule", "paragraph horizontal_rule paragraph")) 51 | it("fails when a sequence is too short", () => invalid("paragraph horizontal_rule paragraph", "paragraph horizontal_rule")) 52 | it("fails when a sequence starts incorrectly", () => invalid("paragraph horizontal_rule", "horizontal_rule paragraph horizontal_rule")) 53 | 54 | it("accepts a sequence asterisk matching zero elements", () => valid("heading paragraph*", "heading")) 55 | it("accepts a sequence asterisk matching multiple elts", () => valid("heading paragraph*", "heading paragraph paragraph")) 56 | it("accepts a sequence plus matching one element", () => valid("heading paragraph+", "heading paragraph")) 57 | it("accepts a sequence plus matching multiple elts", () => valid("heading paragraph+", "heading paragraph paragraph")) 58 | it("fails when a sequence plus has no elements", () => invalid("heading paragraph+", "heading")) 59 | it("fails when a sequence plus misses its start", () => invalid("heading paragraph+", "paragraph paragraph")) 60 | 61 | it("accepts an optional element being present", () => valid("image?", "image")) 62 | it("accepts an optional element being missing", () => valid("image?", "")) 63 | it("fails when an optional element is present twice", () => invalid("image?", "image image")) 64 | 65 | it("accepts a nested repeat", () => 66 | valid("(heading paragraph+)+", "heading paragraph heading paragraph paragraph")) 67 | it("fails on extra input after a nested repeat", () => 68 | invalid("(heading paragraph+)+", "heading paragraph heading paragraph paragraph horizontal_rule")) 69 | 70 | it("accepts a matching count", () => valid("hard_break{2}", "hard_break hard_break")) 71 | it("rejects a count that comes up short", () => invalid("hard_break{2}", "hard_break")) 72 | it("rejects a count that has too many elements", () => invalid("hard_break{2}", "hard_break hard_break hard_break")) 73 | it("accepts a count on the lower bound", () => valid("hard_break{2, 4}", "hard_break hard_break")) 74 | it("accepts a count on the upper bound", () => valid("hard_break{2, 4}", "hard_break hard_break hard_break hard_break")) 75 | it("accepts a count between the bounds", () => valid("hard_break{2, 4}", "hard_break hard_break hard_break")) 76 | it("rejects a sequence with too few elements", () => invalid("hard_break{2, 4}", "hard_break")) 77 | it("rejects a sequence with too many elements", 78 | () => invalid("hard_break{2, 4}", "hard_break hard_break hard_break hard_break hard_break")) 79 | it("rejects a sequence with a bad element after it", () => invalid("hard_break{2, 4} text*", "hard_break hard_break image")) 80 | it("accepts a sequence with a matching element after it", () => valid("hard_break{2, 4} image?", "hard_break hard_break image")) 81 | it("accepts an open range", () => valid("hard_break{2,}", "hard_break hard_break")) 82 | it("accepts an open range matching many", () => valid("hard_break{2,}", "hard_break hard_break hard_break hard_break")) 83 | it("rejects an open range with too few elements", () => invalid("hard_break{2,}", "hard_break")) 84 | }) 85 | 86 | describe("fillBefore", () => { 87 | it("returns the empty fragment when things match", () => 88 | fill("paragraph horizontal_rule paragraph", doc(p(), hr()), doc(p()), doc())) 89 | 90 | it("adds a node when necessary", () => 91 | fill("paragraph horizontal_rule paragraph", doc(p()), doc(p()), doc(hr()))) 92 | 93 | it("accepts an asterisk across the bound", () => fill("hard_break*", p(br()), p(br()), p())) 94 | 95 | it("accepts an asterisk only on the left", () => fill("hard_break*", p(br()), p(), p())) 96 | 97 | it("accepts an asterisk only on the right", () => fill("hard_break*", p(), p(br()), p())) 98 | 99 | it("accepts an asterisk with no elements", () => fill("hard_break*", p(), p(), p())) 100 | 101 | it("accepts a plus across the bound", () => fill("hard_break+", p(br()), p(br()), p())) 102 | 103 | it("adds an element for a content-less plus", () => fill("hard_break+", p(), p(), p(br()))) 104 | 105 | it("fails for a mismatched plus", () => fill("hard_break+", p(), p(img()), null)) 106 | 107 | it("accepts asterisk with content on both sides", () => fill("heading* paragraph*", doc(h1()), doc(p()), doc())) 108 | 109 | it("accepts asterisk with no content after", () => fill("heading* paragraph*", doc(h1()), doc(), doc())) 110 | 111 | it("accepts plus with content on both sides", () => fill("heading+ paragraph+", doc(h1()), doc(p()), doc())) 112 | 113 | it("accepts plus with no content after", () => fill("heading+ paragraph+", doc(h1()), doc(), doc(p()))) 114 | 115 | it("adds elements to match a count", () => fill("hard_break{3}", p(br()), p(br()), p(br()))) 116 | 117 | it("fails when there are too many elements", () => fill("hard_break{3}", p(br(), br()), p(br(), br()), null)) 118 | 119 | it("adds elements for two counted groups", () => fill("code_block{2} paragraph{2}", doc(pre()), doc(p()), doc(pre(), p()))) 120 | 121 | it("doesn't include optional elements", () => fill("heading paragraph? horizontal_rule", doc(h1()), doc(), doc(hr()))) 122 | 123 | it("completes a sequence", () => 124 | fill3("paragraph horizontal_rule paragraph horizontal_rule paragraph", 125 | doc(p()), doc(p()), doc(p()), doc(hr()), doc(hr()))) 126 | 127 | it("accepts plus across two bounds", () => 128 | fill3("code_block+ paragraph+", 129 | doc(pre()), doc(pre()), doc(p()), doc(), doc())) 130 | 131 | it("fills a plus from empty input", () => 132 | fill3("code_block+ paragraph+", 133 | doc(), doc(), doc(), doc(), doc(pre(), p()))) 134 | 135 | it("completes a count", () => 136 | fill3("code_block{3} paragraph{3}", 137 | doc(pre()), doc(p()), doc(), doc(pre(), pre()), doc(p(), p()))) 138 | 139 | it("fails on non-matching elements", () => 140 | fill3("paragraph*", doc(p()), doc(pre()), doc(p()), null)) 141 | 142 | it("completes a plus across two bounds", () => 143 | fill3("paragraph{4}", doc(p()), doc(p()), doc(p()), doc(), doc(p()))) 144 | 145 | it("refuses to complete an overflown count across two bounds", () => 146 | fill3("paragraph{2}", doc(p()), doc(p()), doc(p()), null)) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /test/test-node.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {Fragment, Schema, Node} from "prosemirror-model" 3 | import {schema, eq, doc, blockquote, p, li, ul, em, strong, code, a, br, hr, img} from "prosemirror-test-builder" 4 | 5 | let customSchema = new Schema({ 6 | nodes: { 7 | doc: {content: "paragraph+"}, 8 | paragraph: {content: "(text|contact)*"}, 9 | text: { toDebugString() { return 'custom_text' } }, 10 | contact: { 11 | inline: true, 12 | attrs: { name: {}, email: {} }, 13 | leafText(node: Node) { return `${node.attrs.name} <${node.attrs.email}>` } 14 | }, 15 | hard_break: { toDebugString() { return 'custom_hard_break' } } 16 | }, 17 | }) 18 | 19 | describe("Node", () => { 20 | describe("toString", () => { 21 | it("nests", () => { 22 | ist(doc(ul(li(p("hey"), p()), li(p("foo")))).toString(), 23 | 'doc(bullet_list(list_item(paragraph("hey"), paragraph), list_item(paragraph("foo"))))') 24 | }) 25 | 26 | it("shows inline children", () => { 27 | ist(doc(p("foo", img(), br(), "bar")).toString(), 28 | 'doc(paragraph("foo", image, hard_break, "bar"))') 29 | }) 30 | 31 | it("shows marks", () => { 32 | ist(doc(p("foo", em("bar", strong("quux")), code("baz"))).toString(), 33 | 'doc(paragraph("foo", em("bar"), em(strong("quux")), code("baz")))') 34 | }) 35 | }) 36 | 37 | describe("cut", () => { 38 | function cut(doc: Node, cut: Node) { 39 | ist(doc.cut((doc as any).tag.a || 0, (doc as any).tag.b), cut, eq) 40 | } 41 | 42 | it("extracts a full block", () => 43 | cut(doc(p("foo"), "", p("bar"), "", p("baz")), 44 | doc(p("bar")))) 45 | 46 | it("cuts text", () => 47 | cut(doc(p("0"), p("foobarbaz"), p("2")), 48 | doc(p("bar")))) 49 | 50 | it("cuts deeply", () => 51 | cut(doc(blockquote(ul(li(p("a"), p("bc")), li(p("d")), "", li(p("e"))), p("3"))), 52 | doc(blockquote(ul(li(p("c")), li(p("d"))))))) 53 | 54 | it("works from the left", () => 55 | cut(doc(blockquote(p("foobar"))), 56 | doc(blockquote(p("foo"))))) 57 | 58 | it("works to the right", () => 59 | cut(doc(blockquote(p("foobar"))), 60 | doc(blockquote(p("bar"))))) 61 | 62 | it("preserves marks", () => 63 | cut(doc(p("foo", em("bar", img(), strong("baz"), br()), "quux", code("xyz"))), 64 | doc(p(em("r", img(), strong("baz"), br()), "qu")))) 65 | }) 66 | 67 | describe("between", () => { 68 | function between(doc: Node, ...nodes: string[]) { 69 | let i = 0 70 | doc.nodesBetween((doc as any).tag.a, (doc as any).tag.b, (node, pos) => { 71 | if (i == nodes.length) 72 | throw new Error("More nodes iterated than listed (" + node.type.name + ")") 73 | let compare = node.isText ? node.text! : node.type.name 74 | if (compare != nodes[i++]) 75 | throw new Error("Expected " + JSON.stringify(nodes[i - 1]) + ", got " + JSON.stringify(compare)) 76 | if (!node.isText && doc.nodeAt(pos) != node) 77 | throw new Error("Pos " + pos + " does not point at node " + node + " " + doc.nodeAt(pos)) 78 | }) 79 | } 80 | 81 | it("iterates over text", () => 82 | between(doc(p("foobarbaz")), 83 | "paragraph", "foobarbaz")) 84 | 85 | it("descends multiple levels", () => 86 | between(doc(blockquote(ul(li(p("foo")), p("b"), ""), p("c"))), 87 | "blockquote", "bullet_list", "list_item", "paragraph", "foo", "paragraph", "b")) 88 | 89 | it("iterates over inline nodes", () => 90 | between(doc(p(em("x"), "foo", em("bar", img(), strong("baz"), br()), "quux", code("xyz"))), 91 | "paragraph", "foo", "bar", "image", "baz", "hard_break", "quux", "xyz")) 92 | }) 93 | 94 | describe("textBetween", () => { 95 | it("works when passing a custom function as leafText", () => { 96 | const d = doc(p("foo", img(), br())) 97 | ist(d.textBetween(0, d.content.size, '', (node) => { 98 | if (node.type.name === 'image') return '' 99 | if (node.type.name === 'hard_break') return '' 100 | return "" 101 | }), 'foo') 102 | }) 103 | 104 | it("works with leafText", () => { 105 | const d = customSchema.nodes.doc.createChecked({}, [ 106 | customSchema.nodes.paragraph.createChecked({}, [ 107 | customSchema.text("Hello "), 108 | customSchema.nodes.contact.createChecked({ name: "Alice", email: "alice@example.com" }) 109 | ]) 110 | ]) 111 | ist(d.textBetween(0, d.content.size), 'Hello Alice ') 112 | }) 113 | 114 | it("should ignore leafText when passing a custom leafText", () => { 115 | const d = customSchema.nodes.doc.createChecked({}, [ 116 | customSchema.nodes.paragraph.createChecked({}, [ 117 | customSchema.text("Hello "), 118 | customSchema.nodes.contact.createChecked({ name: "Alice", email: "alice@example.com" }) 119 | ]) 120 | ]) 121 | ist(d.textBetween(0, d.content.size, '', ''), 'Hello ') 122 | }) 123 | 124 | it("adds block separator around empty paragraphs", () => { 125 | ist(doc(p("one"), p(), p("two")).textBetween(0, 12, "\n"), "one\n\ntwo") 126 | }) 127 | 128 | it("adds block separator around leaf nodes", () => { 129 | ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n", "---"), "one\n---\n---\ntwo") 130 | }) 131 | 132 | it("doesn't add block separator around non-rendered leaf nodes", () => { 133 | ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n"), "one\ntwo") 134 | }) 135 | }) 136 | 137 | describe("textContent", () => { 138 | it("works on a whole doc", () => { 139 | ist(doc(p("foo")).textContent, "foo") 140 | }) 141 | 142 | it("works on a text node", () => { 143 | ist(schema.text("foo").textContent, "foo") 144 | }) 145 | 146 | it("works on a nested element", () => { 147 | ist(doc(ul(li(p("hi")), li(p(em("a"), "b")))).textContent, 148 | "hiab") 149 | }) 150 | }) 151 | 152 | describe("check", () => { 153 | it("notices invalid content", () => { 154 | ist.throws(() => doc(li("x")).check(), 155 | /Invalid content for node doc/) 156 | }) 157 | 158 | it("notices marks in wrong places", () => { 159 | ist.throws(() => doc(schema.nodes.paragraph.create(null, [], [schema.marks.em.create()])).check(), 160 | /Invalid content for node doc/) 161 | }) 162 | 163 | it("notices incorrect sets of marks", () => { 164 | ist.throws(() => schema.text("a", [schema.marks.em.create(), schema.marks.em.create()]).check(), 165 | /Invalid collection of marks/) 166 | }) 167 | 168 | it("notices wrong attribute types", () => { 169 | ist.throws(() => schema.nodes.image.create({src: true}).check(), 170 | /Expected value of type string for attribute src on type image, got boolean/) 171 | }) 172 | }) 173 | 174 | describe("from", () => { 175 | function from(arg: Node | Node[] | Fragment | null, expect: Node) { 176 | ist(expect.copy(Fragment.from(arg)), expect, eq) 177 | } 178 | 179 | it("wraps a single node", () => 180 | from(schema.node("paragraph"), doc(p()))) 181 | 182 | it("wraps an array", () => 183 | from([schema.node("hard_break"), schema.text("foo")], p(br, "foo"))) 184 | 185 | it("preserves a fragment", () => 186 | from(doc(p("foo")).content, doc(p("foo")))) 187 | 188 | it("accepts null", () => 189 | from(null, p())) 190 | 191 | it("joins adjacent text", () => 192 | from([schema.text("a"), schema.text("b")], p("ab"))) 193 | }) 194 | 195 | describe("toJSON", () => { 196 | function roundTrip(doc: Node) { 197 | ist(schema.nodeFromJSON(doc.toJSON()), doc, eq) 198 | } 199 | 200 | it("can serialize a simple node", () => roundTrip(doc(p("foo")))) 201 | 202 | it("can serialize marks", () => roundTrip(doc(p("foo", em("bar", strong("baz")), " ", a("x"))))) 203 | 204 | it("can serialize inline leaf nodes", () => roundTrip(doc(p("foo", em(img(), "bar"))))) 205 | 206 | it("can serialize block leaf nodes", () => roundTrip(doc(p("a"), hr(), p("b"), p()))) 207 | 208 | it("can serialize nested nodes", () => roundTrip(doc(blockquote(ul(li(p("a"), p("b")), li(p(img()))), p("c")), p("d")))) 209 | }) 210 | 211 | describe("toString", () => { 212 | it("should have the default toString method [text]", () => ist(schema.text("hello").toString(), "\"hello\"")) 213 | it("should have the default toString method [br]", () => ist(br().toString(), "hard_break")) 214 | 215 | it("should be able to redefine it from NodeSpec by specifying toDebugString method", 216 | () => ist(customSchema.text("hello").toString(), "custom_text")) 217 | 218 | it("should be respected by Fragment", () => 219 | ist( 220 | Fragment.fromArray( 221 | [customSchema.text("hello"), customSchema.nodes.hard_break.createChecked(), customSchema.text("world")] 222 | ), 223 | "" 224 | ) 225 | ) 226 | }) 227 | 228 | describe("leafText", () => { 229 | it("should custom the textContent of a leaf node", () => { 230 | let contact = customSchema.nodes.contact.createChecked({ name: "Bob", email: "bob@example.com" }) 231 | let paragraph = customSchema.nodes.paragraph.createChecked({}, [customSchema.text('Hello '), contact]) 232 | 233 | ist(contact.textContent, "Bob ") 234 | ist(paragraph.textContent, "Hello Bob ") 235 | }) 236 | }) 237 | }) 238 | -------------------------------------------------------------------------------- /src/replace.ts: -------------------------------------------------------------------------------- 1 | import {Fragment} from "./fragment" 2 | import {Schema} from "./schema" 3 | import {Node, TextNode} from "./node" 4 | import {ResolvedPos} from "./resolvedpos" 5 | 6 | /// Error type raised by [`Node.replace`](#model.Node.replace) when 7 | /// given an invalid replacement. 8 | export class ReplaceError extends Error {} 9 | /* 10 | ReplaceError = function(this: any, message: string) { 11 | let err = Error.call(this, message) 12 | ;(err as any).__proto__ = ReplaceError.prototype 13 | return err 14 | } as any 15 | 16 | ReplaceError.prototype = Object.create(Error.prototype) 17 | ReplaceError.prototype.constructor = ReplaceError 18 | ReplaceError.prototype.name = "ReplaceError" 19 | */ 20 | 21 | /// A slice represents a piece cut out of a larger document. It 22 | /// stores not only a fragment, but also the depth up to which nodes on 23 | /// both side are ‘open’ (cut through). 24 | export class Slice { 25 | /// Create a slice. When specifying a non-zero open depth, you must 26 | /// make sure that there are nodes of at least that depth at the 27 | /// appropriate side of the fragment—i.e. if the fragment is an 28 | /// empty paragraph node, `openStart` and `openEnd` can't be greater 29 | /// than 1. 30 | /// 31 | /// It is not necessary for the content of open nodes to conform to 32 | /// the schema's content constraints, though it should be a valid 33 | /// start/end/middle for such a node, depending on which sides are 34 | /// open. 35 | constructor( 36 | /// The slice's content. 37 | readonly content: Fragment, 38 | /// The open depth at the start of the fragment. 39 | readonly openStart: number, 40 | /// The open depth at the end. 41 | readonly openEnd: number 42 | ) {} 43 | 44 | /// The size this slice would add when inserted into a document. 45 | get size(): number { 46 | return this.content.size - this.openStart - this.openEnd 47 | } 48 | 49 | /// @internal 50 | insertAt(pos: number, fragment: Fragment) { 51 | let content = insertInto(this.content, pos + this.openStart, fragment) 52 | return content && new Slice(content, this.openStart, this.openEnd) 53 | } 54 | 55 | /// @internal 56 | removeBetween(from: number, to: number) { 57 | return new Slice(removeRange(this.content, from + this.openStart, to + this.openStart), this.openStart, this.openEnd) 58 | } 59 | 60 | /// Tests whether this slice is equal to another slice. 61 | eq(other: Slice): boolean { 62 | return this.content.eq(other.content) && this.openStart == other.openStart && this.openEnd == other.openEnd 63 | } 64 | 65 | /// @internal 66 | toString() { 67 | return this.content + "(" + this.openStart + "," + this.openEnd + ")" 68 | } 69 | 70 | /// Convert a slice to a JSON-serializable representation. 71 | toJSON(): any { 72 | if (!this.content.size) return null 73 | let json: any = {content: this.content.toJSON()} 74 | if (this.openStart > 0) json.openStart = this.openStart 75 | if (this.openEnd > 0) json.openEnd = this.openEnd 76 | return json 77 | } 78 | 79 | /// Deserialize a slice from its JSON representation. 80 | static fromJSON(schema: Schema, json: any): Slice { 81 | if (!json) return Slice.empty 82 | let openStart = json.openStart || 0, openEnd = json.openEnd || 0 83 | if (typeof openStart != "number" || typeof openEnd != "number") 84 | throw new RangeError("Invalid input for Slice.fromJSON") 85 | return new Slice(Fragment.fromJSON(schema, json.content), openStart, openEnd) 86 | } 87 | 88 | /// Create a slice from a fragment by taking the maximum possible 89 | /// open value on both side of the fragment. 90 | static maxOpen(fragment: Fragment, openIsolating = true) { 91 | let openStart = 0, openEnd = 0 92 | for (let n = fragment.firstChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.firstChild) openStart++ 93 | for (let n = fragment.lastChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.lastChild) openEnd++ 94 | return new Slice(fragment, openStart, openEnd) 95 | } 96 | 97 | /// The empty slice. 98 | static empty = new Slice(Fragment.empty, 0, 0) 99 | } 100 | 101 | function removeRange(content: Fragment, from: number, to: number): Fragment { 102 | let {index, offset} = content.findIndex(from), child = content.maybeChild(index) 103 | let {index: indexTo, offset: offsetTo} = content.findIndex(to) 104 | if (offset == from || child!.isText) { 105 | if (offsetTo != to && !content.child(indexTo).isText) throw new RangeError("Removing non-flat range") 106 | return content.cut(0, from).append(content.cut(to)) 107 | } 108 | if (index != indexTo) throw new RangeError("Removing non-flat range") 109 | return content.replaceChild(index, child!.copy(removeRange(child!.content, from - offset - 1, to - offset - 1))) 110 | } 111 | 112 | function insertInto(content: Fragment, dist: number, insert: Fragment, parent?: Node | null): Fragment | null { 113 | let {index, offset} = content.findIndex(dist), child = content.maybeChild(index) 114 | if (offset == dist || child!.isText) { 115 | if (parent && !parent.canReplace(index, index, insert)) return null 116 | return content.cut(0, dist).append(insert).append(content.cut(dist)) 117 | } 118 | let inner = insertInto(child!.content, dist - offset - 1, insert, child) 119 | return inner && content.replaceChild(index, child!.copy(inner)) 120 | } 121 | 122 | export function replace($from: ResolvedPos, $to: ResolvedPos, slice: Slice) { 123 | if (slice.openStart > $from.depth) 124 | throw new ReplaceError("Inserted content deeper than insertion position") 125 | if ($from.depth - slice.openStart != $to.depth - slice.openEnd) 126 | throw new ReplaceError("Inconsistent open depths") 127 | return replaceOuter($from, $to, slice, 0) 128 | } 129 | 130 | function replaceOuter($from: ResolvedPos, $to: ResolvedPos, slice: Slice, depth: number): Node { 131 | let index = $from.index(depth), node = $from.node(depth) 132 | if (index == $to.index(depth) && depth < $from.depth - slice.openStart) { 133 | let inner = replaceOuter($from, $to, slice, depth + 1) 134 | return node.copy(node.content.replaceChild(index, inner)) 135 | } else if (!slice.content.size) { 136 | return close(node, replaceTwoWay($from, $to, depth)) 137 | } else if (!slice.openStart && !slice.openEnd && $from.depth == depth && $to.depth == depth) { // Simple, flat case 138 | let parent = $from.parent, content = parent.content 139 | return close(parent, content.cut(0, $from.parentOffset).append(slice.content).append(content.cut($to.parentOffset))) 140 | } else { 141 | let {start, end} = prepareSliceForReplace(slice, $from) 142 | return close(node, replaceThreeWay($from, start, end, $to, depth)) 143 | } 144 | } 145 | 146 | function checkJoin(main: Node, sub: Node) { 147 | if (!sub.type.compatibleContent(main.type)) 148 | throw new ReplaceError("Cannot join " + sub.type.name + " onto " + main.type.name) 149 | } 150 | 151 | function joinable($before: ResolvedPos, $after: ResolvedPos, depth: number) { 152 | let node = $before.node(depth) 153 | checkJoin(node, $after.node(depth)) 154 | return node 155 | } 156 | 157 | function addNode(child: Node, target: Node[]) { 158 | let last = target.length - 1 159 | if (last >= 0 && child.isText && child.sameMarkup(target[last])) 160 | target[last] = (child as TextNode).withText(target[last].text! + child.text!) 161 | else 162 | target.push(child) 163 | } 164 | 165 | function addRange($start: ResolvedPos | null, $end: ResolvedPos | null, depth: number, target: Node[]) { 166 | let node = ($end || $start)!.node(depth) 167 | let startIndex = 0, endIndex = $end ? $end.index(depth) : node.childCount 168 | if ($start) { 169 | startIndex = $start.index(depth) 170 | if ($start.depth > depth) { 171 | startIndex++ 172 | } else if ($start.textOffset) { 173 | addNode($start.nodeAfter!, target) 174 | startIndex++ 175 | } 176 | } 177 | for (let i = startIndex; i < endIndex; i++) addNode(node.child(i), target) 178 | if ($end && $end.depth == depth && $end.textOffset) 179 | addNode($end.nodeBefore!, target) 180 | } 181 | 182 | function close(node: Node, content: Fragment) { 183 | node.type.checkContent(content) 184 | return node.copy(content) 185 | } 186 | 187 | function replaceThreeWay($from: ResolvedPos, $start: ResolvedPos, $end: ResolvedPos, $to: ResolvedPos, depth: number) { 188 | let openStart = $from.depth > depth && joinable($from, $start, depth + 1) 189 | let openEnd = $to.depth > depth && joinable($end, $to, depth + 1) 190 | 191 | let content: Node[] = [] 192 | addRange(null, $from, depth, content) 193 | if (openStart && openEnd && $start.index(depth) == $end.index(depth)) { 194 | checkJoin(openStart, openEnd) 195 | addNode(close(openStart, replaceThreeWay($from, $start, $end, $to, depth + 1)), content) 196 | } else { 197 | if (openStart) 198 | addNode(close(openStart, replaceTwoWay($from, $start, depth + 1)), content) 199 | addRange($start, $end, depth, content) 200 | if (openEnd) 201 | addNode(close(openEnd, replaceTwoWay($end, $to, depth + 1)), content) 202 | } 203 | addRange($to, null, depth, content) 204 | return new Fragment(content) 205 | } 206 | 207 | function replaceTwoWay($from: ResolvedPos, $to: ResolvedPos, depth: number) { 208 | let content: Node[] = [] 209 | addRange(null, $from, depth, content) 210 | if ($from.depth > depth) { 211 | let type = joinable($from, $to, depth + 1) 212 | addNode(close(type, replaceTwoWay($from, $to, depth + 1)), content) 213 | } 214 | addRange($to, null, depth, content) 215 | return new Fragment(content) 216 | } 217 | 218 | function prepareSliceForReplace(slice: Slice, $along: ResolvedPos) { 219 | let extra = $along.depth - slice.openStart, parent = $along.node(extra) 220 | let node = parent.copy(slice.content) 221 | for (let i = extra - 1; i >= 0; i--) 222 | node = $along.node(i).copy(Fragment.from(node)) 223 | return {start: node.resolveNoCache(slice.openStart + extra), 224 | end: node.resolveNoCache(node.content.size - slice.openEnd - extra)} 225 | } 226 | -------------------------------------------------------------------------------- /src/to_dom.ts: -------------------------------------------------------------------------------- 1 | import {Fragment} from "./fragment" 2 | import {Node} from "./node" 3 | import {Schema, NodeType, MarkType} from "./schema" 4 | import {Mark} from "./mark" 5 | import {DOMNode} from "./dom" 6 | 7 | /// A description of a DOM structure. Can be either a string, which is 8 | /// interpreted as a text node, a DOM node, which is interpreted as 9 | /// itself, a `{dom, contentDOM}` object, or an array. 10 | /// 11 | /// An array describes a DOM element. The first value in the array 12 | /// should be a string—the name of the DOM element, optionally prefixed 13 | /// by a namespace URL and a space. If the second element is plain 14 | /// object, it is interpreted as a set of attributes for the element. 15 | /// Any elements after that (including the 2nd if it's not an attribute 16 | /// object) are interpreted as children of the DOM elements, and must 17 | /// either be valid `DOMOutputSpec` values, or the number zero. 18 | /// 19 | /// The number zero (pronounced “hole”) is used to indicate the place 20 | /// where a node's child nodes should be inserted. If it occurs in an 21 | /// output spec, it should be the only child element in its parent 22 | /// node. 23 | export type DOMOutputSpec = string | DOMNode | {dom: DOMNode, contentDOM?: HTMLElement} | readonly [string, ...any[]] 24 | 25 | /// A DOM serializer knows how to convert ProseMirror nodes and 26 | /// marks of various types to DOM nodes. 27 | export class DOMSerializer { 28 | /// Create a serializer. `nodes` should map node names to functions 29 | /// that take a node and return a description of the corresponding 30 | /// DOM. `marks` does the same for mark names, but also gets an 31 | /// argument that tells it whether the mark's content is block or 32 | /// inline content (for typical use, it'll always be inline). A mark 33 | /// serializer may be `null` to indicate that marks of that type 34 | /// should not be serialized. 35 | constructor( 36 | /// The node serialization functions. 37 | readonly nodes: {[node: string]: (node: Node) => DOMOutputSpec}, 38 | /// The mark serialization functions. 39 | readonly marks: {[mark: string]: (mark: Mark, inline: boolean) => DOMOutputSpec} 40 | ) {} 41 | 42 | /// Serialize the content of this fragment to a DOM fragment. When 43 | /// not in the browser, the `document` option, containing a DOM 44 | /// document, should be passed so that the serializer can create 45 | /// nodes. 46 | serializeFragment(fragment: Fragment, options: {document?: Document} = {}, target?: HTMLElement | DocumentFragment) { 47 | if (!target) target = doc(options).createDocumentFragment() 48 | 49 | let top = target!, active: [Mark, HTMLElement | DocumentFragment][] = [] 50 | fragment.forEach(node => { 51 | if (active.length || node.marks.length) { 52 | let keep = 0, rendered = 0 53 | while (keep < active.length && rendered < node.marks.length) { 54 | let next = node.marks[rendered] 55 | if (!this.marks[next.type.name]) { rendered++; continue } 56 | if (!next.eq(active[keep][0]) || next.type.spec.spanning === false) break 57 | keep++; rendered++ 58 | } 59 | while (keep < active.length) top = active.pop()![1] 60 | while (rendered < node.marks.length) { 61 | let add = node.marks[rendered++] 62 | let markDOM = this.serializeMark(add, node.isInline, options) 63 | if (markDOM) { 64 | active.push([add, top]) 65 | top.appendChild(markDOM.dom) 66 | top = markDOM.contentDOM || markDOM.dom as HTMLElement 67 | } 68 | } 69 | } 70 | top.appendChild(this.serializeNodeInner(node, options)) 71 | }) 72 | 73 | return target 74 | } 75 | 76 | /// @internal 77 | serializeNodeInner(node: Node, options: {document?: Document}) { 78 | let {dom, contentDOM} = 79 | renderSpec(doc(options), this.nodes[node.type.name](node), null, node.attrs) 80 | if (contentDOM) { 81 | if (node.isLeaf) 82 | throw new RangeError("Content hole not allowed in a leaf node spec") 83 | this.serializeFragment(node.content, options, contentDOM) 84 | } 85 | return dom 86 | } 87 | 88 | /// Serialize this node to a DOM node. This can be useful when you 89 | /// need to serialize a part of a document, as opposed to the whole 90 | /// document. To serialize a whole document, use 91 | /// [`serializeFragment`](#model.DOMSerializer.serializeFragment) on 92 | /// its [content](#model.Node.content). 93 | serializeNode(node: Node, options: {document?: Document} = {}) { 94 | let dom = this.serializeNodeInner(node, options) 95 | for (let i = node.marks.length - 1; i >= 0; i--) { 96 | let wrap = this.serializeMark(node.marks[i], node.isInline, options) 97 | if (wrap) { 98 | ;(wrap.contentDOM || wrap.dom).appendChild(dom) 99 | dom = wrap.dom 100 | } 101 | } 102 | return dom 103 | } 104 | 105 | /// @internal 106 | serializeMark(mark: Mark, inline: boolean, options: {document?: Document} = {}) { 107 | let toDOM = this.marks[mark.type.name] 108 | return toDOM && renderSpec(doc(options), toDOM(mark, inline), null, mark.attrs) 109 | } 110 | 111 | /// Render an [output spec](#model.DOMOutputSpec) to a DOM node. If 112 | /// the spec has a hole (zero) in it, `contentDOM` will point at the 113 | /// node with the hole. 114 | static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS?: string | null): { 115 | dom: DOMNode, 116 | contentDOM?: HTMLElement 117 | } 118 | static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null = null, 119 | blockArraysIn?: {[name: string]: any}): { 120 | dom: DOMNode, 121 | contentDOM?: HTMLElement 122 | } { 123 | return renderSpec(doc, structure, xmlNS, blockArraysIn) 124 | } 125 | 126 | /// Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) 127 | /// properties in a schema's node and mark specs. 128 | static fromSchema(schema: Schema): DOMSerializer { 129 | return schema.cached.domSerializer as DOMSerializer || 130 | (schema.cached.domSerializer = new DOMSerializer(this.nodesFromSchema(schema), this.marksFromSchema(schema))) 131 | } 132 | 133 | /// Gather the serializers in a schema's node specs into an object. 134 | /// This can be useful as a base to build a custom serializer from. 135 | static nodesFromSchema(schema: Schema) { 136 | let result = gatherToDOM(schema.nodes) 137 | if (!result.text) result.text = node => node.text 138 | return result as {[node: string]: (node: Node) => DOMOutputSpec} 139 | } 140 | 141 | /// Gather the serializers in a schema's mark specs into an object. 142 | static marksFromSchema(schema: Schema) { 143 | return gatherToDOM(schema.marks) as {[mark: string]: (mark: Mark, inline: boolean) => DOMOutputSpec} 144 | } 145 | } 146 | 147 | function gatherToDOM(obj: {[node: string]: NodeType | MarkType}) { 148 | let result: {[node: string]: (value: any, inline: boolean) => DOMOutputSpec} = {} 149 | for (let name in obj) { 150 | let toDOM = obj[name].spec.toDOM 151 | if (toDOM) result[name] = toDOM 152 | } 153 | return result 154 | } 155 | 156 | function doc(options: {document?: Document}) { 157 | return options.document || window.document 158 | } 159 | 160 | const suspiciousAttributeCache = new WeakMap() 161 | 162 | function suspiciousAttributes(attrs: {[name: string]: any}): readonly any[] | null { 163 | let value = suspiciousAttributeCache.get(attrs) 164 | if (value === undefined) 165 | suspiciousAttributeCache.set(attrs, value = suspiciousAttributesInner(attrs)) 166 | return value 167 | } 168 | 169 | function suspiciousAttributesInner(attrs: {[name: string]: any}): readonly any[] | null { 170 | let result: any[] | null = null 171 | function scan(value: any) { 172 | if (value && typeof value == "object") { 173 | if (Array.isArray(value)) { 174 | if (typeof value[0] == "string") { 175 | if (!result) result = [] 176 | result.push(value) 177 | } else { 178 | for (let i = 0; i < value.length; i++) scan(value[i]) 179 | } 180 | } else { 181 | for (let prop in value) scan(value[prop]) 182 | } 183 | } 184 | } 185 | scan(attrs) 186 | return result 187 | } 188 | 189 | function renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null, 190 | blockArraysIn?: {[name: string]: any}): { 191 | dom: DOMNode, 192 | contentDOM?: HTMLElement 193 | } { 194 | if (typeof structure == "string") 195 | return {dom: doc.createTextNode(structure)} 196 | if ((structure as DOMNode).nodeType != null) 197 | return {dom: structure as DOMNode} 198 | if ((structure as any).dom && (structure as any).dom.nodeType != null) 199 | return structure as {dom: DOMNode, contentDOM?: HTMLElement} 200 | let tagName = (structure as [string])[0], suspicious 201 | if (typeof tagName != "string") throw new RangeError("Invalid array passed to renderSpec") 202 | if (blockArraysIn && (suspicious = suspiciousAttributes(blockArraysIn)) && 203 | suspicious.indexOf(structure) > -1) 204 | throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.") 205 | let space = tagName.indexOf(" ") 206 | if (space > 0) { 207 | xmlNS = tagName.slice(0, space) 208 | tagName = tagName.slice(space + 1) 209 | } 210 | let contentDOM: HTMLElement | undefined 211 | let dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)) as HTMLElement 212 | let attrs = (structure as any)[1], start = 1 213 | if (attrs && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs)) { 214 | start = 2 215 | for (let name in attrs) if (attrs[name] != null) { 216 | let space = name.indexOf(" ") 217 | if (space > 0) dom.setAttributeNS(name.slice(0, space), name.slice(space + 1), attrs[name]) 218 | else if (name == "style" && dom.style) dom.style.cssText = attrs[name] 219 | else dom.setAttribute(name, attrs[name]) 220 | } 221 | } 222 | for (let i = start; i < (structure as readonly any[]).length; i++) { 223 | let child = (structure as any)[i] as DOMOutputSpec | 0 224 | if (child === 0) { 225 | if (i < (structure as readonly any[]).length - 1 || i > start) 226 | throw new RangeError("Content hole must be the only child of its parent node") 227 | return {dom, contentDOM: dom} 228 | } else { 229 | let {dom: inner, contentDOM: innerContent} = renderSpec(doc, child, xmlNS, blockArraysIn) 230 | dom.appendChild(inner) 231 | if (innerContent) { 232 | if (contentDOM) throw new RangeError("Multiple content holes") 233 | contentDOM = innerContent as HTMLElement 234 | } 235 | } 236 | } 237 | return {dom, contentDOM} 238 | } 239 | -------------------------------------------------------------------------------- /src/fragment.ts: -------------------------------------------------------------------------------- 1 | import {findDiffStart, findDiffEnd} from "./diff" 2 | import {Node, TextNode} from "./node" 3 | import {Schema} from "./schema" 4 | 5 | /// A fragment represents a node's collection of child nodes. 6 | /// 7 | /// Like nodes, fragments are persistent data structures, and you 8 | /// should not mutate them or their content. Rather, you create new 9 | /// instances whenever needed. The API tries to make this easy. 10 | export class Fragment { 11 | /// The size of the fragment, which is the total of the size of 12 | /// its content nodes. 13 | readonly size: number 14 | 15 | /// @internal 16 | constructor( 17 | /// The child nodes in this fragment. 18 | readonly content: readonly Node[], 19 | size?: number 20 | ) { 21 | this.size = size || 0 22 | if (size == null) for (let i = 0; i < content.length; i++) 23 | this.size += content[i].nodeSize 24 | } 25 | 26 | /// Invoke a callback for all descendant nodes between the given two 27 | /// positions (relative to start of this fragment). Doesn't descend 28 | /// into a node when the callback returns `false`. 29 | nodesBetween(from: number, to: number, 30 | f: (node: Node, start: number, parent: Node | null, index: number) => boolean | void, 31 | nodeStart = 0, 32 | parent?: Node) { 33 | for (let i = 0, pos = 0; pos < to; i++) { 34 | let child = this.content[i], end = pos + child.nodeSize 35 | if (end > from && f(child, nodeStart + pos, parent || null, i) !== false && child.content.size) { 36 | let start = pos + 1 37 | child.nodesBetween(Math.max(0, from - start), 38 | Math.min(child.content.size, to - start), 39 | f, nodeStart + start) 40 | } 41 | pos = end 42 | } 43 | } 44 | 45 | /// Call the given callback for every descendant node. `pos` will be 46 | /// relative to the start of the fragment. The callback may return 47 | /// `false` to prevent traversal of a given node's children. 48 | descendants(f: (node: Node, pos: number, parent: Node | null, index: number) => boolean | void) { 49 | this.nodesBetween(0, this.size, f) 50 | } 51 | 52 | /// Extract the text between `from` and `to`. See the same method on 53 | /// [`Node`](#model.Node.textBetween). 54 | textBetween(from: number, to: number, blockSeparator?: string | null, leafText?: string | null | ((leafNode: Node) => string)) { 55 | let text = "", first = true 56 | this.nodesBetween(from, to, (node, pos) => { 57 | let nodeText = node.isText ? node.text!.slice(Math.max(from, pos) - pos, to - pos) 58 | : !node.isLeaf ? "" 59 | : leafText ? (typeof leafText === "function" ? leafText(node) : leafText) 60 | : node.type.spec.leafText ? node.type.spec.leafText(node) 61 | : "" 62 | if (node.isBlock && (node.isLeaf && nodeText || node.isTextblock) && blockSeparator) { 63 | if (first) first = false 64 | else text += blockSeparator 65 | } 66 | text += nodeText 67 | }, 0) 68 | return text 69 | } 70 | 71 | /// Create a new fragment containing the combined content of this 72 | /// fragment and the other. 73 | append(other: Fragment) { 74 | if (!other.size) return this 75 | if (!this.size) return other 76 | let last = this.lastChild!, first = other.firstChild!, content = this.content.slice(), i = 0 77 | if (last.isText && last.sameMarkup(first)) { 78 | content[content.length - 1] = (last as TextNode).withText(last.text! + first.text!) 79 | i = 1 80 | } 81 | for (; i < other.content.length; i++) content.push(other.content[i]) 82 | return new Fragment(content, this.size + other.size) 83 | } 84 | 85 | /// Cut out the sub-fragment between the two given positions. 86 | cut(from: number, to = this.size) { 87 | if (from == 0 && to == this.size) return this 88 | let result: Node[] = [], size = 0 89 | if (to > from) for (let i = 0, pos = 0; pos < to; i++) { 90 | let child = this.content[i], end = pos + child.nodeSize 91 | if (end > from) { 92 | if (pos < from || end > to) { 93 | if (child.isText) 94 | child = child.cut(Math.max(0, from - pos), Math.min(child.text!.length, to - pos)) 95 | else 96 | child = child.cut(Math.max(0, from - pos - 1), Math.min(child.content.size, to - pos - 1)) 97 | } 98 | result.push(child) 99 | size += child.nodeSize 100 | } 101 | pos = end 102 | } 103 | return new Fragment(result, size) 104 | } 105 | 106 | /// @internal 107 | cutByIndex(from: number, to: number) { 108 | if (from == to) return Fragment.empty 109 | if (from == 0 && to == this.content.length) return this 110 | return new Fragment(this.content.slice(from, to)) 111 | } 112 | 113 | /// Create a new fragment in which the node at the given index is 114 | /// replaced by the given node. 115 | replaceChild(index: number, node: Node) { 116 | let current = this.content[index] 117 | if (current == node) return this 118 | let copy = this.content.slice() 119 | let size = this.size + node.nodeSize - current.nodeSize 120 | copy[index] = node 121 | return new Fragment(copy, size) 122 | } 123 | 124 | /// Create a new fragment by prepending the given node to this 125 | /// fragment. 126 | addToStart(node: Node) { 127 | return new Fragment([node].concat(this.content), this.size + node.nodeSize) 128 | } 129 | 130 | /// Create a new fragment by appending the given node to this 131 | /// fragment. 132 | addToEnd(node: Node) { 133 | return new Fragment(this.content.concat(node), this.size + node.nodeSize) 134 | } 135 | 136 | /// Compare this fragment to another one. 137 | eq(other: Fragment): boolean { 138 | if (this.content.length != other.content.length) return false 139 | for (let i = 0; i < this.content.length; i++) 140 | if (!this.content[i].eq(other.content[i])) return false 141 | return true 142 | } 143 | 144 | /// The first child of the fragment, or `null` if it is empty. 145 | get firstChild(): Node | null { return this.content.length ? this.content[0] : null } 146 | 147 | /// The last child of the fragment, or `null` if it is empty. 148 | get lastChild(): Node | null { return this.content.length ? this.content[this.content.length - 1] : null } 149 | 150 | /// The number of child nodes in this fragment. 151 | get childCount() { return this.content.length } 152 | 153 | /// Get the child node at the given index. Raise an error when the 154 | /// index is out of range. 155 | child(index: number) { 156 | let found = this.content[index] 157 | if (!found) throw new RangeError("Index " + index + " out of range for " + this) 158 | return found 159 | } 160 | 161 | /// Get the child node at the given index, if it exists. 162 | maybeChild(index: number): Node | null { 163 | return this.content[index] || null 164 | } 165 | 166 | /// Call `f` for every child node, passing the node, its offset 167 | /// into this parent node, and its index. 168 | forEach(f: (node: Node, offset: number, index: number) => void) { 169 | for (let i = 0, p = 0; i < this.content.length; i++) { 170 | let child = this.content[i] 171 | f(child, p, i) 172 | p += child.nodeSize 173 | } 174 | } 175 | 176 | /// Find the first position at which this fragment and another 177 | /// fragment differ, or `null` if they are the same. 178 | findDiffStart(other: Fragment, pos = 0) { 179 | return findDiffStart(this, other, pos) 180 | } 181 | 182 | /// Find the first position, searching from the end, at which this 183 | /// fragment and the given fragment differ, or `null` if they are 184 | /// the same. Since this position will not be the same in both 185 | /// nodes, an object with two separate positions is returned. 186 | findDiffEnd(other: Fragment, pos = this.size, otherPos = other.size) { 187 | return findDiffEnd(this, other, pos, otherPos) 188 | } 189 | 190 | /// Find the index and inner offset corresponding to a given relative 191 | /// position in this fragment. The result object will be reused 192 | /// (overwritten) the next time the function is called. @internal 193 | findIndex(pos: number): {index: number, offset: number} { 194 | if (pos == 0) return retIndex(0, pos) 195 | if (pos == this.size) return retIndex(this.content.length, pos) 196 | if (pos > this.size || pos < 0) throw new RangeError(`Position ${pos} outside of fragment (${this})`) 197 | for (let i = 0, curPos = 0;; i++) { 198 | let cur = this.child(i), end = curPos + cur.nodeSize 199 | if (end >= pos) { 200 | if (end == pos) return retIndex(i + 1, end) 201 | return retIndex(i, curPos) 202 | } 203 | curPos = end 204 | } 205 | } 206 | 207 | /// Return a debugging string that describes this fragment. 208 | toString(): string { return "<" + this.toStringInner() + ">" } 209 | 210 | /// @internal 211 | toStringInner() { return this.content.join(", ") } 212 | 213 | /// Create a JSON-serializeable representation of this fragment. 214 | toJSON(): any { 215 | return this.content.length ? this.content.map(n => n.toJSON()) : null 216 | } 217 | 218 | /// Deserialize a fragment from its JSON representation. 219 | static fromJSON(schema: Schema, value: any) { 220 | if (!value) return Fragment.empty 221 | if (!Array.isArray(value)) throw new RangeError("Invalid input for Fragment.fromJSON") 222 | return new Fragment(value.map(schema.nodeFromJSON)) 223 | } 224 | 225 | /// Build a fragment from an array of nodes. Ensures that adjacent 226 | /// text nodes with the same marks are joined together. 227 | static fromArray(array: readonly Node[]) { 228 | if (!array.length) return Fragment.empty 229 | let joined: Node[] | undefined, size = 0 230 | for (let i = 0; i < array.length; i++) { 231 | let node = array[i] 232 | size += node.nodeSize 233 | if (i && node.isText && array[i - 1].sameMarkup(node)) { 234 | if (!joined) joined = array.slice(0, i) 235 | joined[joined.length - 1] = (node as TextNode) 236 | .withText((joined[joined.length - 1] as TextNode).text + (node as TextNode).text) 237 | } else if (joined) { 238 | joined.push(node) 239 | } 240 | } 241 | return new Fragment(joined || array, size) 242 | } 243 | 244 | /// Create a fragment from something that can be interpreted as a 245 | /// set of nodes. For `null`, it returns the empty fragment. For a 246 | /// fragment, the fragment itself. For a node or array of nodes, a 247 | /// fragment containing those nodes. 248 | static from(nodes?: Fragment | Node | readonly Node[] | null) { 249 | if (!nodes) return Fragment.empty 250 | if (nodes instanceof Fragment) return nodes 251 | if (Array.isArray(nodes)) return this.fromArray(nodes) 252 | if ((nodes as Node).attrs) return new Fragment([nodes as Node], (nodes as Node).nodeSize) 253 | throw new RangeError("Can not convert " + nodes + " to a Fragment" + 254 | ((nodes as any).nodesBetween ? " (looks like multiple versions of prosemirror-model were loaded)" : "")) 255 | } 256 | 257 | /// An empty fragment. Intended to be reused whenever a node doesn't 258 | /// contain anything (rather than allocating a new empty fragment for 259 | /// each leaf node). 260 | static empty: Fragment = new Fragment([], 0) 261 | } 262 | 263 | const found = {index: 0, offset: 0} 264 | function retIndex(index: number, offset: number) { 265 | found.index = index 266 | found.offset = offset 267 | return found 268 | } 269 | -------------------------------------------------------------------------------- /src/resolvedpos.ts: -------------------------------------------------------------------------------- 1 | import {Mark} from "./mark" 2 | import {Node} from "./node" 3 | 4 | /// You can [_resolve_](#model.Node.resolve) a position to get more 5 | /// information about it. Objects of this class represent such a 6 | /// resolved position, providing various pieces of context 7 | /// information, and some helper methods. 8 | /// 9 | /// Throughout this interface, methods that take an optional `depth` 10 | /// parameter will interpret undefined as `this.depth` and negative 11 | /// numbers as `this.depth + value`. 12 | export class ResolvedPos { 13 | /// The number of levels the parent node is from the root. If this 14 | /// position points directly into the root node, it is 0. If it 15 | /// points into a top-level paragraph, 1, and so on. 16 | depth: number 17 | 18 | /// @internal 19 | constructor( 20 | /// The position that was resolved. 21 | readonly pos: number, 22 | /// @internal 23 | readonly path: any[], 24 | /// The offset this position has into its parent node. 25 | readonly parentOffset: number 26 | ) { 27 | this.depth = path.length / 3 - 1 28 | } 29 | 30 | /// @internal 31 | resolveDepth(val: number | undefined | null) { 32 | if (val == null) return this.depth 33 | if (val < 0) return this.depth + val 34 | return val 35 | } 36 | 37 | /// The parent node that the position points into. Note that even if 38 | /// a position points into a text node, that node is not considered 39 | /// the parent—text nodes are ‘flat’ in this model, and have no content. 40 | get parent() { return this.node(this.depth) } 41 | 42 | /// The root node in which the position was resolved. 43 | get doc() { return this.node(0) } 44 | 45 | /// The ancestor node at the given level. `p.node(p.depth)` is the 46 | /// same as `p.parent`. 47 | node(depth?: number | null): Node { return this.path[this.resolveDepth(depth) * 3] } 48 | 49 | /// The index into the ancestor at the given level. If this points 50 | /// at the 3rd node in the 2nd paragraph on the top level, for 51 | /// example, `p.index(0)` is 1 and `p.index(1)` is 2. 52 | index(depth?: number | null): number { return this.path[this.resolveDepth(depth) * 3 + 1] } 53 | 54 | /// The index pointing after this position into the ancestor at the 55 | /// given level. 56 | indexAfter(depth?: number | null): number { 57 | depth = this.resolveDepth(depth) 58 | return this.index(depth) + (depth == this.depth && !this.textOffset ? 0 : 1) 59 | } 60 | 61 | /// The (absolute) position at the start of the node at the given 62 | /// level. 63 | start(depth?: number | null): number { 64 | depth = this.resolveDepth(depth) 65 | return depth == 0 ? 0 : this.path[depth * 3 - 1] + 1 66 | } 67 | 68 | /// The (absolute) position at the end of the node at the given 69 | /// level. 70 | end(depth?: number | null): number { 71 | depth = this.resolveDepth(depth) 72 | return this.start(depth) + this.node(depth).content.size 73 | } 74 | 75 | /// The (absolute) position directly before the wrapping node at the 76 | /// given level, or, when `depth` is `this.depth + 1`, the original 77 | /// position. 78 | before(depth?: number | null): number { 79 | depth = this.resolveDepth(depth) 80 | if (!depth) throw new RangeError("There is no position before the top-level node") 81 | return depth == this.depth + 1 ? this.pos : this.path[depth * 3 - 1] 82 | } 83 | 84 | /// The (absolute) position directly after the wrapping node at the 85 | /// given level, or the original position when `depth` is `this.depth + 1`. 86 | after(depth?: number | null): number { 87 | depth = this.resolveDepth(depth) 88 | if (!depth) throw new RangeError("There is no position after the top-level node") 89 | return depth == this.depth + 1 ? this.pos : this.path[depth * 3 - 1] + this.path[depth * 3].nodeSize 90 | } 91 | 92 | /// When this position points into a text node, this returns the 93 | /// distance between the position and the start of the text node. 94 | /// Will be zero for positions that point between nodes. 95 | get textOffset(): number { return this.pos - this.path[this.path.length - 1] } 96 | 97 | /// Get the node directly after the position, if any. If the position 98 | /// points into a text node, only the part of that node after the 99 | /// position is returned. 100 | get nodeAfter(): Node | null { 101 | let parent = this.parent, index = this.index(this.depth) 102 | if (index == parent.childCount) return null 103 | let dOff = this.pos - this.path[this.path.length - 1], child = parent.child(index) 104 | return dOff ? parent.child(index).cut(dOff) : child 105 | } 106 | 107 | /// Get the node directly before the position, if any. If the 108 | /// position points into a text node, only the part of that node 109 | /// before the position is returned. 110 | get nodeBefore(): Node | null { 111 | let index = this.index(this.depth) 112 | let dOff = this.pos - this.path[this.path.length - 1] 113 | if (dOff) return this.parent.child(index).cut(0, dOff) 114 | return index == 0 ? null : this.parent.child(index - 1) 115 | } 116 | 117 | /// Get the position at the given index in the parent node at the 118 | /// given depth (which defaults to `this.depth`). 119 | posAtIndex(index: number, depth?: number | null): number { 120 | depth = this.resolveDepth(depth) 121 | let node = this.path[depth * 3], pos = depth == 0 ? 0 : this.path[depth * 3 - 1] + 1 122 | for (let i = 0; i < index; i++) pos += node.child(i).nodeSize 123 | return pos 124 | } 125 | 126 | /// Get the marks at this position, factoring in the surrounding 127 | /// marks' [`inclusive`](#model.MarkSpec.inclusive) property. If the 128 | /// position is at the start of a non-empty node, the marks of the 129 | /// node after it (if any) are returned. 130 | marks(): readonly Mark[] { 131 | let parent = this.parent, index = this.index() 132 | 133 | // In an empty parent, return the empty array 134 | if (parent.content.size == 0) return Mark.none 135 | 136 | // When inside a text node, just return the text node's marks 137 | if (this.textOffset) return parent.child(index).marks 138 | 139 | let main = parent.maybeChild(index - 1), other = parent.maybeChild(index) 140 | // If the `after` flag is true of there is no node before, make 141 | // the node after this position the main reference. 142 | if (!main) { let tmp = main; main = other; other = tmp } 143 | 144 | // Use all marks in the main node, except those that have 145 | // `inclusive` set to false and are not present in the other node. 146 | let marks = main!.marks 147 | for (var i = 0; i < marks.length; i++) 148 | if (marks[i].type.spec.inclusive === false && (!other || !marks[i].isInSet(other.marks))) 149 | marks = marks[i--].removeFromSet(marks) 150 | 151 | return marks 152 | } 153 | 154 | /// Get the marks after the current position, if any, except those 155 | /// that are non-inclusive and not present at position `$end`. This 156 | /// is mostly useful for getting the set of marks to preserve after a 157 | /// deletion. Will return `null` if this position is at the end of 158 | /// its parent node or its parent node isn't a textblock (in which 159 | /// case no marks should be preserved). 160 | marksAcross($end: ResolvedPos): readonly Mark[] | null { 161 | let after = this.parent.maybeChild(this.index()) 162 | if (!after || !after.isInline) return null 163 | 164 | let marks = after.marks, next = $end.parent.maybeChild($end.index()) 165 | for (var i = 0; i < marks.length; i++) 166 | if (marks[i].type.spec.inclusive === false && (!next || !marks[i].isInSet(next.marks))) 167 | marks = marks[i--].removeFromSet(marks) 168 | return marks 169 | } 170 | 171 | /// The depth up to which this position and the given (non-resolved) 172 | /// position share the same parent nodes. 173 | sharedDepth(pos: number): number { 174 | for (let depth = this.depth; depth > 0; depth--) 175 | if (this.start(depth) <= pos && this.end(depth) >= pos) return depth 176 | return 0 177 | } 178 | 179 | /// Returns a range based on the place where this position and the 180 | /// given position diverge around block content. If both point into 181 | /// the same textblock, for example, a range around that textblock 182 | /// will be returned. If they point into different blocks, the range 183 | /// around those blocks in their shared ancestor is returned. You can 184 | /// pass in an optional predicate that will be called with a parent 185 | /// node to see if a range into that parent is acceptable. 186 | blockRange(other: ResolvedPos = this, pred?: (node: Node) => boolean): NodeRange | null { 187 | if (other.pos < this.pos) return other.blockRange(this) 188 | for (let d = this.depth - (this.parent.inlineContent || this.pos == other.pos ? 1 : 0); d >= 0; d--) 189 | if (other.pos <= this.end(d) && (!pred || pred(this.node(d)))) 190 | return new NodeRange(this, other, d) 191 | return null 192 | } 193 | 194 | /// Query whether the given position shares the same parent node. 195 | sameParent(other: ResolvedPos): boolean { 196 | return this.pos - this.parentOffset == other.pos - other.parentOffset 197 | } 198 | 199 | /// Return the greater of this and the given position. 200 | max(other: ResolvedPos): ResolvedPos { 201 | return other.pos > this.pos ? other : this 202 | } 203 | 204 | /// Return the smaller of this and the given position. 205 | min(other: ResolvedPos): ResolvedPos { 206 | return other.pos < this.pos ? other : this 207 | } 208 | 209 | /// @internal 210 | toString() { 211 | let str = "" 212 | for (let i = 1; i <= this.depth; i++) 213 | str += (str ? "/" : "") + this.node(i).type.name + "_" + this.index(i - 1) 214 | return str + ":" + this.parentOffset 215 | } 216 | 217 | /// @internal 218 | static resolve(doc: Node, pos: number): ResolvedPos { 219 | if (!(pos >= 0 && pos <= doc.content.size)) throw new RangeError("Position " + pos + " out of range") 220 | let path: Array = [] 221 | let start = 0, parentOffset = pos 222 | for (let node = doc;;) { 223 | let {index, offset} = node.content.findIndex(parentOffset) 224 | let rem = parentOffset - offset 225 | path.push(node, index, start + offset) 226 | if (!rem) break 227 | node = node.child(index) 228 | if (node.isText) break 229 | parentOffset = rem - 1 230 | start += offset + 1 231 | } 232 | return new ResolvedPos(pos, path, parentOffset) 233 | } 234 | 235 | /// @internal 236 | static resolveCached(doc: Node, pos: number): ResolvedPos { 237 | let cache = resolveCache.get(doc) 238 | if (cache) { 239 | for (let i = 0; i < cache.elts.length; i++) { 240 | let elt = cache.elts[i] 241 | if (elt.pos == pos) return elt 242 | } 243 | } else { 244 | resolveCache.set(doc, cache = new ResolveCache) 245 | } 246 | let result = cache.elts[cache.i] = ResolvedPos.resolve(doc, pos) 247 | cache.i = (cache.i + 1) % resolveCacheSize 248 | return result 249 | } 250 | } 251 | 252 | class ResolveCache { 253 | elts: ResolvedPos[] = [] 254 | i = 0 255 | } 256 | 257 | const resolveCacheSize = 12, resolveCache = new WeakMap() 258 | 259 | /// Represents a flat range of content, i.e. one that starts and 260 | /// ends in the same node. 261 | export class NodeRange { 262 | /// Construct a node range. `$from` and `$to` should point into the 263 | /// same node until at least the given `depth`, since a node range 264 | /// denotes an adjacent set of nodes in a single parent node. 265 | constructor( 266 | /// A resolved position along the start of the content. May have a 267 | /// `depth` greater than this object's `depth` property, since 268 | /// these are the positions that were used to compute the range, 269 | /// not re-resolved positions directly at its boundaries. 270 | readonly $from: ResolvedPos, 271 | /// A position along the end of the content. See 272 | /// caveat for [`$from`](#model.NodeRange.$from). 273 | readonly $to: ResolvedPos, 274 | /// The depth of the node that this range points into. 275 | readonly depth: number 276 | ) {} 277 | 278 | /// The position at the start of the range. 279 | get start() { return this.$from.before(this.depth + 1) } 280 | /// The position at the end of the range. 281 | get end() { return this.$to.after(this.depth + 1) } 282 | 283 | /// The parent node that the range points into. 284 | get parent() { return this.$from.node(this.depth) } 285 | /// The start index of the range in the parent node. 286 | get startIndex() { return this.$from.index(this.depth) } 287 | /// The end index of the range in the parent node. 288 | get endIndex() { return this.$to.indexAfter(this.depth) } 289 | } 290 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import {Fragment} from "./fragment" 2 | import {NodeType} from "./schema" 3 | 4 | type MatchEdge = {type: NodeType, next: ContentMatch} 5 | 6 | /// Instances of this class represent a match state of a node type's 7 | /// [content expression](#model.NodeSpec.content), and can be used to 8 | /// find out whether further content matches here, and whether a given 9 | /// position is a valid end of the node. 10 | export class ContentMatch { 11 | /// @internal 12 | readonly next: MatchEdge[] = [] 13 | /// @internal 14 | readonly wrapCache: (NodeType | readonly NodeType[] | null)[] = [] 15 | 16 | /// @internal 17 | constructor( 18 | /// True when this match state represents a valid end of the node. 19 | readonly validEnd: boolean 20 | ) {} 21 | 22 | /// @internal 23 | static parse(string: string, nodeTypes: {readonly [name: string]: NodeType}): ContentMatch { 24 | let stream = new TokenStream(string, nodeTypes) 25 | if (stream.next == null) return ContentMatch.empty 26 | let expr = parseExpr(stream) 27 | if (stream.next) stream.err("Unexpected trailing text") 28 | let match = dfa(nfa(expr)) 29 | checkForDeadEnds(match, stream) 30 | return match 31 | } 32 | 33 | /// Match a node type, returning a match after that node if 34 | /// successful. 35 | matchType(type: NodeType): ContentMatch | null { 36 | for (let i = 0; i < this.next.length; i++) 37 | if (this.next[i].type == type) return this.next[i].next 38 | return null 39 | } 40 | 41 | /// Try to match a fragment. Returns the resulting match when 42 | /// successful. 43 | matchFragment(frag: Fragment, start = 0, end = frag.childCount): ContentMatch | null { 44 | let cur: ContentMatch | null = this 45 | for (let i = start; cur && i < end; i++) 46 | cur = cur.matchType(frag.child(i).type) 47 | return cur 48 | } 49 | 50 | /// @internal 51 | get inlineContent() { 52 | return this.next.length != 0 && this.next[0].type.isInline 53 | } 54 | 55 | /// Get the first matching node type at this match position that can 56 | /// be generated. 57 | get defaultType(): NodeType | null { 58 | for (let i = 0; i < this.next.length; i++) { 59 | let {type} = this.next[i] 60 | if (!(type.isText || type.hasRequiredAttrs())) return type 61 | } 62 | return null 63 | } 64 | 65 | /// @internal 66 | compatible(other: ContentMatch) { 67 | for (let i = 0; i < this.next.length; i++) 68 | for (let j = 0; j < other.next.length; j++) 69 | if (this.next[i].type == other.next[j].type) return true 70 | return false 71 | } 72 | 73 | /// Try to match the given fragment, and if that fails, see if it can 74 | /// be made to match by inserting nodes in front of it. When 75 | /// successful, return a fragment of inserted nodes (which may be 76 | /// empty if nothing had to be inserted). When `toEnd` is true, only 77 | /// return a fragment if the resulting match goes to the end of the 78 | /// content expression. 79 | fillBefore(after: Fragment, toEnd = false, startIndex = 0): Fragment | null { 80 | let seen: ContentMatch[] = [this] 81 | function search(match: ContentMatch, types: readonly NodeType[]): Fragment | null { 82 | let finished = match.matchFragment(after, startIndex) 83 | if (finished && (!toEnd || finished.validEnd)) 84 | return Fragment.from(types.map(tp => tp.createAndFill()!)) 85 | 86 | for (let i = 0; i < match.next.length; i++) { 87 | let {type, next} = match.next[i] 88 | if (!(type.isText || type.hasRequiredAttrs()) && seen.indexOf(next) == -1) { 89 | seen.push(next) 90 | let found = search(next, types.concat(type)) 91 | if (found) return found 92 | } 93 | } 94 | return null 95 | } 96 | 97 | return search(this, []) 98 | } 99 | 100 | /// Find a set of wrapping node types that would allow a node of the 101 | /// given type to appear at this position. The result may be empty 102 | /// (when it fits directly) and will be null when no such wrapping 103 | /// exists. 104 | findWrapping(target: NodeType): readonly NodeType[] | null { 105 | for (let i = 0; i < this.wrapCache.length; i += 2) 106 | if (this.wrapCache[i] == target) return this.wrapCache[i + 1] as (readonly NodeType[] | null) 107 | let computed = this.computeWrapping(target) 108 | this.wrapCache.push(target, computed) 109 | return computed 110 | } 111 | 112 | /// @internal 113 | computeWrapping(target: NodeType): readonly NodeType[] | null { 114 | type Active = {match: ContentMatch, type: NodeType | null, via: Active | null} 115 | let seen = Object.create(null), active: Active[] = [{match: this, type: null, via: null}] 116 | while (active.length) { 117 | let current = active.shift()!, match = current.match 118 | if (match.matchType(target)) { 119 | let result: NodeType[] = [] 120 | for (let obj: Active = current; obj.type; obj = obj.via!) 121 | result.push(obj.type) 122 | return result.reverse() 123 | } 124 | for (let i = 0; i < match.next.length; i++) { 125 | let {type, next} = match.next[i] 126 | if (!type.isLeaf && !type.hasRequiredAttrs() && !(type.name in seen) && (!current.type || next.validEnd)) { 127 | active.push({match: type.contentMatch, type, via: current}) 128 | seen[type.name] = true 129 | } 130 | } 131 | } 132 | return null 133 | } 134 | 135 | /// The number of outgoing edges this node has in the finite 136 | /// automaton that describes the content expression. 137 | get edgeCount() { 138 | return this.next.length 139 | } 140 | 141 | /// Get the _n_​th outgoing edge from this node in the finite 142 | /// automaton that describes the content expression. 143 | edge(n: number): MatchEdge { 144 | if (n >= this.next.length) throw new RangeError(`There's no ${n}th edge in this content match`) 145 | return this.next[n] 146 | } 147 | 148 | /// @internal 149 | toString() { 150 | let seen: ContentMatch[] = [] 151 | function scan(m: ContentMatch) { 152 | seen.push(m) 153 | for (let i = 0; i < m.next.length; i++) 154 | if (seen.indexOf(m.next[i].next) == -1) scan(m.next[i].next) 155 | } 156 | scan(this) 157 | return seen.map((m, i) => { 158 | let out = i + (m.validEnd ? "*" : " ") + " " 159 | for (let i = 0; i < m.next.length; i++) 160 | out += (i ? ", " : "") + m.next[i].type.name + "->" + seen.indexOf(m.next[i].next) 161 | return out 162 | }).join("\n") 163 | } 164 | 165 | /// @internal 166 | static empty = new ContentMatch(true) 167 | } 168 | 169 | class TokenStream { 170 | inline: boolean | null = null 171 | pos = 0 172 | tokens: string[] 173 | 174 | constructor( 175 | readonly string: string, 176 | readonly nodeTypes: {readonly [name: string]: NodeType} 177 | ) { 178 | this.tokens = string.split(/\s*(?=\b|\W|$)/) 179 | if (this.tokens[this.tokens.length - 1] == "") this.tokens.pop() 180 | if (this.tokens[0] == "") this.tokens.shift() 181 | } 182 | 183 | get next() { return this.tokens[this.pos] } 184 | 185 | eat(tok: string) { return this.next == tok && (this.pos++ || true) } 186 | 187 | err(str: string): never { throw new SyntaxError(str + " (in content expression '" + this.string + "')") } 188 | } 189 | 190 | type Expr = 191 | {type: "choice", exprs: Expr[]} | 192 | {type: "seq", exprs: Expr[]} | 193 | {type: "plus", expr: Expr} | 194 | {type: "star", expr: Expr} | 195 | {type: "opt", expr: Expr} | 196 | {type: "range", min: number, max: number, expr: Expr} | 197 | {type: "name", value: NodeType} 198 | 199 | function parseExpr(stream: TokenStream): Expr { 200 | let exprs: Expr[] = [] 201 | do { exprs.push(parseExprSeq(stream)) } 202 | while (stream.eat("|")) 203 | return exprs.length == 1 ? exprs[0] : {type: "choice", exprs} 204 | } 205 | 206 | function parseExprSeq(stream: TokenStream): Expr { 207 | let exprs: Expr[] = [] 208 | do { exprs.push(parseExprSubscript(stream)) } 209 | while (stream.next && stream.next != ")" && stream.next != "|") 210 | return exprs.length == 1 ? exprs[0] : {type: "seq", exprs} 211 | } 212 | 213 | function parseExprSubscript(stream: TokenStream): Expr { 214 | let expr = parseExprAtom(stream) 215 | for (;;) { 216 | if (stream.eat("+")) 217 | expr = {type: "plus", expr} 218 | else if (stream.eat("*")) 219 | expr = {type: "star", expr} 220 | else if (stream.eat("?")) 221 | expr = {type: "opt", expr} 222 | else if (stream.eat("{")) 223 | expr = parseExprRange(stream, expr) 224 | else break 225 | } 226 | return expr 227 | } 228 | 229 | function parseNum(stream: TokenStream) { 230 | if (/\D/.test(stream.next)) stream.err("Expected number, got '" + stream.next + "'") 231 | let result = Number(stream.next) 232 | stream.pos++ 233 | return result 234 | } 235 | 236 | function parseExprRange(stream: TokenStream, expr: Expr): Expr { 237 | let min = parseNum(stream), max = min 238 | if (stream.eat(",")) { 239 | if (stream.next != "}") max = parseNum(stream) 240 | else max = -1 241 | } 242 | if (!stream.eat("}")) stream.err("Unclosed braced range") 243 | return {type: "range", min, max, expr} 244 | } 245 | 246 | function resolveName(stream: TokenStream, name: string): readonly NodeType[] { 247 | let types = stream.nodeTypes, type = types[name] 248 | if (type) return [type] 249 | let result: NodeType[] = [] 250 | for (let typeName in types) { 251 | let type = types[typeName] 252 | if (type.isInGroup(name)) result.push(type) 253 | } 254 | if (result.length == 0) stream.err("No node type or group '" + name + "' found") 255 | return result 256 | } 257 | 258 | function parseExprAtom(stream: TokenStream): Expr { 259 | if (stream.eat("(")) { 260 | let expr = parseExpr(stream) 261 | if (!stream.eat(")")) stream.err("Missing closing paren") 262 | return expr 263 | } else if (!/\W/.test(stream.next)) { 264 | let exprs = resolveName(stream, stream.next).map(type => { 265 | if (stream.inline == null) stream.inline = type.isInline 266 | else if (stream.inline != type.isInline) stream.err("Mixing inline and block content") 267 | return {type: "name", value: type} as Expr 268 | }) 269 | stream.pos++ 270 | return exprs.length == 1 ? exprs[0] : {type: "choice", exprs} 271 | } else { 272 | stream.err("Unexpected token '" + stream.next + "'") 273 | } 274 | } 275 | 276 | // The code below helps compile a regular-expression-like language 277 | // into a deterministic finite automaton. For a good introduction to 278 | // these concepts, see https://swtch.com/~rsc/regexp/regexp1.html 279 | 280 | type Edge = {term: NodeType | undefined, to: number | undefined} 281 | 282 | // Construct an NFA from an expression as returned by the parser. The 283 | // NFA is represented as an array of states, which are themselves 284 | // arrays of edges, which are `{term, to}` objects. The first state is 285 | // the entry state and the last node is the success state. 286 | // 287 | // Note that unlike typical NFAs, the edge ordering in this one is 288 | // significant, in that it is used to contruct filler content when 289 | // necessary. 290 | function nfa(expr: Expr): Edge[][] { 291 | let nfa: Edge[][] = [[]] 292 | connect(compile(expr, 0), node()) 293 | return nfa 294 | 295 | function node() { return nfa.push([]) - 1 } 296 | function edge(from: number, to?: number, term?: NodeType) { 297 | let edge = {term, to} 298 | nfa[from].push(edge) 299 | return edge 300 | } 301 | function connect(edges: Edge[], to: number) { 302 | edges.forEach(edge => edge.to = to) 303 | } 304 | 305 | function compile(expr: Expr, from: number): Edge[] { 306 | if (expr.type == "choice") { 307 | return expr.exprs.reduce((out, expr) => out.concat(compile(expr, from)), [] as Edge[]) 308 | } else if (expr.type == "seq") { 309 | for (let i = 0;; i++) { 310 | let next = compile(expr.exprs[i], from) 311 | if (i == expr.exprs.length - 1) return next 312 | connect(next, from = node()) 313 | } 314 | } else if (expr.type == "star") { 315 | let loop = node() 316 | edge(from, loop) 317 | connect(compile(expr.expr, loop), loop) 318 | return [edge(loop)] 319 | } else if (expr.type == "plus") { 320 | let loop = node() 321 | connect(compile(expr.expr, from), loop) 322 | connect(compile(expr.expr, loop), loop) 323 | return [edge(loop)] 324 | } else if (expr.type == "opt") { 325 | return [edge(from)].concat(compile(expr.expr, from)) 326 | } else if (expr.type == "range") { 327 | let cur = from 328 | for (let i = 0; i < expr.min; i++) { 329 | let next = node() 330 | connect(compile(expr.expr, cur), next) 331 | cur = next 332 | } 333 | if (expr.max == -1) { 334 | connect(compile(expr.expr, cur), cur) 335 | } else { 336 | for (let i = expr.min; i < expr.max; i++) { 337 | let next = node() 338 | edge(cur, next) 339 | connect(compile(expr.expr, cur), next) 340 | cur = next 341 | } 342 | } 343 | return [edge(cur)] 344 | } else if (expr.type == "name") { 345 | return [edge(from, undefined, expr.value)] 346 | } else { 347 | throw new Error("Unknown expr type") 348 | } 349 | } 350 | } 351 | 352 | function cmp(a: number, b: number) { return b - a } 353 | 354 | // Get the set of nodes reachable by null edges from `node`. Omit 355 | // nodes with only a single null-out-edge, since they may lead to 356 | // needless duplicated nodes. 357 | function nullFrom(nfa: Edge[][], node: number): readonly number[] { 358 | let result: number[] = [] 359 | scan(node) 360 | return result.sort(cmp) 361 | 362 | function scan(node: number): void { 363 | let edges = nfa[node] 364 | if (edges.length == 1 && !edges[0].term) return scan(edges[0].to!) 365 | result.push(node) 366 | for (let i = 0; i < edges.length; i++) { 367 | let {term, to} = edges[i] 368 | if (!term && result.indexOf(to!) == -1) scan(to!) 369 | } 370 | } 371 | } 372 | 373 | // Compiles an NFA as produced by `nfa` into a DFA, modeled as a set 374 | // of state objects (`ContentMatch` instances) with transitions 375 | // between them. 376 | function dfa(nfa: Edge[][]): ContentMatch { 377 | let labeled = Object.create(null) 378 | return explore(nullFrom(nfa, 0)) 379 | 380 | function explore(states: readonly number[]) { 381 | let out: [NodeType, number[]][] = [] 382 | states.forEach(node => { 383 | nfa[node].forEach(({term, to}) => { 384 | if (!term) return 385 | let set: number[] | undefined 386 | for (let i = 0; i < out.length; i++) if (out[i][0] == term) set = out[i][1] 387 | nullFrom(nfa, to!).forEach(node => { 388 | if (!set) out.push([term, set = []]) 389 | if (set.indexOf(node) == -1) set.push(node) 390 | }) 391 | }) 392 | }) 393 | let state = labeled[states.join(",")] = new ContentMatch(states.indexOf(nfa.length - 1) > -1) 394 | for (let i = 0; i < out.length; i++) { 395 | let states = out[i][1].sort(cmp) 396 | state.next.push({type: out[i][0], next: labeled[states.join(",")] || explore(states)}) 397 | } 398 | return state 399 | } 400 | } 401 | 402 | function checkForDeadEnds(match: ContentMatch, stream: TokenStream) { 403 | for (let i = 0, work = [match]; i < work.length; i++) { 404 | let state = work[i], dead = !state.validEnd, nodes: string[] = [] 405 | for (let j = 0; j < state.next.length; j++) { 406 | let {type, next} = state.next[j] 407 | nodes.push(type.name) 408 | if (dead && !(type.isText || type.hasRequiredAttrs())) dead = false 409 | if (work.indexOf(next) == -1) work.push(next) 410 | } 411 | if (dead) stream.err("Only non-generatable nodes (" + nodes.join(", ") + ") in a required position (see https://prosemirror.net/docs/guide/#generatable)") 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import {Fragment} from "./fragment" 2 | import {Mark} from "./mark" 3 | import {Schema, NodeType, Attrs, MarkType} from "./schema" 4 | import {Slice, replace} from "./replace" 5 | import {ResolvedPos} from "./resolvedpos" 6 | import {compareDeep} from "./comparedeep" 7 | 8 | const emptyAttrs: Attrs = Object.create(null) 9 | 10 | /// This class represents a node in the tree that makes up a 11 | /// ProseMirror document. So a document is an instance of `Node`, with 12 | /// children that are also instances of `Node`. 13 | /// 14 | /// Nodes are persistent data structures. Instead of changing them, you 15 | /// create new ones with the content you want. Old ones keep pointing 16 | /// at the old document shape. This is made cheaper by sharing 17 | /// structure between the old and new data as much as possible, which a 18 | /// tree shape like this (without back pointers) makes easy. 19 | /// 20 | /// **Do not** directly mutate the properties of a `Node` object. See 21 | /// [the guide](/docs/guide/#doc) for more information. 22 | export class Node { 23 | /// @internal 24 | constructor( 25 | /// The type of node that this is. 26 | readonly type: NodeType, 27 | /// An object mapping attribute names to values. The kind of 28 | /// attributes allowed and required are 29 | /// [determined](#model.NodeSpec.attrs) by the node type. 30 | readonly attrs: Attrs, 31 | // A fragment holding the node's children. 32 | content?: Fragment | null, 33 | /// The marks (things like whether it is emphasized or part of a 34 | /// link) applied to this node. 35 | readonly marks = Mark.none 36 | ) { 37 | this.content = content || Fragment.empty 38 | } 39 | 40 | /// A container holding the node's children. 41 | readonly content: Fragment 42 | 43 | /// The array of this node's child nodes. 44 | get children() { return this.content.content } 45 | 46 | /// For text nodes, this contains the node's text content. 47 | readonly text: string | undefined 48 | 49 | /// The size of this node, as defined by the integer-based [indexing 50 | /// scheme](/docs/guide/#doc.indexing). For text nodes, this is the 51 | /// amount of characters. For other leaf nodes, it is one. For 52 | /// non-leaf nodes, it is the size of the content plus two (the 53 | /// start and end token). 54 | get nodeSize(): number { return this.isLeaf ? 1 : 2 + this.content.size } 55 | 56 | /// The number of children that the node has. 57 | get childCount() { return this.content.childCount } 58 | 59 | /// Get the child node at the given index. Raises an error when the 60 | /// index is out of range. 61 | child(index: number) { return this.content.child(index) } 62 | 63 | /// Get the child node at the given index, if it exists. 64 | maybeChild(index: number) { return this.content.maybeChild(index) } 65 | 66 | /// Call `f` for every child node, passing the node, its offset 67 | /// into this parent node, and its index. 68 | forEach(f: (node: Node, offset: number, index: number) => void) { this.content.forEach(f) } 69 | 70 | /// Invoke a callback for all descendant nodes recursively between 71 | /// the given two positions that are relative to start of this 72 | /// node's content. The callback is invoked with the node, its 73 | /// position relative to the original node (method receiver), 74 | /// its parent node, and its child index. When the callback returns 75 | /// false for a given node, that node's children will not be 76 | /// recursed over. The last parameter can be used to specify a 77 | /// starting position to count from. 78 | nodesBetween(from: number, to: number, 79 | f: (node: Node, pos: number, parent: Node | null, index: number) => void | boolean, 80 | startPos = 0) { 81 | this.content.nodesBetween(from, to, f, startPos, this) 82 | } 83 | 84 | /// Call the given callback for every descendant node. Doesn't 85 | /// descend into a node when the callback returns `false`. 86 | descendants(f: (node: Node, pos: number, parent: Node | null, index: number) => void | boolean) { 87 | this.nodesBetween(0, this.content.size, f) 88 | } 89 | 90 | /// Concatenates all the text nodes found in this fragment and its 91 | /// children. 92 | get textContent() { 93 | return (this.isLeaf && this.type.spec.leafText) 94 | ? this.type.spec.leafText(this) 95 | : this.textBetween(0, this.content.size, "") 96 | } 97 | 98 | /// Get all text between positions `from` and `to`. When 99 | /// `blockSeparator` is given, it will be inserted to separate text 100 | /// from different block nodes. If `leafText` is given, it'll be 101 | /// inserted for every non-text leaf node encountered, otherwise 102 | /// [`leafText`](#model.NodeSpec.leafText) will be used. 103 | textBetween(from: number, to: number, blockSeparator?: string | null, 104 | leafText?: null | string | ((leafNode: Node) => string)) { 105 | return this.content.textBetween(from, to, blockSeparator, leafText) 106 | } 107 | 108 | /// Returns this node's first child, or `null` if there are no 109 | /// children. 110 | get firstChild(): Node | null { return this.content.firstChild } 111 | 112 | /// Returns this node's last child, or `null` if there are no 113 | /// children. 114 | get lastChild(): Node | null { return this.content.lastChild } 115 | 116 | /// Test whether two nodes represent the same piece of document. 117 | eq(other: Node) { 118 | return this == other || (this.sameMarkup(other) && this.content.eq(other.content)) 119 | } 120 | 121 | /// Compare the markup (type, attributes, and marks) of this node to 122 | /// those of another. Returns `true` if both have the same markup. 123 | sameMarkup(other: Node) { 124 | return this.hasMarkup(other.type, other.attrs, other.marks) 125 | } 126 | 127 | /// Check whether this node's markup correspond to the given type, 128 | /// attributes, and marks. 129 | hasMarkup(type: NodeType, attrs?: Attrs | null, marks?: readonly Mark[]): boolean { 130 | return this.type == type && 131 | compareDeep(this.attrs, attrs || type.defaultAttrs || emptyAttrs) && 132 | Mark.sameSet(this.marks, marks || Mark.none) 133 | } 134 | 135 | /// Create a new node with the same markup as this node, containing 136 | /// the given content (or empty, if no content is given). 137 | copy(content: Fragment | null = null): Node { 138 | if (content == this.content) return this 139 | return new Node(this.type, this.attrs, content, this.marks) 140 | } 141 | 142 | /// Create a copy of this node, with the given set of marks instead 143 | /// of the node's own marks. 144 | mark(marks: readonly Mark[]): Node { 145 | return marks == this.marks ? this : new Node(this.type, this.attrs, this.content, marks) 146 | } 147 | 148 | /// Create a copy of this node with only the content between the 149 | /// given positions. If `to` is not given, it defaults to the end of 150 | /// the node. 151 | cut(from: number, to: number = this.content.size): Node { 152 | if (from == 0 && to == this.content.size) return this 153 | return this.copy(this.content.cut(from, to)) 154 | } 155 | 156 | /// Cut out the part of the document between the given positions, and 157 | /// return it as a `Slice` object. 158 | slice(from: number, to: number = this.content.size, includeParents = false) { 159 | if (from == to) return Slice.empty 160 | 161 | let $from = this.resolve(from), $to = this.resolve(to) 162 | let depth = includeParents ? 0 : $from.sharedDepth(to) 163 | let start = $from.start(depth), node = $from.node(depth) 164 | let content = node.content.cut($from.pos - start, $to.pos - start) 165 | return new Slice(content, $from.depth - depth, $to.depth - depth) 166 | } 167 | 168 | /// Replace the part of the document between the given positions with 169 | /// the given slice. The slice must 'fit', meaning its open sides 170 | /// must be able to connect to the surrounding content, and its 171 | /// content nodes must be valid children for the node they are placed 172 | /// into. If any of this is violated, an error of type 173 | /// [`ReplaceError`](#model.ReplaceError) is thrown. 174 | replace(from: number, to: number, slice: Slice) { 175 | return replace(this.resolve(from), this.resolve(to), slice) 176 | } 177 | 178 | /// Find the node directly after the given position. 179 | nodeAt(pos: number): Node | null { 180 | for (let node: Node | null = this;;) { 181 | let {index, offset} = node.content.findIndex(pos) 182 | node = node.maybeChild(index) 183 | if (!node) return null 184 | if (offset == pos || node.isText) return node 185 | pos -= offset + 1 186 | } 187 | } 188 | 189 | /// Find the (direct) child node after the given offset, if any, 190 | /// and return it along with its index and offset relative to this 191 | /// node. 192 | childAfter(pos: number): {node: Node | null, index: number, offset: number} { 193 | let {index, offset} = this.content.findIndex(pos) 194 | return {node: this.content.maybeChild(index), index, offset} 195 | } 196 | 197 | /// Find the (direct) child node before the given offset, if any, 198 | /// and return it along with its index and offset relative to this 199 | /// node. 200 | childBefore(pos: number): {node: Node | null, index: number, offset: number} { 201 | if (pos == 0) return {node: null, index: 0, offset: 0} 202 | let {index, offset} = this.content.findIndex(pos) 203 | if (offset < pos) return {node: this.content.child(index), index, offset} 204 | let node = this.content.child(index - 1) 205 | return {node, index: index - 1, offset: offset - node.nodeSize} 206 | } 207 | 208 | /// Resolve the given position in the document, returning an 209 | /// [object](#model.ResolvedPos) with information about its context. 210 | resolve(pos: number) { return ResolvedPos.resolveCached(this, pos) } 211 | 212 | /// @internal 213 | resolveNoCache(pos: number) { return ResolvedPos.resolve(this, pos) } 214 | 215 | /// Test whether a given mark or mark type occurs in this document 216 | /// between the two given positions. 217 | rangeHasMark(from: number, to: number, type: Mark | MarkType): boolean { 218 | let found = false 219 | if (to > from) this.nodesBetween(from, to, node => { 220 | if (type.isInSet(node.marks)) found = true 221 | return !found 222 | }) 223 | return found 224 | } 225 | 226 | /// True when this is a block (non-inline node) 227 | get isBlock() { return this.type.isBlock } 228 | 229 | /// True when this is a textblock node, a block node with inline 230 | /// content. 231 | get isTextblock() { return this.type.isTextblock } 232 | 233 | /// True when this node allows inline content. 234 | get inlineContent() { return this.type.inlineContent } 235 | 236 | /// True when this is an inline node (a text node or a node that can 237 | /// appear among text). 238 | get isInline() { return this.type.isInline } 239 | 240 | /// True when this is a text node. 241 | get isText() { return this.type.isText } 242 | 243 | /// True when this is a leaf node. 244 | get isLeaf() { return this.type.isLeaf } 245 | 246 | /// True when this is an atom, i.e. when it does not have directly 247 | /// editable content. This is usually the same as `isLeaf`, but can 248 | /// be configured with the [`atom` property](#model.NodeSpec.atom) 249 | /// on a node's spec (typically used when the node is displayed as 250 | /// an uneditable [node view](#view.NodeView)). 251 | get isAtom() { return this.type.isAtom } 252 | 253 | /// Return a string representation of this node for debugging 254 | /// purposes. 255 | toString(): string { 256 | if (this.type.spec.toDebugString) return this.type.spec.toDebugString(this) 257 | let name = this.type.name 258 | if (this.content.size) 259 | name += "(" + this.content.toStringInner() + ")" 260 | return wrapMarks(this.marks, name) 261 | } 262 | 263 | /// Get the content match in this node at the given index. 264 | contentMatchAt(index: number) { 265 | let match = this.type.contentMatch.matchFragment(this.content, 0, index) 266 | if (!match) throw new Error("Called contentMatchAt on a node with invalid content") 267 | return match 268 | } 269 | 270 | /// Test whether replacing the range between `from` and `to` (by 271 | /// child index) with the given replacement fragment (which defaults 272 | /// to the empty fragment) would leave the node's content valid. You 273 | /// can optionally pass `start` and `end` indices into the 274 | /// replacement fragment. 275 | canReplace(from: number, to: number, replacement = Fragment.empty, start = 0, end = replacement.childCount) { 276 | let one = this.contentMatchAt(from).matchFragment(replacement, start, end) 277 | let two = one && one.matchFragment(this.content, to) 278 | if (!two || !two.validEnd) return false 279 | for (let i = start; i < end; i++) if (!this.type.allowsMarks(replacement.child(i).marks)) return false 280 | return true 281 | } 282 | 283 | /// Test whether replacing the range `from` to `to` (by index) with 284 | /// a node of the given type would leave the node's content valid. 285 | canReplaceWith(from: number, to: number, type: NodeType, marks?: readonly Mark[]) { 286 | if (marks && !this.type.allowsMarks(marks)) return false 287 | let start = this.contentMatchAt(from).matchType(type) 288 | let end = start && start.matchFragment(this.content, to) 289 | return end ? end.validEnd : false 290 | } 291 | 292 | /// Test whether the given node's content could be appended to this 293 | /// node. If that node is empty, this will only return true if there 294 | /// is at least one node type that can appear in both nodes (to avoid 295 | /// merging completely incompatible nodes). 296 | canAppend(other: Node) { 297 | if (other.content.size) return this.canReplace(this.childCount, this.childCount, other.content) 298 | else return this.type.compatibleContent(other.type) 299 | } 300 | 301 | /// Check whether this node and its descendants conform to the 302 | /// schema, and raise an exception when they do not. 303 | check() { 304 | this.type.checkContent(this.content) 305 | this.type.checkAttrs(this.attrs) 306 | let copy = Mark.none 307 | for (let i = 0; i < this.marks.length; i++) { 308 | let mark = this.marks[i] 309 | mark.type.checkAttrs(mark.attrs) 310 | copy = mark.addToSet(copy) 311 | } 312 | if (!Mark.sameSet(copy, this.marks)) 313 | throw new RangeError(`Invalid collection of marks for node ${this.type.name}: ${this.marks.map(m => m.type.name)}`) 314 | this.content.forEach(node => node.check()) 315 | } 316 | 317 | /// Return a JSON-serializeable representation of this node. 318 | toJSON(): any { 319 | let obj: any = {type: this.type.name} 320 | for (let _ in this.attrs) { 321 | obj.attrs = this.attrs 322 | break 323 | } 324 | if (this.content.size) 325 | obj.content = this.content.toJSON() 326 | if (this.marks.length) 327 | obj.marks = this.marks.map(n => n.toJSON()) 328 | return obj 329 | } 330 | 331 | /// Deserialize a node from its JSON representation. 332 | static fromJSON(schema: Schema, json: any): Node { 333 | if (!json) throw new RangeError("Invalid input for Node.fromJSON") 334 | let marks: Mark[] | undefined = undefined 335 | if (json.marks) { 336 | if (!Array.isArray(json.marks)) throw new RangeError("Invalid mark data for Node.fromJSON") 337 | marks = json.marks.map(schema.markFromJSON) 338 | } 339 | if (json.type == "text") { 340 | if (typeof json.text != "string") throw new RangeError("Invalid text node in JSON") 341 | return schema.text(json.text, marks) 342 | } 343 | let content = Fragment.fromJSON(schema, json.content) 344 | let node = schema.nodeType(json.type).create(json.attrs, content, marks) 345 | node.type.checkAttrs(node.attrs) 346 | return node 347 | } 348 | } 349 | 350 | ;(Node.prototype as any).text = undefined 351 | 352 | export class TextNode extends Node { 353 | readonly text: string 354 | 355 | /// @internal 356 | constructor(type: NodeType, attrs: Attrs, content: string, marks?: readonly Mark[]) { 357 | super(type, attrs, null, marks) 358 | if (!content) throw new RangeError("Empty text nodes are not allowed") 359 | this.text = content 360 | } 361 | 362 | toString() { 363 | if (this.type.spec.toDebugString) return this.type.spec.toDebugString(this) 364 | return wrapMarks(this.marks, JSON.stringify(this.text)) 365 | } 366 | 367 | get textContent() { return this.text } 368 | 369 | textBetween(from: number, to: number) { return this.text.slice(from, to) } 370 | 371 | get nodeSize() { return this.text.length } 372 | 373 | mark(marks: readonly Mark[]) { 374 | return marks == this.marks ? this : new TextNode(this.type, this.attrs, this.text, marks) 375 | } 376 | 377 | withText(text: string) { 378 | if (text == this.text) return this 379 | return new TextNode(this.type, this.attrs, text, this.marks) 380 | } 381 | 382 | cut(from = 0, to = this.text.length) { 383 | if (from == 0 && to == this.text.length) return this 384 | return this.withText(this.text.slice(from, to)) 385 | } 386 | 387 | eq(other: Node) { 388 | return this.sameMarkup(other) && this.text == other.text 389 | } 390 | 391 | toJSON() { 392 | let base = super.toJSON() 393 | base.text = this.text 394 | return base 395 | } 396 | } 397 | 398 | function wrapMarks(marks: readonly Mark[], str: string) { 399 | for (let i = marks.length - 1; i >= 0; i--) 400 | str = marks[i].type.name + "(" + str + ")" 401 | return str 402 | } 403 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import OrderedMap from "orderedmap" 2 | 3 | import {Node, TextNode} from "./node" 4 | import {Fragment} from "./fragment" 5 | import {Mark} from "./mark" 6 | import {ContentMatch} from "./content" 7 | import {DOMOutputSpec} from "./to_dom" 8 | import {ParseRule, TagParseRule} from "./from_dom" 9 | 10 | /// An object holding the attributes of a node. 11 | export type Attrs = {readonly [attr: string]: any} 12 | 13 | // For node types where all attrs have a default value (or which don't 14 | // have any attributes), build up a single reusable default attribute 15 | // object, and use it for all nodes that don't specify specific 16 | // attributes. 17 | function defaultAttrs(attrs: {[name: string]: Attribute}) { 18 | let defaults = Object.create(null) 19 | for (let attrName in attrs) { 20 | let attr = attrs[attrName] 21 | if (!attr.hasDefault) return null 22 | defaults[attrName] = attr.default 23 | } 24 | return defaults 25 | } 26 | 27 | function computeAttrs(attrs: {[name: string]: Attribute}, value: Attrs | null) { 28 | let built = Object.create(null) 29 | for (let name in attrs) { 30 | let given = value && value[name] 31 | if (given === undefined) { 32 | let attr = attrs[name] 33 | if (attr.hasDefault) given = attr.default 34 | else throw new RangeError("No value supplied for attribute " + name) 35 | } 36 | built[name] = given 37 | } 38 | return built 39 | } 40 | 41 | export function checkAttrs(attrs: {[name: string]: Attribute}, values: Attrs, type: string, name: string) { 42 | for (let name in values) 43 | if (!(name in attrs)) throw new RangeError(`Unsupported attribute ${name} for ${type} of type ${name}`) 44 | for (let name in attrs) { 45 | let attr = attrs[name] 46 | if (attr.validate) attr.validate(values[name]) 47 | } 48 | } 49 | 50 | function initAttrs(typeName: string, attrs?: {[name: string]: AttributeSpec}) { 51 | let result: {[name: string]: Attribute} = Object.create(null) 52 | if (attrs) for (let name in attrs) result[name] = new Attribute(typeName, name, attrs[name]) 53 | return result 54 | } 55 | 56 | /// Node types are objects allocated once per `Schema` and used to 57 | /// [tag](#model.Node.type) `Node` instances. They contain information 58 | /// about the node type, such as its name and what kind of node it 59 | /// represents. 60 | export class NodeType { 61 | /// @internal 62 | groups: readonly string[] 63 | /// @internal 64 | attrs: {[name: string]: Attribute} 65 | /// @internal 66 | defaultAttrs: Attrs 67 | 68 | /// @internal 69 | constructor( 70 | /// The name the node type has in this schema. 71 | readonly name: string, 72 | /// A link back to the `Schema` the node type belongs to. 73 | readonly schema: Schema, 74 | /// The spec that this type is based on 75 | readonly spec: NodeSpec 76 | ) { 77 | this.groups = spec.group ? spec.group.split(" ") : [] 78 | this.attrs = initAttrs(name, spec.attrs) 79 | this.defaultAttrs = defaultAttrs(this.attrs) 80 | 81 | // Filled in later 82 | ;(this as any).contentMatch = null 83 | ;(this as any).inlineContent = null 84 | 85 | this.isBlock = !(spec.inline || name == "text") 86 | this.isText = name == "text" 87 | } 88 | 89 | /// True if this node type has inline content. 90 | declare inlineContent: boolean 91 | /// True if this is a block type 92 | isBlock: boolean 93 | /// True if this is the text node type. 94 | isText: boolean 95 | 96 | /// True if this is an inline type. 97 | get isInline() { return !this.isBlock } 98 | 99 | /// True if this is a textblock type, a block that contains inline 100 | /// content. 101 | get isTextblock() { return this.isBlock && this.inlineContent } 102 | 103 | /// True for node types that allow no content. 104 | get isLeaf() { return this.contentMatch == ContentMatch.empty } 105 | 106 | /// True when this node is an atom, i.e. when it does not have 107 | /// directly editable content. 108 | get isAtom() { return this.isLeaf || !!this.spec.atom } 109 | 110 | /// Return true when this node type is part of the given 111 | /// [group](#model.NodeSpec.group). 112 | isInGroup(group: string) { 113 | return this.groups.indexOf(group) > -1 114 | } 115 | 116 | /// The starting match of the node type's content expression. 117 | declare contentMatch: ContentMatch 118 | 119 | /// The set of marks allowed in this node. `null` means all marks 120 | /// are allowed. 121 | markSet: readonly MarkType[] | null = null 122 | 123 | /// The node type's [whitespace](#model.NodeSpec.whitespace) option. 124 | get whitespace(): "pre" | "normal" { 125 | return this.spec.whitespace || (this.spec.code ? "pre" : "normal") 126 | } 127 | 128 | /// Tells you whether this node type has any required attributes. 129 | hasRequiredAttrs() { 130 | for (let n in this.attrs) if (this.attrs[n].isRequired) return true 131 | return false 132 | } 133 | 134 | /// Indicates whether this node allows some of the same content as 135 | /// the given node type. 136 | compatibleContent(other: NodeType) { 137 | return this == other || this.contentMatch.compatible(other.contentMatch) 138 | } 139 | 140 | /// @internal 141 | computeAttrs(attrs: Attrs | null): Attrs { 142 | if (!attrs && this.defaultAttrs) return this.defaultAttrs 143 | else return computeAttrs(this.attrs, attrs) 144 | } 145 | 146 | /// Create a `Node` of this type. The given attributes are 147 | /// checked and defaulted (you can pass `null` to use the type's 148 | /// defaults entirely, if no required attributes exist). `content` 149 | /// may be a `Fragment`, a node, an array of nodes, or 150 | /// `null`. Similarly `marks` may be `null` to default to the empty 151 | /// set of marks. 152 | create(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) { 153 | if (this.isText) throw new Error("NodeType.create can't construct text nodes") 154 | return new Node(this, this.computeAttrs(attrs), Fragment.from(content), Mark.setFrom(marks)) 155 | } 156 | 157 | /// Like [`create`](#model.NodeType.create), but check the given content 158 | /// against the node type's content restrictions, and throw an error 159 | /// if it doesn't match. 160 | createChecked(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) { 161 | content = Fragment.from(content) 162 | this.checkContent(content) 163 | return new Node(this, this.computeAttrs(attrs), content, Mark.setFrom(marks)) 164 | } 165 | 166 | /// Like [`create`](#model.NodeType.create), but see if it is 167 | /// necessary to add nodes to the start or end of the given fragment 168 | /// to make it fit the node. If no fitting wrapping can be found, 169 | /// return null. Note that, due to the fact that required nodes can 170 | /// always be created, this will always succeed if you pass null or 171 | /// `Fragment.empty` as content. 172 | createAndFill(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) { 173 | attrs = this.computeAttrs(attrs) 174 | content = Fragment.from(content) 175 | if (content.size) { 176 | let before = this.contentMatch.fillBefore(content) 177 | if (!before) return null 178 | content = before.append(content) 179 | } 180 | let matched = this.contentMatch.matchFragment(content) 181 | let after = matched && matched.fillBefore(Fragment.empty, true) 182 | if (!after) return null 183 | return new Node(this, attrs, (content as Fragment).append(after), Mark.setFrom(marks)) 184 | } 185 | 186 | /// Returns true if the given fragment is valid content for this node 187 | /// type. 188 | validContent(content: Fragment) { 189 | let result = this.contentMatch.matchFragment(content) 190 | if (!result || !result.validEnd) return false 191 | for (let i = 0; i < content.childCount; i++) 192 | if (!this.allowsMarks(content.child(i).marks)) return false 193 | return true 194 | } 195 | 196 | /// Throws a RangeError if the given fragment is not valid content for this 197 | /// node type. 198 | /// @internal 199 | checkContent(content: Fragment) { 200 | if (!this.validContent(content)) 201 | throw new RangeError(`Invalid content for node ${this.name}: ${content.toString().slice(0, 50)}`) 202 | } 203 | 204 | /// @internal 205 | checkAttrs(attrs: Attrs) { 206 | checkAttrs(this.attrs, attrs, "node", this.name) 207 | } 208 | 209 | /// Check whether the given mark type is allowed in this node. 210 | allowsMarkType(markType: MarkType) { 211 | return this.markSet == null || this.markSet.indexOf(markType) > -1 212 | } 213 | 214 | /// Test whether the given set of marks are allowed in this node. 215 | allowsMarks(marks: readonly Mark[]) { 216 | if (this.markSet == null) return true 217 | for (let i = 0; i < marks.length; i++) if (!this.allowsMarkType(marks[i].type)) return false 218 | return true 219 | } 220 | 221 | /// Removes the marks that are not allowed in this node from the given set. 222 | allowedMarks(marks: readonly Mark[]): readonly Mark[] { 223 | if (this.markSet == null) return marks 224 | let copy 225 | for (let i = 0; i < marks.length; i++) { 226 | if (!this.allowsMarkType(marks[i].type)) { 227 | if (!copy) copy = marks.slice(0, i) 228 | } else if (copy) { 229 | copy.push(marks[i]) 230 | } 231 | } 232 | return !copy ? marks : copy.length ? copy : Mark.none 233 | } 234 | 235 | /// @internal 236 | static compile(nodes: OrderedMap, schema: Schema): {readonly [name in Nodes]: NodeType} { 237 | let result = Object.create(null) 238 | nodes.forEach((name, spec) => result[name] = new NodeType(name, schema, spec)) 239 | 240 | let topType = schema.spec.topNode || "doc" 241 | if (!result[topType]) throw new RangeError("Schema is missing its top node type ('" + topType + "')") 242 | if (!result.text) throw new RangeError("Every schema needs a 'text' type") 243 | for (let _ in result.text.attrs) throw new RangeError("The text node type should not have attributes") 244 | 245 | return result 246 | } 247 | } 248 | 249 | function validateType(typeName: string, attrName: string, type: string) { 250 | let types = type.split("|") 251 | return (value: any) => { 252 | let name = value === null ? "null" : typeof value 253 | if (types.indexOf(name) < 0) throw new RangeError(`Expected value of type ${types} for attribute ${attrName} on type ${typeName}, got ${name}`) 254 | } 255 | } 256 | 257 | // Attribute descriptors 258 | 259 | class Attribute { 260 | hasDefault: boolean 261 | default: any 262 | validate: undefined | ((value: any) => void) 263 | 264 | constructor(typeName: string, attrName: string, options: AttributeSpec) { 265 | this.hasDefault = Object.prototype.hasOwnProperty.call(options, "default") 266 | this.default = options.default 267 | this.validate = typeof options.validate == "string" ? validateType(typeName, attrName, options.validate) : options.validate 268 | } 269 | 270 | get isRequired() { 271 | return !this.hasDefault 272 | } 273 | } 274 | 275 | // Marks 276 | 277 | /// Like nodes, marks (which are associated with nodes to signify 278 | /// things like emphasis or being part of a link) are 279 | /// [tagged](#model.Mark.type) with type objects, which are 280 | /// instantiated once per `Schema`. 281 | export class MarkType { 282 | /// @internal 283 | attrs: {[name: string]: Attribute} 284 | /// @internal 285 | declare excluded: readonly MarkType[] 286 | /// @internal 287 | instance: Mark | null 288 | 289 | /// @internal 290 | constructor( 291 | /// The name of the mark type. 292 | readonly name: string, 293 | /// @internal 294 | readonly rank: number, 295 | /// The schema that this mark type instance is part of. 296 | readonly schema: Schema, 297 | /// The spec on which the type is based. 298 | readonly spec: MarkSpec 299 | ) { 300 | this.attrs = initAttrs(name, spec.attrs) 301 | ;(this as any).excluded = null 302 | let defaults = defaultAttrs(this.attrs) 303 | this.instance = defaults ? new Mark(this, defaults) : null 304 | } 305 | 306 | /// Create a mark of this type. `attrs` may be `null` or an object 307 | /// containing only some of the mark's attributes. The others, if 308 | /// they have defaults, will be added. 309 | create(attrs: Attrs | null = null) { 310 | if (!attrs && this.instance) return this.instance 311 | return new Mark(this, computeAttrs(this.attrs, attrs)) 312 | } 313 | 314 | /// @internal 315 | static compile(marks: OrderedMap, schema: Schema) { 316 | let result = Object.create(null), rank = 0 317 | marks.forEach((name, spec) => result[name] = new MarkType(name, rank++, schema, spec)) 318 | return result 319 | } 320 | 321 | /// When there is a mark of this type in the given set, a new set 322 | /// without it is returned. Otherwise, the input set is returned. 323 | removeFromSet(set: readonly Mark[]): readonly Mark[] { 324 | for (var i = 0; i < set.length; i++) if (set[i].type == this) { 325 | set = set.slice(0, i).concat(set.slice(i + 1)) 326 | i-- 327 | } 328 | return set 329 | } 330 | 331 | /// Tests whether there is a mark of this type in the given set. 332 | isInSet(set: readonly Mark[]): Mark | undefined { 333 | for (let i = 0; i < set.length; i++) 334 | if (set[i].type == this) return set[i] 335 | } 336 | 337 | /// @internal 338 | checkAttrs(attrs: Attrs) { 339 | checkAttrs(this.attrs, attrs, "mark", this.name) 340 | } 341 | 342 | /// Queries whether a given mark type is 343 | /// [excluded](#model.MarkSpec.excludes) by this one. 344 | excludes(other: MarkType) { 345 | return this.excluded.indexOf(other) > -1 346 | } 347 | } 348 | 349 | /// An object describing a schema, as passed to the [`Schema`](#model.Schema) 350 | /// constructor. 351 | export interface SchemaSpec { 352 | /// The node types in this schema. Maps names to 353 | /// [`NodeSpec`](#model.NodeSpec) objects that describe the node type 354 | /// associated with that name. Their order is significant—it 355 | /// determines which [parse rules](#model.NodeSpec.parseDOM) take 356 | /// precedence by default, and which nodes come first in a given 357 | /// [group](#model.NodeSpec.group). 358 | nodes: {[name in Nodes]: NodeSpec} | OrderedMap, 359 | 360 | /// The mark types that exist in this schema. The order in which they 361 | /// are provided determines the order in which [mark 362 | /// sets](#model.Mark.addToSet) are sorted and in which [parse 363 | /// rules](#model.MarkSpec.parseDOM) are tried. 364 | marks?: {[name in Marks]: MarkSpec} | OrderedMap 365 | 366 | /// The name of the default top-level node for the schema. Defaults 367 | /// to `"doc"`. 368 | topNode?: string 369 | } 370 | 371 | /// A description of a node type, used when defining a schema. 372 | export interface NodeSpec { 373 | /// The content expression for this node, as described in the [schema 374 | /// guide](/docs/guide/#schema.content_expressions). When not given, 375 | /// the node does not allow any content. 376 | content?: string 377 | 378 | /// The marks that are allowed inside of this node. May be a 379 | /// space-separated string referring to mark names or groups, `"_"` 380 | /// to explicitly allow all marks, or `""` to disallow marks. When 381 | /// not given, nodes with inline content default to allowing all 382 | /// marks, other nodes default to not allowing marks. 383 | marks?: string 384 | 385 | /// The group or space-separated groups to which this node belongs, 386 | /// which can be referred to in the content expressions for the 387 | /// schema. 388 | group?: string 389 | 390 | /// Should be set to true for inline nodes. (Implied for text nodes.) 391 | inline?: boolean 392 | 393 | /// Can be set to true to indicate that, though this isn't a [leaf 394 | /// node](#model.NodeType.isLeaf), it doesn't have directly editable 395 | /// content and should be treated as a single unit in the view. 396 | atom?: boolean 397 | 398 | /// The attributes that nodes of this type get. 399 | attrs?: {[name: string]: AttributeSpec} 400 | 401 | /// Controls whether nodes of this type can be selected as a [node 402 | /// selection](#state.NodeSelection). Defaults to true for non-text 403 | /// nodes. 404 | selectable?: boolean 405 | 406 | /// Determines whether nodes of this type can be dragged without 407 | /// being selected. Defaults to false. 408 | draggable?: boolean 409 | 410 | /// Can be used to indicate that this node contains code, which 411 | /// causes some commands to behave differently. 412 | code?: boolean 413 | 414 | /// Controls way whitespace in this a node is parsed. The default is 415 | /// `"normal"`, which causes the [DOM parser](#model.DOMParser) to 416 | /// collapse whitespace in normal mode, and normalize it (replacing 417 | /// newlines and such with spaces) otherwise. `"pre"` causes the 418 | /// parser to preserve spaces inside the node. When this option isn't 419 | /// given, but [`code`](#model.NodeSpec.code) is true, `whitespace` 420 | /// will default to `"pre"`. Note that this option doesn't influence 421 | /// the way the node is rendered—that should be handled by `toDOM` 422 | /// and/or styling. 423 | whitespace?: "pre" | "normal" 424 | 425 | /// Determines whether this node is considered an important parent 426 | /// node during replace operations (such as paste). Non-defining (the 427 | /// default) nodes get dropped when their entire content is replaced, 428 | /// whereas defining nodes persist and wrap the inserted content. 429 | definingAsContext?: boolean 430 | 431 | /// In inserted content the defining parents of the content are 432 | /// preserved when possible. Typically, non-default-paragraph 433 | /// textblock types, and possibly list items, are marked as defining. 434 | definingForContent?: boolean 435 | 436 | /// When enabled, enables both 437 | /// [`definingAsContext`](#model.NodeSpec.definingAsContext) and 438 | /// [`definingForContent`](#model.NodeSpec.definingForContent). 439 | defining?: boolean 440 | 441 | /// When enabled (default is false), the sides of nodes of this type 442 | /// count as boundaries that regular editing operations, like 443 | /// backspacing or lifting, won't cross. An example of a node that 444 | /// should probably have this enabled is a table cell. 445 | isolating?: boolean 446 | 447 | /// Defines the default way a node of this type should be serialized 448 | /// to DOM/HTML (as used by 449 | /// [`DOMSerializer.fromSchema`](#model.DOMSerializer^fromSchema)). 450 | /// Should return a DOM node or an [array 451 | /// structure](#model.DOMOutputSpec) that describes one, with an 452 | /// optional number zero (“hole”) in it to indicate where the node's 453 | /// content should be inserted. 454 | /// 455 | /// For text nodes, the default is to create a text DOM node. Though 456 | /// it is possible to create a serializer where text is rendered 457 | /// differently, this is not supported inside the editor, so you 458 | /// shouldn't override that in your text node spec. 459 | toDOM?: (node: Node) => DOMOutputSpec 460 | 461 | /// Associates DOM parser information with this node, which can be 462 | /// used by [`DOMParser.fromSchema`](#model.DOMParser^fromSchema) to 463 | /// automatically derive a parser. The `node` field in the rules is 464 | /// implied (the name of this node will be filled in automatically). 465 | /// If you supply your own parser, you do not need to also specify 466 | /// parsing rules in your schema. 467 | parseDOM?: readonly TagParseRule[] 468 | 469 | /// Defines the default way a node of this type should be serialized 470 | /// to a string representation for debugging (e.g. in error messages). 471 | toDebugString?: (node: Node) => string 472 | 473 | /// Defines the default way a [leaf node](#model.NodeType.isLeaf) of 474 | /// this type should be serialized to a string (as used by 475 | /// [`Node.textBetween`](#model.Node.textBetween) and 476 | /// [`Node.textContent`](#model.Node.textContent)). 477 | leafText?: (node: Node) => string 478 | 479 | /// A single inline node in a schema can be set to be a linebreak 480 | /// equivalent. When converting between block types that support the 481 | /// node and block types that don't but have 482 | /// [`whitespace`](#model.NodeSpec.whitespace) set to `"pre"`, 483 | /// [`setBlockType`](#transform.Transform.setBlockType) will convert 484 | /// between newline characters to or from linebreak nodes as 485 | /// appropriate. 486 | linebreakReplacement?: boolean 487 | 488 | /// Node specs may include arbitrary properties that can be read by 489 | /// other code via [`NodeType.spec`](#model.NodeType.spec). 490 | [key: string]: any 491 | } 492 | 493 | /// Used to define marks when creating a schema. 494 | export interface MarkSpec { 495 | /// The attributes that marks of this type get. 496 | attrs?: {[name: string]: AttributeSpec} 497 | 498 | /// Whether this mark should be active when the cursor is positioned 499 | /// at its end (or at its start when that is also the start of the 500 | /// parent node). Defaults to true. 501 | inclusive?: boolean 502 | 503 | /// Determines which other marks this mark can coexist with. Should 504 | /// be a space-separated strings naming other marks or groups of marks. 505 | /// When a mark is [added](#model.Mark.addToSet) to a set, all marks 506 | /// that it excludes are removed in the process. If the set contains 507 | /// any mark that excludes the new mark but is not, itself, excluded 508 | /// by the new mark, the mark can not be added an the set. You can 509 | /// use the value `"_"` to indicate that the mark excludes all 510 | /// marks in the schema. 511 | /// 512 | /// Defaults to only being exclusive with marks of the same type. You 513 | /// can set it to an empty string (or any string not containing the 514 | /// mark's own name) to allow multiple marks of a given type to 515 | /// coexist (as long as they have different attributes). 516 | excludes?: string 517 | 518 | /// The group or space-separated groups to which this mark belongs. 519 | group?: string 520 | 521 | /// Determines whether marks of this type can span multiple adjacent 522 | /// nodes when serialized to DOM/HTML. Defaults to true. 523 | spanning?: boolean 524 | 525 | /// Marks the content of this span as being code, which causes some 526 | /// commands and extensions to treat it differently. 527 | code?: boolean 528 | 529 | /// Defines the default way marks of this type should be serialized 530 | /// to DOM/HTML. When the resulting spec contains a hole, that is 531 | /// where the marked content is placed. Otherwise, it is appended to 532 | /// the top node. 533 | toDOM?: (mark: Mark, inline: boolean) => DOMOutputSpec 534 | 535 | /// Associates DOM parser information with this mark (see the 536 | /// corresponding [node spec field](#model.NodeSpec.parseDOM)). The 537 | /// `mark` field in the rules is implied. 538 | parseDOM?: readonly ParseRule[] 539 | 540 | /// Mark specs can include additional properties that can be 541 | /// inspected through [`MarkType.spec`](#model.MarkType.spec) when 542 | /// working with the mark. 543 | [key: string]: any 544 | } 545 | 546 | /// Used to [define](#model.NodeSpec.attrs) attributes on nodes or 547 | /// marks. 548 | export interface AttributeSpec { 549 | /// The default value for this attribute, to use when no explicit 550 | /// value is provided. Attributes that have no default must be 551 | /// provided whenever a node or mark of a type that has them is 552 | /// created. 553 | default?: any 554 | /// A function or type name used to validate values of this 555 | /// attribute. This will be used when deserializing the attribute 556 | /// from JSON, and when running [`Node.check`](#model.Node.check). 557 | /// When a function, it should raise an exception if the value isn't 558 | /// of the expected type or shape. When a string, it should be a 559 | /// `|`-separated string of primitive types (`"number"`, `"string"`, 560 | /// `"boolean"`, `"null"`, and `"undefined"`), and the library will 561 | /// raise an error when the value is not one of those types. 562 | validate?: string | ((value: any) => void) 563 | } 564 | 565 | /// A document schema. Holds [node](#model.NodeType) and [mark 566 | /// type](#model.MarkType) objects for the nodes and marks that may 567 | /// occur in conforming documents, and provides functionality for 568 | /// creating and deserializing such documents. 569 | /// 570 | /// When given, the type parameters provide the names of the nodes and 571 | /// marks in this schema. 572 | export class Schema { 573 | /// The [spec](#model.SchemaSpec) on which the schema is based, 574 | /// with the added guarantee that its `nodes` and `marks` 575 | /// properties are 576 | /// [`OrderedMap`](https://github.com/marijnh/orderedmap) instances 577 | /// (not raw objects). 578 | spec: { 579 | nodes: OrderedMap, 580 | marks: OrderedMap, 581 | topNode?: string 582 | } 583 | 584 | /// An object mapping the schema's node names to node type objects. 585 | nodes: {readonly [name in Nodes]: NodeType} & {readonly [key: string]: NodeType} 586 | 587 | /// A map from mark names to mark type objects. 588 | marks: {readonly [name in Marks]: MarkType} & {readonly [key: string]: MarkType} 589 | 590 | /// The [linebreak 591 | /// replacement](#model.NodeSpec.linebreakReplacement) node defined 592 | /// in this schema, if any. 593 | linebreakReplacement: NodeType | null = null 594 | 595 | /// Construct a schema from a schema [specification](#model.SchemaSpec). 596 | constructor(spec: SchemaSpec) { 597 | let instanceSpec = this.spec = {} as any 598 | for (let prop in spec) instanceSpec[prop] = (spec as any)[prop] 599 | instanceSpec.nodes = OrderedMap.from(spec.nodes), 600 | instanceSpec.marks = OrderedMap.from(spec.marks || {}), 601 | 602 | this.nodes = NodeType.compile(this.spec.nodes, this) 603 | this.marks = MarkType.compile(this.spec.marks, this) 604 | 605 | let contentExprCache = Object.create(null) 606 | for (let prop in this.nodes) { 607 | if (prop in this.marks) 608 | throw new RangeError(prop + " can not be both a node and a mark") 609 | let type = this.nodes[prop], contentExpr = type.spec.content || "", markExpr = type.spec.marks 610 | type.contentMatch = contentExprCache[contentExpr] || 611 | (contentExprCache[contentExpr] = ContentMatch.parse(contentExpr, this.nodes)) 612 | ;(type as any).inlineContent = type.contentMatch.inlineContent 613 | if (type.spec.linebreakReplacement) { 614 | if (this.linebreakReplacement) throw new RangeError("Multiple linebreak nodes defined") 615 | if (!type.isInline || !type.isLeaf) throw new RangeError("Linebreak replacement nodes must be inline leaf nodes") 616 | this.linebreakReplacement = type 617 | } 618 | type.markSet = markExpr == "_" ? null : 619 | markExpr ? gatherMarks(this, markExpr.split(" ")) : 620 | markExpr == "" || !type.inlineContent ? [] : null 621 | } 622 | for (let prop in this.marks) { 623 | let type = this.marks[prop], excl = type.spec.excludes 624 | type.excluded = excl == null ? [type] : excl == "" ? [] : gatherMarks(this, excl.split(" ")) 625 | } 626 | 627 | this.nodeFromJSON = json => Node.fromJSON(this, json) 628 | this.markFromJSON = json => Mark.fromJSON(this, json) 629 | this.topNodeType = this.nodes[this.spec.topNode || "doc"] 630 | this.cached.wrappings = Object.create(null) 631 | } 632 | 633 | /// The type of the [default top node](#model.SchemaSpec.topNode) 634 | /// for this schema. 635 | topNodeType: NodeType 636 | 637 | /// An object for storing whatever values modules may want to 638 | /// compute and cache per schema. (If you want to store something 639 | /// in it, try to use property names unlikely to clash.) 640 | cached: {[key: string]: any} = Object.create(null) 641 | 642 | /// Create a node in this schema. The `type` may be a string or a 643 | /// `NodeType` instance. Attributes will be extended with defaults, 644 | /// `content` may be a `Fragment`, `null`, a `Node`, or an array of 645 | /// nodes. 646 | node(type: string | NodeType, 647 | attrs: Attrs | null = null, 648 | content?: Fragment | Node | readonly Node[], 649 | marks?: readonly Mark[]) { 650 | if (typeof type == "string") 651 | type = this.nodeType(type) 652 | else if (!(type instanceof NodeType)) 653 | throw new RangeError("Invalid node type: " + type) 654 | else if (type.schema != this) 655 | throw new RangeError("Node type from different schema used (" + type.name + ")") 656 | 657 | return type.createChecked(attrs, content, marks) 658 | } 659 | 660 | /// Create a text node in the schema. Empty text nodes are not 661 | /// allowed. 662 | text(text: string, marks?: readonly Mark[] | null): Node { 663 | let type = this.nodes.text 664 | return new TextNode(type, type.defaultAttrs, text, Mark.setFrom(marks)) 665 | } 666 | 667 | /// Create a mark with the given type and attributes. 668 | mark(type: string | MarkType, attrs?: Attrs | null) { 669 | if (typeof type == "string") type = this.marks[type] 670 | return type.create(attrs) 671 | } 672 | 673 | /// Deserialize a node from its JSON representation. This method is 674 | /// bound. 675 | nodeFromJSON: (json: any) => Node 676 | 677 | /// Deserialize a mark from its JSON representation. This method is 678 | /// bound. 679 | markFromJSON: (json: any) => Mark 680 | 681 | /// @internal 682 | nodeType(name: string) { 683 | let found = this.nodes[name] 684 | if (!found) throw new RangeError("Unknown node type: " + name) 685 | return found 686 | } 687 | } 688 | 689 | function gatherMarks(schema: Schema, marks: readonly string[]) { 690 | let found: MarkType[] = [] 691 | for (let i = 0; i < marks.length; i++) { 692 | let name = marks[i], mark = schema.marks[name], ok = mark 693 | if (mark) { 694 | found.push(mark) 695 | } else { 696 | for (let prop in schema.marks) { 697 | let mark = schema.marks[prop] 698 | if (name == "_" || (mark.spec.group && mark.spec.group.split(" ").indexOf(name) > -1)) 699 | found.push(ok = mark) 700 | } 701 | } 702 | if (!ok) throw new SyntaxError("Unknown mark type: '" + marks[i] + "'") 703 | } 704 | return found 705 | } 706 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.25.4 (2025-10-21) 2 | 3 | ### Bug fixes 4 | 5 | The DOM parser will now, if a line break replacement is defined in the schema, try to use that instead of spaces to replace newlines when preserving whitespace. 6 | 7 | ## 1.25.3 (2025-08-06) 8 | 9 | ### Bug fixes 10 | 11 | Fix a bug in `Slice` that made it possible for invalid `ReplaceAroundStep`s to be applied in some situations. 12 | 13 | ## 1.25.2 (2025-07-11) 14 | 15 | ### Bug fixes 16 | 17 | Suppress lint warnings about dereferencing methods by making `Schema.nodeFromJSON` and `markFromJSON` properties instead of methods. 18 | 19 | Avoid using `setAttribute("style", ...)` to stay clear of content security policies. 20 | 21 | ## 1.25.1 (2025-04-22) 22 | 23 | ### Bug fixes 24 | 25 | Make the DOM parser not discard nodes whose document representation cannot be placed inside the represenation of some parent DOM node. 26 | 27 | ## 1.25.0 (2025-03-18) 28 | 29 | ### New features 30 | 31 | Mark specs can now be marked with a `code` property. 32 | 33 | ## 1.24.1 (2024-12-10) 34 | 35 | ### Bug fixes 36 | 37 | The parser now automatically preserves whitespace inside `
` or `white-space: pre` elements.
 38 | 
 39 | ## 1.24.0 (2024-11-27)
 40 | 
 41 | ### New features
 42 | 
 43 | `Fragment.content` and `Node.children` now expose a node's set of children as an array.
 44 | 
 45 | ## 1.23.0 (2024-10-05)
 46 | 
 47 | ### New features
 48 | 
 49 | The new `NodeType.isInGroup` method can be used to query group membership.
 50 | 
 51 | ## 1.22.3 (2024-08-06)
 52 | 
 53 | ### Bug fixes
 54 | 
 55 | Fix some corner cases in the way the DOM parser tracks active marks.
 56 | 
 57 | ## 1.22.2 (2024-07-18)
 58 | 
 59 | ### Bug fixes
 60 | 
 61 | Make attribute validation messages more informative.
 62 | 
 63 | ## 1.22.1 (2024-07-14)
 64 | 
 65 | ### Bug fixes
 66 | 
 67 | Add code to `DOMSerializer` that rejects DOM output specs when they originate from attribute values, to protect against XSS attacks that use corrupt attribute input.
 68 | 
 69 | ## 1.22.0 (2024-07-14)
 70 | 
 71 | ### New features
 72 | 
 73 | Attribute specs now support a `validate` property that can be used to provide a validation function for the attribute, to guard against corrupt JSON input.
 74 | 
 75 | ## 1.21.3 (2024-06-26)
 76 | 
 77 | ### Bug fixes
 78 | 
 79 | Fix an issue where parse rules for CSS properties that were shorthands for a number of more detailed properties weren't matching properly.
 80 | 
 81 | ## 1.21.2 (2024-06-25)
 82 | 
 83 | ### Bug fixes
 84 | 
 85 | Make sure resolved positions (and thus the document and schema hanging off them) don't get kept in the cache when their document can be garbage-collected.
 86 | 
 87 | ## 1.21.1 (2024-06-03)
 88 | 
 89 | ### Bug fixes
 90 | 
 91 | Improve performance and accuracy of `DOMParser` style matching by using the DOM's own `style` object.
 92 | 
 93 | ## 1.21.0 (2024-05-06)
 94 | 
 95 | ### New features
 96 | 
 97 | The new `linebreakReplacement` property on node specs makes it possible to configure a node type that `setBlockType` will convert to and from line breaks when appropriate.
 98 | 
 99 | ## 1.20.0 (2024-04-08)
100 | 
101 | ### New features
102 | 
103 | The `ParseRule` type is now a union of `TagParseRule` and `StyleParseRule`, with more specific types being used when appropriate.
104 | 
105 | ## 1.19.4 (2023-12-11)
106 | 
107 | ### Bug fixes
108 | 
109 | Make `textBetween` emit block separators for empty textblocks.
110 | 
111 | ## 1.19.3 (2023-07-13)
112 | 
113 | ### Bug fixes
114 | 
115 | Don't apply style parse rules for nodes that are skipped by other parse rules.
116 | 
117 | ## 1.19.2 (2023-05-23)
118 | 
119 | ### Bug fixes
120 | 
121 | Allow parse rules with a `clearMark` directive to clear marks that have already been applied.
122 | 
123 | ## 1.19.1 (2023-05-17)
124 | 
125 | ### Bug fixes
126 | 
127 | Fix the types of `Fragment.desendants` to include the index parameter to the callback. Add release note
128 | 
129 | Include CommonJS type declarations in the package to please new TypeScript resolution settings.
130 | 
131 | ## 1.19.0 (2023-01-18)
132 | 
133 | ### New features
134 | 
135 | Parse rules for styles can now provide a `clearMark` property to remove pending marks (for example for `font-style: normal`).
136 | 
137 | ## 1.18.3 (2022-11-18)
138 | 
139 | ### Bug fixes
140 | 
141 | Copy all properties from the input spec to `Schema.spec`.
142 | 
143 | ## 1.18.2 (2022-11-14)
144 | 
145 | ### Bug fixes
146 | 
147 | Improve DOM parsing of nested block elements mixing block and inline children.
148 | 
149 | ## 1.18.1 (2022-06-15)
150 | 
151 | ### Bug fixes
152 | 
153 | Upgrade to orderedmap 2.0.0 to avoid around a TypeScript compilation issue.
154 | 
155 | ## 1.18.0 (2022-06-07)
156 | 
157 | ### New features
158 | 
159 | Node specs for leaf nodes now support a property `leafText` which, when given, will be used by `textContent` and `textBetween` to serialize the node.
160 | 
161 | Add optional type parameters to `Schema` for the node and mark names. Clarify Schema type parameters
162 | 
163 | ## 1.17.0 (2022-05-30)
164 | 
165 | ### Bug fixes
166 | 
167 | Fix a crash in DOM parsing.
168 | 
169 | ### New features
170 | 
171 | Include TypeScript type declarations.
172 | 
173 | ## 1.16.1 (2021-12-29)
174 | 
175 | ### Bug fixes
176 | 
177 | Fix a bug in the way whitespace-preservation options were handled in `DOMParser`.
178 | 
179 | ## 1.16.0 (2021-12-27)
180 | 
181 | ### New features
182 | 
183 | A new `NodeSpec` property, `whitespace`, allows more control over the way whitespace in the content of the node is parsed.
184 | 
185 | ## 1.15.0 (2021-10-25)
186 | 
187 | ### New features
188 | 
189 | `textBetween` now allows its leaf text argument to be a function.
190 | 
191 | ## 1.14.3 (2021-07-22)
192 | 
193 | ### Bug fixes
194 | 
195 | `DOMSerializer.serializeNode` will no longer ignore the node's marks.
196 | 
197 | ## 1.14.2 (2021-06-16)
198 | 
199 | ### Bug fixes
200 | 
201 | Be less agressive about dropping whitespace when the context isn't know in `DOMParser.parseSlice`.
202 | 
203 | ## 1.14.1 (2021-04-26)
204 | 
205 | ### Bug fixes
206 | 
207 | DOM parsing with `preserveWhitespace: "full"` will no longer ignore whitespace-only nodes.
208 | 
209 | ## 1.14.0 (2021-04-06)
210 | 
211 | ### Bug fixes
212 | 
213 | `Node.check` will now error if a node has an invalid combination of marks.
214 | 
215 | Don't leave carriage return characters in parsed DOM content, since they confuse Chrome's cursor motion.
216 | 
217 | ### New features
218 | 
219 | `Fragment.textBetween` is now public.
220 | 
221 | ## 1.13.3 (2021-02-04)
222 | 
223 | ### Bug fixes
224 | 
225 | Fix an issue where nested tags that match mark parser rules could cause the parser to apply marks in invalid places.
226 | 
227 | ## 1.13.2 (2021-02-04)
228 | 
229 | ### Bug fixes
230 | 
231 | `MarkType.removeFromSet` now removes all instances of the mark, not just the first one.
232 | 
233 | ## 1.13.1 (2020-12-20)
234 | 
235 | ### Bug fixes
236 | 
237 | Fix a bug where nested marks of the same type would be applied to the wrong node when parsing from DOM.
238 | 
239 | ## 1.13.0 (2020-12-11)
240 | 
241 | ### New features
242 | 
243 | Parse rules can now have a `consuming: false` property which allows other rules to match their tag or style even when they apply.
244 | 
245 | ## 1.12.0 (2020-10-11)
246 | 
247 | ### New features
248 | 
249 | The output of `toDOM` functions can now be a `{dom, contentDOM}` object specifying the precise parent and content DOM elements.
250 | 
251 | ## 1.11.2 (2020-09-12)
252 | 
253 | ### Bug fixes
254 | 
255 | Fix issue where 1.11.1 uses an array method not available on Internet Explorer.
256 | 
257 | ## 1.11.1 (2020-09-11)
258 | 
259 | ### Bug fixes
260 | 
261 | Fix an issue where an inner node's mark information could reset the same mark provided by an outer node in the DOM parser.
262 | 
263 | ## 1.11.0 (2020-07-08)
264 | 
265 | ### New features
266 | 
267 | Resolved positions have a new convenience method, `posAtIndex`, which can resolve a depth and index to a position.
268 | 
269 | ## 1.10.1 (2020-07-08)
270 | 
271 | ### Bug fixes
272 | 
273 | Fix a bug that prevented non-canonical list structure from being normalized.
274 | 
275 | ## 1.10.0 (2020-05-25)
276 | 
277 | ### Bug fixes
278 | 
279 | Avoid fixing directly nested list nodes during DOM parsing when it looks like the schema allows those.
280 | 
281 | ### New features
282 | 
283 | DOM parser rules can now specify `closeParent: true` to have the effect of closing their parent node when matched.
284 | 
285 | ## 1.9.1 (2020-01-17)
286 | 
287 | ### Bug fixes
288 | 
289 | Marks found in the DOM at the wrong level (for example, a bold style on a block node) are now properly moved to the node content.
290 | 
291 | ## 1.9.0 (2020-01-07)
292 | 
293 | ### New features
294 | 
295 | The `NodeType` method [`hasRequiredAttrs`](https://prosemirror.net/docs/ref/#model.NodeType.hasRequiredAttrs) is now public.
296 | 
297 | Element and attribute names in [`DOMOutputSpec`](https://prosemirror.net/docs/ref/#model.DOMOutputSpec) structures can now contain namespaces.
298 | 
299 | ## 1.8.2 (2019-11-20)
300 | 
301 | ### Bug fixes
302 | 
303 | Rename ES module files to use a .js extension, since Webpack gets confused by .mjs
304 | 
305 | ## 1.8.1 (2019-11-19)
306 | 
307 | ### Bug fixes
308 | 
309 | The file referred to in the package's `module` field now is compiled down to ES5.
310 | 
311 | ## 1.8.0 (2019-11-08)
312 | 
313 | ### New features
314 | 
315 | Add a `module` field to package json file.
316 | 
317 | Add a `module` field to package json file.
318 | 
319 | Add a `module` field to package json file.
320 | 
321 | Add a `module` field to package json file.
322 | 
323 | Add a `module` field to package json file.
324 | 
325 | Add a `module` field to package json file.
326 | 
327 | Add a `module` field to package json file.
328 | 
329 | Add a `module` field to package json file.
330 | 
331 | Add a `module` field to package json file.
332 | 
333 | Add a `module` field to package json file.
334 | 
335 | Add a `module` field to package json file.
336 | 
337 | Add a `module` field to package json file.
338 | 
339 | Add a `module` field to package json file.
340 | 
341 | Add a `module` field to package json file.
342 | 
343 | Add a `module` field to package json file.
344 | 
345 | Add a `module` field to package json file.
346 | 
347 | Add a `module` field to package json file.
348 | 
349 | ## 1.7.5 (2019-11-07)
350 | 
351 | ### Bug fixes
352 | 
353 | `ContentMatch.edge` now throws, as it is supposed to, when you try to access the edge past the last one.
354 | 
355 | ## 1.7.4 (2019-10-10)
356 | 
357 | ### Bug fixes
358 | 
359 | Fix an issue where `fillBefore` would in some cases insert unneccesary optional child nodes in the generated content.
360 | 
361 | ## 1.7.3 (2019-10-03)
362 | 
363 | ### Bug fixes
364 | 
365 | Fix an issue where _any_ whitespace (not just the characters that HTML collapses) was collapsed by the parser in non-whitespace-preserving mode.
366 | 
367 | ## 1.7.2 (2019-09-04)
368 | 
369 | ### Bug fixes
370 | 
371 | When `
` DOM nodes can't be parsed normally, the parser now converts them to newlines. This should improve parsing of some forms of source code HTML. 372 | 373 | ## 1.7.1 (2019-05-31) 374 | 375 | ### Bug fixes 376 | 377 | Using `Fragment.from` on an invalid value, including a `Fragment` instance from a different version/instance of the library, now raises a meaningful error rather than getting confused. 378 | 379 | Fix a bug in parsing overlapping marks of the same non-self-exclusive type. 380 | 381 | ## 1.7.0 (2019-01-29) 382 | 383 | ### New features 384 | 385 | Mark specs now support a property [`spanning`](https://prosemirror.net/docs/ref/#model.MarkSpec.spanning) which, when set to `false`, prevents the mark's DOM markup from spanning multiple nodes, so that a separate wrapper is created for each adjacent marked node. 386 | 387 | ## 1.6.4 (2019-01-05) 388 | 389 | ### Bug fixes 390 | 391 | Don't output empty style attributes when a style property with a null value is present in `renderSpec`. 392 | 393 | ## 1.6.3 (2018-10-26) 394 | 395 | ### Bug fixes 396 | 397 | The DOM parser now drops whitespace after BR nodes when not in whitespace-preserving mode. 398 | 399 | ## 1.6.2 (2018-10-01) 400 | 401 | ### Bug fixes 402 | 403 | Prevent [`ContentMatch.findWrapping`](https://prosemirror.net/docs/ref/#model.ContentMatch.findWrapping) from returning node types with required attributes. 404 | 405 | ## 1.6.1 (2018-07-24) 406 | 407 | ### Bug fixes 408 | 409 | Fix a bug where marks were sometimes parsed incorrectly. 410 | 411 | ## 1.6.0 (2018-07-20) 412 | 413 | ### Bug fixes 414 | 415 | Fix issue where marks would be applied to the wrong node when parsing a slice from DOM. 416 | 417 | ### New features 418 | 419 | Adds a new node spec property, [`toDebugString`](https://prosemirror.net/docs/ref/#model.NodeSpec.toDebugString), making it possible to customize your nodes' `toString` behavior. 420 | 421 | ## 1.5.0 (2018-05-31) 422 | 423 | ### New features 424 | 425 | [`ParseRule.getContent`](https://prosemirror.net/docs/ref/#model.ParseRule.getContent) is now passed the parser schema as second argument. 426 | 427 | ## 1.4.4 (2018-05-03) 428 | 429 | ### Bug fixes 430 | 431 | Fix a regression where `DOMParser.parse` would fail to apply mark nodes directly at the start of the input. 432 | 433 | ## 1.4.3 (2018-04-27) 434 | 435 | ### Bug fixes 436 | 437 | [`DOMParser.parseSlice`](https://prosemirror.net/docs/ref/#model.DOMParser.parseSlice) can now correctly parses marks at the top level again. 438 | 439 | ## 1.4.2 (2018-04-15) 440 | 441 | ### Bug fixes 442 | 443 | Remove a `console.log` that was accidentally left in the previous release. 444 | 445 | ## 1.4.1 (2018-04-13) 446 | 447 | ### Bug fixes 448 | 449 | `DOMParser` can now parse marks on block nodes. 450 | 451 | ## 1.4.0 (2018-03-22) 452 | 453 | ### New features 454 | 455 | [`ContentMatch.defaultType`](https://prosemirror.net/docs/ref/#model.ContentMatch.defaultType), a way to get a matching node type at a content match position, is now public. 456 | 457 | ## 1.3.0 (2018-03-22) 458 | 459 | ### New features 460 | 461 | `ContentMatch` objects now have an [`edgeCount`](https://prosemirror.net/docs/ref/#model.ContentMatch.edgeCount) property and an [`edge`](https://prosemirror.net/docs/ref/#model.ContentMatch.edge) method, providing direct access to the finite automaton structure. 462 | 463 | ## 1.2.2 (2018-03-15) 464 | 465 | ### Bug fixes 466 | 467 | Throw errors, rather than constructing invalid objects, when deserializing from invalid JSON data. 468 | 469 | ## 1.2.1 (2018-03-15) 470 | 471 | ### Bug fixes 472 | 473 | Content expressions with text nodes in required positions now raise the appropriate error about being unable to generate such nodes. 474 | 475 | ## 1.2.0 (2018-03-14) 476 | 477 | ### Bug fixes 478 | 479 | [`rangeHasMark`](https://prosemirror.net/docs/ref/#model.Node.rangeHasMark) now always returns false for empty ranges. 480 | 481 | The DOM renderer no longer needlessly splits mark nodes when starting a non-rendered mark. 482 | 483 | ### New features 484 | 485 | [`DOMSerializer`](https://prosemirror.net/docs/ref/#model.DOMSerializer) now allows [DOM specs](https://prosemirror.net/docs/ref/#model.DOMOutputSpec) for marks to have holes in them, to specify the precise position where their content should be rendered. 486 | 487 | The base position parameter to [`Node.nodesBetween`](https://prosemirror.net/docs/ref/#model.Node.nodesBetween) and [`Fragment.nodesBetween`](https://prosemirror.net/docs/ref/#model.Fragment.nodesBetween) is now part of the public interface. 488 | 489 | ## 1.1.0 (2018-01-05) 490 | 491 | ### New features 492 | 493 | [`Slice.maxOpen`](https://prosemirror.net/docs/ref/#model.Slice^maxOpen) now has a second argument that can be used to prevent it from opening isolating nodes. 494 | 495 | ## 1.0.1 (2017-11-10) 496 | 497 | ### Bug fixes 498 | 499 | [`ReplaceError`](https://prosemirror.net/docs/ref/#model.ReplaceError) instances now properly inherit from `Error`. 500 | 501 | ## 1.0.0 (2017-10-13) 502 | 503 | ### New features 504 | 505 | [`ParseRule.context`](https://prosemirror.net/docs/ref/#model.ParseRule.context) may now include multiple, pipe-separated context expressions. 506 | 507 | ## 0.23.1 (2017-09-21) 508 | 509 | ### Bug fixes 510 | 511 | `NodeType.allowsMarks` and `allowedMarks` now actually work for nodes that allow only specific marks. 512 | 513 | ## 0.23.0 (2017-09-13) 514 | 515 | ### Breaking changes 516 | 517 | [`ResolvedPos.marks`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ResolvedPos.marks) no longer takes a parameter (you probably want [`marksAcross`](https://prosemirror.net/doc/ref/version/0.23.0.html#model.ResolvedPos.marksAcross) if you were passing true there). 518 | 519 | Attribute and mark constraints in [content expressions](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeSpec.content) are no longer supported (this also means the `prosemirror-schema-table` package, which relied on them, is no longer supported). In this release, mark constraints are still (approximately) recognized with a warning, when present. 520 | 521 | [`ContentMatch`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ContentMatch) objects lost a number of methods: `matchNode`, `matchToEnd`, `findWrappingFor` (which can be easily emulated using the remaining API), and `allowsMark`, which is now the responsibility of [node types](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeType.allowsMarkType) instead. 522 | 523 | [`ContentMatch.validEnd`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ContentMatch.validEnd) is now a property rather than a method. 524 | 525 | [`ContentMatch.findWrapping`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ContentMatch.findWrapping) now returns an array of plain node types, with no attribute information (since this is no longer necessary). 526 | 527 | The `compute` method for attributes is no longer supported. 528 | 529 | Fragments no longer have an `offsetAt` method. 530 | 531 | `DOMParser.schemaRules` is no longer public (use [`fromSchema`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.DOMParser^fromSchema) and get the resulting parser's `rules` property instead). 532 | 533 | The DOM parser option `topStart` has been replaced by [`topMatch`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ParseOptions.topMatch). 534 | 535 | The `DOMSerializer` methods `nodesFromSchema` and `marksFromSchema` are no longer public (construct a serializer with [`fromSchema`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.DOMSerializer^fromSchema) and read its `nodes` and `marks` properties instead). 536 | 537 | ### Bug fixes 538 | 539 | Fix issue where whitespace at node boundaries was sometimes dropped during content parsing. 540 | 541 | Attribute default values of `undefined` are now allowed. 542 | 543 | ### New features 544 | 545 | [`contentElement`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ParseRule.contentElement) in parse rules may now be a function. 546 | 547 | The new method [`ResolvedPos.marksAcross`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ResolvedPos.marksAcross) can be used to find the set of marks that should be preserved after a deletion. 548 | 549 | [Content expressions](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeSpec.content) are now a regular language, meaning all the operators can be nested and composed as desired, and a bunch of constraints on what could appear next to what have been lifted. 550 | 551 | The starting content match for a node type now lives in [`NodeType.contentMatch`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeType.contentMatch). 552 | 553 | Allowed marks are now specified per node, rather than in content expressions, using the [`marks`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeSpec.marks) property on the node spec. 554 | 555 | Node types received new methods [`allowsMarkType`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeType.allowsMarkType), [`allowsMarks`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeType.allowsMarks), and [`allowedMarks`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.NodeType.allowedMarks), which tell you about the marks that node supports. 556 | 557 | The [`style`](https://prosemirror.net/docs/ref/version/0.23.0.html#model.ParseRule.style) property on parse rules may now have the form `"font-style=italic"` to only match styles that have the value after the equals sign. 558 | 559 | ## 0.22.0 (2017-06-29) 560 | 561 | ### Bug fixes 562 | 563 | When using [`parseSlice`](https://prosemirror.net/docs/ref/version/0.22.0.html#model.DOMParser.parseSlice), inline DOM content wrapped in block elements for which no parse rule is defined will now be properly wrapped in a textblock node. 564 | 565 | ### New features 566 | 567 | [Resolved positions](https://prosemirror.net/docs/ref/version/0.22.0.html#model.ResolvedPos) now have a [`doc`](https://prosemirror.net/docs/ref/version/0.22.0.html#model.ResolvedPos.doc) accessor to easily get their root node. 568 | 569 | Parse rules now support a [`namespace` property](https://prosemirror.net/docs/ref/version/0.22.0.html#model.ParseRule.namespace) to match XML namespaces. 570 | 571 | The [`NodeRange`](https://prosemirror.net/docs/ref/version/0.22.0.html#model.NodeRange) constructor is now public (whereas before you could only construct these through [`blockRange`](https://prosemirror.net/docs/ref/version/0.22.0.html#model.ResolvedPos.blockRange)). 572 | 573 | ## 0.21.0 (2017-05-03) 574 | 575 | ### Breaking changes 576 | 577 | The `openLeft` and `openRight` properties of `Slice` objects have been renamed to [`openStart`](https://prosemirror.net/docs/ref/version/0.21.0.html#model.Slice.openStart) and [`openEnd`](https://prosemirror.net/docs/ref/version/0.21.0.html#model.Slice.openEnd) to avoid confusion in right-to-left text. The old names will continue to work with a warning until the next release. 578 | 579 | ### New features 580 | 581 | Mark [serializing functions](https://prosemirror.net/docs/ref/version/0.21.0.html#model.MarkSpec.toDOM) now get a second parameter that indicates whether the mark's content is inline or block nodes. 582 | 583 | Setting a mark serializer to `null` in a [`DOMSerializer`](https://prosemirror.net/docs/ref/version/0.21.0.html#model.DOMSerializer) can now be used to omit that mark when serializing. 584 | 585 | Node specs support a new property [`isolating`](https://prosemirror.net/docs/ref/version/0.21.0.html#model.NodeSpec.isolating), which is used to disable editing actions like backspacing and lifting across such a node's boundaries. 586 | 587 | ## 0.20.0 (2017-04-03) 588 | 589 | ### Breaking changes 590 | 591 | Newlines in the text are now normalized to spaces when parsing except when you set `preserveWhitespace` to `"full"` in your [options](https://prosemirror.net/docs/ref/version/0.20.0.html#model.DOMParser.parse) or in a [parse rule](https://prosemirror.net/docs/ref/version/0.20.0.html#model.ParseRule.preserveWhitespace). 592 | 593 | ### Bug fixes 594 | 595 | Fix crash in IE when parsing DOM content. 596 | 597 | ### New features 598 | 599 | Fragments now have [`nodesBetween`](https://prosemirror.net/docs/ref/version/0.20.0.html#model.Fragment.nodesBetween) and [`descendants`](https://prosemirror.net/docs/ref/version/0.20.0.html#model.Fragments.descendants) methods, providing the same functionality as the methods by the same name on nodes. 600 | 601 | Resolved positions now have [`max`](https://prosemirror.net/docs/ref/version/0.20.0.html#model.ResolvedPos.max) and [`min`](https://prosemirror.net/docs/ref/version/0.20.0.html#model.ResolvedPos.min) methods to easily find a maximum or minimum position. 602 | 603 | ## 0.19.0 (2017-03-16) 604 | 605 | ### Breaking changes 606 | 607 | `MarkSpec.inclusiveRight` was replaced by [`inclusive`](https://prosemirror.net/docs/ref/version/0.19.0.html#model.MarkSpec.inclusive), which behaves slightly differently. `inclusiveRight` will be interpreted as `inclusive` (with a warning) until the next release. 608 | 609 | ### New features 610 | 611 | The new [`inlineContent`](https://prosemirror.net/docs/ref/version/0.19.0.html#model.Node.inlineContent) property on nodes and node types tells you whether a node type supports inline content. 612 | 613 | [`MarkSpec.inclusive`](https://prosemirror.net/docs/ref/version/0.19.0.html#model.MarkSpec.inclusive) can now be used to control whether content inserted at the boundary of a mark receives that mark. 614 | 615 | Parse rule [`context`](https://prosemirror.net/docs/ref/version/0.19.0.html#model.ParseRule.context) restrictions can now use node [groups](https://prosemirror.net/docs/ref/version/0.19.0.html#model.NodeSpec.group), not just node names, to specify valid context. 616 | 617 | ## 0.18.0 (2017-02-24) 618 | 619 | ### Breaking changes 620 | 621 | `schema.nodeSpec` and `schema.markSpec` have been deprecated in favor of [`schema.spec`](https://prosemirror.net/docs/ref/version/0.18.0.html#model.Schema.spec). The properties still work with a warning in this release, but will be dropped in the next. 622 | 623 | ### New features 624 | 625 | `Node` objects now have a [`check`](https://prosemirror.net/docs/ref/version/0.18.0.html#model.Node.check) method which can be used to assert that they conform to the schema. 626 | 627 | Node specs now support an [`atom` property](https://prosemirror.net/docs/ref/version/0.18.0.html#model.NodeSpec.atom), and nodes an [`isAtom` accessor](https://prosemirror.net/docs/ref/version/0.18.0.html#model.Node.isAtom), which is currently only used to determine whether such nodes should be directly selectable (for example when they are rendered as an uneditable node view). 628 | 629 | The new [`excludes`](https://prosemirror.net/docs/ref/version/0.18.0.html#model.MarkSpec.excludes) field on mark specs can be used to control the marks that this mark may coexist with. Mark type objects also gained an [`excludes` _method_](https://prosemirror.net/docs/ref/version/0.18.0.html#model.MarkType.excludes) to querty this relation. 630 | 631 | Mark specs now support a [`group`](https://prosemirror.net/docs/ref/version/0.18.0.html#model.MarkSpec.group) property, and marks can be referred to by group name in content specs. 632 | 633 | The `Schema` class now provides its whole [spec](https://prosemirror.net/docs/ref/version/0.18.0.html#model.SchemaSpec) under its [`spec`](https://prosemirror.net/docs/ref/version/0.18.0.html#model.Schema.spec) property. 634 | 635 | The name of a schema's default top-level node is now [configurable](https://prosemirror.net/docs/ref/version/0.18.0.html#model.SchemaSpec.topNode). You can use [`schema.topNodeType`](https://prosemirror.net/docs/ref/version/0.18.0.html#model.Schema.topNodeType) to retrieve the top node type. 636 | 637 | [Parse rules](https://prosemirror.net/docs/ref/version/0.18.0.html#model.ParseRule) now support a [`context` field](https://prosemirror.net/docs/ref/version/0.18.0.html#model.ParseRule.context) that can be used to only make the rule match inside certain ancestor nodes. 638 | 639 | ## 0.17.0 (2017-01-05) 640 | 641 | ### Breaking changes 642 | 643 | `Node.marksAt` was replaced with [`ResolvedPos.marks`](https://prosemirror.net/docs/ref/version/0.17.0.html#model.ResolvedPos.marks). It still works (with a warning) in this release, but will be removed in the next one. 644 | 645 | ## 0.15.0 (2016-12-10) 646 | 647 | ### Breaking changes 648 | 649 | `ResolvedPos.atNodeBoundary` is deprecated and will be removed in the next release. Use `textOffset > 0` instead. 650 | 651 | ### New features 652 | 653 | Parse rules associated with a schema can now specify a [`priority`](https://prosemirror.net/docs/ref/version/0.15.0.html#model.ParseRule.priority) to influence the order in which they are applied. 654 | 655 | Resolved positions have a new getter [`textOffset`](https://prosemirror.net/docs/ref/version/0.15.0.html#model.ResolvedPos.textOffset) to find their position within a text node (if any). 656 | 657 | ## 0.14.1 (2016-11-30) 658 | 659 | ### Bug fixes 660 | 661 | [`DOMParser.parseSlice`](https://prosemirror.net/docs/ref/version/0.14.0.html#model.DOMParser.parseSlice) will now ignore whitespace-only text nodes at the top of the slice. 662 | 663 | ## 0.14.0 (2016-11-28) 664 | 665 | ### New features 666 | 667 | Parse rules now support [`skip`](https://prosemirror.net/docs/ref/version/0.14.0.html#model.ParseRule.skip) (skip outer element, parse content) and [`getContent`](https://prosemirror.net/docs/ref/version/0.14.0.html#model.ParseRule.getContent) (compute content using custom code) properties. 668 | 669 | The `DOMSerializer` class now exports a static [`renderSpec`](https://prosemirror.net/docs/ref/version/0.14.0.html#model.DOMSerializer^renderSpec) method that can help render DOM spec arrays. 670 | 671 | ## 0.13.0 (2016-11-11) 672 | 673 | ### Breaking changes 674 | 675 | `ResolvedPos.sameDepth` is now called [`ResolvedPos.sharedDepth`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.ResolvedPos.sharedDepth), and takes a raw, unresolved position as argument. 676 | 677 | ### New features 678 | 679 | [`DOMSerializer`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.DOMSerializer)'s `nodes` and `marks` properties are now public. 680 | 681 | [`ContentMatch.findWrapping`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.ContentMatch.findWrapping) now takes a third argument, `marks`. There's a new method [`findWrappingFor`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.ContentMatch.findWrappingFor) that accepts a whole node. 682 | 683 | Adds [`Slice.maxOpen`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.Slice^maxOpen) static method to create maximally open slices. 684 | 685 | DOM parser objects now have a [`parseSlice`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.DOMParser.parseSlice) method which parses an HTML fragment into a [`Slice`](https://prosemirror.net/docs/ref/version/0.13.0.html#model.Slice), rather than trying to create a whole document from it. 686 | 687 | ## 0.12.0 (2016-10-21) 688 | 689 | ### Breaking changes 690 | 691 | Drops support for some undocumented options to the DOM 692 | serializer that were used by the view. 693 | 694 | ### Bug fixes 695 | 696 | When rendering DOM attributes, only ignore null values, not all 697 | falsy values. 698 | 699 | ## 0.11.0 (2016-09-21) 700 | 701 | ### Breaking changes 702 | 703 | Moved into a separate module. 704 | 705 | The JSON representation of [marks](https://prosemirror.net/docs/ref/version/0.11.0.html#model.Mark) has changed from 706 | `{"_": "type", "attr1": "value"}` to `{"type": "type", "attrs": 707 | {"attr1": "value"}}`, where `attrs` may be omitted when the mark has 708 | no attributes. 709 | 710 | Mark-related JSON methods now live on the 711 | [`Mark` class](https://prosemirror.net/docs/ref/version/0.11.0.html#model.Mark^fromJSON). 712 | 713 | The way node and mark types in a schema are defined was changed from 714 | defining subclasses to passing plain objects 715 | ([`NodeSpec`](https://prosemirror.net/docs/ref/version/0.11.0.html#model.NodeSpec) and [`MarkSpec`](https://prosemirror.net/docs/ref/version/0.11.0.html#model.MarkSpec)). 716 | 717 | DOM serialization and parsing logic is now done through dedicated 718 | objects ([`DOMSerializer`](https://prosemirror.net/docs/ref/version/0.11.0.html#model.DOMSerializer) and 719 | [`DOMParser`](https://prosemirror.net/docs/ref/version/0.11.0.html#model.DOMParser)), rather than through the schema. It 720 | is now possible to define alternative parsing and serializing 721 | strategies without touching the schema. 722 | 723 | ### New features 724 | 725 | The [`Slice`](https://prosemirror.net/docs/ref/version/0.11.0.html#model.Slice) class now has an [`eq` method](https://prosemirror.net/docs/ref/version/0.11.0.html#model.Slice.eq). 726 | 727 | The [`Node.marksAt`](https://prosemirror.net/docs/ref/version/0.11.0.html#model.Node.marksAt) method got a second 728 | parameter to indicate you're interested in the marks _after_ the 729 | position. 730 | 731 | -------------------------------------------------------------------------------- /test/test-dom.ts: -------------------------------------------------------------------------------- 1 | import {schema, eq, doc, blockquote, pre, h1, h2, p, li, ol, ul, em, strong, code, a, br, img, hr, 2 | builders} from "prosemirror-test-builder" 3 | import ist from "ist" 4 | import {DOMParser, DOMSerializer, Slice, Fragment, Schema, Node as PMNode, Mark, 5 | ParseOptions, ParseRule} from "prosemirror-model" 6 | 7 | // @ts-ignore 8 | import {JSDOM} from "jsdom" 9 | const document = new JSDOM().window.document 10 | const xmlDocument = new JSDOM("", {contentType: "application/xml"}).window.document 11 | 12 | const parser = DOMParser.fromSchema(schema) 13 | const serializer = DOMSerializer.fromSchema(schema) 14 | 15 | describe("DOMParser", () => { 16 | describe("parse", () => { 17 | function domFrom(html: string, document_ = document) { 18 | let dom = document_.createElement("div") 19 | dom.innerHTML = html 20 | return dom 21 | } 22 | 23 | function test(doc: PMNode, html: string, document_ = document) { 24 | return () => { 25 | let derivedDOM = document_.createElement("div"), schema = doc.type.schema 26 | derivedDOM.appendChild(DOMSerializer.fromSchema(schema).serializeFragment(doc.content, {document: document_})) 27 | let declaredDOM = domFrom(html, document_) 28 | 29 | ist(derivedDOM.innerHTML, declaredDOM.innerHTML) 30 | ist(DOMParser.fromSchema(schema).parse(derivedDOM), doc, eq) 31 | } 32 | } 33 | 34 | it("can represent simple node", 35 | test(doc(p("hello")), 36 | "

hello

")) 37 | 38 | it("can represent a line break", 39 | test(doc(p("hi", br(), "there")), 40 | "

hi
there

")) 41 | 42 | it("can represent an image", 43 | test(doc(p("hi", img({alt: "x"}), "there")), 44 | '

hixthere

')) 45 | 46 | it("joins styles", 47 | test(doc(p("one", strong("two", em("three")), em("four"), "five")), 48 | "

onetwothreefourfive

")) 49 | 50 | it("can represent links", 51 | test(doc(p("a ", a({href: "foo"}, "big ", a({href: "bar"}, "nested"), " link"))), 52 | "

a big nested link

")) 53 | 54 | it("can represent and unordered list", 55 | test(doc(ul(li(p("one")), li(p("two")), li(p("three", strong("!")))), p("after")), 56 | "
  • one

  • two

  • three!

after

")) 57 | 58 | it("can represent an ordered list", 59 | test(doc(ol(li(p("one")), li(p("two")), li(p("three", strong("!")))), p("after")), 60 | "
  1. one

  2. two

  3. three!

after

")) 61 | 62 | it("can represent a blockquote", 63 | test(doc(blockquote(p("hello"), p("bye"))), 64 | "

hello

bye

")) 65 | 66 | it("can represent a nested blockquote", 67 | test(doc(blockquote(blockquote(blockquote(p("he said"))), p("i said"))), 68 | "

he said

i said

")) 69 | 70 | it("can represent headings", 71 | test(doc(h1("one"), h2("two"), p("text")), 72 | "

one

two

text

")) 73 | 74 | it("can represent inline code", 75 | test(doc(p("text and ", code("code that is ", em("emphasized"), "..."))), 76 | "

text and code that is emphasized...

")) 77 | 78 | it("can represent a code block", 79 | test(doc(blockquote(pre("some code")), p("and")), 80 | "
some code

and

")) 81 | 82 | it("supports leaf nodes in marks", 83 | test(doc(p(em("hi", br(), "x"))), 84 | "

hi
x

")) 85 | 86 | it("doesn't collapse non-breaking spaces", 87 | test(doc(p("\u00a0 \u00a0hello\u00a0")), 88 | "

\u00a0 \u00a0hello\u00a0

")) 89 | 90 | it("can parse marks on block nodes", () => { 91 | let commentSchema = new Schema({ 92 | nodes: schema.spec.nodes.update("doc", Object.assign({marks: "comment"}, schema.spec.nodes.get("doc"))), 93 | marks: schema.spec.marks.update("comment", { 94 | parseDOM: [{tag: "div.comment"}], 95 | toDOM() { return ["div", {class: "comment"}, 0] } 96 | }) 97 | }) 98 | let b = builders(commentSchema) as any 99 | test(b.doc(b.paragraph("one"), b.comment(b.paragraph("two"), b.paragraph(b.strong("three"))), b.paragraph("four")), 100 | "

one

two

three

four

")() 101 | }) 102 | 103 | it("parses unique, non-exclusive, same-typed marks", () => { 104 | let commentSchema = new Schema({ 105 | nodes: schema.spec.nodes, 106 | marks: schema.spec.marks.update("comment", { 107 | attrs: { id: { default: null }}, 108 | parseDOM: [{ 109 | tag: "span.comment", 110 | getAttrs(dom: HTMLElement) { return { id: parseInt(dom.getAttribute('data-id')!, 10) } } 111 | }], 112 | excludes: '', 113 | toDOM(mark: Mark) { return ["span", {class: "comment", "data-id": mark.attrs.id }, 0] } 114 | }) 115 | }) 116 | let b = builders(commentSchema) 117 | test(b.schema.nodes.doc.createAndFill(undefined, [ 118 | b.schema.nodes.paragraph.createAndFill(undefined, [ 119 | b.schema.text('double comment', [ 120 | b.schema.marks.comment.create({ id: 1 }), 121 | b.schema.marks.comment.create({ id: 2 }) 122 | ])! 123 | ])! 124 | ])!, 125 | "

double comment

")() 126 | }) 127 | 128 | it("serializes non-spanning marks correctly", () => { 129 | let markSchema = new Schema({ 130 | nodes: schema.spec.nodes, 131 | marks: schema.spec.marks.update("test", { 132 | parseDOM: [{tag: "test"}], 133 | toDOM() { return ["test", 0] }, 134 | spanning: false 135 | }) 136 | }) 137 | let b = builders(markSchema) as any 138 | test(b.doc(b.paragraph(b.test("a", b.image({src: "x"}), "b"))), 139 | "

ab

")() 140 | }) 141 | 142 | it("serializes an element and an attribute with XML namespace", () => { 143 | let xmlnsSchema = new Schema({ 144 | nodes: { 145 | doc: { content: "svg*" }, text: {}, 146 | "svg": { 147 | parseDOM: [{tag: "svg", namespace: 'http://www.w3.org/2000/svg'}], 148 | group: 'block', 149 | toDOM() { return ["http://www.w3.org/2000/svg svg", ["use", { "http://www.w3.org/1999/xlink href": "#svg-id" }]] }, 150 | }, 151 | }, 152 | }) 153 | 154 | let b = builders(xmlnsSchema) as any 155 | let d = b.doc(b.svg()) 156 | test(d, '', xmlDocument)() 157 | 158 | let dom = xmlDocument.createElement('div') 159 | dom.appendChild(DOMSerializer.fromSchema(xmlnsSchema).serializeFragment(d.content, {document: xmlDocument})) 160 | ist(dom.querySelector('svg').namespaceURI, 'http://www.w3.org/2000/svg') 161 | ist(dom.querySelector('use').namespaceURI, 'http://www.w3.org/2000/svg') 162 | ist(dom.querySelector('use').attributes[0].namespaceURI, 'http://www.w3.org/1999/xlink') 163 | }) 164 | 165 | function recover(html: string | HTMLElement, doc: PMNode, options?: ParseOptions) { 166 | return () => { 167 | let dom = document.createElement("div") 168 | if (typeof html == "string") dom.innerHTML = html 169 | else dom.appendChild(html) 170 | ist(parser.parse(dom, options), doc, eq) 171 | } 172 | } 173 | 174 | it("can recover a list item", 175 | recover("

    Oh no

", 176 | doc(ol(li(p("Oh no")))))) 177 | 178 | it("wraps a list item in a list", 179 | recover("
  • hey
  • ", 180 | doc(ol(li(p("hey")))))) 181 | 182 | it("can turn divs into paragraphs", 183 | recover("
    hi
    bye
    ", 184 | doc(p("hi"), p("bye")))) 185 | 186 | it("interprets and as emphasis and strong", 187 | recover("

    hello there

    ", 188 | doc(p(em("hello ", strong("there")))))) 189 | 190 | it("wraps stray text in a paragraph", 191 | recover("hi", 192 | doc(p("hi")))) 193 | 194 | it("ignores an extra wrapping
    ", 195 | recover("

    one

    two

    ", 196 | doc(p("one"), p("two")))) 197 | 198 | it("ignores meaningless whitespace", 199 | recover("

    woo \n hooo

    ", 200 | doc(blockquote(p("woo ", em("hooo")))))) 201 | 202 | it("removes whitespace after a hard break", 203 | recover("

    hello
    \n world

    ", 204 | doc(p("hello", br(), "world")))) 205 | 206 | it("converts br nodes to newlines when they would otherwise be ignored", 207 | recover("
    foo
    bar
    ", 208 | doc(pre("foo\nbar")))) 209 | 210 | it("finds a valid place for invalid content", 211 | recover("
    • hi
    • whoah

    • again
    ", 212 | doc(ul(li(p("hi")), li(p("whoah")), li(p("again")))))) 213 | 214 | it("moves nodes up when they don't fit the current context", 215 | recover("
    hello
    bye
    ", 216 | doc(p("hello"), hr(), p("bye")))) 217 | 218 | it("doesn't ignore whitespace-only text nodes", 219 | recover("

    one two

    ", 220 | doc(p(em("one"), " ", strong("two"))))) 221 | 222 | it("can handle stray tab characters", 223 | recover("

    ", 224 | doc(p()))) 225 | 226 | it("normalizes random spaces", 227 | recover("

    1

    ", 228 | doc(p(strong("1"))))) 229 | 230 | it("can parse an empty code block", 231 | recover("
    ",
    232 |                doc(pre())))
    233 | 
    234 |     it("preserves trailing space in a code block",
    235 |        recover("
    foo\n
    ", 236 | doc(pre("foo\n")))) 237 | 238 | it("normalizes newlines when preserving whitespace", 239 | recover("

    foo bar\nbaz

    ", 240 | doc(p("foo bar baz")), {preserveWhitespace: true})) 241 | 242 | it("ignores !

    ", 244 | doc(p("hello!")))) 245 | 246 | it("can handle a head/body input structure", 247 | recover("Thi", 248 | doc(p("hi")))) 249 | 250 | it("only applies a mark once", 251 | recover("

    A big strong monster.

    ", 252 | doc(p("A ", strong("big strong monster"), ".")))) 253 | 254 | it("interprets font-style: italic as em", 255 | recover("

    Hello!

    ", 256 | doc(p(em("Hello"), "!")))) 257 | 258 | it("interprets font-weight: bold as strong", 259 | recover("

    Hello

    ", 260 | doc(p(strong("Hello"))))) 261 | 262 | it("allows clearing of pending marks", 263 | recover("

    One

    Two

  • Foo" + 268 | "Bar

  • ", 269 | doc(ul(li(p(em("Foo"), "Bar")))))) 270 | 271 | it("ignores unknown inline tags", 272 | recover("

    abc

    ", 273 | doc(p("abc")))) 274 | 275 | it("can add marks specified before their parent node is opened", 276 | recover("hi you", 277 | doc(p(em("hi"), " you")))) 278 | 279 | it("keeps applying a mark for the all of the node's content", 280 | recover("

    xxbar

    ", 281 | doc(p(strong("xxbar"))))) 282 | 283 | it("doesn't ignore whitespace-only nodes in preserveWhitespace full mode", 284 | recover(" x", doc(p(" x")), {preserveWhitespace: "full"})) 285 | 286 | it("closes block with inline content on seeing block-level children", 287 | recover("

    CCC
    DDD

    ", 288 | doc(p(br()), p("CCC"), p("DDD"), p(br())))) 289 | 290 | it("can move a block node out of a paragraph", () => { 291 | let dom = document.createElement("p") 292 | dom.appendChild(document.createTextNode("Hello")) 293 | dom.appendChild(document.createElement("hr")) 294 | recover(dom, doc(p("Hello"), hr()))() 295 | }) 296 | 297 | function parse(html: string, options: ParseOptions, doc: PMNode) { 298 | return () => { 299 | let dom = document.createElement("div") 300 | dom.innerHTML = html 301 | let result = parser.parse(dom, options) 302 | ist(result, doc, eq) 303 | } 304 | } 305 | 306 | it("accepts the topNode option", 307 | parse("
  • wow
  • such
  • ", {topNode: schema.nodes.bullet_list.createAndFill()!}, 308 | ul(li(p("wow")), li(p("such"))))) 309 | 310 | let item = schema.nodes.list_item.createAndFill()! 311 | it("accepts the topMatch option", 312 | parse("
    • x
    ", {topNode: item, topMatch: item.contentMatchAt(1)!}, 313 | li(ul(li(p("x")))))) 314 | 315 | it("accepts from and to options", 316 | parse("

    foo

    bar

    ", {from: 1, to: 3}, 317 | doc(p("foo"), p("bar")))) 318 | 319 | it("accepts the preserveWhitespace option", 320 | parse("foo bar", {preserveWhitespace: true}, 321 | doc(p("foo bar")))) 322 | 323 | function open(html: string, nodes: (string | PMNode)[], openStart: number, openEnd: number, options?: ParseOptions) { 324 | return () => { 325 | let dom = document.createElement("div") 326 | dom.innerHTML = html 327 | let result = parser.parseSlice(dom, options) 328 | ist(result, new Slice(Fragment.from(nodes.map(n => typeof n == "string" ? schema.text(n) : n)), openStart, openEnd), eq) 329 | } 330 | } 331 | 332 | it("can parse an open slice", 333 | open("foo", ["foo"], 0, 0)) 334 | 335 | it("will accept weird siblings", 336 | open("foo

    bar

    ", ["foo", p("bar")], 0, 1)) 337 | 338 | it("will open all the way to the inner nodes", 339 | open("
    • foo
    • bar
    ", [ul(li(p("foo")), li(p("bar", br())))], 3, 3)) 340 | 341 | it("accepts content open to the left", 342 | open("
    • a
  • ", [li(ul(li(p("a"))))], 4, 4)) 343 | 344 | it("accepts content open to the right", 345 | open("
  • foo
  • ", [li(p("foo")), li()], 2, 1)) 346 | 347 | it("will create textblocks for block nodes", 348 | open("
    foo
    bar
    ", [p("foo"), p("bar")], 1, 1)) 349 | 350 | it("can parse marks at the start of defaulted textblocks", 351 | open("
    foo
    bar
    ", 352 | [p("foo"), p(em("bar"))], 1, 1)) 353 | 354 | it("will not apply invalid marks to nodes", 355 | open("
    • foo
    ", [ul(li(p(strong("foo"))))], 3, 3)) 356 | 357 | it("will apply pending marks from parents to all children", 358 | open("
    • foo
    • bar
    ", [ul(li(p(strong("foo"))), li(p(strong("bar"))))], 3, 3)) 359 | 360 | it("can parse nested mark with same type", 361 | open("

    foobarbaz

    ", 362 | [p(strong("foobarbaz"))], 1, 1)) 363 | 364 | it("drops block-level whitespace", 365 | open("
    ", [], 0, 0, {preserveWhitespace: true})) 366 | 367 | it("keeps whitespace in inline elements", 368 | open(" ", [p(strong(" ")).child(0)], 0, 0, {preserveWhitespace: true})) 369 | 370 | it("can parse nested mark with same type but different attrs", () => { 371 | let markSchema = new Schema({ 372 | nodes: schema.spec.nodes, 373 | marks: schema.spec.marks.update("s", { 374 | attrs: { 375 | 'data-s': { default: 'tag' } 376 | }, 377 | excludes: '', 378 | parseDOM: [{ 379 | tag: "s", 380 | }, { 381 | style: "text-decoration", 382 | getAttrs() { 383 | return { 384 | 'data-s': 'style' 385 | } 386 | } 387 | }] 388 | }) 389 | }) 390 | let b = builders(markSchema) 391 | let dom = document.createElement("div") 392 | dom.innerHTML = "

    ooo

    " 393 | let result = DOMParser.fromSchema(markSchema).parseSlice(dom) 394 | ist(result, new Slice(Fragment.from( 395 | b.schema.nodes.paragraph.create( 396 | undefined, 397 | [ 398 | b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]), 399 | b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' }), b.schema.marks.s.create({ 'data-s': 'tag' })]), 400 | b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]) 401 | ] 402 | ) 403 | ), 1, 1), eq) 404 | 405 | dom.innerHTML = "

    ooo

    " 406 | result = DOMParser.fromSchema(markSchema).parseSlice(dom) 407 | ist(result, new Slice(Fragment.from( 408 | b.schema.nodes.paragraph.create( 409 | undefined, 410 | [ 411 | b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' }), b.schema.marks.s.create({ 'data-s': 'tag' })]), 412 | b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]), 413 | b.schema.text('o') 414 | ] 415 | ) 416 | ), 1, 1), eq) 417 | }) 418 | 419 | it("can temporary shadow a mark with another configuration of the same type", () => { 420 | let s = new Schema({nodes: schema.spec.nodes, marks: {color: { 421 | attrs: {color: {}}, 422 | toDOM: m => ["span", {style: `color: ${m.attrs.color}`}], 423 | parseDOM: [{style: "color", getAttrs: v => ({color: v})}] 424 | }}}) 425 | let d = DOMParser.fromSchema(s) 426 | .parse(domFrom('

    abcdefghi

    ')) 427 | ist(d, s.node("doc", null, [s.node("paragraph", null, [ 428 | s.text("abc", [s.mark("color", {color: "red"})]), 429 | s.text("def", [s.mark("color", {color: "blue"})]), 430 | s.text("ghi", [s.mark("color", {color: "red"})]) 431 | ])]), eq) 432 | }) 433 | 434 | function find(html: string, doc: PMNode) { 435 | return () => { 436 | let dom = document.createElement("div") 437 | dom.innerHTML = html 438 | let tag = dom.querySelector("var"), prev = tag.previousSibling!, next = tag.nextSibling, pos 439 | if (prev && next && prev.nodeType == 3 && next.nodeType == 3) { 440 | pos = {node: prev, offset: prev.nodeValue.length} 441 | prev.nodeValue += next.nodeValue 442 | next.parentNode.removeChild(next) 443 | } else { 444 | pos = {node: tag.parentNode, offset: Array.prototype.indexOf.call(tag.parentNode.childNodes, tag)} 445 | } 446 | tag.parentNode.removeChild(tag) 447 | let result = parser.parse(dom, { 448 | findPositions: [pos] 449 | }) 450 | ist(result, doc, eq) 451 | ist((pos as any).pos, (doc as any).tag.a) 452 | } 453 | } 454 | 455 | it("can find a position at the start of a paragraph", 456 | find("

    hello

    ", 457 | doc(p("hello")))) 458 | 459 | it("can find a position at the end of a paragraph", 460 | find("

    hello

    ", 461 | doc(p("hello
    ")))) 462 | 463 | it("can find a position inside text", 464 | find("

    hello

    ", 465 | doc(p("hel
    lo")))) 466 | 467 | it("can find a position inside an ignored node", 468 | find("

    hi

    foo

    ok

    ", 469 | doc(p("hi"), "
    ", p("ok")))) 470 | 471 | it("can find a position between nodes", 472 | find("
    • foo
    • bar
    ", 473 | doc(ul(li(p("foo")), "
    ", li(p("bar")))))) 474 | 475 | it("can find a position at the start of the document", 476 | find("

    hi

    ", 477 | doc("
    ", p("hi")))) 478 | 479 | it("can find a position at the end of the document", 480 | find("

    hi

    ", 481 | doc(p("hi"), "
    "))) 482 | 483 | let quoteSchema = new Schema({nodes: schema.spec.nodes, marks: schema.spec.marks, topNode: "blockquote"}) 484 | 485 | it("uses a custom top node when parsing", 486 | test(quoteSchema.node("blockquote", null, quoteSchema.node("paragraph", null, quoteSchema.text("hello"))), 487 | "

    hello

    ")) 488 | 489 | function contextParser(context: string) { 490 | return new DOMParser(schema, [{tag: "foo", node: "horizontal_rule", context} as ParseRule] 491 | .concat(DOMParser.schemaRules(schema) as ParseRule[])) 492 | } 493 | 494 | it("recognizes context restrictions", () => { 495 | ist(contextParser("blockquote/").parse(domFrom("

    ")), 496 | doc(blockquote(hr(), p())), eq) 497 | }) 498 | 499 | it("accepts group names in contexts", () => { 500 | ist(contextParser("block/").parse(domFrom("

    ")), 501 | doc(blockquote(hr(), p())), eq) 502 | }) 503 | 504 | it("understands nested context restrictions", () => { 505 | ist(contextParser("blockquote/ordered_list//") 506 | .parse(domFrom("
    1. a

    ")), 507 | doc(blockquote(ol(li(p("a"), hr())))), eq) 508 | }) 509 | 510 | it("understands double slashes in context restrictions", () => { 511 | ist(contextParser("blockquote//list_item/") 512 | .parse(domFrom("
    1. a

    ")), 513 | doc(blockquote(ol(li(p("a"), hr())))), eq) 514 | }) 515 | 516 | it("understands pipes in context restrictions", () => { 517 | ist(contextParser("list_item/|blockquote/") 518 | .parse(domFrom("

    1. a

    ")), 519 | doc(blockquote(p(), hr()), ol(li(p("a"), hr()))), eq) 520 | }) 521 | 522 | it("uses the passed context", () => { 523 | let cxDoc = doc(blockquote("
    ", hr())) 524 | ist(contextParser("doc//blockquote/").parse(domFrom("
    "), { 525 | topNode: blockquote(), 526 | context: cxDoc.resolve((cxDoc as any).tag.a) 527 | }), blockquote(blockquote(hr())), eq) 528 | }) 529 | 530 | it("uses the passed context when parsing a slice", () => { 531 | let cxDoc = doc(blockquote("
    ", hr())) 532 | ist(contextParser("doc//blockquote/").parseSlice(domFrom(""), { 533 | context: cxDoc.resolve((cxDoc as any).tag.a) 534 | }), new Slice(blockquote(hr()).content, 0, 0), eq) 535 | }) 536 | 537 | it("can close parent nodes from a rule", () => { 538 | let closeParser = new DOMParser(schema, [{tag: "br", closeParent: true} as ParseRule] 539 | .concat(DOMParser.schemaRules(schema))) 540 | ist(closeParser.parse(domFrom("

    one
    two

    ")), doc(p("one"), p("two")), eq) 541 | }) 542 | 543 | it("supports non-consuming node rules", () => { 544 | let parser = new DOMParser(schema, [{tag: "ol", consuming: false, node: "blockquote"} as ParseRule] 545 | .concat(DOMParser.schemaRules(schema))) 546 | ist(parser.parse(domFrom("

      one

    ")), doc(blockquote(ol(li(p("one"))))), eq) 547 | }) 548 | 549 | it("supports non-consuming style rules", () => { 550 | let parser = new DOMParser(schema, [{style: "font-weight", consuming: false, mark: "em"} as ParseRule] 551 | .concat(DOMParser.schemaRules(schema))) 552 | ist(parser.parse(domFrom("

    one

    ")), doc(p(em(strong("one")))), eq) 553 | }) 554 | 555 | it("doesn't get confused by nested mark tags", 556 | recover("
    AB
    C", 557 | doc(p(strong("A"), "B"), p("C")))) 558 | 559 | it("ignores styles on skipped nodes", () => { 560 | let dom = document.createElement("div") 561 | dom.innerHTML = "

    abc def

    " 562 | ist(parser.parse(dom, { 563 | ruleFromNode: node => { 564 | return node.nodeType == 1 && (node as HTMLElement).tagName == "SPAN" ? {skip: node as any} : null 565 | } 566 | }), doc(p("abc def")), eq) 567 | 568 | }) 569 | 570 | it("preserves whitespace in
     elements", () => {
    571 |       let schema = new Schema({nodes: {
    572 |         doc: {content: "block+"},
    573 |         text: {group: "inline"},
    574 |         p: {group: "block", content: "inline*"}
    575 |       }})
    576 |       ist(DOMParser.fromSchema(schema).parse(domFrom("
      hello 
    ")), 577 | schema.node("doc", null, [schema.node("p", null, [schema.text(" hello ")])]), eq) 578 | }) 579 | 580 | it("preserves whitespace in nodes styled with white-space", () => { 581 | recover("
    okay then

    x

    ", 582 | doc(p(" okay then "), p("x"))) 583 | }) 584 | 585 | it("inserts line break replacements", () => { 586 | let s = new Schema({ 587 | nodes: schema.spec.nodes.update("hard_break", {...schema.spec.nodes.get("hard_break"), linebreakReplacement: true}) 588 | }) 589 | ist(DOMParser.fromSchema(s).parse(domFrom("

    one\ntwo\n\nthree

    ")).toString(), 590 | 'doc(paragraph("one", hard_break, "two", hard_break, hard_break, "three"))') 591 | ist(DOMParser.fromSchema(s).parse(domFrom("

    one\ntwo\n\nthree

    ")).toString(), 592 | 'doc(paragraph("one two three"))') 593 | }) 594 | }) 595 | 596 | describe("schemaRules", () => { 597 | it("defaults to schema order", () => { 598 | let schema = new Schema({ 599 | marks: {em: {parseDOM: [{tag: "i"}, {tag: "em"}]}}, 600 | nodes: {doc: {content: "inline*"}, 601 | text: {group: "inline"}, 602 | foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, 603 | bar: {group: "inline", inline: true, parseDOM: [{tag: "bar"}]}} 604 | }) 605 | ist(DOMParser.schemaRules(schema).map(r => (r as any).tag).join(" "), "i em foo bar") 606 | }) 607 | 608 | it("understands priority", () => { 609 | let schema = new Schema({ 610 | marks: {em: {parseDOM: [{tag: "i", priority: 40}, {tag: "em", priority: 70}]}}, 611 | nodes: {doc: {content: "inline*"}, 612 | text: {group: "inline"}, 613 | foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, 614 | bar: {group: "inline", inline: true, parseDOM: [{tag: "bar", priority: 60}]}} 615 | }) 616 | ist(DOMParser.schemaRules(schema).map(r => (r as any).tag).join(" "), "em bar foo i") 617 | }) 618 | 619 | function nsParse(doc: Node, namespace?: string) { 620 | let schema = new Schema({ 621 | nodes: {doc: {content: "h*"}, text: {}, 622 | h: {parseDOM: [{tag: "h", namespace}]}} 623 | }) 624 | return DOMParser.fromSchema(schema).parse(doc) 625 | } 626 | 627 | it("includes nodes when namespace is correct", () => { 628 | let doc = xmlDocument.createElement("doc") 629 | let h = xmlDocument.createElementNS("urn:ns", "h") 630 | doc.appendChild(h) 631 | ist(nsParse(doc, "urn:ns").childCount, 1) 632 | }) 633 | 634 | it("excludes nodes when namespace is wrong", () => { 635 | let doc = xmlDocument.createElement("doc") 636 | let h = xmlDocument.createElementNS("urn:nt", "h") 637 | doc.appendChild(h) 638 | ist(nsParse(doc, "urn:ns").childCount, 0) 639 | }) 640 | 641 | it("excludes nodes when namespace is absent", () => { 642 | let doc = xmlDocument.createElement("doc") 643 | // in HTML documents, createElement gives namespace 644 | // 'http://www.w3.org/1999/xhtml' so use createElementNS 645 | let h = xmlDocument.createElementNS(null, "h") 646 | doc.appendChild(h) 647 | ist(nsParse(doc, "urn:ns").childCount, 0) 648 | }) 649 | 650 | it("excludes nodes when namespace is wrong and xhtml", () => { 651 | let doc = xmlDocument.createElement("doc") 652 | let h = xmlDocument.createElementNS("urn:nt", "h") 653 | doc.appendChild(h) 654 | ist(nsParse(doc, "http://www.w3.org/1999/xhtml").childCount, 0) 655 | }) 656 | 657 | it("excludes nodes when namespace is wrong and empty", () => { 658 | let doc = xmlDocument.createElement("doc") 659 | let h = xmlDocument.createElementNS("urn:nt", "h") 660 | doc.appendChild(h) 661 | ist(nsParse(doc, "").childCount, 0) 662 | }) 663 | 664 | it("includes nodes when namespace is correct and empty", () => { 665 | let doc = xmlDocument.createElement("doc") 666 | let h = xmlDocument.createElementNS(null, "h") 667 | doc.appendChild(h) 668 | ist(nsParse(doc).childCount, 1) 669 | }) 670 | }) 671 | }) 672 | 673 | describe("DOMSerializer", () => { 674 | let noEm = new DOMSerializer(serializer.nodes, Object.assign({}, serializer.marks, {em: null})) 675 | 676 | it("can omit a mark", () => { 677 | ist((noEm.serializeNode(p("foo", em("bar"), strong("baz")), {document}) as HTMLElement).innerHTML, 678 | "foobarbaz") 679 | }) 680 | 681 | it("doesn't split other marks for omitted marks", () => { 682 | ist((noEm.serializeNode(p("foo", code("bar"), em(code("baz"), "quux"), "xyz"), {document}) as HTMLElement).innerHTML, 683 | "foobarbazquuxxyz") 684 | }) 685 | 686 | it("can render marks with complex structure", () => { 687 | let deepEm = new DOMSerializer(serializer.nodes, Object.assign({}, serializer.marks, { 688 | em() { return ["em", ["i", {"data-emphasis": true}, 0]] } 689 | })) 690 | let node = deepEm.serializeNode(p(strong("foo", code("bar"), em(code("baz"))), em("quux"), "xyz"), {document}) 691 | ist((node as HTMLElement).innerHTML, 692 | "foobarbazquuxxyz") 693 | }) 694 | 695 | it("refuses to use values from attributes as DOM specs", () => { 696 | let weird = new DOMSerializer(Object.assign({}, serializer.nodes, { 697 | image: (node: PMNode) => ["span", ["img", {src: node.attrs.src}], node.attrs.alt] 698 | }), serializer.marks) 699 | ist.throws(() => weird.serializeNode(img({src: "x.png", alt: ["script", {src: "http://evil.com/inject.js"}]}), 700 | {document}), 701 | /Using an array from an attribute object as a DOM spec/) 702 | }) 703 | }) 704 | --------------------------------------------------------------------------------