This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
ProseMirror's view module displays a given [editor
state](#state.EditorState) in the DOM, and handles user events.
Make sure you load `style/prosemirror.css` as a stylesheet when using
this module.
@EditorView
### Props
@EditorProps
@NodeViewConstructor
@MarkViewConstructor
@DirectEditorProps
@NodeView
@DOMEventMap
### Decorations
Decorations make it possible to influence the way the document is
drawn, without actually changing the document.
@Decoration
@DecorationAttrs
@DecorationSet
@DecorationSource

View File

@@ -0,0 +1,24 @@
const nav = typeof navigator != "undefined" ? navigator : null
const doc = typeof document != "undefined" ? document : null
const agent = (nav && nav.userAgent) || ""
const ie_edge = /Edge\/(\d+)/.exec(agent)
const ie_upto10 = /MSIE \d/.exec(agent)
const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent)
export const ie = !!(ie_upto10 || ie_11up || ie_edge)
export const ie_version = ie_upto10 ? (document as any).documentMode : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0
export const gecko = !ie && /gecko\/(\d+)/i.test(agent)
export const gecko_version = gecko && +(/Firefox\/(\d+)/.exec(agent) || [0, 0])[1]
const _chrome = !ie && /Chrome\/(\d+)/.exec(agent)
export const chrome = !!_chrome
export const chrome_version = _chrome ? +_chrome[1] : 0
export const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor)
// Is true for both iOS and iPadOS for convenience
export const ios = safari && (/Mobile\/\w+/.test(agent) || !!nav && nav.maxTouchPoints > 2)
export const mac = ios || (nav ? /Mac/.test(nav.platform) : false)
export const windows = nav ? /Win/.test(nav.platform) : false
export const android = /Android \d/.test(agent)
export const webkit = !!doc && "webkitFontSmoothing" in doc.documentElement.style
export const webkit_version = webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0

View File

@@ -0,0 +1,344 @@
import {Selection, NodeSelection, TextSelection, AllSelection, EditorState} from "prosemirror-state"
import {EditorView} from "./index"
import * as browser from "./browser"
import {domIndex, selectionCollapsed, hasBlockDesc} from "./dom"
import {selectionToDOM} from "./selection"
function moveSelectionBlock(state: EditorState, dir: number) {
let {$anchor, $head} = state.selection
let $side = dir > 0 ? $anchor.max($head) : $anchor.min($head)
let $start = !$side.parent.inlineContent ? $side : $side.depth ? state.doc.resolve(dir > 0 ? $side.after() : $side.before()) : null
return $start && Selection.findFrom($start, dir)
}
function apply(view: EditorView, sel: Selection) {
view.dispatch(view.state.tr.setSelection(sel).scrollIntoView())
return true
}
function selectHorizontally(view: EditorView, dir: number, mods: string) {
let sel = view.state.selection
if (sel instanceof TextSelection) {
if (mods.indexOf("s") > -1) {
let {$head} = sel, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter
if (!node || node.isText || !node.isLeaf) return false
let $newHead = view.state.doc.resolve($head.pos + node.nodeSize * (dir < 0 ? -1 : 1))
return apply(view, new TextSelection(sel.$anchor, $newHead))
} else if (!sel.empty) {
return false
} else if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) {
let next = moveSelectionBlock(view.state, dir)
if (next && (next instanceof NodeSelection)) return apply(view, next)
return false
} else if (!(browser.mac && mods.indexOf("m") > -1)) {
let $head = sel.$head, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter, desc
if (!node || node.isText) return false
let nodePos = dir < 0 ? $head.pos - node.nodeSize : $head.pos
if (!(node.isAtom || (desc = view.docView.descAt(nodePos)) && !desc.contentDOM)) return false
if (NodeSelection.isSelectable(node)) {
return apply(view, new NodeSelection(dir < 0 ? view.state.doc.resolve($head.pos - node.nodeSize) : $head))
} else if (browser.webkit) {
// Chrome and Safari will introduce extra pointless cursor
// positions around inline uneditable nodes, so we have to
// take over and move the cursor past them (#937)
return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize)))
} else {
return false
}
}
} else if (sel instanceof NodeSelection && sel.node.isInline) {
return apply(view, new TextSelection(dir > 0 ? sel.$to : sel.$from))
} else {
let next = moveSelectionBlock(view.state, dir)
if (next) return apply(view, next)
return false
}
}
function nodeLen(node: Node) {
return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length
}
function isIgnorable(dom: Node, dir: number) {
let desc = dom.pmViewDesc
return desc && desc.size == 0 && (dir < 0 || dom.nextSibling || dom.nodeName != "BR")
}
function skipIgnoredNodes(view: EditorView, dir: number) {
return dir < 0 ? skipIgnoredNodesBefore(view) : skipIgnoredNodesAfter(view)
}
// Make sure the cursor isn't directly after one or more ignored
// nodes, which will confuse the browser's cursor motion logic.
function skipIgnoredNodesBefore(view: EditorView) {
let sel = view.domSelectionRange()
let node = sel.focusNode!, offset = sel.focusOffset
if (!node) return
let moveNode, moveOffset: number | undefined, force = false
// Gecko will do odd things when the selection is directly in front
// of a non-editable node, so in that case, move it into the next
// node if possible. Issue prosemirror/prosemirror#832.
if (browser.gecko && node.nodeType == 1 && offset < nodeLen(node) && isIgnorable(node.childNodes[offset], -1)) force = true
for (;;) {
if (offset > 0) {
if (node.nodeType != 1) {
break
} else {
let before = node.childNodes[offset - 1]
if (isIgnorable(before, -1)) {
moveNode = node
moveOffset = --offset
} else if (before.nodeType == 3) {
node = before
offset = node.nodeValue!.length
} else break
}
} else if (isBlockNode(node)) {
break
} else {
let prev = node.previousSibling
while (prev && isIgnorable(prev, -1)) {
moveNode = node.parentNode
moveOffset = domIndex(prev)
prev = prev.previousSibling
}
if (!prev) {
node = node.parentNode!
if (node == view.dom) break
offset = 0
} else {
node = prev
offset = nodeLen(node)
}
}
}
if (force) setSelFocus(view, node, offset)
else if (moveNode) setSelFocus(view, moveNode, moveOffset!)
}
// Make sure the cursor isn't directly before one or more ignored
// nodes.
function skipIgnoredNodesAfter(view: EditorView) {
let sel = view.domSelectionRange()
let node = sel.focusNode!, offset = sel.focusOffset
if (!node) return
let len = nodeLen(node)
let moveNode, moveOffset: number | undefined
for (;;) {
if (offset < len) {
if (node.nodeType != 1) break
let after = node.childNodes[offset]
if (isIgnorable(after, 1)) {
moveNode = node
moveOffset = ++offset
}
else break
} else if (isBlockNode(node)) {
break
} else {
let next = node.nextSibling
while (next && isIgnorable(next, 1)) {
moveNode = next.parentNode
moveOffset = domIndex(next) + 1
next = next.nextSibling
}
if (!next) {
node = node.parentNode!
if (node == view.dom) break
offset = len = 0
} else {
node = next
offset = 0
len = nodeLen(node)
}
}
}
if (moveNode) setSelFocus(view, moveNode, moveOffset!)
}
function isBlockNode(dom: Node) {
let desc = dom.pmViewDesc
return desc && desc.node && desc.node.isBlock
}
function textNodeAfter(node: Node | null, offset: number): Text | undefined {
while (node && offset == node.childNodes.length && !hasBlockDesc(node)) {
offset = domIndex(node) + 1
node = node.parentNode
}
while (node && offset < node.childNodes.length) {
let next = node.childNodes[offset]
if (next.nodeType == 3) return next as Text
if (next.nodeType == 1 && (next as HTMLElement).contentEditable == "false") break
node = next
offset = 0
}
}
function textNodeBefore(node: Node | null, offset: number): Text | undefined {
while (node && !offset && !hasBlockDesc(node)) {
offset = domIndex(node)
node = node.parentNode
}
while (node && offset) {
let next = node.childNodes[offset - 1]
if (next.nodeType == 3) return next as Text
if (next.nodeType == 1 && (next as HTMLElement).contentEditable == "false") break
node = next
offset = node.childNodes.length
}
}
function setSelFocus(view: EditorView, node: Node, offset: number) {
if (node.nodeType != 3) {
let before, after
if (after = textNodeAfter(node, offset)) {
node = after
offset = 0
} else if (before = textNodeBefore(node, offset)) {
node = before
offset = before.nodeValue!.length
}
}
let sel = view.domSelection()
if (selectionCollapsed(sel)) {
let range = document.createRange()
range.setEnd(node, offset)
range.setStart(node, offset)
sel.removeAllRanges()
sel.addRange(range)
} else if (sel.extend) {
sel.extend(node, offset)
}
view.domObserver.setCurSelection()
let {state} = view
// If no state update ends up happening, reset the selection.
setTimeout(() => {
if (view.state == state) selectionToDOM(view)
}, 50)
}
function findDirection(view: EditorView, pos: number): "rtl" | "ltr" {
let $pos = view.state.doc.resolve(pos)
if (!(browser.chrome || browser.windows) && $pos.parent.inlineContent) {
let coords = view.coordsAtPos(pos)
if (pos > $pos.start()) {
let before = view.coordsAtPos(pos - 1)
let mid = (before.top + before.bottom) / 2
if (mid > coords.top && mid < coords.bottom && Math.abs(before.left - coords.left) > 1)
return before.left < coords.left ? "ltr" : "rtl"
}
if (pos < $pos.end()) {
let after = view.coordsAtPos(pos + 1)
let mid = (after.top + after.bottom) / 2
if (mid > coords.top && mid < coords.bottom && Math.abs(after.left - coords.left) > 1)
return after.left > coords.left ? "ltr" : "rtl"
}
}
let computed = getComputedStyle(view.dom).direction
return computed == "rtl" ? "rtl" : "ltr"
}
// Check whether vertical selection motion would involve node
// selections. If so, apply it (if not, the result is left to the
// browser)
function selectVertically(view: EditorView, dir: number, mods: string) {
let sel = view.state.selection
if (sel instanceof TextSelection && !sel.empty || mods.indexOf("s") > -1) return false
if (browser.mac && mods.indexOf("m") > -1) return false
let {$from, $to} = sel
if (!$from.parent.inlineContent || view.endOfTextblock(dir < 0 ? "up" : "down")) {
let next = moveSelectionBlock(view.state, dir)
if (next && (next instanceof NodeSelection))
return apply(view, next)
}
if (!$from.parent.inlineContent) {
let side = dir < 0 ? $from : $to
let beyond = sel instanceof AllSelection ? Selection.near(side, dir) : Selection.findFrom(side, dir)
return beyond ? apply(view, beyond) : false
}
return false
}
function stopNativeHorizontalDelete(view: EditorView, dir: number) {
if (!(view.state.selection instanceof TextSelection)) return true
let {$head, $anchor, empty} = view.state.selection
if (!$head.sameParent($anchor)) return true
if (!empty) return false
if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) return true
let nextNode = !$head.textOffset && (dir < 0 ? $head.nodeBefore : $head.nodeAfter)
if (nextNode && !nextNode.isText) {
let tr = view.state.tr
if (dir < 0) tr.delete($head.pos - nextNode.nodeSize, $head.pos)
else tr.delete($head.pos, $head.pos + nextNode.nodeSize)
view.dispatch(tr)
return true
}
return false
}
function switchEditable(view: EditorView, node: HTMLElement, state: string) {
view.domObserver.stop()
node.contentEditable = state
view.domObserver.start()
}
// Issue #867 / #1090 / https://bugs.chromium.org/p/chromium/issues/detail?id=903821
// In which Safari (and at some point in the past, Chrome) does really
// wrong things when the down arrow is pressed when the cursor is
// directly at the start of a textblock and has an uneditable node
// after it
function safariDownArrowBug(view: EditorView) {
if (!browser.safari || view.state.selection.$head.parentOffset > 0) return false
let {focusNode, focusOffset} = view.domSelectionRange()
if (focusNode && focusNode.nodeType == 1 && focusOffset == 0 &&
focusNode.firstChild && (focusNode.firstChild as HTMLElement).contentEditable == "false") {
let child = focusNode.firstChild as HTMLElement
switchEditable(view, child, "true")
setTimeout(() => switchEditable(view, child, "false"), 20)
}
return false
}
// A backdrop key mapping used to make sure we always suppress keys
// that have a dangerous default effect, even if the commands they are
// bound to return false, and to make sure that cursor-motion keys
// find a cursor (as opposed to a node selection) when pressed. For
// cursor-motion keys, the code in the handlers also takes care of
// block selections.
function getMods(event: KeyboardEvent) {
let result = ""
if (event.ctrlKey) result += "c"
if (event.metaKey) result += "m"
if (event.altKey) result += "a"
if (event.shiftKey) result += "s"
return result
}
export function captureKeyDown(view: EditorView, event: KeyboardEvent) {
let code = event.keyCode, mods = getMods(event)
if (code == 8 || (browser.mac && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac
return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodes(view, -1)
} else if ((code == 46 && !event.shiftKey) || (browser.mac && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac
return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodes(view, 1)
} else if (code == 13 || code == 27) { // Enter, Esc
return true
} else if (code == 37 || (browser.mac && code == 66 && mods == "c")) { // Left arrow, Ctrl-b on Mac
let dir = code == 37 ? (findDirection(view, view.state.selection.from) == "ltr" ? -1 : 1) : -1
return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir)
} else if (code == 39 || (browser.mac && code == 70 && mods == "c")) { // Right arrow, Ctrl-f on Mac
let dir = code == 39 ? (findDirection(view, view.state.selection.from) == "ltr" ? 1 : -1) : 1
return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir)
} else if (code == 38 || (browser.mac && code == 80 && mods == "c")) { // Up arrow, Ctrl-p on Mac
return selectVertically(view, -1, mods) || skipIgnoredNodes(view, -1)
} else if (code == 40 || (browser.mac && code == 78 && mods == "c")) { // Down arrow, Ctrl-n on Mac
return safariDownArrowBug(view) || selectVertically(view, 1, mods) || skipIgnoredNodes(view, 1)
} else if (mods == (browser.mac ? "m" : "c") &&
(code == 66 || code == 73 || code == 89 || code == 90)) { // Mod-[biyz]
return true
}
return false
}

View File

