capturekeys.ts (13414B)
1 import {Selection, NodeSelection, TextSelection, AllSelection, EditorState} from "prosemirror-state" 2 import {EditorView} from "./index" 3 import * as browser from "./browser" 4 import {domIndex, selectionCollapsed, hasBlockDesc} from "./dom" 5 import {selectionToDOM} from "./selection" 6 7 function moveSelectionBlock(state: EditorState, dir: number) { 8 let {$anchor, $head} = state.selection 9 let $side = dir > 0 ? $anchor.max($head) : $anchor.min($head) 10 let $start = !$side.parent.inlineContent ? $side : $side.depth ? state.doc.resolve(dir > 0 ? $side.after() : $side.before()) : null 11 return $start && Selection.findFrom($start, dir) 12 } 13 14 function apply(view: EditorView, sel: Selection) { 15 view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()) 16 return true 17 } 18 19 function selectHorizontally(view: EditorView, dir: number, mods: string) { 20 let sel = view.state.selection 21 if (sel instanceof TextSelection) { 22 if (mods.indexOf("s") > -1) { 23 let {$head} = sel, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter 24 if (!node || node.isText || !node.isLeaf) return false 25 let $newHead = view.state.doc.resolve($head.pos + node.nodeSize * (dir < 0 ? -1 : 1)) 26 return apply(view, new TextSelection(sel.$anchor, $newHead)) 27 } else if (!sel.empty) { 28 return false 29 } else if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) { 30 let next = moveSelectionBlock(view.state, dir) 31 if (next && (next instanceof NodeSelection)) return apply(view, next) 32 return false 33 } else if (!(browser.mac && mods.indexOf("m") > -1)) { 34 let $head = sel.$head, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter, desc 35 if (!node || node.isText) return false 36 let nodePos = dir < 0 ? $head.pos - node.nodeSize : $head.pos 37 if (!(node.isAtom || (desc = view.docView.descAt(nodePos)) && !desc.contentDOM)) return false 38 if (NodeSelection.isSelectable(node)) { 39 return apply(view, new NodeSelection(dir < 0 ? view.state.doc.resolve($head.pos - node.nodeSize) : $head)) 40 } else if (browser.webkit) { 41 // Chrome and Safari will introduce extra pointless cursor 42 // positions around inline uneditable nodes, so we have to 43 // take over and move the cursor past them (#937) 44 return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize))) 45 } else { 46 return false 47 } 48 } 49 } else if (sel instanceof NodeSelection && sel.node.isInline) { 50 return apply(view, new TextSelection(dir > 0 ? sel.$to : sel.$from)) 51 } else { 52 let next = moveSelectionBlock(view.state, dir) 53 if (next) return apply(view, next) 54 return false 55 } 56 } 57 58 function nodeLen(node: Node) { 59 return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length 60 } 61 62 function isIgnorable(dom: Node, dir: number) { 63 let desc = dom.pmViewDesc 64 return desc && desc.size == 0 && (dir < 0 || dom.nextSibling || dom.nodeName != "BR") 65 } 66 67 function skipIgnoredNodes(view: EditorView, dir: number) { 68 return dir < 0 ? skipIgnoredNodesBefore(view) : skipIgnoredNodesAfter(view) 69 } 70 71 // Make sure the cursor isn't directly after one or more ignored 72 // nodes, which will confuse the browser's cursor motion logic. 73 function skipIgnoredNodesBefore(view: EditorView) { 74 let sel = view.domSelectionRange() 75 let node = sel.focusNode!, offset = sel.focusOffset 76 if (!node) return 77 let moveNode, moveOffset: number | undefined, force = false 78 // Gecko will do odd things when the selection is directly in front 79 // of a non-editable node, so in that case, move it into the next 80 // node if possible. Issue prosemirror/prosemirror#832. 81 if (browser.gecko && node.nodeType == 1 && offset < nodeLen(node) && isIgnorable(node.childNodes[offset], -1)) force = true 82 for (;;) { 83 if (offset > 0) { 84 if (node.nodeType != 1) { 85 break 86 } else { 87 let before = node.childNodes[offset - 1] 88 if (isIgnorable(before, -1)) { 89 moveNode = node 90 moveOffset = --offset 91 } else if (before.nodeType == 3) { 92 node = before 93 offset = node.nodeValue!.length 94 } else break 95 } 96 } else if (isBlockNode(node)) { 97 break 98 } else { 99 let prev = node.previousSibling 100 while (prev && isIgnorable(prev, -1)) { 101 moveNode = node.parentNode 102 moveOffset = domIndex(prev) 103 prev = prev.previousSibling 104 } 105 if (!prev) { 106 node = node.parentNode! 107 if (node == view.dom) break 108 offset = 0 109 } else { 110 node = prev 111 offset = nodeLen(node) 112 } 113 } 114 } 115 if (force) setSelFocus(view, node, offset) 116 else if (moveNode) setSelFocus(view, moveNode, moveOffset!) 117 } 118 119 // Make sure the cursor isn't directly before one or more ignored 120 // nodes. 121 function skipIgnoredNodesAfter(view: EditorView) { 122 let sel = view.domSelectionRange() 123 let node = sel.focusNode!, offset = sel.focusOffset 124 if (!node) return 125 let len = nodeLen(node) 126 let moveNode, moveOffset: number | undefined 127 for (;;) { 128 if (offset < len) { 129 if (node.nodeType != 1) break 130 let after = node.childNodes[offset] 131 if (isIgnorable(after, 1)) { 132 moveNode = node 133 moveOffset = ++offset 134 } 135 else break 136 } else if (isBlockNode(node)) { 137 break 138 } else { 139 let next = node.nextSibling 140 while (next && isIgnorable(next, 1)) { 141 moveNode = next.parentNode 142 moveOffset = domIndex(next) + 1 143 next = next.nextSibling 144 } 145 if (!next) { 146 node = node.parentNode! 147 if (node == view.dom) break 148 offset = len = 0 149 } else { 150 node = next 151 offset = 0 152 len = nodeLen(node) 153 } 154 } 155 } 156 if (moveNode) setSelFocus(view, moveNode, moveOffset!) 157 } 158 159 function isBlockNode(dom: Node) { 160 let desc = dom.pmViewDesc 161 return desc && desc.node && desc.node.isBlock 162 } 163 164 function textNodeAfter(node: Node | null, offset: number): Text | undefined { 165 while (node && offset == node.childNodes.length && !hasBlockDesc(node)) { 166 offset = domIndex(node) + 1 167 node = node.parentNode 168 } 169 while (node && offset < node.childNodes.length) { 170 let next = node.childNodes[offset] 171 if (next.nodeType == 3) return next as Text 172 if (next.nodeType == 1 && (next as HTMLElement).contentEditable == "false") break 173 node = next 174 offset = 0 175 } 176 } 177 178 function textNodeBefore(node: Node | null, offset: number): Text | undefined { 179 while (node && !offset && !hasBlockDesc(node)) { 180 offset = domIndex(node) 181 node = node.parentNode 182 } 183 while (node && offset) { 184 let next = node.childNodes[offset - 1] 185 if (next.nodeType == 3) return next as Text 186 if (next.nodeType == 1 && (next as HTMLElement).contentEditable == "false") break 187 node = next 188 offset = node.childNodes.length 189 } 190 } 191 192 function setSelFocus(view: EditorView, node: Node, offset: number) { 193 if (node.nodeType != 3) { 194 let before, after 195 if (after = textNodeAfter(node, offset)) { 196 node = after 197 offset = 0 198 } else if (before = textNodeBefore(node, offset)) { 199 node = before 200 offset = before.nodeValue!.length 201 } 202 } 203 204 let sel = view.domSelection() 205 if (!sel) return 206 if (selectionCollapsed(sel)) { 207 let range = document.createRange() 208 range.setEnd(node, offset) 209 range.setStart(node, offset) 210 sel.removeAllRanges() 211 sel.addRange(range) 212 } else if (sel.extend) { 213 sel.extend(node, offset) 214 } 215 view.domObserver.setCurSelection() 216 let {state} = view 217 // If no state update ends up happening, reset the selection. 218 setTimeout(() => { 219 if (view.state == state) selectionToDOM(view) 220 }, 50) 221 } 222 223 function findDirection(view: EditorView, pos: number): "rtl" | "ltr" { 224 let $pos = view.state.doc.resolve(pos) 225 if (!(browser.chrome || browser.windows) && $pos.parent.inlineContent) { 226 let coords = view.coordsAtPos(pos) 227 if (pos > $pos.start()) { 228 let before = view.coordsAtPos(pos - 1) 229 let mid = (before.top + before.bottom) / 2 230 if (mid > coords.top && mid < coords.bottom && Math.abs(before.left - coords.left) > 1) 231 return before.left < coords.left ? "ltr" : "rtl" 232 } 233 if (pos < $pos.end()) { 234 let after = view.coordsAtPos(pos + 1) 235 let mid = (after.top + after.bottom) / 2 236 if (mid > coords.top && mid < coords.bottom && Math.abs(after.left - coords.left) > 1) 237 return after.left > coords.left ? "ltr" : "rtl" 238 } 239 } 240 let computed = getComputedStyle(view.dom).direction 241 return computed == "rtl" ? "rtl" : "ltr" 242 } 243 244 // Check whether vertical selection motion would involve node 245 // selections. If so, apply it (if not, the result is left to the 246 // browser) 247 function selectVertically(view: EditorView, dir: number, mods: string) { 248 let sel = view.state.selection 249 if (sel instanceof TextSelection && !sel.empty || mods.indexOf("s") > -1) return false 250 if (browser.mac && mods.indexOf("m") > -1) return false 251 let {$from, $to} = sel 252 253 if (!$from.parent.inlineContent || view.endOfTextblock(dir < 0 ? "up" : "down")) { 254 let next = moveSelectionBlock(view.state, dir) 255 if (next && (next instanceof NodeSelection)) 256 return apply(view, next) 257 } 258 if (!$from.parent.inlineContent) { 259 let side = dir < 0 ? $from : $to 260 let beyond = sel instanceof AllSelection ? Selection.near(side, dir) : Selection.findFrom(side, dir) 261 return beyond ? apply(view, beyond) : false 262 } 263 return false 264 } 265 266 function stopNativeHorizontalDelete(view: EditorView, dir: number) { 267 if (!(view.state.selection instanceof TextSelection)) return true 268 let {$head, $anchor, empty} = view.state.selection 269 if (!$head.sameParent($anchor)) return true 270 if (!empty) return false 271 if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) return true 272 let nextNode = !$head.textOffset && (dir < 0 ? $head.nodeBefore : $head.nodeAfter) 273 if (nextNode && !nextNode.isText) { 274 let tr = view.state.tr 275 if (dir < 0) tr.delete($head.pos - nextNode.nodeSize, $head.pos) 276 else tr.delete($head.pos, $head.pos + nextNode.nodeSize) 277 view.dispatch(tr) 278 return true 279 } 280 return false 281 } 282 283 function switchEditable(view: EditorView, node: HTMLElement, state: string) { 284 view.domObserver.stop() 285 node.contentEditable = state 286 view.domObserver.start() 287 } 288 289 // Issue #867 / #1090 / https://bugs.chromium.org/p/chromium/issues/detail?id=903821 290 // In which Safari (and at some point in the past, Chrome) does really 291 // wrong things when the down arrow is pressed when the cursor is 292 // directly at the start of a textblock and has an uneditable node 293 // after it 294 function safariDownArrowBug(view: EditorView) { 295 if (!browser.safari || view.state.selection.$head.parentOffset > 0) return false 296 let {focusNode, focusOffset} = view.domSelectionRange() 297 if (focusNode && focusNode.nodeType == 1 && focusOffset == 0 && 298 focusNode.firstChild && (focusNode.firstChild as HTMLElement).contentEditable == "false") { 299 let child = focusNode.firstChild as HTMLElement 300 switchEditable(view, child, "true") 301 setTimeout(() => switchEditable(view, child, "false"), 20) 302 } 303 return false 304 } 305 306 // A backdrop key mapping used to make sure we always suppress keys 307 // that have a dangerous default effect, even if the commands they are 308 // bound to return false, and to make sure that cursor-motion keys 309 // find a cursor (as opposed to a node selection) when pressed. For 310 // cursor-motion keys, the code in the handlers also takes care of 311 // block selections. 312 313 function getMods(event: KeyboardEvent) { 314 let result = "" 315 if (event.ctrlKey) result += "c" 316 if (event.metaKey) result += "m" 317 if (event.altKey) result += "a" 318 if (event.shiftKey) result += "s" 319 return result 320 } 321 322 export function captureKeyDown(view: EditorView, event: KeyboardEvent) { 323 let code = event.keyCode, mods = getMods(event) 324 if (code == 8 || (browser.mac && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac 325 return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodes(view, -1) 326 } else if ((code == 46 && !event.shiftKey) || (browser.mac && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac 327 return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodes(view, 1) 328 } else if (code == 13 || code == 27) { // Enter, Esc 329 return true 330 } else if (code == 37 || (browser.mac && code == 66 && mods == "c")) { // Left arrow, Ctrl-b on Mac 331 let dir = code == 37 ? (findDirection(view, view.state.selection.from) == "ltr" ? -1 : 1) : -1 332 return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir) 333 } else if (code == 39 || (browser.mac && code == 70 && mods == "c")) { // Right arrow, Ctrl-f on Mac 334 let dir = code == 39 ? (findDirection(view, view.state.selection.from) == "ltr" ? 1 : -1) : 1 335 return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir) 336 } else if (code == 38 || (browser.mac && code == 80 && mods == "c")) { // Up arrow, Ctrl-p on Mac 337 return selectVertically(view, -1, mods) || skipIgnoredNodes(view, -1) 338 } else if (code == 40 || (browser.mac && code == 78 && mods == "c")) { // Down arrow, Ctrl-n on Mac 339 return safariDownArrowBug(view) || selectVertically(view, 1, mods) || skipIgnoredNodes(view, 1) 340 } else if (mods == (browser.mac ? "m" : "c") && 341 (code == 66 || code == 73 || code == 89 || code == 90)) { // Mod-[biyz] 342 return true 343 } 344 return false 345 }