UNPKG

prosemirror-view

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