Merge pull request #2258 from atom/ns-react-scroll-perf

Improve scroll performance of the React editor
This commit is contained in:
Nathan Sobo
2014-05-19 14:42:18 -06:00
16 changed files with 666 additions and 376 deletions

View File

@@ -84,7 +84,7 @@
"find-and-replace": "0.105.0",
"fuzzy-finder": "0.51.0",
"git-diff": "0.28.0",
"go-to-line": "0.20.0",
"go-to-line": "0.21.0",
"grammar-selector": "0.26.0",
"image-view": "0.33.0",
"keybinding-resolver": "0.17.0",

View File

@@ -4,9 +4,11 @@ nbsp = String.fromCharCode(160)
describe "EditorComponent", ->
[contentNode, editor, wrapperView, component, node, verticalScrollbarNode, horizontalScrollbarNode] = []
[lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = []
[lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame, lineOverdrawMargin] = []
beforeEach ->
lineOverdrawMargin = 2
waitsForPromise ->
atom.packages.activatePackage('language-javascript')
@@ -29,7 +31,7 @@ describe "EditorComponent", ->
contentNode = document.querySelector('#jasmine-content')
contentNode.style.width = '1000px'
wrapperView = new ReactEditorView(editor)
wrapperView = new ReactEditorView(editor, {lineOverdrawMargin})
wrapperView.attachToDom()
{component} = wrapperView
component.setLineHeight(1.3)
@@ -41,57 +43,62 @@ describe "EditorComponent", ->
verticalScrollbarNode = node.querySelector('.vertical-scrollbar')
horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar')
node.style.height = editor.getLineCount() * lineHeightInPixels + 'px'
node.style.width = '1000px'
component.measureHeightAndWidth()
afterEach ->
contentNode.style.width = ''
describe "line rendering", ->
it "renders only the currently-visible lines", ->
it "renders the currently-visible lines plus the overdraw margin", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
lines = node.querySelectorAll('.line')
expect(lines.length).toBe 6
expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text
expect(lines[5].textContent).toBe editor.lineForScreenRow(5).text
linesNode = node.querySelector('.lines')
expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)"
expect(node.querySelectorAll('.line').length).toBe 6 + 2 # no margin above
expect(component.lineNodeForScreenRow(0).textContent).toBe editor.lineForScreenRow(0).text
expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0
expect(component.lineNodeForScreenRow(5).textContent).toBe editor.lineForScreenRow(5).text
expect(component.lineNodeForScreenRow(5).offsetTop).toBe 5 * lineHeightInPixels
verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels
verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels
verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate3d(0px, #{-2.5 * lineHeightInPixels}px, 0)"
expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, #{-4.5 * lineHeightInPixels}px, 0px)"
expect(node.querySelectorAll('.line').length).toBe 6 + 4 # margin above and below
expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels
expect(component.lineNodeForScreenRow(2).textContent).toBe editor.lineForScreenRow(2).text
expect(component.lineNodeForScreenRow(9).offsetTop).toBe 9 * lineHeightInPixels
expect(component.lineNodeForScreenRow(9).textContent).toBe editor.lineForScreenRow(9).text
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
it "updates absolute positions of subsequent lines when lines are inserted or removed", ->
it "updates the top position of subsequent lines when lines are inserted or removed", ->
editor.getBuffer().deleteRows(0, 1)
lineNodes = node.querySelectorAll('.line')
expect(lineNodes[0].offsetTop).toBe 0
expect(lineNodes[1].offsetTop).toBe 1 * lineHeightInPixels
expect(lineNodes[2].offsetTop).toBe 2 * lineHeightInPixels
expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0
expect(component.lineNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels
expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels
editor.getBuffer().insert([0, 0], '\n\n')
lineNodes = node.querySelectorAll('.line')
expect(lineNodes[0].offsetTop).toBe 0
expect(lineNodes[1].offsetTop).toBe 1 * lineHeightInPixels
expect(lineNodes[2].offsetTop).toBe 2 * lineHeightInPixels
expect(lineNodes[3].offsetTop).toBe 3 * lineHeightInPixels
expect(lineNodes[4].offsetTop).toBe 4 * lineHeightInPixels
expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 * lineHeightInPixels
expect(component.lineNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels
expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels
expect(component.lineNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels
expect(component.lineNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels
describe "when indent guides are enabled", ->
beforeEach ->
component.setShowIndentGuide(true)
it "adds an 'indent-guide' class to spans comprising the leading whitespace", ->
lines = node.querySelectorAll('.line')
line1LeafNodes = getLeafNodes(lines[1])
line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1))
expect(line1LeafNodes[0].textContent).toBe ' '
expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe true
expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe false
line2LeafNodes = getLeafNodes(lines[2])
line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
expect(line2LeafNodes[0].textContent).toBe ' '
expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true
expect(line2LeafNodes[1].textContent).toBe ' '
@@ -101,8 +108,7 @@ describe "EditorComponent", ->
it "renders leading whitespace spans with the 'indent-guide' class for empty lines", ->
editor.getBuffer().insert([1, Infinity], '\n')
lines = node.querySelectorAll('.line')
line2LeafNodes = getLeafNodes(lines[2])
line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
expect(line2LeafNodes.length).toBe 3
expect(line2LeafNodes[0].textContent).toBe ' '
@@ -114,8 +120,7 @@ describe "EditorComponent", ->
it "renders indent guides correctly on lines containing only whitespace", ->
editor.getBuffer().insert([1, Infinity], '\n ')
lines = node.querySelectorAll('.line')
line2LeafNodes = getLeafNodes(lines[2])
line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
expect(line2LeafNodes.length).toBe 3
expect(line2LeafNodes[0].textContent).toBe ' '
expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true
@@ -125,9 +130,8 @@ describe "EditorComponent", ->
expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true
it "does not render indent guides in trailing whitespace for lines containing non whitespace characters", ->
editor.getBuffer().setText (" hi ")
lines = node.querySelectorAll('.line')
line0LeafNodes = getLeafNodes(lines[0])
editor.getBuffer().setText " hi "
line0LeafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
expect(line0LeafNodes[0].textContent).toBe ' '
expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe true
expect(line0LeafNodes[1].textContent).toBe ' '
@@ -144,40 +148,39 @@ describe "EditorComponent", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
lines = node.querySelectorAll('.line-number')
expect(lines.length).toBe 6
expect(lines[0].textContent).toBe "#{nbsp}1"
expect(lines[5].textContent).toBe "#{nbsp}6"
expect(node.querySelectorAll('.line-number').length).toBe 6 + 2 + 1 # line overdraw margin below + dummy line number
expect(component.lineNumberNodeForScreenRow(0).textContent).toBe "#{nbsp}1"
expect(component.lineNumberNodeForScreenRow(5).textContent).toBe "#{nbsp}6"
verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels
verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translate3d(0, #{-2.5 * lineHeightInPixels}px, 0)"
expect(node.querySelectorAll('.line-number').length).toBe 6 + 4 + 1 # line overdraw margin above/below + dummy line number
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"
expect(component.lineNumberNodeForScreenRow(2).textContent).toBe "#{nbsp}3"
expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels
return
expect(component.lineNumberNodeForScreenRow(7).textContent).toBe "#{nbsp}8"
expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe 7 * lineHeightInPixels
it "updates absolute positions of subsequent line numbers when lines are inserted or removed", ->
it "updates the translation of subsequent line numbers when lines are inserted or removed", ->
editor.getBuffer().insert([0, 0], '\n\n')
lineNumberNodes = node.querySelectorAll('.line-number')
expect(lineNumberNodes[0].offsetTop).toBe 0
expect(lineNumberNodes[1].offsetTop).toBe 1 * lineHeightInPixels
expect(lineNumberNodes[2].offsetTop).toBe 2 * lineHeightInPixels
expect(lineNumberNodes[3].offsetTop).toBe 3 * lineHeightInPixels
expect(lineNumberNodes[4].offsetTop).toBe 4 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0
expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels
editor.getBuffer().insert([0, 0], '\n\n')
lineNumberNodes = node.querySelectorAll('.line-number')
expect(lineNumberNodes[0].offsetTop).toBe 0
expect(lineNumberNodes[1].offsetTop).toBe 1 * lineHeightInPixels
expect(lineNumberNodes[2].offsetTop).toBe 2 * lineHeightInPixels
expect(lineNumberNodes[3].offsetTop).toBe 3 * lineHeightInPixels
expect(lineNumberNodes[4].offsetTop).toBe 4 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0
expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe 5 * lineHeightInPixels
expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe 6 * lineHeightInPixels
it "renders • characters for soft-wrapped lines", ->
editor.setSoftWrap(true)
@@ -185,43 +188,50 @@ describe "EditorComponent", ->
node.style.width = 30 * charWidth + 'px'
component.measureHeightAndWidth()
lines = node.querySelectorAll('.line-number')
expect(lines.length).toBe 6
expect(lines[0].textContent).toBe "#{nbsp}1"
expect(lines[1].textContent).toBe "#{nbsp}"
expect(lines[2].textContent).toBe "#{nbsp}2"
expect(lines[3].textContent).toBe "#{nbsp}"
expect(lines[4].textContent).toBe "#{nbsp}3"
expect(lines[5].textContent).toBe "#{nbsp}"
expect(node.querySelectorAll('.line-number').length).toBe 6 + lineOverdrawMargin + 1 # 1 dummy line node
expect(component.lineNumberNodeForScreenRow(0).textContent).toBe "#{nbsp}1"
expect(component.lineNumberNodeForScreenRow(1).textContent).toBe "#{nbsp}"
expect(component.lineNumberNodeForScreenRow(2).textContent).toBe "#{nbsp}2"
expect(component.lineNumberNodeForScreenRow(3).textContent).toBe "#{nbsp}"
expect(component.lineNumberNodeForScreenRow(4).textContent).toBe "#{nbsp}3"
expect(component.lineNumberNodeForScreenRow(5).textContent).toBe "#{nbsp}"
it "pads line numbers to be right justified based on the maximum number of line number digits", ->
it "pads line numbers to be right-justified based on the maximum number of line number digits", ->
editor.getBuffer().setText([1..10].join('\n'))
lineNumberNodes = toArray(node.querySelectorAll('.line-number'))
for screenRow in [0..8]
expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe "#{nbsp}#{screenRow + 1}"
expect(component.lineNumberNodeForScreenRow(9).textContent).toBe "10"
for node, i in lineNumberNodes[0..8]
expect(node.textContent).toBe "#{nbsp}#{i + 1}"
expect(lineNumberNodes[9].textContent).toBe '10'
gutterNode = node.querySelector('.gutter')
initialGutterWidth = gutterNode.offsetWidth
# Removes padding when the max number of digits goes down
editor.getBuffer().delete([[1, 0], [2, 0]])
lineNumberNodes = toArray(node.querySelectorAll('.line-number'))
for node, i in lineNumberNodes
expect(node.textContent).toBe "#{i + 1}"
for screenRow in [0..8]
expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe "#{screenRow + 1}"
expect(gutterNode.offsetWidth).toBeLessThan initialGutterWidth
# Increases padding when the max number of digits goes up
editor.getBuffer().insert([0, 0], '\n\n')
for screenRow in [0..8]
expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe "#{nbsp}#{screenRow + 1}"
expect(component.lineNumberNodeForScreenRow(9).textContent).toBe "10"
expect(gutterNode.offsetWidth).toBe initialGutterWidth
describe "cursor rendering", ->
it "renders the currently visible cursors", ->
it "renders the currently visible cursors, translated relative to the scroll position", ->
cursor1 = editor.getCursor()
cursor1.setScreenPosition([0, 5])
node.style.height = 4.5 * lineHeightInPixels + 'px'
node.style.width = 20 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 1
expect(cursorNodes[0].offsetHeight).toBe lineHeightInPixels
expect(cursorNodes[0].offsetWidth).toBe charWidth
expect(cursorNodes[0].offsetTop).toBe 0
expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth
expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{5 * charWidth}px, #{0 * lineHeightInPixels}px, 0px)"
cursor2 = editor.addCursorAtScreenPosition([6, 11])
cursor3 = editor.addCursorAtScreenPosition([4, 10])
@@ -229,25 +239,23 @@ describe "EditorComponent", ->
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 2
expect(cursorNodes[0].offsetTop).toBe 0
expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth
expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels
expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth
expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{5 * charWidth}px, #{0 * lineHeightInPixels}px, 0px)"
expect(cursorNodes[1].style['-webkit-transform']).toBe "translate3d(#{10 * charWidth}px, #{4 * lineHeightInPixels}px, 0px)"
verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels
verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
horizontalScrollbarNode.scrollLeft = 3.5 * charWidth
horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 2
expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels
expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth
expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels
expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth
expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{(11 - 3.5) * charWidth}px, #{(6 - 2.5) * lineHeightInPixels}px, 0px)"
expect(cursorNodes[1].style['-webkit-transform']).toBe "translate3d(#{(10 - 3.5) * charWidth}px, #{(4 - 2.5) * lineHeightInPixels}px, 0px)"
cursor3.destroy()
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 1
expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels
expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth
expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{(11 - 3.5) * charWidth}px, #{(6 - 2.5) * lineHeightInPixels}px, 0px)"
it "accounts for character widths when positioning cursors", ->
atom.config.set('editor.fontFamily', 'sans-serif')
@@ -256,7 +264,7 @@ describe "EditorComponent", ->
cursor = node.querySelector('.cursor')
cursorRect = cursor.getBoundingClientRect()
cursorLocationTextNode = node.querySelector('.storage.type.function.js').firstChild.firstChild
cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.storage.type.function.js').firstChild
range = document.createRange()
range.setStart(cursorLocationTextNode, 0)
range.setEnd(cursorLocationTextNode, 1)
@@ -266,44 +274,19 @@ describe "EditorComponent", ->
expect(cursorRect.width).toBe rangeRect.width
it "blinks cursors when they aren't moving", ->
editor.addCursorAtScreenPosition([1, 0])
[cursorNode1, cursorNode2] = node.querySelectorAll('.cursor')
expect(cursorNode1.classList.contains('blink-off')).toBe false
expect(cursorNode2.classList.contains('blink-off')).toBe false
jasmine.unspy(window, 'setTimeout')
advanceClock(component.props.cursorBlinkPeriod / 2)
expect(cursorNode1.classList.contains('blink-off')).toBe true
expect(cursorNode2.classList.contains('blink-off')).toBe true
cursorsNode = node.querySelector('.cursors')
expect(cursorsNode.classList.contains('blinking')).toBe true
advanceClock(component.props.cursorBlinkPeriod / 2)
expect(cursorNode1.classList.contains('blink-off')).toBe false
expect(cursorNode2.classList.contains('blink-off')).toBe false
advanceClock(component.props.cursorBlinkPeriod / 2)
expect(cursorNode1.classList.contains('blink-off')).toBe true
expect(cursorNode2.classList.contains('blink-off')).toBe true
# Stop blinking immediately when cursors move
advanceClock(component.props.cursorBlinkPeriod / 4)
expect(cursorNode1.classList.contains('blink-off')).toBe true
expect(cursorNode2.classList.contains('blink-off')).toBe true
# Stop blinking for one full period after moving the cursor
# Stop blinking after moving the cursor
editor.moveCursorRight()
expect(cursorNode1.classList.contains('blink-off')).toBe false
expect(cursorNode2.classList.contains('blink-off')).toBe false
expect(cursorsNode.classList.contains('blinking')).toBe false
advanceClock(component.props.cursorBlinkResumeDelay / 2)
expect(cursorNode1.classList.contains('blink-off')).toBe false
expect(cursorNode2.classList.contains('blink-off')).toBe false
advanceClock(component.props.cursorBlinkResumeDelay / 2)
expect(cursorNode1.classList.contains('blink-off')).toBe true
expect(cursorNode2.classList.contains('blink-off')).toBe true
advanceClock(component.props.cursorBlinkPeriod / 2)
expect(cursorNode1.classList.contains('blink-off')).toBe false
expect(cursorNode2.classList.contains('blink-off')).toBe false
# Resume blinking after resume delay passes
waits component.props.cursorBlinkResumeDelay
runs ->
expect(cursorsNode.classList.contains('blinking')).toBe true
it "renders the hidden input field at the position of the last cursor if it is on screen", ->
inputNode = node.querySelector('.hidden-input')
@@ -330,13 +313,13 @@ describe "EditorComponent", ->
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 1
expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels
expect(cursorNodes[0].offsetLeft).toBe 8 * charWidth
expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{8 * charWidth}px, #{6 * lineHeightInPixels}px, 0px)"
describe "selection rendering", ->
scrollViewClientLeft = null
[scrollViewNode, scrollViewClientLeft] = []
beforeEach ->
scrollViewNode = node.querySelector('.scroll-view')
scrollViewClientLeft = node.querySelector('.scroll-view').getBoundingClientRect().left
it "renders 1 region for 1-line selections", ->
@@ -360,7 +343,7 @@ describe "EditorComponent", ->
expect(region1Rect.top).toBe 1 * lineHeightInPixels
expect(region1Rect.height).toBe 1 * lineHeightInPixels
expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth
expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed
expect(region1Rect.right).toBe scrollViewNode.getBoundingClientRect().right
region2Rect = regions[1].getBoundingClientRect()
expect(region2Rect.top).toBe 2 * lineHeightInPixels
@@ -377,13 +360,13 @@ describe "EditorComponent", ->
expect(region1Rect.top).toBe 1 * lineHeightInPixels
expect(region1Rect.height).toBe 1 * lineHeightInPixels
expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth
expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed
expect(region1Rect.right).toBe scrollViewNode.getBoundingClientRect().right
region2Rect = regions[1].getBoundingClientRect()
expect(region2Rect.top).toBe 2 * lineHeightInPixels
expect(region2Rect.height).toBe 3 * lineHeightInPixels
expect(region2Rect.left).toBe scrollViewClientLeft + 0
expect(Math.ceil(region2Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed
expect(region2Rect.right).toBe scrollViewNode.getBoundingClientRect().right
region3Rect = regions[2].getBoundingClientRect()
expect(region3Rect.top).toBe 5 * lineHeightInPixels
@@ -391,9 +374,12 @@ describe "EditorComponent", ->
expect(region3Rect.left).toBe scrollViewClientLeft + 0
expect(region3Rect.width).toBe 10 * charWidth
it "does not render empty selections", ->
expect(editor.getSelection().isEmpty()).toBe true
expect(node.querySelectorAll('.selection').length).toBe 0
it "does not render empty selections unless they are the first selection (to prevent a Chromium rendering artifact caused by removing it)", ->
editor.addSelectionForBufferRange([[2, 2], [2, 2]])
expect(editor.getSelection(0).isEmpty()).toBe true
expect(editor.getSelection(1).isEmpty()).toBe true
expect(node.querySelectorAll('.selection').length).toBe 1
describe "mouse interactions", ->
linesNode = null
@@ -527,16 +513,16 @@ describe "EditorComponent", ->
editor.setScrollTop(10)
expect(verticalScrollbarNode.scrollTop).toBe 10
it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", ->
it "updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model", ->
node.style.width = 30 * charWidth + 'px'
component.measureHeightAndWidth()
scrollViewContentNode = node.querySelector('.scroll-view-content')
expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0)"
linesNode = node.querySelector('.lines')
expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)"
expect(horizontalScrollbarNode.scrollLeft).toBe 0
editor.setScrollLeft(100)
expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0)"
expect(linesNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0px)"
expect(horizontalScrollbarNode.scrollLeft).toBe 100
it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", ->
@@ -554,7 +540,7 @@ describe "EditorComponent", ->
node.style.width = 10 * charWidth + 'px'
component.measureHeightAndWidth()
editor.setScrollBottom(editor.getScrollHeight())
lastLineNode = last(node.querySelectorAll('.line'))
lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow())
bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom
topOfHorizontalScrollbar = horizontalScrollbarNode.getBoundingClientRect().top
expect(bottomOfLastLine).toBe topOfHorizontalScrollbar
@@ -562,7 +548,6 @@ describe "EditorComponent", ->
# Scroll so there's no space below the last line when the horizontal scrollbar disappears
node.style.width = 100 * charWidth + 'px'
component.measureHeightAndWidth()
lastLineNode = last(node.querySelectorAll('.line'))
bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom
bottomOfEditor = node.getBoundingClientRect().bottom
expect(bottomOfLastLine).toBe bottomOfEditor
@@ -574,11 +559,9 @@ describe "EditorComponent", ->
editor.setScrollLeft(Infinity)
lineNodes = node.querySelectorAll('.line')
rightOfLongestLine = lineNodes[6].getBoundingClientRect().right
rightOfLongestLine = component.lineNodeForScreenRow(6).getBoundingClientRect().right
leftOfVerticalScrollbar = verticalScrollbarNode.getBoundingClientRect().left
expect(rightOfLongestLine).toBe leftOfVerticalScrollbar - 1 # Leave 1 px so the cursor is visible on the end of the line
expect(Math.round(rightOfLongestLine)).toBe leftOfVerticalScrollbar - 1 # Leave 1 px so the cursor is visible on the end of the line
it "only displays dummy scrollbars when scrollable in that direction", ->
expect(verticalScrollbarNode.style.display).toBe 'none'
@@ -670,6 +653,32 @@ describe "EditorComponent", ->
expect(verticalScrollbarNode.scrollTop).toBe 10
expect(horizontalScrollbarNode.scrollLeft).toBe 15
describe "when the mousewheel event's target is a line", ->
it "keeps the line on the DOM if it is scrolled off-screen", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
node.style.width = 20 * charWidth + 'px'
component.measureHeightAndWidth()
lineNode = node.querySelector('.line')
wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -500)
Object.defineProperty(wheelEvent, 'target', get: -> lineNode)
node.dispatchEvent(wheelEvent)
expect(node.contains(lineNode)).toBe true
describe "when the mousewheel event's target is a line number", ->
it "keeps the line number on the DOM if it is scrolled off-screen", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
node.style.width = 20 * charWidth + 'px'
component.measureHeightAndWidth()
lineNumberNode = node.querySelectorAll('.line-number')[1]
wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -500)
Object.defineProperty(wheelEvent, 'target', get: -> lineNumberNode)
node.dispatchEvent(wheelEvent)
expect(node.contains(lineNumberNode)).toBe true
describe "input events", ->
inputNode = null

