From 22496ceeb1746ef13ce7fca948c84e90836719d4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 20 Apr 2014 11:44:48 -0600 Subject: [PATCH] WIP: Minimize paint when scrolling and composite lines with the GPU --- spec/editor-component-spec.coffee | 42 +++--------------- src/editor-component.coffee | 57 +++++++++++++------------ src/editor-scroll-view-component.coffee | 6 +-- src/gutter-component.coffee | 32 +++++++------- src/lines-component.coffee | 36 +++++++--------- static/editor.less | 19 ++++++++- 6 files changed, 89 insertions(+), 103 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index f2e8c8bee..ab3708ea2 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -48,17 +48,14 @@ describe "EditorComponent", -> verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate(0px, #{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate3d(0px, #{-2.5 * lineHeightInPixels}px, 0)" lineNodes = node.querySelectorAll('.line') expect(lineNodes.length).toBe 6 + expect(lineNodes[0].offsetTop).toBe 2 * lineHeightInPixels expect(lineNodes[0].textContent).toBe editor.lineForScreenRow(2).text expect(lineNodes[5].textContent).toBe editor.lineForScreenRow(7).text - linesNode = node.querySelector('.lines') - expect(linesNode.style.paddingTop).toBe 2 * lineHeightInPixels + 'px' - expect(linesNode.style.paddingBottom).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + 'px' - describe "when indent guides are enabled", -> beforeEach -> component.setShowIndentGuide(true) @@ -131,17 +128,15 @@ describe "EditorComponent", -> verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translate3d(0, #{-2.5 * lineHeightInPixels}px, 0)" lineNumberNodes = node.querySelectorAll('.line-number') expect(lineNumberNodes.length).toBe 6 + expect(lineNumberNodes[0].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNumberNodes[5].offsetTop).toBe 7 * lineHeightInPixels expect(lineNumberNodes[0].textContent).toBe "#{nbsp}3" expect(lineNumberNodes[5].textContent).toBe "#{nbsp}8" - lineNumbersNode = node.querySelector('.line-numbers') - expect(lineNumbersNode.style.paddingTop).toBe 2 * lineHeightInPixels + 'px' - expect(lineNumbersNode.style.paddingBottom).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + 'px' - it "renders • characters for soft-wrapped lines", -> editor.setSoftWrap(true) node.style.height = 4.5 * lineHeightInPixels + 'px' @@ -481,11 +476,11 @@ describe "EditorComponent", -> component.measureHeightAndWidth() scrollViewContentNode = node.querySelector('.scroll-view-content') - expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0)" expect(horizontalScrollbarNode.scrollLeft).toBe 0 editor.setScrollLeft(100) - expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(-100px, 0px)" + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0)" expect(horizontalScrollbarNode.scrollLeft).toBe 100 it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> @@ -515,29 +510,6 @@ describe "EditorComponent", -> expect(verticalScrollbarNode.scrollTop).toBe 10 expect(horizontalScrollbarNode.scrollLeft).toBe 15 - it "preserves the target of the mousewheel event when scrolling vertically", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - node.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() - - lineNodes = node.querySelectorAll('.line') - expect(lineNodes.length).toBe 6 - mousewheelEvent = new WheelEvent('mousewheel', wheelDeltaX: -5, wheelDeltaY: -100) - Object.defineProperty(mousewheelEvent, 'target', get: -> lineNodes[0].querySelector('span')) - node.dispatchEvent(mousewheelEvent) - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - - expect(editor.getScrollTop()).toBe 100 - - # Preserves the line and line number for the scroll event's target screen row - lineNodes = node.querySelectorAll('.line') - expect(lineNodes.length).toBe 7 - expect(lineNodes[6].textContent).toBe editor.lineForScreenRow(0).text - - lineNumberNodes = node.querySelectorAll('.line-number') - expect(lineNumberNodes.length).toBe 7 - expect(lineNumberNodes[6].textContent).toBe "#{nbsp}1" - describe "input events", -> inputNode = null diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 4864cecaa..a01ca207b 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -18,29 +18,31 @@ EditorComponent = React.createClass batchingUpdates: false updateRequested: false cursorsMoved: false - preservedScreenRow: null + preservedRowRange: null + scrollingVertically: false render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props if @isMounted() - visibleRowRange = editor.getVisibleRowRange() + renderedRowRange = @getRenderedRowRange() scrollHeight = editor.getScrollHeight() scrollWidth = editor.getScrollWidth() scrollTop = editor.getScrollTop() scrollLeft = editor.getScrollLeft() + lineHeightInPixels = editor.getLineHeight() className = 'editor editor-colors react' className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, - GutterComponent({editor, visibleRowRange, @preservedScreenRow, scrollTop, @pendingChanges}) + GutterComponent({editor, renderedRowRange, scrollTop, lineHeight: lineHeightInPixels, @pendingChanges}) EditorScrollViewComponent { - ref: 'scrollView', editor, visibleRowRange, @preservedScreenRow, @pendingChanges, - showIndentGuide, fontSize, fontFamily, lineHeight, - @cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay, - @onInputFocused, @onInputBlurred + ref: 'scrollView', editor, renderedRowRange, @pendingChanges, + @scrollingVertically, showIndentGuide, fontSize, fontFamily, + lineHeight: lineHeightInPixels, @cursorsMoved, cursorBlinkPeriod, + cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred, } ScrollbarComponent @@ -59,6 +61,13 @@ EditorComponent = React.createClass scrollLeft: scrollLeft scrollWidth: scrollWidth + getRenderedRowRange: -> + renderedRowRange = @props.editor.getVisibleRowRange() + if @preservedRowRange? + renderedRowRange[0] = Math.min(@preservedRowRange[0], renderedRowRange[0]) + renderedRowRange[1] = Math.max(@preservedRowRange[1], renderedRowRange[1]) + renderedRowRange + getInitialState: -> {} getDefaultProps: -> @@ -98,7 +107,7 @@ EditorComponent = React.createClass @subscribe editor, 'selection-screen-range-changed', @requestUpdate @subscribe editor, 'selection-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded - @subscribe editor.$scrollTop.changes, @requestUpdate + @subscribe editor.$scrollTop.changes, @onScrollTopChanged @subscribe editor.$scrollLeft.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @subscribe editor.$width.changes, @requestUpdate @@ -262,13 +271,6 @@ EditorComponent = React.createClass @pendingScrollLeft = null onMouseWheel: (event) -> - # To preserve velocity scrolling, delay removal of the event's target until - # after mousewheel events stop being fired. Removing the target before then - # will cause scrolling to stop suddenly. - @preservedScreenRow = @screenRowForNode(event.target) - @clearPreservedScreenRowAfterDelay ?= debounce(@clearPreservedScreenRow, 300) - @clearPreservedScreenRowAfterDelay() - # Only scroll in one direction at a time {wheelDeltaX, wheelDeltaY} = event if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) @@ -278,12 +280,12 @@ EditorComponent = React.createClass event.preventDefault() - screenRowForNode: (node) -> - editorNode = @getDOMNode() - while node isnt editorNode - screenRow = node.dataset.screenRow - return screenRow if screenRow? - node = node.parentNode + clearPreservedRowRange: -> + @preservedRowRange = null + @scrollingVertically = false + @requestUpdate() + + clearPreservedRowRangeAfterDelay: null # Created lazily onBatchedUpdatesStarted: -> @batchingUpdates = true @@ -304,6 +306,13 @@ EditorComponent = React.createClass {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + onScrollTopChanged: -> + @preservedRowRange = @getRenderedRowRange() + @scrollingVertically = true + @clearPreservedRowRangeAfterDelay ?= debounce(@clearPreservedRowRange, 200) + @clearPreservedRowRangeAfterDelay() + @requestUpdate() + onSelectionRemoved: (selection) -> {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) @@ -311,12 +320,6 @@ EditorComponent = React.createClass onCursorsMoved: -> @cursorsMoved = true - clearPreservedScreenRow: -> - @preservedScreenRow = null - @requestUpdate() - - clearPreservedScreenRowAfterDelay: null # Created lazily - requestUpdate: -> if @batchingUpdates @updateRequested = true diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 21075f6a0..7a1648fd7 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -17,12 +17,12 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - {visibleRowRange, preservedScreenRow, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props + {renderedRowRange, pendingChanges, scrollingVertically, cursorsMoved, onInputFocused, onInputBlurred} = @props if @isMounted() contentStyle = height: editor.getScrollHeight() - WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" + WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)" div className: 'scroll-view', InputComponent @@ -37,7 +37,7 @@ EditorScrollViewComponent = React.createClass CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay}) LinesComponent { ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide, - visibleRowRange, preservedScreenRow, pendingChanges + renderedRowRange, pendingChanges, scrollingVertically } div className: 'underlayer', SelectionsComponent({editor}) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 05b122cea..979376985 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -1,6 +1,6 @@ React = require 'react' {div} = require 'reactionary' -{isEqual, multiplyString} = require 'underscore-plus' +{isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus' SubscriberMixin = require './subscriber-mixin' module.exports = @@ -13,15 +13,15 @@ GutterComponent = React.createClass @renderLineNumbers() if @isMounted() renderLineNumbers: -> - {editor, visibleRowRange, preservedScreenRow, scrollTop} = @props - [startRow, endRow] = visibleRowRange - lineHeightInPixels = editor.getLineHeight() + {editor, renderedRowRange, scrollTop} = @props + [startRow, endRow] = renderedRowRange + charWidth = editor.getDefaultCharWidth() + lineHeight = editor.getLineHeight() maxDigits = editor.getLastBufferRow().toString().length style = + width: charWidth * (maxDigits + 1.5) height: editor.getScrollHeight() - WebkitTransform: "translateY(#{-scrollTop}px)" - paddingTop: startRow * lineHeightInPixels - paddingBottom: (editor.getScreenLineCount() - endRow) * lineHeightInPixels + WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)" lineNumbers = [] tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) @@ -35,12 +35,9 @@ GutterComponent = React.createClass key = tokenizedLines[i]?.id screenRow = startRow + i - lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow})) + lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow, lineHeight})) lastBufferRow = bufferRow - if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) - lineNumbers.push(LineNumberComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - div className: 'line-numbers', style: style, lineNumbers @@ -51,13 +48,12 @@ GutterComponent = React.createClass # non-zero-delta change to the screen lines has occurred within the current # visible row range. shouldComponentUpdate: (newProps) -> - {visibleRowRange, pendingChanges, scrollTop} = @props + {renderedRowRange, pendingChanges, scrollTop} = @props - return true unless newProps.scrollTop is scrollTop - return true unless isEqual(newProps.visibleRowRange, visibleRowRange) + return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'scrollTop', 'lineHeight') for change in pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 - return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start + return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start false @@ -65,9 +61,10 @@ LineNumberComponent = React.createClass displayName: 'LineNumberComponent' render: -> - {bufferRow, screenRow} = @props + {bufferRow, screenRow, lineHeight} = @props div className: "line-number line-number-#{bufferRow}" + style: {top: screenRow * lineHeight} 'data-buffer-row': bufferRow 'data-screen-row': screenRow dangerouslySetInnerHTML: {__html: @buildInnerHTML()} @@ -82,4 +79,5 @@ LineNumberComponent = React.createClass iconDivHTML: '
' - shouldComponentUpdate: -> false + shouldComponentUpdate: (newProps) -> + not isEqualForProperties(newProps, @props, 'lineHeight') diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 4223811bc..3d0da5bef 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -12,21 +12,14 @@ LinesComponent = React.createClass render: -> if @isMounted() - {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props - [startRow, endRow] = visibleRowRange - - style = - paddingTop: startRow * editor.getLineHeight() - paddingBottom: (editor.getScreenLineCount() - endRow) * editor.getLineHeight() + {editor, renderedRowRange, lineHeight, showIndentGuide} = @props + [startRow, endRow] = renderedRowRange lines = for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) + LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, lineHeight, screenRow: startRow + i}) - if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) - lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - - div {className: 'lines', style}, lines + div {className: 'lines'}, lines componentWillMount: -> @measuredLines = new WeakSet @@ -35,18 +28,18 @@ LinesComponent = React.createClass @measureLineHeightAndCharWidth() shouldComponentUpdate: (newProps) -> - return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'preservedScreenRow', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') + return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') - {visibleRowRange, pendingChanges} = newProps + {renderedRowRange, pendingChanges} = newProps for change in pendingChanges - return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start + return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start false componentDidUpdate: (prevProps) -> @measureLineHeightAndCharWidth() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') - @measureCharactersInNewLines() unless @props.preservedScreenRow? + @measureCharactersInNewLines() unless @props.scrollingVertically measureLineHeightAndCharWidth: -> node = @getDOMNode() @@ -60,7 +53,7 @@ LinesComponent = React.createClass editor.setDefaultCharWidth(charWidth) measureCharactersInNewLines: -> - [visibleStartRow, visibleEndRow] = @props.visibleRowRange + [visibleStartRow, visibleEndRow] = @props.renderedRowRange node = @getDOMNode() for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) @@ -110,9 +103,13 @@ LineComponent = React.createClass displayName: 'LineComponent' render: -> - {screenRow, preserved} = @props + {screenRow, lineHeight} = @props - div className: 'line', 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + style = + top: screenRow * lineHeight + position: 'absolute' + + div className: 'line', style: style, 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()} buildInnerHTML: -> if @props.tokenizedLine.text.length is 0 @@ -140,5 +137,4 @@ LineComponent = React.createClass "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" shouldComponentUpdate: (newProps) -> - return false if newProps.preserved - not isEqualForProperties(newProps, @props, 'showIndentGuide', 'preserved') + not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight') diff --git a/static/editor.less b/static/editor.less index 5a98e798b..255f79d65 100644 --- a/static/editor.less +++ b/static/editor.less @@ -39,6 +39,24 @@ .scroll-view-content { position: relative; } + + .gutter { + padding-left: 0.5em; + padding-right: 0.5em; + + .line-number { + position: absolute; + left: 0; + right: 0; + padding: 0; + white-space: nowrap; + + .icon-right { + padding: 0; + padding-left: .1em; + } + } + } } .editor { @@ -67,7 +85,6 @@ .editor .gutter .line-number { padding-left: .5em; opacity: 0.6; - position: relative; } .editor .gutter .line-numbers {