@@ -0,0 +1,246 @@
import {Slice, Fragment, DOMParser, DOMSerializer, ResolvedPos, NodeType, Node} from "prosemirror-model"
import * as browser from "./browser"
import {EditorView} from "./index"
export function serializeForClipboard(view: EditorView, slice: Slice) {
view.someProp("transformCopied", f => { slice = f(slice!, view) })
let context = [], {content, openStart, openEnd} = slice
while (openStart > 1 && openEnd > 1 && content.childCount == 1 && content.firstChild!.childCount == 1) {
openStart--
openEnd--
let node = content.firstChild!
context.push(node.type.name, node.attrs != node.type.defaultAttrs ? node.attrs : null)
content = node.content
}
let serializer = view.someProp("clipboardSerializer") || DOMSerializer.fromSchema(view.state.schema)
let doc = detachedDoc(), wrap = doc.createElement("div")
wrap.appendChild(serializer.serializeFragment(content, {document: doc}))
let firstChild = wrap.firstChild, needsWrap, wrappers = 0
while (firstChild && firstChild.nodeType == 1 && (needsWrap = wrapMap[firstChild.nodeName.toLowerCase()])) {
for (let i = needsWrap.length - 1; i >= 0; i--) {
let wrapper = doc.createElement(needsWrap[i])
while (wrap.firstChild) wrapper.appendChild(wrap.firstChild)
wrap.appendChild(wrapper)
wrappers++
}
firstChild = wrap.firstChild
}
if (firstChild && firstChild.nodeType == 1)
(firstChild as HTMLElement).setAttribute(
"data-pm-slice", `${openStart} ${openEnd}${wrappers ? ` -${wrappers}` : ""} ${JSON.stringify(context)}`)
let text = view.someProp("clipboardTextSerializer", f => f(slice, view)) ||
slice.content.textBetween(0, slice.content.size, "\n\n")
return {dom: wrap, text, slice}
}
// Read a slice of content from the clipboard (or drop data).
export function parseFromClipboard(view: EditorView, text: string, html: string | null, plainText: boolean, $context: ResolvedPos) {
let inCode = $context.parent.type.spec.code
let dom: HTMLElement | undefined, slice: Slice | undefined
if (!html && !text) return null
let asText = text && (plainText || inCode || !html)
if (asText) {
view.someProp("transformPastedText", f => { text = f(text, inCode || plainText, view) })
if (inCode) return text ? new Slice(Fragment.from(view.state.schema.text(text.replace(/\r\n?/g, "\n"))), 0, 0) : Slice.empty
let parsed = view.someProp("clipboardTextParser", f => f(text, $context, plainText, view))
if (parsed) {
slice = parsed
} else {
let marks = $context.marks()
let {schema} = view.state, serializer = DOMSerializer.fromSchema(schema)
dom = document.createElement("div")
text.split(/(?:\r\n?|\n)+/).forEach(block => {
let p = dom!.appendChild(document.createElement("p"))
if (block) p.appendChild(serializer.serializeNode(schema.text(block, marks)))
})
}
} else {
view.someProp("transformPastedHTML", f => { html = f(html!, view) })
dom = readHTML(html!)
if (browser.webkit) restoreReplacedSpaces(dom)
}
let contextNode = dom && dom.querySelector("[data-pm-slice]")
let sliceData = contextNode && /^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(contextNode.getAttribute("data-pm-slice") || "")
if (sliceData && sliceData[3]) for (let i = +sliceData[3]; i > 0; i--) {
let child = dom!.firstChild
while (child && child.nodeType != 1) child = child.nextSibling
if (!child) break
dom = child as HTMLElement
}
if (!slice) {
let parser = view.someProp("clipboardParser") || view.someProp("domParser") || DOMParser.fromSchema(view.state.schema)
slice = parser.parseSlice(dom!, {
preserveWhitespace: !!(asText || sliceData),
context: $context,
ruleFromNode(dom) {
if (dom.nodeName == "BR" && !dom.nextSibling &&
dom.parentNode && !inlineParents.test(dom.parentNode.nodeName)) return {ignore: true}
return null
}
})
}
if (sliceData) {
slice = addContext(closeSlice(slice, +sliceData[1], +sliceData[2]), sliceData[4])
} else { // HTML wasn't created by ProseMirror. Make sure top-level siblings are coherent
slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true)
if (slice.openStart || slice.openEnd) {
let openStart = 0, openEnd = 0
for (let node = slice.content.firstChild; openStart < slice.openStart && !node!.type.spec.isolating;
openStart++, node = node!.firstChild) {}
for (let node = slice.content.lastChild; openEnd < slice.openEnd && !node!.type.spec.isolating;
openEnd++, node = node!.lastChild) {}
slice = closeSlice(slice, openStart, openEnd)
}
}
view.someProp("transformPasted", f => { slice = f(slice!, view) })
return slice
}
const inlineParents = /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i
// Takes a slice parsed with parseSlice, which means there hasn't been
// any content-expression checking done on the top nodes, tries to
// find a parent node in the current context that might fit the nodes,
// and if successful, rebuilds the slice so that it fits into that parent.
//
// This addresses the problem that Transform.replace expects a
// coherent slice, and will fail to place a set of siblings that don't
// fit anywhere in the schema.
function normalizeSiblings(fragment: Fragment, $context: ResolvedPos) {
if (fragment.childCount < 2) return fragment
for (let d = $context.depth; d >= 0; d--) {
let parent = $context.node(d)
let match = parent.contentMatchAt($context.index(d))
let lastWrap: readonly NodeType[] | undefined, result: Node[] | null = []
fragment.forEach(node => {
if (!result) return
let wrap = match.findWrapping(node.type), inLast
if (!wrap) return result = null
if (inLast = result.length && lastWrap!.length && addToSibling(wrap, lastWrap!, node, result[result.length - 1], 0)) {
result[result.length - 1] = inLast
} else {
if (result.length) result[result.length - 1] = closeRight(result[result.length - 1], lastWrap!.length)
let wrapped = withWrappers(node, wrap)
result.push(wrapped)
match = match.matchType(wrapped.type)!
lastWrap = wrap
}
})
if (result) return Fragment.from(result)
}
return fragment
}
function withWrappers(node: Node, wrap: readonly NodeType[], from = 0) {
for (let i = wrap.length - 1; i >= from; i--)
node = wrap[i].create(null, Fragment.from(node))
return node
}
// Used to group adjacent nodes wrapped in similar parents by
// normalizeSiblings into the same parent node
function addToSibling(wrap: readonly NodeType[], lastWrap: readonly NodeType[],
node: Node, sibling: Node, depth: number): Node | undefined {
if (depth < wrap.length && depth < lastWrap.length && wrap[depth] == lastWrap[depth]) {
let inner = addToSibling(wrap, lastWrap, node, sibling.lastChild!, depth + 1)
if (inner) return sibling.copy(sibling.content.replaceChild(sibling.childCount - 1, inner))
let match = sibling.contentMatchAt(sibling.childCount)
if (match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1]))
return sibling.copy(sibling.content.append(Fragment.from(withWrappers(node, wrap, depth + 1))))
}
}
function closeRight(node: Node, depth: number) {
if (depth == 0) return node
let fragment = node.content.replaceChild(node.childCount - 1, closeRight(node.lastChild!, depth - 1))
let fill = node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true)!
return node.copy(fragment.append(fill))
}
function closeRange(fragment: Fragment, side: number, from: number, to: number, depth: number, openEnd: number) {
let node = side < 0 ? fragment.firstChild! : fragment.lastChild!, inner = node.content
if (fragment.childCount > 1) openEnd = 0
if (depth < to - 1) inner = closeRange(inner, side, from, to, depth + 1, openEnd)
if (depth >= from)
inner = side < 0 ? node.contentMatchAt(0)!.fillBefore(inner, openEnd <= depth)!.append(inner)
: inner.append(node.contentMatchAt(node.childCount)!.fillBefore(Fragment.empty, true)!)
return fragment.replaceChild(side < 0 ? 0 : fragment.childCount - 1, node.copy(inner))
}
function closeSlice(slice: Slice, openStart: number, openEnd: number) {
if (openStart < slice.openStart)
slice = new Slice(closeRange(slice.content, -1, openStart, slice.openStart, 0, slice.openEnd), openStart, slice.openEnd)
if (openEnd < slice.openEnd)
slice = new Slice(closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0), slice.openStart, openEnd)
return slice
}
// Trick from jQuery -- some elements must be wrapped in other
// elements for innerHTML to work. I.e. if you do `div.innerHTML =
// "<td>..</td>"` the table cells are ignored.
const wrapMap: {[node: string]: string[]} = {
thead: ["table"],
tbody: ["table"],
tfoot: ["table"],
caption: ["table"],
colgroup: ["table"],
col: ["table", "colgroup"],
tr: ["table", "tbody"],
td: ["table", "tbody", "tr"],
th: ["table", "tbody", "tr"]
}
let _detachedDoc: Document | null = null
function detachedDoc() {
return _detachedDoc || (_detachedDoc = document.implementation.createHTMLDocument("title"))
}
function readHTML(html: string) {
let metas = /^(\s*<meta [^>]*>)*/.exec(html)
if (metas) html = html.slice(metas[0].length)
let elt = detachedDoc().createElement("div")
let firstTag = /<([a-z][^>\s]+)/i.exec(html), wrap
if (wrap = firstTag && wrapMap[firstTag[1].toLowerCase()])
html = wrap.map(n => "<" + n + ">").join("") + html + wrap.map(n => "</" + n + ">").reverse().join("")
elt.innerHTML = html
if (wrap) for (let i = 0; i < wrap.length; i++) elt = elt.querySelector(wrap[i]) || elt
return elt
}
// Webkit browsers do some hard-to-predict replacement of regular
// spaces with non-breaking spaces when putting content on the
// clipboard. This tries to convert such non-breaking spaces (which
// will be wrapped in a plain span on Chrome, a span with class
// Apple-converted-space on Safari) back to regular spaces.
function restoreReplacedSpaces(dom: HTMLElement) {
let nodes = dom.querySelectorAll(browser.chrome ? "span:not([class]):not([style])" : "span.Apple-converted-space")
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i]
if (node.childNodes.length == 1 && node.textContent == "\u00a0" && node.parentNode)
node.parentNode.replaceChild(dom.ownerDocument.createTextNode(" "), node)
}
}
function addContext(slice: Slice, context: string) {
if (!slice.size) return slice
let schema = slice.content.firstChild!.type.schema, array
try { array = JSON.parse(context) }
catch(e) { return slice }
let {content, openStart, openEnd} = slice
for (let i = array.length - 2; i >= 0; i -= 2) {
let type = schema.nodes[array[i]]
if (!type || type.hasRequiredAttrs()) break
content = Fragment.from(type.create(array[i + 1], content))
openStart++; openEnd++
}
return new Slice(content, openStart, openEnd)
}

View File

