UNPKG

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