View File

@@ -6,8 +6,10 @@ CursorComponent = React.createClass
displayName: 'CursorComponent'
render: ->
{top, left, height, width} = @props.cursor.getPixelRect()
className = 'cursor'
className += ' blink-off' if @props.blinkOff
{cursor, scrollTop, scrollLeft} = @props
{top, left, height, width} = cursor.getPixelRect()
top -= scrollTop
left -= scrollLeft
WebkitTransform = "translate3d(#{left}px, #{top}px, 0px)"
div className: className, style: {top, left, height, width}
div className: 'cursor', style: {height, width, WebkitTransform}

View File

@@ -1,10 +1,9 @@
React = require 'react'
{div} = require 'reactionary'
{debounce} = require 'underscore-plus'
{debounce, toArray} = require 'underscore-plus'
SubscriberMixin = require './subscriber-mixin'
CursorComponent = require './cursor-component'
module.exports =
CursorsComponent = React.createClass
displayName: 'CursorsComponent'
@@ -13,22 +12,24 @@ CursorsComponent = React.createClass
cursorBlinkIntervalHandle: null
render: ->
{editor} = @props
blinkOff = @state.blinkCursorsOff
{editor, scrollTop, scrollLeft} = @props
{blinking} = @state
div className: 'cursors',
className = 'cursors'
className += ' blinking' if blinking
div {className},
if @isMounted()
for selection in editor.getSelections()
if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection)
{cursor} = selection
CursorComponent({key: cursor.id, cursor, blinkOff})
CursorComponent({key: cursor.id, cursor, scrollTop, scrollLeft})
getInitialState: ->
blinkCursorsOff: false
blinking: true
componentDidMount: ->
{editor} = @props
@startBlinkingCursors()
componentWillUnmount: ->
clearInterval(@cursorBlinkIntervalHandle)
@@ -36,15 +37,21 @@ CursorsComponent = React.createClass
componentWillUpdate: ({cursorsMoved}) ->
@pauseCursorBlinking() if cursorsMoved
componentDidUpdate: ->
@syncCursorAnimations() if @props.selectionAdded
startBlinkingCursors: ->
@cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2)
@setState(blinking: true) if @isMounted()
startBlinkingCursorsAfterDelay: null # Created lazily
toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff)
pauseCursorBlinking: ->
@state.blinkCursorsOff = false
clearInterval(@cursorBlinkIntervalHandle)
@state.blinking = false
@startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay)
@startBlinkingCursorsAfterDelay()
syncCursorAnimations: ->
node = @getDOMNode()
cursorNodes = toArray(node.children)
node.removeChild(cursorNode) for cursorNode in cursorNodes
node.appendChild(cursorNode) for cursorNode in cursorNodes

View File

@@ -241,7 +241,8 @@ class DisplayBuffer extends Model
heightInLines = Math.ceil(@getHeight() / @getLineHeight()) + 1
startRow = Math.floor(@getScrollTop() / @getLineHeight())
endRow = Math.min(@getLineCount(), Math.ceil(startRow + heightInLines))
endRow = Math.min(@getLineCount(), startRow + heightInLines)
[startRow, endRow]
intersectsVisibleRowRange: (startRow, endRow) ->

View File

@@ -20,15 +20,19 @@ EditorComponent = React.createClass
batchingUpdates: false
updateRequested: false
cursorsMoved: false
preservedRowRange: null
selectionChanged: false
selectionAdded: false
scrollingVertically: false
gutterWidth: 0
refreshingScrollbars: false
measuringScrollbars: true
pendingVerticalScrollDelta: 0
pendingHorizontalScrollDelta: 0
mouseWheelScreenRow: null
render: ->
{focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state
{editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props
{editor, cursorBlinkResumeDelay} = @props
maxLineNumberDigits = editor.getScreenLineCount().toString().length
if @isMounted()
@@ -48,16 +52,17 @@ EditorComponent = React.createClass
div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1,
GutterComponent {
editor, renderedRowRange, maxLineNumberDigits, scrollTop, scrollHeight,
lineHeight: lineHeightInPixels, fontSize, fontFamily, @pendingChanges,
onWidthChanged: @onGutterWidthChanged
ref: 'gutter', editor, renderedRowRange, maxLineNumberDigits,
scrollTop, scrollHeight, lineHeight: lineHeightInPixels, fontSize, fontFamily,
@pendingChanges, onWidthChanged: @onGutterWidthChanged, @mouseWheelScreenRow
}
EditorScrollViewComponent {
ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide
scrollHeight, scrollWidth, lineHeight: lineHeightInPixels,
renderedRowRange, @pendingChanges, @scrollingVertically, @cursorsMoved,
cursorBlinkPeriod, cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred
ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide,
lineHeight: lineHeightInPixels, renderedRowRange, @pendingChanges,
scrollTop, scrollLeft, scrollHeight, scrollWidth, @scrollingVertically,
@cursorsMoved, @selectionChanged, @selectionAdded, cursorBlinkResumeDelay,
@onInputFocused, @onInputBlurred, @mouseWheelScreenRow
}
ScrollbarComponent
@@ -93,17 +98,17 @@ EditorComponent = React.createClass
width: verticalScrollbarWidth
getRenderedRowRange: ->
renderedRowRange = @props.editor.getVisibleRowRange()
if @preservedRowRange?
renderedRowRange[0] = Math.min(@preservedRowRange[0], renderedRowRange[0])
renderedRowRange[1] = Math.max(@preservedRowRange[1], renderedRowRange[1])
renderedRowRange
{editor, lineOverdrawMargin} = @props
[visibleStartRow, visibleEndRow] = editor.getVisibleRowRange()
renderedStartRow = Math.max(0, visibleStartRow - lineOverdrawMargin)
renderedEndRow = Math.min(editor.getLineCount(), visibleEndRow + lineOverdrawMargin)
[renderedStartRow, renderedEndRow]
getInitialState: -> {}
getDefaultProps: ->
cursorBlinkPeriod: 800
cursorBlinkResumeDelay: 200
cursorBlinkResumeDelay: 100
lineOverdrawMargin: 8
componentWillMount: ->
@pendingChanges = []
@@ -130,6 +135,8 @@ EditorComponent = React.createClass
componentDidUpdate: ->
@pendingChanges.length = 0
@cursorsMoved = false
@selectionChanged = false
@selectionAdded = false
@refreshingScrollbars = false
@measureScrollbars() if @measuringScrollbars
@props.parentView.trigger 'editor:display-updated'
@@ -140,9 +147,8 @@ EditorComponent = React.createClass
@subscribe editor, 'batched-updates-ended', @onBatchedUpdatesEnded
@subscribe editor, 'screen-lines-changed', @onScreenLinesChanged
@subscribe editor, 'cursors-moved', @onCursorsMoved
@subscribe editor, 'selection-screen-range-changed', @requestUpdate
@subscribe editor, 'selection-removed selection-screen-range-changed', @onSelectionChanged
@subscribe editor, 'selection-added', @onSelectionAdded
@subscribe editor, 'selection-removed', @onSelectionAdded
@subscribe editor.$scrollTop.changes, @onScrollTopChanged
@subscribe editor.$scrollLeft.changes, @requestUpdate
@subscribe editor.$height.changes, @requestUpdate
@@ -319,14 +325,32 @@ EditorComponent = React.createClass
@pendingScrollLeft = null
onMouseWheel: (event) ->
event.preventDefault()
screenRow = @screenRowForNode(event.target)
@mouseWheelScreenRow = screenRow if screenRow?
animationFramePending = @pendingHorizontalScrollDelta isnt 0 or @pendingVerticalScrollDelta isnt 0
# Only scroll in one direction at a time
{wheelDeltaX, wheelDeltaY} = event
if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY)
@refs.horizontalScrollbar.getDOMNode().scrollLeft -= wheelDeltaX
@pendingHorizontalScrollDelta -= wheelDeltaX
else
@refs.verticalScrollbar.getDOMNode().scrollTop -= wheelDeltaY
@pendingVerticalScrollDelta -= wheelDeltaY
event.preventDefault()
unless animationFramePending
requestAnimationFrame =>
{editor} = @props
editor.setScrollTop(editor.getScrollTop() + @pendingVerticalScrollDelta)
editor.setScrollLeft(editor.getScrollLeft() + @pendingHorizontalScrollDelta)
@pendingVerticalScrollDelta = 0
@pendingHorizontalScrollDelta = 0
screenRowForNode: (node) ->
while node isnt document
if screenRow = node.dataset.screenRow
return parseInt(screenRow)
node = node.parentNode
null
onStylesheetsChanged: (stylesheet) ->
@refreshScrollbars() if @containsScrollbarSelector(stylesheet)
@@ -357,13 +381,6 @@ EditorComponent = React.createClass
# if the editor's content and dimensions require them to be visible.
@requestUpdate()
clearPreservedRowRange: ->
@preservedRowRange = null
@scrollingVertically = false
@requestUpdate()
clearPreservedRowRangeAfterDelay: null # Created lazily
onBatchedUpdatesStarted: ->
@batchingUpdates = true
@@ -379,20 +396,31 @@ EditorComponent = React.createClass
@pendingChanges.push(change)
@requestUpdate() if editor.intersectsVisibleRowRange(change.start, change.end + 1) # TODO: Use closed-open intervals for change events
onSelectionChanged: (selection) ->
{editor} = @props
if editor.selectionIntersectsVisibleRowRange(selection)
@selectionChanged = true
@requestUpdate()
onSelectionAdded: (selection) ->
{editor} = @props
@requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection)
if editor.selectionIntersectsVisibleRowRange(selection)
@selectionChanged = true
@selectionAdded = true
@requestUpdate()
onScrollTopChanged: ->
@preservedRowRange = @getRenderedRowRange()
@scrollingVertically = true
@clearPreservedRowRangeAfterDelay ?= debounce(@clearPreservedRowRange, 200)
@clearPreservedRowRangeAfterDelay()
@requestUpdate()
@stopScrollingAfterDelay ?= debounce(@onStoppedScrolling, 100)
@stopScrollingAfterDelay()
onStoppedScrolling: ->
@scrollingVertically = false
@mouseWheelScreenRow = null
@requestUpdate()
onSelectionRemoved: (selection) ->
{editor} = @props
@requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection)
stopScrollingAfterDelay: null # created lazily
onCursorsMoved: ->
@cursorsMoved = true
@@ -411,3 +439,7 @@ EditorComponent = React.createClass
consolidateSelections: (e) ->
e.abortKeyBinding() unless @props.editor.consolidateSelections()
lineNodeForScreenRow: (screenRow) -> @refs.scrollView.lineNodeForScreenRow(screenRow)
lineNumberNodeForScreenRow: (screenRow) -> @refs.gutter.lineNumberNodeForScreenRow(screenRow)

