diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 79f9501ab..841008b37 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -38,8 +38,7 @@ describe('TextEditorComponent', () => { expect(element.querySelectorAll('.line-number').length).toBe(9) expect(element.querySelectorAll('.line').length).toBe(9) - component.setScrollTop(5 * component.getLineHeight()) - await component.getNextUpdatePromise() + await setScrollTop(component, 5 * component.getLineHeight()) // 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 @@ -59,8 +58,7 @@ describe('TextEditorComponent', () => { editor.lineTextForScreenRow(8) ]) - component.setScrollTop(2.5 * component.getLineHeight()) - await component.getNextUpdatePromise() + await setScrollTop(component, 2.5 * component.getLineHeight()) expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' ]) @@ -95,8 +93,7 @@ describe('TextEditorComponent', () => { await setEditorHeightInLines(component, 6) // scroll to end - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() + await setScrollTop(component, scrollContainer.scrollHeight - scrollContainer.clientHeight) expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) editor.update({scrollPastEnd: false}) @@ -106,8 +103,7 @@ describe('TextEditorComponent', () => { // Always allows at least 3 lines worth of overscroll if the editor is short await setEditorHeightInLines(component, 2) await editor.update({scrollPastEnd: true}) - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() + await setScrollTop(component, scrollContainer.scrollHeight - scrollContainer.clientHeight) expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) }) @@ -125,10 +121,73 @@ describe('TextEditorComponent', () => { expect(gutterElement.firstChild.style.contain).toBe('strict') }) + it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { + const {component, element, editor} = buildComponent({height: 100, width: 100}) + const verticalScrollbar = component.refs.verticalScrollbar.element + const horizontalScrollbar = component.refs.horizontalScrollbar.element + expect(verticalScrollbar.scrollHeight).toBe(component.getContentHeight()) + expect(horizontalScrollbar.scrollWidth).toBe(component.getContentWidth()) + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0) + expect(verticalScrollbar.style.bottom).toBe(getVerticalScrollbarWidth(component) + 'px') + expect(horizontalScrollbar.style.right).toBe(getHorizontalScrollbarHeight(component) + 'px') + expect(component.refs.scrollbarCorner).toBeDefined() + + setScrollTop(component, 100) + await setScrollLeft(component, 100) + expect(verticalScrollbar.scrollTop).toBe(100) + expect(horizontalScrollbar.scrollLeft).toBe(100) + + verticalScrollbar.scrollTop = 120 + horizontalScrollbar.scrollLeft = 120 + await component.getNextUpdatePromise() + expect(component.getScrollTop()).toBe(120) + expect(component.getScrollLeft()).toBe(120) + + editor.setText('a\n'.repeat(15)) + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0) + expect(getHorizontalScrollbarHeight(component)).toBe(0) + expect(verticalScrollbar.style.bottom).toBe('0px') + expect(component.refs.scrollbarCorner).toBeUndefined() + + editor.setText('a'.repeat(100)) + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBe(0) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0) + expect(horizontalScrollbar.style.right).toBe('0px') + expect(component.refs.scrollbarCorner).toBeUndefined() + + editor.setText('') + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBe(0) + expect(getHorizontalScrollbarHeight(component)).toBe(0) + expect(component.refs.scrollbarCorner).toBeUndefined() + }) + + it('updates the bottom/right of dummy scrollbars and client height/width measurements when scrollbar styles change', async () => { + const {component, element, editor} = buildComponent({height: 100, width: 100}) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(10) + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(10) + + const style = document.createElement('style') + style.textContent = '::-webkit-scrollbar { height: 10px; width: 10px; }' + jasmine.attachToDOM(style) + + TextEditor.didUpdateScrollbarStyles() + await component.getNextUpdatePromise() + + expect(getHorizontalScrollbarHeight(component)).toBe(10) + expect(getVerticalScrollbarWidth(component)).toBe(10) + expect(component.refs.horizontalScrollbar.element.style.right).toBe('10px') + expect(component.refs.verticalScrollbar.element.style.bottom).toBe('10px') + expect(component.getScrollContainerClientHeight()).toBe(100 - 10) + expect(component.getScrollContainerClientWidth()).toBe(100 - component.getGutterContainerWidth() - 10) + }) + it('renders cursors within the visible row range', async () => { const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) - component.setScrollTop(100) - await component.getNextUpdatePromise() + await setScrollTop(component, 100) expect(component.getRenderedStartRow()).toBe(4) expect(component.getRenderedEndRow()).toBe(10) @@ -171,9 +230,8 @@ describe('TextEditorComponent', () => { 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.setScrollTop(100) - component.setScrollLeft(40) - await component.getNextUpdatePromise() + setScrollTop(component, 100) + await setScrollLeft(component, 40) expect(component.getRenderedStartRow()).toBe(4) expect(component.getRenderedEndRow()).toBe(12) @@ -718,8 +776,7 @@ describe('TextEditorComponent', () => { ) // Don't flash on next update if another flash wasn't requested - component.setScrollTop(100) - await component.getNextUpdatePromise() + await setScrollTop(component, 100) expect(highlights[0].classList.contains('b')).toBe(false) expect(highlights[1].classList.contains('b')).toBe(false) @@ -1184,9 +1241,8 @@ describe('TextEditorComponent', () => { const maxScrollTop = component.getMaxScrollTop() const maxScrollLeft = component.getMaxScrollLeft() - component.setScrollTop(maxScrollTop) - component.setScrollLeft(maxScrollLeft) - await component.getNextUpdatePromise() + setScrollTop(component, maxScrollTop) + await setScrollLeft(component, maxScrollLeft) didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) @@ -1413,9 +1469,8 @@ describe('TextEditorComponent', () => { const maxScrollTop = component.getMaxScrollTop() const maxScrollLeft = component.getMaxScrollLeft() - component.setScrollTop(maxScrollTop) - component.setScrollLeft(maxScrollLeft) - await component.getNextUpdatePromise() + setScrollTop(component, maxScrollTop) + await setScrollLeft(component, maxScrollLeft) didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) @@ -1518,6 +1573,28 @@ function textNodesForScreenRow (component, row) { return component.textNodesByScreenLineId.get(screenLine.id) } +function setScrollTop (component, scrollTop) { + component.setScrollTop(scrollTop) + component.scheduleUpdate() + return component.getNextUpdatePromise() +} + +function setScrollLeft (component, scrollTop) { + component.setScrollLeft(scrollTop) + component.scheduleUpdate() + return component.getNextUpdatePromise() +} + +function getHorizontalScrollbarHeight (component) { + const element = component.refs.horizontalScrollbar.element + return element.offsetHeight - element.clientHeight +} + +function getVerticalScrollbarWidth (component) { + const element = component.refs.verticalScrollbar.element + return element.offsetWidth - element.clientWidth +} + function assertDocumentFocused () { if (!document.hasFocus()) { throw new Error('The document needs to be focused to run this test') diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ff199d491..dc3f7dd66 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -55,9 +55,12 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.scrollbarsVisible = true this.refreshScrollbarStyling = false this.pendingAutoscroll = null + this.scrollTopPending = false + this.scrollLeftPending = false this.scrollTop = 0 this.scrollLeft = 0 this.previousScrollWidth = 0 @@ -134,7 +137,8 @@ class TextEditorComponent { etch.updateSync(this) this.currentFrameLineNumberGutterProps = null - + this.scrollTopPending = false + this.scrollLeftPending = false if (this.refreshScrollbarStyling) { this.measureScrollbarDimensions() this.refreshScrollbarStyling = false @@ -529,11 +533,13 @@ class TextEditorComponent { $(DummyScrollbarComponent, { ref: 'verticalScrollbar', orientation: 'vertical', + didScroll: this.didScrollDummyScrollbar, scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible }), $(DummyScrollbarComponent, { ref: 'horizontalScrollbar', orientation: 'horizontal', + didScroll: this.didScrollDummyScrollbar, scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible }) ] @@ -543,6 +549,7 @@ class TextEditorComponent { if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) { elements.push($.div( { + ref: 'scrollbarCorner', style: { position: 'absolute', height: '20px', @@ -869,6 +876,18 @@ class TextEditorComponent { } } + didScrollDummyScrollbar () { + let scrollTopChanged = false + let scrollLeftChanged = false + if (!this.scrollTopPending) { + scrollTopChanged = this.setScrollTop(this.refs.verticalScrollbar.element.scrollTop) + } + if (!this.scrollLeftPending) { + scrollLeftChanged = this.setScrollLeft(this.refs.horizontalScrollbar.element.scrollLeft) + } + if (scrollTopChanged || scrollLeftChanged) this.updateSync() + } + didUpdateScrollbarStyles () { this.refreshScrollbarStyling = true this.scheduleUpdate() @@ -1197,17 +1216,17 @@ class TextEditorComponent { if (!options || options.reversed !== false) { if (desiredScrollBottom > this.getScrollBottom()) { - return this.setScrollBottom(desiredScrollBottom, true) + this.setScrollBottom(desiredScrollBottom) } if (desiredScrollTop < this.getScrollTop()) { - return this.setScrollTop(desiredScrollTop, true) + this.setScrollTop(desiredScrollTop) } } else { if (desiredScrollTop < this.getScrollTop()) { - return this.setScrollTop(desiredScrollTop, true) + this.setScrollTop(desiredScrollTop) } if (desiredScrollBottom > this.getScrollBottom()) { - return this.setScrollBottom(desiredScrollBottom, true) + this.setScrollBottom(desiredScrollBottom) } } @@ -1226,17 +1245,17 @@ class TextEditorComponent { if (!options || options.reversed !== false) { if (desiredScrollRight > this.getScrollRight()) { - this.setScrollRight(desiredScrollRight, true) + this.setScrollRight(desiredScrollRight) } if (desiredScrollLeft < this.getScrollLeft()) { - this.setScrollLeft(desiredScrollLeft, true) + this.setScrollLeft(desiredScrollLeft) } } else { if (desiredScrollLeft < this.getScrollLeft()) { - this.setScrollLeft(desiredScrollLeft, true) + this.setScrollLeft(desiredScrollLeft) } if (desiredScrollRight > this.getScrollRight()) { - this.setScrollRight(desiredScrollRight, true) + this.setScrollRight(desiredScrollRight) } } } @@ -1691,11 +1710,11 @@ class TextEditorComponent { return this.scrollTop } - setScrollTop (scrollTop, suppressUpdate = false) { + setScrollTop (scrollTop) { scrollTop = Math.round(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop))) if (scrollTop !== this.scrollTop) { + this.scrollTopPending = true this.scrollTop = scrollTop - if (!suppressUpdate) this.scheduleUpdate() return true } else { return false @@ -1710,8 +1729,8 @@ class TextEditorComponent { return this.getScrollTop() + this.getScrollContainerClientHeight() } - setScrollBottom (scrollBottom, suppressUpdate = false) { - return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight(), suppressUpdate) + setScrollBottom (scrollBottom) { + return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight()) } getScrollLeft () { @@ -1719,11 +1738,11 @@ class TextEditorComponent { return this.scrollLeft } - setScrollLeft (scrollLeft, suppressUpdate = false) { + setScrollLeft (scrollLeft) { scrollLeft = Math.round(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft))) if (scrollLeft !== this.scrollLeft) { + this.scrollLeftPending = true this.scrollLeft = scrollLeft - if (!suppressUpdate) this.scheduleUpdate() return true } else { return false @@ -1738,8 +1757,8 @@ class TextEditorComponent { return this.getScrollLeft() + this.getScrollContainerClientWidth() } - setScrollRight (scrollRight, suppressUpdate = false) { - return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth(), suppressUpdate) + setScrollRight (scrollRight) { + return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth()) } // Ensure the spatial index is populated with rows that are currently @@ -1807,7 +1826,13 @@ class DummyScrollbarComponent { innerStyle.height = (this.props.scrollHeight || 0) + 'px' } - return $.div({style: outerStyle, scrollTop, scrollLeft}, + return $.div( + { + style: outerStyle, + scrollTop, + scrollLeft, + on: {scroll: this.props.didScroll} + }, $.div({style: innerStyle}) ) }