@@ -0,0 +1,772 @@
import {Node, Mark} from "prosemirror-model"
import {Mappable, Mapping} from "prosemirror-transform"
import {EditorView} from "./index"
import {DOMNode} from "./dom"
function compareObjs(a: {[prop: string]: any}, b: {[prop: string]: any}) {
if (a == b) return true
for (let p in a) if (a[p] !== b[p]) return false
for (let p in b) if (!(p in a)) return false
return true
}
export interface DecorationType {
spec: any
map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null
valid(node: Node, span: Decoration): boolean
eq(other: DecorationType): boolean
destroy(dom: DOMNode): void
}
export type WidgetConstructor = ((view: EditorView, getPos: () => number | undefined) => DOMNode) | DOMNode
export class WidgetType implements DecorationType {
spec: any
side: number
constructor(readonly toDOM: WidgetConstructor, spec: any) {
this.spec = spec || noSpec
this.side = this.spec.side || 0
}
map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null {
let {pos, deleted} = mapping.mapResult(span.from + oldOffset, this.side < 0 ? -1 : 1)
return deleted ? null : new Decoration(pos - offset, pos - offset, this)
}
valid() { return true }
eq(other: WidgetType) {
return this == other ||
(other instanceof WidgetType &&
(this.spec.key && this.spec.key == other.spec.key ||
this.toDOM == other.toDOM && compareObjs(this.spec, other.spec)))
}
destroy(node: DOMNode) {
if (this.spec.destroy) this.spec.destroy(node)
}
}
export class InlineType implements DecorationType {
spec: any
constructor(readonly attrs: DecorationAttrs, spec: any) {
this.spec = spec || noSpec
}
map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null {
let from = mapping.map(span.from + oldOffset, this.spec.inclusiveStart ? -1 : 1) - offset
let to = mapping.map(span.to + oldOffset, this.spec.inclusiveEnd ? 1 : -1) - offset
return from >= to ? null : new Decoration(from, to, this)
}
valid(_: Node, span: Decoration) { return span.from < span.to }
eq(other: DecorationType): boolean {
return this == other ||
(other instanceof InlineType && compareObjs(this.attrs, other.attrs) &&
compareObjs(this.spec, other.spec))
}
static is(span: Decoration) { return span.type instanceof InlineType }
destroy() {}
}
export class NodeType implements DecorationType {
spec: any
constructor(readonly attrs: DecorationAttrs, spec: any) {
this.spec = spec || noSpec
}
map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null {
let from = mapping.mapResult(span.from + oldOffset, 1)
if (from.deleted) return null
let to = mapping.mapResult(span.to + oldOffset, -1)
if (to.deleted || to.pos <= from.pos) return null
return new Decoration(from.pos - offset, to.pos - offset, this)
}
valid(node: Node, span: Decoration): boolean {
let {index, offset} = node.content.findIndex(span.from), child
return offset == span.from && !(child = node.child(index)).isText && offset + child.nodeSize == span.to
}
eq(other: DecorationType): boolean {
return this == other ||
(other instanceof NodeType && compareObjs(this.attrs, other.attrs) &&
compareObjs(this.spec, other.spec))
}
destroy() {}
}
/// Decoration objects can be provided to the view through the
/// [`decorations` prop](#view.EditorProps.decorations). They come in
/// several variants—see the static members of this class for details.
export class Decoration {
/// @internal
constructor(
/// The start position of the decoration.
readonly from: number,
/// The end position. Will be the same as `from` for [widget
/// decorations](#view.Decoration^widget).
readonly to: number,
/// @internal
readonly type: DecorationType
) {}
/// @internal
copy(from: number, to: number) {
return new Decoration(from, to, this.type)
}
/// @internal
eq(other: Decoration, offset = 0) {
return this.type.eq(other.type) && this.from + offset == other.from && this.to + offset == other.to
}
/// @internal
map(mapping: Mappable, offset: number, oldOffset: number) {
return this.type.map(mapping, this, offset, oldOffset)
}
/// Creates a widget decoration, which is a DOM node that's shown in
/// the document at the given position. It is recommended that you
/// delay rendering the widget by passing a function that will be
/// called when the widget is actually drawn in a view, but you can
/// also directly pass a DOM node. `getPos` can be used to find the
/// widget's current document position.
static widget(pos: number, toDOM: WidgetConstructor, spec?: {
/// Controls which side of the document position this widget is
/// associated with. When negative, it is drawn before a cursor
/// at its position, and content inserted at that position ends
/// up after the widget. When zero (the default) or positive, the
/// widget is drawn after the cursor and content inserted there
/// ends up before the widget.
///
/// When there are multiple widgets at a given position, their
/// `side` values determine the order in which they appear. Those
/// with lower values appear first. The ordering of widgets with
/// the same `side` value is unspecified.
///
/// When `marks` is null, `side` also determines the marks that
/// the widget is wrapped in—those of the node before when
/// negative, those of the node after when positive.
side?: number
/// The precise set of marks to draw around the widget.
marks?: readonly Mark[]
/// Can be used to control which DOM events, when they bubble out
/// of this widget, the editor view should ignore.
stopEvent?: (event: Event) => boolean
/// When set (defaults to false), selection changes inside the
/// widget are ignored, and don't cause ProseMirror to try and
/// re-sync the selection with its selection state.
ignoreSelection?: boolean
/// When comparing decorations of this type (in order to decide
/// whether it needs to be redrawn), ProseMirror will by default
/// compare the widget DOM node by identity. If you pass a key,
/// that key will be compared instead, which can be useful when
/// you generate decorations on the fly and don't want to store
/// and reuse DOM nodes. Make sure that any widgets with the same
/// key are interchangeable—if widgets differ in, for example,
/// the behavior of some event handler, they should get
/// different keys.
key?: string
/// Called when the widget decoration is removed or the editor is
/// destroyed.
destroy?: (node: DOMNode) => void
/// Specs allow arbitrary additional properties.
[key: string]: any
}): Decoration {
return new Decoration(pos, pos, new WidgetType(toDOM, spec))
}
/// Creates an inline decoration, which adds the given attributes to
/// each inline node between `from` and `to`.
static inline(from: number, to: number, attrs: DecorationAttrs, spec?: {
/// Determines how the left side of the decoration is
/// [mapped](#transform.Position_Mapping) when content is
/// inserted directly at that position. By default, the decoration
/// won't include the new content, but you can set this to `true`
/// to make it inclusive.
inclusiveStart?: boolean
/// Determines how the right side of the decoration is mapped.
/// See
/// [`inclusiveStart`](#view.Decoration^inline^spec.inclusiveStart).
inclusiveEnd?: boolean
/// Specs may have arbitrary additional properties.
[key: string]: any
}) {
return new Decoration(from, to, new InlineType(attrs, spec))
}
/// Creates a node decoration. `from` and `to` should point precisely
/// before and after a node in the document. That node, and only that
/// node, will receive the given attributes.
static node(from: number, to: number, attrs: DecorationAttrs, spec?: any) {
return new Decoration(from, to, new NodeType(attrs, spec))
}
/// The spec provided when creating this decoration. Can be useful
/// if you've stored extra information in that object.
get spec() { return this.type.spec }
/// @internal
get inline() { return this.type instanceof InlineType }
/// @internal
get widget() { return this.type instanceof WidgetType }
}
/// A set of attributes to add to a decorated node. Most properties
/// simply directly correspond to DOM attributes of the same name,
/// which will be set to the property's value. These are exceptions:
export type DecorationAttrs = {
/// When non-null, the target node is wrapped in a DOM element of
/// this type (and the other attributes are applied to this element).
nodeName?: string
/// A CSS class name or a space-separated set of class names to be
/// _added_ to the classes that the node already had.
class?: string
/// A string of CSS to be _added_ to the node's existing `style` property.
style?: string
/// Any other properties are treated as regular DOM attributes.
[attribute: string]: string | undefined
}
const none: readonly any[] = [], noSpec = {}
/// An object that can [provide](#view.EditorProps.decorations)
/// decorations. Implemented by [`DecorationSet`](#view.DecorationSet),
/// and passed to [node views](#view.EditorProps.nodeViews).
export interface DecorationSource {
/// Map the set of decorations in response to a change in the
/// document.
map: (mapping: Mapping, node: Node) => DecorationSource
/// @internal
locals(node: Node): readonly Decoration[]
/// Extract a DecorationSource containing decorations for the given child node at the given offset.
forChild(offset: number, child: Node): DecorationSource
/// @internal
eq(other: DecorationSource): boolean
}
/// A collection of [decorations](#view.Decoration), organized in such
/// a way that the drawing algorithm can efficiently use and compare
/// them. This is a persistent data structure—it is not modified,
/// updates create a new value.
export class DecorationSet implements DecorationSource {
/// @internal
local: readonly Decoration[]
/// @internal
children: readonly (number | DecorationSet)[]
/// @internal
constructor(local: readonly Decoration[], children: readonly (number | DecorationSet)[]) {
this.local = local.length ? local : none
this.children = children.length ? children : none
}
/// Create a set of decorations, using the structure of the given
/// document. This will consume (modify) the `decorations` array, so
/// you must make a copy if you want need to preserve that.
static create(doc: Node, decorations: Decoration[]) {
return decorations.length ? buildTree(decorations, doc, 0, noSpec) : empty
}
/// Find all decorations in this set which touch the given range
/// (including decorations that start or end directly at the
/// boundaries) and match the given predicate on their spec. When
/// `start` and `end` are omitted, all decorations in the set are
/// considered. When `predicate` isn't given, all decorations are
/// assumed to match.
find(start?: number, end?: number, predicate?: (spec: any) => boolean): Decoration[] {
let result: Decoration[] = []
this.findInner(start == null ? 0 : start, end == null ? 1e9 : end, result, 0, predicate)
return result
}
private findInner(start: number, end: number, result: Decoration[], offset: number, predicate?: (spec: any) => boolean) {
for (let i = 0; i < this.local.length; i++) {
let span = this.local[i]
if (span.from <= end && span.to >= start && (!predicate || predicate(span.spec)))
result.push(span.copy(span.from + offset, span.to + offset))
}
for (let i = 0; i < this.children.length; i += 3) {
if ((this.children[i] as number) < end && (this.children[i + 1] as number) > start) {
let childOff = (this.children[i] as number) + 1
;(this.children[i + 2] as DecorationSet).findInner(start - childOff, end - childOff,
result, offset + childOff, predicate)
}
}
}
/// Map the set of decorations in response to a change in the
/// document.
map(mapping: Mapping, doc: Node, options?: {
/// When given, this function will be called for each decoration
/// that gets dropped as a result of the mapping, passing the
/// spec of that decoration.
onRemove?: (decorationSpec: any) => void
}) {
if (this == empty || mapping.maps.length == 0) return this
return this.mapInner(mapping, doc, 0, 0, options || noSpec)
}
/// @internal
mapInner(mapping: Mapping, node: Node, offset: number, oldOffset: number, options: {
onRemove?: (decorationSpec: any) => void
}) {
let newLocal: Decoration[] | undefined
for (let i = 0; i < this.local.length; i++) {
let mapped = this.local[i].map(mapping, offset, oldOffset)
if (mapped && mapped.type.valid(node, mapped)) (newLocal || (newLocal = [])).push(mapped)
else if (options.onRemove) options.onRemove(this.local[i].spec)
}
if (this.children.length)
return mapChildren(this.children, newLocal || [], mapping, node, offset, oldOffset, options)
else
return newLocal ? new DecorationSet(newLocal.sort(byPos), none) : empty
}
/// Add the given array of decorations to the ones in the set,
/// producing a new set. Consumes the `decorations` array. Needs
/// access to the current document to create the appropriate tree
/// structure.
add(doc: Node, decorations: Decoration[]) {
if (!decorations.length) return this
if (this == empty) return DecorationSet.create(doc, decorations)
return this.addInner(doc, decorations, 0)
}
private addInner(doc: Node, decorations: Decoration[], offset: number) {
let children: (number | DecorationSet)[] | undefined, childIndex = 0
doc.forEach((childNode, childOffset) => {
let baseOffset = childOffset + offset, found
if (!(found = takeSpansForNode(decorations, childNode, baseOffset))) return
if (!children) children = this.children.slice()
while (childIndex < children.length && (children[childIndex] as number) < childOffset) childIndex += 3
if (children[childIndex] == childOffset)
children[childIndex + 2] = (children[childIndex + 2] as DecorationSet).addInner(childNode, found, baseOffset + 1)
else
children.splice(childIndex, 0, childOffset, childOffset + childNode.nodeSize, buildTree(found, childNode, baseOffset + 1, noSpec))
childIndex += 3
})
let local = moveSpans(childIndex ? withoutNulls(decorations) : decorations, -offset)
for (let i = 0; i < local.length; i++) if (!local[i].type.valid(doc, local[i])) local.splice(i--, 1)
return new DecorationSet(local.length ? this.local.concat(local).sort(byPos) : this.local,
children || this.children)
}
/// Create a new set that contains the decorations in this set, minus
/// the ones in the given array.
remove(decorations: Decoration[]) {
if (decorations.length == 0 || this == empty) return this
return this.removeInner(decorations, 0)
}
private removeInner(decorations: (Decoration | null)[], offset: number) {
let children = this.children as (number | DecorationSet)[], local = this.local as Decoration[]
for (let i = 0; i < children.length; i += 3) {
let found: Decoration[] | undefined
let from = (children[i] as number) + offset, to = (children[i + 1] as number) + offset
for (let j = 0, span; j < decorations.length; j++) if (span = decorations[j]) {
if (span.from > from && span.to < to) {
decorations[j] = null
;(found || (found = [])).push(span)
}
}
if (!found) continue
if (children == this.children) children = this.children.slice()
let removed = (children[i + 2] as DecorationSet).removeInner(found, from + 1)
if (removed != empty) {
children[i + 2] = removed
} else {
children.splice(i, 3)
i -= 3
}
}
if (local.length) for (let i = 0, span; i < decorations.length; i++) if (span = decorations[i]) {
for (let j = 0; j < local.length; j++) if (local[j].eq(span, offset)) {
if (local == this.local) local = this.local.slice()
local.splice(j--, 1)
}
}
if (children == this.children && local == this.local) return this
return local.length || children.length ? new DecorationSet(local, children) : empty
}
forChild(offset: number, node: Node): DecorationSet | DecorationGroup {
if (this == empty) return this
if (node.isLeaf) return DecorationSet.empty
let child, local: Decoration[] | undefined
for (let i = 0; i < this.children.length; i += 3) if ((this.children[i] as number) >= offset) {
if (this.children[i] == offset) child = this.children[i + 2] as DecorationSet
break
}
let start = offset + 1, end = start + node.content.size
for (let i = 0; i < this.local.length; i++) {
let dec = this.local[i]
if (dec.from < end && dec.to > start && (dec.type instanceof InlineType)) {
let from = Math.max(start, dec.from) - start, to = Math.min(end, dec.to) - start
if (from < to) (local || (local = [])).push(dec.copy(from, to))
}
}
if (local) {
let localSet = new DecorationSet(local.sort(byPos), none)
return child ? new DecorationGroup([localSet, child]) : localSet
}
return child || empty
}
/// @internal
eq(other: DecorationSet) {
if (this == other) return true
if (!(other instanceof DecorationSet) ||
this.local.length != other.local.length ||
this.children.length != other.children.length) return false
for (let i = 0; i < this.local.length; i++)
if (!this.local[i].eq(other.local[i])) return false
for (let i = 0; i < this.children.length; i += 3)
if (this.children[i] != other.children[i] ||
this.children[i + 1] != other.children[i + 1] ||
!(this.children[i + 2] as DecorationSet).eq(other.children[i + 2] as DecorationSet))
return false
return true
}
/// @internal
locals(node: Node) {
return removeOverlap(this.localsInner(node))
}
/// @internal
localsInner(node: Node): readonly Decoration[] {
if (this == empty) return none
if (node.inlineContent || !this.local.some(InlineType.is)) return this.local
let result = []
for (let i = 0; i < this.local.length; i++) {
if (!(this.local[i].type instanceof InlineType))
result.push(this.local[i])
}
return result
}
/// The empty set of decorations.
static empty: DecorationSet = new DecorationSet([], [])
/// @internal
static removeOverlap = removeOverlap
}
const empty = DecorationSet.empty
// An abstraction that allows the code dealing with decorations to
// treat multiple DecorationSet objects as if it were a single object
// with (a subset of) the same interface.
class DecorationGroup implements DecorationSource {
constructor(readonly members: readonly DecorationSet[]) {}
map(mapping: Mapping, doc: Node) {
const mappedDecos = this.members.map(
member => member.map(mapping, doc, noSpec)
)
return DecorationGroup.from(mappedDecos)
}
forChild(offset: number, child: Node) {
if (child.isLeaf) return DecorationSet.empty
let found: DecorationSet[] = []
for (let i = 0; i < this.members.length; i++) {
let result = this.members[i].forChild(offset, child)
if (result == empty) continue
if (result instanceof DecorationGroup) found = found.concat(result.members)
else found.push(result)
}
return DecorationGroup.from(found)
}
eq(other: DecorationGroup) {
if (!(other instanceof DecorationGroup) ||
other.members.length != this.members.length) return false
for (let i = 0; i < this.members.length; i++)
if (!this.members[i].eq(other.members[i])) return false
return true
}
locals(node: Node) {
let result: Decoration[] | undefined, sorted = true
for (let i = 0; i < this.members.length; i++) {
let locals = this.members[i].localsInner(node)
if (!locals.length) continue
if (!result) {
result = locals as Decoration[]
} else {
if (sorted) {
result = result.slice()
sorted = false
}
for (let j = 0; j < locals.length; j++) result.push(locals[j])
}
}
return result ? removeOverlap(sorted ? result : result.sort(byPos)) : none
}
// Create a group for the given array of decoration sets, or return
// a single set when possible.
static from(members: readonly DecorationSource[]): DecorationSource {
switch (members.length) {
case 0: return empty
case 1: return members[0]
default: return new DecorationGroup(
members.every(m => m instanceof DecorationSet) ? members as DecorationSet[] :
members.reduce((r, m) => r.concat(m instanceof DecorationSet ? m : (m as DecorationGroup).members),
[] as DecorationSet[]))
}
}
}
function mapChildren(
oldChildren: readonly (number | DecorationSet)[],
newLocal: Decoration[],
mapping: Mapping,
node: Node,
offset: number,
oldOffset: number,
options: {onRemove?: (decorationSpec: any) => void}
) {
let children = oldChildren.slice() as (number | DecorationSet)[]
// Mark the children that are directly touched by changes, and
// move those that are after the changes.
for (let i = 0, baseOffset = oldOffset; i < mapping.maps.length; i++) {
let moved = 0
mapping.maps[i].forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
let dSize = (newEnd - newStart) - (oldEnd - oldStart)
for (let i = 0; i < children.length; i += 3) {
let end = children[i + 1] as number
if (end < 0 || oldStart > end + baseOffset - moved) continue
let start = (children[i] as number) + baseOffset - moved
if (oldEnd >= start) {
children[i + 1] = oldStart <= start ? -2 : -1
} else if (oldStart >= baseOffset && dSize) {
;(children[i] as number) += dSize
;(children[i + 1] as number) += dSize
}
}
moved += dSize
})
baseOffset = mapping.maps[i].map(baseOffset, -1)
}
// Find the child nodes that still correspond to a single node,
// recursively call mapInner on them and update their positions.
let mustRebuild = false
for (let i = 0; i < children.length; i += 3) if ((children[i + 1] as number) < 0) { // Touched nodes
if (children[i + 1] == -2) {
mustRebuild = true
children[i + 1] = -1
continue
}
let from = mapping.map((oldChildren[i] as number) + oldOffset), fromLocal = from - offset
if (fromLocal < 0 || fromLocal >= node.content.size) {
mustRebuild = true
continue
}
// Must read oldChildren because children was tagged with -1
let to = mapping.map((oldChildren[i + 1] as number) + oldOffset, -1), toLocal = to - offset
let {index, offset: childOffset} = node.content.findIndex(fromLocal)
let childNode = node.maybeChild(index)
if (childNode && childOffset == fromLocal && childOffset + childNode.nodeSize == toLocal) {
let mapped = (children[i + 2] as DecorationSet)
.mapInner(mapping, childNode, from + 1, (oldChildren[i] as number) + oldOffset + 1, options)
if (mapped != empty) {
children[i] = fromLocal
children[i + 1] = toLocal
children[i + 2] = mapped
} else {
children[i + 1] = -2
mustRebuild = true
}
} else {
mustRebuild = true
}
}
// Remaining children must be collected and rebuilt into the appropriate structure
if (mustRebuild) {
let decorations = mapAndGatherRemainingDecorations(children, oldChildren, newLocal, mapping,
offset, oldOffset, options)
let built = buildTree(decorations, node, 0, options)
newLocal = built.local as Decoration[]
for (let i = 0; i < children.length; i += 3) if ((children[i + 1] as number) < 0) {
children.splice(i, 3)
i -= 3
}
for (let i = 0, j = 0; i < built.children.length; i += 3) {
let from = built.children[i]
while (j < children.length && children[j] < from) j += 3
children.splice(j, 0, built.children[i], built.children[i + 1], built.children[i + 2])
}
}
return new DecorationSet(newLocal.sort(byPos), children)
}
function moveSpans(spans: Decoration[], offset: number) {
if (!offset || !spans.length) return spans
let result = []
for (let i = 0; i < spans.length; i++) {
let span = spans[i]
result.push(new Decoration(span.from + offset, span.to + offset, span.type))
}
return result
}
function mapAndGatherRemainingDecorations(
children: (number | DecorationSet)[],
oldChildren: readonly (number | DecorationSet)[],
decorations: Decoration[],
mapping: Mapping,
offset: number,
oldOffset: number,
options: {onRemove?: (decorationSpec: any) => void}
) {
// Gather all decorations from the remaining marked children
function gather(set: DecorationSet, oldOffset: number) {
for (let i = 0; i < set.local.length; i++) {
let mapped = set.local[i].map(mapping, offset, oldOffset)
if (mapped) decorations.push(mapped)
else if (options.onRemove) options.onRemove(set.local[i].spec)
}
for (let i = 0; i < set.children.length; i += 3)
gather(set.children[i + 2] as DecorationSet, set.children[i] as number + oldOffset + 1)
}
for (let i = 0; i < children.length; i += 3) if (children[i + 1] == -1)
gather(children[i + 2] as DecorationSet, oldChildren[i] as number + oldOffset + 1)
return decorations
}
function takeSpansForNode(spans: (Decoration | null)[], node: Node, offset: number): Decoration[] | null {
if (node.isLeaf) return null
let end = offset + node.nodeSize, found = null
for (let i = 0, span; i < spans.length; i++) {
if ((span = spans[i]) && span.from > offset && span.to < end) {
;(found || (found = [])).push(span)
spans[i] = null
}
}
return found
}
function withoutNulls<T>(array: readonly (T | null)[]): T[] {
let result: T[] = []
for (let i = 0; i < array.length; i++)
if (array[i] != null) result.push(array[i]!)
return result
}
// Build up a tree that corresponds to a set of decorations. `offset`
// is a base offset that should be subtracted from the `from` and `to`
// positions in the spans (so that we don't have to allocate new spans
// for recursive calls).
function buildTree(
spans: Decoration[],
node: Node,
offset: number,
options: {onRemove?: (decorationSpec: any) => void}
) {
let children: (DecorationSet | number)[] = [], hasNulls = false
node.forEach((childNode, localStart) => {
let found = takeSpansForNode(spans, childNode, localStart + offset)
if (found) {
hasNulls = true
let subtree = buildTree(found, childNode, offset + localStart + 1, options)
if (subtree != empty)
children.push(localStart, localStart + childNode.nodeSize, subtree)
}
})
let locals = moveSpans(hasNulls ? withoutNulls(spans) : spans, -offset).sort(byPos)
for (let i = 0; i < locals.length; i++) if (!locals[i].type.valid(node, locals[i])) {
if (options.onRemove) options.onRemove(locals[i].spec)
locals.splice(i--, 1)
}
return locals.length || children.length ? new DecorationSet(locals, children) : empty
}
// Used to sort decorations so that ones with a low start position
// come first, and within a set with the same start position, those
// with an smaller end position come first.
function byPos(a: Decoration, b: Decoration) {
return a.from - b.from || a.to - b.to
}
// Scan a sorted array of decorations for partially overlapping spans,
// and split those so that only fully overlapping spans are left (to
// make subsequent rendering easier). Will return the input array if
// no partially overlapping spans are found (the common case).
function removeOverlap(spans: readonly Decoration[]): Decoration[] {
let working: Decoration[] = spans as Decoration[]
for (let i = 0; i < working.length - 1; i++) {
let span = working[i]
if (span.from != span.to) for (let j = i + 1; j < working.length; j++) {
let next = working[j]
if (next.from == span.from) {
if (next.to != span.to) {
if (working == spans) working = spans.slice()
// Followed by a partially overlapping larger span. Split that
// span.
working[j] = next.copy(next.from, span.to)
insertAhead(working, j + 1, next.copy(span.to, next.to))
}
continue
} else {
if (next.from < span.to) {
if (working == spans) working = spans.slice()
// The end of this one overlaps with a subsequent span. Split
// this one.
working[i] = span.copy(span.from, next.from)
insertAhead(working, j, span.copy(next.from, span.to))
}
break
}
}
}
return working
}
function insertAhead(array: Decoration[], i: number, deco: Decoration) {
while (i < array.length && byPos(deco, array[i]) > 0) i++
array.splice(i, 0, deco)
}
// Get the decorations associated with the current props of a view.
export function viewDecorations(view: EditorView): DecorationSource {
let found: DecorationSource[] = []
view.someProp("decorations", f => {
let result = f(view.state)
if (result && result != empty) found.push(result)
})
if (view.cursorWrapper)
found.push(DecorationSet.create(view.state.doc, [view.cursorWrapper.deco]))
return DecorationGroup.from(found)
}

151
resources/app/node_modules/prosemirror-view/src/dom.ts generated vendored Normal file
View File

@@ -0,0 +1,151 @@
export type DOMNode = InstanceType<typeof window.Node>
export type DOMSelection = InstanceType<typeof window.Selection>
export type DOMSelectionRange = {
focusNode: DOMNode | null, focusOffset: number,
anchorNode: DOMNode | null, anchorOffset: number
}
export const domIndex = function(node: Node) {
for (var index = 0;; index++) {
node = node.previousSibling!
if (!node) return index
}
}
export const parentNode = function(node: Node): Node | null {
let parent = (node as HTMLSlotElement).assignedSlot || node.parentNode
return parent && parent.nodeType == 11 ? (parent as ShadowRoot).host : parent
}
let reusedRange: Range | null = null
// Note that this will always return the same range, because DOM range
// objects are every expensive, and keep slowing down subsequent DOM
// updates, for some reason.
export const textRange = function(node: Text, from?: number, to?: number) {
let range = reusedRange || (reusedRange = document.createRange())
range.setEnd(node, to == null ? node.nodeValue!.length : to)
range.setStart(node, from || 0)
return range
}
export const clearReusedRange = function() {
reusedRange = null;
}
// 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)
export const isEquivalentPosition = function(node: Node, off: number, targetNode: Node, targetOff: number) {
return targetNode && (scanFor(node, off, targetNode, targetOff, -1) ||
scanFor(node, off, targetNode, targetOff, 1))
}
const atomElements = /^(img|br|input|textarea|hr)$/i
function scanFor(node: Node, off: number, targetNode: Node, targetOff: number, dir: number) {
for (;;) {
if (node == targetNode && off == targetOff) return true
if (off == (dir < 0 ? 0 : nodeSize(node))) {
let parent = node.parentNode
if (!parent || parent.nodeType != 1 || hasBlockDesc(node) || atomElements.test(node.nodeName) ||
(node as HTMLElement).contentEditable == "false")
return false
off = domIndex(node) + (dir < 0 ? 0 : 1)
node = parent
} else if (node.nodeType == 1) {
node = node.childNodes[off + (dir < 0 ? -1 : 0)]
if ((node as HTMLElement).contentEditable == "false") return false
off = dir < 0 ? nodeSize(node) : 0
} else {
return false
}
}
}
export function nodeSize(node: Node) {
return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length
}
export function textNodeBefore(node: Node, offset: number) {
for (;;) {
if (node.nodeType == 3 && offset) return node as Text
if (node.nodeType == 1 && offset > 0) {
if ((node as HTMLElement).contentEditable == "false") return null
node = node.childNodes[offset - 1]
offset = nodeSize(node)
} else if (node.parentNode && !hasBlockDesc(node)) {
offset = domIndex(node)
node = node.parentNode
} else {
return null
}
}
}
export function textNodeAfter(node: Node, offset: number) {
for (;;) {
if (node.nodeType == 3 && offset < node.nodeValue!.length) return node as Text
if (node.nodeType == 1 && offset < node.childNodes.length) {
if ((node as HTMLElement).contentEditable == "false") return null
node = node.childNodes[offset]
offset = 0
} else if (node.parentNode && !hasBlockDesc(node)) {
offset = domIndex(node) + 1
node = node.parentNode
} else {
return null
}
}
}
export function isOnEdge(node: Node, offset: number, parent: Node) {
for (let atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) {
if (node == parent) return true
let index = domIndex(node)
node = node.parentNode!
if (!node) return false
atStart = atStart && index == 0
atEnd = atEnd && index == nodeSize(node)
}
}
export function hasBlockDesc(dom: Node) {
let desc
for (let cur: Node | null = dom; cur; cur = cur.parentNode) if (desc = cur.pmViewDesc) break
return desc && desc.node && desc.node.isBlock && (desc.dom == dom || desc.contentDOM == dom)
}
// Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523
// (isCollapsed inappropriately returns true in shadow dom)
export const selectionCollapsed = function(domSel: DOMSelectionRange) {
return domSel.focusNode && isEquivalentPosition(domSel.focusNode, domSel.focusOffset,
domSel.anchorNode!, domSel.anchorOffset)
}
export function keyEvent(keyCode: number, key: string) {
let event = document.createEvent("Event") as KeyboardEvent
event.initEvent("keydown", true, true)
;(event as any).keyCode = keyCode
;(event as any).key = (event as any).code = key
return event
}
export function deepActiveElement(doc: Document) {
let elt = doc.activeElement
while (elt && elt.shadowRoot) elt = elt.shadowRoot.activeElement
return elt
}
export function caretFromPoint(doc: Document, x: number, y: number): {node: Node, offset: number} | undefined {
if ((doc as any).caretPositionFromPoint) {
try { // Firefox throws for this call in hard-to-predict circumstances (#994)
let pos = (doc as any).caretPositionFromPoint(x, y)
if (pos) return {node: pos.offsetNode, offset: pos.offset}
} catch (_) {}
}
if (doc.caretRangeFromPoint) {
let range = doc.caretRangeFromPoint(x, y)
if (range) return {node: range.startContainer, offset: range.startOffset}
}
}

View File

