-
Notifications
You must be signed in to change notification settings - Fork 64
/
selection.ts
462 lines (393 loc) · 16.9 KB
/
selection.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
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 {
/// @internal
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))
}