replace.ts (20249B)
1 import {Fragment, Slice, Node, ResolvedPos, NodeType, ContentMatch, Attrs} from "prosemirror-model" 2 3 import {Step} from "./step" 4 import {ReplaceStep, ReplaceAroundStep} from "./replace_step" 5 import {Transform} from "./transform" 6 import {insertPoint} from "./structure" 7 8 /// ‘Fit’ a slice into a given position in the document, producing a 9 /// [step](#transform.Step) that inserts it. Will return null if 10 /// there's no meaningful way to insert the slice here, or inserting it 11 /// would be a no-op (an empty slice over an empty range). 12 export function replaceStep(doc: Node, from: number, to = from, slice = Slice.empty): Step | null { 13 if (from == to && !slice.size) return null 14 15 let $from = doc.resolve(from), $to = doc.resolve(to) 16 // Optimization -- avoid work if it's obvious that it's not needed. 17 if (fitsTrivially($from, $to, slice)) return new ReplaceStep(from, to, slice) 18 return new Fitter($from, $to, slice).fit() 19 } 20 21 function fitsTrivially($from: ResolvedPos, $to: ResolvedPos, slice: Slice) { 22 return !slice.openStart && !slice.openEnd && $from.start() == $to.start() && 23 $from.parent.canReplace($from.index(), $to.index(), slice.content) 24 } 25 26 interface Fittable { 27 sliceDepth: number 28 frontierDepth: number 29 parent: Node | null 30 inject?: Fragment | null 31 wrap?: readonly NodeType[] 32 } 33 34 // Algorithm for 'placing' the elements of a slice into a gap: 35 // 36 // We consider the content of each node that is open to the left to be 37 // independently placeable. I.e. in <p("foo"), p("bar")>, when the 38 // paragraph on the left is open, "foo" can be placed (somewhere on 39 // the left side of the replacement gap) independently from p("bar"). 40 // 41 // This class tracks the state of the placement progress in the 42 // following properties: 43 // 44 // - `frontier` holds a stack of `{type, match}` objects that 45 // represent the open side of the replacement. It starts at 46 // `$from`, then moves forward as content is placed, and is finally 47 // reconciled with `$to`. 48 // 49 // - `unplaced` is a slice that represents the content that hasn't 50 // been placed yet. 51 // 52 // - `placed` is a fragment of placed content. Its open-start value 53 // is implicit in `$from`, and its open-end value in `frontier`. 54 class Fitter { 55 frontier: {type: NodeType, match: ContentMatch}[] = [] 56 placed: Fragment = Fragment.empty 57 58 constructor( 59 readonly $from: ResolvedPos, 60 readonly $to: ResolvedPos, 61 public unplaced: Slice 62 ) { 63 for (let i = 0; i <= $from.depth; i++) { 64 let node = $from.node(i) 65 this.frontier.push({ 66 type: node.type, 67 match: node.contentMatchAt($from.indexAfter(i)) 68 }) 69 } 70 71 for (let i = $from.depth; i > 0; i--) 72 this.placed = Fragment.from($from.node(i).copy(this.placed)) 73 } 74 75 get depth() { return this.frontier.length - 1 } 76 77 fit() { 78 // As long as there's unplaced content, try to place some of it. 79 // If that fails, either increase the open score of the unplaced 80 // slice, or drop nodes from it, and then try again. 81 while (this.unplaced.size) { 82 let fit = this.findFittable() 83 if (fit) this.placeNodes(fit) 84 else this.openMore() || this.dropNode() 85 } 86 // When there's inline content directly after the frontier _and_ 87 // directly after `this.$to`, we must generate a `ReplaceAround` 88 // step that pulls that content into the node after the frontier. 89 // That means the fitting must be done to the end of the textblock 90 // node after `this.$to`, not `this.$to` itself. 91 let moveInline = this.mustMoveInline(), placedSize = this.placed.size - this.depth - this.$from.depth 92 let $from = this.$from, $to = this.close(moveInline < 0 ? this.$to : $from.doc.resolve(moveInline)) 93 if (!$to) return null 94 95 // If closing to `$to` succeeded, create a step 96 let content = this.placed, openStart = $from.depth, openEnd = $to.depth 97 while (openStart && openEnd && content.childCount == 1) { // Normalize by dropping open parent nodes 98 content = content.firstChild!.content 99 openStart--; openEnd-- 100 } 101 let slice = new Slice(content, openStart, openEnd) 102 if (moveInline > -1) 103 return new ReplaceAroundStep($from.pos, moveInline, this.$to.pos, this.$to.end(), slice, placedSize) 104 if (slice.size || $from.pos != this.$to.pos) // Don't generate no-op steps 105 return new ReplaceStep($from.pos, $to.pos, slice) 106 return null 107 } 108 109 // Find a position on the start spine of `this.unplaced` that has 110 // content that can be moved somewhere on the frontier. Returns two 111 // depths, one for the slice and one for the frontier. 112 findFittable(): Fittable | undefined { 113 let startDepth = this.unplaced.openStart 114 for (let cur = this.unplaced.content, d = 0, openEnd = this.unplaced.openEnd; d < startDepth; d++) { 115 let node = cur.firstChild! 116 if (cur.childCount > 1) openEnd = 0 117 if (node.type.spec.isolating && openEnd <= d) { 118 startDepth = d 119 break 120 } 121 cur = node.content 122 } 123 124 // Only try wrapping nodes (pass 2) after finding a place without 125 // wrapping failed. 126 for (let pass = 1; pass <= 2; pass++) { 127 for (let sliceDepth = pass == 1 ? startDepth : this.unplaced.openStart; sliceDepth >= 0; sliceDepth--) { 128 let fragment, parent = null 129 if (sliceDepth) { 130 parent = contentAt(this.unplaced.content, sliceDepth - 1).firstChild 131 fragment = parent!.content 132 } else { 133 fragment = this.unplaced.content 134 } 135 let first = fragment.firstChild 136 for (let frontierDepth = this.depth; frontierDepth >= 0; frontierDepth--) { 137 let {type, match} = this.frontier[frontierDepth], wrap, inject: Fragment | null = null 138 // In pass 1, if the next node matches, or there is no next 139 // node but the parents look compatible, we've found a 140 // place. 141 if (pass == 1 && (first ? match.matchType(first.type) || (inject = match.fillBefore(Fragment.from(first), false)) 142 : parent && type.compatibleContent(parent.type))) 143 return {sliceDepth, frontierDepth, parent, inject} 144 // In pass 2, look for a set of wrapping nodes that make 145 // `first` fit here. 146 else if (pass == 2 && first && (wrap = match.findWrapping(first.type))) 147 return {sliceDepth, frontierDepth, parent, wrap} 148 // Don't continue looking further up if the parent node 149 // would fit here. 150 if (parent && match.matchType(parent.type)) break 151 } 152 } 153 } 154 } 155 156 openMore() { 157 let {content, openStart, openEnd} = this.unplaced 158 let inner = contentAt(content, openStart) 159 if (!inner.childCount || inner.firstChild!.isLeaf) return false 160 this.unplaced = new Slice(content, openStart + 1, 161 Math.max(openEnd, inner.size + openStart >= content.size - openEnd ? openStart + 1 : 0)) 162 return true 163 } 164 165 dropNode() { 166 let {content, openStart, openEnd} = this.unplaced 167 let inner = contentAt(content, openStart) 168 if (inner.childCount <= 1 && openStart > 0) { 169 let openAtEnd = content.size - openStart <= openStart + inner.size 170 this.unplaced = new Slice(dropFromFragment(content, openStart - 1, 1), openStart - 1, 171 openAtEnd ? openStart - 1 : openEnd) 172 } else { 173 this.unplaced = new Slice(dropFromFragment(content, openStart, 1), openStart, openEnd) 174 } 175 } 176 177 // Move content from the unplaced slice at `sliceDepth` to the 178 // frontier node at `frontierDepth`. Close that frontier node when 179 // applicable. 180 placeNodes({sliceDepth, frontierDepth, parent, inject, wrap}: Fittable) { 181 while (this.depth > frontierDepth) this.closeFrontierNode() 182 if (wrap) for (let i = 0; i < wrap.length; i++) this.openFrontierNode(wrap[i]) 183 184 let slice = this.unplaced, fragment = parent ? parent.content : slice.content 185 let openStart = slice.openStart - sliceDepth 186 let taken = 0, add = [] 187 let {match, type} = this.frontier[frontierDepth] 188 if (inject) { 189 for (let i = 0; i < inject.childCount; i++) add.push(inject.child(i)) 190 match = match.matchFragment(inject)! 191 } 192 // Computes the amount of (end) open nodes at the end of the 193 // fragment. When 0, the parent is open, but no more. When 194 // negative, nothing is open. 195 let openEndCount = (fragment.size + sliceDepth) - (slice.content.size - slice.openEnd) 196 // Scan over the fragment, fitting as many child nodes as 197 // possible. 198 while (taken < fragment.childCount) { 199 let next = fragment.child(taken), matches = match.matchType(next.type) 200 if (!matches) break 201 taken++ 202 if (taken > 1 || openStart == 0 || next.content.size) { // Drop empty open nodes 203 match = matches 204 add.push(closeNodeStart(next.mark(type.allowedMarks(next.marks)), taken == 1 ? openStart : 0, 205 taken == fragment.childCount ? openEndCount : -1)) 206 } 207 } 208 let toEnd = taken == fragment.childCount 209 if (!toEnd) openEndCount = -1 210 211 this.placed = addToFragment(this.placed, frontierDepth, Fragment.from(add)) 212 this.frontier[frontierDepth].match = match 213 214 // If the parent types match, and the entire node was moved, and 215 // it's not open, close this frontier node right away. 216 if (toEnd && openEndCount < 0 && parent && parent.type == this.frontier[this.depth].type && this.frontier.length > 1) 217 this.closeFrontierNode() 218 219 // Add new frontier nodes for any open nodes at the end. 220 for (let i = 0, cur = fragment; i < openEndCount; i++) { 221 let node = cur.lastChild! 222 this.frontier.push({type: node.type, match: node.contentMatchAt(node.childCount)}) 223 cur = node.content 224 } 225 226 // Update `this.unplaced`. Drop the entire node from which we 227 // placed it we got to its end, otherwise just drop the placed 228 // nodes. 229 this.unplaced = !toEnd ? new Slice(dropFromFragment(slice.content, sliceDepth, taken), slice.openStart, slice.openEnd) 230 : sliceDepth == 0 ? Slice.empty 231 : new Slice(dropFromFragment(slice.content, sliceDepth - 1, 1), 232 sliceDepth - 1, openEndCount < 0 ? slice.openEnd : sliceDepth - 1) 233 } 234 235 mustMoveInline() { 236 if (!this.$to.parent.isTextblock) return -1 237 let top = this.frontier[this.depth], level 238 if (!top.type.isTextblock || !contentAfterFits(this.$to, this.$to.depth, top.type, top.match, false) || 239 (this.$to.depth == this.depth && (level = this.findCloseLevel(this.$to)) && level.depth == this.depth)) return -1 240 241 let {depth} = this.$to, after = this.$to.after(depth) 242 while (depth > 1 && after == this.$to.end(--depth)) ++after 243 return after 244 } 245 246 findCloseLevel($to: ResolvedPos) { 247 scan: for (let i = Math.min(this.depth, $to.depth); i >= 0; i--) { 248 let {match, type} = this.frontier[i] 249 let dropInner = i < $to.depth && $to.end(i + 1) == $to.pos + ($to.depth - (i + 1)) 250 let fit = contentAfterFits($to, i, type, match, dropInner) 251 if (!fit) continue 252 for (let d = i - 1; d >= 0; d--) { 253 let {match, type} = this.frontier[d] 254 let matches = contentAfterFits($to, d, type, match, true) 255 if (!matches || matches.childCount) continue scan 256 } 257 return {depth: i, fit, move: dropInner ? $to.doc.resolve($to.after(i + 1)) : $to} 258 } 259 } 260 261 close($to: ResolvedPos) { 262 let close = this.findCloseLevel($to) 263 if (!close) return null 264 265 while (this.depth > close.depth) this.closeFrontierNode() 266 if (close.fit.childCount) this.placed = addToFragment(this.placed, close.depth, close.fit) 267 $to = close.move 268 for (let d = close.depth + 1; d <= $to.depth; d++) { 269 let node = $to.node(d), add = node.type.contentMatch.fillBefore(node.content, true, $to.index(d))! 270 this.openFrontierNode(node.type, node.attrs, add) 271 } 272 return $to 273 } 274 275 openFrontierNode(type: NodeType, attrs: Attrs | null = null, content?: Fragment) { 276 let top = this.frontier[this.depth] 277 top.match = top.match.matchType(type)! 278 this.placed = addToFragment(this.placed, this.depth, Fragment.from(type.create(attrs, content))) 279 this.frontier.push({type, match: type.contentMatch}) 280 } 281 282 closeFrontierNode() { 283 let open = this.frontier.pop()! 284 let add = open.match.fillBefore(Fragment.empty, true)! 285 if (add.childCount) this.placed = addToFragment(this.placed, this.frontier.length, add) 286 } 287 } 288 289 function dropFromFragment(fragment: Fragment, depth: number, count: number): Fragment { 290 if (depth == 0) return fragment.cutByIndex(count, fragment.childCount) 291 return fragment.replaceChild(0, fragment.firstChild!.copy(dropFromFragment(fragment.firstChild!.content, depth - 1, count))) 292 } 293 294 function addToFragment(fragment: Fragment, depth: number, content: Fragment): Fragment { 295 if (depth == 0) return fragment.append(content) 296 return fragment.replaceChild(fragment.childCount - 1, 297 fragment.lastChild!.copy(addToFragment(fragment.lastChild!.content, depth - 1, content))) 298 } 299 300 function contentAt(fragment: Fragment, depth: number) { 301 for (let i = 0; i < depth; i++) fragment = fragment.firstChild!.content 302 return fragment 303 } 304 305 function closeNodeStart(node: Node, openStart: number, openEnd: number) { 306 if (openStart <= 0) return node 307 let frag = node.content 308 if (openStart > 1) 309 frag = frag.replaceChild(0, closeNodeStart(frag.firstChild!, openStart - 1, frag.childCount == 1 ? openEnd - 1 : 0)) 310 if (openStart > 0) { 311 frag = node.type.contentMatch.fillBefore(frag)!.append(frag) 312 if (openEnd <= 0) frag = frag.append(node.type.contentMatch.matchFragment(frag)!.fillBefore(Fragment.empty, true)!) 313 } 314 return node.copy(frag) 315 } 316 317 function contentAfterFits($to: ResolvedPos, depth: number, type: NodeType, match: ContentMatch, open: boolean) { 318 let node = $to.node(depth), index = open ? $to.indexAfter(depth) : $to.index(depth) 319 if (index == node.childCount && !type.compatibleContent(node.type)) return null 320 let fit = match.fillBefore(node.content, true, index) 321 return fit && !invalidMarks(type, node.content, index) ? fit : null 322 } 323 324 function invalidMarks(type: NodeType, fragment: Fragment, start: number) { 325 for (let i = start; i < fragment.childCount; i++) 326 if (!type.allowsMarks(fragment.child(i).marks)) return true 327 return false 328 } 329 330 function definesContent(type: NodeType) { 331 return type.spec.defining || type.spec.definingForContent 332 } 333 334 export function replaceRange(tr: Transform, from: number, to: number, slice: Slice) { 335 if (!slice.size) return tr.deleteRange(from, to) 336 337 let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to) 338 if (fitsTrivially($from, $to, slice)) 339 return tr.step(new ReplaceStep(from, to, slice)) 340 341 let targetDepths = coveredDepths($from, $to) 342 // Can't replace the whole document, so remove 0 if it's present 343 if (targetDepths[targetDepths.length - 1] == 0) targetDepths.pop() 344 // Negative numbers represent not expansion over the whole node at 345 // that depth, but replacing from $from.before(-D) to $to.pos. 346 let preferredTarget = -($from.depth + 1) 347 targetDepths.unshift(preferredTarget) 348 // This loop picks a preferred target depth, if one of the covering 349 // depths is not outside of a defining node, and adds negative 350 // depths for any depth that has $from at its start and does not 351 // cross a defining node. 352 for (let d = $from.depth, pos = $from.pos - 1; d > 0; d--, pos--) { 353 let spec = $from.node(d).type.spec 354 if (spec.defining || spec.definingAsContext || spec.isolating) break 355 if (targetDepths.indexOf(d) > -1) preferredTarget = d 356 else if ($from.before(d) == pos) targetDepths.splice(1, 0, -d) 357 } 358 // Try to fit each possible depth of the slice into each possible 359 // target depth, starting with the preferred depths. 360 let preferredTargetIndex = targetDepths.indexOf(preferredTarget) 361 362 let leftNodes: Node[] = [], preferredDepth = slice.openStart 363 for (let content = slice.content, i = 0;; i++) { 364 let node = content.firstChild! 365 leftNodes.push(node) 366 if (i == slice.openStart) break 367 content = node.content 368 } 369 370 // Back up preferredDepth to cover defining textblocks directly 371 // above it, possibly skipping a non-defining textblock. 372 for (let d = preferredDepth - 1; d >= 0; d--) { 373 let leftNode = leftNodes[d], def = definesContent(leftNode.type) 374 if (def && !leftNode.sameMarkup($from.node(Math.abs(preferredTarget) - 1))) preferredDepth = d 375 else if (def || !leftNode.type.isTextblock) break 376 } 377 378 for (let j = slice.openStart; j >= 0; j--) { 379 let openDepth = (j + preferredDepth + 1) % (slice.openStart + 1) 380 let insert = leftNodes[openDepth] 381 if (!insert) continue 382 for (let i = 0; i < targetDepths.length; i++) { 383 // Loop over possible expansion levels, starting with the 384 // preferred one 385 let targetDepth = targetDepths[(i + preferredTargetIndex) % targetDepths.length], expand = true 386 if (targetDepth < 0) { expand = false; targetDepth = -targetDepth } 387 let parent = $from.node(targetDepth - 1), index = $from.index(targetDepth - 1) 388 if (parent.canReplaceWith(index, index, insert.type, insert.marks)) 389 return tr.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to, 390 new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth), 391 openDepth, slice.openEnd)) 392 } 393 } 394 395 let startSteps = tr.steps.length 396 for (let i = targetDepths.length - 1; i >= 0; i--) { 397 tr.replace(from, to, slice) 398 if (tr.steps.length > startSteps) break 399 let depth = targetDepths[i] 400 if (depth < 0) continue 401 from = $from.before(depth); to = $to.after(depth) 402 } 403 } 404 405 function closeFragment(fragment: Fragment, depth: number, oldOpen: number, newOpen: number, parent?: Node) { 406 if (depth < oldOpen) { 407 let first = fragment.firstChild! 408 fragment = fragment.replaceChild(0, first.copy(closeFragment(first.content, depth + 1, oldOpen, newOpen, first))) 409 } 410 if (depth > newOpen) { 411 let match = parent!.contentMatchAt(0)! 412 let start = match.fillBefore(fragment)!.append(fragment) 413 fragment = start.append(match.matchFragment(start)!.fillBefore(Fragment.empty, true)!) 414 } 415 return fragment 416 } 417 418 export function replaceRangeWith(tr: Transform, from: number, to: number, node: Node) { 419 if (!node.isInline && from == to && tr.doc.resolve(from).parent.content.size) { 420 let point = insertPoint(tr.doc, from, node.type) 421 if (point != null) from = to = point 422 } 423 tr.replaceRange(from, to, new Slice(Fragment.from(node), 0, 0)) 424 } 425 426 export function deleteRange(tr: Transform, from: number, to: number) { 427 let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to) 428 let covered = coveredDepths($from, $to) 429 for (let i = 0; i < covered.length; i++) { 430 let depth = covered[i], last = i == covered.length - 1 431 if ((last && depth == 0) || $from.node(depth).type.contentMatch.validEnd) 432 return tr.delete($from.start(depth), $to.end(depth)) 433 if (depth > 0 && (last || $from.node(depth - 1).canReplace($from.index(depth - 1), $to.indexAfter(depth - 1)))) 434 return tr.delete($from.before(depth), $to.after(depth)) 435 } 436 for (let d = 1; d <= $from.depth && d <= $to.depth; d++) { 437 if (from - $from.start(d) == $from.depth - d && to > $from.end(d) && $to.end(d) - to != $to.depth - d && 438 $from.start(d - 1) == $to.start(d - 1) && $from.node(d - 1).canReplace($from.index(d - 1), $to.index(d - 1))) 439 return tr.delete($from.before(d), to) 440 } 441 tr.delete(from, to) 442 } 443 444 // Returns an array of all depths for which $from - $to spans the 445 // whole content of the nodes at that depth. 446 function coveredDepths($from: ResolvedPos, $to: ResolvedPos) { 447 let result: number[] = [], minDepth = Math.min($from.depth, $to.depth) 448 for (let d = minDepth; d >= 0; d--) { 449 let start = $from.start(d) 450 if (start < $from.pos - ($from.depth - d) || 451 $to.end(d) > $to.pos + ($to.depth - d) || 452 $from.node(d).type.spec.isolating || 453 $to.node(d).type.spec.isolating) break 454 if (start == $to.start(d) || 455 (d == $from.depth && d == $to.depth && $from.parent.inlineContent && $to.parent.inlineContent && 456 d && $to.start(d - 1) == start - 1)) 457 result.push(d) 458 } 459 return result 460 }