@@ -0,0 +1,375 @@
import {Fragment, DOMParser, TagParseRule, Node, Mark, ResolvedPos} from "prosemirror-model"
import {Selection, TextSelection} from "prosemirror-state"
import {selectionBetween, selectionFromDOM, selectionToDOM} from "./selection"
import {selectionCollapsed, keyEvent, DOMNode} from "./dom"
import * as browser from "./browser"
import {EditorView} from "./index"
// Note that all referencing and parsing is done with the
// start-of-operation selection and document, since that's the one
// that the DOM represents. If any changes came in in the meantime,
// the modification is mapped over those before it is applied, in
// readDOMChange.
function parseBetween(view: EditorView, from_: number, to_: number) {
let {node: parent, fromOffset, toOffset, from, to} = view.docView.parseRange(from_, to_)
let domSel = view.domSelectionRange()
let find: {node: DOMNode, offset: number, pos?: number}[] | undefined
let anchor = domSel.anchorNode
if (anchor && view.dom.contains(anchor.nodeType == 1 ? anchor : anchor.parentNode)) {
find = [{node: anchor, offset: domSel.anchorOffset}]
if (!selectionCollapsed(domSel))
find.push({node: domSel.focusNode!, offset: domSel.focusOffset})
}
// Work around issue in Chrome where backspacing sometimes replaces
// the deleted content with a random BR node (issues #799, #831)
if (browser.chrome && view.input.lastKeyCode === 8) {
for (let off = toOffset; off > fromOffset; off--) {
let node = parent.childNodes[off - 1], desc = node.pmViewDesc
if (node.nodeName == "BR" && !desc) { toOffset = off; break }
if (!desc || desc.size) break
}
}
let startDoc = view.state.doc
let parser = view.someProp("domParser") || DOMParser.fromSchema(view.state.schema)
let $from = startDoc.resolve(from)
let sel = null, doc = parser.parse(parent, {
topNode: $from.parent,
topMatch: $from.parent.contentMatchAt($from.index()),
topOpen: true,
from: fromOffset,
to: toOffset,
preserveWhitespace: $from.parent.type.whitespace == "pre" ? "full" : true,
findPositions: find,
ruleFromNode,
context: $from
})
if (find && find[0].pos != null) {
let anchor = find[0].pos, head = find[1] && find[1].pos
if (head == null) head = anchor
sel = {anchor: anchor + from, head: head + from}
}
return {doc, sel, from, to}
}
function ruleFromNode(dom: DOMNode): Omit<TagParseRule, "tag"> | null {
let desc = dom.pmViewDesc
if (desc) {
return desc.parseRule()
} else if (dom.nodeName == "BR" && dom.parentNode) {
// Safari replaces the list item or table cell with a BR
// directly in the list node (?!) if you delete the last
// character in a list item or table cell (#708, #862)
if (browser.safari && /^(ul|ol)$/i.test(dom.parentNode.nodeName)) {
let skip = document.createElement("div")
skip.appendChild(document.createElement("li"))
return {skip} as any
} else if (dom.parentNode.lastChild == dom || browser.safari && /^(tr|table)$/i.test(dom.parentNode.nodeName)) {
return {ignore: true}
}
} else if (dom.nodeName == "IMG" && (dom as HTMLElement).getAttribute("mark-placeholder")) {
return {ignore: true}
}
return null
}
const isInline = /^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i
export function readDOMChange(view: EditorView, from: number, to: number, typeOver: boolean, addedNodes: readonly DOMNode[]) {
let compositionID = view.input.compositionPendingChanges || (view.composing ? view.input.compositionID : 0)
view.input.compositionPendingChanges = 0
if (from < 0) {
let origin = view.input.lastSelectionTime > Date.now() - 50 ? view.input.lastSelectionOrigin : null
let newSel = selectionFromDOM(view, origin)
if (newSel && !view.state.selection.eq(newSel)) {
if (browser.chrome && browser.android &&
view.input.lastKeyCode === 13 && Date.now() - 100 < view.input.lastKeyCodeTime &&
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter"))))
return
let tr = view.state.tr.setSelection(newSel)
if (origin == "pointer") tr.setMeta("pointer", true)
else if (origin == "key") tr.scrollIntoView()
if (compositionID) tr.setMeta("composition", compositionID)
view.dispatch(tr)
}
return
}
let $before = view.state.doc.resolve(from)
let shared = $before.sharedDepth(to)
from = $before.before(shared + 1)
to = view.state.doc.resolve(to).after(shared + 1)
let sel = view.state.selection
let parse = parseBetween(view, from, to)
let doc = view.state.doc, compare = doc.slice(parse.from, parse.to)
let preferredPos, preferredSide: "start" | "end"
// Prefer anchoring to end when Backspace is pressed
if (view.input.lastKeyCode === 8 && Date.now() - 100 < view.input.lastKeyCodeTime) {
preferredPos = view.state.selection.to
preferredSide = "end"
} else {
preferredPos = view.state.selection.from
preferredSide = "start"
}
view.input.lastKeyCode = null
let change = findDiff(compare.content, parse.doc.content, parse.from, preferredPos, preferredSide)
if ((browser.ios && view.input.lastIOSEnter > Date.now() - 225 || browser.android) &&
addedNodes.some(n => n.nodeType == 1 && !isInline.test(n.nodeName)) &&
(!change || change.endA >= change.endB) &&
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
view.input.lastIOSEnter = 0
return
}
if (!change) {
if (typeOver && sel instanceof TextSelection && !sel.empty && sel.$head.sameParent(sel.$anchor) &&
!view.composing && !(parse.sel && parse.sel.anchor != parse.sel.head)) {
change = {start: sel.from, endA: sel.to, endB: sel.to}
} else {
if (parse.sel) {
let sel = resolveSelection(view, view.state.doc, parse.sel)
if (sel && !sel.eq(view.state.selection)) {
let tr = view.state.tr.setSelection(sel)
if (compositionID) tr.setMeta("composition", compositionID)
view.dispatch(tr)
}
}
return
}
}
view.input.domChangeCount++
// Handle the case where overwriting a selection by typing matches
// the start or end of the selected content, creating a change
// that's smaller than what was actually overwritten.
if (view.state.selection.from < view.state.selection.to &&
change.start == change.endB &&
view.state.selection instanceof TextSelection) {
if (change.start > view.state.selection.from && change.start <= view.state.selection.from + 2 &&
view.state.selection.from >= parse.from) {
change.start = view.state.selection.from
} else if (change.endA < view.state.selection.to && change.endA >= view.state.selection.to - 2 &&
view.state.selection.to <= parse.to) {
change.endB += (view.state.selection.to - change.endA)
change.endA = view.state.selection.to
}
}
// IE11 will insert a non-breaking space _ahead_ of the space after
// the cursor space when adding a space before another space. When
// that happened, adjust the change to cover the space instead.
if (browser.ie && browser.ie_version <= 11 && change.endB == change.start + 1 &&
change.endA == change.start && change.start > parse.from &&
parse.doc.textBetween(change.start - parse.from - 1, change.start - parse.from + 1) == " \u00a0") {
change.start--
change.endA--
change.endB--
}
let $from = parse.doc.resolveNoCache(change.start - parse.from)
let $to = parse.doc.resolveNoCache(change.endB - parse.from)
let $fromA = doc.resolve(change.start)
let inlineChange = $from.sameParent($to) && $from.parent.inlineContent && $fromA.end() >= change.endA
let nextSel
// If this looks like the effect of pressing Enter (or was recorded
// as being an iOS enter press), just dispatch an Enter key instead.
if (((browser.ios && view.input.lastIOSEnter > Date.now() - 225 &&
(!inlineChange || addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P"))) ||
(!inlineChange && $from.pos < parse.doc.content.size && !$from.sameParent($to) &&
(nextSel = Selection.findFrom(parse.doc.resolve($from.pos + 1), 1, true)) &&
nextSel.head == $to.pos)) &&
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
view.input.lastIOSEnter = 0
return
}
// Same for backspace
if (view.state.selection.anchor > change.start &&
looksLikeBackspace(doc, change.start, change.endA, $from, $to) &&
view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) {
if (browser.android && browser.chrome) view.domObserver.suppressSelectionUpdates() // #820
return
}
// Chrome Android will occasionally, during composition, delete the
// entire composition and then immediately insert it again. This is
// used to detect that situation.
if (browser.chrome && browser.android && change.endB == change.start)
view.input.lastAndroidDelete = Date.now()
// This tries to detect Android virtual keyboard
// enter-and-pick-suggestion action. That sometimes (see issue
// #1059) first fires a DOM mutation, before moving the selection to
// the newly created block. And then, because ProseMirror cleans up
// the DOM selection, it gives up moving the selection entirely,
// leaving the cursor in the wrong place. When that happens, we drop
// the new paragraph from the initial change, and fire a simulated
// enter key afterwards.
if (browser.android && !inlineChange && $from.start() != $to.start() && $to.parentOffset == 0 && $from.depth == $to.depth &&
parse.sel && parse.sel.anchor == parse.sel.head && parse.sel.head == change.endA) {
change.endB -= 2
$to = parse.doc.resolveNoCache(change.endB - parse.from)
setTimeout(() => {
view.someProp("handleKeyDown", function (f) { return f(view, keyEvent(13, "Enter")); })
}, 20)
}
let chFrom = change.start, chTo = change.endA
let tr, storedMarks, markChange
if (inlineChange) {
if ($from.pos == $to.pos) { // Deletion
// IE11 sometimes weirdly moves the DOM selection around after
// backspacing out the first element in a textblock
if (browser.ie && browser.ie_version <= 11 && $from.parentOffset == 0) {
view.domObserver.suppressSelectionUpdates()
setTimeout(() => selectionToDOM(view), 20)
}
tr = view.state.tr.delete(chFrom, chTo)
storedMarks = doc.resolve(change.start).marksAcross(doc.resolve(change.endA))
} else if ( // Adding or removing a mark
change.endA == change.endB &&
(markChange = isMarkChange($from.parent.content.cut($from.parentOffset, $to.parentOffset),
$fromA.parent.content.cut($fromA.parentOffset, change.endA - $fromA.start())))
) {
tr = view.state.tr
if (markChange.type == "add") tr.addMark(chFrom, chTo, markChange.mark)
else tr.removeMark(chFrom, chTo, markChange.mark)
} else if ($from.parent.child($from.index()).isText && $from.index() == $to.index() - ($to.textOffset ? 0 : 1)) {
// Both positions in the same text node -- simply insert text
let text = $from.parent.textBetween($from.parentOffset, $to.parentOffset)
if (view.someProp("handleTextInput", f => f(view, chFrom, chTo, text))) return
tr = view.state.tr.insertText(text, chFrom, chTo)
}
}
if (!tr)
tr = view.state.tr.replace(chFrom, chTo, parse.doc.slice(change.start - parse.from, change.endB - parse.from))
if (parse.sel) {
let sel = resolveSelection(view, tr.doc, parse.sel)
// Chrome Android will sometimes, during composition, report the
// selection in the wrong place. If it looks like that is
// happening, don't update the selection.
// Edge just doesn't move the cursor forward when you start typing
// in an empty block or between br nodes.
if (sel && !(browser.chrome && browser.android && view.composing && sel.empty &&
(change.start != change.endB || view.input.lastAndroidDelete < Date.now() - 100) &&
(sel.head == chFrom || sel.head == tr.mapping.map(chTo) - 1) ||
browser.ie && sel.empty && sel.head == chFrom))
tr.setSelection(sel)
}
if (storedMarks) tr.ensureMarks(storedMarks)
if (compositionID) tr.setMeta("composition", compositionID)
view.dispatch(tr.scrollIntoView())
}
function resolveSelection(view: EditorView, doc: Node, parsedSel: {anchor: number, head: number}) {
if (Math.max(parsedSel.anchor, parsedSel.head) > doc.content.size) return null
return selectionBetween(view, doc.resolve(parsedSel.anchor), doc.resolve(parsedSel.head))
}
// Given two same-length, non-empty fragments of inline content,
// determine whether the first could be created from the second by
// removing or adding a single mark type.
function isMarkChange(cur: Fragment, prev: Fragment) {
let curMarks = cur.firstChild!.marks, prevMarks = prev.firstChild!.marks
let added = curMarks, removed = prevMarks, type, mark: Mark | undefined, update
for (let i = 0; i < prevMarks.length; i++) added = prevMarks[i].removeFromSet(added)
for (let i = 0; i < curMarks.length; i++) removed = curMarks[i].removeFromSet(removed)
if (added.length == 1 && removed.length == 0) {
mark = added[0]
type = "add"
update = (node: Node) => node.mark(mark!.addToSet(node.marks))
} else if (added.length == 0 && removed.length == 1) {
mark = removed[0]
type = "remove"
update = (node: Node) => node.mark(mark!.removeFromSet(node.marks))
} else {
return null
}
let updated = []
for (let i = 0; i < prev.childCount; i++) updated.push(update(prev.child(i)))
if (Fragment.from(updated).eq(cur)) return {mark, type}
}
function looksLikeBackspace(old: Node, start: number, end: number, $newStart: ResolvedPos, $newEnd: ResolvedPos) {
if (// The content must have shrunk
end - start <= $newEnd.pos - $newStart.pos ||
// newEnd must point directly at or after the end of the block that newStart points into
skipClosingAndOpening($newStart, true, false) < $newEnd.pos)
return false
let $start = old.resolve(start)
// Handle the case where, rather than joining blocks, the change just removed an entire block
if (!$newStart.parent.isTextblock) {
let after = $start.nodeAfter
return after != null && end == start + after.nodeSize
}
// Start must be at the end of a block
if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock)
return false
let $next = old.resolve(skipClosingAndOpening($start, true, true))
// The next textblock must start before end and end near it
if (!$next.parent.isTextblock || $next.pos > end ||
skipClosingAndOpening($next, true, false) < end)
return false
// The fragments after the join point must match
return $newStart.parent.content.cut($newStart.parentOffset).eq($next.parent.content)
}
function skipClosingAndOpening($pos: ResolvedPos, fromEnd: boolean, mayOpen: boolean) {
let depth = $pos.depth, end = fromEnd ? $pos.end() : $pos.pos
while (depth > 0 && (fromEnd || $pos.indexAfter(depth) == $pos.node(depth).childCount)) {
depth--
end++
fromEnd = false
}
if (mayOpen) {
let next = $pos.node(depth).maybeChild($pos.indexAfter(depth))
while (next && !next.isLeaf) {
next = next.firstChild
end++
}
}
return end
}
function findDiff(a: Fragment, b: Fragment, pos: number, preferredPos: number, preferredSide: "start" | "end") {
let start = a.findDiffStart(b, pos)
if (start == null) return null
let {a: endA, b: endB} = a.findDiffEnd(b, pos + a.size, pos + b.size)!
if (preferredSide == "end") {
let adjust = Math.max(0, start - Math.min(endA, endB))
preferredPos -= endA + adjust - start
}
if (endA < start && a.size < b.size) {
let move = preferredPos <= start && preferredPos >= endA ? start - preferredPos : 0
start -= move
if (start && start < b.size && isSurrogatePair(b.textBetween(start - 1, start + 1)))
start += move ? 1 : -1
endB = start + (endB - endA)
endA = start
} else if (endB < start) {
let move = preferredPos <= start && preferredPos >= endB ? start - preferredPos : 0
start -= move
if (start && start < a.size && isSurrogatePair(a.textBetween(start - 1, start + 1)))
start += move ? 1 : -1
endA = start + (endA - endB)
endB = start
}
return {start, endA, endB}
}
function isSurrogatePair(str: string) {
if (str.length != 2) return false
let a = str.charCodeAt(0), b = str.charCodeAt(1)
return a >= 0xDC00 && a <= 0xDFFF && b >= 0xD800 && b <= 0xDBFF
}

View File

@@ -0,0 +1,507 @@
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 <input> nodes, which
// have no actual children, from caretPositionFromPoint (#953)
offset = Math.min(offset, node.childNodes.length)
// It'll also move the returned position before image nodes,
// even if those are behind it.
if (offset < node.childNodes.length) {
let next = node.childNodes[offset], box
if (next.nodeName == "IMG" && (box = (next 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<T>(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)
}

View File

@@ -0,0 +1,319 @@
import {Selection} from "prosemirror-state"
import * as browser from "./browser"
import {domIndex, isEquivalentPosition, selectionCollapsed, parentNode, DOMSelectionRange, DOMNode, DOMSelection} from "./dom"
import {hasFocusAndSelection, selectionToDOM, selectionFromDOM} from "./selection"
import {EditorView} from "./index"
const observeOptions = {
childList: true,
characterData: true,
characterDataOldValue: true,
attributes: true,
attributeOldValue: true,
subtree: true
}
// IE11 has very broken mutation observers, so we also listen to DOMCharacterDataModified
const useCharData = browser.ie && browser.ie_version <= 11
class SelectionState {
anchorNode: Node | null = null
anchorOffset: number = 0
focusNode: Node | null = null
focusOffset: number = 0
set(sel: DOMSelectionRange) {
this.anchorNode = sel.anchorNode; this.anchorOffset = sel.anchorOffset
this.focusNode = sel.focusNode; this.focusOffset = sel.focusOffset
}
clear() {
this.anchorNode = this.focusNode = null
}
eq(sel: DOMSelectionRange) {
return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset &&
sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset
}
}
export class DOMObserver {
queue: MutationRecord[] = []
flushingSoon = -1
observer: MutationObserver | null = null
currentSelection = new SelectionState
onCharData: ((e: Event) => void) | null = null
suppressingSelectionUpdates = false
constructor(
readonly view: EditorView,
readonly handleDOMChange: (from: number, to: number, typeOver: boolean, added: Node[]) => void
) {
this.observer = window.MutationObserver &&
new window.MutationObserver(mutations => {
for (let i = 0; i < mutations.length; i++) this.queue.push(mutations[i])
// IE11 will sometimes (on backspacing out a single character
// text node after a BR node) call the observer callback
// before actually updating the DOM, which will cause
// ProseMirror to miss the change (see #930)
if (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 = e => {
this.queue.push({target: e.target as Node, type: "characterData", oldValue: (e as any).prevValue} as MutationRecord)
this.flushSoon()
}
}
this.onSelectionChange = this.onSelectionChange.bind(this)
}
flushSoon() {
if (this.flushingSoon < 0)
this.flushingSoon = window.setTimeout(() => { this.flushingSoon = -1; this.flush() }, 20)
}
forceFlush() {
if (this.flushingSoon > -1) {
window.clearTimeout(this.flushingSoon)
this.flushingSoon = -1
this.flush()
}
}
start() {
if (this.observer) {
this.observer.takeRecords()
this.observer.observe(this.view.dom, observeOptions)
}
if (this.onCharData)
this.view.dom.addEventListener("DOMCharacterDataModified", this.onCharData)
this.connectSelection()
}
stop() {
if (this.observer) {
let take = this.observer.takeRecords()
if (take.length) {
for (let i = 0; i < take.length; i++) this.queue.push(take[i])
window.setTimeout(() => this.flush(), 20)
}
this.observer.disconnect()
}
if (this.onCharData) this.view.dom.removeEventListener("DOMCharacterDataModified", this.onCharData)
this.disconnectSelection()
}
connectSelection() {
this.view.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange)
}
disconnectSelection() {
this.view.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange)
}
suppressSelectionUpdates() {
this.suppressingSelectionUpdates = true
setTimeout(() => this.suppressingSelectionUpdates = false, 50)
}
onSelectionChange() {
if (!hasFocusAndSelection(this.view)) return
if (this.suppressingSelectionUpdates) return selectionToDOM(this.view)
// Deletions on IE11 fire their events in the wrong order, giving
// us a selection change event before the DOM changes are
// reported.
if (browser.ie && browser.ie_version <= 11 && !this.view.state.selection.empty) {
let sel = this.view.domSelectionRange()
// Selection.isCollapsed isn't reliable on IE
if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode!, sel.anchorOffset))
return this.flushSoon()
}
this.flush()
}
setCurSelection() {
this.currentSelection.set(this.view.domSelectionRange())
}
ignoreSelectionChange(sel: DOMSelectionRange) {
if (!sel.focusNode) return true
let ancestors: Set<Node> = new Set, container: DOMNode | undefined
for (let scan: DOMNode | null = sel.focusNode; scan; scan = parentNode(scan)) ancestors.add(scan)
for (let scan = sel.anchorNode; scan; scan = parentNode(scan)) if (ancestors.has(scan)) {
container = scan
break
}
let desc = container && this.view.docView.nearestDesc(container)
if (desc && desc.ignoreMutation({
type: "selection",
target: container!.nodeType == 3 ? container!.parentNode : container
} as any)) {
this.setCurSelection()
return true
}
}
pendingRecords() {
if (this.observer) for (let mut of this.observer.takeRecords()) this.queue.push(mut)
return this.queue
}
flush() {
let {view} = this
if (!view.docView || this.flushingSoon > -1) return
let mutations = this.pendingRecords()
if (mutations.length) this.queue = []
let sel = view.domSelectionRange()
let newSel = !this.suppressingSelectionUpdates && !this.currentSelection.eq(sel) && hasFocusAndSelection(view) && !this.ignoreSelectionChange(sel)
let from = -1, to = -1, typeOver = false, added: Node[] = []
if (view.editable) {
for (let i = 0; i < mutations.length; i++) {
let result = this.registerMutation(mutations[i], added)
if (result) {
from = from < 0 ? result.from : Math.min(result.from, from)
to = to < 0 ? result.to : Math.max(result.to, to)
if (result.typeOver) typeOver = true
}
}
}
if (browser.gecko && added.length > 1) {
let brs = added.filter(n => n.nodeName == "BR")
if (brs.length == 2) {
let a = brs[0] as HTMLElement, b = brs[1] as HTMLElement
if (a.parentNode && a.parentNode.parentNode == b.parentNode) b.remove()
else a.remove()
}
}
let readSel: Selection | null = null
// If it looks like the browser has reset the selection to the
// start of the document after focus, restore the selection from
// the state
if (from < 0 && newSel && view.input.lastFocus > Date.now() - 200 &&
Math.max(view.input.lastTouch, view.input.lastClick.time) < Date.now() - 300 &&
selectionCollapsed(sel) && (readSel = selectionFromDOM(view)) &&
readSel.eq(Selection.near(view.state.doc.resolve(0), 1))) {
view.input.lastFocus = 0
selectionToDOM(view)
this.currentSelection.set(sel)
view.scrollToSelection()
} else if (from > -1 || newSel) {
if (from > -1) {
view.docView.markDirty(from, to)
checkCSS(view)
}
this.handleDOMChange(from, to, typeOver, added)
if (view.docView && view.docView.dirty) view.updateState(view.state)
else if (!this.currentSelection.eq(sel)) selectionToDOM(view)
this.currentSelection.set(sel)
}
}
registerMutation(mut: MutationRecord, added: Node[]) {
// Ignore mutations inside nodes that were already noted as inserted
if (added.indexOf(mut.target) > -1) return null
let desc = this.view.docView.nearestDesc(mut.target)
if (mut.type == "attributes" &&
(desc == this.view.docView || mut.attributeName == "contenteditable" ||
// Firefox sometimes fires spurious events for null/empty styles
(mut.attributeName == "style" && !mut.oldValue && !(mut.target as HTMLElement).getAttribute("style"))))
return null
if (!desc || desc.ignoreMutation(mut)) return null
if (mut.type == "childList") {
for (let i = 0; i < mut.addedNodes.length; i++) added.push(mut.addedNodes[i])
if (desc.contentDOM && desc.contentDOM != desc.dom && !desc.contentDOM.contains(mut.target))
return {from: desc.posBefore, to: desc.posAfter}
let prev = mut.previousSibling, next = mut.nextSibling
if (browser.ie && browser.ie_version <= 11 && mut.addedNodes.length) {
// IE11 gives us incorrect next/prev siblings for some
// insertions, so if there are added nodes, recompute those
for (let i = 0; i < mut.addedNodes.length; i++) {
let {previousSibling, nextSibling} = mut.addedNodes[i]
if (!previousSibling || Array.prototype.indexOf.call(mut.addedNodes, previousSibling) < 0) prev = previousSibling
if (!nextSibling || Array.prototype.indexOf.call(mut.addedNodes, nextSibling) < 0) next = nextSibling
}
}
let fromOffset = prev && prev.parentNode == mut.target
? domIndex(prev) + 1 : 0
let from = desc.localPosFromDOM(mut.target, fromOffset, -1)
let toOffset = next && next.parentNode == mut.target
? domIndex(next) : mut.target.childNodes.length
let to = desc.localPosFromDOM(mut.target, toOffset, 1)
return {from, to}
} else if (mut.type == "attributes") {
return {from: desc.posAtStart - desc.border, to: desc.posAtEnd + desc.border}
} else { // "characterData"
return {
from: desc.posAtStart,
to: desc.posAtEnd,
// An event was generated for a text change that didn't change
// any text. Mark the dom change to fall back to assuming the
// selection was typed over with an identical value if it can't
// find another change.
typeOver: mut.target.nodeValue == mut.oldValue
}
}
}
}
let cssChecked: WeakMap<EditorView, null> = new WeakMap()
let cssCheckWarned: boolean = false
function checkCSS(view: EditorView) {
if (cssChecked.has(view)) return
cssChecked.set(view, null)
if (['normal', 'nowrap', 'pre-line'].indexOf(getComputedStyle(view.dom).whiteSpace) !== -1) {
view.requiresGeckoHackNode = browser.gecko
if (cssCheckWarned) return
console["warn"]("ProseMirror expects the CSS white-space property to be set, preferably to 'pre-wrap'. It is recommended to load style/prosemirror.css from the prosemirror-view package.")
cssCheckWarned = true
}
}
function rangeToSelectionRange(view: EditorView, range: StaticRange) {
let anchorNode = range.startContainer, anchorOffset = range.startOffset
let focusNode = range.endContainer, focusOffset = range.endOffset
let currentAnchor = view.domAtPos(view.state.selection.anchor)
// Since such a range doesn't distinguish between anchor and head,
// use a heuristic that flips it around if its end matches the
// current anchor.
if (isEquivalentPosition(currentAnchor.node, currentAnchor.offset, focusNode, focusOffset))
[anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset]
return {anchorNode, anchorOffset, focusNode, focusOffset}
}
// Used to work around a Safari Selection/shadow DOM bug
// Based on https://github.com/codemirror/dev/issues/414 fix
export function safariShadowSelectionRange(view: EditorView, selection: DOMSelection): DOMSelectionRange | null {
if ((selection as any).getComposedRanges) {
let range = (selection as any).getComposedRanges(view.root)[0] as StaticRange
if (range) return rangeToSelectionRange(view, range)
}
let found: StaticRange | undefined
function read(event: InputEvent) {
event.preventDefault()
event.stopImmediatePropagation()
found = event.getTargetRanges()[0]
}
// Because Safari (at least in 2018-2022) doesn't provide regular
// access to the selection inside a shadowRoot, we have to perform a
// ridiculous hack to get at it—using `execCommand` to trigger a
// `beforeInput` event so that we can read the target range from the
// event.
view.dom.addEventListener("beforeinput", read, true)
document.execCommand("indent")
view.dom.removeEventListener("beforeinput", read, true)
return found ? rangeToSelectionRange(view, found) : null
}

View File

@@ -0,0 +1,805 @@
import {NodeSelection, EditorState, Plugin, PluginView, Transaction, Selection} from "prosemirror-state"
import {Slice, ResolvedPos, DOMParser, DOMSerializer, Node, Mark} from "prosemirror-model"
import {scrollRectIntoView, posAtCoords, coordsAtPos, endOfTextblock, storeScrollPos,
resetScrollPos, focusPreventScroll} from "./domcoords"
import {docViewDesc, ViewDesc, NodeView, NodeViewDesc} from "./viewdesc"
import {initInput, destroyInput, dispatchEvent, ensureListeners, clearComposition,
InputState, doPaste, Dragging, findCompositionNode} from "./input"
import {selectionToDOM, anchorInRightPlace, syncNodeSelection} from "./selection"
import {Decoration, viewDecorations, DecorationSource} from "./decoration"
import {DOMObserver, safariShadowSelectionRange} from "./domobserver"
import {readDOMChange} from "./domchange"
import {DOMSelection, DOMNode, DOMSelectionRange, deepActiveElement, clearReusedRange} from "./dom"
import * as browser from "./browser"
export {Decoration, DecorationSet, DecorationAttrs, DecorationSource} from "./decoration"
export {NodeView} from "./viewdesc"
// Exported for testing
import {serializeForClipboard, parseFromClipboard} from "./clipboard"
import {endComposition} from "./input"
/// @internal
export const __serializeForClipboard = serializeForClipboard
/// @internal
export const __parseFromClipboard = parseFromClipboard
/// @internal
export const __endComposition = endComposition
/// An editor view manages the DOM structure that represents an
/// editable document. Its state and behavior are determined by its
/// [props](#view.DirectEditorProps).
export class EditorView {
private _props: DirectEditorProps
private directPlugins: readonly Plugin[]
private _root: Document | ShadowRoot | null = null
/// @internal
focused = false
/// Kludge used to work around a Chrome bug @internal
trackWrites: DOMNode | null = null
private mounted = false
/// @internal
markCursor: readonly Mark[] | null = null
/// @internal
cursorWrapper: {dom: DOMNode, deco: Decoration} | null = null
/// @internal
nodeViews: NodeViewSet
/// @internal
lastSelectedViewDesc: ViewDesc | undefined = undefined
/// @internal
docView: NodeViewDesc
/// @internal
input = new InputState
private prevDirectPlugins: readonly Plugin[] = []
private pluginViews: PluginView[] = []
/// @internal
domObserver!: DOMObserver
/// Holds `true` when a hack node is needed in Firefox to prevent the
/// [space is eaten issue](https://github.com/ProseMirror/prosemirror/issues/651)
/// @internal
requiresGeckoHackNode: boolean = false
/// The view's current [state](#state.EditorState).
public state: EditorState
/// Create a view. `place` may be a DOM node that the editor should
/// be appended to, a function that will place it into the document,
/// or an object whose `mount` property holds the node to use as the
/// document container. If it is `null`, the editor will not be
/// added to the document.
constructor(place: null | DOMNode | ((editor: HTMLElement) => void) | {mount: HTMLElement}, props: DirectEditorProps) {
this._props = props
this.state = props.state
this.directPlugins = props.plugins || []
this.directPlugins.forEach(checkStateComponent)
this.dispatch = this.dispatch.bind(this)
this.dom = (place && (place as {mount: HTMLElement}).mount) || document.createElement("div")
if (place) {
if ((place as DOMNode).appendChild) (place as DOMNode).appendChild(this.dom)
else if (typeof place == "function") place(this.dom)
else if ((place as {mount: HTMLElement}).mount) this.mounted = true
}
this.editable = getEditable(this)
updateCursorWrapper(this)
this.nodeViews = buildNodeViews(this)
this.docView = docViewDesc(this.state.doc, computeDocDeco(this), viewDecorations(this), this.dom, this)
this.domObserver = new DOMObserver(this, (from, to, typeOver, added) => readDOMChange(this, from, to, typeOver, added))
this.domObserver.start()
initInput(this)
this.updatePluginViews()
}
/// An editable DOM node containing the document. (You probably
/// should not directly interfere with its content.)
readonly dom: HTMLElement
/// Indicates whether the editor is currently [editable](#view.EditorProps.editable).
editable: boolean
/// When editor content is being dragged, this object contains
/// information about the dragged slice and whether it is being
/// copied or moved. At any other time, it is null.
dragging: null | {slice: Slice, move: boolean} = null
/// Holds `true` when a
/// [composition](https://w3c.github.io/uievents/#events-compositionevents)
/// is active.
get composing() { return this.input.composing }
/// The view's current [props](#view.EditorProps).
get props() {
if (this._props.state != this.state) {
let prev = this._props
this._props = {} as any
for (let name in prev) (this._props as any)[name] = (prev as any)[name]
this._props.state = this.state
}
return this._props
}
/// Update the view's props. Will immediately cause an update to
/// the DOM.
update(props: DirectEditorProps) {
if (props.handleDOMEvents != this._props.handleDOMEvents) ensureListeners(this)
let prevProps = this._props
this._props = props
if (props.plugins) {
props.plugins.forEach(checkStateComponent)
this.directPlugins = props.plugins
}
this.updateStateInner(props.state, prevProps)
}
/// Update the view by updating existing props object with the object
/// given as argument. Equivalent to `view.update(Object.assign({},
/// view.props, props))`.
setProps(props: Partial<DirectEditorProps>) {
let updated = {} as DirectEditorProps
for (let name in this._props) (updated as any)[name] = (this._props as any)[name]
updated.state = this.state
for (let name in props) (updated as any)[name] = (props as any)[name]
this.update(updated)
}
/// Update the editor's `state` prop, without touching any of the
/// other props.
updateState(state: EditorState) {
this.updateStateInner(state, this._props)
}
private updateStateInner(state: EditorState, prevProps: DirectEditorProps) {
let prev = this.state, redraw = false, updateSel = false
// When stored marks are added, stop composition, so that they can
// be displayed.
if (state.storedMarks && this.composing) {
clearComposition(this)
updateSel = true
}
this.state = state
let pluginsChanged = prev.plugins != state.plugins || this._props.plugins != prevProps.plugins
if (pluginsChanged || this._props.plugins != prevProps.plugins || this._props.nodeViews != prevProps.nodeViews) {
let nodeViews = buildNodeViews(this)
if (changedNodeViews(nodeViews, this.nodeViews)) {
this.nodeViews = nodeViews
redraw = true
}
}
if (pluginsChanged || prevProps.handleDOMEvents != this._props.handleDOMEvents) {
ensureListeners(this)
}
this.editable = getEditable(this)
updateCursorWrapper(this)
let innerDeco = viewDecorations(this), outerDeco = computeDocDeco(this)
let scroll = prev.plugins != state.plugins && !prev.doc.eq(state.doc) ? "reset"
: (state as any).scrollToSelection > (prev as any).scrollToSelection ? "to selection" : "preserve"
let updateDoc = redraw || !this.docView.matchesNode(state.doc, outerDeco, innerDeco)
if (updateDoc || !state.selection.eq(prev.selection)) updateSel = true
let oldScrollPos = scroll == "preserve" && updateSel && this.dom.style.overflowAnchor == null && storeScrollPos(this)
if (updateSel) {
this.domObserver.stop()
// Work around an issue in Chrome, IE, and Edge where changing
// the DOM around an active selection puts it into a broken
// state where the thing the user sees differs from the
// selection reported by the Selection object (#710, #973,
// #1011, #1013, #1035).
let forceSelUpdate = updateDoc && (browser.ie || browser.chrome) && !this.composing &&
!prev.selection.empty && !state.selection.empty && selectionContextChanged(prev.selection, state.selection)
if (updateDoc) {
// If the node that the selection points into is written to,
// Chrome sometimes starts misreporting the selection, so this
// tracks that and forces a selection reset when our update
// did write to the node.
let chromeKludge = browser.chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null
if (this.composing) this.input.compositionNode = findCompositionNode(this)
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
this.docView.updateOuterDeco(outerDeco)
this.docView.destroy()
this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this)
}
if (chromeKludge && !this.trackWrites) forceSelUpdate = true
}
// Work around for an issue where an update arriving right between
// a DOM selection change and the "selectionchange" event for it
// can cause a spurious DOM selection update, disrupting mouse
// drag selection.
if (forceSelUpdate ||
!(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) &&
anchorInRightPlace(this))) {
selectionToDOM(this, forceSelUpdate)
} else {
syncNodeSelection(this, state.selection)
this.domObserver.setCurSelection()
}
this.domObserver.start()
}
this.updatePluginViews(prev)
if ((this.dragging as Dragging)?.node && !prev.doc.eq(state.doc))
this.updateDraggedNode(this.dragging as Dragging, prev)
if (scroll == "reset") {
this.dom.scrollTop = 0
} else if (scroll == "to selection") {
this.scrollToSelection()
} else if (oldScrollPos) {
resetScrollPos(oldScrollPos)
}
}
/// @internal
scrollToSelection() {
let startDOM = this.domSelectionRange().focusNode!
if (this.someProp("handleScrollToSelection", f => f(this))) {
// Handled
} else if (this.state.selection instanceof NodeSelection) {
let target = this.docView.domAfterPos(this.state.selection.from)
if (target.nodeType == 1) scrollRectIntoView(this, (target as HTMLElement).getBoundingClientRect(), startDOM)
} else {
scrollRectIntoView(this, this.coordsAtPos(this.state.selection.head, 1), startDOM)
}
}
private destroyPluginViews() {
let view
while (view = this.pluginViews.pop()) if (view.destroy) view.destroy()
}
private updatePluginViews(prevState?: EditorState) {
if (!prevState || prevState.plugins != this.state.plugins || this.directPlugins != this.prevDirectPlugins) {
this.prevDirectPlugins = this.directPlugins
this.destroyPluginViews()
for (let i = 0; i < this.directPlugins.length; i++) {
let plugin = this.directPlugins[i]
if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this))
}
for (let i = 0; i < this.state.plugins.length; i++) {
let plugin = this.state.plugins[i]
if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this))
}
} else {
for (let i = 0; i < this.pluginViews.length; i++) {
let pluginView = this.pluginViews[i]
if (pluginView.update) pluginView.update(this, prevState)
}
}
}
private updateDraggedNode(dragging: Dragging, prev: EditorState) {
let sel = dragging.node!, found = -1
if (this.state.doc.nodeAt(sel.from) == sel.node) {
found = sel.from
} else {
let movedPos = sel.from + (this.state.doc.content.size - prev.doc.content.size)
let moved = movedPos > 0 && this.state.doc.nodeAt(movedPos)
if (moved == sel.node) found = movedPos
}
this.dragging = new Dragging(dragging.slice, dragging.move,
found < 0 ? undefined : NodeSelection.create(this.state.doc, found))
}
/// Goes over the values of a prop, first those provided directly,
/// then those from plugins given to the view, then from plugins in
/// the state (in order), and calls `f` every time a non-undefined
/// value is found. When `f` returns a truthy value, that is
/// immediately returned. When `f` isn't provided, it is treated as
/// the identity function (the prop value is returned directly).
someProp<PropName extends keyof EditorProps, Result>(
propName: PropName,
f: (value: NonNullable<EditorProps[PropName]>) => Result
): Result | undefined
someProp<PropName extends keyof EditorProps>(propName: PropName): NonNullable<EditorProps[PropName]> | undefined
someProp<PropName extends keyof EditorProps, Result>(
propName: PropName,
f?: (value: NonNullable<EditorProps[PropName]>) => Result
): Result | undefined {
let prop = this._props && this._props[propName], value
if (prop != null && (value = f ? f(prop as any) : prop)) return value as any
for (let i = 0; i < this.directPlugins.length; i++) {
let prop = this.directPlugins[i].props[propName]
if (prop != null && (value = f ? f(prop as any) : prop)) return value as any
}
let plugins = this.state.plugins
if (plugins) for (let i = 0; i < plugins.length; i++) {
let prop = plugins[i].props[propName]
if (prop != null && (value = f ? f(prop as any) : prop)) return value as any
}
}
/// Query whether the view has focus.
hasFocus() {
// Work around IE not handling focus correctly if resize handles are shown.
// If the cursor is inside an element with resize handles, activeElement
// will be that element instead of this.dom.
if (browser.ie) {
// If activeElement is within this.dom, and there are no other elements
// setting `contenteditable` to false in between, treat it as focused.
let node = this.root.activeElement
if (node == this.dom) return true
if (!node || !this.dom.contains(node)) return false
while (node && this.dom != node && this.dom.contains(node)) {
if ((node as HTMLElement).contentEditable == 'false') return false
node = node.parentElement
}
return true
}
return this.root.activeElement == this.dom
}
/// Focus the editor.
focus() {
this.domObserver.stop()
if (this.editable) focusPreventScroll(this.dom)
selectionToDOM(this)
this.domObserver.start()
}
/// Get the document root in which the editor exists. This will
/// usually be the top-level `document`, but might be a [shadow
/// DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM)
/// root if the editor is inside one.
get root(): Document | ShadowRoot {
let cached = this._root
if (cached == null) for (let search = this.dom.parentNode; search; search = search.parentNode) {
if (search.nodeType == 9 || (search.nodeType == 11 && (search as any).host)) {
if (!(search as any).getSelection)
Object.getPrototypeOf(search).getSelection = () => (search as DOMNode).ownerDocument!.getSelection()
return this._root = search as Document | ShadowRoot
}
}
return cached || document
}
/// When an existing editor view is moved to a new document or
/// shadow tree, call this to make it recompute its root.
updateRoot() {
this._root = null
}
/// Given a pair of viewport coordinates, return the document
/// position that corresponds to them. May return null if the given
/// coordinates aren't inside of the editor. When an object is
/// returned, its `pos` property is the position nearest to the
/// coordinates, and its `inside` property holds the position of the
/// inner node that the position falls inside of, or -1 if it is at
/// the top level, not in any node.
posAtCoords(coords: {left: number, top: number}): {pos: number, inside: number} | null {
return posAtCoords(this, coords)
}
/// Returns the viewport rectangle at a given document position.
/// `left` and `right` will be the same number, as this returns a
/// flat cursor-ish rectangle. If the position is between two things
/// that aren't directly adjacent, `side` determines which element
/// is used. When < 0, the element before the position is used,
/// otherwise the element after.
coordsAtPos(pos: number, side = 1): {left: number, right: number, top: number, bottom: number} {
return coordsAtPos(this, pos, side)
}
/// Find the DOM position that corresponds to the given document
/// position. When `side` is negative, find the position as close as
/// possible to the content before the position. When positive,
/// prefer positions close to the content after the position. When
/// zero, prefer as shallow a position as possible.
///
/// Note that you should **not** mutate the editor's internal DOM,
/// only inspect it (and even that is usually not necessary).
domAtPos(pos: number, side = 0): {node: DOMNode, offset: number} {
return this.docView.domFromPos(pos, side)
}
/// Find the DOM node that represents the document node after the
/// given position. May return `null` when the position doesn't point
/// in front of a node or if the node is inside an opaque node view.
///
/// This is intended to be able to call things like
/// `getBoundingClientRect` on that DOM node. Do **not** mutate the
/// editor DOM directly, or add styling this way, since that will be
/// immediately overriden by the editor as it redraws the node.
nodeDOM(pos: number): DOMNode | null {
let desc = this.docView.descAt(pos)
return desc ? (desc as NodeViewDesc).nodeDOM : null
}
/// Find the document position that corresponds to a given DOM
/// position. (Whenever possible, it is preferable to inspect the
/// document structure directly, rather than poking around in the
/// DOM, but sometimes—for example when interpreting an event
/// target—you don't have a choice.)
///
/// The `bias` parameter can be used to influence which side of a DOM
/// node to use when the position is inside a leaf node.
posAtDOM(node: DOMNode, offset: number, bias = -1): number {
let pos = this.docView.posFromDOM(node, offset, bias)
if (pos == null) throw new RangeError("DOM position not inside the editor")
return pos
}
/// Find out whether the selection is at the end of a textblock when
/// moving in a given direction. When, for example, given `"left"`,
/// it will return true if moving left from the current cursor
/// position would leave that position's parent textblock. Will apply
/// to the view's current state by default, but it is possible to
/// pass a different state.
endOfTextblock(dir: "up" | "down" | "left" | "right" | "forward" | "backward", state?: EditorState): boolean {
return endOfTextblock(this, state || this.state, dir)
}
/// Run the editor's paste logic with the given HTML string. The
/// `event`, if given, will be passed to the
/// [`handlePaste`](#view.EditorProps.handlePaste) hook.
pasteHTML(html: string, event?: ClipboardEvent) {
return doPaste(this, "", html, false, event || new ClipboardEvent("paste"))
}
/// Run the editor's paste logic with the given plain-text input.
pasteText(text: string, event?: ClipboardEvent) {
return doPaste(this, text, null, true, event || new ClipboardEvent("paste"))
}
/// Removes the editor from the DOM and destroys all [node
/// views](#view.NodeView).
destroy() {
if (!this.docView) return
destroyInput(this)
this.destroyPluginViews()
if (this.mounted) {
this.docView.update(this.state.doc, [], viewDecorations(this), this)
this.dom.textContent = ""
} else if (this.dom.parentNode) {
this.dom.parentNode.removeChild(this.dom)
}
this.docView.destroy()
;(this as any).docView = null
clearReusedRange();
}
/// This is true when the view has been
/// [destroyed](#view.EditorView.destroy) (and thus should not be
/// used anymore).
get isDestroyed() {
return this.docView == null
}
/// Used for testing.
dispatchEvent(event: Event) {
return dispatchEvent(this, event)
}
/// Dispatch a transaction. Will call
/// [`dispatchTransaction`](#view.DirectEditorProps.dispatchTransaction)
/// when given, and otherwise defaults to applying the transaction to
/// the current state and calling
/// [`updateState`](#view.EditorView.updateState) with the result.
/// This method is bound to the view instance, so that it can be
/// easily passed around.
dispatch(tr: Transaction) {
let dispatchTransaction = this._props.dispatchTransaction
if (dispatchTransaction) dispatchTransaction.call(this, tr)
else this.updateState(this.state.apply(tr))
}
/// @internal
domSelectionRange(): DOMSelectionRange {
let sel = this.domSelection()
return browser.safari && this.root.nodeType === 11 &&
deepActiveElement(this.dom.ownerDocument) == this.dom && safariShadowSelectionRange(this, sel) || sel
}
/// @internal
domSelection(): DOMSelection {
return (this.root as Document).getSelection()!
}
}
function computeDocDeco(view: EditorView) {
let attrs = Object.create(null)
attrs.class = "ProseMirror"
attrs.contenteditable = String(view.editable)
view.someProp("attributes", value => {
if (typeof value == "function") value = value(view.state)
if (value) for (let attr in value) {
if (attr == "class")
attrs.class += " " + value[attr]
else if (attr == "style")
attrs.style = (attrs.style ? attrs.style + ";" : "") + value[attr]
else if (!attrs[attr] && attr != "contenteditable" && attr != "nodeName")
attrs[attr] = String(value[attr])
}
})
if (!attrs.translate) attrs.translate = "no"
return [Decoration.node(0, view.state.doc.content.size, attrs)]
}
function updateCursorWrapper(view: EditorView) {
if (view.markCursor) {
let dom = document.createElement("img")
dom.className = "ProseMirror-separator"
dom.setAttribute("mark-placeholder", "true")
dom.setAttribute("alt", "")
view.cursorWrapper = {dom, deco: Decoration.widget(view.state.selection.head,
dom, {raw: true, marks: view.markCursor} as any)}
} else {
view.cursorWrapper = null
}
}
function getEditable(view: EditorView) {
return !view.someProp("editable", value => value(view.state) === false)
}
function selectionContextChanged(sel1: Selection, sel2: Selection) {
let depth = Math.min(sel1.$anchor.sharedDepth(sel1.head), sel2.$anchor.sharedDepth(sel2.head))
return sel1.$anchor.start(depth) != sel2.$anchor.start(depth)
}
function buildNodeViews(view: EditorView) {
let result: NodeViewSet = Object.create(null)
function add(obj: NodeViewSet) {
for (let prop in obj) if (!Object.prototype.hasOwnProperty.call(result, prop))
result[prop] = obj[prop]
}
view.someProp("nodeViews", add)
view.someProp("markViews", add)
return result
}
function changedNodeViews(a: NodeViewSet, b: NodeViewSet) {
let nA = 0, nB = 0
for (let prop in a) {
if (a[prop] != b[prop]) return true
nA++
}
for (let _ in b) nB++
return nA != nB
}
function checkStateComponent(plugin: Plugin) {
if (plugin.spec.state || plugin.spec.filterTransaction || plugin.spec.appendTransaction)
throw new RangeError("Plugins passed directly to the view must not have a state component")
}
/// The type of function [provided](#view.EditorProps.nodeViews) to
/// create [node views](#view.NodeView).
export type NodeViewConstructor = (node: Node, view: EditorView, getPos: () => number | undefined,
decorations: readonly Decoration[], innerDecorations: DecorationSource) => NodeView
/// The function types [used](#view.EditorProps.markViews) to create
/// mark views.
export type MarkViewConstructor = (mark: Mark, view: EditorView, inline: boolean) => {dom: HTMLElement, contentDOM?: HTMLElement}
type NodeViewSet = {[name: string]: NodeViewConstructor | MarkViewConstructor}
/// Helper type that maps event names to event object types, but
/// includes events that TypeScript's HTMLElementEventMap doesn't know
/// about.
export interface DOMEventMap extends HTMLElementEventMap {
[event: string]: any
}
/// Props are configuration values that can be passed to an editor view
/// or included in a plugin. This interface lists the supported props.
///
/// The various event-handling functions may all return `true` to
/// indicate that they handled the given event. The view will then take
/// care to call `preventDefault` on the event, except with
/// `handleDOMEvents`, where the handler itself is responsible for that.
///
/// How a prop is resolved depends on the prop. Handler functions are
/// called one at a time, starting with the base props and then
/// searching through the plugins (in order of appearance) until one of
/// them returns true. For some props, the first plugin that yields a
/// value gets precedence.
///
/// The optional type parameter refers to the type of `this` in prop
/// functions, and is used to pass in the plugin type when defining a
/// [plugin](#state.Plugin).
export interface EditorProps<P = any> {
/// Can be an object mapping DOM event type names to functions that
/// handle them. Such functions will be called before any handling
/// ProseMirror does of events fired on the editable DOM element.
/// Contrary to the other event handling props, when returning true
/// from such a function, you are responsible for calling
/// `preventDefault` yourself (or not, if you want to allow the
/// default behavior).
handleDOMEvents?: {
[event in keyof DOMEventMap]?: (this: P, view: EditorView, event: DOMEventMap[event]) => boolean | void
}
/// Called when the editor receives a `keydown` event.
handleKeyDown?: (this: P, view: EditorView, event: KeyboardEvent) => boolean | void
/// Handler for `keypress` events.
handleKeyPress?: (this: P, view: EditorView, event: KeyboardEvent) => boolean | void
/// Whenever the user directly input text, this handler is called
/// before the input is applied. If it returns `true`, the default
/// behavior of actually inserting the text is suppressed.
handleTextInput?: (this: P, view: EditorView, from: number, to: number, text: string) => boolean | void
/// Called for each node around a click, from the inside out. The
/// `direct` flag will be true for the inner node.
handleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void
/// Called when the editor is clicked, after `handleClickOn` handlers
/// have been called.
handleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void
/// Called for each node around a double click.
handleDoubleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void
/// Called when the editor is double-clicked, after `handleDoubleClickOn`.
handleDoubleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void
/// Called for each node around a triple click.
handleTripleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void
/// Called when the editor is triple-clicked, after `handleTripleClickOn`.
handleTripleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void
/// Can be used to override the behavior of pasting. `slice` is the
/// pasted content parsed by the editor, but you can directly access
/// the event to get at the raw content.
handlePaste?: (this: P, view: EditorView, event: ClipboardEvent, slice: Slice) => boolean | void
/// Called when something is dropped on the editor. `moved` will be
/// true if this drop moves from the current selection (which should
/// thus be deleted).
handleDrop?: (this: P, view: EditorView, event: DragEvent, slice: Slice, moved: boolean) => boolean | void
/// Called when the view, after updating its state, tries to scroll
/// the selection into view. A handler function may return false to
/// indicate that it did not handle the scrolling and further
/// handlers or the default behavior should be tried.
handleScrollToSelection?: (this: P, view: EditorView) => boolean
/// Can be used to override the way a selection is created when
/// reading a DOM selection between the given anchor and head.
createSelectionBetween?: (this: P, view: EditorView, anchor: ResolvedPos, head: ResolvedPos) => Selection | null
/// The [parser](#model.DOMParser) to use when reading editor changes
/// from the DOM. Defaults to calling
/// [`DOMParser.fromSchema`](#model.DOMParser^fromSchema) on the
/// editor's schema.
domParser?: DOMParser
/// Can be used to transform pasted HTML text, _before_ it is parsed,
/// for example to clean it up.
transformPastedHTML?: (this: P, html: string, view: EditorView) => string
/// The [parser](#model.DOMParser) to use when reading content from
/// the clipboard. When not given, the value of the
/// [`domParser`](#view.EditorProps.domParser) prop is used.
clipboardParser?: DOMParser
/// Transform pasted plain text. The `plain` flag will be true when
/// the text is pasted as plain text.
transformPastedText?: (this: P, text: string, plain: boolean, view: EditorView) => string
/// A function to parse text from the clipboard into a document
/// slice. Called after
/// [`transformPastedText`](#view.EditorProps.transformPastedText).
/// The default behavior is to split the text into lines, wrap them
/// in `<p>` tags, and call
/// [`clipboardParser`](#view.EditorProps.clipboardParser) on it.
/// The `plain` flag will be true when the text is pasted as plain text.
clipboardTextParser?: (this: P, text: string, $context: ResolvedPos, plain: boolean, view: EditorView) => Slice
/// Can be used to transform pasted or dragged-and-dropped content
/// before it is applied to the document.
transformPasted?: (this: P, slice: Slice, view: EditorView) => Slice
/// Can be used to transform copied or cut content before it is
/// serialized to the clipboard.
transformCopied?: (this: P, slice: Slice, view: EditorView) => Slice
/// Allows you to pass custom rendering and behavior logic for
/// nodes. Should map node names to constructor functions that
/// produce a [`NodeView`](#view.NodeView) object implementing the
/// node's display behavior. The third argument `getPos` is a
/// function that can be called to get the node's current position,
/// which can be useful when creating transactions to update it.
/// Note that if the node is not in the document, the position
/// returned by this function will be `undefined`.
///
/// `decorations` is an array of node or inline decorations that are
/// active around the node. They are automatically drawn in the
/// normal way, and you will usually just want to ignore this, but
/// they can also be used as a way to provide context information to
/// the node view without adding it to the document itself.
///
/// `innerDecorations` holds the decorations for the node's content.
/// You can safely ignore this if your view has no content or a
/// `contentDOM` property, since the editor will draw the decorations
/// on the content. But if you, for example, want to create a nested
/// editor with the content, it may make sense to provide it with the
/// inner decorations.
///
/// (For backwards compatibility reasons, [mark
/// views](#view.EditorProps.markViews) can also be included in this
/// object.)
nodeViews?: {[node: string]: NodeViewConstructor}
/// Pass custom mark rendering functions. Note that these cannot
/// provide the kind of dynamic behavior that [node
/// views](#view.NodeView) can—they just provide custom rendering
/// logic. The third argument indicates whether the mark's content
/// is inline.
markViews?: {[mark: string]: MarkViewConstructor}
/// The DOM serializer to use when putting content onto the
/// clipboard. If not given, the result of
/// [`DOMSerializer.fromSchema`](#model.DOMSerializer^fromSchema)
/// will be used. This object will only have its
/// [`serializeFragment`](#model.DOMSerializer.serializeFragment)
/// method called, and you may provide an alternative object type
/// implementing a compatible method.
clipboardSerializer?: DOMSerializer
/// A function that will be called to get the text for the current
/// selection when copying text to the clipboard. By default, the
/// editor will use [`textBetween`](#model.Node.textBetween) on the
/// selected range.
clipboardTextSerializer?: (this: P, content: Slice, view: EditorView) => string
/// A set of [document decorations](#view.Decoration) to show in the
/// view.
decorations?: (this: P, state: EditorState) => DecorationSource | null | undefined
/// When this returns false, the content of the view is not directly
/// editable.
editable?: (this: P, state: EditorState) => boolean
/// Control the DOM attributes of the editable element. May be either
/// an object or a function going from an editor state to an object.
/// By default, the element will get a class `"ProseMirror"`, and
/// will have its `contentEditable` attribute determined by the
/// [`editable` prop](#view.EditorProps.editable). Additional classes
/// provided here will be added to the class. For other attributes,
/// the value provided first (as in
/// [`someProp`](#view.EditorView.someProp)) will be used.
attributes?: {[name: string]: string} | ((state: EditorState) => {[name: string]: string})
/// Determines the distance (in pixels) between the cursor and the
/// end of the visible viewport at which point, when scrolling the
/// cursor into view, scrolling takes place. Defaults to 0.
scrollThreshold?: number | {top: number, right: number, bottom: number, left: number}
/// Determines the extra space (in pixels) that is left above or
/// below the cursor when it is scrolled into view. Defaults to 5.
scrollMargin?: number | {top: number, right: number, bottom: number, left: number}
}
/// The props object given directly to the editor view supports some
/// fields that can't be used in plugins:
export interface DirectEditorProps extends EditorProps {
/// The current state of the editor.
state: EditorState
/// A set of plugins to use in the view, applying their [plugin
/// view](#state.PluginSpec.view) and
/// [props](#state.PluginSpec.props). Passing plugins with a state
/// component (a [state field](#state.PluginSpec.state) field or a
/// [transaction](#state.PluginSpec.filterTransaction) filter or
/// appender) will result in an error, since such plugins must be
/// present in the state to work.
plugins?: readonly Plugin[]
/// The callback over which to send transactions (state updates)
/// produced by the view. If you specify this, you probably want to
/// make sure this ends up calling the view's
/// [`updateState`](#view.EditorView.updateState) method with a new
/// state that has the transaction
/// [applied](#state.EditorState.apply). The callback will be bound to have
/// the view instance as its `this` binding.
dispatchTransaction?: (tr: Transaction) => void
}

