diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d8528a460..999779295 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -151,6 +151,51 @@ describe('TextEditorComponent', () => { cursorNodes = Array.from(element.querySelectorAll('.cursor')) expect(cursorNodes.length).toBe(0) }) + + it('places the hidden input element at the location of the last cursor if it is visible', async () => { + const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) + const {hiddenInput} = component.refs + component.refs.scroller.scrollTop = 100 + component.refs.scroller.scrollLeft = 40 + await component.getNextUpdatePromise() + + expect(component.getRenderedStartRow()).toBe(4) + expect(component.getRenderedEndRow()).toBe(12) + + // When out of view, the hidden input is positioned at 0, 0 + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + console.log(hiddenInput.offsetParent); + console.log(hiddenInput.offsetTop); + expect(hiddenInput.offsetTop).toBe(0) + expect(hiddenInput.offsetLeft).toBe(0) + + // Otherwise it is positioned at the last cursor position + editor.addCursorAtScreenPosition([7, 4]) + await component.getNextUpdatePromise() + expect(hiddenInput.getBoundingClientRect().top).toBe(clientTopForLine(component, 7)) + expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) + }) + + it('focuses the hidden input elemnent and adds the is-focused class when focused', async () => { + const {component, element, editor} = buildComponent() + const {hiddenInput} = component.refs + + expect(document.activeElement).not.toBe(hiddenInput) + element.focus() + expect(document.activeElement).toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) + + element.focus() // focusing back to the element does not blur + expect(document.activeElement).toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) + + document.body.focus() + expect(document.activeElement).not.toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(false) + }) }) function verifyCursorPosition (component, cursorNode, row, column) { @@ -180,10 +225,10 @@ function clientLeftForCharacter (component, row, column) { function lineNodeForScreenRow (component, row) { const screenLine = component.getModel().screenLineForScreenRow(row) - return component.lineNodesByScreenLine.get(screenLine) + return component.lineNodesByScreenLineId.get(screenLine.id) } function textNodesForScreenRow (component, row) { const screenLine = component.getModel().screenLineForScreenRow(row) - return component.textNodesByScreenLine.get(screenLine) + return component.textNodesByScreenLineId.get(screenLine.id) } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index de5584ae9..a70d23821 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -26,9 +26,9 @@ class TextEditorComponent { this.measurements = null this.visible = false this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure - this.horizontalPixelPositionsByScreenLine = new WeakMap() // Values are maps from column to horiontal pixel positions - this.lineNodesByScreenLine = new WeakMap() - this.textNodesByScreenLine = new WeakMap() + this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions + this.lineNodesByScreenLineId = new Map() + this.textNodesByScreenLineId = new Map() this.cursorsToRender = [] if (this.props.model) this.observeModel() @@ -87,8 +87,18 @@ class TextEditorComponent { style = {contain: 'strict'} } - return $('atom-text-editor', {style}, - $.div({ref: 'scroller', onScroll: this.didScroll, className: 'scroll-view'}, + let className = 'editor' + if (this.focused) { + className += ' is-focused' + } + + return $('atom-text-editor', { + className, + style, + tabIndex: -1, + on: {focus: this.didFocus} + }, + $.div({ref: 'scroller', on: {scroll: this.didScroll}, className: 'scroll-view'}, $.div({ style: { isolate: 'content', @@ -212,7 +222,7 @@ class TextEditorComponent { style.width = width style.height = height children = [ - this.renderCursors(width, height), + this.renderCursorsAndInput(width, height), this.renderLineTiles(width, height) ] } else { @@ -230,7 +240,7 @@ class TextEditorComponent { renderLineTiles (width, height) { if (!this.measurements) return [] - const {lineNodesByScreenLine, textNodesByScreenLine} = this + const {lineNodesByScreenLineId, textNodesByScreenLineId} = this const firstTileStartRow = this.getFirstTileStartRow() const visibleTileCount = this.getVisibleTileCount() @@ -250,8 +260,8 @@ class TextEditorComponent { key: screenLine.id, screenLine, displayLayer, - lineNodesByScreenLine, - textNodesByScreenLine + lineNodesByScreenLineId, + textNodesByScreenLineId })) if (screenLine === this.longestLineToMeasure) { this.longestLineToMeasure = null @@ -280,8 +290,8 @@ class TextEditorComponent { key: this.longestLineToMeasure.id, screenLine: this.longestLineToMeasure, displayLayer, - lineNodesByScreenLine, - textNodesByScreenLine + lineNodesByScreenLineId, + textNodesByScreenLineId })) this.longestLineToMeasure = null } @@ -297,9 +307,23 @@ class TextEditorComponent { }, tileNodes) } - renderCursors (width, height) { + renderCursorsAndInput (width, height) { const cursorHeight = this.measurements.lineHeight + 'px' + const children = [this.renderHiddenInput()] + + for (let i = 0; i < this.cursorsToRender.length; i++) { + const {pixelLeft, pixelTop, pixelWidth} = this.cursorsToRender[i] + children.push($.div({ + className: 'cursor', + style: { + height: cursorHeight, + width: pixelWidth + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + } + })) + } + return $.div({ key: 'cursors', className: 'cursors', @@ -308,18 +332,37 @@ class TextEditorComponent { contain: 'strict', width, height } - }, - this.cursorsToRender.map(({pixelLeft, pixelTop, pixelWidth}) => - $.div({ - className: 'cursor', - style: { - height: cursorHeight, - width: pixelWidth + 'px', - transform: `translate(${pixelLeft}px, ${pixelTop}px)` - } - }) - ) - ) + }, children) + } + + renderHiddenInput () { + let top, left + const hiddenInputState = this.getHiddenInputState() + if (hiddenInputState) { + top = hiddenInputState.pixelTop + left = hiddenInputState.pixelLeft + } else { + top = 0 + left = 0 + } + + return $.input({ + ref: 'hiddenInput', + key: 'hiddenInput', + className: 'hidden-input', + on: {blur: this.didBlur}, + tabIndex: -1, + style: { + position: 'absolute', + width: '1px', + height: this.measurements.lineHeight + 'px', + top: top + 'px', + left: left + 'px', + opacity: 0, + padding: 0, + border: 0 + } + }) } queryCursorsToRender () { @@ -330,10 +373,14 @@ class TextEditorComponent { this.getRenderedEndRow() - 1, ] }) + const lastCursorMarker = model.getLastCursor().getMarker() this.cursorsToRender.length = cursorMarkers.length + this.lastCursorIndex = -1 for (let i = 0; i < cursorMarkers.length; i++) { - const screenPosition = cursorMarkers[i].getHeadScreenPosition() + const cursorMarker = cursorMarkers[i] + if (cursorMarker === lastCursorMarker) this.lastCursorIndex = i + const screenPosition = cursorMarker.getHeadScreenPosition() const {row, column} = screenPosition this.requestHorizontalMeasurement(row, column) let columnWidth = 0 @@ -367,6 +414,12 @@ class TextEditorComponent { } } + getHiddenInputState () { + if (this.lastCursorIndex >= 0) { + return this.cursorsToRender[this.lastCursorIndex] + } + } + didAttach () { this.intersectionObserver = new IntersectionObserver((entries) => { const {intersectionRect} = entries[entries.length - 1] @@ -396,6 +449,37 @@ class TextEditorComponent { } } + didFocus () { + const {hiddenInput} = this.refs + + // Ensure the input is in the visible part of the scrolled content to avoid + // the browser trying to auto-scroll to the form-field. + hiddenInput.style.top = this.measurements.scrollTop + 'px' + hiddenInput.style.left = this.measurements.scrollLeft + 'px' + + hiddenInput.focus() + this.focused = true + + // Restore the previous position of the field now that it is focused. + const currentHiddenInputState = this.getHiddenInputState() + if (currentHiddenInputState) { + hiddenInput.style.top = currentHiddenInputState.pixelTop + 'px' + hiddenInput.style.left = currentHiddenInputState.pixelLeft + 'px' + } else { + hiddenInput.style.top = 0 + hiddenInput.style.left = 0 + } + + this.scheduleUpdate() + } + + didBlur (event) { + if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { + this.focused = false + this.scheduleUpdate() + } + } + didScroll () { this.measureScrollPosition() this.updateSync() @@ -417,13 +501,18 @@ class TextEditorComponent { } measureEditorDimensions () { + let dimensionsChanged = false const scrollerHeight = this.refs.scroller.offsetHeight + const scrollerWidth = this.refs.scroller.offsetWidth if (scrollerHeight !== this.measurements.scrollerHeight) { - this.measurements.scrollerHeight = this.refs.scroller.offsetHeight - return true - } else { - return false + this.measurements.scrollerHeight = scrollerHeight + dimensionsChanged = true } + if (scrollerWidth !== this.measurements.scrollerWidth) { + this.measurements.scrollerWidth = scrollerWidth + dimensionsChanged = true + } + return dimensionsChanged } measureScrollPosition () { @@ -440,7 +529,7 @@ class TextEditorComponent { } measureLongestLineWidth (screenLine) { - this.measurements.scrollWidth = this.lineNodesByScreenLine.get(screenLine).firstChild.offsetWidth + this.measurements.scrollWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth } measureGutterDimensions () { @@ -461,12 +550,12 @@ class TextEditorComponent { columnsToMeasure.sort((a, b) => a - b) const screenLine = this.getModel().displayLayer.getScreenLine(row) - const lineNode = this.lineNodesByScreenLine.get(screenLine) - const textNodes = this.textNodesByScreenLine.get(screenLine) - let positionsForLine = this.horizontalPixelPositionsByScreenLine.get(screenLine) + const lineNode = this.lineNodesByScreenLineId.get(screenLine.id) + const textNodes = this.textNodesByScreenLineId.get(screenLine.id) + let positionsForLine = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id) if (positionsForLine == null) { positionsForLine = new Map() - this.horizontalPixelPositionsByScreenLine.set(screenLine, positionsForLine) + this.horizontalPixelPositionsByScreenLineId.set(screenLine.id, positionsForLine) } this.measureHorizontalPositionsOnLine(lineNode, textNodes, columnsToMeasure, positionsForLine) @@ -478,6 +567,8 @@ class TextEditorComponent { let textNodeStartColumn = 0 let textNodesIndex = 0 + if (!textNodes) debugger + columnLoop: for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { while (textNodesIndex < textNodes.length) { @@ -519,8 +610,11 @@ class TextEditorComponent { } pixelLeftForScreenRowAndColumn (row, column) { + if (column === 0) return 0 const screenLine = this.getModel().displayLayer.getScreenLine(row) - return this.horizontalPixelPositionsByScreenLine.get(screenLine).get(column) + + if (!this.horizontalPixelPositionsByScreenLineId.has(screenLine.id)) debugger + return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } getModel () { @@ -622,13 +716,15 @@ class TextEditorComponent { } class LineComponent { - constructor ({displayLayer, screenLine, lineNodesByScreenLine, textNodesByScreenLine}) { + constructor (props) { + const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props + this.props = props this.element = document.createElement('div') this.element.classList.add('line') - lineNodesByScreenLine.set(screenLine, this.element) + lineNodesByScreenLineId.set(screenLine.id, this.element) const textNodes = [] - textNodesByScreenLine.set(screenLine, textNodes) + textNodesByScreenLineId.set(screenLine.id, textNodes) const {lineText, tagCodes} = screenLine let startIndex = 0 @@ -671,6 +767,11 @@ class LineComponent { } update () {} + + destroy () { + this.props.lineNodesByScreenLineId.delete(this.props.screenLine.id) + this.props.textNodesByScreenLineId.delete(this.props.screenLine.id) + } } const classNamesByScopeName = new Map() diff --git a/static/text-editor-light.less b/static/text-editor-light.less index f8d87270c..d688db3c0 100644 --- a/static/text-editor-light.less +++ b/static/text-editor-light.less @@ -146,17 +146,6 @@ atom-text-editor { box-shadow: inset 1px 0; } - .hidden-input { - padding: 0; - border: 0; - position: absolute; - z-index: -1; - top: 0; - left: 0; - opacity: 0; - width: 1px; - } - .cursor { z-index: 4; pointer-events: none;