Merge pull request #1883 from atom/ns-react-editor-view

React Editor View, Take 2
This commit is contained in:
Nathan Sobo 2014-04-23 10:06:00 -06:00
commit 104aa5efc7
36 changed files with 2547 additions and 95 deletions

View File

@ -40,6 +40,8 @@
"property-accessors": "1.x", "property-accessors": "1.x",
"q": "^1.0.1", "q": "^1.0.1",
"random-words": "0.0.1", "random-words": "0.0.1",
"react": "^0.10.0",
"reactionary": "^0.8.0",
"runas": "0.5.x", "runas": "0.5.x",
"scandal": "0.15.2", "scandal": "0.15.2",
"scoped-property-store": "^0.8.0", "scoped-property-store": "^0.8.0",
@ -51,7 +53,7 @@
"temp": "0.5.0", "temp": "0.5.0",
"text-buffer": "^2.1.0", "text-buffer": "^2.1.0",
"theorist": "1.x", "theorist": "1.x",
"underscore-plus": "^1.1.2", "underscore-plus": "^1.2.1",
"vm-compatibility-layer": "0.1.0" "vm-compatibility-layer": "0.1.0"
}, },
"packageDependencies": { "packageDependencies": {

View File

@ -943,3 +943,69 @@ describe "DisplayBuffer", ->
expect(displayBuffer.getMarkerCount()).toBe initialMarkerCount + 2 expect(displayBuffer.getMarkerCount()).toBe initialMarkerCount + 2
expect(marker1.getAttributes()).toEqual a: 1, b: 2 expect(marker1.getAttributes()).toEqual a: 1, b: 2
expect(marker2.getAttributes()).toEqual a: 1, b: 3 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, n: 11)
{start, end} = marker.getPixelRange()
expect(start.top).toBe 5 * 20
expect(start.left).toBe (4 * 10) + (6 * 11)
describe "::setScrollTop", ->
beforeEach ->
displayBuffer.manageScrollPosition = true
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.manageScrollPosition = true
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
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

View File

@ -0,0 +1,580 @@
{extend, flatten, toArray} = require 'underscore-plus'
ReactEditorView = require '../src/react-editor-view'
nbsp = String.fromCharCode(160)
describe "EditorComponent", ->
[editor, wrapperView, component, node, verticalScrollbarNode, horizontalScrollbarNode] = []
[lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = []
beforeEach ->
waitsForPromise ->
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) ->
if delayAnimationFrames
nextAnimationFrame = fn
else
fn()
editor = atom.project.openSync('sample.js')
wrapperView = new ReactEditorView(editor)
wrapperView.attachToDom()
{component} = wrapperView
component.setLineHeight(1.3)
component.setFontSize(20)
lineHeightInPixels = editor.getLineHeight()
charWidth = editor.getDefaultCharWidth()
node = component.getDOMNode()
verticalScrollbarNode = node.querySelector('.vertical-scrollbar')
horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar')
describe "line rendering", ->
it "renders only the currently-visible lines", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
lines = node.querySelectorAll('.line')
expect(lines.length).toBe 6
expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text
expect(lines[5].textContent).toBe editor.lineForScreenRow(5).text
verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels
verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
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
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)
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 ' '
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
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
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
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))
else
[node]
describe "gutter rendering", ->
it "renders the currently-visible line numbers", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
lines = node.querySelectorAll('.line-number')
expect(lines.length).toBe 6
expect(lines[0].textContent).toBe "#{nbsp}1"
expect(lines[5].textContent).toBe "#{nbsp}6"
verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels
verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translate3d(0, #{-2.5 * lineHeightInPixels}px, 0)"
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"
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'
node.style.width = 30 * charWidth + 'px'
component.measureHeightAndWidth()
lines = node.querySelectorAll('.line-number')
expect(lines.length).toBe 6
expect(lines[0].textContent).toBe "#{nbsp}1"
expect(lines[1].textContent).toBe "#{nbsp}"
expect(lines[2].textContent).toBe "#{nbsp}2"
expect(lines[3].textContent).toBe "#{nbsp}"
expect(lines[4].textContent).toBe "#{nbsp}3"
expect(lines[5].textContent).toBe "#{nbsp}"
describe "cursor rendering", ->
it "renders the currently visible cursors", ->
cursor1 = editor.getCursor()
cursor1.setScreenPosition([0, 5])
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 1
expect(cursorNodes[0].offsetHeight).toBe lineHeightInPixels
expect(cursorNodes[0].offsetWidth).toBe charWidth
expect(cursorNodes[0].offsetTop).toBe 0
expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth
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
verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels
verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 2
expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels
expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth
expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels
expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth
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
it "blinks cursors when they aren't moving", ->
editor.addCursorAtScreenPosition([1, 0])
[cursorNode1, cursorNode2] = node.querySelectorAll('.cursor')
expect(cursorNode1.classList.contains('blink-off')).toBe false
expect(cursorNode2.classList.contains('blink-off')).toBe false
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
# 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
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.measureHeightAndWidth()
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()
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
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]])
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 scrollViewClientLeft + 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 scrollViewClientLeft + 6 * charWidth
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 1 * lineHeightInPixels
expect(region2Rect.left).toBe scrollViewClientLeft + 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 scrollViewClientLeft + 6 * charWidth
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(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
expect(region3Rect.height).toBe 1 * lineHeightInPixels
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
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", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
node.style.width = 10 * charWidth + 'px'
component.measureHeightAndWidth()
editor.setScrollTop(3.5 * lineHeightInPixels)
editor.setScrollLeft(2 * charWidth)
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])
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])
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))
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 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, 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, 5], [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))
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)
scrollViewClientRect = node.querySelector('.scroll-view').getBoundingClientRect()
clientX = scrollViewClientRect.left + positionOffset.left - editor.getScrollLeft()
clientY = scrollViewClientRect.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
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
describe "scrolling", ->
it "updates the vertical scrollbar when the scrollTop is changed in the model", ->
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.measureHeightAndWidth()
expect(verticalScrollbarNode.scrollTop).toBe 0
editor.setScrollTop(10)
expect(verticalScrollbarNode.scrollTop).toBe 10
it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", ->
node.style.width = 30 * charWidth + 'px'
component.measureHeightAndWidth()
scrollViewContentNode = node.querySelector('.scroll-view-content')
expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0)"
expect(horizontalScrollbarNode.scrollLeft).toBe 0
editor.setScrollLeft(100)
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", ->
node.style.width = 30 * charWidth + 'px'
component.measureHeightAndWidth()
expect(editor.getScrollLeft()).toBe 0
horizontalScrollbarNode.scrollLeft = 100
horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
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.measureHeightAndWidth()
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
describe "input events", ->
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 () {'
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, as occurs with the accented character menu", ->
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 () {'
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()

View File

@ -111,6 +111,21 @@ describe "Editor", ->
editor.moveCursorDown() editor.moveCursorDown()
expect(editor.getCursorBufferPosition()).toEqual [1, 1] 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)", -> describe ".setCursorScreenPosition(screenPosition)", ->
it "clears a goal column established by vertical movement", -> it "clears a goal column established by vertical movement", ->
# set a goal column by moving down # set a goal column by moving down
@ -645,6 +660,67 @@ describe "Editor", ->
cursor2 = editor.addCursorAtBufferPosition([1,4]) cursor2 = editor.addCursorAtBufferPosition([1,4])
expect(cursor2.marker).toBe cursor1.marker expect(cursor2.marker).toBe cursor1.marker
describe "autoscroll", ->
beforeEach ->
editor.manageScrollPosition = true
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
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
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", -> describe "selection", ->
selection = null selection = null
@ -1000,7 +1076,7 @@ describe "Editor", ->
expect(selection1).toBe selection expect(selection1).toBe selection
expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] 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", -> it "removes folds that contain the selections", ->
editor.setSelectedBufferRange([[0,0], [0,0]]) editor.setSelectedBufferRange([[0,0], [0,0]])
editor.createFold(1, 4) editor.createFold(1, 4)
@ -1014,7 +1090,7 @@ describe "Editor", ->
expect(editor.lineForScreenRow(6).fold).toBeUndefined() expect(editor.lineForScreenRow(6).fold).toBeUndefined()
expect(editor.lineForScreenRow(10).fold).toBeDefined() 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", -> it "does not remove folds that contain the selections", ->
editor.setSelectedBufferRange([[0,0], [0,0]]) editor.setSelectedBufferRange([[0,0], [0,0]])
editor.createFold(1, 4) editor.createFold(1, 4)
@ -1023,6 +1099,24 @@ describe "Editor", ->
expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() expect(editor.isFoldedAtBufferRow(1)).toBeTruthy()
expect(editor.isFoldedAtBufferRow(6)).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 ".selectMarker(marker)", ->
describe "if the marker is valid", -> describe "if the marker is valid", ->
it "selects the marker's range and returns the selected range", -> it "selects the marker's range and returns the selected range", ->
@ -2934,3 +3028,37 @@ describe "Editor", ->
editor.setSoftTabs(false) editor.setSoftTabs(false)
editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]])
expect(editor.getText()).toBe ' ' 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
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