View File

@@ -0,0 +1,803 @@
import {Selection, NodeSelection, TextSelection} from "prosemirror-state"
import {dropPoint} from "prosemirror-transform"
import {Slice, Node} from "prosemirror-model"
import * as browser from "./browser"
import {captureKeyDown} from "./capturekeys"
import {parseFromClipboard, serializeForClipboard} from "./clipboard"
import {selectionBetween, selectionToDOM, selectionFromDOM} from "./selection"
import {keyEvent, DOMNode, textNodeBefore, textNodeAfter} from "./dom"
import {EditorView} from "./index"
import {ViewDesc} from "./viewdesc"
// A collection of DOM events that occur within the editor, and callback functions
// to invoke when the event fires.
const handlers: {[event: string]: (view: EditorView, event: Event) => void} = {}
const editHandlers: {[event: string]: (view: EditorView, event: Event) => void} = {}
const passiveHandlers: Record<string, boolean> = {touchstart: true, touchmove: true}
export class InputState {
shiftKey = false
mouseDown: MouseDown | null = null
lastKeyCode: number | null = null
lastKeyCodeTime = 0
lastClick = {time: 0, x: 0, y: 0, type: ""}
lastSelectionOrigin: string | null = null
lastSelectionTime = 0
lastIOSEnter = 0
lastIOSEnterFallbackTimeout = -1
lastFocus = 0
lastTouch = 0
lastAndroidDelete = 0
composing = false
compositionNode: Text | null = null
composingTimeout = -1
compositionNodes: ViewDesc[] = []
compositionEndedAt = -2e8
compositionID = 1
// Set to a composition ID when there are pending changes at compositionend
compositionPendingChanges = 0
domChangeCount = 0
eventHandlers: {[event: string]: (event: Event) => void} = Object.create(null)
hideSelectionGuard: (() => void) | null = null
}
export function initInput(view: EditorView) {
for (let event in handlers) {
let handler = handlers[event]
view.dom.addEventListener(event, view.input.eventHandlers[event] = (event: Event) => {
if (eventBelongsToView(view, event) && !runCustomHandler(view, event) &&
(view.editable || !(event.type in editHandlers)))
handler(view, event)
}, passiveHandlers[event] ? {passive: true} : undefined)
}
// On Safari, for reasons beyond my understanding, adding an input
// event handler makes an issue where the composition vanishes when
// you press enter go away.
if (browser.safari) view.dom.addEventListener("input", () => null)
ensureListeners(view)
}
function setSelectionOrigin(view: EditorView, origin: string) {
view.input.lastSelectionOrigin = origin
view.input.lastSelectionTime = Date.now()
}
export function destroyInput(view: EditorView) {
view.domObserver.stop()
for (let type in view.input.eventHandlers)
view.dom.removeEventListener(type, view.input.eventHandlers[type])
clearTimeout(view.input.composingTimeout)
clearTimeout(view.input.lastIOSEnterFallbackTimeout)
}
export function ensureListeners(view: EditorView) {
view.someProp("handleDOMEvents", currentHandlers => {
for (let type in currentHandlers) if (!view.input.eventHandlers[type])
view.dom.addEventListener(type, view.input.eventHandlers[type] = event => runCustomHandler(view, event))
})
}
function runCustomHandler(view: EditorView, event: Event) {
return view.someProp("handleDOMEvents", handlers => {
let handler = handlers[event.type]
return handler ? handler(view, event) || event.defaultPrevented : false
})
}
function eventBelongsToView(view: EditorView, event: Event) {
if (!event.bubbles) return true
if (event.defaultPrevented) return false
for (let node = event.target as DOMNode; node != view.dom; node = node.parentNode!)
if (!node || node.nodeType == 11 ||
(node.pmViewDesc && node.pmViewDesc.stopEvent(event)))
return false
return true
}
export function dispatchEvent(view: EditorView, event: Event) {
if (!runCustomHandler(view, event) && handlers[event.type] &&
(view.editable || !(event.type in editHandlers)))
handlers[event.type](view, event)
}
editHandlers.keydown = (view: EditorView, _event: Event) => {
let event = _event as KeyboardEvent
view.input.shiftKey = event.keyCode == 16 || event.shiftKey
if (inOrNearComposition(view, event)) return
view.input.lastKeyCode = event.keyCode
view.input.lastKeyCodeTime = Date.now()
// Suppress enter key events on Chrome Android, because those tend
// to be part of a confused sequence of composition events fired,
// and handling them eagerly tends to corrupt the input.
if (browser.android && browser.chrome && event.keyCode == 13) return
if (event.keyCode != 229) view.domObserver.forceFlush()
// On iOS, if we preventDefault enter key presses, the virtual
// keyboard gets confused. So the hack here is to set a flag that
// makes the DOM change code recognize that what just happens should
// be replaced by whatever the Enter key handlers do.
if (browser.ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) {
let now = Date.now()
view.input.lastIOSEnter = now
view.input.lastIOSEnterFallbackTimeout = setTimeout(() => {
if (view.input.lastIOSEnter == now) {
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))
view.input.lastIOSEnter = 0
}
}, 200)
} else if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) {
event.preventDefault()
} else {
setSelectionOrigin(view, "key")
}
}
editHandlers.keyup = (view, event) => {
if ((event as KeyboardEvent).keyCode == 16) view.input.shiftKey = false
}
editHandlers.keypress = (view, _event) => {
let event = _event as KeyboardEvent
if (inOrNearComposition(view, event) || !event.charCode ||
event.ctrlKey && !event.altKey || browser.mac && event.metaKey) return
if (view.someProp("handleKeyPress", f => f(view, event))) {
event.preventDefault()
return
}
let sel = view.state.selection
if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) {
let text = String.fromCharCode(event.charCode)
if (!/[\r\n]/.test(text) && !view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text)))
view.dispatch(view.state.tr.insertText(text).scrollIntoView())
event.preventDefault()
}
}
function eventCoords(event: MouseEvent) { return {left: event.clientX, top: event.clientY} }
function isNear(event: MouseEvent, click: {x: number, y: number}) {
let dx = click.x - event.clientX, dy = click.y - event.clientY
return dx * dx + dy * dy < 100
}
function runHandlerOnContext(
view: EditorView,
propName: "handleClickOn" | "handleDoubleClickOn" | "handleTripleClickOn",
pos: number,
inside: number,
event: MouseEvent
) {
if (inside == -1) return false
let $pos = view.state.doc.resolve(inside)
for (let i = $pos.depth + 1; i > 0; i--) {
if (view.someProp(propName, f => i > $pos.depth ? f(view, pos, $pos.nodeAfter!, $pos.before(i), event, true)
: f(view, pos, $pos.node(i), $pos.before(i), event, false)))
return true
}
return false
}
function updateSelection(view: EditorView, selection: Selection, origin: string) {
if (!view.focused) view.focus()
let tr = view.state.tr.setSelection(selection)
if (origin == "pointer") tr.setMeta("pointer", true)
view.dispatch(tr)
}
function selectClickedLeaf(view: EditorView, inside: number) {
if (inside == -1) return false
let $pos = view.state.doc.resolve(inside), node = $pos.nodeAfter
if (node && node.isAtom && NodeSelection.isSelectable(node)) {
updateSelection(view, new NodeSelection($pos), "pointer")
return true
}
return false
}
function selectClickedNode(view: EditorView, inside: number) {
if (inside == -1) return false
let sel = view.state.selection, selectedNode, selectAt
if (sel instanceof NodeSelection) selectedNode = sel.node
let $pos = view.state.doc.resolve(inside)
for (let i = $pos.depth + 1; i > 0; i--) {
let node = i > $pos.depth ? $pos.nodeAfter! : $pos.node(i)
if (NodeSelection.isSelectable(node)) {
if (selectedNode && sel.$from.depth > 0 &&
i >= sel.$from.depth && $pos.before(sel.$from.depth + 1) == sel.$from.pos)
selectAt = $pos.before(sel.$from.depth)
else
selectAt = $pos.before(i)
break
}
}
if (selectAt != null) {
updateSelection(view, NodeSelection.create(view.state.doc, selectAt), "pointer")
return true
} else {
return false
}
}
function handleSingleClick(view: EditorView, pos: number, inside: number, event: MouseEvent, selectNode: boolean) {
return runHandlerOnContext(view, "handleClickOn", pos, inside, event) ||
view.someProp("handleClick", f => f(view, pos, event)) ||
(selectNode ? selectClickedNode(view, inside) : selectClickedLeaf(view, inside))
}
function handleDoubleClick(view: EditorView, pos: number, inside: number, event: MouseEvent) {
return runHandlerOnContext(view, "handleDoubleClickOn", pos, inside, event) ||
view.someProp("handleDoubleClick", f => f(view, pos, event))
}
function handleTripleClick(view: EditorView, pos: number, inside: number, event: MouseEvent) {
return runHandlerOnContext(view, "handleTripleClickOn", pos, inside, event) ||
view.someProp("handleTripleClick", f => f(view, pos, event)) ||
defaultTripleClick(view, inside, event)
}
function defaultTripleClick(view: EditorView, inside: number, event: MouseEvent) {
if (event.button != 0) return false
let doc = view.state.doc
if (inside == -1) {
if (doc.inlineContent) {
updateSelection(view, TextSelection.create(doc, 0, doc.content.size), "pointer")
return true
}
return false
}
let $pos = doc.resolve(inside)
for (let i = $pos.depth + 1; i > 0; i--) {
let node = i > $pos.depth ? $pos.nodeAfter! : $pos.node(i)
let nodePos = $pos.before(i)
if (node.inlineContent)
updateSelection(view, TextSelection.create(doc, nodePos + 1, nodePos + 1 + node.content.size), "pointer")
else if (NodeSelection.isSelectable(node))
updateSelection(view, NodeSelection.create(doc, nodePos), "pointer")
else
continue
return true
}
}
function forceDOMFlush(view: EditorView) {
return endComposition(view)
}
const selectNodeModifier: keyof MouseEvent = browser.mac ? "metaKey" : "ctrlKey"
handlers.mousedown = (view, _event) => {
let event = _event as MouseEvent
view.input.shiftKey = event.shiftKey
let flushed = forceDOMFlush(view)
let now = Date.now(), type = "singleClick"
if (now - view.input.lastClick.time < 500 && isNear(event, view.input.lastClick) && !event[selectNodeModifier]) {
if (view.input.lastClick.type == "singleClick") type = "doubleClick"
else if (view.input.lastClick.type == "doubleClick") type = "tripleClick"
}
view.input.lastClick = {time: now, x: event.clientX, y: event.clientY, type}
let pos = view.posAtCoords(eventCoords(event))
if (!pos) return
if (type == "singleClick") {
if (view.input.mouseDown) view.input.mouseDown.done()
view.input.mouseDown = new MouseDown(view, pos, event, !!flushed)
} else if ((type == "doubleClick" ? handleDoubleClick : handleTripleClick)(view, pos.pos, pos.inside, event)) {
event.preventDefault()
} else {
setSelectionOrigin(view, "pointer")
}
}
class MouseDown {
startDoc: Node
selectNode: boolean
allowDefault: boolean
delayedSelectionSync = false
mightDrag: {node: Node, pos: number, addAttr: boolean, setUneditable: boolean} | null = null
target: HTMLElement | null
constructor(
readonly view: EditorView,
readonly pos: {pos: number, inside: number},
readonly event: MouseEvent,
readonly flushed: boolean
) {
this.startDoc = view.state.doc
this.selectNode = !!event[selectNodeModifier]
this.allowDefault = event.shiftKey
let targetNode: Node, targetPos
if (pos.inside > -1) {
targetNode = view.state.doc.nodeAt(pos.inside)!
targetPos = pos.inside
} else {
let $pos = view.state.doc.resolve(pos.pos)
targetNode = $pos.parent
targetPos = $pos.depth ? $pos.before() : 0
}
const target = flushed ? null : event.target as HTMLElement
const targetDesc = target ? view.docView.nearestDesc(target, true) : null
this.target = targetDesc ? targetDesc.dom as HTMLElement : null
let {selection} = view.state
if (event.button == 0 &&
targetNode.type.spec.draggable && targetNode.type.spec.selectable !== false ||
selection instanceof NodeSelection && selection.from <= targetPos && selection.to > targetPos)
this.mightDrag = {
node: targetNode,
pos: targetPos,
addAttr: !!(this.target && !this.target.draggable),
setUneditable: !!(this.target && browser.gecko && !this.target.hasAttribute("contentEditable"))
}
if (this.target && this.mightDrag && (this.mightDrag.addAttr || this.mightDrag.setUneditable)) {
this.view.domObserver.stop()
if (this.mightDrag.addAttr) this.target.draggable = true
if (this.mightDrag.setUneditable)
setTimeout(() => {
if (this.view.input.mouseDown == this) this.target!.setAttribute("contentEditable", "false")
}, 20)
this.view.domObserver.start()
}
view.root.addEventListener("mouseup", this.up = this.up.bind(this) as any)
view.root.addEventListener("mousemove", this.move = this.move.bind(this) as any)
setSelectionOrigin(view, "pointer")
}
done() {
this.view.root.removeEventListener("mouseup", this.up as any)
this.view.root.removeEventListener("mousemove", this.move as any)
if (this.mightDrag && this.target) {
this.view.domObserver.stop()
if (this.mightDrag.addAttr) this.target.removeAttribute("draggable")
if (this.mightDrag.setUneditable) this.target.removeAttribute("contentEditable")
this.view.domObserver.start()
}
if (this.delayedSelectionSync) setTimeout(() => selectionToDOM(this.view))
this.view.input.mouseDown = null
}
up(event: MouseEvent) {
this.done()
if (!this.view.dom.contains(event.target as HTMLElement))
return
let pos: {pos: number, inside: number} | null = this.pos
if (this.view.state.doc != this.startDoc) pos = this.view.posAtCoords(eventCoords(event))
this.updateAllowDefault(event)
if (this.allowDefault || !pos) {
setSelectionOrigin(this.view, "pointer")
} else if (handleSingleClick(this.view, pos.pos, pos.inside, event, this.selectNode)) {
event.preventDefault()
} else if (event.button == 0 &&
(this.flushed ||
// Safari ignores clicks on draggable elements
(browser.safari && this.mightDrag && !this.mightDrag.node.isAtom) ||
// Chrome will sometimes treat a node selection as a
// cursor, but still report that the node is selected
// when asked through getSelection. You'll then get a
// situation where clicking at the point where that
// (hidden) cursor is doesn't change the selection, and
// thus doesn't get a reaction from ProseMirror. This
// works around that.
(browser.chrome && !this.view.state.selection.visible &&
Math.min(Math.abs(pos.pos - this.view.state.selection.from),
Math.abs(pos.pos - this.view.state.selection.to)) <= 2))) {
updateSelection(this.view, Selection.near(this.view.state.doc.resolve(pos.pos)), "pointer")
event.preventDefault()
} else {
setSelectionOrigin(this.view, "pointer")
}
}
move(event: MouseEvent) {
this.updateAllowDefault(event)
setSelectionOrigin(this.view, "pointer")
if (event.buttons == 0) this.done()
}
updateAllowDefault(event: MouseEvent) {
if (!this.allowDefault && (Math.abs(this.event.x - event.clientX) > 4 ||
Math.abs(this.event.y - event.clientY) > 4))
this.allowDefault = true
}
}
handlers.touchstart = view => {
view.input.lastTouch = Date.now()
forceDOMFlush(view)
setSelectionOrigin(view, "pointer")
}
handlers.touchmove = view => {
view.input.lastTouch = Date.now()
setSelectionOrigin(view, "pointer")
}
handlers.contextmenu = view => forceDOMFlush(view)
function inOrNearComposition(view: EditorView, event: Event) {
if (view.composing) return true
// See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/.
// On Japanese input method editors (IMEs), the Enter key is used to confirm character
// selection. On Safari, when Enter is pressed, compositionend and keydown events are
// emitted. The keydown event triggers newline insertion, which we don't want.
// This method returns true if the keydown event should be ignored.
// We only ignore it once, as pressing Enter a second time *should* insert a newline.
// Furthermore, the keydown event timestamp must be close to the compositionEndedAt timestamp.
// This guards against the case where compositionend is triggered without the keyboard
// (e.g. character confirmation may be done with the mouse), and keydown is triggered
// afterwards- we wouldn't want to ignore the keydown event in this case.
if (browser.safari && Math.abs(event.timeStamp - view.input.compositionEndedAt) < 500) {
view.input.compositionEndedAt = -2e8
return true
}
return false
}
// Drop active composition after 5 seconds of inactivity on Android
const timeoutComposition = browser.android ? 5000 : -1
editHandlers.compositionstart = editHandlers.compositionupdate = view => {
if (!view.composing) {
view.domObserver.flush()
let {state} = view, $pos = state.selection.$from
if (state.selection.empty &&
(state.storedMarks ||
(!$pos.textOffset && $pos.parentOffset && $pos.nodeBefore!.marks.some(m => m.type.spec.inclusive === false)))) {
// Need to wrap the cursor in mark nodes different from the ones in the DOM context
view.markCursor = view.state.storedMarks || $pos.marks()
endComposition(view, true)
view.markCursor = null
} else {
endComposition(view)
// In firefox, if the cursor is after but outside a marked node,
// the inserted text won't inherit the marks. So this moves it
// inside if necessary.
if (browser.gecko && state.selection.empty && $pos.parentOffset && !$pos.textOffset && $pos.nodeBefore!.marks.length) {
let sel = view.domSelectionRange()
for (let node = sel.focusNode, offset = sel.focusOffset; node && node.nodeType == 1 && offset != 0;) {
let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1]
if (!before) break
if (before.nodeType == 3) {
view.domSelection().collapse(before, before.nodeValue!.length)
break
} else {
node = before
offset = -1
}
}
}
}
view.input.composing = true
}
scheduleComposeEnd(view, timeoutComposition)
}
editHandlers.compositionend = (view, event) => {
if (view.composing) {
view.input.composing = false
view.input.compositionEndedAt = event.timeStamp
view.input.compositionPendingChanges = view.domObserver.pendingRecords().length ? view.input.compositionID : 0
view.input.compositionNode = null
if (view.input.compositionPendingChanges) Promise.resolve().then(() => view.domObserver.flush())
view.input.compositionID++
scheduleComposeEnd(view, 20)
}
}
function scheduleComposeEnd(view: EditorView, delay: number) {
clearTimeout(view.input.composingTimeout)
if (delay > -1) view.input.composingTimeout = setTimeout(() => endComposition(view), delay)
}
export function clearComposition(view: EditorView) {
if (view.composing) {
view.input.composing = false
view.input.compositionEndedAt = timestampFromCustomEvent()
}
while (view.input.compositionNodes.length > 0) view.input.compositionNodes.pop()!.markParentsDirty()
}
export function findCompositionNode(view: EditorView) {
let sel = view.domSelectionRange()
if (!sel.focusNode) return null
let textBefore = textNodeBefore(sel.focusNode, sel.focusOffset)
let textAfter = textNodeAfter(sel.focusNode, sel.focusOffset)
if (textBefore && textAfter && textBefore != textAfter) {
let descAfter = textAfter.pmViewDesc
if (!descAfter || !descAfter.isText(textAfter.nodeValue!)) {
return textAfter
} else if (view.input.compositionNode == textAfter) {
let descBefore = textBefore.pmViewDesc
if (!(!descBefore || !descBefore.isText(textBefore.nodeValue!)))
return textAfter
}
}
return textBefore || textAfter
}
function timestampFromCustomEvent() {
let event = document.createEvent("Event")
event.initEvent("event", true, true)
return event.timeStamp
}
/// @internal
export function endComposition(view: EditorView, forceUpdate = false) {
if (browser.android && view.domObserver.flushingSoon >= 0) return
view.domObserver.forceFlush()
clearComposition(view)
if (forceUpdate || view.docView && view.docView.dirty) {
let sel = selectionFromDOM(view)
if (sel && !sel.eq(view.state.selection)) view.dispatch(view.state.tr.setSelection(sel))
else view.updateState(view.state)
return true
}
return false
}
function captureCopy(view: EditorView, dom: HTMLElement) {
// The extra wrapper is somehow necessary on IE/Edge to prevent the
// content from being mangled when it is put onto the clipboard
if (!view.dom.parentNode) return
let wrap = view.dom.parentNode.appendChild(document.createElement("div"))
wrap.appendChild(dom)
wrap.style.cssText = "position: fixed; left: -10000px; top: 10px"
let sel = getSelection()!, range = document.createRange()
range.selectNodeContents(dom)
// Done because IE will fire a selectionchange moving the selection
// to its start when removeAllRanges is called and the editor still
// has focus (which will mess up the editor's selection state).
view.dom.blur()
sel.removeAllRanges()
sel.addRange(range)
setTimeout(() => {
if (wrap.parentNode) wrap.parentNode.removeChild(wrap)
view.focus()
}, 50)
}
// This is very crude, but unfortunately both these browsers _pretend_
// that they have a clipboard API—all the objects and methods are
// there, they just don't work, and they are hard to test.
const brokenClipboardAPI = (browser.ie && browser.ie_version < 15) ||
(browser.ios && browser.webkit_version < 604)
handlers.copy = editHandlers.cut = (view, _event) => {
let event = _event as ClipboardEvent
let sel = view.state.selection, cut = event.type == "cut"
if (sel.empty) return
// IE and Edge's clipboard interface is completely broken
let data = brokenClipboardAPI ? null : event.clipboardData
let slice = sel.content(), {dom, text} = serializeForClipboard(view, slice)
if (data) {
event.preventDefault()
data.clearData()
data.setData("text/html", dom.innerHTML)
data.setData("text/plain", text)
} else {
captureCopy(view, dom)
}
if (cut) view.dispatch(view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut"))
}
function sliceSingleNode(slice: Slice) {
return slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 ? slice.content.firstChild : null
}
function capturePaste(view: EditorView, event: ClipboardEvent) {
if (!view.dom.parentNode) return
let plainText = view.input.shiftKey || view.state.selection.$from.parent.type.spec.code
let target = view.dom.parentNode.appendChild(document.createElement(plainText ? "textarea" : "div"))
if (!plainText) target.contentEditable = "true"
target.style.cssText = "position: fixed; left: -10000px; top: 10px"
target.focus()
let plain = view.input.shiftKey && view.input.lastKeyCode != 45
setTimeout(() => {
view.focus()
if (target.parentNode) target.parentNode.removeChild(target)
if (plainText) doPaste(view, (target as HTMLTextAreaElement).value, null, plain, event)
else doPaste(view, target.textContent!, target.innerHTML, plain, event)
}, 50)
}
export function doPaste(view: EditorView, text: string, html: string | null, preferPlain: boolean, event: ClipboardEvent) {
let slice = parseFromClipboard(view, text, html, preferPlain, view.state.selection.$from)
if (view.someProp("handlePaste", f => f(view, event, slice || Slice.empty))) return true
if (!slice) return false
let singleNode = sliceSingleNode(slice)
let tr = singleNode
? view.state.tr.replaceSelectionWith(singleNode, preferPlain)
: view.state.tr.replaceSelection(slice)
view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"))
return true
}
function getText(clipboardData: DataTransfer) {
let text = clipboardData.getData("text/plain") || clipboardData.getData("Text")
if (text) return text
let uris = clipboardData.getData("text/uri-list")
return uris ? uris.replace(/\r?\n/g, " ") : ""
}
editHandlers.paste = (view, _event) => {
let event = _event as ClipboardEvent
// Handling paste from JavaScript during composition is very poorly
// handled by browsers, so as a dodgy but preferable kludge, we just
// let the browser do its native thing there, except on Android,
// where the editor is almost always composing.
if (view.composing && !browser.android) return
let data = brokenClipboardAPI ? null : event.clipboardData
let plain = view.input.shiftKey && view.input.lastKeyCode != 45
if (data && doPaste(view, getText(data), data.getData("text/html"), plain, event))
event.preventDefault()
else
capturePaste(view, event)
}
export class Dragging {
constructor(readonly slice: Slice, readonly move: boolean, readonly node?: NodeSelection) {}
}
const dragCopyModifier: keyof DragEvent = browser.mac ? "altKey" : "ctrlKey"
handlers.dragstart = (view, _event) => {
let event = _event as DragEvent
let mouseDown = view.input.mouseDown
if (mouseDown) mouseDown.done()
if (!event.dataTransfer) return
let sel = view.state.selection
let pos = sel.empty ? null : view.posAtCoords(eventCoords(event))
let node: undefined | NodeSelection
if (pos && pos.pos >= sel.from && pos.pos <= (sel instanceof NodeSelection ? sel.to - 1: sel.to)) {
// In selection
} else if (mouseDown && mouseDown.mightDrag) {
node = NodeSelection.create(view.state.doc, mouseDown.mightDrag.pos)
} else if (event.target && (event.target as HTMLElement).nodeType == 1) {
let desc = view.docView.nearestDesc(event.target as HTMLElement, true)
if (desc && desc.node.type.spec.draggable && desc != view.docView)
node = NodeSelection.create(view.state.doc, desc.posBefore)
}
let draggedSlice = (node || view.state.selection).content()
let {dom, text, slice} = serializeForClipboard(view, draggedSlice)
event.dataTransfer.clearData()
event.dataTransfer.setData(brokenClipboardAPI ? "Text" : "text/html", dom.innerHTML)
// See https://github.com/ProseMirror/prosemirror/issues/1156
event.dataTransfer.effectAllowed = "copyMove"
if (!brokenClipboardAPI) event.dataTransfer.setData("text/plain", text)
view.dragging = new Dragging(slice, !event[dragCopyModifier], node)
}
handlers.dragend = view => {
let dragging = view.dragging
window.setTimeout(() => {
if (view.dragging == dragging) view.dragging = null
}, 50)
}
editHandlers.dragover = editHandlers.dragenter = (_, e) => e.preventDefault()
editHandlers.drop = (view, _event) => {
let event = _event as DragEvent
let dragging = view.dragging
view.dragging = null
if (!event.dataTransfer) return
let eventPos = view.posAtCoords(eventCoords(event))
if (!eventPos) return
let $mouse = view.state.doc.resolve(eventPos.pos)
let slice = dragging && dragging.slice
if (slice) {
view.someProp("transformPasted", f => { slice = f(slice!, view) })
} else {
slice = parseFromClipboard(view, getText(event.dataTransfer),
brokenClipboardAPI ? null : event.dataTransfer.getData("text/html"), false, $mouse)
}
let move = !!(dragging && !event[dragCopyModifier])
if (view.someProp("handleDrop", f => f(view, event, slice || Slice.empty, move))) {
event.preventDefault()
return
}
if (!slice) return
event.preventDefault()
let insertPos = slice ? dropPoint(view.state.doc, $mouse.pos, slice) : $mouse.pos
if (insertPos == null) insertPos = $mouse.pos
let tr = view.state.tr
if (move) {
let {node} = dragging as Dragging
if (node) node.replace(tr)
else tr.deleteSelection()
}
let pos = tr.mapping.map(insertPos)
let isNode = slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1
let beforeInsert = tr.doc
if (isNode)
tr.replaceRangeWith(pos, pos, slice.content.firstChild!)
else
tr.replaceRange(pos, pos, slice)
if (tr.doc.eq(beforeInsert)) return
let $pos = tr.doc.resolve(pos)
if (isNode && NodeSelection.isSelectable(slice.content.firstChild!) &&
$pos.nodeAfter && $pos.nodeAfter.sameMarkup(slice.content.firstChild!)) {
tr.setSelection(new NodeSelection($pos))
} else {
let end = tr.mapping.map(insertPos)
tr.mapping.maps[tr.mapping.maps.length - 1].forEach((_from, _to, _newFrom, newTo) => end = newTo)
tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end)))
}
view.focus()
view.dispatch(tr.setMeta("uiEvent", "drop"))
}
handlers.focus = view => {
view.input.lastFocus = Date.now()
if (!view.focused) {
view.domObserver.stop()
view.dom.classList.add("ProseMirror-focused")
view.domObserver.start()
view.focused = true
setTimeout(() => {
if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelectionRange()))
selectionToDOM(view)
}, 20)
}
}
handlers.blur = (view, _event) => {
let event = _event as FocusEvent
if (view.focused) {
view.domObserver.stop()
view.dom.classList.remove("ProseMirror-focused")
view.domObserver.start()
if (event.relatedTarget && view.dom.contains(event.relatedTarget as HTMLElement))
view.domObserver.currentSelection.clear()
view.focused = false
}
}
handlers.beforeinput = (view, _event: Event) => {
let event = _event as InputEvent
// We should probably do more with beforeinput events, but support
// is so spotty that I'm still waiting to see where they are going.
// Very specific hack to deal with backspace sometimes failing on
// Chrome Android when after an uneditable node.
if (browser.chrome && browser.android && event.inputType == "deleteContentBackward") {
view.domObserver.flushSoon()
let {domChangeCount} = view.input
setTimeout(() => {
if (view.input.domChangeCount != domChangeCount) return // Event already had some effect
// This bug tends to close the virtual keyboard, so we refocus
view.dom.blur()
view.focus()
if (view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) return
let {$cursor} = view.state.selection as TextSelection
// Crude approximation of backspace behavior when no command handled it
if ($cursor && $cursor.pos > 0) view.dispatch(view.state.tr.delete($cursor.pos - 1, $cursor.pos).scrollIntoView())
}, 50)
}
}
// Make sure all handlers get registered
for (let prop in editHandlers) handlers[prop] = editHandlers[prop]

