import {EditorState} from "prosemirror-state"
import {nodeSize, textRange, parentNode, caretFromPoint} from "./dom"
import * as browser from "./browser"
import {EditorView} from "./index"
export type Rect = {left: number, right: number, top: number, bottom: number}
function windowRect(doc: Document): Rect {
let vp = doc.defaultView && doc.defaultView.visualViewport
if (vp) return {
left: 0, right: vp.width,
top: 0, bottom: vp.height
}
return {left: 0, right: doc.documentElement.clientWidth,
top: 0, bottom: doc.documentElement.clientHeight}
}
function getSide(value: number | Rect, side: keyof Rect): number {
return typeof value == "number" ? value : value[side]
}
function clientRect(node: HTMLElement): Rect {
let rect = node.getBoundingClientRect()
// Adjust for elements with style "transform: scale()"
let scaleX = (rect.width / node.offsetWidth) || 1
let scaleY = (rect.height / node.offsetHeight) || 1
// Make sure scrollbar width isn't included in the rectangle
return {left: rect.left, right: rect.left + node.clientWidth * scaleX,
top: rect.top, bottom: rect.top + node.clientHeight * scaleY}
}
export function scrollRectIntoView(view: EditorView, rect: Rect, startDOM: Node) {
let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5
let doc = view.dom.ownerDocument
for (let parent: Node | null = startDOM || view.dom;; parent = parentNode(parent)) {
if (!parent) break
if (parent.nodeType != 1) continue
let elt = parent as HTMLElement
let atTop = elt == doc.body
let bounding = atTop ? windowRect(doc) : clientRect(elt as HTMLElement)
let moveX = 0, moveY = 0
if (rect.top < bounding.top + getSide(scrollThreshold, "top"))
moveY = -(bounding.top - rect.top + getSide(scrollMargin, "top"))
else if (rect.bottom > bounding.bottom - getSide(scrollThreshold, "bottom"))
moveY = rect.bottom - rect.top > bounding.bottom - bounding.top
? rect.top + getSide(scrollMargin, "top") - bounding.top
: rect.bottom - bounding.bottom + getSide(scrollMargin, "bottom")
if (rect.left < bounding.left + getSide(scrollThreshold, "left"))
moveX = -(bounding.left - rect.left + getSide(scrollMargin, "left"))
else if (rect.right > bounding.right - getSide(scrollThreshold, "right"))
moveX = rect.right - bounding.right + getSide(scrollMargin, "right")
if (moveX || moveY) {
if (atTop) {
doc.defaultView!.scrollBy(moveX, moveY)
} else {
let startX = elt.scrollLeft, startY = elt.scrollTop
if (moveY) elt.scrollTop += moveY
if (moveX) elt.scrollLeft += moveX
let dX = elt.scrollLeft - startX, dY = elt.scrollTop - startY
rect = {left: rect.left - dX, top: rect.top - dY, right: rect.right - dX, bottom: rect.bottom - dY}
}
}
if (atTop || /^(fixed|sticky)$/.test(getComputedStyle(parent as HTMLElement).position)) break
}
}
// Store the scroll position of the editor's parent nodes, along with
// the top position of an element near the top of the editor, which
// will be used to make sure the visible viewport remains stable even
// when the size of the content above changes.
export function storeScrollPos(view: EditorView): {
refDOM: HTMLElement,
refTop: number,
stack: {dom: HTMLElement, top: number, left: number}[]
} {
let rect = view.dom.getBoundingClientRect(), startY = Math.max(0, rect.top)
let refDOM: HTMLElement, refTop: number
for (let x = (rect.left + rect.right) / 2, y = startY + 1;
y < Math.min(innerHeight, rect.bottom); y += 5) {
let dom = view.root.elementFromPoint(x, y)
if (!dom || dom == view.dom || !view.dom.contains(dom)) continue
let localRect = (dom as HTMLElement).getBoundingClientRect()
if (localRect.top >= startY - 20) {
refDOM = dom as HTMLElement
refTop = localRect.top
break
}
}
return {refDOM: refDOM!, refTop: refTop!, stack: scrollStack(view.dom)}
}
function scrollStack(dom: Node): {dom: HTMLElement, top: number, left: number}[] {
let stack = [], doc = dom.ownerDocument
for (let cur: Node | null = dom; cur; cur = parentNode(cur)) {
stack.push({dom: cur as HTMLElement, top: (cur as HTMLElement).scrollTop, left: (cur as HTMLElement).scrollLeft})
if (dom == doc) break
}
return stack
}
// Reset the scroll position of the editor's parent nodes to that what
// it was before, when storeScrollPos was called.
export function resetScrollPos({refDOM, refTop, stack}: {
refDOM: HTMLElement,
refTop: number,
stack: {dom: HTMLElement, top: number, left: number}[]
}) {
let newRefTop = refDOM ? refDOM.getBoundingClientRect().top : 0
restoreScrollStack(stack, newRefTop == 0 ? 0 : newRefTop - refTop)
}
function restoreScrollStack(stack: {dom: HTMLElement, top: number, left: number}[], dTop: number) {
for (let i = 0; i < stack.length; i++) {
let {dom, top, left} = stack[i]
if (dom.scrollTop != top + dTop) dom.scrollTop = top + dTop
if (dom.scrollLeft != left) dom.scrollLeft = left
}
}
let preventScrollSupported: false | null | {preventScroll: boolean} = null
// Feature-detects support for .focus({preventScroll: true}), and uses
// a fallback kludge when not supported.
export function focusPreventScroll(dom: HTMLElement) {
if ((dom as any).setActive) return (dom as any).setActive() // in IE
if (preventScrollSupported) return dom.focus(preventScrollSupported)
let stored = scrollStack(dom)
dom.focus(preventScrollSupported == null ? {
get preventScroll() {
preventScrollSupported = {preventScroll: true}
return true
}
} : undefined)
if (!preventScrollSupported) {
preventScrollSupported = false
restoreScrollStack(stored, 0)
}
}
function findOffsetInNode(node: HTMLElement, coords: {top: number, left: number}): {node: Node, offset: number} {
let closest, dxClosest = 2e8, coordsClosest: {left: number, top: number} | undefined, offset = 0
let rowBot = coords.top, rowTop = coords.top
let firstBelow: Node | undefined, coordsBelow: {left: number, top: number} | undefined
for (let child = node.firstChild, childIndex = 0; child; child = child.nextSibling, childIndex++) {
let rects
if (child.nodeType == 1) rects = (child as HTMLElement).getClientRects()
else if (child.nodeType == 3) rects = textRange(child as Text).getClientRects()
else continue
for (let i = 0; i < rects.length; i++) {
let rect = rects[i]
if (rect.top <= rowBot && rect.bottom >= rowTop) {
rowBot = Math.max(rect.bottom, rowBot)
rowTop = Math.min(rect.top, rowTop)
let dx = rect.left > coords.left ? rect.left - coords.left
: rect.right < coords.left ? coords.left - rect.right : 0
if (dx < dxClosest) {
closest = child
dxClosest = dx
coordsClosest = dx && closest.nodeType == 3 ? {
left: rect.right < coords.left ? rect.right : rect.left,
top: coords.top
} : coords
if (child.nodeType == 1 && dx)
offset = childIndex + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0)
continue
}
} else if (rect.top > coords.top && !firstBelow && rect.left <= coords.left && rect.right >= coords.left) {
firstBelow = child
coordsBelow = {left: Math.max(rect.left, Math.min(rect.right, coords.left)), top: rect.top}
}
if (!closest && (coords.left >= rect.right && coords.top >= rect.top ||
coords.left >= rect.left && coords.top >= rect.bottom))
offset = childIndex + 1
}
}
if (!closest && firstBelow) { closest = firstBelow; coordsClosest = coordsBelow; dxClosest = 0 }
if (closest && closest.nodeType == 3) return findOffsetInText(closest as Text, coordsClosest!)
if (!closest || (dxClosest && closest.nodeType == 1)) return {node, offset}
return findOffsetInNode(closest as HTMLElement, coordsClosest!)
}
function findOffsetInText(node: Text, coords: {top: number, left: number}) {
let len = node.nodeValue!.length
let range = document.createRange()
for (let i = 0; i < len; i++) {
range.setEnd(node, i + 1)
range.setStart(node, i)
let rect = singleRect(range, 1)
if (rect.top == rect.bottom) continue
if (inRect(coords, rect))
return {node, offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0)}
}
return {node, offset: 0}
}
function inRect(coords: {top: number, left: number}, rect: Rect) {
return coords.left >= rect.left - 1 && coords.left <= rect.right + 1&&
coords.top >= rect.top - 1 && coords.top <= rect.bottom + 1
}
function targetKludge(dom: HTMLElement, coords: {top: number, left: number}) {
let parent = dom.parentNode
if (parent && /^li$/i.test(parent.nodeName) && coords.left < dom.getBoundingClientRect().left)
return parent as HTMLElement
return dom
}
function posFromElement(view: EditorView, elt: HTMLElement, coords: {top: number, left: number}) {
let {node, offset} = findOffsetInNode(elt, coords), bias = -1
if (node.nodeType == 1 && !node.firstChild) {
let rect = (node as HTMLElement).getBoundingClientRect()
bias = rect.left != rect.right && coords.left > (rect.left + rect.right) / 2 ? 1 : -1
}
return view.docView.posFromDOM(node, offset, bias)
}
function posFromCaret(view: EditorView, node: Node, offset: number, coords: {top: number, left: number}) {
// Browser (in caretPosition/RangeFromPoint) will agressively
// normalize towards nearby inline nodes. Since we are interested in
// positions between block nodes too, we first walk up the hierarchy
// of nodes to see if there are block nodes that the coordinates
// fall outside of. If so, we take the position before/after that
// block. If not, we call `posFromDOM` on the raw node/offset.
let outsideBlock = -1
for (let cur = node, sawBlock = false;;) {
if (cur == view.dom) break
let desc = view.docView.nearestDesc(cur, true)
if (!desc) return null
if (desc.dom.nodeType == 1 && (desc.node.isBlock && desc.parent && !sawBlock || !desc.contentDOM)) {
let rect = (desc.dom as HTMLElement).getBoundingClientRect()
if (desc.node.isBlock && desc.parent && !sawBlock) {
sawBlock = true
if (rect.left > coords.left || rect.top > coords.top) outsideBlock = desc.posBefore
else if (rect.right < coords.left || rect.bottom < coords.top) outsideBlock = desc.posAfter
}
if (!desc.contentDOM && outsideBlock < 0 && !desc.node.isText) {
// If we are inside a leaf, return the side of the leaf closer to the coords
let before = desc.node.isBlock ? coords.top < (rect.top + rect.bottom) / 2
: coords.left < (rect.left + rect.right) / 2
return before ? desc.posBefore : desc.posAfter
}
}
cur = desc.dom.parentNode!
}
return outsideBlock > -1 ? outsideBlock : view.docView.posFromDOM(node, offset, -1)
}
function elementFromPoint(element: HTMLElement, coords: {top: number, left: number}, box: Rect): HTMLElement {
let len = element.childNodes.length
if (len && box.top < box.bottom) {
for (let startI = Math.max(0, Math.min(len - 1, Math.floor(len * (coords.top - box.top) / (box.bottom - box.top)) - 2)), i = startI;;) {
let child = element.childNodes[i]
if (child.nodeType == 1) {
let rects = (child as HTMLElement).getClientRects()
for (let j = 0; j < rects.length; j++) {
let rect = rects[j]
if (inRect(coords, rect)) return elementFromPoint(child as HTMLElement, coords, rect)
}
}
if ((i = (i + 1) % len) == startI) break
}
}
return element
}
// Given an x,y position on the editor, get the position in the document.
export function posAtCoords(view: EditorView, coords: {top: number, left: number}) {
let doc = view.dom.ownerDocument, node: Node | undefined, offset = 0
let caret = caretFromPoint(doc, coords.left, coords.top)
if (caret) ({node, offset} = caret)
let elt = ((view.root as any).elementFromPoint ? view.root : doc)
.elementFromPoint(coords.left, coords.top) as HTMLElement
let pos
if (!elt || !view.dom.contains(elt.nodeType != 1 ? elt.parentNode : elt)) {
let box = view.dom.getBoundingClientRect()
if (!inRect(coords, box)) return null
elt = elementFromPoint(view.dom, coords, box)
if (!elt) return null
}
// Safari's caretRangeFromPoint returns nonsense when on a draggable element
if (browser.safari) {
for (let p: Node | null = elt; node && p; p = parentNode(p))
if ((p as HTMLElement).draggable) node = undefined
}
elt = targetKludge(elt, coords)
if (node) {
if (browser.gecko && node.nodeType == 1) {
// Firefox will sometimes return offsets into nodes, which
// have no actual children, from caretPositionFromPoint (#953)
offset = Math.min(offset, node.childNodes.length)
// It'll also move the returned position before image nodes,
// even if those are behind it.
if (offset < node.childNodes.length) {
let next = node.childNodes[offset], box
if (next.nodeName == "IMG" && (box = (next as HTMLElement).getBoundingClientRect()).right <= coords.left &&
box.bottom > coords.top)
offset++
}
}
let prev
// When clicking above the right side of an uneditable node, Chrome will report a cursor position after that node.
if (browser.webkit && offset && node.nodeType == 1 && (prev = node.childNodes[offset - 1]).nodeType == 1 &&
(prev as HTMLElement).contentEditable == "false" && (prev as HTMLElement).getBoundingClientRect().top >= coords.top)
offset--
// Suspiciously specific kludge to work around caret*FromPoint
// never returning a position at the end of the document
if (node == view.dom && offset == node.childNodes.length - 1 && node.lastChild!.nodeType == 1 &&
coords.top > (node.lastChild as HTMLElement).getBoundingClientRect().bottom)
pos = view.state.doc.content.size
// Ignore positions directly after a BR, since caret*FromPoint
// 'round up' positions that would be more accurately placed
// before the BR node.
else if (offset == 0 || node.nodeType != 1 || node.childNodes[offset - 1].nodeName != "BR")
pos = posFromCaret(view, node, offset, coords)
}
if (pos == null) pos = posFromElement(view, elt, coords)
let desc = view.docView.nearestDesc(elt, true)
return {pos, inside: desc ? desc.posAtStart - desc.border : -1}
}
function nonZero(rect: DOMRect) {
return rect.top < rect.bottom || rect.left < rect.right
}
function singleRect(target: HTMLElement | Range, bias: number): DOMRect {
let rects = target.getClientRects()
if (rects.length) {
let first = rects[bias < 0 ? 0 : rects.length - 1]
if (nonZero(first)) return first
}
return Array.prototype.find.call(rects, nonZero) || target.getBoundingClientRect()
}
const BIDI = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/
// Given a position in the document model, get a bounding box of the
// character at that position, relative to the window.
export function coordsAtPos(view: EditorView, pos: number, side: number): Rect {
let {node, offset, atom} = view.docView.domFromPos(pos, side < 0 ? -1 : 1)
let supportEmptyRange = browser.webkit || browser.gecko
if (node.nodeType == 3) {
// These browsers support querying empty text ranges. Prefer that in
// bidi context or when at the end of a node.
if (supportEmptyRange && (BIDI.test(node.nodeValue!) || (side < 0 ? !offset : offset == node.nodeValue!.length))) {
let rect = singleRect(textRange(node as Text, offset, offset), side)
// Firefox returns bad results (the position before the space)
// when querying a position directly after line-broken
// whitespace. Detect this situation and and kludge around it
if (browser.gecko && offset && /\s/.test(node.nodeValue![offset - 1]) && offset < node.nodeValue!.length) {
let rectBefore = singleRect(textRange(node as Text, offset - 1, offset - 1), -1)
if (rectBefore.top == rect.top) {
let rectAfter = singleRect(textRange(node as Text, offset, offset + 1), -1)
if (rectAfter.top != rect.top)
return flattenV(rectAfter, rectAfter.left < rectBefore.left)
}
}
return rect
} else {
let from = offset, to = offset, takeSide = side < 0 ? 1 : -1
if (side < 0 && !offset) { to++; takeSide = -1 }
else if (side >= 0 && offset == node.nodeValue!.length) { from--; takeSide = 1 }
else if (side < 0) { from-- }
else { to ++ }
return flattenV(singleRect(textRange(node as Text, from, to), takeSide), takeSide < 0)
}
}
let $dom = view.state.doc.resolve(pos - (atom || 0))
// Return a horizontal line in block context
if (!$dom.parent.inlineContent) {
if (atom == null && offset && (side < 0 || offset == nodeSize(node))) {
let before = node.childNodes[offset - 1]
if (before.nodeType == 1) return flattenH((before as HTMLElement).getBoundingClientRect(), false)
}
if (atom == null && offset < nodeSize(node)) {
let after = node.childNodes[offset]
if (after.nodeType == 1) return flattenH((after as HTMLElement).getBoundingClientRect(), true)
}
return flattenH((node as HTMLElement).getBoundingClientRect(), side >= 0)
}
// Inline, not in text node (this is not Bidi-safe)
if (atom == null && offset && (side < 0 || offset == nodeSize(node))) {
let before = node.childNodes[offset - 1]
let target = before.nodeType == 3 ? textRange(before as Text, nodeSize(before) - (supportEmptyRange ? 0 : 1))
// BR nodes tend to only return the rectangle before them.
// Only use them if they are the last element in their parent
: before.nodeType == 1 && (before.nodeName != "BR" || !before.nextSibling) ? before : null
if (target) return flattenV(singleRect(target as Range | HTMLElement, 1), false)
}
if (atom == null && offset < nodeSize(node)) {
let after = node.childNodes[offset]
while (after.pmViewDesc && after.pmViewDesc.ignoreForCoords) after = after.nextSibling!
let target = !after ? null : after.nodeType == 3 ? textRange(after as Text, 0, (supportEmptyRange ? 0 : 1))
: after.nodeType == 1 ? after : null
if (target) return flattenV(singleRect(target as Range | HTMLElement, -1), true)
}
// All else failed, just try to get a rectangle for the target node
return flattenV(singleRect(node.nodeType == 3 ? textRange(node as Text) : node as HTMLElement, -side), side >= 0)
}
function flattenV(rect: DOMRect, left: boolean) {
if (rect.width == 0) return rect
let x = left ? rect.left : rect.right
return {top: rect.top, bottom: rect.bottom, left: x, right: x}
}
function flattenH(rect: DOMRect, top: boolean) {
if (rect.height == 0) return rect
let y = top ? rect.top : rect.bottom
return {top: y, bottom: y, left: rect.left, right: rect.right}
}
function withFlushedState(view: EditorView, state: EditorState, f: () => T): T {
let viewState = view.state, active = view.root.activeElement as HTMLElement
if (viewState != state) view.updateState(state)
if (active != view.dom) view.focus()
try {
return f()
} finally {
if (viewState != state) view.updateState(viewState)
if (active != view.dom && active) active.focus()
}
}
// Whether vertical position motion in a given direction
// from a position would leave a text block.
function endOfTextblockVertical(view: EditorView, state: EditorState, dir: "up" | "down") {
let sel = state.selection
let $pos = dir == "up" ? sel.$from : sel.$to
return withFlushedState(view, state, () => {
let {node: dom} = view.docView.domFromPos($pos.pos, dir == "up" ? -1 : 1)
for (;;) {
let nearest = view.docView.nearestDesc(dom, true)
if (!nearest) break
if (nearest.node.isBlock) { dom = nearest.contentDOM || nearest.dom; break }
dom = nearest.dom.parentNode!
}
let coords = coordsAtPos(view, $pos.pos, 1)
for (let child = dom.firstChild; child; child = child.nextSibling) {
let boxes
if (child.nodeType == 1) boxes = (child as HTMLElement).getClientRects()
else if (child.nodeType == 3) boxes = textRange(child as Text, 0, child.nodeValue!.length).getClientRects()
else continue
for (let i = 0; i < boxes.length; i++) {
let box = boxes[i]
if (box.bottom > box.top + 1 &&
(dir == "up" ? coords.top - box.top > (box.bottom - coords.top) * 2
: box.bottom - coords.bottom > (coords.bottom - box.top) * 2))
return false
}
}
return true
})
}
const maybeRTL = /[\u0590-\u08ac]/
function endOfTextblockHorizontal(view: EditorView, state: EditorState, dir: "left" | "right" | "forward" | "backward") {
let {$head} = state.selection
if (!$head.parent.isTextblock) return false
let offset = $head.parentOffset, atStart = !offset, atEnd = offset == $head.parent.content.size
let sel = view.domSelection()
// If the textblock is all LTR, or the browser doesn't support
// Selection.modify (Edge), fall back to a primitive approach
if (!maybeRTL.test($head.parent.textContent) || !(sel as any).modify)
return dir == "left" || dir == "backward" ? atStart : atEnd
return withFlushedState(view, state, () => {
// This is a huge hack, but appears to be the best we can
// currently do: use `Selection.modify` to move the selection by
// one character, and see if that moves the cursor out of the
// textblock (or doesn't move it at all, when at the start/end of
// the document).
let {focusNode: oldNode, focusOffset: oldOff, anchorNode, anchorOffset} = view.domSelectionRange()
let oldBidiLevel = (sel as any).caretBidiLevel // Only for Firefox
;(sel as any).modify("move", dir, "character")
let parentDOM = $head.depth ? view.docView.domAfterPos($head.before()) : view.dom
let {focusNode: newNode, focusOffset: newOff} = view.domSelectionRange()
let result = newNode && !parentDOM.contains(newNode.nodeType == 1 ? newNode : newNode.parentNode) ||
(oldNode == newNode && oldOff == newOff)
// Restore the previous selection
try {
sel.collapse(anchorNode, anchorOffset)
if (oldNode && (oldNode != anchorNode || oldOff != anchorOffset) && sel.extend) sel.extend(oldNode, oldOff)
} catch (_) {}
if (oldBidiLevel != null) (sel as any).caretBidiLevel = oldBidiLevel
return result
})
}
export type TextblockDir = "up" | "down" | "left" | "right" | "forward" | "backward"
let cachedState: EditorState | null = null
let cachedDir: TextblockDir | null = null
let cachedResult: boolean = false
export function endOfTextblock(view: EditorView, state: EditorState, dir: TextblockDir) {
if (cachedState == state && cachedDir == dir) return cachedResult
cachedState = state; cachedDir = dir
return cachedResult = dir == "up" || dir == "down"
? endOfTextblockVertical(view, state, dir)
: endOfTextblockHorizontal(view, state, dir)
}