input.ts (32888B)
1 import {Selection, NodeSelection, TextSelection} from "prosemirror-state" 2 import {dropPoint} from "prosemirror-transform" 3 import {Slice, Node} from "prosemirror-model" 4 5 import * as browser from "./browser" 6 import {captureKeyDown} from "./capturekeys" 7 import {parseFromClipboard, serializeForClipboard} from "./clipboard" 8 import {selectionBetween, selectionToDOM, selectionFromDOM} from "./selection" 9 import {keyEvent, DOMNode, textNodeBefore, textNodeAfter} from "./dom" 10 import {EditorView} from "./index" 11 import {ViewDesc} from "./viewdesc" 12 13 // A collection of DOM events that occur within the editor, and callback functions 14 // to invoke when the event fires. 15 const handlers: {[event: string]: (view: EditorView, event: Event) => void} = {} 16 const editHandlers: {[event: string]: (view: EditorView, event: Event) => void} = {} 17 const passiveHandlers: Record<string, boolean> = {touchstart: true, touchmove: true} 18 19 export class InputState { 20 shiftKey = false 21 mouseDown: MouseDown | null = null 22 lastKeyCode: number | null = null 23 lastKeyCodeTime = 0 24 lastClick = {time: 0, x: 0, y: 0, type: "", button: 0} 25 lastSelectionOrigin: string | null = null 26 lastSelectionTime = 0 27 lastIOSEnter = 0 28 lastIOSEnterFallbackTimeout = -1 29 lastFocus = 0 30 lastTouch = 0 31 lastChromeDelete = 0 32 composing = false 33 compositionNode: Text | null = null 34 composingTimeout = -1 35 compositionNodes: ViewDesc[] = [] 36 compositionEndedAt = -2e8 37 compositionID = 1 38 // Set to a composition ID when there are pending changes at compositionend 39 compositionPendingChanges = 0 40 domChangeCount = 0 41 eventHandlers: {[event: string]: (event: Event) => void} = Object.create(null) 42 hideSelectionGuard: (() => void) | null = null 43 } 44 45 export function initInput(view: EditorView) { 46 for (let event in handlers) { 47 let handler = handlers[event] 48 view.dom.addEventListener(event, view.input.eventHandlers[event] = (event: Event) => { 49 if (eventBelongsToView(view, event) && !runCustomHandler(view, event) && 50 (view.editable || !(event.type in editHandlers))) 51 handler(view, event) 52 }, passiveHandlers[event] ? {passive: true} : undefined) 53 } 54 // On Safari, for reasons beyond my understanding, adding an input 55 // event handler makes an issue where the composition vanishes when 56 // you press enter go away. 57 if (browser.safari) view.dom.addEventListener("input", () => null) 58 59 ensureListeners(view) 60 } 61 62 function setSelectionOrigin(view: EditorView, origin: string) { 63 view.input.lastSelectionOrigin = origin 64 view.input.lastSelectionTime = Date.now() 65 } 66 67 export function destroyInput(view: EditorView) { 68 view.domObserver.stop() 69 for (let type in view.input.eventHandlers) 70 view.dom.removeEventListener(type, view.input.eventHandlers[type]) 71 clearTimeout(view.input.composingTimeout) 72 clearTimeout(view.input.lastIOSEnterFallbackTimeout) 73 } 74 75 export function ensureListeners(view: EditorView) { 76 view.someProp("handleDOMEvents", currentHandlers => { 77 for (let type in currentHandlers) if (!view.input.eventHandlers[type]) 78 view.dom.addEventListener(type, view.input.eventHandlers[type] = event => runCustomHandler(view, event)) 79 }) 80 } 81 82 function runCustomHandler(view: EditorView, event: Event) { 83 return view.someProp("handleDOMEvents", handlers => { 84 let handler = handlers[event.type] 85 return handler ? handler(view, event) || event.defaultPrevented : false 86 }) 87 } 88 89 function eventBelongsToView(view: EditorView, event: Event) { 90 if (!event.bubbles) return true 91 if (event.defaultPrevented) return false 92 for (let node = event.target as DOMNode; node != view.dom; node = node.parentNode!) 93 if (!node || node.nodeType == 11 || 94 (node.pmViewDesc && node.pmViewDesc.stopEvent(event))) 95 return false 96 return true 97 } 98 99 export function dispatchEvent(view: EditorView, event: Event) { 100 if (!runCustomHandler(view, event) && handlers[event.type] && 101 (view.editable || !(event.type in editHandlers))) 102 handlers[event.type](view, event) 103 } 104 105 editHandlers.keydown = (view: EditorView, _event: Event) => { 106 let event = _event as KeyboardEvent 107 view.input.shiftKey = event.keyCode == 16 || event.shiftKey 108 if (inOrNearComposition(view, event)) return 109 view.input.lastKeyCode = event.keyCode 110 view.input.lastKeyCodeTime = Date.now() 111 // Suppress enter key events on Chrome Android, because those tend 112 // to be part of a confused sequence of composition events fired, 113 // and handling them eagerly tends to corrupt the input. 114 if (browser.android && browser.chrome && event.keyCode == 13) return 115 if (event.keyCode != 229) view.domObserver.forceFlush() 116 117 // On iOS, if we preventDefault enter key presses, the virtual 118 // keyboard gets confused. So the hack here is to set a flag that 119 // makes the DOM change code recognize that what just happens should 120 // be replaced by whatever the Enter key handlers do. 121 if (browser.ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) { 122 let now = Date.now() 123 view.input.lastIOSEnter = now 124 view.input.lastIOSEnterFallbackTimeout = setTimeout(() => { 125 if (view.input.lastIOSEnter == now) { 126 view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter"))) 127 view.input.lastIOSEnter = 0 128 } 129 }, 200) 130 } else if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) { 131 event.preventDefault() 132 } else { 133 setSelectionOrigin(view, "key") 134 } 135 } 136 137 editHandlers.keyup = (view, event) => { 138 if ((event as KeyboardEvent).keyCode == 16) view.input.shiftKey = false 139 } 140 141 editHandlers.keypress = (view, _event) => { 142 let event = _event as KeyboardEvent 143 if (inOrNearComposition(view, event) || !event.charCode || 144 event.ctrlKey && !event.altKey || browser.mac && event.metaKey) return 145 146 if (view.someProp("handleKeyPress", f => f(view, event))) { 147 event.preventDefault() 148 return 149 } 150 151 let sel = view.state.selection 152 if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) { 153 let text = String.fromCharCode(event.charCode) 154 let deflt = () => view.state.tr.insertText(text).scrollIntoView() 155 if (!/[\r\n]/.test(text) && !view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text, deflt))) 156 view.dispatch(deflt()) 157 event.preventDefault() 158 } 159 } 160 161 function eventCoords(event: MouseEvent) { return {left: event.clientX, top: event.clientY} } 162 163 function isNear(event: MouseEvent, click: {x: number, y: number}) { 164 let dx = click.x - event.clientX, dy = click.y - event.clientY 165 return dx * dx + dy * dy < 100 166 } 167 168 function runHandlerOnContext( 169 view: EditorView, 170 propName: "handleClickOn" | "handleDoubleClickOn" | "handleTripleClickOn", 171 pos: number, 172 inside: number, 173 event: MouseEvent 174 ) { 175 if (inside == -1) return false 176 let $pos = view.state.doc.resolve(inside) 177 for (let i = $pos.depth + 1; i > 0; i--) { 178 if (view.someProp(propName, f => i > $pos.depth ? f(view, pos, $pos.nodeAfter!, $pos.before(i), event, true) 179 : f(view, pos, $pos.node(i), $pos.before(i), event, false))) 180 return true 181 } 182 return false 183 } 184 185 function updateSelection(view: EditorView, selection: Selection, origin: string) { 186 if (!view.focused) view.focus() 187 if (view.state.selection.eq(selection)) return 188 let tr = view.state.tr.setSelection(selection) 189 if (origin == "pointer") tr.setMeta("pointer", true) 190 view.dispatch(tr) 191 } 192 193 function selectClickedLeaf(view: EditorView, inside: number) { 194 if (inside == -1) return false 195 let $pos = view.state.doc.resolve(inside), node = $pos.nodeAfter 196 if (node && node.isAtom && NodeSelection.isSelectable(node)) { 197 updateSelection(view, new NodeSelection($pos), "pointer") 198 return true 199 } 200 return false 201 } 202 203 function selectClickedNode(view: EditorView, inside: number) { 204 if (inside == -1) return false 205 let sel = view.state.selection, selectedNode, selectAt 206 if (sel instanceof NodeSelection) selectedNode = sel.node 207 208 let $pos = view.state.doc.resolve(inside) 209 for (let i = $pos.depth + 1; i > 0; i--) { 210 let node = i > $pos.depth ? $pos.nodeAfter! : $pos.node(i) 211 if (NodeSelection.isSelectable(node)) { 212 if (selectedNode && sel.$from.depth > 0 && 213 i >= sel.$from.depth && $pos.before(sel.$from.depth + 1) == sel.$from.pos) 214 selectAt = $pos.before(sel.$from.depth) 215 else 216 selectAt = $pos.before(i) 217 break 218 } 219 } 220 221 if (selectAt != null) { 222 updateSelection(view, NodeSelection.create(view.state.doc, selectAt), "pointer") 223 return true 224 } else { 225 return false 226 } 227 } 228 229 function handleSingleClick(view: EditorView, pos: number, inside: number, event: MouseEvent, selectNode: boolean) { 230 return runHandlerOnContext(view, "handleClickOn", pos, inside, event) || 231 view.someProp("handleClick", f => f(view, pos, event)) || 232 (selectNode ? selectClickedNode(view, inside) : selectClickedLeaf(view, inside)) 233 } 234 235 function handleDoubleClick(view: EditorView, pos: number, inside: number, event: MouseEvent) { 236 return runHandlerOnContext(view, "handleDoubleClickOn", pos, inside, event) || 237 view.someProp("handleDoubleClick", f => f(view, pos, event)) 238 } 239 240 function handleTripleClick(view: EditorView, pos: number, inside: number, event: MouseEvent) { 241 return runHandlerOnContext(view, "handleTripleClickOn", pos, inside, event) || 242 view.someProp("handleTripleClick", f => f(view, pos, event)) || 243 defaultTripleClick(view, inside, event) 244 } 245 246 function defaultTripleClick(view: EditorView, inside: number, event: MouseEvent) { 247 if (event.button != 0) return false 248 let doc = view.state.doc 249 if (inside == -1) { 250 if (doc.inlineContent) { 251 updateSelection(view, TextSelection.create(doc, 0, doc.content.size), "pointer") 252 return true 253 } 254 return false 255 } 256 257 let $pos = doc.resolve(inside) 258 for (let i = $pos.depth + 1; i > 0; i--) { 259 let node = i > $pos.depth ? $pos.nodeAfter! : $pos.node(i) 260 let nodePos = $pos.before(i) 261 if (node.inlineContent) 262 updateSelection(view, TextSelection.create(doc, nodePos + 1, nodePos + 1 + node.content.size), "pointer") 263 else if (NodeSelection.isSelectable(node)) 264 updateSelection(view, NodeSelection.create(doc, nodePos), "pointer") 265 else 266 continue 267 return true 268 } 269 } 270 271 function forceDOMFlush(view: EditorView) { 272 return endComposition(view) 273 } 274 275 const selectNodeModifier: keyof MouseEvent = browser.mac ? "metaKey" : "ctrlKey" 276 277 handlers.mousedown = (view, _event) => { 278 let event = _event as MouseEvent 279 view.input.shiftKey = event.shiftKey 280 let flushed = forceDOMFlush(view) 281 let now = Date.now(), type = "singleClick" 282 if (now - view.input.lastClick.time < 500 && isNear(event, view.input.lastClick) && !event[selectNodeModifier] && 283 view.input.lastClick.button == event.button) { 284 if (view.input.lastClick.type == "singleClick") type = "doubleClick" 285 else if (view.input.lastClick.type == "doubleClick") type = "tripleClick" 286 } 287 view.input.lastClick = {time: now, x: event.clientX, y: event.clientY, type, button: event.button} 288 289 let pos = view.posAtCoords(eventCoords(event)) 290 if (!pos) return 291 292 if (type == "singleClick") { 293 if (view.input.mouseDown) view.input.mouseDown.done() 294 view.input.mouseDown = new MouseDown(view, pos, event, !!flushed) 295 } else if ((type == "doubleClick" ? handleDoubleClick : handleTripleClick)(view, pos.pos, pos.inside, event)) { 296 event.preventDefault() 297 } else { 298 setSelectionOrigin(view, "pointer") 299 } 300 } 301 302 class MouseDown { 303 startDoc: Node 304 selectNode: boolean 305 allowDefault: boolean 306 delayedSelectionSync = false 307 mightDrag: {node: Node, pos: number, addAttr: boolean, setUneditable: boolean} | null = null 308 target: HTMLElement | null 309 310 constructor( 311 readonly view: EditorView, 312 readonly pos: {pos: number, inside: number}, 313 readonly event: MouseEvent, 314 readonly flushed: boolean 315 ) { 316 this.startDoc = view.state.doc 317 this.selectNode = !!event[selectNodeModifier] 318 this.allowDefault = event.shiftKey 319 320 let targetNode: Node, targetPos 321 if (pos.inside > -1) { 322 targetNode = view.state.doc.nodeAt(pos.inside)! 323 targetPos = pos.inside 324 } else { 325 let $pos = view.state.doc.resolve(pos.pos) 326 targetNode = $pos.parent 327 targetPos = $pos.depth ? $pos.before() : 0 328 } 329 330 const target = flushed ? null : event.target as HTMLElement 331 const targetDesc = target ? view.docView.nearestDesc(target, true) : null 332 this.target = targetDesc && targetDesc.nodeDOM.nodeType == 1 ? targetDesc.nodeDOM as HTMLElement : null 333 334 let {selection} = view.state 335 if (event.button == 0 && 336 targetNode.type.spec.draggable && targetNode.type.spec.selectable !== false || 337 selection instanceof NodeSelection && selection.from <= targetPos && selection.to > targetPos) 338 this.mightDrag = { 339 node: targetNode, 340 pos: targetPos, 341 addAttr: !!(this.target && !this.target.draggable), 342 setUneditable: !!(this.target && browser.gecko && !this.target.hasAttribute("contentEditable")) 343 } 344 345 if (this.target && this.mightDrag && (this.mightDrag.addAttr || this.mightDrag.setUneditable)) { 346 this.view.domObserver.stop() 347 if (this.mightDrag.addAttr) this.target.draggable = true 348 if (this.mightDrag.setUneditable) 349 setTimeout(() => { 350 if (this.view.input.mouseDown == this) this.target!.setAttribute("contentEditable", "false") 351 }, 20) 352 this.view.domObserver.start() 353 } 354 355 view.root.addEventListener("mouseup", this.up = this.up.bind(this) as any) 356 view.root.addEventListener("mousemove", this.move = this.move.bind(this) as any) 357 setSelectionOrigin(view, "pointer") 358 } 359 360 done() { 361 this.view.root.removeEventListener("mouseup", this.up as any) 362 this.view.root.removeEventListener("mousemove", this.move as any) 363 if (this.mightDrag && this.target) { 364 this.view.domObserver.stop() 365 if (this.mightDrag.addAttr) this.target.removeAttribute("draggable") 366 if (this.mightDrag.setUneditable) this.target.removeAttribute("contentEditable") 367 this.view.domObserver.start() 368 } 369 if (this.delayedSelectionSync) setTimeout(() => selectionToDOM(this.view)) 370 this.view.input.mouseDown = null 371 } 372 373 up(event: MouseEvent) { 374 this.done() 375 376 if (!this.view.dom.contains(event.target as HTMLElement)) 377 return 378 379 let pos: {pos: number, inside: number} | null = this.pos 380 if (this.view.state.doc != this.startDoc) pos = this.view.posAtCoords(eventCoords(event)) 381 382 this.updateAllowDefault(event) 383 if (this.allowDefault || !pos) { 384 setSelectionOrigin(this.view, "pointer") 385 } else if (handleSingleClick(this.view, pos.pos, pos.inside, event, this.selectNode)) { 386 event.preventDefault() 387 } else if (event.button == 0 && 388 (this.flushed || 389 // Safari ignores clicks on draggable elements 390 (browser.safari && this.mightDrag && !this.mightDrag.node.isAtom) || 391 // Chrome will sometimes treat a node selection as a 392 // cursor, but still report that the node is selected 393 // when asked through getSelection. You'll then get a 394 // situation where clicking at the point where that 395 // (hidden) cursor is doesn't change the selection, and 396 // thus doesn't get a reaction from ProseMirror. This 397 // works around that. 398 (browser.chrome && !this.view.state.selection.visible && 399 Math.min(Math.abs(pos.pos - this.view.state.selection.from), 400 Math.abs(pos.pos - this.view.state.selection.to)) <= 2))) { 401 updateSelection(this.view, Selection.near(this.view.state.doc.resolve(pos.pos)), "pointer") 402 event.preventDefault() 403 } else { 404 setSelectionOrigin(this.view, "pointer") 405 } 406 } 407 408 move(event: MouseEvent) { 409 this.updateAllowDefault(event) 410 setSelectionOrigin(this.view, "pointer") 411 if (event.buttons == 0) this.done() 412 } 413 414 updateAllowDefault(event: MouseEvent) { 415 if (!this.allowDefault && (Math.abs(this.event.x - event.clientX) > 4 || 416 Math.abs(this.event.y - event.clientY) > 4)) 417 this.allowDefault = true 418 } 419 } 420 421 handlers.touchstart = view => { 422 view.input.lastTouch = Date.now() 423 forceDOMFlush(view) 424 setSelectionOrigin(view, "pointer") 425 } 426 427 handlers.touchmove = view => { 428 view.input.lastTouch = Date.now() 429 setSelectionOrigin(view, "pointer") 430 } 431 432 handlers.contextmenu = view => forceDOMFlush(view) 433 434 function inOrNearComposition(view: EditorView, event: Event) { 435 if (view.composing) return true 436 // See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/. 437 // On Japanese input method editors (IMEs), the Enter key is used to confirm character 438 // selection. On Safari, when Enter is pressed, compositionend and keydown events are 439 // emitted. The keydown event triggers newline insertion, which we don't want. 440 // This method returns true if the keydown event should be ignored. 441 // We only ignore it once, as pressing Enter a second time *should* insert a newline. 442 // Furthermore, the keydown event timestamp must be close to the compositionEndedAt timestamp. 443 // This guards against the case where compositionend is triggered without the keyboard 444 // (e.g. character confirmation may be done with the mouse), and keydown is triggered 445 // afterwards- we wouldn't want to ignore the keydown event in this case. 446 if (browser.safari && Math.abs(event.timeStamp - view.input.compositionEndedAt) < 500) { 447 view.input.compositionEndedAt = -2e8 448 return true 449 } 450 return false 451 } 452 453 // Drop active composition after 5 seconds of inactivity on Android 454 const timeoutComposition = browser.android ? 5000 : -1 455 456 editHandlers.compositionstart = editHandlers.compositionupdate = view => { 457 if (!view.composing) { 458 view.domObserver.flush() 459 let {state} = view, $pos = state.selection.$to 460 if (state.selection instanceof TextSelection && 461 (state.storedMarks || 462 (!$pos.textOffset && $pos.parentOffset && $pos.nodeBefore!.marks.some(m => m.type.spec.inclusive === false)) || 463 browser.chrome && browser.windows && selectionBeforeUneditable(view))) { // Issue #1500 464 // Need to wrap the cursor in mark nodes different from the ones in the DOM context 465 view.markCursor = view.state.storedMarks || $pos.marks() 466 endComposition(view, true) 467 view.markCursor = null 468 } else { 469 endComposition(view, !state.selection.empty) 470 // In firefox, if the cursor is after but outside a marked node, 471 // the inserted text won't inherit the marks. So this moves it 472 // inside if necessary. 473 if (browser.gecko && state.selection.empty && $pos.parentOffset && !$pos.textOffset && $pos.nodeBefore!.marks.length) { 474 let sel = view.domSelectionRange() 475 for (let node = sel.focusNode, offset = sel.focusOffset; node && node.nodeType == 1 && offset != 0;) { 476 let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1] 477 if (!before) break 478 if (before.nodeType == 3) { 479 let sel = view.domSelection() 480 if (sel) sel.collapse(before, before.nodeValue!.length) 481 break 482 } else { 483 node = before 484 offset = -1 485 } 486 } 487 } 488 } 489 view.input.composing = true 490 } 491 scheduleComposeEnd(view, timeoutComposition) 492 } 493 494 function selectionBeforeUneditable(view: EditorView) { 495 let {focusNode, focusOffset} = view.domSelectionRange() 496 if (!focusNode || focusNode.nodeType != 1 || focusOffset >= focusNode.childNodes.length) return false 497 let next = focusNode.childNodes[focusOffset] 498 return next.nodeType == 1 && (next as HTMLElement).contentEditable == "false" 499 } 500 501 editHandlers.compositionend = (view, event) => { 502 if (view.composing) { 503 view.input.composing = false 504 view.input.compositionEndedAt = event.timeStamp 505 view.input.compositionPendingChanges = view.domObserver.pendingRecords().length ? view.input.compositionID : 0 506 view.input.compositionNode = null 507 if (view.input.compositionPendingChanges) Promise.resolve().then(() => view.domObserver.flush()) 508 view.input.compositionID++ 509 scheduleComposeEnd(view, 20) 510 } 511 } 512 513 function scheduleComposeEnd(view: EditorView, delay: number) { 514 clearTimeout(view.input.composingTimeout) 515 if (delay > -1) view.input.composingTimeout = setTimeout(() => endComposition(view), delay) 516 } 517 518 export function clearComposition(view: EditorView) { 519 if (view.composing) { 520 view.input.composing = false 521 view.input.compositionEndedAt = timestampFromCustomEvent() 522 } 523 while (view.input.compositionNodes.length > 0) view.input.compositionNodes.pop()!.markParentsDirty() 524 } 525 526 export function findCompositionNode(view: EditorView) { 527 let sel = view.domSelectionRange() 528 if (!sel.focusNode) return null 529 let textBefore = textNodeBefore(sel.focusNode, sel.focusOffset) 530 let textAfter = textNodeAfter(sel.focusNode, sel.focusOffset) 531 if (textBefore && textAfter && textBefore != textAfter) { 532 let descAfter = textAfter.pmViewDesc, lastChanged = view.domObserver.lastChangedTextNode 533 if (textBefore == lastChanged || textAfter == lastChanged) return lastChanged 534 if (!descAfter || !descAfter.isText(textAfter.nodeValue!)) { 535 return textAfter 536 } else if (view.input.compositionNode == textAfter) { 537 let descBefore = textBefore.pmViewDesc 538 if (!(!descBefore || !descBefore.isText(textBefore.nodeValue!))) 539 return textAfter 540 } 541 } 542 return textBefore || textAfter 543 } 544 545 function timestampFromCustomEvent() { 546 let event = document.createEvent("Event") 547 event.initEvent("event", true, true) 548 return event.timeStamp 549 } 550 551 /// @internal 552 export function endComposition(view: EditorView, restarting = false) { 553 if (browser.android && view.domObserver.flushingSoon >= 0) return 554 view.domObserver.forceFlush() 555 clearComposition(view) 556 if (restarting || view.docView && view.docView.dirty) { 557 let sel = selectionFromDOM(view), cur = view.state.selection 558 if (sel && !sel.eq(cur)) view.dispatch(view.state.tr.setSelection(sel)) 559 else if ((view.markCursor || restarting) && !cur.$from.node(cur.$from.sharedDepth(cur.to)).inlineContent) view.dispatch(view.state.tr.deleteSelection()) 560 else view.updateState(view.state) 561 return true 562 } 563 return false 564 } 565 566 function captureCopy(view: EditorView, dom: HTMLElement) { 567 // The extra wrapper is somehow necessary on IE/Edge to prevent the 568 // content from being mangled when it is put onto the clipboard 569 if (!view.dom.parentNode) return 570 let wrap = view.dom.parentNode.appendChild(document.createElement("div")) 571 wrap.appendChild(dom) 572 wrap.style.cssText = "position: fixed; left: -10000px; top: 10px" 573 let sel = getSelection()!, range = document.createRange() 574 range.selectNodeContents(dom) 575 // Done because IE will fire a selectionchange moving the selection 576 // to its start when removeAllRanges is called and the editor still 577 // has focus (which will mess up the editor's selection state). 578 view.dom.blur() 579 sel.removeAllRanges() 580 sel.addRange(range) 581 setTimeout(() => { 582 if (wrap.parentNode) wrap.parentNode.removeChild(wrap) 583 view.focus() 584 }, 50) 585 } 586 587 // This is very crude, but unfortunately both these browsers _pretend_ 588 // that they have a clipboard API—all the objects and methods are 589 // there, they just don't work, and they are hard to test. 590 const brokenClipboardAPI = (browser.ie && browser.ie_version < 15) || 591 (browser.ios && browser.webkit_version < 604) 592 593 handlers.copy = editHandlers.cut = (view, _event) => { 594 let event = _event as ClipboardEvent 595 let sel = view.state.selection, cut = event.type == "cut" 596 if (sel.empty) return 597 598 // IE and Edge's clipboard interface is completely broken 599 let data = brokenClipboardAPI ? null : event.clipboardData 600 let slice = sel.content(), {dom, text} = serializeForClipboard(view, slice) 601 if (data) { 602 event.preventDefault() 603 data.clearData() 604 data.setData("text/html", dom.innerHTML) 605 data.setData("text/plain", text) 606 } else { 607 captureCopy(view, dom) 608 } 609 if (cut) view.dispatch(view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut")) 610 } 611 612 function sliceSingleNode(slice: Slice) { 613 return slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 ? slice.content.firstChild : null 614 } 615 616 function capturePaste(view: EditorView, event: ClipboardEvent) { 617 if (!view.dom.parentNode) return 618 let plainText = view.input.shiftKey || view.state.selection.$from.parent.type.spec.code 619 let target = view.dom.parentNode.appendChild(document.createElement(plainText ? "textarea" : "div")) 620 if (!plainText) target.contentEditable = "true" 621 target.style.cssText = "position: fixed; left: -10000px; top: 10px" 622 target.focus() 623 let plain = view.input.shiftKey && view.input.lastKeyCode != 45 624 setTimeout(() => { 625 view.focus() 626 if (target.parentNode) target.parentNode.removeChild(target) 627 if (plainText) doPaste(view, (target as HTMLTextAreaElement).value, null, plain, event) 628 else doPaste(view, target.textContent!, target.innerHTML, plain, event) 629 }, 50) 630 } 631 632 export function doPaste(view: EditorView, text: string, html: string | null, preferPlain: boolean, event: ClipboardEvent) { 633 let slice = parseFromClipboard(view, text, html, preferPlain, view.state.selection.$from) 634 if (view.someProp("handlePaste", f => f(view, event, slice || Slice.empty))) return true 635 if (!slice) return false 636 637 let singleNode = sliceSingleNode(slice) 638 let tr = singleNode 639 ? view.state.tr.replaceSelectionWith(singleNode, preferPlain) 640 : view.state.tr.replaceSelection(slice) 641 view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste")) 642 return true 643 } 644 645 function getText(clipboardData: DataTransfer) { 646 let text = clipboardData.getData("text/plain") || clipboardData.getData("Text") 647 if (text) return text 648 let uris = clipboardData.getData("text/uri-list") 649 return uris ? uris.replace(/\r?\n/g, " ") : "" 650 } 651 652 editHandlers.paste = (view, _event) => { 653 let event = _event as ClipboardEvent 654 // Handling paste from JavaScript during composition is very poorly 655 // handled by browsers, so as a dodgy but preferable kludge, we just 656 // let the browser do its native thing there, except on Android, 657 // where the editor is almost always composing. 658 if (view.composing && !browser.android) return 659 let data = brokenClipboardAPI ? null : event.clipboardData 660 let plain = view.input.shiftKey && view.input.lastKeyCode != 45 661 if (data && doPaste(view, getText(data), data.getData("text/html"), plain, event)) 662 event.preventDefault() 663 else 664 capturePaste(view, event) 665 } 666 667 export class Dragging { 668 constructor(readonly slice: Slice, readonly move: boolean, readonly node?: NodeSelection) {} 669 } 670 671 const dragCopyModifier: keyof DragEvent = browser.mac ? "altKey" : "ctrlKey" 672 673 function dragMoves(view: EditorView, event: DragEvent) { 674 let moves = view.someProp("dragCopies", test => !test(event)) 675 return moves != null ? moves : !event[dragCopyModifier] 676 } 677 678 handlers.dragstart = (view, _event) => { 679 let event = _event as DragEvent 680 let mouseDown = view.input.mouseDown 681 if (mouseDown) mouseDown.done() 682 if (!event.dataTransfer) return 683 684 let sel = view.state.selection 685 let pos = sel.empty ? null : view.posAtCoords(eventCoords(event)) 686 let node: undefined | NodeSelection 687 if (pos && pos.pos >= sel.from && pos.pos <= (sel instanceof NodeSelection ? sel.to - 1: sel.to)) { 688 // In selection 689 } else if (mouseDown && mouseDown.mightDrag) { 690 node = NodeSelection.create(view.state.doc, mouseDown.mightDrag.pos) 691 } else if (event.target && (event.target as HTMLElement).nodeType == 1) { 692 let desc = view.docView.nearestDesc(event.target as HTMLElement, true) 693 if (desc && desc.node.type.spec.draggable && desc != view.docView) 694 node = NodeSelection.create(view.state.doc, desc.posBefore) 695 } 696 let draggedSlice = (node || view.state.selection).content() 697 let {dom, text, slice} = serializeForClipboard(view, draggedSlice) 698 // Pre-120 Chrome versions clear files when calling `clearData` (#1472) 699 if (!event.dataTransfer.files.length || !browser.chrome || browser.chrome_version > 120) 700 event.dataTransfer.clearData() 701 event.dataTransfer.setData(brokenClipboardAPI ? "Text" : "text/html", dom.innerHTML) 702 // See https://github.com/ProseMirror/prosemirror/issues/1156 703 event.dataTransfer.effectAllowed = "copyMove" 704 if (!brokenClipboardAPI) event.dataTransfer.setData("text/plain", text) 705 view.dragging = new Dragging(slice, dragMoves(view, event), node) 706 } 707 708 handlers.dragend = view => { 709 let dragging = view.dragging 710 window.setTimeout(() => { 711 if (view.dragging == dragging) view.dragging = null 712 }, 50) 713 } 714 715 editHandlers.dragover = editHandlers.dragenter = (_, e) => e.preventDefault() 716 717 editHandlers.drop = (view, event) => { 718 try { 719 handleDrop(view, event as DragEvent, view.dragging) 720 } finally { 721 view.dragging = null 722 } 723 } 724 725 function handleDrop(view: EditorView, event: DragEvent, dragging: Dragging | null) { 726 if (!event.dataTransfer) return 727 728 let eventPos = view.posAtCoords(eventCoords(event)) 729 if (!eventPos) return 730 let $mouse = view.state.doc.resolve(eventPos.pos) 731 let slice = dragging && dragging.slice 732 if (slice) { 733 view.someProp("transformPasted", f => { slice = f(slice!, view, false) }) 734 } else { 735 slice = parseFromClipboard(view, getText(event.dataTransfer), 736 brokenClipboardAPI ? null : event.dataTransfer.getData("text/html"), false, $mouse) 737 } 738 let move = !!(dragging && dragMoves(view, event)) 739 if (view.someProp("handleDrop", f => f(view, event, slice || Slice.empty, move))) { 740 event.preventDefault() 741 return 742 } 743 if (!slice) return 744 745 event.preventDefault() 746 let insertPos = slice ? dropPoint(view.state.doc, $mouse.pos, slice) : $mouse.pos 747 if (insertPos == null) insertPos = $mouse.pos 748 749 let tr = view.state.tr 750 if (move) { 751 let {node} = dragging as Dragging 752 if (node) node.replace(tr) 753 else tr.deleteSelection() 754 } 755 756 let pos = tr.mapping.map(insertPos) 757 let isNode = slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 758 let beforeInsert = tr.doc 759 if (isNode) 760 tr.replaceRangeWith(pos, pos, slice.content.firstChild!) 761 else 762 tr.replaceRange(pos, pos, slice) 763 if (tr.doc.eq(beforeInsert)) return 764 765 let $pos = tr.doc.resolve(pos) 766 if (isNode && NodeSelection.isSelectable(slice.content.firstChild!) && 767 $pos.nodeAfter && $pos.nodeAfter.sameMarkup(slice.content.firstChild!)) { 768 tr.setSelection(new NodeSelection($pos)) 769 } else { 770 let end = tr.mapping.map(insertPos) 771 tr.mapping.maps[tr.mapping.maps.length - 1].forEach((_from, _to, _newFrom, newTo) => end = newTo) 772 tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end))) 773 } 774 view.focus() 775 view.dispatch(tr.setMeta("uiEvent", "drop")) 776 } 777 778 handlers.focus = view => { 779 view.input.lastFocus = Date.now() 780 if (!view.focused) { 781 view.domObserver.stop() 782 view.dom.classList.add("ProseMirror-focused") 783 view.domObserver.start() 784 view.focused = true 785 setTimeout(() => { 786 if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelectionRange())) 787 selectionToDOM(view) 788 }, 20) 789 } 790 } 791 792 handlers.blur = (view, _event) => { 793 let event = _event as FocusEvent 794 if (view.focused) { 795 view.domObserver.stop() 796 view.dom.classList.remove("ProseMirror-focused") 797 view.domObserver.start() 798 if (event.relatedTarget && view.dom.contains(event.relatedTarget as HTMLElement)) 799 view.domObserver.currentSelection.clear() 800 view.focused = false 801 } 802 } 803 804 handlers.beforeinput = (view, _event: Event) => { 805 let event = _event as InputEvent 806 // We should probably do more with beforeinput events, but support 807 // is so spotty that I'm still waiting to see where they are going. 808 809 // Very specific hack to deal with backspace sometimes failing on 810 // Chrome Android when after an uneditable node. 811 if (browser.chrome && browser.android && event.inputType == "deleteContentBackward") { 812 view.domObserver.flushSoon() 813 let {domChangeCount} = view.input 814 setTimeout(() => { 815 if (view.input.domChangeCount != domChangeCount) return // Event already had some effect 816 // This bug tends to close the virtual keyboard, so we refocus 817 view.dom.blur() 818 view.focus() 819 if (view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) return 820 let {$cursor} = view.state.selection as TextSelection 821 // Crude approximation of backspace behavior when no command handled it 822 if ($cursor && $cursor.pos > 0) view.dispatch(view.state.tr.delete($cursor.pos - 1, $cursor.pos).scrollIntoView()) 823 }, 50) 824 } 825 } 826 827 // Make sure all handlers get registered 828 for (let prop in editHandlers) handlers[prop] = editHandlers[prop]