From f29372077399fc5bbf604b50f41e83e47847c6eb Mon Sep 17 00:00:00 2001
From: Frederic Salmen <frederic@fsalmen.de>
Date: Tue, 20 Aug 2024 22:05:47 +0200
Subject: [PATCH] Fix imports of domchange workaround

---
 @webwriter/core/view/editor/nodeviews.ts      |    2 +-
 .../view/editor/prosemirror-view/README.md    |   34 +
 .../view/editor/prosemirror-view/browser.ts   |   24 +
 .../editor/prosemirror-view/capturekeys.ts    |  345 ++++
 .../view/editor/prosemirror-view/clipboard.ts |  246 +++
 .../editor/prosemirror-view/decoration.ts     |  772 +++++++++
 .../core/view/editor/prosemirror-view/dom.ts  |  151 ++
 .../{ => prosemirror-view}/domchange.ts       |    8 +-
 .../view/editor/prosemirror-view/domcoords.ts |  509 ++++++
 .../editor/prosemirror-view/domobserver.ts    |  340 ++++
 .../view/editor/prosemirror-view/index.ts     |  806 +++++++++
 .../view/editor/prosemirror-view/input.ts     |  809 +++++++++
 .../view/editor/prosemirror-view/selection.ts |  207 +++
 .../view/editor/prosemirror-view/viewdesc.ts  | 1527 +++++++++++++++++
 14 files changed, 5775 insertions(+), 5 deletions(-)
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/README.md
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/browser.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/capturekeys.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/clipboard.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/decoration.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/dom.ts
 rename @webwriter/core/view/editor/{ => prosemirror-view}/domchange.ts (98%)
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/domcoords.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/domobserver.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/index.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/input.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/selection.ts
 create mode 100644 @webwriter/core/view/editor/prosemirror-view/viewdesc.ts

diff --git a/@webwriter/core/view/editor/nodeviews.ts b/@webwriter/core/view/editor/nodeviews.ts
index 89fa28c..62f81ae 100644
--- a/@webwriter/core/view/editor/nodeviews.ts
+++ b/@webwriter/core/view/editor/nodeviews.ts
@@ -7,7 +7,7 @@ import { EditorStateWithHead, getAttrs, globalHTMLAttributes, toAttributes } fro
 import {EditorViewController} from "."
 import { selectParentNode } from "prosemirror-commands"
 import { filterObject, sameMembers, shallowCompare, browser } from "../../utility"
-import { readDOMChange } from "./domchange"
+import { readDOMChange } from "./prosemirror-view/domchange"
 
 
 
diff --git a/@webwriter/core/view/editor/prosemirror-view/README.md b/@webwriter/core/view/editor/prosemirror-view/README.md
new file mode 100644
index 0000000..c820b1e
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/README.md
@@ -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
diff --git a/@webwriter/core/view/editor/prosemirror-view/browser.ts b/@webwriter/core/view/editor/prosemirror-view/browser.ts
new file mode 100644
index 0000000..3ff9710
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/browser.ts
@@ -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
diff --git a/@webwriter/core/view/editor/prosemirror-view/capturekeys.ts b/@webwriter/core/view/editor/prosemirror-view/capturekeys.ts
new file mode 100644
index 0000000..35f3632
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/capturekeys.ts
@@ -0,0 +1,345 @@
+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 (!sel) return
+  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
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/clipboard.ts b/@webwriter/core/view/editor/prosemirror-view/clipboard.ts
new file mode 100644
index 0000000..5cac94e
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/clipboard.ts
@@ -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)
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/decoration.ts b/@webwriter/core/view/editor/prosemirror-view/decoration.ts
new file mode 100644
index 0000000..10a2026
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/decoration.ts
@@ -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)
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/dom.ts b/@webwriter/core/view/editor/prosemirror-view/dom.ts
new file mode 100644
index 0000000..722138f
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/dom.ts
@@ -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}
+  }
+}
diff --git a/@webwriter/core/view/editor/domchange.ts b/@webwriter/core/view/editor/prosemirror-view/domchange.ts
similarity index 98%
rename from @webwriter/core/view/editor/domchange.ts
rename to @webwriter/core/view/editor/prosemirror-view/domchange.ts
index 729abc1..a0e8e85 100644
--- a/@webwriter/core/view/editor/domchange.ts
+++ b/@webwriter/core/view/editor/prosemirror-view/domchange.ts
@@ -1,10 +1,10 @@
 import {Fragment, DOMParser, TagParseRule, Node, Mark, ResolvedPos} from "prosemirror-model"
 import {Selection, TextSelection} from "prosemirror-state"
 
-import {selectionBetween, selectionFromDOM, selectionToDOM} from "prosemirror-view/src/selection"
-import {selectionCollapsed, keyEvent, DOMNode} from "prosemirror-view/src/dom"
-import { browser } from "../../utility"
-import { EditorView } from "prosemirror-view"
+import {selectionBetween, selectionFromDOM, selectionToDOM} from "./selection"
+import {selectionCollapsed, keyEvent, DOMNode} from "./dom"
+import { browser } from "../../../utility"
+import { EditorView } from "."
 
 // Note that all referencing and parsing is done with the
 // start-of-operation selection and document, since that's the one