View File

@ -1596,7 +1596,7 @@ describe "EditorView", ->
editor.setSoftWrap(true) editor.setSoftWrap(true)
it "doesn't show the end of line invisible at the end of lines broken due to wrapping", -> 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.attachToDom()
editorView.setWidthInChars(6) editorView.setWidthInChars(6)
atom.config.set "editor.showInvisibles", true atom.config.set "editor.showInvisibles", true
@ -1604,11 +1604,11 @@ describe "EditorView", ->
expect(space).toBeTruthy() expect(space).toBeTruthy()
eol = editorView.invisibles?.eol eol = editorView.invisibles?.eol
expect(eol).toBeTruthy() expect(eol).toBeTruthy()
expect(editorView.renderedLines.find('.line:first').text()).toBe "a line#{space}" expect(editorView.renderedLines.find('.line:first').text()).toBe "a line "
expect(editorView.renderedLines.find('.line:last').text()).toBe "wraps#{eol}" expect(editorView.renderedLines.find('.line:last').text()).toBe "wraps#{space}#{eol}"
it "displays trailing carriage return using a visible non-empty value", -> 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.attachToDom()
editorView.setWidthInChars(6) editorView.setWidthInChars(6)
atom.config.set "editor.showInvisibles", true atom.config.set "editor.showInvisibles", true
@ -1618,8 +1618,8 @@ describe "EditorView", ->
expect(cr).toBeTruthy() expect(cr).toBeTruthy()
eol = editorView.invisibles?.eol eol = editorView.invisibles?.eol
expect(eol).toBeTruthy() expect(eol).toBeTruthy()
expect(editorView.renderedLines.find('.line:first').text()).toBe "a line#{space}" expect(editorView.renderedLines.find('.line:first').text()).toBe "a line "
expect(editorView.renderedLines.find('.line:eq(1)').text()).toBe "that#{cr}#{eol}" expect(editorView.renderedLines.find('.line:eq(1)').text()).toBe "that#{space}#{cr}#{eol}"
expect(editorView.renderedLines.find('.line:last').text()).toBe "#{eol}" expect(editorView.renderedLines.find('.line:last').text()).toBe "#{eol}"
describe "when editor.showIndentGuide is set to true", -> describe "when editor.showIndentGuide is set to true", ->

View File

@ -84,6 +84,7 @@ beforeEach ->
config.set "editor.autoIndent", false config.set "editor.autoIndent", false
config.set "core.disabledPackages", ["package-that-throws-an-exception", config.set "core.disabledPackages", ["package-that-throws-an-exception",
"package-with-broken-package-json", "package-with-broken-keymap"] "package-with-broken-package-json", "package-with-broken-keymap"]
config.set "core.useReactEditor", false
config.save.reset() config.save.reset()
atom.config = config atom.config = config
@ -243,6 +244,15 @@ window.fakeSetTimeout = (callback, ms) ->
window.fakeClearTimeout = (idToClear) -> window.fakeClearTimeout = (idToClear) ->
window.timeouts = window.timeouts.filter ([id]) -> id != 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.advanceClock = (delta=1) ->
window.now += delta window.now += delta
callbacks = [] callbacks = []

View File

@ -463,3 +463,77 @@ describe "TokenizedBuffer", ->
expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' '
atom.config.set('editor.tabLength', 0) atom.config.set('editor.tabLength', 0)
expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' 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
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
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

View File

@ -0,0 +1,22 @@
describe "TokenizedLine", ->
editor = null
beforeEach ->
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()
ensureValidScopeTree(scopeTree)

View File

@ -157,6 +157,9 @@ class Atom extends Model
# Still set NODE_PATH since tasks may need it. # Still set NODE_PATH since tasks may need it.
process.env.NODE_PATH = exportsPath process.env.NODE_PATH = exportsPath
# Make react.js faster
process.env.NODE_ENV ?= 'production'
@config = new Config({configDirPath, resourcePath}) @config = new Config({configDirPath, resourcePath})
@keymaps = new KeymapManager({configDirPath, resourcePath}) @keymaps = new KeymapManager({configDirPath, resourcePath})
@keymap = @keymaps # Deprecated @keymap = @keymaps # Deprecated

View File

@ -0,0 +1,13 @@
React = require 'react'
{div} = require 'reactionary'
module.exports =
CursorComponent = React.createClass
displayName: 'CursorComponent'
render: ->
{top, left, height, width} = @props.cursor.getPixelRect()
className = 'cursor'
className += ' blink-off' if @props.blinkOff
div className: className, style: {top, left, height, width}

View File

@ -1,5 +1,5 @@
{Point, Range} = require 'text-buffer' {Point, Range} = require 'text-buffer'
{Emitter} = require 'emissary' {Model} = require 'theorist'
_ = require 'underscore-plus' _ = require 'underscore-plus'
# Public: The `Cursor` class represents the little blinking line identifying # 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 # Cursors belong to {Editor}s and have some metadata attached in the form
# of a {Marker}. # of a {Marker}.
module.exports = module.exports =
class Cursor class Cursor extends Model
Emitter.includeInto(this)
screenPosition: null screenPosition: null
bufferPosition: null bufferPosition: null
goalColumn: null goalColumn: null
@ -18,7 +16,8 @@ class Cursor
needsAutoscroll: null needsAutoscroll: null
# Instantiated by an {Editor} # Instantiated by an {Editor}
constructor: ({@editor, @marker}) -> constructor: ({@editor, @marker, id}) ->
@assignId(id)
@updateVisibility() @updateVisibility()
@marker.on 'changed', (e) => @marker.on 'changed', (e) =>
@updateVisibility() @updateVisibility()
@ -27,7 +26,12 @@ class Cursor
{textChanged} = e {textChanged} = e
return if oldHeadScreenPosition.isEqual(newHeadScreenPosition) return if oldHeadScreenPosition.isEqual(newHeadScreenPosition)
# Supports old editor view
@needsAutoscroll ?= @isLastCursor() and !textChanged @needsAutoscroll ?= @isLastCursor() and !textChanged
# Supports react editor view
@autoscroll() if @needsAutoscroll and @editor.manageScrollPosition
@goalColumn = null @goalColumn = null
movedEvent = movedEvent =
@ -38,7 +42,7 @@ class Cursor
textChanged: textChanged textChanged: textChanged
@emit 'moved', movedEvent @emit 'moved', movedEvent
@editor.emit 'cursor-moved', movedEvent @editor.cursorMoved(movedEvent)
@marker.on 'destroyed', => @marker.on 'destroyed', =>
@destroyed = true @destroyed = true
@editor.removeCursor(this) @editor.removeCursor(this)
@ -54,6 +58,9 @@ class Cursor
unless fn() unless fn()
@emit 'autoscrolled' if @needsAutoscroll @emit 'autoscrolled' if @needsAutoscroll
getPixelRect: ->
@editor.pixelRectForScreenRange(@getScreenRange())
# Public: Moves a cursor to a given screen position. # Public: Moves a cursor to a given screen position.
# #
# screenPosition - An {Array} of two numbers: the screen row, and the screen # screenPosition - An {Array} of two numbers: the screen row, and the screen
@ -69,6 +76,10 @@ class Cursor
getScreenPosition: -> getScreenPosition: ->
@marker.getHeadScreenPosition() @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. # Public: Moves a cursor to a given buffer position.
# #
# bufferPosition - An {Array} of two numbers: the buffer row, and the buffer # bufferPosition - An {Array} of two numbers: the buffer row, and the buffer
@ -84,6 +95,9 @@ class Cursor
getBufferPosition: -> getBufferPosition: ->
@marker.getHeadBufferPosition() @marker.getHeadBufferPosition()
autoscroll: ->
@editor.scrollToScreenRange(@getScreenRange())
# Public: If the marker range is empty, the cursor is marked as being visible. # Public: If the marker range is empty, the cursor is marked as being visible.
updateVisibility: -> updateVisibility: ->
@setVisible(@marker.getBufferRange().isEmpty()) @setVisible(@marker.getBufferRange().isEmpty())

View File

