@depup/prosemirror-state 1.4.4-depup.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,462 @@
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
+ }