ContentEditableInput.js (18356B)
1 import { operation, runInOp } from "../display/operations.js" 2 import { prepareSelection } from "../display/selection.js" 3 import { regChange } from "../display/view_tracking.js" 4 import { applyTextInput, copyableRanges, disableBrowserMagic, handlePaste, hiddenTextarea, lastCopied, setLastCopied } from "./input.js" 5 import { cmp, maxPos, minPos, Pos } from "../line/pos.js" 6 import { getBetween, getLine, lineNo } from "../line/utils_line.js" 7 import { findViewForLine, findViewIndex, mapFromLineView, nodeAndOffsetInLineMap } from "../measurement/position_measurement.js" 8 import { replaceRange } from "../model/changes.js" 9 import { simpleSelection } from "../model/selection.js" 10 import { setSelection } from "../model/selection_updates.js" 11 import { getBidiPartAt, getOrder } from "../util/bidi.js" 12 import { android, chrome, gecko, ie_version } from "../util/browser.js" 13 import { contains, range, removeChildrenAndAdd, selectInput } from "../util/dom.js" 14 import { on, signalDOMEvent } from "../util/event.js" 15 import { Delayed, lst, sel_dontScroll } from "../util/misc.js" 16 17 // CONTENTEDITABLE INPUT STYLE 18 19 export default class ContentEditableInput { 20 constructor(cm) { 21 this.cm = cm 22 this.lastAnchorNode = this.lastAnchorOffset = this.lastFocusNode = this.lastFocusOffset = null 23 this.polling = new Delayed() 24 this.composing = null 25 this.gracePeriod = false 26 this.readDOMTimeout = null 27 } 28 29 init(display) { 30 let input = this, cm = input.cm 31 let div = input.div = display.lineDiv 32 disableBrowserMagic(div, cm.options.spellcheck) 33 34 on(div, "paste", e => { 35 if (signalDOMEvent(cm, e) || handlePaste(e, cm)) return 36 // IE doesn't fire input events, so we schedule a read for the pasted content in this way 37 if (ie_version <= 11) setTimeout(operation(cm, () => this.updateFromDOM()), 20) 38 }) 39 40 on(div, "compositionstart", e => { 41 this.composing = {data: e.data, done: false} 42 }) 43 on(div, "compositionupdate", e => { 44 if (!this.composing) this.composing = {data: e.data, done: false} 45 }) 46 on(div, "compositionend", e => { 47 if (this.composing) { 48 if (e.data != this.composing.data) this.readFromDOMSoon() 49 this.composing.done = true 50 } 51 }) 52 53 on(div, "touchstart", () => input.forceCompositionEnd()) 54 55 on(div, "input", () => { 56 if (!this.composing) this.readFromDOMSoon() 57 }) 58 59 function onCopyCut(e) { 60 if (signalDOMEvent(cm, e)) return 61 if (cm.somethingSelected()) { 62 setLastCopied({lineWise: false, text: cm.getSelections()}) 63 if (e.type == "cut") cm.replaceSelection("", null, "cut") 64 } else if (!cm.options.lineWiseCopyCut) { 65 return 66 } else { 67 let ranges = copyableRanges(cm) 68 setLastCopied({lineWise: true, text: ranges.text}) 69 if (e.type == "cut") { 70 cm.operation(() => { 71 cm.setSelections(ranges.ranges, 0, sel_dontScroll) 72 cm.replaceSelection("", null, "cut") 73 }) 74 } 75 } 76 if (e.clipboardData) { 77 e.clipboardData.clearData() 78 let content = lastCopied.text.join("\n") 79 // iOS exposes the clipboard API, but seems to discard content inserted into it 80 e.clipboardData.setData("Text", content) 81 if (e.clipboardData.getData("Text") == content) { 82 e.preventDefault() 83 return 84 } 85 } 86 // Old-fashioned briefly-focus-a-textarea hack 87 let kludge = hiddenTextarea(), te = kludge.firstChild 88 cm.display.lineSpace.insertBefore(kludge, cm.display.lineSpace.firstChild) 89 te.value = lastCopied.text.join("\n") 90 let hadFocus = document.activeElement 91 selectInput(te) 92 setTimeout(() => { 93 cm.display.lineSpace.removeChild(kludge) 94 hadFocus.focus() 95 if (hadFocus == div) input.showPrimarySelection() 96 }, 50) 97 } 98 on(div, "copy", onCopyCut) 99 on(div, "cut", onCopyCut) 100 } 101 102 prepareSelection() { 103 let result = prepareSelection(this.cm, false) 104 result.focus = this.cm.state.focused 105 return result 106 } 107 108 showSelection(info, takeFocus) { 109 if (!info || !this.cm.display.view.length) return 110 if (info.focus || takeFocus) this.showPrimarySelection() 111 this.showMultipleSelections(info) 112 } 113 114 showPrimarySelection() { 115 let sel = window.getSelection(), cm = this.cm, prim = cm.doc.sel.primary() 116 let from = prim.from(), to = prim.to() 117 118 if (cm.display.viewTo == cm.display.viewFrom || from.line >= cm.display.viewTo || to.line < cm.display.viewFrom) { 119 sel.removeAllRanges() 120 return 121 } 122 123 let curAnchor = domToPos(cm, sel.anchorNode, sel.anchorOffset) 124 let curFocus = domToPos(cm, sel.focusNode, sel.focusOffset) 125 if (curAnchor && !curAnchor.bad && curFocus && !curFocus.bad && 126 cmp(minPos(curAnchor, curFocus), from) == 0 && 127 cmp(maxPos(curAnchor, curFocus), to) == 0) 128 return 129 130 let view = cm.display.view 131 let start = (from.line >= cm.display.viewFrom && posToDOM(cm, from)) || 132 {node: view[0].measure.map[2], offset: 0} 133 let end = to.line < cm.display.viewTo && posToDOM(cm, to) 134 if (!end) { 135 let measure = view[view.length - 1].measure 136 let map = measure.maps ? measure.maps[measure.maps.length - 1] : measure.map 137 end = {node: map[map.length - 1], offset: map[map.length - 2] - map[map.length - 3]} 138 } 139 140 if (!start || !end) { 141 sel.removeAllRanges() 142 return 143 } 144 145 let old = sel.rangeCount && sel.getRangeAt(0), rng 146 try { rng = range(start.node, start.offset, end.offset, end.node) } 147 catch(e) {} // Our model of the DOM might be outdated, in which case the range we try to set can be impossible 148 if (rng) { 149 if (!gecko && cm.state.focused) { 150 sel.collapse(start.node, start.offset) 151 if (!rng.collapsed) { 152 sel.removeAllRanges() 153 sel.addRange(rng) 154 } 155 } else { 156 sel.removeAllRanges() 157 sel.addRange(rng) 158 } 159 if (old && sel.anchorNode == null) sel.addRange(old) 160 else if (gecko) this.startGracePeriod() 161 } 162 this.rememberSelection() 163 } 164 165 startGracePeriod() { 166 clearTimeout(this.gracePeriod) 167 this.gracePeriod = setTimeout(() => { 168 this.gracePeriod = false 169 if (this.selectionChanged()) 170 this.cm.operation(() => this.cm.curOp.selectionChanged = true) 171 }, 20) 172 } 173 174 showMultipleSelections(info) { 175 removeChildrenAndAdd(this.cm.display.cursorDiv, info.cursors) 176 removeChildrenAndAdd(this.cm.display.selectionDiv, info.selection) 177 } 178 179 rememberSelection() { 180 let sel = window.getSelection() 181 this.lastAnchorNode = sel.anchorNode; this.lastAnchorOffset = sel.anchorOffset 182 this.lastFocusNode = sel.focusNode; this.lastFocusOffset = sel.focusOffset 183 } 184 185 selectionInEditor() { 186 let sel = window.getSelection() 187 if (!sel.rangeCount) return false 188 let node = sel.getRangeAt(0).commonAncestorContainer 189 return contains(this.div, node) 190 } 191 192 focus() { 193 if (this.cm.options.readOnly != "nocursor") { 194 if (!this.selectionInEditor()) 195 this.showSelection(this.prepareSelection(), true) 196 this.div.focus() 197 } 198 } 199 blur() { this.div.blur() } 200 getField() { return this.div } 201 202 supportsTouch() { return true } 203 204 receivedFocus() { 205 let input = this 206 if (this.selectionInEditor()) 207 this.pollSelection() 208 else 209 runInOp(this.cm, () => input.cm.curOp.selectionChanged = true) 210 211 function poll() { 212 if (input.cm.state.focused) { 213 input.pollSelection() 214 input.polling.set(input.cm.options.pollInterval, poll) 215 } 216 } 217 this.polling.set(this.cm.options.pollInterval, poll) 218 } 219 220 selectionChanged() { 221 let sel = window.getSelection() 222 return sel.anchorNode != this.lastAnchorNode || sel.anchorOffset != this.lastAnchorOffset || 223 sel.focusNode != this.lastFocusNode || sel.focusOffset != this.lastFocusOffset 224 } 225 226 pollSelection() { 227 if (this.readDOMTimeout != null || this.gracePeriod || !this.selectionChanged()) return 228 let sel = window.getSelection(), cm = this.cm 229 // On Android Chrome (version 56, at least), backspacing into an 230 // uneditable block element will put the cursor in that element, 231 // and then, because it's not editable, hide the virtual keyboard. 232 // Because Android doesn't allow us to actually detect backspace 233 // presses in a sane way, this code checks for when that happens 234 // and simulates a backspace press in this case. 235 if (android && chrome && this.cm.options.gutters.length && isInGutter(sel.anchorNode)) { 236 this.cm.triggerOnKeyDown({type: "keydown", keyCode: 8, preventDefault: Math.abs}) 237 this.blur() 238 this.focus() 239 return 240 } 241 if (this.composing) return 242 this.rememberSelection() 243 let anchor = domToPos(cm, sel.anchorNode, sel.anchorOffset) 244 let head = domToPos(cm, sel.focusNode, sel.focusOffset) 245 if (anchor && head) runInOp(cm, () => { 246 setSelection(cm.doc, simpleSelection(anchor, head), sel_dontScroll) 247 if (anchor.bad || head.bad) cm.curOp.selectionChanged = true 248 }) 249 } 250 251 pollContent() { 252 if (this.readDOMTimeout != null) { 253 clearTimeout(this.readDOMTimeout) 254 this.readDOMTimeout = null 255 } 256 257 let cm = this.cm, display = cm.display, sel = cm.doc.sel.primary() 258 let from = sel.from(), to = sel.to() 259 if (from.ch == 0 && from.line > cm.firstLine()) 260 from = Pos(from.line - 1, getLine(cm.doc, from.line - 1).length) 261 if (to.ch == getLine(cm.doc, to.line).text.length && to.line < cm.lastLine()) 262 to = Pos(to.line + 1, 0) 263 if (from.line < display.viewFrom || to.line > display.viewTo - 1) return false 264 265 let fromIndex, fromLine, fromNode 266 if (from.line == display.viewFrom || (fromIndex = findViewIndex(cm, from.line)) == 0) { 267 fromLine = lineNo(display.view[0].line) 268 fromNode = display.view[0].node 269 } else { 270 fromLine = lineNo(display.view[fromIndex].line) 271 fromNode = display.view[fromIndex - 1].node.nextSibling 272 } 273 let toIndex = findViewIndex(cm, to.line) 274 let toLine, toNode 275 if (toIndex == display.view.length - 1) { 276 toLine = display.viewTo - 1 277 toNode = display.lineDiv.lastChild 278 } else { 279 toLine = lineNo(display.view[toIndex + 1].line) - 1 280 toNode = display.view[toIndex + 1].node.previousSibling 281 } 282 283 if (!fromNode) return false 284 let newText = cm.doc.splitLines(domTextBetween(cm, fromNode, toNode, fromLine, toLine)) 285 let oldText = getBetween(cm.doc, Pos(fromLine, 0), Pos(toLine, getLine(cm.doc, toLine).text.length)) 286 while (newText.length > 1 && oldText.length > 1) { 287 if (lst(newText) == lst(oldText)) { newText.pop(); oldText.pop(); toLine-- } 288 else if (newText[0] == oldText[0]) { newText.shift(); oldText.shift(); fromLine++ } 289 else break 290 } 291 292 let cutFront = 0, cutEnd = 0 293 let newTop = newText[0], oldTop = oldText[0], maxCutFront = Math.min(newTop.length, oldTop.length) 294 while (cutFront < maxCutFront && newTop.charCodeAt(cutFront) == oldTop.charCodeAt(cutFront)) 295 ++cutFront 296 let newBot = lst(newText), oldBot = lst(oldText) 297 let maxCutEnd = Math.min(newBot.length - (newText.length == 1 ? cutFront : 0), 298 oldBot.length - (oldText.length == 1 ? cutFront : 0)) 299 while (cutEnd < maxCutEnd && 300 newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) 301 ++cutEnd 302 // Try to move start of change to start of selection if ambiguous 303 if (newText.length == 1 && oldText.length == 1 && fromLine == from.line) { 304 while (cutFront && cutFront > from.ch && 305 newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) { 306 cutFront-- 307 cutEnd++ 308 } 309 } 310 311 newText[newText.length - 1] = newBot.slice(0, newBot.length - cutEnd).replace(/^\u200b+/, "") 312 newText[0] = newText[0].slice(cutFront).replace(/\u200b+$/, "") 313 314 let chFrom = Pos(fromLine, cutFront) 315 let chTo = Pos(toLine, oldText.length ? lst(oldText).length - cutEnd : 0) 316 if (newText.length > 1 || newText[0] || cmp(chFrom, chTo)) { 317 replaceRange(cm.doc, newText, chFrom, chTo, "+input") 318 return true 319 } 320 } 321 322 ensurePolled() { 323 this.forceCompositionEnd() 324 } 325 reset() { 326 this.forceCompositionEnd() 327 } 328 forceCompositionEnd() { 329 if (!this.composing) return 330 clearTimeout(this.readDOMTimeout) 331 this.composing = null 332 this.updateFromDOM() 333 this.div.blur() 334 this.div.focus() 335 } 336 readFromDOMSoon() { 337 if (this.readDOMTimeout != null) return 338 this.readDOMTimeout = setTimeout(() => { 339 this.readDOMTimeout = null 340 if (this.composing) { 341 if (this.composing.done) this.composing = null 342 else return 343 } 344 this.updateFromDOM() 345 }, 80) 346 } 347 348 updateFromDOM() { 349 if (this.cm.isReadOnly() || !this.pollContent()) 350 runInOp(this.cm, () => regChange(this.cm)) 351 } 352 353 setUneditable(node) { 354 node.contentEditable = "false" 355 } 356 357 onKeyPress(e) { 358 if (e.charCode == 0) return 359 e.preventDefault() 360 if (!this.cm.isReadOnly()) 361 operation(this.cm, applyTextInput)(this.cm, String.fromCharCode(e.charCode == null ? e.keyCode : e.charCode), 0) 362 } 363 364 readOnlyChanged(val) { 365 this.div.contentEditable = String(val != "nocursor") 366 } 367 368 onContextMenu() {} 369 resetPosition() {} 370 } 371 372 ContentEditableInput.prototype.needsContentAttribute = true 373 374 function posToDOM(cm, pos) { 375 let view = findViewForLine(cm, pos.line) 376 if (!view || view.hidden) return null 377 let line = getLine(cm.doc, pos.line) 378 let info = mapFromLineView(view, line, pos.line) 379 380 let order = getOrder(line, cm.doc.direction), side = "left" 381 if (order) { 382 let partPos = getBidiPartAt(order, pos.ch) 383 side = partPos % 2 ? "right" : "left" 384 } 385 let result = nodeAndOffsetInLineMap(info.map, pos.ch, side) 386 result.offset = result.collapse == "right" ? result.end : result.start 387 return result 388 } 389 390 function isInGutter(node) { 391 for (let scan = node; scan; scan = scan.parentNode) 392 if (/CodeMirror-gutter-wrapper/.test(scan.className)) return true 393 return false 394 } 395 396 function badPos(pos, bad) { if (bad) pos.bad = true; return pos } 397 398 function domTextBetween(cm, from, to, fromLine, toLine) { 399 let text = "", closing = false, lineSep = cm.doc.lineSeparator() 400 function recognizeMarker(id) { return marker => marker.id == id } 401 function close() { 402 if (closing) { 403 text += lineSep 404 closing = false 405 } 406 } 407 function addText(str) { 408 if (str) { 409 close() 410 text += str 411 } 412 } 413 function walk(node) { 414 if (node.nodeType == 1) { 415 let cmText = node.getAttribute("cm-text") 416 if (cmText != null) { 417 addText(cmText || node.textContent.replace(/\u200b/g, "")) 418 return 419 } 420 let markerID = node.getAttribute("cm-marker"), range 421 if (markerID) { 422 let found = cm.findMarks(Pos(fromLine, 0), Pos(toLine + 1, 0), recognizeMarker(+markerID)) 423 if (found.length && (range = found[0].find(0))) 424 addText(getBetween(cm.doc, range.from, range.to).join(lineSep)) 425 return 426 } 427 if (node.getAttribute("contenteditable") == "false") return 428 let isBlock = /^(pre|div|p)$/i.test(node.nodeName) 429 if (isBlock) close() 430 for (let i = 0; i < node.childNodes.length; i++) 431 walk(node.childNodes[i]) 432 if (isBlock) closing = true 433 } else if (node.nodeType == 3) { 434 addText(node.nodeValue) 435 } 436 } 437 for (;;) { 438 walk(from) 439 if (from == to) break 440 from = from.nextSibling 441 } 442 return text 443 } 444 445 function domToPos(cm, node, offset) { 446 let lineNode 447 if (node == cm.display.lineDiv) { 448 lineNode = cm.display.lineDiv.childNodes[offset] 449 if (!lineNode) return badPos(cm.clipPos(Pos(cm.display.viewTo - 1)), true) 450 node = null; offset = 0 451 } else { 452 for (lineNode = node;; lineNode = lineNode.parentNode) { 453 if (!lineNode || lineNode == cm.display.lineDiv) return null 454 if (lineNode.parentNode && lineNode.parentNode == cm.display.lineDiv) break 455 } 456 } 457 for (let i = 0; i < cm.display.view.length; i++) { 458 let lineView = cm.display.view[i] 459 if (lineView.node == lineNode) 460 return locateNodeInLineView(lineView, node, offset) 461 } 462 } 463 464 function locateNodeInLineView(lineView, node, offset) { 465 let wrapper = lineView.text.firstChild, bad = false 466 if (!node || !contains(wrapper, node)) return badPos(Pos(lineNo(lineView.line), 0), true) 467 if (node == wrapper) { 468 bad = true 469 node = wrapper.childNodes[offset] 470 offset = 0 471 if (!node) { 472 let line = lineView.rest ? lst(lineView.rest) : lineView.line 473 return badPos(Pos(lineNo(line), line.text.length), bad) 474 } 475 } 476 477 let textNode = node.nodeType == 3 ? node : null, topNode = node 478 if (!textNode && node.childNodes.length == 1 && node.firstChild.nodeType == 3) { 479 textNode = node.firstChild 480 if (offset) offset = textNode.nodeValue.length 481 } 482 while (topNode.parentNode != wrapper) topNode = topNode.parentNode 483 let measure = lineView.measure, maps = measure.maps 484 485 function find(textNode, topNode, offset) { 486 for (let i = -1; i < (maps ? maps.length : 0); i++) { 487 let map = i < 0 ? measure.map : maps[i] 488 for (let j = 0; j < map.length; j += 3) { 489 let curNode = map[j + 2] 490 if (curNode == textNode || curNode == topNode) { 491 let line = lineNo(i < 0 ? lineView.line : lineView.rest[i]) 492 let ch = map[j] + offset 493 if (offset < 0 || curNode != textNode) ch = map[j + (offset ? 1 : 0)] 494 return Pos(line, ch) 495 } 496 } 497 } 498 } 499 let found = find(textNode, topNode, offset) 500 if (found) return badPos(found, bad) 501 502 // FIXME this is all really shaky. might handle the few cases it needs to handle, but likely to cause problems 503 for (let after = topNode.nextSibling, dist = textNode ? textNode.nodeValue.length - offset : 0; after; after = after.nextSibling) { 504 found = find(after, after.firstChild, 0) 505 if (found) 506 return badPos(Pos(found.line, found.ch - dist), bad) 507 else 508 dist += after.textContent.length 509 } 510 for (let before = topNode.previousSibling, dist = offset; before; before = before.previousSibling) { 511 found = find(before, before.firstChild, -1) 512 if (found) 513 return badPos(Pos(found.line, found.ch + dist), bad) 514 else 515 dist += before.textContent.length 516 } 517 }