From 2075f06404465833c14033b93a47eb5f210a7f4f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 20 Mar 2017 15:21:05 -0700 Subject: [PATCH] WIP: Introduce dummy scrollbars Still need tests on all of this --- spec/text-editor-component-spec.js | 5 +- src/atom-environment.coffee | 9 + src/text-editor-component.js | 262 +++++++++++++++++++++++++---- src/text-editor.coffee | 6 +- 4 files changed, 247 insertions(+), 35 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e96d5ecd9..c209ce6ef 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -195,7 +195,6 @@ describe('TextEditorComponent', () => { jasmine.attachToDOM(element) expect(getBaseCharacterWidth(component)).toBe(55) - console.log('running expectation'); expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left = [], ' ) @@ -337,7 +336,6 @@ describe('TextEditorComponent', () => { element.style.display = 'none' jasmine.attachToDOM(element) element.style.display = 'block' - console.log('focus in test'); element.focus() await component.getNextUpdatePromise() @@ -383,7 +381,6 @@ describe('TextEditorComponent', () => { it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => { const {component, element, editor} = buildComponent({autoHeight: false}) - const {scrollContainer} = component.refs element.style.height = 5.5 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() expect(component.getLastVisibleRow()).toBe(6) @@ -430,7 +427,7 @@ describe('TextEditorComponent', () => { clientLeftForCharacter(component, 2, 28) - lineNodeForScreenRow(component, 2).getBoundingClientRect().left + (editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) - - scrollContainer.clientWidth + component.getScrollContainerClientWidth() ) expect(component.getScrollLeft()).toBe(expectedScrollLeft) }) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 8ed491c15..7167cc36a 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -696,6 +696,11 @@ class AtomEnvironment extends Model callback = => @applicationDelegate.didSaveWindowState() @saveState({isUnloading: true}).catch(callback).then(callback) + didChangeStyles = @didChangeStyles.bind(this) + @disposables.add(@styles.onDidAddStyleElement(didChangeStyles)) + @disposables.add(@styles.onDidUpdateStyleElement(didChangeStyles)) + @disposables.add(@styles.onDidRemoveStyleElement(didChangeStyles)) + @listenForUpdates() @registerDefaultTargetForKeymaps() @@ -798,6 +803,10 @@ class AtomEnvironment extends Model @windowEventHandler?.unsubscribe() @windowEventHandler = null + didChangeStyles: (styleElement) -> + if styleElement.textContent.indexOf('scrollbar') >= 0 + TextEditor.didUpdateScrollbarStyles() + ### Section: Messaging the User ### diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1016dcb7c..ff199d491 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -24,6 +24,14 @@ function scaleMouseDragAutoscrollDelta (delta) { module.exports = class TextEditorComponent { + static didUpdateScrollbarStyles () { + if (this.attachedComponents) { + this.attachedComponents.forEach((component) => { + component.didUpdateScrollbarStyles() + }) + } + } + constructor (props) { this.props = props @@ -47,6 +55,8 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.scrollbarsVisible = true + this.refreshScrollbarStyling = false this.pendingAutoscroll = null this.scrollTop = 0 this.scrollLeft = 0 @@ -66,7 +76,7 @@ class TextEditorComponent { cursors: [] } - if (this.props.model) this.observeModel() + this.observeModel() resizeDetector.listenTo(this.element, this.didResize.bind(this)) etch.updateSync(this) @@ -98,26 +108,38 @@ class TextEditorComponent { this.resolveNextUpdatePromise = null } + const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() this.queryScreenLinesToRender() this.queryDecorationsToRender() + this.scrollbarsVisible = !this.refreshScrollbarStyling etch.updateSync(this) this.measureHorizontalPositions() if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) this.updateAbsolutePositionedDecorations() + if (this.pendingAutoscroll) { + this.autoscrollHorizontally() + if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { + this.autoscrollVertically() + } + this.pendingAutoscroll = null + } + this.scrollbarsVisible = true etch.updateSync(this) - if (this.pendingAutoscroll) { - this.autoscrollHorizontally() - this.pendingAutoscroll = null - } this.currentFrameLineNumberGutterProps = null + + if (this.refreshScrollbarStyling) { + this.measureScrollbarDimensions() + this.refreshScrollbarStyling = false + etch.updateSync(this) + } } checkIfScrollDimensionsChanged () { @@ -134,11 +156,12 @@ class TextEditorComponent { render () { const {model} = this.props - const style = {} + if (!model.getAutoHeight() && !model.getAutoWidth()) { style.contain = 'strict' } + if (this.measurements) { if (model.getAutoHeight()) { style.height = this.getContentHeight() + 'px' @@ -294,7 +317,8 @@ class TextEditorComponent { className: 'scroll-view', style }, - this.renderContent() + this.renderContent(), + this.renderDummyScrollbars() ) } @@ -478,6 +502,65 @@ class TextEditorComponent { }) } + renderDummyScrollbars () { + if (this.scrollbarsVisible) { + let scrollHeight, scrollTop, horizontalScrollbarHeight, + scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible + + if (this.measurements) { + scrollHeight = this.getScrollHeight() + scrollWidth = this.getScrollWidth() + scrollTop = this.getScrollTop() + scrollLeft = this.getScrollLeft() + horizontalScrollbarHeight = + this.isHorizontalScrollbarVisible() + ? this.getHorizontalScrollbarHeight() + : 0 + verticalScrollbarWidth = + this.isVerticalScrollbarVisible() + ? this.getVerticalScrollbarWidth() + : 0 + forceScrollbarVisible = this.refreshScrollbarStyling + } else { + forceScrollbarVisible = true + } + + const elements = [ + $(DummyScrollbarComponent, { + ref: 'verticalScrollbar', + orientation: 'vertical', + scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible + }), + $(DummyScrollbarComponent, { + ref: 'horizontalScrollbar', + orientation: 'horizontal', + scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible + }) + ] + + // If both scrollbars are visible, push a dummy element to force a "corner" + // to render where the two scrollbars meet at the lower right + if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) { + elements.push($.div( + { + style: { + position: 'absolute', + height: '20px', + width: '20px', + bottom: 0, + right: 0, + overflow: 'scroll' + } + } + )) + } + + return elements + } else { + return null + } + } + // This is easier to mock getPlatform () { return process.platform @@ -679,6 +762,10 @@ class TextEditorComponent { } else { this.didHide() } + if (!this.constructor.attachedComponents) { + this.constructor.attachedComponents = new Set() + } + this.constructor.attachedComponents.add(this) } } @@ -686,6 +773,7 @@ class TextEditorComponent { if (this.attached) { this.didHide() this.attached = false + this.constructor.attachedComponents.delete(this) } } @@ -781,6 +869,11 @@ class TextEditorComponent { } } + didUpdateScrollbarStyles () { + this.refreshScrollbarStyling = true + this.scheduleUpdate() + } + didTextInput (event) { event.stopPropagation() @@ -1175,6 +1268,30 @@ class TextEditorComponent { this.measureCharacterDimensions() this.measureGutterDimensions() this.measureClientContainerDimensions() + this.measureScrollbarDimensions() + } + + measureCharacterDimensions () { + this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height + this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width + this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width + this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width + this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt + + this.props.model.setDefaultCharWidth( + this.measurements.baseCharacterWidth, + this.measurements.doubleWidthCharacterWidth, + this.measurements.halfWidthCharacterWidth, + this.measurements.koreanCharacterWidth + ) + } + + measureGutterDimensions () { + if (this.refs.lineNumberGutter) { + this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + } else { + this.measurements.lineNumberGutterWidth = 0 + } } measureClientContainerDimensions () { @@ -1195,19 +1312,9 @@ class TextEditorComponent { return dimensionsChanged } - measureCharacterDimensions () { - this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height - this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width - this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width - this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width - this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt - - this.props.model.setDefaultCharWidth( - this.measurements.baseCharacterWidth, - this.measurements.doubleWidthCharacterWidth, - this.measurements.halfWidthCharacterWidth, - this.measurements.koreanCharacterWidth - ) + measureScrollbarDimensions () { + this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth() + this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() } checkForNewLongestLine () { @@ -1228,14 +1335,6 @@ class TextEditorComponent { this.longestLineToMeasure = null } - measureGutterDimensions () { - if (this.refs.lineNumberGutter) { - this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth - } else { - this.measurements.lineNumberGutterWidth = 0 - } - } - requestHorizontalMeasurement (row, column) { if (column === 0) return let columns = this.horizontalPositionsToMeasure.get(row) @@ -1445,11 +1544,48 @@ class TextEditorComponent { } getScrollContainerClientWidth () { - return this.getScrollContainerWidth() + if (this.isVerticalScrollbarVisible()) { + return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() + } else { + return this.getScrollContainerWidth() + } } getScrollContainerClientHeight () { - return this.getScrollContainerHeight() + if (this.isHorizontalScrollbarVisible()) { + return this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() + } else { + return this.getScrollContainerHeight() + } + } + + isVerticalScrollbarVisible () { + return ( + this.getContentHeight() > this.getScrollContainerHeight() || + this.isContentMinimallyOverlappingBothScrollbars() + ) + } + + isHorizontalScrollbarVisible () { + return ( + !this.props.model.isSoftWrapped() && + ( + this.getContentWidth() > this.getScrollContainerWidth() || + this.isContentMinimallyOverlappingBothScrollbars() + ) + ) + } + + isContentMinimallyOverlappingBothScrollbars () { + const clientHeightWithHorizontalScrollbar = + this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() + const clientWidthWithVerticalScrollbar = + this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() + + return ( + this.getContentHeight() > clientHeightWithHorizontalScrollbar && + this.getContentWidth() > clientWidthWithVerticalScrollbar + ) } getScrollHeight () { @@ -1491,6 +1627,14 @@ class TextEditorComponent { return this.measurements.lineNumberGutterWidth } + getVerticalScrollbarWidth () { + return this.measurements.verticalScrollbarWidth + } + + getHorizontalScrollbarHeight () { + return this.measurements.horizontalScrollbarHeight + } + getRowsPerTile () { return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE } @@ -1619,6 +1763,64 @@ class TextEditorComponent { } } +class DummyScrollbarComponent { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (props) { + this.props = props + etch.updateSync(this) + } + + render () { + let scrollTop = 0 + let scrollLeft = 0 + const outerStyle = { + position: 'absolute', + contain: 'strict', + zIndex: 1 + } + const innerStyle = {} + if (this.props.orientation === 'horizontal') { + scrollLeft = this.props.scrollLeft || 0 + let right = (this.props.verticalScrollbarWidth || 0) + outerStyle.bottom = 0 + outerStyle.left = 0 + outerStyle.right = right + 'px' + outerStyle.height = '20px' + outerStyle.overflowY = 'hidden' + outerStyle.overflowX = this.props.forceScrollbarVisible ? 'scroll' : 'auto' + innerStyle.height = '20px' + innerStyle.width = (this.props.scrollWidth || 0) + 'px' + } else { + scrollTop = this.props.scrollTop || 0 + let bottom = (this.props.horizontalScrollbarHeight || 0) + outerStyle.right = 0 + outerStyle.top = 0 + outerStyle.bottom = bottom + 'px' + outerStyle.width = '20px' + outerStyle.overflowX = 'hidden' + outerStyle.overflowY = this.props.forceScrollbarVisible ? 'scroll' : 'auto' + innerStyle.width = '20px' + innerStyle.height = (this.props.scrollHeight || 0) + 'px' + } + + return $.div({style: outerStyle, scrollTop, scrollLeft}, + $.div({style: innerStyle}) + ) + } + + getRealScrollbarWidth () { + return this.element.offsetWidth - this.element.clientWidth + } + + getRealScrollbarHeight () { + return this.element.offsetHeight - this.element.clientHeight + } +} + class LineNumberGutterComponent { constructor (props) { this.props = props diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 4664168da..442858434 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -61,6 +61,10 @@ class TextEditor extends Model @setClipboard: (clipboard) -> @clipboard = clipboard + @didUpdateScrollbarStyles: -> + TextEditorComponent ?= require './text-editor-component' + TextEditorComponent.didUpdateScrollbarStyles() + serializationVersion: 1 buffer: null @@ -3561,7 +3565,7 @@ class TextEditor extends Model @component.element else TextEditorComponent ?= require('./text-editor-component') - new TextEditorComponent({model: this}) + new TextEditorComponent({model: this, styleManager: atom.styles}) @component.element # Essential: Retrieves the greyed out placeholder of a mini editor.