@ -0,0 +1,50 @@
React = require 'react'
{div} = require 'reactionary'
{debounce} = require 'underscore-plus'
SubscriberMixin = require './subscriber-mixin'
CursorComponent = require './cursor-component'
module.exports =
CursorsComponent = React.createClass
displayName: 'CursorsComponent'
mixins: [SubscriberMixin]
cursorBlinkIntervalHandle: null
render: ->
{editor} = @props
blinkOff = @state.blinkCursorsOff
div className: 'cursors',
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
componentDidMount: ->
{editor} = @props
@startBlinkingCursors()
componentWillUnmount: ->
clearInterval(@cursorBlinkIntervalHandle)
componentWillUpdate: ({cursorsMoved}) ->
@pauseCursorBlinking() if cursorsMoved
startBlinkingCursors: ->
@cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2)
startBlinkingCursorsAfterDelay: null # Created lazily
toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff)
pauseCursorBlinking: ->
@state.blinkCursorsOff = false
clearInterval(@cursorBlinkIntervalHandle)
@startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay)
@startBlinkingCursorsAfterDelay()

View File

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

View File

@ -54,6 +54,9 @@ class DisplayBufferMarker
setBufferRange: (bufferRange, options) -> setBufferRange: (bufferRange, options) ->
@bufferMarker.setRange(bufferRange, options) @bufferMarker.setRange(bufferRange, options)
getPixelRange: ->
@displayBuffer.pixelRangeForScreenRange(@getScreenRange(), false)
# Retrieves the screen position of the marker's head. # Retrieves the screen position of the marker's head.
# #
# Returns a {Point}. # Returns a {Point}.

View File

@ -20,14 +20,25 @@ class DisplayBuffer extends Model
Serializable.includeInto(this) Serializable.includeInto(this)
@properties @properties
manageScrollPosition: false
softWrap: null softWrap: null
editorWidthInChars: null editorWidthInChars: null
lineHeight: null
defaultCharWidth: null
height: null
width: null
scrollTop: 0
scrollLeft: 0
verticalScrollMargin: 2
horizontalScrollMargin: 6
constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer}={}) -> constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer}={}) ->
super super
@softWrap ?= atom.config.get('editor.softWrap') ? false @softWrap ?= atom.config.get('editor.softWrap') ? false
@tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer}) @tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer})
@buffer = @tokenizedBuffer.buffer @buffer = @tokenizedBuffer.buffer
@charWidthsByScope = {}
@markers = {} @markers = {}
@foldsByMarkerId = {} @foldsByMarkerId = {}
@updateAllScreenLines() @updateAllScreenLines()
@ -51,6 +62,8 @@ class DisplayBuffer extends Model
id: @id id: @id
softWrap: @softWrap softWrap: @softWrap
editorWidthInChars: @editorWidthInChars editorWidthInChars: @editorWidthInChars
scrollTop: @scrollTop
scrollLeft: @scrollLeft
tokenizedBuffer: @tokenizedBuffer.serialize() tokenizedBuffer: @tokenizedBuffer.serialize()
deserializeParams: (params) -> deserializeParams: (params) ->
@ -59,6 +72,9 @@ class DisplayBuffer extends Model
copy: -> copy: ->
newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength()}) newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength()})
newDisplayBuffer.setScrollTop(@getScrollTop())
newDisplayBuffer.setScrollLeft(@getScrollLeft())
for marker in @findMarkers(displayBufferId: @id) for marker in @findMarkers(displayBufferId: @id)
marker.copy(displayBufferId: newDisplayBuffer.id) marker.copy(displayBufferId: newDisplayBuffer.id)
newDisplayBuffer newDisplayBuffer
@ -89,6 +105,151 @@ class DisplayBuffer extends Model
# visible - A {Boolean} indicating of the tokenized buffer is shown # visible - A {Boolean} indicating of the tokenized buffer is shown
setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) setVisible: (visible) -> @tokenizedBuffer.setVisible(visible)
getVerticalScrollMargin: -> @verticalScrollMargin
setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin
getHorizontalScrollMargin: -> @horizontalScrollMargin
setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin
getHeight: -> @height ? @getScrollHeight()
setHeight: (@height) -> @height
getWidth: -> @width ? @getScrollWidth()
setWidth: (newWidth) ->
oldWidth = @width
@width = newWidth
@updateWrappedScreenLines() if newWidth isnt oldWidth and @softWrap
@width
getScrollTop: -> @scrollTop
setScrollTop: (scrollTop) ->
if @manageScrollPosition
@scrollTop = Math.max(0, Math.min(@getScrollHeight() - @getHeight(), scrollTop))
else
@scrollTop = scrollTop
getScrollBottom: -> @scrollTop + @height
setScrollBottom: (scrollBottom) ->
@setScrollTop(scrollBottom - @height)
@getScrollBottom()
getScrollLeft: -> @scrollLeft
setScrollLeft: (scrollLeft) ->
if @manageScrollPosition
@scrollLeft = Math.max(0, Math.min(@getScrollWidth() - @getWidth(), scrollLeft))
else
@scrollLeft = scrollLeft
getScrollRight: -> @scrollLeft + @width
setScrollRight: (scrollRight) ->
@setScrollLeft(scrollRight - @width)
@getScrollRight()
getLineHeight: -> @lineHeight
setLineHeight: (@lineHeight) -> @lineHeight
getDefaultCharWidth: -> @defaultCharWidth
setDefaultCharWidth: (@defaultCharWidth) -> @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: ->
unless @getLineHeight() > 0
throw new Error("You must assign lineHeight before calling ::getScrollHeight()")
@getLineCount() * @getLineHeight()
getScrollWidth: ->
@getMaxLineLength() * @getDefaultCharWidth()
getVisibleRowRange: ->
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.min(@getLineCount(), 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)
scrollToScreenRange: (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)
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
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}.
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 # Deprecated: Use the softWrap property directly
setSoftWrap: (@softWrap) -> @softWrap setSoftWrap: (@softWrap) -> @softWrap
@ -105,12 +266,19 @@ class DisplayBuffer extends Model
if editorWidthInChars isnt previousWidthInChars and @softWrap if editorWidthInChars isnt previousWidthInChars and @softWrap
@updateWrappedScreenLines() @updateWrappedScreenLines()
getSoftWrapColumn: -> getEditorWidthInChars: ->
if atom.config.get('editor.softWrapAtPreferredLineLength') width = @getWidth()
Math.min(@editorWidthInChars, atom.config.getPositiveInt('editor.preferredLineLength', @editorWidthInChars)) if width? and @defaultCharWidth > 0
Math.floor(width / @defaultCharWidth)
else else
@editorWidthInChars @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. # Gets the screen line for the given screen row.
# #
# screenRow - A {Number} indicating the screen row. # screenRow - A {Number} indicating the screen row.
@ -134,6 +302,9 @@ class DisplayBuffer extends Model
getLines: -> getLines: ->
new Array(@screenLines...) new Array(@screenLines...)
indentLevelForLine: (line) ->
@tokenizedBuffer.indentLevelForLine(line)
# Given starting and ending screen rows, this returns an array of the # Given starting and ending screen rows, this returns an array of the
# buffer rows corresponding to every screen row in the range # buffer rows corresponding to every screen row in the range
# #
@ -273,6 +444,52 @@ class DisplayBuffer extends Model
end = @bufferPositionForScreenPosition(screenRange.end) end = @bufferPositionForScreenPosition(screenRange.end)
new Range(start, 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}
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)
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)
pixelPositionForBufferPosition: (bufferPosition) ->
@pixelPositionForScreenPosition(@screenPositionForBufferPosition(bufferPosition))
# Gets the number of screen lines. # Gets the number of screen lines.
# #
# Returns a {Number}. # Returns a {Number}.
@ -358,18 +575,6 @@ class DisplayBuffer extends Model
tokenForBufferPosition: (bufferPosition) -> tokenForBufferPosition: (bufferPosition) ->
@tokenizedBuffer.tokenForPosition(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)
# Get the grammar for this buffer. # Get the grammar for this buffer.
# #
# Returns the current {Grammar} or the {NullGrammar}. # Returns the current {Grammar} or the {NullGrammar}.

338
src/editor-component.coffee Normal file
View File

