-
-
Save marijnh/8c180c05d0f70e8c725507a42b1a4b87 to your computer and use it in GitHub Desktop.
view/dist/index.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { MapMode, Facet, Text as Text$1, EditorSelection, ChangeSet, Transaction, CharCategory, EditorState, precedence, StateField, combineConfig } from '@codemirror/next/state'; | |
import { StyleModule } from 'style-mod'; | |
import { RangeValue, RangeSet } from '@codemirror/next/rangeset'; | |
export { Range } from '@codemirror/next/rangeset'; | |
import { Text, countColumn, findColumn, codePointAt } from '@codemirror/next/text'; | |
import { keyName, base } from 'w3c-keyname'; | |
let [nav, doc] = typeof navigator != "undefined" | |
? [navigator, document] | |
: [{ userAgent: "", vendor: "", platform: "" }, { documentElement: { style: {} } }]; | |
const ie_edge = /Edge\/(\d+)/.exec(nav.userAgent); | |
const ie_upto10 = /MSIE \d/.test(nav.userAgent); | |
const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent); | |
const ie = !!(ie_upto10 || ie_11up || ie_edge); | |
const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent); | |
const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent); | |
const webkit = "webkitFontSmoothing" in doc.documentElement.style; | |
var browser = { | |
mac: /Mac/.test(nav.platform), | |
ie, | |
ie_version: ie_upto10 ? doc.documentMode || 6 : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0, | |
gecko, | |
gecko_version: gecko ? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1] : 0, | |
chrome: !!chrome, | |
chrome_version: chrome ? +chrome[1] : 0, | |
ios: !ie && /AppleWebKit/.test(nav.userAgent) && /Mobile\/\w+/.test(nav.userAgent), | |
android: /Android\b/.test(nav.userAgent), | |
webkit, | |
safari: /Apple Computer/.test(nav.vendor), | |
webkit_version: webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0, | |
tabSize: doc.documentElement.style.tabSize != null ? "tab-size" : "-moz-tab-size" | |
}; | |
function getSelection(root) { | |
return (root.getSelection ? root.getSelection() : document.getSelection()); | |
} | |
// Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523 | |
// (isCollapsed inappropriately returns true in shadow dom) | |
function selectionCollapsed(domSel) { | |
let collapsed = domSel.isCollapsed; | |
if (collapsed && browser.chrome && domSel.rangeCount && !domSel.getRangeAt(0).collapsed) | |
collapsed = false; | |
return collapsed; | |
} | |
function hasSelection(dom, selection) { | |
if (!selection.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 dom.contains(selection.anchorNode.nodeType == 3 ? selection.anchorNode.parentNode : selection.anchorNode); | |
} | |
catch (_) { | |
return false; | |
} | |
} | |
function clientRectsFor(dom) { | |
if (dom.nodeType == 3) { | |
let range = document.createRange(); | |
range.setEnd(dom, dom.nodeValue.length); | |
range.setStart(dom, 0); | |
return range.getClientRects(); | |
} | |
else if (dom.nodeType == 1) { | |
return dom.getClientRects(); | |
} | |
else { | |
return []; | |
} | |
} | |
// 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) | |
function isEquivalentPosition(node, off, targetNode, targetOff) { | |
return targetNode ? (scanFor(node, off, targetNode, targetOff, -1) || | |
scanFor(node, off, targetNode, targetOff, 1)) : false; | |
} | |
function domIndex(node) { | |
for (var index = 0;; index++) { | |
node = node.previousSibling; | |
if (!node) | |
return index; | |
} | |
} | |
function scanFor(node, off, targetNode, targetOff, dir) { | |
for (;;) { | |
if (node == targetNode && off == targetOff) | |
return true; | |
if (off == (dir < 0 ? 0 : maxOffset(node))) { | |
if (node.nodeName == "DIV") | |
return false; | |
let parent = node.parentNode; | |
if (!parent || parent.nodeType != 1) | |
return false; | |
off = domIndex(node) + (dir < 0 ? 0 : 1); | |
node = parent; | |
} | |
else if (node.nodeType == 1) { | |
node = node.childNodes[off + (dir < 0 ? -1 : 0)]; | |
off = dir < 0 ? maxOffset(node) : 0; | |
} | |
else { | |
return false; | |
} | |
} | |
} | |
function maxOffset(node) { | |
return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length; | |
} | |
function flattenRect(rect, left) { | |
let x = left ? rect.left : rect.right; | |
return { left: x, right: x, top: rect.top, bottom: rect.bottom }; | |
} | |
function windowRect(win) { | |
return { left: 0, right: win.innerWidth, | |
top: 0, bottom: win.innerHeight }; | |
} | |
const ScrollSpace = 5; | |
function scrollRectIntoView(dom, rect) { | |
let doc = dom.ownerDocument, win = doc.defaultView; | |
for (let cur = dom.parentNode; cur;) { | |
if (cur.nodeType == 1) { // Element | |
let bounding, top = cur == document.body; | |
if (top) { | |
bounding = windowRect(win); | |
} | |
else { | |
if (cur.scrollHeight <= cur.clientHeight && cur.scrollWidth <= cur.clientWidth) { | |
cur = cur.parentNode; | |
continue; | |
} | |
let rect = cur.getBoundingClientRect(); | |
// Make sure scrollbar width isn't included in the rectangle | |
bounding = { left: rect.left, right: rect.left + cur.clientWidth, | |
top: rect.top, bottom: rect.top + cur.clientHeight }; | |
} | |
let moveX = 0, moveY = 0; | |
if (rect.top < bounding.top) | |
moveY = -(bounding.top - rect.top + ScrollSpace); | |
else if (rect.bottom > bounding.bottom) | |
moveY = rect.bottom - bounding.bottom + ScrollSpace; | |
if (rect.left < bounding.left) | |
moveX = -(bounding.left - rect.left + ScrollSpace); | |
else if (rect.right > bounding.right) | |
moveX = rect.right - bounding.right + ScrollSpace; | |
if (moveX || moveY) { | |
if (top) { | |
win.scrollBy(moveX, moveY); | |
} | |
else { | |
if (moveY) { | |
let start = cur.scrollTop; | |
cur.scrollTop += moveY; | |
moveY = cur.scrollTop - start; | |
} | |
if (moveX) { | |
let start = cur.scrollLeft; | |
cur.scrollLeft += moveX; | |
moveX = cur.scrollLeft - start; | |
} | |
rect = { left: rect.left - moveX, top: rect.top - moveY, | |
right: rect.right - moveX, bottom: rect.bottom - moveY }; | |
} | |
} | |
if (top) | |
break; | |
cur = cur.parentNode; | |
} | |
else if (cur.nodeType == 11) { // A shadow root | |
cur = cur.host; | |
} | |
else { | |
break; | |
} | |
} | |
} | |
class DOMSelection { | |
constructor() { | |
this.anchorNode = null; | |
this.anchorOffset = 0; | |
this.focusNode = null; | |
this.focusOffset = 0; | |
} | |
eq(domSel) { | |
return this.anchorNode == domSel.anchorNode && this.anchorOffset == domSel.anchorOffset && | |
this.focusNode == domSel.focusNode && this.focusOffset == domSel.focusOffset; | |
} | |
set(domSel) { | |
this.anchorNode = domSel.anchorNode; | |
this.anchorOffset = domSel.anchorOffset; | |
this.focusNode = domSel.focusNode; | |
this.focusOffset = domSel.focusOffset; | |
} | |
} | |
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 stack = []; | |
for (let cur = dom; cur; cur = cur.parentNode) { | |
stack.push(cur, cur.scrollTop, cur.scrollLeft); | |
if (cur == cur.ownerDocument) | |
break; | |
} | |
dom.focus(preventScrollSupported == null ? { | |
get preventScroll() { | |
preventScrollSupported = { preventScroll: true }; | |
return true; | |
} | |
} : undefined); | |
if (!preventScrollSupported) { | |
preventScrollSupported = false; | |
for (let i = 0; i < stack.length;) { | |
let elt = stack[i++], top = stack[i++], left = stack[i++]; | |
if (elt.scrollTop != top) | |
elt.scrollTop = top; | |
if (elt.scrollLeft != left) | |
elt.scrollLeft = left; | |
} | |
} | |
} | |
class DOMPos { | |
constructor(node, offset, precise = true) { | |
this.node = node; | |
this.offset = offset; | |
this.precise = precise; | |
} | |
static before(dom, precise) { return new DOMPos(dom.parentNode, domIndex(dom), precise); } | |
static after(dom, precise) { return new DOMPos(dom.parentNode, domIndex(dom) + 1, precise); } | |
} | |
const none = []; | |
class ContentView { | |
constructor() { | |
this.parent = null; | |
this.dom = null; | |
this.dirty = 2 /* Node */; | |
} | |
get editorView() { | |
if (!this.parent) | |
throw new Error("Accessing view in orphan content view"); | |
return this.parent.editorView; | |
} | |
get overrideDOMText() { return null; } | |
get posAtStart() { | |
return this.parent ? this.parent.posBefore(this) : 0; | |
} | |
get posAtEnd() { | |
return this.posAtStart + this.length; | |
} | |
posBefore(view) { | |
let pos = this.posAtStart; | |
for (let child of this.children) { | |
if (child == view) | |
return pos; | |
pos += child.length + child.breakAfter; | |
} | |
throw new RangeError("Invalid child in posBefore"); | |
} | |
posAfter(view) { | |
return this.posBefore(view) + view.length; | |
} | |
// Will return a rectangle directly before (when side < 0), after | |
// (side > 0) or directly on (when the browser supports it) the | |
// given position. | |
coordsAt(_pos, _side) { return null; } | |
sync(track) { | |
if (this.dirty & 2 /* Node */) { | |
let parent = this.dom, pos = null; | |
for (let child of this.children) { | |
if (child.dirty) { | |
let next = pos ? pos.nextSibling : parent.firstChild; | |
if (next && !child.dom && !ContentView.get(next)) | |
child.reuseDOM(next); | |
child.sync(track); | |
child.dirty = 0 /* Not */; | |
} | |
if (track && track.node == parent && pos != child.dom) | |
track.written = true; | |
syncNodeInto(parent, pos, child.dom); | |
pos = child.dom; | |
} | |
let next = pos ? pos.nextSibling : parent.firstChild; | |
if (next && track && track.node == parent) | |
track.written = true; | |
while (next) | |
next = rm(next); | |
} | |
else if (this.dirty & 1 /* Child */) { | |
for (let child of this.children) | |
if (child.dirty) { | |
child.sync(track); | |
child.dirty = 0 /* Not */; | |
} | |
} | |
} | |
reuseDOM(_dom) { return false; } | |
localPosFromDOM(node, offset) { | |
let after; | |
if (node == this.dom) { | |
after = this.dom.childNodes[offset]; | |
} | |
else { | |
let bias = maxOffset(node) == 0 ? 0 : offset == 0 ? -1 : 1; | |
for (;;) { | |
let parent = node.parentNode; | |
if (parent == this.dom) | |
break; | |
if (bias == 0 && parent.firstChild != parent.lastChild) { | |
if (node == parent.firstChild) | |
bias = -1; | |
else | |
bias = 1; | |
} | |
node = parent; | |
} | |
if (bias < 0) | |
after = node; | |
else | |
after = node.nextSibling; | |
} | |
if (after == this.dom.firstChild) | |
return 0; | |
while (after && !ContentView.get(after)) | |
after = after.nextSibling; | |
if (!after) | |
return this.length; | |
for (let i = 0, pos = 0;; i++) { | |
let child = this.children[i]; | |
if (child.dom == after) | |
return pos; | |
pos += child.length + child.breakAfter; | |
} | |
} | |
domBoundsAround(from, to, offset = 0) { | |
let fromI = -1, fromStart = -1, toI = -1, toEnd = -1; | |
for (let i = 0, pos = offset; i < this.children.length; i++) { | |
let child = this.children[i], end = pos + child.length; | |
if (pos < from && end > to) | |
return child.domBoundsAround(from, to, pos); | |
if (end >= from && fromI == -1) { | |
fromI = i; | |
fromStart = pos; | |
} | |
if (end >= to && toI == -1) { | |
toI = i; | |
toEnd = end; | |
break; | |
} | |
pos = end + child.breakAfter; | |
} | |
return { from: fromStart, to: toEnd, startDOM: (fromI ? this.children[fromI - 1].dom.nextSibling : null) || this.dom.firstChild, endDOM: toI < this.children.length - 1 ? this.children[toI + 1].dom : null }; | |
} | |
// FIXME track precise dirty ranges, to avoid full DOM sync on every touched node? | |
markDirty(andParent = false) { | |
if (this.dirty & 2 /* Node */) | |
return; | |
this.dirty |= 2 /* Node */; | |
this.markParentsDirty(andParent); | |
} | |
markParentsDirty(childList) { | |
for (let parent = this.parent; parent; parent = parent.parent) { | |
if (childList) | |
parent.dirty |= 2 /* Node */; | |
if (parent.dirty & 1 /* Child */) | |
return; | |
parent.dirty |= 1 /* Child */; | |
childList = false; | |
} | |
} | |
setParent(parent) { | |
if (this.parent != parent) { | |
this.parent = parent; | |
if (this.dirty) | |
this.markParentsDirty(true); | |
} | |
} | |
setDOM(dom) { | |
this.dom = dom; | |
dom.cmView = this; | |
} | |
get rootView() { | |
for (let v = this;;) { | |
let parent = v.parent; | |
if (!parent) | |
return v; | |
v = parent; | |
} | |
} | |
replaceChildren(from, to, children = none) { | |
this.markDirty(); | |
for (let i = from; i < to; i++) | |
this.children[i].parent = null; | |
this.children.splice(from, to - from, ...children); | |
for (let i = 0; i < children.length; i++) | |
children[i].setParent(this); | |
} | |
ignoreMutation(_rec) { return false; } | |
ignoreEvent(_event) { return false; } | |
childCursor(pos = this.length) { | |
return new ChildCursor(this.children, pos, this.children.length); | |
} | |
childPos(pos, bias = 1) { | |
return this.childCursor().findPos(pos, bias); | |
} | |
toString() { | |
let name = this.constructor.name.replace("View", ""); | |
return name + (this.children.length ? "(" + this.children.join() + ")" : | |
this.length ? "[" + (name == "Text" ? this.text : this.length) + "]" : "") + | |
(this.breakAfter ? "#" : ""); | |
} | |
static get(node) { return node.cmView; } | |
} | |
ContentView.prototype.breakAfter = 0; | |
// Remove a DOM node and return its next sibling. | |
function rm(dom) { | |
let next = dom.nextSibling; | |
dom.parentNode.removeChild(dom); | |
return next; | |
} | |
function syncNodeInto(parent, after, dom) { | |
let next = after ? after.nextSibling : parent.firstChild; | |
if (dom.parentNode == parent) | |
while (next != dom) | |
next = rm(next); | |
else | |
parent.insertBefore(dom, next); | |
} | |
class ChildCursor { | |
constructor(children, pos, i) { | |
this.children = children; | |
this.pos = pos; | |
this.i = i; | |
this.off = 0; | |
} | |
findPos(pos, bias = 1) { | |
for (;;) { | |
if (pos > this.pos || pos == this.pos && | |
(bias > 0 || this.i == 0 || this.children[this.i - 1].breakAfter)) { | |
this.off = pos - this.pos; | |
return this; | |
} | |
let next = this.children[--this.i]; | |
this.pos -= next.length + next.breakAfter; | |
} | |
} | |
} | |
function combineAttrs(source, target) { | |
for (let name in source) { | |
if (name == "class" && target.class) | |
target.class += " " + source.class; | |
else if (name == "style" && target.style) | |
target.style += ";" + source.style; | |
else | |
target[name] = source[name]; | |
} | |
return target; | |
} | |
function attrsEq(a, b) { | |
if (a == b) | |
return true; | |
if (!a || !b) | |
return false; | |
let keysA = Object.keys(a), keysB = Object.keys(b); | |
if (keysA.length != keysB.length) | |
return false; | |
for (let key of keysA) { | |
if (keysB.indexOf(key) == -1 || a[key] !== b[key]) | |
return false; | |
} | |
return true; | |
} | |
function updateAttrs(dom, prev, attrs) { | |
if (prev) | |
for (let name in prev) | |
if (!(attrs && name in attrs)) | |
dom.removeAttribute(name); | |
if (attrs) | |
for (let name in attrs) | |
if (!(prev && prev[name] == attrs[name])) | |
dom.setAttribute(name, attrs[name]); | |
} | |
const none$1 = []; | |
class InlineView extends ContentView { | |
match(_other) { return false; } | |
get children() { return none$1; } | |
getSide() { return 0; } | |
} | |
const MaxJoinLen = 256; | |
class TextView extends InlineView { | |
constructor(text, tagName, clss, attrs) { | |
super(); | |
this.text = text; | |
this.tagName = tagName; | |
this.attrs = attrs; | |
this.textDOM = null; | |
this.class = clss; | |
} | |
get length() { return this.text.length; } | |
createDOM(textDOM) { | |
let tagName = this.tagName || (this.attrs || this.class ? "span" : null); | |
this.textDOM = textDOM || document.createTextNode(this.text); | |
if (tagName) { | |
let dom = document.createElement(tagName); | |
dom.appendChild(this.textDOM); | |
if (this.class) | |
dom.className = this.class; | |
if (this.attrs) | |
for (let name in this.attrs) | |
dom.setAttribute(name, this.attrs[name]); | |
this.setDOM(dom); | |
} | |
else { | |
this.setDOM(this.textDOM); | |
} | |
} | |
sync(track) { | |
if (!this.dom) | |
this.createDOM(); | |
if (this.textDOM.nodeValue != this.text) { | |
if (track && track.node == this.textDOM) | |
track.written = true; | |
this.textDOM.nodeValue = this.text; | |
let dom = this.dom; | |
if (this.textDOM != dom && (this.dom.firstChild != this.textDOM || dom.lastChild != this.textDOM)) { | |
while (dom.firstChild) | |
dom.removeChild(dom.firstChild); | |
dom.appendChild(this.textDOM); | |
} | |
} | |
} | |
reuseDOM(dom) { | |
if (dom.nodeType != 3) | |
return false; | |
this.createDOM(dom); | |
return true; | |
} | |
merge(from, to = this.length, source = null) { | |
if (source && | |
(!(source instanceof TextView) || | |
source.tagName != this.tagName || source.class != this.class || | |
!attrsEq(source.attrs, this.attrs) || this.length - (to - from) + source.length > MaxJoinLen)) | |
return false; | |
this.text = this.text.slice(0, from) + (source ? source.text : "") + this.text.slice(to); | |
this.markDirty(); | |
return true; | |
} | |
slice(from, to = this.length) { | |
return new TextView(this.text.slice(from, to), this.tagName, this.class, this.attrs); | |
} | |
localPosFromDOM(node, offset) { | |
return node == this.textDOM ? offset : offset ? this.text.length : 0; | |
} | |
domAtPos(pos) { return new DOMPos(this.textDOM, pos); } | |
domBoundsAround(_from, _to, offset) { | |
return { from: offset, to: offset + this.length, startDOM: this.dom, endDOM: this.dom.nextSibling }; | |
} | |
coordsAt(pos, side) { | |
return textCoords(this.textDOM, pos, side, this.length); | |
} | |
} | |
function textCoords(text, pos, side, length) { | |
let from = pos, to = pos, flatten = 0; | |
if (pos == 0 && side < 0 || pos == length && side >= 0) { | |
if (!(browser.chrome || browser.gecko)) { // These browsers reliably return valid rectangles for empty ranges | |
if (pos) { | |
from--; | |
flatten = 1; | |
} // FIXME this is wrong in RTL text | |
else { | |
to++; | |
flatten = -1; | |
} | |
} | |
} | |
else { | |
if (side < 0) | |
from--; | |
else | |
to++; | |
} | |
let range = document.createRange(); | |
range.setEnd(text, to); | |
range.setStart(text, from); | |
let rect = range.getBoundingClientRect(); | |
return flatten ? flattenRect(rect, flatten < 0) : rect; | |
} | |
// Also used for collapsed ranges that don't have a placeholder widget! | |
class WidgetView extends InlineView { | |
constructor(widget, length, side, open) { | |
super(); | |
this.widget = widget; | |
this.length = length; | |
this.side = side; | |
this.open = open; | |
} | |
static create(widget, length, side, open = 0) { | |
return new (widget.customView || WidgetView)(widget, length, side, open); | |
} | |
slice(from, to = this.length) { return WidgetView.create(this.widget, to - from, this.side); } | |
sync() { | |
if (!this.dom || !this.widget.updateDOM(this.dom)) { | |
this.setDOM(this.widget.toDOM(this.editorView)); | |
this.dom.contentEditable = "false"; | |
} | |
} | |
getSide() { return this.side; } | |
merge(from, to = this.length, source = null) { | |
if (source) { | |
if (!(source instanceof WidgetView) || !source.open || | |
from > 0 && !(source.open & 1 /* Start */) || | |
to < this.length && !(source.open & 2 /* End */)) | |
return false; | |
if (!this.widget.compare(source.widget)) | |
throw new Error("Trying to merge incompatible widgets"); | |
} | |
this.length = from + (source ? source.length : 0) + (this.length - to); | |
return true; | |
} | |
match(other) { | |
if (other.length == this.length && other instanceof WidgetView && other.side == this.side) { | |
if (this.widget.constructor == other.widget.constructor) { | |
if (!this.widget.eq(other.widget.value)) | |
this.markDirty(true); | |
this.widget = other.widget; | |
return true; | |
} | |
} | |
return false; | |
} | |
ignoreMutation() { return true; } | |
ignoreEvent(event) { return this.widget.ignoreEvent(event); } | |
get overrideDOMText() { | |
if (this.length == 0) | |
return Text.empty; | |
let top = this; | |
while (top.parent) | |
top = top.parent; | |
let view = top.editorView, text = view && view.state.doc, start = this.posAtStart; | |
return text ? text.slice(start, start + this.length) : Text.empty; | |
} | |
domAtPos(pos) { | |
return pos == 0 ? DOMPos.before(this.dom) : DOMPos.after(this.dom, pos == this.length); | |
} | |
domBoundsAround() { return null; } | |
coordsAt(pos, _side) { | |
let rects = this.dom.getClientRects(), rect = null; | |
for (let i = pos > 0 ? rects.length - 1 : 0;; i += (pos > 0 ? -1 : 1)) { | |
rect = rects[i]; | |
if (pos > 0 ? i == 0 : i == rects.length - 1 || rect.top < rect.bottom) | |
break; | |
} | |
return rect; | |
} | |
} | |
class CompositionView extends WidgetView { | |
domAtPos(pos) { return new DOMPos(this.widget.value.text, pos); } | |
sync() { if (!this.dom) | |
this.setDOM(this.widget.toDOM(this.editorView)); } | |
localPosFromDOM(node, offset) { | |
return !offset ? 0 : node.nodeType == 3 ? Math.min(offset, this.length) : this.length; | |
} | |
ignoreMutation() { return false; } | |
get overrideDOMText() { return null; } | |
coordsAt(pos, side) { return textCoords(this.widget.value.text, pos, side, this.length); } | |
} | |
/// Widgets added to the content are described by subclasses of this | |
/// class. This makes it possible to delay creating of the DOM | |
/// structure for a widget until it is needed, and to avoid redrawing | |
/// widgets even when the decorations that define them are recreated. | |
/// `T` can be a type of value passed to instances of the widget type. | |
class WidgetType { | |
/// Create an instance of this widget type. | |
constructor( | |
/// @internal | |
value) { | |
this.value = value; | |
} | |
/// Compare this instance to another instance of the same class. By | |
/// default, it'll compare the instances' parameters with `===`. | |
eq(value) { return this.value === value; } | |
/// Update a DOM element created by a widget of the same type but | |
/// with a different value to reflect this widget. May return true | |
/// to indicate that it could update, false to indicate it couldn't | |
/// (in which case the widget will be redrawn). The default | |
/// implementation just returns false. | |
updateDOM(_dom) { return false; } | |
/// @internal | |
compare(other) { | |
return this == other || this.constructor == other.constructor && this.eq(other.value); | |
} | |
/// The estimated height this widget will have, to be used when | |
/// estimating the height of content that hasn't been drawn. May | |
/// return -1 to indicate you don't know. The default implementation | |
/// returns -1. | |
get estimatedHeight() { return -1; } | |
/// Can be used to configure which kinds of events inside the widget | |
/// should be ignored by the editor. The default is to ignore all | |
/// events. | |
ignoreEvent(_event) { return true; } | |
//// @internal | |
get customView() { return null; } | |
} | |
/// The different types of blocks that can occur in an editor view. | |
var BlockType; | |
(function (BlockType) { | |
/// A line of text. | |
BlockType[BlockType["Text"] = 0] = "Text"; | |
/// A block widget associated with the position after it. | |
BlockType[BlockType["WidgetBefore"] = 1] = "WidgetBefore"; | |
/// A block widget associated with the position before it. | |
BlockType[BlockType["WidgetAfter"] = 2] = "WidgetAfter"; | |
/// A block widget [replacing](#view.Decoration^replace) a range of content. | |
BlockType[BlockType["WidgetRange"] = 3] = "WidgetRange"; | |
})(BlockType || (BlockType = {})); | |
/// A decoration provides information on how to draw or style a piece | |
/// of content. You'll usually use it wrapped in a | |
/// [`Range`](#rangeset.Range), which adds a start and | |
/// end position. | |
class Decoration extends RangeValue { | |
/// @internal | |
constructor( | |
/// @internal | |
startSide, | |
/// @internal | |
endSide, | |
/// @internal | |
widget, | |
/// The config object used to create this decoration. | |
spec) { | |
super(); | |
this.startSide = startSide; | |
this.endSide = endSide; | |
this.widget = widget; | |
this.spec = spec; | |
} | |
/// @internal | |
get point() { return false; } | |
/// @internal | |
get heightRelevant() { return false; } | |
/// Create a mark decoration, which influences the styling of the | |
/// text in its range. | |
static mark(spec) { | |
return new MarkDecoration(spec); | |
} | |
/// Create a widget decoration, which adds an element at the given | |
/// position. | |
static widget(spec) { | |
let side = spec.side || 0; | |
if (spec.block) | |
side += (200000000 /* BigBlock */ + 1) * (side > 0 ? 1 : -1); | |
return new PointDecoration(spec, side, side, !!spec.block, spec.widget || null, false); | |
} | |
/// Create a replace decoration which replaces the given range with | |
/// a widget, or simply hides it. | |
static replace(spec) { | |
let block = !!spec.block; | |
let { start, end } = getInclusive(spec); | |
let startSide = block ? -200000000 /* BigBlock */ * (start ? 2 : 1) : 100000000 /* BigInline */ * (start ? -1 : 1); | |
let endSide = block ? 200000000 /* BigBlock */ * (end ? 2 : 1) : 100000000 /* BigInline */ * (end ? 1 : -1); | |
return new PointDecoration(spec, startSide, endSide, block, spec.widget || null, true); | |
} | |
/// Create a line decoration, which can add DOM attributes to the | |
/// line starting at the given position. | |
static line(spec) { | |
return new LineDecoration(spec); | |
} | |
/// Build a [`DecorationSet`](#view.DecorationSet) from the given | |
/// decorated range or ranges. | |
static set(of, sort = false) { | |
return RangeSet.of(of, sort); | |
} | |
/// @internal | |
hasHeight() { return this.widget ? this.widget.estimatedHeight > -1 : false; } | |
} | |
/// The empty set of decorations. | |
Decoration.none = RangeSet.empty; | |
class MarkDecoration extends Decoration { | |
constructor(spec) { | |
let { start, end } = getInclusive(spec); | |
super(100000000 /* BigInline */ * (start ? -1 : 1), 100000000 /* BigInline */ * (end ? 1 : -1), null, spec); | |
} | |
eq(other) { | |
return this == other || | |
other instanceof MarkDecoration && | |
this.spec.tagName == other.spec.tagName && | |
this.spec.class == other.spec.class && | |
attrsEq(this.spec.attributes || null, other.spec.attributes || null); | |
} | |
range(from, to = from) { | |
if (from >= to) | |
throw new RangeError("Mark decorations may not be empty"); | |
return super.range(from, to); | |
} | |
} | |
class LineDecoration extends Decoration { | |
constructor(spec) { | |
super(-100000000 /* BigInline */, -100000000 /* BigInline */, null, spec); | |
} | |
get point() { return true; } | |
eq(other) { | |
return other instanceof LineDecoration && attrsEq(this.spec.attributes, other.spec.attributes); | |
} | |
range(from, to = from) { | |
if (to != from) | |
throw new RangeError("Line decoration ranges must be zero-length"); | |
return super.range(from, to); | |
} | |
} | |
LineDecoration.prototype.mapMode = MapMode.TrackBefore; | |
class PointDecoration extends Decoration { | |
constructor(spec, startSide, endSide, block, widget, isReplace) { | |
super(startSide, endSide, widget, spec); | |
this.block = block; | |
this.isReplace = isReplace; | |
this.mapMode = !block ? MapMode.TrackDel : startSide < 0 ? MapMode.TrackBefore : MapMode.TrackAfter; | |
} | |
get point() { return true; } | |
// Only relevant when this.block == true | |
get type() { | |
return this.startSide < this.endSide ? BlockType.WidgetRange | |
: this.startSide < 0 ? BlockType.WidgetBefore : BlockType.WidgetAfter; | |
} | |
get heightRelevant() { return this.block || !!this.widget && this.widget.estimatedHeight >= 5; } | |
eq(other) { | |
return other instanceof PointDecoration && | |
widgetsEq(this.widget, other.widget) && | |
this.block == other.block && | |
this.startSide == other.startSide && this.endSide == other.endSide; | |
} | |
range(from, to = from) { | |
if (this.isReplace && (from > to || (from == to && this.startSide > 0 && this.endSide < 0))) | |
throw new RangeError("Invalid range for replacement decoration"); | |
if (!this.isReplace && to != from) | |
throw new RangeError("Widget decorations can only have zero-length ranges"); | |
return super.range(from, to); | |
} | |
} | |
function getInclusive(spec) { | |
let { inclusiveStart: start, inclusiveEnd: end } = spec; | |
if (start == null) | |
start = spec.inclusive; | |
if (end == null) | |
end = spec.inclusive; | |
return { start: start || false, end: end || false }; | |
} | |
function widgetsEq(a, b) { | |
return a == b || !!(a && b && a.compare(b)); | |
} | |
const MinRangeGap = 4; | |
function addRange(from, to, ranges) { | |
let last = ranges.length - 1; | |
if (last >= 0 && ranges[last] + MinRangeGap > from) | |
ranges[last] = Math.max(ranges[last], to); | |
else | |
ranges.push(from, to); | |
} | |
const theme = Facet.define({ combine: strs => strs.join(" ") }); | |
const darkTheme = Facet.define({ combine: values => values.indexOf(true) > -1 }); | |
const baseThemeID = StyleModule.newName(); | |
const baseLightThemeID = StyleModule.newName(); | |
const baseDarkThemeID = StyleModule.newName(); | |
function buildTheme(mainID, spec) { | |
let styles = Object.create(null); | |
for (let prop in spec) { | |
let selector = prop.split(/\s*,\s*/).map(piece => { | |
let id = mainID, narrow; | |
if (id == baseThemeID && (narrow = /^(.*?)@(light|dark)$/.exec(piece))) { | |
id = narrow[2] == "dark" ? baseDarkThemeID : baseLightThemeID; | |
piece = narrow[1]; | |
} | |
let parts = piece.split("."), selector = "." + id + (parts[0] == "wrap" ? "" : " "); | |
for (let i = 1; i <= parts.length; i++) | |
selector += ".cm-" + parts.slice(0, i).join("-"); | |
return selector; | |
}).join(", "); | |
styles[selector] = spec[prop]; | |
} | |
return new StyleModule(styles, { generateClasses: false }); | |
} | |
/// Create a set of CSS class names for the given theme selector, | |
/// which can be added to a DOM element within an editor to make | |
/// themes able to style it. Theme selectors can be single words or | |
/// words separated by dot characters. In the latter case, the | |
/// returned classes combine those that match the full name and those | |
/// that match some prefix—for example `"panel.search"` will match | |
/// both the theme styles specified as `"panel.search"` and those with | |
/// just `"panel"`. More specific theme styles (with more dots) take | |
/// precedence. | |
function themeClass(selector) { | |
let parts = selector.split("."), result = ""; | |
for (let i = 1; i <= parts.length; i++) | |
result += (result ? " " : "") + "cm-" + parts.slice(0, i).join("-"); | |
return result; | |
} | |
const baseTheme = buildTheme(baseThemeID, { | |
wrap: { | |
position: "relative !important", | |
boxSizing: "border-box", | |
"&.cm-focused": { | |
// FIXME it would be great if we could directly use the browser's | |
// default focus outline, but it appears we can't, so this tries to | |
// approximate that | |
outline_fallback: "1px dotted #212121", | |
outline: "5px auto -webkit-focus-ring-color" | |
}, | |
display: "flex !important", | |
flexDirection: "column" | |
}, | |
scroller: { | |
display: "flex !important", | |
alignItems: "flex-start !important", | |
fontFamily: "monospace", | |
lineHeight: 1.4, | |
height: "100%", | |
overflowX: "auto" | |
}, | |
content: { | |
margin: 0, | |
flexGrow: 2, | |
minHeight: "100%", | |
display: "block", | |
whiteSpace: "pre", | |
boxSizing: "border-box", | |
padding: "4px 0", | |
outline: "none" | |
}, | |
"content@light": { caretColor: "black" }, | |
"content@dark": { caretColor: "white" }, | |
line: { | |
display: "block", | |
padding: "0 2px 0 4px" | |
}, | |
button: { | |
verticalAlign: "middle", | |
color: "inherit", | |
fontSize: "70%", | |
padding: ".2em 1em", | |
borderRadius: "3px" | |
}, | |
"button@light": { | |
backgroundImage: "linear-gradient(#eff1f5, #d9d9df)", | |
border: "1px solid #888", | |
"&:active": { | |
backgroundImage: "linear-gradient(#b4b4b4, #d0d3d6)" | |
} | |
}, | |
"button@dark": { | |
backgroundImage: "linear-gradient(#555, #111)", | |
border: "1px solid #888", | |
"&:active": { | |
backgroundImage: "linear-gradient(#111, #333)" | |
} | |
}, | |
textfield: { | |
verticalAlign: "middle", | |
color: "inherit", | |
fontSize: "70%", | |
border: "1px solid silver", | |
padding: ".2em .5em" | |
}, | |
"textfield@light": { | |
backgroundColor: "white" | |
}, | |
"textfield@dark": { | |
border: "1px solid #555", | |
backgroundColor: "inherit" | |
}, | |
secondarySelection: { | |
backgroundColor_fallback: "#3297FD", | |
color_fallback: "white !important", | |
backgroundColor: "Highlight", | |
color: "HighlightText !important" | |
}, | |
secondaryCursor: { | |
display: "inline-block", | |
verticalAlign: "text-top", | |
width: 0, | |
height: "1.15em", | |
margin: "0 -0.7px -.7em" | |
}, | |
"secondaryCursor@light": { borderLeft: "1.4px solid #555" }, | |
"secondaryCursor@dark": { borderLeft: "1.4px solid #ddd" } | |
}); | |
const LineClass = themeClass("line"); | |
class LineView extends ContentView { | |
constructor() { | |
super(...arguments); | |
this.children = []; | |
this.length = 0; | |
this.prevAttrs = undefined; | |
this.attrs = null; | |
this.breakAfter = 0; | |
} | |
// Consumes source | |
merge(from, to, source, takeDeco) { | |
if (source) { | |
if (!(source instanceof LineView)) | |
return false; | |
if (!this.dom) | |
source.transferDOM(this); // Reuse source.dom when appropriate | |
} | |
if (takeDeco) | |
this.setDeco(source ? source.attrs : null); | |
let elts = source ? source.children : []; | |
let cur = this.childCursor(); | |
let { i: toI, off: toOff } = cur.findPos(to, 1); | |
let { i: fromI, off: fromOff } = cur.findPos(from, -1); | |
let dLen = from - to; | |
for (let view of elts) | |
dLen += view.length; | |
this.length += dLen; | |
// Both from and to point into the same text view | |
if (fromI == toI && fromOff) { | |
let start = this.children[fromI]; | |
// Maybe just update that view and be done | |
if (elts.length == 1 && start.merge(fromOff, toOff, elts[0])) | |
return true; | |
if (elts.length == 0) { | |
start.merge(fromOff, toOff, null); | |
return true; | |
} | |
// Otherwise split it, so that we don't have to worry about aliasing front/end afterwards | |
let after = start.slice(toOff); | |
if (after.merge(0, 0, elts[elts.length - 1])) | |
elts[elts.length - 1] = after; | |
else | |
elts.push(after); | |
toI++; | |
toOff = 0; | |
} | |
// Make sure start and end positions fall on node boundaries | |
// (fromOff/toOff are no longer used after this), and that if the | |
// start or end of the elts can be merged with adjacent nodes, | |
// this is done | |
if (toOff) { | |
let end = this.children[toI]; | |
if (elts.length && end.merge(0, toOff, elts[elts.length - 1])) | |
elts.pop(); | |
else | |
end.merge(0, toOff, null); | |
} | |
else if (toI < this.children.length && elts.length && | |
this.children[toI].merge(0, 0, elts[elts.length - 1])) { | |
elts.pop(); | |
} | |
if (fromOff) { | |
let start = this.children[fromI]; | |
if (elts.length && start.merge(fromOff, undefined, elts[0])) | |
elts.shift(); | |
else | |
start.merge(fromOff, undefined, null); | |
fromI++; | |
} | |
else if (fromI && elts.length && this.children[fromI - 1].merge(this.children[fromI - 1].length, undefined, elts[0])) { | |
elts.shift(); | |
} | |
// Then try to merge any mergeable nodes at the start and end of | |
// the changed range | |
while (fromI < toI && elts.length && this.children[toI - 1].match(elts[elts.length - 1])) { | |
elts.pop(); | |
toI--; | |
} | |
while (fromI < toI && elts.length && this.children[fromI].match(elts[0])) { | |
elts.shift(); | |
fromI++; | |
} | |
// And if anything remains, splice the child array to insert the new elts | |
if (elts.length || fromI != toI) | |
this.replaceChildren(fromI, toI, elts); | |
return true; | |
} | |
split(at) { | |
let end = new LineView; | |
end.breakAfter = this.breakAfter; | |
if (this.length == 0) | |
return end; | |
let { i, off } = this.childPos(at); | |
if (off) { | |
end.append(this.children[i].slice(off)); | |
this.children[i].merge(off, undefined, null); | |
i++; | |
} | |
for (let j = i; j < this.children.length; j++) | |
end.append(this.children[j]); | |
while (i > 0 && this.children[i - 1].length == 0) { | |
this.children[i - 1].parent = null; | |
i--; | |
} | |
this.children.length = i; | |
this.markDirty(); | |
this.length = at; | |
return end; | |
} | |
transferDOM(other) { | |
if (!this.dom) | |
return; | |
other.setDOM(this.dom); | |
other.prevAttrs = this.prevAttrs === undefined ? this.attrs : this.prevAttrs; | |
this.prevAttrs = undefined; | |
this.dom = null; | |
} | |
setDeco(attrs) { | |
if (!attrsEq(this.attrs, attrs)) { | |
if (this.dom) { | |
this.prevAttrs = this.attrs; | |
this.markDirty(); | |
} | |
this.attrs = attrs; | |
} | |
} | |
// Only called when building a line view in ContentBuilder | |
append(child) { | |
this.children.push(child); | |
child.setParent(this); | |
this.length += child.length; | |
} | |
// Only called when building a line view in ContentBuilder | |
addLineDeco(deco) { | |
let attrs = deco.spec.attributes; | |
if (attrs) | |
this.attrs = combineAttrs(attrs, this.attrs || {}); | |
} | |
domAtPos(pos) { | |
let i = 0; | |
for (let off = 0; i < this.children.length; i++) { | |
let child = this.children[i], end = off + child.length; | |
if (end == off && child.getSide() <= 0) | |
continue; | |
if (pos > off && pos < end && child.dom.parentNode == this.dom) | |
return child.domAtPos(pos - off); | |
if (pos <= off) | |
break; | |
off = end; | |
} | |
for (; i > 0; i--) { | |
let before = this.children[i - 1].dom; | |
if (before.parentNode == this.dom) | |
return DOMPos.after(before); | |
} | |
return new DOMPos(this.dom, 0); | |
} | |
// FIXME might need another hack to work around Firefox's behavior | |
// of not actually displaying the cursor even though it's there in | |
// the DOM | |
sync(track) { | |
if (!this.dom) { | |
this.setDOM(document.createElement("div")); | |
this.dom.className = LineClass; | |
this.prevAttrs = this.attrs ? null : undefined; | |
} | |
if (this.prevAttrs !== undefined) { | |
updateAttrs(this.dom, this.prevAttrs, this.attrs); | |
this.dom.classList.add(LineClass); | |
this.prevAttrs = undefined; | |
} | |
super.sync(track); | |
let last = this.dom.lastChild; | |
if (!last || (last.nodeName != "BR" && !(ContentView.get(last) instanceof TextView))) { | |
let hack = document.createElement("BR"); | |
hack.cmIgnore = true; | |
this.dom.appendChild(hack); | |
} | |
} | |
measureTextSize() { | |
if (this.children.length == 0 || this.length > 20) | |
return null; | |
let totalWidth = 0; | |
for (let child of this.children) { | |
if (!(child instanceof TextView)) | |
return null; | |
let rects = clientRectsFor(child.dom); | |
if (rects.length != 1) | |
return null; | |
totalWidth += rects[0].width; | |
} | |
return { lineHeight: this.dom.getBoundingClientRect().height, charWidth: totalWidth / this.length }; | |
} | |
coordsAt(pos, side) { | |
for (let off = 0, i = 0; i < this.children.length; i++) { | |
let child = this.children[i], end = off + child.length; | |
if (end != off && (side <= 0 || end == this.length ? end >= pos : end > pos)) | |
return child.coordsAt(pos - off, side); | |
off = end; | |
} | |
return this.dom.lastChild.getBoundingClientRect(); | |
} | |
match(_other) { return false; } | |
get type() { return BlockType.Text; } | |
static find(docView, pos) { | |
for (let i = 0, off = 0;; i++) { | |
let block = docView.children[i], end = off + block.length; | |
if (end >= pos) { | |
if (block instanceof LineView) | |
return block; | |
if (block.length) | |
return null; | |
} | |
off = end + block.breakAfter; | |
} | |
} | |
} | |
const none$2 = []; | |
class BlockWidgetView extends ContentView { | |
constructor(widget, length, type, | |
// This is set by the builder and used to distinguish between | |
// adjacent widgets and parts of the same widget when calling | |
// `merge`. It's kind of silly that it's an instance variable, but | |
// it's hard to route there otherwise. | |
open = 0) { | |
super(); | |
this.widget = widget; | |
this.length = length; | |
this.type = type; | |
this.open = open; | |
this.breakAfter = 0; | |
} | |
merge(from, to, source) { | |
if (!(source instanceof BlockWidgetView) || !source.open || | |
from > 0 && !(source.open & 1 /* Start */) || | |
to < this.length && !(source.open & 2 /* End */)) | |
return false; | |
if (!this.widget.compare(source.widget)) | |
throw new Error("Trying to merge an open widget with an incompatible node"); | |
this.length = from + source.length + (this.length - to); | |
return true; | |
} | |
domAtPos(pos) { | |
return pos == 0 ? DOMPos.before(this.dom) : DOMPos.after(this.dom, pos == this.length); | |
} | |
split(at) { | |
let len = this.length - at; | |
this.length = at; | |
return new BlockWidgetView(this.widget, len, this.type); | |
} | |
get children() { return none$2; } | |
sync() { | |
if (!this.dom || !this.widget.updateDOM(this.dom)) { | |
this.setDOM(this.widget.toDOM(this.editorView)); | |
this.dom.contentEditable = "false"; | |
} | |
} | |
get overrideDOMText() { | |
return this.parent ? this.parent.view.state.doc.slice(this.posAtStart, this.posAtEnd) : Text$1.empty; | |
} | |
domBoundsAround() { return null; } | |
match(other) { | |
if (other instanceof BlockWidgetView && other.type == this.type && | |
other.widget.constructor == this.widget.constructor) { | |
if (!other.widget.eq(this.widget.value)) | |
this.markDirty(true); | |
this.widget = other.widget; | |
this.length = other.length; | |
this.breakAfter = other.breakAfter; | |
return true; | |
} | |
return false; | |
} | |
} | |
class ContentBuilder { | |
constructor(doc, pos, end) { | |
this.doc = doc; | |
this.pos = pos; | |
this.end = end; | |
this.content = []; | |
this.curLine = null; | |
this.breakAtStart = 0; | |
this.text = ""; | |
this.textOff = 0; | |
this.cursor = doc.iter(); | |
this.skip = pos; | |
} | |
posCovered() { | |
if (this.content.length == 0) | |
return !this.breakAtStart && this.doc.lineAt(this.pos).from != this.pos; | |
let last = this.content[this.content.length - 1]; | |
return !last.breakAfter && !(last instanceof BlockWidgetView && last.type == BlockType.WidgetBefore); | |
} | |
getLine() { | |
if (!this.curLine) | |
this.content.push(this.curLine = new LineView); | |
return this.curLine; | |
} | |
addWidget(view) { | |
this.curLine = null; | |
this.content.push(view); | |
} | |
finish() { | |
if (!this.posCovered()) | |
this.getLine(); | |
} | |
buildText(length, tagName, clss, attrs, _ranges) { | |
while (length > 0) { | |
if (this.textOff == this.text.length) { | |
let { value, lineBreak, done } = this.cursor.next(this.skip); | |
this.skip = 0; | |
if (done) | |
throw new Error("Ran out of text content when drawing inline views"); | |
if (lineBreak) { | |
if (!this.posCovered()) | |
this.getLine(); | |
if (this.content.length) | |
this.content[this.content.length - 1].breakAfter = 1; | |
else | |
this.breakAtStart = 1; | |
this.curLine = null; | |
length--; | |
continue; | |
} | |
else { | |
this.text = value; | |
this.textOff = 0; | |
} | |
} | |
let take = Math.min(this.text.length - this.textOff, length); | |
this.getLine().append(new TextView(this.text.slice(this.textOff, this.textOff + take), tagName, clss, attrs)); | |
length -= take; | |
this.textOff += take; | |
} | |
} | |
span(from, to, active) { | |
let tagName = null, clss = null; | |
let attrs = null; | |
for (let { spec } of active) { | |
if (spec.tagName) | |
tagName = spec.tagName; | |
if (spec.class) | |
clss = clss ? clss + " " + spec.class : spec.class; | |
if (spec.attributes) | |
for (let name in spec.attributes) { | |
let value = spec.attributes[name]; | |
if (value == null) | |
continue; | |
if (name == "class") { | |
clss = clss ? clss + " " + value : value; | |
} | |
else { | |
if (!attrs) | |
attrs = {}; | |
if (name == "style" && attrs.style) | |
value = attrs.style + ";" + value; | |
attrs[name] = value; | |
} | |
} | |
} | |
this.buildText(to - from, tagName, clss, attrs, active); | |
this.pos = to; | |
} | |
point(from, to, deco, openStart, openEnd) { | |
let open = (openStart ? 1 /* Start */ : 0) | (openEnd ? 2 /* End */ : 0); | |
let len = to - from; | |
if (deco instanceof PointDecoration) { | |
if (deco.block) { | |
let { type } = deco; | |
if (type == BlockType.WidgetAfter && !this.posCovered()) | |
this.getLine(); | |
this.addWidget(new BlockWidgetView(deco.widget || new NullWidget("div"), len, type, open)); | |
} | |
else { | |
this.getLine().append(WidgetView.create(deco.widget || new NullWidget("span"), len, deco.startSide, open)); | |
} | |
} | |
else if (this.doc.lineAt(this.pos).from == this.pos) { // Line decoration | |
this.getLine().addLineDeco(deco); | |
} | |
if (len) { | |
// Advance the iterator past the replaced content | |
if (this.textOff + len <= this.text.length) { | |
this.textOff += len; | |
} | |
else { | |
this.skip += len - (this.text.length - this.textOff); | |
this.text = ""; | |
this.textOff = 0; | |
} | |
this.pos = to; | |
} | |
} | |
static build(text, from, to, decorations) { | |
let builder = new ContentBuilder(text, from, to); | |
RangeSet.spans(decorations, from, to, builder); | |
builder.finish(); | |
return builder; | |
} | |
} | |
class NullWidget extends WidgetType { | |
toDOM() { return document.createElement(this.value); } | |
updateDOM(elt) { return elt.nodeName.toLowerCase() == this.value; } | |
} | |
/// Used to indicate [text direction](#view.EditorView.textDirection). | |
var Direction; | |
(function (Direction) { | |
// (These are chosen to match the base levels, in bidi algorithm | |
// terms, of spans in that direction.) | |
Direction[Direction["LTR"] = 0] = "LTR"; | |
Direction[Direction["RTL"] = 1] = "RTL"; | |
})(Direction || (Direction = {})); | |
const LTR = Direction.LTR, RTL = Direction.RTL; | |
// Decode a string with each type encoded as log2(type) | |
function dec(str) { | |
let result = []; | |
for (let i = 0; i < str.length; i++) | |
result.push(1 << +str[i]); | |
return result; | |
} | |
// Character types for codepoints 0 to 0xf8 | |
const LowTypes = dec("88888888888888888888888888888888888666888888787833333333337888888000000000000000000000000008888880000000000000000000000000088888888888888888888888888888888888887866668888088888663380888308888800000000000000000000000800000000000000000000000000000008"); | |
// Character types for codepoints 0x600 to 0x6f9 | |
const ArabicTypes = dec("4444448826627288999999999992222222222222222222222222222222222222222222222229999999999999999999994444444444644222822222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222999999949999999229989999223333333333"); | |
function charType(ch) { | |
return ch <= 0xf7 ? LowTypes[ch] : | |
0x590 <= ch && ch <= 0x5f4 ? 2 /* R */ : | |
0x600 <= ch && ch <= 0x6f9 ? ArabicTypes[ch - 0x600] : | |
0x6ee <= ch && ch <= 0x8ac ? 4 /* AL */ : | |
0x2000 <= ch && ch <= 0x200b ? 256 /* NI */ : | |
ch == 0x200c ? 256 /* NI */ : 1 /* L */; | |
} | |
const BidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; | |
class BidiSpan { | |
constructor(from, to, level) { | |
this.from = from; | |
this.to = to; | |
this.level = level; | |
} | |
get dir() { return this.level % 2 ? RTL : LTR; } | |
side(end, dir) { return (this.dir == dir) == end ? this.to : this.from; } | |
static find(order, index, level, assoc) { | |
let maybe = -1; | |
for (let i = 0; i < order.length; i++) { | |
let span = order[i]; | |
if (span.from <= index && span.to >= index) { | |
if (span.level == level) | |
return i; | |
// When multiple spans match, if assoc != 0, take the one that | |
// covers that side, otherwise take the one with the minimum | |
// level. | |
if (maybe < 0 || (assoc != 0 ? (assoc < 0 ? span.from < index : span.to > index) : order[maybe].level > span.level)) | |
maybe = i; | |
} | |
} | |
if (maybe < 0) | |
throw new RangeError("Index out of range"); | |
return maybe; | |
} | |
} | |
// Reused array of character types | |
const types = []; | |
function computeOrder(line, direction) { | |
let len = line.length, outerType = direction == LTR ? 1 /* L */ : 2 /* R */; | |
if (!line || outerType == 1 /* L */ && !BidiRE.test(line)) | |
return trivialOrder(len); | |
// W1. Examine each non-spacing mark (NSM) in the level run, and | |
// change the type of the NSM to the type of the previous | |
// character. If the NSM is at the start of the level run, it will | |
// get the type of sor. | |
// W2. Search backwards from each instance of a European number | |
// until the first strong type (R, L, AL, or sor) is found. If an | |
// AL is found, change the type of the European number to Arabic | |
// number. | |
// W3. Change all ALs to R. | |
// (Left after this: L, R, EN, AN, ET, CS, NI) | |
for (let i = 0, prev = outerType, prevStrong = outerType; i < len; i++) { | |
let type = charType(line.charCodeAt(i)); | |
if (type == 512 /* NSM */) | |
type = prev; | |
else if (type == 8 /* EN */ && prevStrong == 4 /* AL */) | |
type = 16 /* AN */; | |
types[i] = type == 4 /* AL */ ? 2 /* R */ : type; | |
if (type & 7 /* Strong */) | |
prevStrong = type; | |
prev = type; | |
} | |
// W5. A sequence of European terminators adjacent to European | |
// numbers changes to all European numbers. | |
// W6. Otherwise, separators and terminators change to Other | |
// Neutral. | |
// W7. Search backwards from each instance of a European number | |
// until the first strong type (R, L, or sor) is found. If an L is | |
// found, then change the type of the European number to L. | |
// (Left after this: L, R, EN+AN, NI) | |
for (let i = 0, prev = outerType, prevStrong = outerType; i < len; i++) { | |
let type = types[i]; | |
if (type == 128 /* CS */) { | |
if (i < len - 1 && prev == types[i + 1] && (prev & 24 /* Num */)) | |
type = types[i] = prev; | |
else | |
types[i] = 256 /* NI */; | |
} | |
else if (type == 64 /* ET */) { | |
let end = i + 1; | |
while (end < len && types[end] == 64 /* ET */) | |
end++; | |
let replace = (i && prev == 8 /* EN */) || (end < len && types[end] == 8 /* EN */) ? (prevStrong == 1 /* L */ ? 1 /* L */ : 8 /* EN */) : 256 /* NI */; | |
for (let j = i; j < end; j++) | |
types[j] = replace; | |
i = end - 1; | |
} | |
else if (type == 8 /* EN */ && prevStrong == 1 /* L */) { | |
types[i] = 1 /* L */; | |
} | |
prev = type; | |
if (type & 7 /* Strong */) | |
prevStrong = type; | |
} | |
// N1. A sequence of neutrals takes the direction of the | |
// surrounding strong text if the text on both sides has the same | |
// direction. European and Arabic numbers act as if they were R in | |
// terms of their influence on neutrals. Start-of-level-run (sor) | |
// and end-of-level-run (eor) are used at level run boundaries. | |
// N2. Any remaining neutrals take the embedding direction. | |
// (Left after this: L, R, EN+AN) | |
for (let i = 0; i < len; i++) { | |
if (types[i] == 256 /* NI */) { | |
let end = i + 1; | |
while (end < len && types[end] == 256 /* NI */) | |
end++; | |
let beforeL = (i ? types[i - 1] : outerType) == 1 /* L */; | |
let afterL = (end < len ? types[end] : outerType) == 1 /* L */; | |
let replace = beforeL == afterL ? (beforeL ? 1 /* L */ : 2 /* R */) : outerType; | |
for (let j = i; j < end; j++) | |
types[j] = replace; | |
i = end - 1; | |
} | |
} | |
// Here we depart from the documented algorithm, in order to avoid | |
// building up an actual levels array. Since there are only three | |
// levels (0, 1, 2) in an implementation that doesn't take | |
// explicit embedding into account, we can build up the order on | |
// the fly, without following the level-based algorithm. | |
let order = []; | |
if (outerType == 1 /* L */) { | |
for (let i = 0; i < len;) { | |
let start = i, rtl = types[i++] != 1 /* L */; | |
while (i < len && rtl == (types[i] != 1 /* L */)) | |
i++; | |
if (rtl) { | |
for (let j = i; j > start;) { | |
let end = j, l = types[--j] != 2 /* R */; | |
while (j > start && l == (types[j - 1] != 2 /* R */)) | |
j--; | |
order.push(new BidiSpan(j, end, l ? 2 : 1)); | |
} | |
} | |
else { | |
order.push(new BidiSpan(start, i, 0)); | |
} | |
} | |
} | |
else { | |
for (let i = 0; i < len;) { | |
let start = i, rtl = types[i++] == 2 /* R */; | |
while (i < len && rtl == (types[i] == 2 /* R */)) | |
i++; | |
order.push(new BidiSpan(start, i, rtl ? 1 : 2)); | |
} | |
} | |
return order; | |
} | |
function trivialOrder(length) { | |
return [new BidiSpan(0, length, 0)]; | |
} | |
let movedOver = ""; | |
function moveVisually(line, order, dir, start, forward) { | |
var _a; | |
let startIndex = start.head - line.from, spanI = -1; | |
if (startIndex == 0) { | |
if (!forward || !line.length) | |
return null; | |
if (order[0].level != dir) { | |
startIndex = order[0].side(false, dir); | |
spanI = 0; | |
} | |
} | |
else if (startIndex == line.length) { | |
if (forward) | |
return null; | |
let last = order[order.length - 1]; | |
if (last.level != dir) { | |
startIndex = last.side(true, dir); | |
spanI = order.length - 1; | |
} | |
} | |
if (spanI < 0) | |
spanI = BidiSpan.find(order, startIndex, (_a = start.bidiLevel) !== null && _a !== void 0 ? _a : -1, start.assoc); | |
let span = order[spanI]; | |
// End of span. (But not end of line--that was checked for above.) | |
if (startIndex == span.side(forward, dir)) { | |
span = order[spanI += forward ? 1 : -1]; | |
startIndex = span.side(!forward, dir); | |
} | |
let indexForward = forward == (span.dir == dir); | |
let nextIndex = line.findClusterBreak(startIndex, indexForward); | |
movedOver = line.slice(Math.min(startIndex, nextIndex), Math.max(startIndex, nextIndex)); | |
if (nextIndex != span.side(forward, dir)) | |
return EditorSelection.cursor(nextIndex + line.from, indexForward ? -1 : 1, span.level); | |
let nextSpan = spanI == (forward ? order.length - 1 : 0) ? null : order[spanI + (forward ? 1 : -1)]; | |
if (!nextSpan && span.level != dir) | |
return EditorSelection.cursor(forward ? line.to : line.from, forward ? -1 : 1, dir); | |
if (nextSpan && nextSpan.level < span.level) | |
return EditorSelection.cursor(nextSpan.side(!forward, dir) + line.from, 0, nextSpan.level); | |
return EditorSelection.cursor(nextIndex + line.from, 0, span.level); | |
} | |
const wrappingWhiteSpace = ["pre-wrap", "normal", "pre-line"]; | |
class HeightOracle { | |
constructor() { | |
this.doc = Text.empty; | |
this.lineWrapping = false; | |
this.direction = Direction.LTR; | |
this.heightSamples = {}; | |
this.lineHeight = 14; | |
this.charWidth = 7; | |
this.lineLength = 30; | |
// Used to track, during updateHeight, if any actual heights changed | |
this.heightChanged = false; | |
} | |
heightForGap(from, to) { | |
let lines = this.doc.lineAt(to).number - this.doc.lineAt(from).number + 1; | |
if (this.lineWrapping) | |
lines += Math.ceil(((to - from) - (lines * this.lineLength * 0.5)) / this.lineLength); | |
return this.lineHeight * lines; | |
} | |
heightForLine(length) { | |
if (!this.lineWrapping) | |
return this.lineHeight; | |
let lines = 1 + Math.max(0, Math.ceil((length - this.lineLength) / (this.lineLength - 5))); | |
return lines * this.lineHeight; | |
} | |
setDoc(doc) { this.doc = doc; return this; } | |
mustRefresh(lineHeights, whiteSpace, direction) { | |
let newHeight = false; | |
for (let i = 0; i < lineHeights.length; i++) { | |
let h = lineHeights[i]; | |
if (h < 0) { | |
i++; | |
} | |
else if (!this.heightSamples[Math.floor(h * 10)]) { // Round to .1 pixels | |
newHeight = true; | |
this.heightSamples[Math.floor(h * 10)] = true; | |
} | |
} | |
return newHeight || (wrappingWhiteSpace.indexOf(whiteSpace) > -1) != this.lineWrapping || this.direction != direction; | |
} | |
refresh(whiteSpace, direction, lineHeight, charWidth, lineLength, knownHeights) { | |
let lineWrapping = wrappingWhiteSpace.indexOf(whiteSpace) > -1; | |
let changed = Math.round(lineHeight) != Math.round(this.lineHeight) || | |
this.lineWrapping != lineWrapping || | |
this.direction != direction; | |
this.lineWrapping = lineWrapping; | |
this.direction = direction; | |
this.lineHeight = lineHeight; | |
this.charWidth = charWidth; | |
this.lineLength = lineLength; | |
if (changed) { | |
this.heightSamples = {}; | |
for (let i = 0; i < knownHeights.length; i++) { | |
let h = knownHeights[i]; | |
if (h < 0) | |
i++; | |
else | |
this.heightSamples[Math.floor(h * 10)] = true; | |
} | |
} | |
return changed; | |
} | |
} | |
// This object is used by `updateHeight` to make DOM measurements | |
// arrive at the right nides. The `heights` array is a sequence of | |
// block heights, starting from position `from`. | |
class MeasuredHeights { | |
constructor(from, heights) { | |
this.from = from; | |
this.heights = heights; | |
this.index = 0; | |
} | |
get more() { return this.index < this.heights.length; } | |
} | |
/// Record used to represent information about a block-level element | |
/// in the editor view. | |
class BlockInfo { | |
/// @internal | |
constructor( | |
/// The start of the element in the document. | |
from, | |
/// The length of the element. | |
length, | |
/// The top position of the element. | |
top, | |
/// Its height. | |
height, | |
/// The type of element this is. When querying lines, this may be | |
/// an array of all the blocks that make up the line. | |
type) { | |
this.from = from; | |
this.length = length; | |
this.top = top; | |
this.height = height; | |
this.type = type; | |
} | |
/// The end of the element as a document position. | |
get to() { return this.from + this.length; } | |
/// The bottom position of the element. | |
get bottom() { return this.top + this.height; } | |
/// @internal | |
join(other) { | |
let detail = (Array.isArray(this.type) ? this.type : [this]) | |
.concat(Array.isArray(other.type) ? other.type : [other]); | |
return new BlockInfo(this.from, this.length + other.length, this.top, this.height + other.height, detail); | |
} | |
} | |
var QueryType; | |
(function (QueryType) { | |
QueryType[QueryType["ByPos"] = 0] = "ByPos"; | |
QueryType[QueryType["ByHeight"] = 1] = "ByHeight"; | |
QueryType[QueryType["ByPosNoHeight"] = 2] = "ByPosNoHeight"; | |
})(QueryType || (QueryType = {})); | |
const Epsilon = 1e-4; | |
class HeightMap { | |
constructor(length, // The number of characters covered | |
height, // Height of this part of the document | |
flags = 2 /* Outdated */) { | |
this.length = length; | |
this.height = height; | |
this.flags = flags; | |
} | |
get outdated() { return (this.flags & 2 /* Outdated */) > 0; } | |
set outdated(value) { this.flags = (value ? 2 /* Outdated */ : 0) | (this.flags & ~2 /* Outdated */); } | |
setHeight(oracle, height) { | |
if (this.height != height) { | |
if (Math.abs(this.height - height) > Epsilon) | |
oracle.heightChanged = true; | |
this.height = height; | |
} | |
} | |
// Base case is to replace a leaf node, which simply builds a tree | |
// from the new nodes and returns that (HeightMapBranch and | |
// HeightMapGap override this to actually use from/to) | |
replace(_from, _to, nodes) { | |
return HeightMap.of(nodes); | |
} | |
// Again, these are base cases, and are overridden for branch and gap nodes. | |
decomposeLeft(_to, result) { result.push(this); } | |
decomposeRight(_from, result) { result.push(this); } | |
applyChanges(decorations, oldDoc, oracle, changes) { | |
let me = this; | |
for (let i = changes.length - 1; i >= 0; i--) { | |
let { fromA, toA, fromB, toB } = changes[i]; | |
let start = me.lineAt(fromA, QueryType.ByPosNoHeight, oldDoc, 0, 0); | |
let end = start.to >= toA ? start : me.lineAt(toA, QueryType.ByPosNoHeight, oldDoc, 0, 0); | |
toB += end.to - toA; | |
toA = end.to; | |
while (i > 0 && start.from <= changes[i - 1].toA) { | |
fromA = changes[i - 1].fromA; | |
fromB = changes[i - 1].fromB; | |
i--; | |
if (fromA < start.from) | |
start = me.lineAt(fromA, QueryType.ByPosNoHeight, oldDoc, 0, 0); | |
} | |
fromB += start.from - fromA; | |
fromA = start.from; | |
let nodes = NodeBuilder.build(oracle, decorations, fromB, toB); | |
me = me.replace(fromA, toA, nodes); | |
} | |
return me.updateHeight(oracle, 0); | |
} | |
static empty() { return new HeightMapText(0, 0); } | |
// nodes uses null values to indicate the position of line breaks. | |
// There are never line breaks at the start or end of the array, or | |
// two line breaks next to each other, and the array isn't allowed | |
// to be empty (same restrictions as return value from the builder). | |
static of(nodes) { | |
if (nodes.length == 1) | |
return nodes[0]; | |
let i = 0, j = nodes.length, before = 0, after = 0; | |
for (;;) { | |
if (i == j) { | |
if (before > after * 2) { | |
let split = nodes[i - 1]; | |
if (split.break) | |
nodes.splice(--i, 1, split.left, null, split.right); | |
else | |
nodes.splice(--i, 1, split.left, split.right); | |
j += 1 + split.break; | |
before -= split.size; | |
} | |
else if (after > before * 2) { | |
let split = nodes[j]; | |
if (split.break) | |
nodes.splice(j, 1, split.left, null, split.right); | |
else | |
nodes.splice(j, 1, split.left, split.right); | |
j += 2 + split.break; | |
after -= split.size; | |
} | |
else { | |
break; | |
} | |
} | |
else if (before < after) { | |
let next = nodes[i++]; | |
if (next) | |
before += next.size; | |
} | |
else { | |
let next = nodes[--j]; | |
if (next) | |
after += next.size; | |
} | |
} | |
let brk = 0; | |
if (nodes[i - 1] == null) { | |
brk = 1; | |
i--; | |
} | |
else if (nodes[i] == null) { | |
brk = 1; | |
j++; | |
} | |
return new HeightMapBranch(HeightMap.of(nodes.slice(0, i)), brk, HeightMap.of(nodes.slice(j))); | |
} | |
} | |
HeightMap.prototype.size = 1; | |
class HeightMapBlock extends HeightMap { | |
constructor(length, height, type) { | |
super(length, height); | |
this.type = type; | |
} | |
blockAt(_height, _doc, top, offset) { | |
return new BlockInfo(offset, this.length, top, this.height, this.type); | |
} | |
lineAt(_value, _type, doc, top, offset) { | |
return this.blockAt(0, doc, top, offset); | |
} | |
forEachLine(_from, _to, doc, top, offset, f) { | |
f(this.blockAt(0, doc, top, offset)); | |
} | |
updateHeight(oracle, offset = 0, _force = false, measured) { | |
if (measured && measured.from <= offset && measured.more) | |
this.setHeight(oracle, measured.heights[measured.index++]); | |
this.outdated = false; | |
return this; | |
} | |
toString() { return `block(${this.length})`; } | |
} | |
class HeightMapText extends HeightMapBlock { | |
constructor(length, height) { | |
super(length, height, BlockType.Text); | |
this.collapsed = 0; // Amount of collapsed content in the line | |
this.widgetHeight = 0; // Maximum inline widget height | |
} | |
replace(_from, _to, nodes) { | |
let node = nodes[0]; | |
if (nodes.length == 1 && (node instanceof HeightMapText || node instanceof HeightMapGap && (node.flags & 4 /* SingleLine */)) && | |
Math.abs(this.length - node.length) < 10) { | |
if (node instanceof HeightMapGap) | |
node = new HeightMapText(node.length, this.height); | |
else | |
node.height = this.height; | |
if (!this.outdated) | |
node.outdated = false; | |
return node; | |
} | |
else { | |
return HeightMap.of(nodes); | |
} | |
} | |
updateHeight(oracle, offset = 0, force = false, measured) { | |
if (measured && measured.from <= offset && measured.more) | |
this.setHeight(oracle, measured.heights[measured.index++]); | |
else if (force || this.outdated) | |
this.setHeight(oracle, Math.max(this.widgetHeight, oracle.heightForLine(this.length - this.collapsed))); | |
this.outdated = false; | |
return this; | |
} | |
toString() { | |
return `line(${this.length}${this.collapsed ? -this.collapsed : ""}${this.widgetHeight ? ":" + this.widgetHeight : ""})`; | |
} | |
} | |
class HeightMapGap extends HeightMap { | |
constructor(length) { super(length, 0); } | |
lines(doc, offset) { | |
let firstLine = doc.lineAt(offset).number, lastLine = doc.lineAt(offset + this.length).number; | |
return { firstLine, lastLine, lineHeight: this.height / (lastLine - firstLine + 1) }; | |
} | |
blockAt(height, doc, top, offset) { | |
let { firstLine, lastLine, lineHeight } = this.lines(doc, offset); | |
let line = Math.max(0, Math.min(lastLine - firstLine, Math.floor((height - top) / lineHeight))); | |
let { from, length } = doc.line(firstLine + line); | |
return new BlockInfo(from, length, top + lineHeight * line, lineHeight, BlockType.Text); | |
} | |
lineAt(value, type, doc, top, offset) { | |
if (type == QueryType.ByHeight) | |
return this.blockAt(value, doc, top, offset); | |
if (type == QueryType.ByPosNoHeight) { | |
let { from, to } = doc.lineAt(value); | |
return new BlockInfo(from, to - from, 0, 0, BlockType.Text); | |
} | |
let { firstLine, lineHeight } = this.lines(doc, offset); | |
let { from, length, number } = doc.lineAt(value); | |
return new BlockInfo(from, length, top + lineHeight * (number - firstLine), lineHeight, BlockType.Text); | |
} | |
forEachLine(from, to, doc, top, offset, f) { | |
let { firstLine, lastLine, lineHeight } = this.lines(doc, offset); | |
for (let line = firstLine; line <= lastLine; line++) { | |
let { from, to } = doc.line(line); | |
if (from > to) | |
break; | |
if (to >= from) | |
f(new BlockInfo(from, to - from, top, top += lineHeight, BlockType.Text)); | |
} | |
} | |
replace(from, to, nodes) { | |
let after = this.length - to; | |
if (after > 0) { | |
let last = nodes[nodes.length - 1]; | |
if (last instanceof HeightMapGap) | |
nodes[nodes.length - 1] = new HeightMapGap(last.length + after); | |
else | |
nodes.push(null, new HeightMapGap(after - 1)); | |
} | |
if (from > 0) { | |
let first = nodes[0]; | |
if (first instanceof HeightMapGap) | |
nodes[0] = new HeightMapGap(from + first.length); | |
else | |
nodes.unshift(new HeightMapGap(from - 1), null); | |
} | |
return HeightMap.of(nodes); | |
} | |
decomposeLeft(to, result) { | |
result.push(new HeightMapGap(to - 1), null); | |
} | |
decomposeRight(from, result) { | |
result.push(null, new HeightMapGap(this.length - from - 1)); | |
} | |
updateHeight(oracle, offset = 0, force = false, measured) { | |
let end = offset + this.length; | |
if (measured && measured.from <= offset + this.length && measured.more) { | |
// Fill in part of this gap with measured lines. We know there | |
// can't be widgets or collapsed ranges in those lines, because | |
// they would already have been added to the heightmap (gaps | |
// only contain plain text). | |
let nodes = [], pos = Math.max(offset, measured.from); | |
if (measured.from > offset) | |
nodes.push(new HeightMapGap(measured.from - offset - 1).updateHeight(oracle, offset)); | |
while (pos <= end && measured.more) { | |
let len = oracle.doc.lineAt(pos).length; | |
if (nodes.length) | |
nodes.push(null); | |
let line = new HeightMapText(len, measured.heights[measured.index++]); | |
line.outdated = false; | |
nodes.push(line); | |
pos += len + 1; | |
} | |
if (pos <= end) | |
nodes.push(null, new HeightMapGap(end - pos).updateHeight(oracle, pos)); | |
oracle.heightChanged = true; | |
return HeightMap.of(nodes); | |
} | |
else if (force || this.outdated) { | |
this.setHeight(oracle, oracle.heightForGap(offset, offset + this.length)); | |
this.outdated = false; | |
} | |
return this; | |
} | |
toString() { return `gap(${this.length})`; } | |
} | |
class HeightMapBranch extends HeightMap { | |
constructor(left, brk, right) { | |
super(left.length + brk + right.length, left.height + right.height, brk | (left.outdated || right.outdated ? 2 /* Outdated */ : 0)); | |
this.left = left; | |
this.right = right; | |
this.size = left.size + right.size; | |
} | |
get break() { return this.flags & 1 /* Break */; } | |
blockAt(height, doc, top, offset) { | |
let mid = top + this.left.height; | |
return height < mid || this.right.height == 0 ? this.left.blockAt(height, doc, top, offset) | |
: this.right.blockAt(height, doc, mid, offset + this.left.length + this.break); | |
} | |
lineAt(value, type, doc, top, offset) { | |
let rightTop = top + this.left.height, rightOffset = offset + this.left.length + this.break; | |
let left = type == QueryType.ByHeight ? value < rightTop || this.right.height == 0 : value < rightOffset; | |
let base = left ? this.left.lineAt(value, type, doc, top, offset) | |
: this.right.lineAt(value, type, doc, rightTop, rightOffset); | |
if (this.break || (left ? base.to < rightOffset : base.from > rightOffset)) | |
return base; | |
let subQuery = type == QueryType.ByPosNoHeight ? QueryType.ByPosNoHeight : QueryType.ByPos; | |
if (left) | |
return base.join(this.right.lineAt(rightOffset, subQuery, doc, rightTop, rightOffset)); | |
else | |
return this.left.lineAt(rightOffset, subQuery, doc, top, offset).join(base); | |
} | |
forEachLine(from, to, doc, top, offset, f) { | |
let rightTop = top + this.left.height, rightOffset = offset + this.left.length + this.break; | |
if (this.break) { | |
if (from < rightOffset) | |
this.left.forEachLine(from, to, doc, top, offset, f); | |
if (to >= rightOffset) | |
this.right.forEachLine(from, to, doc, rightTop, rightOffset, f); | |
} | |
else { | |
let mid = this.lineAt(rightOffset, QueryType.ByPos, doc, top, offset); | |
if (from < mid.from) | |
this.left.forEachLine(from, mid.from - 1, doc, top, offset, f); | |
if (mid.to >= from && mid.from <= to) | |
f(mid); | |
if (to > mid.to) | |
this.right.forEachLine(mid.to + 1, to, doc, rightTop, rightOffset, f); | |
} | |
} | |
replace(from, to, nodes) { | |
let rightStart = this.left.length + this.break; | |
if (to < rightStart) | |
return this.balanced(this.left.replace(from, to, nodes), this.right); | |
if (from > this.left.length) | |
return this.balanced(this.left, this.right.replace(from - rightStart, to - rightStart, nodes)); | |
let result = []; | |
if (from > 0) | |
this.decomposeLeft(from, result); | |
let left = result.length; | |
for (let node of nodes) | |
result.push(node); | |
if (from > 0) | |
mergeGaps(result, left - 1); | |
if (to < this.length) { | |
let right = result.length; | |
this.decomposeRight(to, result); | |
mergeGaps(result, right); | |
} | |
return HeightMap.of(result); | |
} | |
decomposeLeft(to, result) { | |
let left = this.left.length; | |
if (to <= left) | |
return this.left.decomposeLeft(to, result); | |
result.push(this.left); | |
if (this.break) { | |
left++; | |
if (to >= left) | |
result.push(null); | |
} | |
if (to > left) | |
this.right.decomposeLeft(to - left, result); | |
} | |
decomposeRight(from, result) { | |
let left = this.left.length, right = left + this.break; | |
if (from >= right) | |
return this.right.decomposeRight(from - right, result); | |
if (from < left) | |
this.left.decomposeRight(from, result); | |
if (this.break && from < right) | |
result.push(null); | |
result.push(this.right); | |
} | |
balanced(left, right) { | |
if (left.size > 2 * right.size || right.size > 2 * left.size) | |
return HeightMap.of(this.break ? [left, null, right] : [left, right]); | |
this.left = left; | |
this.right = right; | |
this.height = left.height + right.height; | |
this.outdated = left.outdated || right.outdated; | |
this.size = left.size + right.size; | |
this.length = left.length + this.break + right.length; | |
return this; | |
} | |
updateHeight(oracle, offset = 0, force = false, measured) { | |
let { left, right } = this, rightStart = offset + left.length + this.break, rebalance = null; | |
if (measured && measured.from <= offset + left.length && measured.more) | |
rebalance = left = left.updateHeight(oracle, offset, force, measured); | |
else | |
left.updateHeight(oracle, offset, force); | |
if (measured && measured.from <= rightStart + right.length && measured.more) | |
rebalance = right = right.updateHeight(oracle, rightStart, force, measured); | |
else | |
right.updateHeight(oracle, rightStart, force); | |
if (rebalance) | |
return this.balanced(left, right); | |
this.height = this.left.height + this.right.height; | |
this.outdated = false; | |
return this; | |
} | |
toString() { return this.left + (this.break ? " " : "-") + this.right; } | |
} | |
function mergeGaps(nodes, around) { | |
let before, after; | |
if (nodes[around] == null && | |
(before = nodes[around - 1]) instanceof HeightMapGap && | |
(after = nodes[around + 1]) instanceof HeightMapGap) | |
nodes.splice(around - 1, 3, new HeightMapGap(before.length + 1 + after.length)); | |
} | |
const relevantWidgetHeight = 5; | |
class NodeBuilder { | |
constructor(pos, oracle) { | |
this.pos = pos; | |
this.oracle = oracle; | |
this.nodes = []; | |
this.lineStart = -1; | |
this.lineEnd = -1; | |
this.covering = null; | |
this.writtenTo = pos; | |
} | |
get isCovered() { | |
return this.covering && this.nodes[this.nodes.length - 1] == this.covering; | |
} | |
span(_from, to) { | |
if (this.lineStart > -1) { | |
let end = Math.min(to, this.lineEnd), last = this.nodes[this.nodes.length - 1]; | |
if (last instanceof HeightMapText) | |
last.length += end - this.pos; | |
else if (end > this.pos || !this.isCovered) | |
this.nodes.push(new HeightMapText(end - this.pos, -1)); | |
this.writtenTo = end; | |
if (to > end) { | |
this.nodes.push(null); | |
this.writtenTo++; | |
this.lineStart = -1; | |
} | |
} | |
this.pos = to; | |
} | |
point(from, to, deco) { | |
if (from < to || deco.heightRelevant) { | |
let height = deco.widget ? Math.max(0, deco.widget.estimatedHeight) : 0; | |
let len = to - from; | |
if (deco.block) { | |
this.addBlock(new HeightMapBlock(len, height, deco.type)); | |
} | |
else if (len || height >= relevantWidgetHeight) { | |
this.addLineDeco(height, len); | |
} | |
} | |
else if (to > from) { | |
this.span(from, to); | |
} | |
if (this.lineEnd > -1 && this.lineEnd < this.pos) | |
this.lineEnd = this.oracle.doc.lineAt(this.pos).to; | |
} | |
enterLine() { | |
if (this.lineStart > -1) | |
return; | |
let { from, to } = this.oracle.doc.lineAt(this.pos); | |
this.lineStart = from; | |
this.lineEnd = to; | |
if (this.writtenTo < from) { | |
if (this.writtenTo < from - 1 || this.nodes[this.nodes.length - 1] == null) | |
this.nodes.push(this.blankContent(this.writtenTo, from - 1)); | |
this.nodes.push(null); | |
} | |
if (this.pos > from) | |
this.nodes.push(new HeightMapText(this.pos - from, -1)); | |
this.writtenTo = this.pos; | |
} | |
blankContent(from, to) { | |
let gap = new HeightMapGap(to - from); | |
if (this.oracle.doc.lineAt(from).to == to) | |
gap.flags |= 4 /* SingleLine */; | |
return gap; | |
} | |
ensureLine() { | |
this.enterLine(); | |
let last = this.nodes.length ? this.nodes[this.nodes.length - 1] : null; | |
if (last instanceof HeightMapText) | |
return last; | |
let line = new HeightMapText(0, -1); | |
this.nodes.push(line); | |
return line; | |
} | |
addBlock(block) { | |
this.enterLine(); | |
if (block.type == BlockType.WidgetAfter && !this.isCovered) | |
this.ensureLine(); | |
this.nodes.push(block); | |
this.writtenTo = this.pos = this.pos + block.length; | |
if (block.type != BlockType.WidgetBefore) | |
this.covering = block; | |
} | |
addLineDeco(height, length) { | |
let line = this.ensureLine(); | |
line.length += length; | |
line.collapsed += length; | |
line.widgetHeight = Math.max(line.widgetHeight, height); | |
this.writtenTo = this.pos = this.pos + length; | |
} | |
finish(from) { | |
let last = this.nodes.length == 0 ? null : this.nodes[this.nodes.length - 1]; | |
if (this.lineStart > -1 && !(last instanceof HeightMapText) && !this.isCovered) | |
this.nodes.push(new HeightMapText(0, -1)); | |
else if (this.writtenTo < this.pos || last == null) | |
this.nodes.push(this.blankContent(this.writtenTo, this.pos)); | |
let pos = from; | |
for (let node of this.nodes) { | |
if (node instanceof HeightMapText) | |
node.updateHeight(this.oracle, pos); | |
pos += node ? node.length : 1; | |
} | |
return this.nodes; | |
} | |
// Always called with a region that on both sides either stretches | |
// to a line break or the end of the document. | |
// The returned array uses null to indicate line breaks, but never | |
// starts or ends in a line break, or has multiple line breaks next | |
// to each other. | |
static build(oracle, decorations, from, to) { | |
let builder = new NodeBuilder(from, oracle); | |
RangeSet.spans(decorations, from, to, builder); | |
return builder.finish(from); | |
} | |
get minPointSize() { return 0; } | |
} | |
function heightRelevantDecoChanges(a, b, diff) { | |
let comp = new DecorationComparator(); | |
RangeSet.compare(a, b, diff, comp); | |
return comp.changes; | |
} | |
class DecorationComparator { | |
constructor() { | |
this.changes = []; | |
} | |
compareRange() { } | |
comparePoint(from, to, a, b) { | |
if (from < to || a && a.heightRelevant || b && b.heightRelevant) | |
addRange(from, to, this.changes); | |
} | |
get minPointSize() { return 0; } | |
} | |
const none$3 = []; | |
const clickAddsSelectionRange = Facet.define(); | |
const dragMovesSelection = Facet.define(); | |
const mouseSelectionStyle = Facet.define(); | |
const exceptionSink = Facet.define(); | |
const updateListener = Facet.define(); | |
/// Log or report an unhandled exception in client code. Should | |
/// probably only be used by extension code that allows client code to | |
/// provide functions, and calls those functions in a context where an | |
/// exception can't be propagated to calling code in a reasonable way | |
/// (for example when in an event handler). | |
/// | |
/// Either calls a handler registered with | |
/// [`EditorView.exceptionSink`](#view.EditorView^exceptionSink), | |
/// `window.onerror`, if defined, or `console.error` (in which case | |
/// it'll pass `context`, when given, as first argument). | |
function logException(state, exception, context) { | |
let handler = state.facet(exceptionSink); | |
if (handler.length) | |
handler[0](exception); | |
else if (window.onerror) | |
window.onerror(String(exception), context, undefined, undefined, exception); | |
else if (context) | |
console.error(context + ":", exception); | |
else | |
console.error(exception); | |
} | |
const editable = Facet.define({ combine: values => values.length ? values[0] : true }); | |
/// Plugin fields are a mechanism for allowing plugins to provide | |
/// values that can be retrieved through the | |
/// [`pluginField`](#view.EditorView.pluginField) view method. | |
class PluginField { | |
static define() { return new PluginField(); } | |
} | |
/// Plugins can provide additional scroll margins (space around the | |
/// sides of the scrolling element that should be considered | |
/// invisible) through this field. This can be useful when the | |
/// plugin introduces elements that cover part of that element (for | |
/// example a horizontally fixed gutter). | |
PluginField.scrollMargins = PluginField.define(); | |
let nextPluginID = 0; | |
const viewPlugin = Facet.define(); | |
/// View plugins associate stateful values with a view. They can | |
/// influence the way the content is drawn, and are notified of things | |
/// that happen in the view. | |
class ViewPlugin { | |
constructor( | |
/// @internal | |
id, | |
/// @internal | |
create, | |
/// @internal | |
fields) { | |
this.id = id; | |
this.create = create; | |
this.fields = fields; | |
this.extension = viewPlugin.of(this); | |
} | |
/// Define a plugin from a constructor function that creates the | |
/// plugin's value, given an editor view. | |
static define(create) { | |
return new ViewPlugin(nextPluginID++, create, []); | |
} | |
/// Create a plugin for a class whose constructor takes a single | |
/// editor view as argument. | |
static fromClass(cls) { | |
return ViewPlugin.define(view => new cls(view)); | |
} | |
/// Create a new version of this plugin that provides a given | |
/// [plugin field](#view.PluginField). | |
provide(field, get) { | |
return new ViewPlugin(this.id, this.create, this.fields.concat({ field, get })); | |
} | |
decorations(get) { | |
return this.provide(pluginDecorations, get || ((value) => value.decorations)); | |
} | |
/// Convenience method that extends a view plugin to automatically | |
/// register [DOM event | |
/// handlers](#view.EditorView^domEventHandlers). | |
eventHandlers(handlers) { | |
return this.provide(domEventHandlers, (value) => ({ plugin: value, handlers })); | |
} | |
} | |
// FIXME somehow ensure that no replacing decorations end up in here | |
const pluginDecorations = PluginField.define(); | |
const domEventHandlers = PluginField.define(); | |
class PluginInstance { | |
constructor(value, spec) { | |
this.value = value; | |
this.spec = spec; | |
this.updateFunc = this.value.update ? this.value.update.bind(this.value) : () => undefined; | |
} | |
static create(spec, view) { | |
let value; | |
try { | |
value = spec.create(view); | |
} | |
catch (e) { | |
logException(view.state, e, "CodeMirror plugin crashed"); | |
return PluginInstance.dummy; | |
} | |
return new PluginInstance(value, spec); | |
} | |
takeField(type, target) { | |
for (let { field, get } of this.spec.fields) | |
if (field == type) | |
target.push(get(this.value)); | |
} | |
update(update) { | |
try { | |
this.updateFunc(update); | |
return this; | |
} | |
catch (e) { | |
logException(update.state, e, "CodeMirror plugin crashed"); | |
if (this.value.destroy) | |
try { | |
this.value.destroy(); | |
} | |
catch (_) { } | |
return PluginInstance.dummy; | |
} | |
} | |
destroy(view) { | |
try { | |
if (this.value.destroy) | |
this.value.destroy(); | |
} | |
catch (e) { | |
logException(view.state, e, "CodeMirror plugin crashed"); | |
} | |
} | |
} | |
PluginInstance.dummy = new PluginInstance({}, ViewPlugin.define(() => ({}))); | |
const editorAttributes = Facet.define({ | |
combine: values => values.reduce((a, b) => combineAttrs(b, a), {}) | |
}); | |
const contentAttributes = Facet.define({ | |
combine: values => values.reduce((a, b) => combineAttrs(b, a), {}) | |
}); | |
// Provide decorations | |
const decorations = Facet.define(); | |
const styleModule = Facet.define(); | |
class ChangedRange { | |
constructor(fromA, toA, fromB, toB) { | |
this.fromA = fromA; | |
this.toA = toA; | |
this.fromB = fromB; | |
this.toB = toB; | |
} | |
join(other) { | |
return new ChangedRange(Math.min(this.fromA, other.fromA), Math.max(this.toA, other.toA), Math.min(this.fromB, other.fromB), Math.max(this.toB, other.toB)); | |
} | |
addToSet(set) { | |
let i = set.length, me = this; | |
for (; i > 0; i--) { | |
let range = set[i - 1]; | |
if (range.fromA > me.toA) | |
continue; | |
if (range.toA < me.fromA) | |
break; | |
me = me.join(range); | |
set.splice(i - 1, 1); | |
} | |
set.splice(i, 0, me); | |
return set; | |
} | |
static extendWithRanges(diff, ranges) { | |
if (ranges.length == 0) | |
return diff; | |
let result = []; | |
for (let dI = 0, rI = 0, posA = 0, posB = 0;; dI++) { | |
let next = dI == diff.length ? null : diff[dI], off = posA - posB; | |
let end = next ? next.fromB : 1e9; | |
while (rI < ranges.length && ranges[rI] < end) { | |
let from = ranges[rI], to = ranges[rI + 1]; | |
let fromB = Math.max(posB, from), toB = Math.min(end, to); | |
if (fromB <= toB) | |
new ChangedRange(fromB + off, toB + off, fromB, toB).addToSet(result); | |
if (to > end) | |
break; | |
else | |
rI += 2; | |
} | |
if (!next) | |
return result; | |
new ChangedRange(next.fromA, next.toA, next.fromB, next.toB).addToSet(result); | |
posA = next.toA; | |
posB = next.toB; | |
} | |
} | |
} | |
/// View [plugins](#view.ViewPlugin) are given instances of this | |
/// class, which describe what happened, whenever the view is updated. | |
class ViewUpdate { | |
/// @internal | |
constructor( | |
/// The editor view that the update is associated with. | |
view, | |
/// The new editor state. | |
state, | |
/// The transactions involved in the update. May be empty. | |
transactions = none$3) { | |
this.view = view; | |
this.state = state; | |
this.transactions = transactions; | |
/// @internal | |
this.flags = 0; | |
this.prevState = view.state; | |
this.changes = ChangeSet.empty(this.prevState.doc.length); | |
for (let tr of transactions) | |
this.changes = this.changes.compose(tr.changes); | |
let changedRanges = []; | |
this.changes.iterChangedRanges((fromA, toA, fromB, toB) => changedRanges.push(new ChangedRange(fromA, toA, fromB, toB))); | |
this.changedRanges = changedRanges; | |
let focus = view.hasFocus; | |
if (focus != view.inputState.notifiedFocused) { | |
view.inputState.notifiedFocused = focus; | |
this.flags != 1 /* Focus */; | |
} | |
if (this.docChanged) | |
this.flags |= 2 /* Height */; | |
} | |
/// Tells you whether the viewport changed in this update. | |
get viewportChanged() { | |
return (this.flags & 4 /* Viewport */) > 0; | |
} | |
/// Indicates whether the line height in the editor changed in this update. | |
get heightChanged() { | |
return (this.flags & 2 /* Height */) > 0; | |
} | |
/// True when this update indicates a focus change. | |
get focusChanged() { | |
return (this.flags & 1 /* Focus */) > 0; | |
} | |
/// Whether the document changed in this update. | |
get docChanged() { | |
return this.transactions.some(tr => tr.docChanged); | |
} | |
/// Whether the selection was explicitly set in this update. | |
get selectionSet() { | |
return this.transactions.some(tr => tr.selection); | |
} | |
/// @internal | |
get empty() { return this.flags == 0 && this.transactions.length == 0; } | |
} | |
function visiblePixelRange(dom, paddingTop) { | |
let rect = dom.getBoundingClientRect(); | |
let left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right); | |
let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom); | |
for (let parent = dom.parentNode; parent;) { // (Cast to any because TypeScript is useless with Node types) | |
if (parent.nodeType == 1) { | |
if ((parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) && | |
window.getComputedStyle(parent).overflow != "visible") { | |
let parentRect = parent.getBoundingClientRect(); | |
left = Math.max(left, parentRect.left); | |
right = Math.min(right, parentRect.right); | |
top = Math.max(top, parentRect.top); | |
bottom = Math.min(bottom, parentRect.bottom); | |
} | |
parent = parent.parentNode; | |
} | |
else if (parent.nodeType == 11) { // Shadow root | |
parent = parent.host; | |
} | |
else { | |
break; | |
} | |
} | |
return { left: left - rect.left, right: right - rect.left, | |
top: top - (rect.top + paddingTop), bottom: bottom - (rect.top + paddingTop) }; | |
} | |
// Line gaps are placeholder widgets used to hide pieces of overlong | |
// lines within the viewport, as a kludge to keep the editor | |
// responsive when a ridiculously long line is loaded into it. | |
class LineGap { | |
constructor(from, to, size) { | |
this.from = from; | |
this.to = to; | |
this.size = size; | |
} | |
static same(a, b) { | |
if (a.length != b.length) | |
return false; | |
for (let i = 0; i < a.length; i++) { | |
let gA = a[i], gB = b[i]; | |
if (gA.from != gB.from || gA.to != gB.to || gA.size != gB.size) | |
return false; | |
} | |
return true; | |
} | |
draw(wrapping) { | |
return Decoration.replace({ widget: new LineGapWidget({ size: this.size, vertical: wrapping }) }).range(this.from, this.to); | |
} | |
} | |
class LineGapWidget extends WidgetType { | |
toDOM() { | |
let elt = document.createElement("div"); | |
if (this.value.vertical) { | |
elt.style.height = this.value.size + "px"; | |
} | |
else { | |
elt.style.width = this.value.size + "px"; | |
elt.style.height = "2px"; | |
elt.style.display = "inline-block"; | |
} | |
return elt; | |
} | |
eq(other) { return this.value.size == other.size && this.value.vertical == other.vertical; } | |
get estimatedHeight() { return this.value.vertical ? this.value.size : -1; } | |
} | |
class ViewState { | |
constructor(state) { | |
this.state = state; | |
// These are contentDOM-local coordinates | |
this.pixelViewport = { left: 0, right: window.innerWidth, top: 0, bottom: 0 }; | |
this.paddingTop = 0; | |
this.paddingBottom = 0; | |
this.heightOracle = new HeightOracle; | |
this.heightMap = HeightMap.empty(); | |
this.scrollTo = null; | |
// Briefly set to true when printing, to disable viewport limiting | |
this.printing = false; | |
this.visibleRanges = []; | |
// Cursor 'assoc' is only significant when the cursor is on a line | |
// wrap point, where it must stick to the character that it is | |
// associated with. Since browsers don't provide a reasonable | |
// interface to set or query this, when a selection is set that | |
// might cause this to be signficant, this flag is set. The next | |
// measure phase will check whether the cursor is on a line-wrapping | |
// boundary and, if so, reset it to make sure it is positioned in | |
// the right place. | |
this.mustEnforceCursorAssoc = false; | |
this.heightMap = this.heightMap.applyChanges(state.facet(decorations), Text.empty, this.heightOracle.setDoc(state.doc), [new ChangedRange(0, 0, 0, state.doc.length)]); | |
this.viewport = this.getViewport(0, null); | |
this.lineGaps = this.ensureLineGaps([]); | |
this.lineGapDeco = Decoration.set(this.lineGaps.map(gap => gap.draw(false))); | |
this.computeVisibleRanges(); | |
} | |
update(update, scrollTo = null) { | |
let prev = this.state; | |
this.state = update.state; | |
let newDeco = this.state.facet(decorations); | |
let contentChanges = update.changedRanges; | |
let heightChanges = ChangedRange.extendWithRanges(contentChanges, heightRelevantDecoChanges(update.prevState.facet(decorations), newDeco, update ? update.changes : ChangeSet.empty(this.state.doc.length))); | |
let prevHeight = this.heightMap.height; | |
this.heightMap = this.heightMap.applyChanges(newDeco, prev.doc, this.heightOracle.setDoc(this.state.doc), heightChanges); | |
if (this.heightMap.height != prevHeight) | |
update.flags |= 2 /* Height */; | |
let viewport = heightChanges.length ? this.mapViewport(this.viewport, update.changes) : this.viewport; | |
if (scrollTo && (scrollTo.head < viewport.from || scrollTo.head > viewport.to) || !this.viewportIsAppropriate(viewport)) | |
viewport = this.getViewport(0, scrollTo); | |
if (!viewport.eq(this.viewport)) { | |
this.viewport = viewport; | |
update.flags |= 4 /* Viewport */; | |
} | |
if (this.lineGaps.length || this.viewport.to - this.viewport.from > 15000 /* MinViewPort */) | |
update.flags |= this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps, update.changes))); | |
this.computeVisibleRanges(); | |
if (scrollTo) | |
this.scrollTo = scrollTo; | |
if (!this.mustEnforceCursorAssoc && update.selectionSet && update.view.lineWrapping && | |
update.state.selection.primary.empty && update.state.selection.primary.assoc) | |
this.mustEnforceCursorAssoc = true; | |
} | |
measure(docView, repeated) { | |
let dom = docView.dom, whiteSpace = "", direction = Direction.LTR; | |
if (!repeated) { | |
// Vertical padding | |
let style = window.getComputedStyle(dom); | |
whiteSpace = style.whiteSpace, direction = (style.direction == "rtl" ? Direction.RTL : Direction.LTR); | |
this.paddingTop = parseInt(style.paddingTop) || 0; | |
this.paddingBottom = parseInt(style.paddingBottom) || 0; | |
} | |
// Pixel viewport | |
let pixelViewport = this.printing ? { top: -1e8, bottom: 1e8, left: -1e8, right: 1e8 } : visiblePixelRange(dom, this.paddingTop); | |
let dTop = pixelViewport.top - this.pixelViewport.top, dBottom = pixelViewport.bottom - this.pixelViewport.bottom; | |
this.pixelViewport = pixelViewport; | |
if (this.pixelViewport.bottom <= this.pixelViewport.top || | |
this.pixelViewport.right <= this.pixelViewport.left) | |
return 0; | |
let lineHeights = docView.measureVisibleLineHeights(); | |
let refresh = false, bias = 0; | |
if (!repeated) { | |
if (this.heightOracle.mustRefresh(lineHeights, whiteSpace, direction)) { | |
let { lineHeight, charWidth } = docView.measureTextSize(); | |
refresh = this.heightOracle.refresh(whiteSpace, direction, lineHeight, charWidth, (docView.dom).clientWidth / charWidth, lineHeights); | |
if (refresh) | |
docView.minWidth = 0; | |
} | |
if (dTop > 0 && dBottom > 0) | |
bias = Math.max(dTop, dBottom); | |
else if (dTop < 0 && dBottom < 0) | |
bias = Math.min(dTop, dBottom); | |
} | |
this.heightOracle.heightChanged = false; | |
this.heightMap = this.heightMap.updateHeight(this.heightOracle, 0, refresh, new MeasuredHeights(this.viewport.from, lineHeights)); | |
let result = this.heightOracle.heightChanged ? 2 /* Height */ : 0; | |
if (!this.viewportIsAppropriate(this.viewport, bias) || | |
this.scrollTo && (this.scrollTo.head < this.viewport.from || this.scrollTo.head > this.viewport.to)) { | |
this.viewport = this.getViewport(bias, this.scrollTo); | |
result |= 4 /* Viewport */; | |
} | |
if (this.lineGaps.length || this.viewport.to - this.viewport.from > 15000 /* MinViewPort */) | |
result |= this.updateLineGaps(this.ensureLineGaps(refresh ? [] : this.lineGaps)); | |
this.computeVisibleRanges(); | |
if (this.mustEnforceCursorAssoc) { | |
this.mustEnforceCursorAssoc = false; | |
// This is done in the read stage, because moving the selection | |
// to a line end is going to trigger a layout anyway, so it | |
// can't be a pure write. It should be rare that it does any | |
// writing. | |
docView.enforceCursorAssoc(); | |
} | |
return result; | |
} | |
getViewport(bias, scrollTo) { | |
// This will divide VP.Margin between the top and the | |
// bottom, depending on the bias (the change in viewport position | |
// since the last update). It'll hold a number between 0 and 1 | |
let marginTop = 0.5 - Math.max(-0.5, Math.min(0.5, bias / 1000 /* Margin */ / 2)); | |
let map = this.heightMap, doc = this.state.doc, { top, bottom } = this.pixelViewport; | |
let viewport = new Viewport(map.lineAt(top - marginTop * 1000 /* Margin */, QueryType.ByHeight, doc, 0, 0).from, map.lineAt(bottom + (1 - marginTop) * 1000 /* Margin */, QueryType.ByHeight, doc, 0, 0).to); | |
// If scrollTo is given, make sure the viewport includes that position | |
if (scrollTo) { | |
if (scrollTo.head < viewport.from) { | |
let { top: newTop } = map.lineAt(scrollTo.head, QueryType.ByPos, doc, 0, 0); | |
viewport = new Viewport(map.lineAt(newTop - 1000 /* Margin */ / 2, QueryType.ByHeight, doc, 0, 0).from, map.lineAt(newTop + (bottom - top) + 1000 /* Margin */ / 2, QueryType.ByHeight, doc, 0, 0).to); | |
} | |
else if (scrollTo.head > viewport.to) { | |
let { bottom: newBottom } = map.lineAt(scrollTo.head, QueryType.ByPos, doc, 0, 0); | |
viewport = new Viewport(map.lineAt(newBottom - (bottom - top) - 1000 /* Margin */ / 2, QueryType.ByHeight, doc, 0, 0).from, map.lineAt(newBottom + 1000 /* Margin */ / 2, QueryType.ByHeight, doc, 0, 0).to); | |
} | |
} | |
return viewport; | |
} | |
mapViewport(viewport, changes) { | |
let from = changes.mapPos(viewport.from, -1), to = changes.mapPos(viewport.to, 1); | |
return new Viewport(this.heightMap.lineAt(from, QueryType.ByPos, this.state.doc, 0, 0).from, this.heightMap.lineAt(to, QueryType.ByPos, this.state.doc, 0, 0).to); | |
} | |
// Checks if a given viewport covers the visible part of the | |
// document and not too much beyond that. | |
viewportIsAppropriate({ from, to }, bias = 0) { | |
let { top } = this.heightMap.lineAt(from, QueryType.ByPos, this.state.doc, 0, 0); | |
let { bottom } = this.heightMap.lineAt(to, QueryType.ByPos, this.state.doc, 0, 0); | |
return (from == 0 || top <= this.pixelViewport.top - Math.max(10 /* MinCoverMargin */, Math.min(-bias, 250 /* MaxCoverMargin */))) && | |
(to == this.state.doc.length || | |
bottom >= this.pixelViewport.bottom + Math.max(10 /* MinCoverMargin */, Math.min(bias, 250 /* MaxCoverMargin */))) && | |
(top > this.pixelViewport.top - 2 * 1000 /* Margin */ && bottom < this.pixelViewport.bottom + 2 * 1000 /* Margin */); | |
} | |
mapLineGaps(gaps, changes) { | |
if (!gaps.length || changes.empty) | |
return gaps; | |
let mapped = []; | |
for (let gap of gaps) | |
if (!changes.touchesRange(gap.from, gap.to)) | |
mapped.push(new LineGap(changes.mapPos(gap.from), changes.mapPos(gap.to), gap.size)); | |
return mapped; | |
} | |
// Computes positions in the viewport where the start or end of a | |
// line should be hidden, trying to reuse existing line gaps when | |
// appropriate to avoid unneccesary redraws. | |
// Uses crude character-counting for the positioning and sizing, | |
// since actual DOM coordinates aren't always available and | |
// predictable. Relies on generous margins (see LG.Margin) to hide | |
// the artifacts this might produce from the user. | |
ensureLineGaps(current) { | |
let gaps = []; | |
// This won't work at all in predominantly right-to-left text. | |
if (this.heightOracle.direction != Direction.LTR) | |
return gaps; | |
this.heightMap.forEachLine(this.viewport.from, this.viewport.to, this.state.doc, 0, 0, line => { | |
if (line.length < 10000 /* Margin */) | |
return; | |
let structure = lineStructure(line.from, line.to, this.state); | |
if (structure.total < 10000 /* Margin */) | |
return; | |
let viewFrom, viewTo; | |
if (this.heightOracle.lineWrapping) { | |
if (line.from != this.viewport.from) | |
viewFrom = line.from; | |
else | |
viewFrom = findPosition(structure, (this.pixelViewport.top - line.top) / line.height); | |
if (line.to != this.viewport.to) | |
viewTo = line.to; | |
else | |
viewTo = findPosition(structure, (this.pixelViewport.bottom - line.top) / line.height); | |
} | |
else { | |
let totalWidth = structure.total * this.heightOracle.charWidth; | |
viewFrom = findPosition(structure, this.pixelViewport.left / totalWidth); | |
viewTo = findPosition(structure, this.pixelViewport.right / totalWidth); | |
} | |
let sel = this.state.selection.primary; | |
// Make sure the gap doesn't cover a selection end | |
if (sel.from <= viewFrom && sel.to >= line.from) | |
viewFrom = sel.from; | |
if (sel.from <= line.to && sel.to >= viewTo) | |
viewTo = sel.to; | |
let gapTo = viewFrom - 10000 /* Margin */, gapFrom = viewTo + 10000 /* Margin */; | |
if (gapTo > line.from + 5000 /* HalfMargin */) | |
gaps.push(find(current, gap => gap.from == line.from && gap.to > gapTo - 5000 /* HalfMargin */ && gap.to < gapTo + 5000 /* HalfMargin */) || | |
new LineGap(line.from, gapTo, this.gapSize(line, gapTo, true, structure))); | |
if (gapFrom < line.to - 5000 /* HalfMargin */) | |
gaps.push(find(current, gap => gap.to == line.to && gap.from > gapFrom - 5000 /* HalfMargin */ && | |
gap.from < gapFrom + 5000 /* HalfMargin */) || | |
new LineGap(gapFrom, line.to, this.gapSize(line, gapFrom, false, structure))); | |
}); | |
return gaps; | |
} | |
gapSize(line, pos, start, structure) { | |
if (this.heightOracle.lineWrapping) { | |
let height = line.height * findFraction(structure, pos); | |
return start ? height : line.height - height; | |
} | |
else { | |
let ratio = findFraction(structure, pos); | |
return structure.total * this.heightOracle.charWidth * (start ? ratio : 1 - ratio); | |
} | |
} | |
updateLineGaps(gaps) { | |
if (!LineGap.same(gaps, this.lineGaps)) { | |
this.lineGaps = gaps; | |
this.lineGapDeco = Decoration.set(gaps.map(gap => gap.draw(this.heightOracle.lineWrapping))); | |
return 16 /* LineGaps */; | |
} | |
return 0; | |
} | |
computeVisibleRanges() { | |
let deco = this.state.facet(decorations); | |
if (this.lineGaps.length) | |
deco = deco.concat(this.lineGapDeco); | |
let ranges = []; | |
RangeSet.spans(deco, this.viewport.from, this.viewport.to, { | |
span(from, to) { ranges.push({ from, to }); }, | |
point() { }, | |
minPointSize: 20 | |
}); | |
this.visibleRanges = ranges; | |
} | |
lineAt(pos, editorTop) { | |
return this.heightMap.lineAt(pos, QueryType.ByPos, this.state.doc, editorTop + this.paddingTop, 0); | |
} | |
lineAtHeight(height, editorTop) { | |
return this.heightMap.lineAt(height, QueryType.ByHeight, this.state.doc, editorTop + this.paddingTop, 0); | |
} | |
blockAtHeight(height, editorTop) { | |
return this.heightMap.blockAt(height, this.state.doc, editorTop + this.paddingTop, 0); | |
} | |
forEachLine(from, to, f, editorTop) { | |
return this.heightMap.forEachLine(from, to, this.state.doc, editorTop + this.paddingTop, 0, f); | |
} | |
} | |
/// Indicates the range of the document that is in the visible | |
/// viewport. | |
class Viewport { | |
constructor(from, to) { | |
this.from = from; | |
this.to = to; | |
} | |
eq(b) { return this.from == b.from && this.to == b.to; } | |
} | |
function lineStructure(from, to, state) { | |
let ranges = [], pos = from, total = 0; | |
RangeSet.spans(state.facet(decorations), from, to, { | |
span() { }, | |
point(from, to) { | |
if (from > pos) { | |
ranges.push({ from: pos, to: from }); | |
total += from - pos; | |
} | |
pos = to; | |
}, | |
minPointSize: 20 // We're only interested in collapsed ranges of a significant size | |
}); | |
if (pos < to) { | |
ranges.push({ from: pos, to }); | |
total += to - pos; | |
} | |
return { total, ranges }; | |
} | |
function findPosition({ total, ranges }, ratio) { | |
if (ratio <= 0) | |
return ranges[0].from; | |
if (ratio >= 1) | |
return ranges[ranges.length - 1].to; | |
let dist = Math.floor(total * ratio); | |
for (let i = 0;; i++) { | |
let { from, to } = ranges[i], size = to - from; | |
if (dist <= size) | |
return from + dist; | |
dist -= size; | |
} | |
} | |
function findFraction(structure, pos) { | |
let counted = 0; | |
for (let { from, to } of structure.ranges) { | |
if (pos <= to) { | |
counted += pos - from; | |
break; | |
} | |
counted += to - from; | |
} | |
return counted / structure.total; | |
} | |
function find(array, f) { | |
for (let val of array) | |
if (f(val)) | |
return val; | |
return undefined; | |
} | |
const none$4 = []; | |
class DocView extends ContentView { | |
constructor(view) { | |
super(); | |
this.view = view; | |
this.viewports = none$4; | |
this.compositionDeco = Decoration.none; | |
this.decorations = []; | |
// Track a minimum width for the editor. When measuring sizes in | |
// checkLayout, this is updated to point at the width of a given | |
// element and its extent in the document. When a change happens in | |
// that range, these are reset. That way, once we've seen a | |
// line/element of a given length, we keep the editor wide enough to | |
// fit at least that element, until it is changed, at which point we | |
// forget it again. | |
this.minWidth = 0; | |
this.minWidthFrom = 0; | |
this.minWidthTo = 0; | |
// Track whether the DOM selection was set in a lossy way, so that | |
// we don't mess it up when reading it back it | |
this.impreciseAnchor = null; | |
this.impreciseHead = null; | |
this.setDOM(view.contentDOM); | |
this.children = [new LineView]; | |
this.children[0].setParent(this); | |
this.updateInner([new ChangedRange(0, 0, 0, view.state.doc.length)], this.updateDeco(), 0); | |
} | |
get root() { return this.view.root; } | |
get editorView() { return this.view; } | |
get length() { return this.view.state.doc.length; } | |
// Update the document view to a given state. scrollIntoView can be | |
// used as a hint to compute a new viewport that includes that | |
// position, if we know the editor is going to scroll that position | |
// into view. | |
update(update) { | |
var _a; | |
let changedRanges = update.changedRanges; | |
if (this.minWidth > 0 && changedRanges.length) { | |
if (!changedRanges.every(({ fromA, toA }) => toA < this.minWidthFrom || fromA > this.minWidthTo)) { | |
this.minWidth = 0; | |
} | |
else { | |
this.minWidthFrom = update.changes.mapPos(this.minWidthFrom, 1); | |
this.minWidthTo = update.changes.mapPos(this.minWidthTo, 1); | |
} | |
} | |
if (!((_a = this.view.inputState) === null || _a === void 0 ? void 0 : _a.composing)) | |
this.compositionDeco = Decoration.none; | |
else if (update.transactions.length) | |
this.compositionDeco = computeCompositionDeco(this.view, update.changes); | |
// When the DOM nodes around the selection are moved to another | |
// parent, Chrome sometimes reports a different selection through | |
// getSelection than the one that it actually shows to the user. | |
// This forces a selection update when lines are joined to work | |
// around that. Issue #54 | |
let forceSelection = (browser.ie || browser.chrome) && !this.compositionDeco.size && update && | |
update.state.doc.lines != update.prevState.doc.lines; | |
let prevDeco = this.decorations, deco = this.updateDeco(); | |
let decoDiff = findChangedDeco(prevDeco, deco, update.changes); | |
changedRanges = ChangedRange.extendWithRanges(changedRanges, decoDiff); | |
let pointerSel = update.transactions.some(tr => tr.annotation(Transaction.userEvent) == "pointerselection"); | |
if (this.dirty == 0 /* Not */ && changedRanges.length == 0 && | |
!(update.flags & (4 /* Viewport */ | 16 /* LineGaps */)) && | |
update.state.selection.primary.from >= this.view.viewport.from && | |
update.state.selection.primary.to <= this.view.viewport.to) { | |
this.updateSelection(forceSelection, pointerSel); | |
return false; | |
} | |
else { | |
this.updateInner(changedRanges, deco, update.prevState.doc.length, forceSelection, pointerSel); | |
return true; | |
} | |
} | |
// Used both by update and checkLayout do perform the actual DOM | |
// update | |
updateInner(changes, deco, oldLength, forceSelection = false, pointerSel = false) { | |
this.updateChildren(changes, deco, oldLength); | |
this.view.observer.ignore(() => { | |
// Lock the height during redrawing, since Chrome sometimes | |
// messes with the scroll position during DOM mutation (though | |
// no relayout is triggered and I cannot imagine how it can | |
// recompute the scroll position without a layout) | |
this.dom.style.height = this.view.viewState.heightMap.height + "px"; | |
this.dom.style.minWidth = this.minWidth ? this.minWidth + "px" : ""; | |
// Chrome will sometimes, when DOM mutations occur directly | |
// around the selection, get confused and report a different | |
// selection from the one it displays (issue #218). This tries | |
// to detect that situation. | |
let track = browser.chrome ? { node: getSelection(this.view.root).focusNode, written: false } : undefined; | |
this.sync(track); | |
this.dirty = 0 /* Not */; | |
if (track === null || track === void 0 ? void 0 : track.written) | |
forceSelection = true; | |
this.updateSelection(forceSelection, pointerSel); | |
this.dom.style.height = ""; | |
}); | |
} | |
updateChildren(changes, deco, oldLength) { | |
let cursor = this.childCursor(oldLength); | |
for (let i = changes.length - 1;; i--) { | |
let next = i >= 0 ? changes[i] : null; | |
if (!next) | |
break; | |
let { fromA, toA, fromB, toB } = next; | |
let { content, breakAtStart } = ContentBuilder.build(this.view.state.doc, fromB, toB, deco); | |
let { i: toI, off: toOff } = cursor.findPos(toA, 1); | |
let { i: fromI, off: fromOff } = cursor.findPos(fromA, -1); | |
this.replaceRange(fromI, fromOff, toI, toOff, content, breakAtStart); | |
} | |
} | |
replaceRange(fromI, fromOff, toI, toOff, content, breakAtStart) { | |
let before = this.children[fromI], last = content.length ? content[content.length - 1] : null; | |
let breakAtEnd = last ? last.breakAfter : breakAtStart; | |
// Change within a single line | |
if (fromI == toI && !breakAtStart && !breakAtEnd && content.length < 2 && | |
before.merge(fromOff, toOff, content.length ? last : null, fromOff == 0)) | |
return; | |
let after = this.children[toI]; | |
// Make sure the end of the line after the update is preserved in `after` | |
if (toOff < after.length || after.children.length && after.children[after.children.length - 1].length == 0) { | |
// If we're splitting a line, separate part of the start line to | |
// avoid that being mangled when updating the start line. | |
if (fromI == toI) { | |
after = after.split(toOff); | |
toOff = 0; | |
} | |
// If the element after the replacement should be merged with | |
// the last replacing element, update `content` | |
if (!breakAtEnd && last && after.merge(0, toOff, last, true)) { | |
content[content.length - 1] = after; | |
} | |
else { | |
// Remove the start of the after element, if necessary, and | |
// add it to `content`. | |
if (toOff || after.children.length && after.children[0].length == 0) | |
after.merge(0, toOff, null, false); | |
content.push(after); | |
} | |
} | |
else if (after.breakAfter) { | |
// The element at `toI` is entirely covered by this range. | |
// Preserve its line break, if any. | |
if (last) | |
last.breakAfter = 1; | |
else | |
breakAtStart = 1; | |
} | |
// Since we've handled the next element from the current elements | |
// now, make sure `toI` points after that. | |
toI++; | |
before.breakAfter = breakAtStart; | |
if (fromOff > 0) { | |
if (!breakAtStart && content.length && before.merge(fromOff, before.length, content[0], false)) { | |
before.breakAfter = content.shift().breakAfter; | |
} | |
else if (fromOff < before.length || before.children.length && before.children[before.children.length - 1].length == 0) { | |
before.merge(fromOff, before.length, null, false); | |
} | |
fromI++; | |
} | |
// Try to merge widgets on the boundaries of the replacement | |
while (fromI < toI && content.length) { | |
if (this.children[toI - 1].match(content[content.length - 1])) | |
toI--, content.pop(); | |
else if (this.children[fromI].match(content[0])) | |
fromI++, content.shift(); | |
else | |
break; | |
} | |
if (fromI < toI || content.length) | |
this.replaceChildren(fromI, toI, content); | |
} | |
// Sync the DOM selection to this.state.selection | |
updateSelection(force = false, fromPointer = false) { | |
if (!(fromPointer || this.mayControlSelection())) | |
return; | |
let primary = this.view.state.selection.primary; | |
// FIXME need to handle the case where the selection falls inside a block range | |
let anchor = this.domAtPos(primary.anchor); | |
let head = this.domAtPos(primary.head); | |
let domSel = getSelection(this.root); | |
// If the selection is already here, or in an equivalent position, don't touch it | |
if (force || !domSel.focusNode || | |
(browser.gecko && primary.empty && nextToUneditable(domSel.focusNode, domSel.focusOffset)) || | |
!isEquivalentPosition(anchor.node, anchor.offset, domSel.anchorNode, domSel.anchorOffset) || | |
!isEquivalentPosition(head.node, head.offset, domSel.focusNode, domSel.focusOffset)) { | |
this.view.observer.ignore(() => { | |
if (primary.empty) { | |
// Work around https://bugzilla.mozilla.org/show_bug.cgi?id=1612076 | |
if (browser.gecko) { | |
let nextTo = nextToUneditable(anchor.node, anchor.offset); | |
if (nextTo && nextTo != (1 /* Before */ | 2 /* After */)) { | |
let text = nearbyTextNode(anchor.node, anchor.offset, nextTo == 1 /* Before */ ? 1 : -1); | |
if (text) | |
anchor = new DOMPos(text, nextTo == 1 /* Before */ ? 0 : text.nodeValue.length); | |
} | |
} | |
domSel.collapse(anchor.node, anchor.offset); | |
if (primary.bidiLevel != null && domSel.cursorBidiLevel != null) | |
domSel.cursorBidiLevel = primary.bidiLevel; | |
} | |
else if (domSel.extend) { | |
// 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. | |
domSel.collapse(anchor.node, anchor.offset); | |
domSel.extend(head.node, head.offset); | |
} | |
else { | |
// Primitive (IE) way | |
let range = document.createRange(); | |
if (primary.anchor > primary.head) | |
[anchor, head] = [head, anchor]; | |
range.setEnd(head.node, head.offset); | |
range.setStart(anchor.node, anchor.offset); | |
domSel.removeAllRanges(); | |
domSel.addRange(range); | |
} | |
}); | |
} | |
this.impreciseAnchor = anchor.precise ? null : new DOMPos(domSel.anchorNode, domSel.anchorOffset); | |
this.impreciseHead = head.precise ? null : new DOMPos(domSel.focusNode, domSel.focusOffset); | |
} | |
enforceCursorAssoc() { | |
let cursor = this.view.state.selection.primary; | |
let sel = getSelection(this.root); | |
if (!cursor.empty || !cursor.assoc || !sel.modify) | |
return; | |
let line = LineView.find(this, cursor.head); // FIXME provide view-line-range finding helper | |
if (!line) | |
return; | |
let lineStart = line.posAtStart; | |
if (cursor.head == lineStart || cursor.head == lineStart + line.length) | |
return; | |
let before = this.coordsAt(cursor.head, -1), after = this.coordsAt(cursor.head, 1); | |
if (!before || !after || before.bottom > after.top) | |
return; | |
let dom = this.domAtPos(cursor.head + cursor.assoc); | |
sel.collapse(dom.node, dom.offset); | |
sel.modify("move", cursor.assoc < 0 ? "forward" : "backward", "lineboundary"); | |
} | |
mayControlSelection() { | |
return this.view.state.facet(editable) ? this.root.activeElement == this.dom : hasSelection(this.dom, getSelection(this.root)); | |
} | |
nearest(dom) { | |
for (let cur = dom; cur;) { | |
let domView = ContentView.get(cur); | |
if (domView && domView.rootView == this) | |
return domView; | |
cur = cur.parentNode; | |
} | |
return null; | |
} | |
posFromDOM(node, offset) { | |
let view = this.nearest(node); | |
if (!view) | |
throw new RangeError("Trying to find position for a DOM position outside of the document"); | |
return view.localPosFromDOM(node, offset) + view.posAtStart; | |
} | |
domAtPos(pos) { | |
let { i, off } = this.childCursor().findPos(pos, -1); | |
for (; i < this.children.length - 1;) { | |
let child = this.children[i]; | |
if (off < child.length || child instanceof LineView) | |
break; | |
i++; | |
off = 0; | |
} | |
return this.children[i].domAtPos(off); | |
} | |
coordsAt(pos, side) { | |
for (let off = this.length, i = this.children.length - 1;; i--) { | |
let child = this.children[i], start = off - child.breakAfter - child.length; | |
if (pos >= start && child.type != BlockType.WidgetAfter) | |
return child.coordsAt(pos - start, side); | |
off = start; | |
} | |
} | |
measureVisibleLineHeights() { | |
let result = [], { from, to } = this.view.viewState.viewport; | |
let minWidth = Math.max(this.view.scrollDOM.clientWidth, this.minWidth) + 1; | |
for (let pos = 0, i = 0; i < this.children.length; i++) { | |
let child = this.children[i], end = pos + child.length; | |
if (end > to) | |
break; | |
if (pos >= from) { | |
result.push(child.dom.getBoundingClientRect().height); | |
let width = child.dom.scrollWidth; | |
if (width > minWidth) { | |
this.minWidth = minWidth = width; | |
this.minWidthFrom = pos; | |
this.minWidthTo = end; | |
} | |
} | |
pos = end + child.breakAfter; | |
} | |
return result; | |
} | |
measureTextSize() { | |
for (let child of this.children) { | |
if (child instanceof LineView) { | |
let measure = child.measureTextSize(); | |
if (measure) | |
return measure; | |
} | |
} | |
// If no workable line exists, force a layout of a measurable element | |
let dummy = document.createElement("div"), lineHeight, charWidth; | |
dummy.className = "cm-line"; | |
dummy.textContent = "abc def ghi jkl mno pqr stu"; | |
this.view.observer.ignore(() => { | |
this.dom.appendChild(dummy); | |
let rect = clientRectsFor(dummy.firstChild)[0]; | |
lineHeight = dummy.getBoundingClientRect().height; | |
charWidth = rect ? rect.width / 27 : 7; | |
dummy.remove(); | |
}); | |
return { lineHeight, charWidth }; | |
} | |
childCursor(pos = this.length) { | |
// Move back to start of last element when possible, so that | |
// `ChildCursor.findPos` doesn't have to deal with the edge case | |
// of being after the last element. | |
let i = this.children.length; | |
if (i) | |
pos -= this.children[--i].length; | |
return new ChildCursor(this.children, pos, i); | |
} | |
computeBlockGapDeco() { | |
let visible = this.view.viewState.viewport, viewports = [visible]; | |
let { head, anchor } = this.view.state.selection.primary; | |
if (head < visible.from || head > visible.to) { | |
let { from, to } = this.view.viewState.lineAt(head, 0); | |
viewports.push(new Viewport(from, to)); | |
} | |
if (!viewports.some(({ from, to }) => anchor >= from && anchor <= to)) { | |
let { from, to } = this.view.viewState.lineAt(anchor, 0); | |
viewports.push(new Viewport(from, to)); | |
} | |
this.viewports = viewports.sort((a, b) => a.from - b.from); | |
let deco = []; | |
for (let pos = 0, i = 0;; i++) { | |
let next = i == viewports.length ? null : viewports[i]; | |
let end = next ? next.from - 1 : this.length; | |
if (end > pos) { | |
let height = this.view.viewState.lineAt(end, 0).bottom - this.view.viewState.lineAt(pos, 0).top; | |
deco.push(Decoration.replace({ widget: new BlockGapWidget(height), block: true, inclusive: true }).range(pos, end)); | |
} | |
if (!next) | |
break; | |
pos = next.to + 1; | |
} | |
return Decoration.set(deco); | |
} | |
updateDeco() { | |
return this.decorations = [ | |
...this.view.state.facet(decorations), | |
this.computeBlockGapDeco(), | |
this.view.viewState.lineGapDeco, | |
this.compositionDeco, | |
...this.view.pluginField(pluginDecorations) | |
]; | |
} | |
scrollPosIntoView(pos, side) { | |
let rect = this.coordsAt(pos, side); | |
if (!rect) | |
return; | |
let mLeft = 0, mRight = 0, mTop = 0, mBottom = 0; | |
for (let margins of this.view.pluginField(PluginField.scrollMargins)) | |
if (margins) { | |
let { left, right, top, bottom } = margins; | |
if (left != null) | |
mLeft = Math.max(mLeft, left); | |
if (right != null) | |
mRight = Math.max(mRight, right); | |
if (top != null) | |
mTop = Math.max(mTop, top); | |
if (bottom != null) | |
mBottom = Math.max(mBottom, bottom); | |
} | |
scrollRectIntoView(this.dom, { | |
left: rect.left - mLeft, top: rect.top - mTop, | |
right: rect.right + mRight, bottom: rect.bottom + mBottom | |
}); | |
} | |
} | |
// Browsers appear to reserve a fixed amount of bits for height | |
// styles, and ignore or clip heights above that. For Chrome and | |
// Firefox, this is in the 20 million range, so we try to stay below | |
// that. | |
const MaxNodeHeight = 1e7; | |
class BlockGapWidget extends WidgetType { | |
toDOM() { | |
let elt = document.createElement("div"); | |
this.updateDOM(elt); | |
return elt; | |
} | |
updateDOM(elt) { | |
if (this.value < MaxNodeHeight) { | |
while (elt.lastChild) | |
elt.lastChild.remove(); | |
elt.style.height = this.value + "px"; | |
} | |
else { | |
elt.style.height = ""; | |
for (let remaining = this.value; remaining > 0; remaining -= MaxNodeHeight) { | |
let fill = elt.appendChild(document.createElement("div")); | |
fill.style.height = Math.min(remaining, MaxNodeHeight) + "px"; | |
} | |
} | |
return true; | |
} | |
get estimatedHeight() { return this.value; } | |
} | |
function computeCompositionDeco(view, changes) { | |
let sel = getSelection(view.root); | |
let textNode = sel.focusNode && nearbyTextNode(sel.focusNode, sel.focusOffset, 0); | |
if (!textNode) | |
return Decoration.none; | |
let cView = view.docView.nearest(textNode); | |
let from, to, topNode = textNode; | |
if (cView instanceof InlineView) { | |
from = cView.posAtStart; | |
to = from + cView.length; | |
topNode = cView.dom; | |
} | |
else if (cView instanceof LineView) { | |
while (topNode.parentNode != cView.dom) | |
topNode = topNode.parentNode; | |
let prev = topNode.previousSibling; | |
while (prev && !ContentView.get(prev)) | |
prev = prev.previousSibling; | |
from = to = prev ? ContentView.get(prev).posAtEnd : cView.posAtStart; | |
} | |
else { | |
return Decoration.none; | |
} | |
let newFrom = changes.mapPos(from, 1), newTo = Math.max(newFrom, changes.mapPos(to, -1)); | |
let text = textNode.nodeValue, { state } = view; | |
if (newTo - newFrom < text.length) { | |
if (state.sliceDoc(newFrom, Math.min(state.doc.length, newFrom + text.length)) == text) | |
newTo = newFrom + text.length; | |
else if (state.sliceDoc(Math.max(0, newTo - text.length), newTo) == text) | |
newFrom = newTo - text.length; | |
else | |
return Decoration.none; | |
} | |
else if (state.sliceDoc(newFrom, newTo) != text) { | |
return Decoration.none; | |
} | |
return Decoration.set(Decoration.replace({ widget: new CompositionWidget({ top: topNode, text: textNode }) }).range(newFrom, newTo)); | |
} | |
class CompositionWidget extends WidgetType { | |
eq(value) { return this.value.top == value.top && this.value.text == value.text; } | |
toDOM() { return this.value.top; } | |
ignoreEvent() { return false; } | |
get customView() { return CompositionView; } | |
} | |
function nearbyTextNode(node, offset, side) { | |
for (;;) { | |
if (node.nodeType == 3) | |
return node; | |
if (node.nodeType == 1 && offset > 0 && side <= 0) { | |
node = node.childNodes[offset - 1]; | |
offset = maxOffset(node); | |
} | |
else if (node.nodeType == 1 && offset < node.childNodes.length && side >= 0) { | |
node = node.childNodes[offset]; | |
offset = 0; | |
} | |
else { | |
return null; | |
} | |
} | |
} | |
function nextToUneditable(node, offset) { | |
if (node.nodeType != 1) | |
return 0; | |
return (offset && node.childNodes[offset - 1].contentEditable == "false" ? 1 /* Before */ : 0) | | |
(offset < node.childNodes.length && node.childNodes[offset].contentEditable == "false" ? 2 /* After */ : 0); | |
} | |
class DecorationComparator$1 { | |
constructor() { | |
this.changes = []; | |
} | |
compareRange(from, to) { addRange(from, to, this.changes); } | |
comparePoint(from, to) { addRange(from, to, this.changes); } | |
} | |
function findChangedDeco(a, b, diff) { | |
let comp = new DecorationComparator$1; | |
RangeSet.compare(a, b, diff, comp); | |
return comp.changes; | |
} | |
function groupAt(state, pos, bias = 1) { | |
let categorize = state.charCategorizer(pos); | |
let line = state.doc.lineAt(pos), linePos = pos - line.from; | |
if (line.length == 0) | |
return EditorSelection.cursor(pos); | |
if (linePos == 0) | |
bias = 1; | |
else if (linePos == line.length) | |
bias = -1; | |
let from = linePos, to = linePos; | |
if (bias < 0) | |
from = line.findClusterBreak(linePos, false); | |
else | |
to = line.findClusterBreak(linePos, true); | |
let cat = categorize(line.slice(from, to)); | |
while (from > 0) { | |
let prev = line.findClusterBreak(from, false); | |
if (categorize(line.slice(prev, from)) != cat) | |
break; | |
from = prev; | |
} | |
while (to < line.length) { | |
let next = line.findClusterBreak(to, true); | |
if (categorize(line.slice(to, next)) != cat) | |
break; | |
to = next; | |
} | |
return EditorSelection.range(from + line.from, to + line.from); | |
} | |
// Search the DOM for the {node, offset} position closest to the given | |
// coordinates. Very inefficient and crude, but can usually be avoided | |
// by calling caret(Position|Range)FromPoint instead. | |
// FIXME holding arrow-up/down at the end of the viewport is a rather | |
// common use case that will repeatedly trigger this code. Maybe | |
// introduce some element of binary search after all? | |
function getdx(x, rect) { | |
return rect.left > x ? rect.left - x : Math.max(0, x - rect.right); | |
} | |
function getdy(y, rect) { | |
return rect.top > y ? rect.top - y : Math.max(0, y - rect.bottom); | |
} | |
function yOverlap(a, b) { | |
return a.top < b.bottom - 1 && a.bottom > b.top + 1; | |
} | |
function upTop(rect, top) { | |
return top < rect.top ? { top, left: rect.left, right: rect.right, bottom: rect.bottom } : rect; | |
} | |
function upBot(rect, bottom) { | |
return bottom > rect.bottom ? { top: rect.top, left: rect.left, right: rect.right, bottom } : rect; | |
} | |
function domPosAtCoords(parent, x, y) { | |
let closest, closestRect, closestX, closestY; | |
let above, below, aboveRect, belowRect; | |
for (let child = parent.firstChild; child; child = child.nextSibling) { | |
let rects = clientRectsFor(child); | |
for (let i = 0; i < rects.length; i++) { | |
let rect = rects[i]; | |
if (closestRect && yOverlap(closestRect, rect)) | |
rect = upTop(upBot(rect, closestRect.bottom), closestRect.top); | |
let dx = getdx(x, rect), dy = getdy(y, rect); | |
if (dx == 0 && dy == 0) | |
return child.nodeType == 3 ? domPosInText(child, x, y) : domPosAtCoords(child, x, y); | |
if (!closest || closestY > dy || closestY == dy && closestX > dx) { | |
closest = child; | |
closestRect = rect; | |
closestX = dx; | |
closestY = dy; | |
} | |
if (dx == 0) { | |
if (y > rect.bottom && (!aboveRect || aboveRect.bottom < rect.bottom)) { | |
above = child; | |
aboveRect = rect; | |
} | |
else if (y < rect.top && (!belowRect || belowRect.top > rect.top)) { | |
below = child; | |
belowRect = rect; | |
} | |
} | |
else if (aboveRect && yOverlap(aboveRect, rect)) { | |
aboveRect = upBot(aboveRect, rect.bottom); | |
} | |
else if (belowRect && yOverlap(belowRect, rect)) { | |
belowRect = upTop(belowRect, rect.top); | |
} | |
} | |
} | |
if (aboveRect && aboveRect.bottom >= y) { | |
closest = above; | |
closestRect = aboveRect; | |
} | |
else if (belowRect && belowRect.top <= y) { | |
closest = below; | |
closestRect = belowRect; | |
} | |
if (!closest) | |
return { node: parent, offset: 0 }; | |
let clipX = Math.max(closestRect.left, Math.min(closestRect.right, x)); | |
if (closest.nodeType == 3) | |
return domPosInText(closest, clipX, y); | |
if (!closestX && closest.contentEditable == "true") | |
return domPosAtCoords(closest, clipX, y); | |
let offset = Array.prototype.indexOf.call(parent.childNodes, closest) + | |
(x >= (closestRect.left + closestRect.right) / 2 ? 1 : 0); | |
return { node: parent, offset }; | |
} | |
function domPosInText(node, x, y) { | |
let len = node.nodeValue.length, range = document.createRange(); | |
for (let i = 0; i < len; i++) { | |
range.setEnd(node, i + 1); | |
range.setStart(node, i); | |
let rects = range.getClientRects(); | |
for (let j = 0; j < rects.length; j++) { | |
let rect = rects[j]; | |
if (rect.top == rect.bottom) | |
continue; | |
if (rect.left - 1 <= x && rect.right + 1 >= x && | |
rect.top - 1 <= y && rect.bottom + 1 >= y) { | |
let right = x >= (rect.left + rect.right) / 2, after = right; | |
if (browser.chrome || browser.gecko) { | |
// Check for RTL on browsers that support getting client | |
// rects for empty ranges. | |
range.setEnd(node, i); | |
let rectBefore = range.getBoundingClientRect(); | |
if (rectBefore.left == rect.right) | |
after = !right; | |
} | |
return { node, offset: i + (after ? 1 : 0) }; | |
} | |
} | |
} | |
return { node, offset: 0 }; | |
} | |
function posAtCoords(view, { x, y }, bias = -1) { | |
let content = view.contentDOM.getBoundingClientRect(), block; | |
let halfLine = view.defaultLineHeight / 2; | |
for (let bounced = false;;) { | |
block = view.blockAtHeight(y, content.top); | |
if (block.top > y || block.bottom < y) { | |
bias = block.top > y ? -1 : 1; | |
y = Math.min(block.bottom - halfLine, Math.max(block.top + halfLine, y)); | |
if (bounced) | |
return -1; | |
else | |
bounced = true; | |
} | |
if (block.type == BlockType.Text) | |
break; | |
y = bias > 0 ? block.bottom + halfLine : block.top - halfLine; | |
} | |
let lineStart = block.from; | |
// If this is outside of the rendered viewport, we can't determine a position | |
if (lineStart < view.viewport.from) | |
return view.viewport.from == 0 ? 0 : -1; | |
if (lineStart > view.viewport.to) | |
return view.viewport.to == view.state.doc.length ? view.state.doc.length : -1; | |
// Clip x to the viewport sides | |
x = Math.max(content.left + 1, Math.min(content.right - 1, x)); | |
let root = view.root, element = root.elementFromPoint(x, y); | |
// There's visible editor content under the point, so we can try | |
// using caret(Position|Range)FromPoint as a shortcut | |
let node, offset = -1; | |
if (element && view.contentDOM.contains(element) && !(view.docView.nearest(element) instanceof WidgetView)) { | |
if (root.caretPositionFromPoint) { | |
let pos = root.caretPositionFromPoint(x, y); | |
if (pos) | |
({ offsetNode: node, offset } = pos); | |
} | |
else if (root.caretRangeFromPoint) { | |
let range = root.caretRangeFromPoint(x, y); | |
if (range) | |
({ startContainer: node, startOffset: offset } = range); | |
} | |
} | |
// No luck, do our own (potentially expensive) search | |
if (!node) { | |
let line = LineView.find(view.docView, lineStart); | |
({ node, offset } = domPosAtCoords(line.dom, x, y)); | |
} | |
return view.docView.posFromDOM(node, offset); | |
} | |
function moveToLineBoundary(view, start, forward, includeWrap) { | |
let line = view.state.doc.lineAt(start.head); | |
let coords = !includeWrap || !view.lineWrapping ? null | |
: view.coordsAtPos(start.assoc < 0 && start.head > line.from ? start.head - 1 : start.head); | |
if (coords) { | |
let editorRect = view.dom.getBoundingClientRect(); | |
let pos = view.posAtCoords({ x: forward == (view.textDirection == Direction.LTR) ? editorRect.right - 1 : editorRect.left + 1, | |
y: (coords.top + coords.bottom) / 2 }); | |
if (pos > -1) | |
return EditorSelection.cursor(pos, forward ? -1 : 1); | |
} | |
let lineView = LineView.find(view.docView, start.head); | |
let end = lineView ? (forward ? lineView.posAtEnd : lineView.posAtStart) : (forward ? line.to : line.from); | |
return EditorSelection.cursor(end, forward ? -1 : 1); | |
} | |
function moveByChar(view, start, forward, by) { | |
let line = view.state.doc.lineAt(start.head), spans = view.bidiSpans(line); | |
for (let cur = start, check = null;;) { | |
let next = moveVisually(line, spans, view.textDirection, cur, forward), char = movedOver; | |
if (!next) { | |
if (line.number == (forward ? view.state.doc.lines : 1)) | |
return cur; | |
char = "\n"; | |
line = view.state.doc.line(line.number + (forward ? 1 : -1)); | |
spans = view.bidiSpans(line); | |
next = EditorSelection.cursor(forward ? line.from : line.to); | |
} | |
if (!check) { | |
if (!by) | |
return next; | |
check = by(char); | |
} | |
else if (!check(char)) { | |
return cur; | |
} | |
cur = next; | |
} | |
} | |
function byGroup(view, pos, start) { | |
let categorize = view.state.charCategorizer(pos); | |
let cat = categorize(start); | |
return (next) => { | |
let nextCat = categorize(next); | |
if (cat == CharCategory.Space) | |
cat = nextCat; | |
return cat == nextCat; | |
}; | |
} | |
function moveVertically(view, start, forward, distance) { | |
var _a; | |
let startPos = start.head, dir = forward ? 1 : -1; | |
let startCoords = view.coordsAtPos(startPos); | |
if (startCoords) { | |
let rect = view.dom.getBoundingClientRect(); | |
let goal = (_a = start.goalColumn) !== null && _a !== void 0 ? _a : startCoords.left - rect.left; | |
let resolvedGoal = rect.left + goal; | |
let dist = distance !== null && distance !== void 0 ? distance : 5; | |
for (let startY = dir < 0 ? startCoords.top : startCoords.bottom, extra = 0; extra < 50; extra += 10) { | |
let pos = posAtCoords(view, { x: resolvedGoal, y: startY + (dist + extra) * dir }, dir); | |
if (pos < 0) | |
break; | |
if (pos != startPos) | |
return EditorSelection.cursor(pos, undefined, undefined, goal); | |
} | |
} | |
// Outside of the drawn viewport, use a crude column-based approach | |
let { doc } = view.state, line = doc.lineAt(startPos), tabSize = view.state.tabSize; | |
let goal = start.goalColumn, goalCol = 0; | |
if (goal == null) { | |
for (const iter = doc.iterRange(line.from, startPos); !iter.next().done;) | |
goalCol = countColumn(iter.value, goalCol, tabSize); | |
goal = goalCol * view.defaultCharacterWidth; | |
} | |
else { | |
goalCol = Math.round(goal / view.defaultCharacterWidth); | |
} | |
if (dir < 0 && line.from == 0) | |
return EditorSelection.cursor(0, undefined, undefined, goal); | |
else if (dir > 0 && line.to == doc.length) | |
return EditorSelection.cursor(line.to, undefined, undefined, goal); | |
let otherLine = doc.line(line.number + dir); | |
let result = otherLine.from; | |
let seen = 0; | |
for (const iter = doc.iterRange(otherLine.from, otherLine.to); seen >= goalCol && !iter.next().done;) { | |
const { offset, leftOver } = findColumn(iter.value, seen, goalCol, tabSize); | |
seen = goalCol - leftOver; | |
result += offset; | |
} | |
return EditorSelection.cursor(result, undefined, undefined, goal); | |
} | |
// This will also be where dragging info and such goes | |
class InputState { | |
constructor(view) { | |
this.lastKeyCode = 0; | |
this.lastKeyTime = 0; | |
this.lastSelectionOrigin = null; | |
this.lastSelectionTime = 0; | |
this.registeredEvents = []; | |
this.customHandlers = []; | |
this.composing = false; | |
this.compositionEndedAt = 0; | |
this.mouseSelection = null; | |
for (let type in handlers) { | |
let handler = handlers[type]; | |
view.contentDOM.addEventListener(type, (event) => { | |
if (!eventBelongsToEditor(view, event) || this.ignoreDuringComposition(event)) | |
return; | |
if (this.mustFlushObserver(event)) | |
view.observer.forceFlush(); | |
if (this.runCustomHandlers(type, view, event)) | |
event.preventDefault(); | |
else | |
handler(view, event); | |
}); | |
this.registeredEvents.push(type); | |
} | |
// Must always run, even if a custom handler handled the event | |
view.contentDOM.addEventListener("keydown", (event) => { | |
view.inputState.lastKeyCode = event.keyCode; | |
view.inputState.lastKeyTime = Date.now(); | |
}); | |
if (view.root.activeElement == view.contentDOM) | |
view.dom.classList.add("cm-focused"); | |
this.notifiedFocused = view.hasFocus; | |
this.ensureHandlers(view); | |
} | |
setSelectionOrigin(origin) { | |
this.lastSelectionOrigin = origin; | |
this.lastSelectionTime = Date.now(); | |
} | |
ensureHandlers(view) { | |
let handlers = this.customHandlers = view.pluginField(domEventHandlers); | |
for (let set of handlers) { | |
for (let type in set.handlers) | |
if (this.registeredEvents.indexOf(type) < 0) { | |
this.registeredEvents.push(type); | |
(type != "scroll" ? view.contentDOM : view.scrollDOM).addEventListener(type, (event) => { | |
if (!eventBelongsToEditor(view, event)) | |
return; | |
if (this.runCustomHandlers(type, view, event)) | |
event.preventDefault(); | |
}); | |
} | |
} | |
} | |
runCustomHandlers(type, view, event) { | |
for (let set of this.customHandlers) { | |
let handler = set.handlers[type]; | |
if (handler) { | |
try { | |
if (handler.call(set.plugin, event, view) || event.defaultPrevented) | |
return true; | |
} | |
catch (e) { | |
logException(view.state, e); | |
} | |
} | |
} | |
return false; | |
} | |
ignoreDuringComposition(event) { | |
if (!/^key/.test(event.type)) | |
return false; | |
if (this.composing) | |
return true; | |
// See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/. | |
// On some input method editors (IMEs), the Enter key is used to | |
// confirm character selection. On Safari, when Enter is pressed, | |
// compositionend and keydown events are sometimes emitted in the | |
// wrong order. The key event should still be ignored, even when | |
// it happens after the compositionend event. | |
if (browser.safari && event.timeStamp - this.compositionEndedAt < 500) { | |
this.compositionEndedAt = 0; | |
return true; | |
} | |
return false; | |
} | |
mustFlushObserver(event) { | |
return event.type == "keydown" || event.type == "compositionend"; | |
} | |
startMouseSelection(view, event, style) { | |
if (this.mouseSelection) | |
this.mouseSelection.destroy(); | |
this.mouseSelection = new MouseSelection(this, view, event, style); | |
} | |
update(update) { | |
if (this.mouseSelection) | |
this.mouseSelection.update(update); | |
this.lastKeyCode = this.lastSelectionTime = 0; | |
} | |
destroy() { | |
if (this.mouseSelection) | |
this.mouseSelection.destroy(); | |
} | |
} | |
class MouseSelection { | |
constructor(inputState, view, startEvent, style) { | |
this.inputState = inputState; | |
this.view = view; | |
this.startEvent = startEvent; | |
this.style = style; | |
let doc = view.contentDOM.ownerDocument; | |
doc.addEventListener("mousemove", this.move = this.move.bind(this)); | |
doc.addEventListener("mouseup", this.up = this.up.bind(this)); | |
this.extend = startEvent.shiftKey; | |
this.multiple = view.state.facet(EditorState.allowMultipleSelections) && addsSelectionRange(view, startEvent); | |
this.dragMove = dragMovesSelection$1(view, startEvent); | |
this.dragging = isInPrimarySelection(view, startEvent) ? null : false; | |
// When clicking outside of the selection, immediately apply the | |
// effect of starting the selection | |
if (this.dragging === false) { | |
startEvent.preventDefault(); | |
this.select(startEvent); | |
} | |
} | |
move(event) { | |
if (event.buttons == 0) | |
return this.destroy(); | |
if (this.dragging !== false) | |
return; | |
this.select(event); | |
} | |
up() { | |
if (this.dragging == null) | |
this.select(this.startEvent); | |
this.destroy(); | |
} | |
destroy() { | |
let doc = this.view.contentDOM.ownerDocument; | |
doc.removeEventListener("mousemove", this.move); | |
doc.removeEventListener("mouseup", this.up); | |
this.inputState.mouseSelection = null; | |
} | |
select(event) { | |
let selection = this.style.get(event, this.extend, this.multiple); | |
if (!selection.eq(this.view.state.selection) || selection.primary.assoc != this.view.state.selection.primary.assoc) | |
this.view.dispatch({ | |
selection, | |
annotations: Transaction.userEvent.of("pointerselection"), | |
scrollIntoView: true | |
}); | |
} | |
update(update) { | |
if (update.docChanged && this.dragging) | |
this.dragging = this.dragging.map(update.changes); | |
this.style.update(update); | |
} | |
} | |
function addsSelectionRange(view, event) { | |
let facet = view.state.facet(clickAddsSelectionRange); | |
return facet.length ? facet[0](event) : browser.mac ? event.metaKey : event.ctrlKey; | |
} | |
function dragMovesSelection$1(view, event) { | |
let facet = view.state.facet(dragMovesSelection); | |
return facet.length ? facet[0](event) : browser.mac ? !event.altKey : !event.ctrlKey; | |
} | |
function isInPrimarySelection(view, event) { | |
let { primary } = view.state.selection; | |
if (primary.empty) | |
return false; | |
// On boundary clicks, check whether the coordinates are inside the | |
// selection's client rectangles | |
let sel = getSelection(view.root); | |
if (sel.rangeCount == 0) | |
return true; | |
let rects = sel.getRangeAt(0).getClientRects(); | |
for (let i = 0; i < rects.length; i++) { | |
let rect = rects[i]; | |
if (rect.left <= event.clientX && rect.right >= event.clientX && | |
rect.top <= event.clientY && rect.bottom >= event.clientY) | |
return true; | |
} | |
return false; | |
} | |
function eventBelongsToEditor(view, event) { | |
if (!event.bubbles) | |
return true; | |
if (event.defaultPrevented) | |
return false; | |
for (let node = event.target, cView; node != view.contentDOM; node = node.parentNode) | |
if (!node || node.nodeType == 11 || ((cView = ContentView.get(node)) && cView.ignoreEvent(event))) | |
return false; | |
return true; | |
} | |
const handlers = Object.create(null); | |
// 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 = (browser.ie && browser.ie_version < 15) || | |
(browser.ios && browser.webkit_version < 604); | |
function capturePaste(view) { | |
let parent = view.dom.parentNode; | |
if (!parent) | |
return; | |
let target = parent.appendChild(document.createElement("textarea")); | |
target.style.cssText = "position: fixed; left: -10000px; top: 10px"; | |
target.focus(); | |
setTimeout(() => { | |
view.focus(); | |
target.remove(); | |
doPaste(view, target.value); | |
}, 50); | |
} | |
function doPaste(view, input) { | |
let text = view.state.toText(input), i = 1; | |
let changes = text.lines == view.state.selection.ranges.length ? | |
view.state.changeByRange(range => { | |
let line = text.line(i++); | |
return { changes: { from: range.from, to: range.to, insert: line.slice() }, | |
range: EditorSelection.cursor(range.from + line.length) }; | |
}) : view.state.replaceSelection(text); | |
view.dispatch(changes, { | |
annotations: Transaction.userEvent.of("paste"), | |
scrollIntoView: true | |
}); | |
} | |
function mustCapture(event) { | |
let mods = (event.ctrlKey ? 1 /* Ctrl */ : 0) | (event.metaKey ? 8 /* Meta */ : 0) | | |
(event.altKey ? 2 /* Alt */ : 0) | (event.shiftKey ? 4 /* Shift */ : 0); | |
let code = event.keyCode, macCtrl = browser.mac && mods == 1 /* Ctrl */; | |
return code == 8 || (macCtrl && code == 72) || // Backspace, Ctrl-h on Mac | |
code == 46 || (macCtrl && code == 68) || // Delete, Ctrl-d on Mac | |
code == 27 || // Esc | |
(mods == (browser.mac ? 8 /* Meta */ : 1 /* Ctrl */) && // Ctrl/Cmd-[biyz] | |
(code == 66 || code == 73 || code == 89 || code == 90)); | |
} | |
handlers.keydown = (view, event) => { | |
if (mustCapture(event)) | |
event.preventDefault(); | |
view.inputState.setSelectionOrigin("keyboardselection"); | |
}; | |
handlers.touchdown = handlers.touchmove = view => { | |
view.inputState.setSelectionOrigin("pointerselection"); | |
}; | |
handlers.mousedown = (view, event) => { | |
let style = null; | |
for (let makeStyle of view.state.facet(mouseSelectionStyle)) { | |
style = makeStyle(view, event); | |
if (style) | |
break; | |
} | |
if (!style && event.button == 0) | |
style = basicMouseSelection(view, event); | |
if (style) { | |
if (view.root.activeElement != view.contentDOM) | |
view.observer.ignore(() => focusPreventScroll(view.contentDOM)); | |
view.inputState.startMouseSelection(view, event, style); | |
} | |
}; | |
function rangeForClick(view, pos, bias, type) { | |
if (type == 1) { // Single click | |
return EditorSelection.cursor(pos, bias); | |
} | |
else if (type == 2) { // Double click | |
return groupAt(view.state, pos, bias); | |
} | |
else { // Triple click | |
let line = LineView.find(view.docView, pos); | |
if (line) | |
return EditorSelection.range(line.posAtStart, line.posAtEnd); | |
let { from, to } = view.state.doc.lineAt(pos); | |
return EditorSelection.range(from, to); | |
} | |
} | |
let insideY = (y, rect) => y >= rect.top && y <= rect.bottom; | |
let inside = (x, y, rect) => insideY(y, rect) && x >= rect.left && x <= rect.right; | |
// Try to determine, for the given coordinates, associated with the | |
// given position, whether they are related to the element before or | |
// the element after the position. | |
function findPositionSide(view, pos, x, y) { | |
let line = LineView.find(view.docView, pos); | |
if (!line) | |
return 1; | |
let off = pos - line.posAtStart; | |
// Line boundaries point into the line | |
if (off == 0) | |
return 1; | |
if (off == line.length) | |
return -1; | |
// Positions on top of an element point at that element | |
let before = line.coordsAt(off, -1); | |
if (before && inside(x, y, before)) | |
return -1; | |
let after = line.coordsAt(off, 1); | |
if (after && inside(x, y, after)) | |
return 1; | |
// This is probably a line wrap point. Pick before if the point is | |
// beside it. | |
return before && insideY(y, before) ? -1 : 1; | |
} | |
function queryPos(view, event) { | |
let pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); | |
if (pos < 0) | |
return null; | |
return { pos, bias: findPositionSide(view, pos, event.clientX, event.clientY) }; | |
} | |
const BadMouseDetail = browser.ie && browser.ie_version <= 11; | |
let lastMouseDown = null, lastMouseDownCount = 0; | |
function getClickType(event) { | |
if (!BadMouseDetail) | |
return event.detail; | |
let last = lastMouseDown; | |
lastMouseDown = event; | |
return lastMouseDownCount = !last || (last.timeStamp > Date.now() - 400 && Math.abs(last.clientX - event.clientX) < 2 && | |
Math.abs(last.clientY - event.clientY) < 2) ? (lastMouseDownCount + 1) % 3 : 1; | |
} | |
function basicMouseSelection(view, event) { | |
let start = queryPos(view, event), type = getClickType(event); | |
let startSel = view.state.selection; | |
let last = start, lastEvent = event; | |
return { | |
update(update) { | |
if (update.changes) { | |
if (start) | |
start.pos = update.changes.mapPos(start.pos); | |
startSel = startSel.map(update.changes); | |
} | |
}, | |
get(event, extend, multiple) { | |
let cur; | |
if (event.clientX == lastEvent.clientX && event.clientY == lastEvent.clientY) | |
cur = last; | |
else { | |
cur = last = queryPos(view, event); | |
lastEvent = event; | |
} | |
if (!cur || !start) | |
return startSel; | |
let range = rangeForClick(view, cur.pos, cur.bias, type); | |
if (start.pos != cur.pos && !extend) { | |
let startRange = rangeForClick(view, start.pos, start.bias, type); | |
let from = Math.min(startRange.from, range.from), to = Math.max(startRange.to, range.to); | |
range = from < range.from ? EditorSelection.range(from, to) : EditorSelection.range(to, from); | |
} | |
if (extend) | |
return startSel.replaceRange(startSel.primary.extend(range.from, range.to)); | |
else if (multiple) | |
return startSel.addRange(range); | |
else | |
return EditorSelection.create([range]); | |
} | |
}; | |
} | |
handlers.dragstart = (view, event) => { | |
let { selection: { primary } } = view.state; | |
let { mouseSelection } = view.inputState; | |
if (mouseSelection) | |
mouseSelection.dragging = primary; | |
if (event.dataTransfer) { | |
event.dataTransfer.setData("Text", view.state.sliceDoc(primary.from, primary.to)); | |
event.dataTransfer.effectAllowed = "copyMove"; | |
} | |
}; | |
handlers.drop = (view, event) => { | |
if (!event.dataTransfer) | |
return; | |
let dropPos = view.posAtCoords({ x: event.clientX, y: event.clientY }); | |
let text = event.dataTransfer.getData("Text"); | |
if (dropPos < 0 || !text) | |
return; | |
event.preventDefault(); | |
let { mouseSelection } = view.inputState; | |
let del = mouseSelection && mouseSelection.dragging && mouseSelection.dragMove ? | |
{ from: mouseSelection.dragging.from, to: mouseSelection.dragging.to } : null; | |
let ins = { from: dropPos, insert: text }; | |
let changes = view.state.changes(del ? [del, ins] : ins); | |
view.focus(); | |
view.dispatch({ | |
changes, | |
selection: { anchor: changes.mapPos(dropPos, -1), head: changes.mapPos(dropPos, 1) }, | |
annotations: Transaction.userEvent.of("drop") | |
}); | |
}; | |
handlers.paste = (view, event) => { | |
view.observer.flush(); | |
let data = brokenClipboardAPI ? null : event.clipboardData; | |
let text = data && data.getData("text/plain"); | |
if (text) { | |
doPaste(view, text); | |
event.preventDefault(); | |
} | |
else { | |
capturePaste(view); | |
} | |
}; | |
function captureCopy(view, text) { | |
// The extra wrapper is somehow necessary on IE/Edge to prevent the | |
// content from being mangled when it is put onto the clipboard | |
let parent = view.dom.parentNode; | |
if (!parent) | |
return; | |
let target = parent.appendChild(document.createElement("textarea")); | |
target.style.cssText = "position: fixed; left: -10000px; top: 10px"; | |
target.value = text; | |
target.focus(); | |
target.selectionEnd = text.length; | |
target.selectionStart = 0; | |
setTimeout(() => { | |
target.remove(); | |
view.focus(); | |
}, 50); | |
} | |
function copiedRange(state) { | |
let content = [], ranges = []; | |
for (let range of state.selection.ranges) | |
if (!range.empty) { | |
content.push(state.sliceDoc(range.from, range.to)); | |
ranges.push(range); | |
} | |
if (!content.length) { | |
// Nothing selected, do a line-wise copy | |
let upto = -1; | |
for (let { from } of state.selection.ranges) { | |
let line = state.doc.lineAt(from); | |
if (line.number > upto) { | |
content.push(line.slice()); | |
ranges.push({ from: line.from, to: Math.min(state.doc.length, line.to + 1) }); | |
} | |
upto = line.number; | |
} | |
} | |
return { text: content.join(state.lineBreak), ranges }; | |
} | |
handlers.copy = handlers.cut = (view, event) => { | |
let { text, ranges } = copiedRange(view.state); | |
if (!text) | |
return; | |
let data = brokenClipboardAPI ? null : event.clipboardData; | |
if (data) { | |
event.preventDefault(); | |
data.clearData(); | |
data.setData("text/plain", text); | |
} | |
else { | |
captureCopy(view, text); | |
} | |
if (event.type == "cut") | |
view.dispatch({ | |
changes: ranges, | |
scrollIntoView: true, | |
annotations: Transaction.userEvent.of("cut") | |
}); | |
}; | |
handlers.focus = handlers.blur = view => { | |
setTimeout(() => { | |
if (view.hasFocus != view.inputState.notifiedFocused) | |
view.update([]); | |
}, 10); | |
}; | |
handlers.beforeprint = view => { | |
view.viewState.printing = true; | |
view.requestMeasure(); | |
setTimeout(() => { | |
view.viewState.printing = false; | |
view.requestMeasure(); | |
}, 2000); | |
}; | |
function forceClearComposition(view) { | |
if (view.docView.compositionDeco.size) | |
view.update([]); | |
} | |
handlers.compositionstart = handlers.compositionupdate = view => { | |
if (!view.inputState.composing) { | |
if (view.docView.compositionDeco.size) { | |
view.observer.flush(); | |
forceClearComposition(view); | |
} | |
// FIXME possibly set a timeout to clear it again on Android | |
view.inputState.composing = true; | |
} | |
}; | |
handlers.compositionend = view => { | |
view.inputState.composing = false; | |
view.inputState.compositionEndedAt = Date.now(); | |
setTimeout(() => { | |
if (!view.inputState.composing) | |
forceClearComposition(view); | |
}, 50); | |
}; | |
const observeOptions = { | |
childList: true, | |
characterData: true, | |
subtree: true, | |
characterDataOldValue: true | |
}; | |
// IE11 has very broken mutation observers, so we also listen to | |
// DOMCharacterDataModified there | |
const useCharData = browser.ie && browser.ie_version <= 11; | |
class DOMObserver { | |
constructor(view, onChange, onScrollChanged) { | |
this.view = view; | |
this.onChange = onChange; | |
this.onScrollChanged = onScrollChanged; | |
this.active = false; | |
this.ignoreSelection = new DOMSelection; | |
this.delayedFlush = -1; | |
this.queue = []; | |
this.scrollTargets = []; | |
this.intersection = null; | |
this.intersecting = false; | |
// Timeout for scheduling check of the parents that need scroll handlers | |
this.parentCheck = -1; | |
this.dom = view.contentDOM; | |
this.observer = new MutationObserver(mutations => { | |
for (let mut of mutations) | |
this.queue.push(mut); | |
// IE11 will sometimes (on typing over a selection or | |
// backspacing out a single character text node) call the | |
// observer callback before actually updating the DOM | |
if (browser.ie && browser.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 = (event) => { | |
this.queue.push({ target: event.target, | |
type: "characterData", | |
oldValue: event.prevValue }); | |
this.flushSoon(); | |
}; | |
this.onSelectionChange = () => { | |
if (this.view.root.activeElement != this.dom) | |
return; | |
// Deletions on IE11 fire their events in the wrong order, giving | |
// us a selection change event before the DOM changes are | |
// reported. | |
if (browser.ie && browser.ie_version <= 11 && !this.view.state.selection.primary.empty) { | |
let sel = getSelection(this.view.root); | |
// Selection.isCollapsed isn't reliable on IE | |
if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode, sel.anchorOffset)) | |
return this.flushSoon(); | |
} | |
this.flush(); | |
}; | |
this.start(); | |
this.onScroll = this.onScroll.bind(this); | |
window.addEventListener("scroll", this.onScroll); | |
if (typeof IntersectionObserver == "function") { | |
this.intersection = new IntersectionObserver(entries => { | |
if (this.parentCheck < 0) | |
this.parentCheck = setTimeout(this.listenForScroll.bind(this), 1000); | |
if (entries[entries.length - 1].intersectionRatio > 0 != this.intersecting) { | |
this.intersecting = !this.intersecting; | |
this.onScroll(); | |
} | |
}, {}); | |
this.intersection.observe(this.dom); | |
} | |
this.listenForScroll(); | |
} | |
onScroll() { | |
if (this.intersecting) { | |
this.flush(); | |
this.onScrollChanged(); | |
} | |
} | |
listenForScroll() { | |
this.parentCheck = -1; | |
let i = 0, changed = null; | |
for (let dom = this.dom; dom;) { | |
if (dom.nodeType == 1) { | |
if (!changed && i < this.scrollTargets.length && this.scrollTargets[i] == dom) | |
i++; | |
else if (!changed) | |
changed = this.scrollTargets.slice(0, i); | |
if (changed) | |
changed.push(dom); | |
dom = dom.parentNode; | |
} | |
else if (dom.nodeType == 11) { // Shadow root | |
dom = dom.host; | |
} | |
else { | |
break; | |
} | |
} | |
if (i < this.scrollTargets.length && !changed) | |
changed = this.scrollTargets.slice(0, i); | |
if (changed) { | |
for (let dom of this.scrollTargets) | |
dom.removeEventListener("scroll", this.onScroll); | |
for (let dom of this.scrollTargets = changed) | |
dom.addEventListener("scroll", this.onScroll); | |
} | |
} | |
ignore(f) { | |
if (!this.active) | |
return f(); | |
try { | |
this.stop(); | |
return f(); | |
} | |
finally { | |
this.start(); | |
this.clear(); | |
} | |
} | |
start() { | |
if (this.active) | |
return; | |
this.observer.observe(this.dom, observeOptions); | |
// FIXME is this shadow-root safe? | |
this.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange); | |
if (useCharData) | |
this.dom.addEventListener("DOMCharacterDataModified", this.onCharData); | |
this.active = true; | |
} | |
stop() { | |
if (!this.active) | |
return; | |
this.active = false; | |
this.observer.disconnect(); | |
this.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange); | |
if (useCharData) | |
this.dom.removeEventListener("DOMCharacterDataModified", this.onCharData); | |
} | |
clearSelection() { | |
this.ignoreSelection.set(getSelection(this.view.root)); | |
} | |
// Throw away any pending changes | |
clear() { | |
this.observer.takeRecords(); | |
this.queue.length = 0; | |
this.clearSelection(); | |
} | |
flushSoon() { | |
if (this.delayedFlush < 0) | |
this.delayedFlush = window.setTimeout(() => { this.delayedFlush = -1; this.flush(); }, 20); | |
} | |
forceFlush() { | |
if (this.delayedFlush >= 0) { | |
window.clearTimeout(this.delayedFlush); | |
this.delayedFlush = -1; | |
this.flush(); | |
} | |
} | |
// Apply pending changes, if any | |
flush() { | |
if (this.delayedFlush >= 0) | |
return; | |
let records = this.queue; | |
for (let mut of this.observer.takeRecords()) | |
records.push(mut); | |
if (records.length) | |
this.queue = []; | |
let selection = getSelection(this.view.root); | |
let newSel = !this.ignoreSelection.eq(selection) && hasSelection(this.dom, selection); | |
if (records.length == 0 && !newSel) | |
return; | |
let from = -1, to = -1, typeOver = false; | |
for (let record of records) { | |
let range = this.readMutation(record); | |
if (!range) | |
continue; | |
if (range.typeOver) | |
typeOver = true; | |
if (from == -1) { | |
({ from, to } = range); | |
} | |
else { | |
from = Math.min(range.from, from); | |
to = Math.max(range.to, to); | |
} | |
} | |
let apply = from > -1 || newSel; | |
if (!apply || !this.onChange(from, to, typeOver)) { | |
if (this.view.docView.dirty) { | |
this.ignore(() => this.view.docView.sync()); | |
this.view.docView.dirty = 0 /* Not */; | |
} | |
this.view.docView.updateSelection(); | |
} | |
this.clearSelection(); | |
} | |
readMutation(rec) { | |
let cView = this.view.docView.nearest(rec.target); | |
if (!cView || cView.ignoreMutation(rec)) | |
return null; | |
cView.markDirty(); | |
if (rec.type == "childList") { | |
let childBefore = findChild(cView, rec.previousSibling || rec.target.previousSibling, -1); | |
let childAfter = findChild(cView, rec.nextSibling || rec.target.nextSibling, 1); | |
return { from: childBefore ? cView.posAfter(childBefore) : cView.posAtStart, | |
to: childAfter ? cView.posBefore(childAfter) : cView.posAtEnd, typeOver: false }; | |
} | |
else { // "characterData" | |
return { from: cView.posAtStart, to: cView.posAtEnd, typeOver: rec.target.nodeValue == rec.oldValue }; | |
} | |
} | |
destroy() { | |
this.stop(); | |
if (this.intersection) | |
this.intersection.disconnect(); | |
for (let dom of this.scrollTargets) | |
dom.removeEventListener("scroll", this.onScroll); | |
window.removeEventListener("scroll", this.onScroll); | |
clearTimeout(this.parentCheck); | |
} | |
} | |
function findChild(cView, dom, dir) { | |
while (dom) { | |
let curView = ContentView.get(dom); | |
if (curView && curView.parent == cView) | |
return curView; | |
let parent = dom.parentNode; | |
dom = parent != cView.dom ? parent : dir > 0 ? dom.nextSibling : dom.previousSibling; | |
} | |
return null; | |
} | |
// FIXME reconsider this kludge (does it break reading dom text with newlines?) | |
const LineSep = "\ufdda"; // A Unicode 'non-character', used to denote newlines internally | |
function applyDOMChange(view, start, end, typeOver) { | |
let change, newSel; | |
let sel = view.state.selection.primary, bounds; | |
if (start > -1 && (bounds = view.docView.domBoundsAround(start, end, 0))) { | |
let { from, to } = bounds; | |
let selPoints = view.docView.impreciseHead || view.docView.impreciseAnchor ? [] : selectionPoints(view.contentDOM, view.root); | |
let reader = new DOMReader(selPoints); | |
reader.readRange(bounds.startDOM, bounds.endDOM); | |
newSel = selectionFromPoints(selPoints, from); | |
let preferredPos = sel.from, preferredSide = null; | |
// Prefer anchoring to end when Backspace is pressed | |
if (view.inputState.lastKeyCode === 8 && view.inputState.lastKeyTime > Date.now() - 100) { | |
preferredPos = sel.to; | |
preferredSide = "end"; | |
} | |
let diff = findDiff(view.state.doc.sliceString(from, to, LineSep), reader.text, preferredPos - from, preferredSide); | |
if (diff) | |
change = { from: from + diff.from, to: from + diff.toA, | |
insert: Text$1.of(reader.text.slice(diff.from, diff.toB).split(LineSep)) }; | |
} | |
else if (view.hasFocus) { | |
let domSel = getSelection(view.root); | |
let { impreciseHead: iHead, impreciseAnchor: iAnchor } = view.docView; | |
let head = iHead && iHead.node == domSel.focusNode && iHead.offset == domSel.focusOffset ? view.state.selection.primary.head | |
: view.docView.posFromDOM(domSel.focusNode, domSel.focusOffset); | |
let anchor = iAnchor && iAnchor.node == domSel.anchorNode && iAnchor.offset == domSel.anchorOffset | |
? view.state.selection.primary.anchor | |
: selectionCollapsed(domSel) ? head : view.docView.posFromDOM(domSel.anchorNode, domSel.anchorOffset); | |
if (head != sel.head || anchor != sel.anchor) | |
newSel = EditorSelection.single(anchor, head); | |
} | |
if (!change && !newSel) | |
return false; | |
// Heuristic to notice typing over a selected character | |
if (!change && typeOver && !sel.empty && newSel && newSel.primary.empty) | |
change = { from: sel.from, to: sel.to, insert: view.state.doc.slice(sel.from, sel.to) }; | |
if (change) { | |
let startState = view.state; | |
// Android browsers don't fire reasonable key events for enter, | |
// backspace, or delete. So this detects changes that look like | |
// they're caused by those keys, and reinterprets them as key | |
// events. | |
if (browser.android && | |
((change.from == sel.from && change.to == sel.to && | |
change.insert.length == 1 && change.insert.lines == 2 && | |
dispatchKey(view, "Enter", 10)) || | |
(change.from == sel.from - 1 && change.to == sel.to && change.insert.length == 0 && | |
dispatchKey(view, "Backspace", 8)) || | |
(change.from == sel.from && change.to == sel.to + 1 && change.insert.length == 0 && | |
dispatchKey(view, "Delete", 46)))) | |
return view.state != startState; | |
let tr; | |
if (change.from >= sel.from && change.to <= sel.to && change.to - change.from >= (sel.to - sel.from) / 3) { | |
let before = sel.from < change.from ? startState.doc.sliceString(sel.from, change.from, LineSep) : ""; | |
let after = sel.to > change.to ? startState.doc.sliceString(change.to, sel.to, LineSep) : ""; | |
tr = startState.replaceSelection(Text$1.of((before + change.insert.sliceString(0, undefined, LineSep) + after).split(LineSep))); | |
} | |
else { | |
let changes = startState.changes(change); | |
tr = { | |
changes, | |
selection: newSel && !startState.selection.primary.eq(newSel.primary) && newSel.primary.to <= changes.newLength | |
? startState.selection.replaceRange(newSel.primary) : undefined | |
}; | |
} | |
view.dispatch(tr, { scrollIntoView: true, annotations: Transaction.userEvent.of("input") }); | |
return true; | |
} | |
else if (newSel && !newSel.primary.eq(sel)) { | |
let scrollIntoView = false, annotations; | |
if (view.inputState.lastSelectionTime > Date.now() - 50) { | |
if (view.inputState.lastSelectionOrigin == "keyboardselection") | |
scrollIntoView = true; | |
else | |
annotations = Transaction.userEvent.of(view.inputState.lastSelectionOrigin); | |
} | |
view.dispatch({ selection: newSel, scrollIntoView, annotations }); | |
return true; | |
} | |
return false; | |
} | |
function findDiff(a, b, preferredPos, preferredSide) { | |
let minLen = Math.min(a.length, b.length); | |
let from = 0; | |
while (from < minLen && a.charCodeAt(from) == b.charCodeAt(from)) | |
from++; | |
if (from == minLen && a.length == b.length) | |
return null; | |
let toA = a.length, toB = b.length; | |
while (toA > 0 && toB > 0 && a.charCodeAt(toA - 1) == b.charCodeAt(toB - 1)) { | |
toA--; | |
toB--; | |
} | |
if (preferredSide == "end") { | |
let adjust = Math.max(0, from - Math.min(toA, toB)); | |
preferredPos -= toA + adjust - from; | |
} | |
if (toA < from && a.length < b.length) { | |
let move = preferredPos <= from && preferredPos >= toA ? from - preferredPos : 0; | |
from -= move; | |
toB = from + (toB - toA); | |
toA = from; | |
} | |
else if (toB < from) { | |
let move = preferredPos <= from && preferredPos >= toB ? from - preferredPos : 0; | |
from -= move; | |
toA = from + (toA - toB); | |
toB = from; | |
} | |
return { from, toA, toB }; | |
} | |
class DOMReader { | |
constructor(points) { | |
this.points = points; | |
this.text = ""; | |
} | |
readRange(start, end) { | |
if (!start) | |
return; | |
let parent = start.parentNode; | |
for (let cur = start;;) { | |
this.findPointBefore(parent, cur); | |
this.readNode(cur); | |
let next = cur.nextSibling; | |
if (next == end) | |
break; | |
let view = ContentView.get(cur), nextView = ContentView.get(next); | |
if ((view ? view.breakAfter : isBlockElement(cur)) || | |
((nextView ? nextView.breakAfter : isBlockElement(next)) && !(cur.nodeName == "BR" && !cur.cmIgnore))) | |
this.text += LineSep; | |
cur = next; | |
} | |
this.findPointBefore(parent, end); | |
} | |
readNode(node) { | |
if (node.cmIgnore) | |
return; | |
let view = ContentView.get(node); | |
let fromView = view && view.overrideDOMText; | |
let text; | |
if (fromView != null) | |
text = fromView.sliceString(0, undefined, LineSep); | |
else if (node.nodeType == 3) | |
text = node.nodeValue; | |
else if (node.nodeName == "BR") | |
text = node.nextSibling ? LineSep : ""; | |
else if (node.nodeType == 1) | |
this.readRange(node.firstChild, null); | |
if (text != null) { | |
this.findPointIn(node, text.length); | |
this.text += text; | |
} | |
} | |
findPointBefore(node, next) { | |
for (let point of this.points) | |
if (point.node == node && node.childNodes[point.offset] == next) | |
point.pos = this.text.length; | |
} | |
findPointIn(node, maxLen) { | |
for (let point of this.points) | |
if (point.node == node) | |
point.pos = this.text.length + Math.min(point.offset, maxLen); | |
} | |
} | |
function isBlockElement(node) { | |
return node.nodeType == 1 && /^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(node.nodeName); | |
} | |
class DOMPoint { | |
constructor(node, offset) { | |
this.node = node; | |
this.offset = offset; | |
this.pos = -1; | |
} | |
} | |
function selectionPoints(dom, root) { | |
let result = []; | |
if (root.activeElement != dom) | |
return result; | |
let { anchorNode, anchorOffset, focusNode, focusOffset } = getSelection(root); | |
if (anchorNode) { | |
result.push(new DOMPoint(anchorNode, anchorOffset)); | |
if (focusNode != anchorNode || focusOffset != anchorOffset) | |
result.push(new DOMPoint(focusNode, focusOffset)); | |
} | |
return result; | |
} | |
function selectionFromPoints(points, base) { | |
if (points.length == 0) | |
return null; | |
let anchor = points[0].pos, head = points.length == 2 ? points[1].pos : anchor; | |
return anchor > -1 && head > -1 ? EditorSelection.single(anchor + base, head + base) : null; | |
} | |
function dispatchKey(view, name, code) { | |
let options = { key: name, code: name, keyCode: code, which: code, cancelable: true }; | |
let down = new KeyboardEvent("keydown", options); | |
view.contentDOM.dispatchEvent(down); | |
let up = new KeyboardEvent("keyup", options); | |
view.contentDOM.dispatchEvent(up); | |
return down.defaultPrevented || up.defaultPrevented; | |
} | |
// The editor's update state machine looks something like this: | |
// | |
// Idle → Updating ⇆ Idle (unchecked) → Measuring → Idle | |
// ↑ ↓ | |
// Updating (measure) | |
// | |
// The difference between 'Idle' and 'Idle (unchecked)' lies in | |
// whether a layout check has been scheduled. A regular update through | |
// the `update` method updates the DOM in a write-only fashion, and | |
// relies on a check (scheduled with `requestAnimationFrame`) to make | |
// sure everything is where it should be and the viewport covers the | |
// visible code. That check continues to measure and then optionally | |
// update until it reaches a coherent state. | |
/// An editor view represents the editor's user interface. It holds | |
/// the editable DOM surface, and possibly other elements such as the | |
/// line number gutter. It handles events and dispatches state | |
/// transactions for editing actions. | |
class EditorView { | |
/// Construct a new view. You'll usually want to put `view.dom` into | |
/// your document after creating a view, so that the user can see | |
/// it. | |
constructor( | |
/// Configuration options. | |
config = {}) { | |
this.plugins = []; | |
this.editorAttrs = {}; | |
this.contentAttrs = {}; | |
this.bidiCache = []; | |
/// @internal | |
this.updateState = 2 /* Updating */; | |
/// @internal | |
this.measureScheduled = -1; | |
/// @internal | |
this.measureRequests = []; | |
this.contentDOM = document.createElement("div"); | |
this.scrollDOM = document.createElement("div"); | |
this.scrollDOM.className = themeClass("scroller"); | |
this.scrollDOM.appendChild(this.contentDOM); | |
this.dom = document.createElement("div"); | |
this.dom.appendChild(this.scrollDOM); | |
this._dispatch = config.dispatch || ((tr) => this.update([tr])); | |
this.dispatch = this.dispatch.bind(this); | |
this.root = (config.root || document); | |
this.viewState = new ViewState(config.state || EditorState.create()); | |
this.plugins = this.state.facet(viewPlugin).map(spec => PluginInstance.create(spec, this)); | |
this.observer = new DOMObserver(this, (from, to, typeOver) => applyDOMChange(this, from, to, typeOver), () => this.measure()); | |
this.docView = new DocView(this); | |
this.inputState = new InputState(this); | |
this.mountStyles(); | |
this.updateAttrs(); | |
this.updateState = 0 /* Idle */; | |
ensureGlobalHandler(); | |
this.requestMeasure(); | |
if (config.parent) | |
config.parent.appendChild(this.dom); | |
} | |
/// The current editor state. | |
get state() { return this.viewState.state; } | |
/// To be able to display large documents without consuming too much | |
/// memory or overloading the browser, CodeMirror only draws the | |
/// code that is visible (plus a margin around it) to the DOM. This | |
/// property tells you the extent of the current drawn viewport, in | |
/// document positions. | |
get viewport() { return this.viewState.viewport; } | |
/// When there are, for example, large collapsed ranges in the | |
/// viewport, its size can be a lot bigger than the actual visible | |
/// content. Thus, if you are doing something like styling the | |
/// content in the viewport, it is preferable to only do so for | |
/// these ranges, which are the subset of the viewport that is | |
/// actually drawn. | |
get visibleRanges() { return this.viewState.visibleRanges; } | |
dispatch(...input) { | |
this._dispatch(input.length == 1 && input[0] instanceof Transaction ? input[0] | |
: this.state.update(...input)); | |
} | |
/// Update the view for the given array of transactions. This will | |
/// update the visible document and selection to match the state | |
/// produced by the transactions, and notify view plugins of the | |
/// change. You should usually call | |
/// [`dispatch`](#view.EditorView.dispatch) instead, which uses this | |
/// as a primitive. | |
update(transactions) { | |
if (this.updateState != 0 /* Idle */) | |
throw new Error("Calls to EditorView.update are not allowed while an update is in progress"); | |
this.updateState = 2 /* Updating */; | |
let state = this.state; | |
for (let tr of transactions) { | |
if (tr.startState != state) | |
throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state."); | |
state = tr.state; | |
} | |
let update = new ViewUpdate(this, state, transactions); | |
let scrollTo = transactions.some(tr => tr.scrolledIntoView) ? state.selection.primary : null; | |
this.viewState.update(update, scrollTo); | |
this.bidiCache = CachedOrder.update(this.bidiCache, update.changes); | |
if (!update.empty) | |
this.updatePlugins(update); | |
let redrawn = this.docView.update(update); | |
if (this.state.facet(styleModule) != this.styleModules) | |
this.mountStyles(); | |
this.updateAttrs(); | |
this.updateState = 0 /* Idle */; | |
if (redrawn || scrollTo || this.viewState.mustEnforceCursorAssoc) | |
this.requestMeasure(); | |
for (let listener of this.state.facet(updateListener)) | |
listener(update); | |
} | |
/// Reset the view to the given state. (This will cause the entire | |
/// document to be redrawn and all view plugins to be reinitialized, | |
/// so you should probably only use it when the new state isn't | |
/// derived from the old state. Otherwise, use | |
/// [`update`](#view.EditorView.update) instead.) | |
setState(newState) { | |
if (this.updateState != 0 /* Idle */) | |
throw new Error("Calls to EditorView.setState are not allowed while an update is in progress"); | |
this.updateState = 2 /* Updating */; | |
for (let plugin of this.plugins) | |
plugin.destroy(this); | |
this.viewState = new ViewState(newState); | |
this.plugins = newState.facet(viewPlugin).map(spec => PluginInstance.create(spec, this)); | |
this.docView = new DocView(this); | |
this.inputState.ensureHandlers(this); | |
this.mountStyles(); | |
this.updateAttrs(); | |
this.bidiCache = []; | |
this.updateState = 0 /* Idle */; | |
this.requestMeasure(); | |
} | |
updatePlugins(update) { | |
let prevSpecs = update.prevState.facet(viewPlugin), specs = update.state.facet(viewPlugin); | |
if (prevSpecs != specs) { | |
let newPlugins = [], reused = []; | |
for (let spec of specs) { | |
let found = prevSpecs.indexOf(spec); | |
if (found < 0) { | |
newPlugins.push(PluginInstance.create(spec, this)); | |
} | |
else { | |
let plugin = this.plugins[found].update(update); | |
reused.push(plugin); | |
newPlugins.push(plugin); | |
} | |
} | |
for (let plugin of this.plugins) | |
if (reused.indexOf(plugin) < 0) | |
plugin.destroy(this); | |
this.plugins = newPlugins; | |
this.inputState.ensureHandlers(this); | |
} | |
else { | |
for (let i = 0; i < this.plugins.length; i++) | |
this.plugins[i] = this.plugins[i].update(update); | |
} | |
} | |
/// @internal | |
measure() { | |
if (this.measureScheduled > -1) | |
cancelAnimationFrame(this.measureScheduled); | |
this.measureScheduled = 1; // Prevent requestMeasure calls from scheduling another animation frame | |
let updated = null; | |
for (let i = 0;; i++) { | |
this.updateState = 1 /* Measuring */; | |
let changed = this.viewState.measure(this.docView, i > 0); | |
let measuring = this.measureRequests; | |
if (!changed && !measuring.length && this.viewState.scrollTo == null) | |
break; | |
this.measureRequests = []; | |
if (i > 5) { | |
console.warn("Viewport failed to stabilize"); | |
break; | |
} | |
let measured = measuring.map(m => { | |
try { | |
return m.read(this); | |
} | |
catch (e) { | |
logException(this.state, e); | |
return BadMeasure; | |
} | |
}); | |
let update = new ViewUpdate(this, this.state); | |
update.flags |= changed; | |
if (!updated) | |
updated = update; | |
else | |
updated.flags |= changed; | |
this.updateState = 2 /* Updating */; | |
this.updatePlugins(update); | |
if (changed) | |
this.docView.update(update); | |
for (let i = 0; i < measuring.length; i++) | |
if (measured[i] != BadMeasure) { | |
try { | |
measuring[i].write(measured[i], this); | |
} | |
catch (e) { | |
logException(this.state, e); | |
} | |
} | |
if (this.viewState.scrollTo) { | |
this.docView.scrollPosIntoView(this.viewState.scrollTo.head, this.viewState.scrollTo.assoc); | |
this.viewState.scrollTo = null; | |
} | |
if (!(changed & 4 /* Viewport */) && this.measureRequests.length == 0) | |
break; | |
} | |
this.updateState = 0 /* Idle */; | |
this.measureScheduled = -1; | |
if (updated) | |
for (let listener of this.state.facet(updateListener)) | |
listener(updated); | |
} | |
/// Get the CSS classes for the currently active editor themes. | |
get themeClasses() { | |
return baseThemeID + " " + | |
(this.state.facet(darkTheme) ? baseDarkThemeID : baseLightThemeID) + " " + | |
this.state.facet(theme); | |
} | |
updateAttrs() { | |
let editorAttrs = combineAttrs(this.state.facet(editorAttributes), { | |
class: themeClass("wrap") + (this.hasFocus ? " cm-focused " : " ") + this.themeClasses | |
}); | |
updateAttrs(this.dom, this.editorAttrs, editorAttrs); | |
this.editorAttrs = editorAttrs; | |
let contentAttrs = combineAttrs(this.state.facet(contentAttributes), { | |
spellcheck: "false", | |
contenteditable: String(this.state.facet(editable)), | |
class: themeClass("content"), | |
style: `${browser.tabSize}: ${this.state.tabSize}`, | |
role: "textbox", | |
"aria-multiline": "true" | |
}); | |
updateAttrs(this.contentDOM, this.contentAttrs, contentAttrs); | |
this.contentAttrs = contentAttrs; | |
} | |
mountStyles() { | |
this.styleModules = this.state.facet(styleModule); | |
StyleModule.mount(this.root, this.styleModules.concat(baseTheme).reverse()); | |
} | |
/// Find the DOM parent node and offset (child offset if `node` is | |
/// an element, character offset when it is a text node) at the | |
/// given document position. | |
domAtPos(pos) { | |
return this.docView.domAtPos(pos); | |
} | |
/// Find the document position at the given DOM node. Can be useful | |
/// for associating positions with DOM events. Will raise an error | |
/// when `node` isn't part of the editor content. | |
posAtDOM(node, offset = 0) { | |
return this.docView.posFromDOM(node, offset); | |
} | |
readMeasured() { | |
if (this.updateState == 2 /* Updating */) | |
throw new Error("Reading the editor layout isn't allowed during an update"); | |
if (this.updateState == 0 /* Idle */ && this.measureScheduled > -1) | |
this.measure(); | |
} | |
/// Make sure plugins get a chance to measure the DOM before the | |
/// next frame. Calling this is preferable to messing with the DOM | |
/// directly from, for example, an even handler, because it'll make | |
/// sure measuring and drawing done by other components is | |
/// synchronized, avoiding unnecessary DOM layout computations. | |
requestMeasure(request) { | |
if (this.measureScheduled < 0) | |
this.measureScheduled = requestAnimationFrame(() => this.measure()); | |
if (request) { | |
if (request.key != null) | |
for (let i = 0; i < this.measureRequests.length; i++) { | |
if (this.measureRequests[i].key === request.key) { | |
this.measureRequests[i] = request; | |
return; | |
} | |
} | |
this.measureRequests.push(request); | |
} | |
} | |
/// Collect all values provided by the active plugins for a given | |
/// field. | |
pluginField(field) { | |
// FIXME make this error when called during plugin updating | |
let result = []; | |
for (let plugin of this.plugins) | |
plugin.takeField(field, result); | |
return result; | |
} | |
/// Get the value of a specific plugin, if present. Note that | |
/// plugins that crash can be dropped from a view, so even when you | |
/// know you registered a given plugin, it is recommended to check | |
/// the return value of this method. | |
plugin(plugin) { | |
for (let inst of this.plugins) | |
if (inst.spec == plugin) | |
return inst.value; | |
return null; | |
} | |
/// Find the line or block widget at the given vertical position. | |
/// `editorTop`, if given, provides the vertical position of the top | |
/// of the editor. It defaults to the editor's screen position | |
/// (which will force a DOM layout). | |
blockAtHeight(height, editorTop) { | |
this.readMeasured(); | |
return this.viewState.blockAtHeight(height, ensureTop(editorTop, this.contentDOM)); | |
} | |
/// Find information for the visual line (see | |
/// [`visualLineAt`](#view.EditorView.visualLineAt)) at the given | |
/// vertical position. The resulting block info might hold another | |
/// array of block info structs in its `type` field if this line | |
/// consists of more than one block. | |
/// | |
/// Heights are interpreted relative to the given `editorTop` | |
/// position. When not given, the top position of the editor's | |
/// [content element](#view.EditorView.contentDOM) is taken. | |
visualLineAtHeight(height, editorTop) { | |
this.readMeasured(); | |
return this.viewState.lineAtHeight(height, ensureTop(editorTop, this.contentDOM)); | |
} | |
/// Find the extent and height of the visual line (the content shown | |
/// in the editor as a line, which may be smaller than a document | |
/// line when broken up by block widgets, or bigger than a document | |
/// line when line breaks are covered by replaced decorations) at | |
/// the given position. | |
/// | |
/// Vertical positions are computed relative to the `editorTop` | |
/// argument. You can pass `view.dom.getBoundingClientRect().top` | |
/// here to get screen coordinates. | |
visualLineAt(pos, editorTop = 0) { | |
return this.viewState.lineAt(pos, editorTop); | |
} | |
/// Iterate over the height information of the lines in the | |
/// viewport. | |
viewportLines(f, editorTop) { | |
let { from, to } = this.viewport; | |
this.viewState.forEachLine(from, to, f, ensureTop(editorTop, this.contentDOM)); | |
} | |
/// The editor's total content height. | |
get contentHeight() { | |
return this.viewState.heightMap.height + this.viewState.paddingTop + this.viewState.paddingBottom; | |
} | |
/// Move a cursor position by [grapheme | |
/// cluster](#text.nextClusterBreak). `forward` determines whether | |
/// the motion is away from the line start, or towards it. Motion in | |
/// bidirectional text is in visual order, in the editor's [text | |
/// direction](#view.EditorView.textDirection). When the start | |
/// position was the last one on the line, the returned position | |
/// will be across the line break. If there is no further line, the | |
/// original position is returned. | |
moveByChar(start, forward, by) { | |
return moveByChar(this, start, forward, by); | |
} | |
/// Move a cursor position across the next group of either | |
/// [letters](#state.EditorState.charCategorizer) or non-letter | |
/// non-whitespace characters. | |
moveByGroup(start, forward) { | |
return moveByChar(this, start, forward, initial => byGroup(this, start.head, initial)); | |
} | |
/// Move to the next line boundary in the given direction. If | |
/// `includeWrap` is true, line wrapping is on, and there is a | |
/// further wrap point on the current line, the wrap point will be | |
/// returned. Otherwise this function will return the start or end | |
/// of the line. | |
moveToLineBoundary(start, forward, includeWrap = true) { | |
return moveToLineBoundary(this, start, forward, includeWrap); | |
} | |
/// Move a cursor position vertically. When `distance` isn't given, | |
/// it defaults to moving to the next line (including wrapped | |
/// lines). Otherwise, `distance` should provide a positive distance | |
/// in pixels. | |
/// | |
/// When `start` has a | |
/// [`goalColumn`](#state.SelectionRange.goalColumn), the vertical | |
/// motion will use that as a target horizontal position. Otherwise, | |
/// the cursor's own horizontal position is used. The returned | |
/// cursor will have its goal column set to whichever column was | |
/// used. | |
moveVertically(start, forward, distance) { | |
return moveVertically(this, start, forward, distance); | |
} | |
/// Scroll the given document position into view. | |
scrollPosIntoView(pos) { | |
this.viewState.scrollTo = EditorSelection.cursor(pos); | |
this.requestMeasure(); | |
} | |
/// Get the document position at the given screen coordinates. | |
/// Returns -1 if no valid position could be found. | |
posAtCoords(coords) { | |
this.readMeasured(); | |
// FIXME return null instead, so you at least get a type error | |
// when you forget the failure case? | |
return posAtCoords(this, coords); | |
} | |
/// Get the screen coordinates at the given document position. | |
coordsAtPos(pos, side = 1) { | |
this.readMeasured(); | |
let line = this.state.doc.lineAt(pos), order = this.bidiSpans(line); | |
let rect = this.docView.coordsAt(pos, side); | |
if (!rect || rect.left == rect.right) | |
return rect; | |
let span = order[BidiSpan.find(order, pos - line.from, -1, side)]; | |
return flattenRect(rect, (span.dir == Direction.LTR) == (side > 0)); | |
} | |
/// The default width of a character in the editor. May not | |
/// accurately reflect the width of all characters. | |
get defaultCharacterWidth() { return this.viewState.heightOracle.charWidth; } | |
/// The default height of a line in the editor. | |
get defaultLineHeight() { return this.viewState.heightOracle.lineHeight; } | |
/// The text direction (`direction` CSS property) of the editor. | |
get textDirection() { return this.viewState.heightOracle.direction; } | |
/// Whether this editor [wraps lines](#view.EditorView.lineWrapping) | |
/// (as determined by the `white-space` CSS property of its content | |
/// element). | |
get lineWrapping() { return this.viewState.heightOracle.lineWrapping; } | |
/// Returns the bidirectional text structure of the given line | |
/// (which should be in the current document) as an array of span | |
/// objects. The order of these spans matches the [text | |
/// direction](#view.EditorView.textDirection)—if that is | |
/// left-to-right, the leftmost spans come first, otherwise the | |
/// rightmost spans come first. | |
bidiSpans(line) { | |
if (line.length > MaxBidiLine) | |
return trivialOrder(line.length); | |
let dir = this.textDirection; | |
for (let entry of this.bidiCache) | |
if (entry.from == line.from && entry.dir == dir) | |
return entry.order; | |
let order = computeOrder(line.slice(), this.textDirection); | |
this.bidiCache.push(new CachedOrder(line.from, line.to, dir, order)); | |
return order; | |
} | |
/// Check whether the editor has focus. | |
get hasFocus() { | |
return this.root.activeElement == this.contentDOM; | |
} | |
/// Put focus on the editor. | |
focus() { | |
this.observer.ignore(() => { | |
focusPreventScroll(this.contentDOM); | |
this.docView.updateSelection(); | |
}); | |
} | |
/// Clean up this editor view, removing its element from the | |
/// document, unregistering event handlers, and notifying | |
/// plugins. The view instance can no longer be used after | |
/// calling this. | |
destroy() { | |
for (let plugin of this.plugins) | |
plugin.destroy(this); | |
this.inputState.destroy(); | |
this.dom.remove(); | |
this.observer.destroy(); | |
if (this.measureScheduled > -1) | |
cancelAnimationFrame(this.measureScheduled); | |
} | |
/// Facet that can be used to add DOM event handlers. The value | |
/// should be an object mapping event names to handler functions. The | |
/// first such function to return true will be assumed to have handled | |
/// that event, and no other handlers or built-in behavior will be | |
/// activated for it. | |
static domEventHandlers(handlers) { | |
return ViewPlugin.define(() => ({})).eventHandlers(handlers); | |
} | |
/// Create a theme extension. The argument object should map [theme | |
/// selectors](#view.themeClass) to styles, which are (potentially | |
/// nested) [style | |
/// declarations](https://github.com/marijnh/style-mod#documentation) | |
/// providing the CSS styling for the selector. | |
/// | |
/// When `dark` is set to true, the theme will be marked as dark, | |
/// which causes the [base theme](#view.EditorView^baseTheme) rules | |
/// marked with `@dark` to apply instead of those marked with | |
/// `@light`. | |
static theme(spec, options) { | |
let prefix = StyleModule.newName(); | |
let result = [theme.of(prefix), styleModule.of(buildTheme(prefix, spec))]; | |
if (options && options.dark) | |
result.push(darkTheme.of(true)); | |
return result; | |
} | |
/// Create an extension that adds styles to the base theme. The | |
/// given object works much like the one passed to | |
/// [`theme`](#view.EditorView^theme), but allows selectors to be | |
/// marked by adding `@dark` to their end to only apply when there | |
/// is a dark theme active, or by `@light` to only apply when there | |
/// is _no_ dark theme active. | |
static baseTheme(spec) { | |
return precedence(styleModule.of(buildTheme(baseThemeID, spec)), "fallback"); | |
} | |
} | |
/// Facet to add a [style | |
/// module](https://github.com/marijnh/style-mod#readme) to an editor | |
/// view. The view will ensure that the module is registered in its | |
/// [document root](#view.EditorView.constructor^config.root). | |
EditorView.styleModule = styleModule; | |
/// Allows you to provide a function that should be called when the | |
/// library catches an exception from an extension (mostly from view | |
/// plugins, but may be used by other extensions to route exceptions | |
/// from user-code-provided callbacks). This is mostly useful for | |
/// debugging and logging. See [`logException`](#view.logException). | |
EditorView.exceptionSink = exceptionSink; | |
/// A facet that can be used to have a listener function be notified | |
/// every time the view updates. | |
EditorView.updateListener = updateListener; | |
/// Facet that controls whether the editor content is editable. When | |
/// its the highest-precedence value is `false`, editing is | |
/// disabled, and the content element will no longer have its | |
/// `contenteditable` attribute set to `true`. (Note that this | |
/// doesn't affect API calls that change the editor content, even | |
/// when those are bound to keys or buttons.) | |
EditorView.editable = editable; | |
/// Facet used to configure whether a given selection drag event | |
/// should move or copy the selection. The given predicate will be | |
/// called with the `mousedown` event, and can return `true` when | |
/// the drag should move the content. | |
EditorView.dragMovesSelection = dragMovesSelection; | |
/// Facet used to configure whether a given selecting click adds | |
/// a new range to the existing selection or replaces it entirely. | |
EditorView.clickAddsSelectionRange = clickAddsSelectionRange; | |
/// Allows you to influence the way mouse selection happens. The | |
/// functions in this facet will be called for a `mousedown` event | |
/// on the editor, and can return an object that overrides the way a | |
/// selection is computed from that mouse click or drag. | |
EditorView.mouseSelectionStyle = mouseSelectionStyle; | |
/// A facet that determines which [decorations](#view.Decoration) | |
/// are shown in the view. See also [view | |
/// plugins](#view.EditorView^decorations), which have a separate | |
/// mechanism for providing decorations. | |
EditorView.decorations = decorations; | |
/// An extension that enables line wrapping in the editor. | |
EditorView.lineWrapping = EditorView.theme({ content: { whiteSpace: "pre-wrap" } }); | |
/// Facet that provides attributes for the editor's editable DOM | |
/// element. | |
EditorView.contentAttributes = contentAttributes; | |
/// Facet that provides editor DOM attributes for the editor's | |
/// outer element. | |
EditorView.editorAttributes = editorAttributes; | |
// Maximum line length for which we compute accurate bidi info | |
const MaxBidiLine = 4096; | |
function ensureTop(given, dom) { | |
return given == null ? dom.getBoundingClientRect().top : given; | |
} | |
let resizeDebounce = -1; | |
function ensureGlobalHandler() { | |
window.addEventListener("resize", () => { | |
if (resizeDebounce == -1) | |
resizeDebounce = setTimeout(handleResize, 50); | |
}); | |
} | |
function handleResize() { | |
resizeDebounce = -1; | |
let found = document.querySelectorAll(".cm-content"); | |
for (let i = 0; i < found.length; i++) { | |
let docView = ContentView.get(found[i]); | |
if (docView) | |
docView.editorView.requestMeasure(); | |
} | |
} | |
const BadMeasure = {}; | |
class CachedOrder { | |
constructor(from, to, dir, order) { | |
this.from = from; | |
this.to = to; | |
this.dir = dir; | |
this.order = order; | |
} | |
static update(cache, changes) { | |
if (changes.empty) | |
return cache; | |
let result = [], lastDir = cache.length ? cache[cache.length - 1].dir : Direction.LTR; | |
for (let i = Math.max(0, cache.length - 10); i < cache.length; i++) { | |
let entry = cache[i]; | |
if (entry.dir == lastDir && !changes.touchesRange(entry.from, entry.to)) | |
result.push(new CachedOrder(changes.mapPos(entry.from, 1), changes.mapPos(entry.to, -1), entry.dir, entry.order)); | |
} | |
return result; | |
} | |
} | |
const currentPlatform = typeof navigator == "undefined" ? "key" | |
: /Mac/.test(navigator.platform) ? "mac" | |
: /Win/.test(navigator.platform) ? "win" | |
: /Linux|X11/.test(navigator.platform) ? "linux" | |
: "key"; | |
function normalizeKeyName(name, platform) { | |
const parts = name.split(/-(?!$)/); | |
let result = parts[parts.length - 1]; | |
if (result == "Space") | |
result = " "; | |
let alt, ctrl, shift, meta; | |
for (let i = 0; i < parts.length - 1; ++i) { | |
const mod = parts[i]; | |
if (/^(cmd|meta|m)$/i.test(mod)) | |
meta = true; | |
else if (/^a(lt)?$/i.test(mod)) | |
alt = true; | |
else if (/^(c|ctrl|control)$/i.test(mod)) | |
ctrl = true; | |
else if (/^s(hift)?$/i.test(mod)) | |
shift = true; | |
else if (/^mod$/i.test(mod)) { | |
if (platform == "mac") | |
meta = true; | |
else | |
ctrl = true; | |
} | |
else | |
throw new Error("Unrecognized modifier name: " + mod); | |
} | |
if (alt) | |
result = "Alt-" + result; | |
if (ctrl) | |
result = "Ctrl-" + result; | |
if (meta) | |
result = "Meta-" + result; | |
if (shift) | |
result = "Shift-" + result; | |
return result; | |
} | |
function modifiers(name, event, shift) { | |
if (event.altKey) | |
name = "Alt-" + name; | |
if (event.ctrlKey) | |
name = "Ctrl-" + name; | |
if (event.metaKey) | |
name = "Meta-" + name; | |
if (shift !== false && event.shiftKey) | |
name = "Shift-" + name; | |
return name; | |
} | |
const keymaps = Facet.define(); | |
const handleKeyEvents = EditorView.domEventHandlers({ | |
keydown(event, view) { | |
return runHandlers(view.state.facet(keymaps), event, view, "editor"); | |
} | |
}); | |
/// Create a view extension that registers a keymap. | |
/// | |
/// You can add multiple keymap extensions to an editor. Their | |
/// priorities determine their precedence (the ones specified early or | |
/// with high priority get checked first). When a handler has returned | |
/// `true` for a given key, no further handlers are called. | |
/// | |
/// When a key is bound multiple times (either in a single keymap or | |
/// in separate maps), the bound commands all get a chance to handle | |
/// the key stroke, in order of precedence, until one of them returns | |
/// true. | |
function keymap(bindings, platform) { | |
return [handleKeyEvents, keymaps.of(buildKeymap(bindings, platform))]; | |
} | |
/// Run the key handlers registered for a given scope. Returns true if | |
/// any of them handled the event. | |
function runScopeHandlers(view, event, scope) { | |
return runHandlers(view.state.facet(keymaps), event, view, scope); | |
} | |
let storedPrefix = null; | |
const PrefixTimeout = 4000; | |
function buildKeymap(bindings, platform = currentPlatform) { | |
let bound = Object.create(null); | |
let isPrefix = Object.create(null); | |
let checkPrefix = (name, is) => { | |
let current = isPrefix[name]; | |
if (current == null) | |
isPrefix[name] = is; | |
else if (current != is) | |
throw new Error("Key binding " + name + " is used both as a regular binding and as a multi-stroke prefix"); | |
}; | |
let add = (scope, key, command, preventDefault) => { | |
let scopeObj = bound[scope] || (bound[scope] = Object.create(null)); | |
let parts = key.split(/ (?!$)/).map(k => normalizeKeyName(k, platform)); | |
for (let i = 1; i < parts.length; i++) { | |
let prefix = parts.slice(0, i).join(" "); | |
checkPrefix(prefix, true); | |
if (!scopeObj[prefix]) | |
scopeObj[prefix] = { | |
preventDefault: true, | |
commands: [(view) => { | |
let ourObj = storedPrefix = { view, prefix, scope }; | |
setTimeout(() => { if (storedPrefix == ourObj) | |
storedPrefix = null; }, PrefixTimeout); | |
return true; | |
}] | |
}; | |
} | |
let full = parts.join(" "); | |
checkPrefix(full, false); | |
let binding = scopeObj[full] || (scopeObj[full] = { preventDefault: false, commands: [] }); | |
binding.commands.push(command); | |
if (preventDefault) | |
binding.preventDefault = true; | |
}; | |
for (let b of bindings) { | |
let name = b[platform] || b.key; | |
if (!name) | |
continue; | |
for (let scope of b.scope ? b.scope.split(" ") : ["editor"]) { | |
add(scope, name, b.run, b.preventDefault); | |
if (b.shift) | |
add(scope, "Shift-" + name, b.shift, b.preventDefault); | |
} | |
} | |
return bound; | |
} | |
function runHandlers(maps, event, view, scope) { | |
let name = keyName(event), isChar = name.length == 1 && name != " "; | |
let prefix = ""; | |
if (storedPrefix && storedPrefix.view == view && storedPrefix.scope == scope) { | |
prefix = storedPrefix.prefix + " "; | |
storedPrefix = null; | |
} | |
let fallthrough = !!prefix; | |
let runFor = (binding) => { | |
if (binding) { | |
for (let cmd of binding.commands) | |
if (cmd(view)) | |
return true; | |
if (binding.preventDefault) | |
fallthrough = true; | |
} | |
return false; | |
}; | |
for (let map of maps) { | |
let scopeObj = map[scope], baseName; | |
if (!scopeObj) | |
continue; | |
if (runFor(scopeObj[prefix + modifiers(name, event, !isChar)])) | |
return true; | |
if (isChar && (event.shiftKey || event.altKey || event.metaKey) && | |
(baseName = base[event.keyCode]) && baseName != name) { | |
if (runFor(scopeObj[prefix + modifiers(baseName, event, true)])) | |
return true; | |
} | |
else if (isChar && event.shiftKey) { | |
if (runFor(scopeObj[prefix + modifiers(name, event, true)])) | |
return true; | |
} | |
} | |
return fallthrough; | |
} | |
const field = StateField.define({ | |
create(state) { | |
return decorateSelections(state.selection); | |
}, | |
update(deco, tr) { | |
return tr.docChanged || tr.selection ? decorateSelections(tr.state.selection) : deco; | |
}, | |
provide: [EditorView.decorations] | |
}); | |
/// Returns an extension that enables multiple selections for the | |
/// editor. Secondary cursors and selected ranges are drawn with | |
/// simple decorations, and might not look the same as the primary | |
/// native selection. | |
function multipleSelections() { | |
return [ | |
EditorState.allowMultipleSelections.of(true), | |
field | |
]; | |
} | |
class CursorWidget extends WidgetType { | |
toDOM() { | |
let span = document.createElement("span"); | |
span.className = themeClass("secondaryCursor"); | |
return span; | |
} | |
} | |
CursorWidget.deco = Decoration.widget({ widget: new CursorWidget(null) }); | |
const rangeMark = Decoration.mark({ class: themeClass("secondarySelection") }); | |
function decorateSelections(selection) { | |
let { ranges, primaryIndex } = selection; | |
if (ranges.length == 1) | |
return Decoration.none; | |
let deco = []; | |
for (let i = 0; i < ranges.length; i++) | |
if (i != primaryIndex) { | |
let range = ranges[i]; | |
deco.push(range.empty ? CursorWidget.deco.range(range.from) : rangeMark.range(ranges[i].from, ranges[i].to)); | |
} | |
return Decoration.set(deco); | |
} | |
const Specials = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200c\u200e\u200f\u2028\u2029\ufeff\ufff9-\ufffc]/gu; | |
const Names = { | |
0: "null", | |
7: "bell", | |
8: "backspace", | |
10: "newline", | |
11: "vertical tab", | |
13: "carriage return", | |
27: "escape", | |
8203: "zero width space", | |
8204: "zero width non-joiner", | |
8205: "zero width joiner", | |
8206: "left-to-right mark", | |
8207: "right-to-left mark", | |
8232: "line separator", | |
8233: "paragraph separator", | |
65279: "zero width no-break space", | |
65532: "object replacement" | |
}; | |
let _supportsTabSize = null; | |
function supportsTabSize() { | |
if (_supportsTabSize == null && typeof document != "undefined" && document.body) { | |
let styles = document.body.style; | |
_supportsTabSize = (styles.tabSize || styles.MozTabSize) != null; | |
} | |
return _supportsTabSize || false; | |
} | |
const UnicodeRegexpSupport = /x/.unicode != null ? "gu" : "g"; | |
const specialCharConfig = Facet.define({ | |
combine(configs) { | |
// FIXME make configurations compose properly | |
let config = combineConfig(configs, { | |
render: null, | |
specialChars: Specials, | |
addSpecialChars: null | |
}); | |
if (config.replaceTabs = !supportsTabSize()) | |
config.specialChars = new RegExp("\t|" + config.specialChars.source, UnicodeRegexpSupport); | |
if (config.addSpecialChars) | |
config.specialChars = new RegExp(config.specialChars.source + "|" + config.addSpecialChars.source, UnicodeRegexpSupport); | |
return config; | |
} | |
}); | |
/// Returns an extension that installs highlighting of special | |
/// characters. | |
function highlightSpecialChars( | |
/// Configuration options. | |
config = {}) { | |
let ext = [specialCharConfig.of(config), specialCharPlugin]; | |
if (!supportsTabSize()) | |
ext.push(tabStyleExt); | |
return ext; | |
} | |
const specialCharPlugin = ViewPlugin.fromClass(class { | |
constructor(view) { | |
this.view = view; | |
this.decorations = Decoration.none; | |
this.decorationCache = Object.create(null); | |
this.recompute(); | |
} | |
update(update) { | |
let confChange = update.prevState.facet(specialCharConfig) != update.state.facet(specialCharConfig); | |
if (confChange) | |
this.decorationCache = Object.create(null); | |
if (confChange || update.changes.length || update.viewportChanged) | |
this.recompute(); | |
} | |
recompute() { | |
let decorations = []; | |
for (let { from, to } of this.view.visibleRanges) | |
this.getDecorationsFor(from, to, decorations); | |
this.decorations = Decoration.set(decorations); | |
} | |
getDecorationsFor(from, to, target) { | |
let config = this.view.state.facet(specialCharConfig); | |
let { doc } = this.view.state; | |
for (let pos = from, cursor = doc.iterRange(from, to), m; !cursor.next().done;) { | |
if (!cursor.lineBreak) { | |
while (m = config.specialChars.exec(cursor.value)) { | |
let code = codePointAt(m[0], 0), deco; | |
if (code == null) | |
continue; | |
if (code == 9) { | |
let line = doc.lineAt(pos + m.index); | |
let size = this.view.state.tabSize, col = countColumn(doc.sliceString(line.from, pos + m.index), 0, size); | |
deco = Decoration.replace({ widget: new TabWidget((size - (col % size)) * this.view.defaultCharacterWidth) }); | |
} | |
else { | |
deco = this.decorationCache[code] || | |
(this.decorationCache[code] = Decoration.replace({ widget: new SpecialCharWidget(config, code) })); | |
} | |
target.push(deco.range(pos + m.index, pos + m.index + m[0].length)); | |
} | |
} | |
pos += cursor.value.length; | |
} | |
} | |
}).decorations(); | |
// Assigns placeholder characters from the Control Pictures block to | |
// ASCII control characters | |
function placeHolder(code) { | |
if (code >= 32) | |
return null; | |
if (code == 10) | |
return "\u2424"; | |
return String.fromCharCode(9216 + code); | |
} | |
const DefaultPlaceholder = "\u2022"; | |
class SpecialCharWidget extends WidgetType { | |
constructor(options, code) { | |
super(code); | |
this.options = options; | |
} | |
toDOM() { | |
let ph = placeHolder(this.value) || DefaultPlaceholder; | |
let desc = "Control character " + (Names[this.value] || this.value); | |
let custom = this.options.render && this.options.render(this.value, desc, ph); | |
if (custom) | |
return custom; | |
let span = document.createElement("span"); | |
span.textContent = ph; | |
span.title = desc; | |
span.setAttribute("aria-label", desc); | |
span.style.color = "red"; | |
return span; | |
} | |
ignoreEvent() { return false; } | |
} | |
class TabWidget extends WidgetType { | |
toDOM() { | |
let span = document.createElement("span"); | |
span.textContent = "\t"; | |
span.className = tabStyle.tab; | |
span.style.width = this.value + "px"; | |
return span; | |
} | |
ignoreEvent() { return false; } | |
} | |
const tabStyle = new StyleModule({ | |
tab: { | |
display: "inline-block", | |
overflow: "hidden", | |
verticalAlign: "bottom" | |
} | |
}); | |
const tabStyleExt = EditorView.styleModule.of(tabStyle); | |
/// @internal | |
const __test = { HeightMap, HeightOracle, MeasuredHeights, QueryType, ChangedRange, computeOrder, moveVisually }; | |
export { BidiSpan, BlockInfo, BlockType, Decoration, Direction, EditorView, PluginField, ViewPlugin, ViewUpdate, WidgetType, __test, highlightSpecialChars, keymap, logException, multipleSelections, runScopeHandlers, themeClass }; | |
//# sourceMappingURL=index.js.map |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment