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.
This commit is contained in:
Nathan Sobo
2014-04-18 12:28:02 -06:00
parent d566726b9f
commit fdccc0bcc2
8 changed files with 106 additions and 79 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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) ->

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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) ->

View File

@@ -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})