@ -0,0 +1,338 @@
React = require 'react'
{div, span} = require 'reactionary'
{debounce} = require 'underscore-plus'
GutterComponent = require './gutter-component'
EditorScrollViewComponent = require './editor-scroll-view-component'
ScrollbarComponent = require './scrollbar-component'
SubscriberMixin = require './subscriber-mixin'
module.exports =
EditorComponent = React.createClass
displayName: 'EditorComponent'
mixins: [SubscriberMixin]
pendingScrollTop: null
pendingScrollLeft: null
selectOnMouseMove: false
batchingUpdates: false
updateRequested: false
cursorsMoved: false
preservedRowRange: null
scrollingVertically: false
render: ->
{focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state
{editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props
if @isMounted()
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,
GutterComponent {
editor, renderedRowRange, scrollTop, scrollHeight,
lineHeight: lineHeightInPixels, @pendingChanges
}
EditorScrollViewComponent {
ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide
scrollHeight, scrollWidth, lineHeight: lineHeightInPixels,
renderedRowRange, @pendingChanges, @scrollingVertically, @cursorsMoved,
cursorBlinkPeriod, cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred
}
ScrollbarComponent
ref: 'verticalScrollbar'
className: 'vertical-scrollbar'
orientation: 'vertical'
onScroll: @onVerticalScroll
scrollTop: scrollTop
scrollHeight: scrollHeight
ScrollbarComponent
ref: 'horizontalScrollbar'
className: 'horizontal-scrollbar'
orientation: 'horizontal'
onScroll: @onHorizontalScroll
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: ->
cursorBlinkPeriod: 800
cursorBlinkResumeDelay: 200
componentWillMount: ->
@pendingChanges = []
@props.editor.manageScrollPosition = true
@observeConfig()
componentDidMount: ->
@observeEditor()
@listenForDOMEvents()
@listenForCommands()
@props.editor.setVisible(true)
@requestUpdate()
componentWillUnmount: ->
@unsubscribe()
@getDOMNode().removeEventListener 'mousewheel', @onMouseWheel
componentWillUpdate: ->
@props.parentView.trigger 'cursor:moved' if @cursorsMoved
componentDidUpdate: ->
@pendingChanges.length = 0
@cursorsMoved = false
@props.parentView.trigger 'editor:display-updated'
observeEditor: ->
{editor} = @props
@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
@subscribe editor.$scrollTop.changes, @onScrollTopChanged
@subscribe editor.$scrollLeft.changes, @requestUpdate
@subscribe editor.$height.changes, @requestUpdate
@subscribe editor.$width.changes, @requestUpdate
@subscribe editor.$defaultCharWidth.changes, @requestUpdate
@subscribe editor.$lineHeight.changes, @requestUpdate
listenForDOMEvents: ->
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
@addCommandListeners
'core:move-left': => editor.moveCursorLeft()
'core:move-right': => editor.moveCursorRight()
'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': @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()
'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
@addCommandListeners
'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')
'editor:scroll-to-cursor': => editor.scrollToCursorPosition()
'core:page-up': => editor.pageUp()
'core:page-down': => editor.pageDown()
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
@subscribe atom.config.observe 'editor.showIndentGuide', @setShowIndentGuide
setFontSize: (fontSize) ->
@setState({fontSize})
setLineHeight: (lineHeight) ->
@setState({lineHeight})
setFontFamily: (fontFamily) ->
@setState({fontFamily})
setShowIndentGuide: (showIndentGuide) ->
@setState({showIndentGuide})
onFocus: ->
@refs.scrollView.focus()
onInputFocused: ->
@setState(focused: true)
onInputBlurred: ->
@setState(focused: false)
onVerticalScroll: (scrollTop) ->
{editor} = @props
return if scrollTop is editor.getScrollTop()
animationFramePending = @pendingScrollTop?
@pendingScrollTop = scrollTop
unless animationFramePending
requestAnimationFrame =>
@props.editor.setScrollTop(@pendingScrollTop)
@pendingScrollTop = null
onHorizontalScroll: (scrollLeft) ->
{editor} = @props
return if scrollLeft is editor.getScrollLeft()
animationFramePending = @pendingScrollLeft?
@pendingScrollLeft = scrollLeft
unless animationFramePending
requestAnimationFrame =>
@props.editor.setScrollLeft(@pendingScrollLeft)
@pendingScrollLeft = null
onMouseWheel: (event) ->
# 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()
clearPreservedRowRange: ->
@preservedRowRange = null
@scrollingVertically = false
@requestUpdate()
clearPreservedRowRangeAfterDelay: null # Created lazily
onBatchedUpdatesStarted: ->
@batchingUpdates = true
onBatchedUpdatesEnded: ->
updateRequested = @updateRequested
@updateRequested = false
@batchingUpdates = false
if updateRequested
@requestUpdate()
onScreenLinesChanged: (change) ->
{editor} = @props
@pendingChanges.push(change)
@requestUpdate() if editor.intersectsVisibleRowRange(change.start, change.end + 1) # TODO: Use closed-open intervals for change events
onSelectionAdded: (selection) ->
{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)
onCursorsMoved: ->
@cursorsMoved = true
requestUpdate: ->
if @batchingUpdates
@updateRequested = true
else
@forceUpdate()
measureHeightAndWidth: ->
@refs.scrollView.measureHeightAndWidth()
consolidateSelections: (e) ->
e.abortKeyBinding() unless @props.editor.consolidateSelections()

View File

@ -0,0 +1,198 @@
React = require 'react'
{div} = require 'reactionary'
{debounce} = require 'underscore-plus'
InputComponent = require './input-component'
LinesComponent = require './lines-component'
CursorsComponent = require './cursors-component'
SelectionsComponent = require './selections-component'
module.exports =
EditorScrollViewComponent = React.createClass
displayName: 'EditorScrollViewComponent'
measurementPending: false
overflowChangedEventsPaused: false
overflowChangedWhilePaused: false
render: ->
{editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props
{scrollHeight, scrollWidth, renderedRowRange, pendingChanges, scrollingVertically} = @props
{cursorBlinkPeriod, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props
if @isMounted()
inputStyle = @getHiddenInputPosition()
inputStyle.WebkitTransform = 'translateZ(0)'
contentStyle =
height: scrollHeight
minWidth: scrollWidth
WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)"
div className: 'scroll-view',
InputComponent
ref: 'input'
className: 'hidden-input'
style: inputStyle
onInput: @onInput
onFocus: onInputFocused
onBlur: onInputBlurred
div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown,
CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay})
LinesComponent {
ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide,
renderedRowRange, pendingChanges, scrollingVertically
}
div className: 'underlayer',
SelectionsComponent({editor})
componentDidMount: ->
@getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged
window.addEventListener('resize', @onWindowResize)
@measureHeightAndWidth()
componentDidUnmount: ->
window.removeEventListener('resize', @onWindowResize)
componentDidUpdate: ->
@pauseOverflowChangedEvents()
onOverflowChanged: ->
if @overflowChangedEventsPaused
@overflowChangedWhilePaused = true
else
@requestMeasurement()
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
if replaceLastCharacter
editor.transact ->
editor.selectLeft()
editor.insertText(char)
else
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 = @getDOMNode().getBoundingClientRect()
top = clientY - editorClientRect.top + editor.getScrollTop()
left = clientX - editorClientRect.left + editor.getScrollLeft()
{top, left}
getHiddenInputPosition: ->
{editor} = @props
return {top: 0, left: 0} unless @isMounted() and editor.getCursor()?
{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}
# 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: ->
return unless @isMounted()
node = @getDOMNode()
computedStyle = getComputedStyle(node)
{editor} = @props
unless computedStyle.height is '0px'
clientHeight = node.clientHeight
editor.setHeight(clientHeight) if clientHeight > 0
unless computedStyle.width is '0px'
clientWidth = node.clientWidth
editor.setWidth(clientWidth) if clientHeight > 0
focus: ->
@refs.input.focus()

View File

@ -1482,16 +1482,11 @@ class EditorView extends View
html = @buildEmptyLineHtml(showIndentGuide, eolInvisibles, htmlEolInvisibles, indentation, editor, mini) html = @buildEmptyLineHtml(showIndentGuide, eolInvisibles, htmlEolInvisibles, indentation, editor, mini)
line.push(html) if html line.push(html) if html
else else
firstNonWhitespacePosition = text.search(/\S/)
firstTrailingWhitespacePosition = text.search(/\s*$/)
lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0
position = 0 position = 0
for token in tokens for token in tokens
@updateScopeStack(line, scopeStack, token.scopes) @updateScopeStack(line, scopeStack, token.scopes)
hasLeadingWhitespace = position < firstNonWhitespacePosition hasIndentGuide = not mini and showIndentGuide
hasTrailingWhitespace = position + token.value.length > firstTrailingWhitespacePosition line.push(token.getValueAsHtml({invisibles, hasIndentGuide}))
hasIndentGuide = not mini and showIndentGuide and (hasLeadingWhitespace or lineIsWhitespaceOnly)
line.push(token.getValueAsHtml({invisibles, hasLeadingWhitespace, hasTrailingWhitespace, hasIndentGuide}))
position += token.value.length position += token.value.length
@popScope(line, scopeStack) while scopeStack.length > 0 @popScope(line, scopeStack) while scopeStack.length > 0

