From da36d5f40f354bf8ad7d26736f3d20a51303312b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 26 Mar 2014 10:28:46 -0600 Subject: [PATCH 001/179] Start experimenting with an EditorView that renders content with React --- spec/editor-contents-component-spec.coffee | 19 ++++++++++++ spec/react-editor-view-spec.coffee | 8 +++++ src/editor-contents-component.coffee | 28 +++++++++++++++++ src/editor-view.coffee | 7 +++++ src/react-editor-view.coffee | 36 ++++++++++++++++++++++ src/space-pen-extensions.coffee | 2 ++ src/tokenized-line.coffee | 28 +++++++++++++++++ 7 files changed, 128 insertions(+) create mode 100644 spec/editor-contents-component-spec.coffee create mode 100644 spec/react-editor-view-spec.coffee create mode 100644 src/editor-contents-component.coffee create mode 100644 src/react-editor-view.coffee diff --git a/spec/editor-contents-component-spec.coffee b/spec/editor-contents-component-spec.coffee new file mode 100644 index 000000000..f2eaacfad --- /dev/null +++ b/spec/editor-contents-component-spec.coffee @@ -0,0 +1,19 @@ +{React} = require 'reactionary' +EditorContentsComponent = require '../src/editor-contents-component' + +describe "EditorComponent", -> + container = null + + beforeEach -> + container = document.querySelector('#jasmine-content') + waitsForPromise -> atom.packages.activatePackage('language-javascript') + + it "renders the lines that are in view based on the relevant dimensions", -> + editor = atom.project.openSync('sample.js') + lineHeight = 20 + component = React.renderComponent(EditorContentsComponent({editor}), container) + component.setState + lineHeight: lineHeight + height: 5 * lineHeight + scrollTop: 3 * lineHeight + console.log component.getDOMNode() diff --git a/spec/react-editor-view-spec.coffee b/spec/react-editor-view-spec.coffee new file mode 100644 index 000000000..019b63fbd --- /dev/null +++ b/spec/react-editor-view-spec.coffee @@ -0,0 +1,8 @@ +ReactEditorView = require '../src/react-editor-view' + +describe "ReactEditorView", -> + it "renders", -> + editor = atom.project.openSync('sample.js') + editorView = new ReactEditorView(editor) + editorView.attachToDom() + console.log editorView.element diff --git a/src/editor-contents-component.coffee b/src/editor-contents-component.coffee new file mode 100644 index 000000000..2477376a8 --- /dev/null +++ b/src/editor-contents-component.coffee @@ -0,0 +1,28 @@ +{React, div, span} = require 'reactionary' +{last} = require 'underscore-plus' + +module.exports = +React.createClass + render: -> + [startRow, endRow] = @getVisibleRowRange() + div className: 'lines', + for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + div className: 'line', @renderScopeTree(tokenizedLine.getScopeTree()) + + renderScopeTree: (scopeTree) -> + if scopeTree.scope? + span className: ".#{scopeTree.scope}", + @renderScopeTree(child) for child in scopeTree.children + else + scopeTree.value + + getInitialState: -> + height: 0 + lineHeight: 0 + scrollTop: 0 + + getVisibleRowRange: -> + heightInLines = @state.height / @state.lineHeight + startRow = Math.floor(@state.scrollTop / @state.lineHeight) + endRow = startRow + heightInLines + [startRow, endRow] diff --git a/src/editor-view.coffee b/src/editor-view.coffee index 1da0e223d..c3007f096 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -4,6 +4,8 @@ GutterView = require './gutter-view' Editor = require './editor' CursorView = require './cursor-view' SelectionView = require './selection-view' +EditorContentsComponent = require './editor-contents-component' + fs = require 'fs-plus' _ = require 'underscore-plus' TextBuffer = require 'text-buffer' @@ -122,6 +124,9 @@ class EditorView extends View @setPlaceholderText(placeholderText) if placeholderText + @contentsComponent = new EditorContentsComponent({editor}) + @renderedLines.replaceWith(@contentsComponent.element) + if editor? @edit(editor) else if @mini @@ -970,6 +975,8 @@ class EditorView extends View @redrawOnReattach = true return + return + scrollViewWidth = @scrollView.width() @updateRenderedLines(scrollViewWidth) @updatePlaceholderText() diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee new file mode 100644 index 000000000..6ca74f650 --- /dev/null +++ b/src/react-editor-view.coffee @@ -0,0 +1,36 @@ +{View} = require 'space-pen' +{$$} = require 'space-pencil' +{React} = require 'reactionary' +EditorContentsComponent = require './editor-contents-component' + +module.exports = +class ReactEditorView extends View + @content: -> + @div class: 'editor', => + @div class: 'scroll-view', outlet: 'scrollView' + + constructor: (@editor) -> + super + @scrollView = @scrollView.element + @contents = React.renderComponent(EditorContentsComponent({@editor}), @scrollView) + + afterAttach: (onDom) -> + @measureLineHeight() + + @contents.setState + height: @scrollView.clientHeight + scrollTop: @scrollView.scrollTop + lineHeight: @lineHeight + + measureLineHeight: -> + fragment = $$ -> + @div class: 'lines', -> + @div class: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' + + @scrollView.appendChild(fragment) + lineRect = fragment.getBoundingClientRect() + charRect = fragment.firstChild.getBoundingClientRect() + @lineHeight = lineRect.height + @charWidth = charRect.width + @charHeight = charRect.height + @scrollView.removeChild(fragment) diff --git a/src/space-pen-extensions.coffee b/src/space-pen-extensions.coffee index 3b6948597..14bf076f1 100644 --- a/src/space-pen-extensions.coffee +++ b/src/space-pen-extensions.coffee @@ -69,4 +69,6 @@ jQuery(document.body).on 'show.bs.tooltip', ({target}) -> jQuery.fn.setTooltip.getKeystroke = getKeystroke jQuery.fn.setTooltip.humanizeKeystrokes = humanizeKeystrokes +Object.defineProperty jQuery.fn, 'element', get: -> @[0] + module.exports = spacePen diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 044c8dd2f..e44d4955e 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -134,3 +134,31 @@ class TokenizedLine for token in @tokens return column if token is targetToken column += token.bufferDelta + + getScopeTree: -> + @scopeTree ?= new ScopeTree(@tokens) + +class ScopeTree + constructor: (@tokens, @scope, @depth=0) -> + @scope ?= @tokens[0].scopes[@depth] + @children = [] + childDepth = @depth + 1 + currentChildScope = null + currentChildTokens = [] + + for token in @tokens + tokenScope = token.scopes[childDepth] + + if tokenScope is currentChildScope + currentChildTokens.push(token) + else + if currentChildScope? + @children.push(new ScopeTree(currentChildTokens, currentChildScope, childDepth)) + currentChildScope = null + currentChildTokens = [] + + if tokenScope? + currentChildScope = tokenScope + currentChildTokens.push(token) + else + @children.push(token) From cc8ba2d6790f79ab94712d366f235b658f8107c1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 10:33:54 -0600 Subject: [PATCH 002/179] Update the rendered lines when the screen lines change --- src/editor-contents-component.coffee | 50 +++++++++++++++++++--------- src/editor.coffee | 2 +- src/react-editor-view.coffee | 14 +++++--- src/tokenized-line.coffee | 6 ++++ 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/editor-contents-component.coffee b/src/editor-contents-component.coffee index 2477376a8..9c8820b92 100644 --- a/src/editor-contents-component.coffee +++ b/src/editor-contents-component.coffee @@ -1,28 +1,48 @@ -{React, div, span} = require 'reactionary' +React = require 'react' +{div, span} = React.DOM {last} = require 'underscore-plus' module.exports = React.createClass render: -> + div className: 'lines', @renderVisibleLines() + + renderVisibleLines: -> + return [] unless @props.lineHeight > 0 + [startRow, endRow] = @getVisibleRowRange() - div className: 'lines', - for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - div className: 'line', @renderScopeTree(tokenizedLine.getScopeTree()) + for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({tokenizedLine, key: tokenizedLine.id}) - renderScopeTree: (scopeTree) -> - if scopeTree.scope? - span className: ".#{scopeTree.scope}", - @renderScopeTree(child) for child in scopeTree.children - else - scopeTree.value - - getInitialState: -> + getDefaultProps: -> height: 0 lineHeight: 0 scrollTop: 0 getVisibleRowRange: -> - heightInLines = @state.height / @state.lineHeight - startRow = Math.floor(@state.scrollTop / @state.lineHeight) - endRow = startRow + heightInLines + heightInLines = @props.height / @props.lineHeight + startRow = Math.floor(@props.scrollTop / @props.lineHeight) + endRow = Math.ceil(startRow + heightInLines) [startRow, endRow] + + onScreenLinesChanged: ({start, end}) -> + [visibleStart, visibleEnd] = @getVisibleRowRange() + @forceUpdate() unless end < visibleStart or visibleEnd <= start + +LineComponent = React.createClass + render: -> + {tokenizedLine} = @props + div className: 'line', + if tokenizedLine.text.length is 0 + span {}, String.fromCharCode(160) # non-breaking space; bypasses escaping + else + @renderScopeTree(tokenizedLine.getScopeTree()) + + renderScopeTree: (scopeTree) -> + if scopeTree.scope? + span className: scopeTree.scope.split('.').join(' '), + scopeTree.children.map (child) => @renderScopeTree(child) + else + span {}, scopeTree.value + +key = 0 diff --git a/src/editor.coffee b/src/editor.coffee index d9db05305..24aa7873a 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -217,7 +217,7 @@ class Editor extends Model @subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args... getViewClass: -> - require './editor-view' + require './react-editor-view' destroyed: -> @unsubscribe() diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 6ca74f650..8a829d62c 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -1,6 +1,6 @@ {View} = require 'space-pen' {$$} = require 'space-pencil' -{React} = require 'reactionary' +React = require 'react' EditorContentsComponent = require './editor-contents-component' module.exports = @@ -13,11 +13,15 @@ class ReactEditorView extends View super @scrollView = @scrollView.element @contents = React.renderComponent(EditorContentsComponent({@editor}), @scrollView) + @subscribe @editor, 'screen-lines-changed', (change) => @contents.onScreenLinesChanged(change) afterAttach: (onDom) -> - @measureLineHeight() + return unless onDom - @contents.setState + @editor.setVisible(true) + + @measureLineHeight() + @contents.setProps height: @scrollView.clientHeight scrollTop: @scrollView.scrollTop lineHeight: @lineHeight @@ -28,8 +32,8 @@ class ReactEditorView extends View @div class: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' @scrollView.appendChild(fragment) - lineRect = fragment.getBoundingClientRect() - charRect = fragment.firstChild.getBoundingClientRect() + lineRect = fragment.firstChild.getBoundingClientRect() + charRect = fragment.firstChild.firstChild.getBoundingClientRect() @lineHeight = lineRect.height @charWidth = charRect.width @charHeight = charRect.height diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index e44d4955e..d4a46d018 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -1,5 +1,7 @@ _ = require 'underscore-plus' +idCounter = 1 + module.exports = class TokenizedLine constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, tabLength}) -> @@ -7,6 +9,7 @@ class TokenizedLine @startBufferColumn ?= 0 @text = _.pluck(@tokens, 'value').join('') @bufferDelta = _.sum(_.pluck(@tokens, 'bufferDelta')) + @id = idCounter++ copy: -> new TokenizedLine({@tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold}) @@ -162,3 +165,6 @@ class ScopeTree currentChildTokens.push(token) else @children.push(token) + + if currentChildScope? + @children.push(new ScopeTree(currentChildTokens, currentChildScope, childDepth)) From 8ad13d3045ed4f923e5db1721653aba37e41071f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 15:15:16 -0600 Subject: [PATCH 003/179] Add a real spec for visible line rendering in the react editor view --- spec/react-editor-view-spec.coffee | 22 ++++++++++++++++++++-- src/react-editor-view.coffee | 3 +++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/spec/react-editor-view-spec.coffee b/spec/react-editor-view-spec.coffee index 019b63fbd..0a5702ae1 100644 --- a/spec/react-editor-view-spec.coffee +++ b/spec/react-editor-view-spec.coffee @@ -1,8 +1,26 @@ ReactEditorView = require '../src/react-editor-view' describe "ReactEditorView", -> - it "renders", -> + [editorView, editor, lineHeight] = [] + + beforeEach -> editor = atom.project.openSync('sample.js') editorView = new ReactEditorView(editor) + + fontSize = 20 + lineHeight = 1.3 * fontSize + editorView.css({lineHeight: 1.3, fontSize}) + + it "renders only the currently-visible lines", -> + editorView.height(4.5 * lineHeight) editorView.attachToDom() - console.log editorView.element + lines = editorView.element.querySelectorAll('.line') + expect(lines.length).toBe 5 + expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text + expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text + + editorView.setScrollTop(2.5 * lineHeight) + lines = editorView.element.querySelectorAll('.line') + expect(lines.length).toBe 5 + expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text + expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 8a829d62c..77379fd07 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -26,6 +26,9 @@ class ReactEditorView extends View scrollTop: @scrollView.scrollTop lineHeight: @lineHeight + setScrollTop: (scrollTop) -> + @contents.setProps({scrollTop}) + measureLineHeight: -> fragment = $$ -> @div class: 'lines', -> From 8cd9160ed50224bf0d242d9475c7842b6c4c0683 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 15:59:48 -0600 Subject: [PATCH 004/179] Add space-pencil dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c4d5ba530..29c6143d0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "semver": "1.1.4", "serializable": "1.x", "space-pen": "3.1.1", + "space-pencil": "^0.3.0", "temp": "0.5.0", "text-buffer": "^2.1.0", "theorist": "1.x", From 9c49a2d97003a0f64bdb7398af2c023619810258 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 16:00:04 -0600 Subject: [PATCH 005/179] Use reactionary helper for creating virtual DOM elements --- package.json | 1 + src/editor-contents-component.coffee | 13 +++++-------- src/react-editor-view.coffee | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 29c6143d0..d3df2c153 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "property-accessors": "1.x", "q": "^1.0.1", "random-words": "0.0.1", + "reactionary": "^0.6.0", "runas": "0.5.x", "scandal": "0.15.2", "scoped-property-store": "^0.8.0", diff --git a/src/editor-contents-component.coffee b/src/editor-contents-component.coffee index 9c8820b92..cab28f5e0 100644 --- a/src/editor-contents-component.coffee +++ b/src/editor-contents-component.coffee @@ -1,11 +1,10 @@ -React = require 'react' -{div, span} = React.DOM +{React, div, span} = require 'reactionary' {last} = require 'underscore-plus' module.exports = React.createClass render: -> - div className: 'lines', @renderVisibleLines() + div class: 'lines', @renderVisibleLines() renderVisibleLines: -> return [] unless @props.lineHeight > 0 @@ -32,7 +31,7 @@ React.createClass LineComponent = React.createClass render: -> {tokenizedLine} = @props - div className: 'line', + div class: 'line', if tokenizedLine.text.length is 0 span {}, String.fromCharCode(160) # non-breaking space; bypasses escaping else @@ -40,9 +39,7 @@ LineComponent = React.createClass renderScopeTree: (scopeTree) -> if scopeTree.scope? - span className: scopeTree.scope.split('.').join(' '), + span class: scopeTree.scope.split('.').join(' '), scopeTree.children.map (child) => @renderScopeTree(child) else - span {}, scopeTree.value - -key = 0 + span scopeTree.value diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 77379fd07..c118c525d 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -1,6 +1,6 @@ {View} = require 'space-pen' {$$} = require 'space-pencil' -React = require 'react' +{React} = require 'reactionary' EditorContentsComponent = require './editor-contents-component' module.exports = From a134a60ce83939b7bd906b89afbb85e557c7a7f1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 19:02:24 -0600 Subject: [PATCH 006/179] Render the entire editor with React. Handle vertical scrolling. The space-pen view is now a simple wrapper around the entire React component to integrate it cleanly into our existing system. React components can't adopt existing DOM nodes, otherwise I would just have the react component take over the entire view instead of wrapping. --- spec/editor-component-spec.coffee | 39 +++++++++ spec/editor-contents-component-spec.coffee | 19 ----- spec/react-editor-view-spec.coffee | 26 ------ src/editor-component.coffee | 97 ++++++++++++++++++++++ src/editor-contents-component.coffee | 45 ---------- src/editor-view.coffee | 1 - src/react-editor-view.coffee | 35 ++------ static/panes.less | 2 +- 8 files changed, 142 insertions(+), 122 deletions(-) create mode 100644 spec/editor-component-spec.coffee delete mode 100644 spec/editor-contents-component-spec.coffee delete mode 100644 spec/react-editor-view-spec.coffee create mode 100644 src/editor-component.coffee delete mode 100644 src/editor-contents-component.coffee diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee new file mode 100644 index 000000000..f7aaa56df --- /dev/null +++ b/spec/editor-component-spec.coffee @@ -0,0 +1,39 @@ +{React} = require 'reactionary' +EditorComponent = require '../src/editor-component' + +describe "EditorComponent", -> + [editor, component, node, lineHeight] = [] + + beforeEach -> + editor = atom.project.openSync('sample.js') + container = document.querySelector('#jasmine-content') + component = React.renderComponent(EditorComponent({editor}), container) + node = component.getDOMNode() + + fontSize = 20 + lineHeight = 1.3 * fontSize + node.style.lineHeight = 1.3 + node.style.fontSize = fontSize + 'px' + + it "renders only the currently-visible lines", -> + node.style.height = 4.5 * lineHeight + 'px' + component.updateAllDimensions() + + lines = node.querySelectorAll('.line') + expect(lines.length).toBe 5 + expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text + expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text + + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight + component.onVerticalScroll() + + expect(node.querySelector('.lines').offsetTop).toBe -2.5 * lineHeight + + lines = node.querySelectorAll('.line') + expect(lines.length).toBe 5 + expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text + expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text + + spacers = node.querySelectorAll('.spacer') + expect(spacers[0].offsetHeight).toBe 2 * lineHeight + expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeight diff --git a/spec/editor-contents-component-spec.coffee b/spec/editor-contents-component-spec.coffee deleted file mode 100644 index f2eaacfad..000000000 --- a/spec/editor-contents-component-spec.coffee +++ /dev/null @@ -1,19 +0,0 @@ -{React} = require 'reactionary' -EditorContentsComponent = require '../src/editor-contents-component' - -describe "EditorComponent", -> - container = null - - beforeEach -> - container = document.querySelector('#jasmine-content') - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - it "renders the lines that are in view based on the relevant dimensions", -> - editor = atom.project.openSync('sample.js') - lineHeight = 20 - component = React.renderComponent(EditorContentsComponent({editor}), container) - component.setState - lineHeight: lineHeight - height: 5 * lineHeight - scrollTop: 3 * lineHeight - console.log component.getDOMNode() diff --git a/spec/react-editor-view-spec.coffee b/spec/react-editor-view-spec.coffee deleted file mode 100644 index 0a5702ae1..000000000 --- a/spec/react-editor-view-spec.coffee +++ /dev/null @@ -1,26 +0,0 @@ -ReactEditorView = require '../src/react-editor-view' - -describe "ReactEditorView", -> - [editorView, editor, lineHeight] = [] - - beforeEach -> - editor = atom.project.openSync('sample.js') - editorView = new ReactEditorView(editor) - - fontSize = 20 - lineHeight = 1.3 * fontSize - editorView.css({lineHeight: 1.3, fontSize}) - - it "renders only the currently-visible lines", -> - editorView.height(4.5 * lineHeight) - editorView.attachToDom() - lines = editorView.element.querySelectorAll('.line') - expect(lines.length).toBe 5 - expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text - expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text - - editorView.setScrollTop(2.5 * lineHeight) - lines = editorView.element.querySelectorAll('.line') - expect(lines.length).toBe 5 - expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text - expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text diff --git a/src/editor-component.coffee b/src/editor-component.coffee new file mode 100644 index 000000000..5a37e974b --- /dev/null +++ b/src/editor-component.coffee @@ -0,0 +1,97 @@ +{React, div, span} = require 'reactionary' +{last} = require 'underscore-plus' +{$$} = require 'space-pencil' + +DummyLineNode = $$ -> + @div class: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' + +module.exports = +React.createClass + render: -> + div class: 'editor', + div class: 'scroll-view', ref: 'scrollView', + div @renderVisibleLines() + div class: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, + div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} + + renderVisibleLines: -> + [startRow, endRow] = @getVisibleRowRange() + precedingHeight = startRow * @state.lineHeight + lineCount = @props.editor.getScreenLineCount() + followingHeight = (lineCount - endRow) * @state.lineHeight + + div class: 'lines', ref: 'lines', style: {top: -@state.scrollTop}, + div class: 'spacer', style: {height: precedingHeight} + for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({tokenizedLine, key: tokenizedLine.id}) + div class: 'spacer', style: {height: followingHeight} + + getInitialState: -> + height: 0 + width: 0 + lineHeight: 0 + scrollTop: 0 + + componentDidMount: -> + @props.editor.on 'screen-lines-changed', @onScreenLinesChanged + @updateAllDimensions() + + componentWillUnmount: -> + @props.editor.off 'screen-lines-changed', @onScreenLinesChanged + + componentWilUpdate: (nextProps, nextState) -> + if nextState.scrollTop? + @refs.verticalScrollbar.getDOMNode().scrollTop = nextState.scrollTop + + onVerticalScroll: -> + scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop + @setState({scrollTop}) + + onScreenLinesChanged: ({start, end}) => + [visibleStart, visibleEnd] = @getVisibleRowRange() + @forceUpdate() unless end < visibleStart or visibleEnd <= start + + getVisibleRowRange: -> + return [0, 0] unless @state.lineHeight > 0 + + heightInLines = @state.height / @state.lineHeight + startRow = Math.floor(@state.scrollTop / @state.lineHeight) + endRow = Math.ceil(startRow + heightInLines) + [startRow, endRow] + + getScrollHeight: -> + @props.editor.getLineCount() * @state.lineHeight + + updateAllDimensions: -> + lineHeight = @measureLineHeight() + {height, width} = @measureScrollViewDimensions() + + console.log "updating dimensions", {lineHeight, height, width} + + @setState({lineHeight, height, width}) + + measureScrollViewDimensions: -> + scrollViewNode = @refs.scrollView.getDOMNode() + {height: scrollViewNode.clientHeight, width: scrollViewNode.clientWidth} + + measureLineHeight: -> + linesNode = @refs.lines.getDOMNode() + linesNode.appendChild(DummyLineNode) + lineHeight = DummyLineNode.getBoundingClientRect().height + linesNode.removeChild(DummyLineNode) + lineHeight + +LineComponent = React.createClass + render: -> + div class: 'line', + if @props.tokenizedLine.text.length is 0 + span String.fromCharCode(160) # non-breaking space; bypasses escaping + else + @renderScopeTree(@props.tokenizedLine.getScopeTree()) + + renderScopeTree: (scopeTree) -> + if scopeTree.scope? + span class: scopeTree.scope.split('.').join(' '), + scopeTree.children.map (child) => @renderScopeTree(child) + else + span scopeTree.value diff --git a/src/editor-contents-component.coffee b/src/editor-contents-component.coffee deleted file mode 100644 index cab28f5e0..000000000 --- a/src/editor-contents-component.coffee +++ /dev/null @@ -1,45 +0,0 @@ -{React, div, span} = require 'reactionary' -{last} = require 'underscore-plus' - -module.exports = -React.createClass - render: -> - div class: 'lines', @renderVisibleLines() - - renderVisibleLines: -> - return [] unless @props.lineHeight > 0 - - [startRow, endRow] = @getVisibleRowRange() - for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, key: tokenizedLine.id}) - - getDefaultProps: -> - height: 0 - lineHeight: 0 - scrollTop: 0 - - getVisibleRowRange: -> - heightInLines = @props.height / @props.lineHeight - startRow = Math.floor(@props.scrollTop / @props.lineHeight) - endRow = Math.ceil(startRow + heightInLines) - [startRow, endRow] - - onScreenLinesChanged: ({start, end}) -> - [visibleStart, visibleEnd] = @getVisibleRowRange() - @forceUpdate() unless end < visibleStart or visibleEnd <= start - -LineComponent = React.createClass - render: -> - {tokenizedLine} = @props - div class: 'line', - if tokenizedLine.text.length is 0 - span {}, String.fromCharCode(160) # non-breaking space; bypasses escaping - else - @renderScopeTree(tokenizedLine.getScopeTree()) - - renderScopeTree: (scopeTree) -> - if scopeTree.scope? - span class: scopeTree.scope.split('.').join(' '), - scopeTree.children.map (child) => @renderScopeTree(child) - else - span scopeTree.value diff --git a/src/editor-view.coffee b/src/editor-view.coffee index c3007f096..ab0a37f11 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -4,7 +4,6 @@ GutterView = require './gutter-view' Editor = require './editor' CursorView = require './cursor-view' SelectionView = require './selection-view' -EditorContentsComponent = require './editor-contents-component' fs = require 'fs-plus' _ = require 'underscore-plus' diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index c118c525d..3ae5577fd 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -1,43 +1,18 @@ {View} = require 'space-pen' {$$} = require 'space-pencil' {React} = require 'reactionary' -EditorContentsComponent = require './editor-contents-component' +EditorComponent = require './editor-component' module.exports = class ReactEditorView extends View - @content: -> - @div class: 'editor', => - @div class: 'scroll-view', outlet: 'scrollView' + @content: -> @div class: 'react-wrapper' constructor: (@editor) -> super - @scrollView = @scrollView.element - @contents = React.renderComponent(EditorContentsComponent({@editor}), @scrollView) - @subscribe @editor, 'screen-lines-changed', (change) => @contents.onScreenLinesChanged(change) afterAttach: (onDom) -> return unless onDom + @component = React.renderComponent(EditorComponent({@editor}), @element) - @editor.setVisible(true) - - @measureLineHeight() - @contents.setProps - height: @scrollView.clientHeight - scrollTop: @scrollView.scrollTop - lineHeight: @lineHeight - - setScrollTop: (scrollTop) -> - @contents.setProps({scrollTop}) - - measureLineHeight: -> - fragment = $$ -> - @div class: 'lines', -> - @div class: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' - - @scrollView.appendChild(fragment) - lineRect = fragment.firstChild.getBoundingClientRect() - charRect = fragment.firstChild.firstChild.getBoundingClientRect() - @lineHeight = lineRect.height - @charWidth = charRect.width - @charHeight = charRect.height - @scrollView.removeChild(fragment) + beforeDetach: -> + React.unmountComponentAtNode(@element) diff --git a/static/panes.less b/static/panes.less index 492a2116e..6a29fcb26 100644 --- a/static/panes.less +++ b/static/panes.less @@ -38,7 +38,7 @@ background-color: @pane-item-background-color; } - > * { + > *, > .react-wrapper > * { position: absolute; top: 0; right: 0; From 3c69fd2d49ccde2fe0b70574bfd806e926f94304 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 21:58:14 -0600 Subject: [PATCH 007/179] Handle mouse wheel and make some tweaks to improve scroll performance --- package.json | 2 +- src/editor-component.coffee | 26 +++++++++++++++++--------- static/editor.less | 4 ++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d3df2c153..db8e868c8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "property-accessors": "1.x", "q": "^1.0.1", "random-words": "0.0.1", - "reactionary": "^0.6.0", + "reactionary": "^0.7.0", "runas": "0.5.x", "scandal": "0.15.2", "scoped-property-store": "^0.8.0", diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 5a37e974b..f6df307f2 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -10,7 +10,8 @@ React.createClass render: -> div class: 'editor', div class: 'scroll-view', ref: 'scrollView', - div @renderVisibleLines() + div class: 'overlayer' + @renderVisibleLines() div class: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} @@ -20,11 +21,12 @@ React.createClass lineCount = @props.editor.getScreenLineCount() followingHeight = (lineCount - endRow) * @state.lineHeight - div class: 'lines', ref: 'lines', style: {top: -@state.scrollTop}, - div class: 'spacer', style: {height: precedingHeight} - for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, key: tokenizedLine.id}) - div class: 'spacer', style: {height: followingHeight} + div class: 'lines', ref: 'lines', style: {WebkitTransform: "translateY(#{-@state.scrollTop}px)"}, [ + div class: 'spacer', key: 'top-spacer', style: {height: precedingHeight} + (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({tokenizedLine, key: tokenizedLine.id}))... + div class: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} + ] getInitialState: -> height: 0 @@ -34,10 +36,12 @@ React.createClass componentDidMount: -> @props.editor.on 'screen-lines-changed', @onScreenLinesChanged + @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel @updateAllDimensions() componentWillUnmount: -> @props.editor.off 'screen-lines-changed', @onScreenLinesChanged + @getDOMNode().removeEventListener 'mousewheel', @onMousewheel componentWilUpdate: (nextProps, nextState) -> if nextState.scrollTop? @@ -47,6 +51,10 @@ React.createClass scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop @setState({scrollTop}) + onMousewheel: (event) -> + @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY + event.preventDefault() + onScreenLinesChanged: ({start, end}) => [visibleStart, visibleEnd] = @getVisibleRowRange() @forceUpdate() unless end < visibleStart or visibleEnd <= start @@ -66,8 +74,6 @@ React.createClass lineHeight = @measureLineHeight() {height, width} = @measureScrollViewDimensions() - console.log "updating dimensions", {lineHeight, height, width} - @setState({lineHeight, height, width}) measureScrollViewDimensions: -> @@ -92,6 +98,8 @@ LineComponent = React.createClass renderScopeTree: (scopeTree) -> if scopeTree.scope? span class: scopeTree.scope.split('.').join(' '), - scopeTree.children.map (child) => @renderScopeTree(child) + scopeTree.children.map((child) => @renderScopeTree(child))... else span scopeTree.value + + shouldComponentUpdate: -> false diff --git a/static/editor.less b/static/editor.less index 97a17cd50..4dd99e728 100644 --- a/static/editor.less +++ b/static/editor.less @@ -134,6 +134,10 @@ .editor .overlayer { z-index: 2; position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; } .editor .line { From 33ed4038183fcb5a0dddac887461dec8abb65229 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 27 Mar 2014 22:11:30 -0600 Subject: [PATCH 008/179] Update editor with tokenized lines when it appears on screen. --- src/editor-component.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f6df307f2..8582e3f06 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -38,6 +38,7 @@ React.createClass @props.editor.on 'screen-lines-changed', @onScreenLinesChanged @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel @updateAllDimensions() + @props.editor.setVisible(true) componentWillUnmount: -> @props.editor.off 'screen-lines-changed', @onScreenLinesChanged @@ -55,7 +56,7 @@ React.createClass @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY event.preventDefault() - onScreenLinesChanged: ({start, end}) => + onScreenLinesChanged: ({start, end}) -> [visibleStart, visibleEnd] = @getVisibleRowRange() @forceUpdate() unless end < visibleStart or visibleEnd <= start From 958bc638d70e5a96ef5c3bbcc39d7399785c1c20 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 28 Mar 2014 10:28:06 -0600 Subject: [PATCH 009/179] Improve scrolling performance --- spec/editor-component-spec.coffee | 3 +- spec/tokenized-line-spec.coffee | 19 +++++++++++++ src/editor-component.coffee | 37 +++++++++++++----------- src/tokenized-line.coffee | 47 +++++++++++++++---------------- 4 files changed, 65 insertions(+), 41 deletions(-) create mode 100644 spec/tokenized-line-spec.coffee diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index f7aaa56df..c477801ec 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -25,9 +25,10 @@ describe "EditorComponent", -> expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight + spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() component.onVerticalScroll() - expect(node.querySelector('.lines').offsetTop).toBe -2.5 * lineHeight + expect(node.querySelector('.lines').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeight}px)" lines = node.querySelectorAll('.line') expect(lines.length).toBe 5 diff --git a/spec/tokenized-line-spec.coffee b/spec/tokenized-line-spec.coffee new file mode 100644 index 000000000..12a869857 --- /dev/null +++ b/spec/tokenized-line-spec.coffee @@ -0,0 +1,19 @@ +describe "TokenizedLine", -> + editor = null + + beforeEach -> + waitsForPromise -> atom.packages.activatePackage('language-javascript') + runs -> editor = atom.project.openSync('sample.js') + + describe "::getScopeTree()", -> + it "returns a tree whose inner nodes are scopes and whose leaf nodes are tokens in those scopes", -> + scopeTree = editor.lineForScreenRow(1).getScopeTree() + expect(scopeTree.scope).toBe 'source.js' + expect(scopeTree.children[0].value).toBe ' ' + expect(scopeTree.children[1].scope).toBe 'storage.modifier.js' + expect(scopeTree.children[1].children[0].value).toBe 'var' + expect(scopeTree.children[2].value).toBe ' ' + expect(scopeTree.children[3].scope).toBe 'meta.function.js' + expect(scopeTree.children[4].value).toBe ' ' + expect(scopeTree.children[5].scope).toBe 'meta.brace.curly.js' + expect(scopeTree.children[5].children[0].value).toBe '{' diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 8582e3f06..f260fa250 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -7,6 +7,8 @@ DummyLineNode = $$ -> module.exports = React.createClass + pendingScrollTop: null + render: -> div class: 'editor', div class: 'scroll-view', ref: 'scrollView', @@ -44,13 +46,13 @@ React.createClass @props.editor.off 'screen-lines-changed', @onScreenLinesChanged @getDOMNode().removeEventListener 'mousewheel', @onMousewheel - componentWilUpdate: (nextProps, nextState) -> - if nextState.scrollTop? - @refs.verticalScrollbar.getDOMNode().scrollTop = nextState.scrollTop - onVerticalScroll: -> - scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop - @setState({scrollTop}) + animationFramePending = @pendingScrollTop? + @pendingScrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop + unless animationFramePending + requestAnimationFrame => + @setState({scrollTop: @pendingScrollTop}) + @pendingScrollTop = null onMousewheel: (event) -> @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY @@ -90,17 +92,20 @@ React.createClass LineComponent = React.createClass render: -> - div class: 'line', - if @props.tokenizedLine.text.length is 0 - span String.fromCharCode(160) # non-breaking space; bypasses escaping - else - @renderScopeTree(@props.tokenizedLine.getScopeTree()) + div class: 'line', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} - renderScopeTree: (scopeTree) -> - if scopeTree.scope? - span class: scopeTree.scope.split('.').join(' '), - scopeTree.children.map((child) => @renderScopeTree(child))... + buildInnerHTML: -> + if @props.tokenizedLine.text.length is 0 + " " else - span scopeTree.value + @buildScopeTreeHTML(@props.tokenizedLine.getScopeTree()) + + buildScopeTreeHTML: (scopeTree) -> + if scopeTree.children? + html = "" + html += @buildScopeTreeHTML(child) for child in scopeTree.children + html + else + "#{scopeTree.value}" shouldComponentUpdate: -> false diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index d4a46d018..d57a957ed 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -139,32 +139,31 @@ class TokenizedLine column += token.bufferDelta getScopeTree: -> - @scopeTree ?= new ScopeTree(@tokens) - -class ScopeTree - constructor: (@tokens, @scope, @depth=0) -> - @scope ?= @tokens[0].scopes[@depth] - @children = [] - childDepth = @depth + 1 - currentChildScope = null - currentChildTokens = [] + return @scopeTree if @scopeTree? + scopeStack = [] for token in @tokens - tokenScope = token.scopes[childDepth] + @updateScopeStack(scopeStack, token.scopes) + _.last(scopeStack).children.push(token) - if tokenScope is currentChildScope - currentChildTokens.push(token) - else - if currentChildScope? - @children.push(new ScopeTree(currentChildTokens, currentChildScope, childDepth)) - currentChildScope = null - currentChildTokens = [] + @scopeTree = scopeStack[0] + @updateScopeStack(scopeStack, []) + @scopeTree - if tokenScope? - currentChildScope = tokenScope - currentChildTokens.push(token) - else - @children.push(token) + updateScopeStack: (scopeStack, desiredScopes) -> + # Find a common prefix + for scope, i in desiredScopes + break unless scopeStack[i]?.scope is desiredScopes[i] - if currentChildScope? - @children.push(new ScopeTree(currentChildTokens, currentChildScope, childDepth)) + # Pop scopes until we're at the common prefx + until scopeStack.length is i + poppedScope = scopeStack.pop() + _.last(scopeStack)?.children.push(poppedScope) + + # Push onto common prefix until scopeStack equals desiredScopes + for j in [i...desiredScopes.length] + scopeStack.push(new Scope(scope)) + +class Scope + constructor: (@scope) -> + @children = [] From 2dda577d7ca17f081978b455710ca6cd8fa115b4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 28 Mar 2014 10:51:30 -0600 Subject: [PATCH 010/179] Improve TokenizedLine::getScopeTree specs and fix bug --- spec/tokenized-line-spec.coffee | 25 ++++++++++++++----------- src/tokenized-line.coffee | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/spec/tokenized-line-spec.coffee b/spec/tokenized-line-spec.coffee index 12a869857..9e017c9ec 100644 --- a/spec/tokenized-line-spec.coffee +++ b/spec/tokenized-line-spec.coffee @@ -2,18 +2,21 @@ describe "TokenizedLine", -> editor = null beforeEach -> - waitsForPromise -> atom.packages.activatePackage('language-javascript') - runs -> editor = atom.project.openSync('sample.js') + waitsForPromise -> atom.packages.activatePackage('language-coffee-script') describe "::getScopeTree()", -> it "returns a tree whose inner nodes are scopes and whose leaf nodes are tokens in those scopes", -> + editor = atom.project.openSync('coffee.coffee') + + ensureValidScopeTree = (scopeTree, scopes=[]) -> + if scopeTree.children? + for child in scopeTree.children + ensureValidScopeTree(child, scopes.concat([scopeTree.scope])) + else + expect(scopeTree).toBe tokens[tokenIndex++] + expect(scopes).toEqual scopeTree.scopes + + tokenIndex = 0 + tokens = editor.lineForScreenRow(1).tokens scopeTree = editor.lineForScreenRow(1).getScopeTree() - expect(scopeTree.scope).toBe 'source.js' - expect(scopeTree.children[0].value).toBe ' ' - expect(scopeTree.children[1].scope).toBe 'storage.modifier.js' - expect(scopeTree.children[1].children[0].value).toBe 'var' - expect(scopeTree.children[2].value).toBe ' ' - expect(scopeTree.children[3].scope).toBe 'meta.function.js' - expect(scopeTree.children[4].value).toBe ' ' - expect(scopeTree.children[5].scope).toBe 'meta.brace.curly.js' - expect(scopeTree.children[5].children[0].value).toBe '{' + ensureValidScopeTree(scopeTree) diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index d57a957ed..acf0d2075 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -162,7 +162,7 @@ class TokenizedLine # Push onto common prefix until scopeStack equals desiredScopes for j in [i...desiredScopes.length] - scopeStack.push(new Scope(scope)) + scopeStack.push(new Scope(desiredScopes[j])) class Scope constructor: (@scope) -> From fddd411279f841835e449efe8a73939158c36eb1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 28 Mar 2014 11:01:24 -0600 Subject: [PATCH 011/179] Use className instead of class Might as well go with the flow --- src/editor-component.coffee | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f260fa250..61ce387b0 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -3,18 +3,18 @@ {$$} = require 'space-pencil' DummyLineNode = $$ -> - @div class: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' + @div className: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' module.exports = React.createClass pendingScrollTop: null render: -> - div class: 'editor', - div class: 'scroll-view', ref: 'scrollView', - div class: 'overlayer' + div className: 'editor', + div className: 'scroll-view', ref: 'scrollView', + div className: 'overlayer' @renderVisibleLines() - div class: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, + div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} renderVisibleLines: -> @@ -23,11 +23,11 @@ React.createClass lineCount = @props.editor.getScreenLineCount() followingHeight = (lineCount - endRow) * @state.lineHeight - div class: 'lines', ref: 'lines', style: {WebkitTransform: "translateY(#{-@state.scrollTop}px)"}, [ - div class: 'spacer', key: 'top-spacer', style: {height: precedingHeight} + div className: 'lines', ref: 'lines', style: {WebkitTransform: "translateY(#{-@state.scrollTop}px)"}, [ + div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) LineComponent({tokenizedLine, key: tokenizedLine.id}))... - div class: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} + div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] getInitialState: -> @@ -92,7 +92,7 @@ React.createClass LineComponent = React.createClass render: -> - div class: 'line', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + div className: 'line', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} buildInnerHTML: -> if @props.tokenizedLine.text.length is 0 From c2858fcae26dfd389ecd5a83486ab1bb2663b29a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 28 Mar 2014 11:03:34 -0600 Subject: [PATCH 012/179] Use overflow: hidden for editor --- static/editor.less | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/editor.less b/static/editor.less index 4dd99e728..e0ef83686 100644 --- a/static/editor.less +++ b/static/editor.less @@ -109,8 +109,7 @@ } .editor .scroll-view { - overflow-x: auto; - overflow-y: hidden; + overflow: hidden; -webkit-flex: 1; min-width: 0; position: relative; From e4c1bf10f5a0e0f345957c4fa91255f54b2b98b4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 28 Mar 2014 15:04:05 -0600 Subject: [PATCH 013/179] Handle basic input --- src/editor-component.coffee | 44 ++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 61ce387b0..b06361f2d 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,4 +1,5 @@ -{React, div, span} = require 'reactionary' +punycode = require 'punycode' +{React, div, span, input} = require 'reactionary' {last} = require 'underscore-plus' {$$} = require 'space-pencil' @@ -24,6 +25,7 @@ React.createClass followingHeight = (lineCount - endRow) * @state.lineHeight div className: 'lines', ref: 'lines', style: {WebkitTransform: "translateY(#{-@state.scrollTop}px)"}, [ + InputComponent ref: 'hiddenInput', onInput: @onInput div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) LineComponent({tokenizedLine, key: tokenizedLine.id}))... @@ -41,6 +43,7 @@ React.createClass @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel @updateAllDimensions() @props.editor.setVisible(true) + @refs.hiddenInput.focus() componentWillUnmount: -> @props.editor.off 'screen-lines-changed', @onScreenLinesChanged @@ -58,6 +61,11 @@ React.createClass @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY event.preventDefault() + onInput: (char, replaceLastChar) -> + console.log char, replaceLastChar + + @props.editor.insertText(char) + onScreenLinesChanged: ({start, end}) -> [visibleStart, visibleEnd] = @getVisibleRowRange() @forceUpdate() unless end < visibleStart or visibleEnd <= start @@ -109,3 +117,37 @@ LineComponent = React.createClass "#{scopeTree.value}" shouldComponentUpdate: -> false + +InputComponent = React.createClass + render: -> + input @props.className, ref: 'input' + + getInitialState: -> + {lastChar: ''} + + componentDidMount: -> + @getDOMNode().addEventListener 'input', @onInput + @getDOMNode().addEventListener 'compositionupdate', @onCompositionUpdate + + # Don't let text accumulate in the input forever, but avoid excessive reflows + componentDidUpdate: -> + if @lastValueLength > 500 and not @isPressAndHoldCharacter(@state.lastChar) + @getDOMNode().value = '' + @lastValueLength = 0 + + # This should actually consult the property lists in /System/Library/Input Methods/PressAndHold.app + isPressAndHoldCharacter: (char) -> + @state.lastChar.match /[aeiouAEIOU]/ + + shouldComponentUpdate: -> false + + onInput: (e) -> + valueCharCodes = punycode.ucs2.decode(@getDOMNode().value) + valueLength = valueCharCodes.length + replaceLastChar = valueLength is @lastValueLength + @lastValueLength = valueLength + lastChar = String.fromCharCode(last(valueCharCodes)) + @props.onInput?(lastChar, replaceLastChar) + + focus: -> + @getDOMNode().focus() From 61d9ff4ba4291a665c426bbe7b4dca72e7e0497f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 13:33:20 -0600 Subject: [PATCH 014/179] Put the hidden input in the overlayer The overlayer is absolutely positioned to exactly fill the scroll-view. If we can retain this strategy and never give the input a position that exceeds the bounds of the overlayer, we can guarantee that it never forces the scroll position of the scroll view to change when it is focused due to the browsers default behavior. --- src/editor-component.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index b06361f2d..0c39f7f5d 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -13,7 +13,8 @@ React.createClass render: -> div className: 'editor', div className: 'scroll-view', ref: 'scrollView', - div className: 'overlayer' + div className: 'overlayer', + InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput @renderVisibleLines() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} @@ -25,7 +26,6 @@ React.createClass followingHeight = (lineCount - endRow) * @state.lineHeight div className: 'lines', ref: 'lines', style: {WebkitTransform: "translateY(#{-@state.scrollTop}px)"}, [ - InputComponent ref: 'hiddenInput', onInput: @onInput div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) LineComponent({tokenizedLine, key: tokenizedLine.id}))... @@ -120,7 +120,7 @@ LineComponent = React.createClass InputComponent = React.createClass render: -> - input @props.className, ref: 'input' + input className: @props.className, ref: 'input' getInitialState: -> {lastChar: ''} From 70e5880b1d02be34dfab04e17dd2aaaecbbaaddd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 15:06:38 -0600 Subject: [PATCH 015/179] Start on cursor rendering --- spec/editor-component-spec.coffee | 21 ++++++++--- src/editor-component.coffee | 62 ++++++++++++++++++++++++------- static/editor.less | 10 +++++ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index c477801ec..19e639d1d 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -2,7 +2,7 @@ EditorComponent = require '../src/editor-component' describe "EditorComponent", -> - [editor, component, node, lineHeight] = [] + [editor, component, node, lineHeight, charWidth] = [] beforeEach -> editor = atom.project.openSync('sample.js') @@ -10,10 +10,9 @@ describe "EditorComponent", -> component = React.renderComponent(EditorComponent({editor}), container) node = component.getDOMNode() - fontSize = 20 - lineHeight = 1.3 * fontSize node.style.lineHeight = 1.3 - node.style.fontSize = fontSize + 'px' + node.style.fontSize = '20px' + {lineHeight, charWidth} = component.measureLineDimensions() it "renders only the currently-visible lines", -> node.style.height = 4.5 * lineHeight + 'px' @@ -28,7 +27,7 @@ describe "EditorComponent", -> spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() component.onVerticalScroll() - expect(node.querySelector('.lines').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeight}px)" + expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeight}px)" lines = node.querySelectorAll('.line') expect(lines.length).toBe 5 @@ -38,3 +37,15 @@ describe "EditorComponent", -> spacers = node.querySelectorAll('.spacer') expect(spacers[0].offsetHeight).toBe 2 * lineHeight expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeight + + it "renders the currently visible selections", -> + editor.setCursorScreenPosition([0, 5]) + + node.style.height = 4.5 * lineHeight + 'px' + component.updateAllDimensions() + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes[0].offsetHeight).toBe lineHeight + expect(cursorNodes[0].offsetWidth).toBe charWidth + expect(cursorNodes[0].offsetTop).toBe 0 + expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 0c39f7f5d..19bfead6f 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -10,22 +10,37 @@ module.exports = React.createClass pendingScrollTop: null + statics: {DummyLineNode} + render: -> div className: 'editor', div className: 'scroll-view', ref: 'scrollView', - div className: 'overlayer', - InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput - @renderVisibleLines() + InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput + @renderScrollableContent() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} + renderScrollableContent: -> + height = @props.editor.getScreenLineCount() * @state.lineHeight + WebkitTransform = "translateY(#{-@state.scrollTop}px)" + + div className: 'scrollable-content', style: {height, WebkitTransform}, + @renderOverlayer() + @renderVisibleLines() + + renderOverlayer: -> + {lineHeight, charWidth} = @state + + div className: 'overlayer', + for selection in @props.editor.getSelections() + SelectionComponent({selection, lineHeight, charWidth}) + renderVisibleLines: -> [startRow, endRow] = @getVisibleRowRange() precedingHeight = startRow * @state.lineHeight - lineCount = @props.editor.getScreenLineCount() - followingHeight = (lineCount - endRow) * @state.lineHeight + followingHeight = (@props.editor.getScreenLineCount() - endRow) * @state.lineHeight - div className: 'lines', ref: 'lines', style: {WebkitTransform: "translateY(#{-@state.scrollTop}px)"}, [ + div className: 'lines', ref: 'lines', [ div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) LineComponent({tokenizedLine, key: tokenizedLine.id}))... @@ -62,8 +77,6 @@ React.createClass event.preventDefault() onInput: (char, replaceLastChar) -> - console.log char, replaceLastChar - @props.editor.insertText(char) onScreenLinesChanged: ({start, end}) -> @@ -82,21 +95,21 @@ React.createClass @props.editor.getLineCount() * @state.lineHeight updateAllDimensions: -> - lineHeight = @measureLineHeight() {height, width} = @measureScrollViewDimensions() - - @setState({lineHeight, height, width}) + {lineHeight, charWidth} = @measureLineDimensions() + @setState({height, width, lineHeight, charWidth}) measureScrollViewDimensions: -> scrollViewNode = @refs.scrollView.getDOMNode() {height: scrollViewNode.clientHeight, width: scrollViewNode.clientWidth} - measureLineHeight: -> + measureLineDimensions: -> linesNode = @refs.lines.getDOMNode() linesNode.appendChild(DummyLineNode) lineHeight = DummyLineNode.getBoundingClientRect().height + charWidth = DummyLineNode.firstChild.getBoundingClientRect().width linesNode.removeChild(DummyLineNode) - lineHeight + {lineHeight, charWidth} LineComponent = React.createClass render: -> @@ -151,3 +164,26 @@ InputComponent = React.createClass focus: -> @getDOMNode().focus() + +SelectionComponent = React.createClass + render: -> + console.log "render selection component" + + {selection, lineHeight, charWidth} = @props + {cursor} = selection + div className: 'selection', + CursorComponent({cursor, lineHeight, charWidth}) + +CursorComponent = React.createClass + render: -> + {cursor, lineHeight, charWidth} = @props + {row, column} = cursor.getScreenPosition() + + console.log "char width", charWidth + + div className: 'cursor', style: { + height: lineHeight, + width: charWidth + top: row * lineHeight + left: column * charWidth + } diff --git a/static/editor.less b/static/editor.less index e0ef83686..ea5c932fd 100644 --- a/static/editor.less +++ b/static/editor.less @@ -200,3 +200,13 @@ color: @text-color-subtle; } } + +.react-wrapper > .editor { + .scrollable-content { + position: relative; + } + + .cursor { + visibility: visible; + } +} From 9c2d3213270585db53f4c12fc02f3adb8d321553 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 15:16:12 -0600 Subject: [PATCH 016/179] Break out selection, cursor, and input components --- src/cursor-component.coffee | 14 ++++++ src/editor-component.coffee | 79 ++++++++-------------------------- src/input-component.coffee | 38 ++++++++++++++++ src/selection-component.coffee | 12 ++++++ 4 files changed, 82 insertions(+), 61 deletions(-) create mode 100644 src/cursor-component.coffee create mode 100644 src/input-component.coffee create mode 100644 src/selection-component.coffee diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee new file mode 100644 index 000000000..8f60c575b --- /dev/null +++ b/src/cursor-component.coffee @@ -0,0 +1,14 @@ +{React, div} = require 'reactionary' + +module.exports = +CursorComponent = React.createClass + render: -> + {cursor, lineHeight, charWidth} = @props + {row, column} = cursor.getScreenPosition() + + div className: 'cursor', style: { + height: lineHeight, + width: charWidth + top: row * lineHeight + left: column * charWidth + } diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 19bfead6f..4b95d84e6 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,17 +1,21 @@ -punycode = require 'punycode' -{React, div, span, input} = require 'reactionary' -{last} = require 'underscore-plus' +{React, div, span} = require 'reactionary' {$$} = require 'space-pencil' +SelectionComponent = require './selection-component' +InputComponent = require './input-component' +CustomEventMixin = require './custom-event-mixin' + DummyLineNode = $$ -> @div className: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' module.exports = -React.createClass +EditorCompont = React.createClass pendingScrollTop: null statics: {DummyLineNode} + mixins: [CustomEventMixin] + render: -> div className: 'editor', div className: 'scroll-view', ref: 'scrollView', @@ -54,6 +58,7 @@ React.createClass scrollTop: 0 componentDidMount: -> + @listenForCustomEvents() @props.editor.on 'screen-lines-changed', @onScreenLinesChanged @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel @updateAllDimensions() @@ -64,6 +69,15 @@ React.createClass @props.editor.off 'screen-lines-changed', @onScreenLinesChanged @getDOMNode().removeEventListener 'mousewheel', @onMousewheel + listenForCustomEvents: -> + {editor} = @props + + @addCustomEventListeners + 'core:move-left': => editor.moveCursorLeft() + 'core:move-right': => editor.moveCursorRight() + 'core:move-up': => editor.moveCursorUp() + 'core:move-down': => editor.moveCursorDown() + onVerticalScroll: -> animationFramePending = @pendingScrollTop? @pendingScrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop @@ -130,60 +144,3 @@ LineComponent = React.createClass "#{scopeTree.value}" shouldComponentUpdate: -> false - -InputComponent = React.createClass - render: -> - input className: @props.className, ref: 'input' - - getInitialState: -> - {lastChar: ''} - - componentDidMount: -> - @getDOMNode().addEventListener 'input', @onInput - @getDOMNode().addEventListener 'compositionupdate', @onCompositionUpdate - - # Don't let text accumulate in the input forever, but avoid excessive reflows - componentDidUpdate: -> - if @lastValueLength > 500 and not @isPressAndHoldCharacter(@state.lastChar) - @getDOMNode().value = '' - @lastValueLength = 0 - - # This should actually consult the property lists in /System/Library/Input Methods/PressAndHold.app - isPressAndHoldCharacter: (char) -> - @state.lastChar.match /[aeiouAEIOU]/ - - shouldComponentUpdate: -> false - - onInput: (e) -> - valueCharCodes = punycode.ucs2.decode(@getDOMNode().value) - valueLength = valueCharCodes.length - replaceLastChar = valueLength is @lastValueLength - @lastValueLength = valueLength - lastChar = String.fromCharCode(last(valueCharCodes)) - @props.onInput?(lastChar, replaceLastChar) - - focus: -> - @getDOMNode().focus() - -SelectionComponent = React.createClass - render: -> - console.log "render selection component" - - {selection, lineHeight, charWidth} = @props - {cursor} = selection - div className: 'selection', - CursorComponent({cursor, lineHeight, charWidth}) - -CursorComponent = React.createClass - render: -> - {cursor, lineHeight, charWidth} = @props - {row, column} = cursor.getScreenPosition() - - console.log "char width", charWidth - - div className: 'cursor', style: { - height: lineHeight, - width: charWidth - top: row * lineHeight - left: column * charWidth - } diff --git a/src/input-component.coffee b/src/input-component.coffee new file mode 100644 index 000000000..79f52d920 --- /dev/null +++ b/src/input-component.coffee @@ -0,0 +1,38 @@ +punycode = require 'punycode' +{last} = require 'underscore-plus' +{React, input} = require 'reactionary' + +module.exports = +InputComponent = React.createClass + render: -> + input className: @props.className, ref: 'input' + + getInitialState: -> + {lastChar: ''} + + componentDidMount: -> + @getDOMNode().addEventListener 'input', @onInput + @getDOMNode().addEventListener 'compositionupdate', @onCompositionUpdate + + # Don't let text accumulate in the input forever, but avoid excessive reflows + componentDidUpdate: -> + if @lastValueLength > 500 and not @isPressAndHoldCharacter(@state.lastChar) + @getDOMNode().value = '' + @lastValueLength = 0 + + # This should actually consult the property lists in /System/Library/Input Methods/PressAndHold.app + isPressAndHoldCharacter: (char) -> + @state.lastChar.match /[aeiouAEIOU]/ + + shouldComponentUpdate: -> false + + onInput: (e) -> + valueCharCodes = punycode.ucs2.decode(@getDOMNode().value) + valueLength = valueCharCodes.length + replaceLastChar = valueLength is @lastValueLength + @lastValueLength = valueLength + lastChar = String.fromCharCode(last(valueCharCodes)) + @props.onInput?(lastChar, replaceLastChar) + + focus: -> + @getDOMNode().focus() diff --git a/src/selection-component.coffee b/src/selection-component.coffee new file mode 100644 index 000000000..c3ebd460e --- /dev/null +++ b/src/selection-component.coffee @@ -0,0 +1,12 @@ +{React, div} = require 'reactionary' +CursorComponent = require './cursor-component' + +module.exports = +SelectionComponent = React.createClass + render: -> + console.log "render selection component" + + {selection, lineHeight, charWidth} = @props + {cursor} = selection + div className: 'selection', + CursorComponent({cursor, lineHeight, charWidth}) From c44fd62eb1889caae040adc95afc3bf036394049 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 16:35:26 -0600 Subject: [PATCH 017/179] Update cursor view when selection screen position changes --- src/custom-event-mixin.coffee | 15 +++++++++++++++ src/selection-component.coffee | 11 +++++++++-- src/subscriber-mixin.coffee | 4 ++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 src/custom-event-mixin.coffee create mode 100644 src/subscriber-mixin.coffee diff --git a/src/custom-event-mixin.coffee b/src/custom-event-mixin.coffee new file mode 100644 index 000000000..1a3bb4d88 --- /dev/null +++ b/src/custom-event-mixin.coffee @@ -0,0 +1,15 @@ +module.exports = +CustomEventMixin = + componentWillMount: -> + @customEventListeners = {} + + componentWillUnmount: -> + for name, listeners in @customEventListeners + for listener in listeners + @getDOMNode().removeEventListener(name, listener) + + addCustomEventListeners: (customEventListeners) -> + for name, listener of customEventListeners + @customEventListeners[name] ?= [] + @customEventListeners[name].push(listener) + @getDOMNode().addEventListener(name, listener) diff --git a/src/selection-component.coffee b/src/selection-component.coffee index c3ebd460e..15a374840 100644 --- a/src/selection-component.coffee +++ b/src/selection-component.coffee @@ -1,12 +1,19 @@ {React, div} = require 'reactionary' +SubscriberMixin = require './subscriber-mixin' CursorComponent = require './cursor-component' module.exports = SelectionComponent = React.createClass - render: -> - console.log "render selection component" + mixins: [SubscriberMixin] + render: -> {selection, lineHeight, charWidth} = @props {cursor} = selection div className: 'selection', CursorComponent({cursor, lineHeight, charWidth}) + + componentDidMount: -> + @subscribe @props.selection, 'screen-range-changed', => @forceUpdate() + + componentWillUnmount: -> + @unsubscribe() diff --git a/src/subscriber-mixin.coffee b/src/subscriber-mixin.coffee new file mode 100644 index 000000000..b6817ce53 --- /dev/null +++ b/src/subscriber-mixin.coffee @@ -0,0 +1,4 @@ +{Subscriber} = require 'emissary' +SubscriberMixin = componentDidUnmount: -> @unsubscribe() +Subscriber.extend(SubscriberMixin) +module.exports = SubscriberMixin From e365e51a2bac05beaa4f1614420475fef84ac803 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 17:06:59 -0600 Subject: [PATCH 018/179] Render all visible cursors --- spec/editor-component-spec.coffee | 29 ++++++++++++++++++++++++++--- src/editor-component.coffee | 24 ++++++++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 19e639d1d..b89b782e5 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -5,6 +5,8 @@ describe "EditorComponent", -> [editor, component, node, lineHeight, charWidth] = [] beforeEach -> + spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() + editor = atom.project.openSync('sample.js') container = document.querySelector('#jasmine-content') component = React.renderComponent(EditorComponent({editor}), container) @@ -24,7 +26,6 @@ describe "EditorComponent", -> expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight - spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() component.onVerticalScroll() expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeight}px)" @@ -38,14 +39,36 @@ describe "EditorComponent", -> expect(spacers[0].offsetHeight).toBe 2 * lineHeight expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeight - it "renders the currently visible selections", -> - editor.setCursorScreenPosition([0, 5]) + it "renders the currently visible cursors", -> + cursor1 = editor.getCursor() + cursor1.setScreenPosition([0, 5]) node.style.height = 4.5 * lineHeight + 'px' component.updateAllDimensions() cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 1 expect(cursorNodes[0].offsetHeight).toBe lineHeight expect(cursorNodes[0].offsetWidth).toBe charWidth expect(cursorNodes[0].offsetTop).toBe 0 expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth + + cursor3 = editor.addCursorAtScreenPosition([6, 11]) + cursor2 = editor.addCursorAtScreenPosition([4, 10]) + + 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 * lineHeight + expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth + + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight + component.onVerticalScroll() + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 2 + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeight + expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + expect(cursorNodes[1].offsetTop).toBe 4 * lineHeight + expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 4b95d84e6..b1b768709 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -4,6 +4,7 @@ SelectionComponent = require './selection-component' InputComponent = require './input-component' CustomEventMixin = require './custom-event-mixin' +SubscriberMixin = require './subscriber-mixin' DummyLineNode = $$ -> @div className: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' @@ -14,7 +15,7 @@ EditorCompont = React.createClass statics: {DummyLineNode} - mixins: [CustomEventMixin] + mixins: [CustomEventMixin, SubscriberMixin] render: -> div className: 'editor', @@ -36,7 +37,7 @@ EditorCompont = React.createClass {lineHeight, charWidth} = @state div className: 'overlayer', - for selection in @props.editor.getSelections() + for selection in @props.editor.getSelections() when @selectionIntersectsVisibleRowRange(selection) SelectionComponent({selection, lineHeight, charWidth}) renderVisibleLines: -> @@ -59,8 +60,12 @@ EditorCompont = React.createClass componentDidMount: -> @listenForCustomEvents() - @props.editor.on 'screen-lines-changed', @onScreenLinesChanged @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel + + {editor} = @props + @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged + @subscribe editor, 'selection-added', @onSelectionAdded + @updateAllDimensions() @props.editor.setVisible(true) @refs.hiddenInput.focus() @@ -95,7 +100,10 @@ EditorCompont = React.createClass onScreenLinesChanged: ({start, end}) -> [visibleStart, visibleEnd] = @getVisibleRowRange() - @forceUpdate() unless end < visibleStart or visibleEnd <= start + @forceUpdate() if @intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events + + onSelectionAdded: (selection) -> + @forceUpdate() if @selectionIntersectsVisibleRowRange(selection) getVisibleRowRange: -> return [0, 0] unless @state.lineHeight > 0 @@ -105,6 +113,14 @@ EditorCompont = React.createClass endRow = Math.ceil(startRow + heightInLines) [startRow, endRow] + intersectsVisibleRowRange: (startRow, endRow) -> + [visibleStart, visibleEnd] = @getVisibleRowRange() + not (endRow <= visibleStart or visibleEnd <= startRow) + + selectionIntersectsVisibleRowRange: (selection) -> + {start, end} = selection.getScreenRange() + @intersectsVisibleRowRange(start.row, end.row + 1) + getScrollHeight: -> @props.editor.getLineCount() * @state.lineHeight From a55c32922636139634c50ff677717c1198a63b84 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 17:10:36 -0600 Subject: [PATCH 019/179] :lipstick: --- src/editor-component.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index b1b768709..3a6c5b46a 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -71,7 +71,6 @@ EditorCompont = React.createClass @refs.hiddenInput.focus() componentWillUnmount: -> - @props.editor.off 'screen-lines-changed', @onScreenLinesChanged @getDOMNode().removeEventListener 'mousewheel', @onMousewheel listenForCustomEvents: -> From c4fdb54650e7c3e3dafcf17d7ff103ede6672fa0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 17:11:10 -0600 Subject: [PATCH 020/179] Update editor component when a visible selection is removed --- spec/editor-component-spec.coffee | 10 ++++++++-- src/editor-component.coffee | 4 ++++ src/editor.coffee | 1 + 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index b89b782e5..437f7c913 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -53,8 +53,8 @@ describe "EditorComponent", -> expect(cursorNodes[0].offsetTop).toBe 0 expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth - cursor3 = editor.addCursorAtScreenPosition([6, 11]) - cursor2 = editor.addCursorAtScreenPosition([4, 10]) + cursor2 = editor.addCursorAtScreenPosition([6, 11]) + cursor3 = editor.addCursorAtScreenPosition([4, 10]) cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 2 @@ -72,3 +72,9 @@ describe "EditorComponent", -> expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth expect(cursorNodes[1].offsetTop).toBe 4 * lineHeight expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth + + cursor3.destroy() + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 1 + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeight + expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 3a6c5b46a..bba079a3f 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -65,6 +65,7 @@ EditorCompont = React.createClass {editor} = @props @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged @subscribe editor, 'selection-added', @onSelectionAdded + @subscribe editor, 'selection-removed', @onSelectionAdded @updateAllDimensions() @props.editor.setVisible(true) @@ -104,6 +105,9 @@ EditorCompont = React.createClass onSelectionAdded: (selection) -> @forceUpdate() if @selectionIntersectsVisibleRowRange(selection) + onSelectionRemoved: (selection) -> + @forceUpdate() if @selectionIntersectsVisibleRowRange(selection) + getVisibleRowRange: -> return [0, 0] unless @state.lineHeight > 0 diff --git a/src/editor.coffee b/src/editor.coffee index 24aa7873a..8c389b312 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1202,6 +1202,7 @@ class Editor extends Model # Remove the given selection. removeSelection: (selection) -> _.remove(@selections, selection) + @emit 'selection-removed', selection # Reduce one or more selections to a single empty selection based on the most # recently added cursor. From 6327094696b6d936d337f540533d4c6af26654c5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 17:22:24 -0600 Subject: [PATCH 021/179] Transfer focus from editor component to its hidden input --- spec/editor-component-spec.coffee | 5 +++++ src/editor-component.coffee | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 437f7c913..86e350f4f 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -78,3 +78,8 @@ describe "EditorComponent", -> expect(cursorNodes.length).toBe 1 expect(cursorNodes[0].offsetTop).toBe 6 * lineHeight expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + + it "transfers focus to the hidden input", -> + expect(document.activeElement).toBe document.body + node.focus() + expect(document.activeElement).toBe node.querySelector('.hidden-input') diff --git a/src/editor-component.coffee b/src/editor-component.coffee index bba079a3f..166ab4886 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -18,7 +18,7 @@ EditorCompont = React.createClass mixins: [CustomEventMixin, SubscriberMixin] render: -> - div className: 'editor', + div className: 'editor', tabIndex: -1, div className: 'scroll-view', ref: 'scrollView', InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput @renderScrollableContent() @@ -61,6 +61,7 @@ EditorCompont = React.createClass componentDidMount: -> @listenForCustomEvents() @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel + @getDOMNode().addEventListener 'focus', @onFocus {editor} = @props @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged @@ -69,7 +70,6 @@ EditorCompont = React.createClass @updateAllDimensions() @props.editor.setVisible(true) - @refs.hiddenInput.focus() componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMousewheel @@ -83,6 +83,9 @@ EditorCompont = React.createClass 'core:move-up': => editor.moveCursorUp() 'core:move-down': => editor.moveCursorDown() + onFocus: -> + @refs.hiddenInput.focus() + onVerticalScroll: -> animationFramePending = @pendingScrollTop? @pendingScrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop From 48e2302ccb67e3121032862147426edc7567545e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 1 Apr 2014 17:53:55 -0600 Subject: [PATCH 022/179] Handle almost all editor commands in EditorComponent --- src/editor-component.coffee | 94 +++++++++++++++++++++++++++++++++++-- src/editor-view.coffee | 3 +- src/editor.coffee | 18 +++++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 166ab4886..6b201a779 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -75,13 +75,101 @@ EditorCompont = React.createClass @getDOMNode().removeEventListener 'mousewheel', @onMousewheel listenForCustomEvents: -> - {editor} = @props + {editor, mini} = @props @addCustomEventListeners 'core:move-left': => editor.moveCursorLeft() 'core:move-right': => editor.moveCursorRight() - 'core:move-up': => editor.moveCursorUp() - 'core:move-down': => editor.moveCursorDown() + 'core:select-left': => editor.selectLeft() + 'core:select-right': => editor.selectRight() + 'core:select-all': => editor.selectAll() + 'core:backspace': => editor.backspace() + 'core:delete': => editor.delete() + 'core:undo': => editor.undo() + 'core:redo': => editor.redo() + 'core:cut': => editor.cutSelectedText() + 'core:copy': => editor.copySelectedText() + 'core:paste': => editor.pasteText() + 'editor:move-to-previous-word': => editor.moveCursorToPreviousWord() + 'editor:select-word': => editor.selectWord() + # 'editor:consolidate-selections': (event) => @consolidateSelections(event) + 'editor:backspace-to-beginning-of-word': => editor.backspaceToBeginningOfWord() + 'editor:backspace-to-beginning-of-line': => editor.backspaceToBeginningOfLine() + 'editor:delete-to-end-of-word': => editor.deleteToEndOfWord() + 'editor:delete-line': => editor.deleteLine() + 'editor:cut-to-end-of-line': => editor.cutToEndOfLine() + 'editor:move-to-beginning-of-screen-line': => editor.moveCursorToBeginningOfScreenLine() + 'editor:move-to-beginning-of-line': => editor.moveCursorToBeginningOfLine() + 'editor:move-to-end-of-screen-line': => editor.moveCursorToEndOfScreenLine() + 'editor:move-to-end-of-line': => editor.moveCursorToEndOfLine() + 'editor:move-to-first-character-of-line': => editor.moveCursorToFirstCharacterOfLine() + 'editor:move-to-beginning-of-word': => editor.moveCursorToBeginningOfWord() + 'editor:move-to-end-of-word': => editor.moveCursorToEndOfWord() + 'editor:move-to-beginning-of-next-word': => editor.moveCursorToBeginningOfNextWord() + 'editor:move-to-previous-word-boundary': => editor.moveCursorToPreviousWordBoundary() + 'editor:move-to-next-word-boundary': => editor.moveCursorToNextWordBoundary() + 'editor:select-to-end-of-line': => editor.selectToEndOfLine() + 'editor:select-to-beginning-of-line': => editor.selectToBeginningOfLine() + 'editor:select-to-end-of-word': => editor.selectToEndOfWord() + 'editor:select-to-beginning-of-word': => editor.selectToBeginningOfWord() + 'editor:select-to-beginning-of-next-word': => editor.selectToBeginningOfNextWord() + 'editor:select-to-next-word-boundary': => editor.selectToNextWordBoundary() + 'editor:select-to-previous-word-boundary': => editor.selectToPreviousWordBoundary() + 'editor:select-to-first-character-of-line': => editor.selectToFirstCharacterOfLine() + 'editor:select-line': => editor.selectLine() + 'editor:transpose': => editor.transpose() + 'editor:upper-case': => editor.upperCase() + 'editor:lower-case': => editor.lowerCase() + + unless mini + @addCustomEventListeners + 'core:move-up': => editor.moveCursorUp() + 'core:move-down': => editor.moveCursorDown() + 'core:move-to-top': => editor.moveCursorToTop() + 'core:move-to-bottom': => editor.moveCursorToBottom() + 'core:select-up': => editor.selectUp() + 'core:select-down': => editor.selectDown() + 'core:select-to-top': => editor.selectToTop() + 'core:select-to-bottom': => editor.selectToBottom() + 'editor:indent': => editor.indent() + 'editor:auto-indent': => editor.autoIndentSelectedRows() + 'editor:indent-selected-rows': => editor.indentSelectedRows() + 'editor:outdent-selected-rows': => editor.outdentSelectedRows() + 'editor:newline': => editor.insertNewline() + 'editor:newline-below': => editor.insertNewlineBelow() + 'editor:newline-above': => editor.insertNewlineAbove() + 'editor:add-selection-below': => editor.addSelectionBelow() + 'editor:add-selection-above': => editor.addSelectionAbove() + 'editor:split-selections-into-lines': => editor.splitSelectionsIntoLines() + 'editor:toggle-soft-tabs': => editor.toggleSoftTabs() + 'editor:toggle-soft-wrap': => editor.toggleSoftWrap() + 'editor:fold-all': => editor.foldAll() + 'editor:unfold-all': => editor.unfoldAll() + 'editor:fold-current-row': => editor.foldCurrentRow() + 'editor:unfold-current-row': => editor.unfoldCurrentRow() + 'editor:fold-selection': => neditor.foldSelectedLines() + 'editor:fold-at-indent-level-1': => editor.foldAllAtIndentLevel(0) + 'editor:fold-at-indent-level-2': => editor.foldAllAtIndentLevel(1) + 'editor:fold-at-indent-level-3': => editor.foldAllAtIndentLevel(2) + 'editor:fold-at-indent-level-4': => editor.foldAllAtIndentLevel(3) + 'editor:fold-at-indent-level-5': => editor.foldAllAtIndentLevel(4) + 'editor:fold-at-indent-level-6': => editor.foldAllAtIndentLevel(5) + 'editor:fold-at-indent-level-7': => editor.foldAllAtIndentLevel(6) + 'editor:fold-at-indent-level-8': => editor.foldAllAtIndentLevel(7) + 'editor:fold-at-indent-level-9': => editor.foldAllAtIndentLevel(8) + 'editor:toggle-line-comments': => editor.toggleLineCommentsInSelection() + 'editor:log-cursor-scope': => editor.logCursorScope() + 'editor:checkout-head-revision': => editor.checkoutHead() + 'editor:copy-path': => editor.copyPathToClipboard() + 'editor:move-line-up': => editor.moveLineUp() + 'editor:move-line-down': => editor.moveLineDown() + 'editor:duplicate-lines': => editor.duplicateLines() + 'editor:join-lines': => editor.joinLines() + 'editor:toggle-indent-guide': => atom.config.toggle('editor.showIndentGuide') + 'editor:toggle-line-numbers': => atom.config.toggle('editor.showLineNumbers') + # 'core:page-down': => @pageDown() + # 'core:page-up': => @pageUp() + # 'editor:scroll-to-cursor': => @scrollToCursorPosition() onFocus: -> @refs.hiddenInput.focus() diff --git a/src/editor-view.coffee b/src/editor-view.coffee index ab0a37f11..975906941 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -335,8 +335,7 @@ class EditorView extends View # Checkout the HEAD revision of this editor's file. checkoutHead: -> - if path = @editor.getPath() - atom.project.getRepo()?.checkoutHead(path) + @editor.checkoutHead() configure: -> @subscribe atom.config.observe 'editor.showLineNumbers', (showLineNumbers) => @gutter.setShowLineNumbers(showLineNumbers) diff --git a/src/editor.coffee b/src/editor.coffee index 8c389b312..868b7f61b 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -301,6 +301,9 @@ class Editor extends Model # softTabs - A {Boolean} setSoftTabs: (@softTabs) -> @softTabs + # Public: Toggle soft tabs for this editor + toggleSoftTabs: -> @setSoftTabs(not @getSoftTabs()) + # Public: Get whether soft wrap is enabled for this editor. getSoftWrap: -> @displayBuffer.getSoftWrap() @@ -309,6 +312,9 @@ class Editor extends Model # softWrap - A {Boolean} setSoftWrap: (softWrap) -> @displayBuffer.setSoftWrap(softWrap) + # Public: Toggle soft wrap for this editor + toggleSoftWrap: -> @setSoftWrap(not @getSoftWrap()) + # Public: Get the text representing a single level of indent. # # If soft tabs are enabled, the text is composed of N spaces, where N is the @@ -421,6 +427,15 @@ class Editor extends Model # filePath - A {String} path. saveAs: (filePath) -> @buffer.saveAs(filePath) + checkoutHead: -> + if path = @getPath() + atom.project.getRepo()?.checkoutHead(path) + + # Copies the current file path to the native clipboard. + copyPathToClipboard: -> + path = @getPath() + atom.clipboard.write(path) if path? + # Public: Returns the {String} path of this editor's text buffer. getPath: -> @buffer.getPath() @@ -599,6 +614,9 @@ class Editor extends Model # Returns an {Array} of {String}s. getCursorScopes: -> @getCursor().getScopes() + logCursorScope: -> + console.log @getCursorScopes() + # Public: For each selection, replace the selected text with the given text. # # text - A {String} representing the text to insert. From 148a9f0248c4bc262651a5c3b5f339874347010e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 05:58:44 -0600 Subject: [PATCH 023/179] Add DisplayBuffer::pixelPositionForScreenPosition This bakes character width tracking into display buffer directly, which moves us toward a world where all rendering decisions can be made in the model to strictly control DOM reads. --- spec/display-buffer-spec.coffee | 12 ++++++++ src/display-buffer-marker.coffee | 3 ++ src/display-buffer.coffee | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index d21b58536..7ecf042f5 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -943,3 +943,15 @@ describe "DisplayBuffer", -> expect(displayBuffer.getMarkerCount()).toBe initialMarkerCount + 2 expect(marker1.getAttributes()).toEqual a: 1, b: 2 expect(marker2.getAttributes()).toEqual a: 1, b: 3 + + describe "DisplayBufferMarker::getPixelRange()", -> + it "returns the start and end positions of the marker based on the line height and character widths assigned to the DisplayBuffer", -> + marker = displayBuffer.markScreenRange([[5, 10], [6, 4]]) + + displayBuffer.setLineHeight(20) + displayBuffer.setDefaultCharWidth(10) + displayBuffer.setScopedCharWidths(["source.js", "keyword.control.js"], r: 11, e: 11, t: 11, u: 11, r: 11, n: 11) + + {start, end} = marker.getPixelRange() + expect(start.top).toBe 5 * 20 + expect(start.left).toBe (4 * 10) + (6 * 11) diff --git a/src/display-buffer-marker.coffee b/src/display-buffer-marker.coffee index e01b571a9..c3a902b99 100644 --- a/src/display-buffer-marker.coffee +++ b/src/display-buffer-marker.coffee @@ -54,6 +54,9 @@ class DisplayBufferMarker setBufferRange: (bufferRange, options) -> @bufferMarker.setRange(bufferRange, options) + getPixelRange: -> + @displayBuffer.pixelRangeForScreenRange(@getScreenRange(), false) + # Retrieves the screen position of the marker's head. # # Returns a {Point}. diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 19bbb334f..30108a74b 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -23,11 +23,15 @@ class DisplayBuffer extends Model softWrap: null editorWidthInChars: null + lineHeight: null + defaultCharWidth: null + constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer}={}) -> super @softWrap ?= atom.config.get('editor.softWrap') ? false @tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer}) @buffer = @tokenizedBuffer.buffer + @charWidthsByScope = {} @markers = {} @foldsByMarkerId = {} @updateAllScreenLines() @@ -273,6 +277,29 @@ class DisplayBuffer extends Model end = @bufferPositionForScreenPosition(screenRange.end) new Range(start, end) + pixelRangeForScreenRange: (screenRange, clip=true) -> + {start, end} = Range.fromObject(screenRange) + {start: @pixelPositionForScreenPosition(start, clip), end: @pixelPositionForScreenPosition(end, clip)} + + pixelPositionForScreenPosition: (screenPosition, clip=true) -> + screenPosition = Point.fromObject(screenPosition) + screenPosition = @clipScreenPosition(screenPosition) if clip + + targetRow = screenPosition.row + targetColumn = screenPosition.column + defaultCharWidth = @defaultCharWidth + + top = targetRow * @lineHeight + left = 0 + column = 0 + for token in @lineForRow(targetRow).tokens + charWidths = @getScopedCharWidths(token.scopes) + for char in token.value + return {top, left} if column is targetColumn + left += charWidths[char] ? defaultCharWidth + column++ + {top, left} + # Gets the number of screen lines. # # Returns a {Number}. @@ -370,6 +397,29 @@ class DisplayBuffer extends Model setTabLength: (tabLength) -> @tokenizedBuffer.setTabLength(tabLength) + getLineHeight: -> @lineHeight + + setLineHeight: (@lineHeight) -> + + setDefaultCharWidth: (@defaultCharWidth) -> + + getScopedCharWidth: (scopeNames, char) -> + @getScopedCharWidths(scopeNames)[char] + + getScopedCharWidths: (scopeNames) -> + scope = @charWidthsByScope + for scopeName in scopeNames + scope[scopeName] ?= {} + scope = scope[scopeName] + scope.charWidths ?= {} + scope.charWidths + + setScopedCharWidth: (scopeNames, char, width) -> + @getScopedCharWidths(scopeNames)[char] = width + + setScopedCharWidths: (scopeNames, charWidths) -> + _.extend(@getScopedCharWidths(scopeNames), charWidths) + # Get the grammar for this buffer. # # Returns the current {Grammar} or the {NullGrammar}. From 53cc5c9856d0e09d473be902262e5cb547e4a1b4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 10:59:57 -0600 Subject: [PATCH 024/179] Base cursor x position on char widths stored in DisplayBuffer Whenever new lines are added to the screen, we measure and store any unseen scope/character combinations in the DisplayBuffer. --- spec/editor-component-spec.coffee | 61 +++++++++++------ src/cursor-component.coffee | 11 +--- src/cursor.coffee | 8 +++ src/display-buffer.coffee | 3 + src/editor-component.coffee | 105 ++++++++++++++++++++++++------ src/editor-view.coffee | 1 + src/editor.coffee | 16 +++++ src/react-editor-view.coffee | 1 - src/selection-component.coffee | 4 +- 9 files changed, 156 insertions(+), 54 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 86e350f4f..51afa07bf 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -2,22 +2,25 @@ EditorComponent = require '../src/editor-component' describe "EditorComponent", -> - [editor, component, node, lineHeight, charWidth] = [] + [editor, component, node, lineHeightInPixels, charWidth] = [] beforeEach -> - spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() + waitsForPromise -> + atom.packages.activatePackage('language-javascript') - editor = atom.project.openSync('sample.js') - container = document.querySelector('#jasmine-content') - component = React.renderComponent(EditorComponent({editor}), container) - node = component.getDOMNode() + runs -> + spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() - node.style.lineHeight = 1.3 - node.style.fontSize = '20px' - {lineHeight, charWidth} = component.measureLineDimensions() + editor = atom.project.openSync('sample.js') + container = document.querySelector('#jasmine-content') + component = React.renderComponent(EditorComponent({editor}), container) + component.setLineHeight(1.3) + component.setFontSize(20) + {lineHeightInPixels, charWidth} = component.measureLineDimensions() + node = component.getDOMNode() it "renders only the currently-visible lines", -> - node.style.height = 4.5 * lineHeight + 'px' + node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() lines = node.querySelectorAll('.line') @@ -25,10 +28,10 @@ describe "EditorComponent", -> expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels component.onVerticalScroll() - expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeight}px)" + expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" lines = node.querySelectorAll('.line') expect(lines.length).toBe 5 @@ -36,19 +39,19 @@ describe "EditorComponent", -> expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text spacers = node.querySelectorAll('.spacer') - expect(spacers[0].offsetHeight).toBe 2 * lineHeight - expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeight + expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels + expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeightInPixels it "renders the currently visible cursors", -> cursor1 = editor.getCursor() cursor1.setScreenPosition([0, 5]) - node.style.height = 4.5 * lineHeight + 'px' + node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 - expect(cursorNodes[0].offsetHeight).toBe lineHeight + 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 @@ -60,25 +63,41 @@ describe "EditorComponent", -> expect(cursorNodes.length).toBe 2 expect(cursorNodes[0].offsetTop).toBe 0 expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth - expect(cursorNodes[1].offsetTop).toBe 4 * lineHeight + expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels component.onVerticalScroll() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 2 - expect(cursorNodes[0].offsetTop).toBe 6 * lineHeight + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth - expect(cursorNodes[1].offsetTop).toBe 4 * lineHeight + expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth cursor3.destroy() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 - expect(cursorNodes[0].offsetTop).toBe 6 * lineHeight + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + it "accounts for character widths when positioning cursors", -> + atom.config.set('editor.fontFamily', 'sans-serif') + editor.setCursorScreenPosition([0, 16]) + + cursor = node.querySelector('.cursor') + cursorRect = cursor.getBoundingClientRect() + + cursorLocationTextNode = node.querySelector('.storage.type.function.js').firstChild.firstChild + range = document.createRange() + range.setStart(cursorLocationTextNode, 0) + range.setEnd(cursorLocationTextNode, 1) + rangeRect = range.getBoundingClientRect() + + expect(cursorRect.left).toBe rangeRect.left + expect(cursorRect.width).toBe rangeRect.width + it "transfers focus to the hidden input", -> expect(document.activeElement).toBe document.body node.focus() diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index 8f60c575b..78e3f387d 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -3,12 +3,5 @@ module.exports = CursorComponent = React.createClass render: -> - {cursor, lineHeight, charWidth} = @props - {row, column} = cursor.getScreenPosition() - - div className: 'cursor', style: { - height: lineHeight, - width: charWidth - top: row * lineHeight - left: column * charWidth - } + {top, left, height, width} = @props.cursor.getPixelRect() + div className: 'cursor', style: {top, left, height, width} diff --git a/src/cursor.coffee b/src/cursor.coffee index 23209e854..d361687fa 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -54,6 +54,14 @@ class Cursor unless fn() @emit 'autoscrolled' if @needsAutoscroll + getPixelRect: -> + screenPosition = @getScreenPosition() + {top, left} = @editor.pixelPositionForScreenPosition(screenPosition, false) + right = @editor.pixelPositionForScreenPosition(screenPosition.add([0, 1])).left + width = right - left + height = @editor.getLineHeight() + {top, left, width, height} + # Public: Moves a cursor to a given screen position. # # screenPosition - An {Array} of two numbers: the screen row, and the screen diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 30108a74b..50a3398fb 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -420,6 +420,9 @@ class DisplayBuffer extends Model setScopedCharWidths: (scopeNames, charWidths) -> _.extend(@getScopedCharWidths(scopeNames), charWidths) + clearScopedCharWidths: -> + @charWidthsByScope = {} + # Get the grammar for this buffer. # # Returns the current {Grammar} or the {NullGrammar}. diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 6b201a779..40a19dc61 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,13 +1,14 @@ {React, div, span} = require 'reactionary' -{$$} = require 'space-pencil' +{$$} = require 'space-pen' SelectionComponent = require './selection-component' InputComponent = require './input-component' CustomEventMixin = require './custom-event-mixin' SubscriberMixin = require './subscriber-mixin' -DummyLineNode = $$ -> - @div className: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x' +DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] +MeasureRange = document.createRange() +TextNodeFilter = { acceptNode: -> NodeFilter.FILTER_ACCEPT } module.exports = EditorCompont = React.createClass @@ -18,7 +19,9 @@ EditorCompont = React.createClass mixins: [CustomEventMixin, SubscriberMixin] render: -> - div className: 'editor', tabIndex: -1, + {fontSize, lineHeight, fontFamily} = @state + + div className: 'editor', tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, div className: 'scroll-view', ref: 'scrollView', InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput @renderScrollableContent() @@ -26,7 +29,7 @@ EditorCompont = React.createClass div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} renderScrollableContent: -> - height = @props.editor.getScreenLineCount() * @state.lineHeight + height = @props.editor.getScreenLineCount() * @state.lineHeightInPixels WebkitTransform = "translateY(#{-@state.scrollTop}px)" div className: 'scrollable-content', style: {height, WebkitTransform}, @@ -34,16 +37,16 @@ EditorCompont = React.createClass @renderVisibleLines() renderOverlayer: -> - {lineHeight, charWidth} = @state + {lineHeightInPixels, charWidth} = @state div className: 'overlayer', for selection in @props.editor.getSelections() when @selectionIntersectsVisibleRowRange(selection) - SelectionComponent({selection, lineHeight, charWidth}) + SelectionComponent({selection}) renderVisibleLines: -> [startRow, endRow] = @getVisibleRowRange() - precedingHeight = startRow * @state.lineHeight - followingHeight = (@props.editor.getScreenLineCount() - endRow) * @state.lineHeight + precedingHeight = startRow * @state.lineHeightInPixels + followingHeight = (@props.editor.getScreenLineCount() - endRow) * @state.lineHeightInPixels div className: 'lines', ref: 'lines', [ div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} @@ -55,11 +58,12 @@ EditorCompont = React.createClass getInitialState: -> height: 0 width: 0 - lineHeight: 0 + lineHeightInPixels: 0 scrollTop: 0 componentDidMount: -> - @listenForCustomEvents() + @measuredLines = new WeakSet + @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel @getDOMNode().addEventListener 'focus', @onFocus @@ -68,12 +72,18 @@ EditorCompont = React.createClass @subscribe editor, 'selection-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded + @listenForCustomEvents() + @observeConfig() + @updateAllDimensions() @props.editor.setVisible(true) componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMousewheel + componentDidUpdate: -> + @measureNewLines() + listenForCustomEvents: -> {editor, mini} = @props @@ -171,6 +181,23 @@ EditorCompont = React.createClass # 'core:page-up': => @pageUp() # 'editor:scroll-to-cursor': => @scrollToCursorPosition() + observeConfig: -> + @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily + + setFontSize: (fontSize) -> + @clearScopedCharWidths() + @setState({fontSize}) + @updateLineDimensions() + + setLineHeight: (lineHeight) -> + @updateLineDimensions() + @setState({lineHeight}) + + setFontFamily: (fontFamily) -> + @clearScopedCharWidths() + @setState({fontFamily}) + @updateLineDimensions() + onFocus: -> @refs.hiddenInput.focus() @@ -200,10 +227,11 @@ EditorCompont = React.createClass @forceUpdate() if @selectionIntersectsVisibleRowRange(selection) getVisibleRowRange: -> - return [0, 0] unless @state.lineHeight > 0 + return [0, 0] unless @state.lineHeightInPixels > 0 + return [0, @props.editor.getScreenLineCount()] if @state.height is 0 - heightInLines = @state.height / @state.lineHeight - startRow = Math.floor(@state.scrollTop / @state.lineHeight) + heightInLines = @state.height / @state.lineHeightInPixels + startRow = Math.floor(@state.scrollTop / @state.lineHeightInPixels) endRow = Math.ceil(startRow + heightInLines) [startRow, endRow] @@ -216,12 +244,20 @@ EditorCompont = React.createClass @intersectsVisibleRowRange(start.row, end.row + 1) getScrollHeight: -> - @props.editor.getLineCount() * @state.lineHeight + @props.editor.getLineCount() * @state.lineHeightInPixels updateAllDimensions: -> {height, width} = @measureScrollViewDimensions() - {lineHeight, charWidth} = @measureLineDimensions() - @setState({height, width, lineHeight, charWidth}) + {lineHeightInPixels, charWidth} = @measureLineDimensions() + @props.editor.setLineHeight(lineHeightInPixels) + @props.editor.setDefaultCharWidth(charWidth) + @setState({height, width, lineHeightInPixels, charWidth}) + + updateLineDimensions: -> + {lineHeightInPixels, charWidth} = @measureLineDimensions() + @props.editor.setLineHeight(lineHeightInPixels) + @props.editor.setDefaultCharWidth(charWidth) + @setState({lineHeightInPixels, charWidth}) measureScrollViewDimensions: -> scrollViewNode = @refs.scrollView.getDOMNode() @@ -230,10 +266,39 @@ EditorCompont = React.createClass measureLineDimensions: -> linesNode = @refs.lines.getDOMNode() linesNode.appendChild(DummyLineNode) - lineHeight = DummyLineNode.getBoundingClientRect().height + lineHeightInPixels = DummyLineNode.getBoundingClientRect().height charWidth = DummyLineNode.firstChild.getBoundingClientRect().width linesNode.removeChild(DummyLineNode) - {lineHeight, charWidth} + {lineHeightInPixels, charWidth} + + measureNewLines: -> + [visibleStartRow, visibleEndRow] = @getVisibleRowRange() + linesNode = @refs.lines.getDOMNode() + + for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) + unless @measuredLines.has(tokenizedLine) + lineNode = linesNode.children[i + 1] + @measureCharactersInLine(tokenizedLine, lineNode) + + measureCharactersInLine: (tokenizedLine, lineNode) -> + {editor} = @props + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, TextNodeFilter) + + for {value, scopes} in tokenizedLine.tokens + textNode = iterator.nextNode() + charWidths = editor.getScopedCharWidths(scopes) + for char, i in value + unless charWidths[char]? + MeasureRange.setStart(textNode, i) + MeasureRange.setEnd(textNode, i + 1) + charWidth = MeasureRange.getBoundingClientRect().width + @props.editor.setScopedCharWidth(scopes, char, charWidth) + + @measuredLines.add(tokenizedLine) + + clearScopedCharWidths: -> + @measuredLines.clear() + @props.editor.clearScopedCharWidths() LineComponent = React.createClass render: -> @@ -251,6 +316,6 @@ LineComponent = React.createClass html += @buildScopeTreeHTML(child) for child in scopeTree.children html else - "#{scopeTree.value}" + "#{scopeTree.getValueAsHtml({})}" shouldComponentUpdate: -> false diff --git a/src/editor-view.coffee b/src/editor-view.coffee index 975906941..efba49aa2 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -1472,6 +1472,7 @@ class EditorView extends View # Copies the current file path to the native clipboard. copyPathToClipboard: -> + @editor.copyPathToClipboard() path = @editor.getPath() atom.clipboard.write(path) if path? diff --git a/src/editor.coffee b/src/editor.coffee index 868b7f61b..21087c1b9 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -514,6 +514,8 @@ class Editor extends Model # this editor. shouldPromptToSave: -> @isModified() and not @buffer.hasMultipleEditors() + pixelPositionForScreenPosition: (screenPosition) -> @displayBuffer.pixelPositionForScreenPosition(screenPosition) + # Public: Convert a position in buffer-coordinates to screen-coordinates. # # The position is clipped via {::clipBufferPosition} prior to the conversion. @@ -1790,6 +1792,20 @@ class Editor extends Model getSelectionMarkerAttributes: -> type: 'selection', editorId: @id, invalidate: 'never' + getLineHeight: -> @displayBuffer.getLineHeight() + + setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight) + + setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth) + + getScopedCharWidth: (args...) -> @displayBuffer.getScopedCharWidth(args...) + + getScopedCharWidths: (args...) -> @displayBuffer.getScopedCharWidths(args...) + + setScopedCharWidth: (args...) -> @displayBuffer.setScopedCharWidth(args...) + + clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths() + # Deprecated: Call {::joinLines} instead. joinLine: -> deprecate("Use Editor::joinLines() instead") diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 3ae5577fd..69aba9f91 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -1,5 +1,4 @@ {View} = require 'space-pen' -{$$} = require 'space-pencil' {React} = require 'reactionary' EditorComponent = require './editor-component' diff --git a/src/selection-component.coffee b/src/selection-component.coffee index 15a374840..34da8c28a 100644 --- a/src/selection-component.coffee +++ b/src/selection-component.coffee @@ -7,10 +7,8 @@ SelectionComponent = React.createClass mixins: [SubscriberMixin] render: -> - {selection, lineHeight, charWidth} = @props - {cursor} = selection div className: 'selection', - CursorComponent({cursor, lineHeight, charWidth}) + CursorComponent(cursor: @props.selection.cursor) componentDidMount: -> @subscribe @props.selection, 'screen-range-changed', => @forceUpdate() From 9898cbc52efe8282cfffbe6fd6303ad7926eeb9f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 11:02:22 -0600 Subject: [PATCH 025/179] Remove space-pencil dependency --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index db8e868c8..a8ca508f9 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "semver": "1.1.4", "serializable": "1.x", "space-pen": "3.1.1", - "space-pencil": "^0.3.0", "temp": "0.5.0", "text-buffer": "^2.1.0", "theorist": "1.x", From 5a8ca1ae66c7ab6ef9caeea75331881e07741ae3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 17:03:07 -0600 Subject: [PATCH 026/179] Move screen-related properties to DisplayBuffer Scroll positions, height, width, line height. We force update when one of these observed properties changes. --- src/display-buffer.coffee | 121 ++++++++++++++++++++++++------------ src/editor-component.coffee | 107 ++++++++++++++++--------------- src/editor.coffee | 26 +++----- 3 files changed, 141 insertions(+), 113 deletions(-) diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 50a3398fb..fcb0d393c 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -22,9 +22,12 @@ class DisplayBuffer extends Model @properties softWrap: null editorWidthInChars: null - - lineHeight: null - defaultCharWidth: null + lineHeight: null + defaultCharWidth: null + height: null + width: null + scrollTop: 0 + scrollLeft: 0 constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer}={}) -> super @@ -55,6 +58,8 @@ class DisplayBuffer extends Model id: @id softWrap: @softWrap editorWidthInChars: @editorWidthInChars + scrollTop: @scrollTop + scrollLeft: @scrollLeft tokenizedBuffer: @tokenizedBuffer.serialize() deserializeParams: (params) -> @@ -63,6 +68,9 @@ class DisplayBuffer extends Model copy: -> newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength()}) + newDisplayBuffer.setScrollTop(@getScrollTop()) + newDisplayBuffer.setScrollLeft(@getScrollLeft()) + for marker in @findMarkers(displayBufferId: @id) marker.copy(displayBufferId: newDisplayBuffer.id) newDisplayBuffer @@ -93,6 +101,75 @@ class DisplayBuffer extends Model # visible - A {Boolean} indicating of the tokenized buffer is shown setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) + getHeight: -> @height + setHeight: (@height) -> @height + + getWidth: -> @width + setWidth: (@width) -> @width + + getScrollTop: -> @scrollTop + setScrollTop: (@scrollTop) -> @scrollTop + + getScrollLeft: -> @scrollLeft + setScrollLeft: (@scrollLeft) -> @scrollLeft + + getLineHeight: -> @lineHeight + setLineHeight: (@lineHeight) -> @lineHeight + + setDefaultCharWidth: (@defaultCharWidth) -> + + getScopedCharWidth: (scopeNames, char) -> + @getScopedCharWidths(scopeNames)[char] + + getScopedCharWidths: (scopeNames) -> + scope = @charWidthsByScope + for scopeName in scopeNames + scope[scopeName] ?= {} + scope = scope[scopeName] + scope.charWidths ?= {} + scope.charWidths + + setScopedCharWidth: (scopeNames, char, width) -> + @getScopedCharWidths(scopeNames)[char] = width + + setScopedCharWidths: (scopeNames, charWidths) -> + _.extend(@getScopedCharWidths(scopeNames), charWidths) + + clearScopedCharWidths: -> + @charWidthsByScope = {} + + getScrollHeight: -> + @getLineCount() * @getLineHeight() + + getVisibleRowRange: -> + return [0, 0] unless @getLineHeight() > 0 + return [0, @getLineCount()] if @getHeight() is 0 + + heightInLines = @getHeight() / @getLineHeight() + startRow = Math.floor(@getScrollTop() / @getLineHeight()) + endRow = Math.ceil(startRow + heightInLines) + [startRow, endRow] + + intersectsVisibleRowRange: (startRow, endRow) -> + [visibleStart, visibleEnd] = @getVisibleRowRange() + not (endRow <= visibleStart or visibleEnd <= startRow) + + selectionIntersectsVisibleRowRange: (selection) -> + {start, end} = selection.getScreenRange() + @intersectsVisibleRowRange(start.row, end.row + 1) + + # Retrieves the current tab length. + # + # Returns a {Number}. + getTabLength: -> + @tokenizedBuffer.getTabLength() + + # Specifies the tab length. + # + # tabLength - A {Number} that defines the new tab length. + setTabLength: (tabLength) -> + @tokenizedBuffer.setTabLength(tabLength) + # Deprecated: Use the softWrap property directly setSoftWrap: (@softWrap) -> @softWrap @@ -385,44 +462,6 @@ class DisplayBuffer extends Model tokenForBufferPosition: (bufferPosition) -> @tokenizedBuffer.tokenForPosition(bufferPosition) - # Retrieves the current tab length. - # - # Returns a {Number}. - getTabLength: -> - @tokenizedBuffer.getTabLength() - - # Specifies the tab length. - # - # tabLength - A {Number} that defines the new tab length. - setTabLength: (tabLength) -> - @tokenizedBuffer.setTabLength(tabLength) - - getLineHeight: -> @lineHeight - - setLineHeight: (@lineHeight) -> - - setDefaultCharWidth: (@defaultCharWidth) -> - - getScopedCharWidth: (scopeNames, char) -> - @getScopedCharWidths(scopeNames)[char] - - getScopedCharWidths: (scopeNames) -> - scope = @charWidthsByScope - for scopeName in scopeNames - scope[scopeName] ?= {} - scope = scope[scopeName] - scope.charWidths ?= {} - scope.charWidths - - setScopedCharWidth: (scopeNames, char, width) -> - @getScopedCharWidths(scopeNames)[char] = width - - setScopedCharWidths: (scopeNames, charWidths) -> - _.extend(@getScopedCharWidths(scopeNames), charWidths) - - clearScopedCharWidths: -> - @charWidthsByScope = {} - # Get the grammar for this buffer. # # Returns the current {Grammar} or the {NullGrammar}. diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 40a19dc61..d243054db 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -20,33 +20,37 @@ EditorCompont = React.createClass render: -> {fontSize, lineHeight, fontFamily} = @state + {editor} = @props div className: 'editor', tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, div className: 'scroll-view', ref: 'scrollView', InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput @renderScrollableContent() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, - div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()} + div outlet: 'verticalScrollbarContent', style: {height: editor.getScrollHeight()} renderScrollableContent: -> - height = @props.editor.getScreenLineCount() * @state.lineHeightInPixels - WebkitTransform = "translateY(#{-@state.scrollTop}px)" + {editor} = @props + height = editor.getScrollHeight() + WebkitTransform = "translateY(#{-editor.getScrollTop()}px)" div className: 'scrollable-content', style: {height, WebkitTransform}, @renderOverlayer() @renderVisibleLines() renderOverlayer: -> - {lineHeightInPixels, charWidth} = @state + {editor} = @props div className: 'overlayer', - for selection in @props.editor.getSelections() when @selectionIntersectsVisibleRowRange(selection) + for selection in @props.editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) SelectionComponent({selection}) renderVisibleLines: -> - [startRow, endRow] = @getVisibleRowRange() - precedingHeight = startRow * @state.lineHeightInPixels - followingHeight = (@props.editor.getScreenLineCount() - endRow) * @state.lineHeightInPixels + {editor} = @props + [startRow, endRow] = editor.getVisibleRowRange() + lineHeightInPixels = editor.getLineHeight() + precedingHeight = startRow * lineHeightInPixels + followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels div className: 'lines', ref: 'lines', [ div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} @@ -55,24 +59,16 @@ EditorCompont = React.createClass div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] - getInitialState: -> - height: 0 - width: 0 - lineHeightInPixels: 0 - scrollTop: 0 + getInitialState: -> {} + + getDefaultProps: -> updateSync: true componentDidMount: -> @measuredLines = new WeakSet - @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel - @getDOMNode().addEventListener 'focus', @onFocus - - {editor} = @props - @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged - @subscribe editor, 'selection-added', @onSelectionAdded - @subscribe editor, 'selection-removed', @onSelectionAdded - + @listenForDOMEvents() @listenForCustomEvents() + @observeEditor() @observeConfig() @updateAllDimensions() @@ -84,6 +80,21 @@ EditorCompont = React.createClass componentDidUpdate: -> @measureNewLines() + observeEditor: -> + {editor} = @props + @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged + @subscribe editor, 'selection-added', @onSelectionAdded + @subscribe editor, 'selection-removed', @onSelectionAdded + @subscribe editor.$scrollTop.changes, @requestUpdate + @subscribe editor.$height.changes, @requestUpdate + @subscribe editor.$width.changes, @requestUpdate + @subscribe editor.$defaultCharWidth.changes, @requestUpdate + @subscribe editor.$lineHeight.changes, @requestUpdate + + listenForDOMEvents: -> + @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel + @getDOMNode().addEventListener 'focus', @onFocus + listenForCustomEvents: -> {editor, mini} = @props @@ -190,7 +201,6 @@ EditorCompont = React.createClass @updateLineDimensions() setLineHeight: (lineHeight) -> - @updateLineDimensions() @setState({lineHeight}) setFontFamily: (fontFamily) -> @@ -206,7 +216,7 @@ EditorCompont = React.createClass @pendingScrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop unless animationFramePending requestAnimationFrame => - @setState({scrollTop: @pendingScrollTop}) + @props.editor.setScrollTop(@pendingScrollTop) @pendingScrollTop = null onMousewheel: (event) -> @@ -217,47 +227,36 @@ EditorCompont = React.createClass @props.editor.insertText(char) onScreenLinesChanged: ({start, end}) -> - [visibleStart, visibleEnd] = @getVisibleRowRange() - @forceUpdate() if @intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events + {editor} = @props + @requestUpdate() if editor.intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events onSelectionAdded: (selection) -> - @forceUpdate() if @selectionIntersectsVisibleRowRange(selection) + {editor} = @props + @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) onSelectionRemoved: (selection) -> - @forceUpdate() if @selectionIntersectsVisibleRowRange(selection) + {editor} = @props + @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) - getVisibleRowRange: -> - return [0, 0] unless @state.lineHeightInPixels > 0 - return [0, @props.editor.getScreenLineCount()] if @state.height is 0 - - heightInLines = @state.height / @state.lineHeightInPixels - startRow = Math.floor(@state.scrollTop / @state.lineHeightInPixels) - endRow = Math.ceil(startRow + heightInLines) - [startRow, endRow] - - intersectsVisibleRowRange: (startRow, endRow) -> - [visibleStart, visibleEnd] = @getVisibleRowRange() - not (endRow <= visibleStart or visibleEnd <= startRow) - - selectionIntersectsVisibleRowRange: (selection) -> - {start, end} = selection.getScreenRange() - @intersectsVisibleRowRange(start.row, end.row + 1) - - getScrollHeight: -> - @props.editor.getLineCount() * @state.lineHeightInPixels + requestUpdate: -> + @forceUpdate() updateAllDimensions: -> {height, width} = @measureScrollViewDimensions() {lineHeightInPixels, charWidth} = @measureLineDimensions() - @props.editor.setLineHeight(lineHeightInPixels) - @props.editor.setDefaultCharWidth(charWidth) - @setState({height, width, lineHeightInPixels, charWidth}) + {editor} = @props + + editor.setHeight(height) + editor.setWidth(width) + editor.setLineHeight(lineHeightInPixels) + editor.setDefaultCharWidth(charWidth) updateLineDimensions: -> {lineHeightInPixels, charWidth} = @measureLineDimensions() - @props.editor.setLineHeight(lineHeightInPixels) - @props.editor.setDefaultCharWidth(charWidth) - @setState({lineHeightInPixels, charWidth}) + {editor} = @props + + editor.setLineHeight(lineHeightInPixels) + editor.setDefaultCharWidth(charWidth) measureScrollViewDimensions: -> scrollViewNode = @refs.scrollView.getDOMNode() @@ -272,7 +271,7 @@ EditorCompont = React.createClass {lineHeightInPixels, charWidth} measureNewLines: -> - [visibleStartRow, visibleEndRow] = @getVisibleRowRange() + [visibleStartRow, visibleEndRow] = @props.editor.getVisibleRowRange() linesNode = @refs.lines.getDOMNode() for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) @@ -292,7 +291,7 @@ EditorCompont = React.createClass MeasureRange.setStart(textNode, i) MeasureRange.setEnd(textNode, i + 1) charWidth = MeasureRange.getBoundingClientRect().width - @props.editor.setScopedCharWidth(scopes, char, charWidth) + editor.setScopedCharWidth(scopes, char, charWidth) @measuredLines.add(tokenizedLine) diff --git a/src/editor.coffee b/src/editor.coffee index 21087c1b9..79d05affd 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -136,10 +136,6 @@ class Editor extends Model atom.deserializers.add(this) Delegator.includeInto(this) - @properties - scrollTop: 0 - scrollLeft: 0 - deserializing: false callDisplayBufferCreatedHook: false registerEditor: false @@ -153,6 +149,14 @@ class Editor extends Model 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', toProperty: 'languageMode' + @delegatesMethods 'setLineHeight', 'getLineHeight', 'setDefaultCharWidth', 'setHeight', + 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', 'setScrollLeft', + 'getScrollLeft', 'getScrollHeight', 'getVisibleRowRange', 'intersectsVisibleRowRange', + 'selectionIntersectsVisibleRowRange', toProperty: 'displayBuffer' + + @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', + '$scrollTop', '$scrollLeft', toProperty: 'displayBuffer' + constructor: ({@softTabs, initialLine, tabLength, softWrap, @displayBuffer, buffer, registerEditor, suppressCursorCreation}) -> super @@ -232,8 +236,6 @@ class Editor extends Model displayBuffer = @displayBuffer.copy() softTabs = @getSoftTabs() newEditor = new Editor({@buffer, displayBuffer, tabLength, softTabs, suppressCursorCreation: true, registerEditor: true}) - newEditor.setScrollTop(@getScrollTop()) - newEditor.setScrollLeft(@getScrollLeft()) for marker in @findMarkers(editorId: @id) marker.copy(editorId: newEditor.id, preserveFolds: true) newEditor @@ -269,18 +271,6 @@ class Editor extends Model # Controls visiblity based on the given {Boolean}. setVisible: (visible) -> @displayBuffer.setVisible(visible) - # Called by {EditorView} when the scroll position changes so it can be - # persisted across reloads. - setScrollTop: (@scrollTop) -> @scrollTop - - getScrollTop: -> @scrollTop - - # Called by {EditorView} when the scroll position changes so it can be - # persisted across reloads. - setScrollLeft: (@scrollLeft) -> @scrollLeft - - getScrollLeft: -> @scrollLeft - # Set the number of characters that can be displayed horizontally in the # editor. # From 2f42f23ec60d55677f679a465bfe217fd26693e9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 17:03:18 -0600 Subject: [PATCH 027/179] Revert changes to editor-view --- src/editor-view.coffee | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/editor-view.coffee b/src/editor-view.coffee index efba49aa2..1da0e223d 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -4,7 +4,6 @@ GutterView = require './gutter-view' Editor = require './editor' CursorView = require './cursor-view' SelectionView = require './selection-view' - fs = require 'fs-plus' _ = require 'underscore-plus' TextBuffer = require 'text-buffer' @@ -123,9 +122,6 @@ class EditorView extends View @setPlaceholderText(placeholderText) if placeholderText - @contentsComponent = new EditorContentsComponent({editor}) - @renderedLines.replaceWith(@contentsComponent.element) - if editor? @edit(editor) else if @mini @@ -335,7 +331,8 @@ class EditorView extends View # Checkout the HEAD revision of this editor's file. checkoutHead: -> - @editor.checkoutHead() + if path = @editor.getPath() + atom.project.getRepo()?.checkoutHead(path) configure: -> @subscribe atom.config.observe 'editor.showLineNumbers', (showLineNumbers) => @gutter.setShowLineNumbers(showLineNumbers) @@ -973,8 +970,6 @@ class EditorView extends View @redrawOnReattach = true return - return - scrollViewWidth = @scrollView.width() @updateRenderedLines(scrollViewWidth) @updatePlaceholderText() @@ -1472,7 +1467,6 @@ class EditorView extends View # Copies the current file path to the native clipboard. copyPathToClipboard: -> - @editor.copyPathToClipboard() path = @editor.getPath() atom.clipboard.write(path) if path? From 08bd03b706f9476210a5239a0ee558f8107d7834 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 17:08:36 -0600 Subject: [PATCH 028/179] Opt in to editor view only when core.useNewEditor is true --- src/editor.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/editor.coffee b/src/editor.coffee index 79d05affd..7902ad98f 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -221,7 +221,10 @@ class Editor extends Model @subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args... getViewClass: -> - require './react-editor-view' + if atom.config.get('core.useNewEditor') + require './react-editor-view' + else + require './editor-view' destroyed: -> @unsubscribe() From 96e6ddac2e1c49c25ce7af6a68f1952f806f50ae Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 18:30:33 -0600 Subject: [PATCH 029/179] Prevent out-of-bounds scrollTop assignment on DisplayBuffer --- src/display-buffer.coffee | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index fcb0d393c..5587fe64b 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -108,7 +108,13 @@ class DisplayBuffer extends Model setWidth: (@width) -> @width getScrollTop: -> @scrollTop - setScrollTop: (@scrollTop) -> @scrollTop + setScrollTop: (scrollTop) -> + @scrollTop = Math.min(@getScrollHeight(), Math.max(0, scrollTop)) + + getScrollBottom: -> @scrollTop + @height + setScrollBottom: (scrollBottom) -> + @setScrollTop(scrollBottom - @height) + @getScrollBottom() getScrollLeft: -> @scrollLeft setScrollLeft: (@scrollLeft) -> @scrollLeft From ba83b0ede06800f257157c8bcfee88ea2cc71995 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 18:31:07 -0600 Subject: [PATCH 030/179] Update height of DisplayBuffer from editor component on overflow changed --- src/editor-component.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index d243054db..5c8d3a70f 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -223,6 +223,9 @@ EditorCompont = React.createClass @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY event.preventDefault() + onOverflowChanged: -> + @props.editor.setHeight(@refs.scrollView.getDOMNode().clientHeight) + onInput: (char, replaceLastChar) -> @props.editor.insertText(char) From e472d7b038e17fbcaef6ae6de722e660e9f9f7b6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 18:32:19 -0600 Subject: [PATCH 031/179] Add autoscrolling with react editor view Its implemented in the model to restrict touching of the DOM. --- spec/editor-spec.coffee | 32 ++++++++++++++++++++++++++++++++ src/cursor.coffee | 18 ++++++++++++++++++ src/editor-component.coffee | 4 +++- src/editor.coffee | 11 ++++++++--- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index aca279e2a..3e038f26e 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -645,6 +645,38 @@ describe "Editor", -> cursor2 = editor.addCursorAtBufferPosition([1,4]) expect(cursor2.marker).toBe cursor1.marker + describe "autoscroll", -> + beforeEach -> + editor.setVerticalScrollMargin(2) + editor.setLineHeight(10) + editor.setHeight(5.5 * 10) + + it "scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor", -> + expect(editor.getScrollTop()).toBe 0 + expect(editor.getScrollBottom()).toBe 5.5 * 10 + + editor.setCursorScreenPosition([2, 0]) + expect(editor.getScrollBottom()).toBe 5.5 * 10 + + editor.moveCursorDown() + expect(editor.getScrollBottom()).toBe 6 * 10 + + editor.moveCursorDown() + expect(editor.getScrollBottom()).toBe 7 * 10 + + it "scrolls up when the last cursor gets closer than ::verticalScrollMargin to the top of the editor", -> + editor.setCursorScreenPosition([11, 0]) + editor.setScrollBottom(editor.getScrollHeight()) + + editor.moveCursorUp() + expect(editor.getScrollBottom()).toBe editor.getScrollHeight() + + editor.moveCursorUp() + expect(editor.getScrollTop()).toBe 7 * 10 + + editor.moveCursorUp() + expect(editor.getScrollTop()).toBe 6 * 10 + describe "selection", -> selection = null diff --git a/src/cursor.coffee b/src/cursor.coffee index d361687fa..d496536f7 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -27,7 +27,12 @@ class Cursor {textChanged} = e return if oldHeadScreenPosition.isEqual(newHeadScreenPosition) + # Supports old editor view @needsAutoscroll ?= @isLastCursor() and !textChanged + + # Supports react editor view + @autoscroll() if @needsAutoscroll + @goalColumn = null movedEvent = @@ -92,6 +97,19 @@ class Cursor getBufferPosition: -> @marker.getHeadBufferPosition() + autoscroll: -> + scrollMarginInPixels = @editor.getVerticalScrollMargin() * @editor.getLineHeight() + {top, height} = @getPixelRect() + bottom = top + height + + desiredScrollTop = top - scrollMarginInPixels + desiredScrollBottom = bottom + scrollMarginInPixels + + if desiredScrollTop < @editor.getScrollTop() + @editor.setScrollTop(desiredScrollTop) + else if desiredScrollBottom > @editor.getScrollBottom() + @editor.setScrollBottom(desiredScrollBottom) + # Public: If the marker range is empty, the cursor is marked as being visible. updateVisibility: -> @setVisible(@marker.getBufferRange().isEmpty()) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 5c8d3a70f..d15b68eaf 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -92,7 +92,9 @@ EditorCompont = React.createClass @subscribe editor.$lineHeight.changes, @requestUpdate listenForDOMEvents: -> - @refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel + scrollViewNode = @refs.scrollView.getDOMNode() + scrollViewNode.addEventListener 'mousewheel', @onMousewheel + scrollViewNode.addEventListener 'overflowchanged', @onOverflowChanged @getDOMNode().addEventListener 'focus', @onFocus listenForCustomEvents: -> diff --git a/src/editor.coffee b/src/editor.coffee index 7902ad98f..b44dd4480 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -144,15 +144,16 @@ class Editor extends Model cursors: null selections: null suppressSelectionMerging: false + verticalScrollMargin: 2 @delegatesMethods 'suggestedIndentForBufferRow', 'autoIndentBufferRow', 'autoIndentBufferRows', 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', toProperty: 'languageMode' @delegatesMethods 'setLineHeight', 'getLineHeight', 'setDefaultCharWidth', 'setHeight', - 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', 'setScrollLeft', - 'getScrollLeft', 'getScrollHeight', 'getVisibleRowRange', 'intersectsVisibleRowRange', - 'selectionIntersectsVisibleRowRange', toProperty: 'displayBuffer' + 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', 'getScrollBottom', + 'setScrollBottom', 'setScrollLeft', 'getScrollLeft', 'getScrollHeight', 'getVisibleRowRange', + 'intersectsVisibleRowRange', 'selectionIntersectsVisibleRowRange', toProperty: 'displayBuffer' @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', '$scrollTop', '$scrollLeft', toProperty: 'displayBuffer' @@ -308,6 +309,10 @@ class Editor extends Model # Public: Toggle soft wrap for this editor toggleSoftWrap: -> @setSoftWrap(not @getSoftWrap()) + getVerticalScrollMargin: -> @verticalScrollMargin + + setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin + # Public: Get the text representing a single level of indent. # # If soft tabs are enabled, the text is composed of N spaces, where N is the From ab02d5f25fa18168ea5e6cde07441be64de9bfd9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 18:55:38 -0600 Subject: [PATCH 032/179] Update the vertical scroll bar when scrollTop changes in the model --- spec/editor-component-spec.coffee | 10 ++++++++++ src/editor-component.coffee | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 51afa07bf..0d37088e9 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -82,6 +82,16 @@ describe "EditorComponent", -> expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + it "updates the scroll bar when the scrollTop is changed in the model", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() + + scrollbarNode = node.querySelector('.vertical-scrollbar') + expect(scrollbarNode.scrollTop).toBe 0 + + editor.setScrollTop(10) + expect(scrollbarNode.scrollTop).toBe 10 + it "accounts for character widths when positioning cursors", -> atom.config.set('editor.fontFamily', 'sans-serif') editor.setCursorScreenPosition([0, 16]) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index d15b68eaf..9d67c646a 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -78,6 +78,10 @@ EditorCompont = React.createClass @getDOMNode().removeEventListener 'mousewheel', @onMousewheel componentDidUpdate: -> + # React offers a scrollTop property on element descriptors but it doesn't + # seem to work when reloading the editor while already scrolled down. + # Perhaps it's trying to assign it before the element inside is tall enough? + @refs.verticalScrollbar.getDOMNode().scrollTop = @props.editor.getScrollTop() @measureNewLines() observeEditor: -> From 1b8f23722b39ae30e41b5871cd6cdd9e2de45393 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 19:23:24 -0600 Subject: [PATCH 033/179] Correctly close scope spans when rendering line HTML --- src/editor-component.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 9d67c646a..24dfaa4ae 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -322,6 +322,7 @@ LineComponent = React.createClass if scopeTree.children? html = "" html += @buildScopeTreeHTML(child) for child in scopeTree.children + html += "" html else "#{scopeTree.getValueAsHtml({})}" From 565b611c18c8cd4e85787de0f271703afa6e2fdd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 2 Apr 2014 20:05:27 -0600 Subject: [PATCH 034/179] Do a better job imitating the old SpacePen-based editor --- src/react-editor-view.coffee | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 69aba9f91..4120789ec 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -4,14 +4,20 @@ EditorComponent = require './editor-component' module.exports = class ReactEditorView extends View - @content: -> @div class: 'react-wrapper' + @content: -> @div class: 'editor react-wrapper' constructor: (@editor) -> super + getEditor: -> @editor + afterAttach: (onDom) -> return unless onDom + @attached = true @component = React.renderComponent(EditorComponent({@editor}), @element) + @trigger 'editor:attached', [this] beforeDetach: -> React.unmountComponentAtNode(@element) + @attached = false + @trigger 'editor:detached', this From bad2cebd6eb0e66d8bb04ef7efcc2e9a7c3262fc Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 3 Apr 2014 17:02:48 -0600 Subject: [PATCH 035/179] Add Editor::setSelectedScreenRange --- src/editor.coffee | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/editor.coffee b/src/editor.coffee index b44dd4480..e4dfeaa62 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1196,6 +1196,16 @@ class Editor extends Model setSelectedBufferRange: (bufferRange, options) -> @setSelectedBufferRanges([bufferRange], options) + # Public: Set the selected range in screen coordinates. If there are multiple + # selections, they are reduced to a single selection with the given range. + # + # screenRange - A {Range} or range-compatible {Array}. + # options - An options {Object}: + # :reversed - A {Boolean} indicating whether to create the selection in a + # reversed orientation. + setSelectedScreenRange: (screenRange, options) -> + @setSelectedBufferRange(@bufferRangeForScreenRange(screenRange, options), options) + # Public: Set the selected ranges in buffer coordinates. If there are multiple # selections, they are replaced by new selections with the given ranges. # From 724babdcb6cb4db9d0a717fa584904a1a4583b89 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 3 Apr 2014 17:04:09 -0600 Subject: [PATCH 036/179] Only update vertical scrollbar's scrollTop if it has changed --- src/editor-component.coffee | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 24dfaa4ae..7e690a180 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -13,6 +13,7 @@ TextNodeFilter = { acceptNode: -> NodeFilter.FILTER_ACCEPT } module.exports = EditorCompont = React.createClass pendingScrollTop: null + lastScrollTop: null statics: {DummyLineNode} @@ -78,12 +79,22 @@ EditorCompont = React.createClass @getDOMNode().removeEventListener 'mousewheel', @onMousewheel componentDidUpdate: -> - # React offers a scrollTop property on element descriptors but it doesn't - # seem to work when reloading the editor while already scrolled down. - # Perhaps it's trying to assign it before the element inside is tall enough? - @refs.verticalScrollbar.getDOMNode().scrollTop = @props.editor.getScrollTop() + @updateVerticalScrollbar() @measureNewLines() + # The React-provided scrollTop property doesn't work in this case because when + # initially rendering, the synthetic scrollHeight hasn't been computed yet. + # trying to assign it before the element inside is tall enough? + updateVerticalScrollbar: -> + {editor} = @props + scrollTop = editor.getScrollTop() + + return if scrollTop is @lastScrollTop + + scrollbarNode = @refs.verticalScrollbar.getDOMNode() + scrollbarNode.scrollTop = scrollTop + @lastScrollTop = scrollbarNode.scrollTop + observeEditor: -> {editor} = @props @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged From 3d3b72a954a95b8fe6f50216708fc815f61ccb87 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 3 Apr 2014 17:04:19 -0600 Subject: [PATCH 037/179] Render selections --- spec/editor-component-spec.coffee | 150 ++++++++++++++++++++---------- src/cursor-component.coffee | 9 ++ src/display-buffer.coffee | 2 +- src/editor-component.coffee | 17 +++- src/selection-component.coffee | 4 +- src/selection.coffee | 41 ++++++++ static/editor.less | 8 ++ 7 files changed, 176 insertions(+), 55 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 0d37088e9..716a5975d 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -42,45 +42,115 @@ describe "EditorComponent", -> expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeightInPixels - it "renders the currently visible cursors", -> - cursor1 = editor.getCursor() - cursor1.setScreenPosition([0, 5]) + describe "cursor rendering", -> + it "renders the currently visible cursors", -> + cursor1 = editor.getCursor() + cursor1.setScreenPosition([0, 5]) - node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() - 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 + 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 - cursor2 = editor.addCursorAtScreenPosition([6, 11]) - cursor3 = editor.addCursorAtScreenPosition([4, 10]) + cursor2 = editor.addCursorAtScreenPosition([6, 11]) + cursor3 = editor.addCursorAtScreenPosition([4, 10]) - 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 + 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 - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels - component.onVerticalScroll() + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels + component.onVerticalScroll() - 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 + 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 + + 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 + + it "accounts for character widths when positioning cursors", -> + atom.config.set('editor.fontFamily', 'sans-serif') + editor.setCursorScreenPosition([0, 16]) + + cursor = node.querySelector('.cursor') + cursorRect = cursor.getBoundingClientRect() + + cursorLocationTextNode = node.querySelector('.storage.type.function.js').firstChild.firstChild + range = document.createRange() + range.setStart(cursorLocationTextNode, 0) + range.setEnd(cursorLocationTextNode, 1) + rangeRect = range.getBoundingClientRect() + + expect(cursorRect.left).toBe rangeRect.left + expect(cursorRect.width).toBe rangeRect.width + + describe "selection rendering", -> + it "renders 1 region for 1-line selections", -> + # 1-line selection + editor.setSelectedScreenRange([[1, 6], [1, 10]]) + regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 1 + regionRect = regions[0].getBoundingClientRect() + expect(regionRect.top).toBe 1 * lineHeightInPixels + expect(regionRect.height).toBe 1 * lineHeightInPixels + expect(regionRect.left).toBe 6 * charWidth + expect(regionRect.width).toBe 4 * charWidth + + it "renders 2 regions for 2-line selections", -> + editor.setSelectedScreenRange([[1, 6], [2, 10]]) + regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 2 + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe 1 * lineHeightInPixels + expect(region1Rect.height).toBe 1 * lineHeightInPixels + expect(region1Rect.left).toBe 6 * charWidth + expect(region1Rect.right).toBe node.clientWidth + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe 2 * lineHeightInPixels + expect(region2Rect.height).toBe 1 * lineHeightInPixels + expect(region2Rect.left).toBe 0 + expect(region2Rect.width).toBe 10 * charWidth + + it "renders 3 regions for selections with more than 2 lines", -> + editor.setSelectedScreenRange([[1, 6], [5, 10]]) + regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 3 + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe 1 * lineHeightInPixels + expect(region1Rect.height).toBe 1 * lineHeightInPixels + expect(region1Rect.left).toBe 6 * charWidth + expect(region1Rect.right).toBe node.clientWidth + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe 2 * lineHeightInPixels + expect(region2Rect.height).toBe 3 * lineHeightInPixels + expect(region2Rect.left).toBe 0 + expect(region2Rect.right).toBe node.clientWidth + + region3Rect = regions[2].getBoundingClientRect() + expect(region3Rect.top).toBe 5 * lineHeightInPixels + expect(region3Rect.height).toBe 1 * lineHeightInPixels + expect(region3Rect.left).toBe 0 + expect(region3Rect.width).toBe 10 * charWidth - 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 it "updates the scroll bar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' @@ -92,22 +162,6 @@ describe "EditorComponent", -> editor.setScrollTop(10) expect(scrollbarNode.scrollTop).toBe 10 - it "accounts for character widths when positioning cursors", -> - atom.config.set('editor.fontFamily', 'sans-serif') - editor.setCursorScreenPosition([0, 16]) - - cursor = node.querySelector('.cursor') - cursorRect = cursor.getBoundingClientRect() - - cursorLocationTextNode = node.querySelector('.storage.type.function.js').firstChild.firstChild - range = document.createRange() - range.setStart(cursorLocationTextNode, 0) - range.setEnd(cursorLocationTextNode, 1) - rangeRect = range.getBoundingClientRect() - - expect(cursorRect.left).toBe rangeRect.left - expect(cursorRect.width).toBe rangeRect.width - it "transfers focus to the hidden input", -> expect(document.activeElement).toBe document.body node.focus() diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index 78e3f387d..7952b7d57 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -1,7 +1,16 @@ {React, div} = require 'reactionary' +SubscriberMixin = require './subscriber-mixin' module.exports = CursorComponent = React.createClass + mixins: [SubscriberMixin] + render: -> {top, left, height, width} = @props.cursor.getPixelRect() div className: 'cursor', style: {top, left, height, width} + + componentDidMount: -> + @subscribe @props.cursor, 'moved', => @forceUpdate() + + componentWillUnmount: -> + @unsubscribe() diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 5587fe64b..cc1d2c362 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -109,7 +109,7 @@ class DisplayBuffer extends Model getScrollTop: -> @scrollTop setScrollTop: (scrollTop) -> - @scrollTop = Math.min(@getScrollHeight(), Math.max(0, scrollTop)) + @scrollTop = Math.min(@getScrollHeight() - @getHeight(), Math.max(0, scrollTop)) getScrollBottom: -> @scrollTop + @height setScrollBottom: (scrollBottom) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 7e690a180..a8ef8125a 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,8 +1,9 @@ {React, div, span} = require 'reactionary' {$$} = require 'space-pen' -SelectionComponent = require './selection-component' InputComponent = require './input-component' +SelectionComponent = require './selection-component' +CursorComponent = require './cursor-component' CustomEventMixin = require './custom-event-mixin' SubscriberMixin = require './subscriber-mixin' @@ -23,7 +24,7 @@ EditorCompont = React.createClass {fontSize, lineHeight, fontFamily} = @state {editor} = @props - div className: 'editor', tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, + div className: 'editor react', tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, div className: 'scroll-view', ref: 'scrollView', InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput @renderScrollableContent() @@ -38,13 +39,14 @@ EditorCompont = React.createClass div className: 'scrollable-content', style: {height, WebkitTransform}, @renderOverlayer() @renderVisibleLines() + @renderUnderlayer() renderOverlayer: -> {editor} = @props div className: 'overlayer', - for selection in @props.editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({selection}) + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + CursorComponent(cursor: selection.cursor) renderVisibleLines: -> {editor} = @props @@ -60,6 +62,13 @@ EditorCompont = React.createClass div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] + renderUnderlayer: -> + {editor} = @props + + div className: 'underlayer', + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({selection}) + getInitialState: -> {} getDefaultProps: -> updateSync: true diff --git a/src/selection-component.coffee b/src/selection-component.coffee index 34da8c28a..6c895aa90 100644 --- a/src/selection-component.coffee +++ b/src/selection-component.coffee @@ -1,6 +1,5 @@ {React, div} = require 'reactionary' SubscriberMixin = require './subscriber-mixin' -CursorComponent = require './cursor-component' module.exports = SelectionComponent = React.createClass @@ -8,7 +7,8 @@ SelectionComponent = React.createClass render: -> div className: 'selection', - CursorComponent(cursor: @props.selection.cursor) + for regionRect, i in @props.selection.getRegionRects() + div className: 'region', key: i, style: regionRect componentDidMount: -> @subscribe @props.selection, 'screen-range-changed', => @forceUpdate() diff --git a/src/selection.coffee b/src/selection.coffee index 2e262cc93..8d31235e5 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -570,6 +570,47 @@ class Selection 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 diff --git a/static/editor.less b/static/editor.less index ea5c932fd..904829427 100644 --- a/static/editor.less +++ b/static/editor.less @@ -2,6 +2,14 @@ @import "octicon-utf-codes"; @import "octicon-mixins"; +.editor.react .underlayer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + .editor { overflow: hidden; cursor: text; From 95bf08dfa0b92705139bee77bcd533b1538456d2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 3 Apr 2014 18:38:42 -0600 Subject: [PATCH 038/179] Eliminate overlayer by preserving lines during mousewheel events Previously, the overlayer served as a permanent target for mousewheel events that would never be removed by scrolling. This is because the velocity scrolling effect on a trackpad is implemented by repeating events on the original mousewheel event target. If this target is removed, the events stop repeating and the velocity effect is ruined. Now we refrain from removing any lines until mousewheel events stop flowing. --- src/editor-component.coffee | 40 +++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a8ef8125a..1786e2b4c 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,5 +1,6 @@ {React, div, span} = require 'reactionary' {$$} = require 'space-pen' +{debounce} = require 'underscore-plus' InputComponent = require './input-component' SelectionComponent = require './selection-component' @@ -37,20 +38,13 @@ EditorCompont = React.createClass WebkitTransform = "translateY(#{-editor.getScrollTop()}px)" div className: 'scrollable-content', style: {height, WebkitTransform}, - @renderOverlayer() + @renderCursors() @renderVisibleLines() @renderUnderlayer() - renderOverlayer: -> - {editor} = @props - - div className: 'overlayer', - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - CursorComponent(cursor: selection.cursor) - renderVisibleLines: -> {editor} = @props - [startRow, endRow] = editor.getVisibleRowRange() + [startRow, endRow] = @getVisibleRowRange() lineHeightInPixels = editor.getLineHeight() precedingHeight = startRow * lineHeightInPixels followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels @@ -62,6 +56,12 @@ EditorCompont = React.createClass div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] + renderCursors: -> + {editor} = @props + + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + CursorComponent(cursor: selection.cursor) + renderUnderlayer: -> {editor} = @props @@ -69,6 +69,13 @@ EditorCompont = React.createClass for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) SelectionComponent({selection}) + getVisibleRowRange: -> + visibleRowRange = @props.editor.getVisibleRowRange() + if @visibleRowOverrides? + visibleRowRange[0] = Math.min(visibleRowRange[0], @visibleRowOverrides[0]) + visibleRowRange[1] = Math.max(visibleRowRange[1], @visibleRowOverrides[1]) + visibleRowRange + getInitialState: -> {} getDefaultProps: -> updateSync: true @@ -246,9 +253,22 @@ EditorCompont = React.createClass @pendingScrollTop = null onMousewheel: (event) -> + # To preserve velocity scrolling, delay removal of the event's target until + # after mousewheel events stop being fired. Removing the target before then + # will cause scrolling to stop suddenly. + @visibleRowOverrides = @getVisibleRowRange() + @clearVisibleRowOverridesAfterDelay ?= debounce(@clearVisibleRowOverrides, 40) + @clearVisibleRowOverridesAfterDelay() + @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY event.preventDefault() + clearVisibleRowOverrides: -> + @visibleRowOverrides = null + @forceUpdate() + + clearVisibleRowOverridesAfterDelay: null + onOverflowChanged: -> @props.editor.setHeight(@refs.scrollView.getDOMNode().clientHeight) @@ -300,7 +320,7 @@ EditorCompont = React.createClass {lineHeightInPixels, charWidth} measureNewLines: -> - [visibleStartRow, visibleEndRow] = @props.editor.getVisibleRowRange() + [visibleStartRow, visibleEndRow] = @getVisibleRowRange() linesNode = @refs.lines.getDOMNode() for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) From fd2ed9a1bc4a459d94da0174bd4d6116081fda06 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 3 Apr 2014 18:40:55 -0600 Subject: [PATCH 039/179] Prevent scroll event feedback loops after scrolling via the model layer When scrolling via the model layer, such as happens on autoscroll due to moving the cursor, we update the scroll position of the fake vertical scrollbar manually. When we receive the scroll event that results from this, we want to do nothing. The best way to determine whether we're getting a "real" scroll event from the user or feedback from setting the scrollTop ourselves is to compare the scrollTop to what's in the model. If the values are equal, there's no need to request an animation frame to assign it. This improves performance. --- src/editor-component.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 1786e2b4c..8a3273162 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -245,8 +245,11 @@ EditorCompont = React.createClass @refs.hiddenInput.focus() onVerticalScroll: -> + scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop + return if @props.editor.getScrollTop() is scrollTop + animationFramePending = @pendingScrollTop? - @pendingScrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop + @pendingScrollTop = scrollTop unless animationFramePending requestAnimationFrame => @props.editor.setScrollTop(@pendingScrollTop) From 5b79bb7f66cd66f16e9f765489fa9731a6b5d069 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 3 Apr 2014 18:43:21 -0600 Subject: [PATCH 040/179] Wait longer to clear preserved lines after mousewheel events --- src/editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 8a3273162..a7773c00b 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -260,7 +260,7 @@ EditorCompont = React.createClass # after mousewheel events stop being fired. Removing the target before then # will cause scrolling to stop suddenly. @visibleRowOverrides = @getVisibleRowRange() - @clearVisibleRowOverridesAfterDelay ?= debounce(@clearVisibleRowOverrides, 40) + @clearVisibleRowOverridesAfterDelay ?= debounce(@clearVisibleRowOverrides, 100) @clearVisibleRowOverridesAfterDelay() @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY From 669586c11bcbce6180258f336752abdd3fab9580 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 4 Apr 2014 12:56:53 -0600 Subject: [PATCH 041/179] :lipstick: --- spec/editor-component-spec.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 716a5975d..2affd5114 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -151,7 +151,6 @@ describe "EditorComponent", -> expect(region3Rect.left).toBe 0 expect(region3Rect.width).toBe 10 * charWidth - it "updates the scroll bar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() From 57e2cf80f40799aa35484f4992f58ea90a38e055 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 4 Apr 2014 12:58:12 -0600 Subject: [PATCH 042/179] :lipstick: Reorganize spec --- spec/editor-component-spec.coffee | 55 ++++++++++++++++--------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 2affd5114..ab5b20bb8 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -19,28 +19,39 @@ describe "EditorComponent", -> {lineHeightInPixels, charWidth} = component.measureLineDimensions() node = component.getDOMNode() - it "renders only the currently-visible lines", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + describe "scrolling", -> + it "renders only the currently-visible lines", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() - lines = node.querySelectorAll('.line') - expect(lines.length).toBe 5 - expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text - expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text + lines = node.querySelectorAll('.line') + expect(lines.length).toBe 5 + expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text + expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels - component.onVerticalScroll() + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels + component.onVerticalScroll() - expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" - lines = node.querySelectorAll('.line') - expect(lines.length).toBe 5 - expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text - expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text + lines = node.querySelectorAll('.line') + expect(lines.length).toBe 5 + expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text + expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text - spacers = node.querySelectorAll('.spacer') - expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels - expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeightInPixels + spacers = node.querySelectorAll('.spacer') + expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels + expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeightInPixels + + it "updates the scroll bar when the scrollTop is changed in the model", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() + + scrollbarNode = node.querySelector('.vertical-scrollbar') + expect(scrollbarNode.scrollTop).toBe 0 + + editor.setScrollTop(10) + expect(scrollbarNode.scrollTop).toBe 10 describe "cursor rendering", -> it "renders the currently visible cursors", -> @@ -151,16 +162,6 @@ describe "EditorComponent", -> expect(region3Rect.left).toBe 0 expect(region3Rect.width).toBe 10 * charWidth - it "updates the scroll bar when the scrollTop is changed in the model", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() - - scrollbarNode = node.querySelector('.vertical-scrollbar') - expect(scrollbarNode.scrollTop).toBe 0 - - editor.setScrollTop(10) - expect(scrollbarNode.scrollTop).toBe 10 - it "transfers focus to the hidden input", -> expect(document.activeElement).toBe document.body node.focus() From 97a353b31a6a2c7ba09ba18013471959f3ea5b3b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 11:44:53 -0600 Subject: [PATCH 043/179] Measure each line with a fresh range Recycling the range leads to strange timing-related measurement errors in certain cases. --- src/editor-component.coffee | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a7773c00b..49254da3c 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -9,7 +9,6 @@ CustomEventMixin = require './custom-event-mixin' SubscriberMixin = require './subscriber-mixin' DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] -MeasureRange = document.createRange() TextNodeFilter = { acceptNode: -> NodeFilter.FILTER_ACCEPT } module.exports = @@ -334,15 +333,16 @@ EditorCompont = React.createClass measureCharactersInLine: (tokenizedLine, lineNode) -> {editor} = @props iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, TextNodeFilter) + rangeForMeasurement = document.createRange() for {value, scopes} in tokenizedLine.tokens textNode = iterator.nextNode() charWidths = editor.getScopedCharWidths(scopes) for char, i in value unless charWidths[char]? - MeasureRange.setStart(textNode, i) - MeasureRange.setEnd(textNode, i + 1) - charWidth = MeasureRange.getBoundingClientRect().width + rangeForMeasurement.setStart(textNode, i) + rangeForMeasurement.setEnd(textNode, i + 1) + charWidth = rangeForMeasurement.getBoundingClientRect().width editor.setScopedCharWidth(scopes, char, charWidth) @measuredLines.add(tokenizedLine) From b879221a966892b97cf19f60824bfd5bbb171650 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 11:45:20 -0600 Subject: [PATCH 044/179] :lipstick: Clarify variable name --- src/editor-component.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 49254da3c..30c77e1a8 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -9,7 +9,7 @@ CustomEventMixin = require './custom-event-mixin' SubscriberMixin = require './subscriber-mixin' DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] -TextNodeFilter = { acceptNode: -> NodeFilter.FILTER_ACCEPT } +AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} module.exports = EditorCompont = React.createClass @@ -332,7 +332,7 @@ EditorCompont = React.createClass measureCharactersInLine: (tokenizedLine, lineNode) -> {editor} = @props - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, TextNodeFilter) + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) rangeForMeasurement = document.createRange() for {value, scopes} in tokenizedLine.tokens From e24196c0ef12f27c324044064ab542cd245b7869 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 11:46:33 -0600 Subject: [PATCH 045/179] Require React directly rather than via reactionary Reactionary is just a tiny helper library that can rely on the react installed by the requiring application instead. --- package.json | 4 +++- spec/editor-component-spec.coffee | 2 +- src/cursor-component.coffee | 3 ++- src/editor-component.coffee | 4 +++- src/input-component.coffee | 3 ++- src/react-editor-view.coffee | 2 +- src/selection-component.coffee | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index a8ca508f9..28f644499 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "coffeestack": "0.7.0", "delegato": "1.x", "emissary": "^1.2.1", + "envify": "^1.2.1", "first-mate": "^1.5.2", "fs-plus": "^2.2.2", "fstream": "0.1.24", @@ -40,7 +41,8 @@ "property-accessors": "1.x", "q": "^1.0.1", "random-words": "0.0.1", - "reactionary": "^0.7.0", + "react": "^0.10.0", + "reactionary": "^0.8.0", "runas": "0.5.x", "scandal": "0.15.2", "scoped-property-store": "^0.8.0", diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index ab5b20bb8..7e59739d3 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -1,4 +1,4 @@ -{React} = require 'reactionary' +React = require 'react' EditorComponent = require '../src/editor-component' describe "EditorComponent", -> diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index 7952b7d57..08344a9fe 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -1,4 +1,5 @@ -{React, div} = require 'reactionary' +React = require 'react' +{div} = require 'reactionary' SubscriberMixin = require './subscriber-mixin' module.exports = diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 30c77e1a8..e95dd40b1 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,4 +1,6 @@ -{React, div, span} = require 'reactionary' +React = require 'react' +ReactUpdates = require 'react/lib/ReactUpdates' +{div, span} = require 'reactionary' {$$} = require 'space-pen' {debounce} = require 'underscore-plus' diff --git a/src/input-component.coffee b/src/input-component.coffee index 79f52d920..55ff458bc 100644 --- a/src/input-component.coffee +++ b/src/input-component.coffee @@ -1,6 +1,7 @@ punycode = require 'punycode' {last} = require 'underscore-plus' -{React, input} = require 'reactionary' +React = require 'react' +{input} = require 'reactionary' module.exports = InputComponent = React.createClass diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 4120789ec..5d1f953ab 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -1,5 +1,5 @@ {View} = require 'space-pen' -{React} = require 'reactionary' +React = require 'react' EditorComponent = require './editor-component' module.exports = diff --git a/src/selection-component.coffee b/src/selection-component.coffee index 6c895aa90..1e826a6e3 100644 --- a/src/selection-component.coffee +++ b/src/selection-component.coffee @@ -1,4 +1,5 @@ -{React, div} = require 'reactionary' +React = require 'react' +{div} = require 'reactionary' SubscriberMixin = require './subscriber-mixin' module.exports = From 8772e9693539034cce24ba3028fa7ec079b0ba9c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 11:47:09 -0600 Subject: [PATCH 046/179] Temporarily remove '.editor' class from react editor wrapper This will prevent warnings from packages during testing. --- src/react-editor-view.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 5d1f953ab..1e463d203 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -4,7 +4,7 @@ EditorComponent = require './editor-component' module.exports = class ReactEditorView extends View - @content: -> @div class: 'editor react-wrapper' + @content: -> @div class: 'react-wrapper' constructor: (@editor) -> super From 0c960552f06859fb6f7dda1801a8c42bb5a8ebe3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 11:47:23 -0600 Subject: [PATCH 047/179] Batch updates on input events --- src/editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index e95dd40b1..9aceadab9 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -277,7 +277,7 @@ EditorCompont = React.createClass @props.editor.setHeight(@refs.scrollView.getDOMNode().clientHeight) onInput: (char, replaceLastChar) -> - @props.editor.insertText(char) + ReactUpdates.batchedUpdates => @props.editor.insertText(char) onScreenLinesChanged: ({start, end}) -> {editor} = @props From b0ad5e2f691dca5eec88b462bc3ad9180bf88d62 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 13:34:27 -0600 Subject: [PATCH 048/179] :lipstick: --- src/editor-component.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 9aceadab9..2f411e78d 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -93,7 +93,7 @@ EditorCompont = React.createClass @props.editor.setVisible(true) componentWillUnmount: -> - @getDOMNode().removeEventListener 'mousewheel', @onMousewheel + @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel componentDidUpdate: -> @updateVerticalScrollbar() @@ -125,7 +125,7 @@ EditorCompont = React.createClass listenForDOMEvents: -> scrollViewNode = @refs.scrollView.getDOMNode() - scrollViewNode.addEventListener 'mousewheel', @onMousewheel + scrollViewNode.addEventListener 'mousewheel', @onMouseWheel scrollViewNode.addEventListener 'overflowchanged', @onOverflowChanged @getDOMNode().addEventListener 'focus', @onFocus @@ -256,7 +256,7 @@ EditorCompont = React.createClass @props.editor.setScrollTop(@pendingScrollTop) @pendingScrollTop = null - onMousewheel: (event) -> + onMouseWheel: (event) -> # To preserve velocity scrolling, delay removal of the event's target until # after mousewheel events stop being fired. Removing the target before then # will cause scrolling to stop suddenly. From 3a433f734caaff8224611d9d9b0ce402fee3f7d7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 13:34:50 -0600 Subject: [PATCH 049/179] Move the cursor on single click --- spec/editor-component-spec.coffee | 16 ++++++++++++++++ src/display-buffer.coffee | 19 +++++++++++++++++++ src/editor-component.coffee | 13 ++++++++++++- src/editor.coffee | 5 ++--- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 7e59739d3..6c034448e 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -162,6 +162,22 @@ describe "EditorComponent", -> expect(region3Rect.left).toBe 0 expect(region3Rect.width).toBe 10 * charWidth + describe "mouse interactions", -> + clientCoordinatesForScreenPosition = (screenPosition) -> + positionOffset = editor.pixelPositionForScreenPosition(screenPosition) + editorClientRect = node.getBoundingClientRect() + clientX = editorClientRect.left + positionOffset.left + clientY = editorClientRect.top + positionOffset.top - editor.getScrollTop() + {clientX, clientY} + + describe "when a non-folded line is single-clicked", -> + it "moves the cursor to the nearest row and column", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() + editor.setScrollTop(3.5 * lineHeightInPixels) + component.onMouseDown(clientCoordinatesForScreenPosition([4, 8])) + expect(editor.getCursorScreenPosition()).toEqual [4, 8] + it "transfers focus to the hidden input", -> expect(document.activeElement).toBe document.body node.focus() diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index cc1d2c362..8da7af7f0 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -383,6 +383,25 @@ class DisplayBuffer extends Model column++ {top, left} + screenPositionForPixelPosition: (pixelPosition) -> + targetTop = pixelPosition.top + targetLeft = pixelPosition.left + row = Math.floor(targetTop / @getLineHeight()) + row = Math.min(row, @getLastRow()) + row = Math.max(0, row) + + left = 0 + column = 0 + for token in @lineForRow(row).tokens + charWidths = @getScopedCharWidths(token.scopes) + for char in token.value + charWidth = charWidths[char] ? defaultCharWidth + break if targetLeft <= left + (charWidth / 2) + left += charWidth + column++ + + new Point(row, column) + # Gets the number of screen lines. # # Returns a {Number}. diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 2f411e78d..80acec657 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -38,7 +38,7 @@ EditorCompont = React.createClass height = editor.getScrollHeight() WebkitTransform = "translateY(#{-editor.getScrollTop()}px)" - div className: 'scrollable-content', style: {height, WebkitTransform}, + div className: 'scrollable-content', style: {height, WebkitTransform}, onMouseDown: @onMouseDown, @renderCursors() @renderVisibleLines() @renderUnderlayer() @@ -267,6 +267,17 @@ EditorCompont = React.createClass @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY event.preventDefault() + onMouseDown: (event) -> + {editor} = @props + {clientX, clientY} = event + editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() + + pixelPosition = + top: clientY - editorClientRect.top + editor.getScrollTop() + left: clientX - editorClientRect.left + screenPosition = editor.screenPositionForPixelPosition(pixelPosition) + editor.setCursorScreenPosition(screenPosition) + clearVisibleRowOverrides: -> @visibleRowOverrides = null @forceUpdate() diff --git a/src/editor.coffee b/src/editor.coffee index e4dfeaa62..c841c4952 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -153,7 +153,8 @@ class Editor extends Model @delegatesMethods 'setLineHeight', 'getLineHeight', 'setDefaultCharWidth', 'setHeight', 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', 'getScrollBottom', 'setScrollBottom', 'setScrollLeft', 'getScrollLeft', 'getScrollHeight', 'getVisibleRowRange', - 'intersectsVisibleRowRange', 'selectionIntersectsVisibleRowRange', toProperty: 'displayBuffer' + 'intersectsVisibleRowRange', 'selectionIntersectsVisibleRowRange', 'pixelPositionForScreenPosition', + 'screenPositionForPixelPosition', toProperty: 'displayBuffer' @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', '$scrollTop', '$scrollLeft', toProperty: 'displayBuffer' @@ -512,8 +513,6 @@ class Editor extends Model # this editor. shouldPromptToSave: -> @isModified() and not @buffer.hasMultipleEditors() - pixelPositionForScreenPosition: (screenPosition) -> @displayBuffer.pixelPositionForScreenPosition(screenPosition) - # Public: Convert a position in buffer-coordinates to screen-coordinates. # # The position is clipped via {::clipBufferPosition} prior to the conversion. From 7738e74df0e1a70e01ee0346a40333ae0a950937 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 13:56:38 -0600 Subject: [PATCH 050/179] When shift-clicking, select to the clicked position --- spec/editor-component-spec.coffee | 26 ++++++++++++++++++-------- src/editor-component.coffee | 8 ++++++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 6c034448e..7b5c68c44 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -1,4 +1,5 @@ React = require 'react' +{extend} = require 'underscore-plus' EditorComponent = require '../src/editor-component' describe "EditorComponent", -> @@ -163,6 +164,23 @@ describe "EditorComponent", -> expect(region3Rect.width).toBe 10 * charWidth describe "mouse interactions", -> + describe "when a non-folded line is single-clicked", -> + describe "when no modifier keys are held down", -> + it "moves the cursor to the nearest row and column", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() + editor.setScrollTop(3.5 * lineHeightInPixels) + + component.onMouseDown(clientCoordinatesForScreenPosition([4, 8])) + expect(editor.getCursorScreenPosition()).toEqual [4, 8] + + describe "when the shift key is held down", -> + it "selects to the nearest row and column", -> + editor.setCursorScreenPosition([3, 4]) + event = extend(clientCoordinatesForScreenPosition([5, 6]), shiftKey: true) + component.onMouseDown(event) + expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [5, 6]] + clientCoordinatesForScreenPosition = (screenPosition) -> positionOffset = editor.pixelPositionForScreenPosition(screenPosition) editorClientRect = node.getBoundingClientRect() @@ -170,14 +188,6 @@ describe "EditorComponent", -> clientY = editorClientRect.top + positionOffset.top - editor.getScrollTop() {clientX, clientY} - describe "when a non-folded line is single-clicked", -> - it "moves the cursor to the nearest row and column", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() - editor.setScrollTop(3.5 * lineHeightInPixels) - component.onMouseDown(clientCoordinatesForScreenPosition([4, 8])) - expect(editor.getCursorScreenPosition()).toEqual [4, 8] - it "transfers focus to the hidden input", -> expect(document.activeElement).toBe document.body node.focus() diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 80acec657..c5a303f74 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -269,14 +269,18 @@ EditorCompont = React.createClass onMouseDown: (event) -> {editor} = @props - {clientX, clientY} = event + {clientX, clientY, shiftKey} = event editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() pixelPosition = top: clientY - editorClientRect.top + editor.getScrollTop() left: clientX - editorClientRect.left screenPosition = editor.screenPositionForPixelPosition(pixelPosition) - editor.setCursorScreenPosition(screenPosition) + + if shiftKey + editor.selectToScreenPosition(screenPosition) + else + editor.setCursorScreenPosition(screenPosition) clearVisibleRowOverrides: -> @visibleRowOverrides = null From af9355e4e6a139619c4a5616f102b1b711039238 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 14:00:44 -0600 Subject: [PATCH 051/179] Add Editor::getSelectedScreenRanges --- src/editor.coffee | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/editor.coffee b/src/editor.coffee index c841c4952..ec4709790 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1356,6 +1356,14 @@ class Editor extends Model getSelectedBufferRanges: -> selection.getBufferRange() for selection in @getSelectionsOrderedByBufferPosition() + # Public: Get the {Range}s of all selections in screen coordinates. + # + # The ranges are sorted by their position in the buffer. + # + # Returns an {Array} of {Range}s. + getSelectedScreenRanges: -> + selection.getScreenRange() for selection in @getSelectionsOrderedByBufferPosition() + # Public: Get the selected text of the most recently added selection. # # Returns a {String}. From 5a0d7e716bd2947c63fb865fcdc402b2691bc6e9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 14:01:19 -0600 Subject: [PATCH 052/179] When cmd-clicking, add a new cursor at the nearest screen position --- spec/editor-component-spec.coffee | 7 +++++++ src/editor-component.coffee | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 7b5c68c44..c05ba4cf7 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -181,6 +181,13 @@ describe "EditorComponent", -> component.onMouseDown(event) expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [5, 6]] + describe "when the command key is held down", -> + it "adds a cursor at the nearest row and column", -> + editor.setCursorScreenPosition([3, 4]) + event = extend(clientCoordinatesForScreenPosition([5, 6]), metaKey: true) + component.onMouseDown(event) + expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] + clientCoordinatesForScreenPosition = (screenPosition) -> positionOffset = editor.pixelPositionForScreenPosition(screenPosition) editorClientRect = node.getBoundingClientRect() diff --git a/src/editor-component.coffee b/src/editor-component.coffee index c5a303f74..7828c7331 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -269,7 +269,7 @@ EditorCompont = React.createClass onMouseDown: (event) -> {editor} = @props - {clientX, clientY, shiftKey} = event + {clientX, clientY, shiftKey, metaKey} = event editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() pixelPosition = @@ -279,6 +279,8 @@ EditorCompont = React.createClass if shiftKey editor.selectToScreenPosition(screenPosition) + else if metaKey + editor.addCursorAtScreenPosition(screenPosition) else editor.setCursorScreenPosition(screenPosition) From 1239ab540f4170a1159f0751ad5c5c8eb4f7d88d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 14:01:48 -0600 Subject: [PATCH 053/179] :lipstick: spec descriptions --- spec/editor-component-spec.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index c05ba4cf7..076ba0ad7 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -166,7 +166,7 @@ describe "EditorComponent", -> describe "mouse interactions", -> describe "when a non-folded line is single-clicked", -> describe "when no modifier keys are held down", -> - it "moves the cursor to the nearest row and column", -> + it "moves the cursor to the nearest screen position", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() editor.setScrollTop(3.5 * lineHeightInPixels) @@ -175,14 +175,14 @@ describe "EditorComponent", -> expect(editor.getCursorScreenPosition()).toEqual [4, 8] describe "when the shift key is held down", -> - it "selects to the nearest row and column", -> + it "selects to the nearest screen position", -> editor.setCursorScreenPosition([3, 4]) event = extend(clientCoordinatesForScreenPosition([5, 6]), shiftKey: true) component.onMouseDown(event) expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [5, 6]] describe "when the command key is held down", -> - it "adds a cursor at the nearest row and column", -> + it "adds a cursor at the nearest screen position", -> editor.setCursorScreenPosition([3, 4]) event = extend(clientCoordinatesForScreenPosition([5, 6]), metaKey: true) component.onMouseDown(event) From accee294dc5514b1a5108b77bd8c2d46e9410a00 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 15:47:08 -0600 Subject: [PATCH 054/179] Test against real mousedown events dispatched on the .lines DOM node --- spec/editor-component-spec.coffee | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 076ba0ad7..f53c00a89 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -164,6 +164,12 @@ describe "EditorComponent", -> expect(region3Rect.width).toBe 10 * charWidth describe "mouse interactions", -> + linesNode = null + + beforeEach -> + delayAnimationFrames = true + linesNode = node.querySelector('.lines') + describe "when a non-folded line is single-clicked", -> describe "when no modifier keys are held down", -> it "moves the cursor to the nearest screen position", -> @@ -171,21 +177,19 @@ describe "EditorComponent", -> component.updateAllDimensions() editor.setScrollTop(3.5 * lineHeightInPixels) - component.onMouseDown(clientCoordinatesForScreenPosition([4, 8])) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]))) expect(editor.getCursorScreenPosition()).toEqual [4, 8] describe "when the shift key is held down", -> it "selects to the nearest screen position", -> editor.setCursorScreenPosition([3, 4]) - event = extend(clientCoordinatesForScreenPosition([5, 6]), shiftKey: true) - component.onMouseDown(event) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), shiftKey: true)) expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [5, 6]] describe "when the command key is held down", -> it "adds a cursor at the nearest screen position", -> editor.setCursorScreenPosition([3, 4]) - event = extend(clientCoordinatesForScreenPosition([5, 6]), metaKey: true) - component.onMouseDown(event) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), metaKey: true)) expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] clientCoordinatesForScreenPosition = (screenPosition) -> @@ -195,6 +199,12 @@ describe "EditorComponent", -> clientY = editorClientRect.top + positionOffset.top - editor.getScrollTop() {clientX, clientY} + buildMouseEvent = (type, properties...) -> + properties = extend({bubbles: true, cancelable: true}, properties...) + event = new MouseEvent(type, properties) + Object.defineProperty(event, 'which', get: -> properties.which) if properties.which? + event + it "transfers focus to the hidden input", -> expect(document.activeElement).toBe document.body node.focus() From c4be3069f767d729c487810ceb1c48de2adfbad7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 6 Apr 2014 15:47:50 -0600 Subject: [PATCH 055/179] Select on mouse drag --- spec/editor-component-spec.coffee | 40 +++++++++++++++++++- src/display-buffer.coffee | 1 + src/editor-component.coffee | 62 ++++++++++++++++++++++++++----- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index f53c00a89..e83b2f298 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -3,14 +3,20 @@ React = require 'react' EditorComponent = require '../src/editor-component' describe "EditorComponent", -> - [editor, component, node, lineHeightInPixels, charWidth] = [] + [editor, component, node, lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] beforeEach -> waitsForPromise -> atom.packages.activatePackage('language-javascript') runs -> - spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() + delayAnimationFrames = false + nextAnimationFrame = null + spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> + if delayAnimationFrames + nextAnimationFrame = fn + else + fn() editor = atom.project.openSync('sample.js') container = document.querySelector('#jasmine-content') @@ -192,6 +198,36 @@ describe "EditorComponent", -> linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), metaKey: true)) expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] + describe "when the mouse is clicked and dragged", -> + it "selects to the nearest screen position until the mouse button is released", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [10, 0]] + + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([12, 0]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [10, 0]] + + it "stops selecting if the mouse is dragged into the dev tools", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), which: 0)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + clientCoordinatesForScreenPosition = (screenPosition) -> positionOffset = editor.pixelPositionForScreenPosition(screenPosition) editorClientRect = node.getBoundingClientRect() diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 8da7af7f0..15e906f00 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -386,6 +386,7 @@ class DisplayBuffer extends Model screenPositionForPixelPosition: (pixelPosition) -> targetTop = pixelPosition.top targetLeft = pixelPosition.left + defaultCharWidth = @defaultCharWidth row = Math.floor(targetTop / @getLineHeight()) row = Math.min(row, @getLastRow()) row = Math.max(0, row) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 7828c7331..9bf0a6215 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -17,6 +17,7 @@ module.exports = EditorCompont = React.createClass pendingScrollTop: null lastScrollTop: null + selectOnMouseMove: false statics: {DummyLineNode} @@ -35,10 +36,11 @@ EditorCompont = React.createClass renderScrollableContent: -> {editor} = @props - height = editor.getScrollHeight() - WebkitTransform = "translateY(#{-editor.getScrollTop()}px)" + style = + height: editor.getScrollHeight() + WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" - div className: 'scrollable-content', style: {height, WebkitTransform}, onMouseDown: @onMouseDown, + div {className: 'scrollable-content', style, @onMouseDown}, @renderCursors() @renderVisibleLines() @renderUnderlayer() @@ -269,13 +271,8 @@ EditorCompont = React.createClass onMouseDown: (event) -> {editor} = @props - {clientX, clientY, shiftKey, metaKey} = event - editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() - - pixelPosition = - top: clientY - editorClientRect.top + editor.getScrollTop() - left: clientX - editorClientRect.left - screenPosition = editor.screenPositionForPixelPosition(pixelPosition) + {shiftKey, metaKey} = event + screenPosition = @screenPositionForMouseEvent(event) if shiftKey editor.selectToScreenPosition(screenPosition) @@ -284,6 +281,51 @@ EditorCompont = React.createClass else editor.setCursorScreenPosition(screenPosition) + @selectToMousePositionUntilMouseUp(event) + + selectToMousePositionUntilMouseUp: (event) -> + dragging = true + lastMousePosition = {clientX: event.clientX, clientY: event.clientY} + + onMouseMove = (event) -> + # Stop dragging when cursor enters dev tools because we can't detect mouseup + dragging = false if event.which is 0 + + lastMousePosition.clientX = event.clientX + lastMousePosition.clientY = event.clientY + + onMouseUp = -> + dragging = false + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + + animationLoop = => + requestAnimationFrame => + if dragging + @selectToMousePosition(lastMousePosition) + animationLoop() + + animationLoop() + + selectToMousePosition: (event) -> + @props.editor.selectToScreenPosition(@screenPositionForMouseEvent(event)) + + screenPositionForMouseEvent: (event) -> + pixelPosition = @pixelPositionForMouseEvent(event) + @props.editor.screenPositionForPixelPosition(pixelPosition) + + pixelPositionForMouseEvent: (event) -> + {editor} = @props + {clientX, clientY} = event + + editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() + top = clientY - editorClientRect.top + editor.getScrollTop() + left = clientX - editorClientRect.left + {top, left} + clearVisibleRowOverrides: -> @visibleRowOverrides = null @forceUpdate() From cbad8a56ecee32fca9f2090d24c75cc40ddc47e3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 11:09:44 -0600 Subject: [PATCH 056/179] Don't start the animation loop until the mouse starts dragging Previously, the animation loop would run multiple times prior to the the mouseup event on click. We only want to select to the current mouse position if the mouse is actually dragged. --- src/editor-component.coffee | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 9bf0a6215..4ae9815df 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -284,16 +284,28 @@ EditorCompont = React.createClass @selectToMousePositionUntilMouseUp(event) selectToMousePositionUntilMouseUp: (event) -> - dragging = true - lastMousePosition = {clientX: event.clientX, clientY: event.clientY} + {editor} = @props + dragging = false + lastMousePosition = {} + + animationLoop = => + requestAnimationFrame => + if dragging + @selectToMousePosition(lastMousePosition) + animationLoop() onMouseMove = (event) -> - # Stop dragging when cursor enters dev tools because we can't detect mouseup - dragging = false if event.which is 0 - lastMousePosition.clientX = event.clientX lastMousePosition.clientY = event.clientY + # Start the animation loop when the mouse moves prior to a mouseup event + unless dragging + dragging = true + animationLoop() + + # Stop dragging when cursor enters dev tools because we can't detect mouseup + onMouseUp() if event.which is 0 + onMouseUp = -> dragging = false window.removeEventListener('mousemove', onMouseMove) @@ -302,14 +314,6 @@ EditorCompont = React.createClass window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) - animationLoop = => - requestAnimationFrame => - if dragging - @selectToMousePosition(lastMousePosition) - animationLoop() - - animationLoop() - selectToMousePosition: (event) -> @props.editor.selectToScreenPosition(@screenPositionForMouseEvent(event)) From 2204571ef5deceb0ae81595743c089a20ed83227 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 11:10:14 -0600 Subject: [PATCH 057/179] Select words on double-click --- spec/editor-component-spec.coffee | 8 ++++++++ src/editor-component.coffee | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index e83b2f298..901db3da0 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -198,6 +198,14 @@ describe "EditorComponent", -> linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), metaKey: true)) expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] + describe "when a non-folded line is double-clicked", -> + it "selects the word containing the nearest screen position", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), detail: 2)) + expect(editor.getSelectedScreenRange()).toEqual [[5, 6], [5, 13]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), detail: 1)) + expect(editor.getSelectedScreenRange()).toEqual [[6, 6], [6, 6]] + describe "when the mouse is clicked and dragged", -> it "selects to the nearest screen position until the mouse button is released", -> linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 4ae9815df..116552ffa 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -271,7 +271,7 @@ EditorCompont = React.createClass onMouseDown: (event) -> {editor} = @props - {shiftKey, metaKey} = event + {detail, shiftKey, metaKey} = event screenPosition = @screenPositionForMouseEvent(event) if shiftKey @@ -280,6 +280,8 @@ EditorCompont = React.createClass editor.addCursorAtScreenPosition(screenPosition) else editor.setCursorScreenPosition(screenPosition) + if detail is 2 + editor.selectWord() @selectToMousePositionUntilMouseUp(event) From 096afcf6f3c5214e086e9918514b960b502334d1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 11:57:53 -0600 Subject: [PATCH 058/179] Finalize selections on mouseup --- spec/editor-component-spec.coffee | 6 ++++++ src/editor-component.coffee | 1 + 2 files changed, 7 insertions(+) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 901db3da0..e2f0644d6 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -201,11 +201,17 @@ describe "EditorComponent", -> describe "when a non-folded line is double-clicked", -> it "selects the word containing the nearest screen position", -> linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), detail: 2)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) expect(editor.getSelectedScreenRange()).toEqual [[5, 6], [5, 13]] linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), detail: 1)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) expect(editor.getSelectedScreenRange()).toEqual [[6, 6], [6, 6]] + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), detail: 1, shiftKey: true)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[6, 6], [8, 8]] + describe "when the mouse is clicked and dragged", -> it "selects to the nearest screen position until the mouse button is released", -> linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 116552ffa..7d5f40f70 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -312,6 +312,7 @@ EditorCompont = React.createClass dragging = false window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) + editor.finalizeSelections() window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) From 4f105001029538fec8644e0f70a5b863afaca31b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 12:04:59 -0600 Subject: [PATCH 059/179] Select lines on triple-click --- spec/editor-component-spec.coffee | 16 ++++++++++++++++ src/editor-component.coffee | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index e2f0644d6..31d941b22 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -212,6 +212,22 @@ describe "EditorComponent", -> linesNode.dispatchEvent(buildMouseEvent('mouseup')) expect(editor.getSelectedScreenRange()).toEqual [[6, 6], [8, 8]] + describe "when a non-folded line is triple-clicked", -> + it "selects the line containing the nearest screen position", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), detail: 3)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[5, 0], [6, 0]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), detail: 1, shiftKey: true)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[5, 0], [7, 0]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 8]), detail: 1)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), detail: 1, shiftKey: true)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[7, 8], [8, 8]] + describe "when the mouse is clicked and dragged", -> it "selects to the nearest screen position until the mouse button is released", -> linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 7d5f40f70..920cf05c1 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -280,8 +280,9 @@ EditorCompont = React.createClass editor.addCursorAtScreenPosition(screenPosition) else editor.setCursorScreenPosition(screenPosition) - if detail is 2 - editor.selectWord() + switch detail + when 2 then editor.selectWord() + when 3 then editor.selectLine() @selectToMousePositionUntilMouseUp(event) From f60f9b9f4f0968aa8fde206b827e21112e3ff146 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 13:06:40 -0600 Subject: [PATCH 060/179] Add 'is-focused' class to editor when hidden input is focused --- spec/editor-component-spec.coffee | 21 +++++++++++++++++---- src/editor-component.coffee | 22 +++++++++++++++------- src/input-component.coffee | 10 +++++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 31d941b22..3aa509754 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -271,7 +271,20 @@ describe "EditorComponent", -> Object.defineProperty(event, 'which', get: -> properties.which) if properties.which? event - it "transfers focus to the hidden input", -> - expect(document.activeElement).toBe document.body - node.focus() - expect(document.activeElement).toBe node.querySelector('.hidden-input') + describe "focus handling", -> + inputNode = null + + beforeEach -> + inputNode = node.querySelector('.hidden-input') + + it "transfers focus to the hidden input", -> + expect(document.activeElement).toBe document.body + node.focus() + expect(document.activeElement).toBe inputNode + + it "adds the 'is-focused' class to the editor when the hidden input is focused", -> + expect(document.activeElement).toBe document.body + inputNode.focus() + expect(node.classList.contains('is-focused')).toBe true + inputNode.blur() + expect(node.classList.contains('is-focused')).toBe false diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 920cf05c1..06dbcaf18 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -24,12 +24,14 @@ EditorCompont = React.createClass mixins: [CustomEventMixin, SubscriberMixin] render: -> - {fontSize, lineHeight, fontFamily} = @state + {fontSize, lineHeight, fontFamily, focused} = @state {editor} = @props + className = 'editor react' + className += ' is-focused' if focused - div className: 'editor react', tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, + div className: className, tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, div className: 'scroll-view', ref: 'scrollView', - InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput + InputComponent ref: 'input', className: 'hidden-input', onInput: @onInput, onFocus: @onInputFocused, onBlur: @onInputBlurred @renderScrollableContent() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div outlet: 'verticalScrollbarContent', style: {height: editor.getScrollHeight()} @@ -245,7 +247,16 @@ EditorCompont = React.createClass @updateLineDimensions() onFocus: -> - @refs.hiddenInput.focus() + @refs.input.focus() + + onInputFocused: -> + @setState(focused: true) + + onInputBlurred: -> + @setState(focused: false) unless document.activeElement is @getDOMNode() + + onInput: (char, replaceLastChar) -> + ReactUpdates.batchedUpdates => @props.editor.insertText(char) onVerticalScroll: -> scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop @@ -343,9 +354,6 @@ EditorCompont = React.createClass onOverflowChanged: -> @props.editor.setHeight(@refs.scrollView.getDOMNode().clientHeight) - onInput: (char, replaceLastChar) -> - ReactUpdates.batchedUpdates => @props.editor.insertText(char) - onScreenLinesChanged: ({start, end}) -> {editor} = @props @requestUpdate() if editor.intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events diff --git a/src/input-component.coffee b/src/input-component.coffee index 55ff458bc..6ea54490c 100644 --- a/src/input-component.coffee +++ b/src/input-component.coffee @@ -6,7 +6,9 @@ React = require 'react' module.exports = InputComponent = React.createClass render: -> - input className: @props.className, ref: 'input' + {className, onFocus, onBlur} = @props + + input {className, onFocus, onBlur} getInitialState: -> {lastChar: ''} @@ -35,5 +37,11 @@ InputComponent = React.createClass lastChar = String.fromCharCode(last(valueCharCodes)) @props.onInput?(lastChar, replaceLastChar) + onFocus: -> + @props.onFocus?() + + onBlur: -> + @props.onBlur?() + focus: -> @getDOMNode().focus() From 2b0ef682550000045126e393ccffe7f76f5fcb87 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 13:51:25 -0600 Subject: [PATCH 061/179] Blink cursors always. Still need to pause blinking when moving. --- spec/editor-component-spec.coffee | 18 ++++++++++++++++++ spec/spec-helper.coffee | 11 +++++++++++ src/cursor-component.coffee | 5 ++++- src/editor-component.coffee | 12 ++++++++++-- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 3aa509754..01a92bcd9 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -117,6 +117,24 @@ describe "EditorComponent", -> expect(cursorRect.left).toBe rangeRect.left expect(cursorRect.width).toBe rangeRect.width + it "blinks cursors", -> + 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 + + advanceClock(component.props.cursorBlinkPeriod / 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 + + advanceClock(component.props.cursorBlinkPeriod / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe true + expect(cursorNode2.classList.contains('blink-off')).toBe true + describe "selection rendering", -> it "renders 1 region for 1-line selections", -> # 1-line selection diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 5574abae5..13b4449d4 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -92,6 +92,8 @@ beforeEach -> spyOn(WorkspaceView.prototype, 'setTitle').andCallFake (@title) -> spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout + spyOn(window, "setInterval").andCallFake window.fakeSetInterval + spyOn(window, "clearInterval").andCallFake window.fakeClearInterval spyOn(pathwatcher.File.prototype, "detectResurrectionAfterDelay").andCallFake -> @detectResurrection() spyOn(Editor.prototype, "shouldPromptToSave").andReturn false @@ -243,6 +245,15 @@ window.fakeSetTimeout = (callback, ms) -> window.fakeClearTimeout = (idToClear) -> window.timeouts = window.timeouts.filter ([id]) -> id != idToClear +window.fakeSetInterval = (callback, ms) -> + action = -> + callback() + window.fakeSetTimeout(action, ms) + window.fakeSetTimeout(action, ms) + +window.fakeClearInterval = (idToClear) -> + window.fakeClearTimeout(idToClear) + window.advanceClock = (delta=1) -> window.now += delta callbacks = [] diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index 08344a9fe..515341e07 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -8,7 +8,10 @@ CursorComponent = React.createClass render: -> {top, left, height, width} = @props.cursor.getPixelRect() - div className: 'cursor', style: {top, left, height, width} + className = 'cursor' + className += ' blink-off' if @props.blinkOff + + div className: className, style: {top, left, height, width} componentDidMount: -> @subscribe @props.cursor, 'moved', => @forceUpdate() diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 06dbcaf18..387e24724 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -63,9 +63,10 @@ EditorCompont = React.createClass renderCursors: -> {editor} = @props + {blinkCursorsOff} = @state for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - CursorComponent(cursor: selection.cursor) + CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) renderUnderlayer: -> {editor} = @props @@ -83,7 +84,7 @@ EditorCompont = React.createClass getInitialState: -> {} - getDefaultProps: -> updateSync: true + getDefaultProps: -> cursorBlinkPeriod: 800 componentDidMount: -> @measuredLines = new WeakSet @@ -92,12 +93,14 @@ EditorCompont = React.createClass @listenForCustomEvents() @observeEditor() @observeConfig() + @blinkCursors() @updateAllDimensions() @props.editor.setVisible(true) componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel + clearInterval(@cursorBlinkIntervalHandle) componentDidUpdate: -> @updateVerticalScrollbar() @@ -233,6 +236,11 @@ EditorCompont = React.createClass observeConfig: -> @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily + blinkCursors: -> + @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) + + toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) + setFontSize: (fontSize) -> @clearScopedCharWidths() @setState({fontSize}) From 5c9a5cdc855afe9ed4104a835bc925e9e623560c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 14:28:07 -0600 Subject: [PATCH 062/179] Emit 'cursors-moved' from editor when one or more cursors move --- spec/editor-spec.coffee | 15 +++++++++++++++ src/cursor.coffee | 2 +- src/editor.coffee | 7 +++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index 3e038f26e..e5b599150 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -111,6 +111,21 @@ describe "Editor", -> editor.moveCursorDown() expect(editor.getCursorBufferPosition()).toEqual [1, 1] + it "emits a single 'cursors-moved' event for all moved cursors", -> + editor.on 'cursors-moved', cursorsMovedHandler = jasmine.createSpy("cursorsMovedHandler") + + editor.moveCursorDown() + expect(cursorsMovedHandler.callCount).toBe 1 + + cursorsMovedHandler.reset() + editor.addCursorAtScreenPosition([3, 0]) + editor.moveCursorDown() + expect(cursorsMovedHandler.callCount).toBe 1 + + cursorsMovedHandler.reset() + editor.getCursor().moveDown() + expect(cursorsMovedHandler.callCount).toBe 1 + describe ".setCursorScreenPosition(screenPosition)", -> it "clears a goal column established by vertical movement", -> # set a goal column by moving down diff --git a/src/cursor.coffee b/src/cursor.coffee index d496536f7..f3f61d986 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -43,7 +43,7 @@ class Cursor textChanged: textChanged @emit 'moved', movedEvent - @editor.emit 'cursor-moved', movedEvent + @editor.cursorMoved(movedEvent) @marker.on 'destroyed', => @destroyed = true @editor.removeCursor(this) diff --git a/src/editor.coffee b/src/editor.coffee index ec4709790..b597323e6 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1468,8 +1468,15 @@ class Editor extends Model @moveCursors (cursor) -> cursor.moveToNextWordBoundary() moveCursors: (fn) -> + @movingCursors = true fn(cursor) for cursor in @getCursors() @mergeCursors() + @movingCursors = false + @emit 'cursors-moved' + + cursorMoved: (event) -> + @emit 'cursor-moved', event + @emit 'cursors-moved' unless @movingCursors # Public: Select from the current cursor position to the given position in # screen coordinates. From d0c61eb2be85a4dd66842ffe58e26d77e3b0c72a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 14:28:44 -0600 Subject: [PATCH 063/179] Pause cursor blinking when the cursor moves --- spec/editor-component-spec.coffee | 24 +++++++++++++++++++++++- src/editor-component.coffee | 28 ++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 01a92bcd9..92b43606f 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -117,7 +117,7 @@ describe "EditorComponent", -> expect(cursorRect.left).toBe rangeRect.left expect(cursorRect.width).toBe rangeRect.width - it "blinks cursors", -> + 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 @@ -135,6 +135,28 @@ describe "EditorComponent", -> 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 + editor.moveCursorRight() + 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 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 + describe "selection rendering", -> it "renders 1 region for 1-line selections", -> # 1-line selection diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 387e24724..095c6451d 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -84,7 +84,9 @@ EditorCompont = React.createClass getInitialState: -> {} - getDefaultProps: -> cursorBlinkPeriod: 800 + getDefaultProps: -> + cursorBlinkPeriod: 800 + cursorBlinkResumeDelay: 200 componentDidMount: -> @measuredLines = new WeakSet @@ -93,14 +95,14 @@ EditorCompont = React.createClass @listenForCustomEvents() @observeEditor() @observeConfig() - @blinkCursors() + @startBlinkingCursors() @updateAllDimensions() @props.editor.setVisible(true) componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel - clearInterval(@cursorBlinkIntervalHandle) + @stopBlinkingCursors() componentDidUpdate: -> @updateVerticalScrollbar() @@ -124,6 +126,7 @@ EditorCompont = React.createClass @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged @subscribe editor, 'selection-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded + @subscribe editor, 'cursors-moved', @pauseCursorBlinking @subscribe editor.$scrollTop.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @subscribe editor.$width.changes, @requestUpdate @@ -236,11 +239,6 @@ EditorCompont = React.createClass observeConfig: -> @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily - blinkCursors: -> - @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) - - toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) - setFontSize: (fontSize) -> @clearScopedCharWidths() @setState({fontSize}) @@ -374,6 +372,20 @@ EditorCompont = React.createClass {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + startBlinkingCursors: -> + @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) + + stopBlinkingCursors: -> + clearInterval(@cursorBlinkIntervalHandle) + @setState(blinkCursorsOff: false) + + toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) + + pauseCursorBlinking: -> + @stopBlinkingCursors() + @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) + @startBlinkingCursorsAfterDelay() + requestUpdate: -> @forceUpdate() From 44413912bc730c0bdcf09ff623c5304f580e9882 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 14:42:44 -0600 Subject: [PATCH 064/179] Assign fontSize based on editor.fontSize config key --- src/editor-component.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 095c6451d..5d083b145 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -238,6 +238,7 @@ EditorCompont = React.createClass observeConfig: -> @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily + @subscribe atom.config.observe 'editor.fontSize', @setFontSize setFontSize: (fontSize) -> @clearScopedCharWidths() From 4a501a7c311dafd7621cf9589edf970ed3f41f45 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 17:46:27 -0600 Subject: [PATCH 065/179] Fix incorrect triple-click spec --- spec/editor-component-spec.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 92b43606f..70d193a58 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -262,11 +262,11 @@ describe "EditorComponent", -> linesNode.dispatchEvent(buildMouseEvent('mouseup')) expect(editor.getSelectedScreenRange()).toEqual [[5, 0], [7, 0]] - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 8]), detail: 1)) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 5]), detail: 1)) linesNode.dispatchEvent(buildMouseEvent('mouseup')) linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), detail: 1, shiftKey: true)) linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual [[7, 8], [8, 8]] + expect(editor.getSelectedScreenRange()).toEqual [[7, 5], [8, 8]] describe "when the mouse is clicked and dragged", -> it "selects to the nearest screen position until the mouse button is released", -> From f5551929d890642be2633f8c6a634e03f638ba98 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 17:48:05 -0600 Subject: [PATCH 066/179] Account for half-visible lines in DisplayBuffer::getVisibleRowRange We need to add 1 to the editor's height in lines, because it's possible to have *partially visible* lines at the top and the bottom. --- spec/editor-component-spec.coffee | 10 +++++----- src/display-buffer.coffee | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 70d193a58..2481c239c 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -32,9 +32,9 @@ describe "EditorComponent", -> component.updateAllDimensions() lines = node.querySelectorAll('.line') - expect(lines.length).toBe 5 + expect(lines.length).toBe 6 expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text - expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text + expect(lines[5].textContent).toBe editor.lineForScreenRow(5).text node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels component.onVerticalScroll() @@ -42,13 +42,13 @@ describe "EditorComponent", -> expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" lines = node.querySelectorAll('.line') - expect(lines.length).toBe 5 + expect(lines.length).toBe 6 expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text - expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text + expect(lines[5].textContent).toBe editor.lineForScreenRow(7).text spacers = node.querySelectorAll('.spacer') expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels - expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeightInPixels + expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels it "updates the scroll bar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 15e906f00..01c654fe4 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -151,7 +151,7 @@ class DisplayBuffer extends Model return [0, 0] unless @getLineHeight() > 0 return [0, @getLineCount()] if @getHeight() is 0 - heightInLines = @getHeight() / @getLineHeight() + heightInLines = Math.ceil(@getHeight() / @getLineHeight()) + 1 startRow = Math.floor(@getScrollTop() / @getLineHeight()) endRow = Math.ceil(startRow + heightInLines) [startRow, endRow] From 0fd8c5441cdec93ad6701e546ed5108fa9d5f693 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 17:52:48 -0600 Subject: [PATCH 067/179] :lipstick: spec organization --- spec/editor-component-spec.coffee | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 2481c239c..fe89c564e 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -26,7 +26,7 @@ describe "EditorComponent", -> {lineHeightInPixels, charWidth} = component.measureLineDimensions() node = component.getDOMNode() - describe "scrolling", -> + describe "line rendering", -> it "renders only the currently-visible lines", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() @@ -50,16 +50,6 @@ describe "EditorComponent", -> expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels - it "updates the scroll bar when the scrollTop is changed in the model", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() - - scrollbarNode = node.querySelector('.vertical-scrollbar') - expect(scrollbarNode.scrollTop).toBe 0 - - editor.setScrollTop(10) - expect(scrollbarNode.scrollTop).toBe 10 - describe "cursor rendering", -> it "renders the currently visible cursors", -> cursor1 = editor.getCursor() @@ -328,3 +318,13 @@ describe "EditorComponent", -> expect(node.classList.contains('is-focused')).toBe true inputNode.blur() expect(node.classList.contains('is-focused')).toBe false + + it "updates the scroll bar when the scrollTop is changed in the model", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() + + scrollbarNode = node.querySelector('.vertical-scrollbar') + expect(scrollbarNode.scrollTop).toBe 0 + + editor.setScrollTop(10) + expect(scrollbarNode.scrollTop).toBe 10 From a931aaff53f7f80339b4c66bc290a693063518cd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 18:12:44 -0600 Subject: [PATCH 068/179] Remove temporary cursor visibility styling --- static/editor.less | 4 ---- 1 file changed, 4 deletions(-) diff --git a/static/editor.less b/static/editor.less index 904829427..de1eb9a82 100644 --- a/static/editor.less +++ b/static/editor.less @@ -213,8 +213,4 @@ .scrollable-content { position: relative; } - - .cursor { - visibility: visible; - } } From 1162af61ed1b473ba55e5fd66867810943b2436e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 18:13:19 -0600 Subject: [PATCH 069/179] Render a basic gutter --- spec/editor-component-spec.coffee | 52 ++++++++++++++++++++++++------- src/editor-component.coffee | 44 +++++++++++++++++++++++--- static/editor.less | 2 +- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index fe89c564e..f5c6c1e3e 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -39,14 +39,39 @@ describe "EditorComponent", -> node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels component.onVerticalScroll() - expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" lines = node.querySelectorAll('.line') expect(lines.length).toBe 6 expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text expect(lines[5].textContent).toBe editor.lineForScreenRow(7).text - spacers = node.querySelectorAll('.spacer') + spacers = node.querySelectorAll('.lines .spacer') + expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels + expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + + describe "gutter rendering", -> + it "renders the currently-visible line numbers", -> + nbsp = String.fromCharCode(160) + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() + + lines = node.querySelectorAll('.line-number') + expect(lines.length).toBe 6 + expect(lines[0].textContent).toBe "#{nbsp}1" + expect(lines[5].textContent).toBe "#{nbsp}6" + + node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels + component.onVerticalScroll() + + expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" + + lines = node.querySelectorAll('.line-number') + expect(lines.length).toBe 6 + expect(lines[0].textContent).toBe "#{nbsp}3" + expect(lines[5].textContent).toBe "#{nbsp}8" + + spacers = node.querySelectorAll('.line-numbers .spacer') expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels @@ -148,6 +173,11 @@ describe "EditorComponent", -> expect(cursorNode2.classList.contains('blink-off')).toBe false describe "selection rendering", -> + scrollViewClientLeft = null + + beforeEach -> + scrollViewClientLeft = node.querySelector('.scroll-view').getBoundingClientRect().left + it "renders 1 region for 1-line selections", -> # 1-line selection editor.setSelectedScreenRange([[1, 6], [1, 10]]) @@ -156,7 +186,7 @@ describe "EditorComponent", -> regionRect = regions[0].getBoundingClientRect() expect(regionRect.top).toBe 1 * lineHeightInPixels expect(regionRect.height).toBe 1 * lineHeightInPixels - expect(regionRect.left).toBe 6 * charWidth + expect(regionRect.left).toBe scrollViewClientLeft + 6 * charWidth expect(regionRect.width).toBe 4 * charWidth it "renders 2 regions for 2-line selections", -> @@ -167,13 +197,13 @@ describe "EditorComponent", -> region1Rect = regions[0].getBoundingClientRect() expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels - expect(region1Rect.left).toBe 6 * charWidth + expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth expect(region1Rect.right).toBe node.clientWidth region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels expect(region2Rect.height).toBe 1 * lineHeightInPixels - expect(region2Rect.left).toBe 0 + expect(region2Rect.left).toBe scrollViewClientLeft + 0 expect(region2Rect.width).toBe 10 * charWidth it "renders 3 regions for selections with more than 2 lines", -> @@ -184,19 +214,19 @@ describe "EditorComponent", -> region1Rect = regions[0].getBoundingClientRect() expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels - expect(region1Rect.left).toBe 6 * charWidth + expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth expect(region1Rect.right).toBe node.clientWidth region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels expect(region2Rect.height).toBe 3 * lineHeightInPixels - expect(region2Rect.left).toBe 0 + expect(region2Rect.left).toBe scrollViewClientLeft + 0 expect(region2Rect.right).toBe node.clientWidth region3Rect = regions[2].getBoundingClientRect() expect(region3Rect.top).toBe 5 * lineHeightInPixels expect(region3Rect.height).toBe 1 * lineHeightInPixels - expect(region3Rect.left).toBe 0 + expect(region3Rect.left).toBe scrollViewClientLeft + 0 expect(region3Rect.width).toBe 10 * charWidth describe "mouse interactions", -> @@ -290,9 +320,9 @@ describe "EditorComponent", -> clientCoordinatesForScreenPosition = (screenPosition) -> positionOffset = editor.pixelPositionForScreenPosition(screenPosition) - editorClientRect = node.getBoundingClientRect() - clientX = editorClientRect.left + positionOffset.left - clientY = editorClientRect.top + positionOffset.top - editor.getScrollTop() + scrollViewClientRect = node.querySelector('.scroll-view').getBoundingClientRect() + clientX = scrollViewClientRect.left + positionOffset.left + clientY = scrollViewClientRect.top + positionOffset.top - editor.getScrollTop() {clientX, clientY} buildMouseEvent = (type, properties...) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 5d083b145..da339231a 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -2,7 +2,7 @@ React = require 'react' ReactUpdates = require 'react/lib/ReactUpdates' {div, span} = require 'reactionary' {$$} = require 'space-pen' -{debounce} = require 'underscore-plus' +{debounce, multiplyString} = require 'underscore-plus' InputComponent = require './input-component' SelectionComponent = require './selection-component' @@ -30,19 +30,40 @@ EditorCompont = React.createClass className += ' is-focused' if focused div className: className, tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, + div className: 'gutter', + @renderGutterContent() div className: 'scroll-view', ref: 'scrollView', InputComponent ref: 'input', className: 'hidden-input', onInput: @onInput, onFocus: @onInputFocused, onBlur: @onInputBlurred - @renderScrollableContent() + @renderScrollViewContent() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div outlet: 'verticalScrollbarContent', style: {height: editor.getScrollHeight()} - renderScrollableContent: -> + renderGutterContent: -> + {editor} = @props + [startRow, endRow] = @getVisibleRowRange() + lineHeightInPixels = editor.getLineHeight() + precedingHeight = startRow * lineHeightInPixels + followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + maxDigits = editor.getLastBufferRow().toString().length + style = + height: editor.getScrollHeight() + WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" + + div className: 'line-numbers', style: style, [ + div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} + (for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) + lineNumber = bufferRow + 1 + LineNumberComponent({lineNumber, maxDigits, key: lineNumber}))... + div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} + ] + + renderScrollViewContent: -> {editor} = @props style = height: editor.getScrollHeight() WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" - div {className: 'scrollable-content', style, @onMouseDown}, + div {className: 'scroll-view-content', style, @onMouseDown}, @renderCursors() @renderVisibleLines() @renderUnderlayer() @@ -469,3 +490,18 @@ LineComponent = React.createClass "#{scopeTree.getValueAsHtml({})}" shouldComponentUpdate: -> false + +LineNumberComponent = React.createClass + render: -> + div className: 'line-number', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + + buildInnerHTML: -> + {lineNumber, maxDigits} = @props + lineNumber = lineNumber.toString() + if lineNumber.length < maxDigits + padding = multiplyString(' ', maxDigits - lineNumber.length) + padding + lineNumber + @iconDivHTML + else + lineNumber + @iconDivHTML + + iconDivHTML: '
' diff --git a/static/editor.less b/static/editor.less index de1eb9a82..1396ae39e 100644 --- a/static/editor.less +++ b/static/editor.less @@ -210,7 +210,7 @@ } .react-wrapper > .editor { - .scrollable-content { + .scroll-view-content { position: relative; } } From 486a7937b578e37161c69660710ff735c7c0e64f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 7 Apr 2014 19:06:39 -0600 Subject: [PATCH 070/179] =?UTF-8?q?Render=20=E2=80=A2=20instead=20of=20lin?= =?UTF-8?q?e=20number=20for=20soft-wrapped=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/editor-component-spec.coffee | 18 +++++++++++++++++- src/display-buffer.coffee | 19 +++++++++++++++---- src/editor-component.coffee | 27 +++++++++++++++++++++++---- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index f5c6c1e3e..e7bfc1d42 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -51,8 +51,9 @@ describe "EditorComponent", -> expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels describe "gutter rendering", -> + nbsp = String.fromCharCode(160) + it "renders the currently-visible line numbers", -> - nbsp = String.fromCharCode(160) node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() @@ -75,6 +76,21 @@ describe "EditorComponent", -> expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + it "renders • characters for soft-wrapped lines", -> + editor.setSoftWrap(true) + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 30 * charWidth + 'px' + component.updateAllDimensions() + + 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}•" + describe "cursor rendering", -> it "renders the currently visible cursors", -> cursor1 = editor.getCursor() diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 01c654fe4..28a03e0c0 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -105,7 +105,11 @@ class DisplayBuffer extends Model setHeight: (@height) -> @height getWidth: -> @width - setWidth: (@width) -> @width + setWidth: (newWidth) -> + oldWidth = @width + @width = newWidth + @updateWrappedScreenLines() if newWidth isnt oldWidth and @softWrap + @width getScrollTop: -> @scrollTop setScrollTop: (scrollTop) -> @@ -192,12 +196,19 @@ class DisplayBuffer extends Model if editorWidthInChars isnt previousWidthInChars and @softWrap @updateWrappedScreenLines() - getSoftWrapColumn: -> - if atom.config.get('editor.softWrapAtPreferredLineLength') - Math.min(@editorWidthInChars, atom.config.getPositiveInt('editor.preferredLineLength', @editorWidthInChars)) + getEditorWidthInChars: -> + width = @getWidth() + if width? and @defaultCharWidth > 0 + Math.floor(width / @defaultCharWidth) else @editorWidthInChars + getSoftWrapColumn: -> + if atom.config.get('editor.softWrapAtPreferredLineLength') + Math.min(@getEditorWidthInChars(), atom.config.getPositiveInt('editor.preferredLineLength', @getEditorWidthInChars())) + else + @getEditorWidthInChars() + # Gets the screen line for the given screen row. # # screenRow - A {Number} indicating the screen row. diff --git a/src/editor-component.coffee b/src/editor-component.coffee index da339231a..1603d900c 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -49,11 +49,20 @@ EditorCompont = React.createClass height: editor.getScrollHeight() WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" + wrapCount = 0 div className: 'line-numbers', style: style, [ div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} (for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) - lineNumber = bufferRow + 1 - LineNumberComponent({lineNumber, maxDigits, key: lineNumber}))... + if bufferRow is lastBufferRow + lineNumber = '•' + key = "#{bufferRow}-#{++wrapCount}" + else + lastBufferRow = bufferRow + wrapCount = 0 + lineNumber = (bufferRow + 1).toString() + key = bufferRow.toString() + + LineNumberComponent({lineNumber, maxDigits, key}))... div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] @@ -380,7 +389,18 @@ EditorCompont = React.createClass clearVisibleRowOverridesAfterDelay: null onOverflowChanged: -> - @props.editor.setHeight(@refs.scrollView.getDOMNode().clientHeight) + {editor} = @props + {height, width} = @measureScrollViewDimensions() + + if height isnt editor.getHeight() + editor.setHeight(height) + update = true + + if width isnt editor.getWidth() + editor.setWidth(width) + update = true + + @requestUpdate() if update onScreenLinesChanged: ({start, end}) -> {editor} = @props @@ -497,7 +517,6 @@ LineNumberComponent = React.createClass buildInnerHTML: -> {lineNumber, maxDigits} = @props - lineNumber = lineNumber.toString() if lineNumber.length < maxDigits padding = multiplyString(' ', maxDigits - lineNumber.length) padding + lineNumber + @iconDivHTML From 171631d20f270b334435d69e8460459d0dfc6aca Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 8 Apr 2014 11:24:39 -0600 Subject: [PATCH 071/179] Autoscroll cursors horizontally at the model layer Still need to respect the horizontal scroll position in the editor view --- spec/editor-spec.coffee | 29 +++++++++++++++++++++++++++++ src/cursor.coffee | 18 +++++++++++++----- src/display-buffer.coffee | 14 ++++++++++++-- src/editor.coffee | 13 ++++++++----- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index e5b599150..2343fca72 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -663,8 +663,12 @@ describe "Editor", -> describe "autoscroll", -> beforeEach -> editor.setVerticalScrollMargin(2) + editor.setHorizontalScrollMargin(2) editor.setLineHeight(10) + editor.setDefaultCharWidth(10) editor.setHeight(5.5 * 10) + editor.setWidth(5.5 * 10) + it "scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor", -> expect(editor.getScrollTop()).toBe 0 @@ -692,6 +696,31 @@ describe "Editor", -> editor.moveCursorUp() expect(editor.getScrollTop()).toBe 6 * 10 + it "scrolls right when the last cursor gets closer than ::horizontalScrollMargin to the right of the editor", -> + expect(editor.getScrollLeft()).toBe 0 + expect(editor.getScrollRight()).toBe 5.5 * 10 + + editor.setCursorScreenPosition([0, 2]) + expect(editor.getScrollRight()).toBe 5.5 * 10 + + editor.moveCursorRight() + expect(editor.getScrollRight()).toBe 6 * 10 + + editor.moveCursorRight() + expect(editor.getScrollRight()).toBe 7 * 10 + + it "scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor", -> + editor.setScrollRight(editor.getScrollWidth()) + editor.setCursorScreenPosition([6, 62]) + + expect(editor.getScrollRight()).toBe editor.getScrollWidth() + + editor.moveCursorLeft() + expect(editor.getScrollLeft()).toBe 59 * 10 + + editor.moveCursorLeft() + expect(editor.getScrollLeft()).toBe 58 * 10 + describe "selection", -> selection = null diff --git a/src/cursor.coffee b/src/cursor.coffee index f3f61d986..f7e3f1b68 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -98,18 +98,26 @@ class Cursor @marker.getHeadBufferPosition() autoscroll: -> - scrollMarginInPixels = @editor.getVerticalScrollMargin() * @editor.getLineHeight() - {top, height} = @getPixelRect() + verticalScrollMarginInPixels = @editor.getVerticalScrollMargin() * @editor.getLineHeight() + horizontalScrollMarginInPixels = @editor.getHorizontalScrollMargin() * @editor.getDefaultCharWidth() + {top, left, height, width} = @getPixelRect() bottom = top + height - - desiredScrollTop = top - scrollMarginInPixels - desiredScrollBottom = bottom + scrollMarginInPixels + right = left + width + desiredScrollTop = top - verticalScrollMarginInPixels + desiredScrollBottom = bottom + verticalScrollMarginInPixels + desiredScrollLeft = left - horizontalScrollMarginInPixels + desiredScrollRight = right + horizontalScrollMarginInPixels if desiredScrollTop < @editor.getScrollTop() @editor.setScrollTop(desiredScrollTop) else if desiredScrollBottom > @editor.getScrollBottom() @editor.setScrollBottom(desiredScrollBottom) + if desiredScrollLeft < @editor.getScrollLeft() + @editor.setScrollLeft(desiredScrollLeft) + else if desiredScrollRight > @editor.getScrollRight() + @editor.setScrollRight(desiredScrollRight) + # Public: If the marker range is empty, the cursor is marked as being visible. updateVisibility: -> @setVisible(@marker.getBufferRange().isEmpty()) diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 28a03e0c0..5e66a9bb5 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -121,12 +121,19 @@ class DisplayBuffer extends Model @getScrollBottom() getScrollLeft: -> @scrollLeft - setScrollLeft: (@scrollLeft) -> @scrollLeft + setScrollLeft: (scrollLeft) -> + @scrollLeft = Math.min(@getScrollWidth() - @getWidth(), Math.max(0, scrollLeft)) + + getScrollRight: -> @scrollLeft + @width + setScrollRight: (scrollRight) -> + @setScrollLeft(scrollRight - @width) + @getScrollRight() getLineHeight: -> @lineHeight setLineHeight: (@lineHeight) -> @lineHeight - setDefaultCharWidth: (@defaultCharWidth) -> + getDefaultCharWidth: -> @defaultCharWidth + setDefaultCharWidth: (@defaultCharWidth) -> @defaultCharWidth getScopedCharWidth: (scopeNames, char) -> @getScopedCharWidths(scopeNames)[char] @@ -151,6 +158,9 @@ class DisplayBuffer extends Model getScrollHeight: -> @getLineCount() * @getLineHeight() + getScrollWidth: -> + @getMaxLineLength() * @getDefaultCharWidth() + getVisibleRowRange: -> return [0, 0] unless @getLineHeight() > 0 return [0, @getLineCount()] if @getHeight() is 0 diff --git a/src/editor.coffee b/src/editor.coffee index b597323e6..981b9fee7 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -150,9 +150,10 @@ class Editor extends Model 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', toProperty: 'languageMode' - @delegatesMethods 'setLineHeight', 'getLineHeight', 'setDefaultCharWidth', 'setHeight', - 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', 'getScrollBottom', - 'setScrollBottom', 'setScrollLeft', 'getScrollLeft', 'getScrollHeight', 'getVisibleRowRange', + @delegatesMethods 'setLineHeight', 'getLineHeight', 'getDefaultCharWidth', 'setDefaultCharWidth', + 'setHeight', 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', + 'getScrollBottom', 'setScrollBottom', 'getScrollLeft', 'setScrollLeft', 'getScrollRight', + 'setScrollRight', 'getScrollHeight', 'getScrollWidth', 'getVisibleRowRange', 'intersectsVisibleRowRange', 'selectionIntersectsVisibleRowRange', 'pixelPositionForScreenPosition', 'screenPositionForPixelPosition', toProperty: 'displayBuffer' @@ -314,6 +315,10 @@ class Editor extends Model setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin + getHorizontalScrollMargin: -> @horizontalScrollMargin + + setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin + # Public: Get the text representing a single level of indent. # # If soft tabs are enabled, the text is composed of N spaces, where N is the @@ -1818,8 +1823,6 @@ class Editor extends Model setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight) - setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth) - getScopedCharWidth: (args...) -> @displayBuffer.getScopedCharWidth(args...) getScopedCharWidths: (args...) -> @displayBuffer.getScopedCharWidths(args...) From 84bb624b5b725e913ac33559ee8bf9cdf35d2244 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 8 Apr 2014 12:04:27 -0600 Subject: [PATCH 072/179] Set x-transform of .scroll-view-content based on the model's scrollLeft --- spec/editor-component-spec.coffee | 26 ++++++++++++++++++-------- src/editor-component.coffee | 3 ++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index e7bfc1d42..aa4d879fb 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -39,7 +39,7 @@ describe "EditorComponent", -> node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels component.onVerticalScroll() - expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate(0px, #{-2.5 * lineHeightInPixels}px)" lines = node.querySelectorAll('.line') expect(lines.length).toBe 6 @@ -365,12 +365,22 @@ describe "EditorComponent", -> inputNode.blur() expect(node.classList.contains('is-focused')).toBe false - it "updates the scroll bar when the scrollTop is changed in the model", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + describe "scrolling", -> + it "updates the vertical scrollbar when the scrollTop is changed in the model", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.updateAllDimensions() - scrollbarNode = node.querySelector('.vertical-scrollbar') - expect(scrollbarNode.scrollTop).toBe 0 + scrollbarNode = node.querySelector('.vertical-scrollbar') + expect(scrollbarNode.scrollTop).toBe 0 - editor.setScrollTop(10) - expect(scrollbarNode.scrollTop).toBe 10 + editor.setScrollTop(10) + expect(scrollbarNode.scrollTop).toBe 10 + + it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> + node.style.width = 30 * charWidth + 'px' + component.updateAllDimensions() + + scrollViewContentNode = node.querySelector('.scroll-view-content') + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" + editor.setScrollLeft(100) + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(-100px, 0px)" diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 1603d900c..666c92d76 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -70,7 +70,7 @@ EditorCompont = React.createClass {editor} = @props style = height: editor.getScrollHeight() - WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" + WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" div {className: 'scroll-view-content', style, @onMouseDown}, @renderCursors() @@ -158,6 +158,7 @@ EditorCompont = React.createClass @subscribe editor, 'selection-removed', @onSelectionAdded @subscribe editor, 'cursors-moved', @pauseCursorBlinking @subscribe editor.$scrollTop.changes, @requestUpdate + @subscribe editor.$scrollLeft.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @subscribe editor.$width.changes, @requestUpdate @subscribe editor.$defaultCharWidth.changes, @requestUpdate From 81233a696bbf769a388512fff14297c4fb158031 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 8 Apr 2014 12:10:24 -0600 Subject: [PATCH 073/179] Set default horizontalScrollMargin --- src/editor.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor.coffee b/src/editor.coffee index 981b9fee7..1d4db2a9b 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -145,6 +145,7 @@ class Editor extends Model selections: null suppressSelectionMerging: false verticalScrollMargin: 2 + horizontalScrollMargin: 6 @delegatesMethods 'suggestedIndentForBufferRow', 'autoIndentBufferRow', 'autoIndentBufferRows', 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', From 48135a1e8d806db3ffecb6fc38a632fc9593be20 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Tue, 8 Apr 2014 14:12:41 -0600 Subject: [PATCH 074/179] Update the horizontal scrollbar when scrollLeft changes in the model --- spec/editor-component-spec.coffee | 4 ++++ src/editor-component.coffee | 15 ++++++++++++++- static/editor.less | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index aa4d879fb..a3ddf3949 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -381,6 +381,10 @@ describe "EditorComponent", -> component.updateAllDimensions() scrollViewContentNode = node.querySelector('.scroll-view-content') + horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" + expect(horizontalScrollbarNode.scrollLeft).toBe 0 + editor.setScrollLeft(100) expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(-100px, 0px)" + expect(horizontalScrollbarNode.scrollLeft).toBe 100 diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 666c92d76..e232c7ceb 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -36,7 +36,9 @@ EditorCompont = React.createClass InputComponent ref: 'input', className: 'hidden-input', onInput: @onInput, onFocus: @onInputFocused, onBlur: @onInputBlurred @renderScrollViewContent() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, - div outlet: 'verticalScrollbarContent', style: {height: editor.getScrollHeight()} + div className: 'scrollbar-content', style: {height: editor.getScrollHeight()} + div className: 'horizontal-scrollbar', ref: 'horizontalScrollbar', onScroll: @onHorizontalScroll, + div className: 'scrollbar-content', style: {width: editor.getScrollWidth()} renderGutterContent: -> {editor} = @props @@ -136,6 +138,7 @@ EditorCompont = React.createClass componentDidUpdate: -> @updateVerticalScrollbar() + @updateHorizontalScrollbar() @measureNewLines() # The React-provided scrollTop property doesn't work in this case because when @@ -151,6 +154,16 @@ EditorCompont = React.createClass scrollbarNode.scrollTop = scrollTop @lastScrollTop = scrollbarNode.scrollTop + updateHorizontalScrollbar: -> + {editor} = @props + scrollLeft = editor.getScrollLeft() + + return if scrollLeft is @lastScrollLeft + + scrollbarNode = @refs.horizontalScrollbar.getDOMNode() + scrollbarNode.scrollLeft = scrollLeft + @lastScrollLeft = scrollbarNode.scrollLeft + observeEditor: -> {editor} = @props @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged diff --git a/static/editor.less b/static/editor.less index 1396ae39e..f395d5d10 100644 --- a/static/editor.less +++ b/static/editor.less @@ -116,6 +116,21 @@ z-index: 3; } +.editor .horizontal-scrollbar { + position: absolute; + left: 0; + right: 0; + bottom: 0; + + height: 15px; + overflow-x: auto; + z-index: 3; + + .scrollbar-content { + height: 15px; + } +} + .editor .scroll-view { overflow: hidden; -webkit-flex: 1; From cfdea7e73f9494d4c3315c3611914e7dabb82191 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Tue, 8 Apr 2014 14:20:19 -0600 Subject: [PATCH 075/179] Update the scrollLeft of the model when the horizontal scrollbar changes --- spec/editor-component-spec.coffee | 10 ++++++++++ src/editor-component.coffee | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index a3ddf3949..95a3867dc 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -388,3 +388,13 @@ describe "EditorComponent", -> editor.setScrollLeft(100) expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(-100px, 0px)" expect(horizontalScrollbarNode.scrollLeft).toBe 100 + + it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> + node.style.width = 30 * charWidth + 'px' + component.updateAllDimensions() + + expect(editor.getScrollLeft()).toBe 0 + node.querySelector('.horizontal-scrollbar').scrollLeft = 100 + component.onHorizontalScroll() + + expect(editor.getScrollLeft()).toBe 100 diff --git a/src/editor-component.coffee b/src/editor-component.coffee index e232c7ceb..33976ef2e 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -16,6 +16,7 @@ AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} module.exports = EditorCompont = React.createClass pendingScrollTop: null + pendingScrollLeft: null lastScrollTop: null selectOnMouseMove: false @@ -320,6 +321,17 @@ EditorCompont = React.createClass @props.editor.setScrollTop(@pendingScrollTop) @pendingScrollTop = null + onHorizontalScroll: -> + scrollLeft = @refs.horizontalScrollbar.getDOMNode().scrollLeft + return if @props.editor.getScrollLeft() is scrollLeft + + animationFramePending = @pendingScrollLeft? + @pendingScrollLeft = scrollLeft + unless animationFramePending + requestAnimationFrame => + @props.editor.setScrollLeft(@pendingScrollLeft) + @pendingScrollLeft = null + onMouseWheel: (event) -> # To preserve velocity scrolling, delay removal of the event's target until # after mousewheel events stop being fired. Removing the target before then @@ -329,6 +341,7 @@ EditorCompont = React.createClass @clearVisibleRowOverridesAfterDelay() @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY + @refs.horizontalScrollbar.getDOMNode().scrollLeft -= event.wheelDeltaX event.preventDefault() onMouseDown: (event) -> From 985662b8f0aa9bfb8c1fa14c1c0f139d00e5525f Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Tue, 8 Apr 2014 14:38:06 -0600 Subject: [PATCH 076/179] Only scroll in one direction a time with the mousewheel --- spec/editor-component-spec.coffee | 20 ++++++++++++++++++++ src/editor-component.coffee | 14 +++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 95a3867dc..589cfcf61 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -398,3 +398,23 @@ describe "EditorComponent", -> component.onHorizontalScroll() expect(editor.getScrollLeft()).toBe 100 + + describe "when a mousewheel event occurs on the editor", -> + it "updates the horizontal or vertical scrollbar depending on which delta is greater (x or y)", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 20 * charWidth + 'px' + component.updateAllDimensions() + + verticalScrollbarNode = node.querySelector('.vertical-scrollbar') + horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') + + expect(verticalScrollbarNode.scrollTop).toBe 0 + expect(horizontalScrollbarNode.scrollLeft).toBe 0 + + node.dispatchEvent(new WheelEvent('mousewheel', wheelDeltaX: -5, wheelDeltaY: -10)) + expect(verticalScrollbarNode.scrollTop).toBe 10 + expect(horizontalScrollbarNode.scrollLeft).toBe 0 + + node.dispatchEvent(new WheelEvent('mousewheel', wheelDeltaX: -15, wheelDeltaY: -5)) + expect(verticalScrollbarNode.scrollTop).toBe 10 + expect(horizontalScrollbarNode.scrollLeft).toBe 15 diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 33976ef2e..f2c3cce35 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -179,10 +179,9 @@ EditorCompont = React.createClass @subscribe editor.$lineHeight.changes, @requestUpdate listenForDOMEvents: -> - scrollViewNode = @refs.scrollView.getDOMNode() - scrollViewNode.addEventListener 'mousewheel', @onMouseWheel - scrollViewNode.addEventListener 'overflowchanged', @onOverflowChanged + @getDOMNode().addEventListener 'mousewheel', @onMouseWheel @getDOMNode().addEventListener 'focus', @onFocus + @refs.scrollView.getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged listenForCustomEvents: -> {editor, mini} = @props @@ -340,8 +339,13 @@ EditorCompont = React.createClass @clearVisibleRowOverridesAfterDelay ?= debounce(@clearVisibleRowOverrides, 100) @clearVisibleRowOverridesAfterDelay() - @refs.verticalScrollbar.getDOMNode().scrollTop -= event.wheelDeltaY - @refs.horizontalScrollbar.getDOMNode().scrollLeft -= event.wheelDeltaX + # Only scroll in one direction at a time + {wheelDeltaX, wheelDeltaY} = event + if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) + @refs.horizontalScrollbar.getDOMNode().scrollLeft -= wheelDeltaX + else + @refs.verticalScrollbar.getDOMNode().scrollTop -= wheelDeltaY + event.preventDefault() onMouseDown: (event) -> From 616b9e4b7d266dc9249f3032d9a09923a6034071 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Tue, 8 Apr 2014 15:54:28 -0600 Subject: [PATCH 077/179] :lipstick: Rename breakOutLeadingWhitespace to breakOutLeadingSoftTabs --- src/token.coffee | 18 +++++++++--------- src/tokenized-line.coffee | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/token.coffee b/src/token.coffee index 366c6a394..08f382f9f 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -40,7 +40,7 @@ class Token whitespaceRegexForTabLength: (tabLength) -> WhitespaceRegexesByTabLength[tabLength] ?= new RegExp("([ ]{#{tabLength}})|(\t)|([^\t]+)", "g") - breakOutAtomicTokens: (tabLength, breakOutLeadingWhitespace) -> + breakOutAtomicTokens: (tabLength, breakOutLeadingSoftTabs) -> if @hasSurrogatePair outputTokens = [] @@ -48,14 +48,14 @@ class Token if token.isAtomic outputTokens.push(token) else - outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingWhitespace)...) - breakOutLeadingWhitespace = token.isOnlyWhitespace() if breakOutLeadingWhitespace + outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingSoftTabs)...) + breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs outputTokens else return [this] if @isAtomic - if breakOutLeadingWhitespace + if breakOutLeadingSoftTabs return [this] unless /^[ ]|\t/.test(@value) else return [this] unless /\t/.test(@value) @@ -64,13 +64,13 @@ class Token regex = @whitespaceRegexForTabLength(tabLength) while match = regex.exec(@value) [fullMatch, softTab, hardTab] = match - if softTab and breakOutLeadingWhitespace - outputTokens.push(@buildSoftTabToken(tabLength, false)) + if softTab and breakOutLeadingSoftTabs + outputTokens.push(@buildSoftTabToken(tabLength)) else if hardTab - breakOutLeadingWhitespace = false - outputTokens.push(@buildHardTabToken(tabLength, true)) + breakOutLeadingSoftTabs = false + outputTokens.push(@buildHardTabToken(tabLength)) else - breakOutLeadingWhitespace = false + breakOutLeadingSoftTabs = false value = match[0] outputTokens.push(new Token({value, @scopes})) diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index acf0d2075..01df41e21 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -111,10 +111,10 @@ class TokenizedLine breakOutAtomicTokens: (inputTokens, tabLength) -> outputTokens = [] - breakOutLeadingWhitespace = true + breakOutLeadingSoftTabs = true for token in inputTokens - outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingWhitespace)...) - breakOutLeadingWhitespace = token.isOnlyWhitespace() if breakOutLeadingWhitespace + outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingSoftTabs)...) + breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs outputTokens isComment: -> From b4af0a79d04e499319c762cfd702e952e7390e71 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 12:52:54 -0600 Subject: [PATCH 078/179] Mark tokens with leading/trailing whitespace when building TokenizedLine --- spec/tokenized-buffer-spec.coffee | 37 +++++++++++++++++++++++++++++++ src/token.coffee | 2 ++ src/tokenized-line.coffee | 11 +++++++++ 3 files changed, 50 insertions(+) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index f45dfa8d5..5d538f51c 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -463,3 +463,40 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' atom.config.set('editor.tabLength', 0) expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + + describe "leading and trailing whitespace", -> + beforeEach -> + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer}) + fullyTokenize(tokenizedBuffer) + + it "sets ::hasLeadingWhitespace to true on tokens that have leading whitespace", -> + expect(tokenizedBuffer.lineForScreenRow(0).tokens[0].hasLeadingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(1).tokens[0].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(1).tokens[1].hasLeadingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(2).tokens[0].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(2).tokens[1].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(2).tokens[2].hasLeadingWhitespace).toBe false + + # The 4th token *has* leading whitespace, but isn't entirely whitespace + buffer.insert([5, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(5).tokens[3].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(5).tokens[4].hasLeadingWhitespace).toBe false + + # Lines that are *only* whitespace are not considered to have leading whitespace + buffer.insert([10, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasLeadingWhitespace).toBe false + + it "sets ::hasTrailingWhitespace to true on tokens that have trailing whitespace", -> + buffer.insert([0, Infinity], ' ') + expect(tokenizedBuffer.lineForScreenRow(0).tokens[11].hasTrailingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(0).tokens[12].hasTrailingWhitespace).toBe true + + # The last token *has* trailing whitespace, but isn't entirely whitespace + buffer.setTextInRange([[2, 39], [2, 40]], ' ') + expect(tokenizedBuffer.lineForScreenRow(2).tokens[14].hasTrailingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(2).tokens[15].hasTrailingWhitespace).toBe true + + # Lines that are *only* whitespace are considered to have trailing whitespace + buffer.insert([10, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasTrailingWhitespace).toBe true diff --git a/src/token.coffee b/src/token.coffee index 08f382f9f..17699f8f8 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -20,6 +20,8 @@ class Token scopes: null isAtomic: null isHardTab: null + hasLeadingWhitespace: false + hasTrailingWhitespace: false constructor: ({@value, @scopes, @isAtomic, @bufferDelta, @isHardTab}) -> @screenDelta = @value.length diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 01df41e21..32c1c281c 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -10,6 +10,7 @@ class TokenizedLine @text = _.pluck(@tokens, 'value').join('') @bufferDelta = _.sum(_.pluck(@tokens, 'bufferDelta')) @id = idCounter++ + @markLeadingAndTrailingWhitespaceTokens() copy: -> new TokenizedLine({@tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold}) @@ -117,6 +118,16 @@ class TokenizedLine breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs outputTokens + markLeadingAndTrailingWhitespaceTokens: -> + firstNonWhitespacePosition = @text.search(/\S/) + firstTrailingWhitespacePosition = @text.search(/\s*$/) + lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0 + position = 0 + for token, i in @tokens + token.hasLeadingWhitespace = position < firstNonWhitespacePosition + token.hasTrailingWhitespace = position + token.value.length > firstTrailingWhitespacePosition + position += token.value.length + isComment: -> for token in @tokens continue if token.scopes.length is 1 From 5e38add177dc9d6b1cb9784b3a137bac199f5fb2 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 13:07:02 -0600 Subject: [PATCH 079/179] Only mark trailing whitespace on the last segment of a soft-wrapped line --- spec/tokenized-buffer-spec.coffee | 9 +++++++++ src/tokenized-line.coffee | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index 5d538f51c..81f5bdcf1 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -500,3 +500,12 @@ describe "TokenizedBuffer", -> # Lines that are *only* whitespace are considered to have trailing whitespace buffer.insert([10, 0], ' ') expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasTrailingWhitespace).toBe true + + it "only marks trailing whitespace on the last segment of a soft-wrapped line", -> + buffer.insert([0, Infinity], ' ') + tokenizedLine = tokenizedBuffer.lineForScreenRow(0) + [segment1, segment2] = tokenizedLine.softWrapAt(16) + expect(segment1.tokens[5].value).toBe ' ' + expect(segment1.tokens[5].hasTrailingWhitespace).toBe false + expect(segment2.tokens[6].value).toBe ' ' + expect(segment2.tokens[6].hasTrailingWhitespace).toBe true diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 32c1c281c..d7e4a9484 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -125,7 +125,8 @@ class TokenizedLine position = 0 for token, i in @tokens token.hasLeadingWhitespace = position < firstNonWhitespacePosition - token.hasTrailingWhitespace = position + token.value.length > firstTrailingWhitespacePosition + # Only the *last* segment of a soft-wrapped line can have trailing whitespace + token.hasTrailingWhitespace = @lineEnding? and (position + token.value.length > firstTrailingWhitespacePosition) position += token.value.length isComment: -> From cf27826156866c6fd7b095765ea8b55eada17be2 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 13:21:11 -0600 Subject: [PATCH 080/179] Mark tokens on whitespace-only lines as having leading whitespace This makes it easy to decide to render the indent guide for a token. If the token has leading whitespace, we can render it. --- spec/tokenized-buffer-spec.coffee | 4 ++-- src/tokenized-line.coffee | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index 81f5bdcf1..4b4c6d841 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -483,9 +483,9 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.lineForScreenRow(5).tokens[3].hasLeadingWhitespace).toBe true expect(tokenizedBuffer.lineForScreenRow(5).tokens[4].hasLeadingWhitespace).toBe false - # Lines that are *only* whitespace are not considered to have leading whitespace + # Lines that are *only* whitespace are considered to have leading whitespace buffer.insert([10, 0], ' ') - expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasLeadingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasLeadingWhitespace).toBe true it "sets ::hasTrailingWhitespace to true on tokens that have trailing whitespace", -> buffer.insert([0, Infinity], ' ') diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index d7e4a9484..005f49e44 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -119,7 +119,7 @@ class TokenizedLine outputTokens markLeadingAndTrailingWhitespaceTokens: -> - firstNonWhitespacePosition = @text.search(/\S/) + firstNonWhitespacePosition = @text.search(/\S|$/) firstTrailingWhitespacePosition = @text.search(/\s*$/) lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0 position = 0 From 6b10fcc2f85306399e1485ff2380dd87af71ec3f Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 13:49:27 -0600 Subject: [PATCH 081/179] Rely on token's knowledge of its own leading/trailing whitespace status Previously, we were determining this at render time. But its baked into the state of tokens when TokenizedLines are constructed now so we no longer need to compute it when rendering. --- src/editor-view.coffee | 4 +--- src/token.coffee | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/editor-view.coffee b/src/editor-view.coffee index 1da0e223d..d1fcbcc29 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -1488,10 +1488,8 @@ class EditorView extends View position = 0 for token in tokens @updateScopeStack(line, scopeStack, token.scopes) - hasLeadingWhitespace = position < firstNonWhitespacePosition - hasTrailingWhitespace = position + token.value.length > firstTrailingWhitespacePosition hasIndentGuide = not mini and showIndentGuide and (hasLeadingWhitespace or lineIsWhitespaceOnly) - line.push(token.getValueAsHtml({invisibles, hasLeadingWhitespace, hasTrailingWhitespace, hasIndentGuide})) + line.push(token.getValueAsHtml({invisibles, hasIndentGuide})) position += token.value.length @popScope(line, scopeStack) while scopeStack.length > 0 diff --git a/src/token.coffee b/src/token.coffee index 17699f8f8..f42c5a9cc 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -129,7 +129,7 @@ class Token scopeClasses = scope.split('.') _.isSubset(targetClasses, scopeClasses) - getValueAsHtml: ({invisibles, hasLeadingWhitespace, hasTrailingWhitespace, hasIndentGuide})-> + getValueAsHtml: ({invisibles, hasIndentGuide})-> invisibles ?= {} if @isHardTab classes = 'hard-tab' @@ -144,7 +144,7 @@ class Token leadingHtml = '' trailingHtml = '' - if hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) + if @hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) classes = 'leading-whitespace' classes += ' indent-guide' if hasIndentGuide classes += ' invisible-character' if invisibles.space @@ -154,9 +154,9 @@ class Token startIndex = match[0].length - if hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) + if @hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not hasLeadingWhitespace + classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace classes += ' invisible-character' if invisibles.space match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space From 1c48f60e42a76bf423a299f5a5eae418d9743d45 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 13:49:56 -0600 Subject: [PATCH 082/179] Render indent guide for react editors on non-empty lines --- spec/editor-component-spec.coffee | 25 ++++++++++++++++++++++++- src/editor-component.coffee | 11 ++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 589cfcf61..f0f3b63e5 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -1,5 +1,5 @@ React = require 'react' -{extend} = require 'underscore-plus' +{extend, flatten, toArray} = require 'underscore-plus' EditorComponent = require '../src/editor-component' describe "EditorComponent", -> @@ -50,6 +50,29 @@ describe "EditorComponent", -> expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + describe "when indent guides are enabled", -> + it "adds an 'indent-guide' class to spans comprising the leading whitespace", -> + component.setShowIndentGuide(true) + + lines = node.querySelectorAll('.line') + line1LeafNodes = getLeafNodes(lines[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]) + expect(line2LeafNodes[0].textContent).toBe ' ' + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[1].textContent).toBe ' ' + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe false + + getLeafNodes = (node) -> + if node.children.length > 0 + flatten(toArray(node.children).map(getLeafNodes)) + else + [node] + describe "gutter rendering", -> nbsp = String.fromCharCode(160) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f2c3cce35..6f1f8438d 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -82,6 +82,7 @@ EditorCompont = React.createClass renderVisibleLines: -> {editor} = @props + {showIndentGuide} = @state [startRow, endRow] = @getVisibleRowRange() lineHeightInPixels = editor.getLineHeight() precedingHeight = startRow * lineHeightInPixels @@ -90,7 +91,7 @@ EditorCompont = React.createClass div className: 'lines', ref: 'lines', [ div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, key: tokenizedLine.id}))... + LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}))... div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] @@ -297,6 +298,9 @@ EditorCompont = React.createClass @setState({fontFamily}) @updateLineDimensions() + setShowIndentGuide: (showIndentGuide) -> + @setState({showIndentGuide}) + onFocus: -> @refs.input.focus() @@ -538,9 +542,10 @@ LineComponent = React.createClass html += "
" html else - "#{scopeTree.getValueAsHtml({})}" + "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" - shouldComponentUpdate: -> false + shouldComponentUpdate: (newProps, newState) -> + newProps.showIndentGuide isnt @props.showIndentGuide LineNumberComponent = React.createClass render: -> From 7fc2e0b5401890b987f69c600ee51455ed0c009d Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 14:54:54 -0600 Subject: [PATCH 083/179] Wire up the editor.showIndentGuide setting --- src/editor-component.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 6f1f8438d..d3674e1a2 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -284,6 +284,7 @@ EditorCompont = React.createClass observeConfig: -> @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily @subscribe atom.config.observe 'editor.fontSize', @setFontSize + @subscribe atom.config.observe 'editor.showIndentGuide', @setShowIndentGuide setFontSize: (fontSize) -> @clearScopedCharWidths() From d0a917ed141538a6ece11d4dfd122f70558cddf6 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 15:04:41 -0600 Subject: [PATCH 084/179] Prevent scrollLeft/scrollTop from going out of bounds --- spec/display-buffer-spec.coffee | 38 +++++++++++++++++++++++++++++++++ src/display-buffer.coffee | 4 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index 7ecf042f5..59a1018ac 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -955,3 +955,41 @@ describe "DisplayBuffer", -> {start, end} = marker.getPixelRange() expect(start.top).toBe 5 * 20 expect(start.left).toBe (4 * 10) + (6 * 11) + + describe "::setScrollTop", -> + beforeEach -> + displayBuffer.setLineHeight(10) + + it "disallows negative values", -> + displayBuffer.setHeight(displayBuffer.getScrollHeight() + 100) + expect(displayBuffer.setScrollTop(-10)).toBe 0 + expect(displayBuffer.getScrollTop()).toBe 0 + + it "disallows values that would make ::getScrollBottom() exceed ::getScrollHeight()", -> + displayBuffer.setHeight(50) + maxScrollTop = displayBuffer.getScrollHeight() - displayBuffer.getHeight() + + expect(displayBuffer.setScrollTop(maxScrollTop)).toBe maxScrollTop + expect(displayBuffer.getScrollTop()).toBe maxScrollTop + + expect(displayBuffer.setScrollTop(maxScrollTop + 50)).toBe maxScrollTop + expect(displayBuffer.getScrollTop()).toBe maxScrollTop + + describe "::setScrollLeft", -> + beforeEach -> + displayBuffer.setDefaultCharWidth(10) + + it "disallows negative values", -> + displayBuffer.setWidth(displayBuffer.getScrollWidth() + 100) + expect(displayBuffer.setScrollLeft(-10)).toBe 0 + expect(displayBuffer.getScrollLeft()).toBe 0 + + it "disallows values that would make ::getScrollRight() exceed ::getScrollWidth()", -> + displayBuffer.setWidth(50) + maxScrollLeft = displayBuffer.getScrollWidth() - displayBuffer.getWidth() + + expect(displayBuffer.setScrollLeft(maxScrollLeft)).toBe maxScrollLeft + expect(displayBuffer.getScrollLeft()).toBe maxScrollLeft + + expect(displayBuffer.setScrollLeft(maxScrollLeft + 50)).toBe maxScrollLeft + expect(displayBuffer.getScrollLeft()).toBe maxScrollLeft diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 5e66a9bb5..419ceb90d 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -113,7 +113,7 @@ class DisplayBuffer extends Model getScrollTop: -> @scrollTop setScrollTop: (scrollTop) -> - @scrollTop = Math.min(@getScrollHeight() - @getHeight(), Math.max(0, scrollTop)) + @scrollTop = Math.max(0, Math.min(@getScrollHeight() - @getHeight(), scrollTop)) getScrollBottom: -> @scrollTop + @height setScrollBottom: (scrollBottom) -> @@ -122,7 +122,7 @@ class DisplayBuffer extends Model getScrollLeft: -> @scrollLeft setScrollLeft: (scrollLeft) -> - @scrollLeft = Math.min(@getScrollWidth() - @getWidth(), Math.max(0, scrollLeft)) + @scrollLeft = Math.max(0, Math.min(@getScrollWidth() - @getWidth(), scrollLeft)) getScrollRight: -> @scrollLeft + @width setScrollRight: (scrollRight) -> From 6997adece927a5bb489ea0fd759b387052392227 Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 16:45:19 -0600 Subject: [PATCH 085/179] Associate TokenizedLines with an ::indentLevel This can be used to render the appropriate number of indent guide spans for empty lines. --- spec/tokenized-buffer-spec.coffee | 28 ++++++++++++++++++++++ src/display-buffer.coffee | 3 +++ src/editor.coffee | 8 +------ src/tokenized-buffer.coffee | 39 +++++++++++++++++++++++++++++-- src/tokenized-line.coffee | 2 +- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index 4b4c6d841..b498bd9e8 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -509,3 +509,31 @@ describe "TokenizedBuffer", -> expect(segment1.tokens[5].hasTrailingWhitespace).toBe false expect(segment2.tokens[6].value).toBe ' ' expect(segment2.tokens[6].hasTrailingWhitespace).toBe true + + describe "indent level", -> + beforeEach -> + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer}) + fullyTokenize(tokenizedBuffer) + + describe "when the line is non-empty", -> + it "has an indent level based on the leading whitespace on the line", -> + expect(tokenizedBuffer.lineForScreenRow(0).indentLevel).toBe 0 + expect(tokenizedBuffer.lineForScreenRow(1).indentLevel).toBe 1 + expect(tokenizedBuffer.lineForScreenRow(2).indentLevel).toBe 2 + buffer.insert([2, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(2).indentLevel).toBe 2.5 + + describe "when the line is empty", -> + it "assumes the indentation level of the first non-empty line below or above if one exists", -> + buffer.insert([12, 0], ' ') + buffer.insert([12, Infinity], '\n\n') + expect(tokenizedBuffer.lineForScreenRow(13).indentLevel).toBe 2 + expect(tokenizedBuffer.lineForScreenRow(14).indentLevel).toBe 2 + + buffer.insert([1, Infinity], '\n\n') + expect(tokenizedBuffer.lineForScreenRow(2).indentLevel).toBe 2 + expect(tokenizedBuffer.lineForScreenRow(3).indentLevel).toBe 2 + + buffer.setText('\n\n\n') + expect(tokenizedBuffer.lineForScreenRow(1).indentLevel).toBe 0 diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 419ceb90d..8fd184736 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -242,6 +242,9 @@ class DisplayBuffer extends Model getLines: -> new Array(@screenLines...) + indentLevelForLine: (line) -> + @tokenizedBuffer.indentLevelForLine(line) + # Given starting and ending screen rows, this returns an array of the # buffer rows corresponding to every screen row in the range # diff --git a/src/editor.coffee b/src/editor.coffee index 1d4db2a9b..e52f9097d 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -405,13 +405,7 @@ class Editor extends Model # # Returns a {Number}. indentLevelForLine: (line) -> - if match = line.match(/^[\t ]+/) - leadingWhitespace = match[0] - tabCount = leadingWhitespace.match(/\t/g)?.length ? 0 - spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0 - tabCount + (spaceCount / @getTabLength()) - else - 0 + @displayBuffer.indentLevelForLine(line) # Constructs the string used for tabs. buildIndentString: (number) -> diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index f3e60b90b..ac2a92d82 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -185,14 +185,16 @@ class TokenizedBuffer extends Model line = @buffer.lineForRow(row) tokens = [new Token(value: line, scopes: [@grammar.scopeName])] tabLength = @getTabLength() - new TokenizedLine({tokens, tabLength}) + indentLevel = @indentLevelForRow(row) + new TokenizedLine({tokens, tabLength, indentLevel}) buildTokenizedTokenizedLineForRow: (row, ruleStack) -> line = @buffer.lineForRow(row) lineEnding = @buffer.lineEndingForRow(row) tabLength = @getTabLength() + indentLevel = @indentLevelForRow(row) { tokens, ruleStack } = @grammar.tokenizeLine(line, ruleStack, row is 0) - new TokenizedLine({tokens, ruleStack, tabLength, lineEnding}) + new TokenizedLine({tokens, ruleStack, tabLength, lineEnding, indentLevel}) # FIXME: benogle says: These are actually buffer rows as all buffer rows are # accounted for in @tokenizedLines @@ -207,6 +209,36 @@ class TokenizedBuffer extends Model stackForRow: (row) -> @tokenizedLines[row]?.ruleStack + indentLevelForRow: (row) -> + line = @buffer.lineForRow(row) + + if line is '' + nextRow = row + 1 + lineCount = @getLineCount() + while nextRow < lineCount + nextLine = @buffer.lineForRow(nextRow) + return @indentLevelForLine(nextLine) unless nextLine is '' + nextRow++ + + previousRow = row - 1 + while previousRow >= 0 + previousLine = @buffer.lineForRow(previousRow) + return @indentLevelForLine(previousLine) unless previousLine is '' + previousRow-- + + 0 + else + @indentLevelForLine(line) + + indentLevelForLine: (line) -> + if match = line.match(/^[\t ]+/) + leadingWhitespace = match[0] + tabCount = leadingWhitespace.match(/\t/g)?.length ? 0 + spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0 + tabCount + (spaceCount / @getTabLength()) + else + 0 + scopesForPosition: (position) -> @tokenForPosition(position).scopes @@ -306,6 +338,9 @@ class TokenizedBuffer extends Model getLastRow: -> @buffer.getLastRow() + getLineCount: -> + @buffer.getLineCount() + logLines: (start=0, end=@buffer.getLastRow()) -> for row in [start..end] line = @lineForScreenRow(row).text diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 005f49e44..a5558281c 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -4,7 +4,7 @@ idCounter = 1 module.exports = class TokenizedLine - constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, tabLength}) -> + constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, tabLength, @indentLevel}) -> @tokens = @breakOutAtomicTokens(tokens, tabLength) @startBufferColumn ?= 0 @text = _.pluck(@tokens, 'value').join('') From 241731f9c8a9bd2883042bc60444adcf3b55806c Mon Sep 17 00:00:00 2001 From: David Graham & Nathan Sobo Date: Wed, 9 Apr 2014 17:07:04 -0600 Subject: [PATCH 086/179] Render indent guides on empty lines --- spec/editor-component-spec.coffee | 17 ++++++++++++++++- src/editor-component.coffee | 12 +++++++++++- src/tokenized-line.coffee | 8 ++++---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index f0f3b63e5..09d280b69 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -51,9 +51,10 @@ describe "EditorComponent", -> expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels describe "when indent guides are enabled", -> - it "adds an 'indent-guide' class to spans comprising the leading whitespace", -> + beforeEach -> component.setShowIndentGuide(true) + it "adds an 'indent-guide' class to spans comprising the leading whitespace", -> lines = node.querySelectorAll('.line') line1LeafNodes = getLeafNodes(lines[1]) expect(line1LeafNodes[0].textContent).toBe ' ' @@ -67,6 +68,20 @@ describe "EditorComponent", -> expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe false + 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]) + + expect(line2LeafNodes.length).toBe 3 + expect(line2LeafNodes[0].textContent).toBe ' ' + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[1].textContent).toBe ' ' + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[2].textContent).toBe ' ' + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true + getLeafNodes = (node) -> if node.children.length > 0 flatten(toArray(node.children).map(getLeafNodes)) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index d3674e1a2..80632ebe5 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -532,10 +532,20 @@ LineComponent = React.createClass 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 = "#{multiplyString(' ', tabLength)}" + multiplyString(indentSpan, indentLevel + 1) + else + " " + buildScopeTreeHTML: (scopeTree) -> if scopeTree.children? html = "" diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index a5558281c..dd7576e16 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -4,8 +4,8 @@ idCounter = 1 module.exports = class TokenizedLine - constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, tabLength, @indentLevel}) -> - @tokens = @breakOutAtomicTokens(tokens, tabLength) + constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, @tabLength, @indentLevel}) -> + @tokens = @breakOutAtomicTokens(tokens) @startBufferColumn ?= 0 @text = _.pluck(@tokens, 'value').join('') @bufferDelta = _.sum(_.pluck(@tokens, 'bufferDelta')) @@ -110,11 +110,11 @@ class TokenizedLine delta = nextDelta delta - breakOutAtomicTokens: (inputTokens, tabLength) -> + breakOutAtomicTokens: (inputTokens) -> outputTokens = [] breakOutLeadingSoftTabs = true for token in inputTokens - outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingSoftTabs)...) + outputTokens.push(token.breakOutAtomicTokens(@tabLength, breakOutLeadingSoftTabs)...) breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs outputTokens From 9bdc78df2eb83bafb17ee304d1d149689d91d1fa Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 9 Apr 2014 17:20:22 -0600 Subject: [PATCH 087/179] Correctly render lines containing only whitespace --- spec/editor-component-spec.coffee | 12 ++++++++++++ src/token.coffee | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 09d280b69..402a14f50 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -82,6 +82,18 @@ describe "EditorComponent", -> expect(line2LeafNodes[2].textContent).toBe ' ' expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true + it "renders indent guides correctly on lines containing only whitespace", -> + editor.getBuffer().insert([1, Infinity], '\n ') + lines = node.querySelectorAll('.line') + line2LeafNodes = getLeafNodes(lines[2]) + expect(line2LeafNodes.length).toBe 3 + expect(line2LeafNodes[0].textContent).toBe ' ' + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[1].textContent).toBe ' ' + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[2].textContent).toBe ' ' + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true + getLeafNodes = (node) -> if node.children.length > 0 flatten(toArray(node.children).map(getLeafNodes)) diff --git a/src/token.coffee b/src/token.coffee index f42c5a9cc..aabd60bdb 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -144,7 +144,7 @@ class Token leadingHtml = '' trailingHtml = '' - if @hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) + if @hasLeadingWhitespace and not @hasTrailingWhitespace and match = LeadingWhitespaceRegex.exec(@value) classes = 'leading-whitespace' classes += ' indent-guide' if hasIndentGuide classes += ' invisible-character' if invisibles.space @@ -156,7 +156,7 @@ class Token if @hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace + classes += ' indent-guide' if hasIndentGuide and @hasLeadingWhitespace classes += ' invisible-character' if invisibles.space match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space From 95b24fb933ecbab3becbf9fbcd122feaaa798a7e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 9 Apr 2014 18:09:23 -0600 Subject: [PATCH 088/179] Position the hidden input on the most recent cursor when in view We won't position the hidden input out of the scroll view's bounds to prevent Chromium's autoscrolling behavior. --- spec/editor-component-spec.coffee | 19 +++++++++++++++++++ src/editor-component.coffee | 23 ++++++++++++++++++++++- src/input-component.coffee | 9 +++++---- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 402a14f50..bfb0b9b46 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -238,6 +238,25 @@ describe "EditorComponent", -> expect(cursorNode1.classList.contains('blink-off')).toBe false expect(cursorNode2.classList.contains('blink-off')).toBe false + it "renders the hidden input field at the position of the last cursor if it is on screen", -> + inputNode = node.querySelector('.hidden-input') + node.style.height = 5 * lineHeightInPixels + 'px' + node.style.width = 10 * charWidth + 'px' + component.updateAllDimensions() + + expect(editor.getCursorScreenPosition()).toEqual [0, 0] + editor.setScrollTop(3 * lineHeightInPixels) + editor.setScrollLeft(3 * charWidth) + expect(inputNode.offsetTop).toBe 0 + expect(inputNode.offsetLeft).toBe 0 + + editor.setCursorBufferPosition([5, 5]) + cursorRect = editor.getCursor().getPixelRect() + cursorTop = cursorRect.top + cursorLeft = cursorRect.left + expect(inputNode.offsetTop).toBe cursorTop - editor.getScrollTop() + expect(inputNode.offsetLeft).toBe cursorLeft - editor.getScrollLeft() + describe "selection rendering", -> scrollViewClientLeft = null diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 80632ebe5..c8ffeb796 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -34,7 +34,13 @@ EditorCompont = React.createClass div className: 'gutter', @renderGutterContent() div className: 'scroll-view', ref: 'scrollView', - InputComponent ref: 'input', className: 'hidden-input', onInput: @onInput, onFocus: @onInputFocused, onBlur: @onInputBlurred + InputComponent + ref: 'input' + className: 'hidden-input' + style: @getHiddenInputPosition() + onInput: @onInput + onFocus: @onInputFocused + onBlur: @onInputBlurred @renderScrollViewContent() div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, div className: 'scrollbar-content', style: {height: editor.getScrollHeight()} @@ -69,6 +75,21 @@ EditorCompont = React.createClass div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] + getHiddenInputPosition: -> + {editor} = @props + + if cursor = editor.getCursor() + cursorRect = cursor.getPixelRect() + top = cursorRect.top - editor.getScrollTop() + top = Math.max(0, Math.min(editor.getHeight(), top)) + left = cursorRect.left - editor.getScrollLeft() + left = Math.max(0, Math.min(editor.getWidth(), left)) + else + top = 0 + left = 0 + + {top, left} + renderScrollViewContent: -> {editor} = @props style = diff --git a/src/input-component.coffee b/src/input-component.coffee index 6ea54490c..c1efaaaa8 100644 --- a/src/input-component.coffee +++ b/src/input-component.coffee @@ -1,14 +1,14 @@ punycode = require 'punycode' -{last} = require 'underscore-plus' +{last, isEqual} = require 'underscore-plus' React = require 'react' {input} = require 'reactionary' module.exports = InputComponent = React.createClass render: -> - {className, onFocus, onBlur} = @props + {className, style, onFocus, onBlur} = @props - input {className, onFocus, onBlur} + input {className, style, onFocus, onBlur} getInitialState: -> {lastChar: ''} @@ -27,7 +27,8 @@ InputComponent = React.createClass isPressAndHoldCharacter: (char) -> @state.lastChar.match /[aeiouAEIOU]/ - shouldComponentUpdate: -> false + shouldComponentUpdate: (newProps) -> + not isEqual(newProps.style, @props.style) onInput: (e) -> valueCharCodes = punycode.ucs2.decode(@getDOMNode().value) From 96ebb9bf0320ca9e378f8739709f500bc956c765 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 12:28:58 -0600 Subject: [PATCH 089/179] Correctly position cursor on mousedown when editor is scrolled left --- spec/editor-component-spec.coffee | 4 +++- src/editor-component.coffee | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index bfb0b9b46..63851e786 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -325,8 +325,10 @@ describe "EditorComponent", -> describe "when no modifier keys are held down", -> it "moves the cursor to the nearest screen position", -> node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 10 * charWidth + 'px' component.updateAllDimensions() editor.setScrollTop(3.5 * lineHeightInPixels) + editor.setScrollLeft(2 * charWidth) linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]))) expect(editor.getCursorScreenPosition()).toEqual [4, 8] @@ -406,7 +408,7 @@ describe "EditorComponent", -> clientCoordinatesForScreenPosition = (screenPosition) -> positionOffset = editor.pixelPositionForScreenPosition(screenPosition) scrollViewClientRect = node.querySelector('.scroll-view').getBoundingClientRect() - clientX = scrollViewClientRect.left + positionOffset.left + clientX = scrollViewClientRect.left + positionOffset.left - editor.getScrollLeft() clientY = scrollViewClientRect.top + positionOffset.top - editor.getScrollTop() {clientX, clientY} diff --git a/src/editor-component.coffee b/src/editor-component.coffee index c8ffeb796..08ea30222 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -436,7 +436,7 @@ EditorCompont = React.createClass editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() top = clientY - editorClientRect.top + editor.getScrollTop() - left = clientX - editorClientRect.left + left = clientX - editorClientRect.left + editor.getScrollLeft() {top, left} clearVisibleRowOverrides: -> From 022f5ca2194891ac8336aeb5c61a3bd56ab844b4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 12:41:36 -0600 Subject: [PATCH 090/179] Replace previous character when inserting accented characters --- spec/editor-component-spec.coffee | 12 +++++++++++- src/editor-component.coffee | 8 ++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 63851e786..b56019f52 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -2,7 +2,7 @@ React = require 'react' {extend, flatten, toArray} = require 'underscore-plus' EditorComponent = require '../src/editor-component' -describe "EditorComponent", -> +fdescribe "EditorComponent", -> [editor, component, node, lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] beforeEach -> @@ -489,3 +489,13 @@ describe "EditorComponent", -> node.dispatchEvent(new WheelEvent('mousewheel', wheelDeltaX: -15, wheelDeltaY: -5)) expect(verticalScrollbarNode.scrollTop).toBe 10 expect(horizontalScrollbarNode.scrollLeft).toBe 15 + + describe "input events", -> + it "inserts the typed character into the buffer", -> + component.onInput('x') + expect(editor.lineForBufferRow(0)).toBe 'xvar quicksort = function () {' + + it "replaces the last character if replaceLastCharacter is true", -> + component.onInput('u') + component.onInput('ü', true) + expect(editor.lineForBufferRow(0)).toBe 'üvar quicksort = function () {' diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 08ea30222..2e80c80e4 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -332,8 +332,12 @@ EditorCompont = React.createClass onInputBlurred: -> @setState(focused: false) unless document.activeElement is @getDOMNode() - onInput: (char, replaceLastChar) -> - ReactUpdates.batchedUpdates => @props.editor.insertText(char) + onInput: (char, replaceLastCharacter) -> + {editor} = @props + + ReactUpdates.batchedUpdates -> + editor.selectLeft() if replaceLastCharacter + editor.insertText(char) onVerticalScroll: -> scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop From 59709a92ba2725a405199b70d143e6ece513a9eb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 12:46:58 -0600 Subject: [PATCH 091/179] Include SpacePen wrapper view in spec During the transition to React, it will be easier if the EditorComponent assumes it's rendered inside the ReactEditorView. This will make it easier to test compatibility with existing editor APIs. --- spec/editor-component-spec.coffee | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index b56019f52..b80d32874 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -1,9 +1,8 @@ -React = require 'react' {extend, flatten, toArray} = require 'underscore-plus' -EditorComponent = require '../src/editor-component' +ReactEditorView = require '../src/react-editor-view' -fdescribe "EditorComponent", -> - [editor, component, node, lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] +describe "EditorComponent", -> + [editor, wrapperView, component, node, lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] beforeEach -> waitsForPromise -> @@ -19,8 +18,9 @@ fdescribe "EditorComponent", -> fn() editor = atom.project.openSync('sample.js') - container = document.querySelector('#jasmine-content') - component = React.renderComponent(EditorComponent({editor}), container) + wrapperView = new ReactEditorView(editor) + wrapperView.attachToDom() + {component} = wrapperView component.setLineHeight(1.3) component.setFontSize(20) {lineHeightInPixels, charWidth} = component.measureLineDimensions() From c862ccbc56e1a75c4678e9e2bf9d20ca77d806f8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 13:41:28 -0600 Subject: [PATCH 092/179] Add command listeners to SpacePen wrapper for backward compatibility This is the only way to integrate with the command palette currently. --- src/editor-component.coffee | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 2e80c80e4..3818fba50 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -7,7 +7,6 @@ ReactUpdates = require 'react/lib/ReactUpdates' InputComponent = require './input-component' SelectionComponent = require './selection-component' CursorComponent = require './cursor-component' -CustomEventMixin = require './custom-event-mixin' SubscriberMixin = require './subscriber-mixin' DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] @@ -22,7 +21,7 @@ EditorCompont = React.createClass statics: {DummyLineNode} - mixins: [CustomEventMixin, SubscriberMixin] + mixins: [SubscriberMixin] render: -> {fontSize, lineHeight, fontFamily, focused} = @state @@ -147,7 +146,7 @@ EditorCompont = React.createClass @measuredLines = new WeakSet @listenForDOMEvents() - @listenForCustomEvents() + @listenForCommands() @observeEditor() @observeConfig() @startBlinkingCursors() @@ -205,10 +204,10 @@ EditorCompont = React.createClass @getDOMNode().addEventListener 'focus', @onFocus @refs.scrollView.getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged - listenForCustomEvents: -> - {editor, mini} = @props + listenForCommands: -> + {parentView, editor, mini} = @props - @addCustomEventListeners + @addCommandListeners 'core:move-left': => editor.moveCursorLeft() 'core:move-right': => editor.moveCursorRight() 'core:select-left': => editor.selectLeft() @@ -253,7 +252,7 @@ EditorCompont = React.createClass 'editor:lower-case': => editor.lowerCase() unless mini - @addCustomEventListeners + @addCommandListeners 'core:move-up': => editor.moveCursorUp() 'core:move-down': => editor.moveCursorDown() 'core:move-to-top': => editor.moveCursorToTop() @@ -302,6 +301,12 @@ EditorCompont = React.createClass # 'core:page-up': => @pageUp() # 'editor:scroll-to-cursor': => @scrollToCursorPosition() + addCommandListeners: (listenersByCommandName) -> + {parentView} = @props + + for command, listener of listenersByCommandName + parentView.command command, listener + observeConfig: -> @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily @subscribe atom.config.observe 'editor.fontSize', @setFontSize From 495b1571ca6efc8dcbb4b85f9d7bf370ee522248 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 13:44:20 -0600 Subject: [PATCH 093/179] Add 'editor' class to ReactEditorView wrapper for backward compatibility --- spec/editor-component-spec.coffee | 6 +++--- src/react-editor-view.coffee | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index b80d32874..15b08af33 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -283,7 +283,7 @@ describe "EditorComponent", -> expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth - expect(region1Rect.right).toBe node.clientWidth + expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels @@ -300,13 +300,13 @@ describe "EditorComponent", -> expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth - expect(region1Rect.right).toBe node.clientWidth + expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels expect(region2Rect.height).toBe 3 * lineHeightInPixels expect(region2Rect.left).toBe scrollViewClientLeft + 0 - expect(region2Rect.right).toBe node.clientWidth + expect(Math.ceil(region2Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed region3Rect = regions[2].getBoundingClientRect() expect(region3Rect.top).toBe 5 * lineHeightInPixels diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 1e463d203..47b46344d 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -4,7 +4,7 @@ EditorComponent = require './editor-component' module.exports = class ReactEditorView extends View - @content: -> @div class: 'react-wrapper' + @content: -> @div class: 'editor react-wrapper' constructor: (@editor) -> super @@ -14,7 +14,7 @@ class ReactEditorView extends View afterAttach: (onDom) -> return unless onDom @attached = true - @component = React.renderComponent(EditorComponent({@editor}), @element) + @component = React.renderComponent(EditorComponent({@editor, parentView: this}), @element) @trigger 'editor:attached', [this] beforeDetach: -> From a2a625a7bbcb2549433e7ea0f77dce8c509f9705 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 13:48:11 -0600 Subject: [PATCH 094/179] Add ReactEditorView::getPane for backward-compatibility --- src/react-editor-view.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 47b46344d..64dc5a1a0 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -21,3 +21,6 @@ class ReactEditorView extends View React.unmountComponentAtNode(@element) @attached = false @trigger 'editor:detached', this + + getPane: -> + @closest('.pane').view() From 9a3f8022ad323b27b4672c917217529d8767d791 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 15:44:12 -0600 Subject: [PATCH 095/179] Add shims to get bracket matcher working --- src/display-buffer.coffee | 3 +++ src/editor.coffee | 2 +- src/react-editor-view.coffee | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 8fd184736..e91ef6886 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -427,6 +427,9 @@ class DisplayBuffer extends Model new Point(row, column) + pixelPositionForBufferPosition: (bufferPosition) -> + @pixelPositionForScreenPosition(@screenPositionForBufferPosition(bufferPosition)) + # Gets the number of screen lines. # # Returns a {Number}. diff --git a/src/editor.coffee b/src/editor.coffee index e52f9097d..29d0e95b7 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -156,7 +156,7 @@ class Editor extends Model 'getScrollBottom', 'setScrollBottom', 'getScrollLeft', 'setScrollLeft', 'getScrollRight', 'setScrollRight', 'getScrollHeight', 'getScrollWidth', 'getVisibleRowRange', 'intersectsVisibleRowRange', 'selectionIntersectsVisibleRowRange', 'pixelPositionForScreenPosition', - 'screenPositionForPixelPosition', toProperty: 'displayBuffer' + 'screenPositionForPixelPosition', 'pixelPositionForBufferPosition', toProperty: 'displayBuffer' @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', '$scrollTop', '$scrollLeft', toProperty: 'displayBuffer' diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 64dc5a1a0..065c47d90 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -1,4 +1,4 @@ -{View} = require 'space-pen' +{View, $} = require 'space-pen' React = require 'react' EditorComponent = require './editor-component' @@ -11,12 +11,19 @@ class ReactEditorView extends View getEditor: -> @editor + Object.defineProperty @::, 'lineHeight', get: -> @editor.getLineHeight() + Object.defineProperty @::, 'charWidth', get: -> @editor.getDefaultCharWidth() + afterAttach: (onDom) -> return unless onDom @attached = true @component = React.renderComponent(EditorComponent({@editor, parentView: this}), @element) + @underlayer = $(@component.getDOMNode()).find('.underlayer') @trigger 'editor:attached', [this] + pixelPositionForBufferPosition: (bufferPosition) -> + @editor.pixelPositionForBufferPosition(bufferPosition) + beforeDetach: -> React.unmountComponentAtNode(@element) @attached = false From 6b4ce5f2050faa6a5956a7c5858cb8614235b46d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 16:09:53 -0600 Subject: [PATCH 096/179] Add shims to get git-diff-view working --- src/editor-component.coffee | 9 +++++++-- src/react-editor-view.coffee | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 3818fba50..622c55006 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -70,7 +70,7 @@ EditorCompont = React.createClass lineNumber = (bufferRow + 1).toString() key = bufferRow.toString() - LineNumberComponent({lineNumber, maxDigits, key}))... + LineNumberComponent({lineNumber, maxDigits, bufferRow, key}))... div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] @@ -162,6 +162,7 @@ EditorCompont = React.createClass @updateVerticalScrollbar() @updateHorizontalScrollbar() @measureNewLines() + @props.parentView.trigger 'editor:display-updated' # The React-provided scrollTop property doesn't work in this case because when # initially rendering, the synthetic scrollHeight hasn't been computed yet. @@ -590,7 +591,11 @@ LineComponent = React.createClass LineNumberComponent = React.createClass render: -> - div className: 'line-number', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + {bufferRow} = @props + div + className: "line-number line-number-#{bufferRow}" + 'data-buffer-row': bufferRow + dangerouslySetInnerHTML: {__html: @buildInnerHTML()} buildInnerHTML: -> {lineNumber, maxDigits} = @props diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 065c47d90..090d6c430 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -18,7 +18,20 @@ class ReactEditorView extends View return unless onDom @attached = true @component = React.renderComponent(EditorComponent({@editor, parentView: this}), @element) - @underlayer = $(@component.getDOMNode()).find('.underlayer') + + node = @component.getDOMNode() + + @underlayer = $(node).find('.underlayer') + + @gutter = $(node).find('.gutter') + @gutter.removeClassFromAllLines = (klass) => + @gutter.find('.line-number').removeClass(klass) + + @gutter.addClassToLine = (bufferRow, klass) => + lines = @gutter.find(".line-number-#{bufferRow}") + lines.addClass(klass) + lines.length > 0 + @trigger 'editor:attached', [this] pixelPositionForBufferPosition: (bufferPosition) -> From 7a4dc0b9a4b70fb5a0ee7e3fa78450c84641ca65 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 10 Apr 2014 18:13:00 -0600 Subject: [PATCH 097/179] Eliminate duplicate key to pass coffeelint --- spec/display-buffer-spec.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index 59a1018ac..cd084ab03 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -950,7 +950,7 @@ describe "DisplayBuffer", -> displayBuffer.setLineHeight(20) displayBuffer.setDefaultCharWidth(10) - displayBuffer.setScopedCharWidths(["source.js", "keyword.control.js"], r: 11, e: 11, t: 11, u: 11, r: 11, n: 11) + displayBuffer.setScopedCharWidths(["source.js", "keyword.control.js"], r: 11, e: 11, t: 11, u: 11, n: 11) {start, end} = marker.getPixelRange() expect(start.top).toBe 5 * 20 From 9ec38ddb0d516a75e2ceedcd7278fb6ec4c27a94 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 08:53:06 -0600 Subject: [PATCH 098/179] Remove setInterval spy. It's now spied in the spec helper w/ setTimeout --- spec/editor-view-spec.coffee | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/editor-view-spec.coffee b/spec/editor-view-spec.coffee index 9880e0d2c..f4069053a 100644 --- a/spec/editor-view-spec.coffee +++ b/spec/editor-view-spec.coffee @@ -624,8 +624,6 @@ describe "EditorView", -> editorView.attachToDom(heightInLines: 5) editorView.scrollToBottom() - spyOn(window, 'setInterval').andCallFake -> - # start editorView.renderedLines.trigger mousedownEvent(editorView: editorView, point: [12, 0]) originalScrollTop = editorView.scrollTop() @@ -674,8 +672,6 @@ describe "EditorView", -> editorView.attachToDom(heightInLines: 5) editorView.scrollToBottom() - spyOn(window, 'setInterval').andCallFake -> - editorView.renderedLines.trigger mousedownEvent(editorView: editorView, point: [12, 0]) originalScrollTop = editorView.scrollTop() From e3eb51c135e53d3e275d0e998d8d7c5703db551e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 08:55:16 -0600 Subject: [PATCH 099/179] Don't expect trailing whitespace invisibles on soft-wrapped lines Now that trailing whitespace status of tokens is assigned at construction time, we no longer render invisibles at the end of soft-wrapped lines. Pretty sure this is not a behavior we wanted anyway. --- spec/editor-view-spec.coffee | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/editor-view-spec.coffee b/spec/editor-view-spec.coffee index f4069053a..8a1d99a94 100644 --- a/spec/editor-view-spec.coffee +++ b/spec/editor-view-spec.coffee @@ -1592,7 +1592,7 @@ describe "EditorView", -> editor.setSoftWrap(true) it "doesn't show the end of line invisible at the end of lines broken due to wrapping", -> - editor.setText "a line that wraps" + editor.setText "a line that wraps " editorView.attachToDom() editorView.setWidthInChars(6) atom.config.set "editor.showInvisibles", true @@ -1600,11 +1600,11 @@ describe "EditorView", -> expect(space).toBeTruthy() eol = editorView.invisibles?.eol expect(eol).toBeTruthy() - expect(editorView.renderedLines.find('.line:first').text()).toBe "a line#{space}" - expect(editorView.renderedLines.find('.line:last').text()).toBe "wraps#{eol}" + expect(editorView.renderedLines.find('.line:first').text()).toBe "a line " + expect(editorView.renderedLines.find('.line:last').text()).toBe "wraps#{space}#{eol}" it "displays trailing carriage return using a visible non-empty value", -> - editor.setText "a line that\r\n" + editor.setText "a line that \r\n" editorView.attachToDom() editorView.setWidthInChars(6) atom.config.set "editor.showInvisibles", true @@ -1614,8 +1614,8 @@ describe "EditorView", -> expect(cr).toBeTruthy() eol = editorView.invisibles?.eol expect(eol).toBeTruthy() - expect(editorView.renderedLines.find('.line:first').text()).toBe "a line#{space}" - expect(editorView.renderedLines.find('.line:eq(1)').text()).toBe "that#{cr}#{eol}" + expect(editorView.renderedLines.find('.line:first').text()).toBe "a line " + expect(editorView.renderedLines.find('.line:eq(1)').text()).toBe "that#{space}#{cr}#{eol}" expect(editorView.renderedLines.find('.line:last').text()).toBe "#{eol}" describe "when editor.showIndentGuide is set to true", -> From 28dd7d4acdb594fe28a4f136ab2c849d18cb0705 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 09:11:49 -0600 Subject: [PATCH 100/179] Treat all whitespace lines as not having leading whitespace Instead it's treated as all trailing whitespace, as it was originally. --- spec/tokenized-buffer-spec.coffee | 4 ++-- src/token.coffee | 4 ++-- src/tokenized-line.coffee | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index b498bd9e8..c1f29196a 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -483,9 +483,9 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.lineForScreenRow(5).tokens[3].hasLeadingWhitespace).toBe true expect(tokenizedBuffer.lineForScreenRow(5).tokens[4].hasLeadingWhitespace).toBe false - # Lines that are *only* whitespace are considered to have leading whitespace + # Lines that are *only* whitespace are not considered to have leading whitespace buffer.insert([10, 0], ' ') - expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasLeadingWhitespace).toBe false it "sets ::hasTrailingWhitespace to true on tokens that have trailing whitespace", -> buffer.insert([0, Infinity], ' ') diff --git a/src/token.coffee b/src/token.coffee index aabd60bdb..f42c5a9cc 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -144,7 +144,7 @@ class Token leadingHtml = '' trailingHtml = '' - if @hasLeadingWhitespace and not @hasTrailingWhitespace and match = LeadingWhitespaceRegex.exec(@value) + if @hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) classes = 'leading-whitespace' classes += ' indent-guide' if hasIndentGuide classes += ' invisible-character' if invisibles.space @@ -156,7 +156,7 @@ class Token if @hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and @hasLeadingWhitespace + classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace classes += ' invisible-character' if invisibles.space match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index dd7576e16..1bbec7972 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -119,7 +119,7 @@ class TokenizedLine outputTokens markLeadingAndTrailingWhitespaceTokens: -> - firstNonWhitespacePosition = @text.search(/\S|$/) + firstNonWhitespacePosition = @text.search(/\S/) firstTrailingWhitespacePosition = @text.search(/\s*$/) lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0 position = 0 From 4b9871fa1394037b425ab7673ae1f631f9f83bfe Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 10:06:33 -0600 Subject: [PATCH 101/179] Enable advanced scroll management only when editor is used by react view This preserves the original behavior of the editor model with respect to scroll position and autoscroll unless it's being used by the react component, which flips the ::manageScrollPosition flag to true. --- spec/display-buffer-spec.coffee | 2 ++ spec/editor-spec.coffee | 1 + src/cursor.coffee | 2 +- src/display-buffer.coffee | 11 +++++++++-- src/editor-component.coffee | 2 ++ src/editor.coffee | 2 +- 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index cd084ab03..8635020b4 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -958,6 +958,7 @@ describe "DisplayBuffer", -> describe "::setScrollTop", -> beforeEach -> + displayBuffer.manageScrollPosition = true displayBuffer.setLineHeight(10) it "disallows negative values", -> @@ -977,6 +978,7 @@ describe "DisplayBuffer", -> describe "::setScrollLeft", -> beforeEach -> + displayBuffer.manageScrollPosition = true displayBuffer.setDefaultCharWidth(10) it "disallows negative values", -> diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index 2343fca72..783a0d5fe 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -662,6 +662,7 @@ describe "Editor", -> describe "autoscroll", -> beforeEach -> + editor.manageScrollPosition = true editor.setVerticalScrollMargin(2) editor.setHorizontalScrollMargin(2) editor.setLineHeight(10) diff --git a/src/cursor.coffee b/src/cursor.coffee index f7e3f1b68..fd960b0cf 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -31,7 +31,7 @@ class Cursor @needsAutoscroll ?= @isLastCursor() and !textChanged # Supports react editor view - @autoscroll() if @needsAutoscroll + @autoscroll() if @needsAutoscroll and @editor.manageScrollPosition @goalColumn = null diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index e91ef6886..77dd8496d 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -20,6 +20,7 @@ class DisplayBuffer extends Model Serializable.includeInto(this) @properties + manageScrollPosition: false softWrap: null editorWidthInChars: null lineHeight: null @@ -113,7 +114,10 @@ class DisplayBuffer extends Model getScrollTop: -> @scrollTop setScrollTop: (scrollTop) -> - @scrollTop = Math.max(0, Math.min(@getScrollHeight() - @getHeight(), scrollTop)) + if @manageScrollPosition + @scrollTop = Math.max(0, Math.min(@getScrollHeight() - @getHeight(), scrollTop)) + else + @scrollTop = scrollTop getScrollBottom: -> @scrollTop + @height setScrollBottom: (scrollBottom) -> @@ -122,7 +126,10 @@ class DisplayBuffer extends Model getScrollLeft: -> @scrollLeft setScrollLeft: (scrollLeft) -> - @scrollLeft = Math.max(0, Math.min(@getScrollWidth() - @getWidth(), scrollLeft)) + if @manageScrollPosition + @scrollLeft = Math.max(0, Math.min(@getScrollWidth() - @getWidth(), scrollLeft)) + else + @scrollLeft = scrollLeft getScrollRight: -> @scrollLeft + @width setScrollRight: (scrollRight) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 622c55006..f86a50de7 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -145,6 +145,8 @@ EditorCompont = React.createClass componentDidMount: -> @measuredLines = new WeakSet + @props.editor.manageScrollPosition = true + @listenForDOMEvents() @listenForCommands() @observeEditor() diff --git a/src/editor.coffee b/src/editor.coffee index 29d0e95b7..5a9fe3b3a 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -159,7 +159,7 @@ class Editor extends Model 'screenPositionForPixelPosition', 'pixelPositionForBufferPosition', toProperty: 'displayBuffer' @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', - '$scrollTop', '$scrollLeft', toProperty: 'displayBuffer' + '$scrollTop', '$scrollLeft', 'manageScrollPosition', toProperty: 'displayBuffer' constructor: ({@softTabs, initialLine, tabLength, softWrap, @displayBuffer, buffer, registerEditor, suppressCursorCreation}) -> super From 2517765821030d93ea104d55e599aafb9f03ca45 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 10:07:23 -0600 Subject: [PATCH 102/179] Rename 'core.useNewEditor' to 'core.useReactEditor' and default to false --- src/editor.coffee | 2 +- src/workspace-view.coffee | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor.coffee b/src/editor.coffee index 5a9fe3b3a..3fc49d0a3 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -225,7 +225,7 @@ class Editor extends Model @subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args... getViewClass: -> - if atom.config.get('core.useNewEditor') + if atom.config.get('core.useReactEditor') require './react-editor-view' else require './editor-view' diff --git a/src/workspace-view.coffee b/src/workspace-view.coffee index c4bcaad36..efbf25b2d 100644 --- a/src/workspace-view.coffee +++ b/src/workspace-view.coffee @@ -71,6 +71,7 @@ class WorkspaceView extends View projectHome: path.join(fs.getHomeDirectory(), 'github') audioBeep: true destroyEmptyPanes: true + useReactEditor: false @content: -> @div class: 'workspace', tabindex: -1, => From ca4dd5a29a93e22225ccf4384244ed46b5fb0bba Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 10:07:37 -0600 Subject: [PATCH 103/179] Explicitly disable use of react editor in specs --- spec/spec-helper.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 13b4449d4..7337f3ca3 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -84,6 +84,7 @@ beforeEach -> config.set "editor.autoIndent", false config.set "core.disabledPackages", ["package-that-throws-an-exception", "package-with-broken-package-json", "package-with-broken-keymap"] + config.set "core.useReactEditor", false config.save.reset() atom.config = config From 205e10fd09cb871f60f957e43ddb5af81d0aebf7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 10:07:57 -0600 Subject: [PATCH 104/179] Fix undefined variable references rendering lines --- src/editor-view.coffee | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/editor-view.coffee b/src/editor-view.coffee index d1fcbcc29..bb15805e1 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -1482,13 +1482,10 @@ class EditorView extends View html = @buildEmptyLineHtml(showIndentGuide, eolInvisibles, htmlEolInvisibles, indentation, editor, mini) line.push(html) if html else - firstNonWhitespacePosition = text.search(/\S/) - firstTrailingWhitespacePosition = text.search(/\s*$/) - lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0 position = 0 for token in tokens @updateScopeStack(line, scopeStack, token.scopes) - hasIndentGuide = not mini and showIndentGuide and (hasLeadingWhitespace or lineIsWhitespaceOnly) + hasIndentGuide = not mini and showIndentGuide line.push(token.getValueAsHtml({invisibles, hasIndentGuide})) position += token.value.length From de773e4f753a974d40d28787d041ce4f1736f986 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 16:41:52 -0600 Subject: [PATCH 105/179] Revert "Remove setInterval spy. It's now spied in the spec helper w/ setTimeout" This reverts commit 930f1d7f018bb9949b0ee0e4ca7330a8a4ce0ec7. I actually don't want to globally spy on setInterval because it interferes with expected behaviors and eventually I'd like both of these to be opt-in. --- spec/editor-view-spec.coffee | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/editor-view-spec.coffee b/spec/editor-view-spec.coffee index 8a1d99a94..2a4ffa455 100644 --- a/spec/editor-view-spec.coffee +++ b/spec/editor-view-spec.coffee @@ -624,6 +624,8 @@ describe "EditorView", -> editorView.attachToDom(heightInLines: 5) editorView.scrollToBottom() + spyOn(window, 'setInterval').andCallFake -> + # start editorView.renderedLines.trigger mousedownEvent(editorView: editorView, point: [12, 0]) originalScrollTop = editorView.scrollTop() @@ -672,6 +674,8 @@ describe "EditorView", -> editorView.attachToDom(heightInLines: 5) editorView.scrollToBottom() + spyOn(window, 'setInterval').andCallFake -> + editorView.renderedLines.trigger mousedownEvent(editorView: editorView, point: [12, 0]) originalScrollTop = editorView.scrollTop() From f1f93f2f70c22901ebbe8c2719a4e9ed080578db Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 16:43:04 -0600 Subject: [PATCH 106/179] Spy on setInterval explicitly when needed in EditorComponent spec --- spec/editor-component-spec.coffee | 3 +++ spec/spec-helper.coffee | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 15b08af33..02d19e3cb 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -9,6 +9,9 @@ describe "EditorComponent", -> atom.packages.activatePackage('language-javascript') runs -> + spyOn(window, "setInterval").andCallFake window.fakeSetInterval + spyOn(window, "clearInterval").andCallFake window.fakeClearInterval + delayAnimationFrames = false nextAnimationFrame = null spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 7337f3ca3..4dd834090 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -93,8 +93,6 @@ beforeEach -> spyOn(WorkspaceView.prototype, 'setTitle').andCallFake (@title) -> spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout - spyOn(window, "setInterval").andCallFake window.fakeSetInterval - spyOn(window, "clearInterval").andCallFake window.fakeClearInterval spyOn(pathwatcher.File.prototype, "detectResurrectionAfterDelay").andCallFake -> @detectResurrection() spyOn(Editor.prototype, "shouldPromptToSave").andReturn false From 8c266957f1e54a6150cb73a9c50289db20413e69 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 11 Apr 2014 18:04:02 -0600 Subject: [PATCH 107/179] Isolate CSS changes to a single selector --- static/editor.less | 66 ++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/static/editor.less b/static/editor.less index f395d5d10..03bb163a7 100644 --- a/static/editor.less +++ b/static/editor.less @@ -2,12 +2,38 @@ @import "octicon-utf-codes"; @import "octicon-mixins"; -.editor.react .underlayer { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; +.editor.react { + .underlayer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + .horizontal-scrollbar { + position: absolute; + left: 0; + right: 0; + bottom: 0; + + height: 15px; + overflow-x: auto; + overflow-y: hidden; + z-index: 3; + + .scrollbar-content { + height: 15px; + } + } + + .scroll-view { + overflow: hidden; + } + + .scroll-view-content { + position: relative; + } } .editor { @@ -116,23 +142,9 @@ z-index: 3; } -.editor .horizontal-scrollbar { - position: absolute; - left: 0; - right: 0; - bottom: 0; - - height: 15px; - overflow-x: auto; - z-index: 3; - - .scrollbar-content { - height: 15px; - } -} - .editor .scroll-view { - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; -webkit-flex: 1; min-width: 0; position: relative; @@ -156,10 +168,6 @@ .editor .overlayer { z-index: 2; position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; } .editor .line { @@ -223,9 +231,3 @@ color: @text-color-subtle; } } - -.react-wrapper > .editor { - .scroll-view-content { - position: relative; - } -} From 35ea4e6de49f898a2a19b21d69256a1145cf1388 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 10:11:11 -0600 Subject: [PATCH 108/179] Extract gutter to its own component --- src/editor-component.coffee | 52 +++---------------------------------- src/gutter-component.coffee | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 48 deletions(-) create mode 100644 src/gutter-component.coffee diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f86a50de7..a8c9c5267 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -4,6 +4,7 @@ ReactUpdates = require 'react/lib/ReactUpdates' {$$} = require 'space-pen' {debounce, multiplyString} = require 'underscore-plus' +GutterComponent = require './gutter-component' InputComponent = require './input-component' SelectionComponent = require './selection-component' CursorComponent = require './cursor-component' @@ -26,12 +27,13 @@ EditorCompont = React.createClass render: -> {fontSize, lineHeight, fontFamily, focused} = @state {editor} = @props + visibleRowRange = @getVisibleRowRange() + className = 'editor react' className += ' is-focused' if focused div className: className, tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, - div className: 'gutter', - @renderGutterContent() + GutterComponent({editor, visibleRowRange}) div className: 'scroll-view', ref: 'scrollView', InputComponent ref: 'input' @@ -46,34 +48,6 @@ EditorCompont = React.createClass div className: 'horizontal-scrollbar', ref: 'horizontalScrollbar', onScroll: @onHorizontalScroll, div className: 'scrollbar-content', style: {width: editor.getScrollWidth()} - renderGutterContent: -> - {editor} = @props - [startRow, endRow] = @getVisibleRowRange() - lineHeightInPixels = editor.getLineHeight() - precedingHeight = startRow * lineHeightInPixels - followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels - maxDigits = editor.getLastBufferRow().toString().length - style = - height: editor.getScrollHeight() - WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" - - wrapCount = 0 - div className: 'line-numbers', style: style, [ - div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} - (for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) - if bufferRow is lastBufferRow - lineNumber = '•' - key = "#{bufferRow}-#{++wrapCount}" - else - lastBufferRow = bufferRow - wrapCount = 0 - lineNumber = (bufferRow + 1).toString() - key = bufferRow.toString() - - LineNumberComponent({lineNumber, maxDigits, bufferRow, key}))... - div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} - ] - getHiddenInputPosition: -> {editor} = @props @@ -590,21 +564,3 @@ LineComponent = React.createClass shouldComponentUpdate: (newProps, newState) -> newProps.showIndentGuide isnt @props.showIndentGuide - -LineNumberComponent = React.createClass - render: -> - {bufferRow} = @props - div - className: "line-number line-number-#{bufferRow}" - 'data-buffer-row': bufferRow - dangerouslySetInnerHTML: {__html: @buildInnerHTML()} - - buildInnerHTML: -> - {lineNumber, maxDigits} = @props - if lineNumber.length < maxDigits - padding = multiplyString(' ', maxDigits - lineNumber.length) - padding + lineNumber + @iconDivHTML - else - lineNumber + @iconDivHTML - - iconDivHTML: '
' diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee new file mode 100644 index 000000000..f01126c86 --- /dev/null +++ b/src/gutter-component.coffee @@ -0,0 +1,52 @@ +React = require 'react' +{div} = require 'reactionary' +{multiplyString} = require 'underscore-plus' + +module.exports = +GutterComponent = React.createClass + render: -> + {editor, visibleRowRange} = @props + [startRow, endRow] = visibleRowRange + lineHeightInPixels = editor.getLineHeight() + precedingHeight = startRow * lineHeightInPixels + followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + maxDigits = editor.getLastBufferRow().toString().length + style = + height: editor.getScrollHeight() + WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" + wrapCount = 0 + + div className: 'gutter', + div className: 'line-numbers', style: style, [ + div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} + (for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) + if bufferRow is lastBufferRow + lineNumber = '•' + key = "#{bufferRow}-#{++wrapCount}" + else + lastBufferRow = bufferRow + wrapCount = 0 + lineNumber = (bufferRow + 1).toString() + key = bufferRow.toString() + + LineNumberComponent({lineNumber, maxDigits, bufferRow, key}))... + div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} + ] + +LineNumberComponent = React.createClass + render: -> + {bufferRow} = @props + div + className: "line-number line-number-#{bufferRow}" + 'data-buffer-row': bufferRow + dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + + buildInnerHTML: -> + {lineNumber, maxDigits} = @props + if lineNumber.length < maxDigits + padding = multiplyString(' ', maxDigits - lineNumber.length) + padding + lineNumber + @iconDivHTML + else + lineNumber + @iconDivHTML + + iconDivHTML: '
' From cec62c56a629a06bd2e60102d3fc522e3117f6d4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 10:22:07 -0600 Subject: [PATCH 109/179] Extract a ScrollbarComponent --- src/editor-component.coffee | 19 +++++++++++++++---- src/scrollbar-component.coffee | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/scrollbar-component.coffee diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a8c9c5267..d101767ea 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -6,6 +6,7 @@ ReactUpdates = require 'react/lib/ReactUpdates' GutterComponent = require './gutter-component' InputComponent = require './input-component' +ScrollbarComponent = require './scrollbar-component' SelectionComponent = require './selection-component' CursorComponent = require './cursor-component' SubscriberMixin = require './subscriber-mixin' @@ -43,10 +44,20 @@ EditorCompont = React.createClass onFocus: @onInputFocused onBlur: @onInputBlurred @renderScrollViewContent() - div className: 'vertical-scrollbar', ref: 'verticalScrollbar', onScroll: @onVerticalScroll, - div className: 'scrollbar-content', style: {height: editor.getScrollHeight()} - div className: 'horizontal-scrollbar', ref: 'horizontalScrollbar', onScroll: @onHorizontalScroll, - div className: 'scrollbar-content', style: {width: editor.getScrollWidth()} + + ScrollbarComponent + ref: 'verticalScrollbar' + className: 'vertical-scrollbar' + orientation: 'vertical' + onScroll: @onVerticalScroll + scrollHeight: editor.getScrollHeight() + + ScrollbarComponent + ref: 'horizontalScrollbar' + className: 'horizontal-scrollbar' + orientation: 'horizontal' + onScroll: @onHorizontalScroll + scrollWidth: editor.getScrollWidth() getHiddenInputPosition: -> {editor} = @props diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee new file mode 100644 index 000000000..9c4cc3ec0 --- /dev/null +++ b/src/scrollbar-component.coffee @@ -0,0 +1,16 @@ +React = require 'react' +{div} = require 'reactionary' + +module.exports = +ScrollbarComponent = React.createClass + render: -> + {orientation, className, onScroll, scrollHeight, scrollWidth} = @props + + div {className, onScroll}, + switch orientation + when 'vertical' + div className: 'scrollbar-content', style: {height: scrollHeight} + when 'horizontal' + div className: 'scrollbar-content', style: {width: scrollWidth} + else + throw new Error("Must specify an orientation property of 'vertical' or 'horizontal'") From 355abef2cf7584bcdf339775794424c316d0f8af Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 10:31:22 -0600 Subject: [PATCH 110/179] Manage update of scrollbar scroll positions in ScrollbarComponent --- src/editor-component.coffee | 28 ++-------------------------- src/scrollbar-component.coffee | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index d101767ea..d2e1deeba 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -18,7 +18,6 @@ module.exports = EditorCompont = React.createClass pendingScrollTop: null pendingScrollLeft: null - lastScrollTop: null selectOnMouseMove: false statics: {DummyLineNode} @@ -50,6 +49,7 @@ EditorCompont = React.createClass className: 'vertical-scrollbar' orientation: 'vertical' onScroll: @onVerticalScroll + scrollTop: editor.getScrollTop() scrollHeight: editor.getScrollHeight() ScrollbarComponent @@ -57,6 +57,7 @@ EditorCompont = React.createClass className: 'horizontal-scrollbar' orientation: 'horizontal' onScroll: @onHorizontalScroll + scrollLeft: editor.getScrollLeft() scrollWidth: editor.getScrollWidth() getHiddenInputPosition: -> @@ -146,34 +147,9 @@ EditorCompont = React.createClass @stopBlinkingCursors() componentDidUpdate: -> - @updateVerticalScrollbar() - @updateHorizontalScrollbar() @measureNewLines() @props.parentView.trigger 'editor:display-updated' - # The React-provided scrollTop property doesn't work in this case because when - # initially rendering, the synthetic scrollHeight hasn't been computed yet. - # trying to assign it before the element inside is tall enough? - updateVerticalScrollbar: -> - {editor} = @props - scrollTop = editor.getScrollTop() - - return if scrollTop is @lastScrollTop - - scrollbarNode = @refs.verticalScrollbar.getDOMNode() - scrollbarNode.scrollTop = scrollTop - @lastScrollTop = scrollbarNode.scrollTop - - updateHorizontalScrollbar: -> - {editor} = @props - scrollLeft = editor.getScrollLeft() - - return if scrollLeft is @lastScrollLeft - - scrollbarNode = @refs.horizontalScrollbar.getDOMNode() - scrollbarNode.scrollLeft = scrollLeft - @lastScrollLeft = scrollbarNode.scrollLeft - observeEditor: -> {editor} = @props @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee index 9c4cc3ec0..a82053b21 100644 --- a/src/scrollbar-component.coffee +++ b/src/scrollbar-component.coffee @@ -3,6 +3,9 @@ React = require 'react' module.exports = ScrollbarComponent = React.createClass + lastScrollTop: null + lastScrollLeft: null + render: -> {orientation, className, onScroll, scrollHeight, scrollWidth} = @props @@ -12,5 +15,23 @@ ScrollbarComponent = React.createClass div className: 'scrollbar-content', style: {height: scrollHeight} when 'horizontal' div className: 'scrollbar-content', style: {width: scrollWidth} - else - throw new Error("Must specify an orientation property of 'vertical' or 'horizontal'") + + componentDidMount: -> + {orientation} = @props + + unless orientation is 'vertical' or orientation is 'horizontal' + throw new Error("Must specify an orientation property of 'vertical' or 'horizontal'") + + componentDidUpdate: -> + {orientation, scrollTop, scrollLeft} = @props + node = @getDOMNode() + + switch orientation + when 'vertical' + unless scrollTop is @lastScrollTop + node.scrollTop = scrollTop + @lastScrollTop = node.scrollTop + when 'horizontal' + unless scrollLeft is @lastScrollLeft + node.scrollLeft = scrollLeft + @lastScrollLeft = node.scrollLeft From aee552476a33ccd8a86923278b163cf2c0563a1d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 10:43:57 -0600 Subject: [PATCH 111/179] Call onScroll with the current scrollTop/Left in ScrollbarComponent --- src/editor-component.coffee | 14 ++++++++------ src/scrollbar-component.coffee | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index d2e1deeba..1abc6b876 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -308,9 +308,10 @@ EditorCompont = React.createClass editor.selectLeft() if replaceLastCharacter editor.insertText(char) - onVerticalScroll: -> - scrollTop = @refs.verticalScrollbar.getDOMNode().scrollTop - return if @props.editor.getScrollTop() is scrollTop + onVerticalScroll: (scrollTop) -> + {editor} = @props + + return if scrollTop is editor.getScrollTop() animationFramePending = @pendingScrollTop? @pendingScrollTop = scrollTop @@ -319,9 +320,10 @@ EditorCompont = React.createClass @props.editor.setScrollTop(@pendingScrollTop) @pendingScrollTop = null - onHorizontalScroll: -> - scrollLeft = @refs.horizontalScrollbar.getDOMNode().scrollLeft - return if @props.editor.getScrollLeft() is scrollLeft + onHorizontalScroll: (scrollLeft) -> + {editor} = @props + + return if scrollLeft is editor.getScrollLeft() animationFramePending = @pendingScrollLeft? @pendingScrollLeft = scrollLeft diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee index a82053b21..8560a6cfe 100644 --- a/src/scrollbar-component.coffee +++ b/src/scrollbar-component.coffee @@ -7,9 +7,9 @@ ScrollbarComponent = React.createClass lastScrollLeft: null render: -> - {orientation, className, onScroll, scrollHeight, scrollWidth} = @props + {orientation, className, scrollHeight, scrollWidth} = @props - div {className, onScroll}, + div {className, @onScroll}, switch orientation when 'vertical' div className: 'scrollbar-content', style: {height: scrollHeight} @@ -35,3 +35,13 @@ ScrollbarComponent = React.createClass unless scrollLeft is @lastScrollLeft node.scrollLeft = scrollLeft @lastScrollLeft = node.scrollLeft + + onScroll: -> + {orientation, onScroll} = @props + node = @getDOMNode() + + switch orientation + when 'vertical' + onScroll(node.scrollTop) + when 'horizontal' + onScroll(node.scrollLeft) From 5c2eb053d88fbcba45551dd5096a154481d59a0b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 13:06:15 -0600 Subject: [PATCH 112/179] Extract an EditorScrollView component --- spec/editor-component-spec.coffee | 54 +++-- src/editor-component.coffee | 295 ++---------------------- src/editor-scroll-view-component.coffee | 293 +++++++++++++++++++++++ 3 files changed, 344 insertions(+), 298 deletions(-) create mode 100644 src/editor-scroll-view-component.coffee diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 02d19e3cb..efbe92a21 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -2,7 +2,8 @@ ReactEditorView = require '../src/react-editor-view' describe "EditorComponent", -> - [editor, wrapperView, component, node, lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] + [editor, wrapperView, component, node, verticalScrollbarNode, horizontalScrollbarNode] = [] + [lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] beforeEach -> waitsForPromise -> @@ -28,6 +29,8 @@ describe "EditorComponent", -> component.setFontSize(20) {lineHeightInPixels, charWidth} = component.measureLineDimensions() node = component.getDOMNode() + verticalScrollbarNode = node.querySelector('.vertical-scrollbar') + horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') describe "line rendering", -> it "renders only the currently-visible lines", -> @@ -39,8 +42,8 @@ describe "EditorComponent", -> expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text expect(lines[5].textContent).toBe editor.lineForScreenRow(5).text - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels - component.onVerticalScroll() + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate(0px, #{-2.5 * lineHeightInPixels}px)" @@ -115,8 +118,8 @@ describe "EditorComponent", -> expect(lines[0].textContent).toBe "#{nbsp}1" expect(lines[5].textContent).toBe "#{nbsp}6" - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels - component.onVerticalScroll() + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" @@ -169,8 +172,8 @@ describe "EditorComponent", -> expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth - node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels - component.onVerticalScroll() + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 2 @@ -444,18 +447,16 @@ describe "EditorComponent", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateAllDimensions() - scrollbarNode = node.querySelector('.vertical-scrollbar') - expect(scrollbarNode.scrollTop).toBe 0 + expect(verticalScrollbarNode.scrollTop).toBe 0 editor.setScrollTop(10) - expect(scrollbarNode.scrollTop).toBe 10 + expect(verticalScrollbarNode.scrollTop).toBe 10 it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> node.style.width = 30 * charWidth + 'px' component.updateAllDimensions() scrollViewContentNode = node.querySelector('.scroll-view-content') - horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" expect(horizontalScrollbarNode.scrollLeft).toBe 0 @@ -468,8 +469,8 @@ describe "EditorComponent", -> component.updateAllDimensions() expect(editor.getScrollLeft()).toBe 0 - node.querySelector('.horizontal-scrollbar').scrollLeft = 100 - component.onHorizontalScroll() + horizontalScrollbarNode.scrollLeft = 100 + horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) expect(editor.getScrollLeft()).toBe 100 @@ -479,9 +480,6 @@ describe "EditorComponent", -> node.style.width = 20 * charWidth + 'px' component.updateAllDimensions() - verticalScrollbarNode = node.querySelector('.vertical-scrollbar') - horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') - expect(verticalScrollbarNode.scrollTop).toBe 0 expect(horizontalScrollbarNode.scrollLeft).toBe 0 @@ -494,11 +492,25 @@ describe "EditorComponent", -> expect(horizontalScrollbarNode.scrollLeft).toBe 15 describe "input events", -> - it "inserts the typed character into the buffer", -> - component.onInput('x') + inputNode = null + + beforeEach -> + inputNode = node.querySelector('.hidden-input') + + it "inserts the newest character in the input's value into the buffer", -> + inputNode.value = 'x' + inputNode.dispatchEvent(new Event('input')) expect(editor.lineForBufferRow(0)).toBe 'xvar quicksort = function () {' - it "replaces the last character if replaceLastCharacter is true", -> - component.onInput('u') - component.onInput('ü', true) + inputNode.value = 'xy' + inputNode.dispatchEvent(new Event('input')) + expect(editor.lineForBufferRow(0)).toBe 'xyvar quicksort = function () {' + + it "replaces the last character if the length of the input's value doesn't increase", -> + inputNode.value = 'u' + inputNode.dispatchEvent(new Event('input')) + expect(editor.lineForBufferRow(0)).toBe 'uvar quicksort = function () {' + + inputNode.value = 'ü' + inputNode.dispatchEvent(new Event('input')) expect(editor.lineForBufferRow(0)).toBe 'üvar quicksort = function () {' diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 1abc6b876..12f1f0f7f 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -1,19 +1,13 @@ React = require 'react' -ReactUpdates = require 'react/lib/ReactUpdates' {div, span} = require 'reactionary' -{$$} = require 'space-pen' -{debounce, multiplyString} = require 'underscore-plus' +{debounce} = require 'underscore-plus' GutterComponent = require './gutter-component' -InputComponent = require './input-component' +EditorScrollViewComponent = require './editor-scroll-view-component' +{DummyLineNode} = EditorScrollViewComponent ScrollbarComponent = require './scrollbar-component' -SelectionComponent = require './selection-component' -CursorComponent = require './cursor-component' SubscriberMixin = require './subscriber-mixin' -DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] -AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} - module.exports = EditorCompont = React.createClass pendingScrollTop: null @@ -25,24 +19,20 @@ EditorCompont = React.createClass mixins: [SubscriberMixin] render: -> - {fontSize, lineHeight, fontFamily, focused} = @state - {editor} = @props + {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state + {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props visibleRowRange = @getVisibleRowRange() className = 'editor react' className += ' is-focused' if focused - div className: className, tabIndex: -1, style: {fontSize, lineHeight, fontFamily}, + div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, GutterComponent({editor, visibleRowRange}) - div className: 'scroll-view', ref: 'scrollView', - InputComponent - ref: 'input' - className: 'hidden-input' - style: @getHiddenInputPosition() - onInput: @onInput - onFocus: @onInputFocused - onBlur: @onInputBlurred - @renderScrollViewContent() + + EditorScrollViewComponent { + ref: 'scrollView', editor, visibleRowRange, @onInputFocused, @onInputBlurred + cursorBlinkPeriod, cursorBlinkResumeDelay, showIndentGuide, fontSize, fontFamily, lineHeight + } ScrollbarComponent ref: 'verticalScrollbar' @@ -60,61 +50,6 @@ EditorCompont = React.createClass scrollLeft: editor.getScrollLeft() scrollWidth: editor.getScrollWidth() - getHiddenInputPosition: -> - {editor} = @props - - if cursor = editor.getCursor() - cursorRect = cursor.getPixelRect() - top = cursorRect.top - editor.getScrollTop() - top = Math.max(0, Math.min(editor.getHeight(), top)) - left = cursorRect.left - editor.getScrollLeft() - left = Math.max(0, Math.min(editor.getWidth(), left)) - else - top = 0 - left = 0 - - {top, left} - - renderScrollViewContent: -> - {editor} = @props - style = - height: editor.getScrollHeight() - WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" - - div {className: 'scroll-view-content', style, @onMouseDown}, - @renderCursors() - @renderVisibleLines() - @renderUnderlayer() - - renderVisibleLines: -> - {editor} = @props - {showIndentGuide} = @state - [startRow, endRow] = @getVisibleRowRange() - lineHeightInPixels = editor.getLineHeight() - precedingHeight = startRow * lineHeightInPixels - followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels - - div className: 'lines', ref: 'lines', [ - div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} - (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}))... - div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} - ] - - renderCursors: -> - {editor} = @props - {blinkCursorsOff} = @state - - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) - - renderUnderlayer: -> - {editor} = @props - - div className: 'underlayer', - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({selection}) - getVisibleRowRange: -> visibleRowRange = @props.editor.getVisibleRowRange() if @visibleRowOverrides? @@ -129,17 +64,13 @@ EditorCompont = React.createClass cursorBlinkResumeDelay: 200 componentDidMount: -> - @measuredLines = new WeakSet - @props.editor.manageScrollPosition = true @listenForDOMEvents() @listenForCommands() @observeEditor() @observeConfig() - @startBlinkingCursors() - @updateAllDimensions() @props.editor.setVisible(true) componentWillUnmount: -> @@ -147,7 +78,6 @@ EditorCompont = React.createClass @stopBlinkingCursors() componentDidUpdate: -> - @measureNewLines() @props.parentView.trigger 'editor:display-updated' observeEditor: -> @@ -155,7 +85,6 @@ EditorCompont = React.createClass @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged @subscribe editor, 'selection-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded - @subscribe editor, 'cursors-moved', @pauseCursorBlinking @subscribe editor.$scrollTop.changes, @requestUpdate @subscribe editor.$scrollLeft.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @@ -165,8 +94,6 @@ EditorCompont = React.createClass listenForDOMEvents: -> @getDOMNode().addEventListener 'mousewheel', @onMouseWheel - @getDOMNode().addEventListener 'focus', @onFocus - @refs.scrollView.getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged listenForCommands: -> {parentView, editor, mini} = @props @@ -277,36 +204,25 @@ EditorCompont = React.createClass @subscribe atom.config.observe 'editor.showIndentGuide', @setShowIndentGuide setFontSize: (fontSize) -> - @clearScopedCharWidths() @setState({fontSize}) - @updateLineDimensions() setLineHeight: (lineHeight) -> @setState({lineHeight}) setFontFamily: (fontFamily) -> - @clearScopedCharWidths() @setState({fontFamily}) - @updateLineDimensions() setShowIndentGuide: (showIndentGuide) -> @setState({showIndentGuide}) onFocus: -> - @refs.input.focus() + @refs.scrollView.focus() onInputFocused: -> @setState(focused: true) onInputBlurred: -> - @setState(focused: false) unless document.activeElement is @getDOMNode() - - onInput: (char, replaceLastCharacter) -> - {editor} = @props - - ReactUpdates.batchedUpdates -> - editor.selectLeft() if replaceLastCharacter - editor.insertText(char) + @setState(focused: false) onVerticalScroll: (scrollTop) -> {editor} = @props @@ -349,91 +265,12 @@ EditorCompont = React.createClass event.preventDefault() - onMouseDown: (event) -> - {editor} = @props - {detail, shiftKey, metaKey} = event - screenPosition = @screenPositionForMouseEvent(event) - - if shiftKey - editor.selectToScreenPosition(screenPosition) - else if metaKey - editor.addCursorAtScreenPosition(screenPosition) - else - editor.setCursorScreenPosition(screenPosition) - switch detail - when 2 then editor.selectWord() - when 3 then editor.selectLine() - - @selectToMousePositionUntilMouseUp(event) - - selectToMousePositionUntilMouseUp: (event) -> - {editor} = @props - dragging = false - lastMousePosition = {} - - animationLoop = => - requestAnimationFrame => - if dragging - @selectToMousePosition(lastMousePosition) - animationLoop() - - onMouseMove = (event) -> - lastMousePosition.clientX = event.clientX - lastMousePosition.clientY = event.clientY - - # Start the animation loop when the mouse moves prior to a mouseup event - unless dragging - dragging = true - animationLoop() - - # Stop dragging when cursor enters dev tools because we can't detect mouseup - onMouseUp() if event.which is 0 - - onMouseUp = -> - dragging = false - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - editor.finalizeSelections() - - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - - selectToMousePosition: (event) -> - @props.editor.selectToScreenPosition(@screenPositionForMouseEvent(event)) - - screenPositionForMouseEvent: (event) -> - pixelPosition = @pixelPositionForMouseEvent(event) - @props.editor.screenPositionForPixelPosition(pixelPosition) - - pixelPositionForMouseEvent: (event) -> - {editor} = @props - {clientX, clientY} = event - - editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() - top = clientY - editorClientRect.top + editor.getScrollTop() - left = clientX - editorClientRect.left + editor.getScrollLeft() - {top, left} - clearVisibleRowOverrides: -> @visibleRowOverrides = null @forceUpdate() clearVisibleRowOverridesAfterDelay: null - onOverflowChanged: -> - {editor} = @props - {height, width} = @measureScrollViewDimensions() - - if height isnt editor.getHeight() - editor.setHeight(height) - update = true - - if width isnt editor.getWidth() - editor.setWidth(width) - update = true - - @requestUpdate() if update - onScreenLinesChanged: ({start, end}) -> {editor} = @props @requestUpdate() if editor.intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events @@ -446,110 +283,14 @@ EditorCompont = React.createClass {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) - startBlinkingCursors: -> - @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) - - stopBlinkingCursors: -> - clearInterval(@cursorBlinkIntervalHandle) - @setState(blinkCursorsOff: false) - - toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) - - pauseCursorBlinking: -> - @stopBlinkingCursors() - @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) - @startBlinkingCursorsAfterDelay() - requestUpdate: -> @forceUpdate() - updateAllDimensions: -> - {height, width} = @measureScrollViewDimensions() - {lineHeightInPixels, charWidth} = @measureLineDimensions() - {editor} = @props - - editor.setHeight(height) - editor.setWidth(width) - editor.setLineHeight(lineHeightInPixels) - editor.setDefaultCharWidth(charWidth) - - updateLineDimensions: -> - {lineHeightInPixels, charWidth} = @measureLineDimensions() - {editor} = @props - - editor.setLineHeight(lineHeightInPixels) - editor.setDefaultCharWidth(charWidth) - - measureScrollViewDimensions: -> - scrollViewNode = @refs.scrollView.getDOMNode() - {height: scrollViewNode.clientHeight, width: scrollViewNode.clientWidth} - measureLineDimensions: -> - linesNode = @refs.lines.getDOMNode() - linesNode.appendChild(DummyLineNode) - lineHeightInPixels = DummyLineNode.getBoundingClientRect().height - charWidth = DummyLineNode.firstChild.getBoundingClientRect().width - linesNode.removeChild(DummyLineNode) - {lineHeightInPixels, charWidth} + @refs.scrollView.measureLineDimensions() - measureNewLines: -> - [visibleStartRow, visibleEndRow] = @getVisibleRowRange() - linesNode = @refs.lines.getDOMNode() + updateAllDimensions: -> + @refs.scrollView.updateAllDimensions() - for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) - unless @measuredLines.has(tokenizedLine) - lineNode = linesNode.children[i + 1] - @measureCharactersInLine(tokenizedLine, lineNode) - - measureCharactersInLine: (tokenizedLine, lineNode) -> - {editor} = @props - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) - rangeForMeasurement = document.createRange() - - for {value, scopes} in tokenizedLine.tokens - textNode = iterator.nextNode() - charWidths = editor.getScopedCharWidths(scopes) - for char, i in value - unless charWidths[char]? - rangeForMeasurement.setStart(textNode, i) - rangeForMeasurement.setEnd(textNode, i + 1) - charWidth = rangeForMeasurement.getBoundingClientRect().width - editor.setScopedCharWidth(scopes, char, charWidth) - - @measuredLines.add(tokenizedLine) - - clearScopedCharWidths: -> - @measuredLines.clear() - @props.editor.clearScopedCharWidths() - -LineComponent = React.createClass - render: -> - div className: 'line', 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 = "#{multiplyString(' ', tabLength)}" - multiplyString(indentSpan, indentLevel + 1) - else - " " - - buildScopeTreeHTML: (scopeTree) -> - if scopeTree.children? - html = "" - html += @buildScopeTreeHTML(child) for child in scopeTree.children - html += "" - html - else - "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" - - shouldComponentUpdate: (newProps, newState) -> - newProps.showIndentGuide isnt @props.showIndentGuide + updateScrollViewDimensions: -> + @refs.scrollView.updateScrollViewDimensions() diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee new file mode 100644 index 000000000..722452b2b --- /dev/null +++ b/src/editor-scroll-view-component.coffee @@ -0,0 +1,293 @@ +React = require 'react' +ReactUpdates = require 'react/lib/ReactUpdates' +{div, span} = require 'reactionary' +{debounce, isEqual, multiplyString, pick} = require 'underscore-plus' +{$$} = require 'space-pen' + +InputComponent = require './input-component' +CursorComponent = require './cursor-component' +SelectionComponent = require './selection-component' +SubscriberMixin = require './subscriber-mixin' + +DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] +AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} + +module.exports = +EditorScrollViewComponent = React.createClass + mixins: [SubscriberMixin] + + render: -> + {onInputFocused, onInputBlurred} = @props + + div className: 'scroll-view', ref: 'scrollView', + InputComponent + ref: 'input' + className: 'hidden-input' + style: @getHiddenInputPosition() + onInput: @onInput + onFocus: onInputFocused + onBlur: onInputBlurred + @renderScrollViewContent() + + renderScrollViewContent: -> + {editor} = @props + style = + height: editor.getScrollHeight() + WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" + + div {className: 'scroll-view-content', style, @onMouseDown}, + @renderCursors() + @renderVisibleLines() + @renderUnderlayer() + + renderCursors: -> + {editor} = @props + {blinkCursorsOff} = @state + + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) + + renderVisibleLines: -> + {editor, visibleRowRange} = @props + {showIndentGuide} = @props + [startRow, endRow] = visibleRowRange + lineHeightInPixels = editor.getLineHeight() + precedingHeight = startRow * lineHeightInPixels + followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + + div className: 'lines', ref: 'lines', [ + div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} + (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}))... + div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} + ] + + renderUnderlayer: -> + {editor} = @props + + div className: 'underlayer', + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({selection}) + + getInitialState: -> + blinkCursorsOff: false + + componentDidMount: -> + @measuredLines = new WeakSet + + @getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged + + @subscribe @props.editor, 'cursors-moved', @pauseCursorBlinking + + + @updateAllDimensions() + @startBlinkingCursors() + + componentDidUpdate: (prevProps) -> + unless isEqual(pick(prevProps, 'fontSize', 'fontFamily', 'lineHeight'), pick(@props, 'fontSize', 'fontFamily', 'lineHeight')) + @updateLineDimensions() + + unless isEqual(pick(prevProps, 'fontSize', 'fontFamily'), pick(@props, 'fontSize', 'fontFamily')) + @clearScopedCharWidths() + + @measureNewLines() + + focus: -> + @refs.input.focus() + + startBlinkingCursors: -> + @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) + + stopBlinkingCursors: -> + clearInterval(@cursorBlinkIntervalHandle) + @setState(blinkCursorsOff: false) + + toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) + + pauseCursorBlinking: -> + @stopBlinkingCursors() + @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) + @startBlinkingCursorsAfterDelay() + + getHiddenInputPosition: -> + {editor} = @props + + if cursor = editor.getCursor() + cursorRect = cursor.getPixelRect() + top = cursorRect.top - editor.getScrollTop() + top = Math.max(0, Math.min(editor.getHeight(), top)) + left = cursorRect.left - editor.getScrollLeft() + left = Math.max(0, Math.min(editor.getWidth(), left)) + else + top = 0 + left = 0 + + {top, left} + + onInput: (char, replaceLastCharacter) -> + {editor} = @props + + ReactUpdates.batchedUpdates -> + editor.selectLeft() if replaceLastCharacter + editor.insertText(char) + + onMouseDown: (event) -> + {editor} = @props + {detail, shiftKey, metaKey} = event + screenPosition = @screenPositionForMouseEvent(event) + + if shiftKey + editor.selectToScreenPosition(screenPosition) + else if metaKey + editor.addCursorAtScreenPosition(screenPosition) + else + editor.setCursorScreenPosition(screenPosition) + switch detail + when 2 then editor.selectWord() + when 3 then editor.selectLine() + + @selectToMousePositionUntilMouseUp(event) + + selectToMousePositionUntilMouseUp: (event) -> + {editor} = @props + dragging = false + lastMousePosition = {} + + animationLoop = => + requestAnimationFrame => + if dragging + @selectToMousePosition(lastMousePosition) + animationLoop() + + onMouseMove = (event) -> + lastMousePosition.clientX = event.clientX + lastMousePosition.clientY = event.clientY + + # Start the animation loop when the mouse moves prior to a mouseup event + unless dragging + dragging = true + animationLoop() + + # Stop dragging when cursor enters dev tools because we can't detect mouseup + onMouseUp() if event.which is 0 + + onMouseUp = -> + dragging = false + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + editor.finalizeSelections() + + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + + selectToMousePosition: (event) -> + @props.editor.selectToScreenPosition(@screenPositionForMouseEvent(event)) + + screenPositionForMouseEvent: (event) -> + pixelPosition = @pixelPositionForMouseEvent(event) + @props.editor.screenPositionForPixelPosition(pixelPosition) + + pixelPositionForMouseEvent: (event) -> + {editor} = @props + {clientX, clientY} = event + + editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() + top = clientY - editorClientRect.top + editor.getScrollTop() + left = clientX - editorClientRect.left + editor.getScrollLeft() + {top, left} + + onOverflowChanged: -> + {editor} = @props + {height, width} = @measureScrollViewDimensions() + editor.setHeight(height) + editor.setWidth(width) + + updateAllDimensions: -> + @updateScrollViewDimensions() + @updateLineDimensions() + + updateScrollViewDimensions: -> + {editor} = @props + {height, width} = @measureScrollViewDimensions() + editor.setHeight(height) + editor.setWidth(width) + + updateLineDimensions: -> + {editor} = @props + {lineHeightInPixels, charWidth} = @measureLineDimensions() + editor.setLineHeight(lineHeightInPixels) + editor.setDefaultCharWidth(charWidth) + + measureScrollViewDimensions: -> + node = @getDOMNode() + {height: node.clientHeight, width: node.clientWidth} + + measureLineDimensions: -> + linesNode = @refs.lines.getDOMNode() + linesNode.appendChild(DummyLineNode) + lineHeightInPixels = DummyLineNode.getBoundingClientRect().height + charWidth = DummyLineNode.firstChild.getBoundingClientRect().width + linesNode.removeChild(DummyLineNode) + {lineHeightInPixels, charWidth} + + measureNewLines: -> + [visibleStartRow, visibleEndRow] = @props.visibleRowRange + linesNode = @refs.lines.getDOMNode() + + for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) + unless @measuredLines.has(tokenizedLine) + lineNode = linesNode.children[i + 1] + @measureCharactersInLine(tokenizedLine, lineNode) + + measureCharactersInLine: (tokenizedLine, lineNode) -> + {editor} = @props + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) + rangeForMeasurement = document.createRange() + + for {value, scopes} in tokenizedLine.tokens + textNode = iterator.nextNode() + charWidths = editor.getScopedCharWidths(scopes) + for char, i in value + unless charWidths[char]? + rangeForMeasurement.setStart(textNode, i) + rangeForMeasurement.setEnd(textNode, i + 1) + charWidth = rangeForMeasurement.getBoundingClientRect().width + editor.setScopedCharWidth(scopes, char, charWidth) + + @measuredLines.add(tokenizedLine) + + clearScopedCharWidths: -> + @measuredLines.clear() + @props.editor.clearScopedCharWidths() + +LineComponent = React.createClass + render: -> + div className: 'line', 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 = "#{multiplyString(' ', tabLength)}" + multiplyString(indentSpan, indentLevel + 1) + else + " " + + buildScopeTreeHTML: (scopeTree) -> + if scopeTree.children? + html = "" + html += @buildScopeTreeHTML(child) for child in scopeTree.children + html += "" + html + else + "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" + + shouldComponentUpdate: (newProps, newState) -> + newProps.showIndentGuide isnt @props.showIndentGuide From e952ab2e0263c8d3410a805064fc770dfd2012cf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 13:48:54 -0600 Subject: [PATCH 113/179] Extract a LinesComponent --- spec/editor-component-spec.coffee | 24 ++-- src/editor-component.coffee | 13 +- src/editor-scroll-view-component.coffee | 155 +++--------------------- src/lines-component.coffee | 112 +++++++++++++++++ 4 files changed, 142 insertions(+), 162 deletions(-) create mode 100644 src/lines-component.coffee diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index efbe92a21..ab37e2d7c 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -27,7 +27,9 @@ describe "EditorComponent", -> {component} = wrapperView component.setLineHeight(1.3) component.setFontSize(20) - {lineHeightInPixels, charWidth} = component.measureLineDimensions() + + lineHeightInPixels = editor.getLineHeight() + charWidth = editor.getDefaultCharWidth() node = component.getDOMNode() verticalScrollbarNode = node.querySelector('.vertical-scrollbar') horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') @@ -35,7 +37,7 @@ describe "EditorComponent", -> describe "line rendering", -> it "renders only the currently-visible lines", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + component.updateModelDimensions() lines = node.querySelectorAll('.line') expect(lines.length).toBe 6 @@ -111,7 +113,7 @@ describe "EditorComponent", -> it "renders the currently-visible line numbers", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + component.updateModelDimensions() lines = node.querySelectorAll('.line-number') expect(lines.length).toBe 6 @@ -136,7 +138,7 @@ describe "EditorComponent", -> editor.setSoftWrap(true) node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 30 * charWidth + 'px' - component.updateAllDimensions() + component.updateModelDimensions() lines = node.querySelectorAll('.line-number') expect(lines.length).toBe 6 @@ -153,7 +155,7 @@ describe "EditorComponent", -> cursor1.setScreenPosition([0, 5]) node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + component.updateModelDimensions() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 @@ -248,7 +250,7 @@ describe "EditorComponent", -> inputNode = node.querySelector('.hidden-input') node.style.height = 5 * lineHeightInPixels + 'px' node.style.width = 10 * charWidth + 'px' - component.updateAllDimensions() + component.updateModelDimensions() expect(editor.getCursorScreenPosition()).toEqual [0, 0] editor.setScrollTop(3 * lineHeightInPixels) @@ -332,7 +334,7 @@ describe "EditorComponent", -> it "moves the cursor to the nearest screen position", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 10 * charWidth + 'px' - component.updateAllDimensions() + component.updateModelDimensions() editor.setScrollTop(3.5 * lineHeightInPixels) editor.setScrollLeft(2 * charWidth) @@ -445,7 +447,7 @@ describe "EditorComponent", -> describe "scrolling", -> it "updates the vertical scrollbar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateAllDimensions() + component.updateModelDimensions() expect(verticalScrollbarNode.scrollTop).toBe 0 @@ -454,7 +456,7 @@ describe "EditorComponent", -> it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> node.style.width = 30 * charWidth + 'px' - component.updateAllDimensions() + component.updateModelDimensions() scrollViewContentNode = node.querySelector('.scroll-view-content') expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" @@ -466,7 +468,7 @@ describe "EditorComponent", -> it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> node.style.width = 30 * charWidth + 'px' - component.updateAllDimensions() + component.updateModelDimensions() expect(editor.getScrollLeft()).toBe 0 horizontalScrollbarNode.scrollLeft = 100 @@ -478,7 +480,7 @@ describe "EditorComponent", -> it "updates the horizontal or vertical scrollbar depending on which delta is greater (x or y)", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 20 * charWidth + 'px' - component.updateAllDimensions() + component.updateModelDimensions() expect(verticalScrollbarNode.scrollTop).toBe 0 expect(horizontalScrollbarNode.scrollLeft).toBe 0 diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 12f1f0f7f..2f4cd8cfb 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -4,7 +4,6 @@ React = require 'react' GutterComponent = require './gutter-component' EditorScrollViewComponent = require './editor-scroll-view-component' -{DummyLineNode} = EditorScrollViewComponent ScrollbarComponent = require './scrollbar-component' SubscriberMixin = require './subscriber-mixin' @@ -14,8 +13,6 @@ EditorCompont = React.createClass pendingScrollLeft: null selectOnMouseMove: false - statics: {DummyLineNode} - mixins: [SubscriberMixin] render: -> @@ -286,11 +283,5 @@ EditorCompont = React.createClass requestUpdate: -> @forceUpdate() - measureLineDimensions: -> - @refs.scrollView.measureLineDimensions() - - updateAllDimensions: -> - @refs.scrollView.updateAllDimensions() - - updateScrollViewDimensions: -> - @refs.scrollView.updateScrollViewDimensions() + updateModelDimensions: -> + @refs.scrollView.updateModelDimensions() diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 722452b2b..a5a46c17d 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -2,22 +2,23 @@ React = require 'react' ReactUpdates = require 'react/lib/ReactUpdates' {div, span} = require 'reactionary' {debounce, isEqual, multiplyString, pick} = require 'underscore-plus' -{$$} = require 'space-pen' InputComponent = require './input-component' +LinesComponent = require './lines-component' CursorComponent = require './cursor-component' SelectionComponent = require './selection-component' SubscriberMixin = require './subscriber-mixin' -DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] -AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} - module.exports = EditorScrollViewComponent = React.createClass mixins: [SubscriberMixin] render: -> - {onInputFocused, onInputBlurred} = @props + {editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props + {visibleRowRange, onInputFocused, onInputBlurred} = @props + contentStyle = + height: editor.getScrollHeight() + WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" div className: 'scroll-view', ref: 'scrollView', InputComponent @@ -27,18 +28,11 @@ EditorScrollViewComponent = React.createClass onInput: @onInput onFocus: onInputFocused onBlur: onInputBlurred - @renderScrollViewContent() - renderScrollViewContent: -> - {editor} = @props - style = - height: editor.getScrollHeight() - WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" - - div {className: 'scroll-view-content', style, @onMouseDown}, - @renderCursors() - @renderVisibleLines() - @renderUnderlayer() + div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, + @renderCursors() + LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, showIndentGuide}) + @renderUnderlayer() renderCursors: -> {editor} = @props @@ -47,21 +41,6 @@ EditorScrollViewComponent = React.createClass for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) - renderVisibleLines: -> - {editor, visibleRowRange} = @props - {showIndentGuide} = @props - [startRow, endRow] = visibleRowRange - lineHeightInPixels = editor.getLineHeight() - precedingHeight = startRow * lineHeightInPixels - followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels - - div className: 'lines', ref: 'lines', [ - div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} - (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}))... - div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} - ] - renderUnderlayer: -> {editor} = @props @@ -73,25 +52,11 @@ EditorScrollViewComponent = React.createClass blinkCursorsOff: false componentDidMount: -> - @measuredLines = new WeakSet - - @getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged - + @getDOMNode().addEventListener 'overflowchanged', @updateModelDimensions @subscribe @props.editor, 'cursors-moved', @pauseCursorBlinking - - - @updateAllDimensions() + @updateModelDimensions() @startBlinkingCursors() - componentDidUpdate: (prevProps) -> - unless isEqual(pick(prevProps, 'fontSize', 'fontFamily', 'lineHeight'), pick(@props, 'fontSize', 'fontFamily', 'lineHeight')) - @updateLineDimensions() - - unless isEqual(pick(prevProps, 'fontSize', 'fontFamily'), pick(@props, 'fontSize', 'fontFamily')) - @clearScopedCharWidths() - - @measureNewLines() - focus: -> @refs.input.focus() @@ -196,98 +161,8 @@ EditorScrollViewComponent = React.createClass left = clientX - editorClientRect.left + editor.getScrollLeft() {top, left} - onOverflowChanged: -> + updateModelDimensions: -> {editor} = @props - {height, width} = @measureScrollViewDimensions() - editor.setHeight(height) - editor.setWidth(width) - - updateAllDimensions: -> - @updateScrollViewDimensions() - @updateLineDimensions() - - updateScrollViewDimensions: -> - {editor} = @props - {height, width} = @measureScrollViewDimensions() - editor.setHeight(height) - editor.setWidth(width) - - updateLineDimensions: -> - {editor} = @props - {lineHeightInPixels, charWidth} = @measureLineDimensions() - editor.setLineHeight(lineHeightInPixels) - editor.setDefaultCharWidth(charWidth) - - measureScrollViewDimensions: -> node = @getDOMNode() - {height: node.clientHeight, width: node.clientWidth} - - measureLineDimensions: -> - linesNode = @refs.lines.getDOMNode() - linesNode.appendChild(DummyLineNode) - lineHeightInPixels = DummyLineNode.getBoundingClientRect().height - charWidth = DummyLineNode.firstChild.getBoundingClientRect().width - linesNode.removeChild(DummyLineNode) - {lineHeightInPixels, charWidth} - - measureNewLines: -> - [visibleStartRow, visibleEndRow] = @props.visibleRowRange - linesNode = @refs.lines.getDOMNode() - - for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) - unless @measuredLines.has(tokenizedLine) - lineNode = linesNode.children[i + 1] - @measureCharactersInLine(tokenizedLine, lineNode) - - measureCharactersInLine: (tokenizedLine, lineNode) -> - {editor} = @props - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) - rangeForMeasurement = document.createRange() - - for {value, scopes} in tokenizedLine.tokens - textNode = iterator.nextNode() - charWidths = editor.getScopedCharWidths(scopes) - for char, i in value - unless charWidths[char]? - rangeForMeasurement.setStart(textNode, i) - rangeForMeasurement.setEnd(textNode, i + 1) - charWidth = rangeForMeasurement.getBoundingClientRect().width - editor.setScopedCharWidth(scopes, char, charWidth) - - @measuredLines.add(tokenizedLine) - - clearScopedCharWidths: -> - @measuredLines.clear() - @props.editor.clearScopedCharWidths() - -LineComponent = React.createClass - render: -> - div className: 'line', 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 = "#{multiplyString(' ', tabLength)}" - multiplyString(indentSpan, indentLevel + 1) - else - " " - - buildScopeTreeHTML: (scopeTree) -> - if scopeTree.children? - html = "" - html += @buildScopeTreeHTML(child) for child in scopeTree.children - html += "" - html - else - "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" - - shouldComponentUpdate: (newProps, newState) -> - newProps.showIndentGuide isnt @props.showIndentGuide + editor.setHeight(node.clientHeight) + editor.setWidth(node.clientWidth) diff --git a/src/lines-component.coffee b/src/lines-component.coffee new file mode 100644 index 000000000..b700e91dd --- /dev/null +++ b/src/lines-component.coffee @@ -0,0 +1,112 @@ +React = require 'react' +{div, span} = require 'reactionary' +{debounce, isEqual, multiplyString, pick} = require 'underscore-plus' +{$$} = require 'space-pen' + +DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] +AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} + +module.exports = +LinesComponent = React.createClass + render: -> + {editor, visibleRowRange, showIndentGuide} = @props + [startRow, endRow] = visibleRowRange + lineHeightInPixels = editor.getLineHeight() + precedingHeight = startRow * lineHeightInPixels + followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + + div className: 'lines', ref: 'lines', [ + div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} + (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}))... + div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} + ] + + componentDidMount: -> + @measuredLines = new WeakSet + @updateModelDimensions() + + componentDidUpdate: (prevProps) -> + @updateModelDimensions() unless @compareProps(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') + @clearScopedCharWidths() unless @compareProps(prevProps, @props, 'fontSize', 'fontFamily') + @measureCharactersInNewLines() + + compareProps: (a, b, whiteList...) -> + isEqual(pick(a, whiteList...), pick(b, whiteList...)) + + updateModelDimensions: -> + {editor} = @props + {lineHeightInPixels, charWidth} = @measureLineDimensions() + editor.setLineHeight(lineHeightInPixels) + editor.setDefaultCharWidth(charWidth) + + measureLineDimensions: -> + linesNode = @refs.lines.getDOMNode() + linesNode.appendChild(DummyLineNode) + lineHeightInPixels = DummyLineNode.getBoundingClientRect().height + charWidth = DummyLineNode.firstChild.getBoundingClientRect().width + linesNode.removeChild(DummyLineNode) + {lineHeightInPixels, charWidth} + + measureCharactersInNewLines: -> + [visibleStartRow, visibleEndRow] = @props.visibleRowRange + linesNode = @refs.lines.getDOMNode() + + for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) + unless @measuredLines.has(tokenizedLine) + lineNode = linesNode.children[i + 1] + @measureCharactersInLine(tokenizedLine, lineNode) + + measureCharactersInLine: (tokenizedLine, lineNode) -> + {editor} = @props + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) + rangeForMeasurement = document.createRange() + + for {value, scopes} in tokenizedLine.tokens + textNode = iterator.nextNode() + charWidths = editor.getScopedCharWidths(scopes) + for char, i in value + unless charWidths[char]? + rangeForMeasurement.setStart(textNode, i) + rangeForMeasurement.setEnd(textNode, i + 1) + charWidth = rangeForMeasurement.getBoundingClientRect().width + editor.setScopedCharWidth(scopes, char, charWidth) + + @measuredLines.add(tokenizedLine) + + clearScopedCharWidths: -> + @measuredLines.clear() + @props.editor.clearScopedCharWidths() + + +LineComponent = React.createClass + render: -> + div className: 'line', 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 = "#{multiplyString(' ', tabLength)}" + multiplyString(indentSpan, indentLevel + 1) + else + " " + + buildScopeTreeHTML: (scopeTree) -> + if scopeTree.children? + html = "" + html += @buildScopeTreeHTML(child) for child in scopeTree.children + html += "" + html + else + "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" + + shouldComponentUpdate: (newProps, newState) -> + newProps.showIndentGuide isnt @props.showIndentGuide From 0ec6cbe141cbb4db164d8a26f2da23307749bd90 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 13:51:39 -0600 Subject: [PATCH 114/179] :lipstick: method order --- src/editor-component.coffee | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 2f4cd8cfb..f06874fab 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -47,13 +47,6 @@ EditorCompont = React.createClass scrollLeft: editor.getScrollLeft() scrollWidth: editor.getScrollWidth() - getVisibleRowRange: -> - visibleRowRange = @props.editor.getVisibleRowRange() - if @visibleRowOverrides? - visibleRowRange[0] = Math.min(visibleRowRange[0], @visibleRowOverrides[0]) - visibleRowRange[1] = Math.max(visibleRowRange[1], @visibleRowOverrides[1]) - visibleRowRange - getInitialState: -> {} getDefaultProps: -> @@ -262,12 +255,6 @@ EditorCompont = React.createClass event.preventDefault() - clearVisibleRowOverrides: -> - @visibleRowOverrides = null - @forceUpdate() - - clearVisibleRowOverridesAfterDelay: null - onScreenLinesChanged: ({start, end}) -> {editor} = @props @requestUpdate() if editor.intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events @@ -280,6 +267,19 @@ EditorCompont = React.createClass {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + getVisibleRowRange: -> + visibleRowRange = @props.editor.getVisibleRowRange() + if @visibleRowOverrides? + visibleRowRange[0] = Math.min(visibleRowRange[0], @visibleRowOverrides[0]) + visibleRowRange[1] = Math.max(visibleRowRange[1], @visibleRowOverrides[1]) + visibleRowRange + + clearVisibleRowOverrides: -> + @visibleRowOverrides = null + @forceUpdate() + + clearVisibleRowOverridesAfterDelay: null + requestUpdate: -> @forceUpdate() From 14bfa90004a8874180f4e9ad99774758fae5f35f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 14:02:31 -0600 Subject: [PATCH 115/179] Extract a CursorsComponent containing all cursors --- src/cursors-component.coffee | 44 +++++++++++++++++++++++++ src/editor-scroll-view-component.coffee | 38 +++------------------ 2 files changed, 48 insertions(+), 34 deletions(-) create mode 100644 src/cursors-component.coffee diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee new file mode 100644 index 000000000..76c7f6ee5 --- /dev/null +++ b/src/cursors-component.coffee @@ -0,0 +1,44 @@ +React = require 'react' +{div} = require 'reactionary' +{debounce} = require 'underscore-plus' +SubscriberMixin = require './subscriber-mixin' +CursorComponent = require './cursor-component' + + +module.exports = +CursorsComponent = React.createClass + mixins: [SubscriberMixin] + + cursorBlinkIntervalHandle: null + + render: -> + {editor} = @props + {blinkCursorsOff} = @state + + div className: 'cursors', + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) + + getInitialState: -> + blinkCursorsOff: false + + componentDidMount: -> + {editor} = @props + @subscribe editor, 'cursors-moved', @pauseCursorBlinking + @startBlinkingCursors() + + startBlinkingCursors: -> + @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) + + startBlinkingCursorsAfterDelay: null # Created lazily + + stopBlinkingCursors: -> + clearInterval(@cursorBlinkIntervalHandle) + @setState(blinkCursorsOff: false) + + toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) + + pauseCursorBlinking: -> + @stopBlinkingCursors() + @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) + @startBlinkingCursorsAfterDelay() diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index a5a46c17d..bc0d48157 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -1,20 +1,16 @@ React = require 'react' ReactUpdates = require 'react/lib/ReactUpdates' -{div, span} = require 'reactionary' -{debounce, isEqual, multiplyString, pick} = require 'underscore-plus' +{div} = require 'reactionary' InputComponent = require './input-component' LinesComponent = require './lines-component' -CursorComponent = require './cursor-component' +CursorsComponent = require './cursors-component' SelectionComponent = require './selection-component' -SubscriberMixin = require './subscriber-mixin' module.exports = EditorScrollViewComponent = React.createClass - mixins: [SubscriberMixin] - render: -> - {editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props + {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props {visibleRowRange, onInputFocused, onInputBlurred} = @props contentStyle = height: editor.getScrollHeight() @@ -30,17 +26,10 @@ EditorScrollViewComponent = React.createClass onBlur: onInputBlurred div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, - @renderCursors() + CursorsComponent({editor, cursorBlinkPeriod, cursorBlinkResumeDelay}) LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, showIndentGuide}) @renderUnderlayer() - renderCursors: -> - {editor} = @props - {blinkCursorsOff} = @state - - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) - renderUnderlayer: -> {editor} = @props @@ -48,32 +37,13 @@ EditorScrollViewComponent = React.createClass for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) SelectionComponent({selection}) - getInitialState: -> - blinkCursorsOff: false - componentDidMount: -> @getDOMNode().addEventListener 'overflowchanged', @updateModelDimensions - @subscribe @props.editor, 'cursors-moved', @pauseCursorBlinking @updateModelDimensions() - @startBlinkingCursors() focus: -> @refs.input.focus() - startBlinkingCursors: -> - @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) - - stopBlinkingCursors: -> - clearInterval(@cursorBlinkIntervalHandle) - @setState(blinkCursorsOff: false) - - toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) - - pauseCursorBlinking: -> - @stopBlinkingCursors() - @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) - @startBlinkingCursorsAfterDelay() - getHiddenInputPosition: -> {editor} = @props From eeba559ec71780d74f95f5d4c770905637b0018c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 14:08:43 -0600 Subject: [PATCH 116/179] Add a SelectionsComponent containing all selections --- src/editor-scroll-view-component.coffee | 12 +++--------- src/selections-component.coffee | 12 ++++++++++++ 2 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 src/selections-component.coffee diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index bc0d48157..2d9c2b78f 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -5,7 +5,7 @@ ReactUpdates = require 'react/lib/ReactUpdates' InputComponent = require './input-component' LinesComponent = require './lines-component' CursorsComponent = require './cursors-component' -SelectionComponent = require './selection-component' +SelectionsComponent = require './selections-component' module.exports = EditorScrollViewComponent = React.createClass @@ -28,14 +28,8 @@ EditorScrollViewComponent = React.createClass div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, CursorsComponent({editor, cursorBlinkPeriod, cursorBlinkResumeDelay}) LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, showIndentGuide}) - @renderUnderlayer() - - renderUnderlayer: -> - {editor} = @props - - div className: 'underlayer', - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({selection}) + div className: 'underlayer', + SelectionsComponent({editor}) componentDidMount: -> @getDOMNode().addEventListener 'overflowchanged', @updateModelDimensions diff --git a/src/selections-component.coffee b/src/selections-component.coffee new file mode 100644 index 000000000..645b89141 --- /dev/null +++ b/src/selections-component.coffee @@ -0,0 +1,12 @@ +React = require 'react' +{div} = require 'reactionary' +SelectionComponent = require './selection-component' + +module.exports = +SelectionsComponent = React.createClass + render: -> + {editor} = @props + + div className: 'selections', + for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({selection}) From 507106d35b5acee3664a9ad5a766ab2f466a1594 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 14:10:46 -0600 Subject: [PATCH 117/179] :lipstick: method order --- src/editor-scroll-view-component.coffee | 36 ++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 2d9c2b78f..3695ab403 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -35,24 +35,6 @@ EditorScrollViewComponent = React.createClass @getDOMNode().addEventListener 'overflowchanged', @updateModelDimensions @updateModelDimensions() - focus: -> - @refs.input.focus() - - getHiddenInputPosition: -> - {editor} = @props - - if cursor = editor.getCursor() - cursorRect = cursor.getPixelRect() - top = cursorRect.top - editor.getScrollTop() - top = Math.max(0, Math.min(editor.getHeight(), top)) - left = cursorRect.left - editor.getScrollLeft() - left = Math.max(0, Math.min(editor.getWidth(), left)) - else - top = 0 - left = 0 - - {top, left} - onInput: (char, replaceLastCharacter) -> {editor} = @props @@ -125,8 +107,26 @@ EditorScrollViewComponent = React.createClass left = clientX - editorClientRect.left + editor.getScrollLeft() {top, left} + getHiddenInputPosition: -> + {editor} = @props + + if cursor = editor.getCursor() + cursorRect = cursor.getPixelRect() + top = cursorRect.top - editor.getScrollTop() + top = Math.max(0, Math.min(editor.getHeight(), top)) + left = cursorRect.left - editor.getScrollLeft() + left = Math.max(0, Math.min(editor.getWidth(), left)) + else + top = 0 + left = 0 + + {top, left} + updateModelDimensions: -> {editor} = @props node = @getDOMNode() editor.setHeight(node.clientHeight) editor.setWidth(node.clientWidth) + + focus: -> + @refs.input.focus() From 0d03e388f1fc92d0fecebb2c86368bbdfc63b691 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 14:10:56 -0600 Subject: [PATCH 118/179] :lipstick: spec description --- spec/editor-component-spec.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index ab37e2d7c..0a9ecc7c2 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -508,7 +508,7 @@ describe "EditorComponent", -> inputNode.dispatchEvent(new Event('input')) expect(editor.lineForBufferRow(0)).toBe 'xyvar quicksort = function () {' - it "replaces the last character if the length of the input's value doesn't increase", -> + it "replaces the last character if the length of the input's value doesn't increase, as occurs with the accented character menu", -> inputNode.value = 'u' inputNode.dispatchEvent(new Event('input')) expect(editor.lineForBufferRow(0)).toBe 'uvar quicksort = function () {' From 274288161d1aa96499dccace338ad425cb59f7c2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 18:07:43 -0600 Subject: [PATCH 119/179] Make line number components immutable --- src/gutter-component.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index f01126c86..e983017dd 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -50,3 +50,5 @@ LineNumberComponent = React.createClass lineNumber + @iconDivHTML iconDivHTML: '
' + + shouldComponentUpdate: -> false From 3657dc0bf4e4e53910e3774821e2d5942bf49275 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 14 Apr 2014 18:07:55 -0600 Subject: [PATCH 120/179] :lipstick: --- src/gutter-component.coffee | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index e983017dd..7d38933a6 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -16,20 +16,24 @@ GutterComponent = React.createClass WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" wrapCount = 0 + lineNumbers = [] + for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) + if bufferRow is lastBufferRow + lineNumber = '•' + key = "#{bufferRow}-#{++wrapCount}" + else + lastBufferRow = bufferRow + wrapCount = 0 + lineNumber = (bufferRow + 1).toString() + key = bufferRow.toString() + + lineNumbers.push(LineNumberComponent({lineNumber, maxDigits, bufferRow, key})) + lastBufferRow = bufferRow + div className: 'gutter', div className: 'line-numbers', style: style, [ div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} - (for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) - if bufferRow is lastBufferRow - lineNumber = '•' - key = "#{bufferRow}-#{++wrapCount}" - else - lastBufferRow = bufferRow - wrapCount = 0 - lineNumber = (bufferRow + 1).toString() - key = bufferRow.toString() - - LineNumberComponent({lineNumber, maxDigits, bufferRow, key}))... + lineNumbers... div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] From 66f3f2d883e6ad1c18530a79a4192985c35d1afa Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 10:39:47 -0600 Subject: [PATCH 121/179] Add displayName to components --- src/cursor-component.coffee | 1 + src/cursors-component.coffee | 1 + src/editor-component.coffee | 4 +++- src/editor-scroll-view-component.coffee | 2 ++ src/gutter-component.coffee | 2 ++ src/input-component.coffee | 2 ++ src/selection-component.coffee | 1 + src/selections-component.coffee | 2 ++ 8 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index 515341e07..cbf24bbdc 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -4,6 +4,7 @@ SubscriberMixin = require './subscriber-mixin' module.exports = CursorComponent = React.createClass + displayName: 'CursorComponent' mixins: [SubscriberMixin] render: -> diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index 76c7f6ee5..e2251351f 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -7,6 +7,7 @@ CursorComponent = require './cursor-component' module.exports = CursorsComponent = React.createClass + displayName: 'CursorsComponent' mixins: [SubscriberMixin] cursorBlinkIntervalHandle: null diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f06874fab..992e6c773 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -9,11 +9,13 @@ SubscriberMixin = require './subscriber-mixin' module.exports = EditorCompont = React.createClass + displayName: 'EditorComponent' + mixins: [SubscriberMixin] + pendingScrollTop: null pendingScrollLeft: null selectOnMouseMove: false - mixins: [SubscriberMixin] render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 3695ab403..50599c4fb 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -9,6 +9,8 @@ SelectionsComponent = require './selections-component' module.exports = EditorScrollViewComponent = React.createClass + displayName: 'EditorScrollViewComponent' + render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props {visibleRowRange, onInputFocused, onInputBlurred} = @props diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 7d38933a6..a6d753dae 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -4,6 +4,8 @@ React = require 'react' module.exports = GutterComponent = React.createClass + displayName: 'GutterComponent' + render: -> {editor, visibleRowRange} = @props [startRow, endRow] = visibleRowRange diff --git a/src/input-component.coffee b/src/input-component.coffee index c1efaaaa8..bb2d1bf4f 100644 --- a/src/input-component.coffee +++ b/src/input-component.coffee @@ -5,6 +5,8 @@ React = require 'react' module.exports = InputComponent = React.createClass + displayName: 'InputComponent' + render: -> {className, style, onFocus, onBlur} = @props diff --git a/src/selection-component.coffee b/src/selection-component.coffee index 1e826a6e3..42a592f58 100644 --- a/src/selection-component.coffee +++ b/src/selection-component.coffee @@ -4,6 +4,7 @@ SubscriberMixin = require './subscriber-mixin' module.exports = SelectionComponent = React.createClass + displayName: 'SelectionComponent' mixins: [SubscriberMixin] render: -> diff --git a/src/selections-component.coffee b/src/selections-component.coffee index 645b89141..1a2ae93fe 100644 --- a/src/selections-component.coffee +++ b/src/selections-component.coffee @@ -4,6 +4,8 @@ SelectionComponent = require './selection-component' module.exports = SelectionsComponent = React.createClass + displayName: 'SelectionsComponent' + render: -> {editor} = @props From 93388c20481895978df8bb8e140d98a182a87a1f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 10:40:07 -0600 Subject: [PATCH 122/179] Make Cursor and Selection models for automatic id assignment --- src/cursor.coffee | 9 ++++----- src/selection.coffee | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/cursor.coffee b/src/cursor.coffee index fd960b0cf..6dd8afdcc 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -1,5 +1,5 @@ {Point, Range} = require 'text-buffer' -{Emitter} = require 'emissary' +{Model} = require 'theorist' _ = require 'underscore-plus' # Public: The `Cursor` class represents the little blinking line identifying @@ -8,9 +8,7 @@ _ = require 'underscore-plus' # Cursors belong to {Editor}s and have some metadata attached in the form # of a {Marker}. module.exports = -class Cursor - Emitter.includeInto(this) - +class Cursor extends Model screenPosition: null bufferPosition: null goalColumn: null @@ -18,7 +16,8 @@ class Cursor needsAutoscroll: null # Instantiated by an {Editor} - constructor: ({@editor, @marker}) -> + constructor: ({@editor, @marker, id}) -> + @assignId(id) @updateVisibility() @marker.on 'changed', (e) => @updateVisibility() diff --git a/src/selection.coffee b/src/selection.coffee index 8d31235e5..7fccda264 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -1,12 +1,10 @@ {Point, Range} = require 'text-buffer' -{Emitter} = require 'emissary' +{Model} = require 'theorist' {pick} = require 'underscore-plus' # Public: Represents a selection in the {Editor}. module.exports = -class Selection - Emitter.includeInto(this) - +class Selection extends Model cursor: null marker: null editor: null @@ -14,7 +12,8 @@ class Selection wordwise: false needsAutoscroll: null - constructor: ({@cursor, @marker, @editor}) -> + constructor: ({@cursor, @marker, @editor, id}) -> + @assignId(id) @cursor.selection = this @marker.on 'changed', => @screenRangeChanged() @marker.on 'destroyed', => From f457b41a81113caaf68794256bf3005f980bb7e0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 10:40:29 -0600 Subject: [PATCH 123/179] Assign a key to cursor and selection components --- src/cursors-component.coffee | 5 +++-- src/selections-component.coffee | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index e2251351f..55f922cc8 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -14,11 +14,12 @@ CursorsComponent = React.createClass render: -> {editor} = @props - {blinkCursorsOff} = @state + blinkOff = @state.blinkCursorsOff div className: 'cursors', for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - CursorComponent(cursor: selection.cursor, blinkOff: blinkCursorsOff) + {cursor} = selection + CursorComponent({key: cursor.id, cursor, blinkOff}) getInitialState: -> blinkCursorsOff: false diff --git a/src/selections-component.coffee b/src/selections-component.coffee index 1a2ae93fe..1cb58b546 100644 --- a/src/selections-component.coffee +++ b/src/selections-component.coffee @@ -11,4 +11,4 @@ SelectionsComponent = React.createClass div className: 'selections', for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({selection}) + SelectionComponent({key: selection.id, selection}) From 48d90e3dfc1145efb6715d8a6df320417005a084 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 10:52:05 -0600 Subject: [PATCH 124/179] Funnel cursor and selection updates through EditorComponent --- src/cursor-component.coffee | 8 -------- src/editor-component.coffee | 1 + src/editor.coffee | 3 +++ src/selection-component.coffee | 8 -------- src/selection.coffee | 1 + 5 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index cbf24bbdc..fcc6c2022 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -1,11 +1,9 @@ React = require 'react' {div} = require 'reactionary' -SubscriberMixin = require './subscriber-mixin' module.exports = CursorComponent = React.createClass displayName: 'CursorComponent' - mixins: [SubscriberMixin] render: -> {top, left, height, width} = @props.cursor.getPixelRect() @@ -13,9 +11,3 @@ CursorComponent = React.createClass className += ' blink-off' if @props.blinkOff div className: className, style: {top, left, height, width} - - componentDidMount: -> - @subscribe @props.cursor, 'moved', => @forceUpdate() - - componentWillUnmount: -> - @unsubscribe() diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 992e6c773..eda7315d2 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -75,6 +75,7 @@ EditorCompont = React.createClass observeEditor: -> {editor} = @props @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged + @subscribe editor, 'selection-screen-range-changed', @requestUpdate @subscribe editor, 'selection-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded @subscribe editor.$scrollTop.changes, @requestUpdate diff --git a/src/editor.coffee b/src/editor.coffee index 3fc49d0a3..09dedff86 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1246,6 +1246,9 @@ class Editor extends Model else false + selectionScreenRangeChanged: (selection) -> + @emit 'selection-screen-range-changed', selection + # Public: Get current {Selection}s. # # Returns: An {Array} of {Selection}s. diff --git a/src/selection-component.coffee b/src/selection-component.coffee index 42a592f58..e3dd22e3a 100644 --- a/src/selection-component.coffee +++ b/src/selection-component.coffee @@ -1,19 +1,11 @@ React = require 'react' {div} = require 'reactionary' -SubscriberMixin = require './subscriber-mixin' module.exports = SelectionComponent = React.createClass displayName: 'SelectionComponent' - mixins: [SubscriberMixin] render: -> div className: 'selection', for regionRect, i in @props.selection.getRegionRects() div className: 'region', key: i, style: regionRect - - componentDidMount: -> - @subscribe @props.selection, 'screen-range-changed', => @forceUpdate() - - componentWillUnmount: -> - @unsubscribe() diff --git a/src/selection.coffee b/src/selection.coffee index 7fccda264..30694deed 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -613,3 +613,4 @@ class Selection extends Model screenRangeChanged: -> screenRange = @getScreenRange() @emit 'screen-range-changed', screenRange + @editor.selectionScreenRangeChanged(this) From fe6a007774993721281b4bf4391df7941138d4f8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 10:52:22 -0600 Subject: [PATCH 125/179] Call requestUpdate instead of forceUpdate so we can track all updates --- src/editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index eda7315d2..f21d70a38 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -279,7 +279,7 @@ EditorCompont = React.createClass clearVisibleRowOverrides: -> @visibleRowOverrides = null - @forceUpdate() + @requestUpdate() clearVisibleRowOverridesAfterDelay: null From ddc677fb304c0622fd920069dd59ea1b5b6a1191 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 13:13:53 -0600 Subject: [PATCH 126/179] Batch multiple view updates with Editor::batchUpdates --- src/editor-component.coffee | 20 ++++++++++++++++++-- src/editor.coffee | 9 ++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f21d70a38..e8f14b9f2 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -15,7 +15,8 @@ EditorCompont = React.createClass pendingScrollTop: null pendingScrollLeft: null selectOnMouseMove: false - + batchingUpdates: false + updateRequested: false render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state @@ -74,6 +75,8 @@ EditorCompont = React.createClass observeEditor: -> {editor} = @props + @subscribe editor, 'batched-updates-started', @onBatchedUpdatesStarted + @subscribe editor, 'batched-updates-ended', @onBatchedUpdatesEnded @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged @subscribe editor, 'selection-screen-range-changed', @requestUpdate @subscribe editor, 'selection-added', @onSelectionAdded @@ -258,6 +261,16 @@ EditorCompont = React.createClass event.preventDefault() + onBatchedUpdatesStarted: -> + @batchingUpdates = true + + onBatchedUpdatesEnded: -> + updateRequested = @updateRequested + @updateRequested = false + @batchingUpdates = false + if updateRequested + @forceUpdate() + onScreenLinesChanged: ({start, end}) -> {editor} = @props @requestUpdate() if editor.intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events @@ -284,7 +297,10 @@ EditorCompont = React.createClass clearVisibleRowOverridesAfterDelay: null requestUpdate: -> - @forceUpdate() + if @batchingUpdates + @updateRequested = true + else + @forceUpdate() updateModelDimensions: -> @refs.scrollView.updateModelDimensions() diff --git a/src/editor.coffee b/src/editor.coffee index 09dedff86..8e1fb37f4 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1781,7 +1781,9 @@ class Editor extends Model # execution and revert any changes performed up to the abortion. # # fn - A {Function} to call inside the transaction. - transact: (fn) -> @buffer.transact(fn) + transact: (fn) -> + @batchUpdates => + @buffer.transact(fn) # Public: Start an open-ended transaction. # @@ -1801,6 +1803,11 @@ class Editor extends Model # within the transaction. abortTransaction: -> @buffer.abortTransaction() + batchUpdates: (fn) -> + @emit 'batched-updates-started' + fn() + @emit 'batched-updates-ended' + inspect: -> "" From 64a487eebbc593258541914aa1c463b197783298 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 13:35:02 -0600 Subject: [PATCH 127/179] Implement shouldComponentUpdate for ScrollbarComponent It checks that the incoming scrollTop/Left and scrollHeight/Width differ from their current values. The scrollTop/Left value are updated in the component properties to always reflect the state of the DOM when scrolling or when assigning a new value. --- package.json | 2 +- src/scrollbar-component.coffee | 29 ++++++++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 28f644499..81d2037a6 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "temp": "0.5.0", "text-buffer": "^2.1.0", "theorist": "1.x", - "underscore-plus": "^1.1.2", + "underscore-plus": "^1.2.0", "vm-compatibility-layer": "0.1.0" }, "packageDependencies": { diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee index 8560a6cfe..760127fb0 100644 --- a/src/scrollbar-component.coffee +++ b/src/scrollbar-component.coffee @@ -1,11 +1,9 @@ React = require 'react' {div} = require 'reactionary' +{isEqualForProperties} = require 'underscore-plus' module.exports = ScrollbarComponent = React.createClass - lastScrollTop: null - lastScrollLeft: null - render: -> {orientation, className, scrollHeight, scrollWidth} = @props @@ -22,19 +20,24 @@ ScrollbarComponent = React.createClass unless orientation is 'vertical' or orientation is 'horizontal' throw new Error("Must specify an orientation property of 'vertical' or 'horizontal'") + shouldComponentUpdate: (newProps) -> + switch @props.orientation + when 'vertical' + not isEqualForProperties(newProps, @props, 'scrollHeight', 'scrollTop') + when 'horizontal' + not isEqualForProperties(newProps, @props, 'scrollWidth', 'scrollLeft') + componentDidUpdate: -> {orientation, scrollTop, scrollLeft} = @props node = @getDOMNode() switch orientation when 'vertical' - unless scrollTop is @lastScrollTop - node.scrollTop = scrollTop - @lastScrollTop = node.scrollTop + node.scrollTop = scrollTop + @props.scrollTop = node.scrollTop # Ensure scrollTop reflects actual DOM without triggering another update when 'horizontal' - unless scrollLeft is @lastScrollLeft - node.scrollLeft = scrollLeft - @lastScrollLeft = node.scrollLeft + node.scrollLeft = scrollLeft + @props.scrollLeft = node.scrollLeft # Ensure scrollLeft reflects actual DOM without triggering another update onScroll: -> {orientation, onScroll} = @props @@ -42,6 +45,10 @@ ScrollbarComponent = React.createClass switch orientation when 'vertical' - onScroll(node.scrollTop) + scrollTop = node.scrollTop + @props.scrollTop = scrollTop # Ensure scrollTop reflects actual DOM without triggering another update + onScroll(scrollTop) when 'horizontal' - onScroll(node.scrollLeft) + scrollLeft = node.scrollLeft + @props.scrollLeft = scrollLeft # Ensure scrollLeft reflects actual DOM without triggering another update + onScroll(scrollLeft) From 033db8997ba235affe991f3659528f88759729b7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 13:42:54 -0600 Subject: [PATCH 128/179] Batch updates when moving cursors This ensures that updates associated with autoscroll and cursor movement get combined. --- src/editor.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor.coffee b/src/editor.coffee index 8e1fb37f4..17291844c 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1472,7 +1472,8 @@ class Editor extends Model moveCursors: (fn) -> @movingCursors = true - fn(cursor) for cursor in @getCursors() + @batchUpdates => + fn(cursor) for cursor in @getCursors() @mergeCursors() @movingCursors = false @emit 'cursors-moved' From efa72bcb1ca95e54e1ae69d84a23c6f962db144f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 13:56:58 -0600 Subject: [PATCH 129/179] Implement shouldComponentUpdate for GutterComponent Only update the gutter when the visible row range has changed or if a screen lines change has occurred within the visible row range. --- src/gutter-component.coffee | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index a6d753dae..424a3857f 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -1,10 +1,12 @@ React = require 'react' {div} = require 'reactionary' -{multiplyString} = require 'underscore-plus' +{isEqual, multiplyString} = require 'underscore-plus' +SubscriberMixin = require './subscriber-mixin' module.exports = GutterComponent = React.createClass displayName: 'GutterComponent' + mixins: [SubscriberMixin] render: -> {editor, visibleRowRange} = @props @@ -39,6 +41,32 @@ GutterComponent = React.createClass div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] + componentDidMount: -> + @pendingChanges = [] + @subscribe @props.editor, 'screen-lines-changed', @onScreenLinesChanged + + componentWillUnmount: -> + @unsubscribe() + + # 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 + # visible row range. + shouldComponentUpdate: (newProps) -> + {visibleRowRange} = @props + + return true unless isEqual(newProps.visibleRowRange, visibleRowRange) + + for change in @pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 + return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start + + false + + componentDidUpdate: -> + @pendingChanges.length = 0 + + onScreenLinesChanged: (change) -> + @pendingChanges.push(change) + LineNumberComponent = React.createClass render: -> {bufferRow} = @props From 56e5fb7a63c9b67d4dbdefc9fab08c81a52343e8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 15:07:11 -0600 Subject: [PATCH 130/179] Set process.env.NODE_ENV to 'production' to speed up React --- src/atom.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/atom.coffee b/src/atom.coffee index 6a096a22f..cae8eb240 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -157,6 +157,9 @@ class Atom extends Model # Still set NODE_PATH since tasks may need it. process.env.NODE_PATH = exportsPath + # Make react.js faster + process.env.NODE_ENV ?= 'production' + @config = new Config({configDirPath, resourcePath}) @keymaps = new KeymapManager({configDirPath, resourcePath}) @keymap = @keymaps # Deprecated From 4fa9c64c2b73c842af001eccb69ed9d1c9b9ed99 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 15:08:12 -0600 Subject: [PATCH 131/179] Drop batchedUpdates on input since we're batching in the model anyway --- src/editor-scroll-view-component.coffee | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 50599c4fb..dd1937e2f 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -1,5 +1,4 @@ React = require 'react' -ReactUpdates = require 'react/lib/ReactUpdates' {div} = require 'reactionary' InputComponent = require './input-component' @@ -40,8 +39,11 @@ EditorScrollViewComponent = React.createClass onInput: (char, replaceLastCharacter) -> {editor} = @props - ReactUpdates.batchedUpdates -> - editor.selectLeft() if replaceLastCharacter + if replaceLastCharacter + editor.transact -> + editor.selectLeft() + editor.insertText(char) + else editor.insertText(char) onMouseDown: (event) -> From 1a56b487a1f11a9a7ddb35657398af65217e2e5c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 15:08:28 -0600 Subject: [PATCH 132/179] Stop propagation of input events to prevent react from doing extra work React seems to be handling these events when they bubble to the root of the document. We want to avoid wasting time on this. --- src/input-component.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/input-component.coffee b/src/input-component.coffee index bb2d1bf4f..d441c2bce 100644 --- a/src/input-component.coffee +++ b/src/input-component.coffee @@ -33,6 +33,7 @@ InputComponent = React.createClass not isEqual(newProps.style, @props.style) onInput: (e) -> + e.stopPropagation() valueCharCodes = punycode.ucs2.decode(@getDOMNode().value) valueLength = valueCharCodes.length replaceLastChar = valueLength is @lastValueLength From 550a4ce906192b0003efb5803cb9744688950d05 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 15:58:44 -0600 Subject: [PATCH 133/179] Use isEqualForProperties in LinesComponent to decide when to re-measure --- src/lines-component.coffee | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lines-component.coffee b/src/lines-component.coffee index b700e91dd..f692e3de3 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -1,6 +1,6 @@ React = require 'react' {div, span} = require 'reactionary' -{debounce, isEqual, multiplyString, pick} = require 'underscore-plus' +{debounce, isEqualForProperties, multiplyString} = require 'underscore-plus' {$$} = require 'space-pen' DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] @@ -27,13 +27,10 @@ LinesComponent = React.createClass @updateModelDimensions() componentDidUpdate: (prevProps) -> - @updateModelDimensions() unless @compareProps(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') - @clearScopedCharWidths() unless @compareProps(prevProps, @props, 'fontSize', 'fontFamily') + @updateModelDimensions() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') + @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') @measureCharactersInNewLines() - compareProps: (a, b, whiteList...) -> - isEqual(pick(a, whiteList...), pick(b, whiteList...)) - updateModelDimensions: -> {editor} = @props {lineHeightInPixels, charWidth} = @measureLineDimensions() From a6f2e926fe1e8f9f428e2349d83954495cc8dfff Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 15:59:03 -0600 Subject: [PATCH 134/179] Upgrade to underscore-plus@1.2.1 for optimized isEqualForProperties --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 81d2037a6..1ab0850b4 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "temp": "0.5.0", "text-buffer": "^2.1.0", "theorist": "1.x", - "underscore-plus": "^1.2.0", + "underscore-plus": "^1.2.1", "vm-compatibility-layer": "0.1.0" }, "packageDependencies": { From b96abfffb71d3e079e045c93e846e680ec332103 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 17:17:30 -0600 Subject: [PATCH 135/179] Add more displayNames --- src/gutter-component.coffee | 2 ++ src/lines-component.coffee | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 424a3857f..25744ef55 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -68,6 +68,8 @@ GutterComponent = React.createClass @pendingChanges.push(change) LineNumberComponent = React.createClass + displayName: 'LineNumberComponent' + render: -> {bufferRow} = @props div diff --git a/src/lines-component.coffee b/src/lines-component.coffee index f692e3de3..9b9e8d5c5 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -8,6 +8,8 @@ AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} module.exports = LinesComponent = React.createClass + displayName: 'LinesComponent' + render: -> {editor, visibleRowRange, showIndentGuide} = @props [startRow, endRow] = visibleRowRange @@ -77,6 +79,8 @@ LinesComponent = React.createClass LineComponent = React.createClass + displayName: 'LineComponent' + render: -> div className: 'line', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} From addbe80e8a668ff8e06b858e71d6ab838f7240fc Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 17:18:11 -0600 Subject: [PATCH 136/179] Update the gutter if the scrollTop has changed --- src/editor-component.coffee | 3 ++- src/gutter-component.coffee | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index e8f14b9f2..bd93a8f19 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -22,12 +22,13 @@ EditorCompont = React.createClass {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props visibleRowRange = @getVisibleRowRange() + scrollTop = editor.getScrollTop() className = 'editor react' className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, - GutterComponent({editor, visibleRowRange}) + GutterComponent({editor, visibleRowRange, scrollTop}) EditorScrollViewComponent { ref: 'scrollView', editor, visibleRowRange, @onInputFocused, @onInputBlurred diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 25744ef55..4fcff9828 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -9,7 +9,7 @@ GutterComponent = React.createClass mixins: [SubscriberMixin] render: -> - {editor, visibleRowRange} = @props + {editor, visibleRowRange, scrollTop} = @props [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() precedingHeight = startRow * lineHeightInPixels @@ -17,7 +17,7 @@ GutterComponent = React.createClass maxDigits = editor.getLastBufferRow().toString().length style = height: editor.getScrollHeight() - WebkitTransform: "translateY(#{-editor.getScrollTop()}px)" + WebkitTransform: "translateY(#{-scrollTop}px)" wrapCount = 0 lineNumbers = [] @@ -52,8 +52,9 @@ GutterComponent = React.createClass # non-zero-delta change to the screen lines has occurred within the current # visible row range. shouldComponentUpdate: (newProps) -> - {visibleRowRange} = @props + {visibleRowRange, scrollTop} = @props + return true unless newProps.scrollTop is scrollTop return true unless isEqual(newProps.visibleRowRange, visibleRowRange) for change in @pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 From febfb120c8cb76fa9b90d4a42d9b70bc540831ae Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 15 Apr 2014 17:18:22 -0600 Subject: [PATCH 137/179] Fix typo --- src/editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index bd93a8f19..6a45168ae 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -8,7 +8,7 @@ ScrollbarComponent = require './scrollbar-component' SubscriberMixin = require './subscriber-mixin' module.exports = -EditorCompont = React.createClass +EditorComponent = React.createClass displayName: 'EditorComponent' mixins: [SubscriberMixin] From d678f367dbcb9c31e0c1905ced53a544cc8bc648 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 16 Apr 2014 11:36:16 -0600 Subject: [PATCH 138/179] Clear cursor blink interval when editor component unmounts --- src/cursors-component.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index 55f922cc8..e32a925cf 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -29,6 +29,9 @@ CursorsComponent = React.createClass @subscribe editor, 'cursors-moved', @pauseCursorBlinking @startBlinkingCursors() + componentWillUnmount: -> + @stopBlinkingCursors() + startBlinkingCursors: -> @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) From 5a9a3c62e140aedf3416cc7b99401bb06255c199 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 16 Apr 2014 12:19:18 -0600 Subject: [PATCH 139/179] Implement shouldComponentUpdate for LinesComponent We accumulate pending changes and pass them to the lines and the gutter to help them determine whether to update. The lines only update if the visible row range changed or if there was a change in the visible row range. --- src/editor-component.coffee | 11 +++++++---- src/editor-scroll-view-component.coffee | 4 ++-- src/gutter-component.coffee | 14 ++------------ src/lines-component.coffee | 11 ++++++++++- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 6a45168ae..172e66707 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -28,10 +28,10 @@ EditorComponent = React.createClass className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, - GutterComponent({editor, visibleRowRange, scrollTop}) + GutterComponent({editor, visibleRowRange, scrollTop, @pendingChanges}) EditorScrollViewComponent { - ref: 'scrollView', editor, visibleRowRange, @onInputFocused, @onInputBlurred + ref: 'scrollView', editor, visibleRowRange, @pendingChanges, @onInputFocused, @onInputBlurred cursorBlinkPeriod, cursorBlinkResumeDelay, showIndentGuide, fontSize, fontFamily, lineHeight } @@ -58,6 +58,7 @@ EditorComponent = React.createClass cursorBlinkResumeDelay: 200 componentDidMount: -> + @pendingChanges = [] @props.editor.manageScrollPosition = true @listenForDOMEvents() @@ -72,6 +73,7 @@ EditorComponent = React.createClass @stopBlinkingCursors() componentDidUpdate: -> + @pendingChanges.length = 0 @props.parentView.trigger 'editor:display-updated' observeEditor: -> @@ -272,9 +274,10 @@ EditorComponent = React.createClass if updateRequested @forceUpdate() - onScreenLinesChanged: ({start, end}) -> + onScreenLinesChanged: (change) -> {editor} = @props - @requestUpdate() if editor.intersectsVisibleRowRange(start, end + 1) # TODO: Use closed-open intervals for change events + @pendingChanges.push(change) + @requestUpdate() if editor.intersectsVisibleRowRange(change.start, change.end + 1) # TODO: Use closed-open intervals for change events onSelectionAdded: (selection) -> {editor} = @props diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index dd1937e2f..d0aec653b 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -12,7 +12,7 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - {visibleRowRange, onInputFocused, onInputBlurred} = @props + {visibleRowRange, pendingChanges, onInputFocused, onInputBlurred} = @props contentStyle = height: editor.getScrollHeight() WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" @@ -28,7 +28,7 @@ EditorScrollViewComponent = React.createClass div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, CursorsComponent({editor, cursorBlinkPeriod, cursorBlinkResumeDelay}) - LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, showIndentGuide}) + LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, pendingChanges, showIndentGuide}) div className: 'underlayer', SelectionsComponent({editor}) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 4fcff9828..7ab6c4846 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -41,10 +41,6 @@ GutterComponent = React.createClass div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} ] - componentDidMount: -> - @pendingChanges = [] - @subscribe @props.editor, 'screen-lines-changed', @onScreenLinesChanged - componentWillUnmount: -> @unsubscribe() @@ -52,22 +48,16 @@ GutterComponent = React.createClass # non-zero-delta change to the screen lines has occurred within the current # visible row range. shouldComponentUpdate: (newProps) -> - {visibleRowRange, scrollTop} = @props + {visibleRowRange, pendingChanges, scrollTop} = @props return true unless newProps.scrollTop is scrollTop return true unless isEqual(newProps.visibleRowRange, visibleRowRange) - for change in @pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 + for change in pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start false - componentDidUpdate: -> - @pendingChanges.length = 0 - - onScreenLinesChanged: (change) -> - @pendingChanges.push(change) - LineNumberComponent = React.createClass displayName: 'LineNumberComponent' diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 9b9e8d5c5..e3d81ab5e 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -1,6 +1,6 @@ React = require 'react' {div, span} = require 'reactionary' -{debounce, isEqualForProperties, multiplyString} = require 'underscore-plus' +{debounce, isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus' {$$} = require 'space-pen' DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] @@ -28,6 +28,15 @@ LinesComponent = React.createClass @measuredLines = new WeakSet @updateModelDimensions() + shouldComponentUpdate: (newProps) -> + return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') + + {visibleRowRange, pendingChanges} = newProps + for change in pendingChanges + return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start + + false + componentDidUpdate: (prevProps) -> @updateModelDimensions() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') From ae9f79bfc4cf796f7498bfde25cc490e29b91e13 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 16 Apr 2014 12:37:35 -0600 Subject: [PATCH 140/179] Only add indent guide to trailing whitespace on whitespace-only lines --- spec/editor-component-spec.coffee | 9 +++++++++ src/token.coffee | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 0a9ecc7c2..7f373b547 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -102,6 +102,15 @@ describe "EditorComponent", -> expect(line2LeafNodes[2].textContent).toBe ' ' 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]) + expect(line0LeafNodes[0].textContent).toBe ' ' + expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line0LeafNodes[1].textContent).toBe ' ' + expect(line0LeafNodes[1].classList.contains('indent-guide')).toBe false + getLeafNodes = (node) -> if node.children.length > 0 flatten(toArray(node.children).map(getLeafNodes)) diff --git a/src/token.coffee b/src/token.coffee index f42c5a9cc..5f1baab1d 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -155,8 +155,9 @@ class Token startIndex = match[0].length if @hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) + tokenIsOnlyWhitespace = match[0].length is @value.length classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace + classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace and tokenIsOnlyWhitespace classes += ' invisible-character' if invisibles.space match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space From 3a42346e5ec8cca9b2e96e0f572b970c3192b48d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 16 Apr 2014 13:20:29 -0600 Subject: [PATCH 141/179] Pause cursor blink as part of the overall editor update This ensures we don't perform two updates of the cursors component when cursors move as part of a larger change, such as typing text. --- src/cursors-component.coffee | 13 ++++++------- src/editor-component.coffee | 14 +++++++++++--- src/editor-scroll-view-component.coffee | 4 ++-- src/editor.coffee | 6 +++--- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index e32a925cf..34d767725 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -26,24 +26,23 @@ CursorsComponent = React.createClass componentDidMount: -> {editor} = @props - @subscribe editor, 'cursors-moved', @pauseCursorBlinking @startBlinkingCursors() componentWillUnmount: -> - @stopBlinkingCursors() + clearInterval(@cursorBlinkIntervalHandle) + + componentWillUpdate: ({cursorsMoved}) -> + @pauseCursorBlinking() if cursorsMoved startBlinkingCursors: -> @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) startBlinkingCursorsAfterDelay: null # Created lazily - stopBlinkingCursors: -> - clearInterval(@cursorBlinkIntervalHandle) - @setState(blinkCursorsOff: false) - toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) pauseCursorBlinking: -> - @stopBlinkingCursors() + @state.blinkCursorsOff = false + clearInterval(@cursorBlinkIntervalHandle) @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) @startBlinkingCursorsAfterDelay() diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 172e66707..af32cc0bf 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -17,6 +17,7 @@ EditorComponent = React.createClass selectOnMouseMove: false batchingUpdates: false updateRequested: false + cursorsMoved: false render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state @@ -31,8 +32,10 @@ EditorComponent = React.createClass GutterComponent({editor, visibleRowRange, scrollTop, @pendingChanges}) EditorScrollViewComponent { - ref: 'scrollView', editor, visibleRowRange, @pendingChanges, @onInputFocused, @onInputBlurred - cursorBlinkPeriod, cursorBlinkResumeDelay, showIndentGuide, fontSize, fontFamily, lineHeight + ref: 'scrollView', editor, visibleRowRange, @pendingChanges, + showIndentGuide, fontSize, fontFamily, lineHeight, + @cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay, + @onInputFocused, @onInputBlurred } ScrollbarComponent @@ -74,6 +77,7 @@ EditorComponent = React.createClass componentDidUpdate: -> @pendingChanges.length = 0 + @cursorsMoved = false @props.parentView.trigger 'editor:display-updated' observeEditor: -> @@ -81,6 +85,7 @@ EditorComponent = React.createClass @subscribe editor, 'batched-updates-started', @onBatchedUpdatesStarted @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-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded @@ -272,7 +277,7 @@ EditorComponent = React.createClass @updateRequested = false @batchingUpdates = false if updateRequested - @forceUpdate() + @requestUpdate() onScreenLinesChanged: (change) -> {editor} = @props @@ -287,6 +292,9 @@ EditorComponent = React.createClass {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + onCursorsMoved: -> + @cursorsMoved = true + getVisibleRowRange: -> visibleRowRange = @props.editor.getVisibleRowRange() if @visibleRowOverrides? diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index d0aec653b..1212d51ca 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -12,7 +12,7 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - {visibleRowRange, pendingChanges, onInputFocused, onInputBlurred} = @props + {visibleRowRange, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props contentStyle = height: editor.getScrollHeight() WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" @@ -27,7 +27,7 @@ EditorScrollViewComponent = React.createClass onBlur: onInputBlurred div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, - CursorsComponent({editor, cursorBlinkPeriod, cursorBlinkResumeDelay}) + CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay}) LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, pendingChanges, showIndentGuide}) div className: 'underlayer', SelectionsComponent({editor}) diff --git a/src/editor.coffee b/src/editor.coffee index 17291844c..10c79232d 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1474,9 +1474,9 @@ class Editor extends Model @movingCursors = true @batchUpdates => fn(cursor) for cursor in @getCursors() - @mergeCursors() - @movingCursors = false - @emit 'cursors-moved' + @mergeCursors() + @movingCursors = false + @emit 'cursors-moved' cursorMoved: (event) -> @emit 'cursor-moved', event From 6607f99c6cdc8f6eb3382571b05b7760430e0ff3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 16 Apr 2014 13:56:24 -0600 Subject: [PATCH 142/179] Use padding-top/bottom rather than spacer divs in lines and gutter It creates a simpler DOM structure. --- spec/editor-component-spec.coffee | 28 ++++++++++++++-------------- src/gutter-component.coffee | 11 ++++------- src/lines-component.coffee | 15 ++++++--------- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 7f373b547..eb39df405 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -49,14 +49,14 @@ describe "EditorComponent", -> expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate(0px, #{-2.5 * lineHeightInPixels}px)" - lines = node.querySelectorAll('.line') - expect(lines.length).toBe 6 - expect(lines[0].textContent).toBe editor.lineForScreenRow(2).text - expect(lines[5].textContent).toBe editor.lineForScreenRow(7).text + lineNodes = node.querySelectorAll('.line') + expect(lineNodes.length).toBe 6 + expect(lineNodes[0].textContent).toBe editor.lineForScreenRow(2).text + expect(lineNodes[5].textContent).toBe editor.lineForScreenRow(7).text - spacers = node.querySelectorAll('.lines .spacer') - expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels - expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + linesNode = node.querySelector('.lines') + expect(linesNode.style.paddingTop).toBe 2 * lineHeightInPixels + 'px' + expect(linesNode.style.paddingBottom).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + 'px' describe "when indent guides are enabled", -> beforeEach -> @@ -134,14 +134,14 @@ describe "EditorComponent", -> expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" - lines = node.querySelectorAll('.line-number') - expect(lines.length).toBe 6 - expect(lines[0].textContent).toBe "#{nbsp}3" - expect(lines[5].textContent).toBe "#{nbsp}8" + lineNumberNodes = node.querySelectorAll('.line-number') + expect(lineNumberNodes.length).toBe 6 + expect(lineNumberNodes[0].textContent).toBe "#{nbsp}3" + expect(lineNumberNodes[5].textContent).toBe "#{nbsp}8" - spacers = node.querySelectorAll('.line-numbers .spacer') - expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels - expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + lineNumbersNode = node.querySelector('.line-numbers') + expect(lineNumbersNode.style.paddingTop).toBe 2 * lineHeightInPixels + 'px' + expect(lineNumbersNode.style.paddingBottom).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + 'px' it "renders • characters for soft-wrapped lines", -> editor.setSoftWrap(true) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 7ab6c4846..8836a142a 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -12,12 +12,12 @@ GutterComponent = React.createClass {editor, visibleRowRange, scrollTop} = @props [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() - precedingHeight = startRow * lineHeightInPixels - followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels maxDigits = editor.getLastBufferRow().toString().length style = height: editor.getScrollHeight() WebkitTransform: "translateY(#{-scrollTop}px)" + paddingTop: startRow * lineHeightInPixels + paddingBottom: (editor.getScreenLineCount() - endRow) * lineHeightInPixels wrapCount = 0 lineNumbers = [] @@ -35,11 +35,8 @@ GutterComponent = React.createClass lastBufferRow = bufferRow div className: 'gutter', - div className: 'line-numbers', style: style, [ - div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} - lineNumbers... - div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} - ] + div className: 'line-numbers', style: style, + lineNumbers componentWillUnmount: -> @unsubscribe() diff --git a/src/lines-component.coffee b/src/lines-component.coffee index e3d81ab5e..56457c7e1 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -14,15 +14,12 @@ LinesComponent = React.createClass {editor, visibleRowRange, showIndentGuide} = @props [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() - precedingHeight = startRow * lineHeightInPixels - followingHeight = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + paddingTop = startRow * lineHeightInPixels + paddingBottom = (editor.getScreenLineCount() - endRow) * lineHeightInPixels - div className: 'lines', ref: 'lines', [ - div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight} - (for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}))... - div className: 'spacer', key: 'bottom-spacer', style: {height: followingHeight} - ] + div className: 'lines', ref: 'lines', style: {paddingTop, paddingBottom}, + for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}) componentDidMount: -> @measuredLines = new WeakSet @@ -62,7 +59,7 @@ LinesComponent = React.createClass for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) unless @measuredLines.has(tokenizedLine) - lineNode = linesNode.children[i + 1] + lineNode = linesNode.children[i] @measureCharactersInLine(tokenizedLine, lineNode) measureCharactersInLine: (tokenizedLine, lineNode) -> From 216d561c795378679a3be63d6cb511b63f272435 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 16 Apr 2014 14:51:39 -0600 Subject: [PATCH 143/179] Delay creating range and node iterator until we actually need to measure --- src/lines-component.coffee | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 56457c7e1..9e3423cd2 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -64,14 +64,21 @@ LinesComponent = React.createClass measureCharactersInLine: (tokenizedLine, lineNode) -> {editor} = @props - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) - rangeForMeasurement = document.createRange() + rangeForMeasurement = null + iterator = null + iteratorIndex = -1 - for {value, scopes} in tokenizedLine.tokens - textNode = iterator.nextNode() + for {value, scopes}, tokenIndex in tokenizedLine.tokens charWidths = editor.getScopedCharWidths(scopes) for char, i in value unless charWidths[char]? + rangeForMeasurement ?= document.createRange() + iterator ?= document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) + + while iteratorIndex < tokenIndex + textNode = iterator.nextNode() + iteratorIndex++ + rangeForMeasurement.setStart(textNode, i) rangeForMeasurement.setEnd(textNode, i + 1) charWidth = rangeForMeasurement.getBoundingClientRect().width From 798739f83713d23fb5d1ffd11fd19bef362f1898 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 11:08:43 -0600 Subject: [PATCH 144/179] Use beforeRemove instead of non-existent beforeDetach --- src/editor-component.coffee | 1 - src/react-editor-view.coffee | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index af32cc0bf..4004e62e1 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -73,7 +73,6 @@ EditorComponent = React.createClass componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel - @stopBlinkingCursors() componentDidUpdate: -> @pendingChanges.length = 0 diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 090d6c430..314aa449b 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -37,7 +37,7 @@ class ReactEditorView extends View pixelPositionForBufferPosition: (bufferPosition) -> @editor.pixelPositionForBufferPosition(bufferPosition) - beforeDetach: -> + beforeRemove: -> React.unmountComponentAtNode(@element) @attached = false @trigger 'editor:detached', this From f02d956362e13a21c89e2a2f515924ac48e824a9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 11:56:50 -0600 Subject: [PATCH 145/179] Preserve the only the target screen row when scrolling via mousewheel When the target of a mousewheel event is removed, it breaks velocity scrolling. Previously, we were preserving the entire screen range when scrolling with the mouse wheel, which caused a lot of DOM nodes to accumulate. Now we only preserve the individual line and line number associated with the target of the mousewheel event, moving them just off screen below all the on-screen lines and line numbers. This keeps the number of DOM nodes limited while retaining velocity effects. --- spec/editor-component-spec.coffee | 26 +++++++++++++++++-- src/editor-component.coffee | 33 +++++++++++++------------ src/editor-scroll-view-component.coffee | 7 ++++-- src/gutter-component.coffee | 19 ++++++++------ src/lines-component.coffee | 24 ++++++++++++------ 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index eb39df405..fa7c0d1b8 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -1,5 +1,6 @@ {extend, flatten, toArray} = require 'underscore-plus' ReactEditorView = require '../src/react-editor-view' +nbsp = String.fromCharCode(160) describe "EditorComponent", -> [editor, wrapperView, component, node, verticalScrollbarNode, horizontalScrollbarNode] = [] @@ -118,8 +119,6 @@ describe "EditorComponent", -> [node] describe "gutter rendering", -> - nbsp = String.fromCharCode(160) - it "renders the currently-visible line numbers", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.updateModelDimensions() @@ -502,6 +501,29 @@ describe "EditorComponent", -> expect(verticalScrollbarNode.scrollTop).toBe 10 expect(horizontalScrollbarNode.scrollLeft).toBe 15 + it "preserves the target of the mousewheel event when scrolling vertically", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 20 * charWidth + 'px' + component.updateModelDimensions() + + lineNodes = node.querySelectorAll('.line') + expect(lineNodes.length).toBe 6 + mousewheelEvent = new WheelEvent('mousewheel', wheelDeltaX: -5, wheelDeltaY: -100) + Object.defineProperty(mousewheelEvent, 'target', get: -> lineNodes[0].querySelector('span')) + node.dispatchEvent(mousewheelEvent) + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + + expect(editor.getScrollTop()).toBe 100 + + # Preserves the line and line number for the scroll event's target screen row + lineNodes = node.querySelectorAll('.line') + expect(lineNodes.length).toBe 7 + expect(lineNodes[6].textContent).toBe editor.lineForScreenRow(0).text + + lineNumberNodes = node.querySelectorAll('.line-number') + expect(lineNumberNodes.length).toBe 7 + expect(lineNumberNodes[6].textContent).toBe "#{nbsp}1" + describe "input events", -> inputNode = null diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 4004e62e1..a9518d7a3 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -18,21 +18,22 @@ EditorComponent = React.createClass batchingUpdates: false updateRequested: false cursorsMoved: false + preservedScreenRow: null render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - visibleRowRange = @getVisibleRowRange() + visibleRowRange = editor.getVisibleRowRange() scrollTop = editor.getScrollTop() className = 'editor react' className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, - GutterComponent({editor, visibleRowRange, scrollTop, @pendingChanges}) + GutterComponent({editor, visibleRowRange, @preservedScreenRow, scrollTop, @pendingChanges}) EditorScrollViewComponent { - ref: 'scrollView', editor, visibleRowRange, @pendingChanges, + ref: 'scrollView', editor, visibleRowRange, @preservedScreenRow, @pendingChanges, showIndentGuide, fontSize, fontFamily, lineHeight, @cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred @@ -255,9 +256,9 @@ EditorComponent = React.createClass # To preserve velocity scrolling, delay removal of the event's target until # after mousewheel events stop being fired. Removing the target before then # will cause scrolling to stop suddenly. - @visibleRowOverrides = @getVisibleRowRange() - @clearVisibleRowOverridesAfterDelay ?= debounce(@clearVisibleRowOverrides, 100) - @clearVisibleRowOverridesAfterDelay() + @preservedScreenRow = @screenRowForNode(event.target) + @clearPreservedScreenRowAfterDelay ?= debounce(@clearPreservedScreenRow, 100) + @clearPreservedScreenRowAfterDelay() # Only scroll in one direction at a time {wheelDeltaX, wheelDeltaY} = event @@ -268,6 +269,13 @@ EditorComponent = React.createClass event.preventDefault() + screenRowForNode: (node) -> + editorNode = @getDOMNode() + while node isnt editorNode + screenRow = node.dataset.screenRow + return screenRow if screenRow? + node = node.parentNode + onBatchedUpdatesStarted: -> @batchingUpdates = true @@ -294,18 +302,11 @@ EditorComponent = React.createClass onCursorsMoved: -> @cursorsMoved = true - getVisibleRowRange: -> - visibleRowRange = @props.editor.getVisibleRowRange() - if @visibleRowOverrides? - visibleRowRange[0] = Math.min(visibleRowRange[0], @visibleRowOverrides[0]) - visibleRowRange[1] = Math.max(visibleRowRange[1], @visibleRowOverrides[1]) - visibleRowRange - - clearVisibleRowOverrides: -> - @visibleRowOverrides = null + clearPreservedScreenRow: -> + @preservedScreenRow = null @requestUpdate() - clearVisibleRowOverridesAfterDelay: null + clearPreservedScreenRowAfterDelay: null # Created lazily requestUpdate: -> if @batchingUpdates diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 1212d51ca..269bba241 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -12,7 +12,7 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - {visibleRowRange, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props + {visibleRowRange, preservedScreenRow, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props contentStyle = height: editor.getScrollHeight() WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" @@ -28,7 +28,10 @@ EditorScrollViewComponent = React.createClass div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay}) - LinesComponent({ref: 'lines', editor, fontSize, fontFamily, lineHeight, visibleRowRange, pendingChanges, showIndentGuide}) + LinesComponent { + ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide, + visibleRowRange, preservedScreenRow, pendingChanges + } div className: 'underlayer', SelectionsComponent({editor}) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 8836a142a..b8dc9874d 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -9,7 +9,7 @@ GutterComponent = React.createClass mixins: [SubscriberMixin] render: -> - {editor, visibleRowRange, scrollTop} = @props + {editor, visibleRowRange, preservedScreenRow, scrollTop} = @props [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() maxDigits = editor.getLastBufferRow().toString().length @@ -18,22 +18,24 @@ GutterComponent = React.createClass WebkitTransform: "translateY(#{-scrollTop}px)" paddingTop: startRow * lineHeightInPixels paddingBottom: (editor.getScreenLineCount() - endRow) * lineHeightInPixels - wrapCount = 0 lineNumbers = [] - for bufferRow in @props.editor.bufferRowsForScreenRows(startRow, endRow - 1) + tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) + for bufferRow, i in editor.bufferRowsForScreenRows(startRow, endRow - 1) if bufferRow is lastBufferRow lineNumber = '•' - key = "#{bufferRow}-#{++wrapCount}" else lastBufferRow = bufferRow - wrapCount = 0 lineNumber = (bufferRow + 1).toString() - key = bufferRow.toString() - lineNumbers.push(LineNumberComponent({lineNumber, maxDigits, bufferRow, key})) + key = tokenizedLines[i]?.id ? 0 + screenRow = startRow + i + lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow})) lastBufferRow = bufferRow + if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) + lineNumbers.push(LineNumberComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) + div className: 'gutter', div className: 'line-numbers', style: style, lineNumbers @@ -59,10 +61,11 @@ LineNumberComponent = React.createClass displayName: 'LineNumberComponent' render: -> - {bufferRow} = @props + {bufferRow, screenRow} = @props div className: "line-number line-number-#{bufferRow}" 'data-buffer-row': bufferRow + 'data-screen-row': screenRow dangerouslySetInnerHTML: {__html: @buildInnerHTML()} buildInnerHTML: -> diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 9e3423cd2..c16411ee1 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -11,22 +11,29 @@ LinesComponent = React.createClass displayName: 'LinesComponent' render: -> - {editor, visibleRowRange, showIndentGuide} = @props + {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props + [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() paddingTop = startRow * lineHeightInPixels paddingBottom = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + lines = + for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) + + if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) + lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) + div className: 'lines', ref: 'lines', style: {paddingTop, paddingBottom}, - for tokenizedLine in @props.editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({tokenizedLine, showIndentGuide, key: tokenizedLine.id}) + lines componentDidMount: -> @measuredLines = new WeakSet @updateModelDimensions() shouldComponentUpdate: (newProps) -> - return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') + return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'preservedScreenRow', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') {visibleRowRange, pendingChanges} = newProps for change in pendingChanges @@ -95,7 +102,9 @@ LineComponent = React.createClass displayName: 'LineComponent' render: -> - div className: 'line', dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + {screenRow, preserved} = @props + + div className: 'line', 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()} buildInnerHTML: -> if @props.tokenizedLine.text.length is 0 @@ -122,5 +131,6 @@ LineComponent = React.createClass else "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" - shouldComponentUpdate: (newProps, newState) -> - newProps.showIndentGuide isnt @props.showIndentGuide + shouldComponentUpdate: (newProps) -> + return false if newProps.preserved + not isEqualForProperties(newProps, @props, 'showIndentGuide', 'preserved') From 201e00aa83decf25b0c4132dd481949a4b14c27c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 12:19:04 -0600 Subject: [PATCH 146/179] Don't measure new lines when scrolling with the mousewheel It impacts scrolling performance. We can measure when scrolling comes to a halt. --- src/editor-component.coffee | 2 +- src/lines-component.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a9518d7a3..f364d2e82 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -257,7 +257,7 @@ EditorComponent = React.createClass # after mousewheel events stop being fired. Removing the target before then # will cause scrolling to stop suddenly. @preservedScreenRow = @screenRowForNode(event.target) - @clearPreservedScreenRowAfterDelay ?= debounce(@clearPreservedScreenRow, 100) + @clearPreservedScreenRowAfterDelay ?= debounce(@clearPreservedScreenRow, 300) @clearPreservedScreenRowAfterDelay() # Only scroll in one direction at a time diff --git a/src/lines-component.coffee b/src/lines-component.coffee index c16411ee1..7061572ae 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -44,7 +44,7 @@ LinesComponent = React.createClass componentDidUpdate: (prevProps) -> @updateModelDimensions() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') - @measureCharactersInNewLines() + @measureCharactersInNewLines() unless @props.preservedScreenRow? updateModelDimensions: -> {editor} = @props From 9b6fa967bec99f5a97cfa0695e32ff7057ad46a3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 12:43:13 -0600 Subject: [PATCH 147/179] Handle the editor:consolidate-selections command in the React editor --- spec/editor-component-spec.coffee | 12 ++++++++++++ src/editor-component.coffee | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index fa7c0d1b8..eee197f4c 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -547,3 +547,15 @@ describe "EditorComponent", -> inputNode.value = 'ü' inputNode.dispatchEvent(new Event('input')) expect(editor.lineForBufferRow(0)).toBe 'üvar quicksort = function () {' + + describe "commands", -> + describe "editor:consolidate-selections", -> + it "consolidates selections on the editor model, aborting the key binding if there is only one selection", -> + spyOn(editor, 'consolidateSelections').andCallThrough() + + event = new CustomEvent('editor:consolidate-selections', bubbles: true, cancelable: true) + event.abortKeyBinding = jasmine.createSpy("event.abortKeyBinding") + node.dispatchEvent(event) + + expect(editor.consolidateSelections).toHaveBeenCalled() + expect(event.abortKeyBinding).toHaveBeenCalled() diff --git a/src/editor-component.coffee b/src/editor-component.coffee index f364d2e82..d1c64c829 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -117,7 +117,7 @@ EditorComponent = React.createClass 'core:paste': => editor.pasteText() 'editor:move-to-previous-word': => editor.moveCursorToPreviousWord() 'editor:select-word': => editor.selectWord() - # 'editor:consolidate-selections': (event) => @consolidateSelections(event) + 'editor:consolidate-selections': @consolidateSelections 'editor:backspace-to-beginning-of-word': => editor.backspaceToBeginningOfWord() 'editor:backspace-to-beginning-of-line': => editor.backspaceToBeginningOfLine() 'editor:delete-to-end-of-word': => editor.deleteToEndOfWord() @@ -316,3 +316,6 @@ EditorComponent = React.createClass updateModelDimensions: -> @refs.scrollView.updateModelDimensions() + + consolidateSelections: (e) -> + e.abortKeyBinding() unless @props.editor.consolidateSelections() From 19a5269a5fd1e16f0b68ce2bc0f34c0fa3a73d8a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 13:01:14 -0600 Subject: [PATCH 148/179] Remove metaprogrammed method delegators --- src/editor.coffee | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/editor.coffee b/src/editor.coffee index 10c79232d..2fe0b51e8 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -151,13 +151,6 @@ class Editor extends Model 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', toProperty: 'languageMode' - @delegatesMethods 'setLineHeight', 'getLineHeight', 'getDefaultCharWidth', 'setDefaultCharWidth', - 'setHeight', 'getHeight', 'setWidth', 'getWidth', 'setScrollTop', 'getScrollTop', - 'getScrollBottom', 'setScrollBottom', 'getScrollLeft', 'setScrollLeft', 'getScrollRight', - 'setScrollRight', 'getScrollHeight', 'getScrollWidth', 'getVisibleRowRange', - 'intersectsVisibleRowRange', 'selectionIntersectsVisibleRowRange', 'pixelPositionForScreenPosition', - 'screenPositionForPixelPosition', 'pixelPositionForBufferPosition', toProperty: 'displayBuffer' - @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', '$scrollTop', '$scrollLeft', 'manageScrollPosition', toProperty: 'displayBuffer' @@ -1826,17 +1819,51 @@ class Editor extends Model type: 'selection', editorId: @id, invalidate: 'never' getLineHeight: -> @displayBuffer.getLineHeight() - setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight) getScopedCharWidth: (args...) -> @displayBuffer.getScopedCharWidth(args...) + setScopedCharWidth: (args...) -> @displayBuffer.setScopedCharWidth(args...) getScopedCharWidths: (args...) -> @displayBuffer.getScopedCharWidths(args...) - setScopedCharWidth: (args...) -> @displayBuffer.setScopedCharWidth(args...) - clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths() + getDefaultCharWidth: -> @displayBuffer.getDefaultCharWidth() + setDefaultCharWidth: (args...) -> @displayBuffer.setDefaultCharWidth(args...) + + setHeight: (args...) -> @displayBuffer.setHeight(args...) + getHeight: -> @displayBuffer.getHeight() + + setWidth: (args...) -> @displayBuffer.setWidth(args...) + getWidth: -> @displayBuffer.getWidth() + + getScrollTop: -> @displayBuffer.getScrollTop() + setScrollTop: (args...) -> @displayBuffer.setScrollTop(args...) + + getScrollBottom: -> @displayBuffer.getScrollBottom() + setScrollBottom: (args...) -> @displayBuffer.setScrollBottom(args...) + + getScrollLeft: -> @displayBuffer.getScrollLeft() + setScrollLeft: (args...) -> @displayBuffer.setScrollLeft(args...) + + getScrollRight: -> @displayBuffer.getScrollRight() + setScrollRight: (args...) -> @displayBuffer.setScrollRight(args...) + + getScrollHeight: -> @displayBuffer.getScrollHeight() + getScrollWidth: (args...) -> @displayBuffer.getScrollWidth(args...) + + getVisibleRowRange: -> @displayBuffer.getVisibleRowRange() + + intersectsVisibleRowRange: (args...) -> @displayBuffer.intersectsVisibleRowRange(args...) + + selectionIntersectsVisibleRowRange: (args...) -> @displayBuffer.selectionIntersectsVisibleRowRange(args...) + + pixelPositionForScreenPosition: (args...) -> @displayBuffer.pixelPositionForScreenPosition(args...) + + pixelPositionForBufferPosition: (args...) -> @displayBuffer.pixelPositionForBufferPosition(args...) + + screenPositionForPixelPosition: (args...) -> @displayBuffer.screenPositionForPixelPosition(args...) + # Deprecated: Call {::joinLines} instead. joinLine: -> deprecate("Use Editor::joinLines() instead") From 51ee591282045dbab39244435bca621ce01d6a4a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 14:19:40 -0600 Subject: [PATCH 149/179] Don't render cursors for non-empty selections --- spec/editor-component-spec.coffee | 9 +++++++++ src/cursors-component.coffee | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index eee197f4c..bf1258e28 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -273,6 +273,15 @@ describe "EditorComponent", -> expect(inputNode.offsetTop).toBe cursorTop - editor.getScrollTop() expect(inputNode.offsetLeft).toBe cursorLeft - editor.getScrollLeft() + it "does not render cursors that are associated with non-empty selections", -> + editor.setSelectedScreenRange([[0, 4], [4, 6]]) + editor.addCursorAtScreenPosition([6, 8]) + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 1 + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels + expect(cursorNodes[0].offsetLeft).toBe 8 * charWidth + describe "selection rendering", -> scrollViewClientLeft = null diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index 34d767725..675cd2f9d 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -17,9 +17,10 @@ CursorsComponent = React.createClass blinkOff = @state.blinkCursorsOff div className: 'cursors', - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - {cursor} = selection - CursorComponent({key: cursor.id, cursor, blinkOff}) + for selection in editor.getSelections() + if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + {cursor} = selection + CursorComponent({key: cursor.id, cursor, blinkOff}) getInitialState: -> blinkCursorsOff: false From dd4b6a6d28f75807cb4b0f18bb1167848e5f6c52 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 14:19:52 -0600 Subject: [PATCH 150/179] Don't render empty selections --- spec/editor-component-spec.coffee | 4 ++++ src/selections-component.coffee | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index bf1258e28..2c7238198 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -339,6 +339,10 @@ 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 + describe "mouse interactions", -> linesNode = null diff --git a/src/selections-component.coffee b/src/selections-component.coffee index 1cb58b546..7f94d99fb 100644 --- a/src/selections-component.coffee +++ b/src/selections-component.coffee @@ -10,5 +10,6 @@ SelectionsComponent = React.createClass {editor} = @props div className: 'selections', - for selection in editor.getSelections() when editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({key: selection.id, selection}) + for selection in editor.getSelections() + if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({key: selection.id, selection}) From a0ff6f532530c59c2d41f11c092b588e4b8b5aa5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 15:24:40 -0600 Subject: [PATCH 151/179] Handle 'autoscroll' option in model when setting selected buffer range --- spec/editor-spec.coffee | 23 +++++++++++++++++--- src/cursor.coffee | 31 ++++++--------------------- src/display-buffer.coffee | 44 +++++++++++++++++++++++++++++++++++++++ src/editor.coffee | 20 +++++++++--------- src/selection.coffee | 6 +++++- 5 files changed, 85 insertions(+), 39 deletions(-) diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index 783a0d5fe..dffdd3374 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -670,7 +670,6 @@ describe "Editor", -> editor.setHeight(5.5 * 10) editor.setWidth(5.5 * 10) - it "scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor", -> expect(editor.getScrollTop()).toBe 0 expect(editor.getScrollBottom()).toBe 5.5 * 10 @@ -1077,7 +1076,7 @@ describe "Editor", -> expect(selection1).toBe selection expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] - describe "when the preserveFolds option is false (the default)", -> + describe "when the 'preserveFolds' option is false (the default)", -> it "removes folds that contain the selections", -> editor.setSelectedBufferRange([[0,0], [0,0]]) editor.createFold(1, 4) @@ -1091,7 +1090,7 @@ describe "Editor", -> expect(editor.lineForScreenRow(6).fold).toBeUndefined() expect(editor.lineForScreenRow(10).fold).toBeDefined() - describe "when the preserve folds option is true", -> + describe "when the 'preserveFolds' option is true", -> it "does not remove folds that contain the selections", -> editor.setSelectedBufferRange([[0,0], [0,0]]) editor.createFold(1, 4) @@ -1100,6 +1099,24 @@ describe "Editor", -> expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + describe ".setSelectedBufferRange(range)", -> + describe "when the 'autoscroll' option is true", -> + it "autoscrolls to the selection", -> + editor.manageScrollPosition = true + editor.setLineHeight(10) + editor.setDefaultCharWidth(10) + editor.setHeight(50) + editor.setWidth(50) + expect(editor.getScrollTop()).toBe 0 + + editor.setSelectedBufferRange([[5, 6], [6, 8]], autoscroll: true) + expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe 50 + + editor.setSelectedBufferRange([[6, 6], [6, 8]], autoscroll: true) + expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe (8 + editor.getHorizontalScrollMargin()) * 10 + describe ".selectMarker(marker)", -> describe "if the marker is valid", -> it "selects the marker's range and returns the selected range", -> diff --git a/src/cursor.coffee b/src/cursor.coffee index 6dd8afdcc..1c7de215b 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -59,12 +59,7 @@ class Cursor extends Model @emit 'autoscrolled' if @needsAutoscroll getPixelRect: -> - screenPosition = @getScreenPosition() - {top, left} = @editor.pixelPositionForScreenPosition(screenPosition, false) - right = @editor.pixelPositionForScreenPosition(screenPosition.add([0, 1])).left - width = right - left - height = @editor.getLineHeight() - {top, left, width, height} + @editor.pixelRectForScreenRange(@getScreenRange()) # Public: Moves a cursor to a given screen position. # @@ -81,6 +76,10 @@ class Cursor extends Model getScreenPosition: -> @marker.getHeadScreenPosition() + getScreenRange: -> + {row, column} = @getScreenPosition() + new Range(new Point(row, column), new Point(row, column + 1)) + # Public: Moves a cursor to a given buffer position. # # bufferPosition - An {Array} of two numbers: the buffer row, and the buffer @@ -97,25 +96,7 @@ class Cursor extends Model @marker.getHeadBufferPosition() autoscroll: -> - verticalScrollMarginInPixels = @editor.getVerticalScrollMargin() * @editor.getLineHeight() - horizontalScrollMarginInPixels = @editor.getHorizontalScrollMargin() * @editor.getDefaultCharWidth() - {top, left, height, width} = @getPixelRect() - bottom = top + height - right = left + width - desiredScrollTop = top - verticalScrollMarginInPixels - desiredScrollBottom = bottom + verticalScrollMarginInPixels - desiredScrollLeft = left - horizontalScrollMarginInPixels - desiredScrollRight = right + horizontalScrollMarginInPixels - - if desiredScrollTop < @editor.getScrollTop() - @editor.setScrollTop(desiredScrollTop) - else if desiredScrollBottom > @editor.getScrollBottom() - @editor.setScrollBottom(desiredScrollBottom) - - if desiredScrollLeft < @editor.getScrollLeft() - @editor.setScrollLeft(desiredScrollLeft) - else if desiredScrollRight > @editor.getScrollRight() - @editor.setScrollRight(desiredScrollRight) + @editor.autoscrollToScreenRange(@getScreenRange()) # Public: If the marker range is empty, the cursor is marked as being visible. updateVisibility: -> diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 77dd8496d..24e098425 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -30,6 +30,9 @@ class DisplayBuffer extends Model scrollTop: 0 scrollLeft: 0 + verticalScrollMargin: 2 + horizontalScrollMargin: 6 + constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer}={}) -> super @softWrap ?= atom.config.get('editor.softWrap') ? false @@ -102,6 +105,12 @@ class DisplayBuffer extends Model # visible - A {Boolean} indicating of the tokenized buffer is shown setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) + getVerticalScrollMargin: -> @verticalScrollMargin + setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin + + getHorizontalScrollMargin: -> @horizontalScrollMargin + setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin + getHeight: -> @height setHeight: (@height) -> @height @@ -185,6 +194,41 @@ class DisplayBuffer extends Model {start, end} = selection.getScreenRange() @intersectsVisibleRowRange(start.row, end.row + 1) + autoscrollToScreenRange: (screenRange) -> + verticalScrollMarginInPixels = @getVerticalScrollMargin() * @getLineHeight() + horizontalScrollMarginInPixels = @getHorizontalScrollMargin() * @getDefaultCharWidth() + + {top, left, height, width} = @pixelRectForScreenRange(screenRange) + bottom = top + height + right = left + width + desiredScrollTop = top - verticalScrollMarginInPixels + desiredScrollBottom = bottom + verticalScrollMarginInPixels + desiredScrollLeft = left - horizontalScrollMarginInPixels + desiredScrollRight = right + horizontalScrollMarginInPixels + + if desiredScrollTop < @getScrollTop() + @setScrollTop(desiredScrollTop) + else if desiredScrollBottom > @getScrollBottom() + @setScrollBottom(desiredScrollBottom) + + if desiredScrollLeft < @getScrollLeft() + @setScrollLeft(desiredScrollLeft) + else if desiredScrollRight > @getScrollRight() + @setScrollRight(desiredScrollRight) + + pixelRectForScreenRange: (screenRange) -> + if screenRange.end.row > screenRange.start.row + top = @pixelPositionForScreenPosition(screenRange.start).top + left = 0 + height = (screenRange.end.row - screenRange.start.row + 1) * @getLineHeight() + width = @getScrollWidth() + else + {top, left} = @pixelPositionForScreenPosition(screenRange.start) + height = @getLineHeight() + width = @pixelPositionForScreenPosition(screenRange.end).left - left + + {top, left, width, height} + # Retrieves the current tab length. # # Returns a {Number}. diff --git a/src/editor.coffee b/src/editor.coffee index 2fe0b51e8..739e71d3a 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -144,8 +144,6 @@ class Editor extends Model cursors: null selections: null suppressSelectionMerging: false - verticalScrollMargin: 2 - horizontalScrollMargin: 6 @delegatesMethods 'suggestedIndentForBufferRow', 'autoIndentBufferRow', 'autoIndentBufferRows', 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', @@ -305,14 +303,6 @@ class Editor extends Model # Public: Toggle soft wrap for this editor toggleSoftWrap: -> @setSoftWrap(not @getSoftWrap()) - getVerticalScrollMargin: -> @verticalScrollMargin - - setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin - - getHorizontalScrollMargin: -> @horizontalScrollMargin - - setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin - # Public: Get the text representing a single level of indent. # # If soft tabs are enabled, the text is composed of N spaces, where N is the @@ -1818,6 +1808,12 @@ class Editor extends Model getSelectionMarkerAttributes: -> type: 'selection', editorId: @id, invalidate: 'never' + getVerticalScrollMargin: -> @displayBuffer.getVerticalScrollMargin() + setVerticalScrollMargin: (args...) -> @displayBuffer.setVerticalScrollMargin(args...) + + getHorizontalScrollMargin: -> @displayBuffer.getHorizontalScrollMargin() + setHorizontalScrollMargin: (args...) -> @displayBuffer.setHorizontalScrollMargin(args...) + getLineHeight: -> @displayBuffer.getLineHeight() setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight) @@ -1864,6 +1860,10 @@ class Editor extends Model screenPositionForPixelPosition: (args...) -> @displayBuffer.screenPositionForPixelPosition(args...) + pixelRectForScreenRange: (args...) -> @displayBuffer.pixelRectForScreenRange(args...) + + autoscrollToScreenRange: (args...) -> @displayBuffer.autoscrollToScreenRange(args...) + # Deprecated: Call {::joinLines} instead. joinLine: -> deprecate("Use Editor::joinLines() instead") diff --git a/src/selection.coffee b/src/selection.coffee index 30694deed..a60374f9e 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -76,8 +76,9 @@ class Selection extends Model options.reversed ?= @isReversed() @editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds @modifySelection => - @cursor.needsAutoscroll = false if options.autoscroll? + @cursor.needsAutoscroll = false if @needsAutoscroll? @marker.setBufferRange(bufferRange, options) + @autoscroll() if @needsAutoscroll # Public: Returns the starting and ending buffer rows the selection is # highlighting. @@ -90,6 +91,9 @@ class Selection extends Model end = Math.max(start, end - 1) if range.end.column == 0 [start, end] + autoscroll: -> + @editor.autoscrollToScreenRange(@getScreenRange()) + # Public: Returns the text in the selection. getText: -> @editor.buffer.getTextInRange(@getBufferRange()) From e5379515b99fdf1b10eb3dd457602dfed71e7a04 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 15:43:01 -0600 Subject: [PATCH 152/179] Transfer focus to ReactComponent when wrapper view is focused --- src/react-editor-view.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 314aa449b..6d039b92e 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -44,3 +44,6 @@ class ReactEditorView extends View getPane: -> @closest('.pane').view() + + focus: -> + @component?.onFocus() From bef554709fca4d35abfb250a83c75a2717fe7df0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 16:03:26 -0600 Subject: [PATCH 153/179] Emit 'cursor:moved' event to update cursor position in status bar Emitting the event *before* update, rather than after. This is because we read from the DOM after update to measure new characters, which forces layout, so emitting the event after measuring forces another layout when the position is updated. --- src/editor-component.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index d1c64c829..a2cd1afee 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -75,6 +75,9 @@ EditorComponent = React.createClass componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel + componentWillUpdate: -> + @props.parentView.trigger 'cursor:moved' if @cursorsMoved + componentDidUpdate: -> @pendingChanges.length = 0 @cursorsMoved = false From f59a8f1e6859faf35010c14e379263b8d1f53b42 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 16:56:42 -0600 Subject: [PATCH 154/179] Return function arg's result from Editor::batchUpdates --- src/editor.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor.coffee b/src/editor.coffee index 739e71d3a..9af3f737d 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1789,8 +1789,9 @@ class Editor extends Model batchUpdates: (fn) -> @emit 'batched-updates-started' - fn() + result = fn() @emit 'batched-updates-ended' + result inspect: -> "" From 083f65ed5d01e5d8aafcc970f93d91f373303230 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 17:52:51 -0600 Subject: [PATCH 155/179] Remove envify dependency --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1ab0850b4..fbb2e40ff 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "coffeestack": "0.7.0", "delegato": "1.x", "emissary": "^1.2.1", - "envify": "^1.2.1", "first-mate": "^1.5.2", "fs-plus": "^2.2.2", "fstream": "0.1.24", From f10076c87d7cae30b862cf2707f16d78d150609d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 17 Apr 2014 20:38:40 -0600 Subject: [PATCH 156/179] Prevent activation events from bubbling The react editor is wrapped in another div with the class of .editor for backward compatibility. This prevents activation events registered on the .editor selector from being triggered twice. --- src/package.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/package.coffee b/src/package.coffee index 6ef7bc3b4..f04294ea3 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -290,6 +290,7 @@ class Package $(event.target).trigger(event) @restoreEventHandlersOnBubblePath(bubblePathEventHandlers) @unsubscribeFromActivationEvents() + false unsubscribeFromActivationEvents: -> return unless atom.workspaceView? From 2532527a6a9cc1162f6d61e7a4c6914a805500c0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 09:10:38 -0600 Subject: [PATCH 157/179] Add editor-colors class to EditorComponent --- src/editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a2cd1afee..1859c903d 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -26,7 +26,7 @@ EditorComponent = React.createClass visibleRowRange = editor.getVisibleRowRange() scrollTop = editor.getScrollTop() - className = 'editor react' + className = 'editor editor-colors react' className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, From e9f2a536ed377a40a9dbc31397df5887f0fb2f8d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 09:11:03 -0600 Subject: [PATCH 158/179] Add more shims to ReactEditorView --- src/react-editor-view.coffee | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 6d039b92e..890101b25 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -37,6 +37,12 @@ class ReactEditorView extends View pixelPositionForBufferPosition: (bufferPosition) -> @editor.pixelPositionForBufferPosition(bufferPosition) + pixelPositionForScreenPosition: (screenPosition) -> + @editor.pixelPositionForScreenPosition(screenPosition) + + appendToLinesView: (view) -> + @find('.scroll-view-content').prepend(view) + beforeRemove: -> React.unmountComponentAtNode(@element) @attached = false From d566726b9fff501451c9e409424ca1b697d8bc80 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 09:11:35 -0600 Subject: [PATCH 159/179] Use negative z-indices so attached views are visible in react editor --- static/editor.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/static/editor.less b/static/editor.less index 03bb163a7..5a98e798b 100644 --- a/static/editor.less +++ b/static/editor.less @@ -9,6 +9,11 @@ bottom: 0; left: 0; right: 0; + z-index: -2; + } + + .lines { + z-index: -1; } .horizontal-scrollbar { From fdccc0bcc2f8aa5f682907b324a4724adedeeb83 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 12:28:02 -0600 Subject: [PATCH 160/179] Measure DOM dimensions before rendering elements that depend on them This commit breaks the initial render of the editor component into two stages. The first stage just renders the shell of the editor so the height, width, line height, and default character width can be measured. Nothing that depends on these values is rendered on the first render pass. Once the editor component is mounted, all these values are measured and we force another update, which fills in the lines, line numbers, selections, etc. We also refrain from assigning an explicit height and width on the model if these values aren't explicitly styled in the DOM, and just assume the editor will stretch to accommodate its contents. --- spec/editor-component-spec.coffee | 23 +++++----- src/cursors-component.coffee | 9 ++-- src/display-buffer.coffee | 13 +++--- src/editor-component.coffee | 31 ++++++++------ src/editor-scroll-view-component.coffee | 33 +++++++++----- src/gutter-component.coffee | 12 ++++-- src/lines-component.coffee | 57 ++++++++++++------------- src/selections-component.coffee | 7 +-- 8 files changed, 106 insertions(+), 79 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 2c7238198..f2e8c8bee 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -38,7 +38,7 @@ describe "EditorComponent", -> describe "line rendering", -> it "renders only the currently-visible lines", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lines = node.querySelectorAll('.line') expect(lines.length).toBe 6 @@ -121,7 +121,7 @@ describe "EditorComponent", -> describe "gutter rendering", -> it "renders the currently-visible line numbers", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lines = node.querySelectorAll('.line-number') expect(lines.length).toBe 6 @@ -146,7 +146,7 @@ describe "EditorComponent", -> editor.setSoftWrap(true) node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 30 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lines = node.querySelectorAll('.line-number') expect(lines.length).toBe 6 @@ -163,7 +163,7 @@ describe "EditorComponent", -> cursor1.setScreenPosition([0, 5]) node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 @@ -258,7 +258,7 @@ describe "EditorComponent", -> inputNode = node.querySelector('.hidden-input') node.style.height = 5 * lineHeightInPixels + 'px' node.style.width = 10 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(editor.getCursorScreenPosition()).toEqual [0, 0] editor.setScrollTop(3 * lineHeightInPixels) @@ -292,6 +292,7 @@ describe "EditorComponent", -> # 1-line selection editor.setSelectedScreenRange([[1, 6], [1, 10]]) regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 1 regionRect = regions[0].getBoundingClientRect() expect(regionRect.top).toBe 1 * lineHeightInPixels @@ -355,7 +356,7 @@ describe "EditorComponent", -> it "moves the cursor to the nearest screen position", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 10 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() editor.setScrollTop(3.5 * lineHeightInPixels) editor.setScrollLeft(2 * charWidth) @@ -468,7 +469,7 @@ describe "EditorComponent", -> describe "scrolling", -> it "updates the vertical scrollbar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(verticalScrollbarNode.scrollTop).toBe 0 @@ -477,7 +478,7 @@ describe "EditorComponent", -> it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> node.style.width = 30 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() scrollViewContentNode = node.querySelector('.scroll-view-content') expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" @@ -489,7 +490,7 @@ describe "EditorComponent", -> it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> node.style.width = 30 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(editor.getScrollLeft()).toBe 0 horizontalScrollbarNode.scrollLeft = 100 @@ -501,7 +502,7 @@ describe "EditorComponent", -> it "updates the horizontal or vertical scrollbar depending on which delta is greater (x or y)", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 20 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() expect(verticalScrollbarNode.scrollTop).toBe 0 expect(horizontalScrollbarNode.scrollLeft).toBe 0 @@ -517,7 +518,7 @@ describe "EditorComponent", -> it "preserves the target of the mousewheel event when scrolling vertically", -> node.style.height = 4.5 * lineHeightInPixels + 'px' node.style.width = 20 * charWidth + 'px' - component.updateModelDimensions() + component.measureHeightAndWidth() lineNodes = node.querySelectorAll('.line') expect(lineNodes.length).toBe 6 diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index 675cd2f9d..e3143f1f1 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -17,10 +17,11 @@ CursorsComponent = React.createClass blinkOff = @state.blinkCursorsOff div className: 'cursors', - for selection in editor.getSelections() - if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) - {cursor} = selection - CursorComponent({key: cursor.id, cursor, blinkOff}) + if @isMounted() + for selection in editor.getSelections() + if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + {cursor} = selection + CursorComponent({key: cursor.id, cursor, blinkOff}) getInitialState: -> blinkCursorsOff: false diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 24e098425..c2379ae87 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -111,10 +111,10 @@ class DisplayBuffer extends Model getHorizontalScrollMargin: -> @horizontalScrollMargin setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin - getHeight: -> @height + getHeight: -> @height ? @getScrollHeight() setHeight: (@height) -> @height - getWidth: -> @width + getWidth: -> @width ? @getScrollWidth() setWidth: (newWidth) -> oldWidth = @width @width = newWidth @@ -172,18 +172,21 @@ class DisplayBuffer extends Model @charWidthsByScope = {} getScrollHeight: -> + unless @getLineHeight() > 0 + throw new Error("You must assign lineHeight before calling ::getScrollHeight()") + @getLineCount() * @getLineHeight() getScrollWidth: -> @getMaxLineLength() * @getDefaultCharWidth() getVisibleRowRange: -> - return [0, 0] unless @getLineHeight() > 0 - return [0, @getLineCount()] if @getHeight() is 0 + unless @getLineHeight() > 0 + throw new Error("You must assign a non-zero lineHeight before calling ::getVisibleRowRange()") heightInLines = Math.ceil(@getHeight() / @getLineHeight()) + 1 startRow = Math.floor(@getScrollTop() / @getLineHeight()) - endRow = Math.ceil(startRow + heightInLines) + endRow = Math.min(@getLineCount(), Math.ceil(startRow + heightInLines)) [startRow, endRow] intersectsVisibleRowRange: (startRow, endRow) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 1859c903d..32db47ccc 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -23,8 +23,12 @@ EditorComponent = React.createClass render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - visibleRowRange = editor.getVisibleRowRange() - scrollTop = editor.getScrollTop() + if @isMounted() + visibleRowRange = editor.getVisibleRowRange() + scrollHeight = editor.getScrollHeight() + scrollWidth = editor.getScrollWidth() + scrollTop = editor.getScrollTop() + scrollLeft = editor.getScrollLeft() className = 'editor editor-colors react' className += ' is-focused' if focused @@ -44,16 +48,16 @@ EditorComponent = React.createClass className: 'vertical-scrollbar' orientation: 'vertical' onScroll: @onVerticalScroll - scrollTop: editor.getScrollTop() - scrollHeight: editor.getScrollHeight() + scrollTop: scrollTop + scrollHeight: scrollHeight ScrollbarComponent ref: 'horizontalScrollbar' className: 'horizontal-scrollbar' orientation: 'horizontal' onScroll: @onHorizontalScroll - scrollLeft: editor.getScrollLeft() - scrollWidth: editor.getScrollWidth() + scrollLeft: scrollLeft + scrollWidth: scrollWidth getInitialState: -> {} @@ -61,16 +65,17 @@ EditorComponent = React.createClass cursorBlinkPeriod: 800 cursorBlinkResumeDelay: 200 - componentDidMount: -> + componentWillMount: -> @pendingChanges = [] @props.editor.manageScrollPosition = true - - @listenForDOMEvents() - @listenForCommands() - @observeEditor() @observeConfig() + componentDidMount: -> + @observeEditor() + @listenForDOMEvents() + @listenForCommands() @props.editor.setVisible(true) + @requestUpdate() componentWillUnmount: -> @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel @@ -317,8 +322,8 @@ EditorComponent = React.createClass else @forceUpdate() - updateModelDimensions: -> - @refs.scrollView.updateModelDimensions() + measureHeightAndWidth: -> + @refs.scrollView.measureHeightAndWidth() consolidateSelections: (e) -> e.abortKeyBinding() unless @props.editor.consolidateSelections() diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 269bba241..1ee1a6f92 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -13,11 +13,13 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props {visibleRowRange, preservedScreenRow, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props - contentStyle = - height: editor.getScrollHeight() - WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" - div className: 'scroll-view', ref: 'scrollView', + if @isMounted() + contentStyle = + height: editor.getScrollHeight() + WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" + + div className: 'scroll-view', InputComponent ref: 'input' className: 'hidden-input' @@ -36,8 +38,8 @@ EditorScrollViewComponent = React.createClass SelectionsComponent({editor}) componentDidMount: -> - @getDOMNode().addEventListener 'overflowchanged', @updateModelDimensions - @updateModelDimensions() + @getDOMNode().addEventListener 'overflowchanged', @measureHeightAndWidth + @measureHeightAndWidth() onInput: (char, replaceLastCharacter) -> {editor} = @props @@ -109,12 +111,14 @@ EditorScrollViewComponent = React.createClass {editor} = @props {clientX, clientY} = event - editorClientRect = @refs.scrollView.getDOMNode().getBoundingClientRect() + editorClientRect = @getDOMNode().getBoundingClientRect() top = clientY - editorClientRect.top + editor.getScrollTop() left = clientX - editorClientRect.left + editor.getScrollLeft() {top, left} getHiddenInputPosition: -> + return {top: 0, left: 0} unless @isMounted() + {editor} = @props if cursor = editor.getCursor() @@ -129,11 +133,20 @@ EditorScrollViewComponent = React.createClass {top, left} - updateModelDimensions: -> + # Measure explicitly-styled height and width and relay them to the model. If + # these values aren't explicitly styled, we assume the editor is unconstrained + # and use the scrollHeight / scrollWidth as its height and width in + # calculations. + measureHeightAndWidth: -> {editor} = @props node = @getDOMNode() - editor.setHeight(node.clientHeight) - editor.setWidth(node.clientWidth) + computedStyle = getComputedStyle(node) + + unless computedStyle.height is '0px' + editor.setHeight(node.clientHeight) + + unless computedStyle.width is '0px' + editor.setWidth(node.clientWidth) focus: -> @refs.input.focus() diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index b8dc9874d..05b122cea 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -9,6 +9,10 @@ GutterComponent = React.createClass mixins: [SubscriberMixin] render: -> + div className: 'gutter', + @renderLineNumbers() if @isMounted() + + renderLineNumbers: -> {editor, visibleRowRange, preservedScreenRow, scrollTop} = @props [startRow, endRow] = visibleRowRange lineHeightInPixels = editor.getLineHeight() @@ -21,6 +25,7 @@ GutterComponent = React.createClass lineNumbers = [] tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) + tokenizedLines.push({id: 0}) if tokenizedLines.length is 0 for bufferRow, i in editor.bufferRowsForScreenRows(startRow, endRow - 1) if bufferRow is lastBufferRow lineNumber = '•' @@ -28,7 +33,7 @@ GutterComponent = React.createClass lastBufferRow = bufferRow lineNumber = (bufferRow + 1).toString() - key = tokenizedLines[i]?.id ? 0 + key = tokenizedLines[i]?.id screenRow = startRow + i lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow})) lastBufferRow = bufferRow @@ -36,9 +41,8 @@ GutterComponent = React.createClass if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) lineNumbers.push(LineNumberComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - div className: 'gutter', - div className: 'line-numbers', style: style, - lineNumbers + div className: 'line-numbers', style: style, + lineNumbers componentWillUnmount: -> @unsubscribe() diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 7061572ae..9c63bdbd6 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -11,26 +11,28 @@ LinesComponent = React.createClass displayName: 'LinesComponent' render: -> - {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props + if @isMounted() + {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props + [startRow, endRow] = visibleRowRange - [startRow, endRow] = visibleRowRange - lineHeightInPixels = editor.getLineHeight() - paddingTop = startRow * lineHeightInPixels - paddingBottom = (editor.getScreenLineCount() - endRow) * lineHeightInPixels + style = + paddingTop: startRow * editor.getLineHeight() + paddingBottom: (editor.getScreenLineCount() - endRow) * editor.getLineHeight() - lines = - for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) + lines = + for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) - if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) - lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) + if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) + lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - div className: 'lines', ref: 'lines', style: {paddingTop, paddingBottom}, - lines + div {className: 'lines', style}, lines + + componentWillMount: -> + @measuredLines = new WeakSet componentDidMount: -> - @measuredLines = new WeakSet - @updateModelDimensions() + @measureLineHeightAndCharWidth() shouldComponentUpdate: (newProps) -> return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'preservedScreenRow', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') @@ -42,31 +44,28 @@ LinesComponent = React.createClass false componentDidUpdate: (prevProps) -> - @updateModelDimensions() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') + @measureLineHeightAndCharWidth() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') @measureCharactersInNewLines() unless @props.preservedScreenRow? - updateModelDimensions: -> - {editor} = @props - {lineHeightInPixels, charWidth} = @measureLineDimensions() - editor.setLineHeight(lineHeightInPixels) - editor.setDefaultCharWidth(charWidth) - - measureLineDimensions: -> - linesNode = @refs.lines.getDOMNode() - linesNode.appendChild(DummyLineNode) - lineHeightInPixels = DummyLineNode.getBoundingClientRect().height + measureLineHeightAndCharWidth: -> + node = @getDOMNode() + node.appendChild(DummyLineNode) + lineHeight = DummyLineNode.getBoundingClientRect().height charWidth = DummyLineNode.firstChild.getBoundingClientRect().width - linesNode.removeChild(DummyLineNode) - {lineHeightInPixels, charWidth} + node.removeChild(DummyLineNode) + + {editor} = @props + editor.setLineHeight(lineHeight) + editor.setDefaultCharWidth(charWidth) measureCharactersInNewLines: -> [visibleStartRow, visibleEndRow] = @props.visibleRowRange - linesNode = @refs.lines.getDOMNode() + node = @getDOMNode() for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) unless @measuredLines.has(tokenizedLine) - lineNode = linesNode.children[i] + lineNode = node.children[i] @measureCharactersInLine(tokenizedLine, lineNode) measureCharactersInLine: (tokenizedLine, lineNode) -> diff --git a/src/selections-component.coffee b/src/selections-component.coffee index 7f94d99fb..616fc62dd 100644 --- a/src/selections-component.coffee +++ b/src/selections-component.coffee @@ -10,6 +10,7 @@ SelectionsComponent = React.createClass {editor} = @props div className: 'selections', - for selection in editor.getSelections() - if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) - SelectionComponent({key: selection.id, selection}) + if @isMounted() + for selection in editor.getSelections() + if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({key: selection.id, selection}) From 168cda4f75d3a55d2596eda25dcee42eaaa85b15 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 13:23:20 -0600 Subject: [PATCH 161/179] Pause measurement on overflowchanged during updates Content updates trigger overflowchanged, but we're mainly using it to detect when the editor component has been resized. Pausing measurement during content updates makes them faster. --- src/editor-scroll-view-component.coffee | 32 ++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 1ee1a6f92..b4e099166 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -1,5 +1,6 @@ React = require 'react' {div} = require 'reactionary' +{debounce} = require 'underscore-plus' InputComponent = require './input-component' LinesComponent = require './lines-component' @@ -10,6 +11,10 @@ module.exports = EditorScrollViewComponent = React.createClass displayName: 'EditorScrollViewComponent' + measurementPaused: false + measurementPending: false + measurementRequested: false + render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props {visibleRowRange, preservedScreenRow, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props @@ -38,9 +43,34 @@ EditorScrollViewComponent = React.createClass SelectionsComponent({editor}) componentDidMount: -> - @getDOMNode().addEventListener 'overflowchanged', @measureHeightAndWidth + @getDOMNode().addEventListener 'overflowchanged', @requestMeasurement @measureHeightAndWidth() + componentDidUpdate: -> + @pauseMeasurement() + + requestMeasurement: -> + if @measurementPaused + @measurementRequested = true + else unless @measurementPending + @measurementPending = true + requestAnimationFrame => + @measurementPending = false + @measureHeightAndWidth() + + pauseMeasurement: -> + @measurementPaused = true + @resumeOverflowChangedEventsAfterDelay ?= debounce(@resumeMeasurement, 500) + @resumeOverflowChangedEventsAfterDelay() + + resumeMeasurement: -> + @measurementPaused = false + if @measurementRequested + @measurementRequested = false + @requestMeasurement() + + resumeMeasurementAfterDelay: null + onInput: (char, replaceLastCharacter) -> {editor} = @props From 4e27e765d037d895c44098903213e43d6549dcfb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 15:59:32 -0600 Subject: [PATCH 162/179] Measure width and height when window size changes Since overflowchanged events are paused for a bit after updates to prevent thrashing, this ensures the editor is still updated promptly when resizing. --- src/editor-scroll-view-component.coffee | 58 +++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index b4e099166..e89ffec12 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -11,9 +11,9 @@ module.exports = EditorScrollViewComponent = React.createClass displayName: 'EditorScrollViewComponent' - measurementPaused: false measurementPending: false - measurementRequested: false + overflowChangedEventsPaused: false + overflowChangedWhilePaused: false render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props @@ -43,33 +43,45 @@ EditorScrollViewComponent = React.createClass SelectionsComponent({editor}) componentDidMount: -> - @getDOMNode().addEventListener 'overflowchanged', @requestMeasurement + @getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged + window.addEventListener('resize', @onWindowResize) + @measureHeightAndWidth() + componentDidUnmount: -> + window.removeEventListener('resize', @onWindowResize) + componentDidUpdate: -> - @pauseMeasurement() + @pauseOverflowChangedEvents() - requestMeasurement: -> - if @measurementPaused - @measurementRequested = true - else unless @measurementPending - @measurementPending = true - requestAnimationFrame => - @measurementPending = false - @measureHeightAndWidth() - - pauseMeasurement: -> - @measurementPaused = true - @resumeOverflowChangedEventsAfterDelay ?= debounce(@resumeMeasurement, 500) - @resumeOverflowChangedEventsAfterDelay() - - resumeMeasurement: -> - @measurementPaused = false - if @measurementRequested - @measurementRequested = false + onOverflowChanged: -> + if @overflowChangedEventsPaused + @overflowChangedWhilePaused = true + else @requestMeasurement() - resumeMeasurementAfterDelay: null + onWindowResize: -> + @requestMeasurement() + + pauseOverflowChangedEvents: -> + @overflowChangedEventsPaused = true + @resumeOverflowChangedEventsAfterDelay ?= debounce(@resumeOverflowChangedEvents, 500) + @resumeOverflowChangedEventsAfterDelay() + + resumeOverflowChangedEvents: -> + if @overflowChangedWhilePaused + @overflowChangedWhilePaused = false + @requestMeasurement() + + resumeOverflowChangedEventsAfterDelay: null + + requestMeasurement: -> + return if @measurementPending + + @measurementPending = true + requestAnimationFrame => + @measurementPending = false + @measureHeightAndWidth() onInput: (char, replaceLastCharacter) -> {editor} = @props From 10d6ec156f4c494f84cbfcdc97ef8d2ad1a70914 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 16:11:21 -0600 Subject: [PATCH 163/179] Unsubscribe EditorComponent before unmounting --- src/editor-component.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 32db47ccc..4864cecaa 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -78,6 +78,7 @@ EditorComponent = React.createClass @requestUpdate() componentWillUnmount: -> + @unsubscribe() @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel componentWillUpdate: -> From 274ca33959dd75366e339aa6c65ce891b659972f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 16:13:18 -0600 Subject: [PATCH 164/179] Don't measure height and width unless component is mounted Since we measure in requestAnimationFrame, it's possible to request measurement prior to be unmounted and have it occur afterward. --- src/editor-scroll-view-component.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index e89ffec12..ba2a50755 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -180,6 +180,8 @@ EditorScrollViewComponent = React.createClass # and use the scrollHeight / scrollWidth as its height and width in # calculations. measureHeightAndWidth: -> + return unless @isMounted() + {editor} = @props node = @getDOMNode() computedStyle = getComputedStyle(node) From a271e52a4e9eb92426bc4d64d8b65ad3eea2b945 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 16:24:30 -0600 Subject: [PATCH 165/179] Never assign a 0 height or width when measuring editor scroll view --- src/editor-scroll-view-component.coffee | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index ba2a50755..c4db12bb2 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -182,15 +182,17 @@ EditorScrollViewComponent = React.createClass measureHeightAndWidth: -> return unless @isMounted() - {editor} = @props node = @getDOMNode() computedStyle = getComputedStyle(node) + {editor} = @props unless computedStyle.height is '0px' - editor.setHeight(node.clientHeight) + clientHeight = node.clientHeight + editor.setHeight(clientHeight) if clientHeight > 0 unless computedStyle.width is '0px' - editor.setWidth(node.clientWidth) + clientWidth = node.clientWidth + editor.setWidth(clientWidth) if clientHeight > 0 focus: -> @refs.input.focus() From 43e6fb73f18ad91056399b0e05e01a82b30e75ae Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 18 Apr 2014 16:34:46 -0600 Subject: [PATCH 166/179] Focus react editor on attachment if it had focus previously --- src/react-editor-view.coffee | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 890101b25..b57b5e927 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -6,6 +6,8 @@ module.exports = class ReactEditorView extends View @content: -> @div class: 'editor react-wrapper' + focusOnAttach: false + constructor: (@editor) -> super @@ -32,6 +34,8 @@ class ReactEditorView extends View lines.addClass(klass) lines.length > 0 + @focus() if @focusOnAttach + @trigger 'editor:attached', [this] pixelPositionForBufferPosition: (bufferPosition) -> @@ -52,4 +56,7 @@ class ReactEditorView extends View @closest('.pane').view() focus: -> - @component?.onFocus() + if @component? + @component.onFocus() + else + @focusOnAttach = true From afec8f1ca0415f13346570e6795599dc79c69ac7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 19 Apr 2014 08:44:02 -0600 Subject: [PATCH 167/179] Account for height of hidden input when positioning it --- src/editor-scroll-view-component.coffee | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index c4db12bb2..21075f6a0 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -159,19 +159,14 @@ EditorScrollViewComponent = React.createClass {top, left} getHiddenInputPosition: -> - return {top: 0, left: 0} unless @isMounted() - {editor} = @props + return {top: 0, left: 0} unless @isMounted() and editor.getCursor()? - if cursor = editor.getCursor() - cursorRect = cursor.getPixelRect() - top = cursorRect.top - editor.getScrollTop() - top = Math.max(0, Math.min(editor.getHeight(), top)) - left = cursorRect.left - editor.getScrollLeft() - left = Math.max(0, Math.min(editor.getWidth(), left)) - else - top = 0 - left = 0 + {top, left, height, width} = editor.getCursor().getPixelRect() + top = top - editor.getScrollTop() + top = Math.max(0, Math.min(editor.getHeight() - height, top)) + left = left - editor.getScrollLeft() + left = Math.max(0, Math.min(editor.getWidth() - width, left)) {top, left} From a03f2f46ee3e0797eae85e358ddcfca9169a89c1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 31 Dec 2011 17:52:47 -0700 Subject: [PATCH 168/179] Don't assume tokens match text nodes when measuring character widths --- src/lines-component.coffee | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 9c63bdbd6..4223811bc 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -72,24 +72,33 @@ LinesComponent = React.createClass {editor} = @props rangeForMeasurement = null iterator = null - iteratorIndex = -1 + charIndex = 0 for {value, scopes}, tokenIndex in tokenizedLine.tokens charWidths = editor.getScopedCharWidths(scopes) - for char, i in value + + for char in value unless charWidths[char]? - rangeForMeasurement ?= document.createRange() - iterator ?= document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) - - while iteratorIndex < tokenIndex + unless textNode? + rangeForMeasurement ?= document.createRange() + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) textNode = iterator.nextNode() - iteratorIndex++ + textNodeIndex = 0 + nextTextNodeIndex = textNode.textContent.length + while nextTextNodeIndex <= charIndex + textNode = iterator.nextNode() + textNodeIndex = nextTextNodeIndex + nextTextNodeIndex = textNodeIndex + textNode.textContent.length + + i = charIndex - textNodeIndex rangeForMeasurement.setStart(textNode, i) rangeForMeasurement.setEnd(textNode, i + 1) charWidth = rangeForMeasurement.getBoundingClientRect().width editor.setScopedCharWidth(scopes, char, charWidth) + charIndex++ + @measuredLines.add(tokenizedLine) clearScopedCharWidths: -> From 22496ceeb1746ef13ce7fca948c84e90836719d4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 20 Apr 2014 11:44:48 -0600 Subject: [PATCH 169/179] WIP: Minimize paint when scrolling and composite lines with the GPU --- spec/editor-component-spec.coffee | 42 +++--------------- src/editor-component.coffee | 57 +++++++++++++------------ src/editor-scroll-view-component.coffee | 6 +-- src/gutter-component.coffee | 32 +++++++------- src/lines-component.coffee | 36 +++++++--------- static/editor.less | 19 ++++++++- 6 files changed, 89 insertions(+), 103 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index f2e8c8bee..ab3708ea2 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -48,17 +48,14 @@ describe "EditorComponent", -> verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate(0px, #{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate3d(0px, #{-2.5 * lineHeightInPixels}px, 0)" lineNodes = node.querySelectorAll('.line') expect(lineNodes.length).toBe 6 + expect(lineNodes[0].offsetTop).toBe 2 * lineHeightInPixels expect(lineNodes[0].textContent).toBe editor.lineForScreenRow(2).text expect(lineNodes[5].textContent).toBe editor.lineForScreenRow(7).text - linesNode = node.querySelector('.lines') - expect(linesNode.style.paddingTop).toBe 2 * lineHeightInPixels + 'px' - expect(linesNode.style.paddingBottom).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + 'px' - describe "when indent guides are enabled", -> beforeEach -> component.setShowIndentGuide(true) @@ -131,17 +128,15 @@ describe "EditorComponent", -> verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)" + expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translate3d(0, #{-2.5 * lineHeightInPixels}px, 0)" lineNumberNodes = node.querySelectorAll('.line-number') expect(lineNumberNodes.length).toBe 6 + expect(lineNumberNodes[0].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNumberNodes[5].offsetTop).toBe 7 * lineHeightInPixels expect(lineNumberNodes[0].textContent).toBe "#{nbsp}3" expect(lineNumberNodes[5].textContent).toBe "#{nbsp}8" - lineNumbersNode = node.querySelector('.line-numbers') - expect(lineNumbersNode.style.paddingTop).toBe 2 * lineHeightInPixels + 'px' - expect(lineNumbersNode.style.paddingBottom).toBe (editor.getScreenLineCount() - 8) * lineHeightInPixels + 'px' - it "renders • characters for soft-wrapped lines", -> editor.setSoftWrap(true) node.style.height = 4.5 * lineHeightInPixels + 'px' @@ -481,11 +476,11 @@ describe "EditorComponent", -> component.measureHeightAndWidth() scrollViewContentNode = node.querySelector('.scroll-view-content') - expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(0px, 0px)" + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0)" expect(horizontalScrollbarNode.scrollLeft).toBe 0 editor.setScrollLeft(100) - expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate(-100px, 0px)" + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0)" expect(horizontalScrollbarNode.scrollLeft).toBe 100 it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> @@ -515,29 +510,6 @@ describe "EditorComponent", -> expect(verticalScrollbarNode.scrollTop).toBe 10 expect(horizontalScrollbarNode.scrollLeft).toBe 15 - it "preserves the target of the mousewheel event when scrolling vertically", -> - node.style.height = 4.5 * lineHeightInPixels + 'px' - node.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() - - lineNodes = node.querySelectorAll('.line') - expect(lineNodes.length).toBe 6 - mousewheelEvent = new WheelEvent('mousewheel', wheelDeltaX: -5, wheelDeltaY: -100) - Object.defineProperty(mousewheelEvent, 'target', get: -> lineNodes[0].querySelector('span')) - node.dispatchEvent(mousewheelEvent) - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - - expect(editor.getScrollTop()).toBe 100 - - # Preserves the line and line number for the scroll event's target screen row - lineNodes = node.querySelectorAll('.line') - expect(lineNodes.length).toBe 7 - expect(lineNodes[6].textContent).toBe editor.lineForScreenRow(0).text - - lineNumberNodes = node.querySelectorAll('.line-number') - expect(lineNumberNodes.length).toBe 7 - expect(lineNumberNodes[6].textContent).toBe "#{nbsp}1" - describe "input events", -> inputNode = null diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 4864cecaa..a01ca207b 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -18,29 +18,31 @@ EditorComponent = React.createClass batchingUpdates: false updateRequested: false cursorsMoved: false - preservedScreenRow: null + preservedRowRange: null + scrollingVertically: false render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props if @isMounted() - visibleRowRange = editor.getVisibleRowRange() + renderedRowRange = @getRenderedRowRange() scrollHeight = editor.getScrollHeight() scrollWidth = editor.getScrollWidth() scrollTop = editor.getScrollTop() scrollLeft = editor.getScrollLeft() + lineHeightInPixels = editor.getLineHeight() className = 'editor editor-colors react' className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, - GutterComponent({editor, visibleRowRange, @preservedScreenRow, scrollTop, @pendingChanges}) + GutterComponent({editor, renderedRowRange, scrollTop, lineHeight: lineHeightInPixels, @pendingChanges}) EditorScrollViewComponent { - ref: 'scrollView', editor, visibleRowRange, @preservedScreenRow, @pendingChanges, - showIndentGuide, fontSize, fontFamily, lineHeight, - @cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay, - @onInputFocused, @onInputBlurred + ref: 'scrollView', editor, renderedRowRange, @pendingChanges, + @scrollingVertically, showIndentGuide, fontSize, fontFamily, + lineHeight: lineHeightInPixels, @cursorsMoved, cursorBlinkPeriod, + cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred, } ScrollbarComponent @@ -59,6 +61,13 @@ EditorComponent = React.createClass scrollLeft: scrollLeft scrollWidth: scrollWidth + getRenderedRowRange: -> + renderedRowRange = @props.editor.getVisibleRowRange() + if @preservedRowRange? + renderedRowRange[0] = Math.min(@preservedRowRange[0], renderedRowRange[0]) + renderedRowRange[1] = Math.max(@preservedRowRange[1], renderedRowRange[1]) + renderedRowRange + getInitialState: -> {} getDefaultProps: -> @@ -98,7 +107,7 @@ EditorComponent = React.createClass @subscribe editor, 'selection-screen-range-changed', @requestUpdate @subscribe editor, 'selection-added', @onSelectionAdded @subscribe editor, 'selection-removed', @onSelectionAdded - @subscribe editor.$scrollTop.changes, @requestUpdate + @subscribe editor.$scrollTop.changes, @onScrollTopChanged @subscribe editor.$scrollLeft.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @subscribe editor.$width.changes, @requestUpdate @@ -262,13 +271,6 @@ EditorComponent = React.createClass @pendingScrollLeft = null onMouseWheel: (event) -> - # To preserve velocity scrolling, delay removal of the event's target until - # after mousewheel events stop being fired. Removing the target before then - # will cause scrolling to stop suddenly. - @preservedScreenRow = @screenRowForNode(event.target) - @clearPreservedScreenRowAfterDelay ?= debounce(@clearPreservedScreenRow, 300) - @clearPreservedScreenRowAfterDelay() - # Only scroll in one direction at a time {wheelDeltaX, wheelDeltaY} = event if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) @@ -278,12 +280,12 @@ EditorComponent = React.createClass event.preventDefault() - screenRowForNode: (node) -> - editorNode = @getDOMNode() - while node isnt editorNode - screenRow = node.dataset.screenRow - return screenRow if screenRow? - node = node.parentNode + clearPreservedRowRange: -> + @preservedRowRange = null + @scrollingVertically = false + @requestUpdate() + + clearPreservedRowRangeAfterDelay: null # Created lazily onBatchedUpdatesStarted: -> @batchingUpdates = true @@ -304,6 +306,13 @@ EditorComponent = React.createClass {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + onScrollTopChanged: -> + @preservedRowRange = @getRenderedRowRange() + @scrollingVertically = true + @clearPreservedRowRangeAfterDelay ?= debounce(@clearPreservedRowRange, 200) + @clearPreservedRowRangeAfterDelay() + @requestUpdate() + onSelectionRemoved: (selection) -> {editor} = @props @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) @@ -311,12 +320,6 @@ EditorComponent = React.createClass onCursorsMoved: -> @cursorsMoved = true - clearPreservedScreenRow: -> - @preservedScreenRow = null - @requestUpdate() - - clearPreservedScreenRowAfterDelay: null # Created lazily - requestUpdate: -> if @batchingUpdates @updateRequested = true diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 21075f6a0..7a1648fd7 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -17,12 +17,12 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - {visibleRowRange, preservedScreenRow, pendingChanges, cursorsMoved, onInputFocused, onInputBlurred} = @props + {renderedRowRange, pendingChanges, scrollingVertically, cursorsMoved, onInputFocused, onInputBlurred} = @props if @isMounted() contentStyle = height: editor.getScrollHeight() - WebkitTransform: "translate(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px)" + WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)" div className: 'scroll-view', InputComponent @@ -37,7 +37,7 @@ EditorScrollViewComponent = React.createClass CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay}) LinesComponent { ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide, - visibleRowRange, preservedScreenRow, pendingChanges + renderedRowRange, pendingChanges, scrollingVertically } div className: 'underlayer', SelectionsComponent({editor}) diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 05b122cea..979376985 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -1,6 +1,6 @@ React = require 'react' {div} = require 'reactionary' -{isEqual, multiplyString} = require 'underscore-plus' +{isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus' SubscriberMixin = require './subscriber-mixin' module.exports = @@ -13,15 +13,15 @@ GutterComponent = React.createClass @renderLineNumbers() if @isMounted() renderLineNumbers: -> - {editor, visibleRowRange, preservedScreenRow, scrollTop} = @props - [startRow, endRow] = visibleRowRange - lineHeightInPixels = editor.getLineHeight() + {editor, renderedRowRange, scrollTop} = @props + [startRow, endRow] = renderedRowRange + charWidth = editor.getDefaultCharWidth() + lineHeight = editor.getLineHeight() maxDigits = editor.getLastBufferRow().toString().length style = + width: charWidth * (maxDigits + 1.5) height: editor.getScrollHeight() - WebkitTransform: "translateY(#{-scrollTop}px)" - paddingTop: startRow * lineHeightInPixels - paddingBottom: (editor.getScreenLineCount() - endRow) * lineHeightInPixels + WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)" lineNumbers = [] tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) @@ -35,12 +35,9 @@ GutterComponent = React.createClass key = tokenizedLines[i]?.id screenRow = startRow + i - lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow})) + lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow, lineHeight})) lastBufferRow = bufferRow - if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) - lineNumbers.push(LineNumberComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - div className: 'line-numbers', style: style, lineNumbers @@ -51,13 +48,12 @@ GutterComponent = React.createClass # non-zero-delta change to the screen lines has occurred within the current # visible row range. shouldComponentUpdate: (newProps) -> - {visibleRowRange, pendingChanges, scrollTop} = @props + {renderedRowRange, pendingChanges, scrollTop} = @props - return true unless newProps.scrollTop is scrollTop - return true unless isEqual(newProps.visibleRowRange, visibleRowRange) + return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'scrollTop', 'lineHeight') for change in pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 - return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start + return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start false @@ -65,9 +61,10 @@ LineNumberComponent = React.createClass displayName: 'LineNumberComponent' render: -> - {bufferRow, screenRow} = @props + {bufferRow, screenRow, lineHeight} = @props div className: "line-number line-number-#{bufferRow}" + style: {top: screenRow * lineHeight} 'data-buffer-row': bufferRow 'data-screen-row': screenRow dangerouslySetInnerHTML: {__html: @buildInnerHTML()} @@ -82,4 +79,5 @@ LineNumberComponent = React.createClass iconDivHTML: '
' - shouldComponentUpdate: -> false + shouldComponentUpdate: (newProps) -> + not isEqualForProperties(newProps, @props, 'lineHeight') diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 4223811bc..3d0da5bef 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -12,21 +12,14 @@ LinesComponent = React.createClass render: -> if @isMounted() - {editor, visibleRowRange, preservedScreenRow, showIndentGuide} = @props - [startRow, endRow] = visibleRowRange - - style = - paddingTop: startRow * editor.getLineHeight() - paddingBottom: (editor.getScreenLineCount() - endRow) * editor.getLineHeight() + {editor, renderedRowRange, lineHeight, showIndentGuide} = @props + [startRow, endRow] = renderedRowRange lines = for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) - LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, screenRow: startRow + i}) + LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, lineHeight, screenRow: startRow + i}) - if preservedScreenRow? and (preservedScreenRow < startRow or endRow <= preservedScreenRow) - lines.push(LineComponent({key: editor.lineForScreenRow(preservedScreenRow).id, preserved: true})) - - div {className: 'lines', style}, lines + div {className: 'lines'}, lines componentWillMount: -> @measuredLines = new WeakSet @@ -35,18 +28,18 @@ LinesComponent = React.createClass @measureLineHeightAndCharWidth() shouldComponentUpdate: (newProps) -> - return true unless isEqualForProperties(newProps, @props, 'visibleRowRange', 'preservedScreenRow', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') + return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') - {visibleRowRange, pendingChanges} = newProps + {renderedRowRange, pendingChanges} = newProps for change in pendingChanges - return true unless change.end <= visibleRowRange.start or visibleRowRange.end <= change.start + return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start false componentDidUpdate: (prevProps) -> @measureLineHeightAndCharWidth() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') - @measureCharactersInNewLines() unless @props.preservedScreenRow? + @measureCharactersInNewLines() unless @props.scrollingVertically measureLineHeightAndCharWidth: -> node = @getDOMNode() @@ -60,7 +53,7 @@ LinesComponent = React.createClass editor.setDefaultCharWidth(charWidth) measureCharactersInNewLines: -> - [visibleStartRow, visibleEndRow] = @props.visibleRowRange + [visibleStartRow, visibleEndRow] = @props.renderedRowRange node = @getDOMNode() for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) @@ -110,9 +103,13 @@ LineComponent = React.createClass displayName: 'LineComponent' render: -> - {screenRow, preserved} = @props + {screenRow, lineHeight} = @props - div className: 'line', 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + style = + top: screenRow * lineHeight + position: 'absolute' + + div className: 'line', style: style, 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()} buildInnerHTML: -> if @props.tokenizedLine.text.length is 0 @@ -140,5 +137,4 @@ LineComponent = React.createClass "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" shouldComponentUpdate: (newProps) -> - return false if newProps.preserved - not isEqualForProperties(newProps, @props, 'showIndentGuide', 'preserved') + not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight') diff --git a/static/editor.less b/static/editor.less index 5a98e798b..255f79d65 100644 --- a/static/editor.less +++ b/static/editor.less @@ -39,6 +39,24 @@ .scroll-view-content { position: relative; } + + .gutter { + padding-left: 0.5em; + padding-right: 0.5em; + + .line-number { + position: absolute; + left: 0; + right: 0; + padding: 0; + white-space: nowrap; + + .icon-right { + padding: 0; + padding-left: .1em; + } + } + } } .editor { @@ -67,7 +85,6 @@ .editor .gutter .line-number { padding-left: .5em; opacity: 0.6; - position: relative; } .editor .gutter .line-numbers { From c730e3c67e45cf9d03a1b0721d7f1b5491daa5e4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 21 Apr 2014 12:54:59 -0600 Subject: [PATCH 170/179] Ensure selections span the entire screen, even when lines are short Also, pass scrollHeight and scrollWidth as props to child components instead of calling the method to compute them in multiple components. --- src/editor-component.coffee | 13 ++++++++----- src/editor-scroll-view-component.coffee | 8 +++++--- src/gutter-component.coffee | 4 ++-- static/editor.less | 1 + 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a01ca207b..8c66bb12f 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -36,13 +36,16 @@ EditorComponent = React.createClass className += ' is-focused' if focused div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, - GutterComponent({editor, renderedRowRange, scrollTop, lineHeight: lineHeightInPixels, @pendingChanges}) + GutterComponent { + editor, renderedRowRange, scrollTop, scrollHeight, + lineHeight: lineHeightInPixels, @pendingChanges + } EditorScrollViewComponent { - ref: 'scrollView', editor, renderedRowRange, @pendingChanges, - @scrollingVertically, showIndentGuide, fontSize, fontFamily, - lineHeight: lineHeightInPixels, @cursorsMoved, cursorBlinkPeriod, - cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred, + ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide + scrollHeight, scrollWidth, lineHeight: lineHeightInPixels, + renderedRowRange, @pendingChanges, @scrollingVertically, @cursorsMoved, + cursorBlinkPeriod, cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred } ScrollbarComponent diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 7a1648fd7..7404a36b5 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -16,12 +16,14 @@ EditorScrollViewComponent = React.createClass overflowChangedWhilePaused: false render: -> - {editor, fontSize, fontFamily, lineHeight, showIndentGuide, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props - {renderedRowRange, pendingChanges, scrollingVertically, cursorsMoved, onInputFocused, onInputBlurred} = @props + {editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props + {scrollHeight, scrollWidth, renderedRowRange, pendingChanges, scrollingVertically} = @props + {cursorBlinkPeriod, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props if @isMounted() contentStyle = - height: editor.getScrollHeight() + height: scrollHeight + minWidth: scrollWidth WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)" div className: 'scroll-view', diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 979376985..ffb5399ef 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -13,14 +13,14 @@ GutterComponent = React.createClass @renderLineNumbers() if @isMounted() renderLineNumbers: -> - {editor, renderedRowRange, scrollTop} = @props + {editor, renderedRowRange, scrollTop, scrollHeight} = @props [startRow, endRow] = renderedRowRange charWidth = editor.getDefaultCharWidth() lineHeight = editor.getLineHeight() maxDigits = editor.getLastBufferRow().toString().length style = width: charWidth * (maxDigits + 1.5) - height: editor.getScrollHeight() + height: scrollHeight WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)" lineNumbers = [] diff --git a/static/editor.less b/static/editor.less index 255f79d65..463ab4dba 100644 --- a/static/editor.less +++ b/static/editor.less @@ -38,6 +38,7 @@ .scroll-view-content { position: relative; + width: 100%; } .gutter { From 68d74e7de0b981fcfc85609b82a0eebf3983f16f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 21 Apr 2014 17:20:50 -0600 Subject: [PATCH 171/179] Put the hidden input component on its own layer This avoids combining its repaint with the scrollbar's cursor position when the cursor moves. --- src/editor-scroll-view-component.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index 7404a36b5..516798b1c 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -21,6 +21,9 @@ EditorScrollViewComponent = React.createClass {cursorBlinkPeriod, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props if @isMounted() + inputStyle = @getHiddenInputPosition() + inputStyle.WebkitTransform = 'translateZ(0)' + contentStyle = height: scrollHeight minWidth: scrollWidth @@ -30,7 +33,7 @@ EditorScrollViewComponent = React.createClass InputComponent ref: 'input' className: 'hidden-input' - style: @getHiddenInputPosition() + style: inputStyle onInput: @onInput onFocus: onInputFocused onBlur: onInputBlurred From b13385b281452a7ceb6d660203ddc10293407212 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 21 Apr 2014 17:35:26 -0600 Subject: [PATCH 172/179] Subscribe to focus events with DOM api to prevent bubbling behavior Using React's onFocus property, focus events seemed to bubble when editors inside the editor were focused, as is the case with the autocomplete menu. --- src/editor-component.coffee | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 8c66bb12f..e6088e752 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -35,7 +35,7 @@ EditorComponent = React.createClass className = 'editor editor-colors react' className += ' is-focused' if focused - div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, onFocus: @onFocus, + div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, GutterComponent { editor, renderedRowRange, scrollTop, scrollHeight, lineHeight: lineHeightInPixels, @pendingChanges @@ -118,7 +118,9 @@ EditorComponent = React.createClass @subscribe editor.$lineHeight.changes, @requestUpdate listenForDOMEvents: -> - @getDOMNode().addEventListener 'mousewheel', @onMouseWheel + node = @getDOMNode() + node.addEventListener 'mousewheel', @onMouseWheel + node.addEventListener 'focus', @onFocus # For some reason, React's built in focus events seem to bubble listenForCommands: -> {parentView, editor, mini} = @props From 1f768a21f0eeb24e38d72b94820440baae4c1f68 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 22 Apr 2014 11:10:48 -0600 Subject: [PATCH 173/179] Update absolute position of lines and line numbers when text changes When lines are inserted or removed, we need to manually shift the on-screen lines since everything is absolutely positioned now. --- spec/editor-component-spec.coffee | 33 +++++++++++++++++++++++++++++++ src/gutter-component.coffee | 2 +- src/lines-component.coffee | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index ab3708ea2..7ae9765eb 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -56,6 +56,21 @@ describe "EditorComponent", -> 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", -> + 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 + + 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 + describe "when indent guides are enabled", -> beforeEach -> component.setShowIndentGuide(true) @@ -137,6 +152,24 @@ describe "EditorComponent", -> expect(lineNumberNodes[0].textContent).toBe "#{nbsp}3" expect(lineNumberNodes[5].textContent).toBe "#{nbsp}8" + it "updates absolute positions 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 + + 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 + it "renders • characters for soft-wrapped lines", -> editor.setSoftWrap(true) node.style.height = 4.5 * lineHeightInPixels + 'px' diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index ffb5399ef..82c1f9be2 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -80,4 +80,4 @@ LineNumberComponent = React.createClass iconDivHTML: '
' shouldComponentUpdate: (newProps) -> - not isEqualForProperties(newProps, @props, 'lineHeight') + not isEqualForProperties(newProps, @props, 'lineHeight', 'screenRow') diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 3d0da5bef..844670f32 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -137,4 +137,4 @@ LineComponent = React.createClass "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" shouldComponentUpdate: (newProps) -> - not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight') + not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight', 'screenRow') From df8a6437a549fbbfde6d76b8f666a1f25f6f5ec9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 22 Apr 2014 11:39:34 -0600 Subject: [PATCH 174/179] Set appended view to 'position: absolute' in ::appendToLinesView --- src/react-editor-view.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index b57b5e927..8ff7c00e1 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -45,6 +45,7 @@ class ReactEditorView extends View @editor.pixelPositionForScreenPosition(screenPosition) appendToLinesView: (view) -> + view.css('position', 'absolute') @find('.scroll-view-content').prepend(view) beforeRemove: -> From 628c2f82bdd6f858fea7051f8b10d992403b8e25 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 22 Apr 2014 11:39:48 -0600 Subject: [PATCH 175/179] Add scrollTop/scrollLeft shims to ReactEditorView --- src/react-editor-view.coffee | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index 8ff7c00e1..b5a9154dd 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -16,6 +16,18 @@ class ReactEditorView extends View Object.defineProperty @::, 'lineHeight', get: -> @editor.getLineHeight() Object.defineProperty @::, 'charWidth', get: -> @editor.getDefaultCharWidth() + scrollTop: (scrollTop) -> + if scrollTop? + @editor.setScrollTop(scrollTop) + else + @editor.getScrollTop() + + scrollLeft: (scrollLeft) -> + if scrollLeft? + @editor.setScrollLeft(scrollLeft) + else + @editor.getScrollLeft() + afterAttach: (onDom) -> return unless onDom @attached = true From f53d489abb6a419a0389ac0f2c46a23785f7a550 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 22 Apr 2014 16:28:20 -0600 Subject: [PATCH 176/179] Add DisplayBuffer::scrollToScreen/BufferPosition Also add delegators in Editor and ReactEditorView --- spec/display-buffer-spec.coffee | 14 ++++++++++++++ src/cursor.coffee | 2 +- src/display-buffer.coffee | 8 +++++++- src/editor.coffee | 6 +++++- src/react-editor-view.coffee | 6 ++++++ src/selection.coffee | 2 +- 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index 8635020b4..9fc1cdce1 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -995,3 +995,17 @@ describe "DisplayBuffer", -> expect(displayBuffer.setScrollLeft(maxScrollLeft + 50)).toBe maxScrollLeft expect(displayBuffer.getScrollLeft()).toBe maxScrollLeft + + describe "::scrollToScreenPosition(position)", -> + it "sets the scroll top and scroll left so the given screen position is in view", -> + displayBuffer.manageScrollPosition = true + displayBuffer.setLineHeight(10) + displayBuffer.setDefaultCharWidth(10) + + displayBuffer.setHeight(50) + displayBuffer.setWidth(50) + maxScrollTop = displayBuffer.getScrollHeight() - displayBuffer.getHeight() + + displayBuffer.scrollToScreenPosition([8, 20]) + expect(displayBuffer.getScrollBottom()).toBe (9 + displayBuffer.getVerticalScrollMargin()) * 10 + expect(displayBuffer.getScrollRight()).toBe (20 + displayBuffer.getHorizontalScrollMargin()) * 10 diff --git a/src/cursor.coffee b/src/cursor.coffee index 1c7de215b..786cee2c4 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -96,7 +96,7 @@ class Cursor extends Model @marker.getHeadBufferPosition() autoscroll: -> - @editor.autoscrollToScreenRange(@getScreenRange()) + @editor.scrollToScreenRange(@getScreenRange()) # Public: If the marker range is empty, the cursor is marked as being visible. updateVisibility: -> diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index c2379ae87..f590920c6 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -197,7 +197,7 @@ class DisplayBuffer extends Model {start, end} = selection.getScreenRange() @intersectsVisibleRowRange(start.row, end.row + 1) - autoscrollToScreenRange: (screenRange) -> + scrollToScreenRange: (screenRange) -> verticalScrollMarginInPixels = @getVerticalScrollMargin() * @getLineHeight() horizontalScrollMarginInPixels = @getHorizontalScrollMargin() * @getDefaultCharWidth() @@ -219,6 +219,12 @@ class DisplayBuffer extends Model else if desiredScrollRight > @getScrollRight() @setScrollRight(desiredScrollRight) + scrollToScreenPosition: (screenPosition) -> + @scrollToScreenRange(new Range(screenPosition, screenPosition)) + + scrollToBufferPosition: (bufferPosition) -> + @scrollToScreenPosition(@screenPositionForBufferPosition(bufferPosition)) + pixelRectForScreenRange: (screenRange) -> if screenRange.end.row > screenRange.start.row top = @pixelPositionForScreenPosition(screenRange.start).top diff --git a/src/editor.coffee b/src/editor.coffee index 9af3f737d..9913a2250 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1863,7 +1863,11 @@ class Editor extends Model pixelRectForScreenRange: (args...) -> @displayBuffer.pixelRectForScreenRange(args...) - autoscrollToScreenRange: (args...) -> @displayBuffer.autoscrollToScreenRange(args...) + scrollToScreenRange: (args...) -> @displayBuffer.scrollToScreenRange(args...) + + scrollToScreenPosition: (screenPosition) -> @displayBuffer.scrollToScreenPosition(screenPosition) + + scrollToBufferPosition: (bufferPosition) -> @displayBuffer.scrollToBufferPosition(bufferPosition) # Deprecated: Call {::joinLines} instead. joinLine: -> diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee index b5a9154dd..9b91eb384 100644 --- a/src/react-editor-view.coffee +++ b/src/react-editor-view.coffee @@ -28,6 +28,12 @@ class ReactEditorView extends View else @editor.getScrollLeft() + scrollToScreenPosition: (screenPosition) -> + @editor.scrollToScreenPosition(screenPosition) + + scrollToBufferPosition: (bufferPosition) -> + @editor.scrollToBufferPosition(bufferPosition) + afterAttach: (onDom) -> return unless onDom @attached = true diff --git a/src/selection.coffee b/src/selection.coffee index a60374f9e..711423ab4 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -92,7 +92,7 @@ class Selection extends Model [start, end] autoscroll: -> - @editor.autoscrollToScreenRange(@getScreenRange()) + @editor.scrollToScreenRange(@getScreenRange()) # Public: Returns the text in the selection. getText: -> From e4639281f8008e1bf5bc60290613798befc35749 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 22 Apr 2014 16:43:32 -0600 Subject: [PATCH 177/179] Handle 'editor:scroll-to-cursor' command Add Editor::scrollToCursorPosition in the model layer --- spec/editor-spec.coffee | 14 ++++++++++++++ src/editor-component.coffee | 2 +- src/editor.coffee | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index dffdd3374..ecbc30948 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -3028,3 +3028,17 @@ describe "Editor", -> editor.setSoftTabs(false) editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) expect(editor.getText()).toBe ' ' + + describe ".scrollToCursorPosition()", -> + it "scrolls the last cursor into view", -> + editor.setCursorScreenPosition([8, 8]) + editor.setLineHeight(10) + editor.setDefaultCharWidth(10) + editor.setHeight(50) + editor.setWidth(50) + expect(editor.getScrollTop()).toBe 0 + expect(editor.getScrollLeft()).toBe 0 + + editor.scrollToCursorPosition() + expect(editor.getScrollBottom()).toBe (9 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe (9 + editor.getHorizontalScrollMargin()) * 10 diff --git a/src/editor-component.coffee b/src/editor-component.coffee index e6088e752..0e62d3a84 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -215,9 +215,9 @@ EditorComponent = React.createClass 'editor:join-lines': => editor.joinLines() 'editor:toggle-indent-guide': => atom.config.toggle('editor.showIndentGuide') 'editor:toggle-line-numbers': => atom.config.toggle('editor.showLineNumbers') + 'editor:scroll-to-cursor': => editor.scrollToCursorPosition() # 'core:page-down': => @pageDown() # 'core:page-up': => @pageUp() - # 'editor:scroll-to-cursor': => @scrollToCursorPosition() addCommandListeners: (listenersByCommandName) -> {parentView} = @props diff --git a/src/editor.coffee b/src/editor.coffee index 9913a2250..95813163d 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1453,6 +1453,9 @@ class Editor extends Model moveCursorToNextWordBoundary: -> @moveCursors (cursor) -> cursor.moveToNextWordBoundary() + scrollToCursorPosition: -> + @getCursor().autoscroll() + moveCursors: (fn) -> @movingCursors = true @batchUpdates => From 752aa9a8e9a4985d8b383edc0e8de17860e65a41 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 22 Apr 2014 16:56:52 -0600 Subject: [PATCH 178/179] Handle editor:page-up/down commands --- spec/editor-spec.coffee | 20 ++++++++++++++++++++ src/editor-component.coffee | 4 ++-- src/editor.coffee | 6 ++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index ecbc30948..1d468f806 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -3042,3 +3042,23 @@ describe "Editor", -> editor.scrollToCursorPosition() expect(editor.getScrollBottom()).toBe (9 + editor.getVerticalScrollMargin()) * 10 expect(editor.getScrollRight()).toBe (9 + editor.getHorizontalScrollMargin()) * 10 + + describe ".pageUp/Down()", -> + it "scrolls one screen height up or down", -> + editor.manageScrollPosition = true + + editor.setLineHeight(10) + editor.setHeight(50) + expect(editor.getScrollHeight()).toBe 130 + + editor.pageDown() + expect(editor.getScrollTop()).toBe 50 + + editor.pageDown() + expect(editor.getScrollTop()).toBe 80 + + editor.pageUp() + expect(editor.getScrollTop()).toBe 30 + + editor.pageUp() + expect(editor.getScrollTop()).toBe 0 diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 0e62d3a84..c3bde3d71 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -216,8 +216,8 @@ EditorComponent = React.createClass 'editor:toggle-indent-guide': => atom.config.toggle('editor.showIndentGuide') 'editor:toggle-line-numbers': => atom.config.toggle('editor.showLineNumbers') 'editor:scroll-to-cursor': => editor.scrollToCursorPosition() - # 'core:page-down': => @pageDown() - # 'core:page-up': => @pageUp() + 'core:page-up': => editor.pageUp() + 'core:page-down': => editor.pageDown() addCommandListeners: (listenersByCommandName) -> {parentView} = @props diff --git a/src/editor.coffee b/src/editor.coffee index 95813163d..8783e68f7 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1456,6 +1456,12 @@ class Editor extends Model scrollToCursorPosition: -> @getCursor().autoscroll() + pageUp: -> + @setScrollTop(@getScrollTop() - @getHeight()) + + pageDown: -> + @setScrollTop(@getScrollTop() + @getHeight()) + moveCursors: (fn) -> @movingCursors = true @batchUpdates => From 7fe0f5b4451e51c76166b76540029cf0126bb283 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 23 Apr 2014 09:56:49 -0600 Subject: [PATCH 179/179] Make parameter names explicit in delegators --- src/editor.coffee | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/editor.coffee b/src/editor.coffee index 8783e68f7..50cb3e2c6 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -1819,60 +1819,60 @@ class Editor extends Model type: 'selection', editorId: @id, invalidate: 'never' getVerticalScrollMargin: -> @displayBuffer.getVerticalScrollMargin() - setVerticalScrollMargin: (args...) -> @displayBuffer.setVerticalScrollMargin(args...) + setVerticalScrollMargin: (verticalScrollMargin) -> @displayBuffer.setVerticalScrollMargin(verticalScrollMargin) getHorizontalScrollMargin: -> @displayBuffer.getHorizontalScrollMargin() - setHorizontalScrollMargin: (args...) -> @displayBuffer.setHorizontalScrollMargin(args...) + setHorizontalScrollMargin: (horizontalScrollMargin) -> @displayBuffer.setHorizontalScrollMargin(horizontalScrollMargin) getLineHeight: -> @displayBuffer.getLineHeight() setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight) - getScopedCharWidth: (args...) -> @displayBuffer.getScopedCharWidth(args...) - setScopedCharWidth: (args...) -> @displayBuffer.setScopedCharWidth(args...) + getScopedCharWidth: (scopeNames, char) -> @displayBuffer.getScopedCharWidth(scopeNames, char) + setScopedCharWidth: (scopeNames, char, width) -> @displayBuffer.setScopedCharWidth(scopeNames, char, width) - getScopedCharWidths: (args...) -> @displayBuffer.getScopedCharWidths(args...) + getScopedCharWidths: (scopeNames) -> @displayBuffer.getScopedCharWidths(scopeNames) clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths() getDefaultCharWidth: -> @displayBuffer.getDefaultCharWidth() - setDefaultCharWidth: (args...) -> @displayBuffer.setDefaultCharWidth(args...) + setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth) - setHeight: (args...) -> @displayBuffer.setHeight(args...) + setHeight: (height) -> @displayBuffer.setHeight(height) getHeight: -> @displayBuffer.getHeight() - setWidth: (args...) -> @displayBuffer.setWidth(args...) + setWidth: (width) -> @displayBuffer.setWidth(width) getWidth: -> @displayBuffer.getWidth() getScrollTop: -> @displayBuffer.getScrollTop() - setScrollTop: (args...) -> @displayBuffer.setScrollTop(args...) + setScrollTop: (scrollTop) -> @displayBuffer.setScrollTop(scrollTop) getScrollBottom: -> @displayBuffer.getScrollBottom() - setScrollBottom: (args...) -> @displayBuffer.setScrollBottom(args...) + setScrollBottom: (scrollBottom) -> @displayBuffer.setScrollBottom(scrollBottom) getScrollLeft: -> @displayBuffer.getScrollLeft() - setScrollLeft: (args...) -> @displayBuffer.setScrollLeft(args...) + setScrollLeft: (scrollLeft) -> @displayBuffer.setScrollLeft(scrollLeft) getScrollRight: -> @displayBuffer.getScrollRight() - setScrollRight: (args...) -> @displayBuffer.setScrollRight(args...) + setScrollRight: (scrollRight) -> @displayBuffer.setScrollRight(scrollRight) getScrollHeight: -> @displayBuffer.getScrollHeight() - getScrollWidth: (args...) -> @displayBuffer.getScrollWidth(args...) + getScrollWidth: (scrollWidth) -> @displayBuffer.getScrollWidth(scrollWidth) getVisibleRowRange: -> @displayBuffer.getVisibleRowRange() - intersectsVisibleRowRange: (args...) -> @displayBuffer.intersectsVisibleRowRange(args...) + intersectsVisibleRowRange: (startRow, endRow) -> @displayBuffer.intersectsVisibleRowRange(startRow, endRow) - selectionIntersectsVisibleRowRange: (args...) -> @displayBuffer.selectionIntersectsVisibleRowRange(args...) + selectionIntersectsVisibleRowRange: (selection) -> @displayBuffer.selectionIntersectsVisibleRowRange(selection) - pixelPositionForScreenPosition: (args...) -> @displayBuffer.pixelPositionForScreenPosition(args...) + pixelPositionForScreenPosition: (screenPosition) -> @displayBuffer.pixelPositionForScreenPosition(screenPosition) - pixelPositionForBufferPosition: (args...) -> @displayBuffer.pixelPositionForBufferPosition(args...) + pixelPositionForBufferPosition: (bufferPosition) -> @displayBuffer.pixelPositionForBufferPosition(bufferPosition) - screenPositionForPixelPosition: (args...) -> @displayBuffer.screenPositionForPixelPosition(args...) + screenPositionForPixelPosition: (pixelPosition) -> @displayBuffer.screenPositionForPixelPosition(pixelPosition) - pixelRectForScreenRange: (args...) -> @displayBuffer.pixelRectForScreenRange(args...) + pixelRectForScreenRange: (screenRange) -> @displayBuffer.pixelRectForScreenRange(screenRange) - scrollToScreenRange: (args...) -> @displayBuffer.scrollToScreenRange(args...) + scrollToScreenRange: (screenRange) -> @displayBuffer.scrollToScreenRange(screenRange) scrollToScreenPosition: (screenPosition) -> @displayBuffer.scrollToScreenPosition(screenPosition)