) => 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 {
+ /// 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 `
` 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 = {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 | 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
+ 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 = {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, 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, 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
+}