View File

@@ -17,19 +17,14 @@ EditorScrollViewComponent = React.createClass
render: ->
{editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props
{scrollHeight, scrollWidth, renderedRowRange, pendingChanges, scrollingVertically} = @props
{cursorBlinkPeriod, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props
{renderedRowRange, pendingChanges, scrollTop, scrollLeft, scrollHeight, scrollWidth, scrollingVertically, mouseWheelScreenRow} = @props
{selectionChanged, selectionAdded, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props
if @isMounted()
inputStyle = @getHiddenInputPosition()
inputStyle.WebkitTransform = 'translateZ(0)'
contentStyle =
height: scrollHeight
minWidth: scrollWidth
WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)"
div className: 'scroll-view',
div className: 'scroll-view', onMouseDown: @onMouseDown,
InputComponent
ref: 'input'
className: 'hidden-input'
@@ -38,14 +33,12 @@ EditorScrollViewComponent = React.createClass
onFocus: onInputFocused
onBlur: onInputBlurred
div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown,
CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay})
LinesComponent {
ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide,
renderedRowRange, pendingChanges, scrollingVertically
}
div className: 'underlayer',
SelectionsComponent({editor})
CursorsComponent({editor, scrollTop, scrollLeft, cursorsMoved, selectionAdded, cursorBlinkResumeDelay})
LinesComponent {
ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide,
renderedRowRange, pendingChanges, scrollTop, scrollLeft, scrollingVertically,
selectionChanged, scrollHeight, scrollWidth, mouseWheelScreenRow
}
componentDidMount: ->
@getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged
@@ -198,3 +191,5 @@ EditorScrollViewComponent = React.createClass
focus: ->
@refs.input.focus()
lineNodeForScreenRow: (screenRow) -> @refs.lines.lineNodeForScreenRow(screenRow)

