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