View File

@ -136,10 +136,6 @@ class Editor extends Model
atom.deserializers.add(this) atom.deserializers.add(this)
Delegator.includeInto(this) Delegator.includeInto(this)
@properties
scrollTop: 0
scrollLeft: 0
deserializing: false deserializing: false
callDisplayBufferCreatedHook: false callDisplayBufferCreatedHook: false
registerEditor: false registerEditor: false
@ -153,6 +149,9 @@ class Editor extends Model
'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows',
toProperty: 'languageMode' toProperty: 'languageMode'
@delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width',
'$scrollTop', '$scrollLeft', 'manageScrollPosition', toProperty: 'displayBuffer'
constructor: ({@softTabs, initialLine, tabLength, softWrap, @displayBuffer, buffer, registerEditor, suppressCursorCreation}) -> constructor: ({@softTabs, initialLine, tabLength, softWrap, @displayBuffer, buffer, registerEditor, suppressCursorCreation}) ->
super super
@ -217,7 +216,10 @@ class Editor extends Model
@subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args... @subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args...
getViewClass: -> getViewClass: ->
require './editor-view' if atom.config.get('core.useReactEditor')
require './react-editor-view'
else
require './editor-view'
destroyed: -> destroyed: ->
@unsubscribe() @unsubscribe()
@ -232,8 +234,6 @@ class Editor extends Model
displayBuffer = @displayBuffer.copy() displayBuffer = @displayBuffer.copy()
softTabs = @getSoftTabs() softTabs = @getSoftTabs()
newEditor = new Editor({@buffer, displayBuffer, tabLength, softTabs, suppressCursorCreation: true, registerEditor: true}) newEditor = new Editor({@buffer, displayBuffer, tabLength, softTabs, suppressCursorCreation: true, registerEditor: true})
newEditor.setScrollTop(@getScrollTop())
newEditor.setScrollLeft(@getScrollLeft())
for marker in @findMarkers(editorId: @id) for marker in @findMarkers(editorId: @id)
marker.copy(editorId: newEditor.id, preserveFolds: true) marker.copy(editorId: newEditor.id, preserveFolds: true)
newEditor newEditor
@ -269,18 +269,6 @@ class Editor extends Model
# Controls visiblity based on the given {Boolean}. # Controls visiblity based on the given {Boolean}.
setVisible: (visible) -> @displayBuffer.setVisible(visible) 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 # Set the number of characters that can be displayed horizontally in the
# editor. # editor.
# #
@ -301,6 +289,9 @@ class Editor extends Model
# softTabs - A {Boolean} # softTabs - A {Boolean}
setSoftTabs: (@softTabs) -> @softTabs setSoftTabs: (@softTabs) -> @softTabs
# Public: Toggle soft tabs for this editor
toggleSoftTabs: -> @setSoftTabs(not @getSoftTabs())
# Public: Get whether soft wrap is enabled for this editor. # Public: Get whether soft wrap is enabled for this editor.
getSoftWrap: -> @displayBuffer.getSoftWrap() getSoftWrap: -> @displayBuffer.getSoftWrap()
@ -309,6 +300,9 @@ class Editor extends Model
# softWrap - A {Boolean} # softWrap - A {Boolean}
setSoftWrap: (softWrap) -> @displayBuffer.setSoftWrap(softWrap) 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. # 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 # If soft tabs are enabled, the text is composed of N spaces, where N is the
@ -394,13 +388,7 @@ class Editor extends Model
# #
# Returns a {Number}. # Returns a {Number}.
indentLevelForLine: (line) -> indentLevelForLine: (line) ->
if match = line.match(/^[\t ]+/) @displayBuffer.indentLevelForLine(line)
leadingWhitespace = match[0]
tabCount = leadingWhitespace.match(/\t/g)?.length ? 0
spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0
tabCount + (spaceCount / @getTabLength())
else
0
# Constructs the string used for tabs. # Constructs the string used for tabs.
buildIndentString: (number) -> buildIndentString: (number) ->
@ -421,6 +409,15 @@ class Editor extends Model
# filePath - A {String} path. # filePath - A {String} path.
saveAs: (filePath) -> @buffer.saveAs(filePath) 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. # Public: Returns the {String} path of this editor's text buffer.
getPath: -> @buffer.getPath() getPath: -> @buffer.getPath()
@ -599,6 +596,9 @@ class Editor extends Model
# Returns an {Array} of {String}s. # Returns an {Array} of {String}s.
getCursorScopes: -> @getCursor().getScopes() getCursorScopes: -> @getCursor().getScopes()
logCursorScope: ->
console.log @getCursorScopes()
# Public: For each selection, replace the selected text with the given text. # Public: For each selection, replace the selected text with the given text.
# #
# text - A {String} representing the text to insert. # text - A {String} representing the text to insert.
@ -1178,6 +1178,16 @@ class Editor extends Model
setSelectedBufferRange: (bufferRange, options) -> setSelectedBufferRange: (bufferRange, options) ->
@setSelectedBufferRanges([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 # Public: Set the selected ranges in buffer coordinates. If there are multiple
# selections, they are replaced by new selections with the given ranges. # selections, they are replaced by new selections with the given ranges.
# #
@ -1202,6 +1212,7 @@ class Editor extends Model
# Remove the given selection. # Remove the given selection.
removeSelection: (selection) -> removeSelection: (selection) ->
_.remove(@selections, selection) _.remove(@selections, selection)
@emit 'selection-removed', selection
# Reduce one or more selections to a single empty selection based on the most # Reduce one or more selections to a single empty selection based on the most
# recently added cursor. # recently added cursor.
@ -1218,6 +1229,9 @@ class Editor extends Model
else else
false false
selectionScreenRangeChanged: (selection) ->
@emit 'selection-screen-range-changed', selection
# Public: Get current {Selection}s. # Public: Get current {Selection}s.
# #
# Returns: An {Array} of {Selection}s. # Returns: An {Array} of {Selection}s.
@ -1328,6 +1342,14 @@ class Editor extends Model
getSelectedBufferRanges: -> getSelectedBufferRanges: ->
selection.getBufferRange() for selection in @getSelectionsOrderedByBufferPosition() 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. # Public: Get the selected text of the most recently added selection.
# #
# Returns a {String}. # Returns a {String}.
@ -1431,9 +1453,26 @@ class Editor extends Model
moveCursorToNextWordBoundary: -> moveCursorToNextWordBoundary: ->
@moveCursors (cursor) -> cursor.moveToNextWordBoundary() @moveCursors (cursor) -> cursor.moveToNextWordBoundary()
scrollToCursorPosition: ->
@getCursor().autoscroll()
pageUp: ->
@setScrollTop(@getScrollTop() - @getHeight())
pageDown: ->
@setScrollTop(@getScrollTop() + @getHeight())
moveCursors: (fn) -> moveCursors: (fn) ->
fn(cursor) for cursor in @getCursors() @movingCursors = true
@mergeCursors() @batchUpdates =>
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 # Public: Select from the current cursor position to the given position in
# screen coordinates. # screen coordinates.
@ -1735,7 +1774,9 @@ class Editor extends Model
# execution and revert any changes performed up to the abortion. # execution and revert any changes performed up to the abortion.
# #
# fn - A {Function} to call inside the transaction. # 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. # Public: Start an open-ended transaction.
# #
@ -1755,6 +1796,12 @@ class Editor extends Model
# within the transaction. # within the transaction.
abortTransaction: -> @buffer.abortTransaction() abortTransaction: -> @buffer.abortTransaction()
batchUpdates: (fn) ->
@emit 'batched-updates-started'
result = fn()
@emit 'batched-updates-ended'
result
inspect: -> inspect: ->
"<Editor #{@id}>" "<Editor #{@id}>"
@ -1771,6 +1818,66 @@ class Editor extends Model
getSelectionMarkerAttributes: -> getSelectionMarkerAttributes: ->
type: 'selection', editorId: @id, invalidate: 'never' type: 'selection', editorId: @id, invalidate: 'never'
getVerticalScrollMargin: -> @displayBuffer.getVerticalScrollMargin()
setVerticalScrollMargin: (verticalScrollMargin) -> @displayBuffer.setVerticalScrollMargin(verticalScrollMargin)
getHorizontalScrollMargin: -> @displayBuffer.getHorizontalScrollMargin()
setHorizontalScrollMargin: (horizontalScrollMargin) -> @displayBuffer.setHorizontalScrollMargin(horizontalScrollMargin)
getLineHeight: -> @displayBuffer.getLineHeight()
setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight)
getScopedCharWidth: (scopeNames, char) -> @displayBuffer.getScopedCharWidth(scopeNames, char)
setScopedCharWidth: (scopeNames, char, width) -> @displayBuffer.setScopedCharWidth(scopeNames, char, width)
getScopedCharWidths: (scopeNames) -> @displayBuffer.getScopedCharWidths(scopeNames)
clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths()
getDefaultCharWidth: -> @displayBuffer.getDefaultCharWidth()
setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth)
setHeight: (height) -> @displayBuffer.setHeight(height)
getHeight: -> @displayBuffer.getHeight()
setWidth: (width) -> @displayBuffer.setWidth(width)
getWidth: -> @displayBuffer.getWidth()
getScrollTop: -> @displayBuffer.getScrollTop()
setScrollTop: (scrollTop) -> @displayBuffer.setScrollTop(scrollTop)
getScrollBottom: -> @displayBuffer.getScrollBottom()
setScrollBottom: (scrollBottom) -> @displayBuffer.setScrollBottom(scrollBottom)
getScrollLeft: -> @displayBuffer.getScrollLeft()
setScrollLeft: (scrollLeft) -> @displayBuffer.setScrollLeft(scrollLeft)
getScrollRight: -> @displayBuffer.getScrollRight()
setScrollRight: (scrollRight) -> @displayBuffer.setScrollRight(scrollRight)
getScrollHeight: -> @displayBuffer.getScrollHeight()
getScrollWidth: (scrollWidth) -> @displayBuffer.getScrollWidth(scrollWidth)
getVisibleRowRange: -> @displayBuffer.getVisibleRowRange()
intersectsVisibleRowRange: (startRow, endRow) -> @displayBuffer.intersectsVisibleRowRange(startRow, endRow)
selectionIntersectsVisibleRowRange: (selection) -> @displayBuffer.selectionIntersectsVisibleRowRange(selection)
pixelPositionForScreenPosition: (screenPosition) -> @displayBuffer.pixelPositionForScreenPosition(screenPosition)
pixelPositionForBufferPosition: (bufferPosition) -> @displayBuffer.pixelPositionForBufferPosition(bufferPosition)
screenPositionForPixelPosition: (pixelPosition) -> @displayBuffer.screenPositionForPixelPosition(pixelPosition)
pixelRectForScreenRange: (screenRange) -> @displayBuffer.pixelRectForScreenRange(screenRange)
scrollToScreenRange: (screenRange) -> @displayBuffer.scrollToScreenRange(screenRange)
scrollToScreenPosition: (screenPosition) -> @displayBuffer.scrollToScreenPosition(screenPosition)
scrollToBufferPosition: (bufferPosition) -> @displayBuffer.scrollToBufferPosition(bufferPosition)
# Deprecated: Call {::joinLines} instead. # Deprecated: Call {::joinLines} instead.
joinLine: -> joinLine: ->
deprecate("Use Editor::joinLines() instead") deprecate("Use Editor::joinLines() instead")