View File

@@ -0,0 +1,206 @@
import {TextSelection, NodeSelection, Selection} from "prosemirror-state"
import {ResolvedPos} from "prosemirror-model"
import * as browser from "./browser"
import {isEquivalentPosition, domIndex, isOnEdge, selectionCollapsed} from "./dom"
import {EditorView} from "./index"
import {NodeViewDesc} from "./viewdesc"
export function selectionFromDOM(view: EditorView, origin: string | null = null) {
let domSel = view.domSelectionRange(), doc = view.state.doc
if (!domSel.focusNode) return null
let nearestDesc = view.docView.nearestDesc(domSel.focusNode), inWidget = nearestDesc && nearestDesc.size == 0
let head = view.docView.posFromDOM(domSel.focusNode, domSel.focusOffset, 1)
if (head < 0) return null
let $head = doc.resolve(head), $anchor, selection
if (selectionCollapsed(domSel)) {
$anchor = $head
while (nearestDesc && !nearestDesc.node) nearestDesc = nearestDesc.parent
let nearestDescNode = (nearestDesc as NodeViewDesc).node
if (nearestDesc && nearestDescNode.isAtom && NodeSelection.isSelectable(nearestDescNode) && nearestDesc.parent
&& !(nearestDescNode.isInline && isOnEdge(domSel.focusNode, domSel.focusOffset, nearestDesc.dom))) {
let pos = nearestDesc.posBefore
selection = new NodeSelection(head == pos ? $head : doc.resolve(pos))
}
} else {
let anchor = view.docView.posFromDOM(domSel.anchorNode!, domSel.anchorOffset, 1)
if (anchor < 0) return null
$anchor = doc.resolve(anchor)
}
if (!selection) {
let bias = origin == "pointer" || (view.state.selection.head < $head.pos && !inWidget) ? 1 : -1
selection = selectionBetween(view, $anchor, $head, bias)
}
return selection
}
function editorOwnsSelection(view: EditorView) {
return view.editable ? view.hasFocus() :
hasSelection(view) && document.activeElement && document.activeElement.contains(view.dom)
}
export function selectionToDOM(view: EditorView, force = false) {
let sel = view.state.selection
syncNodeSelection(view, sel)
if (!editorOwnsSelection(view)) return
// The delayed drag selection causes issues with Cell Selections
// in Safari. And the drag selection delay is to workarond issues
// which only present in Chrome.
if (!force && view.input.mouseDown && view.input.mouseDown.allowDefault && browser.chrome) {
let domSel = view.domSelectionRange(), curSel = view.domObserver.currentSelection
if (domSel.anchorNode && curSel.anchorNode &&
isEquivalentPosition(domSel.anchorNode, domSel.anchorOffset,
curSel.anchorNode, curSel.anchorOffset)) {
view.input.mouseDown.delayedSelectionSync = true
view.domObserver.setCurSelection()
return
}
}
view.domObserver.disconnectSelection()
if (view.cursorWrapper) {
selectCursorWrapper(view)
} else {
let {anchor, head} = sel, resetEditableFrom, resetEditableTo
if (brokenSelectBetweenUneditable && !(sel instanceof TextSelection)) {
if (!sel.$from.parent.inlineContent)
resetEditableFrom = temporarilyEditableNear(view, sel.from)
if (!sel.empty && !sel.$from.parent.inlineContent)
resetEditableTo = temporarilyEditableNear(view, sel.to)
}
view.docView.setSelection(anchor, head, view.root, force)
if (brokenSelectBetweenUneditable) {
if (resetEditableFrom) resetEditable(resetEditableFrom)
if (resetEditableTo) resetEditable(resetEditableTo)
}
if (sel.visible) {
view.dom.classList.remove("ProseMirror-hideselection")
} else {
view.dom.classList.add("ProseMirror-hideselection")
if ("onselectionchange" in document) removeClassOnSelectionChange(view)
}
}
view.domObserver.setCurSelection()
view.domObserver.connectSelection()
}
// Kludge to work around Webkit not allowing a selection to start/end
// between non-editable block nodes. We briefly make something
// editable, set the selection, then set it uneditable again.
const brokenSelectBetweenUneditable = browser.safari || browser.chrome && browser.chrome_version < 63
function temporarilyEditableNear(view: EditorView, pos: number) {
let {node, offset} = view.docView.domFromPos(pos, 0)
let after = offset < node.childNodes.length ? node.childNodes[offset] : null
let before = offset ? node.childNodes[offset - 1] : null
if (browser.safari && after && (after as HTMLElement).contentEditable == "false") return setEditable(after as HTMLElement)
if ((!after || (after as HTMLElement).contentEditable == "false") &&
(!before || (before as HTMLElement).contentEditable == "false")) {
if (after) return setEditable(after as HTMLElement)
else if (before) return setEditable(before as HTMLElement)
}
}
function setEditable(element: HTMLElement) {
element.contentEditable = "true"
if (browser.safari && element.draggable) { element.draggable = false; (element as any).wasDraggable = true }
return element
}
function resetEditable(element: HTMLElement) {
element.contentEditable = "false"
if ((element as any).wasDraggable) { element.draggable = true; (element as any).wasDraggable = null }
}
function removeClassOnSelectionChange(view: EditorView) {
let doc = view.dom.ownerDocument
doc.removeEventListener("selectionchange", view.input.hideSelectionGuard!)
let domSel = view.domSelectionRange()
let node = domSel.anchorNode, offset = domSel.anchorOffset
doc.addEventListener("selectionchange", view.input.hideSelectionGuard = () => {
if (domSel.anchorNode != node || domSel.anchorOffset != offset) {
doc.removeEventListener("selectionchange", view.input.hideSelectionGuard!)
setTimeout(() => {
if (!editorOwnsSelection(view) || view.state.selection.visible)
view.dom.classList.remove("ProseMirror-hideselection")
}, 20)
}
})
}
function selectCursorWrapper(view: EditorView) {
let domSel = view.domSelection(), range = document.createRange()
let node = view.cursorWrapper!.dom, img = node.nodeName == "IMG"
if (img) range.setEnd(node.parentNode!, domIndex(node) + 1)
else range.setEnd(node, 0)
range.collapse(false)
domSel.removeAllRanges()
domSel.addRange(range)
// Kludge to kill 'control selection' in IE11 when selecting an
// invisible cursor wrapper, since that would result in those weird
// resize handles and a selection that considers the absolutely
// positioned wrapper, rather than the root editable node, the
// focused element.
if (!img && !view.state.selection.visible && browser.ie && browser.ie_version <= 11) {
;(node as any).disabled = true
;(node as any).disabled = false
}
}
export function syncNodeSelection(view: EditorView, sel: Selection) {
if (sel instanceof NodeSelection) {
let desc = view.docView.descAt(sel.from)
if (desc != view.lastSelectedViewDesc) {
clearNodeSelection(view)
if (desc) (desc as NodeViewDesc).selectNode()
view.lastSelectedViewDesc = desc
}
} else {
clearNodeSelection(view)
}
}
// Clear all DOM statefulness of the last node selection.
function clearNodeSelection(view: EditorView) {
if (view.lastSelectedViewDesc) {
if (view.lastSelectedViewDesc.parent)
(view.lastSelectedViewDesc as NodeViewDesc).deselectNode()
view.lastSelectedViewDesc = undefined
}
}
export function selectionBetween(view: EditorView, $anchor: ResolvedPos, $head: ResolvedPos, bias?: number) {
return view.someProp("createSelectionBetween", f => f(view, $anchor, $head))
|| TextSelection.between($anchor, $head, bias)
}
export function hasFocusAndSelection(view: EditorView) {
if (view.editable && !view.hasFocus()) return false
return hasSelection(view)
}
export function hasSelection(view: EditorView) {
let sel = view.domSelectionRange()
if (!sel.anchorNode) return false
try {
// Firefox will raise 'permission denied' errors when accessing
// properties of `sel.anchorNode` when it's in a generated CSS
// element.
return view.dom.contains(sel.anchorNode.nodeType == 3 ? sel.anchorNode.parentNode : sel.anchorNode) &&
(view.editable || view.dom.contains(sel.focusNode!.nodeType == 3 ? sel.focusNode!.parentNode : sel.focusNode))
} catch(_) {
return false
}
}
export function anchorInRightPlace(view: EditorView) {
let anchorDOM = view.docView.domFromPos(view.state.selection.anchor, 0)
let domSel = view.domSelectionRange()
return isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode!, domSel.anchorOffset)
}

File diff suppressed because it is too large Load Diff