prosemirror-view 1.33.1 → 1.33.2
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.
- package/CHANGELOG.md +14 -0
- package/dist/index.cjs +56 -27
- package/dist/index.js +66 -33
- package/package.json +1 -1
- package/src/#index.ts# +803 -0
- package/src/dom.ts +36 -0
- package/src/domchange.ts +10 -11
- package/src/domcoords.ts +5 -0
- package/src/index.ts +3 -2
- package/src/viewdesc.ts +9 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 1.33.2 (2024-03-21)
|
|
2
|
+
|
|
3
|
+
### Bug fixes
|
|
4
|
+
|
|
5
|
+
Improve Android backspace detection when the native effect is to delete an entire block element.
|
|
6
|
+
|
|
7
|
+
Fix a bug where reconfiguring node views caused the editor to lose focus.
|
|
8
|
+
|
|
9
|
+
Fix an issue where some commands didn't properly scroll the cursor into view on Mobile Safari.
|
|
10
|
+
|
|
11
|
+
Fix a bug where starting a composition with stored marks or after a noninclusive mark, with an IME that keeps the cursor at the start of the composed text (like many Chinese IMEs) disrupted composition on Chrome.
|
|
12
|
+
|
|
13
|
+
Fix a memory leak where a (single) destroyed editor could be kept alive through a reused `Range` object.
|
|
14
|
+
|
|
1
15
|
## 1.33.1 (2024-02-15)
|
|
2
16
|
|
|
3
17
|
### Bug fixes
|
package/dist/index.cjs
CHANGED
|
@@ -38,6 +38,9 @@ var textRange = function textRange(node, from, to) {
|
|
|
38
38
|
range.setStart(node, from || 0);
|
|
39
39
|
return range;
|
|
40
40
|
};
|
|
41
|
+
var clearReusedRange = function clearReusedRange() {
|
|
42
|
+
reusedRange = null;
|
|
43
|
+
};
|
|
41
44
|
var isEquivalentPosition = function isEquivalentPosition(node, off, targetNode, targetOff) {
|
|
42
45
|
return targetNode && (scanFor(node, off, targetNode, targetOff, -1) || scanFor(node, off, targetNode, targetOff, 1));
|
|
43
46
|
};
|
|
@@ -62,6 +65,36 @@ function scanFor(node, off, targetNode, targetOff, dir) {
|
|
|
62
65
|
function nodeSize(node) {
|
|
63
66
|
return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length;
|
|
64
67
|
}
|
|
68
|
+
function textNodeBefore$1(node, offset) {
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (node.nodeType == 3 && offset) return node;
|
|
71
|
+
if (node.nodeType == 1 && offset > 0) {
|
|
72
|
+
if (node.contentEditable != "false") return null;
|
|
73
|
+
node = node.childNodes[offset - 1];
|
|
74
|
+
offset = nodeSize(node);
|
|
75
|
+
} else if (node.parentNode && !hasBlockDesc(node)) {
|
|
76
|
+
offset = domIndex(node);
|
|
77
|
+
node = node.parentNode;
|
|
78
|
+
} else {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function textNodeAfter$1(node, offset) {
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (node.nodeType == 3 && offset < node.nodeValue.length) return node;
|
|
86
|
+
if (node.nodeType == 1 && offset < node.childNodes.length) {
|
|
87
|
+
if (node.contentEditable != "false") return null;
|
|
88
|
+
node = node.childNodes[offset];
|
|
89
|
+
offset = 0;
|
|
90
|
+
} else if (node.parentNode && !hasBlockDesc(node)) {
|
|
91
|
+
offset = domIndex(node) + 1;
|
|
92
|
+
node = node.parentNode;
|
|
93
|
+
} else {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
65
98
|
function isOnEdge(node, offset, parent) {
|
|
66
99
|
for (var atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) {
|
|
67
100
|
if (node == parent) return true;
|
|
@@ -131,6 +164,13 @@ var android = /Android \d/.test(agent);
|
|
|
131
164
|
var webkit = !!doc && "webkitFontSmoothing" in doc.documentElement.style;
|
|
132
165
|
var webkit_version = webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0;
|
|
133
166
|
function windowRect(doc) {
|
|
167
|
+
var vp = doc.defaultView && doc.defaultView.visualViewport;
|
|
168
|
+
if (vp) return {
|
|
169
|
+
left: 0,
|
|
170
|
+
right: vp.width,
|
|
171
|
+
top: 0,
|
|
172
|
+
bottom: vp.height
|
|
173
|
+
};
|
|
134
174
|
return {
|
|
135
175
|
left: 0,
|
|
136
176
|
right: doc.documentElement.clientWidth,
|
|
@@ -1269,7 +1309,13 @@ var NodeViewDesc = function (_ViewDesc4) {
|
|
|
1269
1309
|
to = _view$state$selection.to;
|
|
1270
1310
|
if (!(view.state.selection instanceof prosemirrorState.TextSelection) || from < pos || to > pos + this.node.content.size) return null;
|
|
1271
1311
|
var sel = view.domSelectionRange();
|
|
1272
|
-
var
|
|
1312
|
+
var textBefore = textNodeBefore$1(sel.focusNode, sel.focusOffset);
|
|
1313
|
+
var textAfter = textNodeAfter$1(sel.focusNode, sel.focusOffset);
|
|
1314
|
+
var textNode = textBefore || textAfter;
|
|
1315
|
+
if (textBefore && textAfter && textBefore != textAfter) {
|
|
1316
|
+
var descAfter = textAfter.pmViewDesc;
|
|
1317
|
+
if (!descAfter || descAfter instanceof TextViewDesc && descAfter.node.text != textAfter.nodeValue) textNode = textAfter;
|
|
1318
|
+
}
|
|
1273
1319
|
if (!textNode || !this.dom.contains(textNode.parentNode)) return null;
|
|
1274
1320
|
if (this.node.inlineContent) {
|
|
1275
1321
|
var text = textNode.nodeValue;
|
|
@@ -1998,21 +2044,6 @@ function iosHacks(dom) {
|
|
|
1998
2044
|
dom.style.cssText = oldCSS;
|
|
1999
2045
|
}
|
|
2000
2046
|
}
|
|
2001
|
-
function nearbyTextNode(node, offset) {
|
|
2002
|
-
for (;;) {
|
|
2003
|
-
if (node.nodeType == 3) return node;
|
|
2004
|
-
if (node.nodeType == 1 && offset > 0) {
|
|
2005
|
-
if (node.childNodes.length > offset && node.childNodes[offset].nodeType == 3) return node.childNodes[offset];
|
|
2006
|
-
node = node.childNodes[offset - 1];
|
|
2007
|
-
offset = nodeSize(node);
|
|
2008
|
-
} else if (node.nodeType == 1 && offset < node.childNodes.length) {
|
|
2009
|
-
node = node.childNodes[offset];
|
|
2010
|
-
offset = 0;
|
|
2011
|
-
} else {
|
|
2012
|
-
return null;
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
2047
|
function findTextInFragment(frag, text, from, to) {
|
|
2017
2048
|
for (var i = 0, pos = 0; i < frag.childCount && pos <= to;) {
|
|
2018
2049
|
var child = frag.child(i++),
|
|
@@ -4474,13 +4505,6 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
|
|
4474
4505
|
return;
|
|
4475
4506
|
}
|
|
4476
4507
|
}
|
|
4477
|
-
if (chrome && view.cursorWrapper && parse.sel && parse.sel.anchor == view.cursorWrapper.deco.from && parse.sel.head == parse.sel.anchor) {
|
|
4478
|
-
var size = change.endB - change.start;
|
|
4479
|
-
parse.sel = {
|
|
4480
|
-
anchor: parse.sel.anchor + size,
|
|
4481
|
-
head: parse.sel.anchor + size
|
|
4482
|
-
};
|
|
4483
|
-
}
|
|
4484
4508
|
view.input.domChangeCount++;
|
|
4485
4509
|
if (view.state.selection.from < view.state.selection.to && change.start == change.endB && view.state.selection instanceof prosemirrorState.TextSelection) {
|
|
4486
4510
|
if (change.start > view.state.selection.from && change.start <= view.state.selection.from + 2 && view.state.selection.from >= parse.from) {
|
|
@@ -4508,7 +4532,7 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
|
|
4508
4532
|
view.input.lastIOSEnter = 0;
|
|
4509
4533
|
return;
|
|
4510
4534
|
}
|
|
4511
|
-
if (view.state.selection.anchor > change.start &&
|
|
4535
|
+
if (view.state.selection.anchor > change.start && looksLikeBackspace(doc, change.start, change.endA, $from, $to) && view.someProp("handleKeyDown", function (f) {
|
|
4512
4536
|
return f(view, keyEvent(8, "Backspace"));
|
|
4513
4537
|
})) {
|
|
4514
4538
|
if (android && chrome) view.domObserver.suppressSelectionUpdates();
|
|
@@ -4593,9 +4617,13 @@ function isMarkChange(cur, prev) {
|
|
|
4593
4617
|
type: type
|
|
4594
4618
|
};
|
|
4595
4619
|
}
|
|
4596
|
-
function
|
|
4597
|
-
if (
|
|
4620
|
+
function looksLikeBackspace(old, start, end, $newStart, $newEnd) {
|
|
4621
|
+
if (end - start <= $newEnd.pos - $newStart.pos || skipClosingAndOpening($newStart, true, false) < $newEnd.pos) return false;
|
|
4598
4622
|
var $start = old.resolve(start);
|
|
4623
|
+
if (!$newStart.parent.isTextblock) {
|
|
4624
|
+
var after = $start.nodeAfter;
|
|
4625
|
+
return after != null && end == start + after.nodeSize;
|
|
4626
|
+
}
|
|
4599
4627
|
if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock) return false;
|
|
4600
4628
|
var $next = old.resolve(skipClosingAndOpening($start, true, true));
|
|
4601
4629
|
if (!$next.parent.isTextblock || $next.pos > end || skipClosingAndOpening($next, true, false) < end) return false;
|
|
@@ -4771,7 +4799,7 @@ var EditorView = function () {
|
|
|
4771
4799
|
if (updateDoc) {
|
|
4772
4800
|
var chromeKludge = chrome ? this.trackWrites = this.domSelectionRange().focusNode : null;
|
|
4773
4801
|
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
|
|
4774
|
-
this.docView.updateOuterDeco(
|
|
4802
|
+
this.docView.updateOuterDeco(outerDeco);
|
|
4775
4803
|
this.docView.destroy();
|
|
4776
4804
|
this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this);
|
|
4777
4805
|
}
|
|
@@ -4978,6 +5006,7 @@ var EditorView = function () {
|
|
|
4978
5006
|
}
|
|
4979
5007
|
this.docView.destroy();
|
|
4980
5008
|
this.docView = null;
|
|
5009
|
+
clearReusedRange();
|
|
4981
5010
|
}
|
|
4982
5011
|
}, {
|
|
4983
5012
|
key: "isDestroyed",
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,9 @@ const textRange = function (node, from, to) {
|
|
|
23
23
|
range.setStart(node, from || 0);
|
|
24
24
|
return range;
|
|
25
25
|
};
|
|
26
|
+
const clearReusedRange = function () {
|
|
27
|
+
reusedRange = null;
|
|
28
|
+
};
|
|
26
29
|
// Scans forward and backward through DOM positions equivalent to the
|
|
27
30
|
// given one to see if the two are in the same place (i.e. after a
|
|
28
31
|
// text node vs at the end of that text node)
|
|
@@ -57,6 +60,44 @@ function scanFor(node, off, targetNode, targetOff, dir) {
|
|
|
57
60
|
function nodeSize(node) {
|
|
58
61
|
return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length;
|
|
59
62
|
}
|
|
63
|
+
function textNodeBefore$1(node, offset) {
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (node.nodeType == 3 && offset)
|
|
66
|
+
return node;
|
|
67
|
+
if (node.nodeType == 1 && offset > 0) {
|
|
68
|
+
if (node.contentEditable != "false")
|
|
69
|
+
return null;
|
|
70
|
+
node = node.childNodes[offset - 1];
|
|
71
|
+
offset = nodeSize(node);
|
|
72
|
+
}
|
|
73
|
+
else if (node.parentNode && !hasBlockDesc(node)) {
|
|
74
|
+
offset = domIndex(node);
|
|
75
|
+
node = node.parentNode;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function textNodeAfter$1(node, offset) {
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (node.nodeType == 3 && offset < node.nodeValue.length)
|
|
85
|
+
return node;
|
|
86
|
+
if (node.nodeType == 1 && offset < node.childNodes.length) {
|
|
87
|
+
if (node.contentEditable != "false")
|
|
88
|
+
return null;
|
|
89
|
+
node = node.childNodes[offset];
|
|
90
|
+
offset = 0;
|
|
91
|
+
}
|
|
92
|
+
else if (node.parentNode && !hasBlockDesc(node)) {
|
|
93
|
+
offset = domIndex(node) + 1;
|
|
94
|
+
node = node.parentNode;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
60
101
|
function isOnEdge(node, offset, parent) {
|
|
61
102
|
for (let atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) {
|
|
62
103
|
if (node == parent)
|
|
@@ -133,6 +174,12 @@ const webkit = !!doc && "webkitFontSmoothing" in doc.documentElement.style;
|
|
|
133
174
|
const webkit_version = webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0;
|
|
134
175
|
|
|
135
176
|
function windowRect(doc) {
|
|
177
|
+
let vp = doc.defaultView && doc.defaultView.visualViewport;
|
|
178
|
+
if (vp)
|
|
179
|
+
return {
|
|
180
|
+
left: 0, right: vp.width,
|
|
181
|
+
top: 0, bottom: vp.height
|
|
182
|
+
};
|
|
136
183
|
return { left: 0, right: doc.documentElement.clientWidth,
|
|
137
184
|
top: 0, bottom: doc.documentElement.clientHeight };
|
|
138
185
|
}
|
|
@@ -1331,7 +1378,14 @@ class NodeViewDesc extends ViewDesc {
|
|
|
1331
1378
|
if (!(view.state.selection instanceof TextSelection) || from < pos || to > pos + this.node.content.size)
|
|
1332
1379
|
return null;
|
|
1333
1380
|
let sel = view.domSelectionRange();
|
|
1334
|
-
let
|
|
1381
|
+
let textBefore = textNodeBefore$1(sel.focusNode, sel.focusOffset);
|
|
1382
|
+
let textAfter = textNodeAfter$1(sel.focusNode, sel.focusOffset);
|
|
1383
|
+
let textNode = textBefore || textAfter;
|
|
1384
|
+
if (textBefore && textAfter && textBefore != textAfter) {
|
|
1385
|
+
let descAfter = textAfter.pmViewDesc;
|
|
1386
|
+
if (!descAfter || descAfter instanceof TextViewDesc && descAfter.node.text != textAfter.nodeValue)
|
|
1387
|
+
textNode = textAfter;
|
|
1388
|
+
}
|
|
1335
1389
|
if (!textNode || !this.dom.contains(textNode.parentNode))
|
|
1336
1390
|
return null;
|
|
1337
1391
|
if (this.node.inlineContent) {
|
|
@@ -2042,25 +2096,6 @@ function iosHacks(dom) {
|
|
|
2042
2096
|
dom.style.cssText = oldCSS;
|
|
2043
2097
|
}
|
|
2044
2098
|
}
|
|
2045
|
-
function nearbyTextNode(node, offset) {
|
|
2046
|
-
for (;;) {
|
|
2047
|
-
if (node.nodeType == 3)
|
|
2048
|
-
return node;
|
|
2049
|
-
if (node.nodeType == 1 && offset > 0) {
|
|
2050
|
-
if (node.childNodes.length > offset && node.childNodes[offset].nodeType == 3)
|
|
2051
|
-
return node.childNodes[offset];
|
|
2052
|
-
node = node.childNodes[offset - 1];
|
|
2053
|
-
offset = nodeSize(node);
|
|
2054
|
-
}
|
|
2055
|
-
else if (node.nodeType == 1 && offset < node.childNodes.length) {
|
|
2056
|
-
node = node.childNodes[offset];
|
|
2057
|
-
offset = 0;
|
|
2058
|
-
}
|
|
2059
|
-
else {
|
|
2060
|
-
return null;
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
2099
|
// Find a piece of text in an inline fragment, overlapping from-to
|
|
2065
2100
|
function findTextInFragment(frag, text, from, to) {
|
|
2066
2101
|
for (let i = 0, pos = 0; i < frag.childCount && pos <= to;) {
|
|
@@ -4822,13 +4857,6 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
|
|
4822
4857
|
return;
|
|
4823
4858
|
}
|
|
4824
4859
|
}
|
|
4825
|
-
// Chrome sometimes leaves the cursor before the inserted text when
|
|
4826
|
-
// composing after a cursor wrapper. This moves it forward.
|
|
4827
|
-
if (chrome && view.cursorWrapper && parse.sel && parse.sel.anchor == view.cursorWrapper.deco.from &&
|
|
4828
|
-
parse.sel.head == parse.sel.anchor) {
|
|
4829
|
-
let size = change.endB - change.start;
|
|
4830
|
-
parse.sel = { anchor: parse.sel.anchor + size, head: parse.sel.anchor + size };
|
|
4831
|
-
}
|
|
4832
4860
|
view.input.domChangeCount++;
|
|
4833
4861
|
// Handle the case where overwriting a selection by typing matches
|
|
4834
4862
|
// the start or end of the selected content, creating a change
|
|
@@ -4874,7 +4902,7 @@ function readDOMChange(view, from, to, typeOver, addedNodes) {
|
|
|
4874
4902
|
}
|
|
4875
4903
|
// Same for backspace
|
|
4876
4904
|
if (view.state.selection.anchor > change.start &&
|
|
4877
|
-
|
|
4905
|
+
looksLikeBackspace(doc, change.start, change.endA, $from, $to) &&
|
|
4878
4906
|
view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) {
|
|
4879
4907
|
if (android && chrome)
|
|
4880
4908
|
view.domObserver.suppressSelectionUpdates(); // #820
|
|
@@ -4986,14 +5014,18 @@ function isMarkChange(cur, prev) {
|
|
|
4986
5014
|
if (Fragment.from(updated).eq(cur))
|
|
4987
5015
|
return { mark, type };
|
|
4988
5016
|
}
|
|
4989
|
-
function
|
|
4990
|
-
if (
|
|
4991
|
-
|
|
4992
|
-
end - start <= $newEnd.pos - $newStart.pos ||
|
|
5017
|
+
function looksLikeBackspace(old, start, end, $newStart, $newEnd) {
|
|
5018
|
+
if ( // The content must have shrunk
|
|
5019
|
+
end - start <= $newEnd.pos - $newStart.pos ||
|
|
4993
5020
|
// newEnd must point directly at or after the end of the block that newStart points into
|
|
4994
5021
|
skipClosingAndOpening($newStart, true, false) < $newEnd.pos)
|
|
4995
5022
|
return false;
|
|
4996
5023
|
let $start = old.resolve(start);
|
|
5024
|
+
// Handle the case where, rather than joining blocks, the change just removed an entire block
|
|
5025
|
+
if (!$newStart.parent.isTextblock) {
|
|
5026
|
+
let after = $start.nodeAfter;
|
|
5027
|
+
return after != null && end == start + after.nodeSize;
|
|
5028
|
+
}
|
|
4997
5029
|
// Start must be at the end of a block
|
|
4998
5030
|
if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock)
|
|
4999
5031
|
return false;
|
|
@@ -5245,7 +5277,7 @@ class EditorView {
|
|
|
5245
5277
|
// did write to the node.
|
|
5246
5278
|
let chromeKludge = chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null;
|
|
5247
5279
|
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
|
|
5248
|
-
this.docView.updateOuterDeco(
|
|
5280
|
+
this.docView.updateOuterDeco(outerDeco);
|
|
5249
5281
|
this.docView.destroy();
|
|
5250
5282
|
this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this);
|
|
5251
5283
|
}
|
|
@@ -5522,6 +5554,7 @@ class EditorView {
|
|
|
5522
5554
|
}
|
|
5523
5555
|
this.docView.destroy();
|
|
5524
5556
|
this.docView = null;
|
|
5557
|
+
clearReusedRange();
|
|
5525
5558
|
}
|
|
5526
5559
|
/**
|
|
5527
5560
|
This is true when the view has been
|
package/package.json
CHANGED
package/src/#index.ts#
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
import {NodeSelection, EditorState, Plugin, PluginView, Transaction, Selection} from "prosemirror-state"
|
|
2
|
+
import {Slice, ResolvedPos, DOMParser, DOMSerializer, Node, Mark} from "prosemirror-model"
|
|
3
|
+
|
|
4
|
+
import {scrollRectIntoView, posAtCoords, coordsAtPos, endOfTextblock, storeScrollPos,
|
|
5
|
+
resetScrollPos, focusPreventScroll} from "./domcoords"
|
|
6
|
+
import {docViewDesc, ViewDesc, NodeView, NodeViewDesc} from "./viewdesc"
|
|
7
|
+
import {initInput, destroyInput, dispatchEvent, ensureListeners, clearComposition,
|
|
8
|
+
InputState, doPaste, Dragging} from "./input"
|
|
9
|
+
import {selectionToDOM, anchorInRightPlace, syncNodeSelection} from "./selection"
|
|
10
|
+
import {Decoration, viewDecorations, DecorationSource} from "./decoration"
|
|
11
|
+
import {DOMObserver, safariShadowSelectionRange} from "./domobserver"
|
|
12
|
+
import {readDOMChange} from "./domchange"
|
|
13
|
+
import {DOMSelection, DOMNode, DOMSelectionRange, deepActiveElement} from "./dom"
|
|
14
|
+
import * as browser from "./browser"
|
|
15
|
+
|
|
16
|
+
export {Decoration, DecorationSet, DecorationAttrs, DecorationSource} from "./decoration"
|
|
17
|
+
export {NodeView} from "./viewdesc"
|
|
18
|
+
|
|
19
|
+
// Exported for testing
|
|
20
|
+
import {serializeForClipboard, parseFromClipboard} from "./clipboard"
|
|
21
|
+
import {endComposition} from "./input"
|
|
22
|
+
/// @internal
|
|
23
|
+
export const __serializeForClipboard = serializeForClipboard
|
|
24
|
+
/// @internal
|
|
25
|
+
export const __parseFromClipboard = parseFromClipboard
|
|
26
|
+
/// @internal
|
|
27
|
+
export const __endComposition = endComposition
|
|
28
|
+
|
|
29
|
+
/// An editor view manages the DOM structure that represents an
|
|
30
|
+
/// editable document. Its state and behavior are determined by its
|
|
31
|
+
/// [props](#view.DirectEditorProps).
|
|
32
|
+
export class EditorView {
|
|
33
|
+
private _props: DirectEditorProps
|
|
34
|
+
private directPlugins: readonly Plugin[]
|
|
35
|
+
private _root: Document | ShadowRoot | null = null
|
|
36
|
+
/// @internal
|
|
37
|
+
focused = false
|
|
38
|
+
/// Kludge used to work around a Chrome bug @internal
|
|
39
|
+
trackWrites: DOMNode | null = null
|
|
40
|
+
private mounted = false
|
|
41
|
+
/// @internal
|
|
42
|
+
markCursor: readonly Mark[] | null = null
|
|
43
|
+
/// @internal
|
|
44
|
+
cursorWrapper: {dom: DOMNode, deco: Decoration} | null = null
|
|
45
|
+
/// @internal
|
|
46
|
+
nodeViews: NodeViewSet
|
|
47
|
+
/// @internal
|
|
48
|
+
lastSelectedViewDesc: ViewDesc | undefined = undefined
|
|
49
|
+
/// @internal
|
|
50
|
+
docView: NodeViewDesc
|
|
51
|
+
/// @internal
|
|
52
|
+
input = new InputState
|
|
53
|
+
private prevDirectPlugins: readonly Plugin[] = []
|
|
54
|
+
private pluginViews: PluginView[] = []
|
|
55
|
+
/// @internal
|
|
56
|
+
domObserver!: DOMObserver
|
|
57
|
+
/// Holds `true` when a hack node is needed in Firefox to prevent the
|
|
58
|
+
/// [space is eaten issue](https://github.com/ProseMirror/prosemirror/issues/651)
|
|
59
|
+
/// @internal
|
|
60
|
+
requiresGeckoHackNode: boolean = false
|
|
61
|
+
|
|
62
|
+
/// The view's current [state](#state.EditorState).
|
|
63
|
+
public state: EditorState
|
|
64
|
+
|
|
65
|
+
/// Create a view. `place` may be a DOM node that the editor should
|
|
66
|
+
/// be appended to, a function that will place it into the document,
|
|
67
|
+
/// or an object whose `mount` property holds the node to use as the
|
|
68
|
+
/// document container. If it is `null`, the editor will not be
|
|
69
|
+
/// added to the document.
|
|
70
|
+
constructor(place: null | DOMNode | ((editor: HTMLElement) => void) | {mount: HTMLElement}, props: DirectEditorProps) {
|
|
71
|
+
this._props = props
|
|
72
|
+
this.state = props.state
|
|
73
|
+
this.directPlugins = props.plugins || []
|
|
74
|
+
this.directPlugins.forEach(checkStateComponent)
|
|
75
|
+
|
|
76
|
+
this.dispatch = this.dispatch.bind(this)
|
|
77
|
+
|
|
78
|
+
this.dom = (place && (place as {mount: HTMLElement}).mount) || document.createElement("div")
|
|
79
|
+
if (place) {
|
|
80
|
+
if ((place as DOMNode).appendChild) (place as DOMNode).appendChild(this.dom)
|
|
81
|
+
else if (typeof place == "function") place(this.dom)
|
|
82
|
+
else if ((place as {mount: HTMLElement}).mount) this.mounted = true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.editable = getEditable(this)
|
|
86
|
+
updateCursorWrapper(this)
|
|
87
|
+
this.nodeViews = buildNodeViews(this)
|
|
88
|
+
this.docView = docViewDesc(this.state.doc, computeDocDeco(this), viewDecorations(this), this.dom, this)
|
|
89
|
+
|
|
90
|
+
this.domObserver = new DOMObserver(this, (from, to, typeOver, added) => readDOMChange(this, from, to, typeOver, added))
|
|
91
|
+
this.domObserver.start()
|
|
92
|
+
initInput(this)
|
|
93
|
+
this.updatePluginViews()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// An editable DOM node containing the document. (You probably
|
|
97
|
+
/// should not directly interfere with its content.)
|
|
98
|
+
readonly dom: HTMLElement
|
|
99
|
+
|
|
100
|
+
/// Indicates whether the editor is currently [editable](#view.EditorProps.editable).
|
|
101
|
+
editable: boolean
|
|
102
|
+
|
|
103
|
+
/// When editor content is being dragged, this object contains
|
|
104
|
+
/// information about the dragged slice and whether it is being
|
|
105
|
+
/// copied or moved. At any other time, it is null.
|
|
106
|
+
dragging: null | {slice: Slice, move: boolean} = null
|
|
107
|
+
|
|
108
|
+
/// Holds `true` when a
|
|
109
|
+
/// [composition](https://w3c.github.io/uievents/#events-compositionevents)
|
|
110
|
+
/// is active.
|
|
111
|
+
get composing() { return this.input.composing }
|
|
112
|
+
|
|
113
|
+
/// The view's current [props](#view.EditorProps).
|
|
114
|
+
get props() {
|
|
115
|
+
if (this._props.state != this.state) {
|
|
116
|
+
let prev = this._props
|
|
117
|
+
this._props = {} as any
|
|
118
|
+
for (let name in prev) (this._props as any)[name] = (prev as any)[name]
|
|
119
|
+
this._props.state = this.state
|
|
120
|
+
}
|
|
121
|
+
return this._props
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Update the view's props. Will immediately cause an update to
|
|
125
|
+
/// the DOM.
|
|
126
|
+
update(props: DirectEditorProps) {
|
|
127
|
+
if (props.handleDOMEvents != this._props.handleDOMEvents) ensureListeners(this)
|
|
128
|
+
let prevProps = this._props
|
|
129
|
+
this._props = props
|
|
130
|
+
if (props.plugins) {
|
|
131
|
+
props.plugins.forEach(checkStateComponent)
|
|
132
|
+
this.directPlugins = props.plugins
|
|
133
|
+
}
|
|
134
|
+
this.updateStateInner(props.state, prevProps)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Update the view by updating existing props object with the object
|
|
138
|
+
/// given as argument. Equivalent to `view.update(Object.assign({},
|
|
139
|
+
/// view.props, props))`.
|
|
140
|
+
setProps(props: Partial<DirectEditorProps>) {
|
|
141
|
+
let updated = {} as DirectEditorProps
|
|
142
|
+
for (let name in this._props) (updated as any)[name] = (this._props as any)[name]
|
|
143
|
+
updated.state = this.state
|
|
144
|
+
for (let name in props) (updated as any)[name] = (props as any)[name]
|
|
145
|
+
this.update(updated)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Update the editor's `state` prop, without touching any of the
|
|
149
|
+
/// other props.
|
|
150
|
+
updateState(state: EditorState) {
|
|
151
|
+
this.updateStateInner(state, this._props)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private updateStateInner(state: EditorState, prevProps: DirectEditorProps) {
|
|
155
|
+
let prev = this.state, redraw = false, updateSel = false
|
|
156
|
+
// When stored marks are added, stop composition, so that they can
|
|
157
|
+
// be displayed.
|
|
158
|
+
if (state.storedMarks && this.composing) {
|
|
159
|
+
clearComposition(this)
|
|
160
|
+
updateSel = true
|
|
161
|
+
}
|
|
162
|
+
this.state = state
|
|
163
|
+
let pluginsChanged = prev.plugins != state.plugins || this._props.plugins != prevProps.plugins
|
|
164
|
+
if (pluginsChanged || this._props.plugins != prevProps.plugins || this._props.nodeViews != prevProps.nodeViews) {
|
|
165
|
+
let nodeViews = buildNodeViews(this)
|
|
166
|
+
if (changedNodeViews(nodeViews, this.nodeViews)) {
|
|
167
|
+
this.nodeViews = nodeViews
|
|
168
|
+
redraw = true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (pluginsChanged || prevProps.handleDOMEvents != this._props.handleDOMEvents) {
|
|
172
|
+
ensureListeners(this)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.editable = getEditable(this)
|
|
176
|
+
updateCursorWrapper(this)
|
|
177
|
+
let innerDeco = viewDecorations(this), outerDeco = computeDocDeco(this)
|
|
178
|
+
|
|
179
|
+
let scroll = prev.plugins != state.plugins && !prev.doc.eq(state.doc) ? "reset"
|
|
180
|
+
: (state as any).scrollToSelection > (prev as any).scrollToSelection ? "to selection" : "preserve"
|
|
181
|
+
let updateDoc = redraw || !this.docView.matchesNode(state.doc, outerDeco, innerDeco)
|
|
182
|
+
if (updateDoc || !state.selection.eq(prev.selection)) updateSel = true
|
|
183
|
+
let oldScrollPos = scroll == "preserve" && updateSel && this.dom.style.overflowAnchor == null && storeScrollPos(this)
|
|
184
|
+
|
|
185
|
+
if (updateSel) {
|
|
186
|
+
this.domObserver.stop()
|
|
187
|
+
// Work around an issue in Chrome, IE, and Edge where changing
|
|
188
|
+
// the DOM around an active selection puts it into a broken
|
|
189
|
+
// state where the thing the user sees differs from the
|
|
190
|
+
// selection reported by the Selection object (#710, #973,
|
|
191
|
+
// #1011, #1013, #1035).
|
|
192
|
+
let forceSelUpdate = updateDoc && (browser.ie || browser.chrome) && !this.composing &&
|
|
193
|
+
!prev.selection.empty && !state.selection.empty && selectionContextChanged(prev.selection, state.selection)
|
|
194
|
+
if (updateDoc) {
|
|
195
|
+
// If the node that the selection points into is written to,
|
|
196
|
+
// Chrome sometimes starts misreporting the selection, so this
|
|
197
|
+
// tracks that and forces a selection reset when our update
|
|
198
|
+
// did write to the node.
|
|
199
|
+
let chromeKludge = browser.chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null
|
|
200
|
+
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
|
|
201
|
+
this.docView.updateOuterDeco(outerDeco)
|
|
202
|
+
this.docView.destroy()
|
|
203
|
+
this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this)
|
|
204
|
+
}
|
|
205
|
+
if (chromeKludge && !this.trackWrites) forceSelUpdate = true
|
|
206
|
+
}
|
|
207
|
+
// Work around for an issue where an update arriving right between
|
|
208
|
+
// a DOM selection change and the "selectionchange" event for it
|
|
209
|
+
// can cause a spurious DOM selection update, disrupting mouse
|
|
210
|
+
// drag selection.
|
|
211
|
+
if (forceSelUpdate ||
|
|
212
|
+
!(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) &&
|
|
213
|
+
anchorInRightPlace(this))) {
|
|
214
|
+
selectionToDOM(this, forceSelUpdate)
|
|
215
|
+
} else {
|
|
216
|
+
syncNodeSelection(this, state.selection)
|
|
217
|
+
this.domObserver.setCurSelection()
|
|
218
|
+
}
|
|
219
|
+
this.domObserver.start()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.updatePluginViews(prev)
|
|
223
|
+
if ((this.dragging as Dragging)?.node && !prev.doc.eq(state.doc))
|
|
224
|
+
this.updateDraggedNode(this.dragging as Dragging, prev)
|
|
225
|
+
|
|
226
|
+
if (scroll == "reset") {
|
|
227
|
+
this.dom.scrollTop = 0
|
|
228
|
+
} else if (scroll == "to selection") {
|
|
229
|
+
this.scrollToSelection()
|
|
230
|
+
} else if (oldScrollPos) {
|
|
231
|
+
resetScrollPos(oldScrollPos)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// @internal
|
|
236
|
+
scrollToSelection() {
|
|
237
|
+
let startDOM = this.domSelectionRange().focusNode!
|
|
238
|
+
if (this.someProp("handleScrollToSelection", f => f(this))) {
|
|
239
|
+
// Handled
|
|
240
|
+
} else if (this.state.selection instanceof NodeSelection) {
|
|
241
|
+
let target = this.docView.domAfterPos(this.state.selection.from)
|
|
242
|
+
if (target.nodeType == 1) scrollRectIntoView(this, (target as HTMLElement).getBoundingClientRect(), startDOM)
|
|
243
|
+
} else {
|
|
244
|
+
scrollRectIntoView(this, this.coordsAtPos(this.state.selection.head, 1), startDOM)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private destroyPluginViews() {
|
|
249
|
+
let view
|
|
250
|
+
while (view = this.pluginViews.pop()) if (view.destroy) view.destroy()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private updatePluginViews(prevState?: EditorState) {
|
|
254
|
+
if (!prevState || prevState.plugins != this.state.plugins || this.directPlugins != this.prevDirectPlugins) {
|
|
255
|
+
this.prevDirectPlugins = this.directPlugins
|
|
256
|
+
this.destroyPluginViews()
|
|
257
|
+
for (let i = 0; i < this.directPlugins.length; i++) {
|
|
258
|
+
let plugin = this.directPlugins[i]
|
|
259
|
+
if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this))
|
|
260
|
+
}
|
|
261
|
+
for (let i = 0; i < this.state.plugins.length; i++) {
|
|
262
|
+
let plugin = this.state.plugins[i]
|
|
263
|
+
if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this))
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
for (let i = 0; i < this.pluginViews.length; i++) {
|
|
267
|
+
let pluginView = this.pluginViews[i]
|
|
268
|
+
if (pluginView.update) pluginView.update(this, prevState)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private updateDraggedNode(dragging: Dragging, prev: EditorState) {
|
|
274
|
+
let sel = dragging.node!, found = -1
|
|
275
|
+
if (this.state.doc.nodeAt(sel.from) == sel.node) {
|
|
276
|
+
found = sel.from
|
|
277
|
+
} else {
|
|
278
|
+
let movedPos = sel.from + (this.state.doc.content.size - prev.doc.content.size)
|
|
279
|
+
let moved = movedPos > 0 && this.state.doc.nodeAt(movedPos)
|
|
280
|
+
if (moved == sel.node) found = movedPos
|
|
281
|
+
}
|
|
282
|
+
this.dragging = new Dragging(dragging.slice, dragging.move,
|
|
283
|
+
found < 0 ? undefined : NodeSelection.create(this.state.doc, found))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Goes over the values of a prop, first those provided directly,
|
|
287
|
+
/// then those from plugins given to the view, then from plugins in
|
|
288
|
+
/// the state (in order), and calls `f` every time a non-undefined
|
|
289
|
+
/// value is found. When `f` returns a truthy value, that is
|
|
290
|
+
/// immediately returned. When `f` isn't provided, it is treated as
|
|
291
|
+
/// the identity function (the prop value is returned directly).
|
|
292
|
+
someProp<PropName extends keyof EditorProps, Result>(
|
|
293
|
+
propName: PropName,
|
|
294
|
+
f: (value: NonNullable<EditorProps[PropName]>) => Result
|
|
295
|
+
): Result | undefined
|
|
296
|
+
someProp<PropName extends keyof EditorProps>(propName: PropName): NonNullable<EditorProps[PropName]> | undefined
|
|
297
|
+
someProp<PropName extends keyof EditorProps, Result>(
|
|
298
|
+
propName: PropName,
|
|
299
|
+
f?: (value: NonNullable<EditorProps[PropName]>) => Result
|
|
300
|
+
): Result | undefined {
|
|
301
|
+
let prop = this._props && this._props[propName], value
|
|
302
|
+
if (prop != null && (value = f ? f(prop as any) : prop)) return value as any
|
|
303
|
+
for (let i = 0; i < this.directPlugins.length; i++) {
|
|
304
|
+
let prop = this.directPlugins[i].props[propName]
|
|
305
|
+
if (prop != null && (value = f ? f(prop as any) : prop)) return value as any
|
|
306
|
+
}
|
|
307
|
+
let plugins = this.state.plugins
|
|
308
|
+
if (plugins) for (let i = 0; i < plugins.length; i++) {
|
|
309
|
+
let prop = plugins[i].props[propName]
|
|
310
|
+
if (prop != null && (value = f ? f(prop as any) : prop)) return value as any
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// Query whether the view has focus.
|
|
315
|
+
hasFocus() {
|
|
316
|
+
// Work around IE not handling focus correctly if resize handles are shown.
|
|
317
|
+
// If the cursor is inside an element with resize handles, activeElement
|
|
318
|
+
// will be that element instead of this.dom.
|
|
319
|
+
if (browser.ie) {
|
|
320
|
+
// If activeElement is within this.dom, and there are no other elements
|
|
321
|
+
// setting `contenteditable` to false in between, treat it as focused.
|
|
322
|
+
let node = this.root.activeElement
|
|
323
|
+
if (node == this.dom) return true
|
|
324
|
+
if (!node || !this.dom.contains(node)) return false
|
|
325
|
+
while (node && this.dom != node && this.dom.contains(node)) {
|
|
326
|
+
if ((node as HTMLElement).contentEditable == 'false') return false
|
|
327
|
+
node = node.parentElement
|
|
328
|
+
}
|
|
329
|
+
return true
|
|
330
|
+
}
|
|
331
|
+
return this.root.activeElement == this.dom
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// Focus the editor.
|
|
335
|
+
focus() {
|
|
336
|
+
this.domObserver.stop()
|
|
337
|
+
if (this.editable) focusPreventScroll(this.dom)
|
|
338
|
+
selectionToDOM(this)
|
|
339
|
+
this.domObserver.start()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/// Get the document root in which the editor exists. This will
|
|
343
|
+
/// usually be the top-level `document`, but might be a [shadow
|
|
344
|
+
/// DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM)
|
|
345
|
+
/// root if the editor is inside one.
|
|
346
|
+
get root(): Document | ShadowRoot {
|
|
347
|
+
let cached = this._root
|
|
348
|
+
if (cached == null) for (let search = this.dom.parentNode; search; search = search.parentNode) {
|
|
349
|
+
if (search.nodeType == 9 || (search.nodeType == 11 && (search as any).host)) {
|
|
350
|
+
if (!(search as any).getSelection)
|
|
351
|
+
Object.getPrototypeOf(search).getSelection = () => (search as DOMNode).ownerDocument!.getSelection()
|
|
352
|
+
return this._root = search as Document | ShadowRoot
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return cached || document
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// When an existing editor view is moved to a new document or
|
|
359
|
+
/// shadow tree, call this to make it recompute its root.
|
|
360
|
+
updateRoot() {
|
|
361
|
+
this._root = null
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// Given a pair of viewport coordinates, return the document
|
|
365
|
+
/// position that corresponds to them. May return null if the given
|
|
366
|
+
/// coordinates aren't inside of the editor. When an object is
|
|
367
|
+
/// returned, its `pos` property is the position nearest to the
|
|
368
|
+
/// coordinates, and its `inside` property holds the position of the
|
|
369
|
+
/// inner node that the position falls inside of, or -1 if it is at
|
|
370
|
+
/// the top level, not in any node.
|
|
371
|
+
posAtCoords(coords: {left: number, top: number}): {pos: number, inside: number} | null {
|
|
372
|
+
return posAtCoords(this, coords)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Returns the viewport rectangle at a given document position.
|
|
376
|
+
/// `left` and `right` will be the same number, as this returns a
|
|
377
|
+
/// flat cursor-ish rectangle. If the position is between two things
|
|
378
|
+
/// that aren't directly adjacent, `side` determines which element
|
|
379
|
+
/// is used. When < 0, the element before the position is used,
|
|
380
|
+
/// otherwise the element after.
|
|
381
|
+
coordsAtPos(pos: number, side = 1): {left: number, right: number, top: number, bottom: number} {
|
|
382
|
+
return coordsAtPos(this, pos, side)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/// Find the DOM position that corresponds to the given document
|
|
386
|
+
/// position. When `side` is negative, find the position as close as
|
|
387
|
+
/// possible to the content before the position. When positive,
|
|
388
|
+
/// prefer positions close to the content after the position. When
|
|
389
|
+
/// zero, prefer as shallow a position as possible.
|
|
390
|
+
///
|
|
391
|
+
/// Note that you should **not** mutate the editor's internal DOM,
|
|
392
|
+
/// only inspect it (and even that is usually not necessary).
|
|
393
|
+
domAtPos(pos: number, side = 0): {node: DOMNode, offset: number} {
|
|
394
|
+
return this.docView.domFromPos(pos, side)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// Find the DOM node that represents the document node after the
|
|
398
|
+
/// given position. May return `null` when the position doesn't point
|
|
399
|
+
/// in front of a node or if the node is inside an opaque node view.
|
|
400
|
+
///
|
|
401
|
+
/// This is intended to be able to call things like
|
|
402
|
+
/// `getBoundingClientRect` on that DOM node. Do **not** mutate the
|
|
403
|
+
/// editor DOM directly, or add styling this way, since that will be
|
|
404
|
+
/// immediately overriden by the editor as it redraws the node.
|
|
405
|
+
nodeDOM(pos: number): DOMNode | null {
|
|
406
|
+
let desc = this.docView.descAt(pos)
|
|
407
|
+
return desc ? (desc as NodeViewDesc).nodeDOM : null
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/// Find the document position that corresponds to a given DOM
|
|
411
|
+
/// position. (Whenever possible, it is preferable to inspect the
|
|
412
|
+
/// document structure directly, rather than poking around in the
|
|
413
|
+
/// DOM, but sometimes—for example when interpreting an event
|
|
414
|
+
/// target—you don't have a choice.)
|
|
415
|
+
///
|
|
416
|
+
/// The `bias` parameter can be used to influence which side of a DOM
|
|
417
|
+
/// node to use when the position is inside a leaf node.
|
|
418
|
+
posAtDOM(node: DOMNode, offset: number, bias = -1): number {
|
|
419
|
+
let pos = this.docView.posFromDOM(node, offset, bias)
|
|
420
|
+
if (pos == null) throw new RangeError("DOM position not inside the editor")
|
|
421
|
+
return pos
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/// Find out whether the selection is at the end of a textblock when
|
|
425
|
+
/// moving in a given direction. When, for example, given `"left"`,
|
|
426
|
+
/// it will return true if moving left from the current cursor
|
|
427
|
+
/// position would leave that position's parent textblock. Will apply
|
|
428
|
+
/// to the view's current state by default, but it is possible to
|
|
429
|
+
/// pass a different state.
|
|
430
|
+
endOfTextblock(dir: "up" | "down" | "left" | "right" | "forward" | "backward", state?: EditorState): boolean {
|
|
431
|
+
return endOfTextblock(this, state || this.state, dir)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// Run the editor's paste logic with the given HTML string. The
|
|
435
|
+
/// `event`, if given, will be passed to the
|
|
436
|
+
/// [`handlePaste`](#view.EditorProps.handlePaste) hook.
|
|
437
|
+
pasteHTML(html: string, event?: ClipboardEvent) {
|
|
438
|
+
return doPaste(this, "", html, false, event || new ClipboardEvent("paste"))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/// Run the editor's paste logic with the given plain-text input.
|
|
442
|
+
pasteText(text: string, event?: ClipboardEvent) {
|
|
443
|
+
return doPaste(this, text, null, true, event || new ClipboardEvent("paste"))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// Removes the editor from the DOM and destroys all [node
|
|
447
|
+
/// views](#view.NodeView).
|
|
448
|
+
destroy() {
|
|
449
|
+
if (!this.docView) return
|
|
450
|
+
destroyInput(this)
|
|
451
|
+
this.destroyPluginViews()
|
|
452
|
+
if (this.mounted) {
|
|
453
|
+
this.docView.update(this.state.doc, [], viewDecorations(this), this)
|
|
454
|
+
this.dom.textContent = ""
|
|
455
|
+
} else if (this.dom.parentNode) {
|
|
456
|
+
this.dom.parentNode.removeChild(this.dom)
|
|
457
|
+
}
|
|
458
|
+
this.docView.destroy()
|
|
459
|
+
;(this as any).docView = null
|
|
460
|
+
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/// This is true when the view has been
|
|
464
|
+
/// [destroyed](#view.EditorView.destroy) (and thus should not be
|
|
465
|
+
/// used anymore).
|
|
466
|
+
get isDestroyed() {
|
|
467
|
+
return this.docView == null
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/// Used for testing.
|
|
471
|
+
dispatchEvent(event: Event) {
|
|
472
|
+
return dispatchEvent(this, event)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/// Dispatch a transaction. Will call
|
|
476
|
+
/// [`dispatchTransaction`](#view.DirectEditorProps.dispatchTransaction)
|
|
477
|
+
/// when given, and otherwise defaults to applying the transaction to
|
|
478
|
+
/// the current state and calling
|
|
479
|
+
/// [`updateState`](#view.EditorView.updateState) with the result.
|
|
480
|
+
/// This method is bound to the view instance, so that it can be
|
|
481
|
+
/// easily passed around.
|
|
482
|
+
dispatch(tr: Transaction) {
|
|
483
|
+
let dispatchTransaction = this._props.dispatchTransaction
|
|
484
|
+
if (dispatchTransaction) dispatchTransaction.call(this, tr)
|
|
485
|
+
else this.updateState(this.state.apply(tr))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/// @internal
|
|
489
|
+
domSelectionRange(): DOMSelectionRange {
|
|
490
|
+
return browser.safari && this.root.nodeType === 11 && deepActiveElement(this.dom.ownerDocument) == this.dom
|
|
491
|
+
? safariShadowSelectionRange(this) : this.domSelection()
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// @internal
|
|
495
|
+
domSelection(): DOMSelection {
|
|
496
|
+
return (this.root as Document).getSelection()!
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function computeDocDeco(view: EditorView) {
|
|
501
|
+
let attrs = Object.create(null)
|
|
502
|
+
attrs.class = "ProseMirror"
|
|
503
|
+
attrs.contenteditable = String(view.editable)
|
|
504
|
+
|
|
505
|
+
view.someProp("attributes", value => {
|
|
506
|
+
if (typeof value == "function") value = value(view.state)
|
|
507
|
+
if (value) for (let attr in value) {
|
|
508
|
+
if (attr == "class")
|
|
509
|
+
attrs.class += " " + value[attr]
|
|
510
|
+
else if (attr == "style")
|
|
511
|
+
attrs.style = (attrs.style ? attrs.style + ";" : "") + value[attr]
|
|
512
|
+
else if (!attrs[attr] && attr != "contenteditable" && attr != "nodeName")
|
|
513
|
+
attrs[attr] = String(value[attr])
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
if (!attrs.translate) attrs.translate = "no"
|
|
517
|
+
|
|
518
|
+
return [Decoration.node(0, view.state.doc.content.size, attrs)]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function updateCursorWrapper(view: EditorView) {
|
|
522
|
+
if (view.markCursor) {
|
|
523
|
+
let dom = document.createElement("img")
|
|
524
|
+
dom.className = "ProseMirror-separator"
|
|
525
|
+
dom.setAttribute("mark-placeholder", "true")
|
|
526
|
+
dom.setAttribute("alt", "")
|
|
527
|
+
view.cursorWrapper = {dom, deco: Decoration.widget(view.state.selection.head,
|
|
528
|
+
dom, {raw: true, marks: view.markCursor} as any)}
|
|
529
|
+
} else {
|
|
530
|
+
view.cursorWrapper = null
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function getEditable(view: EditorView) {
|
|
535
|
+
return !view.someProp("editable", value => value(view.state) === false)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function selectionContextChanged(sel1: Selection, sel2: Selection) {
|
|
539
|
+
let depth = Math.min(sel1.$anchor.sharedDepth(sel1.head), sel2.$anchor.sharedDepth(sel2.head))
|
|
540
|
+
return sel1.$anchor.start(depth) != sel2.$anchor.start(depth)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function buildNodeViews(view: EditorView) {
|
|
544
|
+
let result: NodeViewSet = Object.create(null)
|
|
545
|
+
function add(obj: NodeViewSet) {
|
|
546
|
+
for (let prop in obj) if (!Object.prototype.hasOwnProperty.call(result, prop))
|
|
547
|
+
result[prop] = obj[prop]
|
|
548
|
+
}
|
|
549
|
+
view.someProp("nodeViews", add)
|
|
550
|
+
view.someProp("markViews", add)
|
|
551
|
+
return result
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function changedNodeViews(a: NodeViewSet, b: NodeViewSet) {
|
|
555
|
+
let nA = 0, nB = 0
|
|
556
|
+
for (let prop in a) {
|
|
557
|
+
if (a[prop] != b[prop]) return true
|
|
558
|
+
nA++
|
|
559
|
+
}
|
|
560
|
+
for (let _ in b) nB++
|
|
561
|
+
return nA != nB
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function checkStateComponent(plugin: Plugin) {
|
|
565
|
+
if (plugin.spec.state || plugin.spec.filterTransaction || plugin.spec.appendTransaction)
|
|
566
|
+
throw new RangeError("Plugins passed directly to the view must not have a state component")
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/// The type of function [provided](#view.EditorProps.nodeViews) to
|
|
570
|
+
/// create [node views](#view.NodeView).
|
|
571
|
+
export type NodeViewConstructor = (node: Node, view: EditorView, getPos: () => number | undefined,
|
|
572
|
+
decorations: readonly Decoration[], innerDecorations: DecorationSource) => NodeView
|
|
573
|
+
|
|
574
|
+
/// The function types [used](#view.EditorProps.markViews) to create
|
|
575
|
+
/// mark views.
|
|
576
|
+
export type MarkViewConstructor = (mark: Mark, view: EditorView, inline: boolean) => {dom: HTMLElement, contentDOM?: HTMLElement}
|
|
577
|
+
|
|
578
|
+
type NodeViewSet = {[name: string]: NodeViewConstructor | MarkViewConstructor}
|
|
579
|
+
|
|
580
|
+
/// Helper type that maps event names to event object types, but
|
|
581
|
+
/// includes events that TypeScript's HTMLElementEventMap doesn't know
|
|
582
|
+
/// about.
|
|
583
|
+
export interface DOMEventMap extends HTMLElementEventMap {
|
|
584
|
+
[event: string]: any
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/// Props are configuration values that can be passed to an editor view
|
|
588
|
+
/// or included in a plugin. This interface lists the supported props.
|
|
589
|
+
///
|
|
590
|
+
/// The various event-handling functions may all return `true` to
|
|
591
|
+
/// indicate that they handled the given event. The view will then take
|
|
592
|
+
/// care to call `preventDefault` on the event, except with
|
|
593
|
+
/// `handleDOMEvents`, where the handler itself is responsible for that.
|
|
594
|
+
///
|
|
595
|
+
/// How a prop is resolved depends on the prop. Handler functions are
|
|
596
|
+
/// called one at a time, starting with the base props and then
|
|
597
|
+
/// searching through the plugins (in order of appearance) until one of
|
|
598
|
+
/// them returns true. For some props, the first plugin that yields a
|
|
599
|
+
/// value gets precedence.
|
|
600
|
+
///
|
|
601
|
+
/// The optional type parameter refers to the type of `this` in prop
|
|
602
|
+
/// functions, and is used to pass in the plugin type when defining a
|
|
603
|
+
/// [plugin](#state.Plugin).
|
|
604
|
+
export interface EditorProps<P = any> {
|
|
605
|
+
/// Can be an object mapping DOM event type names to functions that
|
|
606
|
+
/// handle them. Such functions will be called before any handling
|
|
607
|
+
/// ProseMirror does of events fired on the editable DOM element.
|
|
608
|
+
/// Contrary to the other event handling props, when returning true
|
|
609
|
+
/// from such a function, you are responsible for calling
|
|
610
|
+
/// `preventDefault` yourself (or not, if you want to allow the
|
|
611
|
+
/// default behavior).
|
|
612
|
+
handleDOMEvents?: {
|
|
613
|
+
[event in keyof DOMEventMap]?: (this: P, view: EditorView, event: DOMEventMap[event]) => boolean | void
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/// Called when the editor receives a `keydown` event.
|
|
617
|
+
handleKeyDown?: (this: P, view: EditorView, event: KeyboardEvent) => boolean | void
|
|
618
|
+
|
|
619
|
+
/// Handler for `keypress` events.
|
|
620
|
+
handleKeyPress?: (this: P, view: EditorView, event: KeyboardEvent) => boolean | void
|
|
621
|
+
|
|
622
|
+
/// Whenever the user directly input text, this handler is called
|
|
623
|
+
/// before the input is applied. If it returns `true`, the default
|
|
624
|
+
/// behavior of actually inserting the text is suppressed.
|
|
625
|
+
handleTextInput?: (this: P, view: EditorView, from: number, to: number, text: string) => boolean | void
|
|
626
|
+
|
|
627
|
+
/// Called for each node around a click, from the inside out. The
|
|
628
|
+
/// `direct` flag will be true for the inner node.
|
|
629
|
+
handleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void
|
|
630
|
+
|
|
631
|
+
/// Called when the editor is clicked, after `handleClickOn` handlers
|
|
632
|
+
/// have been called.
|
|
633
|
+
handleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void
|
|
634
|
+
|
|
635
|
+
/// Called for each node around a double click.
|
|
636
|
+
handleDoubleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void
|
|
637
|
+
|
|
638
|
+
/// Called when the editor is double-clicked, after `handleDoubleClickOn`.
|
|
639
|
+
handleDoubleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void
|
|
640
|
+
|
|
641
|
+
/// Called for each node around a triple click.
|
|
642
|
+
handleTripleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void
|
|
643
|
+
|
|
644
|
+
/// Called when the editor is triple-clicked, after `handleTripleClickOn`.
|
|
645
|
+
handleTripleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void
|
|
646
|
+
|
|
647
|
+
/// Can be used to override the behavior of pasting. `slice` is the
|
|
648
|
+
/// pasted content parsed by the editor, but you can directly access
|
|
649
|
+
/// the event to get at the raw content.
|
|
650
|
+
handlePaste?: (this: P, view: EditorView, event: ClipboardEvent, slice: Slice) => boolean | void
|
|
651
|
+
|
|
652
|
+
/// Called when something is dropped on the editor. `moved` will be
|
|
653
|
+
/// true if this drop moves from the current selection (which should
|
|
654
|
+
/// thus be deleted).
|
|
655
|
+
handleDrop?: (this: P, view: EditorView, event: DragEvent, slice: Slice, moved: boolean) => boolean | void
|
|
656
|
+
|
|
657
|
+
/// Called when the view, after updating its state, tries to scroll
|
|
658
|
+
/// the selection into view. A handler function may return false to
|
|
659
|
+
/// indicate that it did not handle the scrolling and further
|
|
660
|
+
/// handlers or the default behavior should be tried.
|
|
661
|
+
handleScrollToSelection?: (this: P, view: EditorView) => boolean
|
|
662
|
+
|
|
663
|
+
/// Can be used to override the way a selection is created when
|
|
664
|
+
/// reading a DOM selection between the given anchor and head.
|
|
665
|
+
createSelectionBetween?: (this: P, view: EditorView, anchor: ResolvedPos, head: ResolvedPos) => Selection | null
|
|
666
|
+
|
|
667
|
+
/// The [parser](#model.DOMParser) to use when reading editor changes
|
|
668
|
+
/// from the DOM. Defaults to calling
|
|
669
|
+
/// [`DOMParser.fromSchema`](#model.DOMParser^fromSchema) on the
|
|
670
|
+
/// editor's schema.
|
|
671
|
+
domParser?: DOMParser
|
|
672
|
+
|
|
673
|
+
/// Can be used to transform pasted HTML text, _before_ it is parsed,
|
|
674
|
+
/// for example to clean it up.
|
|
675
|
+
transformPastedHTML?: (this: P, html: string, view: EditorView) => string
|
|
676
|
+
|
|
677
|
+
/// The [parser](#model.DOMParser) to use when reading content from
|
|
678
|
+
/// the clipboard. When not given, the value of the
|
|
679
|
+
/// [`domParser`](#view.EditorProps.domParser) prop is used.
|
|
680
|
+
clipboardParser?: DOMParser
|
|
681
|
+
|
|
682
|
+
/// Transform pasted plain text. The `plain` flag will be true when
|
|
683
|
+
/// the text is pasted as plain text.
|
|
684
|
+
transformPastedText?: (this: P, text: string, plain: boolean, view: EditorView) => string
|
|
685
|
+
|
|
686
|
+
/// A function to parse text from the clipboard into a document
|
|
687
|
+
/// slice. Called after
|
|
688
|
+
/// [`transformPastedText`](#view.EditorProps.transformPastedText).
|
|
689
|
+
/// The default behavior is to split the text into lines, wrap them
|
|
690
|
+
/// in `<p>` tags, and call
|
|
691
|
+
/// [`clipboardParser`](#view.EditorProps.clipboardParser) on it.
|
|
692
|
+
/// The `plain` flag will be true when the text is pasted as plain text.
|
|
693
|
+
clipboardTextParser?: (this: P, text: string, $context: ResolvedPos, plain: boolean, view: EditorView) => Slice
|
|
694
|
+
|
|
695
|
+
/// Can be used to transform pasted or dragged-and-dropped content
|
|
696
|
+
/// before it is applied to the document.
|
|
697
|
+
transformPasted?: (this: P, slice: Slice, view: EditorView) => Slice
|
|
698
|
+
|
|
699
|
+
/// Can be used to transform copied or cut content before it is
|
|
700
|
+
/// serialized to the clipboard.
|
|
701
|
+
transformCopied?: (this: P, slice: Slice, view: EditorView) => Slice
|
|
702
|
+
|
|
703
|
+
/// Allows you to pass custom rendering and behavior logic for
|
|
704
|
+
/// nodes. Should map node names to constructor functions that
|
|
705
|
+
/// produce a [`NodeView`](#view.NodeView) object implementing the
|
|
706
|
+
/// node's display behavior. The third argument `getPos` is a
|
|
707
|
+
/// function that can be called to get the node's current position,
|
|
708
|
+
/// which can be useful when creating transactions to update it.
|
|
709
|
+
/// Note that if the node is not in the document, the position
|
|
710
|
+
/// returned by this function will be `undefined`.
|
|
711
|
+
///
|
|
712
|
+
/// `decorations` is an array of node or inline decorations that are
|
|
713
|
+
/// active around the node. They are automatically drawn in the
|
|
714
|
+
/// normal way, and you will usually just want to ignore this, but
|
|
715
|
+
/// they can also be used as a way to provide context information to
|
|
716
|
+
/// the node view without adding it to the document itself.
|
|
717
|
+
///
|
|
718
|
+
/// `innerDecorations` holds the decorations for the node's content.
|
|
719
|
+
/// You can safely ignore this if your view has no content or a
|
|
720
|
+
/// `contentDOM` property, since the editor will draw the decorations
|
|
721
|
+
/// on the content. But if you, for example, want to create a nested
|
|
722
|
+
/// editor with the content, it may make sense to provide it with the
|
|
723
|
+
/// inner decorations.
|
|
724
|
+
///
|
|
725
|
+
/// (For backwards compatibility reasons, [mark
|
|
726
|
+
/// views](#view.EditorProps.markViews) can also be included in this
|
|
727
|
+
/// object.)
|
|
728
|
+
nodeViews?: {[node: string]: NodeViewConstructor}
|
|
729
|
+
|
|
730
|
+
/// Pass custom mark rendering functions. Note that these cannot
|
|
731
|
+
/// provide the kind of dynamic behavior that [node
|
|
732
|
+
/// views](#view.NodeView) can—they just provide custom rendering
|
|
733
|
+
/// logic. The third argument indicates whether the mark's content
|
|
734
|
+
/// is inline.
|
|
735
|
+
markViews?: {[mark: string]: MarkViewConstructor}
|
|
736
|
+
|
|
737
|
+
/// The DOM serializer to use when putting content onto the
|
|
738
|
+
/// clipboard. If not given, the result of
|
|
739
|
+
/// [`DOMSerializer.fromSchema`](#model.DOMSerializer^fromSchema)
|
|
740
|
+
/// will be used. This object will only have its
|
|
741
|
+
/// [`serializeFragment`](#model.DOMSerializer.serializeFragment)
|
|
742
|
+
/// method called, and you may provide an alternative object type
|
|
743
|
+
/// implementing a compatible method.
|
|
744
|
+
clipboardSerializer?: DOMSerializer
|
|
745
|
+
|
|
746
|
+
/// A function that will be called to get the text for the current
|
|
747
|
+
/// selection when copying text to the clipboard. By default, the
|
|
748
|
+
/// editor will use [`textBetween`](#model.Node.textBetween) on the
|
|
749
|
+
/// selected range.
|
|
750
|
+
clipboardTextSerializer?: (this: P, content: Slice, view: EditorView) => string
|
|
751
|
+
|
|
752
|
+
/// A set of [document decorations](#view.Decoration) to show in the
|
|
753
|
+
/// view.
|
|
754
|
+
decorations?: (this: P, state: EditorState) => DecorationSource | null | undefined
|
|
755
|
+
|
|
756
|
+
/// When this returns false, the content of the view is not directly
|
|
757
|
+
/// editable.
|
|
758
|
+
editable?: (this: P, state: EditorState) => boolean
|
|
759
|
+
|
|
760
|
+
/// Control the DOM attributes of the editable element. May be either
|
|
761
|
+
/// an object or a function going from an editor state to an object.
|
|
762
|
+
/// By default, the element will get a class `"ProseMirror"`, and
|
|
763
|
+
/// will have its `contentEditable` attribute determined by the
|
|
764
|
+
/// [`editable` prop](#view.EditorProps.editable). Additional classes
|
|
765
|
+
/// provided here will be added to the class. For other attributes,
|
|
766
|
+
/// the value provided first (as in
|
|
767
|
+
/// [`someProp`](#view.EditorView.someProp)) will be used.
|
|
768
|
+
attributes?: {[name: string]: string} | ((state: EditorState) => {[name: string]: string})
|
|
769
|
+
|
|
770
|
+
/// Determines the distance (in pixels) between the cursor and the
|
|
771
|
+
/// end of the visible viewport at which point, when scrolling the
|
|
772
|
+
/// cursor into view, scrolling takes place. Defaults to 0.
|
|
773
|
+
scrollThreshold?: number | {top: number, right: number, bottom: number, left: number}
|
|
774
|
+
|
|
775
|
+
/// Determines the extra space (in pixels) that is left above or
|
|
776
|
+
/// below the cursor when it is scrolled into view. Defaults to 5.
|
|
777
|
+
scrollMargin?: number | {top: number, right: number, bottom: number, left: number}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/// The props object given directly to the editor view supports some
|
|
781
|
+
/// fields that can't be used in plugins:
|
|
782
|
+
export interface DirectEditorProps extends EditorProps {
|
|
783
|
+
/// The current state of the editor.
|
|
784
|
+
state: EditorState
|
|
785
|
+
|
|
786
|
+
/// A set of plugins to use in the view, applying their [plugin
|
|
787
|
+
/// view](#state.PluginSpec.view) and
|
|
788
|
+
/// [props](#state.PluginSpec.props). Passing plugins with a state
|
|
789
|
+
/// component (a [state field](#state.PluginSpec.state) field or a
|
|
790
|
+
/// [transaction](#state.PluginSpec.filterTransaction) filter or
|
|
791
|
+
/// appender) will result in an error, since such plugins must be
|
|
792
|
+
/// present in the state to work.
|
|
793
|
+
plugins?: readonly Plugin[]
|
|
794
|
+
|
|
795
|
+
/// The callback over which to send transactions (state updates)
|
|
796
|
+
/// produced by the view. If you specify this, you probably want to
|
|
797
|
+
/// make sure this ends up calling the view's
|
|
798
|
+
/// [`updateState`](#view.EditorView.updateState) method with a new
|
|
799
|
+
/// state that has the transaction
|
|
800
|
+
/// [applied](#state.EditorState.apply). The callback will be bound to have
|
|
801
|
+
/// the view instance as its `this` binding.
|
|
802
|
+
dispatchTransaction?: (tr: Transaction) => void
|
|
803
|
+
}
|
package/src/dom.ts
CHANGED
|
@@ -29,6 +29,10 @@ export const textRange = function(node: Text, from?: number, to?: number) {
|
|
|
29
29
|
return range
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export const clearReusedRange = function() {
|
|
33
|
+
reusedRange = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
// Scans forward and backward through DOM positions equivalent to the
|
|
33
37
|
// given one to see if the two are in the same place (i.e. after a
|
|
34
38
|
// text node vs at the end of that text node)
|
|
@@ -63,6 +67,38 @@ export function nodeSize(node: Node) {
|
|
|
63
67
|
return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
export function textNodeBefore(node: Node, offset: number) {
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (node.nodeType == 3 && offset) return node as Text
|
|
73
|
+
if (node.nodeType == 1 && offset > 0) {
|
|
74
|
+
if ((node as HTMLElement).contentEditable != "false") return null
|
|
75
|
+
node = node.childNodes[offset - 1]
|
|
76
|
+
offset = nodeSize(node)
|
|
77
|
+
} else if (node.parentNode && !hasBlockDesc(node)) {
|
|
78
|
+
offset = domIndex(node)
|
|
79
|
+
node = node.parentNode
|
|
80
|
+
} else {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function textNodeAfter(node: Node, offset: number) {
|
|
87
|
+
for (;;) {
|
|
88
|
+
if (node.nodeType == 3 && offset < node.nodeValue!.length) return node as Text
|
|
89
|
+
if (node.nodeType == 1 && offset < node.childNodes.length) {
|
|
90
|
+
if ((node as HTMLElement).contentEditable != "false") return null
|
|
91
|
+
node = node.childNodes[offset]
|
|
92
|
+
offset = 0
|
|
93
|
+
} else if (node.parentNode && !hasBlockDesc(node)) {
|
|
94
|
+
offset = domIndex(node) + 1
|
|
95
|
+
node = node.parentNode
|
|
96
|
+
} else {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
export function isOnEdge(node: Node, offset: number, parent: Node) {
|
|
67
103
|
for (let atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) {
|
|
68
104
|
if (node == parent) return true
|
package/src/domchange.ts
CHANGED
|
@@ -143,13 +143,6 @@ export function readDOMChange(view: EditorView, from: number, to: number, typeOv
|
|
|
143
143
|
return
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
-
// Chrome sometimes leaves the cursor before the inserted text when
|
|
147
|
-
// composing after a cursor wrapper. This moves it forward.
|
|
148
|
-
if (browser.chrome && view.cursorWrapper && parse.sel && parse.sel.anchor == view.cursorWrapper.deco.from &&
|
|
149
|
-
parse.sel.head == parse.sel.anchor) {
|
|
150
|
-
let size = change.endB - change.start
|
|
151
|
-
parse.sel = {anchor: parse.sel.anchor + size, head: parse.sel.anchor + size}
|
|
152
|
-
}
|
|
153
146
|
|
|
154
147
|
view.input.domChangeCount++
|
|
155
148
|
// Handle the case where overwriting a selection by typing matches
|
|
@@ -197,7 +190,7 @@ export function readDOMChange(view: EditorView, from: number, to: number, typeOv
|
|
|
197
190
|
}
|
|
198
191
|
// Same for backspace
|
|
199
192
|
if (view.state.selection.anchor > change.start &&
|
|
200
|
-
|
|
193
|
+
looksLikeBackspace(doc, change.start, change.endA, $from, $to) &&
|
|
201
194
|
view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) {
|
|
202
195
|
if (browser.android && browser.chrome) view.domObserver.suppressSelectionUpdates() // #820
|
|
203
196
|
return
|
|
@@ -304,15 +297,21 @@ function isMarkChange(cur: Fragment, prev: Fragment) {
|
|
|
304
297
|
if (Fragment.from(updated).eq(cur)) return {mark, type}
|
|
305
298
|
}
|
|
306
299
|
|
|
307
|
-
function
|
|
308
|
-
if (
|
|
309
|
-
// The content must have shrunk
|
|
300
|
+
function looksLikeBackspace(old: Node, start: number, end: number, $newStart: ResolvedPos, $newEnd: ResolvedPos) {
|
|
301
|
+
if (// The content must have shrunk
|
|
310
302
|
end - start <= $newEnd.pos - $newStart.pos ||
|
|
311
303
|
// newEnd must point directly at or after the end of the block that newStart points into
|
|
312
304
|
skipClosingAndOpening($newStart, true, false) < $newEnd.pos)
|
|
313
305
|
return false
|
|
314
306
|
|
|
315
307
|
let $start = old.resolve(start)
|
|
308
|
+
|
|
309
|
+
// Handle the case where, rather than joining blocks, the change just removed an entire block
|
|
310
|
+
if (!$newStart.parent.isTextblock) {
|
|
311
|
+
let after = $start.nodeAfter
|
|
312
|
+
return after != null && end == start + after.nodeSize
|
|
313
|
+
}
|
|
314
|
+
|
|
316
315
|
// Start must be at the end of a block
|
|
317
316
|
if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock)
|
|
318
317
|
return false
|
package/src/domcoords.ts
CHANGED
|
@@ -6,6 +6,11 @@ import {EditorView} from "./index"
|
|
|
6
6
|
export type Rect = {left: number, right: number, top: number, bottom: number}
|
|
7
7
|
|
|
8
8
|
function windowRect(doc: Document): Rect {
|
|
9
|
+
let vp = doc.defaultView && doc.defaultView.visualViewport
|
|
10
|
+
if (vp) return {
|
|
11
|
+
left: 0, right: vp.width,
|
|
12
|
+
top: 0, bottom: vp.height
|
|
13
|
+
}
|
|
9
14
|
return {left: 0, right: doc.documentElement.clientWidth,
|
|
10
15
|
top: 0, bottom: doc.documentElement.clientHeight}
|
|
11
16
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {selectionToDOM, anchorInRightPlace, syncNodeSelection} from "./selection
|
|
|
10
10
|
import {Decoration, viewDecorations, DecorationSource} from "./decoration"
|
|
11
11
|
import {DOMObserver, safariShadowSelectionRange} from "./domobserver"
|
|
12
12
|
import {readDOMChange} from "./domchange"
|
|
13
|
-
import {DOMSelection, DOMNode, DOMSelectionRange, deepActiveElement} from "./dom"
|
|
13
|
+
import {DOMSelection, DOMNode, DOMSelectionRange, deepActiveElement, clearReusedRange} from "./dom"
|
|
14
14
|
import * as browser from "./browser"
|
|
15
15
|
|
|
16
16
|
export {Decoration, DecorationSet, DecorationAttrs, DecorationSource} from "./decoration"
|
|
@@ -198,7 +198,7 @@ export class EditorView {
|
|
|
198
198
|
// did write to the node.
|
|
199
199
|
let chromeKludge = browser.chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null
|
|
200
200
|
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
|
|
201
|
-
this.docView.updateOuterDeco(
|
|
201
|
+
this.docView.updateOuterDeco(outerDeco)
|
|
202
202
|
this.docView.destroy()
|
|
203
203
|
this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this)
|
|
204
204
|
}
|
|
@@ -457,6 +457,7 @@ export class EditorView {
|
|
|
457
457
|
}
|
|
458
458
|
this.docView.destroy()
|
|
459
459
|
;(this as any).docView = null
|
|
460
|
+
clearReusedRange();
|
|
460
461
|
}
|
|
461
462
|
|
|
462
463
|
/// This is true when the view has been
|
package/src/viewdesc.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {DOMSerializer, Fragment, Mark, Node, ParseRule} from "prosemirror-model"
|
|
2
2
|
import {TextSelection} from "prosemirror-state"
|
|
3
3
|
|
|
4
|
-
import {domIndex, isEquivalentPosition,
|
|
4
|
+
import {domIndex, isEquivalentPosition, DOMNode, textNodeBefore, textNodeAfter} from "./dom"
|
|
5
5
|
import * as browser from "./browser"
|
|
6
6
|
import {Decoration, DecorationSource, WidgetConstructor, WidgetType, NodeType} from "./decoration"
|
|
7
7
|
import {EditorView} from "./index"
|
|
@@ -760,7 +760,14 @@ export class NodeViewDesc extends ViewDesc {
|
|
|
760
760
|
let {from, to} = view.state.selection
|
|
761
761
|
if (!(view.state.selection instanceof TextSelection) || from < pos || to > pos + this.node.content.size) return null
|
|
762
762
|
let sel = view.domSelectionRange()
|
|
763
|
-
let
|
|
763
|
+
let textBefore = textNodeBefore(sel.focusNode!, sel.focusOffset)
|
|
764
|
+
let textAfter = textNodeAfter(sel.focusNode!, sel.focusOffset)
|
|
765
|
+
let textNode = textBefore || textAfter
|
|
766
|
+
if (textBefore && textAfter && textBefore != textAfter) {
|
|
767
|
+
let descAfter = textAfter.pmViewDesc
|
|
768
|
+
if (!descAfter || descAfter instanceof TextViewDesc && descAfter.node.text != textAfter.nodeValue)
|
|
769
|
+
textNode = textAfter
|
|
770
|
+
}
|
|
764
771
|
if (!textNode || !this.dom.contains(textNode.parentNode)) return null
|
|
765
772
|
|
|
766
773
|
if (this.node.inlineContent) {
|
|
@@ -1470,23 +1477,6 @@ function iosHacks(dom: HTMLElement) {
|
|
|
1470
1477
|
}
|
|
1471
1478
|
}
|
|
1472
1479
|
|
|
1473
|
-
function nearbyTextNode(node: DOMNode, offset: number): Text | null {
|
|
1474
|
-
for (;;) {
|
|
1475
|
-
if (node.nodeType == 3) return node as Text
|
|
1476
|
-
if (node.nodeType == 1 && offset > 0) {
|
|
1477
|
-
if (node.childNodes.length > offset && node.childNodes[offset].nodeType == 3)
|
|
1478
|
-
return node.childNodes[offset] as Text
|
|
1479
|
-
node = node.childNodes[offset - 1]
|
|
1480
|
-
offset = nodeSize(node)
|
|
1481
|
-
} else if (node.nodeType == 1 && offset < node.childNodes.length) {
|
|
1482
|
-
node = node.childNodes[offset]
|
|
1483
|
-
offset = 0
|
|
1484
|
-
} else {
|
|
1485
|
-
return null
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
1480
|
// Find a piece of text in an inline fragment, overlapping from-to
|
|
1491
1481
|
function findTextInFragment(frag: Fragment, text: string, from: number, to: number) {
|
|
1492
1482
|
for (let i = 0, pos = 0; i < frag.childCount && pos <= to;) {
|