diff --git a/package.json b/package.json index d5790d633..0c22a133e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 23cd6a2d1..28f740531 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -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 diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index fcc6c2022..36ba33a08 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -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} diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index e3143f1f1..16213b1b9 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -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 diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 98225df6e..5c6a05d1e 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -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) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 556fe5e17..9a30ab6ca 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -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) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index bc04b41f3..1363c5e67 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -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) diff --git a/src/editor.coffee b/src/editor.coffee index 92f9ae370..bd714ecb1 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -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. # diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 16903d5fd..a78c1b540 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -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(' ', 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: '
' + "