diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d8d80ecec..2e1b66e16 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1,6 +1,4 @@ -/** @babel */ - -import {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} from './async-spec-helpers' +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') const TextEditorComponent = require('../src/text-editor-component') const TextEditor = require('../src/text-editor') @@ -21,7 +19,7 @@ document.registerElement('text-editor-component-test-element', { }) }) -describe('TextEditorComponent', () => { +fdescribe('TextEditorComponent', () => { beforeEach(() => { jasmine.useRealClock() }) @@ -30,12 +28,12 @@ describe('TextEditorComponent', () => { it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) - expect(element.querySelectorAll('.line-number').length).toBe(13) + expect(element.querySelectorAll('.line-number').length).toBe(13 + 1) // +1 for placeholder line number expect(element.querySelectorAll('.line').length).toBe(13) element.style.height = 4 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() - expect(element.querySelectorAll('.line-number').length).toBe(9) + expect(element.querySelectorAll('.line-number').length).toBe(9 + 1) // +1 for placeholder line number expect(element.querySelectorAll('.line').length).toBe(9) await setScrollTop(component, 5 * component.getLineHeight()) @@ -43,7 +41,7 @@ describe('TextEditorComponent', () => { // After scrolling down beyond > 3 rows, the order of line numbers and lines // in the DOM is a bit weird because the first tile is recycled to the bottom // when it is scrolled out of view - expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + expect(Array.from(element.querySelectorAll('.line-number')).slice(1).map(element => element.textContent.trim())).toEqual([ '10', '11', '12', '4', '5', '6', '7', '8', '9' ]) expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ @@ -59,7 +57,7 @@ describe('TextEditorComponent', () => { ]) await setScrollTop(component, 2.5 * component.getLineHeight()) - expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + expect(Array.from(element.querySelectorAll('.line-number')).slice(1).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' ]) expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ @@ -107,18 +105,19 @@ describe('TextEditorComponent', () => { expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) }) - it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { + it('gives the line number tiles an explicit width and height so their layout can be strictly contained', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) - const gutterElement = element.querySelector('.gutter.line-numbers') - expect(gutterElement.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') - expect(gutterElement.style.height).toBe(editor.getScreenLineCount() * component.measurements.lineHeight + 'px') - expect(gutterElement.style.contain).toBe('strict') + const gutterElement = component.refs.lineNumberGutter.element + for (const child of gutterElement.children) { + expect(child.offsetWidth).toBe(gutterElement.offsetWidth) + } - // Tile nodes also have explicit width and height assignment - expect(gutterElement.firstChild.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') - expect(gutterElement.firstChild.style.height).toBe(3 * component.measurements.lineHeight + 'px') - expect(gutterElement.firstChild.style.contain).toBe('strict') + editor.setText('\n'.repeat(99)) + await component.getNextUpdatePromise() + for (const child of gutterElement.children) { + expect(child.offsetWidth).toBe(gutterElement.offsetWidth) + } }) it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { @@ -994,26 +993,30 @@ describe('TextEditorComponent', () => { const {component, element, editor} = buildComponent() const {scrollContainer, gutterContainer} = component.refs - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + function checkScrollContainerLeft () { + expect(scrollContainer.getBoundingClientRect().left).toBe(Math.round(gutterContainer.getBoundingClientRect().right)) + } + + checkScrollContainerLeft() const gutterA = editor.addGutter({name: 'a'}) await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() const gutterB = editor.addGutter({name: 'b'}) await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() gutterA.getElement().style.width = 100 + 'px' await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() gutterA.destroy() await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() gutterB.destroy() await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() }) it('allows the element of custom gutters to be retrieved before being rendered in the editor component', async () => { @@ -1858,7 +1861,7 @@ function lineNumberNodeForScreenRow (component, row) { const gutterElement = component.refs.lineNumberGutter.element const tileStartRow = component.tileStartRowForRow(row) const tileIndex = component.tileIndexForTileStartRow(tileStartRow) - return gutterElement.children[tileIndex].children[row - tileStartRow] + return gutterElement.children[tileIndex + 1].children[row - tileStartRow] } function lineNodeForScreenRow (component, row) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 29eef8f3e..a895997a1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,8 +74,14 @@ class TextEditorComponent { this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.accentedCharacterMenuIsOpen = false - this.remeasureGutterContainer = false - this.guttersToRender = [] + this.remeasureGutterDimensions = false + this.guttersToRender = [this.props.model.getLineNumberGutter()] + this.lineNumbersToRender = { + maxDigits: 2, + numbers: [], + keys: [], + foldableFlags: [] + } this.decorationsToRender = { lineNumbers: new Map(), lines: new Map(), @@ -89,6 +95,9 @@ class TextEditorComponent { cursors: [] } + this.queryGuttersToRender() + this.queryMaxLineNumberDigits() + etch.updateSync(this) this.observeModel() @@ -140,6 +149,7 @@ class TextEditorComponent { if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() + this.queryLineNumbersToRender() this.queryGuttersToRender() this.queryDecorationsToRender() this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle @@ -150,9 +160,9 @@ class TextEditorComponent { measureContentDuringUpdateSync () { this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() - if (this.remeasureGutterContainer) { + if (this.remeasureGutterDimensions) { this.measureGutterDimensions() - this.remeasureGutterContainer = false + this.remeasureGutterDimensions = false } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() this.measureLongestLineWidth() @@ -243,25 +253,10 @@ class TextEditorComponent { display: 'flex' } - let gutterNodes + let scrollHeight if (this.measurements) { innerStyle.transform = `translateY(${-this.getScrollTop()}px)` - gutterNodes = this.guttersToRender.map((gutter) => { - if (gutter.name === 'line-number') { - return this.renderLineNumberGutter(gutter) - } else { - return $(CustomGutterComponent, { - key: gutter, - element: gutter.getElement(), - name: gutter.name, - visible: gutter.isVisible(), - height: this.getScrollHeight(), - decorations: this.decorationsToRender.customGutter.get(gutter.name) - }) - } - }) - } else { - gutterNodes = this.renderLineNumberGutter() + scrollHeight = this.getScrollHeight() } return $.div( @@ -274,68 +269,52 @@ class TextEditorComponent { backgroundColor: 'inherit' } }, - $.div({style: innerStyle}, gutterNodes) + $.div({style: innerStyle}, + this.guttersToRender.map((gutter) => { + if (gutter.name === 'line-number') { + return this.renderLineNumberGutter(gutter) + } else { + return $(CustomGutterComponent, { + key: gutter, + element: gutter.getElement(), + name: gutter.name, + visible: gutter.isVisible(), + height: scrollHeight, + decorations: this.decorationsToRender.customGutter.get(gutter.name) + }) + } + }) + ) ) } renderLineNumberGutter (gutter) { - const {model} = this.props - - if (!model.isLineNumberGutterVisible()) return null - - if (this.currentFrameLineNumberGutterProps) { - return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) - } - - const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length) + if (!this.props.model.isLineNumberGutterVisible()) return null if (this.measurements) { - const startRow = this.getRenderedStartRow() - const endRow = this.getRenderedEndRow() - const renderedRowCount = this.getRenderedRowCount() - const bufferRows = new Array(renderedRowCount) - const foldableFlags = new Array(renderedRowCount) - const softWrappedFlags = new Array(renderedRowCount) - const lineNumberDecorations = new Array(renderedRowCount) - - let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 - for (let row = startRow; row < endRow; row++) { - const i = row - startRow - const bufferRow = model.bufferRowForScreenRow(row) - bufferRows[i] = bufferRow - softWrappedFlags[i] = bufferRow === previousBufferRow - foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) - lineNumberDecorations[i] = this.decorationsToRender.lineNumbers.get(row) - previousBufferRow = bufferRow - } - - const rowsPerTile = this.getRowsPerTile() - - this.currentFrameLineNumberGutterProps = { + const {maxDigits, keys, numbers, foldableFlags} = this.lineNumbersToRender + return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), parentComponent: this, + startRow: this.getRenderedStartRow(), + endRow: this.getRenderedEndRow(), + rowsPerTile: this.getRowsPerTile(), + maxDigits: maxDigits, + keys: keys, + numbers: numbers, + foldableFlags: foldableFlags, + decorations: this.decorationsToRender.lineNumbers, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), lineHeight: this.getLineHeight(), - startRow, endRow, rowsPerTile, maxLineNumberDigits, - bufferRows, lineNumberDecorations, softWrappedFlags, - foldableFlags - } - - return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) + }) } else { - return $.div( - { - ref: 'lineNumberGutter', - className: 'gutter line-numbers', - attributes: {'gutter-name': 'line-number'} - }, - $.div({className: 'line-number'}, - '0'.repeat(maxLineNumberDigits), - $.div({className: 'icon-right'}) - ) - ) + return $(LineNumberGutterComponent, { + ref: 'lineNumberGutter', + element: gutter.getElement(), + maxDigits: this.lineNumbersToRender.maxDigits + }) } } @@ -639,6 +618,51 @@ class TextEditorComponent { } } + queryLineNumbersToRender () { + const {model} = this.props + if (!model.isLineNumberGutterVisible()) return + + this.queryMaxLineNumberDigits() + + const startRow = this.getRenderedStartRow() + const endRow = this.getRenderedEndRow() + const renderedRowCount = this.getRenderedRowCount() + + const {numbers, keys, foldableFlags} = this.lineNumbersToRender + numbers.length = renderedRowCount + keys.length = renderedRowCount + foldableFlags.length = renderedRowCount + + let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 + let softWrapCount = 0 + for (let row = startRow; row < endRow; row++) { + const i = row - startRow + const bufferRow = model.bufferRowForScreenRow(row) + if (bufferRow === previousBufferRow) { + numbers[i] = -1 + keys[i] = bufferRow + 1 + '-' + softWrapCount++ + foldableFlags[i] = false + } else { + softWrapCount = 0 + numbers[i] = bufferRow + 1 + keys[i] = bufferRow + 1 + foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) + } + previousBufferRow = bufferRow + } + } + + queryMaxLineNumberDigits () { + const {model} = this.props + if (model.isLineNumberGutterVisible()) { + const maxDigits = Math.max(2, model.getLineCount().toString().length) + if (maxDigits !== this.lineNumbersToRender.maxDigits) { + this.remeasureGutterDimensions = true + this.lineNumbersToRender.maxDigits = maxDigits + } + } + } + renderedScreenLineForRow (row) { return this.renderedScreenLines[row - this.getRenderedStartRow()] } @@ -648,11 +672,11 @@ class TextEditorComponent { this.guttersToRender = this.props.model.getGutters() if (!oldGuttersToRender || oldGuttersToRender.length !== this.guttersToRender.length) { - this.remeasureGutterContainer = true + this.remeasureGutterDimensions = true } else { for (let i = 0, length = this.guttersToRender.length; i < length; i++) { if (this.guttersToRender[i] !== oldGuttersToRender[i]) { - this.remeasureGutterContainer = true + this.remeasureGutterDimensions = true break } } @@ -1027,7 +1051,6 @@ class TextEditorComponent { } didResizeGutterContainer () { - console.log('didResizeGutterContainer'); if (this.measureGutterDimensions()) { this.scheduleUpdate() } @@ -1476,10 +1499,10 @@ class TextEditorComponent { } if (this.refs.lineNumberGutter) { - const lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + const lineNumberGutterWidth = this.refs.lineNumberGutter.element.offsetWidth if (lineNumberGutterWidth !== this.measurements.lineNumberGutterWidth) { dimensionsChanged = true - this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + this.measurements.lineNumberGutterWidth = lineNumberGutterWidth } } else { this.measurements.lineNumberGutterWidth = 0 @@ -2068,85 +2091,81 @@ class LineNumberGutterComponent { render () { const { parentComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, - maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags, - lineNumberDecorations + maxDigits, keys, numbers, foldableFlags, decorations } = this.props - const renderedTileCount = parentComponent.getRenderedTileCount() - const children = new Array(renderedTileCount) - const tileHeight = rowsPerTile * lineHeight + 'px' - const tileWidth = width + 'px' + let children = null - let softWrapCount = 0 - for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { - const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) - const tileChildren = new Array(tileEndRow - tileStartRow) - for (let row = tileStartRow; row < tileEndRow; row++) { - const i = row - startRow - const bufferRow = bufferRows[i] - const softWrapped = softWrappedFlags[i] - const foldable = foldableFlags[i] - let key, lineNumber - let className = 'line-number' - if (softWrapped) { - softWrapCount++ - key = `${bufferRow}-${softWrapCount}` - lineNumber = '•' - } else { - softWrapCount = 0 - key = bufferRow - lineNumber = (bufferRow + 1).toString() + if (numbers) { + const renderedTileCount = parentComponent.getRenderedTileCount() + children = new Array(renderedTileCount) + const tileHeight = rowsPerTile * lineHeight + 'px' + const tileWidth = width + 'px' + + let softWrapCount = 0 + for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { + const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) + const tileChildren = new Array(tileEndRow - tileStartRow) + for (let row = tileStartRow; row < tileEndRow; row++) { + const i = row - startRow + const key = keys[i] + const foldable = foldableFlags[i] + let number = numbers[i] + + let className = 'line-number' if (foldable) className += ' foldable' + + const decorationsForRow = decorations.get(row) + if (decorationsForRow) className += ' ' + decorationsForRow + + if (number === -1) number = '•' + number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number + + tileChildren[row - tileStartRow] = $.div({key, className}, + number, + $.div({className: 'icon-right'}) + ) } - const lineNumberDecoration = lineNumberDecorations[i] - if (lineNumberDecoration != null) className += ' ' + lineNumberDecoration + const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) + const top = tileStartRow * lineHeight - lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber - - tileChildren[row - tileStartRow] = $.div({key, className}, - lineNumber, - $.div({className: 'icon-right'}) - ) + children[tileIndex] = $.div({ + key: tileIndex, + style: { + contain: 'strict', + overflow: 'hidden', + position: 'absolute', + top: 0, + height: tileHeight, + width: tileWidth, + willChange: 'transform', + transform: `translateY(${top}px)`, + backgroundColor: 'inherit' + } + }, ...tileChildren) } - - const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) - const top = tileStartRow * lineHeight - - children[tileIndex] = $.div({ - key: tileIndex, - on: { - mousedown: this.didMouseDown - }, - style: { - contain: 'strict', - overflow: 'hidden', - position: 'absolute', - height: tileHeight, - width: tileWidth, - willChange: 'transform', - transform: `translateY(${top}px)`, - backgroundColor: 'inherit' - } - }, ...tileChildren) } return $.div( { className: 'gutter line-numbers', attributes: {'gutter-name': 'line-number'}, - style: { - contain: 'strict', - overflow: 'hidden', - height: height + 'px', - width: tileWidth - } + style: {position: 'relative'}, + on: { + mousedown: this.didMouseDown + }, }, - ...children + $.div({key: 'placeholder', className: 'line-number', style: {visibility: 'hidden'}}, + '0'.repeat(maxDigits), + $.div({className: 'icon-right'}) + ), + children ) } shouldUpdate (newProps) { + return true const oldProps = this.props if (oldProps.height !== newProps.height) return true @@ -2155,11 +2174,11 @@ class LineNumberGutterComponent { if (oldProps.startRow !== newProps.startRow) return true if (oldProps.endRow !== newProps.endRow) return true if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true - if (oldProps.maxLineNumberDigits !== newProps.maxLineNumberDigits) return true - if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true - if (!arraysEqual(oldProps.softWrappedFlags, newProps.softWrappedFlags)) return true + if (oldProps.maxDigits !== newProps.maxDigits) return true + if (!arraysEqual(oldProps.keys, newProps.keys)) return true + if (!arraysEqual(oldProps.numbers, newProps.numbers)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true - if (!arraysEqual(oldProps.lineNumberDecorations, newProps.lineNumberDecorations)) return true + if (!arraysEqual(oldProps.decorations, newProps.decorations)) return true return false } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 2cd30944c..f2c0ab92f 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -2671,7 +2671,6 @@ class TextEditor extends Model _.last(@selections) getSelectionAtScreenPosition: (position) -> - debugger if global.debug markers = @selectionsMarkerLayer.findMarkers(containsScreenPosition: position) if markers.length > 0 @cursorsByMarkerId.get(markers[0].id).selection @@ -3405,6 +3404,9 @@ class TextEditor extends Model getGutters: -> @gutterContainer.getGutters() + getLineNumberGutter: -> + @lineNumberGutter + # Essential: Get the gutter with the given name. # # Returns a {Gutter}, or `null` if no gutter exists for the given name.