View File

@ -0,0 +1,83 @@
React = require 'react'
{div} = require 'reactionary'
{isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus'
SubscriberMixin = require './subscriber-mixin'
module.exports =
GutterComponent = React.createClass
displayName: 'GutterComponent'
mixins: [SubscriberMixin]
render: ->
div className: 'gutter',
@renderLineNumbers() if @isMounted()
renderLineNumbers: ->
{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: scrollHeight
WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)"
lineNumbers = []
tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1)
tokenizedLines.push({id: 0}) if tokenizedLines.length is 0
for bufferRow, i in editor.bufferRowsForScreenRows(startRow, endRow - 1)
if bufferRow is lastBufferRow
lineNumber = ''
else
lastBufferRow = bufferRow
lineNumber = (bufferRow + 1).toString()
key = tokenizedLines[i]?.id
screenRow = startRow + i
lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow, lineHeight}))
lastBufferRow = bufferRow
div className: 'line-numbers', style: style,
lineNumbers
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) ->
{renderedRowRange, pendingChanges, scrollTop} = @props
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 <= renderedRowRange.start or renderedRowRange.end <= change.start
false
LineNumberComponent = React.createClass
displayName: 'LineNumberComponent'
render: ->
{bufferRow, screenRow, lineHeight} = @props
div
className: "line-number line-number-#{bufferRow}"
style: {top: screenRow * lineHeight}
'data-buffer-row': bufferRow
'data-screen-row': screenRow
dangerouslySetInnerHTML: {__html: @buildInnerHTML()}
buildInnerHTML: ->
{lineNumber, maxDigits} = @props
if lineNumber.length < maxDigits
padding = multiplyString('&nbsp;', maxDigits - lineNumber.length)
padding + lineNumber + @iconDivHTML
else
lineNumber + @iconDivHTML
iconDivHTML: '<div class="icon-right"></div>'
shouldComponentUpdate: (newProps) ->
not isEqualForProperties(newProps, @props, 'lineHeight', 'screenRow')

View File

@ -0,0 +1,51 @@
punycode = require 'punycode'
{last, isEqual} = require 'underscore-plus'
React = require 'react'
{input} = require 'reactionary'
module.exports =
InputComponent = React.createClass
displayName: 'InputComponent'
render: ->
{className, style, onFocus, onBlur} = @props
input {className, style, onFocus, onBlur}
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: (newProps) ->
not isEqual(newProps.style, @props.style)
onInput: (e) ->
e.stopPropagation()
valueCharCodes = punycode.ucs2.decode(@getDOMNode().value)
valueLength = valueCharCodes.length
replaceLastChar = valueLength is @lastValueLength
@lastValueLength = valueLength
lastChar = String.fromCharCode(last(valueCharCodes))
@props.onInput?(lastChar, replaceLastChar)
onFocus: ->
@props.onFocus?()
onBlur: ->
@props.onBlur?()
focus: ->
@getDOMNode().focus()

140
src/lines-component.coffee Normal file
View File

@ -0,0 +1,140 @@
React = require 'react'
{div, span} = require 'reactionary'
{debounce, isEqual, isEqualForProperties, multiplyString} = 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
displayName: 'LinesComponent'
render: ->
if @isMounted()
{editor, renderedRowRange, lineHeight, showIndentGuide} = @props
[startRow, endRow] = renderedRowRange
lines =
for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1)
LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, lineHeight, screenRow: startRow + i})
div {className: 'lines'}, lines
componentWillMount: ->
@measuredLines = new WeakSet
componentDidMount: ->
@measureLineHeightAndCharWidth()
shouldComponentUpdate: (newProps) ->
return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide')
{renderedRowRange, pendingChanges} = newProps
for change in pendingChanges
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.scrollingVertically
measureLineHeightAndCharWidth: ->
node = @getDOMNode()
node.appendChild(DummyLineNode)
lineHeight = DummyLineNode.getBoundingClientRect().height
charWidth = DummyLineNode.firstChild.getBoundingClientRect().width
node.removeChild(DummyLineNode)
{editor} = @props
editor.setLineHeight(lineHeight)
editor.setDefaultCharWidth(charWidth)
measureCharactersInNewLines: ->
[visibleStartRow, visibleEndRow] = @props.renderedRowRange
node = @getDOMNode()
for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1)
unless @measuredLines.has(tokenizedLine)
lineNode = node.children[i]
@measureCharactersInLine(tokenizedLine, lineNode)
measureCharactersInLine: (tokenizedLine, lineNode) ->
{editor} = @props
rangeForMeasurement = null
iterator = null
charIndex = 0
for {value, scopes}, tokenIndex in tokenizedLine.tokens
charWidths = editor.getScopedCharWidths(scopes)
for char in value
unless charWidths[char]?
unless textNode?
rangeForMeasurement ?= document.createRange()
iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter)
textNode = iterator.nextNode()
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: ->
@measuredLines.clear()
@props.editor.clearScopedCharWidths()
LineComponent = React.createClass
displayName: 'LineComponent'
render: ->
{screenRow, lineHeight} = @props
style =
top: screenRow * lineHeight
position: 'absolute'
div className: 'line', style: style, 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()}
buildInnerHTML: ->
if @props.tokenizedLine.text.length is 0
@buildEmptyLineHTML()
else
@buildScopeTreeHTML(@props.tokenizedLine.getScopeTree())
buildEmptyLineHTML: ->
{showIndentGuide, tokenizedLine} = @props
{indentLevel, tabLength} = tokenizedLine
if showIndentGuide and indentLevel > 0
indentSpan = "<span class='indent-guide'>#{multiplyString(' ', tabLength)}</span>"
multiplyString(indentSpan, indentLevel + 1)
else
"&nbsp;"
buildScopeTreeHTML: (scopeTree) ->
if scopeTree.children?
html = "<span class='#{scopeTree.scope.replace(/\./g, ' ')}'>"
html += @buildScopeTreeHTML(child) for child in scopeTree.children
html += "</span>"
html
else
"<span>#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}</span>"
shouldComponentUpdate: (newProps) ->
not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight', 'screenRow')

