You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
5387 lines
217 KiB
5387 lines
217 KiB
3 years ago
|
import { TextSelection, NodeSelection, Selection, AllSelection } from 'prosemirror-state';
|
||
|
import { DOMSerializer, Fragment, Mark, Slice, DOMParser } from 'prosemirror-model';
|
||
|
import { dropPoint } from 'prosemirror-transform';
|
||
|
|
||
|
const domIndex = function (node) {
|
||
|
for (var index = 0;; index++) {
|
||
|
node = node.previousSibling;
|
||
|
if (!node)
|
||
|
return index;
|
||
|
}
|
||
|
};
|
||
|
const parentNode = function (node) {
|
||
|
let parent = node.assignedSlot || node.parentNode;
|
||
|
return parent && parent.nodeType == 11 ? parent.host : parent;
|
||
|
};
|
||
|
let reusedRange = null;
|
||
|
// Note that this will always return the same range, because DOM range
|
||
|
// objects are every expensive, and keep slowing down subsequent DOM
|
||
|
// updates, for some reason.
|
||
|
const textRange = function (node, from, to) {
|
||
|
let range = reusedRange || (reusedRange = document.createRange());
|
||
|
range.setEnd(node, to == null ? node.nodeValue.length : to);
|
||
|
range.setStart(node, from || 0);
|
||
|
return range;
|
||
|
};
|
||
|
// Scans forward and backward through DOM positions equivalent to the
|
||
|
// given one to see if the two are in the same place (i.e. after a
|
||
|
// text node vs at the end of that text node)
|
||
|
const isEquivalentPosition = function (node, off, targetNode, targetOff) {
|
||
|
return targetNode && (scanFor(node, off, targetNode, targetOff, -1) ||
|
||
|
scanFor(node, off, targetNode, targetOff, 1));
|
||
|
};
|
||
|
const atomElements = /^(img|br|input|textarea|hr)$/i;
|
||
|
function scanFor(node, off, targetNode, targetOff, dir) {
|
||
|
for (;;) {
|
||
|
if (node == targetNode && off == targetOff)
|
||
|
return true;
|
||
|
if (off == (dir < 0 ? 0 : nodeSize(node))) {
|
||
|
let parent = node.parentNode;
|
||
|
if (!parent || parent.nodeType != 1 || hasBlockDesc(node) || atomElements.test(node.nodeName) ||
|
||
|
node.contentEditable == "false")
|
||
|
return false;
|
||
|
off = domIndex(node) + (dir < 0 ? 0 : 1);
|
||
|
node = parent;
|
||
|
}
|
||
|
else if (node.nodeType == 1) {
|
||
|
node = node.childNodes[off + (dir < 0 ? -1 : 0)];
|
||
|
if (node.contentEditable == "false")
|
||
|
return false;
|
||
|
off = dir < 0 ? nodeSize(node) : 0;
|
||
|
}
|
||
|
else {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
function nodeSize(node) {
|
||
|
return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length;
|
||
|
}
|
||
|
function isOnEdge(node, offset, parent) {
|
||
|
for (let atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) {
|
||
|
if (node == parent)
|
||
|
return true;
|
||
|
let index = domIndex(node);
|
||
|
node = node.parentNode;
|
||
|
if (!node)
|
||
|
return false;
|
||
|
atStart = atStart && index == 0;
|
||
|
atEnd = atEnd && index == nodeSize(node);
|
||
|
}
|
||
|
}
|
||
|
function hasBlockDesc(dom) {
|
||
|
let desc;
|
||
|
for (let cur = dom; cur; cur = cur.parentNode)
|
||
|
if (desc = cur.pmViewDesc)
|
||
|
break;
|
||
|
return desc && desc.node && desc.node.isBlock && (desc.dom == dom || desc.contentDOM == dom);
|
||
|
}
|
||
|
// Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523
|
||
|
// (isCollapsed inappropriately returns true in shadow dom)
|
||
|
const selectionCollapsed = function (domSel) {
|
||
|
return domSel.focusNode && isEquivalentPosition(domSel.focusNode, domSel.focusOffset, domSel.anchorNode, domSel.anchorOffset);
|
||
|
};
|
||
|
function keyEvent(keyCode, key) {
|
||
|
let event = document.createEvent("Event");
|
||
|
event.initEvent("keydown", true, true);
|
||
|
event.keyCode = keyCode;
|
||
|
event.key = event.code = key;
|
||
|
return event;
|
||
|
}
|
||
|
function deepActiveElement(doc) {
|
||
|
let elt = doc.activeElement;
|
||
|
while (elt && elt.shadowRoot)
|
||
|
elt = elt.shadowRoot.activeElement;
|
||
|
return elt;
|
||
|
}
|
||
|
|
||
|
const nav = typeof navigator != "undefined" ? navigator : null;
|
||
|
const doc = typeof document != "undefined" ? document : null;
|
||
|
const agent = (nav && nav.userAgent) || "";
|
||
|
const ie_edge = /Edge\/(\d+)/.exec(agent);
|
||
|
const ie_upto10 = /MSIE \d/.exec(agent);
|
||
|
const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent);
|
||
|
const ie = !!(ie_upto10 || ie_11up || ie_edge);
|
||
|
const ie_version = ie_upto10 ? document.documentMode : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0;
|
||
|
const gecko = !ie && /gecko\/(\d+)/i.test(agent);
|
||
|
gecko && +(/Firefox\/(\d+)/.exec(agent) || [0, 0])[1];
|
||
|
const _chrome = !ie && /Chrome\/(\d+)/.exec(agent);
|
||
|
const chrome = !!_chrome;
|
||
|
const chrome_version = _chrome ? +_chrome[1] : 0;
|
||
|
const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor);
|
||
|
// Is true for both iOS and iPadOS for convenience
|
||
|
const ios = safari && (/Mobile\/\w+/.test(agent) || !!nav && nav.maxTouchPoints > 2);
|
||
|
const mac = ios || (nav ? /Mac/.test(nav.platform) : false);
|
||
|
const android = /Android \d/.test(agent);
|
||
|
const webkit = !!doc && "webkitFontSmoothing" in doc.documentElement.style;
|
||
|
const webkit_version = webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0;
|
||
|
|
||
|
function windowRect(doc) {
|
||
|
return { left: 0, right: doc.documentElement.clientWidth,
|
||
|
top: 0, bottom: doc.documentElement.clientHeight };
|
||
|
}
|
||
|
function getSide(value, side) {
|
||
|
return typeof value == "number" ? value : value[side];
|
||
|
}
|
||
|
function clientRect(node) {
|
||
|
let rect = node.getBoundingClientRect();
|
||
|
// Adjust for elements with style "transform: scale()"
|
||
|
let scaleX = (rect.width / node.offsetWidth) || 1;
|
||
|
let scaleY = (rect.height / node.offsetHeight) || 1;
|
||
|
// Make sure scrollbar width isn't included in the rectangle
|
||
|
return { left: rect.left, right: rect.left + node.clientWidth * scaleX,
|
||
|
top: rect.top, bottom: rect.top + node.clientHeight * scaleY };
|
||
|
}
|
||
|
function scrollRectIntoView(view, rect, startDOM) {
|
||
|
let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5;
|
||
|
let doc = view.dom.ownerDocument;
|
||
|
for (let parent = startDOM || view.dom;; parent = parentNode(parent)) {
|
||
|
if (!parent)
|
||
|
break;
|
||
|
if (parent.nodeType != 1)
|
||
|
continue;
|
||
|
let elt = parent;
|
||
|
let atTop = elt == doc.body;
|
||
|
let bounding = atTop ? windowRect(doc) : clientRect(elt);
|
||
|
let moveX = 0, moveY = 0;
|
||
|
if (rect.top < bounding.top + getSide(scrollThreshold, "top"))
|
||
|
moveY = -(bounding.top - rect.top + getSide(scrollMargin, "top"));
|
||
|
else if (rect.bottom > bounding.bottom - getSide(scrollThreshold, "bottom"))
|
||
|
moveY = rect.bottom - bounding.bottom + getSide(scrollMargin, "bottom");
|
||
|
if (rect.left < bounding.left + getSide(scrollThreshold, "left"))
|
||
|
moveX = -(bounding.left - rect.left + getSide(scrollMargin, "left"));
|
||
|
else if (rect.right > bounding.right - getSide(scrollThreshold, "right"))
|
||
|
moveX = rect.right - bounding.right + getSide(scrollMargin, "right");
|
||
|
if (moveX || moveY) {
|
||
|
if (atTop) {
|
||
|
doc.defaultView.scrollBy(moveX, moveY);
|
||
|
}
|
||
|
else {
|
||
|
let startX = elt.scrollLeft, startY = elt.scrollTop;
|
||
|
if (moveY)
|
||
|
elt.scrollTop += moveY;
|
||
|
if (moveX)
|
||
|
elt.scrollLeft += moveX;
|
||
|
let dX = elt.scrollLeft - startX, dY = elt.scrollTop - startY;
|
||
|
rect = { left: rect.left - dX, top: rect.top - dY, right: rect.right - dX, bottom: rect.bottom - dY };
|
||
|
}
|
||
|
}
|
||
|
if (atTop)
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
// Store the scroll position of the editor's parent nodes, along with
|
||
|
// the top position of an element near the top of the editor, which
|
||
|
// will be used to make sure the visible viewport remains stable even
|
||
|
// when the size of the content above changes.
|
||
|
function storeScrollPos(view) {
|
||
|
let rect = view.dom.getBoundingClientRect(), startY = Math.max(0, rect.top);
|
||
|
let refDOM, refTop;
|
||
|
for (let x = (rect.left + rect.right) / 2, y = startY + 1; y < Math.min(innerHeight, rect.bottom); y += 5) {
|
||
|
let dom = view.root.elementFromPoint(x, y);
|
||
|
if (!dom || dom == view.dom || !view.dom.contains(dom))
|
||
|
continue;
|
||
|
let localRect = dom.getBoundingClientRect();
|
||
|
if (localRect.top >= startY - 20) {
|
||
|
refDOM = dom;
|
||
|
refTop = localRect.top;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
return { refDOM: refDOM, refTop: refTop, stack: scrollStack(view.dom) };
|
||
|
}
|
||
|
function scrollStack(dom) {
|
||
|
let stack = [], doc = dom.ownerDocument;
|
||
|
for (let cur = dom; cur; cur = parentNode(cur)) {
|
||
|
stack.push({ dom: cur, top: cur.scrollTop, left: cur.scrollLeft });
|
||
|
if (dom == doc)
|
||
|
break;
|
||
|
}
|
||
|
return stack;
|
||
|
}
|
||
|
// Reset the scroll position of the editor's parent nodes to that what
|
||
|
// it was before, when storeScrollPos was called.
|
||
|
function resetScrollPos({ refDOM, refTop, stack }) {
|
||
|
let newRefTop = refDOM ? refDOM.getBoundingClientRect().top : 0;
|
||
|
restoreScrollStack(stack, newRefTop == 0 ? 0 : newRefTop - refTop);
|
||
|
}
|
||
|
function restoreScrollStack(stack, dTop) {
|
||
|
for (let i = 0; i < stack.length; i++) {
|
||
|
let { dom, top, left } = stack[i];
|
||
|
if (dom.scrollTop != top + dTop)
|
||
|
dom.scrollTop = top + dTop;
|
||
|
if (dom.scrollLeft != left)
|
||
|
dom.scrollLeft = left;
|
||
|
}
|
||
|
}
|
||
|
let preventScrollSupported = null;
|
||
|
// Feature-detects support for .focus({preventScroll: true}), and uses
|
||
|
// a fallback kludge when not supported.
|
||
|
function focusPreventScroll(dom) {
|
||
|
if (dom.setActive)
|
||
|
return dom.setActive(); // in IE
|
||
|
if (preventScrollSupported)
|
||
|
return dom.focus(preventScrollSupported);
|
||
|
let stored = scrollStack(dom);
|
||
|
dom.focus(preventScrollSupported == null ? {
|
||
|
get preventScroll() {
|
||
|
preventScrollSupported = { preventScroll: true };
|
||
|
return true;
|
||
|
}
|
||
|
} : undefined);
|
||
|
if (!preventScrollSupported) {
|
||
|
preventScrollSupported = false;
|
||
|
restoreScrollStack(stored, 0);
|
||
|
}
|
||
|
}
|
||
|
function findOffsetInNode(node, coords) {
|
||
|
let closest, dxClosest = 2e8, coordsClosest, offset = 0;
|
||
|
let rowBot = coords.top, rowTop = coords.top;
|
||
|
for (let child = node.firstChild, childIndex = 0; child; child = child.nextSibling, childIndex++) {
|
||
|
let rects;
|
||
|
if (child.nodeType == 1)
|
||
|
rects = child.getClientRects();
|
||
|
else if (child.nodeType == 3)
|
||
|
rects = textRange(child).getClientRects();
|
||
|
else
|
||
|
continue;
|
||
|
for (let i = 0; i < rects.length; i++) {
|
||
|
let rect = rects[i];
|
||
|
if (rect.top <= rowBot && rect.bottom >= rowTop) {
|
||
|
rowBot = Math.max(rect.bottom, rowBot);
|
||
|
rowTop = Math.min(rect.top, rowTop);
|
||
|
let dx = rect.left > coords.left ? rect.left - coords.left
|
||
|
: rect.right < coords.left ? coords.left - rect.right : 0;
|
||
|
if (dx < dxClosest) {
|
||
|
closest = child;
|
||
|
dxClosest = dx;
|
||
|
coordsClosest = dx && closest.nodeType == 3 ? {
|
||
|
left: rect.right < coords.left ? rect.right : rect.left,
|
||
|
top: coords.top
|
||
|
} : coords;
|
||
|
if (child.nodeType == 1 && dx)
|
||
|
offset = childIndex + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0);
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
if (!closest && (coords.left >= rect.right && coords.top >= rect.top ||
|
||
|
coords.left >= rect.left && coords.top >= rect.bottom))
|
||
|
offset = childIndex + 1;
|
||
|
}
|
||
|
}
|
||
|
if (closest && closest.nodeType == 3)
|
||
|
return findOffsetInText(closest, coordsClosest);
|
||
|
if (!closest || (dxClosest && closest.nodeType == 1))
|
||
|
return { node, offset };
|
||
|
return findOffsetInNode(closest, coordsClosest);
|
||
|
}
|
||
|
function findOffsetInText(node, coords) {
|
||
|
let len = node.nodeValue.length;
|
||
|
let range = document.createRange();
|
||
|
for (let i = 0; i < len; i++) {
|
||
|
range.setEnd(node, i + 1);
|
||
|
range.setStart(node, i);
|
||
|
let rect = singleRect(range, 1);
|
||
|
if (rect.top == rect.bottom)
|
||
|
continue;
|
||
|
if (inRect(coords, rect))
|
||
|
return { node, offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0) };
|
||
|
}
|
||
|
return { node, offset: 0 };
|
||
|
}
|
||
|
function inRect(coords, rect) {
|
||
|
return coords.left >= rect.left - 1 && coords.left <= rect.right + 1 &&
|
||
|
coords.top >= rect.top - 1 && coords.top <= rect.bottom + 1;
|
||
|
}
|
||
|
function targetKludge(dom, coords) {
|
||
|
let parent = dom.parentNode;
|
||
|
if (parent && /^li$/i.test(parent.nodeName) && coords.left < dom.getBoundingClientRect().left)
|
||
|
return parent;
|
||
|
return dom;
|
||
|
}
|
||
|
function posFromElement(view, elt, coords) {
|
||
|
let { node, offset } = findOffsetInNode(elt, coords), bias = -1;
|
||
|
if (node.nodeType == 1 && !node.firstChild) {
|
||
|
let rect = node.getBoundingClientRect();
|
||
|
bias = rect.left != rect.right && coords.left > (rect.left + rect.right) / 2 ? 1 : -1;
|
||
|
}
|
||
|
return view.docView.posFromDOM(node, offset, bias);
|
||
|
}
|
||
|
function posFromCaret(view, node, offset, coords) {
|
||
|
// Browser (in caretPosition/RangeFromPoint) will agressively
|
||
|
// normalize towards nearby inline nodes. Since we are interested in
|
||
|
// positions between block nodes too, we first walk up the hierarchy
|
||
|
// of nodes to see if there are block nodes that the coordinates
|
||
|
// fall outside of. If so, we take the position before/after that
|
||
|
// block. If not, we call `posFromDOM` on the raw node/offset.
|
||
|
let outside = -1;
|
||
|
for (let cur = node;;) {
|
||
|
if (cur == view.dom)
|
||
|
break;
|
||
|
let desc = view.docView.nearestDesc(cur, true);
|
||
|
if (!desc)
|
||
|
return null;
|
||
|
if (desc.node.isBlock && desc.parent) {
|
||
|
let rect = desc.dom.getBoundingClientRect();
|
||
|
if (rect.left > coords.left || rect.top > coords.top)
|
||
|
outside = desc.posBefore;
|
||
|
else if (rect.right < coords.left || rect.bottom < coords.top)
|
||
|
outside = desc.posAfter;
|
||
|
else
|
||
|
break;
|
||
|
}
|
||
|
cur = desc.dom.parentNode;
|
||
|
}
|
||
|
return outside > -1 ? outside : view.docView.posFromDOM(node, offset, 1);
|
||
|
}
|
||
|
function elementFromPoint(element, coords, box) {
|
||
|
let len = element.childNodes.length;
|
||
|
if (len && box.top < box.bottom) {
|
||
|
for (let startI = Math.max(0, Math.min(len - 1, Math.floor(len * (coords.top - box.top) / (box.bottom - box.top)) - 2)), i = startI;;) {
|
||
|
let child = element.childNodes[i];
|
||
|
if (child.nodeType == 1) {
|
||
|
let rects = child.getClientRects();
|
||
|
for (let j = 0; j < rects.length; j++) {
|
||
|
let rect = rects[j];
|
||
|
if (inRect(coords, rect))
|
||
|
return elementFromPoint(child, coords, rect);
|
||
|
}
|
||
|
}
|
||
|
if ((i = (i + 1) % len) == startI)
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
return element;
|
||
|
}
|
||
|
// Given an x,y position on the editor, get the position in the document.
|
||
|
function posAtCoords(view, coords) {
|
||
|
let doc = view.dom.ownerDocument, node, offset = 0;
|
||
|
if (doc.caretPositionFromPoint) {
|
||
|
try { // Firefox throws for this call in hard-to-predict circumstances (#994)
|
||
|
let pos = doc.caretPositionFromPoint(coords.left, coords.top);
|
||
|
if (pos)
|
||
|
({ offsetNode: node, offset } = pos);
|
||
|
}
|
||
|
catch (_) { }
|
||
|
}
|
||
|
if (!node && doc.caretRangeFromPoint) {
|
||
|
let range = doc.caretRangeFromPoint(coords.left, coords.top);
|
||
|
if (range)
|
||
|
({ startContainer: node, startOffset: offset } = range);
|
||
|
}
|
||
|
let elt = (view.root.elementFromPoint ? view.root : doc)
|
||
|
.elementFromPoint(coords.left, coords.top);
|
||
|
let pos;
|
||
|
if (!elt || !view.dom.contains(elt.nodeType != 1 ? elt.parentNode : elt)) {
|
||
|
let box = view.dom.getBoundingClientRect();
|
||
|
if (!inRect(coords, box))
|
||
|
return null;
|
||
|
elt = elementFromPoint(view.dom, coords, box);
|
||
|
if (!elt)
|
||
|
return null;
|
||
|
}
|
||
|
// Safari's caretRangeFromPoint returns nonsense when on a draggable element
|
||
|
if (safari) {
|
||
|
for (let p = elt; node && p; p = parentNode(p))
|
||
|
if (p.draggable)
|
||
|
node = undefined;
|
||
|
}
|
||
|
elt = targetKludge(elt, coords);
|
||
|
if (node) {
|
||
|
if (gecko && node.nodeType == 1) {
|
||
|
// Firefox will sometimes return offsets into <input> nodes, which
|
||
|
// have no actual children, from caretPositionFromPoint (#953)
|
||
|
offset = Math.min(offset, node.childNodes.length);
|
||
|
// It'll also move the returned position before image nodes,
|
||
|
// even if those are behind it.
|
||
|
if (offset < node.childNodes.length) {
|
||
|
let next = node.childNodes[offset], box;
|
||
|
if (next.nodeName == "IMG" && (box = next.getBoundingClientRect()).right <= coords.left &&
|
||
|
box.bottom > coords.top)
|
||
|
offset++;
|
||
|
}
|
||
|
}
|
||
|
// Suspiciously specific kludge to work around caret*FromPoint
|
||
|
// never returning a position at the end of the document
|
||
|
if (node == view.dom && offset == node.childNodes.length - 1 && node.lastChild.nodeType == 1 &&
|
||
|
coords.top > node.lastChild.getBoundingClientRect().bottom)
|
||
|
pos = view.state.doc.content.size;
|
||
|
// Ignore positions directly after a BR, since caret*FromPoint
|
||
|
// 'round up' positions that would be more accurately placed
|
||
|
// before the BR node.
|
||
|
else if (offset == 0 || node.nodeType != 1 || node.childNodes[offset - 1].nodeName != "BR")
|
||
|
pos = posFromCaret(view, node, offset, coords);
|
||
|
}
|
||
|
if (pos == null)
|
||
|
pos = posFromElement(view, elt, coords);
|
||
|
let desc = view.docView.nearestDesc(elt, true);
|
||
|
return { pos, inside: desc ? desc.posAtStart - desc.border : -1 };
|
||
|
}
|
||
|
function singleRect(target, bias) {
|
||
|
let rects = target.getClientRects();
|
||
|
return !rects.length ? target.getBoundingClientRect() : rects[bias < 0 ? 0 : rects.length - 1];
|
||
|
}
|
||
|
const BIDI = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/;
|
||
|
// Given a position in the document model, get a bounding box of the
|
||
|
// character at that position, relative to the window.
|
||
|
function coordsAtPos(view, pos, side) {
|
||
|
let { node, offset, atom } = view.docView.domFromPos(pos, side < 0 ? -1 : 1);
|
||
|
let supportEmptyRange = webkit || gecko;
|
||
|
if (node.nodeType == 3) {
|
||
|
// These browsers support querying empty text ranges. Prefer that in
|
||
|
// bidi context or when at the end of a node.
|
||
|
if (supportEmptyRange && (BIDI.test(node.nodeValue) || (side < 0 ? !offset : offset == node.nodeValue.length))) {
|
||
|
let rect = singleRect(textRange(node, offset, offset), side);
|
||
|
// Firefox returns bad results (the position before the space)
|
||
|
// when querying a position directly after line-broken
|
||
|
// whitespace. Detect this situation and and kludge around it
|
||
|
if (gecko && offset && /\s/.test(node.nodeValue[offset - 1]) && offset < node.nodeValue.length) {
|
||
|
let rectBefore = singleRect(textRange(node, offset - 1, offset - 1), -1);
|
||
|
if (rectBefore.top == rect.top) {
|
||
|
let rectAfter = singleRect(textRange(node, offset, offset + 1), -1);
|
||
|
if (rectAfter.top != rect.top)
|
||
|
return flattenV(rectAfter, rectAfter.left < rectBefore.left);
|
||
|
}
|
||
|
}
|
||
|
return rect;
|
||
|
}
|
||
|
else {
|
||
|
let from = offset, to = offset, takeSide = side < 0 ? 1 : -1;
|
||
|
if (side < 0 && !offset) {
|
||
|
to++;
|
||
|
takeSide = -1;
|
||
|
}
|
||
|
else if (side >= 0 && offset == node.nodeValue.length) {
|
||
|
from--;
|
||
|
takeSide = 1;
|
||
|
}
|
||
|
else if (side < 0) {
|
||
|
from--;
|
||
|
}
|
||
|
else {
|
||
|
to++;
|
||
|
}
|
||
|
return flattenV(singleRect(textRange(node, from, to), 1), takeSide < 0);
|
||
|
}
|
||
|
}
|
||
|
let $dom = view.state.doc.resolve(pos - (atom || 0));
|
||
|
// Return a horizontal line in block context
|
||
|
if (!$dom.parent.inlineContent) {
|
||
|
if (atom == null && offset && (side < 0 || offset == nodeSize(node))) {
|
||
|
let before = node.childNodes[offset - 1];
|
||
|
if (before.nodeType == 1)
|
||
|
return flattenH(before.getBoundingClientRect(), false);
|
||
|
}
|
||
|
if (atom == null && offset < nodeSize(node)) {
|
||
|
let after = node.childNodes[offset];
|
||
|
if (after.nodeType == 1)
|
||
|
return flattenH(after.getBoundingClientRect(), true);
|
||
|
}
|
||
|
return flattenH(node.getBoundingClientRect(), side >= 0);
|
||
|
}
|
||
|
// Inline, not in text node (this is not Bidi-safe)
|
||
|
if (atom == null && offset && (side < 0 || offset == nodeSize(node))) {
|
||
|
let before = node.childNodes[offset - 1];
|
||
|
let target = before.nodeType == 3 ? textRange(before, nodeSize(before) - (supportEmptyRange ? 0 : 1))
|
||
|
// BR nodes tend to only return the rectangle before them.
|
||
|
// Only use them if they are the last element in their parent
|
||
|
: before.nodeType == 1 && (before.nodeName != "BR" || !before.nextSibling) ? before : null;
|
||
|
if (target)
|
||
|
return flattenV(singleRect(target, 1), false);
|
||
|
}
|
||
|
if (atom == null && offset < nodeSize(node)) {
|
||
|
let after = node.childNodes[offset];
|
||
|
while (after.pmViewDesc && after.pmViewDesc.ignoreForCoords)
|
||
|
after = after.nextSibling;
|
||
|
let target = !after ? null : after.nodeType == 3 ? textRange(after, 0, (supportEmptyRange ? 0 : 1))
|
||
|
: after.nodeType == 1 ? after : null;
|
||
|
if (target)
|
||
|
return flattenV(singleRect(target, -1), true);
|
||
|
}
|
||
|
// All else failed, just try to get a rectangle for the target node
|
||
|
return flattenV(singleRect(node.nodeType == 3 ? textRange(node) : node, -side), side >= 0);
|
||
|
}
|
||
|
function flattenV(rect, left) {
|
||
|
if (rect.width == 0)
|
||
|
return rect;
|
||
|
let x = left ? rect.left : rect.right;
|
||
|
return { top: rect.top, bottom: rect.bottom, left: x, right: x };
|
||
|
}
|
||
|
function flattenH(rect, top) {
|
||
|
if (rect.height == 0)
|
||
|
return rect;
|
||
|
let y = top ? rect.top : rect.bottom;
|
||
|
return { top: y, bottom: y, left: rect.left, right: rect.right };
|
||
|
}
|
||
|
function withFlushedState(view, state, f) {
|
||
|
let viewState = view.state, active = view.root.activeElement;
|
||
|
if (viewState != state)
|
||
|
view.updateState(state);
|
||
|
if (active != view.dom)
|
||
|
view.focus();
|
||
|
try {
|
||
|
return f();
|
||
|
}
|
||
|
finally {
|
||
|
if (viewState != state)
|
||
|
view.updateState(viewState);
|
||
|
if (active != view.dom && active)
|
||
|
active.focus();
|
||
|
}
|
||
|
}
|
||
|
// Whether vertical position motion in a given direction
|
||
|
// from a position would leave a text block.
|
||
|
function endOfTextblockVertical(view, state, dir) {
|
||
|
let sel = state.selection;
|
||
|
let $pos = dir == "up" ? sel.$from : sel.$to;
|
||
|
return withFlushedState(view, state, () => {
|
||
|
let { node: dom } = view.docView.domFromPos($pos.pos, dir == "up" ? -1 : 1);
|
||
|
for (;;) {
|
||
|
let nearest = view.docView.nearestDesc(dom, true);
|
||
|
if (!nearest)
|
||
|
break;
|
||
|
if (nearest.node.isBlock) {
|
||
|
dom = nearest.dom;
|
||
|
break;
|
||
|
}
|
||
|
dom = nearest.dom.parentNode;
|
||
|
}
|
||
|
let coords = coordsAtPos(view, $pos.pos, 1);
|
||
|
for (let child = dom.firstChild; child; child = child.nextSibling) {
|
||
|
let boxes;
|
||
|
if (child.nodeType == 1)
|
||
|
boxes = child.getClientRects();
|
||
|
else if (child.nodeType == 3)
|
||
|
boxes = textRange(child, 0, child.nodeValue.length).getClientRects();
|
||
|
else
|
||
|
continue;
|
||
|
for (let i = 0; i < boxes.length; i++) {
|
||
|
let box = boxes[i];
|
||
|
if (box.bottom > box.top + 1 &&
|
||
|
(dir == "up" ? coords.top - box.top > (box.bottom - coords.top) * 2
|
||
|
: box.bottom - coords.bottom > (coords.bottom - box.top) * 2))
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
return true;
|
||
|
});
|
||
|
}
|
||
|
const maybeRTL = /[\u0590-\u08ac]/;
|
||
|
function endOfTextblockHorizontal(view, state, dir) {
|
||
|
let { $head } = state.selection;
|
||
|
if (!$head.parent.isTextblock)
|
||
|
return false;
|
||
|
let offset = $head.parentOffset, atStart = !offset, atEnd = offset == $head.parent.content.size;
|
||
|
let sel = view.domSelection();
|
||
|
// If the textblock is all LTR, or the browser doesn't support
|
||
|
// Selection.modify (Edge), fall back to a primitive approach
|
||
|
if (!maybeRTL.test($head.parent.textContent) || !sel.modify)
|
||
|
return dir == "left" || dir == "backward" ? atStart : atEnd;
|
||
|
return withFlushedState(view, state, () => {
|
||
|
// This is a huge hack, but appears to be the best we can
|
||
|
// currently do: use `Selection.modify` to move the selection by
|
||
|
// one character, and see if that moves the cursor out of the
|
||
|
// textblock (or doesn't move it at all, when at the start/end of
|
||
|
// the document).
|
||
|
let { focusNode: oldNode, focusOffset: oldOff, anchorNode, anchorOffset } = view.domSelectionRange();
|
||
|
let oldBidiLevel = sel.caretBidiLevel // Only for Firefox
|
||
|
;
|
||
|
sel.modify("move", dir, "character");
|
||
|
let parentDOM = $head.depth ? view.docView.domAfterPos($head.before()) : view.dom;
|
||
|
let { focusNode: newNode, focusOffset: newOff } = view.domSelectionRange();
|
||
|
let result = newNode && !parentDOM.contains(newNode.nodeType == 1 ? newNode : newNode.parentNode) ||
|
||
|
(oldNode == newNode && oldOff == newOff);
|
||
|
// Restore the previous selection
|
||
|
try {
|
||
|
sel.collapse(anchorNode, anchorOffset);
|
||
|
if (oldNode && (oldNode != anchorNode || oldOff != anchorOffset) && sel.extend)
|
||
|
sel.extend(oldNode, oldOff);
|
||
|
}
|
||
|
catch (_) { }
|
||
|
if (oldBidiLevel != null)
|
||
|
sel.caretBidiLevel = oldBidiLevel;
|
||
|
return result;
|
||
|
});
|
||
|
}
|
||
|
let cachedState = null;
|
||
|
let cachedDir = null;
|
||
|
let cachedResult = false;
|
||
|
function endOfTextblock(view, state, dir) {
|
||
|
if (cachedState == state && cachedDir == dir)
|
||
|
return cachedResult;
|
||
|
cachedState = state;
|
||
|
cachedDir = dir;
|
||
|
return cachedResult = dir == "up" || dir == "down"
|
||
|
? endOfTextblockVertical(view, state, dir)
|
||
|
: endOfTextblockHorizontal(view, state, dir);
|
||
|
}
|
||
|
|
||
|
// View descriptions are data structures that describe the DOM that is
|
||
|
// used to represent the editor's content. They are used for:
|
||
|
//
|
||
|
// - Incremental redrawing when the document changes
|
||
|
//
|
||
|
// - Figuring out what part of the document a given DOM position
|
||
|
// corresponds to
|
||
|
//
|
||
|
// - Wiring in custom implementations of the editing interface for a
|
||
|
// given node
|
||
|
//
|
||
|
// They form a doubly-linked mutable tree, starting at `view.docView`.
|
||
|
const NOT_DIRTY = 0, CHILD_DIRTY = 1, CONTENT_DIRTY = 2, NODE_DIRTY = 3;
|
||
|
// Superclass for the various kinds of descriptions. Defines their
|
||
|
// basic structure and shared methods.
|
||
|
class ViewDesc {
|
||
|
constructor(parent, children, dom,
|
||
|
// This is the node that holds the child views. It may be null for
|
||
|
// descs that don't have children.
|
||
|
contentDOM) {
|
||
|
this.parent = parent;
|
||
|
this.children = children;
|
||
|
this.dom = dom;
|
||
|
this.contentDOM = contentDOM;
|
||
|
this.dirty = NOT_DIRTY;
|
||
|
// An expando property on the DOM node provides a link back to its
|
||
|
// description.
|
||
|
dom.pmViewDesc = this;
|
||
|
}
|
||
|
// Used to check whether a given description corresponds to a
|
||
|
// widget/mark/node.
|
||
|
matchesWidget(widget) { return false; }
|
||
|
matchesMark(mark) { return false; }
|
||
|
matchesNode(node, outerDeco, innerDeco) { return false; }
|
||
|
matchesHack(nodeName) { return false; }
|
||
|
// When parsing in-editor content (in domchange.js), we allow
|
||
|
// descriptions to determine the parse rules that should be used to
|
||
|
// parse them.
|
||
|
parseRule() { return null; }
|
||
|
// Used by the editor's event handler to ignore events that come
|
||
|
// from certain descs.
|
||
|
stopEvent(event) { return false; }
|
||
|
// The size of the content represented by this desc.
|
||
|
get size() {
|
||
|
let size = 0;
|
||
|
for (let i = 0; i < this.children.length; i++)
|
||
|
size += this.children[i].size;
|
||
|
return size;
|
||
|
}
|
||
|
// For block nodes, this represents the space taken up by their
|
||
|
// start/end tokens.
|
||
|
get border() { return 0; }
|
||
|
destroy() {
|
||
|
this.parent = undefined;
|
||
|
if (this.dom.pmViewDesc == this)
|
||
|
this.dom.pmViewDesc = undefined;
|
||
|
for (let i = 0; i < this.children.length; i++)
|
||
|
this.children[i].destroy();
|
||
|
}
|
||
|
posBeforeChild(child) {
|
||
|
for (let i = 0, pos = this.posAtStart;; i++) {
|
||
|
let cur = this.children[i];
|
||
|
if (cur == child)
|
||
|
return pos;
|
||
|
pos += cur.size;
|
||
|
}
|
||
|
}
|
||
|
get posBefore() {
|
||
|
return this.parent.posBeforeChild(this);
|
||
|
}
|
||
|
get posAtStart() {
|
||
|
return this.parent ? this.parent.posBeforeChild(this) + this.border : 0;
|
||
|
}
|
||
|
get posAfter() {
|
||
|
return this.posBefore + this.size;
|
||
|
}
|
||
|
get posAtEnd() {
|
||
|
return this.posAtStart + this.size - 2 * this.border;
|
||
|
}
|
||
|
localPosFromDOM(dom, offset, bias) {
|
||
|
// If the DOM position is in the content, use the child desc after
|
||
|
// it to figure out a position.
|
||
|
if (this.contentDOM && this.contentDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode)) {
|
||
|
if (bias < 0) {
|
||
|
let domBefore, desc;
|
||
|
if (dom == this.contentDOM) {
|
||
|
domBefore = dom.childNodes[offset - 1];
|
||
|
}
|
||
|
else {
|
||
|
while (dom.parentNode != this.contentDOM)
|
||
|
dom = dom.parentNode;
|
||
|
domBefore = dom.previousSibling;
|
||
|
}
|
||
|
while (domBefore && !((desc = domBefore.pmViewDesc) && desc.parent == this))
|
||
|
domBefore = domBefore.previousSibling;
|
||
|
return domBefore ? this.posBeforeChild(desc) + desc.size : this.posAtStart;
|
||
|
}
|
||
|
else {
|
||
|
let domAfter, desc;
|
||
|
if (dom == this.contentDOM) {
|
||
|
domAfter = dom.childNodes[offset];
|
||
|
}
|
||
|
else {
|
||
|
while (dom.parentNode != this.contentDOM)
|
||
|
dom = dom.parentNode;
|
||
|
domAfter = dom.nextSibling;
|
||
|
}
|
||
|
while (domAfter && !((desc = domAfter.pmViewDesc) && desc.parent == this))
|
||
|
domAfter = domAfter.nextSibling;
|
||
|
return domAfter ? this.posBeforeChild(desc) : this.posAtEnd;
|
||
|
}
|
||
|
}
|
||
|
// Otherwise, use various heuristics, falling back on the bias
|
||
|
// parameter, to determine whether to return the position at the
|
||
|
// start or at the end of this view desc.
|
||
|
let atEnd;
|
||
|
if (dom == this.dom && this.contentDOM) {
|
||
|
atEnd = offset > domIndex(this.contentDOM);
|
||
|
}
|
||
|
else if (this.contentDOM && this.contentDOM != this.dom && this.dom.contains(this.contentDOM)) {
|
||
|
atEnd = dom.compareDocumentPosition(this.contentDOM) & 2;
|
||
|
}
|
||
|
else if (this.dom.firstChild) {
|
||
|
if (offset == 0)
|
||
|
for (let search = dom;; search = search.parentNode) {
|
||
|
if (search == this.dom) {
|
||
|
atEnd = false;
|
||
|
break;
|
||
|
}
|
||
|
if (search.previousSibling)
|
||
|
break;
|
||
|
}
|
||
|
if (atEnd == null && offset == dom.childNodes.length)
|
||
|
for (let search = dom;; search = search.parentNode) {
|
||
|
if (search == this.dom) {
|
||
|
atEnd = true;
|
||
|
break;
|
||
|
}
|
||
|
if (search.nextSibling)
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
return (atEnd == null ? bias > 0 : atEnd) ? this.posAtEnd : this.posAtStart;
|
||
|
}
|
||
|
// Scan up the dom finding the first desc that is a descendant of
|
||
|
// this one.
|
||
|
nearestDesc(dom, onlyNodes = false) {
|
||
|
for (let first = true, cur = dom; cur; cur = cur.parentNode) {
|
||
|
let desc = this.getDesc(cur), nodeDOM;
|
||
|
if (desc && (!onlyNodes || desc.node)) {
|
||
|
// If dom is outside of this desc's nodeDOM, don't count it.
|
||
|
if (first && (nodeDOM = desc.nodeDOM) &&
|
||
|
!(nodeDOM.nodeType == 1 ? nodeDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode) : nodeDOM == dom))
|
||
|
first = false;
|
||
|
else
|
||
|
return desc;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
getDesc(dom) {
|
||
|
let desc = dom.pmViewDesc;
|
||
|
for (let cur = desc; cur; cur = cur.parent)
|
||
|
if (cur == this)
|
||
|
return desc;
|
||
|
}
|
||
|
posFromDOM(dom, offset, bias) {
|
||
|
for (let scan = dom; scan; scan = scan.parentNode) {
|
||
|
let desc = this.getDesc(scan);
|
||
|
if (desc)
|
||
|
return desc.localPosFromDOM(dom, offset, bias);
|
||
|
}
|
||
|
return -1;
|
||
|
}
|
||
|
// Find the desc for the node after the given pos, if any. (When a
|
||
|
// parent node overrode rendering, there might not be one.)
|
||
|
descAt(pos) {
|
||
|
for (let i = 0, offset = 0; i < this.children.length; i++) {
|
||
|
let child = this.children[i], end = offset + child.size;
|
||
|
if (offset == pos && end != offset) {
|
||
|
while (!child.border && child.children.length)
|
||
|
child = child.children[0];
|
||
|
return child;
|
||
|
}
|
||
|
if (pos < end)
|
||
|
return child.descAt(pos - offset - child.border);
|
||
|
offset = end;
|
||
|
}
|
||
|
}
|
||
|
domFromPos(pos, side) {
|
||
|
if (!this.contentDOM)
|
||
|
return { node: this.dom, offset: 0, atom: pos + 1 };
|
||
|
// First find the position in the child array
|
||
|
let i = 0, offset = 0;
|
||
|
for (let curPos = 0; i < this.children.length; i++) {
|
||
|
let child = this.children[i], end = curPos + child.size;
|
||
|
if (end > pos || child instanceof TrailingHackViewDesc) {
|
||
|
offset = pos - curPos;
|
||
|
break;
|
||
|
}
|
||
|
curPos = end;
|
||
|
}
|
||
|
// If this points into the middle of a child, call through
|
||
|
if (offset)
|
||
|
return this.children[i].domFromPos(offset - this.children[i].border, side);
|
||
|
// Go back if there were any zero-length widgets with side >= 0 before this point
|
||
|
for (let prev; i && !(prev = this.children[i - 1]).size && prev instanceof WidgetViewDesc && prev.side >= 0; i--) { }
|
||
|
// Scan towards the first useable node
|
||
|
if (side <= 0) {
|
||
|
let prev, enter = true;
|
||
|
for (;; i--, enter = false) {
|
||
|
prev = i ? this.children[i - 1] : null;
|
||
|
if (!prev || prev.dom.parentNode == this.contentDOM)
|
||
|
break;
|
||
|
}
|
||
|
if (prev && side && enter && !prev.border && !prev.domAtom)
|
||
|
return prev.domFromPos(prev.size, side);
|
||
|
return { node: this.contentDOM, offset: prev ? domIndex(prev.dom) + 1 : 0 };
|
||
|
}
|
||
|
else {
|
||
|
let next, enter = true;
|
||
|
for (;; i++, enter = false) {
|
||
|
next = i < this.children.length ? this.children[i] : null;
|
||
|
if (!next || next.dom.parentNode == this.contentDOM)
|
||
|
break;
|
||
|
}
|
||
|
if (next && enter && !next.border && !next.domAtom)
|
||
|
return next.domFromPos(0, side);
|
||
|
return { node: this.contentDOM, offset: next ? domIndex(next.dom) : this.contentDOM.childNodes.length };
|
||
|
}
|
||
|
}
|
||
|
// Used to find a DOM range in a single parent for a given changed
|
||
|
// range.
|
||
|
parseRange(from, to, base = 0) {
|
||
|
if (this.children.length == 0)
|
||
|
return { node: this.contentDOM, from, to, fromOffset: 0, toOffset: this.contentDOM.childNodes.length };
|
||
|
let fromOffset = -1, toOffset = -1;
|
||
|
for (let offset = base, i = 0;; i++) {
|
||
|
let child = this.children[i], end = offset + child.size;
|
||
|
if (fromOffset == -1 && from <= end) {
|
||
|
let childBase = offset + child.border;
|
||
|
// FIXME maybe descend mark views to parse a narrower range?
|
||
|
if (from >= childBase && to <= end - child.border && child.node &&
|
||
|
child.contentDOM && this.contentDOM.contains(child.contentDOM))
|
||
|
return child.parseRange(from, to, childBase);
|
||
|
from = offset;
|
||
|
for (let j = i; j > 0; j--) {
|
||
|
let prev = this.children[j - 1];
|
||
|
if (prev.size && prev.dom.parentNode == this.contentDOM && !prev.emptyChildAt(1)) {
|
||
|
fromOffset = domIndex(prev.dom) + 1;
|
||
|
break;
|
||
|
}
|
||
|
from -= prev.size;
|
||
|
}
|
||
|
if (fromOffset == -1)
|
||
|
fromOffset = 0;
|
||
|
}
|
||
|
if (fromOffset > -1 && (end > to || i == this.children.length - 1)) {
|
||
|
to = end;
|
||
|
for (let j = i + 1; j < this.children.length; j++) {
|
||
|
let next = this.children[j];
|
||
|
if (next.size && next.dom.parentNode == this.contentDOM && !next.emptyChildAt(-1)) {
|
||
|
toOffset = domIndex(next.dom);
|
||
|
break;
|
||
|
}
|
||
|
to += next.size;
|
||
|
}
|
||
|
if (toOffset == -1)
|
||
|
toOffset = this.contentDOM.childNodes.length;
|
||
|
break;
|
||
|
}
|
||
|
offset = end;
|
||
|
}
|
||
|
return { node: this.contentDOM, from, to, fromOffset, toOffset };
|
||
|
}
|
||
|
emptyChildAt(side) {
|
||
|
if (this.border || !this.contentDOM || !this.children.length)
|
||
|
return false;
|
||
|
let child = this.children[side < 0 ? 0 : this.children.length - 1];
|
||
|
return child.size == 0 || child.emptyChildAt(side);
|
||
|
}
|
||
|
domAfterPos(pos) {
|
||
|
let { node, offset } = this.domFromPos(pos, 0);
|
||
|
if (node.nodeType != 1 || offset == node.childNodes.length)
|
||
|
throw new RangeError("No node after pos " + pos);
|
||
|
return node.childNodes[offset];
|
||
|
}
|
||
|
// View descs are responsible for setting any selection that falls
|
||
|
// entirely inside of them, so that custom implementations can do
|
||
|
// custom things with the selection. Note that this falls apart when
|
||
|
// a selection starts in such a node and ends in another, in which
|
||
|
// case we just use whatever domFromPos produces as a best effort.
|
||
|
setSelection(anchor, head, root, force = false) {
|
||
|
// If the selection falls entirely in a child, give it to that child
|
||
|
let from = Math.min(anchor, head), to = Math.max(anchor, head);
|
||
|
for (let i = 0, offset = 0; i < this.children.length; i++) {
|
||
|
let child = this.children[i], end = offset + child.size;
|
||
|
if (from > offset && to < end)
|
||
|
return child.setSelection(anchor - offset - child.border, head - offset - child.border, root, force);
|
||
|
offset = end;
|
||
|
}
|
||
|
let anchorDOM = this.domFromPos(anchor, anchor ? -1 : 1);
|
||
|
let headDOM = head == anchor ? anchorDOM : this.domFromPos(head, head ? -1 : 1);
|
||
|
let domSel = root.getSelection();
|
||
|
let brKludge = false;
|
||
|
// On Firefox, using Selection.collapse to put the cursor after a
|
||
|
// BR node for some reason doesn't always work (#1073). On Safari,
|
||
|
// the cursor sometimes inexplicable visually lags behind its
|
||
|
// reported position in such situations (#1092).
|
||
|
if ((gecko || safari) && anchor == head) {
|
||
|
let { node, offset } = anchorDOM;
|
||
|
if (node.nodeType == 3) {
|
||
|
brKludge = !!(offset && node.nodeValue[offset - 1] == "\n");
|
||
|
// Issue #1128
|
||
|
if (brKludge && offset == node.nodeValue.length) {
|
||
|
for (let scan = node, after; scan; scan = scan.parentNode) {
|
||
|
if (after = scan.nextSibling) {
|
||
|
if (after.nodeName == "BR")
|
||
|
anchorDOM = headDOM = { node: after.parentNode, offset: domIndex(after) + 1 };
|
||
|
break;
|
||
|
}
|
||
|
let desc = scan.pmViewDesc;
|
||
|
if (desc && desc.node && desc.node.isBlock)
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
let prev = node.childNodes[offset - 1];
|
||
|
brKludge = prev && (prev.nodeName == "BR" || prev.contentEditable == "false");
|
||
|
}
|
||
|
}
|
||
|
// Firefox can act strangely when the selection is in front of an
|
||
|
// uneditable node. See #1163 and https://bugzilla.mozilla.org/show_bug.cgi?id=1709536
|
||
|
if (gecko && domSel.focusNode && domSel.focusNode != headDOM.node && domSel.focusNode.nodeType == 1) {
|
||
|
let after = domSel.focusNode.childNodes[domSel.focusOffset];
|
||
|
if (after && after.contentEditable == "false")
|
||
|
force = true;
|
||
|
}
|
||
|
if (!(force || brKludge && safari) &&
|
||
|
isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode, domSel.anchorOffset) &&
|
||
|
isEquivalentPosition(headDOM.node, headDOM.offset, domSel.focusNode, domSel.focusOffset))
|
||
|
return;
|
||
|
// Selection.extend can be used to create an 'inverted' selection
|
||
|
// (one where the focus is before the anchor), but not all
|
||
|
// browsers support it yet.
|
||
|
let domSelExtended = false;
|
||
|
if ((domSel.extend || anchor == head) && !brKludge) {
|
||
|
domSel.collapse(anchorDOM.node, anchorDOM.offset);
|
||
|
try {
|
||
|
if (anchor != head)
|
||
|
domSel.extend(headDOM.node, headDOM.offset);
|
||
|
domSelExtended = true;
|
||
|
}
|
||
|
catch (_) {
|
||
|
// In some cases with Chrome the selection is empty after calling
|
||
|
// collapse, even when it should be valid. This appears to be a bug, but
|
||
|
// it is difficult to isolate. If this happens fallback to the old path
|
||
|
// without using extend.
|
||
|
// Similarly, this could crash on Safari if the editor is hidden, and
|
||
|
// there was no selection.
|
||
|
}
|
||
|
}
|
||
|
if (!domSelExtended) {
|
||
|
if (anchor > head) {
|
||
|
let tmp = anchorDOM;
|
||
|
anchorDOM = headDOM;
|
||
|
headDOM = tmp;
|
||
|
}
|
||
|
let range = document.createRange();
|
||
|
range.setEnd(headDOM.node, headDOM.offset);
|
||
|
range.setStart(anchorDOM.node, anchorDOM.offset);
|
||
|
domSel.removeAllRanges();
|
||
|
domSel.addRange(range);
|
||
|
}
|
||
|
}
|
||
|
ignoreMutation(mutation) {
|
||
|
return !this.contentDOM && mutation.type != "selection";
|
||
|
}
|
||
|
get contentLost() {
|
||
|
return this.contentDOM && this.contentDOM != this.dom && !this.dom.contains(this.contentDOM);
|
||
|
}
|
||
|
// Remove a subtree of the element tree that has been touched
|
||
|
// by a DOM change, so that the next update will redraw it.
|
||
|
markDirty(from, to) {
|
||
|
for (let offset = 0, i = 0; i < this.children.length; i++) {
|
||
|
let child = this.children[i], end = offset + child.size;
|
||
|
if (offset == end ? from <= end && to >= offset : from < end && to > offset) {
|
||
|
let startInside = offset + child.border, endInside = end - child.border;
|
||
|
if (from >= startInside && to <= endInside) {
|
||
|
this.dirty = from == offset || to == end ? CONTENT_DIRTY : CHILD_DIRTY;
|
||
|
if (from == startInside && to == endInside &&
|
||
|
(child.contentLost || child.dom.parentNode != this.contentDOM))
|
||
|
child.dirty = NODE_DIRTY;
|
||
|
else
|
||
|
child.markDirty(from - startInside, to - startInside);
|
||
|
return;
|
||
|
}
|
||
|
else {
|
||
|
child.dirty = child.dom == child.contentDOM && child.dom.parentNode == this.contentDOM && !child.children.length
|
||
|
? CONTENT_DIRTY : NODE_DIRTY;
|
||
|
}
|
||
|
}
|
||
|
offset = end;
|
||
|
}
|
||
|
this.dirty = CONTENT_DIRTY;
|
||
|
}
|
||
|
markParentsDirty() {
|
||
|
let level = 1;
|
||
|
for (let node = this.parent; node; node = node.parent, level++) {
|
||
|
let dirty = level == 1 ? CONTENT_DIRTY : CHILD_DIRTY;
|
||
|
if (node.dirty < dirty)
|
||
|
node.dirty = dirty;
|
||
|
}
|
||
|
}
|
||
|
get domAtom() { return false; }
|
||
|
get ignoreForCoords() { return false; }
|
||
|
}
|
||
|
// A widget desc represents a widget decoration, which is a DOM node
|
||
|
// drawn between the document nodes.
|
||
|
class WidgetViewDesc extends ViewDesc {
|
||
|
constructor(parent, widget, view, pos) {
|
||
|
let self, dom = widget.type.toDOM;
|
||
|
if (typeof dom == "function")
|
||
|
dom = dom(view, () => {
|
||
|
if (!self)
|
||
|
return pos;
|
||
|
if (self.parent)
|
||
|
return self.parent.posBeforeChild(self);
|
||
|
});
|
||
|
if (!widget.type.spec.raw) {
|
||
|
if (dom.nodeType != 1) {
|
||
|
let wrap = document.createElement("span");
|
||
|
wrap.appendChild(dom);
|
||
|
dom = wrap;
|
||
|
}
|
||
|
dom.contentEditable = "false";
|
||
|
dom.classList.add("ProseMirror-widget");
|
||
|
}
|
||
|
super(parent, [], dom, null);
|
||
|
this.widget = widget;
|
||
|
this.widget = widget;
|
||
|
self = this;
|
||
|
}
|
||
|
matchesWidget(widget) {
|
||
|
return this.dirty == NOT_DIRTY && widget.type.eq(this.widget.type);
|
||
|
}
|
||
|
parseRule() { return { ignore: true }; }
|
||
|
stopEvent(event) {
|
||
|
let stop = this.widget.spec.stopEvent;
|
||
|
return stop ? stop(event) : false;
|
||
|
}
|
||
|
ignoreMutation(mutation) {
|
||
|
return mutation.type != "selection" || this.widget.spec.ignoreSelection;
|
||
|
}
|
||
|
destroy() {
|
||
|
this.widget.type.destroy(this.dom);
|
||
|
super.destroy();
|
||
|
}
|
||
|
get domAtom() { return true; }
|
||
|
get side() { return this.widget.type.side; }
|
||
|
}
|
||
|
class CompositionViewDesc extends ViewDesc {
|
||
|
constructor(parent, dom, textDOM, text) {
|
||
|
super(parent, [], dom, null);
|
||
|
this.textDOM = textDOM;
|
||
|
this.text = text;
|
||
|
}
|
||
|
get size() { return this.text.length; }
|
||
|
localPosFromDOM(dom, offset) {
|
||
|
if (dom != this.textDOM)
|
||
|
return this.posAtStart + (offset ? this.size : 0);
|
||
|
return this.posAtStart + offset;
|
||
|
}
|
||
|
domFromPos(pos) {
|
||
|
return { node: this.textDOM, offset: pos };
|
||
|
}
|
||
|
ignoreMutation(mut) {
|
||
|
return mut.type === 'characterData' && mut.target.nodeValue == mut.oldValue;
|
||
|
}
|
||
|
}
|
||
|
// A mark desc represents a mark. May have multiple children,
|
||
|
// depending on how the mark is split. Note that marks are drawn using
|
||
|
// a fixed nesting order, for simplicity and predictability, so in
|
||
|
// some cases they will be split more often than would appear
|
||
|
// necessary.
|
||
|
class MarkViewDesc extends ViewDesc {
|
||
|
constructor(parent, mark, dom, contentDOM) {
|
||
|
super(parent, [], dom, contentDOM);
|
||
|
this.mark = mark;
|
||
|
}
|
||
|
static create(parent, mark, inline, view) {
|
||
|
let custom = view.nodeViews[mark.type.name];
|
||
|
let spec = custom && custom(mark, view, inline);
|
||
|
if (!spec || !spec.dom)
|
||
|
spec = DOMSerializer.renderSpec(document, mark.type.spec.toDOM(mark, inline));
|
||
|
return new MarkViewDesc(parent, mark, spec.dom, spec.contentDOM || spec.dom);
|
||
|
}
|
||
|
parseRule() {
|
||
|
if ((this.dirty & NODE_DIRTY) || this.mark.type.spec.reparseInView)
|
||
|
return null;
|
||
|
return { mark: this.mark.type.name, attrs: this.mark.attrs, contentElement: this.contentDOM || undefined };
|
||
|
}
|
||
|
matchesMark(mark) { return this.dirty != NODE_DIRTY && this.mark.eq(mark); }
|
||
|
markDirty(from, to) {
|
||
|
super.markDirty(from, to);
|
||
|
// Move dirty info to nearest node view
|
||
|
if (this.dirty != NOT_DIRTY) {
|
||
|
let parent = this.parent;
|
||
|
while (!parent.node)
|
||
|
parent = parent.parent;
|
||
|
if (parent.dirty < this.dirty)
|
||
|
parent.dirty = this.dirty;
|
||
|
this.dirty = NOT_DIRTY;
|
||
|
}
|
||
|
}
|
||
|
slice(from, to, view) {
|
||
|
let copy = MarkViewDesc.create(this.parent, this.mark, true, view);
|
||
|
let nodes = this.children, size = this.size;
|
||
|
if (to < size)
|
||
|
nodes = replaceNodes(nodes, to, size, view);
|
||
|
if (from > 0)
|
||
|
nodes = replaceNodes(nodes, 0, from, view);
|
||
|
for (let i = 0; i < nodes.length; i++)
|
||
|
nodes[i].parent = copy;
|
||
|
copy.children = nodes;
|
||
|
return copy;
|
||
|
}
|
||
|
}
|
||
|
// Node view descs are the main, most common type of view desc, and
|
||
|
// correspond to an actual node in the document. Unlike mark descs,
|
||
|
// they populate their child array themselves.
|
||
|
class NodeViewDesc extends ViewDesc {
|
||
|
constructor(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, view, pos) {
|
||
|
super(parent, [], dom, contentDOM);
|
||
|
this.node = node;
|
||
|
this.outerDeco = outerDeco;
|
||
|
this.innerDeco = innerDeco;
|
||
|
this.nodeDOM = nodeDOM;
|
||
|
if (contentDOM)
|
||
|
this.updateChildren(view, pos);
|
||
|
}
|
||
|
// By default, a node is rendered using the `toDOM` method from the
|
||
|
// node type spec. But client code can use the `nodeViews` spec to
|
||
|
// supply a custom node view, which can influence various aspects of
|
||
|
// the way the node works.
|
||
|
//
|
||
|
// (Using subclassing for this was intentionally decided against,
|
||
|
// since it'd require exposing a whole slew of finicky
|
||
|
// implementation details to the user code that they probably will
|
||
|
// never need.)
|
||
|
static create(parent, node, outerDeco, innerDeco, view, pos) {
|
||
|
let custom = view.nodeViews[node.type.name], descObj;
|
||
|
let spec = custom && custom(node, view, () => {
|
||
|
// (This is a function that allows the custom view to find its
|
||
|
// own position)
|
||
|
if (!descObj)
|
||
|
return pos;
|
||
|
if (descObj.parent)
|
||
|
return descObj.parent.posBeforeChild(descObj);
|
||
|
}, outerDeco, innerDeco);
|
||
|
let dom = spec && spec.dom, contentDOM = spec && spec.contentDOM;
|
||
|
if (node.isText) {
|
||
|
if (!dom)
|
||
|
dom = document.createTextNode(node.text);
|
||
|
else if (dom.nodeType != 3)
|
||
|
throw new RangeError("Text must be rendered as a DOM text node");
|
||
|
}
|
||
|
else if (!dom) {
|
||
|
({ dom, contentDOM } = DOMSerializer.renderSpec(document, node.type.spec.toDOM(node)));
|
||
|
}
|
||
|
if (!contentDOM && !node.isText && dom.nodeName != "BR") { // Chrome gets confused by <br contenteditable=false>
|
||
|
if (!dom.hasAttribute("contenteditable"))
|
||
|
dom.contentEditable = "false";
|
||
|
if (node.type.spec.draggable)
|
||
|
dom.draggable = true;
|
||
|
}
|
||
|
let nodeDOM = dom;
|
||
|
dom = applyOuterDeco(dom, outerDeco, node);
|
||
|
if (spec)
|
||
|
return descObj = new CustomNodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM, spec, view, pos + 1);
|
||
|
else if (node.isText)
|
||
|
return new TextViewDesc(parent, node, outerDeco, innerDeco, dom, nodeDOM, view);
|
||
|
else
|
||
|
return new NodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM, view, pos + 1);
|
||
|
}
|
||
|
parseRule() {
|
||
|
// Experimental kludge to allow opt-in re-parsing of nodes
|
||
|
if (this.node.type.spec.reparseInView)
|
||
|
return null;
|
||
|
// FIXME the assumption that this can always return the current
|
||
|
// attrs means that if the user somehow manages to change the
|
||
|
// attrs in the dom, that won't be picked up. Not entirely sure
|
||
|
// whether this is a problem
|
||
|
let rule = { node: this.node.type.name, attrs: this.node.attrs };
|
||
|
if (this.node.type.whitespace == "pre")
|
||
|
rule.preserveWhitespace = "full";
|
||
|
if (!this.contentDOM) {
|
||
|
rule.getContent = () => this.node.content;
|
||
|
}
|
||
|
else if (!this.contentLost) {
|
||
|
rule.contentElement = this.contentDOM;
|
||
|
}
|
||
|
else {
|
||
|
// Chrome likes to randomly recreate parent nodes when
|
||
|
// backspacing things. When that happens, this tries to find the
|
||
|
// new parent.
|
||
|
for (let i = this.children.length - 1; i >= 0; i--) {
|
||
|
let child = this.children[i];
|
||
|
if (this.dom.contains(child.dom.parentNode)) {
|
||
|
rule.contentElement = child.dom.parentNode;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
if (!rule.contentElement)
|
||
|
rule.getContent = () => Fragment.empty;
|
||
|
}
|
||
|
return rule;
|
||
|
}
|
||
|
matchesNode(node, outerDeco, innerDeco) {
|
||
|
return this.dirty == NOT_DIRTY && node.eq(this.node) &&
|
||
|
sameOuterDeco(outerDeco, this.outerDeco) && innerDeco.eq(this.innerDeco);
|
||
|
}
|
||
|
get size() { return this.node.nodeSize; }
|
||
|
get border() { return this.node.isLeaf ? 0 : 1; }
|
||
|
// Syncs `this.children` to match `this.node.content` and the local
|
||
|
// decorations, possibly introducing nesting for marks. Then, in a
|
||
|
// separate step, syncs the DOM inside `this.contentDOM` to
|
||
|
// `this.children`.
|
||
|
updateChildren(view, pos) {
|
||
|
let inline = this.node.inlineContent, off = pos;
|
||
|
let composition = view.composing ? this.localCompositionInfo(view, pos) : null;
|
||
|
let localComposition = composition && composition.pos > -1 ? composition : null;
|
||
|
let compositionInChild = composition && composition.pos < 0;
|
||
|
let updater = new ViewTreeUpdater(this, localComposition && localComposition.node, view);
|
||
|
iterDeco(this.node, this.innerDeco, (widget, i, insideNode) => {
|
||
|
if (widget.spec.marks)
|
||
|
updater.syncToMarks(widget.spec.marks, inline, view);
|
||
|
else if (widget.type.side >= 0 && !insideNode)
|
||
|
updater.syncToMarks(i == this.node.childCount ? Mark.none : this.node.child(i).marks, inline, view);
|
||
|
// If the next node is a desc matching this widget, reuse it,
|
||
|
// otherwise insert the widget as a new view desc.
|
||
|
updater.placeWidget(widget, view, off);
|
||
|
}, (child, outerDeco, innerDeco, i) => {
|
||
|
// Make sure the wrapping mark descs match the node's marks.
|
||
|
updater.syncToMarks(child.marks, inline, view);
|
||
|
// Try several strategies for drawing this node
|
||
|
let compIndex;
|
||
|
if (updater.findNodeMatch(child, outerDeco, innerDeco, i)) ;
|
||
|
else if (compositionInChild && view.state.selection.from > off &&
|
||
|
view.state.selection.to < off + child.nodeSize &&
|
||
|
(compIndex = updater.findIndexWithChild(composition.node)) > -1 &&
|
||
|
updater.updateNodeAt(child, outerDeco, innerDeco, compIndex, view)) ;
|
||
|
else if (updater.updateNextNode(child, outerDeco, innerDeco, view, i)) ;
|
||
|
else {
|
||
|
// Add it as a new view
|
||
|
updater.addNode(child, outerDeco, innerDeco, view, off);
|
||
|
}
|
||
|
off += child.nodeSize;
|
||
|
});
|
||
|
// Drop all remaining descs after the current position.
|
||
|
updater.syncToMarks([], inline, view);
|
||
|
if (this.node.isTextblock)
|
||
|
updater.addTextblockHacks();
|
||
|
updater.destroyRest();
|
||
|
// Sync the DOM if anything changed
|
||
|
if (updater.changed || this.dirty == CONTENT_DIRTY) {
|
||
|
// May have to protect focused DOM from being changed if a composition is active
|
||
|
if (localComposition)
|
||
|
this.protectLocalComposition(view, localComposition);
|
||
|
renderDescs(this.contentDOM, this.children, view);
|
||
|
if (ios)
|
||
|
iosHacks(this.dom);
|
||
|
}
|
||
|
}
|
||
|
localCompositionInfo(view, pos) {
|
||
|
// Only do something if both the selection and a focused text node
|
||
|
// are inside of this node
|
||
|
let { from, to } = view.state.selection;
|
||
|
if (!(view.state.selection instanceof TextSelection) || from < pos || to > pos + this.node.content.size)
|
||
|
return null;
|
||
|
let sel = view.domSelectionRange();
|
||
|
let textNode = nearbyTextNode(sel.focusNode, sel.focusOffset);
|
||
|
if (!textNode || !this.dom.contains(textNode.parentNode))
|
||
|
return null;
|
||
|
if (this.node.inlineContent) {
|
||
|
// Find the text in the focused node in the node, stop if it's not
|
||
|
// there (may have been modified through other means, in which
|
||
|
// case it should overwritten)
|
||
|
let text = textNode.nodeValue;
|
||
|
let textPos = findTextInFragment(this.node.content, text, from - pos, to - pos);
|
||
|
return textPos < 0 ? null : { node: textNode, pos: textPos, text };
|
||
|
}
|
||
|
else {
|
||
|
return { node: textNode, pos: -1, text: "" };
|
||
|
}
|
||
|
}
|
||
|
protectLocalComposition(view, { node, pos, text }) {
|
||
|
// The node is already part of a local view desc, leave it there
|
||
|
if (this.getDesc(node))
|
||
|
return;
|
||
|
// Create a composition view for the orphaned nodes
|
||
|
let topNode = node;
|
||
|
for (;; topNode = topNode.parentNode) {
|
||
|
if (topNode.parentNode == this.contentDOM)
|
||
|
break;
|
||
|
while (topNode.previousSibling)
|
||
|
topNode.parentNode.removeChild(topNode.previousSibling);
|
||
|
while (topNode.nextSibling)
|
||
|
topNode.parentNode.removeChild(topNode.nextSibling);
|
||
|
if (topNode.pmViewDesc)
|
||
|
topNode.pmViewDesc = undefined;
|
||
|
}
|
||
|
let desc = new CompositionViewDesc(this, topNode, node, text);
|
||
|
view.input.compositionNodes.push(desc);
|
||
|
// Patch up this.children to contain the composition view
|
||
|
this.children = replaceNodes(this.children, pos, pos + text.length, view, desc);
|
||
|
}
|
||
|
// If this desc must be updated to match the given node decoration,
|
||
|
// do so and return true.
|
||
|
update(node, outerDeco, innerDeco, view) {
|
||
|
if (this.dirty == NODE_DIRTY ||
|
||
|
!node.sameMarkup(this.node))
|
||
|
return false;
|
||
|
this.updateInner(node, outerDeco, innerDeco, view);
|
||
|
return true;
|
||
|
}
|
||
|
updateInner(node, outerDeco, innerDeco, view) {
|
||
|
this.updateOuterDeco(outerDeco);
|
||
|
this.node = node;
|
||
|
this.innerDeco = innerDeco;
|
||
|
if (this.contentDOM)
|
||
|
this.updateChildren(view, this.posAtStart);
|
||
|
this.dirty = NOT_DIRTY;
|
||
|
}
|
||
|
updateOuterDeco(outerDeco) {
|
||
|
if (sameOuterDeco(outerDeco, this.outerDeco))
|
||
|
return;
|
||
|
let needsWrap = this.nodeDOM.nodeType != 1;
|
||
|
let oldDOM = this.dom;
|
||
|
this.dom = patchOuterDeco(this.dom, this.nodeDOM, computeOuterDeco(this.outerDeco, this.node, needsWrap), computeOuterDeco(outerDeco, this.node, needsWrap));
|
||
|
if (this.dom != oldDOM) {
|
||
|
oldDOM.pmViewDesc = undefined;
|
||
|
this.dom.pmViewDesc = this;
|
||
|
}
|
||
|
this.outerDeco = outerDeco;
|
||
|
}
|
||
|
// Mark this node as being the selected node.
|
||
|
selectNode() {
|
||
|
if (this.nodeDOM.nodeType == 1)
|
||
|
this.nodeDOM.classList.add("ProseMirror-selectednode");
|
||
|
if (this.contentDOM || !this.node.type.spec.draggable)
|
||
|
this.dom.draggable = true;
|
||
|
}
|
||
|
// Remove selected node marking from this node.
|
||
|
deselectNode() {
|
||
|
if (this.nodeDOM.nodeType == 1)
|
||
|
this.nodeDOM.classList.remove("ProseMirror-selectednode");
|
||
|
if (this.contentDOM || !this.node.type.spec.draggable)
|
||
|
this.dom.removeAttribute("draggable");
|
||
|
}
|
||
|
get domAtom() { return this.node.isAtom; }
|
||
|
}
|
||
|
// Create a view desc for the top-level document node, to be exported
|
||
|
// and used by the view class.
|
||
|
function docViewDesc(doc, outerDeco, innerDeco, dom, view) {
|
||
|
applyOuterDeco(dom, outerDeco, doc);
|
||
|
return new NodeViewDesc(undefined, doc, outerDeco, innerDeco, dom, dom, dom, view, 0);
|
||
|
}
|
||
|
class TextViewDesc extends NodeViewDesc {
|
||
|
constructor(parent, node, outerDeco, innerDeco, dom, nodeDOM, view) {
|
||
|
super(parent, node, outerDeco, innerDeco, dom, null, nodeDOM, view, 0);
|
||
|
}
|
||
|
parseRule() {
|
||
|
let skip = this.nodeDOM.parentNode;
|
||
|
while (skip && skip != this.dom && !skip.pmIsDeco)
|
||
|
skip = skip.parentNode;
|
||
|
return { skip: (skip || true) };
|
||
|
}
|
||
|
update(node, outerDeco, innerDeco, view) {
|
||
|
if (this.dirty == NODE_DIRTY || (this.dirty != NOT_DIRTY && !this.inParent()) ||
|
||
|
!node.sameMarkup(this.node))
|
||
|
return false;
|
||
|
this.updateOuterDeco(outerDeco);
|
||
|
if ((this.dirty != NOT_DIRTY || node.text != this.node.text) && node.text != this.nodeDOM.nodeValue) {
|
||
|
this.nodeDOM.nodeValue = node.text;
|
||
|
if (view.trackWrites == this.nodeDOM)
|
||
|
view.trackWrites = null;
|
||
|
}
|
||
|
this.node = node;
|
||
|
this.dirty = NOT_DIRTY;
|
||
|
return true;
|
||
|
}
|
||
|
inParent() {
|
||
|
let parentDOM = this.parent.contentDOM;
|
||
|
for (let n = this.nodeDOM; n; n = n.parentNode)
|
||
|
if (n == parentDOM)
|
||
|
return true;
|
||
|
return false;
|
||
|
}
|
||
|
domFromPos(pos) {
|
||
|
return { node: this.nodeDOM, offset: pos };
|
||
|
}
|
||
|
localPosFromDOM(dom, offset, bias) {
|
||
|
if (dom == this.nodeDOM)
|
||
|
return this.posAtStart + Math.min(offset, this.node.text.length);
|
||
|
return super.localPosFromDOM(dom, offset, bias);
|
||
|
}
|
||
|
ignoreMutation(mutation) {
|
||
|
return mutation.type != "characterData" && mutation.type != "selection";
|
||
|
}
|
||
|
slice(from, to, view) {
|
||
|
let node = this.node.cut(from, to), dom = document.createTextNode(node.text);
|
||
|
return new TextViewDesc(this.parent, node, this.outerDeco, this.innerDeco, dom, dom, view);
|
||
|
}
|
||
|
markDirty(from, to) {
|
||
|
super.markDirty(from, to);
|
||
|
if (this.dom != this.nodeDOM && (from == 0 || to == this.nodeDOM.nodeValue.length))
|
||
|
this.dirty = NODE_DIRTY;
|
||
|
}
|
||
|
get domAtom() { return false; }
|
||
|
}
|
||
|
// A dummy desc used to tag trailing BR or IMG nodes created to work
|
||
|
// around contentEditable terribleness.
|
||
|
class TrailingHackViewDesc extends ViewDesc {
|
||
|
parseRule() { return { ignore: true }; }
|
||
|
matchesHack(nodeName) { return this.dirty == NOT_DIRTY && this.dom.nodeName == nodeName; }
|
||
|
get domAtom() { return true; }
|
||
|
get ignoreForCoords() { return this.dom.nodeName == "IMG"; }
|
||
|
}
|
||
|
// A separate subclass is used for customized node views, so that the
|
||
|
// extra checks only have to be made for nodes that are actually
|
||
|
// customized.
|
||
|
class CustomNodeViewDesc extends NodeViewDesc {
|
||
|
constructor(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, spec, view, pos) {
|
||
|
super(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, view, pos);
|
||
|
this.spec = spec;
|
||
|
}
|
||
|
// A custom `update` method gets to decide whether the update goes
|
||
|
// through. If it does, and there's a `contentDOM` node, our logic
|
||
|
// updates the children.
|
||
|
update(node, outerDeco, innerDeco, view) {
|
||
|
if (this.dirty == NODE_DIRTY)
|
||
|
return false;
|
||
|
if (this.spec.update) {
|
||
|
let result = this.spec.update(node, outerDeco, innerDeco);
|
||
|
if (result)
|
||
|
this.updateInner(node, outerDeco, innerDeco, view);
|
||
|
return result;
|
||
|
}
|
||
|
else if (!this.contentDOM && !node.isLeaf) {
|
||
|
return false;
|
||
|
}
|
||
|
else {
|
||
|
return super.update(node, outerDeco, innerDeco, view);
|
||
|
}
|
||
|
}
|
||
|
selectNode() {
|
||
|
this.spec.selectNode ? this.spec.selectNode() : super.selectNode();
|
||
|
}
|
||
|
deselectNode() {
|
||
|
this.spec.deselectNode ? this.spec.deselectNode() : super.deselectNode();
|
||
|
}
|
||
|
setSelection(anchor, head, root, force) {
|
||
|
this.spec.setSelection ? this.spec.setSelection(anchor, head, root)
|
||
|
: super.setSelection(anchor, head, root, force);
|
||
|
}
|
||
|
destroy() {
|
||
|
if (this.spec.destroy)
|
||
|
this.spec.destroy();
|
||
|
super.destroy();
|
||
|
}
|
||
|
stopEvent(event) {
|
||
|
return this.spec.stopEvent ? this.spec.stopEvent(event) : false;
|
||
|
}
|
||
|
ignoreMutation(mutation) {
|
||
|
return this.spec.ignoreMutation ? this.spec.ignoreMutation(mutation) : super.ignoreMutation(mutation);
|
||
|
}
|
||
|
}
|
||
|
// Sync the content of the given DOM node with the nodes associated
|
||
|
// with the given array of view descs, recursing into mark descs
|
||
|
// because this should sync the subtree for a whole node at a time.
|
||
|
function renderDescs(parentDOM, descs, view) {
|
||
|
let dom = parentDOM.firstChild, written = false;
|
||
|
for (let i = 0; i < descs.length; i++) {
|
||
|
let desc = descs[i], childDOM = desc.dom;
|
||
|
if (childDOM.parentNode == parentDOM) {
|
||
|
while (childDOM != dom) {
|
||
|
dom = rm(dom);
|
||
|
written = true;
|
||
|
}
|
||
|
dom = dom.nextSibling;
|
||
|
}
|
||
|
else {
|
||
|
written = true;
|
||
|
parentDOM.insertBefore(childDOM, dom);
|
||
|
}
|
||
|
if (desc instanceof MarkViewDesc) {
|
||
|
let pos = dom ? dom.previousSibling : parentDOM.lastChild;
|
||
|
renderDescs(desc.contentDOM, desc.children, view);
|
||
|
dom = pos ? pos.nextSibling : parentDOM.firstChild;
|
||
|
}
|
||
|
}
|
||
|
while (dom) {
|
||
|
dom = rm(dom);
|
||
|
written = true;
|
||
|
}
|
||
|
if (written && view.trackWrites == parentDOM)
|
||
|
view.trackWrites = null;
|
||
|
}
|
||
|
const OuterDecoLevel = function (nodeName) {
|
||
|
if (nodeName)
|
||
|
this.nodeName = nodeName;
|
||
|
};
|
||
|
OuterDecoLevel.prototype = Object.create(null);
|
||
|
const noDeco = [new OuterDecoLevel];
|
||
|
function computeOuterDeco(outerDeco, node, needsWrap) {
|
||
|
if (outerDeco.length == 0)
|
||
|
return noDeco;
|
||
|
let top = needsWrap ? noDeco[0] : new OuterDecoLevel, result = [top];
|
||
|
for (let i = 0; i < outerDeco.length; i++) {
|
||
|
let attrs = outerDeco[i].type.attrs;
|
||
|
if (!attrs)
|
||
|
continue;
|
||
|
if (attrs.nodeName)
|
||
|
result.push(top = new OuterDecoLevel(attrs.nodeName));
|
||
|
for (let name in attrs) {
|
||
|
let val = attrs[name];
|
||
|
if (val == null)
|
||
|
continue;
|
||
|
if (needsWrap && result.length == 1)
|
||
|
result.push(top = new OuterDecoLevel(node.isInline ? "span" : "div"));
|
||
|
if (name == "class")
|
||
|
top.class = (top.class ? top.class + " " : "") + val;
|
||
|
else if (name == "style")
|
||
|
top.style = (top.style ? top.style + ";" : "") + val;
|
||
|
else if (name != "nodeName")
|
||
|
top[name] = val;
|
||
|
}
|
||
|
}
|
||
|
return result;
|
||
|
}
|
||
|
function patchOuterDeco(outerDOM, nodeDOM, prevComputed, curComputed) {
|
||
|
// Shortcut for trivial case
|
||
|
if (prevComputed == noDeco && curComputed == noDeco)
|
||
|
return nodeDOM;
|
||
|
let curDOM = nodeDOM;
|
||
|
for (let i = 0; i < curComputed.length; i++) {
|
||
|
let deco = curComputed[i], prev = prevComputed[i];
|
||
|
if (i) {
|
||
|
let parent;
|
||
|
if (prev && prev.nodeName == deco.nodeName && curDOM != outerDOM &&
|
||
|
(parent = curDOM.parentNode) && parent.nodeName.toLowerCase() == deco.nodeName) {
|
||
|
curDOM = parent;
|
||
|
}
|
||
|
else {
|
||
|
parent = document.createElement(deco.nodeName);
|
||
|
parent.pmIsDeco = true;
|
||
|
parent.appendChild(curDOM);
|
||
|
prev = noDeco[0];
|
||
|
curDOM = parent;
|
||
|
}
|
||
|
}
|
||
|
patchAttributes(curDOM, prev || noDeco[0], deco);
|
||
|
}
|
||
|
return curDOM;
|
||
|
}
|
||
|
function patchAttributes(dom, prev, cur) {
|
||
|
for (let name in prev)
|
||
|
if (name != "class" && name != "style" && name != "nodeName" && !(name in cur))
|
||
|
dom.removeAttribute(name);
|
||
|
for (let name in cur)
|
||
|
if (name != "class" && name != "style" && name != "nodeName" && cur[name] != prev[name])
|
||
|
dom.setAttribute(name, cur[name]);
|
||
|
if (prev.class != cur.class) {
|
||
|
let prevList = prev.class ? prev.class.split(" ").filter(Boolean) : [];
|
||
|
let curList = cur.class ? cur.class.split(" ").filter(Boolean) : [];
|
||
|
for (let i = 0; i < prevList.length; i++)
|
||
|
if (curList.indexOf(prevList[i]) == -1)
|
||
|
dom.classList.remove(prevList[i]);
|
||
|
for (let i = 0; i < curList.length; i++)
|
||
|
if (prevList.indexOf(curList[i]) == -1)
|
||
|
dom.classList.add(curList[i]);
|
||
|
if (dom.classList.length == 0)
|
||
|
dom.removeAttribute("class");
|
||
|
}
|
||
|
if (prev.style != cur.style) {
|
||
|
if (prev.style) {
|
||
|
let prop = /\s*([\w\-\xa1-\uffff]+)\s*:(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\(.*?\)|[^;])*/g, m;
|
||
|
while (m = prop.exec(prev.style))
|
||
|
dom.style.removeProperty(m[1]);
|
||
|
}
|
||
|
if (cur.style)
|
||
|
dom.style.cssText += cur.style;
|
||
|
}
|
||
|
}
|
||
|
function applyOuterDeco(dom, deco, node) {
|
||
|
return patchOuterDeco(dom, dom, noDeco, computeOuterDeco(deco, node, dom.nodeType != 1));
|
||
|
}
|
||
|
function sameOuterDeco(a, b) {
|
||
|
if (a.length != b.length)
|
||
|
return false;
|
||
|
for (let i = 0; i < a.length; i++)
|
||
|
if (!a[i].type.eq(b[i].type))
|
||
|
return false;
|
||
|
return true;
|
||
|
}
|
||
|
// Remove a DOM node and return its next sibling.
|
||
|
function rm(dom) {
|
||
|
let next = dom.nextSibling;
|
||
|
dom.parentNode.removeChild(dom);
|
||
|
return next;
|
||
|
}
|
||
|
// Helper class for incrementally updating a tree of mark descs and
|
||
|
// the widget and node descs inside of them.
|
||
|
class ViewTreeUpdater {
|
||
|
constructor(top, lock, view) {
|
||
|
this.lock = lock;
|
||
|
this.view = view;
|
||
|
// Index into `this.top`'s child array, represents the current
|
||
|
// update position.
|
||
|
this.index = 0;
|
||
|
// When entering a mark, the current top and index are pushed
|
||
|
// onto this.
|
||
|
this.stack = [];
|
||
|
// Tracks whether anything was changed
|
||
|
this.changed = false;
|
||
|
this.top = top;
|
||
|
this.preMatch = preMatch(top.node.content, top);
|
||
|
}
|
||
|
// Destroy and remove the children between the given indices in
|
||
|
// `this.top`.
|
||
|
destroyBetween(start, end) {
|
||
|
if (start == end)
|
||
|
return;
|
||
|
for (let i = start; i < end; i++)
|
||
|
this.top.children[i].destroy();
|
||
|
this.top.children.splice(start, end - start);
|
||
|
this.changed = true;
|
||
|
}
|
||
|
// Destroy all remaining children in `this.top`.
|
||
|
destroyRest() {
|
||
|
this.destroyBetween(this.index, this.top.children.length);
|
||
|
}
|
||
|
// Sync the current stack of mark descs with the given array of
|
||
|
// marks, reusing existing mark descs when possible.
|
||
|
syncToMarks(marks, inline, view) {
|
||
|
let keep = 0, depth = this.stack.length >> 1;
|
||
|
let maxKeep = Math.min(depth, marks.length);
|
||
|
while (keep < maxKeep &&
|
||
|
(keep == depth - 1 ? this.top : this.stack[(keep + 1) << 1])
|
||
|
.matchesMark(marks[keep]) && marks[keep].type.spec.spanning !== false)
|
||
|
keep++;
|
||
|
while (keep < depth) {
|
||
|
this.destroyRest();
|
||
|
this.top.dirty = NOT_DIRTY;
|
||
|
this.index = this.stack.pop();
|
||
|
this.top = this.stack.pop();
|
||
|
depth--;
|
||
|
}
|
||
|
while (depth < marks.length) {
|
||
|
this.stack.push(this.top, this.index + 1);
|
||
|
let found = -1;
|
||
|
for (let i = this.index; i < Math.min(this.index + 3, this.top.children.length); i++) {
|
||
|
if (this.top.children[i].matchesMark(marks[depth])) {
|
||
|
found = i;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
if (found > -1) {
|
||
|
if (found > this.index) {
|
||
|
this.changed = true;
|
||
|
this.destroyBetween(this.index, found);
|
||
|
}
|
||
|
this.top = this.top.children[this.index];
|
||
|
}
|
||
|
else {
|
||
|
let markDesc = MarkViewDesc.create(this.top, marks[depth], inline, view);
|
||
|
this.top.children.splice(this.index, 0, markDesc);
|
||
|
this.top = markDesc;
|
||
|
this.changed = true;
|
||
|
}
|
||
|
this.index = 0;
|
||
|
depth++;
|
||
|
}
|
||
|
}
|
||
|
// Try to find a node desc matching the given data. Skip over it and
|
||
|
// return true when successful.
|
||
|
findNodeMatch(node, outerDeco, innerDeco, index) {
|
||
|
let found = -1, targetDesc;
|
||
|
if (index >= this.preMatch.index &&
|
||
|
(targetDesc = this.preMatch.matches[index - this.preMatch.index]).parent == this.top &&
|
||
|
targetDesc.matchesNode(node, outerDeco, innerDeco)) {
|
||
|
found = this.top.children.indexOf(targetDesc, this.index);
|
||
|
}
|
||
|
else {
|
||
|
for (let i = this.index, e = Math.min(this.top.children.length, i + 5); i < e; i++) {
|
||
|
let child = this.top.children[i];
|
||
|
if (child.matchesNode(node, outerDeco, innerDeco) && !this.preMatch.matched.has(child)) {
|
||
|
found = i;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (found < 0)
|
||
|
return false;
|
||
|
this.destroyBetween(this.index, found);
|
||
|
this.index++;
|
||
|
return true;
|
||
|
}
|
||
|
updateNodeAt(node, outerDeco, innerDeco, index, view) {
|
||
|
let child = this.top.children[index];
|
||
|
if (child.dirty == NODE_DIRTY && child.dom == child.contentDOM)
|
||
|
child.dirty = CONTENT_DIRTY;
|
||
|
if (!child.update(node, outerDeco, innerDeco, view))
|
||
|
return false;
|
||
|
this.destroyBetween(this.index, index);
|
||
|
this.index++;
|
||
|
return true;
|
||
|
}
|
||
|
findIndexWithChild(domNode) {
|
||
|
for (;;) {
|
||
|
let parent = domNode.parentNode;
|
||
|
if (!parent)
|
||
|
return -1;
|
||
|
if (parent == this.top.contentDOM) {
|
||
|
let desc = domNode.pmViewDesc;
|
||
|
if (desc)
|
||
|
for (let i = this.index; i < this.top.children.length; i++) {
|
||
|
if (this.top.children[i] == desc)
|
||
|
return i;
|
||
|
}
|
||
|
return -1;
|
||
|
}
|
||
|
domNode = parent;
|
||
|
}
|
||
|
}
|
||
|
// Try to update the next node, if any, to the given data. Checks
|
||
|
// pre-matches to avoid overwriting nodes that could still be used.
|
||
|
updateNextNode(node, outerDeco, innerDeco, view, index) {
|
||
|
for (let i = this.index; i < this.top.children.length; i++) {
|
||
|
let next = this.top.children[i];
|
||
|
if (next instanceof NodeViewDesc) {
|
||
|
let preMatch = this.preMatch.matched.get(next);
|
||
|
if (preMatch != null && preMatch != index)
|
||
|
return false;
|
||
|
let nextDOM = next.dom;
|
||
|
// Can't update if nextDOM is or contains this.lock, except if
|
||
|
// it's a text node whose content already matches the new text
|
||
|
// and whose decorations match the new ones.
|
||
|
let locked = this.lock && (nextDOM == this.lock || nextDOM.nodeType == 1 && nextDOM.contains(this.lock.parentNode)) &&
|
||
|
!(node.isText && next.node && next.node.isText && next.nodeDOM.nodeValue == node.text &&
|
||
|
next.dirty != NODE_DIRTY && sameOuterDeco(outerDeco, next.outerDeco));
|
||
|
if (!locked && next.update(node, outerDeco, innerDeco, view)) {
|
||
|
this.destroyBetween(this.index, i);
|
||
|
if (next.dom != nextDOM)
|
||
|
this.changed = true;
|
||
|
this.index++;
|
||
|
return true;
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
// Insert the node as a newly created node desc.
|
||
|
addNode(node, outerDeco, innerDeco, view, pos) {
|
||
|
this.top.children.splice(this.index++, 0, NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos));
|
||
|
this.changed = true;
|
||
|
}
|
||
|
placeWidget(widget, view, pos) {
|
||
|
let next = this.index < this.top.children.length ? this.top.children[this.index] : null;
|
||
|
if (next && next.matchesWidget(widget) &&
|
||
|
(widget == next.widget || !next.widget.type.toDOM.parentNode)) {
|
||
|
this.index++;
|
||
|
}
|
||
|
else {
|
||
|
let desc = new WidgetViewDesc(this.top, widget, view, pos);
|
||
|
this.top.children.splice(this.index++, 0, desc);
|
||
|
this.changed = true;
|
||
|
}
|
||
|
}
|
||
|
// Make sure a textblock looks and behaves correctly in
|
||
|
// contentEditable.
|
||
|
addTextblockHacks() {
|
||
|
let lastChild = this.top.children[this.index - 1], parent = this.top;
|
||
|
while (lastChild instanceof MarkViewDesc) {
|
||
|
parent = lastChild;
|
||
|
lastChild = parent.children[parent.children.length - 1];
|
||
|
}
|
||
|
if (!lastChild || // Empty textblock
|
||
|
!(lastChild instanceof TextViewDesc) ||
|
||
|
/\n$/.test(lastChild.node.text) ||
|
||
|
(this.view.requiresGeckoHackNode && /\s$/.test(lastChild.node.text))) {
|
||
|
// Avoid bugs in Safari's cursor drawing (#1165) and Chrome's mouse selection (#1152)
|
||
|
if ((safari || chrome) && lastChild && lastChild.dom.contentEditable == "false")
|
||
|
this.addHackNode("IMG", parent);
|
||
|
this.addHackNode("BR", this.top);
|
||
|
}
|
||
|
}
|
||
|
addHackNode(nodeName, parent) {
|
||
|
if (parent == this.top && this.index < parent.children.length && parent.children[this.index].matchesHack(nodeName)) {
|
||
|
this.index++;
|
||
|
}
|
||
|
else {
|
||
|
let dom = document.createElement(nodeName);
|
||
|
if (nodeName == "IMG") {
|
||
|
dom.className = "ProseMirror-separator";
|
||
|
dom.alt = "";
|
||
|
}
|
||
|
if (nodeName == "BR")
|
||
|
dom.className = "ProseMirror-trailingBreak";
|
||
|
let hack = new TrailingHackViewDesc(this.top, [], dom, null);
|
||
|
if (parent != this.top)
|
||
|
parent.children.push(hack);
|
||
|
else
|
||
|
parent.children.splice(this.index++, 0, hack);
|
||
|
this.changed = true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
// Iterate from the end of the fragment and array of descs to find
|
||
|
// directly matching ones, in order to avoid overeagerly reusing those
|
||
|
// for other nodes. Returns the fragment index of the first node that
|
||
|
// is part of the sequence of matched nodes at the end of the
|
||
|
// fragment.
|
||
|
function preMatch(frag, parentDesc) {
|
||
|
let curDesc = parentDesc, descI = curDesc.children.length;
|
||
|
let fI = frag.childCount, matched = new Map, matches = [];
|
||
|
outer: while (fI > 0) {
|
||
|
let desc;
|
||
|
for (;;) {
|
||
|
if (descI) {
|
||
|
let next = curDesc.children[descI - 1];
|
||
|
if (next instanceof MarkViewDesc) {
|
||
|
curDesc = next;
|
||
|
descI = next.children.length;
|
||
|
}
|
||
|
else {
|
||
|
desc = next;
|
||
|
descI--;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
else if (curDesc == parentDesc) {
|
||
|
break outer;
|
||
|
}
|
||
|
else {
|
||
|
// FIXME
|
||
|
descI = curDesc.parent.children.indexOf(curDesc);
|
||
|
curDesc = curDesc.parent;
|
||
|
}
|
||
|
}
|
||
|
let node = desc.node;
|
||
|
if (!node)
|
||
|
continue;
|
||
|
if (node != frag.child(fI - 1))
|
||
|
break;
|
||
|
--fI;
|
||
|
matched.set(desc, fI);
|
||
|
matches.push(desc);
|
||
|
}
|
||
|
return { index: fI, matched, matches: matches.reverse() };
|
||
|
}
|
||
|
function compareSide(a, b) {
|
||
|
return a.type.side - b.type.side;
|
||
|
}
|
||
|
// This function abstracts iterating over the nodes and decorations in
|
||
|
// a fragment. Calls `onNode` for each node, with its local and child
|
||
|
// decorations. Splits text nodes when there is a decoration starting
|
||
|
// or ending inside of them. Calls `onWidget` for each widget.
|
||
|
function iterDeco(parent, deco, onWidget, onNode) {
|
||
|
let locals = deco.locals(parent), offset = 0;
|
||
|
// Simple, cheap variant for when there are no local decorations
|
||
|
if (locals.length == 0) {
|
||
|
for (let i = 0; i < parent.childCount; i++) {
|
||
|
let child = parent.child(i);
|
||
|
onNode(child, locals, deco.forChild(offset, child), i);
|
||
|
offset += child.nodeSize;
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
let decoIndex = 0, active = [], restNode = null;
|
||
|
for (let parentIndex = 0;;) {
|
||
|
if (decoIndex < locals.length && locals[decoIndex].to == offset) {
|
||
|
let widget = locals[decoIndex++], widgets;
|
||
|
while (decoIndex < locals.length && locals[decoIndex].to == offset)
|
||
|
(widgets || (widgets = [widget])).push(locals[decoIndex++]);
|
||
|
if (widgets) {
|
||
|
widgets.sort(compareSide);
|
||
|
for (let i = 0; i < widgets.length; i++)
|
||
|
onWidget(widgets[i], parentIndex, !!restNode);
|
||
|
}
|
||
|
else {
|
||
|
onWidget(widget, parentIndex, !!restNode);
|
||
|
}
|
||
|
}
|
||
|
let child, index;
|
||
|
if (restNode) {
|
||
|
index = -1;
|
||
|
child = restNode;
|
||
|
restNode = null;
|
||
|
}
|
||
|
else if (parentIndex < parent.childCount) {
|
||
|
index = parentIndex;
|
||
|
child = parent.child(parentIndex++);
|
||
|
}
|
||
|
else {
|
||
|
break;
|
||
|
}
|
||
|
for (let i = 0; i < active.length; i++)
|
||
|
if (active[i].to <= offset)
|
||
|
active.splice(i--, 1);
|
||
|
while (decoIndex < locals.length && locals[decoIndex].from <= offset && locals[decoIndex].to > offset)
|
||
|
active.push(locals[decoIndex++]);
|
||
|
let end = offset + child.nodeSize;
|
||
|
if (child.isText) {
|
||
|
let cutAt = end;
|
||
|
if (decoIndex < locals.length && locals[decoIndex].from < cutAt)
|
||
|
cutAt = locals[decoIndex].from;
|
||
|
for (let i = 0; i < active.length; i++)
|
||
|
if (active[i].to < cutAt)
|
||
|
cutAt = active[i].to;
|
||
|
if (cutAt < end) {
|
||
|
restNode = child.cut(cutAt - offset);
|
||
|
child = child.cut(0, cutAt - offset);
|
||
|
end = cutAt;
|
||
|
index = -1;
|
||
|
}
|
||
|
}
|
||
|
let outerDeco = child.isInline && !child.isLeaf ? active.filter(d => !d.inline) : active.slice();
|
||
|
onNode(child, outerDeco, deco.forChild(offset, child), index);
|
||
|
offset = end;
|
||
|
}
|
||
|
}
|
||
|
// List markers in Mobile Safari will mysteriously disappear
|
||
|
// sometimes. This works around that.
|
||
|
function iosHacks(dom) {
|
||
|
if (dom.nodeName == "UL" || dom.nodeName == "OL") {
|
||
|
let oldCSS = dom.style.cssText;
|
||
|
dom.style.cssText = oldCSS + "; list-style: square !important";
|
||
|
window.getComputedStyle(dom).listStyle;
|
||
|
dom.style.cssText = oldCSS;
|
||
|
}
|
||
|
}
|
||
|
function nearbyTextNode(node, offset) {
|
||
|
for (;;) {
|
||
|
if (node.nodeType == 3)
|
||
|
return node;
|
||
|
if (node.nodeType == 1 && offset > 0) {
|
||
|
if (node.childNodes.length > offset && node.childNodes[offset].nodeType == 3)
|
||
|
return node.childNodes[offset];
|
||
|
node = node.childNodes[offset - 1];
|
||
|
offset = nodeSize(node);
|
||
|
}
|
||
|
else if (node.nodeType == 1 && offset < node.childNodes.length) {
|
||
|
node = node.childNodes[offset];
|
||
|
offset = 0;
|
||
|
}
|
||
|
else {
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
// Find a piece of text in an inline fragment, overlapping from-to
|
||
|
function findTextInFragment(frag, text, from, to) {
|
||
|
for (let i = 0, pos = 0; i < frag.childCount && pos <= to;) {
|
||
|
let child = frag.child(i++), childStart = pos;
|
||
|
pos += child.nodeSize;
|
||
|
if (!child.isText)
|
||
|
continue;
|
||
|
let str = child.text;
|
||
|
while (i < frag.childCount) {
|
||
|
let next = frag.child(i++);
|
||
|
pos += next.nodeSize;
|
||
|
if (!next.isText)
|
||
|
break;
|
||
|
str += next.text;
|
||
|
}
|
||
|
if (pos >= from) {
|
||
|
let found = childStart < to ? str.lastIndexOf(text, to - childStart - 1) : -1;
|
||
|
if (found >= 0 && found + text.length + childStart >= from)
|
||
|
return childStart + found;
|
||
|
if (from == to && str.length >= (to + text.length) - childStart &&
|
||
|
str.slice(to - childStart, to - childStart + text.length) == text)
|
||
|
return to;
|
||
|
}
|
||
|
}
|
||
|
return -1;
|
||
|
}
|
||
|
// Replace range from-to in an array of view descs with replacement
|
||
|
// (may be null to just delete). This goes very much against the grain
|
||
|
// of the rest of this code, which tends to create nodes with the
|
||
|
// right shape in one go, rather than messing with them after
|
||
|
// creation, but is necessary in the composition hack.
|
||
|
function replaceNodes(nodes, from, to, view, replacement) {
|
||
|
let result = [];
|
||
|
for (let i = 0, off = 0; i < nodes.length; i++) {
|
||
|
let child = nodes[i], start = off, end = off += child.size;
|
||
|
if (start >= to || end <= from) {
|
||
|
result.push(child);
|
||
|
}
|
||
|
else {
|
||
|
if (start < from)
|
||
|
result.push(child.slice(0, from - start, view));
|
||
|
if (replacement) {
|
||
|
result.push(replacement);
|
||
|
replacement = undefined;
|
||
|
}
|
||
|
if (end > to)
|
||
|
result.push(child.slice(to - start, child.size, view));
|
||
|
}
|
||
|
}
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
function selectionFromDOM(view, origin = null) {
|
||
|
let domSel = view.domSelectionRange(), doc = view.state.doc;
|
||
|
if (!domSel.focusNode)
|
||
|
return null;
|
||
|
let nearestDesc = view.docView.nearestDesc(domSel.focusNode), inWidget = nearestDesc && nearestDesc.size == 0;
|
||
|
let head = view.docView.posFromDOM(domSel.focusNode, domSel.focusOffset, 1);
|
||
|
if (head < 0)
|
||
|
return null;
|
||
|
let $head = doc.resolve(head), $anchor, selection;
|
||
|
if (selectionCollapsed(domSel)) {
|
||
|
$anchor = $head;
|
||
|
while (nearestDesc && !nearestDesc.node)
|
||
|
nearestDesc = nearestDesc.parent;
|
||
|
let nearestDescNode = nearestDesc.node;
|
||
|
if (nearestDesc && nearestDescNode.isAtom && NodeSelection.isSelectable(nearestDescNode) && nearestDesc.parent
|
||
|
&& !(nearestDescNode.isInline && isOnEdge(domSel.focusNode, domSel.focusOffset, nearestDesc.dom))) {
|
||
|
let pos = nearestDesc.posBefore;
|
||
|
selection = new NodeSelection(head == pos ? $head : doc.resolve(pos));
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
let anchor = view.docView.posFromDOM(domSel.anchorNode, domSel.anchorOffset, 1);
|
||
|
if (anchor < 0)
|
||
|
return null;
|
||
|
$anchor = doc.resolve(anchor);
|
||
|
}
|
||
|
if (!selection) {
|
||
|
let bias = origin == "pointer" || (view.state.selection.head < $head.pos && !inWidget) ? 1 : -1;
|
||
|
selection = selectionBetween(view, $anchor, $head, bias);
|
||
|
}
|
||
|
return selection;
|
||
|
}
|
||
|
function editorOwnsSelection(view) {
|
||
|
return view.editable ? view.hasFocus() :
|
||
|
hasSelection(view) && document.activeElement && document.activeElement.contains(view.dom);
|
||
|
}
|
||
|
function selectionToDOM(view, force = false) {
|
||
|
let sel = view.state.selection;
|
||
|
syncNodeSelection(view, sel);
|
||
|
if (!editorOwnsSelection(view))
|
||
|
return;
|
||
|
// The delayed drag selection causes issues with Cell Selections
|
||
|
// in Safari. And the drag selection delay is to workarond issues
|
||
|
// which only present in Chrome.
|
||
|
if (!force && view.input.mouseDown && view.input.mouseDown.allowDefault && chrome) {
|
||
|
let domSel = view.domSelectionRange(), curSel = view.domObserver.currentSelection;
|
||
|
if (domSel.anchorNode && curSel.anchorNode &&
|
||
|
isEquivalentPosition(domSel.anchorNode, domSel.anchorOffset, curSel.anchorNode, curSel.anchorOffset)) {
|
||
|
view.input.mouseDown.delayedSelectionSync = true;
|
||
|
view.domObserver.setCurSelection();
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
view.domObserver.disconnectSelection();
|
||
|
if (view.cursorWrapper) {
|
||
|
selectCursorWrapper(view);
|
||
|
}
|
||
|
else {
|
||
|
let { anchor, head } = sel, resetEditableFrom, resetEditableTo;
|
||
|
if (brokenSelectBetweenUneditable && !(sel instanceof TextSelection)) {
|
||
|
if (!sel.$from.parent.inlineContent)
|
||
|
resetEditableFrom = temporarilyEditableNear(view, sel.from);
|
||
|
if (!sel.empty && !sel.$from.parent.inlineContent)
|
||
|
resetEditableTo = temporarilyEditableNear(view, sel.to);
|
||
|
}
|
||
|
view.docView.setSelection(anchor, head, view.root, force);
|
||
|
if (brokenSelectBetweenUneditable) {
|
||
|
if (resetEditableFrom)
|
||
|
resetEditable(resetEditableFrom);
|
||
|
if (resetEditableTo)
|
||
|
resetEditable(resetEditableTo);
|
||
|
}
|
||
|
if (sel.visible) {
|
||
|
view.dom.classList.remove("ProseMirror-hideselection");
|
||
|
}
|
||
|
else {
|
||
|
view.dom.classList.add("ProseMirror-hideselection");
|
||
|
if ("onselectionchange" in document)
|
||
|
removeClassOnSelectionChange(view);
|
||
|
}
|
||
|
}
|
||
|
view.domObserver.setCurSelection();
|
||
|
view.domObserver.connectSelection();
|
||
|
}
|
||
|
// Kludge to work around Webkit not allowing a selection to start/end
|
||
|
// between non-editable block nodes. We briefly make something
|
||
|
// editable, set the selection, then set it uneditable again.
|
||
|
const brokenSelectBetweenUneditable = safari || chrome && chrome_version < 63;
|
||
|
function temporarilyEditableNear(view, pos) {
|
||
|
let { node, offset } = view.docView.domFromPos(pos, 0);
|
||
|
let after = offset < node.childNodes.length ? node.childNodes[offset] : null;
|
||
|
let before = offset ? node.childNodes[offset - 1] : null;
|
||
|
if (safari && after && after.contentEditable == "false")
|
||
|
return setEditable(after);
|
||
|
if ((!after || after.contentEditable == "false") &&
|
||
|
(!before || before.contentEditable == "false")) {
|
||
|
if (after)
|
||
|
return setEditable(after);
|
||
|
else if (before)
|
||
|
return setEditable(before);
|
||
|
}
|
||
|
}
|
||
|
function setEditable(element) {
|
||
|
element.contentEditable = "true";
|
||
|
if (safari && element.draggable) {
|
||
|
element.draggable = false;
|
||
|
element.wasDraggable = true;
|
||
|
}
|
||
|
return element;
|
||
|
}
|
||
|
function resetEditable(element) {
|
||
|
element.contentEditable = "false";
|
||
|
if (element.wasDraggable) {
|
||
|
element.draggable = true;
|
||
|
element.wasDraggable = null;
|
||
|
}
|
||
|
}
|
||
|
function removeClassOnSelectionChange(view) {
|
||
|
let doc = view.dom.ownerDocument;
|
||
|
doc.removeEventListener("selectionchange", view.input.hideSelectionGuard);
|
||
|
let domSel = view.domSelectionRange();
|
||
|
let node = domSel.anchorNode, offset = domSel.anchorOffset;
|
||
|
doc.addEventListener("selectionchange", view.input.hideSelectionGuard = () => {
|
||
|
if (domSel.anchorNode != node || domSel.anchorOffset != offset) {
|
||
|
doc.removeEventListener("selectionchange", view.input.hideSelectionGuard);
|
||
|
setTimeout(() => {
|
||
|
if (!editorOwnsSelection(view) || view.state.selection.visible)
|
||
|
view.dom.classList.remove("ProseMirror-hideselection");
|
||
|
}, 20);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
function selectCursorWrapper(view) {
|
||
|
let domSel = view.domSelection(), range = document.createRange();
|
||
|
let node = view.cursorWrapper.dom, img = node.nodeName == "IMG";
|
||
|
if (img)
|
||
|
range.setEnd(node.parentNode, domIndex(node) + 1);
|
||
|
else
|
||
|
range.setEnd(node, 0);
|
||
|
range.collapse(false);
|
||
|
domSel.removeAllRanges();
|
||
|
domSel.addRange(range);
|
||
|
// Kludge to kill 'control selection' in IE11 when selecting an
|
||
|
// invisible cursor wrapper, since that would result in those weird
|
||
|
// resize handles and a selection that considers the absolutely
|
||
|
// positioned wrapper, rather than the root editable node, the
|
||
|
// focused element.
|
||
|
if (!img && !view.state.selection.visible && ie && ie_version <= 11) {
|
||
|
node.disabled = true;
|
||
|
node.disabled = false;
|
||
|
}
|
||
|
}
|
||
|
function syncNodeSelection(view, sel) {
|
||
|
if (sel instanceof NodeSelection) {
|
||
|
let desc = view.docView.descAt(sel.from);
|
||
|
if (desc != view.lastSelectedViewDesc) {
|
||
|
clearNodeSelection(view);
|
||
|
if (desc)
|
||
|
desc.selectNode();
|
||
|
view.lastSelectedViewDesc = desc;
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
clearNodeSelection(view);
|
||
|
}
|
||
|
}
|
||
|
// Clear all DOM statefulness of the last node selection.
|
||
|
function clearNodeSelection(view) {
|
||
|
if (view.lastSelectedViewDesc) {
|
||
|
if (view.lastSelectedViewDesc.parent)
|
||
|
view.lastSelectedViewDesc.deselectNode();
|
||
|
view.lastSelectedViewDesc = undefined;
|
||
|
}
|
||
|
}
|
||
|
function selectionBetween(view, $anchor, $head, bias) {
|
||
|
return view.someProp("createSelectionBetween", f => f(view, $anchor, $head))
|
||
|
|| TextSelection.between($anchor, $head, bias);
|
||
|
}
|
||
|
function hasFocusAndSelection(view) {
|
||
|
if (view.editable && !view.hasFocus())
|
||
|
return false;
|
||
|
return hasSelection(view);
|
||
|
}
|
||
|
function hasSelection(view) {
|
||
|
let sel = view.domSelectionRange();
|
||
|
if (!sel.anchorNode)
|
||
|
return false;
|
||
|
try {
|
||
|
// Firefox will raise 'permission denied' errors when accessing
|
||
|
// properties of `sel.anchorNode` when it's in a generated CSS
|
||
|
// element.
|
||
|
return view.dom.contains(sel.anchorNode.nodeType == 3 ? sel.anchorNode.parentNode : sel.anchorNode) &&
|
||
|
(view.editable || view.dom.contains(sel.focusNode.nodeType == 3 ? sel.focusNode.parentNode : sel.focusNode));
|
||
|
}
|
||
|
catch (_) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
function anchorInRightPlace(view) {
|
||
|
let anchorDOM = view.docView.domFromPos(view.state.selection.anchor, 0);
|
||
|
let domSel = view.domSelectionRange();
|
||
|
return isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode, domSel.anchorOffset);
|
||
|
}
|
||
|
|
||
|
function moveSelectionBlock(state, dir) {
|
||
|
let { $anchor, $head } = state.selection;
|
||
|
let $side = dir > 0 ? $anchor.max($head) : $anchor.min($head);
|
||
|
let $start = !$side.parent.inlineContent ? $side : $side.depth ? state.doc.resolve(dir > 0 ? $side.after() : $side.before()) : null;
|
||
|
return $start && Selection.findFrom($start, dir);
|
||
|
}
|
||
|
function apply(view, sel) {
|
||
|
view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
|
||
|
return true;
|
||
|
}
|
||
|
function selectHorizontally(view, dir, mods) {
|
||
|
let sel = view.state.selection;
|
||
|
if (sel instanceof TextSelection) {
|
||
|
if (!sel.empty || mods.indexOf("s") > -1) {
|
||
|
return false;
|
||
|
}
|
||
|
else if (view.endOfTextblock(dir > 0 ? "right" : "left")) {
|
||
|
let next = moveSelectionBlock(view.state, dir);
|
||
|
if (next && (next instanceof NodeSelection))
|
||
|
return apply(view, next);
|
||
|
return false;
|
||
|
}
|
||
|
else if (!(mac && mods.indexOf("m") > -1)) {
|
||
|
let $head = sel.$head, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter, desc;
|
||
|
if (!node || node.isText)
|
||
|
return false;
|
||
|
let nodePos = dir < 0 ? $head.pos - node.nodeSize : $head.pos;
|
||
|
if (!(node.isAtom || (desc = view.docView.descAt(nodePos)) && !desc.contentDOM))
|
||
|
return false;
|
||
|
if (NodeSelection.isSelectable(node)) {
|
||
|
return apply(view, new NodeSelection(dir < 0 ? view.state.doc.resolve($head.pos - node.nodeSize) : $head));
|
||
|
}
|
||
|
else if (webkit) {
|
||
|
// Chrome and Safari will introduce extra pointless cursor
|
||
|
// positions around inline uneditable nodes, so we have to
|
||
|
// take over and move the cursor past them (#937)
|
||
|
return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize)));
|
||
|
}
|
||
|
else {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
else if (sel instanceof NodeSelection && sel.node.isInline) {
|
||
|
return apply(view, new TextSelection(dir > 0 ? sel.$to : sel.$from));
|
||
|
}
|
||
|
else {
|
||
|
let next = moveSelectionBlock(view.state, dir);
|
||
|
if (next)
|
||
|
return apply(view, next);
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
function nodeLen(node) {
|
||
|
return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length;
|
||
|
}
|
||
|
function isIgnorable(dom) {
|
||
|
let desc = dom.pmViewDesc;
|
||
|
return desc && desc.size == 0 && (dom.nextSibling || dom.nodeName != "BR");
|
||
|
}
|
||
|
// Make sure the cursor isn't directly after one or more ignored
|
||
|
// nodes, which will confuse the browser's cursor motion logic.
|
||
|
function skipIgnoredNodesLeft(view) {
|
||
|
let sel = view.domSelectionRange();
|
||
|
let node = sel.focusNode, offset = sel.focusOffset;
|
||
|
if (!node)
|
||
|
return;
|
||
|
let moveNode, moveOffset, force = false;
|
||
|
// Gecko will do odd things when the selection is directly in front
|
||
|
// of a non-editable node, so in that case, move it into the next
|
||
|
// node if possible. Issue prosemirror/prosemirror#832.
|
||
|
if (gecko && node.nodeType == 1 && offset < nodeLen(node) && isIgnorable(node.childNodes[offset]))
|
||
|
force = true;
|
||
|
for (;;) {
|
||
|
if (offset > 0) {
|
||
|
if (node.nodeType != 1) {
|
||
|
break;
|
||
|
}
|
||
|
else {
|
||
|
let before = node.childNodes[offset - 1];
|
||
|
if (isIgnorable(before)) {
|
||
|
moveNode = node;
|
||
|
moveOffset = --offset;
|
||
|
}
|
||
|
else if (before.nodeType == 3) {
|
||
|
node = before;
|
||
|
offset = node.nodeValue.length;
|
||
|
}
|
||
|
else
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
else if (isBlockNode(node)) {
|
||
|
break;
|
||
|
}
|
||
|
else {
|
||
|
let prev = node.previousSibling;
|
||
|
while (prev && isIgnorable(prev)) {
|
||
|
moveNode = node.parentNode;
|
||
|
moveOffset = domIndex(prev);
|
||
|
prev = prev.previousSibling;
|
||
|
}
|
||
|
if (!prev) {
|
||
|
node = node.parentNode;
|
||
|
if (node == view.dom)
|
||
|
break;
|
||
|
offset = 0;
|
||
|
}
|
||
|
else {
|
||
|
node = prev;
|
||
|
offset = nodeLen(node);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (force)
|
||
|
setSelFocus(view, node, offset);
|
||
|
else if (moveNode)
|
||
|
setSelFocus(view, moveNode, moveOffset);
|
||
|
}
|
||
|
// Make sure the cursor isn't directly before one or more ignored
|
||
|
// nodes.
|
||
|
function skipIgnoredNodesRight(view) {
|
||
|
let sel = view.domSelectionRange();
|
||
|
let node = sel.focusNode, offset = sel.focusOffset;
|
||
|
if (!node)
|
||
|
return;
|
||
|
let len = nodeLen(node);
|
||
|
let moveNode, moveOffset;
|
||
|
for (;;) {
|
||
|
if (offset < len) {
|
||
|
if (node.nodeType != 1)
|
||
|
break;
|
||
|
let after = node.childNodes[offset];
|
||
|
if (isIgnorable(after)) {
|
||
|
moveNode = node;
|
||
|
moveOffset = ++offset;
|
||
|
}
|
||
|
else
|
||
|
break;
|
||
|
}
|
||
|
else if (isBlockNode(node)) {
|
||
|
break;
|
||
|
}
|
||
|
else {
|
||
|
let next = node.nextSibling;
|
||
|
while (next && isIgnorable(next)) {
|
||
|
moveNode = next.parentNode;
|
||
|
moveOffset = domIndex(next) + 1;
|
||
|
next = next.nextSibling;
|
||
|
}
|
||
|
if (!next) {
|
||
|
node = node.parentNode;
|
||
|
if (node == view.dom)
|
||
|
break;
|
||
|
offset = len = 0;
|
||
|
}
|
||
|
else {
|
||
|
node = next;
|
||
|
offset = 0;
|
||
|
len = nodeLen(node);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (moveNode)
|
||
|
setSelFocus(view, moveNode, moveOffset);
|
||
|
}
|
||
|
function isBlockNode(dom) {
|
||
|
let desc = dom.pmViewDesc;
|
||
|
return desc && desc.node && desc.node.isBlock;
|
||
|
}
|
||
|
function setSelFocus(view, node, offset) {
|
||
|
let sel = view.domSelection();
|
||
|
if (selectionCollapsed(sel)) {
|
||
|
let range = document.createRange();
|
||
|
range.setEnd(node, offset);
|
||
|
range.setStart(node, offset);
|
||
|
sel.removeAllRanges();
|
||
|
sel.addRange(range);
|
||
|
}
|
||
|
else if (sel.extend) {
|
||
|
sel.extend(node, offset);
|
||
|
}
|
||
|
view.domObserver.setCurSelection();
|
||
|
let { state } = view;
|
||
|
// If no state update ends up happening, reset the selection.
|
||
|
setTimeout(() => {
|
||
|
if (view.state == state)
|
||
|
selectionToDOM(view);
|
||
|
}, 50);
|
||
|
}
|
||
|
// Check whether vertical selection motion would involve node
|
||
|
// selections. If so, apply it (if not, the result is left to the
|
||
|
// browser)
|
||
|
function selectVertically(view, dir, mods) {
|
||
|
let sel = view.state.selection;
|
||
|
if (sel instanceof TextSelection && !sel.empty || mods.indexOf("s") > -1)
|
||
|
return false;
|
||
|
if (mac && mods.indexOf("m") > -1)
|
||
|
return false;
|
||
|
let { $from, $to } = sel;
|
||
|
if (!$from.parent.inlineContent || view.endOfTextblock(dir < 0 ? "up" : "down")) {
|
||
|
let next = moveSelectionBlock(view.state, dir);
|
||
|
if (next && (next instanceof NodeSelection))
|
||
|
return apply(view, next);
|
||
|
}
|
||
|
if (!$from.parent.inlineContent) {
|
||
|
let side = dir < 0 ? $from : $to;
|
||
|
let beyond = sel instanceof AllSelection ? Selection.near(side, dir) : Selection.findFrom(side, dir);
|
||
|
return beyond ? apply(view, beyond) : false;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
function stopNativeHorizontalDelete(view, dir) {
|
||
|
if (!(view.state.selection instanceof TextSelection))
|
||
|
return true;
|
||
|
let { $head, $anchor, empty } = view.state.selection;
|
||
|
if (!$head.sameParent($anchor))
|
||
|
return true;
|
||
|
if (!empty)
|
||
|
return false;
|
||
|
if (view.endOfTextblock(dir > 0 ? "forward" : "backward"))
|
||
|
return true;
|
||
|
let nextNode = !$head.textOffset && (dir < 0 ? $head.nodeBefore : $head.nodeAfter);
|
||
|
if (nextNode && !nextNode.isText) {
|
||
|
let tr = view.state.tr;
|
||
|
if (dir < 0)
|
||
|
tr.delete($head.pos - nextNode.nodeSize, $head.pos);
|
||
|
else
|
||
|
tr.delete($head.pos, $head.pos + nextNode.nodeSize);
|
||
|
view.dispatch(tr);
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
function switchEditable(view, node, state) {
|
||
|
view.domObserver.stop();
|
||
|
node.contentEditable = state;
|
||
|
view.domObserver.start();
|
||
|
}
|
||
|
// Issue #867 / #1090 / https://bugs.chromium.org/p/chromium/issues/detail?id=903821
|
||
|
// In which Safari (and at some point in the past, Chrome) does really
|
||
|
// wrong things when the down arrow is pressed when the cursor is
|
||
|
// directly at the start of a textblock and has an uneditable node
|
||
|
// after it
|
||
|
function safariDownArrowBug(view) {
|
||
|
if (!safari || view.state.selection.$head.parentOffset > 0)
|
||
|
return false;
|
||
|
let { focusNode, focusOffset } = view.domSelectionRange();
|
||
|
if (focusNode && focusNode.nodeType == 1 && focusOffset == 0 &&
|
||
|
focusNode.firstChild && focusNode.firstChild.contentEditable == "false") {
|
||
|
let child = focusNode.firstChild;
|
||
|
switchEditable(view, child, "true");
|
||
|
setTimeout(() => switchEditable(view, child, "false"), 20);
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
// A backdrop key mapping used to make sure we always suppress keys
|
||
|
// that have a dangerous default effect, even if the commands they are
|
||
|
// bound to return false, and to make sure that cursor-motion keys
|
||
|
// find a cursor (as opposed to a node selection) when pressed. For
|
||
|
// cursor-motion keys, the code in the handlers also takes care of
|
||
|
// block selections.
|
||
|
function getMods(event) {
|
||
|
let result = "";
|
||
|
if (event.ctrlKey)
|
||
|
result += "c";
|
||
|
if (event.metaKey)
|
||
|
result += "m";
|
||
|
if (event.altKey)
|
||
|
result += "a";
|
||
|
if (event.shiftKey)
|
||
|
result += "s";
|
||
|
return result;
|
||
|
}
|
||
|
function captureKeyDown(view, event) {
|
||
|
let code = event.keyCode, mods = getMods(event);
|
||
|
if (code == 8 || (mac && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac
|
||
|
return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodesLeft(view);
|
||
|
}
|
||
|
else if (code == 46 || (mac && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac
|
||
|
return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodesRight(view);
|
||
|
}
|
||
|
else if (code == 13 || code == 27) { // Enter, Esc
|
||
|
return true;
|
||
|
}
|
||
|
else if (code == 37 || (mac && code == 66 && mods == "c")) { // Left arrow, Ctrl-b on Mac
|
||
|
return selectHorizontally(view, -1, mods) || skipIgnoredNodesLeft(view);
|
||
|
}
|
||
|
else if (code == 39 || (mac && code == 70 && mods == "c")) { // Right arrow, Ctrl-f on Mac
|
||
|
return selectHorizontally(view, 1, mods) || skipIgnoredNodesRight(view);
|
||
|
}
|
||
|
else if (code == 38 || (mac && code == 80 && mods == "c")) { // Up arrow, Ctrl-p on Mac
|
||
|
return selectVertically(view, -1, mods) || skipIgnoredNodesLeft(view);
|
||
|
}
|
||
|
else if (code == 40 || (mac && code == 78 && mods == "c")) { // Down arrow, Ctrl-n on Mac
|
||
|
return safariDownArrowBug(view) || selectVertically(view, 1, mods) || skipIgnoredNodesRight(view);
|
||
|
}
|
||
|
else if (mods == (mac ? "m" : "c") &&
|
||
|
(code == 66 || code == 73 || code == 89 || code == 90)) { // Mod-[biyz]
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
function serializeForClipboard(view, slice) {
|
||
|
view.someProp("transformCopied", f => { slice = f(slice, view); });
|
||
|
let context = [], { content, openStart, openEnd } = slice;
|
||
|
while (openStart > 1 && openEnd > 1 && content.childCount == 1 && content.firstChild.childCount == 1) {
|
||
|
openStart--;
|
||
|
openEnd--;
|
||
|
let node = content.firstChild;
|
||
|
context.push(node.type.name, node.attrs != node.type.defaultAttrs ? node.attrs : null);
|
||
|
content = node.content;
|
||
|
}
|
||
|
let serializer = view.someProp("clipboardSerializer") || DOMSerializer.fromSchema(view.state.schema);
|
||
|
let doc = detachedDoc(), wrap = doc.createElement("div");
|
||
|
wrap.appendChild(serializer.serializeFragment(content, { document: doc }));
|
||
|
let firstChild = wrap.firstChild, needsWrap, wrappers = 0;
|
||
|
while (firstChild && firstChild.nodeType == 1 && (needsWrap = wrapMap[firstChild.nodeName.toLowerCase()])) {
|
||
|
for (let i = needsWrap.length - 1; i >= 0; i--) {
|
||
|
let wrapper = doc.createElement(needsWrap[i]);
|
||
|
while (wrap.firstChild)
|
||
|
wrapper.appendChild(wrap.firstChild);
|
||
|
wrap.appendChild(wrapper);
|
||
|
wrappers++;
|
||
|
}
|
||
|
firstChild = wrap.firstChild;
|
||
|
}
|
||
|
if (firstChild && firstChild.nodeType == 1)
|
||
|
firstChild.setAttribute("data-pm-slice", `${openStart} ${openEnd}${wrappers ? ` -${wrappers}` : ""} ${JSON.stringify(context)}`);
|
||
|
let text = view.someProp("clipboardTextSerializer", f => f(slice, view)) ||
|
||
|
slice.content.textBetween(0, slice.content.size, "\n\n");
|
||
|
return { dom: wrap, text };
|
||
|
}
|
||
|
// Read a slice of content from the clipboard (or drop data).
|
||
|
function parseFromClipboard(view, text, html, plainText, $context) {
|
||
|
let inCode = $context.parent.type.spec.code;
|
||
|
let dom, slice;
|
||
|
if (!html && !text)
|
||
|
return null;
|
||
|
let asText = text && (plainText || inCode || !html);
|
||
|
if (asText) {
|
||
|
view.someProp("transformPastedText", f => { text = f(text, inCode || plainText, view); });
|
||
|
if (inCode)
|
||
|
return text ? new Slice(Fragment.from(view.state.schema.text(text.replace(/\r\n?/g, "\n"))), 0, 0) : Slice.empty;
|
||
|
let parsed = view.someProp("clipboardTextParser", f => f(text, $context, plainText, view));
|
||
|
if (parsed) {
|
||
|
slice = parsed;
|
||
|
}
|
||
|
else {
|
||
|
let marks = $context.marks();
|
||
|
let { schema } = view.state, serializer = DOMSerializer.fromSchema(schema);
|
||
|
dom = document.createElement("div");
|
||
|
text.split(/(?:\r\n?|\n)+/).forEach(block => {
|
||
|
let p = dom.appendChild(document.createElement("p"));
|
||
|
if (block)
|
||
|
p.appendChild(serializer.serializeNode(schema.text(block, marks)));
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
view.someProp("transformPastedHTML", f => { html = f(html, view); });
|
||
|
dom = readHTML(html);
|
||
|
if (webkit)
|
||
|
restoreReplacedSpaces(dom);
|
||
|
}
|
||
|
let contextNode = dom && dom.querySelector("[data-pm-slice]");
|
||
|
let sliceData = contextNode && /^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(contextNode.getAttribute("data-pm-slice") || "");
|
||
|
if (sliceData && sliceData[3])
|
||
|
for (let i = +sliceData[3]; i > 0; i--) {
|
||
|
let child = dom.firstChild;
|
||
|
while (child && child.nodeType != 1)
|
||
|
child = child.nextSibling;
|
||
|
if (!child)
|
||
|
break;
|
||
|
dom = child;
|
||
|
}
|
||
|
if (!slice) {
|
||
|
let parser = view.someProp("clipboardParser") || view.someProp("domParser") || DOMParser.fromSchema(view.state.schema);
|
||
|
slice = parser.parseSlice(dom, {
|
||
|
preserveWhitespace: !!(asText || sliceData),
|
||
|
context: $context,
|
||
|
ruleFromNode(dom) {
|
||
|
if (dom.nodeName == "BR" && !dom.nextSibling &&
|
||
|
dom.parentNode && !inlineParents.test(dom.parentNode.nodeName))
|
||
|
return { ignore: true };
|
||
|
return null;
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
if (sliceData) {
|
||
|
slice = addContext(closeSlice(slice, +sliceData[1], +sliceData[2]), sliceData[4]);
|
||
|
}
|
||
|
else { // HTML wasn't created by ProseMirror. Make sure top-level siblings are coherent
|
||
|
slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true);
|
||
|
if (slice.openStart || slice.openEnd) {
|
||
|
let openStart = 0, openEnd = 0;
|
||
|
for (let node = slice.content.firstChild; openStart < slice.openStart && !node.type.spec.isolating; openStart++, node = node.firstChild) { }
|
||
|
for (let node = slice.content.lastChild; openEnd < slice.openEnd && !node.type.spec.isolating; openEnd++, node = node.lastChild) { }
|
||
|
slice = closeSlice(slice, openStart, openEnd);
|
||
|
}
|
||
|
}
|
||
|
view.someProp("transformPasted", f => { slice = f(slice, view); });
|
||
|
return slice;
|
||
|
}
|
||
|
const inlineParents = /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;
|
||
|
// Takes a slice parsed with parseSlice, which means there hasn't been
|
||
|
// any content-expression checking done on the top nodes, tries to
|
||
|
// find a parent node in the current context that might fit the nodes,
|
||
|
// and if successful, rebuilds the slice so that it fits into that parent.
|
||
|
//
|
||
|
// This addresses the problem that Transform.replace expects a
|
||
|
// coherent slice, and will fail to place a set of siblings that don't
|
||
|
// fit anywhere in the schema.
|
||
|
function normalizeSiblings(fragment, $context) {
|
||
|
if (fragment.childCount < 2)
|
||
|
return fragment;
|
||
|
for (let d = $context.depth; d >= 0; d--) {
|
||
|
let parent = $context.node(d);
|
||
|
let match = parent.contentMatchAt($context.index(d));
|
||
|
let lastWrap, result = [];
|
||
|
fragment.forEach(node => {
|
||
|
if (!result)
|
||
|
return;
|
||
|
let wrap = match.findWrapping(node.type), inLast;
|
||
|
if (!wrap)
|
||
|
return result = null;
|
||
|
if (inLast = result.length && lastWrap.length && addToSibling(wrap, lastWrap, node, result[result.length - 1], 0)) {
|
||
|
result[result.length - 1] = inLast;
|
||
|
}
|
||
|
else {
|
||
|
if (result.length)
|
||
|
result[result.length - 1] = closeRight(result[result.length - 1], lastWrap.length);
|
||
|
let wrapped = withWrappers(node, wrap);
|
||
|
result.push(wrapped);
|
||
|
match = match.matchType(wrapped.type);
|
||
|
lastWrap = wrap;
|
||
|
}
|
||
|
});
|
||
|
if (result)
|
||
|
return Fragment.from(result);
|
||
|
}
|
||
|
return fragment;
|
||
|
}
|
||
|
function withWrappers(node, wrap, from = 0) {
|
||
|
for (let i = wrap.length - 1; i >= from; i--)
|
||
|
node = wrap[i].create(null, Fragment.from(node));
|
||
|
return node;
|
||
|
}
|
||
|
// Used to group adjacent nodes wrapped in similar parents by
|
||
|
// normalizeSiblings into the same parent node
|
||
|
function addToSibling(wrap, lastWrap, node, sibling, depth) {
|
||
|
if (depth < wrap.length && depth < lastWrap.length && wrap[depth] == lastWrap[depth]) {
|
||
|
let inner = addToSibling(wrap, lastWrap, node, sibling.lastChild, depth + 1);
|
||
|
if (inner)
|
||
|
return sibling.copy(sibling.content.replaceChild(sibling.childCount - 1, inner));
|
||
|
let match = sibling.contentMatchAt(sibling.childCount);
|
||
|
if (match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1]))
|
||
|
return sibling.copy(sibling.content.append(Fragment.from(withWrappers(node, wrap, depth + 1))));
|
||
|
}
|
||
|
}
|
||
|
function closeRight(node, depth) {
|
||
|
if (depth == 0)
|
||
|
return node;
|
||
|
let fragment = node.content.replaceChild(node.childCount - 1, closeRight(node.lastChild, depth - 1));
|
||
|
let fill = node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true);
|
||
|
return node.copy(fragment.append(fill));
|
||
|
}
|
||
|
function closeRange(fragment, side, from, to, depth, openEnd) {
|
||
|
let node = side < 0 ? fragment.firstChild : fragment.lastChild, inner = node.content;
|
||
|
if (depth < to - 1)
|
||
|
inner = closeRange(inner, side, from, to, depth + 1, openEnd);
|
||
|
if (depth >= from)
|
||
|
inner = side < 0 ? node.contentMatchAt(0).fillBefore(inner, fragment.childCount > 1 || openEnd <= depth).append(inner)
|
||
|
: inner.append(node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true));
|
||
|
return fragment.replaceChild(side < 0 ? 0 : fragment.childCount - 1, node.copy(inner));
|
||
|
}
|
||
|
function closeSlice(slice, openStart, openEnd) {
|
||
|
if (openStart < slice.openStart)
|
||
|
slice = new Slice(closeRange(slice.content, -1, openStart, slice.openStart, 0, slice.openEnd), openStart, slice.openEnd);
|
||
|
if (openEnd < slice.openEnd)
|
||
|
slice = new Slice(closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0), slice.openStart, openEnd);
|
||
|
return slice;
|
||
|
}
|
||
|
// Trick from jQuery -- some elements must be wrapped in other
|
||
|
// elements for innerHTML to work. I.e. if you do `div.innerHTML =
|
||
|
// "<td>..</td>"` the table cells are ignored.
|
||
|
const wrapMap = {
|
||
|
thead: ["table"],
|
||
|
tbody: ["table"],
|
||
|
tfoot: ["table"],
|
||
|
caption: ["table"],
|
||
|
colgroup: ["table"],
|
||
|
col: ["table", "colgroup"],
|
||
|
tr: ["table", "tbody"],
|
||
|
td: ["table", "tbody", "tr"],
|
||
|
th: ["table", "tbody", "tr"]
|
||
|
};
|
||
|
let _detachedDoc = null;
|
||
|
function detachedDoc() {
|
||
|
return _detachedDoc || (_detachedDoc = document.implementation.createHTMLDocument("title"));
|
||
|
}
|
||
|
function readHTML(html) {
|
||
|
let metas = /^(\s*<meta [^>]*>)*/.exec(html);
|
||
|
if (metas)
|
||
|
html = html.slice(metas[0].length);
|
||
|
let elt = detachedDoc().createElement("div");
|
||
|
let firstTag = /<([a-z][^>\s]+)/i.exec(html), wrap;
|
||
|
if (wrap = firstTag && wrapMap[firstTag[1].toLowerCase()])
|
||
|
html = wrap.map(n => "<" + n + ">").join("") + html + wrap.map(n => "</" + n + ">").reverse().join("");
|
||
|
elt.innerHTML = html;
|
||
|
if (wrap)
|
||
|
for (let i = 0; i < wrap.length; i++)
|
||
|
elt = elt.querySelector(wrap[i]) || elt;
|
||
|
return elt;
|
||
|
}
|
||
|
// Webkit browsers do some hard-to-predict replacement of regular
|
||
|
// spaces with non-breaking spaces when putting content on the
|
||
|
// clipboard. This tries to convert such non-breaking spaces (which
|
||
|
// will be wrapped in a plain span on Chrome, a span with class
|
||
|
// Apple-converted-space on Safari) back to regular spaces.
|
||
|
function restoreReplacedSpaces(dom) {
|
||
|
let nodes = dom.querySelectorAll(chrome ? "span:not([class]):not([style])" : "span.Apple-converted-space");
|
||
|
for (let i = 0; i < nodes.length; i++) {
|
||
|
let node = nodes[i];
|
||
|
if (node.childNodes.length == 1 && node.textContent == "\u00a0" && node.parentNode)
|
||
|
node.parentNode.replaceChild(dom.ownerDocument.createTextNode(" "), node);
|
||
|
}
|
||
|
}
|
||
|
function addContext(slice, context) {
|
||
|
if (!slice.size)
|
||
|
return slice;
|
||
|
let schema = slice.content.firstChild.type.schema, array;
|
||
|
try {
|
||
|
array = JSON.parse(context);
|
||
|
}
|
||
|
catch (e) {
|
||
|
return slice;
|
||
|
}
|
||
|
let { content, openStart, openEnd } = slice;
|
||
|
for (let i = array.length - 2; i >= 0; i -= 2) {
|
||
|
let type = schema.nodes[array[i]];
|
||
|
if (!type || type.hasRequiredAttrs())
|
||
|
break;
|
||
|
content = Fragment.from(type.create(array[i + 1], content));
|
||
|
openStart++;
|
||
|
openEnd++;
|
||
|
}
|
||
|
return new Slice(content, openStart, openEnd);
|
||
|
}
|
||
|
|
||
|
// A collection of DOM events that occur within the editor, and callback functions
|
||
|
// to invoke when the event fires.
|
||
|
const handlers = {};
|
||
|
const editHandlers = {};
|
||
|
const passiveHandlers = { touchstart: true, touchmove: true };
|
||
|
class InputState {
|
||
|
constructor() {
|
||
|
this.shiftKey = false;
|
||
|
this.mouseDown = null;
|
||
|
this.lastKeyCode = null;
|
||
|
this.lastKeyCodeTime = 0;
|
||
|
this.lastClick = { time: 0, x: 0, y: 0, type: "" };
|
||
|
this.lastSelectionOrigin = null;
|
||
|
this.lastSelectionTime = 0;
|
||
|
this.lastIOSEnter = 0;
|
||
|
this.lastIOSEnterFallbackTimeout = -1;
|
||
|
this.lastFocus = 0;
|
||
|
this.lastTouch = 0;
|
||
|
this.lastAndroidDelete = 0;
|
||
|
this.composing = false;
|
||
|
this.composingTimeout = -1;
|
||
|
this.compositionNodes = [];
|
||
|
this.compositionEndedAt = -2e8;
|
||
|
this.domChangeCount = 0;
|
||
|
this.eventHandlers = Object.create(null);
|
||
|
this.hideSelectionGuard = null;
|
||
|
}
|
||
|
}
|
||
|
function initInput(view) {
|
||
|
for (let event in handlers) {
|
||
|
let handler = handlers[event];
|
||
|
view.dom.addEventListener(event, view.input.eventHandlers[event] = (event) => {
|
||
|
if (eventBelongsToView(view, event) && !runCustomHandler(view, event) &&
|
||
|
(view.editable || !(event.type in editHandlers)))
|
||
|
handler(view, event);
|
||
|
}, passiveHandlers[event] ? { passive: true } : undefined);
|
||
|
}
|
||
|
// On Safari, for reasons beyond my understanding, adding an input
|
||
|
// event handler makes an issue where the composition vanishes when
|
||
|
// you press enter go away.
|
||
|
if (safari)
|
||
|
view.dom.addEventListener("input", () => null);
|
||
|
ensureListeners(view);
|
||
|
}
|
||
|
function setSelectionOrigin(view, origin) {
|
||
|
view.input.lastSelectionOrigin = origin;
|
||
|
view.input.lastSelectionTime = Date.now();
|
||
|
}
|
||
|
function destroyInput(view) {
|
||
|
view.domObserver.stop();
|
||
|
for (let type in view.input.eventHandlers)
|
||
|
view.dom.removeEventListener(type, view.input.eventHandlers[type]);
|
||
|
clearTimeout(view.input.composingTimeout);
|
||
|
clearTimeout(view.input.lastIOSEnterFallbackTimeout);
|
||
|
}
|
||
|
function ensureListeners(view) {
|
||
|
view.someProp("handleDOMEvents", currentHandlers => {
|
||
|
for (let type in currentHandlers)
|
||
|
if (!view.input.eventHandlers[type])
|
||
|
view.dom.addEventListener(type, view.input.eventHandlers[type] = event => runCustomHandler(view, event));
|
||
|
});
|
||
|
}
|
||
|
function runCustomHandler(view, event) {
|
||
|
return view.someProp("handleDOMEvents", handlers => {
|
||
|
let handler = handlers[event.type];
|
||
|
return handler ? handler(view, event) || event.defaultPrevented : false;
|
||
|
});
|
||
|
}
|
||
|
function eventBelongsToView(view, event) {
|
||
|
if (!event.bubbles)
|
||
|
return true;
|
||
|
if (event.defaultPrevented)
|
||
|
return false;
|
||
|
for (let node = event.target; node != view.dom; node = node.parentNode)
|
||
|
if (!node || node.nodeType == 11 ||
|
||
|
(node.pmViewDesc && node.pmViewDesc.stopEvent(event)))
|
||
|
return false;
|
||
|
return true;
|
||
|
}
|
||
|
function dispatchEvent(view, event) {
|
||
|
if (!runCustomHandler(view, event) && handlers[event.type] &&
|
||
|
(view.editable || !(event.type in editHandlers)))
|
||
|
handlers[event.type](view, event);
|
||
|
}
|
||
|
editHandlers.keydown = (view, _event) => {
|
||
|
let event = _event;
|
||
|
view.input.shiftKey = event.keyCode == 16 || event.shiftKey;
|
||
|
if (inOrNearComposition(view, event))
|
||
|
return;
|
||
|
view.input.lastKeyCode = event.keyCode;
|
||
|
view.input.lastKeyCodeTime = Date.now();
|
||
|
// Suppress enter key events on Chrome Android, because those tend
|
||
|
// to be part of a confused sequence of composition events fired,
|
||
|
// and handling them eagerly tends to corrupt the input.
|
||
|
if (android && chrome && event.keyCode == 13)
|
||
|
return;
|
||
|
if (event.keyCode != 229)
|
||
|
view.domObserver.forceFlush();
|
||
|
// On iOS, if we preventDefault enter key presses, the virtual
|
||
|
// keyboard gets confused. So the hack here is to set a flag that
|
||
|
// makes the DOM change code recognize that what just happens should
|
||
|
// be replaced by whatever the Enter key handlers do.
|
||
|
if (ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||
|
let now = Date.now();
|
||
|
view.input.lastIOSEnter = now;
|
||
|
view.input.lastIOSEnterFallbackTimeout = setTimeout(() => {
|
||
|
if (view.input.lastIOSEnter == now) {
|
||
|
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")));
|
||
|
view.input.lastIOSEnter = 0;
|
||
|
}
|
||
|
}, 200);
|
||
|
}
|
||
|
else if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) {
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
else {
|
||
|
setSelectionOrigin(view, "key");
|
||
|
}
|
||
|
};
|
||
|
editHandlers.keyup = (view, event) => {
|
||
|
if (event.keyCode == 16)
|
||
|
view.input.shiftKey = false;
|
||
|
};
|
||
|
editHandlers.keypress = (view, _event) => {
|
||
|
let event = _event;
|
||
|
if (inOrNearComposition(view, event) || !event.charCode ||
|
||
|
event.ctrlKey && !event.altKey || mac && event.metaKey)
|
||
|
return;
|
||
|
if (view.someProp("handleKeyPress", f => f(view, event))) {
|
||
|
event.preventDefault();
|
||
|
return;
|
||
|
}
|
||
|
let sel = view.state.selection;
|
||
|
if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) {
|
||
|
let text = String.fromCharCode(event.charCode);
|
||
|
if (!view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text)))
|
||
|
view.dispatch(view.state.tr.insertText(text).scrollIntoView());
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
};
|
||
|
function eventCoords(event) { return { left: event.clientX, top: event.clientY }; }
|
||
|
function isNear(event, click) {
|
||
|
let dx = click.x - event.clientX, dy = click.y - event.clientY;
|
||
|
return dx * dx + dy * dy < 100;
|
||
|
}
|
||
|
function runHandlerOnContext(view, propName, pos, inside, event) {
|
||
|
if (inside == -1)
|
||
|
return false;
|
||
|
let $pos = view.state.doc.resolve(inside);
|
||
|
for (let i = $pos.depth + 1; i > 0; i--) {
|
||
|
if (view.someProp(propName, f => i > $pos.depth ? f(view, pos, $pos.nodeAfter, $pos.before(i), event, true)
|
||
|
: f(view, pos, $pos.node(i), $pos.before(i), event, false)))
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
function updateSelection(view, selection, origin) {
|
||
|
if (!view.focused)
|
||
|
view.focus();
|
||
|
let tr = view.state.tr.setSelection(selection);
|
||
|
if (origin == "pointer")
|
||
|
tr.setMeta("pointer", true);
|
||
|
view.dispatch(tr);
|
||
|
}
|
||
|
function selectClickedLeaf(view, inside) {
|
||
|
if (inside == -1)
|
||
|
return false;
|
||
|
let $pos = view.state.doc.resolve(inside), node = $pos.nodeAfter;
|
||
|
if (node && node.isAtom && NodeSelection.isSelectable(node)) {
|
||
|
updateSelection(view, new NodeSelection($pos), "pointer");
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
function selectClickedNode(view, inside) {
|
||
|
if (inside == -1)
|
||
|
return false;
|
||
|
let sel = view.state.selection, selectedNode, selectAt;
|
||
|
if (sel instanceof NodeSelection)
|
||
|
selectedNode = sel.node;
|
||
|
let $pos = view.state.doc.resolve(inside);
|
||
|
for (let i = $pos.depth + 1; i > 0; i--) {
|
||
|
let node = i > $pos.depth ? $pos.nodeAfter : $pos.node(i);
|
||
|
if (NodeSelection.isSelectable(node)) {
|
||
|
if (selectedNode && sel.$from.depth > 0 &&
|
||
|
i >= sel.$from.depth && $pos.before(sel.$from.depth + 1) == sel.$from.pos)
|
||
|
selectAt = $pos.before(sel.$from.depth);
|
||
|
else
|
||
|
selectAt = $pos.before(i);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
if (selectAt != null) {
|
||
|
updateSelection(view, NodeSelection.create(view.state.doc, selectAt), "pointer");
|
||
|
return true;
|
||
|
}
|
||
|
else {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
function handleSingleClick(view, pos, inside, event, selectNode) {
|
||
|
return runHandlerOnContext(view, "handleClickOn", pos, inside, event) ||
|
||
|
view.someProp("handleClick", f => f(view, pos, event)) ||
|
||
|
(selectNode ? selectClickedNode(view, inside) : selectClickedLeaf(view, inside));
|
||
|
}
|
||
|
function handleDoubleClick(view, pos, inside, event) {
|
||
|
return runHandlerOnContext(view, "handleDoubleClickOn", pos, inside, event) ||
|
||
|
view.someProp("handleDoubleClick", f => f(view, pos, event));
|
||
|
}
|
||
|
function handleTripleClick(view, pos, inside, event) {
|
||
|
return runHandlerOnContext(view, "handleTripleClickOn", pos, inside, event) ||
|
||
|
view.someProp("handleTripleClick", f => f(view, pos, event)) ||
|
||
|
defaultTripleClick(view, inside, event);
|
||
|
}
|
||
|
function defaultTripleClick(view, inside, event) {
|
||
|
if (event.button != 0)
|
||
|
return false;
|
||
|
let doc = view.state.doc;
|
||
|
if (inside == -1) {
|
||
|
if (doc.inlineContent) {
|
||
|
updateSelection(view, TextSelection.create(doc, 0, doc.content.size), "pointer");
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
let $pos = doc.resolve(inside);
|
||
|
for (let i = $pos.depth + 1; i > 0; i--) {
|
||
|
let node = i > $pos.depth ? $pos.nodeAfter : $pos.node(i);
|
||
|
let nodePos = $pos.before(i);
|
||
|
if (node.inlineContent)
|
||
|
updateSelection(view, TextSelection.create(doc, nodePos + 1, nodePos + 1 + node.content.size), "pointer");
|
||
|
else if (NodeSelection.isSelectable(node))
|
||
|
updateSelection(view, NodeSelection.create(doc, nodePos), "pointer");
|
||
|
else
|
||
|
continue;
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
function forceDOMFlush(view) {
|
||
|
return endComposition(view);
|
||
|
}
|
||
|
const selectNodeModifier = mac ? "metaKey" : "ctrlKey";
|
||
|
handlers.mousedown = (view, _event) => {
|
||
|
let event = _event;
|
||
|
view.input.shiftKey = event.shiftKey;
|
||
|
let flushed = forceDOMFlush(view);
|
||
|
let now = Date.now(), type = "singleClick";
|
||
|
if (now - view.input.lastClick.time < 500 && isNear(event, view.input.lastClick) && !event[selectNodeModifier]) {
|
||
|
if (view.input.lastClick.type == "singleClick")
|
||
|
type = "doubleClick";
|
||
|
else if (view.input.lastClick.type == "doubleClick")
|
||
|
type = "tripleClick";
|
||
|
}
|
||
|
view.input.lastClick = { time: now, x: event.clientX, y: event.clientY, type };
|
||
|
let pos = view.posAtCoords(eventCoords(event));
|
||
|
if (!pos)
|
||
|
return;
|
||
|
if (type == "singleClick") {
|
||
|
if (view.input.mouseDown)
|
||
|
view.input.mouseDown.done();
|
||
|
view.input.mouseDown = new MouseDown(view, pos, event, !!flushed);
|
||
|
}
|
||
|
else if ((type == "doubleClick" ? handleDoubleClick : handleTripleClick)(view, pos.pos, pos.inside, event)) {
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
else {
|
||
|
setSelectionOrigin(view, "pointer");
|
||
|
}
|
||
|
};
|
||
|
class MouseDown {
|
||
|
constructor(view, pos, event, flushed) {
|
||
|
this.view = view;
|
||
|
this.pos = pos;
|
||
|
this.event = event;
|
||
|
this.flushed = flushed;
|
||
|
this.delayedSelectionSync = false;
|
||
|
this.mightDrag = null;
|
||
|
this.startDoc = view.state.doc;
|
||
|
this.selectNode = !!event[selectNodeModifier];
|
||
|
this.allowDefault = event.shiftKey;
|
||
|
let targetNode, targetPos;
|
||
|
if (pos.inside > -1) {
|
||
|
targetNode = view.state.doc.nodeAt(pos.inside);
|
||
|
targetPos = pos.inside;
|
||
|
}
|
||
|
else {
|
||
|
let $pos = view.state.doc.resolve(pos.pos);
|
||
|
targetNode = $pos.parent;
|
||
|
targetPos = $pos.depth ? $pos.before() : 0;
|
||
|
}
|
||
|
const target = flushed ? null : event.target;
|
||
|
const targetDesc = target ? view.docView.nearestDesc(target, true) : null;
|
||
|
this.target = targetDesc ? targetDesc.dom : null;
|
||
|
let { selection } = view.state;
|
||
|
if (event.button == 0 &&
|
||
|
targetNode.type.spec.draggable && targetNode.type.spec.selectable !== false ||
|
||
|
selection instanceof NodeSelection && selection.from <= targetPos && selection.to > targetPos)
|
||
|
this.mightDrag = {
|
||
|
node: targetNode,
|
||
|
pos: targetPos,
|
||
|
addAttr: !!(this.target && !this.target.draggable),
|
||
|
setUneditable: !!(this.target && gecko && !this.target.hasAttribute("contentEditable"))
|
||
|
};
|
||
|
if (this.target && this.mightDrag && (this.mightDrag.addAttr || this.mightDrag.setUneditable)) {
|
||
|
this.view.domObserver.stop();
|
||
|
if (this.mightDrag.addAttr)
|
||
|
this.target.draggable = true;
|
||
|
if (this.mightDrag.setUneditable)
|
||
|
setTimeout(() => {
|
||
|
if (this.view.input.mouseDown == this)
|
||
|
this.target.setAttribute("contentEditable", "false");
|
||
|
}, 20);
|
||
|
this.view.domObserver.start();
|
||
|
}
|
||
|
view.root.addEventListener("mouseup", this.up = this.up.bind(this));
|
||
|
view.root.addEventListener("mousemove", this.move = this.move.bind(this));
|
||
|
setSelectionOrigin(view, "pointer");
|
||
|
}
|
||
|
done() {
|
||
|
this.view.root.removeEventListener("mouseup", this.up);
|
||
|
this.view.root.removeEventListener("mousemove", this.move);
|
||
|
if (this.mightDrag && this.target) {
|
||
|
this.view.domObserver.stop();
|
||
|
if (this.mightDrag.addAttr)
|
||
|
this.target.removeAttribute("draggable");
|
||
|
if (this.mightDrag.setUneditable)
|
||
|
this.target.removeAttribute("contentEditable");
|
||
|
this.view.domObserver.start();
|
||
|
}
|
||
|
if (this.delayedSelectionSync)
|
||
|
setTimeout(() => selectionToDOM(this.view));
|
||
|
this.view.input.mouseDown = null;
|
||
|
}
|
||
|
up(event) {
|
||
|
this.done();
|
||
|
if (!this.view.dom.contains(event.target))
|
||
|
return;
|
||
|
let pos = this.pos;
|
||
|
if (this.view.state.doc != this.startDoc)
|
||
|
pos = this.view.posAtCoords(eventCoords(event));
|
||
|
this.updateAllowDefault(event);
|
||
|
if (this.allowDefault || !pos) {
|
||
|
setSelectionOrigin(this.view, "pointer");
|
||
|
}
|
||
|
else if (handleSingleClick(this.view, pos.pos, pos.inside, event, this.selectNode)) {
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
else if (event.button == 0 &&
|
||
|
(this.flushed ||
|
||
|
// Safari ignores clicks on draggable elements
|
||
|
(safari && this.mightDrag && !this.mightDrag.node.isAtom) ||
|
||
|
// Chrome will sometimes treat a node selection as a
|
||
|
// cursor, but still report that the node is selected
|
||
|
// when asked through getSelection. You'll then get a
|
||
|
// situation where clicking at the point where that
|
||
|
// (hidden) cursor is doesn't change the selection, and
|
||
|
// thus doesn't get a reaction from ProseMirror. This
|
||
|
// works around that.
|
||
|
(chrome && !this.view.state.selection.visible &&
|
||
|
Math.min(Math.abs(pos.pos - this.view.state.selection.from), Math.abs(pos.pos - this.view.state.selection.to)) <= 2))) {
|
||
|
updateSelection(this.view, Selection.near(this.view.state.doc.resolve(pos.pos)), "pointer");
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
else {
|
||
|
setSelectionOrigin(this.view, "pointer");
|
||
|
}
|
||
|
}
|
||
|
move(event) {
|
||
|
this.updateAllowDefault(event);
|
||
|
setSelectionOrigin(this.view, "pointer");
|
||
|
if (event.buttons == 0)
|
||
|
this.done();
|
||
|
}
|
||
|
updateAllowDefault(event) {
|
||
|
if (!this.allowDefault && (Math.abs(this.event.x - event.clientX) > 4 ||
|
||
|
Math.abs(this.event.y - event.clientY) > 4))
|
||
|
this.allowDefault = true;
|
||
|
}
|
||
|
}
|
||
|
handlers.touchstart = view => {
|
||
|
view.input.lastTouch = Date.now();
|
||
|
forceDOMFlush(view);
|
||
|
setSelectionOrigin(view, "pointer");
|
||
|
};
|
||
|
handlers.touchmove = view => {
|
||
|
view.input.lastTouch = Date.now();
|
||
|
setSelectionOrigin(view, "pointer");
|
||
|
};
|
||
|
handlers.contextmenu = view => forceDOMFlush(view);
|
||
|
function inOrNearComposition(view, event) {
|
||
|
if (view.composing)
|
||
|
return true;
|
||
|
// See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/.
|
||
|
// On Japanese input method editors (IMEs), the Enter key is used to confirm character
|
||
|
// selection. On Safari, when Enter is pressed, compositionend and keydown events are
|
||
|
// emitted. The keydown event triggers newline insertion, which we don't want.
|
||
|
// This method returns true if the keydown event should be ignored.
|
||
|
// We only ignore it once, as pressing Enter a second time *should* insert a newline.
|
||
|
// Furthermore, the keydown event timestamp must be close to the compositionEndedAt timestamp.
|
||
|
// This guards against the case where compositionend is triggered without the keyboard
|
||
|
// (e.g. character confirmation may be done with the mouse), and keydown is triggered
|
||
|
// afterwards- we wouldn't want to ignore the keydown event in this case.
|
||
|
if (safari && Math.abs(event.timeStamp - view.input.compositionEndedAt) < 500) {
|
||
|
view.input.compositionEndedAt = -2e8;
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
// Drop active composition after 5 seconds of inactivity on Android
|
||
|
const timeoutComposition = android ? 5000 : -1;
|
||
|
editHandlers.compositionstart = editHandlers.compositionupdate = view => {
|
||
|
if (!view.composing) {
|
||
|
view.domObserver.flush();
|
||
|
let { state } = view, $pos = state.selection.$from;
|
||
|
if (state.selection.empty &&
|
||
|
(state.storedMarks ||
|
||
|
(!$pos.textOffset && $pos.parentOffset && $pos.nodeBefore.marks.some(m => m.type.spec.inclusive === false)))) {
|
||
|
// Need to wrap the cursor in mark nodes different from the ones in the DOM context
|
||
|
view.markCursor = view.state.storedMarks || $pos.marks();
|
||
|
endComposition(view, true);
|
||
|
view.markCursor = null;
|
||
|
}
|
||
|
else {
|
||
|
endComposition(view);
|
||
|
// In firefox, if the cursor is after but outside a marked node,
|
||
|
// the inserted text won't inherit the marks. So this moves it
|
||
|
// inside if necessary.
|
||
|
if (gecko && state.selection.empty && $pos.parentOffset && !$pos.textOffset && $pos.nodeBefore.marks.length) {
|
||
|
let sel = view.domSelectionRange();
|
||
|
for (let node = sel.focusNode, offset = sel.focusOffset; node && node.nodeType == 1 && offset != 0;) {
|
||
|
let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1];
|
||
|
if (!before)
|
||
|
break;
|
||
|
if (before.nodeType == 3) {
|
||
|
view.domSelection().collapse(before, before.nodeValue.length);
|
||
|
break;
|
||
|
}
|
||
|
else {
|
||
|
node = before;
|
||
|
offset = -1;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
view.input.composing = true;
|
||
|
}
|
||
|
scheduleComposeEnd(view, timeoutComposition);
|
||
|
};
|
||
|
editHandlers.compositionend = (view, event) => {
|
||
|
if (view.composing) {
|
||
|
view.input.composing = false;
|
||
|
view.input.compositionEndedAt = event.timeStamp;
|
||
|
scheduleComposeEnd(view, 20);
|
||
|
}
|
||
|
};
|
||
|
function scheduleComposeEnd(view, delay) {
|
||
|
clearTimeout(view.input.composingTimeout);
|
||
|
if (delay > -1)
|
||
|
view.input.composingTimeout = setTimeout(() => endComposition(view), delay);
|
||
|
}
|
||
|
function clearComposition(view) {
|
||
|
if (view.composing) {
|
||
|
view.input.composing = false;
|
||
|
view.input.compositionEndedAt = timestampFromCustomEvent();
|
||
|
}
|
||
|
while (view.input.compositionNodes.length > 0)
|
||
|
view.input.compositionNodes.pop().markParentsDirty();
|
||
|
}
|
||
|
function timestampFromCustomEvent() {
|
||
|
let event = document.createEvent("Event");
|
||
|
event.initEvent("event", true, true);
|
||
|
return event.timeStamp;
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
function endComposition(view, forceUpdate = false) {
|
||
|
if (android && view.domObserver.flushingSoon >= 0)
|
||
|
return;
|
||
|
view.domObserver.forceFlush();
|
||
|
clearComposition(view);
|
||
|
if (forceUpdate || view.docView && view.docView.dirty) {
|
||
|
let sel = selectionFromDOM(view);
|
||
|
if (sel && !sel.eq(view.state.selection))
|
||
|
view.dispatch(view.state.tr.setSelection(sel));
|
||
|
else
|
||
|
view.updateState(view.state);
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
function captureCopy(view, dom) {
|
||
|
// The extra wrapper is somehow necessary on IE/Edge to prevent the
|
||
|
// content from being mangled when it is put onto the clipboard
|
||
|
if (!view.dom.parentNode)
|
||
|
return;
|
||
|
let wrap = view.dom.parentNode.appendChild(document.createElement("div"));
|
||
|
wrap.appendChild(dom);
|
||
|
wrap.style.cssText = "position: fixed; left: -10000px; top: 10px";
|
||
|
let sel = getSelection(), range = document.createRange();
|
||
|
range.selectNodeContents(dom);
|
||
|
// Done because IE will fire a selectionchange moving the selection
|
||
|
// to its start when removeAllRanges is called and the editor still
|
||
|
// has focus (which will mess up the editor's selection state).
|
||
|
view.dom.blur();
|
||
|
sel.removeAllRanges();
|
||
|
sel.addRange(range);
|
||
|
setTimeout(() => {
|
||
|
if (wrap.parentNode)
|
||
|
wrap.parentNode.removeChild(wrap);
|
||
|
view.focus();
|
||
|
}, 50);
|
||
|
}
|
||
|
// This is very crude, but unfortunately both these browsers _pretend_
|
||
|
// that they have a clipboard API—all the objects and methods are
|
||
|
// there, they just don't work, and they are hard to test.
|
||
|
const brokenClipboardAPI = (ie && ie_version < 15) ||
|
||
|
(ios && webkit_version < 604);
|
||
|
handlers.copy = editHandlers.cut = (view, _event) => {
|
||
|
let event = _event;
|
||
|
let sel = view.state.selection, cut = event.type == "cut";
|
||
|
if (sel.empty)
|
||
|
return;
|
||
|
// IE and Edge's clipboard interface is completely broken
|
||
|
let data = brokenClipboardAPI ? null : event.clipboardData;
|
||
|
let slice = sel.content(), { dom, text } = serializeForClipboard(view, slice);
|
||
|
if (data) {
|
||
|
event.preventDefault();
|
||
|
data.clearData();
|
||
|
data.setData("text/html", dom.innerHTML);
|
||
|
data.setData("text/plain", text);
|
||
|
}
|
||
|
else {
|
||
|
captureCopy(view, dom);
|
||
|
}
|
||
|
if (cut)
|
||
|
view.dispatch(view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut"));
|
||
|
};
|
||
|
function sliceSingleNode(slice) {
|
||
|
return slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 ? slice.content.firstChild : null;
|
||
|
}
|
||
|
function capturePaste(view, event) {
|
||
|
if (!view.dom.parentNode)
|
||
|
return;
|
||
|
let plainText = view.input.shiftKey || view.state.selection.$from.parent.type.spec.code;
|
||
|
let target = view.dom.parentNode.appendChild(document.createElement(plainText ? "textarea" : "div"));
|
||
|
if (!plainText)
|
||
|
target.contentEditable = "true";
|
||
|
target.style.cssText = "position: fixed; left: -10000px; top: 10px";
|
||
|
target.focus();
|
||
|
setTimeout(() => {
|
||
|
view.focus();
|
||
|
if (target.parentNode)
|
||
|
target.parentNode.removeChild(target);
|
||
|
if (plainText)
|
||
|
doPaste(view, target.value, null, event);
|
||
|
else
|
||
|
doPaste(view, target.textContent, target.innerHTML, event);
|
||
|
}, 50);
|
||
|
}
|
||
|
function doPaste(view, text, html, event) {
|
||
|
let slice = parseFromClipboard(view, text, html, view.input.shiftKey, view.state.selection.$from);
|
||
|
if (view.someProp("handlePaste", f => f(view, event, slice || Slice.empty)))
|
||
|
return true;
|
||
|
if (!slice)
|
||
|
return false;
|
||
|
let singleNode = sliceSingleNode(slice);
|
||
|
let tr = singleNode
|
||
|
? view.state.tr.replaceSelectionWith(singleNode, view.input.shiftKey)
|
||
|
: view.state.tr.replaceSelection(slice);
|
||
|
view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"));
|
||
|
return true;
|
||
|
}
|
||
|
editHandlers.paste = (view, _event) => {
|
||
|
let event = _event;
|
||
|
// Handling paste from JavaScript during composition is very poorly
|
||
|
// handled by browsers, so as a dodgy but preferable kludge, we just
|
||
|
// let the browser do its native thing there, except on Android,
|
||
|
// where the editor is almost always composing.
|
||
|
if (view.composing && !android)
|
||
|
return;
|
||
|
let data = brokenClipboardAPI ? null : event.clipboardData;
|
||
|
if (data && doPaste(view, data.getData("text/plain"), data.getData("text/html"), event))
|
||
|
event.preventDefault();
|
||
|
else
|
||
|
capturePaste(view, event);
|
||
|
};
|
||
|
class Dragging {
|
||
|
constructor(slice, move) {
|
||
|
this.slice = slice;
|
||
|
this.move = move;
|
||
|
}
|
||
|
}
|
||
|
const dragCopyModifier = mac ? "altKey" : "ctrlKey";
|
||
|
handlers.dragstart = (view, _event) => {
|
||
|
let event = _event;
|
||
|
let mouseDown = view.input.mouseDown;
|
||
|
if (mouseDown)
|
||
|
mouseDown.done();
|
||
|
if (!event.dataTransfer)
|
||
|
return;
|
||
|
let sel = view.state.selection;
|
||
|
let pos = sel.empty ? null : view.posAtCoords(eventCoords(event));
|
||
|
if (pos && pos.pos >= sel.from && pos.pos <= (sel instanceof NodeSelection ? sel.to - 1 : sel.to)) ;
|
||
|
else if (mouseDown && mouseDown.mightDrag) {
|
||
|
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, mouseDown.mightDrag.pos)));
|
||
|
}
|
||
|
else if (event.target && event.target.nodeType == 1) {
|
||
|
let desc = view.docView.nearestDesc(event.target, true);
|
||
|
if (desc && desc.node.type.spec.draggable && desc != view.docView)
|
||
|
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, desc.posBefore)));
|
||
|
}
|
||
|
let slice = view.state.selection.content(), { dom, text } = serializeForClipboard(view, slice);
|
||
|
event.dataTransfer.clearData();
|
||
|
event.dataTransfer.setData(brokenClipboardAPI ? "Text" : "text/html", dom.innerHTML);
|
||
|
// See https://github.com/ProseMirror/prosemirror/issues/1156
|
||
|
event.dataTransfer.effectAllowed = "copyMove";
|
||
|
if (!brokenClipboardAPI)
|
||
|
event.dataTransfer.setData("text/plain", text);
|
||
|
view.dragging = new Dragging(slice, !event[dragCopyModifier]);
|
||
|
};
|
||
|
handlers.dragend = view => {
|
||
|
let dragging = view.dragging;
|
||
|
window.setTimeout(() => {
|
||
|
if (view.dragging == dragging)
|
||
|
view.dragging = null;
|
||
|
}, 50);
|
||
|
};
|
||
|
editHandlers.dragover = editHandlers.dragenter = (_, e) => e.preventDefault();
|
||
|
editHandlers.drop = (view, _event) => {
|
||
|
let event = _event;
|
||
|
let dragging = view.dragging;
|
||
|
view.dragging = null;
|
||
|
if (!event.dataTransfer)
|
||
|
return;
|
||
|
let eventPos = view.posAtCoords(eventCoords(event));
|
||
|
if (!eventPos)
|
||
|
return;
|
||
|
let $mouse = view.state.doc.resolve(eventPos.pos);
|
||
|
let slice = dragging && dragging.slice;
|
||
|
if (slice) {
|
||
|
view.someProp("transformPasted", f => { slice = f(slice, view); });
|
||
|
}
|
||
|
else {
|
||
|
slice = parseFromClipboard(view, event.dataTransfer.getData(brokenClipboardAPI ? "Text" : "text/plain"), brokenClipboardAPI ? null : event.dataTransfer.getData("text/html"), false, $mouse);
|
||
|
}
|
||
|
let move = !!(dragging && !event[dragCopyModifier]);
|
||
|
if (view.someProp("handleDrop", f => f(view, event, slice || Slice.empty, move))) {
|
||
|
event.preventDefault();
|
||
|
return;
|
||
|
}
|
||
|
if (!slice)
|
||
|
return;
|
||
|
event.preventDefault();
|
||
|
let insertPos = slice ? dropPoint(view.state.doc, $mouse.pos, slice) : $mouse.pos;
|
||
|
if (insertPos == null)
|
||
|
insertPos = $mouse.pos;
|
||
|
let tr = view.state.tr;
|
||
|
if (move)
|
||
|
tr.deleteSelection();
|
||
|
let pos = tr.mapping.map(insertPos);
|
||
|
let isNode = slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1;
|
||
|
let beforeInsert = tr.doc;
|
||
|
if (isNode)
|
||
|
tr.replaceRangeWith(pos, pos, slice.content.firstChild);
|
||
|
else
|
||
|
tr.replaceRange(pos, pos, slice);
|
||
|
if (tr.doc.eq(beforeInsert))
|
||
|
return;
|
||
|
let $pos = tr.doc.resolve(pos);
|
||
|
if (isNode && NodeSelection.isSelectable(slice.content.firstChild) &&
|
||
|
$pos.nodeAfter && $pos.nodeAfter.sameMarkup(slice.content.firstChild)) {
|
||
|
tr.setSelection(new NodeSelection($pos));
|
||
|
}
|
||
|
else {
|
||
|
let end = tr.mapping.map(insertPos);
|
||
|
tr.mapping.maps[tr.mapping.maps.length - 1].forEach((_from, _to, _newFrom, newTo) => end = newTo);
|
||
|
tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end)));
|
||
|
}
|
||
|
view.focus();
|
||
|
view.dispatch(tr.setMeta("uiEvent", "drop"));
|
||
|
};
|
||
|
handlers.focus = view => {
|
||
|
view.input.lastFocus = Date.now();
|
||
|
if (!view.focused) {
|
||
|
view.domObserver.stop();
|
||
|
view.dom.classList.add("ProseMirror-focused");
|
||
|
view.domObserver.start();
|
||
|
view.focused = true;
|
||
|
setTimeout(() => {
|
||
|
if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelectionRange()))
|
||
|
selectionToDOM(view);
|
||
|
}, 20);
|
||
|
}
|
||
|
};
|
||
|
handlers.blur = (view, _event) => {
|
||
|
let event = _event;
|
||
|
if (view.focused) {
|
||
|
view.domObserver.stop();
|
||
|
view.dom.classList.remove("ProseMirror-focused");
|
||
|
view.domObserver.start();
|
||
|
if (event.relatedTarget && view.dom.contains(event.relatedTarget))
|
||
|
view.domObserver.currentSelection.clear();
|
||
|
view.focused = false;
|
||
|
}
|
||
|
};
|
||
|
handlers.beforeinput = (view, _event) => {
|
||
|
let event = _event;
|
||
|
// We should probably do more with beforeinput events, but support
|
||
|
// is so spotty that I'm still waiting to see where they are going.
|
||
|
// Very specific hack to deal with backspace sometimes failing on
|
||
|
// Chrome Android when after an uneditable node.
|
||
|
if (chrome && android && event.inputType == "deleteContentBackward") {
|
||
|
view.domObserver.flushSoon();
|
||
|
let { domChangeCount } = view.input;
|
||
|
setTimeout(() => {
|
||
|
if (view.input.domChangeCount != domChangeCount)
|
||
|
return; // Event already had some effect
|
||
|
// This bug tends to close the virtual keyboard, so we refocus
|
||
|
view.dom.blur();
|
||
|
view.focus();
|
||
|
if (view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace"))))
|
||
|
return;
|
||
|
let { $cursor } = view.state.selection;
|
||
|
// Crude approximation of backspace behavior when no command handled it
|
||
|
if ($cursor && $cursor.pos > 0)
|
||
|
view.dispatch(view.state.tr.delete($cursor.pos - 1, $cursor.pos).scrollIntoView());
|
||
|
}, 50);
|
||
|
}
|
||
|
};
|
||
|
// Make sure all handlers get registered
|
||
|
for (let prop in editHandlers)
|
||
|
handlers[prop] = editHandlers[prop];
|
||
|
|
||
|
function compareObjs(a, b) {
|
||
|
if (a == b)
|
||
|
return true;
|
||
|
for (let p in a)
|
||
|
if (a[p] !== b[p])
|
||
|
return false;
|
||
|
for (let p in b)
|
||
|
if (!(p in a))
|
||
|
return false;
|
||
|
return true;
|
||
|
}
|
||
|
class WidgetType {
|
||
|
constructor(toDOM, spec) {
|
||
|
this.toDOM = toDOM;
|
||
|
this.spec = spec || noSpec;
|
||
|
this.side = this.spec.side || 0;
|
||
|
}
|
||
|
map(mapping, span, offset, oldOffset) {
|
||
|
let { pos, deleted } = mapping.mapResult(span.from + oldOffset, this.side < 0 ? -1 : 1);
|
||
|
return deleted ? null : new Decoration(pos - offset, pos - offset, this);
|
||
|
}
|
||
|
valid() { return true; }
|
||
|
eq(other) {
|
||
|
return this == other ||
|
||
|
(other instanceof WidgetType &&
|
||
|
(this.spec.key && this.spec.key == other.spec.key ||
|
||
|
this.toDOM == other.toDOM && compareObjs(this.spec, other.spec)));
|
||
|
}
|
||
|
destroy(node) {
|
||
|
if (this.spec.destroy)
|
||
|
this.spec.destroy(node);
|
||
|
}
|
||
|
}
|
||
|
class InlineType {
|
||
|
constructor(attrs, spec) {
|
||
|
this.attrs = attrs;
|
||
|
this.spec = spec || noSpec;
|
||
|
}
|
||
|
map(mapping, span, offset, oldOffset) {
|
||
|
let from = mapping.map(span.from + oldOffset, this.spec.inclusiveStart ? -1 : 1) - offset;
|
||
|
let to = mapping.map(span.to + oldOffset, this.spec.inclusiveEnd ? 1 : -1) - offset;
|
||
|
return from >= to ? null : new Decoration(from, to, this);
|
||
|
}
|
||
|
valid(_, span) { return span.from < span.to; }
|
||
|
eq(other) {
|
||
|
return this == other ||
|
||
|
(other instanceof InlineType && compareObjs(this.attrs, other.attrs) &&
|
||
|
compareObjs(this.spec, other.spec));
|
||
|
}
|
||
|
static is(span) { return span.type instanceof InlineType; }
|
||
|
destroy() { }
|
||
|
}
|
||
|
class NodeType {
|
||
|
constructor(attrs, spec) {
|
||
|
this.attrs = attrs;
|
||
|
this.spec = spec || noSpec;
|
||
|
}
|
||
|
map(mapping, span, offset, oldOffset) {
|
||
|
let from = mapping.mapResult(span.from + oldOffset, 1);
|
||
|
if (from.deleted)
|
||
|
return null;
|
||
|
let to = mapping.mapResult(span.to + oldOffset, -1);
|
||
|
if (to.deleted || to.pos <= from.pos)
|
||
|
return null;
|
||
|
return new Decoration(from.pos - offset, to.pos - offset, this);
|
||
|
}
|
||
|
valid(node, span) {
|
||
|
let { index, offset } = node.content.findIndex(span.from), child;
|
||
|
return offset == span.from && !(child = node.child(index)).isText && offset + child.nodeSize == span.to;
|
||
|
}
|
||
|
eq(other) {
|
||
|
return this == other ||
|
||
|
(other instanceof NodeType && compareObjs(this.attrs, other.attrs) &&
|
||
|
compareObjs(this.spec, other.spec));
|
||
|
}
|
||
|
destroy() { }
|
||
|
}
|
||
|
/**
|
||
|
Decoration objects can be provided to the view through the
|
||
|
[`decorations` prop](https://prosemirror.net/docs/ref/#view.EditorProps.decorations). They come in
|
||
|
several variants—see the static members of this class for details.
|
||
|
*/
|
||
|
class Decoration {
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
constructor(
|
||
|
/**
|
||
|
The start position of the decoration.
|
||
|
*/
|
||
|
from,
|
||
|
/**
|
||
|
The end position. Will be the same as `from` for [widget
|
||
|
decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget).
|
||
|
*/
|
||
|
to,
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
type) {
|
||
|
this.from = from;
|
||
|
this.to = to;
|
||
|
this.type = type;
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
copy(from, to) {
|
||
|
return new Decoration(from, to, this.type);
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
eq(other, offset = 0) {
|
||
|
return this.type.eq(other.type) && this.from + offset == other.from && this.to + offset == other.to;
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
map(mapping, offset, oldOffset) {
|
||
|
return this.type.map(mapping, this, offset, oldOffset);
|
||
|
}
|
||
|
/**
|
||
|
Creates a widget decoration, which is a DOM node that's shown in
|
||
|
the document at the given position. It is recommended that you
|
||
|
delay rendering the widget by passing a function that will be
|
||
|
called when the widget is actually drawn in a view, but you can
|
||
|
also directly pass a DOM node. `getPos` can be used to find the
|
||
|
widget's current document position.
|
||
|
*/
|
||
|
static widget(pos, toDOM, spec) {
|
||
|
return new Decoration(pos, pos, new WidgetType(toDOM, spec));
|
||
|
}
|
||
|
/**
|
||
|
Creates an inline decoration, which adds the given attributes to
|
||
|
each inline node between `from` and `to`.
|
||
|
*/
|
||
|
static inline(from, to, attrs, spec) {
|
||
|
return new Decoration(from, to, new InlineType(attrs, spec));
|
||
|
}
|
||
|
/**
|
||
|
Creates a node decoration. `from` and `to` should point precisely
|
||
|
before and after a node in the document. That node, and only that
|
||
|
node, will receive the given attributes.
|
||
|
*/
|
||
|
static node(from, to, attrs, spec) {
|
||
|
return new Decoration(from, to, new NodeType(attrs, spec));
|
||
|
}
|
||
|
/**
|
||
|
The spec provided when creating this decoration. Can be useful
|
||
|
if you've stored extra information in that object.
|
||
|
*/
|
||
|
get spec() { return this.type.spec; }
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
get inline() { return this.type instanceof InlineType; }
|
||
|
}
|
||
|
const none = [], noSpec = {};
|
||
|
/**
|
||
|
A collection of [decorations](https://prosemirror.net/docs/ref/#view.Decoration), organized in such
|
||
|
a way that the drawing algorithm can efficiently use and compare
|
||
|
them. This is a persistent data structure—it is not modified,
|
||
|
updates create a new value.
|
||
|
*/
|
||
|
class DecorationSet {
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
constructor(local, children) {
|
||
|
this.local = local.length ? local : none;
|
||
|
this.children = children.length ? children : none;
|
||
|
}
|
||
|
/**
|
||
|
Create a set of decorations, using the structure of the given
|
||
|
document.
|
||
|
*/
|
||
|
static create(doc, decorations) {
|
||
|
return decorations.length ? buildTree(decorations, doc, 0, noSpec) : empty;
|
||
|
}
|
||
|
/**
|
||
|
Find all decorations in this set which touch the given range
|
||
|
(including decorations that start or end directly at the
|
||
|
boundaries) and match the given predicate on their spec. When
|
||
|
`start` and `end` are omitted, all decorations in the set are
|
||
|
considered. When `predicate` isn't given, all decorations are
|
||
|
assumed to match.
|
||
|
*/
|
||
|
find(start, end, predicate) {
|
||
|
let result = [];
|
||
|
this.findInner(start == null ? 0 : start, end == null ? 1e9 : end, result, 0, predicate);
|
||
|
return result;
|
||
|
}
|
||
|
findInner(start, end, result, offset, predicate) {
|
||
|
for (let i = 0; i < this.local.length; i++) {
|
||
|
let span = this.local[i];
|
||
|
if (span.from <= end && span.to >= start && (!predicate || predicate(span.spec)))
|
||
|
result.push(span.copy(span.from + offset, span.to + offset));
|
||
|
}
|
||
|
for (let i = 0; i < this.children.length; i += 3) {
|
||
|
if (this.children[i] < end && this.children[i + 1] > start) {
|
||
|
let childOff = this.children[i] + 1;
|
||
|
this.children[i + 2].findInner(start - childOff, end - childOff, result, offset + childOff, predicate);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
Map the set of decorations in response to a change in the
|
||
|
document.
|
||
|
*/
|
||
|
map(mapping, doc, options) {
|
||
|
if (this == empty || mapping.maps.length == 0)
|
||
|
return this;
|
||
|
return this.mapInner(mapping, doc, 0, 0, options || noSpec);
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
mapInner(mapping, node, offset, oldOffset, options) {
|
||
|
let newLocal;
|
||
|
for (let i = 0; i < this.local.length; i++) {
|
||
|
let mapped = this.local[i].map(mapping, offset, oldOffset);
|
||
|
if (mapped && mapped.type.valid(node, mapped))
|
||
|
(newLocal || (newLocal = [])).push(mapped);
|
||
|
else if (options.onRemove)
|
||
|
options.onRemove(this.local[i].spec);
|
||
|
}
|
||
|
if (this.children.length)
|
||
|
return mapChildren(this.children, newLocal || [], mapping, node, offset, oldOffset, options);
|
||
|
else
|
||
|
return newLocal ? new DecorationSet(newLocal.sort(byPos), none) : empty;
|
||
|
}
|
||
|
/**
|
||
|
Add the given array of decorations to the ones in the set,
|
||
|
producing a new set. Needs access to the current document to
|
||
|
create the appropriate tree structure.
|
||
|
*/
|
||
|
add(doc, decorations) {
|
||
|
if (!decorations.length)
|
||
|
return this;
|
||
|
if (this == empty)
|
||
|
return DecorationSet.create(doc, decorations);
|
||
|
return this.addInner(doc, decorations, 0);
|
||
|
}
|
||
|
addInner(doc, decorations, offset) {
|
||
|
let children, childIndex = 0;
|
||
|
doc.forEach((childNode, childOffset) => {
|
||
|
let baseOffset = childOffset + offset, found;
|
||
|
if (!(found = takeSpansForNode(decorations, childNode, baseOffset)))
|
||
|
return;
|
||
|
if (!children)
|
||
|
children = this.children.slice();
|
||
|
while (childIndex < children.length && children[childIndex] < childOffset)
|
||
|
childIndex += 3;
|
||
|
if (children[childIndex] == childOffset)
|
||
|
children[childIndex + 2] = children[childIndex + 2].addInner(childNode, found, baseOffset + 1);
|
||
|
else
|
||
|
children.splice(childIndex, 0, childOffset, childOffset + childNode.nodeSize, buildTree(found, childNode, baseOffset + 1, noSpec));
|
||
|
childIndex += 3;
|
||
|
});
|
||
|
let local = moveSpans(childIndex ? withoutNulls(decorations) : decorations, -offset);
|
||
|
for (let i = 0; i < local.length; i++)
|
||
|
if (!local[i].type.valid(doc, local[i]))
|
||
|
local.splice(i--, 1);
|
||
|
return new DecorationSet(local.length ? this.local.concat(local).sort(byPos) : this.local, children || this.children);
|
||
|
}
|
||
|
/**
|
||
|
Create a new set that contains the decorations in this set, minus
|
||
|
the ones in the given array.
|
||
|
*/
|
||
|
remove(decorations) {
|
||
|
if (decorations.length == 0 || this == empty)
|
||
|
return this;
|
||
|
return this.removeInner(decorations, 0);
|
||
|
}
|
||
|
removeInner(decorations, offset) {
|
||
|
let children = this.children, local = this.local;
|
||
|
for (let i = 0; i < children.length; i += 3) {
|
||
|
let found;
|
||
|
let from = children[i] + offset, to = children[i + 1] + offset;
|
||
|
for (let j = 0, span; j < decorations.length; j++)
|
||
|
if (span = decorations[j]) {
|
||
|
if (span.from > from && span.to < to) {
|
||
|
decorations[j] = null;
|
||
|
(found || (found = [])).push(span);
|
||
|
}
|
||
|
}
|
||
|
if (!found)
|
||
|
continue;
|
||
|
if (children == this.children)
|
||
|
children = this.children.slice();
|
||
|
let removed = children[i + 2].removeInner(found, from + 1);
|
||
|
if (removed != empty) {
|
||
|
children[i + 2] = removed;
|
||
|
}
|
||
|
else {
|
||
|
children.splice(i, 3);
|
||
|
i -= 3;
|
||
|
}
|
||
|
}
|
||
|
if (local.length)
|
||
|
for (let i = 0, span; i < decorations.length; i++)
|
||
|
if (span = decorations[i]) {
|
||
|
for (let j = 0; j < local.length; j++)
|
||
|
if (local[j].eq(span, offset)) {
|
||
|
if (local == this.local)
|
||
|
local = this.local.slice();
|
||
|
local.splice(j--, 1);
|
||
|
}
|
||
|
}
|
||
|
if (children == this.children && local == this.local)
|
||
|
return this;
|
||
|
return local.length || children.length ? new DecorationSet(local, children) : empty;
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
forChild(offset, node) {
|
||
|
if (this == empty)
|
||
|
return this;
|
||
|
if (node.isLeaf)
|
||
|
return DecorationSet.empty;
|
||
|
let child, local;
|
||
|
for (let i = 0; i < this.children.length; i += 3)
|
||
|
if (this.children[i] >= offset) {
|
||
|
if (this.children[i] == offset)
|
||
|
child = this.children[i + 2];
|
||
|
break;
|
||
|
}
|
||
|
let start = offset + 1, end = start + node.content.size;
|
||
|
for (let i = 0; i < this.local.length; i++) {
|
||
|
let dec = this.local[i];
|
||
|
if (dec.from < end && dec.to > start && (dec.type instanceof InlineType)) {
|
||
|
let from = Math.max(start, dec.from) - start, to = Math.min(end, dec.to) - start;
|
||
|
if (from < to)
|
||
|
(local || (local = [])).push(dec.copy(from, to));
|
||
|
}
|
||
|
}
|
||
|
if (local) {
|
||
|
let localSet = new DecorationSet(local.sort(byPos), none);
|
||
|
return child ? new DecorationGroup([localSet, child]) : localSet;
|
||
|
}
|
||
|
return child || empty;
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
eq(other) {
|
||
|
if (this == other)
|
||
|
return true;
|
||
|
if (!(other instanceof DecorationSet) ||
|
||
|
this.local.length != other.local.length ||
|
||
|
this.children.length != other.children.length)
|
||
|
return false;
|
||
|
for (let i = 0; i < this.local.length; i++)
|
||
|
if (!this.local[i].eq(other.local[i]))
|
||
|
return false;
|
||
|
for (let i = 0; i < this.children.length; i += 3)
|
||
|
if (this.children[i] != other.children[i] ||
|
||
|
this.children[i + 1] != other.children[i + 1] ||
|
||
|
!this.children[i + 2].eq(other.children[i + 2]))
|
||
|
return false;
|
||
|
return true;
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
locals(node) {
|
||
|
return removeOverlap(this.localsInner(node));
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
localsInner(node) {
|
||
|
if (this == empty)
|
||
|
return none;
|
||
|
if (node.inlineContent || !this.local.some(InlineType.is))
|
||
|
return this.local;
|
||
|
let result = [];
|
||
|
for (let i = 0; i < this.local.length; i++) {
|
||
|
if (!(this.local[i].type instanceof InlineType))
|
||
|
result.push(this.local[i]);
|
||
|
}
|
||
|
return result;
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
The empty set of decorations.
|
||
|
*/
|
||
|
DecorationSet.empty = new DecorationSet([], []);
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
DecorationSet.removeOverlap = removeOverlap;
|
||
|
const empty = DecorationSet.empty;
|
||
|
// An abstraction that allows the code dealing with decorations to
|
||
|
// treat multiple DecorationSet objects as if it were a single object
|
||
|
// with (a subset of) the same interface.
|
||
|
class DecorationGroup {
|
||
|
constructor(members) {
|
||
|
this.members = members;
|
||
|
}
|
||
|
map(mapping, doc) {
|
||
|
const mappedDecos = this.members.map(member => member.map(mapping, doc, noSpec));
|
||
|
return DecorationGroup.from(mappedDecos);
|
||
|
}
|
||
|
forChild(offset, child) {
|
||
|
if (child.isLeaf)
|
||
|
return DecorationSet.empty;
|
||
|
let found = [];
|
||
|
for (let i = 0; i < this.members.length; i++) {
|
||
|
let result = this.members[i].forChild(offset, child);
|
||
|
if (result == empty)
|
||
|
continue;
|
||
|
if (result instanceof DecorationGroup)
|
||
|
found = found.concat(result.members);
|
||
|
else
|
||
|
found.push(result);
|
||
|
}
|
||
|
return DecorationGroup.from(found);
|
||
|
}
|
||
|
eq(other) {
|
||
|
if (!(other instanceof DecorationGroup) ||
|
||
|
other.members.length != this.members.length)
|
||
|
return false;
|
||
|
for (let i = 0; i < this.members.length; i++)
|
||
|
if (!this.members[i].eq(other.members[i]))
|
||
|
return false;
|
||
|
return true;
|
||
|
}
|
||
|
locals(node) {
|
||
|
let result, sorted = true;
|
||
|
for (let i = 0; i < this.members.length; i++) {
|
||
|
let locals = this.members[i].localsInner(node);
|
||
|
if (!locals.length)
|
||
|
continue;
|
||
|
if (!result) {
|
||
|
result = locals;
|
||
|
}
|
||
|
else {
|
||
|
if (sorted) {
|
||
|
result = result.slice();
|
||
|
sorted = false;
|
||
|
}
|
||
|
for (let j = 0; j < locals.length; j++)
|
||
|
result.push(locals[j]);
|
||
|
}
|
||
|
}
|
||
|
return result ? removeOverlap(sorted ? result : result.sort(byPos)) : none;
|
||
|
}
|
||
|
// Create a group for the given array of decoration sets, or return
|
||
|
// a single set when possible.
|
||
|
static from(members) {
|
||
|
switch (members.length) {
|
||
|
case 0: return empty;
|
||
|
case 1: return members[0];
|
||
|
default: return new DecorationGroup(members.every(m => m instanceof DecorationSet) ? members :
|
||
|
members.reduce((r, m) => r.concat(m instanceof DecorationSet ? m : m.members), []));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
function mapChildren(oldChildren, newLocal, mapping, node, offset, oldOffset, options) {
|
||
|
let children = oldChildren.slice();
|
||
|
// Mark the children that are directly touched by changes, and
|
||
|
// move those that are after the changes.
|
||
|
for (let i = 0, baseOffset = oldOffset; i < mapping.maps.length; i++) {
|
||
|
let moved = 0;
|
||
|
mapping.maps[i].forEach((oldStart, oldEnd, newStart, newEnd) => {
|
||
|
let dSize = (newEnd - newStart) - (oldEnd - oldStart);
|
||
|
for (let i = 0; i < children.length; i += 3) {
|
||
|
let end = children[i + 1];
|
||
|
if (end < 0 || oldStart > end + baseOffset - moved)
|
||
|
continue;
|
||
|
let start = children[i] + baseOffset - moved;
|
||
|
if (oldEnd >= start) {
|
||
|
children[i + 1] = oldStart <= start ? -2 : -1;
|
||
|
}
|
||
|
else if (newStart >= offset && dSize) {
|
||
|
children[i] += dSize;
|
||
|
children[i + 1] += dSize;
|
||
|
}
|
||
|
}
|
||
|
moved += dSize;
|
||
|
});
|
||
|
baseOffset = mapping.maps[i].map(baseOffset, -1);
|
||
|
}
|
||
|
// Find the child nodes that still correspond to a single node,
|
||
|
// recursively call mapInner on them and update their positions.
|
||
|
let mustRebuild = false;
|
||
|
for (let i = 0; i < children.length; i += 3)
|
||
|
if (children[i + 1] < 0) { // Touched nodes
|
||
|
if (children[i + 1] == -2) {
|
||
|
mustRebuild = true;
|
||
|
children[i + 1] = -1;
|
||
|
continue;
|
||
|
}
|
||
|
let from = mapping.map(oldChildren[i] + oldOffset), fromLocal = from - offset;
|
||
|
if (fromLocal < 0 || fromLocal >= node.content.size) {
|
||
|
mustRebuild = true;
|
||
|
continue;
|
||
|
}
|
||
|
// Must read oldChildren because children was tagged with -1
|
||
|
let to = mapping.map(oldChildren[i + 1] + oldOffset, -1), toLocal = to - offset;
|
||
|
let { index, offset: childOffset } = node.content.findIndex(fromLocal);
|
||
|
let childNode = node.maybeChild(index);
|
||
|
if (childNode && childOffset == fromLocal && childOffset + childNode.nodeSize == toLocal) {
|
||
|
let mapped = children[i + 2]
|
||
|
.mapInner(mapping, childNode, from + 1, oldChildren[i] + oldOffset + 1, options);
|
||
|
if (mapped != empty) {
|
||
|
children[i] = fromLocal;
|
||
|
children[i + 1] = toLocal;
|
||
|
children[i + 2] = mapped;
|
||
|
}
|
||
|
else {
|
||
|
children[i + 1] = -2;
|
||
|
mustRebuild = true;
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
mustRebuild = true;
|
||
|
}
|
||
|
}
|
||
|
// Remaining children must be collected and rebuilt into the appropriate structure
|
||
|
if (mustRebuild) {
|
||
|
let decorations = mapAndGatherRemainingDecorations(children, oldChildren, newLocal, mapping, offset, oldOffset, options);
|
||
|
let built = buildTree(decorations, node, 0, options);
|
||
|
newLocal = built.local;
|
||
|
for (let i = 0; i < children.length; i += 3)
|
||
|
if (children[i + 1] < 0) {
|
||
|
children.splice(i, 3);
|
||
|
i -= 3;
|
||
|
}
|
||
|
for (let i = 0, j = 0; i < built.children.length; i += 3) {
|
||
|
let from = built.children[i];
|
||
|
while (j < children.length && children[j] < from)
|
||
|
j += 3;
|
||
|
children.splice(j, 0, built.children[i], built.children[i + 1], built.children[i + 2]);
|
||
|
}
|
||
|
}
|
||
|
return new DecorationSet(newLocal.sort(byPos), children);
|
||
|
}
|
||
|
function moveSpans(spans, offset) {
|
||
|
if (!offset || !spans.length)
|
||
|
return spans;
|
||
|
let result = [];
|
||
|
for (let i = 0; i < spans.length; i++) {
|
||
|
let span = spans[i];
|
||
|
result.push(new Decoration(span.from + offset, span.to + offset, span.type));
|
||
|
}
|
||
|
return result;
|
||
|
}
|
||
|
function mapAndGatherRemainingDecorations(children, oldChildren, decorations, mapping, offset, oldOffset, options) {
|
||
|
// Gather all decorations from the remaining marked children
|
||
|
function gather(set, oldOffset) {
|
||
|
for (let i = 0; i < set.local.length; i++) {
|
||
|
let mapped = set.local[i].map(mapping, offset, oldOffset);
|
||
|
if (mapped)
|
||
|
decorations.push(mapped);
|
||
|
else if (options.onRemove)
|
||
|
options.onRemove(set.local[i].spec);
|
||
|
}
|
||
|
for (let i = 0; i < set.children.length; i += 3)
|
||
|
gather(set.children[i + 2], set.children[i] + oldOffset + 1);
|
||
|
}
|
||
|
for (let i = 0; i < children.length; i += 3)
|
||
|
if (children[i + 1] == -1)
|
||
|
gather(children[i + 2], oldChildren[i] + oldOffset + 1);
|
||
|
return decorations;
|
||
|
}
|
||
|
function takeSpansForNode(spans, node, offset) {
|
||
|
if (node.isLeaf)
|
||
|
return null;
|
||
|
let end = offset + node.nodeSize, found = null;
|
||
|
for (let i = 0, span; i < spans.length; i++) {
|
||
|
if ((span = spans[i]) && span.from > offset && span.to < end) {
|
||
|
(found || (found = [])).push(span);
|
||
|
spans[i] = null;
|
||
|
}
|
||
|
}
|
||
|
return found;
|
||
|
}
|
||
|
function withoutNulls(array) {
|
||
|
let result = [];
|
||
|
for (let i = 0; i < array.length; i++)
|
||
|
if (array[i] != null)
|
||
|
result.push(array[i]);
|
||
|
return result;
|
||
|
}
|
||
|
// Build up a tree that corresponds to a set of decorations. `offset`
|
||
|
// is a base offset that should be subtracted from the `from` and `to`
|
||
|
// positions in the spans (so that we don't have to allocate new spans
|
||
|
// for recursive calls).
|
||
|
function buildTree(spans, node, offset, options) {
|
||
|
let children = [], hasNulls = false;
|
||
|
node.forEach((childNode, localStart) => {
|
||
|
let found = takeSpansForNode(spans, childNode, localStart + offset);
|
||
|
if (found) {
|
||
|
hasNulls = true;
|
||
|
let subtree = buildTree(found, childNode, offset + localStart + 1, options);
|
||
|
if (subtree != empty)
|
||
|
children.push(localStart, localStart + childNode.nodeSize, subtree);
|
||
|
}
|
||
|
});
|
||
|
let locals = moveSpans(hasNulls ? withoutNulls(spans) : spans, -offset).sort(byPos);
|
||
|
for (let i = 0; i < locals.length; i++)
|
||
|
if (!locals[i].type.valid(node, locals[i])) {
|
||
|
if (options.onRemove)
|
||
|
options.onRemove(locals[i].spec);
|
||
|
locals.splice(i--, 1);
|
||
|
}
|
||
|
return locals.length || children.length ? new DecorationSet(locals, children) : empty;
|
||
|
}
|
||
|
// Used to sort decorations so that ones with a low start position
|
||
|
// come first, and within a set with the same start position, those
|
||
|
// with an smaller end position come first.
|
||
|
function byPos(a, b) {
|
||
|
return a.from - b.from || a.to - b.to;
|
||
|
}
|
||
|
// Scan a sorted array of decorations for partially overlapping spans,
|
||
|
// and split those so that only fully overlapping spans are left (to
|
||
|
// make subsequent rendering easier). Will return the input array if
|
||
|
// no partially overlapping spans are found (the common case).
|
||
|
function removeOverlap(spans) {
|
||
|
let working = spans;
|
||
|
for (let i = 0; i < working.length - 1; i++) {
|
||
|
let span = working[i];
|
||
|
if (span.from != span.to)
|
||
|
for (let j = i + 1; j < working.length; j++) {
|
||
|
let next = working[j];
|
||
|
if (next.from == span.from) {
|
||
|
if (next.to != span.to) {
|
||
|
if (working == spans)
|
||
|
working = spans.slice();
|
||
|
// Followed by a partially overlapping larger span. Split that
|
||
|
// span.
|
||
|
working[j] = next.copy(next.from, span.to);
|
||
|
insertAhead(working, j + 1, next.copy(span.to, next.to));
|
||
|
}
|
||
|
continue;
|
||
|
}
|
||
|
else {
|
||
|
if (next.from < span.to) {
|
||
|
if (working == spans)
|
||
|
working = spans.slice();
|
||
|
// The end of this one overlaps with a subsequent span. Split
|
||
|
// this one.
|
||
|
working[i] = span.copy(span.from, next.from);
|
||
|
insertAhead(working, j, span.copy(next.from, span.to));
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return working;
|
||
|
}
|
||
|
function insertAhead(array, i, deco) {
|
||
|
while (i < array.length && byPos(deco, array[i]) > 0)
|
||
|
i++;
|
||
|
array.splice(i, 0, deco);
|
||
|
}
|
||
|
// Get the decorations associated with the current props of a view.
|
||
|
function viewDecorations(view) {
|
||
|
let found = [];
|
||
|
view.someProp("decorations", f => {
|
||
|
let result = f(view.state);
|
||
|
if (result && result != empty)
|
||
|
found.push(result);
|
||
|
});
|
||
|
if (view.cursorWrapper)
|
||
|
found.push(DecorationSet.create(view.state.doc, [view.cursorWrapper.deco]));
|
||
|
return DecorationGroup.from(found);
|
||
|
}
|
||
|
|
||
|
const observeOptions = {
|
||
|
childList: true,
|
||
|
characterData: true,
|
||
|
characterDataOldValue: true,
|
||
|
attributes: true,
|
||
|
attributeOldValue: true,
|
||
|
subtree: true
|
||
|
};
|
||
|
// IE11 has very broken mutation observers, so we also listen to DOMCharacterDataModified
|
||
|
const useCharData = ie && ie_version <= 11;
|
||
|
class SelectionState {
|
||
|
constructor() {
|
||
|
this.anchorNode = null;
|
||
|
this.anchorOffset = 0;
|
||
|
this.focusNode = null;
|
||
|
this.focusOffset = 0;
|
||
|
}
|
||
|
set(sel) {
|
||
|
this.anchorNode = sel.anchorNode;
|
||
|
this.anchorOffset = sel.anchorOffset;
|
||
|
this.focusNode = sel.focusNode;
|
||
|
this.focusOffset = sel.focusOffset;
|
||
|
}
|
||
|
clear() {
|
||
|
this.anchorNode = this.focusNode = null;
|
||
|
}
|
||
|
eq(sel) {
|
||
|
return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset &&
|
||
|
sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset;
|
||
|
}
|
||
|
}
|
||
|
class DOMObserver {
|
||
|
constructor(view, handleDOMChange) {
|
||
|
this.view = view;
|
||
|
this.handleDOMChange = handleDOMChange;
|
||
|
this.queue = [];
|
||
|
this.flushingSoon = -1;
|
||
|
this.observer = null;
|
||
|
this.currentSelection = new SelectionState;
|
||
|
this.onCharData = null;
|
||
|
this.suppressingSelectionUpdates = false;
|
||
|
this.observer = window.MutationObserver &&
|
||
|
new window.MutationObserver(mutations => {
|
||
|
for (let i = 0; i < mutations.length; i++)
|
||
|
this.queue.push(mutations[i]);
|
||
|
// IE11 will sometimes (on backspacing out a single character
|
||
|
// text node after a BR node) call the observer callback
|
||
|
// before actually updating the DOM, which will cause
|
||
|
// ProseMirror to miss the change (see #930)
|
||
|
if (ie && ie_version <= 11 && mutations.some(m => m.type == "childList" && m.removedNodes.length ||
|
||
|
m.type == "characterData" && m.oldValue.length > m.target.nodeValue.length))
|
||
|
this.flushSoon();
|
||
|
else
|
||
|
this.flush();
|
||
|
});
|
||
|
if (useCharData) {
|
||
|
this.onCharData = e => {
|
||
|
this.queue.push({ target: e.target, type: "characterData", oldValue: e.prevValue });
|
||
|
this.flushSoon();
|
||
|
};
|
||
|
}
|
||
|
this.onSelectionChange = this.onSelectionChange.bind(this);
|
||
|
}
|
||
|
flushSoon() {
|
||
|
if (this.flushingSoon < 0)
|
||
|
this.flushingSoon = window.setTimeout(() => { this.flushingSoon = -1; this.flush(); }, 20);
|
||
|
}
|
||
|
forceFlush() {
|
||
|
if (this.flushingSoon > -1) {
|
||
|
window.clearTimeout(this.flushingSoon);
|
||
|
this.flushingSoon = -1;
|
||
|
this.flush();
|
||
|
}
|
||
|
}
|
||
|
start() {
|
||
|
if (this.observer) {
|
||
|
this.observer.takeRecords();
|
||
|
this.observer.observe(this.view.dom, observeOptions);
|
||
|
}
|
||
|
if (this.onCharData)
|
||
|
this.view.dom.addEventListener("DOMCharacterDataModified", this.onCharData);
|
||
|
this.connectSelection();
|
||
|
}
|
||
|
stop() {
|
||
|
if (this.observer) {
|
||
|
let take = this.observer.takeRecords();
|
||
|
if (take.length) {
|
||
|
for (let i = 0; i < take.length; i++)
|
||
|
this.queue.push(take[i]);
|
||
|
window.setTimeout(() => this.flush(), 20);
|
||
|
}
|
||
|
this.observer.disconnect();
|
||
|
}
|
||
|
if (this.onCharData)
|
||
|
this.view.dom.removeEventListener("DOMCharacterDataModified", this.onCharData);
|
||
|
this.disconnectSelection();
|
||
|
}
|
||
|
connectSelection() {
|
||
|
this.view.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange);
|
||
|
}
|
||
|
disconnectSelection() {
|
||
|
this.view.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange);
|
||
|
}
|
||
|
suppressSelectionUpdates() {
|
||
|
this.suppressingSelectionUpdates = true;
|
||
|
setTimeout(() => this.suppressingSelectionUpdates = false, 50);
|
||
|
}
|
||
|
onSelectionChange() {
|
||
|
if (!hasFocusAndSelection(this.view))
|
||
|
return;
|
||
|
if (this.suppressingSelectionUpdates)
|
||
|
return selectionToDOM(this.view);
|
||
|
// Deletions on IE11 fire their events in the wrong order, giving
|
||
|
// us a selection change event before the DOM changes are
|
||
|
// reported.
|
||
|
if (ie && ie_version <= 11 && !this.view.state.selection.empty) {
|
||
|
let sel = this.view.domSelectionRange();
|
||
|
// Selection.isCollapsed isn't reliable on IE
|
||
|
if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode, sel.anchorOffset))
|
||
|
return this.flushSoon();
|
||
|
}
|
||
|
this.flush();
|
||
|
}
|
||
|
setCurSelection() {
|
||
|
this.currentSelection.set(this.view.domSelectionRange());
|
||
|
}
|
||
|
ignoreSelectionChange(sel) {
|
||
|
if (!sel.focusNode)
|
||
|
return true;
|
||
|
let ancestors = new Set, container;
|
||
|
for (let scan = sel.focusNode; scan; scan = parentNode(scan))
|
||
|
ancestors.add(scan);
|
||
|
for (let scan = sel.anchorNode; scan; scan = parentNode(scan))
|
||
|
if (ancestors.has(scan)) {
|
||
|
container = scan;
|
||
|
break;
|
||
|
}
|
||
|
let desc = container && this.view.docView.nearestDesc(container);
|
||
|
if (desc && desc.ignoreMutation({
|
||
|
type: "selection",
|
||
|
target: container.nodeType == 3 ? container.parentNode : container
|
||
|
})) {
|
||
|
this.setCurSelection();
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
flush() {
|
||
|
let { view } = this;
|
||
|
if (!view.docView || this.flushingSoon > -1)
|
||
|
return;
|
||
|
let mutations = this.observer ? this.observer.takeRecords() : [];
|
||
|
if (this.queue.length) {
|
||
|
mutations = this.queue.concat(mutations);
|
||
|
this.queue.length = 0;
|
||
|
}
|
||
|
let sel = view.domSelectionRange();
|
||
|
let newSel = !this.suppressingSelectionUpdates && !this.currentSelection.eq(sel) && hasFocusAndSelection(view) && !this.ignoreSelectionChange(sel);
|
||
|
let from = -1, to = -1, typeOver = false, added = [];
|
||
|
if (view.editable) {
|
||
|
for (let i = 0; i < mutations.length; i++) {
|
||
|
let result = this.registerMutation(mutations[i], added);
|
||
|
if (result) {
|
||
|
from = from < 0 ? result.from : Math.min(result.from, from);
|
||
|
to = to < 0 ? result.to : Math.max(result.to, to);
|
||
|
if (result.typeOver)
|
||
|
typeOver = true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (gecko && added.length > 1) {
|
||
|
let brs = added.filter(n => n.nodeName == "BR");
|
||
|
if (brs.length == 2) {
|
||
|
let a = brs[0], b = brs[1];
|
||
|
if (a.parentNode && a.parentNode.parentNode == b.parentNode)
|
||
|
b.remove();
|
||
|
else
|
||
|
a.remove();
|
||
|
}
|
||
|
}
|
||
|
let readSel = null;
|
||
|
// If it looks like the browser has reset the selection to the
|
||
|
// start of the document after focus, restore the selection from
|
||
|
// the state
|
||
|
if (from < 0 && newSel && view.input.lastFocus > Date.now() - 200 &&
|
||
|
view.input.lastTouch < Date.now() - 300 &&
|
||
|
selectionCollapsed(sel) && (readSel = selectionFromDOM(view)) &&
|
||
|
readSel.eq(Selection.near(view.state.doc.resolve(0), 1))) {
|
||
|
view.input.lastFocus = 0;
|
||
|
selectionToDOM(view);
|
||
|
this.currentSelection.set(sel);
|
||
|
view.scrollToSelection();
|
||
|
}
|
||
|
else if (from > -1 || newSel) {
|
||
|
if (from > -1) {
|
||
|
view.docView.markDirty(from, to);
|
||
|
checkCSS(view);
|
||
|
}
|
||
|
this.handleDOMChange(from, to, typeOver, added);
|
||
|
if (view.docView && view.docView.dirty)
|
||
|
view.updateState(view.state);
|
||
|
else if (!this.currentSelection.eq(sel))
|
||
|
selectionToDOM(view);
|
||
|
this.currentSelection.set(sel);
|
||
|
}
|
||
|
}
|
||
|
registerMutation(mut, added) {
|
||
|
// Ignore mutations inside nodes that were already noted as inserted
|
||
|
if (added.indexOf(mut.target) > -1)
|
||
|
return null;
|
||
|
let desc = this.view.docView.nearestDesc(mut.target);
|
||
|
if (mut.type == "attributes" &&
|
||
|
(desc == this.view.docView || mut.attributeName == "contenteditable" ||
|
||
|
// Firefox sometimes fires spurious events for null/empty styles
|
||
|
(mut.attributeName == "style" && !mut.oldValue && !mut.target.getAttribute("style"))))
|
||
|
return null;
|
||
|
if (!desc || desc.ignoreMutation(mut))
|
||
|
return null;
|
||
|
if (mut.type == "childList") {
|
||
|
for (let i = 0; i < mut.addedNodes.length; i++)
|
||
|
added.push(mut.addedNodes[i]);
|
||
|
if (desc.contentDOM && desc.contentDOM != desc.dom && !desc.contentDOM.contains(mut.target))
|
||
|
return { from: desc.posBefore, to: desc.posAfter };
|
||
|
let prev = mut.previousSibling, next = mut.nextSibling;
|
||
|
if (ie && ie_version <= 11 && mut.addedNodes.length) {
|
||
|
// IE11 gives us incorrect next/prev siblings for some
|
||
|
// insertions, so if there are added nodes, recompute those
|
||
|
for (let i = 0; i < mut.addedNodes.length; i++) {
|
||
|
let { previousSibling, nextSibling } = mut.addedNodes[i];
|
||
|
if (!previousSibling || Array.prototype.indexOf.call(mut.addedNodes, previousSibling) < 0)
|
||
|
prev = previousSibling;
|
||
|
if (!nextSibling || Array.prototype.indexOf.call(mut.addedNodes, nextSibling) < 0)
|
||
|
next = nextSibling;
|
||
|
}
|
||
|
}
|
||
|
let fromOffset = prev && prev.parentNode == mut.target
|
||
|
? domIndex(prev) + 1 : 0;
|
||
|
let from = desc.localPosFromDOM(mut.target, fromOffset, -1);
|
||
|
let toOffset = next && next.parentNode == mut.target
|
||
|
? domIndex(next) : mut.target.childNodes.length;
|
||
|
let to = desc.localPosFromDOM(mut.target, toOffset, 1);
|
||
|
return { from, to };
|
||
|
}
|
||
|
else if (mut.type == "attributes") {
|
||
|
return { from: desc.posAtStart - desc.border, to: desc.posAtEnd + desc.border };
|
||
|
}
|
||
|
else { // "characterData"
|
||
|
return {
|
||
|
from: desc.posAtStart,
|
||
|
to: desc.posAtEnd,
|
||
|
// An event was generated for a text change that didn't change
|
||
|
// any text. Mark the dom change to fall back to assuming the
|
||
|
// selection was typed over with an identical value if it can't
|
||
|
// find another change.
|
||
|
typeOver: mut.target.nodeValue == mut.oldValue
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
let cssChecked = new WeakMap();
|
||
|
let cssCheckWarned = false;
|
||
|
function checkCSS(view) {
|
||
|
if (cssChecked.has(view))
|
||
|
return;
|
||
|
cssChecked.set(view, null);
|
||
|
if (['normal', 'nowrap', 'pre-line'].indexOf(getComputedStyle(view.dom).whiteSpace) !== -1) {
|
||
|
view.requiresGeckoHackNode = gecko;
|
||
|
if (cssCheckWarned)
|
||
|
return;
|
||
|
console["warn"]("ProseMirror expects the CSS white-space property to be set, preferably to 'pre-wrap'. It is recommended to load style/prosemirror.css from the prosemirror-view package.");
|
||
|
cssCheckWarned = true;
|
||
|
}
|
||
|
}
|
||
|
// Used to work around a Safari Selection/shadow DOM bug
|
||
|
// Based on https://github.com/codemirror/dev/issues/414 fix
|
||
|
function safariShadowSelectionRange(view) {
|
||
|
let found;
|
||
|
function read(event) {
|
||
|
event.preventDefault();
|
||
|
event.stopImmediatePropagation();
|
||
|
found = event.getTargetRanges()[0];
|
||
|
}
|
||
|
// Because Safari (at least in 2018-2022) doesn't provide regular
|
||
|
// access to the selection inside a shadowRoot, we have to perform a
|
||
|
// ridiculous hack to get at it—using `execCommand` to trigger a
|
||
|
// `beforeInput` event so that we can read the target range from the
|
||
|
// event.
|
||
|
view.dom.addEventListener("beforeinput", read, true);
|
||
|
document.execCommand("indent");
|
||
|
view.dom.removeEventListener("beforeinput", read, true);
|
||
|
let anchorNode = found.startContainer, anchorOffset = found.startOffset;
|
||
|
let focusNode = found.endContainer, focusOffset = found.endOffset;
|
||
|
let currentAnchor = view.domAtPos(view.state.selection.anchor);
|
||
|
// Since such a range doesn't distinguish between anchor and head,
|
||
|
// use a heuristic that flips it around if its end matches the
|
||
|
// current anchor.
|
||
|
if (isEquivalentPosition(currentAnchor.node, currentAnchor.offset, focusNode, focusOffset))
|
||
|
[anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset];
|
||
|
return { anchorNode, anchorOffset, focusNode, focusOffset };
|
||
|
}
|
||
|
|
||
|
// Note that all referencing and parsing is done with the
|
||
|
// start-of-operation selection and document, since that's the one
|
||
|
// that the DOM represents. If any changes came in in the meantime,
|
||
|
// the modification is mapped over those before it is applied, in
|
||
|
// readDOMChange.
|
||
|
function parseBetween(view, from_, to_) {
|
||
|
let { node: parent, fromOffset, toOffset, from, to } = view.docView.parseRange(from_, to_);
|
||
|
let domSel = view.domSelectionRange();
|
||
|
let find;
|
||
|
let anchor = domSel.anchorNode;
|
||
|
if (anchor && view.dom.contains(anchor.nodeType == 1 ? anchor : anchor.parentNode)) {
|
||
|
find = [{ node: anchor, offset: domSel.anchorOffset }];
|
||
|
if (!selectionCollapsed(domSel))
|
||
|
find.push({ node: domSel.focusNode, offset: domSel.focusOffset });
|
||
|
}
|
||
|
// Work around issue in Chrome where backspacing sometimes replaces
|
||
|
// the deleted content with a random BR node (issues #799, #831)
|
||
|
if (chrome && view.input.lastKeyCode === 8) {
|
||
|
for (let off = toOffset; off > fromOffset; off--) {
|
||
|
let node = parent.childNodes[off - 1], desc = node.pmViewDesc;
|
||
|
if (node.nodeName == "BR" && !desc) {
|
||
|
toOffset = off;
|
||
|
break;
|
||
|
}
|
||
|
if (!desc || desc.size)
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
let startDoc = view.state.doc;
|
||
|
let parser = view.someProp("domParser") || DOMParser.fromSchema(view.state.schema);
|
||
|
let $from = startDoc.resolve(from);
|
||
|
let sel = null, doc = parser.parse(parent, {
|
||
|
topNode: $from.parent,
|
||
|
topMatch: $from.parent.contentMatchAt($from.index()),
|
||
|
topOpen: true,
|
||
|
from: fromOffset,
|
||
|
to: toOffset,
|
||
|
preserveWhitespace: $from.parent.type.whitespace == "pre" ? "full" : true,
|
||
|
findPositions: find,
|
||
|
ruleFromNode,
|
||
|
context: $from
|
||
|
});
|
||
|
if (find && find[0].pos != null) {
|
||
|
let anchor = find[0].pos, head = find[1] && find[1].pos;
|
||
|
if (head == null)
|
||
|
head = anchor;
|
||
|
sel = { anchor: anchor + from, head: head + from };
|
||
|
}
|
||
|
return { doc, sel, from, to };
|
||
|
}
|
||
|
function ruleFromNode(dom) {
|
||
|
let desc = dom.pmViewDesc;
|
||
|
if (desc) {
|
||
|
return desc.parseRule();
|
||
|
}
|
||
|
else if (dom.nodeName == "BR" && dom.parentNode) {
|
||
|
// Safari replaces the list item or table cell with a BR
|
||
|
// directly in the list node (?!) if you delete the last
|
||
|
// character in a list item or table cell (#708, #862)
|
||
|
if (safari && /^(ul|ol)$/i.test(dom.parentNode.nodeName)) {
|
||
|
let skip = document.createElement("div");
|
||
|
skip.appendChild(document.createElement("li"));
|
||
|
return { skip };
|
||
|
}
|
||
|
else if (dom.parentNode.lastChild == dom || safari && /^(tr|table)$/i.test(dom.parentNode.nodeName)) {
|
||
|
return { ignore: true };
|
||
|
}
|
||
|
}
|
||
|
else if (dom.nodeName == "IMG" && dom.getAttribute("mark-placeholder")) {
|
||
|
return { ignore: true };
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
function readDOMChange(view, from, to, typeOver, addedNodes) {
|
||
|
if (from < 0) {
|
||
|
let origin = view.input.lastSelectionTime > Date.now() - 50 ? view.input.lastSelectionOrigin : null;
|
||
|
let newSel = selectionFromDOM(view, origin);
|
||
|
if (newSel && !view.state.selection.eq(newSel)) {
|
||
|
let tr = view.state.tr.setSelection(newSel);
|
||
|
if (origin == "pointer")
|
||
|
tr.setMeta("pointer", true);
|
||
|
else if (origin == "key")
|
||
|
tr.scrollIntoView();
|
||
|
view.dispatch(tr);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
let $before = view.state.doc.resolve(from);
|
||
|
let shared = $before.sharedDepth(to);
|
||
|
from = $before.before(shared + 1);
|
||
|
to = view.state.doc.resolve(to).after(shared + 1);
|
||
|
let sel = view.state.selection;
|
||
|
let parse = parseBetween(view, from, to);
|
||
|
let doc = view.state.doc, compare = doc.slice(parse.from, parse.to);
|
||
|
let preferredPos, preferredSide;
|
||
|
// Prefer anchoring to end when Backspace is pressed
|
||
|
if (view.input.lastKeyCode === 8 && Date.now() - 100 < view.input.lastKeyCodeTime) {
|
||
|
preferredPos = view.state.selection.to;
|
||
|
preferredSide = "end";
|
||
|
}
|
||
|
else {
|
||
|
preferredPos = view.state.selection.from;
|
||
|
preferredSide = "start";
|
||
|
}
|
||
|
view.input.lastKeyCode = null;
|
||
|
let change = findDiff(compare.content, parse.doc.content, parse.from, preferredPos, preferredSide);
|
||
|
if ((ios && view.input.lastIOSEnter > Date.now() - 225 || android) &&
|
||
|
addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P" || n.nodeName == "LI") &&
|
||
|
(!change || change.endA >= change.endB) &&
|
||
|
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
|
||
|
view.input.lastIOSEnter = 0;
|
||
|
return;
|
||
|
}
|
||
|
if (!change) {
|
||
|
if (typeOver && sel instanceof TextSelection && !sel.empty && sel.$head.sameParent(sel.$anchor) &&
|
||
|
!view.composing && !(parse.sel && parse.sel.anchor != parse.sel.head)) {
|
||
|
change = { start: sel.from, endA: sel.to, endB: sel.to };
|
||
|
}
|
||
|
else {
|
||
|
if (parse.sel) {
|
||
|
let sel = resolveSelection(view, view.state.doc, parse.sel);
|
||
|
if (sel && !sel.eq(view.state.selection))
|
||
|
view.dispatch(view.state.tr.setSelection(sel));
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
// Chrome sometimes leaves the cursor before the inserted text when
|
||
|
// composing after a cursor wrapper. This moves it forward.
|
||
|
if (chrome && view.cursorWrapper && parse.sel && parse.sel.anchor == view.cursorWrapper.deco.from &&
|
||
|
parse.sel.head == parse.sel.anchor) {
|
||
|
let size = change.endB - change.start;
|
||
|
parse.sel = { anchor: parse.sel.anchor + size, head: parse.sel.anchor + size };
|
||
|
}
|
||
|
view.input.domChangeCount++;
|
||
|
// Handle the case where overwriting a selection by typing matches
|
||
|
// the start or end of the selected content, creating a change
|
||
|
// that's smaller than what was actually overwritten.
|
||
|
if (view.state.selection.from < view.state.selection.to &&
|
||
|
change.start == change.endB &&
|
||
|
view.state.selection instanceof TextSelection) {
|
||
|
if (change.start > view.state.selection.from && change.start <= view.state.selection.from + 2 &&
|
||
|
view.state.selection.from >= parse.from) {
|
||
|
change.start = view.state.selection.from;
|
||
|
}
|
||
|
else if (change.endA < view.state.selection.to && change.endA >= view.state.selection.to - 2 &&
|
||
|
view.state.selection.to <= parse.to) {
|
||
|
change.endB += (view.state.selection.to - change.endA);
|
||
|
change.endA = view.state.selection.to;
|
||
|
}
|
||
|
}
|
||
|
// IE11 will insert a non-breaking space _ahead_ of the space after
|
||
|
// the cursor space when adding a space before another space. When
|
||
|
// that happened, adjust the change to cover the space instead.
|
||
|
if (ie && ie_version <= 11 && change.endB == change.start + 1 &&
|
||
|
change.endA == change.start && change.start > parse.from &&
|
||
|
parse.doc.textBetween(change.start - parse.from - 1, change.start - parse.from + 1) == " \u00a0") {
|
||
|
change.start--;
|
||
|
change.endA--;
|
||
|
change.endB--;
|
||
|
}
|
||
|
let $from = parse.doc.resolveNoCache(change.start - parse.from);
|
||
|
let $to = parse.doc.resolveNoCache(change.endB - parse.from);
|
||
|
let $fromA = doc.resolve(change.start);
|
||
|
let inlineChange = $from.sameParent($to) && $from.parent.inlineContent && $fromA.end() >= change.endA;
|
||
|
let nextSel;
|
||
|
// If this looks like the effect of pressing Enter (or was recorded
|
||
|
// as being an iOS enter press), just dispatch an Enter key instead.
|
||
|
if (((ios && view.input.lastIOSEnter > Date.now() - 225 &&
|
||
|
(!inlineChange || addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P"))) ||
|
||
|
(!inlineChange && $from.pos < parse.doc.content.size &&
|
||
|
(nextSel = Selection.findFrom(parse.doc.resolve($from.pos + 1), 1, true)) &&
|
||
|
nextSel.head == $to.pos)) &&
|
||
|
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
|
||
|
view.input.lastIOSEnter = 0;
|
||
|
return;
|
||
|
}
|
||
|
// Same for backspace
|
||
|
if (view.state.selection.anchor > change.start &&
|
||
|
looksLikeJoin(doc, change.start, change.endA, $from, $to) &&
|
||
|
view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) {
|
||
|
if (android && chrome)
|
||
|
view.domObserver.suppressSelectionUpdates(); // #820
|
||
|
return;
|
||
|
}
|
||
|
// Chrome Android will occasionally, during composition, delete the
|
||
|
// entire composition and then immediately insert it again. This is
|
||
|
// used to detect that situation.
|
||
|
if (chrome && android && change.endB == change.start)
|
||
|
view.input.lastAndroidDelete = Date.now();
|
||
|
// This tries to detect Android virtual keyboard
|
||
|
// enter-and-pick-suggestion action. That sometimes (see issue
|
||
|
// #1059) first fires a DOM mutation, before moving the selection to
|
||
|
// the newly created block. And then, because ProseMirror cleans up
|
||
|
// the DOM selection, it gives up moving the selection entirely,
|
||
|
// leaving the cursor in the wrong place. When that happens, we drop
|
||
|
// the new paragraph from the initial change, and fire a simulated
|
||
|
// enter key afterwards.
|
||
|
if (android && !inlineChange && $from.start() != $to.start() && $to.parentOffset == 0 && $from.depth == $to.depth &&
|
||
|
parse.sel && parse.sel.anchor == parse.sel.head && parse.sel.head == change.endA) {
|
||
|
change.endB -= 2;
|
||
|
$to = parse.doc.resolveNoCache(change.endB - parse.from);
|
||
|
setTimeout(() => {
|
||
|
view.someProp("handleKeyDown", function (f) { return f(view, keyEvent(13, "Enter")); });
|
||
|
}, 20);
|
||
|
}
|
||
|
let chFrom = change.start, chTo = change.endA;
|
||
|
let tr, storedMarks, markChange;
|
||
|
if (inlineChange) {
|
||
|
if ($from.pos == $to.pos) { // Deletion
|
||
|
// IE11 sometimes weirdly moves the DOM selection around after
|
||
|
// backspacing out the first element in a textblock
|
||
|
if (ie && ie_version <= 11 && $from.parentOffset == 0) {
|
||
|
view.domObserver.suppressSelectionUpdates();
|
||
|
setTimeout(() => selectionToDOM(view), 20);
|
||
|
}
|
||
|
tr = view.state.tr.delete(chFrom, chTo);
|
||
|
storedMarks = doc.resolve(change.start).marksAcross(doc.resolve(change.endA));
|
||
|
}
|
||
|
else if ( // Adding or removing a mark
|
||
|
change.endA == change.endB &&
|
||
|
(markChange = isMarkChange($from.parent.content.cut($from.parentOffset, $to.parentOffset), $fromA.parent.content.cut($fromA.parentOffset, change.endA - $fromA.start())))) {
|
||
|
tr = view.state.tr;
|
||
|
if (markChange.type == "add")
|
||
|
tr.addMark(chFrom, chTo, markChange.mark);
|
||
|
else
|
||
|
tr.removeMark(chFrom, chTo, markChange.mark);
|
||
|
}
|
||
|
else if ($from.parent.child($from.index()).isText && $from.index() == $to.index() - ($to.textOffset ? 0 : 1)) {
|
||
|
// Both positions in the same text node -- simply insert text
|
||
|
let text = $from.parent.textBetween($from.parentOffset, $to.parentOffset);
|
||
|
if (view.someProp("handleTextInput", f => f(view, chFrom, chTo, text)))
|
||
|
return;
|
||
|
tr = view.state.tr.insertText(text, chFrom, chTo);
|
||
|
}
|
||
|
}
|
||
|
if (!tr)
|
||
|
tr = view.state.tr.replace(chFrom, chTo, parse.doc.slice(change.start - parse.from, change.endB - parse.from));
|
||
|
if (parse.sel) {
|
||
|
let sel = resolveSelection(view, tr.doc, parse.sel);
|
||
|
// Chrome Android will sometimes, during composition, report the
|
||
|
// selection in the wrong place. If it looks like that is
|
||
|
// happening, don't update the selection.
|
||
|
// Edge just doesn't move the cursor forward when you start typing
|
||
|
// in an empty block or between br nodes.
|
||
|
if (sel && !(chrome && android && view.composing && sel.empty &&
|
||
|
(change.start != change.endB || view.input.lastAndroidDelete < Date.now() - 100) &&
|
||
|
(sel.head == chFrom || sel.head == tr.mapping.map(chTo) - 1) ||
|
||
|
ie && sel.empty && sel.head == chFrom))
|
||
|
tr.setSelection(sel);
|
||
|
}
|
||
|
if (storedMarks)
|
||
|
tr.ensureMarks(storedMarks);
|
||
|
view.dispatch(tr.scrollIntoView());
|
||
|
}
|
||
|
function resolveSelection(view, doc, parsedSel) {
|
||
|
if (Math.max(parsedSel.anchor, parsedSel.head) > doc.content.size)
|
||
|
return null;
|
||
|
return selectionBetween(view, doc.resolve(parsedSel.anchor), doc.resolve(parsedSel.head));
|
||
|
}
|
||
|
// Given two same-length, non-empty fragments of inline content,
|
||
|
// determine whether the first could be created from the second by
|
||
|
// removing or adding a single mark type.
|
||
|
function isMarkChange(cur, prev) {
|
||
|
let curMarks = cur.firstChild.marks, prevMarks = prev.firstChild.marks;
|
||
|
let added = curMarks, removed = prevMarks, type, mark, update;
|
||
|
for (let i = 0; i < prevMarks.length; i++)
|
||
|
added = prevMarks[i].removeFromSet(added);
|
||
|
for (let i = 0; i < curMarks.length; i++)
|
||
|
removed = curMarks[i].removeFromSet(removed);
|
||
|
if (added.length == 1 && removed.length == 0) {
|
||
|
mark = added[0];
|
||
|
type = "add";
|
||
|
update = (node) => node.mark(mark.addToSet(node.marks));
|
||
|
}
|
||
|
else if (added.length == 0 && removed.length == 1) {
|
||
|
mark = removed[0];
|
||
|
type = "remove";
|
||
|
update = (node) => node.mark(mark.removeFromSet(node.marks));
|
||
|
}
|
||
|
else {
|
||
|
return null;
|
||
|
}
|
||
|
let updated = [];
|
||
|
for (let i = 0; i < prev.childCount; i++)
|
||
|
updated.push(update(prev.child(i)));
|
||
|
if (Fragment.from(updated).eq(cur))
|
||
|
return { mark, type };
|
||
|
}
|
||
|
function looksLikeJoin(old, start, end, $newStart, $newEnd) {
|
||
|
if (!$newStart.parent.isTextblock ||
|
||
|
// The content must have shrunk
|
||
|
end - start <= $newEnd.pos - $newStart.pos ||
|
||
|
// newEnd must point directly at or after the end of the block that newStart points into
|
||
|
skipClosingAndOpening($newStart, true, false) < $newEnd.pos)
|
||
|
return false;
|
||
|
let $start = old.resolve(start);
|
||
|
// Start must be at the end of a block
|
||
|
if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock)
|
||
|
return false;
|
||
|
let $next = old.resolve(skipClosingAndOpening($start, true, true));
|
||
|
// The next textblock must start before end and end near it
|
||
|
if (!$next.parent.isTextblock || $next.pos > end ||
|
||
|
skipClosingAndOpening($next, true, false) < end)
|
||
|
return false;
|
||
|
// The fragments after the join point must match
|
||
|
return $newStart.parent.content.cut($newStart.parentOffset).eq($next.parent.content);
|
||
|
}
|
||
|
function skipClosingAndOpening($pos, fromEnd, mayOpen) {
|
||
|
let depth = $pos.depth, end = fromEnd ? $pos.end() : $pos.pos;
|
||
|
while (depth > 0 && (fromEnd || $pos.indexAfter(depth) == $pos.node(depth).childCount)) {
|
||
|
depth--;
|
||
|
end++;
|
||
|
fromEnd = false;
|
||
|
}
|
||
|
if (mayOpen) {
|
||
|
let next = $pos.node(depth).maybeChild($pos.indexAfter(depth));
|
||
|
while (next && !next.isLeaf) {
|
||
|
next = next.firstChild;
|
||
|
end++;
|
||
|
}
|
||
|
}
|
||
|
return end;
|
||
|
}
|
||
|
function findDiff(a, b, pos, preferredPos, preferredSide) {
|
||
|
let start = a.findDiffStart(b, pos);
|
||
|
if (start == null)
|
||
|
return null;
|
||
|
let { a: endA, b: endB } = a.findDiffEnd(b, pos + a.size, pos + b.size);
|
||
|
if (preferredSide == "end") {
|
||
|
let adjust = Math.max(0, start - Math.min(endA, endB));
|
||
|
preferredPos -= endA + adjust - start;
|
||
|
}
|
||
|
if (endA < start && a.size < b.size) {
|
||
|
let move = preferredPos <= start && preferredPos >= endA ? start - preferredPos : 0;
|
||
|
start -= move;
|
||
|
endB = start + (endB - endA);
|
||
|
endA = start;
|
||
|
}
|
||
|
else if (endB < start) {
|
||
|
let move = preferredPos <= start && preferredPos >= endB ? start - preferredPos : 0;
|
||
|
start -= move;
|
||
|
endA = start + (endA - endB);
|
||
|
endB = start;
|
||
|
}
|
||
|
return { start, endA, endB };
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
const __serializeForClipboard = serializeForClipboard;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
const __parseFromClipboard = parseFromClipboard;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
const __endComposition = endComposition;
|
||
|
/**
|
||
|
An editor view manages the DOM structure that represents an
|
||
|
editable document. Its state and behavior are determined by its
|
||
|
[props](https://prosemirror.net/docs/ref/#view.DirectEditorProps).
|
||
|
*/
|
||
|
class EditorView {
|
||
|
/**
|
||
|
Create a view. `place` may be a DOM node that the editor should
|
||
|
be appended to, a function that will place it into the document,
|
||
|
or an object whose `mount` property holds the node to use as the
|
||
|
document container. If it is `null`, the editor will not be
|
||
|
added to the document.
|
||
|
*/
|
||
|
constructor(place, props) {
|
||
|
this._root = null;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
this.focused = false;
|
||
|
/**
|
||
|
Kludge used to work around a Chrome bug @internal
|
||
|
*/
|
||
|
this.trackWrites = null;
|
||
|
this.mounted = false;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
this.markCursor = null;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
this.cursorWrapper = null;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
this.lastSelectedViewDesc = undefined;
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
this.input = new InputState;
|
||
|
this.prevDirectPlugins = [];
|
||
|
this.pluginViews = [];
|
||
|
/**
|
||
|
Holds `true` when a hack node is needed in Firefox to prevent the
|
||
|
[space is eaten issue](https://github.com/ProseMirror/prosemirror/issues/651)
|
||
|
@internal
|
||
|
*/
|
||
|
this.requiresGeckoHackNode = false;
|
||
|
/**
|
||
|
When editor content is being dragged, this object contains
|
||
|
information about the dragged slice and whether it is being
|
||
|
copied or moved. At any other time, it is null.
|
||
|
*/
|
||
|
this.dragging = null;
|
||
|
this._props = props;
|
||
|
this.state = props.state;
|
||
|
this.directPlugins = props.plugins || [];
|
||
|
this.directPlugins.forEach(checkStateComponent);
|
||
|
this.dispatch = this.dispatch.bind(this);
|
||
|
this.dom = (place && place.mount) || document.createElement("div");
|
||
|
if (place) {
|
||
|
if (place.appendChild)
|
||
|
place.appendChild(this.dom);
|
||
|
else if (typeof place == "function")
|
||
|
place(this.dom);
|
||
|
else if (place.mount)
|
||
|
this.mounted = true;
|
||
|
}
|
||
|
this.editable = getEditable(this);
|
||
|
updateCursorWrapper(this);
|
||
|
this.nodeViews = buildNodeViews(this);
|
||
|
this.docView = docViewDesc(this.state.doc, computeDocDeco(this), viewDecorations(this), this.dom, this);
|
||
|
this.domObserver = new DOMObserver(this, (from, to, typeOver, added) => readDOMChange(this, from, to, typeOver, added));
|
||
|
this.domObserver.start();
|
||
|
initInput(this);
|
||
|
this.updatePluginViews();
|
||
|
}
|
||
|
/**
|
||
|
Holds `true` when a
|
||
|
[composition](https://w3c.github.io/uievents/#events-compositionevents)
|
||
|
is active.
|
||
|
*/
|
||
|
get composing() { return this.input.composing; }
|
||
|
/**
|
||
|
The view's current [props](https://prosemirror.net/docs/ref/#view.EditorProps).
|
||
|
*/
|
||
|
get props() {
|
||
|
if (this._props.state != this.state) {
|
||
|
let prev = this._props;
|
||
|
this._props = {};
|
||
|
for (let name in prev)
|
||
|
this._props[name] = prev[name];
|
||
|
this._props.state = this.state;
|
||
|
}
|
||
|
return this._props;
|
||
|
}
|
||
|
/**
|
||
|
Update the view's props. Will immediately cause an update to
|
||
|
the DOM.
|
||
|
*/
|
||
|
update(props) {
|
||
|
if (props.handleDOMEvents != this._props.handleDOMEvents)
|
||
|
ensureListeners(this);
|
||
|
let prevProps = this._props;
|
||
|
this._props = props;
|
||
|
if (props.plugins) {
|
||
|
props.plugins.forEach(checkStateComponent);
|
||
|
this.directPlugins = props.plugins;
|
||
|
}
|
||
|
this.updateStateInner(props.state, prevProps);
|
||
|
}
|
||
|
/**
|
||
|
Update the view by updating existing props object with the object
|
||
|
given as argument. Equivalent to `view.update(Object.assign({},
|
||
|
view.props, props))`.
|
||
|
*/
|
||
|
setProps(props) {
|
||
|
let updated = {};
|
||
|
for (let name in this._props)
|
||
|
updated[name] = this._props[name];
|
||
|
updated.state = this.state;
|
||
|
for (let name in props)
|
||
|
updated[name] = props[name];
|
||
|
this.update(updated);
|
||
|
}
|
||
|
/**
|
||
|
Update the editor's `state` prop, without touching any of the
|
||
|
other props.
|
||
|
*/
|
||
|
updateState(state) {
|
||
|
this.updateStateInner(state, this._props);
|
||
|
}
|
||
|
updateStateInner(state, prevProps) {
|
||
|
let prev = this.state, redraw = false, updateSel = false;
|
||
|
// When stored marks are added, stop composition, so that they can
|
||
|
// be displayed.
|
||
|
if (state.storedMarks && this.composing) {
|
||
|
clearComposition(this);
|
||
|
updateSel = true;
|
||
|
}
|
||
|
this.state = state;
|
||
|
let pluginsChanged = prev.plugins != state.plugins || this._props.plugins != prevProps.plugins;
|
||
|
if (pluginsChanged || this._props.plugins != prevProps.plugins || this._props.nodeViews != prevProps.nodeViews) {
|
||
|
let nodeViews = buildNodeViews(this);
|
||
|
if (changedNodeViews(nodeViews, this.nodeViews)) {
|
||
|
this.nodeViews = nodeViews;
|
||
|
redraw = true;
|
||
|
}
|
||
|
}
|
||
|
if (pluginsChanged || prevProps.handleDOMEvents != this._props.handleDOMEvents) {
|
||
|
ensureListeners(this);
|
||
|
}
|
||
|
this.editable = getEditable(this);
|
||
|
updateCursorWrapper(this);
|
||
|
let innerDeco = viewDecorations(this), outerDeco = computeDocDeco(this);
|
||
|
let scroll = prev.plugins != state.plugins && !prev.doc.eq(state.doc) ? "reset"
|
||
|
: state.scrollToSelection > prev.scrollToSelection ? "to selection" : "preserve";
|
||
|
let updateDoc = redraw || !this.docView.matchesNode(state.doc, outerDeco, innerDeco);
|
||
|
if (updateDoc || !state.selection.eq(prev.selection))
|
||
|
updateSel = true;
|
||
|
let oldScrollPos = scroll == "preserve" && updateSel && this.dom.style.overflowAnchor == null && storeScrollPos(this);
|
||
|
if (updateSel) {
|
||
|
this.domObserver.stop();
|
||
|
// Work around an issue in Chrome, IE, and Edge where changing
|
||
|
// the DOM around an active selection puts it into a broken
|
||
|
// state where the thing the user sees differs from the
|
||
|
// selection reported by the Selection object (#710, #973,
|
||
|
// #1011, #1013, #1035).
|
||
|
let forceSelUpdate = updateDoc && (ie || chrome) && !this.composing &&
|
||
|
!prev.selection.empty && !state.selection.empty && selectionContextChanged(prev.selection, state.selection);
|
||
|
if (updateDoc) {
|
||
|
// If the node that the selection points into is written to,
|
||
|
// Chrome sometimes starts misreporting the selection, so this
|
||
|
// tracks that and forces a selection reset when our update
|
||
|
// did write to the node.
|
||
|
let chromeKludge = chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null;
|
||
|
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
|
||
|
this.docView.updateOuterDeco([]);
|
||
|
this.docView.destroy();
|
||
|
this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this);
|
||
|
}
|
||
|
if (chromeKludge && !this.trackWrites)
|
||
|
forceSelUpdate = true;
|
||
|
}
|
||
|
// Work around for an issue where an update arriving right between
|
||
|
// a DOM selection change and the "selectionchange" event for it
|
||
|
// can cause a spurious DOM selection update, disrupting mouse
|
||
|
// drag selection.
|
||
|
if (forceSelUpdate ||
|
||
|
!(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) &&
|
||
|
anchorInRightPlace(this))) {
|
||
|
selectionToDOM(this, forceSelUpdate);
|
||
|
}
|
||
|
else {
|
||
|
syncNodeSelection(this, state.selection);
|
||
|
this.domObserver.setCurSelection();
|
||
|
}
|
||
|
this.domObserver.start();
|
||
|
}
|
||
|
this.updatePluginViews(prev);
|
||
|
if (scroll == "reset") {
|
||
|
this.dom.scrollTop = 0;
|
||
|
}
|
||
|
else if (scroll == "to selection") {
|
||
|
this.scrollToSelection();
|
||
|
}
|
||
|
else if (oldScrollPos) {
|
||
|
resetScrollPos(oldScrollPos);
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
scrollToSelection() {
|
||
|
let startDOM = this.domSelectionRange().focusNode;
|
||
|
if (this.someProp("handleScrollToSelection", f => f(this))) ;
|
||
|
else if (this.state.selection instanceof NodeSelection) {
|
||
|
let target = this.docView.domAfterPos(this.state.selection.from);
|
||
|
if (target.nodeType == 1)
|
||
|
scrollRectIntoView(this, target.getBoundingClientRect(), startDOM);
|
||
|
}
|
||
|
else {
|
||
|
scrollRectIntoView(this, this.coordsAtPos(this.state.selection.head, 1), startDOM);
|
||
|
}
|
||
|
}
|
||
|
destroyPluginViews() {
|
||
|
let view;
|
||
|
while (view = this.pluginViews.pop())
|
||
|
if (view.destroy)
|
||
|
view.destroy();
|
||
|
}
|
||
|
updatePluginViews(prevState) {
|
||
|
if (!prevState || prevState.plugins != this.state.plugins || this.directPlugins != this.prevDirectPlugins) {
|
||
|
this.prevDirectPlugins = this.directPlugins;
|
||
|
this.destroyPluginViews();
|
||
|
for (let i = 0; i < this.directPlugins.length; i++) {
|
||
|
let plugin = this.directPlugins[i];
|
||
|
if (plugin.spec.view)
|
||
|
this.pluginViews.push(plugin.spec.view(this));
|
||
|
}
|
||
|
for (let i = 0; i < this.state.plugins.length; i++) {
|
||
|
let plugin = this.state.plugins[i];
|
||
|
if (plugin.spec.view)
|
||
|
this.pluginViews.push(plugin.spec.view(this));
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
for (let i = 0; i < this.pluginViews.length; i++) {
|
||
|
let pluginView = this.pluginViews[i];
|
||
|
if (pluginView.update)
|
||
|
pluginView.update(this, prevState);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
someProp(propName, f) {
|
||
|
let prop = this._props && this._props[propName], value;
|
||
|
if (prop != null && (value = f ? f(prop) : prop))
|
||
|
return value;
|
||
|
for (let i = 0; i < this.directPlugins.length; i++) {
|
||
|
let prop = this.directPlugins[i].props[propName];
|
||
|
if (prop != null && (value = f ? f(prop) : prop))
|
||
|
return value;
|
||
|
}
|
||
|
let plugins = this.state.plugins;
|
||
|
if (plugins)
|
||
|
for (let i = 0; i < plugins.length; i++) {
|
||
|
let prop = plugins[i].props[propName];
|
||
|
if (prop != null && (value = f ? f(prop) : prop))
|
||
|
return value;
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
Query whether the view has focus.
|
||
|
*/
|
||
|
hasFocus() {
|
||
|
// Work around IE not handling focus correctly if resize handles are shown.
|
||
|
// If the cursor is inside an element with resize handles, activeElement
|
||
|
// will be that element instead of this.dom.
|
||
|
if (ie) {
|
||
|
// If activeElement is within this.dom, and there are no other elements
|
||
|
// setting `contenteditable` to false in between, treat it as focused.
|
||
|
let node = this.root.activeElement;
|
||
|
if (node == this.dom)
|
||
|
return true;
|
||
|
if (!node || !this.dom.contains(node))
|
||
|
return false;
|
||
|
while (node && this.dom != node && this.dom.contains(node)) {
|
||
|
if (node.contentEditable == 'false')
|
||
|
return false;
|
||
|
node = node.parentElement;
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
return this.root.activeElement == this.dom;
|
||
|
}
|
||
|
/**
|
||
|
Focus the editor.
|
||
|
*/
|
||
|
focus() {
|
||
|
this.domObserver.stop();
|
||
|
if (this.editable)
|
||
|
focusPreventScroll(this.dom);
|
||
|
selectionToDOM(this);
|
||
|
this.domObserver.start();
|
||
|
}
|
||
|
/**
|
||
|
Get the document root in which the editor exists. This will
|
||
|
usually be the top-level `document`, but might be a [shadow
|
||
|
DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM)
|
||
|
root if the editor is inside one.
|
||
|
*/
|
||
|
get root() {
|
||
|
let cached = this._root;
|
||
|
if (cached == null)
|
||
|
for (let search = this.dom.parentNode; search; search = search.parentNode) {
|
||
|
if (search.nodeType == 9 || (search.nodeType == 11 && search.host)) {
|
||
|
if (!search.getSelection)
|
||
|
Object.getPrototypeOf(search).getSelection = () => search.ownerDocument.getSelection();
|
||
|
return this._root = search;
|
||
|
}
|
||
|
}
|
||
|
return cached || document;
|
||
|
}
|
||
|
/**
|
||
|
Given a pair of viewport coordinates, return the document
|
||
|
position that corresponds to them. May return null if the given
|
||
|
coordinates aren't inside of the editor. When an object is
|
||
|
returned, its `pos` property is the position nearest to the
|
||
|
coordinates, and its `inside` property holds the position of the
|
||
|
inner node that the position falls inside of, or -1 if it is at
|
||
|
the top level, not in any node.
|
||
|
*/
|
||
|
posAtCoords(coords) {
|
||
|
return posAtCoords(this, coords);
|
||
|
}
|
||
|
/**
|
||
|
Returns the viewport rectangle at a given document position.
|
||
|
`left` and `right` will be the same number, as this returns a
|
||
|
flat cursor-ish rectangle. If the position is between two things
|
||
|
that aren't directly adjacent, `side` determines which element
|
||
|
is used. When < 0, the element before the position is used,
|
||
|
otherwise the element after.
|
||
|
*/
|
||
|
coordsAtPos(pos, side = 1) {
|
||
|
return coordsAtPos(this, pos, side);
|
||
|
}
|
||
|
/**
|
||
|
Find the DOM position that corresponds to the given document
|
||
|
position. When `side` is negative, find the position as close as
|
||
|
possible to the content before the position. When positive,
|
||
|
prefer positions close to the content after the position. When
|
||
|
zero, prefer as shallow a position as possible.
|
||
|
|
||
|
Note that you should **not** mutate the editor's internal DOM,
|
||
|
only inspect it (and even that is usually not necessary).
|
||
|
*/
|
||
|
domAtPos(pos, side = 0) {
|
||
|
return this.docView.domFromPos(pos, side);
|
||
|
}
|
||
|
/**
|
||
|
Find the DOM node that represents the document node after the
|
||
|
given position. May return `null` when the position doesn't point
|
||
|
in front of a node or if the node is inside an opaque node view.
|
||
|
|
||
|
This is intended to be able to call things like
|
||
|
`getBoundingClientRect` on that DOM node. Do **not** mutate the
|
||
|
editor DOM directly, or add styling this way, since that will be
|
||
|
immediately overriden by the editor as it redraws the node.
|
||
|
*/
|
||
|
nodeDOM(pos) {
|
||
|
let desc = this.docView.descAt(pos);
|
||
|
return desc ? desc.nodeDOM : null;
|
||
|
}
|
||
|
/**
|
||
|
Find the document position that corresponds to a given DOM
|
||
|
position. (Whenever possible, it is preferable to inspect the
|
||
|
document structure directly, rather than poking around in the
|
||
|
DOM, but sometimes—for example when interpreting an event
|
||
|
target—you don't have a choice.)
|
||
|
|
||
|
The `bias` parameter can be used to influence which side of a DOM
|
||
|
node to use when the position is inside a leaf node.
|
||
|
*/
|
||
|
posAtDOM(node, offset, bias = -1) {
|
||
|
let pos = this.docView.posFromDOM(node, offset, bias);
|
||
|
if (pos == null)
|
||
|
throw new RangeError("DOM position not inside the editor");
|
||
|
return pos;
|
||
|
}
|
||
|
/**
|
||
|
Find out whether the selection is at the end of a textblock when
|
||
|
moving in a given direction. When, for example, given `"left"`,
|
||
|
it will return true if moving left from the current cursor
|
||
|
position would leave that position's parent textblock. Will apply
|
||
|
to the view's current state by default, but it is possible to
|
||
|
pass a different state.
|
||
|
*/
|
||
|
endOfTextblock(dir, state) {
|
||
|
return endOfTextblock(this, state || this.state, dir);
|
||
|
}
|
||
|
/**
|
||
|
Removes the editor from the DOM and destroys all [node
|
||
|
views](https://prosemirror.net/docs/ref/#view.NodeView).
|
||
|
*/
|
||
|
destroy() {
|
||
|
if (!this.docView)
|
||
|
return;
|
||
|
destroyInput(this);
|
||
|
this.destroyPluginViews();
|
||
|
if (this.mounted) {
|
||
|
this.docView.update(this.state.doc, [], viewDecorations(this), this);
|
||
|
this.dom.textContent = "";
|
||
|
}
|
||
|
else if (this.dom.parentNode) {
|
||
|
this.dom.parentNode.removeChild(this.dom);
|
||
|
}
|
||
|
this.docView.destroy();
|
||
|
this.docView = null;
|
||
|
}
|
||
|
/**
|
||
|
This is true when the view has been
|
||
|
[destroyed](https://prosemirror.net/docs/ref/#view.EditorView.destroy) (and thus should not be
|
||
|
used anymore).
|
||
|
*/
|
||
|
get isDestroyed() {
|
||
|
return this.docView == null;
|
||
|
}
|
||
|
/**
|
||
|
Used for testing.
|
||
|
*/
|
||
|
dispatchEvent(event) {
|
||
|
return dispatchEvent(this, event);
|
||
|
}
|
||
|
/**
|
||
|
Dispatch a transaction. Will call
|
||
|
[`dispatchTransaction`](https://prosemirror.net/docs/ref/#view.DirectEditorProps.dispatchTransaction)
|
||
|
when given, and otherwise defaults to applying the transaction to
|
||
|
the current state and calling
|
||
|
[`updateState`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) with the result.
|
||
|
This method is bound to the view instance, so that it can be
|
||
|
easily passed around.
|
||
|
*/
|
||
|
dispatch(tr) {
|
||
|
let dispatchTransaction = this._props.dispatchTransaction;
|
||
|
if (dispatchTransaction)
|
||
|
dispatchTransaction.call(this, tr);
|
||
|
else
|
||
|
this.updateState(this.state.apply(tr));
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
domSelectionRange() {
|
||
|
return safari && this.root.nodeType === 11 && deepActiveElement(this.dom.ownerDocument) == this.dom
|
||
|
? safariShadowSelectionRange(this) : this.domSelection();
|
||
|
}
|
||
|
/**
|
||
|
@internal
|
||
|
*/
|
||
|
domSelection() {
|
||
|
return this.root.getSelection();
|
||
|
}
|
||
|
}
|
||
|
function computeDocDeco(view) {
|
||
|
let attrs = Object.create(null);
|
||
|
attrs.class = "ProseMirror";
|
||
|
attrs.contenteditable = String(view.editable);
|
||
|
attrs.translate = "no";
|
||
|
view.someProp("attributes", value => {
|
||
|
if (typeof value == "function")
|
||
|
value = value(view.state);
|
||
|
if (value)
|
||
|
for (let attr in value) {
|
||
|
if (attr == "class")
|
||
|
attrs.class += " " + value[attr];
|
||
|
if (attr == "style") {
|
||
|
attrs.style = (attrs.style ? attrs.style + ";" : "") + value[attr];
|
||
|
}
|
||
|
else if (!attrs[attr] && attr != "contenteditable" && attr != "nodeName")
|
||
|
attrs[attr] = String(value[attr]);
|
||
|
}
|
||
|
});
|
||
|
return [Decoration.node(0, view.state.doc.content.size, attrs)];
|
||
|
}
|
||
|
function updateCursorWrapper(view) {
|
||
|
if (view.markCursor) {
|
||
|
let dom = document.createElement("img");
|
||
|
dom.className = "ProseMirror-separator";
|
||
|
dom.setAttribute("mark-placeholder", "true");
|
||
|
dom.setAttribute("alt", "");
|
||
|
view.cursorWrapper = { dom, deco: Decoration.widget(view.state.selection.head, dom, { raw: true, marks: view.markCursor }) };
|
||
|
}
|
||
|
else {
|
||
|
view.cursorWrapper = null;
|
||
|
}
|
||
|
}
|
||
|
function getEditable(view) {
|
||
|
return !view.someProp("editable", value => value(view.state) === false);
|
||
|
}
|
||
|
function selectionContextChanged(sel1, sel2) {
|
||
|
let depth = Math.min(sel1.$anchor.sharedDepth(sel1.head), sel2.$anchor.sharedDepth(sel2.head));
|
||
|
return sel1.$anchor.start(depth) != sel2.$anchor.start(depth);
|
||
|
}
|
||
|
function buildNodeViews(view) {
|
||
|
let result = Object.create(null);
|
||
|
function add(obj) {
|
||
|
for (let prop in obj)
|
||
|
if (!Object.prototype.hasOwnProperty.call(result, prop))
|
||
|
result[prop] = obj[prop];
|
||
|
}
|
||
|
view.someProp("nodeViews", add);
|
||
|
view.someProp("markViews", add);
|
||
|
return result;
|
||
|
}
|
||
|
function changedNodeViews(a, b) {
|
||
|
let nA = 0, nB = 0;
|
||
|
for (let prop in a) {
|
||
|
if (a[prop] != b[prop])
|
||
|
return true;
|
||
|
nA++;
|
||
|
}
|
||
|
for (let _ in b)
|
||
|
nB++;
|
||
|
return nA != nB;
|
||
|
}
|
||
|
function checkStateComponent(plugin) {
|
||
|
if (plugin.spec.state || plugin.spec.filterTransaction || plugin.spec.appendTransaction)
|
||
|
throw new RangeError("Plugins passed directly to the view must not have a state component");
|
||
|
}
|
||
|
|
||
|
export { Decoration, DecorationSet, EditorView, __endComposition, __parseFromClipboard, __serializeForClipboard };
|