structure.ts (15448B)
1 import {Slice, Fragment, NodeRange, NodeType, Node, Mark, Attrs, ContentMatch} from "prosemirror-model" 2 3 import {Transform} from "./transform" 4 import {ReplaceStep, ReplaceAroundStep} from "./replace_step" 5 import {clearIncompatible} from "./mark" 6 7 function canCut(node: Node, start: number, end: number) { 8 return (start == 0 || node.canReplace(start, node.childCount)) && 9 (end == node.childCount || node.canReplace(0, end)) 10 } 11 12 /// Try to find a target depth to which the content in the given range 13 /// can be lifted. Will not go across 14 /// [isolating](#model.NodeSpec.isolating) parent nodes. 15 export function liftTarget(range: NodeRange): number | null { 16 let parent = range.parent 17 let content = parent.content.cutByIndex(range.startIndex, range.endIndex) 18 for (let depth = range.depth, contentBefore = 0, contentAfter = 0;; --depth) { 19 let node = range.$from.node(depth) 20 let index = range.$from.index(depth) + contentBefore, endIndex = range.$to.indexAfter(depth) - contentAfter 21 if (depth < range.depth && node.canReplace(index, endIndex, content)) 22 return depth 23 if (depth == 0 || node.type.spec.isolating || !canCut(node, index, endIndex)) break 24 if (index) contentBefore = 1 25 if (endIndex < node.childCount) contentAfter = 1 26 } 27 return null 28 } 29 30 export function lift(tr: Transform, range: NodeRange, target: number) { 31 let {$from, $to, depth} = range 32 33 let gapStart = $from.before(depth + 1), gapEnd = $to.after(depth + 1) 34 let start = gapStart, end = gapEnd 35 36 let before = Fragment.empty, openStart = 0 37 for (let d = depth, splitting = false; d > target; d--) 38 if (splitting || $from.index(d) > 0) { 39 splitting = true 40 before = Fragment.from($from.node(d).copy(before)) 41 openStart++ 42 } else { 43 start-- 44 } 45 let after = Fragment.empty, openEnd = 0 46 for (let d = depth, splitting = false; d > target; d--) 47 if (splitting || $to.after(d + 1) < $to.end(d)) { 48 splitting = true 49 after = Fragment.from($to.node(d).copy(after)) 50 openEnd++ 51 } else { 52 end++ 53 } 54 55 tr.step(new ReplaceAroundStep(start, end, gapStart, gapEnd, 56 new Slice(before.append(after), openStart, openEnd), 57 before.size - openStart, true)) 58 } 59 60 /// Try to find a valid way to wrap the content in the given range in a 61 /// node of the given type. May introduce extra nodes around and inside 62 /// the wrapper node, if necessary. Returns null if no valid wrapping 63 /// could be found. When `innerRange` is given, that range's content is 64 /// used as the content to fit into the wrapping, instead of the 65 /// content of `range`. 66 export function findWrapping( 67 range: NodeRange, 68 nodeType: NodeType, 69 attrs: Attrs | null = null, 70 innerRange = range 71 ): {type: NodeType, attrs: Attrs | null}[] | null { 72 let around = findWrappingOutside(range, nodeType) 73 let inner = around && findWrappingInside(innerRange, nodeType) 74 if (!inner) return null 75 return (around!.map(withAttrs) as {type: NodeType, attrs: Attrs | null}[]) 76 .concat({type: nodeType, attrs}).concat(inner.map(withAttrs)) 77 } 78 79 function withAttrs(type: NodeType) { return {type, attrs: null} } 80 81 function findWrappingOutside(range: NodeRange, type: NodeType) { 82 let {parent, startIndex, endIndex} = range 83 let around = parent.contentMatchAt(startIndex).findWrapping(type) 84 if (!around) return null 85 let outer = around.length ? around[0] : type 86 return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null 87 } 88 89 function findWrappingInside(range: NodeRange, type: NodeType) { 90 let {parent, startIndex, endIndex} = range 91 let inner = parent.child(startIndex) 92 let inside = type.contentMatch.findWrapping(inner.type) 93 if (!inside) return null 94 let lastType = inside.length ? inside[inside.length - 1] : type 95 let innerMatch: ContentMatch | null = lastType.contentMatch 96 for (let i = startIndex; innerMatch && i < endIndex; i++) 97 innerMatch = innerMatch.matchType(parent.child(i).type) 98 if (!innerMatch || !innerMatch.validEnd) return null 99 return inside 100 } 101 102 export function wrap(tr: Transform, range: NodeRange, wrappers: readonly {type: NodeType, attrs?: Attrs | null}[]) { 103 let content = Fragment.empty 104 for (let i = wrappers.length - 1; i >= 0; i--) { 105 if (content.size) { 106 let match = wrappers[i].type.contentMatch.matchFragment(content) 107 if (!match || !match.validEnd) 108 throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper") 109 } 110 content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)) 111 } 112 113 let start = range.start, end = range.end 114 tr.step(new ReplaceAroundStep(start, end, start, end, new Slice(content, 0, 0), wrappers.length, true)) 115 } 116 117 export function setBlockType(tr: Transform, from: number, to: number, 118 type: NodeType, attrs: Attrs | null | ((oldNode: Node) => Attrs)) { 119 if (!type.isTextblock) throw new RangeError("Type given to setBlockType should be a textblock") 120 let mapFrom = tr.steps.length 121 tr.doc.nodesBetween(from, to, (node, pos) => { 122 let attrsHere = typeof attrs == "function" ? attrs(node) : attrs 123 if (node.isTextblock && !node.hasMarkup(type, attrsHere) && 124 canChangeType(tr.doc, tr.mapping.slice(mapFrom).map(pos), type)) { 125 let convertNewlines = null 126 if (type.schema.linebreakReplacement) { 127 let pre = type.whitespace == "pre", supportLinebreak = !!type.contentMatch.matchType(type.schema.linebreakReplacement) 128 if (pre && !supportLinebreak) convertNewlines = false 129 else if (!pre && supportLinebreak) convertNewlines = true 130 } 131 // Ensure all markup that isn't allowed in the new node type is cleared 132 if (convertNewlines === false) replaceLinebreaks(tr, node, pos, mapFrom) 133 clearIncompatible(tr, tr.mapping.slice(mapFrom).map(pos, 1), type, undefined, convertNewlines === null) 134 let mapping = tr.mapping.slice(mapFrom) 135 let startM = mapping.map(pos, 1), endM = mapping.map(pos + node.nodeSize, 1) 136 tr.step(new ReplaceAroundStep(startM, endM, startM + 1, endM - 1, 137 new Slice(Fragment.from(type.create(attrsHere, null, node.marks)), 0, 0), 1, true)) 138 if (convertNewlines === true) replaceNewlines(tr, node, pos, mapFrom) 139 return false 140 } 141 }) 142 } 143 144 function replaceNewlines(tr: Transform, node: Node, pos: number, mapFrom: number) { 145 node.forEach((child, offset) => { 146 if (child.isText) { 147 let m, newline = /\r?\n|\r/g 148 while (m = newline.exec(child.text!)) { 149 let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset + m.index) 150 tr.replaceWith(start, start + 1, node.type.schema.linebreakReplacement!.create()) 151 } 152 } 153 }) 154 } 155 156 function replaceLinebreaks(tr: Transform, node: Node, pos: number, mapFrom: number) { 157 node.forEach((child, offset) => { 158 if (child.type == child.type.schema.linebreakReplacement) { 159 let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset) 160 tr.replaceWith(start, start + 1, node.type.schema.text("\n")) 161 } 162 }) 163 } 164 165 function canChangeType(doc: Node, pos: number, type: NodeType) { 166 let $pos = doc.resolve(pos), index = $pos.index() 167 return $pos.parent.canReplaceWith(index, index + 1, type) 168 } 169 170 /// Change the type, attributes, and/or marks of the node at `pos`. 171 /// When `type` isn't given, the existing node type is preserved, 172 export function setNodeMarkup(tr: Transform, pos: number, type: NodeType | undefined | null, 173 attrs: Attrs | null, marks: readonly Mark[] | undefined) { 174 let node = tr.doc.nodeAt(pos) 175 if (!node) throw new RangeError("No node at given position") 176 if (!type) type = node.type 177 let newNode = type.create(attrs, null, marks || node.marks) 178 if (node.isLeaf) 179 return tr.replaceWith(pos, pos + node.nodeSize, newNode) 180 181 if (!type.validContent(node.content)) 182 throw new RangeError("Invalid content for node type " + type.name) 183 184 tr.step(new ReplaceAroundStep(pos, pos + node.nodeSize, pos + 1, pos + node.nodeSize - 1, 185 new Slice(Fragment.from(newNode), 0, 0), 1, true)) 186 } 187 188 /// Check whether splitting at the given position is allowed. 189 export function canSplit(doc: Node, pos: number, depth = 1, 190 typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]): boolean { 191 let $pos = doc.resolve(pos), base = $pos.depth - depth 192 let innerType = (typesAfter && typesAfter[typesAfter.length - 1]) || $pos.parent 193 if (base < 0 || $pos.parent.type.spec.isolating || 194 !$pos.parent.canReplace($pos.index(), $pos.parent.childCount) || 195 !innerType.type.validContent($pos.parent.content.cutByIndex($pos.index(), $pos.parent.childCount))) 196 return false 197 for (let d = $pos.depth - 1, i = depth - 2; d > base; d--, i--) { 198 let node = $pos.node(d), index = $pos.index(d) 199 if (node.type.spec.isolating) return false 200 let rest = node.content.cutByIndex(index, node.childCount) 201 let overrideChild = typesAfter && typesAfter[i + 1] 202 if (overrideChild) 203 rest = rest.replaceChild(0, overrideChild.type.create(overrideChild.attrs)) 204 let after = (typesAfter && typesAfter[i]) || node 205 if (!node.canReplace(index + 1, node.childCount) || !after.type.validContent(rest)) 206 return false 207 } 208 let index = $pos.indexAfter(base) 209 let baseType = typesAfter && typesAfter[0] 210 return $pos.node(base).canReplaceWith(index, index, baseType ? baseType.type : $pos.node(base + 1).type) 211 } 212 213 export function split(tr: Transform, pos: number, depth = 1, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]) { 214 let $pos = tr.doc.resolve(pos), before = Fragment.empty, after = Fragment.empty 215 for (let d = $pos.depth, e = $pos.depth - depth, i = depth - 1; d > e; d--, i--) { 216 before = Fragment.from($pos.node(d).copy(before)) 217 let typeAfter = typesAfter && typesAfter[i] 218 after = Fragment.from(typeAfter ? typeAfter.type.create(typeAfter.attrs, after) : $pos.node(d).copy(after)) 219 } 220 tr.step(new ReplaceStep(pos, pos, new Slice(before.append(after), depth, depth), true)) 221 } 222 223 /// Test whether the blocks before and after a given position can be 224 /// joined. 225 export function canJoin(doc: Node, pos: number): boolean { 226 let $pos = doc.resolve(pos), index = $pos.index() 227 return joinable($pos.nodeBefore, $pos.nodeAfter) && 228 $pos.parent.canReplace(index, index + 1) 229 } 230 231 function canAppendWithSubstitutedLinebreaks(a: Node, b: Node) { 232 if (!b.content.size) a.type.compatibleContent(b.type) 233 let match: ContentMatch | null = a.contentMatchAt(a.childCount) 234 let {linebreakReplacement} = a.type.schema 235 for (let i = 0; i < b.childCount; i++) { 236 let child = b.child(i) 237 let type = child.type == linebreakReplacement ? a.type.schema.nodes.text : child.type 238 match = match.matchType(type) 239 if (!match) return false 240 if (!a.type.allowsMarks(child.marks)) return false 241 } 242 return match.validEnd 243 } 244 245 function joinable(a: Node | null, b: Node | null) { 246 return !!(a && b && !a.isLeaf && canAppendWithSubstitutedLinebreaks(a, b)) 247 } 248 249 /// Find an ancestor of the given position that can be joined to the 250 /// block before (or after if `dir` is positive). Returns the joinable 251 /// point, if any. 252 export function joinPoint(doc: Node, pos: number, dir = -1) { 253 let $pos = doc.resolve(pos) 254 for (let d = $pos.depth;; d--) { 255 let before, after, index = $pos.index(d) 256 if (d == $pos.depth) { 257 before = $pos.nodeBefore 258 after = $pos.nodeAfter 259 } else if (dir > 0) { 260 before = $pos.node(d + 1) 261 index++ 262 after = $pos.node(d).maybeChild(index) 263 } else { 264 before = $pos.node(d).maybeChild(index - 1) 265 after = $pos.node(d + 1) 266 } 267 if (before && !before.isTextblock && joinable(before, after) && 268 $pos.node(d).canReplace(index, index + 1)) return pos 269 if (d == 0) break 270 pos = dir < 0 ? $pos.before(d) : $pos.after(d) 271 } 272 } 273 274 export function join(tr: Transform, pos: number, depth: number) { 275 let convertNewlines = null 276 let {linebreakReplacement} = tr.doc.type.schema 277 let $before = tr.doc.resolve(pos - depth), beforeType = $before.node().type 278 if (linebreakReplacement && beforeType.inlineContent) { 279 let pre = beforeType.whitespace == "pre" 280 let supportLinebreak = !!beforeType.contentMatch.matchType(linebreakReplacement) 281 if (pre && !supportLinebreak) convertNewlines = false 282 else if (!pre && supportLinebreak) convertNewlines = true 283 } 284 let mapFrom = tr.steps.length 285 if (convertNewlines === false) { 286 let $after = tr.doc.resolve(pos + depth) 287 replaceLinebreaks(tr, $after.node(), $after.before(), mapFrom) 288 } 289 if (beforeType.inlineContent) 290 clearIncompatible(tr, pos + depth - 1, beforeType, 291 $before.node().contentMatchAt($before.index()), convertNewlines == null) 292 let mapping = tr.mapping.slice(mapFrom), start = mapping.map(pos - depth) 293 tr.step(new ReplaceStep(start, mapping.map(pos + depth, - 1), Slice.empty, true)) 294 if (convertNewlines === true) { 295 let $full = tr.doc.resolve(start) 296 replaceNewlines(tr, $full.node(), $full.before(), tr.steps.length) 297 } 298 return tr 299 } 300 301 /// Try to find a point where a node of the given type can be inserted 302 /// near `pos`, by searching up the node hierarchy when `pos` itself 303 /// isn't a valid place but is at the start or end of a node. Return 304 /// null if no position was found. 305 export function insertPoint(doc: Node, pos: number, nodeType: NodeType): number | null { 306 let $pos = doc.resolve(pos) 307 if ($pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) return pos 308 309 if ($pos.parentOffset == 0) 310 for (let d = $pos.depth - 1; d >= 0; d--) { 311 let index = $pos.index(d) 312 if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.before(d + 1) 313 if (index > 0) return null 314 } 315 if ($pos.parentOffset == $pos.parent.content.size) 316 for (let d = $pos.depth - 1; d >= 0; d--) { 317 let index = $pos.indexAfter(d) 318 if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.after(d + 1) 319 if (index < $pos.node(d).childCount) return null 320 } 321 return null 322 } 323 324 /// Finds a position at or around the given position where the given 325 /// slice can be inserted. Will look at parent nodes' nearest boundary 326 /// and try there, even if the original position wasn't directly at the 327 /// start or end of that node. Returns null when no position was found. 328 export function dropPoint(doc: Node, pos: number, slice: Slice): number | null { 329 let $pos = doc.resolve(pos) 330 if (!slice.content.size) return pos 331 let content = slice.content 332 for (let i = 0; i < slice.openStart; i++) content = content.firstChild!.content 333 for (let pass = 1; pass <= (slice.openStart == 0 && slice.size ? 2 : 1); pass++) { 334 for (let d = $pos.depth; d >= 0; d--) { 335 let bias = d == $pos.depth ? 0 : $pos.pos <= ($pos.start(d + 1) + $pos.end(d + 1)) / 2 ? -1 : 1 336 let insertPos = $pos.index(d) + (bias > 0 ? 1 : 0) 337 let parent = $pos.node(d), fits: boolean | null = false 338 if (pass == 1) { 339 fits = parent.canReplace(insertPos, insertPos, content) 340 } else { 341 let wrapping = parent.contentMatchAt(insertPos).findWrapping(content.firstChild!.type) 342 fits = wrapping && parent.canReplaceWith(insertPos, insertPos, wrapping[0]) 343 } 344 if (fits) 345 return bias == 0 ? $pos.pos : bias < 0 ? $pos.before(d + 1) : $pos.after(d + 1) 346 } 347 } 348 return null 349 }