View File

@ -290,6 +290,7 @@ class Package
$(event.target).trigger(event) $(event.target).trigger(event)
@restoreEventHandlersOnBubblePath(bubblePathEventHandlers) @restoreEventHandlersOnBubblePath(bubblePathEventHandlers)
@unsubscribeFromActivationEvents() @unsubscribeFromActivationEvents()
false
unsubscribeFromActivationEvents: -> unsubscribeFromActivationEvents: ->
return unless atom.workspaceView? return unless atom.workspaceView?

View File

@ -0,0 +1,81 @@
{View, $} = require 'space-pen'
React = require 'react'
EditorComponent = require './editor-component'
module.exports =
class ReactEditorView extends View
@content: -> @div class: 'editor react-wrapper'
focusOnAttach: false
constructor: (@editor) ->
super
getEditor: -> @editor
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()
scrollToScreenPosition: (screenPosition) ->
@editor.scrollToScreenPosition(screenPosition)
scrollToBufferPosition: (bufferPosition) ->
@editor.scrollToBufferPosition(bufferPosition)
afterAttach: (onDom) ->
return unless onDom
@attached = true
@component = React.renderComponent(EditorComponent({@editor, parentView: this}), @element)
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
@focus() if @focusOnAttach
@trigger 'editor:attached', [this]
pixelPositionForBufferPosition: (bufferPosition) ->
@editor.pixelPositionForBufferPosition(bufferPosition)
pixelPositionForScreenPosition: (screenPosition) ->
@editor.pixelPositionForScreenPosition(screenPosition)
appendToLinesView: (view) ->
view.css('position', 'absolute')
@find('.scroll-view-content').prepend(view)
beforeRemove: ->
React.unmountComponentAtNode(@element)
@attached = false
@trigger 'editor:detached', this
getPane: ->
@closest('.pane').view()
focus: ->
if @component?
@component.onFocus()
else
@focusOnAttach = true

View File

@ -0,0 +1,54 @@
React = require 'react'
{div} = require 'reactionary'
{isEqualForProperties} = require 'underscore-plus'
module.exports =
ScrollbarComponent = React.createClass
render: ->
{orientation, className, 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}
componentDidMount: ->
{orientation} = @props
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'
node.scrollTop = scrollTop
@props.scrollTop = node.scrollTop # Ensure scrollTop reflects actual DOM without triggering another update
when 'horizontal'
node.scrollLeft = scrollLeft
@props.scrollLeft = node.scrollLeft # Ensure scrollLeft reflects actual DOM without triggering another update
onScroll: ->
{orientation, onScroll} = @props
node = @getDOMNode()
switch orientation
when 'vertical'
scrollTop = node.scrollTop
@props.scrollTop = scrollTop # Ensure scrollTop reflects actual DOM without triggering another update
onScroll(scrollTop)
when 'horizontal'
scrollLeft = node.scrollLeft
@props.scrollLeft = scrollLeft # Ensure scrollLeft reflects actual DOM without triggering another update
onScroll(scrollLeft)

View File

@ -0,0 +1,11 @@
React = require 'react'
{div} = require 'reactionary'
module.exports =
SelectionComponent = React.createClass
displayName: 'SelectionComponent'
render: ->
div className: 'selection',
for regionRect, i in @props.selection.getRegionRects()
div className: 'region', key: i, style: regionRect

View File

@ -1,12 +1,10 @@
{Point, Range} = require 'text-buffer' {Point, Range} = require 'text-buffer'
{Emitter} = require 'emissary' {Model} = require 'theorist'
{pick} = require 'underscore-plus' {pick} = require 'underscore-plus'
# Public: Represents a selection in the {Editor}. # Public: Represents a selection in the {Editor}.
module.exports = module.exports =
class Selection class Selection extends Model
Emitter.includeInto(this)
cursor: null cursor: null
marker: null marker: null
editor: null editor: null
@ -14,7 +12,8 @@ class Selection
wordwise: false wordwise: false
needsAutoscroll: null needsAutoscroll: null
constructor: ({@cursor, @marker, @editor}) -> constructor: ({@cursor, @marker, @editor, id}) ->
@assignId(id)
@cursor.selection = this @cursor.selection = this
@marker.on 'changed', => @screenRangeChanged() @marker.on 'changed', => @screenRangeChanged()
@marker.on 'destroyed', => @marker.on 'destroyed', =>
@ -77,8 +76,9 @@ class Selection
options.reversed ?= @isReversed() options.reversed ?= @isReversed()
@editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds @editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds
@modifySelection => @modifySelection =>
@cursor.needsAutoscroll = false if options.autoscroll? @cursor.needsAutoscroll = false if @needsAutoscroll?
@marker.setBufferRange(bufferRange, options) @marker.setBufferRange(bufferRange, options)
@autoscroll() if @needsAutoscroll
# Public: Returns the starting and ending buffer rows the selection is # Public: Returns the starting and ending buffer rows the selection is
# highlighting. # highlighting.
@ -91,6 +91,9 @@ class Selection
end = Math.max(start, end - 1) if range.end.column == 0 end = Math.max(start, end - 1) if range.end.column == 0
[start, end] [start, end]
autoscroll: ->
@editor.scrollToScreenRange(@getScreenRange())
# Public: Returns the text in the selection. # Public: Returns the text in the selection.
getText: -> getText: ->
@editor.buffer.getTextInRange(@getBufferRange()) @editor.buffer.getTextInRange(@getBufferRange())
@ -570,6 +573,48 @@ class Selection
compare: (otherSelection) -> compare: (otherSelection) ->
@getBufferRange().compare(otherSelection.getBufferRange()) @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: -> screenRangeChanged: ->
screenRange = @getScreenRange() screenRange = @getScreenRange()
@emit 'screen-range-changed', screenRange @emit 'screen-range-changed', screenRange
@editor.selectionScreenRangeChanged(this)

View File

@ -0,0 +1,16 @@
React = require 'react'
{div} = require 'reactionary'
SelectionComponent = require './selection-component'
module.exports =
SelectionsComponent = React.createClass
displayName: 'SelectionsComponent'
render: ->
{editor} = @props
div className: 'selections',
if @isMounted()
for selection in editor.getSelections()
if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection)
SelectionComponent({key: selection.id, selection})

View File

@ -69,4 +69,6 @@ jQuery(document.body).on 'show.bs.tooltip', ({target}) ->
jQuery.fn.setTooltip.getKeystroke = getKeystroke jQuery.fn.setTooltip.getKeystroke = getKeystroke
jQuery.fn.setTooltip.humanizeKeystrokes = humanizeKeystrokes jQuery.fn.setTooltip.humanizeKeystrokes = humanizeKeystrokes
Object.defineProperty jQuery.fn, 'element', get: -> @[0]
module.exports = spacePen module.exports = spacePen

View File

@ -0,0 +1,4 @@
{Subscriber} = require 'emissary'
SubscriberMixin = componentDidUnmount: -> @unsubscribe()
Subscriber.extend(SubscriberMixin)
module.exports = SubscriberMixin

View File