View File

@@ -1269,6 +1269,9 @@ class Editor extends Model
# Returns: An {Array} of {Selection}s.
getSelections: -> new Array(@selections...)
selectionsForScreenRows: (startRow, endRow) ->
@getSelections().filter (selection) -> selection.intersectsScreenRowRange(startRow, endRow)
# Public: Get the most recent {Selection} or the selection at the given
# index.
#

View File

@@ -1,46 +1,33 @@
React = require 'react'
{div} = require 'reactionary'
{isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus'
{isEqual, isEqualForProperties, multiplyString, toArray} = require 'underscore-plus'
SubscriberMixin = require './subscriber-mixin'
WrapperDiv = document.createElement('div')
module.exports =
GutterComponent = React.createClass
displayName: 'GutterComponent'
mixins: [SubscriberMixin]
lastMeasuredWidth: null
dummyLineNumberNode: null
render: ->
{scrollHeight, scrollTop} = @props
div className: 'gutter',
@renderLineNumbers() if @isMounted()
div className: 'line-numbers', ref: 'lineNumbers', style:
height: scrollHeight
WebkitTransform: "translate3d(0px, #{-scrollTop}px, 0px)"
renderLineNumbers: ->
{editor, renderedRowRange, maxLineNumberDigits, scrollTop, scrollHeight} = @props
[startRow, endRow] = renderedRowRange
charWidth = editor.getDefaultCharWidth()
lineHeight = editor.getLineHeight()
style =
width: charWidth * (maxLineNumberDigits + 1.5)
height: scrollHeight
WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)"
componentWillMount: ->
@lineNumberNodesById = {}
@lineNumberIdsByScreenRow = {}
@screenRowsByLineNumberId = {}
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 = ''
else
lastBufferRow = bufferRow
lineNumber = (bufferRow + 1).toString()
key = tokenizedLines[i].id
screenRow = startRow + i
lineNumbers.push(LineNumberComponent({key, lineNumber, maxLineNumberDigits, bufferRow, screenRow, lineHeight}))
lastBufferRow = bufferRow
div className: 'line-numbers', style: style,
lineNumbers
componentDidMount: ->
@appendDummyLineNumber()
# Only update the gutter if the visible row range has changed or if a
# non-zero-delta change to the screen lines has occurred within the current
@@ -49,39 +36,130 @@ GutterComponent = React.createClass
return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'scrollTop', 'lineHeight', 'fontSize')
{renderedRowRange, pendingChanges} = newProps
for change in pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0
for change in pendingChanges when Math.abs(change.screenDelta) > 0 or Math.abs(change.bufferDelta) > 0
return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start
false
componentDidUpdate: (oldProps) ->
unless @lastMeasuredWidth? and isEqualForProperties(oldProps, @props, 'maxLineNumberDigits', 'fontSize', 'fontFamily')
width = @getDOMNode().offsetWidth
if width isnt @lastMeasuredWidth
@lastMeasuredWidth = width
@props.onWidthChanged(width)
unless oldProps.maxLineNumberDigits is @props.maxLineNumberDigits
@updateDummyLineNumber()
@removeLineNumberNodes()
LineNumberComponent = React.createClass
displayName: 'LineNumberComponent'
@measureWidth() unless @lastMeasuredWidth? and isEqualForProperties(oldProps, @props, 'maxLineNumberDigits', 'fontSize', 'fontFamily')
@clearScreenRowCaches() unless oldProps.lineHeight is @props.lineHeight
@updateLineNumbers()
render: ->
{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()}
clearScreenRowCaches: ->
@lineNumberIdsByScreenRow = {}
@screenRowsByLineNumberId = {}
buildInnerHTML: ->
{lineNumber, maxLineNumberDigits} = @props
if lineNumber.length < maxLineNumberDigits
padding = multiplyString('&nbsp;', maxLineNumberDigits - lineNumber.length)
padding + lineNumber + @iconDivHTML
# This dummy line number element holds the gutter to the appropriate width,
# since the real line numbers are absolutely positioned for performance reasons.
appendDummyLineNumber: ->
{maxLineNumberDigits} = @props
WrapperDiv.innerHTML = @buildLineNumberHTML(0, false, maxLineNumberDigits)
@dummyLineNumberNode = WrapperDiv.children[0]
@refs.lineNumbers.getDOMNode().appendChild(@dummyLineNumberNode)
updateDummyLineNumber: ->
@dummyLineNumberNode.innerHTML = @buildLineNumberInnerHTML(0, false, @props.maxLineNumberDigits)
updateLineNumbers: ->
lineNumberIdsToPreserve = @appendOrUpdateVisibleLineNumberNodes()
@removeLineNumberNodes(lineNumberIdsToPreserve)
appendOrUpdateVisibleLineNumberNodes: ->
{editor, renderedRowRange, scrollTop, maxLineNumberDigits} = @props
[startRow, endRow] = renderedRowRange
newLineNumberIds = null
newLineNumbersHTML = null
visibleLineNumberIds = new Set
wrapCount = 0
for bufferRow, index in editor.bufferRowsForScreenRows(startRow, endRow - 1)
screenRow = startRow + index
if bufferRow is lastBufferRow
id = "#{bufferRow}-#{wrapCount++}"
else
id = bufferRow.toString()
lastBufferRow = bufferRow
wrapCount = 0
visibleLineNumberIds.add(id)
if @hasLineNumberNode(id)
@updateLineNumberNode(id, screenRow)
else
newLineNumberIds ?= []
newLineNumbersHTML ?= ""
newLineNumberIds.push(id)
newLineNumbersHTML += @buildLineNumberHTML(bufferRow, wrapCount > 0, maxLineNumberDigits, screenRow)
@screenRowsByLineNumberId[id] = screenRow
@lineNumberIdsByScreenRow[screenRow] = id
if newLineNumberIds?
WrapperDiv.innerHTML = newLineNumbersHTML
newLineNumberNodes = toArray(WrapperDiv.children)
node = @refs.lineNumbers.getDOMNode()
for lineNumberId, i in newLineNumberIds
lineNumberNode = newLineNumberNodes[i]
@lineNumberNodesById[lineNumberId] = lineNumberNode
node.appendChild(lineNumberNode)
visibleLineNumberIds
removeLineNumberNodes: (lineNumberIdsToPreserve) ->
{mouseWheelScreenRow} = @props
node = @refs.lineNumbers.getDOMNode()
for lineNumberId, lineNumberNode of @lineNumberNodesById when not lineNumberIdsToPreserve?.has(lineNumberId)
screenRow = @screenRowsByLineNumberId[lineNumberId]
unless screenRow is mouseWheelScreenRow
delete @lineNumberNodesById[lineNumberId]
delete @lineNumberIdsByScreenRow[screenRow] if @lineNumberIdsByScreenRow[screenRow] is lineNumberId
delete @screenRowsByLineNumberId[lineNumberId]
node.removeChild(lineNumberNode)
buildLineNumberHTML: (bufferRow, softWrapped, maxLineNumberDigits, screenRow) ->
if screenRow?
{lineHeight} = @props
style = "position: absolute; top: #{screenRow * lineHeight}px;"
else
lineNumber + @iconDivHTML
style = "visibility: hidden;"
innerHTML = @buildLineNumberInnerHTML(bufferRow, softWrapped, maxLineNumberDigits)
iconDivHTML: '<div class="icon-right"></div>'
"<div class=\"line-number editor-colors\" style=\"#{style}\" data-screen-row=\"#{screenRow}\">#{innerHTML}</div>"
shouldComponentUpdate: (newProps) ->
not isEqualForProperties(newProps, @props, 'lineHeight', 'screenRow', 'maxLineNumberDigits')
buildLineNumberInnerHTML: (bufferRow, softWrapped, maxLineNumberDigits) ->
if softWrapped
lineNumber = ""
else
lineNumber = (bufferRow + 1).toString()
padding = multiplyString('&nbsp;', maxLineNumberDigits - lineNumber.length)
iconHTML = '<div class="icon-right"></div>'
padding + lineNumber + iconHTML
updateLineNumberNode: (lineNumberId, screenRow) ->
unless @screenRowsByLineNumberId[lineNumberId] is screenRow
{lineHeight} = @props
@lineNumberNodesById[lineNumberId].style.top = screenRow * lineHeight + 'px'
@screenRowsByLineNumberId[lineNumberId] = screenRow
@lineNumberIdsByScreenRow[screenRow] = lineNumberId
hasLineNumberNode: (lineNumberId) ->
@lineNumberNodesById.hasOwnProperty(lineNumberId)
lineNumberNodeForScreenRow: (screenRow) ->
@lineNumberNodesById[@lineNumberIdsByScreenRow[screenRow]]
measureWidth: ->
lineNumberNode = @refs.lineNumbers.getDOMNode().firstChild
# return unless lineNumberNode?
width = lineNumberNode.offsetWidth
if width isnt @lastMeasuredWidth
@props.onWidthChanged(@lastMeasuredWidth = width)