diff --git a/@webwriter/core/view/editor/prosemirror-view/domcoords.ts b/@webwriter/core/view/editor/prosemirror-view/domcoords.ts
new file mode 100644
index 0000000..36dc16e
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/domcoords.ts
@@ -0,0 +1,509 @@
+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 || !desc.contentDOM)) {
+      let rect = (desc.dom as HTMLElement).getBoundingClientRect()
+      if (desc.node.isBlock && desc.parent) {
+        // Only apply the horizontal test to the innermost block. Vertical for any parent.
+        if (!sawBlock && rect.left > coords.left || rect.top > coords.top) outsideBlock = desc.posBefore
+        else if (!sawBlock && rect.right < coords.left || rect.bottom < coords.top) outsideBlock = desc.posAfter
+        sawBlock = true
+      }
+      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: Selection = view.domSelection()!
+  if (!sel) return $head.pos == $head.start() || $head.pos == $head.end()
+  // 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)
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/domobserver.ts b/@webwriter/core/view/editor/prosemirror-view/domobserver.ts
new file mode 100644
index 0000000..35464ff
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/domobserver.ts
@@ -0,0 +1,340 @@
+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
+  lastChangedTextNode: Text | null = null
+
+  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) {
+      let brs = added.filter(n => n.nodeName == "BR") as HTMLElement[]
+      if (brs.length == 2) {
+        let [a, b] = brs
+        if (a.parentNode && a.parentNode.parentNode == b.parentNode) b.remove()
+        else a.remove()
+      } else {
+        let {focusNode} = this.currentSelection
+        for (let br of brs) {
+          let parent = br.parentNode
+          if (parent && parent.nodeName == "LI" && (!focusNode || blockParent(view, focusNode) != parent))
+            br.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++) {
+        let node = mut.addedNodes[i]
+        added.push(node)
+        if (node.nodeType == 3) this.lastChangedTextNode = node as Text
+      }
+      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"
+      this.lastChangedTextNode = mut.target as Text
+      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
+}
+
+function blockParent(view: EditorView, node: DOMNode): Node | null {
+  for (let p = node.parentNode; p && p != view.dom; p = p.parentNode) {
+    let desc = view.docView.nearestDesc(p, true)
+    if (desc && desc.node.isBlock) return p
+  }
+  return null
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/index.ts b/@webwriter/core/view/editor/prosemirror-view/index.ts
new file mode 100644
index 0000000..0d96a51
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/index.ts
@@ -0,0 +1,806 @@
+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()
+    if (!sel) return {focusNode: null, focusOffset: 0, anchorNode: null, anchorOffset: 0}
+    return browser.safari && this.root.nodeType === 11 &&
+      deepActiveElement(this.dom.ownerDocument) == this.dom && safariShadowSelectionRange(this, sel) || sel
+  }
+
+  /// @internal
+  domSelection(): DOMSelection | null {
+    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.from,
+                                                       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
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/input.ts b/@webwriter/core/view/editor/prosemirror-view/input.ts
new file mode 100644
index 0000000..bfaf68e
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/input.ts
@@ -0,0 +1,809 @@
+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()
+  if (view.state.selection.eq(selection)) return
+  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.nodeType == 1 ? 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.$to
+    if (state.selection instanceof TextSelection &&
+        (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, !state.selection.empty)
+      // 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) {
+            let sel = view.domSelection()
+            if (sel) sel.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, lastChanged = view.domObserver.lastChangedTextNode
+    if (textBefore == lastChanged || textAfter == lastChanged) return lastChanged
+    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, restarting = false) {
+  if (browser.android && view.domObserver.flushingSoon >= 0) return
+  view.domObserver.forceFlush()
+  clearComposition(view)
+  if (restarting || view.docView && view.docView.dirty) {
+    let sel = selectionFromDOM(view)
+    if (sel && !sel.eq(view.state.selection)) view.dispatch(view.state.tr.setSelection(sel))
+    else if ((view.markCursor || restarting) && !view.state.selection.empty) view.dispatch(view.state.tr.deleteSelection())
+    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)
+  // Pre-120 Chrome versions clear files when calling `clearData` (#1472)
+  if (!event.dataTransfer.files.length || !browser.chrome || browser.chrome_version > 120)
+    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]
diff --git a/@webwriter/core/view/editor/prosemirror-view/selection.ts b/@webwriter/core/view/editor/prosemirror-view/selection.ts
new file mode 100644
index 0000000..f48af82
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/selection.ts
@@ -0,0 +1,207 @@
+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()
+  if (!domSel) return
+  let node = view.cursorWrapper!.dom, img = node.nodeName == "IMG"
+  if (img) range.setStart(node.parentNode!, domIndex(node) + 1)
+  else range.setStart(node, 0)
+  range.collapse(true)
+  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)
+}
diff --git a/@webwriter/core/view/editor/prosemirror-view/viewdesc.ts b/@webwriter/core/view/editor/prosemirror-view/viewdesc.ts
new file mode 100644
index 0000000..93fd958
--- /dev/null
+++ b/@webwriter/core/view/editor/prosemirror-view/viewdesc.ts
@@ -0,0 +1,1527 @@
+import {DOMSerializer, Fragment, Mark, Node, TagParseRule} from "prosemirror-model"
+import {TextSelection} from "prosemirror-state"
+
+import {domIndex, isEquivalentPosition, DOMNode} from "./dom"
+import * as browser from "./browser"
+import {Decoration, DecorationSource, WidgetConstructor, WidgetType, NodeType} from "./decoration"
+import {EditorView} from "./index"
+
+declare global {
+  interface Node { pmViewDesc?: ViewDesc }
+}
+
+/// By default, document nodes are rendered using the result of the
+/// [`toDOM`](#model.NodeSpec.toDOM) method of their spec, and managed
+/// entirely by the editor. For some use cases, such as embedded
+/// node-specific editing interfaces, you want more control over
+/// the behavior of a node's in-editor representation, and need to
+/// [define](#view.EditorProps.nodeViews) a custom node view.
+///
+/// Mark views only support `dom` and `contentDOM`, and don't support
+/// any of the node view methods.
+///
+/// Objects returned as node views must conform to this interface.
+export interface NodeView {
+  /// The outer DOM node that represents the document node.
+  dom: DOMNode
+
+  /// The DOM node that should hold the node's content. Only meaningful
+  /// if the node view also defines a `dom` property and if its node
+  /// type is not a leaf node type. When this is present, ProseMirror
+  /// will take care of rendering the node's children into it. When it
+  /// is not present, the node view itself is responsible for rendering
+  /// (or deciding not to render) its child nodes.
+  contentDOM?: HTMLElement | null
+
+  /// When given, this will be called when the view is updating itself.
+  /// It will be given a node (possibly of a different type), an array
+  /// of active decorations around the node (which are automatically
+  /// drawn, and the node view may ignore if it isn't interested in
+  /// them), and a [decoration source](#view.DecorationSource) that
+  /// represents any decorations that apply to the content of the node
+  /// (which again may be ignored). It should return true if it was
+  /// able to update to that node, and false otherwise. If the node
+  /// view has a `contentDOM` property (or no `dom` property), updating
+  /// its child nodes will be handled by ProseMirror.
+  update?: (node: Node, decorations: readonly Decoration[], innerDecorations: DecorationSource) => boolean
+
+  /// Can be used to override the way the node's selected status (as a
+  /// node selection) is displayed.
+  selectNode?: () => void
+
+  /// When defining a `selectNode` method, you should also provide a
+  /// `deselectNode` method to remove the effect again.
+  deselectNode?: () => void
+
+  /// This will be called to handle setting the selection inside the
+  /// node. The `anchor` and `head` positions are relative to the start
+  /// of the node. By default, a DOM selection will be created between
+  /// the DOM positions corresponding to those positions, but if you
+  /// override it you can do something else.
+  setSelection?: (anchor: number, head: number, root: Document | ShadowRoot) => void
+
+  /// Can be used to prevent the editor view from trying to handle some
+  /// or all DOM events that bubble up from the node view. Events for
+  /// which this returns true are not handled by the editor.
+  stopEvent?: (event: Event) => boolean
+
+  /// Called when a DOM
+  /// [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)
+  /// or a selection change happens within the view. When the change is
+  /// a selection change, the record will have a `type` property of
+  /// `"selection"` (which doesn't occur for native mutation records).
+  /// Return false if the editor should re-read the selection or
+  /// re-parse the range around the mutation, true if it can safely be
+  /// ignored.
+  ignoreMutation?: (mutation: MutationRecord) => boolean
+
+  /// Called when the node view is removed from the editor or the whole
+  /// editor is destroyed. (Not available for marks.)
+  destroy?: () => void
+}
+
+// View descriptions are data structures that describe the DOM that is
+// used to represent the editor's content. They are used for:
+//
+// - Incremental redrawing when the document changes
+//
+// - Figuring out what part of the document a given DOM position
+//   corresponds to
+//
+// - Wiring in custom implementations of the editing interface for a
+//   given node
+//
+// They form a doubly-linked mutable tree, starting at `view.docView`.
+
+const NOT_DIRTY = 0, CHILD_DIRTY = 1, CONTENT_DIRTY = 2, NODE_DIRTY = 3
+
+// Superclass for the various kinds of descriptions. Defines their
+// basic structure and shared methods.
+export class ViewDesc {
+  dirty = NOT_DIRTY
+  node!: Node | null
+
+  constructor(
+    public parent: ViewDesc | undefined,
+    public children: ViewDesc[],
+    public dom: DOMNode,
+    // This is the node that holds the child views. It may be null for
+    // descs that don't have children.
+    public contentDOM: HTMLElement | null
+  ) {
+    // An expando property on the DOM node provides a link back to its
+    // description.
+    dom.pmViewDesc = this
+  }
+
+  // Used to check whether a given description corresponds to a
+  // widget/mark/node.
+  matchesWidget(widget: Decoration) { return false }
+  matchesMark(mark: Mark) { return false }
+  matchesNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource) { return false }
+  matchesHack(nodeName: string) { return false }
+
+  // When parsing in-editor content (in domchange.js), we allow
+  // descriptions to determine the parse rules that should be used to
+  // parse them.
+  parseRule(): Omit<TagParseRule, "tag"> | null { return null }
+
+  // Used by the editor's event handler to ignore events that come
+  // from certain descs.
+  stopEvent(event: Event) { return false }
+
+  // The size of the content represented by this desc.
+  get size() {
+    let size = 0
+    for (let i = 0; i < this.children.length; i++) size += this.children[i].size
+    return size
+  }
+
+  // For block nodes, this represents the space taken up by their
+  // start/end tokens.
+  get border() { return 0 }
+
+  destroy() {
+    this.parent = undefined
+    if (this.dom.pmViewDesc == this) this.dom.pmViewDesc = undefined
+    for (let i = 0; i < this.children.length; i++)
+      this.children[i].destroy()
+  }
+
+  posBeforeChild(child: ViewDesc): number {
+    for (let i = 0, pos = this.posAtStart;; i++) {
+      let cur = this.children[i]
+      if (cur == child) return pos
+      pos += cur.size
+    }
+  }
+
+  get posBefore() {
+    return this.parent!.posBeforeChild(this)
+  }
+
+  get posAtStart() {
+    return this.parent ? this.parent.posBeforeChild(this) + this.border : 0
+  }
+
+  get posAfter() {
+    return this.posBefore + this.size
+  }
+
+  get posAtEnd() {
+    return this.posAtStart + this.size - 2 * this.border
+  }
+
+  localPosFromDOM(dom: DOMNode, offset: number, bias: number): number {
+    // If the DOM position is in the content, use the child desc after
+    // it to figure out a position.
+    if (this.contentDOM && this.contentDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode)) {
+      if (bias < 0) {
+        let domBefore, desc: ViewDesc | undefined
+        if (dom == this.contentDOM) {
+          domBefore = dom.childNodes[offset - 1]
+        } else {
+          while (dom.parentNode != this.contentDOM) dom = dom.parentNode!
+          domBefore = dom.previousSibling
+        }
+        while (domBefore && !((desc = domBefore.pmViewDesc) && desc.parent == this)) domBefore = domBefore.previousSibling
+        return domBefore ? this.posBeforeChild(desc!) + desc!.size : this.posAtStart
+      } else {
+        let domAfter, desc: ViewDesc | undefined
+        if (dom == this.contentDOM) {
+          domAfter = dom.childNodes[offset]
+        } else {
+          while (dom.parentNode != this.contentDOM) dom = dom.parentNode!
+          domAfter = dom.nextSibling
+        }
+        while (domAfter && !((desc = domAfter.pmViewDesc) && desc.parent == this)) domAfter = domAfter.nextSibling
+        return domAfter ? this.posBeforeChild(desc!) : this.posAtEnd
+      }
+    }
+    // Otherwise, use various heuristics, falling back on the bias
+    // parameter, to determine whether to return the position at the
+    // start or at the end of this view desc.
+    let atEnd
+    if (dom == this.dom && this.contentDOM) {
+      atEnd = offset > domIndex(this.contentDOM)
+    } else if (this.contentDOM && this.contentDOM != this.dom && this.dom.contains(this.contentDOM)) {
+      atEnd = dom.compareDocumentPosition(this.contentDOM) & 2
+    } else if (this.dom.firstChild) {
+      if (offset == 0) for (let search = dom;; search = search.parentNode!) {
+        if (search == this.dom) { atEnd = false; break }
+        if (search.previousSibling) break
+      }
+      if (atEnd == null && offset == dom.childNodes.length) for (let search = dom;; search = search.parentNode!) {
+        if (search == this.dom) { atEnd = true; break }
+        if (search.nextSibling) break
+      }
+    }
+    return (atEnd == null ? bias > 0 : atEnd) ? this.posAtEnd : this.posAtStart
+  }
+
+  // Scan up the dom finding the first desc that is a descendant of
+  // this one.
+  nearestDesc(dom: DOMNode): ViewDesc | undefined
+  nearestDesc(dom: DOMNode, onlyNodes: true): NodeViewDesc | undefined
+  nearestDesc(dom: DOMNode, onlyNodes: boolean = false) {
+    for (let first = true, cur: DOMNode | null = dom; cur; cur = cur.parentNode) {
+      let desc = this.getDesc(cur), nodeDOM
+      if (desc && (!onlyNodes || desc.node)) {
+        // If dom is outside of this desc's nodeDOM, don't count it.
+        if (first && (nodeDOM = (desc as NodeViewDesc).nodeDOM) &&
+            !(nodeDOM.nodeType == 1 ? nodeDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode) : nodeDOM == dom))
+          first = false
+        else
+          return desc
+      }
+    }
+  }
+
+  getDesc(dom: DOMNode) {
+    let desc = dom.pmViewDesc
+    for (let cur: ViewDesc | undefined = desc; cur; cur = cur.parent) if (cur == this) return desc
+  }
+
+  posFromDOM(dom: DOMNode, offset: number, bias: number) {
+    for (let scan: DOMNode | null = dom; scan; scan = scan.parentNode) {
+      let desc = this.getDesc(scan)
+      if (desc) return desc.localPosFromDOM(dom, offset, bias)
+    }
+    return -1
+  }
+
+  // Find the desc for the node after the given pos, if any. (When a
+  // parent node overrode rendering, there might not be one.)
+  descAt(pos: number): ViewDesc | undefined {
+    for (let i = 0, offset = 0; i < this.children.length; i++) {
+      let child = this.children[i], end = offset + child.size
+      if (offset == pos && end != offset) {
+        while (!child.border && child.children.length) child = child.children[0]
+        return child
+      }
+      if (pos < end) return child.descAt(pos - offset - child.border)
+      offset = end
+    }
+  }
+
+  domFromPos(pos: number, side: number): {node: DOMNode, offset: number, atom?: number} {
+    if (!this.contentDOM) return {node: this.dom, offset: 0, atom: pos + 1}
+    // First find the position in the child array
+    let i = 0, offset = 0
+    for (let curPos = 0; i < this.children.length; i++) {
+      let child = this.children[i], end = curPos + child.size
+      if (end > pos || child instanceof TrailingHackViewDesc) { offset = pos - curPos; break }
+      curPos = end
+    }
+    // If this points into the middle of a child, call through
+    if (offset) return this.children[i].domFromPos(offset - this.children[i].border, side)
+    // Go back if there were any zero-length widgets with side >= 0 before this point
+    for (let prev; i && !(prev = this.children[i - 1]).size && prev instanceof WidgetViewDesc && prev.side >= 0; i--) {}
+    // Scan towards the first useable node
+    if (side <= 0) {
+      let prev, enter = true
+      for (;; i--, enter = false) {
+        prev = i ? this.children[i - 1] : null
+        if (!prev || prev.dom.parentNode == this.contentDOM) break
+      }
+      if (prev && side && enter && !prev.border && !prev.domAtom) return prev.domFromPos(prev.size, side)
+      return {node: this.contentDOM, offset: prev ? domIndex(prev.dom) + 1 : 0}
+    } else {
+      let next, enter = true
+      for (;; i++, enter = false) {
+        next = i < this.children.length ? this.children[i] : null
+        if (!next || next.dom.parentNode == this.contentDOM) break
+      }
+      if (next && enter && !next.border && !next.domAtom) return next.domFromPos(0, side)
+      return {node: this.contentDOM, offset: next ? domIndex(next.dom) : this.contentDOM.childNodes.length}
+    }
+  }
+
+  // Used to find a DOM range in a single parent for a given changed
+  // range.
+  parseRange(
+    from: number, to: number, base = 0
+  ): {node: DOMNode, from: number, to: number, fromOffset: number, toOffset: number} {
+    if (this.children.length == 0)
+      return {node: this.contentDOM!, from, to, fromOffset: 0, toOffset: this.contentDOM!.childNodes.length}
+
+    let fromOffset = -1, toOffset = -1
+    for (let offset = base, i = 0;; i++) {
+      let child = this.children[i], end = offset + child.size
+      if (fromOffset == -1 && from <= end) {
+        let childBase = offset + child.border
+        // FIXME maybe descend mark views to parse a narrower range?
+        if (from >= childBase && to <= end - child.border && child.node &&
+            child.contentDOM && this.contentDOM!.contains(child.contentDOM))
+          return child.parseRange(from, to, childBase)
+
+        from = offset
+        for (let j = i; j > 0; j--) {
+          let prev = this.children[j - 1]
+          if (prev.size && prev.dom.parentNode == this.contentDOM && !prev.emptyChildAt(1)) {
+            fromOffset = domIndex(prev.dom) + 1
+            break
+          }
+          from -= prev.size
+        }
+        if (fromOffset == -1) fromOffset = 0
+      }
+      if (fromOffset > -1 && (end > to || i == this.children.length - 1)) {
+        to = end
+        for (let j = i + 1; j < this.children.length; j++) {
+          let next = this.children[j]
+          if (next.size && next.dom.parentNode == this.contentDOM && !next.emptyChildAt(-1)) {
+            toOffset = domIndex(next.dom)
+            break
+          }
+          to += next.size
+        }
+        if (toOffset == -1) toOffset = this.contentDOM!.childNodes.length
+        break
+      }
+      offset = end
+    }
+    return {node: this.contentDOM!, from, to, fromOffset, toOffset}
+  }
+
+  emptyChildAt(side: number): boolean {
+    if (this.border || !this.contentDOM || !this.children.length) return false
+    let child = this.children[side < 0 ? 0 : this.children.length - 1]
+    return child.size == 0 || child.emptyChildAt(side)
+  }
+
+  domAfterPos(pos: number): DOMNode {
+    let {node, offset} = this.domFromPos(pos, 0)
+    if (node.nodeType != 1 || offset == node.childNodes.length)
+      throw new RangeError("No node after pos " + pos)
+    return node.childNodes[offset]
+  }
+
+  // View descs are responsible for setting any selection that falls
+  // entirely inside of them, so that custom implementations can do
+  // custom things with the selection. Note that this falls apart when
+  // a selection starts in such a node and ends in another, in which
+  // case we just use whatever domFromPos produces as a best effort.
+  setSelection(anchor: number, head: number, root: Document | ShadowRoot, force = false): void {
+    // If the selection falls entirely in a child, give it to that child
+    let from = Math.min(anchor, head), to = Math.max(anchor, head)
+    for (let i = 0, offset = 0; i < this.children.length; i++) {
+      let child = this.children[i], end = offset + child.size
+      if (from > offset && to < end)
+        return child.setSelection(anchor - offset - child.border, head - offset - child.border, root, force)
+      offset = end
+    }
+
+    let anchorDOM = this.domFromPos(anchor, anchor ? -1 : 1)
+    let headDOM = head == anchor ? anchorDOM : this.domFromPos(head, head ? -1 : 1)
+    let domSel = (root as Document).getSelection()!
+
+    let brKludge = false
+    // On Firefox, using Selection.collapse to put the cursor after a
+    // BR node for some reason doesn't always work (#1073). On Safari,
+    // the cursor sometimes inexplicable visually lags behind its
+    // reported position in such situations (#1092).
+    if ((browser.gecko || browser.safari) && anchor == head) {
+      let {node, offset} = anchorDOM
+      if (node.nodeType == 3) {
+        brKludge = !!(offset && node.nodeValue![offset - 1] == "\n")
+        // Issue #1128
+        if (brKludge && offset == node.nodeValue!.length) {
+           for (let scan: DOMNode | null = node, after; scan; scan = scan.parentNode) {
+            if (after = scan.nextSibling) {
+              if (after.nodeName == "BR")
+                anchorDOM = headDOM = {node: after.parentNode!, offset: domIndex(after) + 1}
+              break
+            }
+            let desc = scan.pmViewDesc
+            if (desc && desc.node && desc.node.isBlock) break
+          }
+        }
+      } else {
+        let prev = node.childNodes[offset - 1]
+        brKludge = prev && (prev.nodeName == "BR" || (prev as HTMLElement).contentEditable == "false")
+      }
+    }
+    // Firefox can act strangely when the selection is in front of an
+    // uneditable node. See #1163 and https://bugzilla.mozilla.org/show_bug.cgi?id=1709536
+    if (browser.gecko && domSel.focusNode && domSel.focusNode != headDOM.node && domSel.focusNode.nodeType == 1) {
+      let after = domSel.focusNode.childNodes[domSel.focusOffset]
+      if (after && (after as HTMLElement).contentEditable == "false") force = true
+    }
+
+    if (!(force || brKludge && browser.safari) &&
+        isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode!, domSel.anchorOffset) &&
+        isEquivalentPosition(headDOM.node, headDOM.offset, domSel.focusNode!, domSel.focusOffset))
+      return
+
+    // Selection.extend can be used to create an 'inverted' selection
+    // (one where the focus is before the anchor), but not all
+    // browsers support it yet.
+    let domSelExtended = false
+    if ((domSel.extend || anchor == head) && !brKludge) {
+      domSel.collapse(anchorDOM.node, anchorDOM.offset)
+      try {
+        if (anchor != head)
+          domSel.extend(headDOM.node, headDOM.offset)
+        domSelExtended = true
+      } catch (_) {
+        // In some cases with Chrome the selection is empty after calling
+        // collapse, even when it should be valid. This appears to be a bug, but
+        // it is difficult to isolate. If this happens fallback to the old path
+        // without using extend.
+        // Similarly, this could crash on Safari if the editor is hidden, and
+        // there was no selection.
+      }
+    }
+    if (!domSelExtended) {
+      if (anchor > head) { let tmp = anchorDOM; anchorDOM = headDOM; headDOM = tmp }
+      let range = document.createRange()
+      range.setEnd(headDOM.node, headDOM.offset)
+      range.setStart(anchorDOM.node, anchorDOM.offset)
+      domSel.removeAllRanges()
+      domSel.addRange(range)
+    }
+  }
+
+  ignoreMutation(mutation: MutationRecord): boolean {
+    return !this.contentDOM && (mutation.type as any) != "selection"
+  }
+
+  get contentLost() {
+    return this.contentDOM && this.contentDOM != this.dom && !this.dom.contains(this.contentDOM)
+  }
+
+  // Remove a subtree of the element tree that has been touched
+  // by a DOM change, so that the next update will redraw it.
+  markDirty(from: number, to: number) {
+    for (let offset = 0, i = 0; i < this.children.length; i++) {
+      let child = this.children[i], end = offset + child.size
+      if (offset == end ? from <= end && to >= offset : from < end && to > offset) {
+        let startInside = offset + child.border, endInside = end - child.border
+        if (from >= startInside && to <= endInside) {
+          this.dirty = from == offset || to == end ? CONTENT_DIRTY : CHILD_DIRTY
+          if (from == startInside && to == endInside &&
+              (child.contentLost || child.dom.parentNode != this.contentDOM)) child.dirty = NODE_DIRTY
+          else child.markDirty(from - startInside, to - startInside)
+          return
+        } else {
+          child.dirty = child.dom == child.contentDOM && child.dom.parentNode == this.contentDOM && !child.children.length
+            ? CONTENT_DIRTY : NODE_DIRTY
+        }
+      }
+      offset = end
+    }
+    this.dirty = CONTENT_DIRTY
+  }
+
+  markParentsDirty() {
+    let level = 1
+    for (let node = this.parent; node; node = node.parent, level++) {
+      let dirty = level == 1 ? CONTENT_DIRTY : CHILD_DIRTY
+      if (node.dirty < dirty) node.dirty = dirty
+    }
+  }
+
+  get domAtom() { return false }
+
+  get ignoreForCoords() { return false }
+
+  isText(text: string) { return false }
+}
+
+// A widget desc represents a widget decoration, which is a DOM node
+// drawn between the document nodes.
+class WidgetViewDesc extends ViewDesc {
+  constructor(parent: ViewDesc, readonly widget: Decoration, view: EditorView, pos: number) {
+    let self: WidgetViewDesc, dom = (widget.type as any).toDOM as WidgetConstructor
+    if (typeof dom == "function") dom = dom(view, () => {
+      if (!self) return pos
+      if (self.parent) return self.parent.posBeforeChild(self)
+    })
+    if (!widget.type.spec.raw) {
+      if (dom.nodeType != 1) {
+        let wrap = document.createElement("span")
+        wrap.appendChild(dom)
+        dom = wrap
+      }
+      ;(dom as HTMLElement).contentEditable = "false"
+      ;(dom as HTMLElement).classList.add("ProseMirror-widget")
+    }
+    super(parent, [], dom, null)
+    this.widget = widget
+    self = this
+  }
+
+  matchesWidget(widget: Decoration) {
+    return this.dirty == NOT_DIRTY && widget.type.eq(this.widget.type)
+  }
+
+  parseRule() { return {ignore: true} }
+
+  stopEvent(event: Event) {
+    let stop = this.widget.spec.stopEvent
+    return stop ? stop(event) : false
+  }
+
+  ignoreMutation(mutation: MutationRecord) {
+    return (mutation.type as any) != "selection" || this.widget.spec.ignoreSelection
+  }
+
+  destroy() {
+    this.widget.type.destroy(this.dom)
+    super.destroy()
+  }
+
+  get domAtom() { return true }
+
+  get side() { return (this.widget.type as any).side as number }
+}
+
+class CompositionViewDesc extends ViewDesc {
+  constructor(parent: ViewDesc, dom: DOMNode, readonly textDOM: Text, readonly text: string) {
+    super(parent, [], dom, null)
+  }
+
+  get size() { return this.text.length }
+
+  localPosFromDOM(dom: DOMNode, offset: number) {
+    if (dom != this.textDOM) return this.posAtStart + (offset ? this.size : 0)
+    return this.posAtStart + offset
+  }
+
+  domFromPos(pos: number) {
+    return {node: this.textDOM, offset: pos}
+  }
+
+  ignoreMutation(mut: MutationRecord) {
+    return mut.type === 'characterData' && mut.target.nodeValue == mut.oldValue
+   }
+}
+
+// A mark desc represents a mark. May have multiple children,
+// depending on how the mark is split. Note that marks are drawn using
+// a fixed nesting order, for simplicity and predictability, so in
+// some cases they will be split more often than would appear
+// necessary.
+class MarkViewDesc extends ViewDesc {
+  constructor(parent: ViewDesc, readonly mark: Mark, dom: DOMNode, contentDOM: HTMLElement) {
+    super(parent, [], dom, contentDOM)
+  }
+
+  static create(parent: ViewDesc, mark: Mark, inline: boolean, view: EditorView) {
+    let custom = view.nodeViews[mark.type.name]
+    let spec: {dom: HTMLElement, contentDOM?: HTMLElement} = custom && (custom as any)(mark, view, inline)
+    if (!spec || !spec.dom)
+      spec = (DOMSerializer.renderSpec as any)(document, mark.type.spec.toDOM!(mark, inline), null, mark.attrs) as any
+    return new MarkViewDesc(parent, mark, spec.dom, spec.contentDOM || spec.dom as HTMLElement)
+  }
+
+  parseRule() {
+    if ((this.dirty & NODE_DIRTY) || this.mark.type.spec.reparseInView) return null
+    return {mark: this.mark.type.name, attrs: this.mark.attrs, contentElement: this.contentDOM!}
+  }
+
+  matchesMark(mark: Mark) { return this.dirty != NODE_DIRTY && this.mark.eq(mark) }
+
+  markDirty(from: number, to: number) {
+    super.markDirty(from, to)
+    // Move dirty info to nearest node view
+    if (this.dirty != NOT_DIRTY) {
+      let parent = this.parent!
+      while (!parent.node) parent = parent.parent!
+      if (parent.dirty < this.dirty) parent.dirty = this.dirty
+      this.dirty = NOT_DIRTY
+    }
+  }
+
+  slice(from: number, to: number, view: EditorView) {
+    let copy = MarkViewDesc.create(this.parent!, this.mark, true, view)
+    let nodes = this.children, size = this.size
+    if (to < size) nodes = replaceNodes(nodes, to, size, view)
+    if (from > 0) nodes = replaceNodes(nodes, 0, from, view)
+    for (let i = 0; i < nodes.length; i++) nodes[i].parent = copy
+    copy.children = nodes
+    return copy
+  }
+}
+
+// Node view descs are the main, most common type of view desc, and
+// correspond to an actual node in the document. Unlike mark descs,
+// they populate their child array themselves.
+export class NodeViewDesc extends ViewDesc {
+  constructor(
+    parent: ViewDesc | undefined,
+    public node: Node,
+    public outerDeco: readonly Decoration[],
+    public innerDeco: DecorationSource,
+    dom: DOMNode,
+    contentDOM: HTMLElement | null,
+    readonly nodeDOM: DOMNode,
+    view: EditorView,
+    pos: number
+  ) {
+    super(parent, [], dom, contentDOM)
+  }
+
+  // By default, a node is rendered using the `toDOM` method from the
+  // node type spec. But client code can use the `nodeViews` spec to
+  // supply a custom node view, which can influence various aspects of
+  // the way the node works.
+  //
+  // (Using subclassing for this was intentionally decided against,
+  // since it'd require exposing a whole slew of finicky
+  // implementation details to the user code that they probably will
+  // never need.)
+  static create(parent: ViewDesc | undefined, node: Node, outerDeco: readonly Decoration[],
+                innerDeco: DecorationSource, view: EditorView, pos: number) {
+    let custom = view.nodeViews[node.type.name], descObj: ViewDesc
+    let spec: NodeView | undefined = custom && (custom as any)(node, view, () => {
+      // (This is a function that allows the custom view to find its
+      // own position)
+      if (!descObj) return pos
+      if (descObj.parent) return descObj.parent.posBeforeChild(descObj)
+    }, outerDeco, innerDeco)
+
+    let dom = spec && spec.dom, contentDOM = spec && spec.contentDOM
+    if (node.isText) {
+      if (!dom) dom = document.createTextNode(node.text!)
+      else if (dom.nodeType != 3) throw new RangeError("Text must be rendered as a DOM text node")
+    } else if (!dom) {
+      let spec = (DOMSerializer.renderSpec as any)(document, node.type.spec.toDOM!(node), null, node.attrs)
+      ;({dom, contentDOM} = spec as {dom: DOMNode, contentDOM?: HTMLElement})
+    }
+    if (!contentDOM && !node.isText && dom.nodeName != "BR") { // Chrome gets confused by <br contenteditable=false>
+      if (!(dom as HTMLElement).hasAttribute("contenteditable")) (dom as HTMLElement).contentEditable = "false"
+      if (node.type.spec.draggable) (dom as HTMLElement).draggable = true
+    }
+
+    let nodeDOM = dom
+    dom = applyOuterDeco(dom, outerDeco, node)
+
+    if (spec)
+      return descObj = new CustomNodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM,
+                                              spec, view, pos + 1)
+    else if (node.isText)
+      return new TextViewDesc(parent, node, outerDeco, innerDeco, dom, nodeDOM, view)
+    else
+      return new NodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM, view, pos + 1)
+  }
+
+  parseRule() {
+    // Experimental kludge to allow opt-in re-parsing of nodes
+    if (this.node.type.spec.reparseInView) return null
+    // FIXME the assumption that this can always return the current
+    // attrs means that if the user somehow manages to change the
+    // attrs in the dom, that won't be picked up. Not entirely sure
+    // whether this is a problem
+    let rule: Omit<TagParseRule, "tag"> = {node: this.node.type.name, attrs: this.node.attrs}
+    if (this.node.type.whitespace == "pre") rule.preserveWhitespace = "full"
+    if (!this.contentDOM) {
+      rule.getContent = () => this.node.content
+    } else if (!this.contentLost) {
+      rule.contentElement = this.contentDOM
+    } else {
+      // Chrome likes to randomly recreate parent nodes when
+      // backspacing things. When that happens, this tries to find the
+      // new parent.
+      for (let i = this.children.length - 1; i >= 0; i--) {
+        let child = this.children[i]
+        if (this.dom.contains(child.dom.parentNode)) {
+          rule.contentElement = child.dom.parentNode as HTMLElement
+          break
+        }
+      }
+      if (!rule.contentElement) rule.getContent = () => Fragment.empty
+    }
+    return rule
+  }
+
+  matchesNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource) {
+    return this.dirty == NOT_DIRTY && node.eq(this.node) &&
+      sameOuterDeco(outerDeco, this.outerDeco) && innerDeco.eq(this.innerDeco)
+  }
+
+  get size() { return this.node.nodeSize }
+
+  get border() { return this.node.isLeaf ? 0 : 1 }
+
+  // Syncs `this.children` to match `this.node.content` and the local
+  // decorations, possibly introducing nesting for marks. Then, in a
+  // separate step, syncs the DOM inside `this.contentDOM` to
+  // `this.children`.
+  updateChildren(view: EditorView, pos: number) {
+    let inline = this.node.inlineContent, off = pos
+    let composition = view.composing ? this.localCompositionInfo(view, pos) : null
+    let localComposition = composition && composition.pos > -1 ? composition : null
+    let compositionInChild = composition && composition.pos < 0
+    let updater = new ViewTreeUpdater(this, localComposition && localComposition.node, view)
+    iterDeco(this.node, this.innerDeco, (widget, i, insideNode) => {
+      if (widget.spec.marks)
+        updater.syncToMarks(widget.spec.marks, inline, view)
+      else if ((widget.type as WidgetType).side >= 0 && !insideNode)
+        updater.syncToMarks(i == this.node.childCount ? Mark.none : this.node.child(i).marks, inline, view)
+      // If the next node is a desc matching this widget, reuse it,
+      // otherwise insert the widget as a new view desc.
+      updater.placeWidget(widget, view, off)
+    }, (child, outerDeco, innerDeco, i) => {
+      // Make sure the wrapping mark descs match the node's marks.
+      updater.syncToMarks(child.marks, inline, view)
+      // Try several strategies for drawing this node
+      let compIndex
+      if (updater.findNodeMatch(child, outerDeco, innerDeco, i)) {
+        // Found precise match with existing node view
+      } else if (compositionInChild && view.state.selection.from > off &&
+                 view.state.selection.to < off + child.nodeSize &&
+                 (compIndex = updater.findIndexWithChild(composition!.node)) > -1 &&
+                 updater.updateNodeAt(child, outerDeco, innerDeco, compIndex, view)) {
+        // Updated the specific node that holds the composition
+      } else if (updater.updateNextNode(child, outerDeco, innerDeco, view, i, off)) {
+        // Could update an existing node to reflect this node
+      } else {
+        // Add it as a new view
+        updater.addNode(child, outerDeco, innerDeco, view, off)
+      }
+      off += child.nodeSize
+    })
+    // Drop all remaining descs after the current position.
+    updater.syncToMarks([], inline, view)
+    if (this.node.isTextblock) updater.addTextblockHacks()
+    updater.destroyRest()
+
+    // Sync the DOM if anything changed
+    if (updater.changed || this.dirty == CONTENT_DIRTY) {
+      // May have to protect focused DOM from being changed if a composition is active
+      if (localComposition) this.protectLocalComposition(view, localComposition)
+      renderDescs(this.contentDOM!, this.children, view)
+      if (browser.ios) iosHacks(this.dom as HTMLElement)
+    }
+  }
+
+  localCompositionInfo(view: EditorView, pos: number): {node: Text, pos: number, text: string} | null {
+    // Only do something if both the selection and a focused text node
+    // are inside of this node
+    let {from, to} = view.state.selection
+    if (!(view.state.selection instanceof TextSelection) || from < pos || to > pos + this.node.content.size) return null
+    let textNode = view.input.compositionNode
+    if (!textNode || !this.dom.contains(textNode.parentNode)) return null
+
+    if (this.node.inlineContent) {
+      // Find the text in the focused node in the node, stop if it's not
+      // there (may have been modified through other means, in which
+      // case it should overwritten)
+      let text = textNode.nodeValue!
+      let textPos = findTextInFragment(this.node.content, text, from - pos, to - pos)
+      return textPos < 0 ? null : {node: textNode, pos: textPos, text}
+    } else {
+      return {node: textNode, pos: -1, text: ""}
+    }
+  }
+
+  protectLocalComposition(view: EditorView, {node, pos, text}: {node: Text, pos: number, text: string}) {
+    // The node is already part of a local view desc, leave it there
+    if (this.getDesc(node)) return
+
+    // Create a composition view for the orphaned nodes
+    let topNode: DOMNode = node
+    for (;; topNode = topNode.parentNode!) {
+      if (topNode.parentNode == this.contentDOM) break
+      while (topNode.previousSibling) topNode.parentNode!.removeChild(topNode.previousSibling)
+      while (topNode.nextSibling) topNode.parentNode!.removeChild(topNode.nextSibling)
+      if (topNode.pmViewDesc) topNode.pmViewDesc = undefined
+    }
+    let desc = new CompositionViewDesc(this, topNode, node, text)
+    view.input.compositionNodes.push(desc)
+
+    // Patch up this.children to contain the composition view
+    this.children = replaceNodes(this.children, pos, pos + text.length, view, desc)
+  }
+
+  // If this desc must be updated to match the given node decoration,
+  // do so and return true.
+  update(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) {
+    if (this.dirty == NODE_DIRTY ||
+        !node.sameMarkup(this.node)) return false
+    this.updateInner(node, outerDeco, innerDeco, view)
+    return true
+  }
+
+  updateInner(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) {
+    this.updateOuterDeco(outerDeco)
+    this.node = node
+    this.innerDeco = innerDeco
+    if (this.contentDOM) this.updateChildren(view, this.posAtStart)
+    this.dirty = NOT_DIRTY
+  }
+
+  updateOuterDeco(outerDeco: readonly Decoration[]) {
+    if (sameOuterDeco(outerDeco, this.outerDeco)) return
+    let needsWrap = this.nodeDOM.nodeType != 1
+    let oldDOM = this.dom
+    this.dom = patchOuterDeco(this.dom, this.nodeDOM,
+                              computeOuterDeco(this.outerDeco, this.node, needsWrap),
+                              computeOuterDeco(outerDeco, this.node, needsWrap))
+    if (this.dom != oldDOM) {
+      oldDOM.pmViewDesc = undefined
+      this.dom.pmViewDesc = this
+    }
+    this.outerDeco = outerDeco
+  }
+
+  // Mark this node as being the selected node.
+  selectNode() {
+    if (this.nodeDOM.nodeType == 1) (this.nodeDOM as HTMLElement).classList.add("ProseMirror-selectednode")
+    if (this.contentDOM || !this.node.type.spec.draggable) (this.dom as HTMLElement).draggable = true
+  }
+
+  // Remove selected node marking from this node.
+  deselectNode() {
+    if (this.nodeDOM.nodeType == 1) {
+      ;(this.nodeDOM as HTMLElement).classList.remove("ProseMirror-selectednode")
+      if (this.contentDOM || !this.node.type.spec.draggable) (this.dom as HTMLElement).removeAttribute("draggable")
+    }
+  }
+
+  get domAtom() { return this.node.isAtom }
+}
+
+// Create a view desc for the top-level document node, to be exported
+// and used by the view class.
+export function docViewDesc(doc: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource,
+                            dom: HTMLElement, view: EditorView): NodeViewDesc {
+  applyOuterDeco(dom, outerDeco, doc)
+  let docView = new NodeViewDesc(undefined, doc, outerDeco, innerDeco, dom, dom, dom, view, 0)
+  if (docView.contentDOM) docView.updateChildren(view, 0)
+  return docView
+}
+
+class TextViewDesc extends NodeViewDesc {
+  constructor(parent: ViewDesc | undefined, node: Node, outerDeco: readonly Decoration[],
+              innerDeco: DecorationSource, dom: DOMNode, nodeDOM: DOMNode, view: EditorView) {
+    super(parent, node, outerDeco, innerDeco, dom, null, nodeDOM, view, 0)
+  }
+
+  parseRule() {
+    let skip = this.nodeDOM.parentNode
+    while (skip && skip != this.dom && !(skip as any).pmIsDeco) skip = skip.parentNode
+    return {skip: (skip || true) as any}
+  }
+
+  update(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) {
+    if (this.dirty == NODE_DIRTY || (this.dirty != NOT_DIRTY && !this.inParent()) ||
+        !node.sameMarkup(this.node)) return false
+    this.updateOuterDeco(outerDeco)
+    if ((this.dirty != NOT_DIRTY || node.text != this.node.text) && node.text != this.nodeDOM.nodeValue) {
+      this.nodeDOM.nodeValue = node.text!
+      if (view.trackWrites == this.nodeDOM) view.trackWrites = null
+    }
+    this.node = node
+    this.dirty = NOT_DIRTY
+    return true
+  }
+
+  inParent() {
+    let parentDOM = this.parent!.contentDOM
+    for (let n: DOMNode | null = this.nodeDOM; n; n = n.parentNode) if (n == parentDOM) return true
+    return false
+  }
+
+  domFromPos(pos: number) {
+    return {node: this.nodeDOM, offset: pos}
+  }
+
+  localPosFromDOM(dom: DOMNode, offset: number, bias: number) {
+    if (dom == this.nodeDOM) return this.posAtStart + Math.min(offset, this.node.text!.length)
+    return super.localPosFromDOM(dom, offset, bias)
+  }
+
+  ignoreMutation(mutation: MutationRecord) {
+    return mutation.type != "characterData" && (mutation.type as any) != "selection"
+  }
+
+  slice(from: number, to: number, view: EditorView) {
+    let node = this.node.cut(from, to), dom = document.createTextNode(node.text!)
+    return new TextViewDesc(this.parent, node, this.outerDeco, this.innerDeco, dom, dom, view)
+  }
+
+  markDirty(from: number, to: number) {
+    super.markDirty(from, to)
+    if (this.dom != this.nodeDOM && (from == 0 || to == this.nodeDOM.nodeValue!.length))
+      this.dirty = NODE_DIRTY
+  }
+
+  get domAtom() { return false }
+
+  isText(text: string) { return this.node.text == text }
+}
+
+// A dummy desc used to tag trailing BR or IMG nodes created to work
+// around contentEditable terribleness.
+class TrailingHackViewDesc extends ViewDesc {
+  parseRule() { return {ignore: true} }
+  matchesHack(nodeName: string) { return this.dirty == NOT_DIRTY && this.dom.nodeName == nodeName }
+  get domAtom() { return true }
+  get ignoreForCoords() { return this.dom.nodeName == "IMG" }
+}
+
+// A separate subclass is used for customized node views, so that the
+// extra checks only have to be made for nodes that are actually
+// customized.
+class CustomNodeViewDesc extends NodeViewDesc {
+  constructor(parent: ViewDesc | undefined, node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource,
+              dom: DOMNode, contentDOM: HTMLElement | null, nodeDOM: DOMNode, readonly spec: NodeView,
+              view: EditorView, pos: number) {
+    super(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, view, pos)
+  }
+
+  // A custom `update` method gets to decide whether the update goes
+  // through. If it does, and there's a `contentDOM` node, our logic
+  // updates the children.
+  update(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) {
+    if (this.dirty == NODE_DIRTY) return false
+    if (this.spec.update) {
+      let result = this.spec.update(node, outerDeco, innerDeco)
+      if (result) this.updateInner(node, outerDeco, innerDeco, view)
+      return result
+    } else if (!this.contentDOM && !node.isLeaf) {
+      return false
+    } else {
+      return super.update(node, outerDeco, innerDeco, view)
+    }
+  }
+
+  selectNode() {
+    this.spec.selectNode ? this.spec.selectNode() : super.selectNode()
+  }
+
+  deselectNode() {
+    this.spec.deselectNode ? this.spec.deselectNode() : super.deselectNode()
+  }
+
+  setSelection(anchor: number, head: number, root: Document | ShadowRoot, force: boolean) {
+    this.spec.setSelection ? this.spec.setSelection(anchor, head, root)
+      : super.setSelection(anchor, head, root, force)
+  }
+
+  destroy() {
+    if (this.spec.destroy) this.spec.destroy()
+    super.destroy()
+  }
+
+  stopEvent(event: Event) {
+    return this.spec.stopEvent ? this.spec.stopEvent(event) : false
+  }
+
+  ignoreMutation(mutation: MutationRecord) {
+    return this.spec.ignoreMutation ? this.spec.ignoreMutation(mutation) : super.ignoreMutation(mutation)
+  }
+}
+
+// Sync the content of the given DOM node with the nodes associated
+// with the given array of view descs, recursing into mark descs
+// because this should sync the subtree for a whole node at a time.
+function renderDescs(parentDOM: HTMLElement, descs: readonly ViewDesc[], view: EditorView) {
+  let dom = parentDOM.firstChild, written = false
+  for (let i = 0; i < descs.length; i++) {
+    let desc = descs[i], childDOM = desc.dom
+    if (childDOM.parentNode == parentDOM) {
+      while (childDOM != dom) { dom = rm(dom!); written = true }
+      dom = dom.nextSibling
+    } else {
+      written = true
+      parentDOM.insertBefore(childDOM, dom)
+    }
+    if (desc instanceof MarkViewDesc) {
+      let pos = dom ? dom.previousSibling : parentDOM.lastChild
+      renderDescs(desc.contentDOM!, desc.children, view)
+      dom = pos ? pos.nextSibling : parentDOM.firstChild
+    }
+  }
+  while (dom) { dom = rm(dom); written = true }
+  if (written && view.trackWrites == parentDOM) view.trackWrites = null
+}
+
+type OuterDecoLevel = {[attr: string]: string}
+
+const OuterDecoLevel: {new (nodeName?: string): OuterDecoLevel} = function(this: any, nodeName?: string) {
+  if (nodeName) this.nodeName = nodeName
+} as any
+OuterDecoLevel.prototype = Object.create(null)
+
+const noDeco = [new OuterDecoLevel]
+
+function computeOuterDeco(outerDeco: readonly Decoration[], node: Node, needsWrap: boolean) {
+  if (outerDeco.length == 0) return noDeco
+
+  let top = needsWrap ? noDeco[0] : new OuterDecoLevel, result = [top]
+
+  for (let i = 0; i < outerDeco.length; i++) {
+    let attrs = (outerDeco[i].type as NodeType).attrs
+    if (!attrs) continue
+    if (attrs.nodeName)
+      result.push(top = new OuterDecoLevel(attrs.nodeName))
+
+    for (let name in attrs) {
+      let val = attrs[name]
+      if (val == null) continue
+      if (needsWrap && result.length == 1)
+        result.push(top = new OuterDecoLevel(node.isInline ? "span" : "div"))
+      if (name == "class") top.class = (top.class ? top.class + " " : "") + val
+      else if (name == "style") top.style = (top.style ? top.style + ";" : "") + val
+      else if (name != "nodeName") top[name] = val
+    }
+  }
+
+  return result
+}
+
+function patchOuterDeco(outerDOM: DOMNode, nodeDOM: DOMNode,
+                        prevComputed: readonly OuterDecoLevel[], curComputed: readonly OuterDecoLevel[]) {
+  // Shortcut for trivial case
+  if (prevComputed == noDeco && curComputed == noDeco) return nodeDOM
+
+  let curDOM = nodeDOM
+  for (let i = 0; i < curComputed.length; i++) {
+    let deco = curComputed[i], prev = prevComputed[i]
+    if (i) {
+      let parent: DOMNode | null
+      if (prev && prev.nodeName == deco.nodeName && curDOM != outerDOM &&
+          (parent = curDOM.parentNode) && parent.nodeName!.toLowerCase() == deco.nodeName) {
+        curDOM = parent
+      } else {
+        parent = document.createElement(deco.nodeName)
+        ;(parent as any).pmIsDeco = true
+        parent.appendChild(curDOM)
+        prev = noDeco[0]
+        curDOM = parent
+      }
+    }
+    patchAttributes(curDOM as HTMLElement, prev || noDeco[0], deco)
+  }
+  return curDOM
+}
+
+function patchAttributes(dom: HTMLElement, prev: {[name: string]: string}, cur: {[name: string]: string}) {
+  for (let name in prev)
+    if (name != "class" && name != "style" && name != "nodeName" && !(name in cur))
+      dom.removeAttribute(name)
+  for (let name in cur)
+    if (name != "class" && name != "style" && name != "nodeName" && cur[name] != prev[name])
+      dom.setAttribute(name, cur[name])
+  if (prev.class != cur.class) {
+    let prevList = prev.class ? prev.class.split(" ").filter(Boolean) : []
+    let curList = cur.class ? cur.class.split(" ").filter(Boolean) : []
+    for (let i = 0; i < prevList.length; i++) if (curList.indexOf(prevList[i]) == -1)
+      dom.classList.remove(prevList[i])
+    for (let i = 0; i < curList.length; i++) if (prevList.indexOf(curList[i]) == -1)
+      dom.classList.add(curList[i])
+    if (dom.classList.length == 0)
+      dom.removeAttribute("class")
+  }
+  if (prev.style != cur.style) {
+    if (prev.style) {
+      let prop = /\s*([\w\-\xa1-\uffff]+)\s*:(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\(.*?\)|[^;])*/g, m
+      while (m = prop.exec(prev.style))
+        dom.style.removeProperty(m[1])
+    }
+    if (cur.style)
+      dom.style.cssText += cur.style
+  }
+}
+
+function applyOuterDeco(dom: DOMNode, deco: readonly Decoration[], node: Node) {
+  return patchOuterDeco(dom, dom, noDeco, computeOuterDeco(deco, node, dom.nodeType != 1))
+}
+
+function sameOuterDeco(a: readonly Decoration[], b: readonly Decoration[]) {
+  if (a.length != b.length) return false
+  for (let i = 0; i < a.length; i++) if (!a[i].type.eq(b[i].type)) return false
+  return true
+}
+
+// Remove a DOM node and return its next sibling.
+function rm(dom: DOMNode) {
+  let next = dom.nextSibling
+  dom.parentNode!.removeChild(dom)
+  return next
+}
+
+// Helper class for incrementally updating a tree of mark descs and
+// the widget and node descs inside of them.
+class ViewTreeUpdater {
+  // Index into `this.top`'s child array, represents the current
+  // update position.
+  index = 0
+  // When entering a mark, the current top and index are pushed
+  // onto this.
+  stack: (ViewDesc | number)[] = []
+  // Tracks whether anything was changed
+  changed = false
+  preMatch: {index: number, matched: Map<ViewDesc, number>, matches: readonly ViewDesc[]}
+  top: ViewDesc
+
+  constructor(top: NodeViewDesc, readonly lock: DOMNode | null, private readonly view: EditorView) {
+    this.top = top
+    this.preMatch = preMatch(top.node.content, top)
+  }
+
+  // Destroy and remove the children between the given indices in
+  // `this.top`.
+  destroyBetween(start: number, end: number) {
+    if (start == end) return
+    for (let i = start; i < end; i++) this.top.children[i].destroy()
+    this.top.children.splice(start, end - start)
+    this.changed = true
+  }
+
+  // Destroy all remaining children in `this.top`.
+  destroyRest() {
+    this.destroyBetween(this.index, this.top.children.length)
+  }
+
+  // Sync the current stack of mark descs with the given array of
+  // marks, reusing existing mark descs when possible.
+  syncToMarks(marks: readonly Mark[], inline: boolean, view: EditorView) {
+    let keep = 0, depth = this.stack.length >> 1
+    let maxKeep = Math.min(depth, marks.length)
+    while (keep < maxKeep &&
+           (keep == depth - 1 ? this.top : this.stack[(keep + 1) << 1] as ViewDesc)
+             .matchesMark(marks[keep]) && marks[keep].type.spec.spanning !== false)
+      keep++
+
+    while (keep < depth) {
+      this.destroyRest()
+      this.top.dirty = NOT_DIRTY
+      this.index = this.stack.pop() as number
+      this.top = this.stack.pop() as ViewDesc
+      depth--
+    }
+    while (depth < marks.length) {
+      this.stack.push(this.top, this.index + 1)
+      let found = -1
+      for (let i = this.index; i < Math.min(this.index + 3, this.top.children.length); i++) {
+        let next = this.top.children[i]
+        if (next.matchesMark(marks[depth]) && !this.isLocked(next.dom)) { found = i; break }
+      }
+      if (found > -1) {
+        if (found > this.index) {
+          this.changed = true
+          this.destroyBetween(this.index, found)
+        }
+        this.top = this.top.children[this.index]
+      } else {
+        let markDesc = MarkViewDesc.create(this.top, marks[depth], inline, view)
+        this.top.children.splice(this.index, 0, markDesc)
+        this.top = markDesc
+        this.changed = true
+      }
+      this.index = 0
+      depth++
+    }
+  }
+
+  // Try to find a node desc matching the given data. Skip over it and
+  // return true when successful.
+  findNodeMatch(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, index: number): boolean {
+    let found = -1, targetDesc
+    if (index >= this.preMatch.index &&
+        (targetDesc = this.preMatch.matches[index - this.preMatch.index]).parent == this.top &&
+        targetDesc.matchesNode(node, outerDeco, innerDeco)) {
+      found = this.top.children.indexOf(targetDesc, this.index)
+    } else {
+      for (let i = this.index, e = Math.min(this.top.children.length, i + 5); i < e; i++) {
+        let child = this.top.children[i]
+        if (child.matchesNode(node, outerDeco, innerDeco) && !this.preMatch.matched.has(child)) {
+          found = i
+          break
+        }
+      }
+    }
+    if (found < 0) return false
+    this.destroyBetween(this.index, found)
+    this.index++
+    return true
+  }
+
+  updateNodeAt(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, index: number, view: EditorView) {
+    let child = this.top.children[index] as NodeViewDesc
+    if (child.dirty == NODE_DIRTY && child.dom == child.contentDOM) child.dirty = CONTENT_DIRTY
+    if (!child.update(node, outerDeco, innerDeco, view)) return false
+    this.destroyBetween(this.index, index)
+    this.index++
+    return true
+  }
+
+  findIndexWithChild(domNode: DOMNode) {
+    for (;;) {
+      let parent = domNode.parentNode
+      if (!parent) return -1
+      if (parent == this.top.contentDOM) {
+        let desc = domNode.pmViewDesc
+        if (desc) for (let i = this.index; i < this.top.children.length; i++) {
+          if (this.top.children[i] == desc) return i
+        }
+        return -1
+      }
+      domNode = parent
+    }
+  }
+
+  // Try to update the next node, if any, to the given data. Checks
+  // pre-matches to avoid overwriting nodes that could still be used.
+  updateNextNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource,
+                 view: EditorView, index: number, pos: number): boolean {
+    for (let i = this.index; i < this.top.children.length; i++) {
+      let next = this.top.children[i]
+      if (next instanceof NodeViewDesc) {
+        let preMatch = this.preMatch.matched.get(next)
+        if (preMatch != null && preMatch != index) return false
+        let nextDOM = next.dom, updated
+
+        // Can't update if nextDOM is or contains this.lock, except if
+        // it's a text node whose content already matches the new text
+        // and whose decorations match the new ones.
+        let locked = this.isLocked(nextDOM) &&
+            !(node.isText && next.node && next.node.isText && next.nodeDOM.nodeValue == node.text &&
+              next.dirty != NODE_DIRTY && sameOuterDeco(outerDeco, next.outerDeco))
+        if (!locked && next.update(node, outerDeco, innerDeco, view)) {
+          this.destroyBetween(this.index, i)
+          if (next.dom != nextDOM) this.changed = true
+          this.index++
+          return true
+        } else if (!locked && (updated = this.recreateWrapper(next, node, outerDeco, innerDeco, view, pos))) {
+          this.top.children[this.index] = updated
+          if (updated.contentDOM) {
+            updated.dirty = CONTENT_DIRTY
+            updated.updateChildren(view, pos + 1)
+            updated.dirty = NOT_DIRTY
+          }
+          this.changed = true
+          this.index++
+          return true
+        }
+        break
+      }
+    }
+    return false
+  }
+
+  // When a node with content is replaced by a different node with
+  // identical content, move over its children.
+  recreateWrapper(next: NodeViewDesc, node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource,
+                  view: EditorView, pos: number) {
+    if (next.dirty || node.isAtom || !next.children.length ||
+        !next.node.content.eq(node.content)) return null
+    let wrapper = NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos)
+    if (wrapper.contentDOM) {
+      wrapper.children = next.children
+      next.children = []
+      for (let ch of wrapper.children) ch.parent = wrapper
+    }
+    next.destroy()
+    return wrapper
+  }
+
+  // Insert the node as a newly created node desc.
+  addNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView, pos: number) {
+    let desc = NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos)
+    if (desc.contentDOM) desc.updateChildren(view, pos + 1)
+    this.top.children.splice(this.index++, 0, desc)
+    this.changed = true
+  }
+
+  placeWidget(widget: Decoration, view: EditorView, pos: number) {
+    let next = this.index < this.top.children.length ? this.top.children[this.index] : null
+    if (next && next.matchesWidget(widget) &&
+        (widget == (next as WidgetViewDesc).widget || !(next as any).widget.type.toDOM.parentNode)) {
+      this.index++
+    } else {
+      let desc = new WidgetViewDesc(this.top, widget, view, pos)
+      this.top.children.splice(this.index++, 0, desc)
+      this.changed = true
+    }
+  }
+
+  // Make sure a textblock looks and behaves correctly in
+  // contentEditable.
+  addTextblockHacks() {
+    let lastChild = this.top.children[this.index - 1], parent = this.top
+    while (lastChild instanceof MarkViewDesc) {
+      parent = lastChild
+      lastChild = parent.children[parent.children.length - 1]
+    }
+
+    if (!lastChild || // Empty textblock
+        !(lastChild instanceof TextViewDesc) ||
+        /\n$/.test(lastChild.node.text!) ||
+        (this.view.requiresGeckoHackNode && /\s$/.test(lastChild.node.text!))) {
+      // Avoid bugs in Safari's cursor drawing (#1165) and Chrome's mouse selection (#1152)
+      if ((browser.safari || browser.chrome) && lastChild && (lastChild.dom as HTMLElement).contentEditable == "false")
+        this.addHackNode("IMG", parent)
+      this.addHackNode("BR", this.top)
+    }
+  }
+
+  addHackNode(nodeName: string, parent: ViewDesc) {
+    if (parent == this.top && this.index < parent.children.length && parent.children[this.index].matchesHack(nodeName)) {
+      this.index++
+    } else {
+      let dom = document.createElement(nodeName)
+      if (nodeName == "IMG") {
+        dom.className = "ProseMirror-separator"
+        ;(dom as HTMLImageElement).alt = ""
+      }
+      if (nodeName == "BR") dom.className = "ProseMirror-trailingBreak"
+      let hack = new TrailingHackViewDesc(this.top, [], dom, null)
+      if (parent != this.top) parent.children.push(hack)
+      else parent.children.splice(this.index++, 0, hack)
+      this.changed = true
+    }
+  }
+
+  isLocked(node: DOMNode) {
+    return this.lock && (node == this.lock || node.nodeType == 1 && node.contains(this.lock.parentNode))
+  }
+}
+
+// Iterate from the end of the fragment and array of descs to find
+// directly matching ones, in order to avoid overeagerly reusing those
+// for other nodes. Returns the fragment index of the first node that
+// is part of the sequence of matched nodes at the end of the
+// fragment.
+function preMatch(
+  frag: Fragment, parentDesc: ViewDesc
+): {index: number, matched: Map<ViewDesc, number>, matches: readonly ViewDesc[]} {
+  let curDesc = parentDesc, descI = curDesc.children.length
+  let fI = frag.childCount, matched = new Map, matches = []
+  outer: while (fI > 0) {
+    let desc
+    for (;;) {
+      if (descI) {
+        let next = curDesc.children[descI - 1]
+        if (next instanceof MarkViewDesc) {
+          curDesc = next
+          descI = next.children.length
+        } else {
+          desc = next
+          descI--
+          break
+        }
+      } else if (curDesc == parentDesc) {
+        break outer
+      } else {
+        // FIXME
+        descI = curDesc.parent!.children.indexOf(curDesc)
+        curDesc = curDesc.parent!
+      }
+    }
+    let node = desc.node
+    if (!node) continue
+    if (node != frag.child(fI - 1)) break
+    --fI
+    matched.set(desc, fI)
+    matches.push(desc)
+  }
+  return {index: fI, matched, matches: matches.reverse()}
+}
+
+function compareSide(a: Decoration, b: Decoration) {
+  return (a.type as WidgetType).side - (b.type as WidgetType).side
+}
+
+// This function abstracts iterating over the nodes and decorations in
+// a fragment. Calls `onNode` for each node, with its local and child
+// decorations. Splits text nodes when there is a decoration starting
+// or ending inside of them. Calls `onWidget` for each widget.
+function iterDeco(
+  parent: Node,
+  deco: DecorationSource,
+  onWidget: (widget: Decoration, index: number, insideNode: boolean) => void,
+  onNode: (node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, index: number) => void
+) {
+  let locals = deco.locals(parent), offset = 0
+  // Simple, cheap variant for when there are no local decorations
+  if (locals.length == 0) {
+    for (let i = 0; i < parent.childCount; i++) {
+      let child = parent.child(i)
+      onNode(child, locals, deco.forChild(offset, child), i)
+      offset += child.nodeSize
+    }
+    return
+  }
+
+  let decoIndex = 0, active = [], restNode = null
+  for (let parentIndex = 0;;) {
+    let widget, widgets
+    while (decoIndex < locals.length && locals[decoIndex].to == offset) {
+      let next = locals[decoIndex++]
+      if (next.widget) {
+        if (!widget) widget = next
+        else (widgets || (widgets = [widget])).push(next)
+      }
+    }
+    if (widget) {
+      if (widgets) {
+        widgets.sort(compareSide)
+        for (let i = 0; i < widgets.length; i++) onWidget(widgets[i], parentIndex, !!restNode)
+      } else {
+        onWidget(widget, parentIndex, !!restNode)
+      }
+    }
+
+    let child, index
+    if (restNode) {
+      index = -1
+      child = restNode
+      restNode = null
+    } else if (parentIndex < parent.childCount) {
+      index = parentIndex
+      child = parent.child(parentIndex++)
+    } else {
+      break
+    }
+
+    for (let i = 0; i < active.length; i++) if (active[i].to <= offset) active.splice(i--, 1)
+    while (decoIndex < locals.length && locals[decoIndex].from <= offset && locals[decoIndex].to > offset)
+      active.push(locals[decoIndex++])
+
+    let end = offset + child.nodeSize
+    if (child.isText) {
+      let cutAt = end
+      if (decoIndex < locals.length && locals[decoIndex].from < cutAt) cutAt = locals[decoIndex].from
+      for (let i = 0; i < active.length; i++) if (active[i].to < cutAt) cutAt = active[i].to
+      if (cutAt < end) {
+        restNode = child.cut(cutAt - offset)
+        child = child.cut(0, cutAt - offset)
+        end = cutAt
+        index = -1
+      }
+    } else {
+      while (decoIndex < locals.length && locals[decoIndex].to < end) decoIndex++
+    }
+
+    let outerDeco = child.isInline && !child.isLeaf ? active.filter(d => !d.inline) : active.slice()
+    onNode(child, outerDeco, deco.forChild(offset, child), index)
+    offset = end
+  }
+}
+
+// List markers in Mobile Safari will mysteriously disappear
+// sometimes. This works around that.
+function iosHacks(dom: HTMLElement) {
+  if (dom.nodeName == "UL" || dom.nodeName == "OL") {
+    let oldCSS = dom.style.cssText
+    dom.style.cssText = oldCSS + "; list-style: square !important"
+    window.getComputedStyle(dom).listStyle
+    dom.style.cssText = oldCSS
+  }
+}
+
+// Find a piece of text in an inline fragment, overlapping from-to
+function findTextInFragment(frag: Fragment, text: string, from: number, to: number) {
+  for (let i = 0, pos = 0; i < frag.childCount && pos <= to;) {
+    let child = frag.child(i++), childStart = pos
+    pos += child.nodeSize
+    if (!child.isText) continue
+    let str = child.text!
+    while (i < frag.childCount) {
+      let next = frag.child(i++)
+      pos += next.nodeSize
+      if (!next.isText) break
+      str += next.text
+    }
+    if (pos >= from) {
+      if (pos >= to && str.slice(to - text.length - childStart, to - childStart) == text)
+        return to - text.length
+      let found = childStart < to ? str.lastIndexOf(text, to - childStart - 1) : -1
+      if (found >= 0 && found + text.length + childStart >= from)
+        return childStart + found
+      if (from == to && str.length >= (to + text.length) - childStart &&
+          str.slice(to - childStart, to - childStart + text.length) == text)
+        return to
+    }
+  }
+  return -1
+}
+
+// Replace range from-to in an array of view descs with replacement
+// (may be null to just delete). This goes very much against the grain
+// of the rest of this code, which tends to create nodes with the
+// right shape in one go, rather than messing with them after
+// creation, but is necessary in the composition hack.
+function replaceNodes(nodes: readonly ViewDesc[], from: number, to: number, view: EditorView, replacement?: ViewDesc) {
+  let result = []
+  for (let i = 0, off = 0; i < nodes.length; i++) {
+    let child = nodes[i], start = off, end = off += child.size
+    if (start >= to || end <= from) {
+      result.push(child)
+    } else {
+      if (start < from) result.push((child as MarkViewDesc | TextViewDesc).slice(0, from - start, view))
+      if (replacement) {
+        result.push(replacement)
+        replacement = undefined
+      }
+      if (end > to) result.push((child as MarkViewDesc | TextViewDesc).slice(to - start, child.size, view))
+    }
+  }
+  return result
+}