@ -20,6 +20,8 @@ class Token
scopes: null scopes: null
isAtomic: null isAtomic: null
isHardTab: null isHardTab: null
hasLeadingWhitespace: false
hasTrailingWhitespace: false
constructor: ({@value, @scopes, @isAtomic, @bufferDelta, @isHardTab}) -> constructor: ({@value, @scopes, @isAtomic, @bufferDelta, @isHardTab}) ->
@screenDelta = @value.length @screenDelta = @value.length
@ -40,7 +42,7 @@ class Token
whitespaceRegexForTabLength: (tabLength) -> whitespaceRegexForTabLength: (tabLength) ->
WhitespaceRegexesByTabLength[tabLength] ?= new RegExp("([ ]{#{tabLength}})|(\t)|([^\t]+)", "g") WhitespaceRegexesByTabLength[tabLength] ?= new RegExp("([ ]{#{tabLength}})|(\t)|([^\t]+)", "g")
breakOutAtomicTokens: (tabLength, breakOutLeadingWhitespace) -> breakOutAtomicTokens: (tabLength, breakOutLeadingSoftTabs) ->
if @hasSurrogatePair if @hasSurrogatePair
outputTokens = [] outputTokens = []
@ -48,14 +50,14 @@ class Token
if token.isAtomic if token.isAtomic
outputTokens.push(token) outputTokens.push(token)
else else
outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingWhitespace)...) outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingSoftTabs)...)
breakOutLeadingWhitespace = token.isOnlyWhitespace() if breakOutLeadingWhitespace breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs
outputTokens outputTokens
else else
return [this] if @isAtomic return [this] if @isAtomic
if breakOutLeadingWhitespace if breakOutLeadingSoftTabs
return [this] unless /^[ ]|\t/.test(@value) return [this] unless /^[ ]|\t/.test(@value)
else else
return [this] unless /\t/.test(@value) return [this] unless /\t/.test(@value)
@ -64,13 +66,13 @@ class Token
regex = @whitespaceRegexForTabLength(tabLength) regex = @whitespaceRegexForTabLength(tabLength)
while match = regex.exec(@value) while match = regex.exec(@value)
[fullMatch, softTab, hardTab] = match [fullMatch, softTab, hardTab] = match
if softTab and breakOutLeadingWhitespace if softTab and breakOutLeadingSoftTabs
outputTokens.push(@buildSoftTabToken(tabLength, false)) outputTokens.push(@buildSoftTabToken(tabLength))
else if hardTab else if hardTab
breakOutLeadingWhitespace = false breakOutLeadingSoftTabs = false
outputTokens.push(@buildHardTabToken(tabLength, true)) outputTokens.push(@buildHardTabToken(tabLength))
else else
breakOutLeadingWhitespace = false breakOutLeadingSoftTabs = false
value = match[0] value = match[0]
outputTokens.push(new Token({value, @scopes})) outputTokens.push(new Token({value, @scopes}))
@ -127,7 +129,7 @@ class Token
scopeClasses = scope.split('.') scopeClasses = scope.split('.')
_.isSubset(targetClasses, scopeClasses) _.isSubset(targetClasses, scopeClasses)
getValueAsHtml: ({invisibles, hasLeadingWhitespace, hasTrailingWhitespace, hasIndentGuide})-> getValueAsHtml: ({invisibles, hasIndentGuide})->
invisibles ?= {} invisibles ?= {}
if @isHardTab if @isHardTab
classes = 'hard-tab' classes = 'hard-tab'
@ -142,7 +144,7 @@ class Token
leadingHtml = '' leadingHtml = ''
trailingHtml = '' trailingHtml = ''
if hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) if @hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value)
classes = 'leading-whitespace' classes = 'leading-whitespace'
classes += ' indent-guide' if hasIndentGuide classes += ' indent-guide' if hasIndentGuide
classes += ' invisible-character' if invisibles.space classes += ' invisible-character' if invisibles.space
@ -152,9 +154,10 @@ class Token
startIndex = match[0].length startIndex = match[0].length
if hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) if @hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value)
tokenIsOnlyWhitespace = match[0].length is @value.length
classes = 'trailing-whitespace' 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 classes += ' invisible-character' if invisibles.space
match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space

View File

@ -185,14 +185,16 @@ class TokenizedBuffer extends Model
line = @buffer.lineForRow(row) line = @buffer.lineForRow(row)
tokens = [new Token(value: line, scopes: [@grammar.scopeName])] tokens = [new Token(value: line, scopes: [@grammar.scopeName])]
tabLength = @getTabLength() tabLength = @getTabLength()
new TokenizedLine({tokens, tabLength}) indentLevel = @indentLevelForRow(row)
new TokenizedLine({tokens, tabLength, indentLevel})
buildTokenizedTokenizedLineForRow: (row, ruleStack) -> buildTokenizedTokenizedLineForRow: (row, ruleStack) ->
line = @buffer.lineForRow(row) line = @buffer.lineForRow(row)
lineEnding = @buffer.lineEndingForRow(row) lineEnding = @buffer.lineEndingForRow(row)
tabLength = @getTabLength() tabLength = @getTabLength()
indentLevel = @indentLevelForRow(row)
{ tokens, ruleStack } = @grammar.tokenizeLine(line, ruleStack, row is 0) { 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 # FIXME: benogle says: These are actually buffer rows as all buffer rows are
# accounted for in @tokenizedLines # accounted for in @tokenizedLines
@ -207,6 +209,36 @@ class TokenizedBuffer extends Model
stackForRow: (row) -> stackForRow: (row) ->
@tokenizedLines[row]?.ruleStack @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) -> scopesForPosition: (position) ->
@tokenForPosition(position).scopes @tokenForPosition(position).scopes
@ -306,6 +338,9 @@ class TokenizedBuffer extends Model
getLastRow: -> getLastRow: ->
@buffer.getLastRow() @buffer.getLastRow()
getLineCount: ->
@buffer.getLineCount()
logLines: (start=0, end=@buffer.getLastRow()) -> logLines: (start=0, end=@buffer.getLastRow()) ->
for row in [start..end] for row in [start..end]
line = @lineForScreenRow(row).text line = @lineForScreenRow(row).text

View File

@ -1,12 +1,16 @@
_ = require 'underscore-plus' _ = require 'underscore-plus'
idCounter = 1
module.exports = module.exports =
class TokenizedLine class TokenizedLine
constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, tabLength}) -> constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, @tabLength, @indentLevel}) ->
@tokens = @breakOutAtomicTokens(tokens, tabLength) @tokens = @breakOutAtomicTokens(tokens)
@startBufferColumn ?= 0 @startBufferColumn ?= 0
@text = _.pluck(@tokens, 'value').join('') @text = _.pluck(@tokens, 'value').join('')
@bufferDelta = _.sum(_.pluck(@tokens, 'bufferDelta')) @bufferDelta = _.sum(_.pluck(@tokens, 'bufferDelta'))
@id = idCounter++
@markLeadingAndTrailingWhitespaceTokens()
copy: -> copy: ->
new TokenizedLine({@tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold}) new TokenizedLine({@tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold})
@ -106,14 +110,25 @@ class TokenizedLine
delta = nextDelta delta = nextDelta
delta delta
breakOutAtomicTokens: (inputTokens, tabLength) -> breakOutAtomicTokens: (inputTokens) ->
outputTokens = [] outputTokens = []
breakOutLeadingWhitespace = true breakOutLeadingSoftTabs = true
for token in inputTokens for token in inputTokens
outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingWhitespace)...) outputTokens.push(token.breakOutAtomicTokens(@tabLength, breakOutLeadingSoftTabs)...)
breakOutLeadingWhitespace = token.isOnlyWhitespace() if breakOutLeadingWhitespace breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs
outputTokens 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
# 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: -> isComment: ->
for token in @tokens for token in @tokens
continue if token.scopes.length is 1 continue if token.scopes.length is 1
@ -134,3 +149,33 @@ class TokenizedLine
for token in @tokens for token in @tokens
return column if token is targetToken return column if token is targetToken
column += token.bufferDelta column += token.bufferDelta
getScopeTree: ->
return @scopeTree if @scopeTree?
scopeStack = []
for token in @tokens
@updateScopeStack(scopeStack, token.scopes)
_.last(scopeStack).children.push(token)
@scopeTree = scopeStack[0]
@updateScopeStack(scopeStack, [])
@scopeTree
updateScopeStack: (scopeStack, desiredScopes) ->
# Find a common prefix
for scope, i in desiredScopes
break unless scopeStack[i]?.scope is desiredScopes[i]
# Pop scopes until we're at the common prefx
until scopeStack.length is i
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(desiredScopes[j]))
class Scope
constructor: (@scope) ->
@children = []

View File

@ -71,6 +71,7 @@ class WorkspaceView extends View
projectHome: path.join(fs.getHomeDirectory(), 'github') projectHome: path.join(fs.getHomeDirectory(), 'github')
audioBeep: true audioBeep: true
destroyEmptyPanes: true destroyEmptyPanes: true
useReactEditor: false
@content: -> @content: ->
@div class: 'workspace', tabindex: -1, => @div class: 'workspace', tabindex: -1, =>

View File

@ -2,6 +2,64 @@
@import "octicon-utf-codes"; @import "octicon-utf-codes";
@import "octicon-mixins"; @import "octicon-mixins";
.editor.react {
.underlayer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: -2;
}
.lines {
z-index: -1;
}
.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;
width: 100%;
}
.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 { .editor {
overflow: hidden; overflow: hidden;
cursor: text; cursor: text;
@ -28,7 +86,6 @@
.editor .gutter .line-number { .editor .gutter .line-number {
padding-left: .5em; padding-left: .5em;
opacity: 0.6; opacity: 0.6;
position: relative;
} }
.editor .gutter .line-numbers { .editor .gutter .line-numbers {

View File

@ -38,7 +38,7 @@
background-color: @pane-item-background-color; background-color: @pane-item-background-color;
} }
> * { > *, > .react-wrapper > * {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;