View File

@@ -10,7 +10,7 @@ InputComponent = React.createClass
render: ->
{className, style, onFocus, onBlur} = @props
input {className, style, onFocus, onBlur}
input {className, style, onFocus, onBlur, 'data-react-skip-selection-restoration': true}
getInitialState: ->
{lastChar: ''}

View File

@@ -1,10 +1,13 @@
React = require 'react'
{div, span} = require 'reactionary'
{debounce, isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus'
{debounce, isEqual, isEqualForProperties, multiplyString, toArray} = require 'underscore-plus'
{$$} = require 'space-pen'
SelectionsComponent = require './selections-component'
DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0]
AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT}
WrapperDiv = document.createElement('div')
module.exports =
LinesComponent = React.createClass
@@ -12,23 +15,27 @@ LinesComponent = React.createClass
render: ->
if @isMounted()
{editor, renderedRowRange, lineHeight, showIndentGuide} = @props
[startRow, endRow] = renderedRowRange
{editor, scrollTop, scrollLeft, scrollHeight, scrollWidth, lineHeight} = @props
style =
height: scrollHeight
width: scrollWidth
WebkitTransform: "translate3d(#{-scrollLeft}px, #{-scrollTop}px, 0px)"
lines =
for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1)
LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, lineHeight, screenRow: startRow + i})
div {className: 'lines'}, lines
div {className: 'lines', style},
SelectionsComponent({editor, lineHeight}) if @isMounted()
componentWillMount: ->
@measuredLines = new WeakSet
@lineNodesByLineId = {}
@screenRowsByLineId = {}
@lineIdsByScreenRow = {}
componentDidMount: ->
@measureLineHeightAndCharWidth()
shouldComponentUpdate: (newProps) ->
return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide')
return true if newProps.selectionChanged
return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'scrollTop', 'scrollLeft', 'showIndentGuide', 'scrollingVertically')
{renderedRowRange, pendingChanges} = newProps
for change in pendingChanges
@@ -38,9 +45,146 @@ LinesComponent = React.createClass
componentDidUpdate: (prevProps) ->
@measureLineHeightAndCharWidth() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight')
@clearScreenRowCaches() unless prevProps.lineHeight is @props.lineHeight
@removeLineNodes() unless prevProps.showIndentGuide is @props.showIndentGuide
@updateLines()
@clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily')
@measureCharactersInNewLines() unless @props.scrollingVertically
clearScreenRowCaches: ->
@screenRowsByLineId = {}
@lineIdsByScreenRow = {}
updateLines: ->
{editor, renderedRowRange, showIndentGuide, selectionChanged} = @props
[startRow, endRow] = renderedRowRange
visibleLines = editor.linesForScreenRows(startRow, endRow - 1)
@removeLineNodes(visibleLines)
@appendOrUpdateVisibleLineNodes(visibleLines, startRow)
removeLineNodes: (visibleLines=[]) ->
{mouseWheelScreenRow} = @props
visibleLineIds = new Set
visibleLineIds.add(line.id.toString()) for line in visibleLines
node = @getDOMNode()
for lineId, lineNode of @lineNodesByLineId when not visibleLineIds.has(lineId)
screenRow = @screenRowsByLineId[lineId]
unless screenRow is mouseWheelScreenRow
delete @lineNodesByLineId[lineId]
delete @lineIdsByScreenRow[screenRow] if @lineIdsByScreenRow[screenRow] is lineId
delete @screenRowsByLineId[lineId]
node.removeChild(lineNode)
appendOrUpdateVisibleLineNodes: (visibleLines, startRow) ->
{lineHeight} = @props
newLines = null
newLinesHTML = null
for line, index in visibleLines
screenRow = startRow + index
if @hasLineNode(line.id)
@updateLineNode(line, screenRow)
else
newLines ?= []
newLinesHTML ?= ""
newLines.push(line)
newLinesHTML += @buildLineHTML(line, screenRow)
@screenRowsByLineId[line.id] = screenRow
@lineIdsByScreenRow[screenRow] = line.id
return unless newLines?
WrapperDiv.innerHTML = newLinesHTML
newLineNodes = toArray(WrapperDiv.children)
node = @getDOMNode()
for line, i in newLines
lineNode = newLineNodes[i]
@lineNodesByLineId[line.id] = lineNode
node.appendChild(lineNode)
hasLineNode: (lineId) ->
@lineNodesByLineId.hasOwnProperty(lineId)
buildTranslate3d: (top) ->
"translate3d(0px, #{top}px, 0px)"
buildLineHTML: (line, screenRow) ->
{editor, mini, showIndentGuide, lineHeight} = @props
{tokens, text, lineEnding, fold, isSoftWrapped, indentLevel} = line
top = screenRow * lineHeight
lineHTML = "<div class=\"line\" style=\"position: absolute; top: #{top}px;\" data-screen-row=\"#{screenRow}\">"
if text is ""
lineHTML += @buildEmptyLineInnerHTML(line)
else
lineHTML += @buildLineInnerHTML(line)
lineHTML += "</div>"
lineHTML
buildEmptyLineInnerHTML: (line) ->
{showIndentGuide} = @props
{indentLevel, tabLength} = line
if showIndentGuide and indentLevel > 0
indentSpan = "<span class='indent-guide'>#{multiplyString(' ', tabLength)}</span>"
multiplyString(indentSpan, indentLevel + 1)
else
"&nbsp;"
buildLineInnerHTML: (line) ->
{invisibles, mini, showIndentGuide} = @props
{tokens, text} = line
innerHTML = ""
scopeStack = []
firstTrailingWhitespacePosition = text.search(/\s*$/)
lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0
for token in tokens
innerHTML += @updateScopeStack(scopeStack, token.scopes)
hasIndentGuide = not mini and showIndentGuide and token.hasLeadingWhitespace or (token.hasTrailingWhitespace and lineIsWhitespaceOnly)
innerHTML += token.getValueAsHtml({invisibles, hasIndentGuide})
innerHTML += @popScope(scopeStack) while scopeStack.length > 0
innerHTML
updateScopeStack: (scopeStack, desiredScopes) ->
html = ""
# Find a common prefix
for scope, i in desiredScopes
break unless scopeStack[i]?.scope is desiredScopes[i]
# Pop scopes until we're at the common prefx
until scopeStack.length is i
html += @popScope(scopeStack)
# Push onto common prefix until scopeStack equals desiredScopes
for j in [i...desiredScopes.length]
html += @pushScope(scopeStack, desiredScopes[j])
html
popScope: (scopeStack) ->
scopeStack.pop()
"</span>"
pushScope: (scopeStack, scope) ->
scopeStack.push(scope)
"<span class=\"#{scope.replace(/\.+/g, ' ')}\">"
updateLineNode: (line, screenRow) ->
unless @screenRowsByLineId[line.id] is screenRow
{lineHeight} = @props
lineNode = @lineNodesByLineId[line.id]
lineNode.style.top = screenRow * lineHeight + 'px'
@screenRowsByLineId[line.id] = screenRow
@lineIdsByScreenRow[screenRow] = line.id
lineNodeForScreenRow: (screenRow) ->
@lineNodesByLineId[@lineIdsByScreenRow[screenRow]]
measureLineHeightAndCharWidth: ->
node = @getDOMNode()
node.appendChild(DummyLineNode)
@@ -56,9 +200,9 @@ LinesComponent = React.createClass
[visibleStartRow, visibleEndRow] = @props.renderedRowRange
node = @getDOMNode()
for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1)
for tokenizedLine in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1)
unless @measuredLines.has(tokenizedLine)
lineNode = node.children[i]
lineNode = @lineNodesByLineId[tokenizedLine.id]
@measureCharactersInLine(tokenizedLine, lineNode)
measureCharactersInLine: (tokenizedLine, lineNode) ->
@@ -97,44 +241,3 @@ LinesComponent = React.createClass
clearScopedCharWidths: ->
@measuredLines.clear()
@props.editor.clearScopedCharWidths()
LineComponent = React.createClass
displayName: 'LineComponent'
render: ->
{screenRow, lineHeight} = @props
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
@buildEmptyLineHTML()
else
@buildScopeTreeHTML(@props.tokenizedLine.getScopeTree())
buildEmptyLineHTML: ->
{showIndentGuide, tokenizedLine} = @props
{indentLevel, tabLength} = tokenizedLine
if showIndentGuide and indentLevel > 0
indentSpan = "<span class='indent-guide'>#{multiplyString(' ', tabLength)}</span>"
multiplyString(indentSpan, indentLevel + 1)
else
"&nbsp;"
buildScopeTreeHTML: (scopeTree) ->
if scopeTree.children?
html = "<span class='#{scopeTree.scope.replace(/\./g, ' ')}'>"
html += @buildScopeTreeHTML(child) for child in scopeTree.children
html += "</span>"
html
else
"<span>#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}</span>"
shouldComponentUpdate: (newProps) ->
not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight', 'screenRow')

