selection.ts (17277B)
1 import {Slice, Fragment, ResolvedPos, Node} from "prosemirror-model" 2 import {ReplaceStep, ReplaceAroundStep, Mappable} from "prosemirror-transform" 3 import {Transaction} from "./transaction" 4 5 const classesById = Object.create(null) 6 7 /// Superclass for editor selections. Every selection type should 8 /// extend this. Should not be instantiated directly. 9 export abstract class Selection { 10 /// Initialize a selection with the head and anchor and ranges. If no 11 /// ranges are given, constructs a single range across `$anchor` and 12 /// `$head`. 13 constructor( 14 /// The resolved anchor of the selection (the side that stays in 15 /// place when the selection is modified). 16 readonly $anchor: ResolvedPos, 17 /// The resolved head of the selection (the side that moves when 18 /// the selection is modified). 19 readonly $head: ResolvedPos, 20 ranges?: readonly SelectionRange[] 21 ) { 22 this.ranges = ranges || [new SelectionRange($anchor.min($head), $anchor.max($head))] 23 } 24 25 /// The ranges covered by the selection. 26 ranges: readonly SelectionRange[] 27 28 /// The selection's anchor, as an unresolved position. 29 get anchor() { return this.$anchor.pos } 30 31 /// The selection's head. 32 get head() { return this.$head.pos } 33 34 /// The lower bound of the selection's main range. 35 get from() { return this.$from.pos } 36 37 /// The upper bound of the selection's main range. 38 get to() { return this.$to.pos } 39 40 /// The resolved lower bound of the selection's main range. 41 get $from() { 42 return this.ranges[0].$from 43 } 44 45 /// The resolved upper bound of the selection's main range. 46 get $to() { 47 return this.ranges[0].$to 48 } 49 50 /// Indicates whether the selection contains any content. 51 get empty(): boolean { 52 let ranges = this.ranges 53 for (let i = 0; i < ranges.length; i++) 54 if (ranges[i].$from.pos != ranges[i].$to.pos) return false 55 return true 56 } 57 58 /// Test whether the selection is the same as another selection. 59 abstract eq(selection: Selection): boolean 60 61 /// Map this selection through a [mappable](#transform.Mappable) 62 /// thing. `doc` should be the new document to which we are mapping. 63 abstract map(doc: Node, mapping: Mappable): Selection 64 65 /// Get the content of this selection as a slice. 66 content() { 67 return this.$from.doc.slice(this.from, this.to, true) 68 } 69 70 /// Replace the selection with a slice or, if no slice is given, 71 /// delete the selection. Will append to the given transaction. 72 replace(tr: Transaction, content = Slice.empty) { 73 // Put the new selection at the position after the inserted 74 // content. When that ended in an inline node, search backwards, 75 // to get the position after that node. If not, search forward. 76 let lastNode = content.content.lastChild, lastParent = null 77 for (let i = 0; i < content.openEnd; i++) { 78 lastParent = lastNode! 79 lastNode = lastNode!.lastChild 80 } 81 82 let mapFrom = tr.steps.length, ranges = this.ranges 83 for (let i = 0; i < ranges.length; i++) { 84 let {$from, $to} = ranges[i], mapping = tr.mapping.slice(mapFrom) 85 tr.replaceRange(mapping.map($from.pos), mapping.map($to.pos), i ? Slice.empty : content) 86 if (i == 0) 87 selectionToInsertionEnd(tr, mapFrom, (lastNode ? lastNode.isInline : lastParent && lastParent.isTextblock) ? -1 : 1) 88 } 89 } 90 91 /// Replace the selection with the given node, appending the changes 92 /// to the given transaction. 93 replaceWith(tr: Transaction, node: Node) { 94 let mapFrom = tr.steps.length, ranges = this.ranges 95 for (let i = 0; i < ranges.length; i++) { 96 let {$from, $to} = ranges[i], mapping = tr.mapping.slice(mapFrom) 97 let from = mapping.map($from.pos), to = mapping.map($to.pos) 98 if (i) { 99 tr.deleteRange(from, to) 100 } else { 101 tr.replaceRangeWith(from, to, node) 102 selectionToInsertionEnd(tr, mapFrom, node.isInline ? -1 : 1) 103 } 104 } 105 } 106 107 /// Convert the selection to a JSON representation. When implementing 108 /// this for a custom selection class, make sure to give the object a 109 /// `type` property whose value matches the ID under which you 110 /// [registered](#state.Selection^jsonID) your class. 111 abstract toJSON(): any 112 113 /// Find a valid cursor or leaf node selection starting at the given 114 /// position and searching back if `dir` is negative, and forward if 115 /// positive. When `textOnly` is true, only consider cursor 116 /// selections. Will return null when no valid selection position is 117 /// found. 118 static findFrom($pos: ResolvedPos, dir: number, textOnly: boolean = false): Selection | null { 119 let inner = $pos.parent.inlineContent ? new TextSelection($pos) 120 : findSelectionIn($pos.node(0), $pos.parent, $pos.pos, $pos.index(), dir, textOnly) 121 if (inner) return inner 122 123 for (let depth = $pos.depth - 1; depth >= 0; depth--) { 124 let found = dir < 0 125 ? findSelectionIn($pos.node(0), $pos.node(depth), $pos.before(depth + 1), $pos.index(depth), dir, textOnly) 126 : findSelectionIn($pos.node(0), $pos.node(depth), $pos.after(depth + 1), $pos.index(depth) + 1, dir, textOnly) 127 if (found) return found 128 } 129 return null 130 } 131 132 /// Find a valid cursor or leaf node selection near the given 133 /// position. Searches forward first by default, but if `bias` is 134 /// negative, it will search backwards first. 135 static near($pos: ResolvedPos, bias = 1): Selection { 136 return this.findFrom($pos, bias) || this.findFrom($pos, -bias) || new AllSelection($pos.node(0)) 137 } 138 139 /// Find the cursor or leaf node selection closest to the start of 140 /// the given document. Will return an 141 /// [`AllSelection`](#state.AllSelection) if no valid position 142 /// exists. 143 static atStart(doc: Node): Selection { 144 return findSelectionIn(doc, doc, 0, 0, 1) || new AllSelection(doc) 145 } 146 147 /// Find the cursor or leaf node selection closest to the end of the 148 /// given document. 149 static atEnd(doc: Node): Selection { 150 return findSelectionIn(doc, doc, doc.content.size, doc.childCount, -1) || new AllSelection(doc) 151 } 152 153 /// Deserialize the JSON representation of a selection. Must be 154 /// implemented for custom classes (as a static class method). 155 static fromJSON(doc: Node, json: any): Selection { 156 if (!json || !json.type) throw new RangeError("Invalid input for Selection.fromJSON") 157 let cls = classesById[json.type] 158 if (!cls) throw new RangeError(`No selection type ${json.type} defined`) 159 return cls.fromJSON(doc, json) 160 } 161 162 /// To be able to deserialize selections from JSON, custom selection 163 /// classes must register themselves with an ID string, so that they 164 /// can be disambiguated. Try to pick something that's unlikely to 165 /// clash with classes from other modules. 166 static jsonID(id: string, selectionClass: {fromJSON: (doc: Node, json: any) => Selection}) { 167 if (id in classesById) throw new RangeError("Duplicate use of selection JSON ID " + id) 168 classesById[id] = selectionClass 169 ;(selectionClass as any).prototype.jsonID = id 170 return selectionClass 171 } 172 173 /// Get a [bookmark](#state.SelectionBookmark) for this selection, 174 /// which is a value that can be mapped without having access to a 175 /// current document, and later resolved to a real selection for a 176 /// given document again. (This is used mostly by the history to 177 /// track and restore old selections.) The default implementation of 178 /// this method just converts the selection to a text selection and 179 /// returns the bookmark for that. 180 getBookmark(): SelectionBookmark { 181 return TextSelection.between(this.$anchor, this.$head).getBookmark() 182 } 183 184 /// Controls whether, when a selection of this type is active in the 185 /// browser, the selected range should be visible to the user. 186 /// Defaults to `true`. 187 declare visible: boolean 188 } 189 190 Selection.prototype.visible = true 191 192 /// A lightweight, document-independent representation of a selection. 193 /// You can define a custom bookmark type for a custom selection class 194 /// to make the history handle it well. 195 export interface SelectionBookmark { 196 /// Map the bookmark through a set of changes. 197 map: (mapping: Mappable) => SelectionBookmark 198 199 /// Resolve the bookmark to a real selection again. This may need to 200 /// do some error checking and may fall back to a default (usually 201 /// [`TextSelection.between`](#state.TextSelection^between)) if 202 /// mapping made the bookmark invalid. 203 resolve: (doc: Node) => Selection 204 } 205 206 /// Represents a selected range in a document. 207 export class SelectionRange { 208 /// Create a range. 209 constructor( 210 /// The lower bound of the range. 211 readonly $from: ResolvedPos, 212 /// The upper bound of the range. 213 readonly $to: ResolvedPos 214 ) {} 215 } 216 217 let warnedAboutTextSelection = false 218 function checkTextSelection($pos: ResolvedPos) { 219 if (!warnedAboutTextSelection && !$pos.parent.inlineContent) { 220 warnedAboutTextSelection = true 221 console["warn"]("TextSelection endpoint not pointing into a node with inline content (" + $pos.parent.type.name + ")") 222 } 223 } 224 225 /// A text selection represents a classical editor selection, with a 226 /// head (the moving side) and anchor (immobile side), both of which 227 /// point into textblock nodes. It can be empty (a regular cursor 228 /// position). 229 export class TextSelection extends Selection { 230 /// Construct a text selection between the given points. 231 constructor($anchor: ResolvedPos, $head = $anchor) { 232 checkTextSelection($anchor) 233 checkTextSelection($head) 234 super($anchor, $head) 235 } 236 237 /// Returns a resolved position if this is a cursor selection (an 238 /// empty text selection), and null otherwise. 239 get $cursor() { return this.$anchor.pos == this.$head.pos ? this.$head : null } 240 241 map(doc: Node, mapping: Mappable): Selection { 242 let $head = doc.resolve(mapping.map(this.head)) 243 if (!$head.parent.inlineContent) return Selection.near($head) 244 let $anchor = doc.resolve(mapping.map(this.anchor)) 245 return new TextSelection($anchor.parent.inlineContent ? $anchor : $head, $head) 246 } 247 248 replace(tr: Transaction, content = Slice.empty) { 249 super.replace(tr, content) 250 if (content == Slice.empty) { 251 let marks = this.$from.marksAcross(this.$to) 252 if (marks) tr.ensureMarks(marks) 253 } 254 } 255 256 eq(other: Selection): boolean { 257 return other instanceof TextSelection && other.anchor == this.anchor && other.head == this.head 258 } 259 260 getBookmark() { 261 return new TextBookmark(this.anchor, this.head) 262 } 263 264 toJSON(): any { 265 return {type: "text", anchor: this.anchor, head: this.head} 266 } 267 268 /// @internal 269 static fromJSON(doc: Node, json: any) { 270 if (typeof json.anchor != "number" || typeof json.head != "number") 271 throw new RangeError("Invalid input for TextSelection.fromJSON") 272 return new TextSelection(doc.resolve(json.anchor), doc.resolve(json.head)) 273 } 274 275 /// Create a text selection from non-resolved positions. 276 static create(doc: Node, anchor: number, head = anchor) { 277 let $anchor = doc.resolve(anchor) 278 return new this($anchor, head == anchor ? $anchor : doc.resolve(head)) 279 } 280 281 /// Return a text selection that spans the given positions or, if 282 /// they aren't text positions, find a text selection near them. 283 /// `bias` determines whether the method searches forward (default) 284 /// or backwards (negative number) first. Will fall back to calling 285 /// [`Selection.near`](#state.Selection^near) when the document 286 /// doesn't contain a valid text position. 287 static between($anchor: ResolvedPos, $head: ResolvedPos, bias?: number): Selection { 288 let dPos = $anchor.pos - $head.pos 289 if (!bias || dPos) bias = dPos >= 0 ? 1 : -1 290 if (!$head.parent.inlineContent) { 291 let found = Selection.findFrom($head, bias, true) || Selection.findFrom($head, -bias, true) 292 if (found) $head = found.$head 293 else return Selection.near($head, bias) 294 } 295 if (!$anchor.parent.inlineContent) { 296 if (dPos == 0) { 297 $anchor = $head 298 } else { 299 $anchor = (Selection.findFrom($anchor, -bias, true) || Selection.findFrom($anchor, bias, true))!.$anchor 300 if (($anchor.pos < $head.pos) != (dPos < 0)) $anchor = $head 301 } 302 } 303 return new TextSelection($anchor, $head) 304 } 305 } 306 307 Selection.jsonID("text", TextSelection) 308 309 class TextBookmark { 310 constructor(readonly anchor: number, readonly head: number) {} 311 312 map(mapping: Mappable) { 313 return new TextBookmark(mapping.map(this.anchor), mapping.map(this.head)) 314 } 315 resolve(doc: Node) { 316 return TextSelection.between(doc.resolve(this.anchor), doc.resolve(this.head)) 317 } 318 } 319 320 /// A node selection is a selection that points at a single node. All 321 /// nodes marked [selectable](#model.NodeSpec.selectable) can be the 322 /// target of a node selection. In such a selection, `from` and `to` 323 /// point directly before and after the selected node, `anchor` equals 324 /// `from`, and `head` equals `to`.. 325 export class NodeSelection extends Selection { 326 /// Create a node selection. Does not verify the validity of its 327 /// argument. 328 constructor($pos: ResolvedPos) { 329 let node = $pos.nodeAfter! 330 let $end = $pos.node(0).resolve($pos.pos + node.nodeSize) 331 super($pos, $end) 332 this.node = node 333 } 334 335 /// The selected node. 336 node: Node 337 338 map(doc: Node, mapping: Mappable): Selection { 339 let {deleted, pos} = mapping.mapResult(this.anchor) 340 let $pos = doc.resolve(pos) 341 if (deleted) return Selection.near($pos) 342 return new NodeSelection($pos) 343 } 344 345 content() { 346 return new Slice(Fragment.from(this.node), 0, 0) 347 } 348 349 eq(other: Selection): boolean { 350 return other instanceof NodeSelection && other.anchor == this.anchor 351 } 352 353 toJSON(): any { 354 return {type: "node", anchor: this.anchor} 355 } 356 357 getBookmark() { return new NodeBookmark(this.anchor) } 358 359 /// @internal 360 static fromJSON(doc: Node, json: any) { 361 if (typeof json.anchor != "number") 362 throw new RangeError("Invalid input for NodeSelection.fromJSON") 363 return new NodeSelection(doc.resolve(json.anchor)) 364 } 365 366 /// Create a node selection from non-resolved positions. 367 static create(doc: Node, from: number) { 368 return new NodeSelection(doc.resolve(from)) 369 } 370 371 /// Determines whether the given node may be selected as a node 372 /// selection. 373 static isSelectable(node: Node) { 374 return !node.isText && node.type.spec.selectable !== false 375 } 376 } 377 378 NodeSelection.prototype.visible = false 379 380 Selection.jsonID("node", NodeSelection) 381 382 class NodeBookmark { 383 constructor(readonly anchor: number) {} 384 map(mapping: Mappable) { 385 let {deleted, pos} = mapping.mapResult(this.anchor) 386 return deleted ? new TextBookmark(pos, pos) : new NodeBookmark(pos) 387 } 388 resolve(doc: Node) { 389 let $pos = doc.resolve(this.anchor), node = $pos.nodeAfter 390 if (node && NodeSelection.isSelectable(node)) return new NodeSelection($pos) 391 return Selection.near($pos) 392 } 393 } 394 395 /// A selection type that represents selecting the whole document 396 /// (which can not necessarily be expressed with a text selection, when 397 /// there are for example leaf block nodes at the start or end of the 398 /// document). 399 export class AllSelection extends Selection { 400 /// Create an all-selection over the given document. 401 constructor(doc: Node) { 402 super(doc.resolve(0), doc.resolve(doc.content.size)) 403 } 404 405 replace(tr: Transaction, content = Slice.empty) { 406 if (content == Slice.empty) { 407 tr.delete(0, tr.doc.content.size) 408 let sel = Selection.atStart(tr.doc) 409 if (!sel.eq(tr.selection)) tr.setSelection(sel) 410 } else { 411 super.replace(tr, content) 412 } 413 } 414 415 toJSON(): any { return {type: "all"} } 416 417 /// @internal 418 static fromJSON(doc: Node) { return new AllSelection(doc) } 419 420 map(doc: Node) { return new AllSelection(doc) } 421 422 eq(other: Selection) { return other instanceof AllSelection } 423 424 getBookmark() { return AllBookmark } 425 } 426 427 Selection.jsonID("all", AllSelection) 428 429 const AllBookmark = { 430 map() { return this }, 431 resolve(doc: Node) { return new AllSelection(doc) } 432 } 433 434 // FIXME we'll need some awareness of text direction when scanning for selections 435 436 // Try to find a selection inside the given node. `pos` points at the 437 // position where the search starts. When `text` is true, only return 438 // text selections. 439 function findSelectionIn(doc: Node, node: Node, pos: number, index: number, dir: number, text = false): Selection | null { 440 if (node.inlineContent) return TextSelection.create(doc, pos) 441 for (let i = index - (dir > 0 ? 0 : 1); dir > 0 ? i < node.childCount : i >= 0; i += dir) { 442 let child = node.child(i) 443 if (!child.isAtom) { 444 let inner = findSelectionIn(doc, child, pos + dir, dir < 0 ? child.childCount : 0, dir, text) 445 if (inner) return inner 446 } else if (!text && NodeSelection.isSelectable(child)) { 447 return NodeSelection.create(doc, pos - (dir < 0 ? child.nodeSize : 0)) 448 } 449 pos += child.nodeSize * dir 450 } 451 return null 452 } 453 454 function selectionToInsertionEnd(tr: Transaction, startLen: number, bias: number) { 455 let last = tr.steps.length - 1 456 if (last < startLen) return 457 let step = tr.steps[last] 458 if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) return 459 let map = tr.mapping.maps[last], end: number | undefined 460 map.forEach((_from, _to, _newFrom, newTo) => { if (end == null) end = newTo }) 461 tr.setSelection(Selection.near(tr.doc.resolve(end!), bias)) 462 }