tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }