You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					463 lines
				
				17 KiB
			
		
		
			
		
	
	
					463 lines
				
				17 KiB
			| 
											3 years ago
										 | import {Slice, Fragment, ResolvedPos, Node} from "prosemirror-model" | ||
|  | import {ReplaceStep, ReplaceAroundStep, Mappable} from "prosemirror-transform" | ||
|  | import {Transaction} from "./transaction" | ||
|  | 
 | ||
|  | const classesById = Object.create(null) | ||
|  | 
 | ||
|  | /// Superclass for editor selections. Every selection type should
 | ||
|  | /// extend this. Should not be instantiated directly.
 | ||
|  | export abstract class Selection { | ||
|  |   /// Initialize a selection with the head and anchor and ranges. If no
 | ||
|  |   /// ranges are given, constructs a single range across `$anchor` and
 | ||
|  |   /// `$head`.
 | ||
|  |   constructor( | ||
|  |     /// The resolved anchor of the selection (the side that stays in
 | ||
|  |     /// place when the selection is modified).
 | ||
|  |     readonly $anchor: ResolvedPos, | ||
|  |     /// The resolved head of the selection (the side that moves when
 | ||
|  |     /// the selection is modified).
 | ||
|  |     readonly $head: ResolvedPos, | ||
|  |     ranges?: readonly SelectionRange[] | ||
|  |   ) { | ||
|  |     this.ranges = ranges || [new SelectionRange($anchor.min($head), $anchor.max($head))] | ||
|  |   } | ||
|  | 
 | ||
|  |   /// The ranges covered by the selection.
 | ||
|  |   ranges: readonly SelectionRange[] | ||
|  | 
 | ||
|  |   /// The selection's anchor, as an unresolved position.
 | ||
|  |   get anchor() { return this.$anchor.pos } | ||
|  | 
 | ||
|  |   /// The selection's head.
 | ||
|  |   get head() { return this.$head.pos } | ||
|  | 
 | ||
|  |   /// The lower bound of the selection's main range.
 | ||
|  |   get from() { return this.$from.pos } | ||
|  | 
 | ||
|  |   /// The upper bound of the selection's main range.
 | ||
|  |   get to() { return this.$to.pos } | ||
|  | 
 | ||
|  |   /// The resolved lower  bound of the selection's main range.
 | ||
|  |   get $from() { | ||
|  |     return this.ranges[0].$from | ||
|  |   } | ||
|  | 
 | ||
|  |   /// The resolved upper bound of the selection's main range.
 | ||
|  |   get $to() { | ||
|  |     return this.ranges[0].$to | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Indicates whether the selection contains any content.
 | ||
|  |   get empty(): boolean { | ||
|  |     let ranges = this.ranges | ||
|  |     for (let i = 0; i < ranges.length; i++) | ||
|  |       if (ranges[i].$from.pos != ranges[i].$to.pos) return false | ||
|  |     return true | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Test whether the selection is the same as another selection.
 | ||
|  |   abstract eq(selection: Selection): boolean | ||
|  | 
 | ||
|  |   /// Map this selection through a [mappable](#transform.Mappable)
 | ||
|  |   /// thing. `doc` should be the new document to which we are mapping.
 | ||
|  |   abstract map(doc: Node, mapping: Mappable): Selection | ||
|  | 
 | ||
|  |   /// Get the content of this selection as a slice.
 | ||
|  |   content() { | ||
|  |     return this.$from.doc.slice(this.from, this.to, true) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Replace the selection with a slice or, if no slice is given,
 | ||
|  |   /// delete the selection. Will append to the given transaction.
 | ||
|  |   replace(tr: Transaction, content = Slice.empty) { | ||
|  |     // Put the new selection at the position after the inserted
 | ||
|  |     // content. When that ended in an inline node, search backwards,
 | ||
|  |     // to get the position after that node. If not, search forward.
 | ||
|  |     let lastNode = content.content.lastChild, lastParent = null | ||
|  |     for (let i = 0; i < content.openEnd; i++) { | ||
|  |       lastParent = lastNode! | ||
|  |       lastNode = lastNode!.lastChild | ||
|  |     } | ||
|  | 
 | ||
|  |     let mapFrom = tr.steps.length, ranges = this.ranges | ||
|  |     for (let i = 0; i < ranges.length; i++) { | ||
|  |       let {$from, $to} = ranges[i], mapping = tr.mapping.slice(mapFrom) | ||
|  |       tr.replaceRange(mapping.map($from.pos), mapping.map($to.pos), i ? Slice.empty : content) | ||
|  |       if (i == 0) | ||
|  |         selectionToInsertionEnd(tr, mapFrom, (lastNode ? lastNode.isInline : lastParent && lastParent.isTextblock) ? -1 : 1) | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Replace the selection with the given node, appending the changes
 | ||
|  |   /// to the given transaction.
 | ||
|  |   replaceWith(tr: Transaction, node: Node) { | ||
|  |     let mapFrom = tr.steps.length, ranges = this.ranges | ||
|  |     for (let i = 0; i < ranges.length; i++) { | ||
|  |       let {$from, $to} = ranges[i], mapping = tr.mapping.slice(mapFrom) | ||
|  |       let from = mapping.map($from.pos), to = mapping.map($to.pos) | ||
|  |       if (i) { | ||
|  |         tr.deleteRange(from, to) | ||
|  |       } else { | ||
|  |         tr.replaceRangeWith(from, to, node) | ||
|  |         selectionToInsertionEnd(tr, mapFrom, node.isInline ? -1 : 1) | ||
|  |       } | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Convert the selection to a JSON representation. When implementing
 | ||
|  |   /// this for a custom selection class, make sure to give the object a
 | ||
|  |   /// `type` property whose value matches the ID under which you
 | ||
|  |   /// [registered](#state.Selection^jsonID) your class.
 | ||
|  |   abstract toJSON(): any | ||
|  | 
 | ||
|  |   /// Find a valid cursor or leaf node selection starting at the given
 | ||
|  |   /// position and searching back if `dir` is negative, and forward if
 | ||
|  |   /// positive. When `textOnly` is true, only consider cursor
 | ||
|  |   /// selections. Will return null when no valid selection position is
 | ||
|  |   /// found.
 | ||
|  |   static findFrom($pos: ResolvedPos, dir: number, textOnly: boolean = false): Selection | null { | ||
|  |     let inner = $pos.parent.inlineContent ? new TextSelection($pos) | ||
|  |         : findSelectionIn($pos.node(0), $pos.parent, $pos.pos, $pos.index(), dir, textOnly) | ||
|  |     if (inner) return inner | ||
|  | 
 | ||
|  |     for (let depth = $pos.depth - 1; depth >= 0; depth--) { | ||
|  |       let found = dir < 0 | ||
|  |           ? findSelectionIn($pos.node(0), $pos.node(depth), $pos.before(depth + 1), $pos.index(depth), dir, textOnly) | ||
|  |           : findSelectionIn($pos.node(0), $pos.node(depth), $pos.after(depth + 1), $pos.index(depth) + 1, dir, textOnly) | ||
|  |       if (found) return found | ||
|  |     } | ||
|  |     return null | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Find a valid cursor or leaf node selection near the given
 | ||
|  |   /// position. Searches forward first by default, but if `bias` is
 | ||
|  |   /// negative, it will search backwards first.
 | ||
|  |   static near($pos: ResolvedPos, bias = 1): Selection { | ||
|  |     return this.findFrom($pos, bias) || this.findFrom($pos, -bias) || new AllSelection($pos.node(0)) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Find the cursor or leaf node selection closest to the start of
 | ||
|  |   /// the given document. Will return an
 | ||
|  |   /// [`AllSelection`](#state.AllSelection) if no valid position
 | ||
|  |   /// exists.
 | ||
|  |   static atStart(doc: Node): Selection { | ||
|  |     return findSelectionIn(doc, doc, 0, 0, 1) || new AllSelection(doc) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Find the cursor or leaf node selection closest to the end of the
 | ||
|  |   /// given document.
 | ||
|  |   static atEnd(doc: Node): Selection { | ||
|  |     return findSelectionIn(doc, doc, doc.content.size, doc.childCount, -1) || new AllSelection(doc) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Deserialize the JSON representation of a selection. Must be
 | ||
|  |   /// implemented for custom classes (as a static class method).
 | ||
|  |   static fromJSON(doc: Node, json: any): Selection { | ||
|  |     if (!json || !json.type) throw new RangeError("Invalid input for Selection.fromJSON") | ||
|  |     let cls = classesById[json.type] | ||
|  |     if (!cls) throw new RangeError(`No selection type ${json.type} defined`) | ||
|  |     return cls.fromJSON(doc, json) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// To be able to deserialize selections from JSON, custom selection
 | ||
|  |   /// classes must register themselves with an ID string, so that they
 | ||
|  |   /// can be disambiguated. Try to pick something that's unlikely to
 | ||
|  |   /// clash with classes from other modules.
 | ||
|  |   static jsonID(id: string, selectionClass: {fromJSON: (doc: Node, json: any) => Selection}) { | ||
|  |     if (id in classesById) throw new RangeError("Duplicate use of selection JSON ID " + id) | ||
|  |     classesById[id] = selectionClass | ||
|  |     ;(selectionClass as any).prototype.jsonID = id | ||
|  |     return selectionClass | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Get a [bookmark](#state.SelectionBookmark) for this selection,
 | ||
|  |   /// which is a value that can be mapped without having access to a
 | ||
|  |   /// current document, and later resolved to a real selection for a
 | ||
|  |   /// given document again. (This is used mostly by the history to
 | ||
|  |   /// track and restore old selections.) The default implementation of
 | ||
|  |   /// this method just converts the selection to a text selection and
 | ||
|  |   /// returns the bookmark for that.
 | ||
|  |   getBookmark(): SelectionBookmark { | ||
|  |     return TextSelection.between(this.$anchor, this.$head).getBookmark() | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Controls whether, when a selection of this type is active in the
 | ||
|  |   /// browser, the selected range should be visible to the user.
 | ||
|  |   /// Defaults to `true`.
 | ||
|  |   visible!: boolean | ||
|  | } | ||
|  | 
 | ||
|  | Selection.prototype.visible = true | ||
|  | 
 | ||
|  | /// A lightweight, document-independent representation of a selection.
 | ||
|  | /// You can define a custom bookmark type for a custom selection class
 | ||
|  | /// to make the history handle it well.
 | ||
|  | export interface SelectionBookmark { | ||
|  |   /// Map the bookmark through a set of changes.
 | ||
|  |   map: (mapping: Mappable) => SelectionBookmark | ||
|  | 
 | ||
|  |   /// Resolve the bookmark to a real selection again. This may need to
 | ||
|  |   /// do some error checking and may fall back to a default (usually
 | ||
|  |   /// [`TextSelection.between`](#state.TextSelection^between)) if
 | ||
|  |   /// mapping made the bookmark invalid.
 | ||
|  |   resolve: (doc: Node) => Selection | ||
|  | } | ||
|  | 
 | ||
|  | /// Represents a selected range in a document.
 | ||
|  | export class SelectionRange { | ||
|  |   /// Create a range.
 | ||
|  |   constructor( | ||
|  |     /// The lower bound of the range.
 | ||
|  |     readonly $from: ResolvedPos, | ||
|  |     /// The upper bound of the range.
 | ||
|  |     readonly $to: ResolvedPos | ||
|  |   ) {} | ||
|  | } | ||
|  | 
 | ||
|  | let warnedAboutTextSelection = false | ||
|  | function checkTextSelection($pos: ResolvedPos) { | ||
|  |   if (!warnedAboutTextSelection && !$pos.parent.inlineContent) { | ||
|  |     warnedAboutTextSelection = true | ||
|  |     console["warn"]("TextSelection endpoint not pointing into a node with inline content (" + $pos.parent.type.name + ")") | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /// A text selection represents a classical editor selection, with a
 | ||
|  | /// head (the moving side) and anchor (immobile side), both of which
 | ||
|  | /// point into textblock nodes. It can be empty (a regular cursor
 | ||
|  | /// position).
 | ||
|  | export class TextSelection extends Selection { | ||
|  |   /// Construct a text selection between the given points.
 | ||
|  |   constructor($anchor: ResolvedPos, $head = $anchor) { | ||
|  |     checkTextSelection($anchor) | ||
|  |     checkTextSelection($head) | ||
|  |     super($anchor, $head) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Returns a resolved position if this is a cursor selection (an
 | ||
|  |   /// empty text selection), and null otherwise.
 | ||
|  |   get $cursor() { return this.$anchor.pos == this.$head.pos ? this.$head : null } | ||
|  | 
 | ||
|  |   map(doc: Node, mapping: Mappable): Selection { | ||
|  |     let $head = doc.resolve(mapping.map(this.head)) | ||
|  |     if (!$head.parent.inlineContent) return Selection.near($head) | ||
|  |     let $anchor = doc.resolve(mapping.map(this.anchor)) | ||
|  |     return new TextSelection($anchor.parent.inlineContent ? $anchor : $head, $head) | ||
|  |   } | ||
|  | 
 | ||
|  |   replace(tr: Transaction, content = Slice.empty) { | ||
|  |     super.replace(tr, content) | ||
|  |     if (content == Slice.empty) { | ||
|  |       let marks = this.$from.marksAcross(this.$to) | ||
|  |       if (marks) tr.ensureMarks(marks) | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   eq(other: Selection): boolean { | ||
|  |     return other instanceof TextSelection && other.anchor == this.anchor && other.head == this.head | ||
|  |   } | ||
|  | 
 | ||
|  |   getBookmark() { | ||
|  |     return new TextBookmark(this.anchor, this.head) | ||
|  |   } | ||
|  | 
 | ||
|  |   toJSON(): any { | ||
|  |     return {type: "text", anchor: this.anchor, head: this.head} | ||
|  |   } | ||
|  | 
 | ||
|  |   /// @internal
 | ||
|  |   static fromJSON(doc: Node, json: any) { | ||
|  |     if (typeof json.anchor != "number" || typeof json.head != "number") | ||
|  |       throw new RangeError("Invalid input for TextSelection.fromJSON") | ||
|  |     return new TextSelection(doc.resolve(json.anchor), doc.resolve(json.head)) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Create a text selection from non-resolved positions.
 | ||
|  |   static create(doc: Node, anchor: number, head = anchor) { | ||
|  |     let $anchor = doc.resolve(anchor) | ||
|  |     return new this($anchor, head == anchor ? $anchor : doc.resolve(head)) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Return a text selection that spans the given positions or, if
 | ||
|  |   /// they aren't text positions, find a text selection near them.
 | ||
|  |   /// `bias` determines whether the method searches forward (default)
 | ||
|  |   /// or backwards (negative number) first. Will fall back to calling
 | ||
|  |   /// [`Selection.near`](#state.Selection^near) when the document
 | ||
|  |   /// doesn't contain a valid text position.
 | ||
|  |   static between($anchor: ResolvedPos, $head: ResolvedPos, bias?: number): Selection { | ||
|  |     let dPos = $anchor.pos - $head.pos | ||
|  |     if (!bias || dPos) bias = dPos >= 0 ? 1 : -1 | ||
|  |     if (!$head.parent.inlineContent) { | ||
|  |       let found = Selection.findFrom($head, bias, true) || Selection.findFrom($head, -bias, true) | ||
|  |       if (found) $head = found.$head | ||
|  |       else return Selection.near($head, bias) | ||
|  |     } | ||
|  |     if (!$anchor.parent.inlineContent) { | ||
|  |       if (dPos == 0) { | ||
|  |         $anchor = $head | ||
|  |       } else { | ||
|  |         $anchor = (Selection.findFrom($anchor, -bias, true) || Selection.findFrom($anchor, bias, true))!.$anchor | ||
|  |         if (($anchor.pos < $head.pos) != (dPos < 0)) $anchor = $head | ||
|  |       } | ||
|  |     } | ||
|  |     return new TextSelection($anchor, $head) | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | Selection.jsonID("text", TextSelection) | ||
|  | 
 | ||
|  | class TextBookmark { | ||
|  |   constructor(readonly anchor: number, readonly head: number) {} | ||
|  | 
 | ||
|  |   map(mapping: Mappable) { | ||
|  |     return new TextBookmark(mapping.map(this.anchor), mapping.map(this.head)) | ||
|  |   } | ||
|  |   resolve(doc: Node) { | ||
|  |     return TextSelection.between(doc.resolve(this.anchor), doc.resolve(this.head)) | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /// A node selection is a selection that points at a single node. All
 | ||
|  | /// nodes marked [selectable](#model.NodeSpec.selectable) can be the
 | ||
|  | /// target of a node selection. In such a selection, `from` and `to`
 | ||
|  | /// point directly before and after the selected node, `anchor` equals
 | ||
|  | /// `from`, and `head` equals `to`..
 | ||
|  | export class NodeSelection extends Selection { | ||
|  |   /// Create a node selection. Does not verify the validity of its
 | ||
|  |   /// argument.
 | ||
|  |   constructor($pos: ResolvedPos) { | ||
|  |     let node = $pos.nodeAfter! | ||
|  |     let $end = $pos.node(0).resolve($pos.pos + node.nodeSize) | ||
|  |     super($pos, $end) | ||
|  |     this.node = node | ||
|  |   } | ||
|  | 
 | ||
|  |   /// The selected node.
 | ||
|  |   node: Node | ||
|  | 
 | ||
|  |   map(doc: Node, mapping: Mappable): Selection { | ||
|  |     let {deleted, pos} = mapping.mapResult(this.anchor) | ||
|  |     let $pos = doc.resolve(pos) | ||
|  |     if (deleted) return Selection.near($pos) | ||
|  |     return new NodeSelection($pos) | ||
|  |   } | ||
|  | 
 | ||
|  |   content() { | ||
|  |     return new Slice(Fragment.from(this.node), 0, 0) | ||
|  |   } | ||
|  | 
 | ||
|  |   eq(other: Selection): boolean { | ||
|  |     return other instanceof NodeSelection && other.anchor == this.anchor | ||
|  |   } | ||
|  | 
 | ||
|  |   toJSON(): any { | ||
|  |     return {type: "node", anchor: this.anchor} | ||
|  |   } | ||
|  | 
 | ||
|  |   getBookmark() { return new NodeBookmark(this.anchor) } | ||
|  | 
 | ||
|  |   /// @internal
 | ||
|  |   static fromJSON(doc: Node, json: any) { | ||
|  |     if (typeof json.anchor != "number") | ||
|  |       throw new RangeError("Invalid input for NodeSelection.fromJSON") | ||
|  |     return new NodeSelection(doc.resolve(json.anchor)) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Create a node selection from non-resolved positions.
 | ||
|  |   static create(doc: Node, from: number) { | ||
|  |     return new NodeSelection(doc.resolve(from)) | ||
|  |   } | ||
|  | 
 | ||
|  |   /// Determines whether the given node may be selected as a node
 | ||
|  |   /// selection.
 | ||
|  |   static isSelectable(node: Node) { | ||
|  |     return !node.isText && node.type.spec.selectable !== false | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | NodeSelection.prototype.visible = false | ||
|  | 
 | ||
|  | Selection.jsonID("node", NodeSelection) | ||
|  | 
 | ||
|  | class NodeBookmark { | ||
|  |   constructor(readonly anchor: number) {} | ||
|  |   map(mapping: Mappable) { | ||
|  |     let {deleted, pos} = mapping.mapResult(this.anchor) | ||
|  |     return deleted ? new TextBookmark(pos, pos) : new NodeBookmark(pos) | ||
|  |   } | ||
|  |   resolve(doc: Node) { | ||
|  |     let $pos = doc.resolve(this.anchor), node = $pos.nodeAfter | ||
|  |     if (node && NodeSelection.isSelectable(node)) return new NodeSelection($pos) | ||
|  |     return Selection.near($pos) | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /// A selection type that represents selecting the whole document
 | ||
|  | /// (which can not necessarily be expressed with a text selection, when
 | ||
|  | /// there are for example leaf block nodes at the start or end of the
 | ||
|  | /// document).
 | ||
|  | export class AllSelection extends Selection { | ||
|  |   /// Create an all-selection over the given document.
 | ||
|  |   constructor(doc: Node) { | ||
|  |     super(doc.resolve(0), doc.resolve(doc.content.size)) | ||
|  |   } | ||
|  | 
 | ||
|  |   replace(tr: Transaction, content = Slice.empty) { | ||
|  |     if (content == Slice.empty) { | ||
|  |       tr.delete(0, tr.doc.content.size) | ||
|  |       let sel = Selection.atStart(tr.doc) | ||
|  |       if (!sel.eq(tr.selection)) tr.setSelection(sel) | ||
|  |     } else { | ||
|  |       super.replace(tr, content) | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   toJSON(): any { return {type: "all"} } | ||
|  | 
 | ||
|  |   /// @internal
 | ||
|  |   static fromJSON(doc: Node) { return new AllSelection(doc) } | ||
|  | 
 | ||
|  |   map(doc: Node) { return new AllSelection(doc) } | ||
|  | 
 | ||
|  |   eq(other: Selection) { return other instanceof AllSelection } | ||
|  | 
 | ||
|  |   getBookmark() { return AllBookmark } | ||
|  | } | ||
|  | 
 | ||
|  | Selection.jsonID("all", AllSelection) | ||
|  | 
 | ||
|  | const AllBookmark = { | ||
|  |   map() { return this }, | ||
|  |   resolve(doc: Node) { return new AllSelection(doc) } | ||
|  | } | ||
|  | 
 | ||
|  | // FIXME we'll need some awareness of text direction when scanning for selections
 | ||
|  | 
 | ||
|  | // Try to find a selection inside the given node. `pos` points at the
 | ||
|  | // position where the search starts. When `text` is true, only return
 | ||
|  | // text selections.
 | ||
|  | function findSelectionIn(doc: Node, node: Node, pos: number, index: number, dir: number, text = false): Selection | null { | ||
|  |   if (node.inlineContent) return TextSelection.create(doc, pos) | ||
|  |   for (let i = index - (dir > 0 ? 0 : 1); dir > 0 ? i < node.childCount : i >= 0; i += dir) { | ||
|  |     let child = node.child(i) | ||
|  |     if (!child.isAtom) { | ||
|  |       let inner = findSelectionIn(doc, child, pos + dir, dir < 0 ? child.childCount : 0, dir, text) | ||
|  |       if (inner) return inner | ||
|  |     } else if (!text && NodeSelection.isSelectable(child)) { | ||
|  |       return NodeSelection.create(doc, pos - (dir < 0 ? child.nodeSize : 0)) | ||
|  |     } | ||
|  |     pos += child.nodeSize * dir | ||
|  |   } | ||
|  |   return null | ||
|  | } | ||
|  | 
 | ||
|  | function selectionToInsertionEnd(tr: Transaction, startLen: number, bias: number) { | ||
|  |   let last = tr.steps.length - 1 | ||
|  |   if (last < startLen) return | ||
|  |   let step = tr.steps[last] | ||
|  |   if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) return | ||
|  |   let map = tr.mapping.maps[last], end: number | undefined | ||
|  |   map.forEach((_from, _to, _newFrom, newTo) => { if (end == null) end = newTo }) | ||
|  |   tr.setSelection(Selection.near(tr.doc.resolve(end!), bias)) | ||
|  | } |