View File

@@ -1,6 +1,7 @@
{View, $} = require 'space-pen'
React = require 'react'
EditorComponent = require './editor-component'
{defaults} = require 'underscore-plus'
module.exports =
class ReactEditorView extends View
@@ -8,7 +9,7 @@ class ReactEditorView extends View
focusOnAttach: false
constructor: (@editor) ->
constructor: (@editor, @props) ->
super
getEditor: -> @editor
@@ -37,11 +38,12 @@ class ReactEditorView extends View
afterAttach: (onDom) ->
return unless onDom
@attached = true
@component = React.renderComponent(EditorComponent({@editor, parentView: this}), @element)
props = defaults({@editor, parentView: this}, @props)
@component = React.renderComponent(EditorComponent(props), @element)
node = @component.getDOMNode()
@underlayer = $(node).find('.underlayer')
@underlayer = $(node).find('.selections')
@gutter = $(node).find('.gutter')
@gutter.removeClassFromAllLines = (klass) =>
@@ -64,7 +66,8 @@ class ReactEditorView extends View
appendToLinesView: (view) ->
view.css('position', 'absolute')
@find('.scroll-view-content').prepend(view)
view.css('z-index', 1)
@find('.lines').prepend(view)
beforeRemove: ->
React.unmountComponentAtNode(@element)

