node.ts (15895B)
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 }