From fdccc0bcc2f8aa5f682907b324a4724adedeeb83 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 12:28:02 -0600 Subject: [PATCH] Measure DOM dimensions before rendering elements that depend on them This commit breaks the initial render of the editor component into two stages. The first stage just renders the shell of the editor so the height, width, line height, and default character width can be measured. Nothing that depends on these values is rendered on the first render pass. Once the editor component is mounted, all these values are measured and we force another update, which fills in the lines, line numbers, selections, etc. We also refrain from assigning an explicit height and width on the model if these values aren't explicitly styled in the DOM, and just assume the editor will stretch to accommodate its contents. --- spec/editor-component-spec.coffee | 23 +++++----- src/cursors-component.coffee | 9 ++-- src/display-buffer.coffee | 13 +++--- src/editor-component.coffee | 31 ++++++++------ src/editor-scroll-view-component.coffee | 33 +++++++++----- src/gutter-component.coffee | 12 ++++-- src/lines-component.coffee | 57 ++++++++++++------------- src/selections-component.coffee | 7 +-- 8 files changed, 106 insertions(+), 79 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 2c7238198..f2e8c8bee 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -38,7 +38,7 @@ describe "EditorComponent", -> describe "line rendering", -> it "renders only the currently-visible lines", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lines = node.querySelectorAll('.line') expect(lines.length).toBe 6 @@ -121,7 +121,7 @@ describe "EditorComponent", -> describe "gutter rendering", -> it "renders the currently-visible line numbers", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lines = node.querySelectorAll('.line-number') expect(lines.length).toBe 6 @@ -146,7 +146,7 @@ describe "EditorComponent", -> editor.setSoftWrap(true) node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 30 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lines = node.querySelectorAll('.line-number') expect(lines.length).toBe 6 @@ -163,7 +163,7 @@ describe "EditorComponent", -> cursor1.setScreenPosition([0, 5]) node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 @@ -258,7 +258,7 @@ describe "EditorComponent", -> inputNode = node.querySelector('.hidden-input') node.style.height = 5 * lineHeightInPixels + 'px' node.style.width = 10 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(editor.getCursorScreenPosition()).toEqual [0, 0] editor.setScrollTop(3 * lineHeightInPixels) @@ -292,6 +292,7 @@ describe "EditorComponent", -> # 1-line selection editor.setSelectedScreenRange([[1, 6], [1, 10]]) regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 1 regionRect = regions[0].getBoundingClientRect() expect(regionRect.top).toBe 1 * lineHeightInPixels @@ -355,7 +356,7 @@ describe "EditorComponent", -> it "moves the cursor to the nearest screen position", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 10 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() editor.setScrollTop(3.5 * lineHeightInPixels) editor.setScrollLeft(2 * charWidth) @@ -468,7 +469,7 @@ describe "EditorComponent", -> describe "scrolling", -> it "updates the vertical scrollbar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(verticalScrollbarNode.scrollTop).toBe 0 @@ -477,7 +478,7 @@ describe "EditorComponent", -> it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> node.style.width = 30 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() scrollViewContentNode = node.querySelector('.scroll-view-content') expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" @@ -489,7 +490,7 @@ describe "EditorComponent", -> it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> node.style.width = 30 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(editor.getScrollLeft()).toBe 0 horizontalScrollbarNode.scrollLeft = 100 @@ -501,7 +502,7 @@ describe "EditorComponent", -> it "updates the horizontal or vertical scrollbar depending on which delta is greater (x or y)", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 20 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(verticalScrollbarNode.scrollTop).toBe 0 expect(horizontalScrollbarNode.scrollLeft).toBe 0 @@ -517,7 +518,7 @@ describe "EditorComponent", -> 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.updateModelDimensions() + component.measureHeightAndWidth() lineNodes = node.querySelectorAll('.line') expect(lineNodes.length).toBe 6 diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index 675cd2f9d..e3143f1f1 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -17,10 +17,11 @@ CursorsComponent = React.createClass blinkOff = @state.blinkCursorsOff div className: 'cursors', - for selection in editor.getSelections() - if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) - {cursor} = selection - CursorComponent({key: cursor.id, cursor, blinkOff}) + if @isMounted() + for selection in editor.getSelections() + if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + {cursor} = selection + CursorComponent({key: cursor.id, cursor, blinkOff}) getInitialState: -> blinkCursorsOff: false diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 24e098425..c2379ae87 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -111,10 +111,10 @@ class DisplayBuffer extends Model getHorizontalScrollMargin: -> @horizontalScrollMargin setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin - getHeight: -> @height + getHeight: -> @height ? @getScrollHeight() setHeight: (@height) -> @height - getWidth: -> @width + getWidth: -> @width ? @getScrollWidth() setWidth: (newWidth) -> oldWidth = @width @width = newWidth @@ -172,18 +172,21 @@ class DisplayBuffer extends Model @charWidthsByScope = {} getScrollHeight: -> + unless @getLineHeight() > 0 + throw new Error("You must assign lineHeight before calling ::getScrollHeight()") + @getLineCount() * @getLineHeight() getScrollWidth: -> @getMaxLineLength() * @getDefaultCharWidth() getVisibleRowRange: -> - return [0, 0] unless @getLineHeight() > 0 - return [0, @getLineCount()] if @getHeight() is 0 + unless @getLineHeight() > 0 + throw new Error("You must assign a non-zero lineHeight before calling ::getVisibleRowRange()") heightInLines = Math.ceil(@getHeight() / @getLineHeight()) + 1 startRow = Math.floor(@getScrollTop() / @getLineHeight()) - endRow = Math.ceil(startRow + heightInLines) + endRow = Math.min(@getLineCount(), Math.ceil(startRow + heightInLines)) [startRow, endRow] intersectsVisibleRowRange: (startRow, endRow) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 1859c903d..32db47ccc 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -23,8 +23,12 @@ EditorComponent = React.createClass render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - visibleRowRange = editor.getVisibleRowRange() - scrollTop = editor.getScrollTop() + if @isMounted() + visibleRowRange = editor.getVisibleRowRange() + scrollHeight = editor.getScrollHeight() + scrollWidth = editor.getScrollWidth() + scrollTop = editor.getScrollTop() + scrollLeft = editor.getScrollLeft() className = 'editor editor-colors react' className += ' is-focused' if focused @@ -44,16 +48,16 @@ EditorComponent = React.createClass className: 'vertical-scrollbar' orientation: 'vertical' onScroll: @onVerticalScroll - scrollTop: editor.getScrollTop() - scrollHeight: editor.getScrollHeight() + scrollTop: scrollTop + scrollHeight: scrollHeight ScrollbarComponent ref: 'horizontalScrollbar' className: 'horizontal-scrollbar' orientation: 'horizontal' onScroll: @onHorizontalScroll - scrollLeft: editor.getScrollLeft() - scrollWidth: editor.getScrollWidth() + scrollLeft: scrollLeft + scrollWidth: scrollWidth getInitialState: -> {} @@ -61,16 +65,17 @@ EditorComponent = React.createClass cursorBlinkPeriod: 800 cursorBlinkResumeDelay: 200 - componentDidMount: -> + componentWillMount: -> @pendingChanges = [] @props.editor.manageScrollPosition = true - - @listenForDOMEvents() - @listenForCommands() - @observeEditor() @observeConfig() + componentDidMount: -> + @observeEditor() + @listenForDOMEvents() + @listenForCommands() @props.editor.setVisible(true) + @requestUpdate() componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel @@ -317,8 +322,8 @@ EditorComponent = React.createClass else @forceUpdate() - updateModelDimensions: -> - @refs.scrollView.updateModelDimensions() + measureHeightAndWidth: -> + @refs.scrollView.measureHeightAndWidth() consolidateSelections: (e) -> e.abortKeyBinding() unless @props.editor.consolidateSelections() diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 269bba241..1ee1a6f92 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -13,11 +13,13 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props {visibleRowRange, preservedScreenRow, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props - contentStyle = - height: editor.getScrollHeight() - WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" - div className: 'scroll-view', ref: 'scrollView', + if @isMounted() + contentStyle = + height: editor.getScrollHeight() + WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" + + div className: 'scroll-view', InputComponent ref: 'input' className: 'hidden-input' @@ -36,8 +38,8 @@ EditorScrollViewComponent = React.createClass SelectionsComponent({editor}) componentDidMount: -> - @getDOMNode().addEventListener 'overflowchanged', @updateModelDimensions - @updateModelDimensions() + @getDOMNode().addEventListener 'overflowchanged', @measureHeightAndWidth + @measureHeightAndWidth() onInput: (char, replaceLastCharacter) -> {editor} = @props @@ -109,12 +111,14 @@ EditorScrollViewComponent = React.createClass {editor} = @props {clientX, clientY} = event - editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() + editorClientRect = @getDOMNode().getBoundingClientRect() top = clientY - editorClientRect.top + editor.getScrollTop() left = clientX - editorClientRect.left + editor.getScrollLeft() {top, left} getHiddenInputPosition: -> + return {top: 0, left: 0} unless @isMounted() + {editor} = @props if cursor = editor.getCursor() @@ -129,11 +133,20 @@ EditorScrollViewComponent = React.createClass {top, left} - updateModelDimensions: -> + # Measure explicitly-styled height and width and relay them to the model. If + # these values aren't explicitly styled, we assume the editor is unconstrained + # and use the scrollHeight / scrollWidth as its height and width in + # calculations. + measureHeightAndWidth: -> {editor} = @props node = @getDOMNode() - editor.setHeight(node.clientHeight) - editor.setWidth(node.clientWidth) + computedStyle = getComputedStyle(node) + + unless computedStyle.height is '0px' + editor.setHeight(node.clientHeight) + + unless computedStyle.width is '0px' + editor.setWidth(node.clientWidth) focus: -> @refs.input.focus() diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index b8dc9874d..05b122cea 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -9,6 +9,10 @@ GutterComponent = React.createClass mixins: [SubscriberMixin] render: -> + div className: 'gutter', + @renderLineNumbers() if @isMounted() + + renderLineNumbers: -> {editor, visibleRowRange, preservedScreenRow, scrollTop} = @props [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() @@ -21,6 +25,7 @@ GutterComponent = React.createClass lineNumbers = [] tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) + tokenizedLines.push({id: 0}) if tokenizedLines.length is 0 for bufferRow, i in editor.bufferRowsForScreenRows(startRow, endRow - 1) if bufferRow is lastBufferRow lineNumber = '•' @@ -28,7 +33,7 @@ GutterComponent = React.createClass lastBufferRow = bufferRow lineNumber = (bufferRow + 1).toString() - key = tokenizedLines[i]?.id ? 0 + key = tokenizedLines[i]?.id screenRow = startRow + i lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow})) lastBufferRow = bufferRow @@ -36,9 +41,8 @@ GutterComponent = React.createClass if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) lineNumbers.push(LineNumberComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - div className: 'gutter', - div className: 'line-numbers', style: style, - lineNumbers + div className: 'line-numbers', style: style, + lineNumbers componentWillUnmount: -> @unsubscribe() diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 7061572ae..9c63bdbd6 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -11,26 +11,28 @@ LinesComponent = React.createClass displayName: 'LinesComponent' render: -> - {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props + if @isMounted() + {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props + [startRow, endRow] = visibleRowRange - [startRow, endRow] = visibleRowRange - lineHeightInPixels = editor.getLineHeight() - paddingTop = startRow * lineHeightInPixels - paddingBottom = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + style = + paddingTop: startRow * editor.getLineHeight() + paddingBottom: (editor.getScreenLineCount() - endRow) * editor.getLineHeight() - lines = - for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) + lines = + for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) - if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) - lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) + if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) + lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - div className: 'lines', ref: 'lines', style: {paddingTop, paddingBottom}, - lines + div {className: 'lines', style}, lines + + componentWillMount: -> + @measuredLines = new WeakSet componentDidMount: -> - @measuredLines = new WeakSet - @updateModelDimensions() + @measureLineHeightAndCharWidth() shouldComponentUpdate: (newProps) -> return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'preservedScreenRow', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') @@ -42,31 +44,28 @@ LinesComponent = React.createClass false componentDidUpdate: (prevProps) -> - @updateModelDimensions() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') + @measureLineHeightAndCharWidth() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') @measureCharactersInNewLines() unless @props.preservedScreenRow? - updateModelDimensions: -> - {editor} = @props - {lineHeightInPixels, charWidth} = @measureLineDimensions() - editor.setLineHeight(lineHeightInPixels) - editor.setDefaultCharWidth(charWidth) - - measureLineDimensions: -> - linesNode = @refs.lines.getDOMNode() - linesNode.appendChild(DummyLineNode) - lineHeightInPixels = DummyLineNode.getBoundingClientRect().height + measureLineHeightAndCharWidth: -> + node = @getDOMNode() + node.appendChild(DummyLineNode) + lineHeight = DummyLineNode.getBoundingClientRect().height charWidth = DummyLineNode.firstChild.getBoundingClientRect().width - linesNode.removeChild(DummyLineNode) - {lineHeightInPixels, charWidth} + node.removeChild(DummyLineNode) + + {editor} = @props + editor.setLineHeight(lineHeight) + editor.setDefaultCharWidth(charWidth) measureCharactersInNewLines: -> [visibleStartRow, visibleEndRow] = @props.visibleRowRange - linesNode = @refs.lines.getDOMNode() + node = @getDOMNode() for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) unless @measuredLines.has(tokenizedLine) - lineNode = linesNode.children[i] + lineNode = node.children[i] @measureCharactersInLine(tokenizedLine, lineNode) measureCharactersInLine: (tokenizedLine, lineNode) -> diff --git a/src/selections-component.coffee b/src/selections-component.coffee index 7f94d99fb..616fc62dd 100644 --- a/src/selections-component.coffee +++ b/src/selections-component.coffee @@ -10,6 +10,7 @@ SelectionsComponent = React.createClass {editor} = @props div className: 'selections', - for selection in editor.getSelections() - if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({key: selection.id, selection}) + if @isMounted() + for selection in editor.getSelections() + if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({key: selection.id, selection})