View File

@@ -6,6 +6,60 @@ SelectionComponent = React.createClass
displayName: 'SelectionComponent'
render: ->
{editor, screenRange, lineHeight} = @props
{start, end} = screenRange
rowCount = end.row - start.row + 1
startPixelPosition = editor.pixelPositionForScreenPosition(start)
endPixelPosition = editor.pixelPositionForScreenPosition(end)
div className: 'selection',
for regionRect, i in @props.selection.getRegionRects()
div className: 'region', key: i, style: regionRect
if rowCount is 1
@renderSingleLineRegions(startPixelPosition, endPixelPosition)
else
@renderMultiLineRegions(startPixelPosition, endPixelPosition, rowCount)
renderSingleLineRegions: (startPixelPosition, endPixelPosition) ->
{lineHeight} = @props
[
div className: 'region', key: 0, style:
top: startPixelPosition.top
height: lineHeight
left: startPixelPosition.left
width: endPixelPosition.left - startPixelPosition.left
]
renderMultiLineRegions: (startPixelPosition, endPixelPosition, rowCount) ->
{lineHeight} = @props
regions = []
index = 0
# First row, extending from selection start to the right side of screen
regions.push(
div className: 'region', key: index++, style:
top: startPixelPosition.top
left: startPixelPosition.left
height: lineHeight
right: 0
)
# Middle rows, extending from left side to right side of screen
if rowCount > 2
regions.push(
div className: 'region', key: index++, style:
top: startPixelPosition.top + lineHeight
height: (rowCount - 2) * lineHeight
left: 0
right: 0
)
# Last row, extending from left side of screen to selection end
regions.push(
div className: 'region', key: index, style:
top: endPixelPosition.top
height: lineHeight
left: 0
width: endPixelPosition.left
)
regions

View File

@@ -563,6 +563,12 @@ class Selection extends Model
intersectsBufferRange: (bufferRange) ->
@getBufferRange().intersectsWith(bufferRange)
intersectsScreenRowRange: (startRow, endRow) ->
@getScreenRange().intersectsRowRange(startRow, endRow)
intersectsScreenRow: (screenRow) ->
@getScreenRange().intersectsRow(screenRow)
# Public: Identifies if a selection intersects with another selection.
#
# otherSelection - A {Selection} to check against.
@@ -595,47 +601,6 @@ class Selection extends Model
compare: (otherSelection) ->
@getBufferRange().compare(otherSelection.getBufferRange())
# Get the pixel dimensions of rectangular regions that cover selection's area
# on the screen. Used by SelectionComponent for rendering.
getRegionRects: ->
lineHeight = @editor.getLineHeight()
{start, end} = @getScreenRange()
rowCount = end.row - start.row + 1
startPixelPosition = @editor.pixelPositionForScreenPosition(start)
endPixelPosition = @editor.pixelPositionForScreenPosition(end)
if rowCount is 1
# Single line selection
rects = [{
top: startPixelPosition.top
height: lineHeight
left: startPixelPosition.left
width: endPixelPosition.left - startPixelPosition.left
}]
else
# Multi-line selection
rects = []
# First row, extending from selection start to the right side of screen
rects.push {
top: startPixelPosition.top
left: startPixelPosition.left
height: lineHeight
right: 0
}
if rowCount > 2
# Middle rows, extending from left side to right side of screen
rects.push {
top: startPixelPosition.top + lineHeight
height: (rowCount - 2) * lineHeight
left: 0
right: 0
}
# Last row, extending from left side of screen to selection end
rects.push {top: endPixelPosition.top, height: lineHeight, left: 0, width: endPixelPosition.left }
rects
screenRangeChanged: ->
screenRange = @getScreenRange()
@emit 'screen-range-changed', screenRange

View File

@@ -7,10 +7,37 @@ SelectionsComponent = React.createClass
displayName: 'SelectionsComponent'
render: ->
{editor} = @props
div className: 'selections', @renderSelections()
div className: 'selections',
if @isMounted()
for selection in editor.getSelections()
if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection)
SelectionComponent({key: selection.id, selection})
renderSelections: ->
{editor, lineHeight} = @props
selectionComponents = []
for selectionId, screenRange of @selectionRanges
selectionComponents.push(SelectionComponent({key: selectionId, screenRange, editor, lineHeight}))
selectionComponents
componentWillMount: ->
@selectionRanges = {}
shouldComponentUpdate: ->
{editor} = @props
oldSelectionRanges = @selectionRanges
newSelectionRanges = {}
@selectionRanges = newSelectionRanges
for selection, index in editor.getSelections()
# Rendering artifacts occur on the lines GPU layer if we remove the last selection
if index is 0 or (not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection))
newSelectionRanges[selection.id] = selection.getScreenRange()
for id, range of newSelectionRanges
if oldSelectionRanges.hasOwnProperty(id)
return true unless range.isEqual(oldSelectionRanges[id])
else
return true
for id of oldSelectionRanges
return true unless newSelectionRanges.hasOwnProperty(id)
false

View File

@@ -13,7 +13,23 @@
}
.lines {
z-index: -1;
min-width: 100%;
}
.cursor {
z-index: 1;
}
&.is-focused .cursors.blinking .cursor {
-webkit-animation: blink 0.8s;
-webkit-animation-iteration-count: infinite;
}
@-webkit-keyframes blink {
0% { opacity: .7; }
50% { opacity: .7; }
51% { opacity: 0; }
100% { opacity: 0; }
}
.horizontal-scrollbar {
@@ -45,6 +61,7 @@
.scroll-view {
overflow: hidden;
z-index: 0;
}
.scroll-view-content {
@@ -53,15 +70,9 @@
}
.gutter {
padding-left: 0.5em;
padding-right: 0.5em;
.line-number {
position: absolute;
left: 0;
right: 0;
padding: 0;
white-space: nowrap;
padding: 0 .5em;
.icon-right {
padding: 0;