/* global ResizeObserver */ const etch = require('etch') const {Point, Range} = require('text-buffer') const LineTopIndex = require('line-top-index') const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') const clipboard = require('./safe-clipboard') const electron = require('electron') const $ = etch.dom let TextEditorElement const DEFAULT_ROWS_PER_TILE = 6 const NORMAL_WIDTH_CHARACTER = 'x' const DOUBLE_WIDTH_CHARACTER = '我' const HALF_WIDTH_CHARACTER = 'ハ' const KOREAN_CHARACTER = '세' const NBSP_CHARACTER = '\u00a0' const ZERO_WIDTH_NBSP_CHARACTER = '\ufeff' const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 const CURSOR_BLINK_RESUME_DELAY = 300 const CURSOR_BLINK_PERIOD = 800 function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 } module.exports = class TextEditorComponent { static setScheduler (scheduler) { etch.setScheduler(scheduler) } static getScheduler () { return etch.getScheduler() } static didUpdateStyles () { if (this.attachedComponents) { this.attachedComponents.forEach((component) => { component.didUpdateStyles() }) } } static didUpdateScrollbarStyles () { if (this.attachedComponents) { this.attachedComponents.forEach((component) => { component.didUpdateScrollbarStyles() }) } } constructor (props) { this.props = props if (!props.model) { props.model = new TextEditor({mini: props.mini, readOnly: props.readOnly}) } this.props.model.component = this if (props.element) { this.element = props.element } else { if (!TextEditorElement) TextEditorElement = require('./text-editor-element') this.element = new TextEditorElement() } this.element.initialize(this) this.virtualNode = $('atom-text-editor') this.virtualNode.domNode = this.element this.refs = {} this.updateSync = this.updateSync.bind(this) this.didBlurHiddenInput = this.didBlurHiddenInput.bind(this) this.didFocusHiddenInput = this.didFocusHiddenInput.bind(this) this.didPaste = this.didPaste.bind(this) this.didTextInput = this.didTextInput.bind(this) this.didKeydown = this.didKeydown.bind(this) this.didKeyup = this.didKeyup.bind(this) this.didKeypress = this.didKeypress.bind(this) this.didCompositionStart = this.didCompositionStart.bind(this) this.didCompositionUpdate = this.didCompositionUpdate.bind(this) this.didCompositionEnd = this.didCompositionEnd.bind(this) this.updatedSynchronously = this.props.updatedSynchronously this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.debouncedResumeCursorBlinking = debounce( this.resumeCursorBlinking.bind(this), (this.props.cursorBlinkResumeDelay || CURSOR_BLINK_RESUME_DELAY) ) this.lineTopIndex = new LineTopIndex() this.lineNodesPool = new NodePool() this.updateScheduled = false this.suppressUpdates = false this.hasInitialMeasurements = false this.measurements = { lineHeight: 0, baseCharacterWidth: 0, doubleWidthCharacterWidth: 0, halfWidthCharacterWidth: 0, koreanCharacterWidth: 0, gutterContainerWidth: 0, lineNumberGutterWidth: 0, clientContainerHeight: 0, clientContainerWidth: 0, verticalScrollbarWidth: 0, horizontalScrollbarHeight: 0, longestLineWidth: 0 } this.derivedDimensionsCache = {} this.visible = false this.cursorsBlinking = false this.cursorsBlinkedOff = false this.nextUpdateOnlyBlinksCursors = null this.linesToMeasure = new Map() this.extraRenderedScreenLines = new Map() this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horizontal pixel positions this.blockDecorationsToMeasure = new Set() this.blockDecorationsByElement = new WeakMap() this.blockDecorationSentinel = document.createElement('div') this.blockDecorationSentinel.style.height = '1px' this.heightsByBlockDecoration = new WeakMap() this.blockDecorationResizeObserver = new ResizeObserver(this.didResizeBlockDecorations.bind(this)) this.lineComponentsByScreenLineId = new Map() this.overlayComponents = new Set() this.shouldRenderDummyScrollbars = true this.remeasureScrollbars = false this.pendingAutoscroll = null this.scrollTopPending = false this.scrollLeftPending = false this.scrollTop = 0 this.scrollLeft = 0 this.previousScrollWidth = 0 this.previousScrollHeight = 0 this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.accentedCharacterMenuIsOpen = false this.remeasureGutterDimensions = false this.guttersToRender = [this.props.model.getLineNumberGutter()] this.guttersVisibility = [this.guttersToRender[0].visible] this.idsByTileStartRow = new Map() this.nextTileId = 0 this.renderedTileStartRows = [] this.showLineNumbers = this.props.model.doesShowLineNumbers() this.lineNumbersToRender = { maxDigits: 2, bufferRows: [], keys: [], softWrappedFlags: [], foldableFlags: [] } this.decorationsToRender = { lineNumbers: null, lines: null, highlights: [], cursors: [], overlays: [], customGutter: new Map(), blocks: new Map(), text: [] } this.decorationsToMeasure = { highlights: [], cursors: new Map() } this.textDecorationsByMarker = new Map() this.textDecorationBoundaries = [] this.pendingScrollTopRow = this.props.initialScrollTopRow this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn this.tabIndex = this.props.element && this.props.element.tabIndex ? this.props.element.tabIndex : -1 this.measuredContent = false this.queryGuttersToRender() this.queryMaxLineNumberDigits() this.observeBlockDecorations() this.updateClassList() etch.updateSync(this) } update (props) { if (props.model !== this.props.model) { this.props.model.component = null props.model.component = this } this.props = props this.scheduleUpdate() } pixelPositionForScreenPosition ({row, column}) { const top = this.pixelPositionAfterBlocksForRow(row) let left = column === 0 ? 0 : this.pixelLeftForRowAndColumn(row, column) if (left == null) { this.requestHorizontalMeasurement(row, column) this.updateSync() left = this.pixelLeftForRowAndColumn(row, column) } return {top, left} } scheduleUpdate (nextUpdateOnlyBlinksCursors = false) { if (!this.visible) return if (this.suppressUpdates) return this.nextUpdateOnlyBlinksCursors = this.nextUpdateOnlyBlinksCursors !== false && nextUpdateOnlyBlinksCursors === true if (this.updatedSynchronously) { this.updateSync() } else if (!this.updateScheduled) { this.updateScheduled = true etch.getScheduler().updateDocument(() => { if (this.updateScheduled) this.updateSync(true) }) } } updateSync (useScheduler = false) { // Don't proceed if we know we are not visible if (!this.visible) { this.updateScheduled = false return } // Don't proceed if we have to pay for a measurement anyway and detect // that we are no longer visible. if ((this.remeasureCharacterDimensions || this.remeasureAllBlockDecorations) && !this.isVisible()) { if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() this.updateScheduled = false return } const onlyBlinkingCursors = this.nextUpdateOnlyBlinksCursors this.nextUpdateOnlyBlinksCursors = null if (useScheduler && onlyBlinkingCursors) { this.refs.cursorsAndInput.updateCursorBlinkSync(this.cursorsBlinkedOff) if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() this.updateScheduled = false return } if (this.remeasureCharacterDimensions) { const originalLineHeight = this.getLineHeight() const originalBaseCharacterWidth = this.getBaseCharacterWidth() const scrollTopRow = this.getScrollTopRow() const scrollLeftColumn = this.getScrollLeftColumn() this.measureCharacterDimensions() this.measureGutterDimensions() this.queryLongestLine() if (this.getLineHeight() !== originalLineHeight) { this.setScrollTopRow(scrollTopRow) } if (this.getBaseCharacterWidth() !== originalBaseCharacterWidth) { this.setScrollLeftColumn(scrollLeftColumn) } this.remeasureCharacterDimensions = false } this.measureBlockDecorations() this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { const scheduler = etch.getScheduler() scheduler.readDocument(() => { const restartFrame = this.measureContentDuringUpdateSync() scheduler.updateDocument(() => { if (restartFrame) { this.updateSync(true) } else { this.updateSyncAfterMeasuringContent() } }) }) } else { const restartFrame = this.measureContentDuringUpdateSync() if (restartFrame) { this.updateSync(false) } else { this.updateSyncAfterMeasuringContent() } } this.updateScheduled = false } measureBlockDecorations () { if (this.remeasureAllBlockDecorations) { this.remeasureAllBlockDecorations = false const decorations = this.props.model.getDecorations() for (var i = 0; i < decorations.length; i++) { const decoration = decorations[i] const marker = decoration.getMarker() if (marker.isValid() && decoration.getProperties().type === 'block') { this.blockDecorationsToMeasure.add(decoration) } } // Update the width of the line tiles to ensure block decorations are // measured with the most recent width. if (this.blockDecorationsToMeasure.size > 0) { this.updateSyncBeforeMeasuringContent() } } if (this.blockDecorationsToMeasure.size > 0) { const {blockDecorationMeasurementArea} = this.refs const sentinelElements = new Set() blockDecorationMeasurementArea.appendChild(document.createElement('div')) this.blockDecorationsToMeasure.forEach((decoration) => { const {item} = decoration.getProperties() const decorationElement = TextEditor.viewForItem(item) if (document.contains(decorationElement)) { const parentElement = decorationElement.parentElement if (!decorationElement.previousSibling) { const sentinelElement = this.blockDecorationSentinel.cloneNode() parentElement.insertBefore(sentinelElement, decorationElement) sentinelElements.add(sentinelElement) } if (!decorationElement.nextSibling) { const sentinelElement = this.blockDecorationSentinel.cloneNode() parentElement.appendChild(sentinelElement) sentinelElements.add(sentinelElement) } this.didMeasureVisibleBlockDecoration = true } else { blockDecorationMeasurementArea.appendChild(this.blockDecorationSentinel.cloneNode()) blockDecorationMeasurementArea.appendChild(decorationElement) blockDecorationMeasurementArea.appendChild(this.blockDecorationSentinel.cloneNode()) } }) if (this.resizeBlockDecorationMeasurementsArea) { this.resizeBlockDecorationMeasurementsArea = false this.refs.blockDecorationMeasurementArea.style.width = this.getScrollWidth() + 'px' } this.blockDecorationsToMeasure.forEach((decoration) => { const {item} = decoration.getProperties() const decorationElement = TextEditor.viewForItem(item) const {previousSibling, nextSibling} = decorationElement const height = nextSibling.getBoundingClientRect().top - previousSibling.getBoundingClientRect().bottom this.heightsByBlockDecoration.set(decoration, height) this.lineTopIndex.resizeBlock(decoration, height) }) sentinelElements.forEach((sentinelElement) => sentinelElement.remove()) while (blockDecorationMeasurementArea.firstChild) { blockDecorationMeasurementArea.firstChild.remove() } this.blockDecorationsToMeasure.clear() } } updateSyncBeforeMeasuringContent () { this.measuredContent = false this.derivedDimensionsCache = {} this.updateModelSoftWrapColumn() if (this.pendingAutoscroll) { let {screenRange, options} = this.pendingAutoscroll this.autoscrollVertically(screenRange, options) this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) } this.populateVisibleRowRange(this.getRenderedStartRow()) this.populateVisibleTiles() this.queryScreenLinesToRender() this.queryLongestLine() this.queryLineNumbersToRender() this.queryGuttersToRender() this.queryDecorationsToRender() this.queryExtraScreenLinesToRender() this.shouldRenderDummyScrollbars = !this.remeasureScrollbars etch.updateSync(this) this.updateClassList() this.shouldRenderDummyScrollbars = true this.didMeasureVisibleBlockDecoration = false } measureContentDuringUpdateSync () { if (this.remeasureGutterDimensions) { this.measureGutterDimensions() this.remeasureGutterDimensions = false } const wasHorizontalScrollbarVisible = ( this.canScrollHorizontally() && this.getHorizontalScrollbarHeight() > 0 ) this.measureLongestLineWidth() this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() const isHorizontalScrollbarVisible = ( this.canScrollHorizontally() && this.getHorizontalScrollbarHeight() > 0 ) if (this.pendingAutoscroll) { this.derivedDimensionsCache = {} const {screenRange, options} = this.pendingAutoscroll this.autoscrollHorizontally(screenRange, options) if (!wasHorizontalScrollbarVisible && isHorizontalScrollbarVisible) { this.autoscrollVertically(screenRange, options) } this.pendingAutoscroll = null } this.linesToMeasure.clear() this.measuredContent = true return wasHorizontalScrollbarVisible !== isHorizontalScrollbarVisible } updateSyncAfterMeasuringContent () { this.derivedDimensionsCache = {} etch.updateSync(this) this.currentFrameLineNumberGutterProps = null this.scrollTopPending = false this.scrollLeftPending = false if (this.remeasureScrollbars) { // Flush stored scroll positions to the vertical and the horizontal // scrollbars. This is because they have just been destroyed and recreated // as a result of their remeasurement, but we could not assign the scroll // top while they were initialized because they were not attached to the // DOM yet. this.refs.verticalScrollbar.flushScrollPosition() this.refs.horizontalScrollbar.flushScrollPosition() this.measureScrollbarDimensions() this.remeasureScrollbars = false etch.updateSync(this) } this.derivedDimensionsCache = {} if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() } render () { const {model} = this.props const style = {} if (!model.getAutoHeight() && !model.getAutoWidth()) { style.contain = 'size' } let clientContainerHeight = '100%' let clientContainerWidth = '100%' if (this.hasInitialMeasurements) { if (model.getAutoHeight()) { clientContainerHeight = this.getContentHeight() if (this.canScrollHorizontally()) clientContainerHeight += this.getHorizontalScrollbarHeight() clientContainerHeight += 'px' } if (model.getAutoWidth()) { style.width = 'min-content' clientContainerWidth = this.getGutterContainerWidth() + this.getContentWidth() if (this.canScrollVertically()) clientContainerWidth += this.getVerticalScrollbarWidth() clientContainerWidth += 'px' } else { style.width = this.element.style.width } } let attributes = {} if (model.isMini()) { attributes.mini = '' } if (!this.isInputEnabled()) { attributes.readonly = '' } const dataset = {encoding: model.getEncoding()} const grammar = model.getGrammar() if (grammar && grammar.scopeName) { dataset.grammar = grammar.scopeName.replace(/\./g, ' ') } return $('atom-text-editor', { // See this.updateClassList() for construction of the class name style, attributes, dataset, tabIndex: -1, on: {mousewheel: this.didMouseWheel} }, $.div( { ref: 'clientContainer', style: { position: 'relative', contain: 'strict', overflow: 'hidden', backgroundColor: 'inherit', height: clientContainerHeight, width: clientContainerWidth } }, this.renderGutterContainer(), this.renderScrollContainer() ), this.renderOverlayDecorations() ) } renderGutterContainer () { if (this.props.model.isMini()) { return null } else { return $(GutterContainerComponent, { ref: 'gutterContainer', key: 'gutterContainer', rootComponent: this, hasInitialMeasurements: this.hasInitialMeasurements, measuredContent: this.measuredContent, scrollTop: this.getScrollTop(), scrollHeight: this.getScrollHeight(), lineNumberGutterWidth: this.getLineNumberGutterWidth(), lineHeight: this.getLineHeight(), renderedStartRow: this.getRenderedStartRow(), renderedEndRow: this.getRenderedEndRow(), rowsPerTile: this.getRowsPerTile(), guttersToRender: this.guttersToRender, decorationsToRender: this.decorationsToRender, isLineNumberGutterVisible: this.props.model.isLineNumberGutterVisible(), showLineNumbers: this.showLineNumbers, lineNumbersToRender: this.lineNumbersToRender, didMeasureVisibleBlockDecoration: this.didMeasureVisibleBlockDecoration }) } } renderScrollContainer () { const style = { position: 'absolute', contain: 'strict', overflow: 'hidden', top: 0, bottom: 0, backgroundColor: 'inherit' } if (this.hasInitialMeasurements) { style.left = this.getGutterContainerWidth() + 'px' style.width = this.getScrollContainerWidth() + 'px' } return $.div( { ref: 'scrollContainer', key: 'scrollContainer', className: 'scroll-view', style }, this.renderContent(), this.renderDummyScrollbars() ) } renderContent () { let style = { contain: 'strict', overflow: 'hidden', backgroundColor: 'inherit' } if (this.hasInitialMeasurements) { style.width = ceilToPhysicalPixelBoundary(this.getScrollWidth()) + 'px' style.height = ceilToPhysicalPixelBoundary(this.getScrollHeight()) + 'px' style.willChange = 'transform' style.transform = `translate(${-roundToPhysicalPixelBoundary(this.getScrollLeft())}px, ${-roundToPhysicalPixelBoundary(this.getScrollTop())}px)` } return $.div( { ref: 'content', on: {mousedown: this.didMouseDownOnContent}, style }, this.renderLineTiles(), this.renderBlockDecorationMeasurementArea(), this.renderCharacterMeasurementLine() ) } renderHighlightDecorations () { return $(HighlightsComponent, { hasInitialMeasurements: this.hasInitialMeasurements, highlightDecorations: this.decorationsToRender.highlights.slice(), width: this.getScrollWidth(), height: this.getScrollHeight(), lineHeight: this.getLineHeight() }) } renderLineTiles () { const style = { position: 'absolute', contain: 'strict', overflow: 'hidden' } const children = [] children.push(this.renderHighlightDecorations()) if (this.hasInitialMeasurements) { const {lineComponentsByScreenLineId} = this const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const rowsPerTile = this.getRowsPerTile() const tileWidth = this.getScrollWidth() for (let i = 0; i < this.renderedTileStartRows.length; i++) { const tileStartRow = this.renderedTileStartRows[i] const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileHeight = this.pixelPositionBeforeBlocksForRow(tileEndRow) - this.pixelPositionBeforeBlocksForRow(tileStartRow) children.push($(LinesTileComponent, { key: this.idsByTileStartRow.get(tileStartRow), measuredContent: this.measuredContent, height: tileHeight, width: tileWidth, top: this.pixelPositionBeforeBlocksForRow(tileStartRow), lineHeight: this.getLineHeight(), renderedStartRow: startRow, tileStartRow, tileEndRow, screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations: this.decorationsToRender.lines.slice(tileStartRow - startRow, tileEndRow - startRow), textDecorations: this.decorationsToRender.text.slice(tileStartRow - startRow, tileEndRow - startRow), blockDecorations: this.decorationsToRender.blocks.get(tileStartRow), displayLayer: this.props.model.displayLayer, nodePool: this.lineNodesPool, lineComponentsByScreenLineId })) } this.extraRenderedScreenLines.forEach((screenLine, screenRow) => { if (screenRow < startRow || screenRow >= endRow) { children.push($(LineComponent, { key: 'extra-' + screenLine.id, offScreen: true, screenLine, screenRow, displayLayer: this.props.model.displayLayer, nodePool: this.lineNodesPool, lineComponentsByScreenLineId })) } }) style.width = this.getScrollWidth() + 'px' style.height = this.getScrollHeight() + 'px' } children.push(this.renderPlaceholderText()) children.push(this.renderCursorsAndInput()) return $.div( {key: 'lineTiles', ref: 'lineTiles', className: 'lines', style}, children ) } renderCursorsAndInput () { return $(CursorsAndInputComponent, { ref: 'cursorsAndInput', key: 'cursorsAndInput', didBlurHiddenInput: this.didBlurHiddenInput, didFocusHiddenInput: this.didFocusHiddenInput, didTextInput: this.didTextInput, didPaste: this.didPaste, didKeydown: this.didKeydown, didKeyup: this.didKeyup, didKeypress: this.didKeypress, didCompositionStart: this.didCompositionStart, didCompositionUpdate: this.didCompositionUpdate, didCompositionEnd: this.didCompositionEnd, measuredContent: this.measuredContent, lineHeight: this.getLineHeight(), scrollHeight: this.getScrollHeight(), scrollWidth: this.getScrollWidth(), decorationsToRender: this.decorationsToRender, cursorsBlinkedOff: this.cursorsBlinkedOff, hiddenInputPosition: this.hiddenInputPosition, tabIndex: this.tabIndex }) } renderPlaceholderText () { const {model} = this.props if (model.isEmpty()) { const placeholderText = model.getPlaceholderText() if (placeholderText != null) { return $.div({className: 'placeholder-text'}, placeholderText) } } return null } renderCharacterMeasurementLine () { return $.div( { key: 'characterMeasurementLine', ref: 'characterMeasurementLine', className: 'line dummy', style: {position: 'absolute', visibility: 'hidden'} }, $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) ) } renderBlockDecorationMeasurementArea () { return $.div({ ref: 'blockDecorationMeasurementArea', key: 'blockDecorationMeasurementArea', style: { contain: 'strict', position: 'absolute', visibility: 'hidden', width: this.getScrollWidth() + 'px' } }) } renderDummyScrollbars () { if (this.shouldRenderDummyScrollbars && !this.props.model.isMini()) { let scrollHeight, scrollTop, horizontalScrollbarHeight let scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible let canScrollHorizontally, canScrollVertically if (this.hasInitialMeasurements) { scrollHeight = this.getScrollHeight() scrollWidth = this.getScrollWidth() scrollTop = this.getScrollTop() scrollLeft = this.getScrollLeft() canScrollHorizontally = this.canScrollHorizontally() canScrollVertically = this.canScrollVertically() horizontalScrollbarHeight = canScrollHorizontally ? this.getHorizontalScrollbarHeight() : 0 verticalScrollbarWidth = canScrollVertically ? this.getVerticalScrollbarWidth() : 0 forceScrollbarVisible = this.remeasureScrollbars } else { forceScrollbarVisible = true } const dummyScrollbarVnodes = [ $(DummyScrollbarComponent, { ref: 'verticalScrollbar', orientation: 'vertical', didScroll: this.didScrollDummyScrollbar, didMouseDown: this.didMouseDownOnContent, canScroll: canScrollVertically, scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible }), $(DummyScrollbarComponent, { ref: 'horizontalScrollbar', orientation: 'horizontal', didScroll: this.didScrollDummyScrollbar, didMouseDown: this.didMouseDownOnContent, canScroll: canScrollHorizontally, scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible }) ] // If both scrollbars are visible, push a dummy element to force a "corner" // to render where the two scrollbars meet at the lower right if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) { dummyScrollbarVnodes.push($.div( { ref: 'scrollbarCorner', className: 'scrollbar-corner', style: { position: 'absolute', height: '20px', width: '20px', bottom: 0, right: 0, overflow: 'scroll' } } )) } return dummyScrollbarVnodes } else { return null } } renderOverlayDecorations () { return this.decorationsToRender.overlays.map((overlayProps) => $(OverlayComponent, Object.assign( { key: overlayProps.element, overlayComponents: this.overlayComponents, didResize: (overlayComponent) => { this.updateOverlayToRender(overlayProps) overlayComponent.update(overlayProps) } }, overlayProps )) ) } // Imperatively manipulate the class list of the root element to avoid // clearing classes assigned by package authors. updateClassList () { const {model} = this.props const oldClassList = this.classList const newClassList = ['editor'] if (this.focused) newClassList.push('is-focused') if (model.isMini()) newClassList.push('mini') for (var i = 0; i < model.selections.length; i++) { if (!model.selections[i].isEmpty()) { newClassList.push('has-selection') break } } if (oldClassList) { for (let i = 0; i < oldClassList.length; i++) { const className = oldClassList[i] if (!newClassList.includes(className)) { this.element.classList.remove(className) } } } for (let i = 0; i < newClassList.length; i++) { const className = newClassList[i] if (!oldClassList || !oldClassList.includes(className)) { this.element.classList.add(className) } } this.classList = newClassList } queryScreenLinesToRender () { const {model} = this.props this.renderedScreenLines = model.displayLayer.getScreenLines( this.getRenderedStartRow(), this.getRenderedEndRow() ) } queryLongestLine () { const {model} = this.props const longestLineRow = model.getApproximateLongestScreenRow() const longestLine = model.screenLineForScreenRow(longestLineRow) if (longestLine !== this.previousLongestLine || this.remeasureCharacterDimensions) { this.requestLineToMeasure(longestLineRow, longestLine) this.longestLineToMeasure = longestLine this.previousLongestLine = longestLine } } queryExtraScreenLinesToRender () { this.extraRenderedScreenLines.clear() this.linesToMeasure.forEach((screenLine, row) => { if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { this.extraRenderedScreenLines.set(row, screenLine) } }) } queryLineNumbersToRender () { const {model} = this.props if (!model.isLineNumberGutterVisible()) return if (this.showLineNumbers !== model.doesShowLineNumbers()) { this.remeasureGutterDimensions = true this.showLineNumbers = model.doesShowLineNumbers() } this.queryMaxLineNumberDigits() const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const renderedRowCount = this.getRenderedRowCount() const bufferRows = model.bufferRowsForScreenRows(startRow, endRow) const screenRows = new Array(renderedRowCount) const keys = new Array(renderedRowCount) const foldableFlags = new Array(renderedRowCount) const softWrappedFlags = new Array(renderedRowCount) let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 let softWrapCount = 0 for (let row = startRow; row < endRow; row++) { const i = row - startRow const bufferRow = bufferRows[i] if (bufferRow === previousBufferRow) { softWrapCount++ softWrappedFlags[i] = true keys[i] = bufferRow + '-' + softWrapCount } else { softWrapCount = 0 softWrappedFlags[i] = false keys[i] = bufferRow } const nextBufferRow = bufferRows[i + 1] if (bufferRow !== nextBufferRow) { foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) } else { foldableFlags[i] = false } screenRows[i] = row previousBufferRow = bufferRow } // Delete extra buffer row at the end because it's not currently on screen. bufferRows.pop() this.lineNumbersToRender.bufferRows = bufferRows this.lineNumbersToRender.screenRows = screenRows this.lineNumbersToRender.keys = keys this.lineNumbersToRender.foldableFlags = foldableFlags this.lineNumbersToRender.softWrappedFlags = softWrappedFlags } queryMaxLineNumberDigits () { const {model} = this.props if (model.isLineNumberGutterVisible()) { const maxDigits = Math.max(2, model.getLineCount().toString().length) if (maxDigits !== this.lineNumbersToRender.maxDigits) { this.remeasureGutterDimensions = true this.lineNumbersToRender.maxDigits = maxDigits } } } renderedScreenLineForRow (row) { return ( this.renderedScreenLines[row - this.getRenderedStartRow()] || this.extraRenderedScreenLines.get(row) ) } queryGuttersToRender () { const oldGuttersToRender = this.guttersToRender const oldGuttersVisibility = this.guttersVisibility this.guttersToRender = this.props.model.getGutters() this.guttersVisibility = this.guttersToRender.map(g => g.visible) if (!oldGuttersToRender || oldGuttersToRender.length !== this.guttersToRender.length) { this.remeasureGutterDimensions = true } else { for (let i = 0, length = this.guttersToRender.length; i < length; i++) { if (this.guttersToRender[i] !== oldGuttersToRender[i] || this.guttersVisibility[i] !== oldGuttersVisibility[i]) { this.remeasureGutterDimensions = true break } } } } queryDecorationsToRender () { this.decorationsToRender.lineNumbers = [] this.decorationsToRender.lines = [] this.decorationsToRender.overlays.length = 0 this.decorationsToRender.customGutter.clear() this.decorationsToRender.blocks = new Map() this.decorationsToRender.text = [] this.decorationsToMeasure.highlights.length = 0 this.decorationsToMeasure.cursors.clear() this.textDecorationsByMarker.clear() this.textDecorationBoundaries.length = 0 const decorationsByMarker = this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( this.getRenderedStartRow(), this.getRenderedEndRow() ) decorationsByMarker.forEach((decorations, marker) => { const screenRange = marker.getScreenRange() const reversed = marker.isReversed() for (let i = 0; i < decorations.length; i++) { const decoration = decorations[i] this.addDecorationToRender(decoration.type, decoration, marker, screenRange, reversed) } }) this.populateTextDecorationsToRender() } addDecorationToRender (type, decoration, marker, screenRange, reversed) { if (Array.isArray(type)) { for (let i = 0, length = type.length; i < length; i++) { this.addDecorationToRender(type[i], decoration, marker, screenRange, reversed) } } else { switch (type) { case 'line': case 'line-number': this.addLineDecorationToRender(type, decoration, screenRange, reversed) break case 'highlight': this.addHighlightDecorationToMeasure(decoration, screenRange, marker.id) break case 'cursor': this.addCursorDecorationToMeasure(decoration, marker, screenRange, reversed) break case 'overlay': this.addOverlayDecorationToRender(decoration, marker) break case 'gutter': this.addCustomGutterDecorationToRender(decoration, screenRange) break case 'block': this.addBlockDecorationToRender(decoration, screenRange, reversed) break case 'text': this.addTextDecorationToRender(decoration, screenRange, marker) break } } } addLineDecorationToRender (type, decoration, screenRange, reversed) { const decorationsToRender = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers let omitLastRow = false if (screenRange.isEmpty()) { if (decoration.onlyNonEmpty) return } else { if (decoration.onlyEmpty) return if (decoration.omitEmptyLastRow !== false) { omitLastRow = screenRange.end.column === 0 } } const renderedStartRow = this.getRenderedStartRow() let rangeStartRow = screenRange.start.row let rangeEndRow = screenRange.end.row if (decoration.onlyHead) { if (reversed) { rangeEndRow = rangeStartRow } else { rangeStartRow = rangeEndRow } } rangeStartRow = Math.max(rangeStartRow, this.getRenderedStartRow()) rangeEndRow = Math.min(rangeEndRow, this.getRenderedEndRow() - 1) for (let row = rangeStartRow; row <= rangeEndRow; row++) { if (omitLastRow && row === screenRange.end.row) break const currentClassName = decorationsToRender[row - renderedStartRow] const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class decorationsToRender[row - renderedStartRow] = newClassName } } addHighlightDecorationToMeasure (decoration, screenRange, key) { screenRange = constrainRangeToRows(screenRange, this.getRenderedStartRow(), this.getRenderedEndRow()) if (screenRange.isEmpty()) return const {class: className, flashRequested, flashClass, flashDuration} = decoration decoration.flashRequested = false this.decorationsToMeasure.highlights.push({ screenRange, key, className, flashRequested, flashClass, flashDuration }) this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) } addCursorDecorationToMeasure (decoration, marker, screenRange, reversed) { const {model} = this.props if (!model.getShowCursorOnSelection() && !screenRange.isEmpty()) return let decorationToMeasure = this.decorationsToMeasure.cursors.get(marker) if (!decorationToMeasure) { const isLastCursor = model.getLastCursor().getMarker() === marker const screenPosition = reversed ? screenRange.start : screenRange.end const {row, column} = screenPosition if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) return this.requestHorizontalMeasurement(row, column) let columnWidth = 0 if (model.lineLengthForScreenRow(row) > column) { columnWidth = 1 this.requestHorizontalMeasurement(row, column + 1) } decorationToMeasure = {screenPosition, columnWidth, isLastCursor} this.decorationsToMeasure.cursors.set(marker, decorationToMeasure) } if (decoration.class) { if (decorationToMeasure.className) { decorationToMeasure.className += ' ' + decoration.class } else { decorationToMeasure.className = decoration.class } } if (decoration.style) { if (decorationToMeasure.style) { Object.assign(decorationToMeasure.style, decoration.style) } else { decorationToMeasure.style = Object.assign({}, decoration.style) } } } addOverlayDecorationToRender (decoration, marker) { const {class: className, item, position, avoidOverflow} = decoration const element = TextEditor.viewForItem(item) const screenPosition = (position === 'tail') ? marker.getTailScreenPosition() : marker.getHeadScreenPosition() this.requestHorizontalMeasurement(screenPosition.row, screenPosition.column) this.decorationsToRender.overlays.push({className, element, avoidOverflow, screenPosition}) } addCustomGutterDecorationToRender (decoration, screenRange) { let decorations = this.decorationsToRender.customGutter.get(decoration.gutterName) if (!decorations) { decorations = [] this.decorationsToRender.customGutter.set(decoration.gutterName, decorations) } const top = this.pixelPositionAfterBlocksForRow(screenRange.start.row) const height = this.pixelPositionBeforeBlocksForRow(screenRange.end.row + 1) - top decorations.push({ className: 'decoration' + (decoration.class ? ' ' + decoration.class : ''), element: TextEditor.viewForItem(decoration.item), top, height }) } addBlockDecorationToRender (decoration, screenRange, reversed) { const {row} = reversed ? screenRange.start : screenRange.end if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) return const tileStartRow = this.tileStartRowForRow(row) const screenLine = this.renderedScreenLines[row - this.getRenderedStartRow()] let decorationsByScreenLine = this.decorationsToRender.blocks.get(tileStartRow) if (!decorationsByScreenLine) { decorationsByScreenLine = new Map() this.decorationsToRender.blocks.set(tileStartRow, decorationsByScreenLine) } let decorations = decorationsByScreenLine.get(screenLine.id) if (!decorations) { decorations = [] decorationsByScreenLine.set(screenLine.id, decorations) } decorations.push(decoration) } addTextDecorationToRender (decoration, screenRange, marker) { if (screenRange.isEmpty()) return let decorationsForMarker = this.textDecorationsByMarker.get(marker) if (!decorationsForMarker) { decorationsForMarker = [] this.textDecorationsByMarker.set(marker, decorationsForMarker) this.textDecorationBoundaries.push({position: screenRange.start, starting: [marker]}) this.textDecorationBoundaries.push({position: screenRange.end, ending: [marker]}) } decorationsForMarker.push(decoration) } populateTextDecorationsToRender () { // Sort all boundaries in ascending order of position this.textDecorationBoundaries.sort((a, b) => a.position.compare(b.position)) // Combine adjacent boundaries with the same position for (let i = 0; i < this.textDecorationBoundaries.length;) { const boundary = this.textDecorationBoundaries[i] const nextBoundary = this.textDecorationBoundaries[i + 1] if (nextBoundary && nextBoundary.position.isEqual(boundary.position)) { if (nextBoundary.starting) { if (boundary.starting) { boundary.starting.push(...nextBoundary.starting) } else { boundary.starting = nextBoundary.starting } } if (nextBoundary.ending) { if (boundary.ending) { boundary.ending.push(...nextBoundary.ending) } else { boundary.ending = nextBoundary.ending } } this.textDecorationBoundaries.splice(i + 1, 1) } else { i++ } } const renderedStartRow = this.getRenderedStartRow() const renderedEndRow = this.getRenderedEndRow() const containingMarkers = [] // Iterate over boundaries to build up text decorations. for (let i = 0; i < this.textDecorationBoundaries.length; i++) { const boundary = this.textDecorationBoundaries[i] // If multiple markers start here, sort them by order of nesting (markers ending later come first) if (boundary.starting && boundary.starting.length > 1) { boundary.starting.sort((a, b) => a.compare(b)) } // If multiple markers start here, sort them by order of nesting (markers starting earlier come first) if (boundary.ending && boundary.ending.length > 1) { boundary.ending.sort((a, b) => b.compare(a)) } // Remove markers ending here from containing markers array if (boundary.ending) { for (let j = boundary.ending.length - 1; j >= 0; j--) { containingMarkers.splice(containingMarkers.lastIndexOf(boundary.ending[j]), 1) } } // Add markers starting here to containing markers array if (boundary.starting) containingMarkers.push(...boundary.starting) // Determine desired className and style based on containing markers let className, style for (let j = 0; j < containingMarkers.length; j++) { const marker = containingMarkers[j] const decorations = this.textDecorationsByMarker.get(marker) for (let k = 0; k < decorations.length; k++) { const decoration = decorations[k] if (decoration.class) { if (className) { className += ' ' + decoration.class } else { className = decoration.class } } if (decoration.style) { if (style) { Object.assign(style, decoration.style) } else { style = Object.assign({}, decoration.style) } } } } // Add decoration start with className/style for current position's column, // and also for the start of every row up until the next decoration boundary if (boundary.position.row >= renderedStartRow) { this.addTextDecorationStart(boundary.position.row, boundary.position.column, className, style) } const nextBoundary = this.textDecorationBoundaries[i + 1] if (nextBoundary) { let row = Math.max(boundary.position.row + 1, renderedStartRow) const endRow = Math.min(nextBoundary.position.row, renderedEndRow) for (; row < endRow; row++) { this.addTextDecorationStart(row, 0, className, style) } if (row === nextBoundary.position.row && nextBoundary.position.column !== 0) { this.addTextDecorationStart(row, 0, className, style) } } } } addTextDecorationStart (row, column, className, style) { const renderedStartRow = this.getRenderedStartRow() let decorationStarts = this.decorationsToRender.text[row - renderedStartRow] if (!decorationStarts) { decorationStarts = [] this.decorationsToRender.text[row - renderedStartRow] = decorationStarts } decorationStarts.push({column, className, style}) } updateAbsolutePositionedDecorations () { this.updateHighlightsToRender() this.updateCursorsToRender() this.updateOverlaysToRender() } updateHighlightsToRender () { this.decorationsToRender.highlights.length = 0 for (let i = 0; i < this.decorationsToMeasure.highlights.length; i++) { const highlight = this.decorationsToMeasure.highlights[i] const {start, end} = highlight.screenRange highlight.startPixelTop = this.pixelPositionAfterBlocksForRow(start.row) highlight.startPixelLeft = this.pixelLeftForRowAndColumn(start.row, start.column) highlight.endPixelTop = this.pixelPositionAfterBlocksForRow(end.row) + this.getLineHeight() highlight.endPixelLeft = this.pixelLeftForRowAndColumn(end.row, end.column) this.decorationsToRender.highlights.push(highlight) } } updateCursorsToRender () { this.decorationsToRender.cursors.length = 0 this.decorationsToMeasure.cursors.forEach((cursor) => { const {screenPosition, className, style} = cursor const {row, column} = screenPosition const pixelTop = this.pixelPositionAfterBlocksForRow(row) const pixelLeft = this.pixelLeftForRowAndColumn(row, column) let pixelWidth if (cursor.columnWidth === 0) { pixelWidth = this.getBaseCharacterWidth() } else { pixelWidth = this.pixelLeftForRowAndColumn(row, column + 1) - pixelLeft } const cursorPosition = {pixelTop, pixelLeft, pixelWidth, className, style} this.decorationsToRender.cursors.push(cursorPosition) if (cursor.isLastCursor) this.hiddenInputPosition = cursorPosition }) } updateOverlayToRender (decoration) { const windowInnerHeight = this.getWindowInnerHeight() const windowInnerWidth = this.getWindowInnerWidth() const contentClientRect = this.refs.content.getBoundingClientRect() const {element, screenPosition, avoidOverflow} = decoration const {row, column} = screenPosition let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight() let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column) const clientRect = element.getBoundingClientRect() if (avoidOverflow !== false) { const computedStyle = window.getComputedStyle(element) const elementTop = wrapperTop + parseInt(computedStyle.marginTop) const elementBottom = elementTop + clientRect.height const flippedElementTop = wrapperTop - this.getLineHeight() - clientRect.height - parseInt(computedStyle.marginBottom) const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft) const elementRight = elementLeft + clientRect.width if (elementBottom > windowInnerHeight && flippedElementTop >= 0) { wrapperTop -= (elementTop - flippedElementTop) } if (elementLeft < 0) { wrapperLeft -= elementLeft } else if (elementRight > windowInnerWidth) { wrapperLeft -= (elementRight - windowInnerWidth) } } decoration.pixelTop = Math.round(wrapperTop) decoration.pixelLeft = Math.round(wrapperLeft) } updateOverlaysToRender () { const overlayCount = this.decorationsToRender.overlays.length if (overlayCount === 0) return null for (let i = 0; i < overlayCount; i++) { const decoration = this.decorationsToRender.overlays[i] this.updateOverlayToRender(decoration) } } didAttach () { if (!this.attached) { this.attached = true this.intersectionObserver = new IntersectionObserver((entries) => { const {intersectionRect} = entries[entries.length - 1] if (intersectionRect.width > 0 || intersectionRect.height > 0) { this.didShow() } else { this.didHide() } }) this.intersectionObserver.observe(this.element) this.resizeObserver = new ResizeObserver(this.didResize.bind(this)) this.resizeObserver.observe(this.element) if (this.refs.gutterContainer) { this.gutterContainerResizeObserver = new ResizeObserver(this.didResizeGutterContainer.bind(this)) this.gutterContainerResizeObserver.observe(this.refs.gutterContainer.element) } this.overlayComponents.forEach((component) => component.didAttach()) if (this.isVisible()) { this.didShow() if (this.refs.verticalScrollbar) this.refs.verticalScrollbar.flushScrollPosition() if (this.refs.horizontalScrollbar) this.refs.horizontalScrollbar.flushScrollPosition() } else { this.didHide() } if (!this.constructor.attachedComponents) { this.constructor.attachedComponents = new Set() } this.constructor.attachedComponents.add(this) } } didDetach () { if (this.attached) { this.intersectionObserver.disconnect() this.resizeObserver.disconnect() if (this.gutterContainerResizeObserver) this.gutterContainerResizeObserver.disconnect() this.overlayComponents.forEach((component) => component.didDetach()) this.didHide() this.attached = false this.constructor.attachedComponents.delete(this) } } didShow () { if (!this.visible && this.isVisible()) { if (!this.hasInitialMeasurements) this.measureDimensions() this.visible = true this.props.model.setVisible(true) this.resizeBlockDecorationMeasurementsArea = true this.updateSync() this.flushPendingLogicalScrollPosition() } } didHide () { if (this.visible) { this.visible = false this.props.model.setVisible(false) } } // Called by TextEditorElement so that focus events can be handled before // the element is attached to the DOM. didFocus () { // This element can be focused from a parent custom element's // attachedCallback before *its* attachedCallback is fired. This protects // against that case. if (!this.attached) this.didAttach() // The element can be focused before the intersection observer detects that // it has been shown for the first time. If this element is being focused, // it is necessarily visible, so we call `didShow` to ensure the hidden // input is rendered before we try to shift focus to it. if (!this.visible) this.didShow() if (!this.focused) { this.focused = true this.startCursorBlinking() this.scheduleUpdate() } this.getHiddenInput().focus() } // Called by TextEditorElement so that this function is always the first // listener to be fired, even if other listeners are bound before creating // the component. didBlur (event) { if (event.relatedTarget === this.getHiddenInput()) { event.stopImmediatePropagation() } } didBlurHiddenInput (event) { if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { this.focused = false this.stopCursorBlinking() this.scheduleUpdate() this.element.dispatchEvent(new FocusEvent(event.type, event)) } } didFocusHiddenInput () { // Focusing the hidden input when it is off-screen causes the browser to // scroll it into view. Since we use synthetic scrolling this behavior // causes all the lines to disappear so we counteract it by always setting // the scroll position to 0. this.refs.scrollContainer.scrollTop = 0 this.refs.scrollContainer.scrollLeft = 0 if (!this.focused) { this.focused = true this.startCursorBlinking() this.scheduleUpdate() } } didMouseWheel (event) { const scrollSensitivity = this.props.model.getScrollSensitivity() / 100 let {wheelDeltaX, wheelDeltaY} = event if (Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY)) { wheelDeltaX = wheelDeltaX * scrollSensitivity wheelDeltaY = 0 } else { wheelDeltaX = 0 wheelDeltaY = wheelDeltaY * scrollSensitivity } if (this.getPlatform() !== 'darwin' && event.shiftKey) { let temp = wheelDeltaX wheelDeltaX = wheelDeltaY wheelDeltaY = temp } const scrollLeftChanged = wheelDeltaX !== 0 && this.setScrollLeft(this.getScrollLeft() - wheelDeltaX) const scrollTopChanged = wheelDeltaY !== 0 && this.setScrollTop(this.getScrollTop() - wheelDeltaY) if (scrollLeftChanged || scrollTopChanged) this.updateSync() } didResize () { // Prevent the component from measuring the client container dimensions when // getting spurious resize events. if (this.isVisible()) { const clientContainerWidthChanged = this.measureClientContainerWidth() const clientContainerHeightChanged = this.measureClientContainerHeight() if (clientContainerWidthChanged || clientContainerHeightChanged) { if (clientContainerWidthChanged) { this.remeasureAllBlockDecorations = true } this.resizeObserver.disconnect() this.scheduleUpdate() process.nextTick(() => { this.resizeObserver.observe(this.element) }) } } } didResizeGutterContainer () { // Prevent the component from measuring the gutter dimensions when getting // spurious resize events. if (this.isVisible() && this.measureGutterDimensions()) { this.gutterContainerResizeObserver.disconnect() this.scheduleUpdate() process.nextTick(() => { this.gutterContainerResizeObserver.observe(this.refs.gutterContainer.element) }) } } didScrollDummyScrollbar () { let scrollTopChanged = false let scrollLeftChanged = false if (!this.scrollTopPending) { scrollTopChanged = this.setScrollTop(this.refs.verticalScrollbar.element.scrollTop) } if (!this.scrollLeftPending) { scrollLeftChanged = this.setScrollLeft(this.refs.horizontalScrollbar.element.scrollLeft) } if (scrollTopChanged || scrollLeftChanged) this.updateSync() } didUpdateStyles () { this.remeasureCharacterDimensions = true this.horizontalPixelPositionsByScreenLineId.clear() this.scheduleUpdate() } didUpdateScrollbarStyles () { if (!this.props.model.isMini()) { this.remeasureScrollbars = true this.scheduleUpdate() } } didPaste (event) { // On Linux, Chromium translates a middle-button mouse click into a // mousedown event *and* a paste event. Since Atom supports the middle mouse // click as a way of closing a tab, we only want the mousedown event, not // the paste event. And since we don't use the `paste` event for any // behavior in Atom, we can no-op the event to eliminate this issue. // See https://github.com/atom/atom/pull/15183#issue-248432413. if (this.getPlatform() === 'linux') event.preventDefault() } didTextInput (event) { if (this.compositionCheckpoint) { this.props.model.revertToCheckpoint(this.compositionCheckpoint) this.compositionCheckpoint = null } if (this.isInputEnabled()) { event.stopPropagation() // WARNING: If we call preventDefault on the input of a space // character, then the browser interprets the spacebar keypress as a // page-down command, causing spaces to scroll elements containing // editors. This means typing space will actually change the contents // of the hidden input, which will cause the browser to autoscroll the // scroll container to reveal the input if it is off screen (See // https://github.com/atom/atom/issues/16046). To correct for this // situation, we automatically reset the scroll position to 0,0 after // typing a space. None of this can really be tested. if (event.data === ' ') { window.setImmediate(() => { this.refs.scrollContainer.scrollTop = 0 this.refs.scrollContainer.scrollLeft = 0 }) } else { event.preventDefault() } // If the input event is fired while the accented character menu is open it // means that the user has chosen one of the accented alternatives. Thus, we // will replace the original non accented character with the selected // alternative. if (this.accentedCharacterMenuIsOpen) { this.props.model.selectLeft() } this.props.model.insertText(event.data, {groupUndo: true}) } } // We need to get clever to detect when the accented character menu is // opened on macOS. Usually, every keydown event that could cause input is // followed by a corresponding keypress. However, pressing and holding // long enough to open the accented character menu causes additional keydown // events to fire that aren't followed by their own keypress and textInput // events. // // Therefore, we assume the accented character menu has been deployed if, // before observing any keyup event, we observe events in the following // sequence: // // keydown(code: X), keypress, keydown(code: X) // // The code X must be the same in the keydown events that bracket the // keypress, meaning we're *holding* the _same_ key we intially pressed. // Got that? didKeydown (event) { // Stop dragging when user interacts with the keyboard. This prevents // unwanted selections in the case edits are performed while selecting text // at the same time. Modifier keys are exempt to preserve the ability to // add selections, shift-scroll horizontally while selecting. if (this.stopDragging && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta' && event.key !== 'Shift') { this.stopDragging() } if (this.lastKeydownBeforeKeypress != null) { if (this.lastKeydownBeforeKeypress.code === event.code) { this.accentedCharacterMenuIsOpen = true } this.lastKeydownBeforeKeypress = null } this.lastKeydown = event } didKeypress (event) { this.lastKeydownBeforeKeypress = this.lastKeydown // This cancels the accented character behavior if we type a key normally // with the menu open. this.accentedCharacterMenuIsOpen = false } didKeyup (event) { if (this.lastKeydownBeforeKeypress && this.lastKeydownBeforeKeypress.code === event.code) { this.lastKeydownBeforeKeypress = null } } // The IME composition events work like this: // // User types 's', chromium pops up the completion helper // 1. compositionstart fired // 2. compositionupdate fired; event.data == 's' // User hits arrow keys to move around in completion helper // 3. compositionupdate fired; event.data == 's' for each arry key press // User escape to cancel OR User chooses a completion // 4. compositionend fired // 5. textInput fired; event.data == the completion string didCompositionStart () { // Workaround for Chromium not preventing composition events when // preventDefault is called on the keydown event that precipitated them. if (this.lastKeydown && this.lastKeydown.defaultPrevented) { this.getHiddenInput().disabled = true process.nextTick(() => { // Disabling the hidden input makes it lose focus as well, so we have to // re-enable and re-focus it. this.getHiddenInput().disabled = false this.getHiddenInput().focus() }) return } this.compositionCheckpoint = this.props.model.createCheckpoint() if (this.accentedCharacterMenuIsOpen) { this.props.model.selectLeft() } } didCompositionUpdate (event) { this.props.model.insertText(event.data, {select: true}) } didCompositionEnd (event) { event.target.value = '' } didMouseDownOnContent (event) { const {model} = this.props const {target, button, detail, ctrlKey, shiftKey, metaKey} = event const platform = this.getPlatform() // Ignore clicks on block decorations. if (target) { let element = target while (element && element !== this.element) { if (this.blockDecorationsByElement.has(element)) { return } element = element.parentElement } } const screenPosition = this.screenPositionForMouseEvent(event) if (button === 1) { model.setCursorScreenPosition(screenPosition, {autoscroll: false}) // On Linux, pasting happens on middle click. A textInput event with the // contents of the selection clipboard will be dispatched by the browser // automatically on mouseup. if (platform === 'linux' && this.isInputEnabled()) model.insertText(clipboard.readText('selection')) return } if (button !== 0) return // Ctrl-click brings up the context menu on macOS if (platform === 'darwin' && ctrlKey) return if (target && target.matches('.fold-marker')) { const bufferPosition = model.bufferPositionForScreenPosition(screenPosition) model.destroyFoldsContainingBufferPositions([bufferPosition], false) return } const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') switch (detail) { case 1: if (addOrRemoveSelection) { const existingSelection = model.getSelectionAtScreenPosition(screenPosition) if (existingSelection) { if (model.hasMultipleCursors()) existingSelection.destroy() } else { model.addCursorAtScreenPosition(screenPosition, {autoscroll: false}) } } else { if (shiftKey) { model.selectToScreenPosition(screenPosition, {autoscroll: false}) } else { model.setCursorScreenPosition(screenPosition, {autoscroll: false}) } } break case 2: if (addOrRemoveSelection) model.addCursorAtScreenPosition(screenPosition, {autoscroll: false}) model.getLastSelection().selectWord({autoscroll: false}) break case 3: if (addOrRemoveSelection) model.addCursorAtScreenPosition(screenPosition, {autoscroll: false}) model.getLastSelection().selectLine(null, {autoscroll: false}) break } this.handleMouseDragUntilMouseUp({ didDrag: (event) => { this.autoscrollOnMouseDrag(event) const screenPosition = this.screenPositionForMouseEvent(event) model.selectToScreenPosition(screenPosition, {suppressSelectionMerge: true, autoscroll: false}) this.updateSync() }, didStopDragging: () => { model.finalizeSelections() model.mergeIntersectingSelections() this.updateSync() } }) } didMouseDownOnLineNumberGutter (event) { const {model} = this.props const {target, button, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button if (button !== 0) return const clickedScreenRow = this.screenPositionForMouseEvent(event).row const startBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, 0]).row if (target && (target.matches('.foldable .icon-right') || target.matches('.folded .icon-right'))) { model.toggleFoldAtBufferRow(startBufferRow) return } const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') const endBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, Infinity]).row const clickedLineBufferRange = Range(Point(startBufferRow, 0), Point(endBufferRow + 1, 0)) let initialBufferRange if (shiftKey) { const lastSelection = model.getLastSelection() initialBufferRange = lastSelection.getBufferRange() lastSelection.setBufferRange(initialBufferRange.union(clickedLineBufferRange), { reversed: clickedScreenRow < lastSelection.getScreenRange().start.row, autoscroll: false, preserveFolds: true, suppressSelectionMerge: true }) } else { initialBufferRange = clickedLineBufferRange if (addOrRemoveSelection) { model.addSelectionForBufferRange(clickedLineBufferRange, {autoscroll: false, preserveFolds: true}) } else { model.setSelectedBufferRange(clickedLineBufferRange, {autoscroll: false, preserveFolds: true}) } } const initialScreenRange = model.screenRangeForBufferRange(initialBufferRange) this.handleMouseDragUntilMouseUp({ didDrag: (event) => { this.autoscrollOnMouseDrag(event, true) const dragRow = this.screenPositionForMouseEvent(event).row const draggedLineScreenRange = Range(Point(dragRow, 0), Point(dragRow + 1, 0)) model.getLastSelection().setScreenRange(draggedLineScreenRange.union(initialScreenRange), { reversed: dragRow < initialScreenRange.start.row, autoscroll: false, preserveFolds: true }) this.updateSync() }, didStopDragging: () => { model.mergeIntersectingSelections() this.updateSync() } }) } handleMouseDragUntilMouseUp ({didDrag, didStopDragging}) { let dragging = false let lastMousemoveEvent const animationFrameLoop = () => { window.requestAnimationFrame(() => { if (dragging && this.visible) { didDrag(lastMousemoveEvent) animationFrameLoop() } }) } function didMouseMove (event) { lastMousemoveEvent = event if (!dragging) { dragging = true animationFrameLoop() } } function didMouseUp () { this.stopDragging = null window.removeEventListener('mousemove', didMouseMove) window.removeEventListener('mouseup', didMouseUp, {capture: true}) if (dragging) { dragging = false didStopDragging() } } window.addEventListener('mousemove', didMouseMove) window.addEventListener('mouseup', didMouseUp, {capture: true}) this.stopDragging = didMouseUp } autoscrollOnMouseDrag ({clientX, clientY}, verticalOnly = false) { var {top, bottom, left, right} = this.refs.scrollContainer.getBoundingClientRect() // Using var to avoid deopt on += assignments below top += MOUSE_DRAG_AUTOSCROLL_MARGIN bottom -= MOUSE_DRAG_AUTOSCROLL_MARGIN left += MOUSE_DRAG_AUTOSCROLL_MARGIN right -= MOUSE_DRAG_AUTOSCROLL_MARGIN let yDelta, yDirection if (clientY < top) { yDelta = top - clientY yDirection = -1 } else if (clientY > bottom) { yDelta = clientY - bottom yDirection = 1 } let xDelta, xDirection if (clientX < left) { xDelta = left - clientX xDirection = -1 } else if (clientX > right) { xDelta = clientX - right xDirection = 1 } let scrolled = false if (yDelta != null) { const scaledDelta = scaleMouseDragAutoscrollDelta(yDelta) * yDirection scrolled = this.setScrollTop(this.getScrollTop() + scaledDelta) } if (!verticalOnly && xDelta != null) { const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection scrolled = this.setScrollLeft(this.getScrollLeft() + scaledDelta) } if (scrolled) this.updateSync() } screenPositionForMouseEvent (event) { return this.screenPositionForPixelPosition(this.pixelPositionForMouseEvent(event)) } pixelPositionForMouseEvent ({clientX, clientY}) { const scrollContainerRect = this.refs.scrollContainer.getBoundingClientRect() clientX = Math.min(scrollContainerRect.right, Math.max(scrollContainerRect.left, clientX)) clientY = Math.min(scrollContainerRect.bottom, Math.max(scrollContainerRect.top, clientY)) const linesRect = this.refs.lineTiles.getBoundingClientRect() return { top: clientY - linesRect.top, left: clientX - linesRect.left } } didUpdateSelections () { this.pauseCursorBlinking() this.scheduleUpdate() } pauseCursorBlinking () { this.stopCursorBlinking() this.debouncedResumeCursorBlinking() } resumeCursorBlinking () { this.cursorsBlinkedOff = true this.startCursorBlinking() } stopCursorBlinking () { if (this.cursorsBlinking) { this.cursorsBlinkedOff = false this.cursorsBlinking = false window.clearInterval(this.cursorBlinkIntervalHandle) this.cursorBlinkIntervalHandle = null this.scheduleUpdate() } } startCursorBlinking () { if (!this.cursorsBlinking) { this.cursorBlinkIntervalHandle = window.setInterval(() => { this.cursorsBlinkedOff = !this.cursorsBlinkedOff this.scheduleUpdate(true) }, (this.props.cursorBlinkPeriod || CURSOR_BLINK_PERIOD) / 2) this.cursorsBlinking = true this.scheduleUpdate(true) } } didRequestAutoscroll (autoscroll) { this.pendingAutoscroll = autoscroll this.scheduleUpdate() } flushPendingLogicalScrollPosition () { let changedScrollTop = false if (this.pendingScrollTopRow > 0) { changedScrollTop = this.setScrollTopRow(this.pendingScrollTopRow, false) this.pendingScrollTopRow = null } let changedScrollLeft = false if (this.pendingScrollLeftColumn > 0) { changedScrollLeft = this.setScrollLeftColumn(this.pendingScrollLeftColumn, false) this.pendingScrollLeftColumn = null } if (changedScrollTop || changedScrollLeft) { this.updateSync() } } autoscrollVertically (screenRange, options) { const screenRangeTop = this.pixelPositionAfterBlocksForRow(screenRange.start.row) const screenRangeBottom = this.pixelPositionAfterBlocksForRow(screenRange.end.row) + this.getLineHeight() const verticalScrollMargin = this.getVerticalAutoscrollMargin() let desiredScrollTop, desiredScrollBottom if (options && options.center) { const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2 if (desiredScrollCenter < this.getScrollTop() || desiredScrollCenter > this.getScrollBottom()) { desiredScrollTop = desiredScrollCenter - this.getScrollContainerClientHeight() / 2 desiredScrollBottom = desiredScrollCenter + this.getScrollContainerClientHeight() / 2 } } else { desiredScrollTop = screenRangeTop - verticalScrollMargin desiredScrollBottom = screenRangeBottom + verticalScrollMargin } if (!options || options.reversed !== false) { if (desiredScrollBottom > this.getScrollBottom()) { this.setScrollBottom(desiredScrollBottom) } if (desiredScrollTop < this.getScrollTop()) { this.setScrollTop(desiredScrollTop) } } else { if (desiredScrollTop < this.getScrollTop()) { this.setScrollTop(desiredScrollTop) } if (desiredScrollBottom > this.getScrollBottom()) { this.setScrollBottom(desiredScrollBottom) } } return false } autoscrollHorizontally (screenRange, options) { const horizontalScrollMargin = this.getHorizontalAutoscrollMargin() const gutterContainerWidth = this.getGutterContainerWidth() let left = this.pixelLeftForRowAndColumn(screenRange.start.row, screenRange.start.column) + gutterContainerWidth let right = this.pixelLeftForRowAndColumn(screenRange.end.row, screenRange.end.column) + gutterContainerWidth const desiredScrollLeft = Math.max(0, left - horizontalScrollMargin - gutterContainerWidth) const desiredScrollRight = Math.min(this.getScrollWidth(), right + horizontalScrollMargin) if (!options || options.reversed !== false) { if (desiredScrollRight > this.getScrollRight()) { this.setScrollRight(desiredScrollRight) } if (desiredScrollLeft < this.getScrollLeft()) { this.setScrollLeft(desiredScrollLeft) } } else { if (desiredScrollLeft < this.getScrollLeft()) { this.setScrollLeft(desiredScrollLeft) } if (desiredScrollRight > this.getScrollRight()) { this.setScrollRight(desiredScrollRight) } } } getVerticalAutoscrollMargin () { const maxMarginInLines = Math.floor( (this.getScrollContainerClientHeight() / this.getLineHeight() - 1) / 2 ) const marginInLines = Math.min( this.props.model.verticalScrollMargin, maxMarginInLines ) return marginInLines * this.getLineHeight() } getHorizontalAutoscrollMargin () { const maxMarginInBaseCharacters = Math.floor( (this.getScrollContainerClientWidth() / this.getBaseCharacterWidth() - 1) / 2 ) const marginInBaseCharacters = Math.min( this.props.model.horizontalScrollMargin, maxMarginInBaseCharacters ) return marginInBaseCharacters * this.getBaseCharacterWidth() } // This method is called at the beginning of a frame render to relay any // potential changes in the editor's width into the model before proceeding. updateModelSoftWrapColumn () { const {model} = this.props const newEditorWidthInChars = this.getScrollContainerClientWidthInBaseCharacters() if (newEditorWidthInChars !== model.getEditorWidthInChars()) { this.suppressUpdates = true const renderedStartRow = this.getRenderedStartRow() this.props.model.setEditorWidthInChars(newEditorWidthInChars) // Relaying a change in to the editor's client width may cause the // vertical scrollbar to appear or disappear, which causes the editor's // client width to change *again*. Make sure the display layer is fully // populated for the visible area before recalculating the editor's // width in characters. Then update the display layer *again* just in // case a change in scrollbar visibility causes lines to wrap // differently. We capture the renderedStartRow before resetting the // display layer because once it has been reset, we can't compute the // rendered start row accurately. 😥 this.populateVisibleRowRange(renderedStartRow) this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) this.derivedDimensionsCache = {} this.suppressUpdates = false } } // This method exists because it existed in the previous implementation and some // package tests relied on it measureDimensions () { this.measureCharacterDimensions() this.measureGutterDimensions() this.measureClientContainerHeight() this.measureClientContainerWidth() this.measureScrollbarDimensions() this.hasInitialMeasurements = true } measureCharacterDimensions () { this.measurements.lineHeight = Math.max(1, this.refs.characterMeasurementLine.getBoundingClientRect().height) this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().width this.props.model.setLineHeightInPixels(this.measurements.lineHeight) this.props.model.setDefaultCharWidth( this.measurements.baseCharacterWidth, this.measurements.doubleWidthCharacterWidth, this.measurements.halfWidthCharacterWidth, this.measurements.koreanCharacterWidth ) this.lineTopIndex.setDefaultLineHeight(this.measurements.lineHeight) } measureGutterDimensions () { let dimensionsChanged = false if (this.refs.gutterContainer) { const gutterContainerWidth = this.refs.gutterContainer.element.offsetWidth if (gutterContainerWidth !== this.measurements.gutterContainerWidth) { dimensionsChanged = true this.measurements.gutterContainerWidth = gutterContainerWidth } } else { this.measurements.gutterContainerWidth = 0 } if (this.refs.gutterContainer && this.refs.gutterContainer.refs.lineNumberGutter) { const lineNumberGutterWidth = this.refs.gutterContainer.refs.lineNumberGutter.element.offsetWidth if (lineNumberGutterWidth !== this.measurements.lineNumberGutterWidth) { dimensionsChanged = true this.measurements.lineNumberGutterWidth = lineNumberGutterWidth } } else { this.measurements.lineNumberGutterWidth = 0 } return dimensionsChanged } measureClientContainerHeight () { const clientContainerHeight = this.refs.clientContainer.offsetHeight if (clientContainerHeight !== this.measurements.clientContainerHeight) { this.measurements.clientContainerHeight = clientContainerHeight return true } else { return false } } measureClientContainerWidth () { const clientContainerWidth = this.refs.clientContainer.offsetWidth if (clientContainerWidth !== this.measurements.clientContainerWidth) { this.measurements.clientContainerWidth = clientContainerWidth return true } else { return false } } measureScrollbarDimensions () { if (this.props.model.isMini()) { this.measurements.verticalScrollbarWidth = 0 this.measurements.horizontalScrollbarHeight = 0 } else { this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth() this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() } } measureLongestLineWidth () { if (this.longestLineToMeasure) { const lineComponent = this.lineComponentsByScreenLineId.get(this.longestLineToMeasure.id) this.measurements.longestLineWidth = lineComponent.element.firstChild.offsetWidth this.longestLineToMeasure = null } } requestLineToMeasure (row, screenLine) { this.linesToMeasure.set(row, screenLine) } requestHorizontalMeasurement (row, column) { if (column === 0) return const screenLine = this.props.model.screenLineForScreenRow(row) if (screenLine) { this.requestLineToMeasure(row, screenLine) let columns = this.horizontalPositionsToMeasure.get(row) if (columns == null) { columns = [] this.horizontalPositionsToMeasure.set(row, columns) } columns.push(column) } } measureHorizontalPositions () { this.horizontalPositionsToMeasure.forEach((columnsToMeasure, row) => { columnsToMeasure.sort((a, b) => a - b) const screenLine = this.renderedScreenLineForRow(row) const lineComponent = this.lineComponentsByScreenLineId.get(screenLine.id) if (!lineComponent) { const error = new Error('Requested measurement of a line component that is not currently rendered') error.metadata = { row, columnsToMeasure, renderedScreenLineIds: this.renderedScreenLines.map((line) => line.id), extraRenderedScreenLineIds: Array.from(this.extraRenderedScreenLines.keys()), lineComponentScreenLineIds: Array.from(this.lineComponentsByScreenLineId.keys()), renderedStartRow: this.getRenderedStartRow(), renderedEndRow: this.getRenderedEndRow(), requestedScreenLineId: screenLine.id } throw error } const lineNode = lineComponent.element const textNodes = lineComponent.textNodes let positionsForLine = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id) if (positionsForLine == null) { positionsForLine = new Map() this.horizontalPixelPositionsByScreenLineId.set(screenLine.id, positionsForLine) } this.measureHorizontalPositionsOnLine(lineNode, textNodes, columnsToMeasure, positionsForLine) }) this.horizontalPositionsToMeasure.clear() } measureHorizontalPositionsOnLine (lineNode, textNodes, columnsToMeasure, positions) { let lineNodeClientLeft = -1 let textNodeStartColumn = 0 let textNodesIndex = 0 let lastTextNodeRight = null columnLoop: // eslint-disable-line no-labels for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { const nextColumnToMeasure = columnsToMeasure[columnsIndex] while (textNodesIndex < textNodes.length) { if (nextColumnToMeasure === 0) { positions.set(0, 0) continue columnLoop // eslint-disable-line no-labels } if (positions.has(nextColumnToMeasure)) continue columnLoop // eslint-disable-line no-labels const textNode = textNodes[textNodesIndex] const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length if (nextColumnToMeasure < textNodeEndColumn) { let clientPixelPosition if (nextColumnToMeasure === textNodeStartColumn) { clientPixelPosition = clientRectForRange(textNode, 0, 1).left } else { clientPixelPosition = clientRectForRange(textNode, 0, nextColumnToMeasure - textNodeStartColumn).right } if (lineNodeClientLeft === -1) { lineNodeClientLeft = lineNode.getBoundingClientRect().left } positions.set(nextColumnToMeasure, Math.round(clientPixelPosition - lineNodeClientLeft)) continue columnLoop // eslint-disable-line no-labels } else { textNodesIndex++ textNodeStartColumn = textNodeEndColumn } } if (lastTextNodeRight == null) { const lastTextNode = textNodes[textNodes.length - 1] lastTextNodeRight = clientRectForRange(lastTextNode, 0, lastTextNode.textContent.length).right } if (lineNodeClientLeft === -1) { lineNodeClientLeft = lineNode.getBoundingClientRect().left } positions.set(nextColumnToMeasure, Math.round(lastTextNodeRight - lineNodeClientLeft)) } } rowForPixelPosition (pixelPosition) { return Math.max(0, this.lineTopIndex.rowForPixelPosition(pixelPosition)) } heightForBlockDecorationsBeforeRow (row) { return this.pixelPositionAfterBlocksForRow(row) - this.pixelPositionBeforeBlocksForRow(row) } heightForBlockDecorationsAfterRow (row) { const currentRowBottom = this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight() const nextRowTop = this.pixelPositionBeforeBlocksForRow(row + 1) return nextRowTop - currentRowBottom } pixelPositionBeforeBlocksForRow (row) { return this.lineTopIndex.pixelPositionBeforeBlocksForRow(row) } pixelPositionAfterBlocksForRow (row) { return this.lineTopIndex.pixelPositionAfterBlocksForRow(row) } pixelLeftForRowAndColumn (row, column) { if (column === 0) return 0 const screenLine = this.renderedScreenLineForRow(row) if (screenLine) { const horizontalPositionsByColumn = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id) if (horizontalPositionsByColumn) { return horizontalPositionsByColumn.get(column) } } } screenPositionForPixelPosition ({top, left}) { const {model} = this.props const row = Math.min( this.rowForPixelPosition(top), model.getApproximateScreenLineCount() - 1 ) let screenLine = this.renderedScreenLineForRow(row) if (!screenLine) { this.requestLineToMeasure(row, model.screenLineForScreenRow(row)) this.updateSyncBeforeMeasuringContent() this.measureContentDuringUpdateSync() screenLine = this.renderedScreenLineForRow(row) } const linesClientLeft = this.refs.lineTiles.getBoundingClientRect().left const targetClientLeft = linesClientLeft + Math.max(0, left) const {textNodes} = this.lineComponentsByScreenLineId.get(screenLine.id) let containingTextNodeIndex { let low = 0 let high = textNodes.length - 1 while (low <= high) { const mid = low + ((high - low) >> 1) const textNode = textNodes[mid] const textNodeRect = clientRectForRange(textNode, 0, textNode.length) if (targetClientLeft < textNodeRect.left) { high = mid - 1 containingTextNodeIndex = Math.max(0, mid - 1) } else if (targetClientLeft > textNodeRect.right) { low = mid + 1 containingTextNodeIndex = Math.min(textNodes.length - 1, mid + 1) } else { containingTextNodeIndex = mid break } } } const containingTextNode = textNodes[containingTextNodeIndex] let characterIndex = 0 { let low = 0 let high = containingTextNode.length - 1 while (low <= high) { const charIndex = low + ((high - low) >> 1) const nextCharIndex = isPairedCharacter(containingTextNode.textContent, charIndex) ? charIndex + 2 : charIndex + 1 const rangeRect = clientRectForRange(containingTextNode, charIndex, nextCharIndex) if (targetClientLeft < rangeRect.left) { high = charIndex - 1 characterIndex = Math.max(0, charIndex - 1) } else if (targetClientLeft > rangeRect.right) { low = nextCharIndex characterIndex = Math.min(containingTextNode.textContent.length, nextCharIndex) } else { if (targetClientLeft <= ((rangeRect.left + rangeRect.right) / 2)) { characterIndex = charIndex } else { characterIndex = nextCharIndex } break } } } let textNodeStartColumn = 0 for (let i = 0; i < containingTextNodeIndex; i++) { textNodeStartColumn = textNodeStartColumn + textNodes[i].length } const column = textNodeStartColumn + characterIndex return Point(row, column) } didResetDisplayLayer () { this.spliceLineTopIndex(0, Infinity, Infinity) this.scheduleUpdate() } didChangeDisplayLayer (changes) { for (let i = 0; i < changes.length; i++) { const {oldRange, newRange} = changes[i] this.spliceLineTopIndex( newRange.start.row, oldRange.end.row - oldRange.start.row, newRange.end.row - newRange.start.row ) } this.scheduleUpdate() } didChangeSelectionRange () { const {model} = this.props if (this.getPlatform() === 'linux') { if (this.selectionClipboardImmediateId) { clearImmediate(this.selectionClipboardImmediateId) } this.selectionClipboardImmediateId = setImmediate(() => { this.selectionClipboardImmediateId = null if (model.isDestroyed()) return const selectedText = model.getSelectedText() if (selectedText) { // This uses ipcRenderer.send instead of clipboard.writeText because // clipboard.writeText is a sync ipcRenderer call on Linux and that // will slow down selections. electron.ipcRenderer.send('write-text-to-selection-clipboard', selectedText) } }) } } observeBlockDecorations () { const {model} = this.props const decorations = model.getDecorations({type: 'block'}) for (let i = 0; i < decorations.length; i++) { this.addBlockDecoration(decorations[i]) } } addBlockDecoration (decoration, subscribeToChanges = true) { const marker = decoration.getMarker() const {item, position} = decoration.getProperties() const element = TextEditor.viewForItem(item) if (marker.isValid()) { const row = marker.getHeadScreenPosition().row this.lineTopIndex.insertBlock(decoration, row, 0, position === 'after') this.blockDecorationsToMeasure.add(decoration) this.blockDecorationsByElement.set(element, decoration) this.blockDecorationResizeObserver.observe(element) this.scheduleUpdate() } if (subscribeToChanges) { let wasValid = marker.isValid() const didUpdateDisposable = marker.bufferMarker.onDidChange(({textChanged}) => { const isValid = marker.isValid() if (wasValid && !isValid) { wasValid = false this.blockDecorationsToMeasure.delete(decoration) this.heightsByBlockDecoration.delete(decoration) this.blockDecorationsByElement.delete(element) this.blockDecorationResizeObserver.unobserve(element) this.lineTopIndex.removeBlock(decoration) this.scheduleUpdate() } else if (!wasValid && isValid) { wasValid = true this.addBlockDecoration(decoration, false) } else if (isValid && !textChanged) { this.lineTopIndex.moveBlock(decoration, marker.getHeadScreenPosition().row) this.scheduleUpdate() } }) const didDestroyDisposable = decoration.onDidDestroy(() => { didUpdateDisposable.dispose() didDestroyDisposable.dispose() if (wasValid) { wasValid = false this.blockDecorationsToMeasure.delete(decoration) this.heightsByBlockDecoration.delete(decoration) this.blockDecorationsByElement.delete(element) this.blockDecorationResizeObserver.unobserve(element) this.lineTopIndex.removeBlock(decoration) this.scheduleUpdate() } }) } } didResizeBlockDecorations (entries) { if (!this.visible) return for (let i = 0; i < entries.length; i++) { const {target, contentRect} = entries[i] const decoration = this.blockDecorationsByElement.get(target) const previousHeight = this.heightsByBlockDecoration.get(decoration) if (this.element.contains(target) && contentRect.height !== previousHeight) { this.invalidateBlockDecorationDimensions(decoration) } } } invalidateBlockDecorationDimensions (decoration) { this.blockDecorationsToMeasure.add(decoration) this.scheduleUpdate() } spliceLineTopIndex (startRow, oldExtent, newExtent) { const invalidatedBlockDecorations = this.lineTopIndex.splice(startRow, oldExtent, newExtent) invalidatedBlockDecorations.forEach((decoration) => { const newPosition = decoration.getMarker().getHeadScreenPosition() this.lineTopIndex.moveBlock(decoration, newPosition.row) }) } isVisible () { return this.element.offsetWidth > 0 || this.element.offsetHeight > 0 } getWindowInnerHeight () { return window.innerHeight } getWindowInnerWidth () { return window.innerWidth } getLineHeight () { return this.measurements.lineHeight } getBaseCharacterWidth () { return this.measurements.baseCharacterWidth } getLongestLineWidth () { return this.measurements.longestLineWidth } getClientContainerHeight () { return this.measurements.clientContainerHeight } getClientContainerWidth () { return this.measurements.clientContainerWidth } getScrollContainerWidth () { if (this.props.model.getAutoWidth()) { return this.getScrollWidth() } else { return this.getClientContainerWidth() - this.getGutterContainerWidth() } } getScrollContainerHeight () { if (this.props.model.getAutoHeight()) { return this.getScrollHeight() } else { return this.getClientContainerHeight() } } getScrollContainerClientWidth () { if (this.canScrollVertically()) { return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() } else { return this.getScrollContainerWidth() } } getScrollContainerClientHeight () { if (this.canScrollHorizontally()) { return this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() } else { return this.getScrollContainerHeight() } } canScrollVertically () { const {model} = this.props if (model.isMini()) return false if (model.getAutoHeight()) return false if (this.getContentHeight() > this.getScrollContainerHeight()) return true return ( this.getContentWidth() > this.getScrollContainerWidth() && this.getContentHeight() > (this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()) ) } canScrollHorizontally () { const {model} = this.props if (model.isMini()) return false if (model.getAutoWidth()) return false if (model.isSoftWrapped()) return false if (this.getContentWidth() > this.getScrollContainerWidth()) return true return ( this.getContentHeight() > this.getScrollContainerHeight() && this.getContentWidth() > (this.getScrollContainerWidth() - this.getVerticalScrollbarWidth()) ) } getScrollHeight () { if (this.props.model.getScrollPastEnd()) { return this.getContentHeight() + Math.max( 3 * this.getLineHeight(), this.getScrollContainerClientHeight() - (3 * this.getLineHeight()) ) } else if (this.props.model.getAutoHeight()) { return this.getContentHeight() } else { return Math.max(this.getContentHeight(), this.getScrollContainerClientHeight()) } } getScrollWidth () { const {model} = this.props if (model.isSoftWrapped()) { return this.getScrollContainerClientWidth() } else if (model.getAutoWidth()) { return this.getContentWidth() } else { return Math.max(this.getContentWidth(), this.getScrollContainerClientWidth()) } } getContentHeight () { return this.pixelPositionAfterBlocksForRow(this.props.model.getApproximateScreenLineCount()) } getContentWidth () { return Math.ceil(this.getLongestLineWidth() + this.getBaseCharacterWidth()) } getScrollContainerClientWidthInBaseCharacters () { return Math.floor(this.getScrollContainerClientWidth() / this.getBaseCharacterWidth()) } getGutterContainerWidth () { return this.measurements.gutterContainerWidth } getLineNumberGutterWidth () { return this.measurements.lineNumberGutterWidth } getVerticalScrollbarWidth () { return this.measurements.verticalScrollbarWidth } getHorizontalScrollbarHeight () { return this.measurements.horizontalScrollbarHeight } getRowsPerTile () { return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE } tileStartRowForRow (row) { return row - (row % this.getRowsPerTile()) } getRenderedStartRow () { if (this.derivedDimensionsCache.renderedStartRow == null) { this.derivedDimensionsCache.renderedStartRow = this.tileStartRowForRow(this.getFirstVisibleRow()) } return this.derivedDimensionsCache.renderedStartRow } getRenderedEndRow () { if (this.derivedDimensionsCache.renderedEndRow == null) { this.derivedDimensionsCache.renderedEndRow = Math.min( this.props.model.getApproximateScreenLineCount(), this.getRenderedStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() ) } return this.derivedDimensionsCache.renderedEndRow } getRenderedRowCount () { if (this.derivedDimensionsCache.renderedRowCount == null) { this.derivedDimensionsCache.renderedRowCount = Math.max(0, this.getRenderedEndRow() - this.getRenderedStartRow()) } return this.derivedDimensionsCache.renderedRowCount } getRenderedTileCount () { if (this.derivedDimensionsCache.renderedTileCount == null) { this.derivedDimensionsCache.renderedTileCount = Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) } return this.derivedDimensionsCache.renderedTileCount } getFirstVisibleRow () { if (this.derivedDimensionsCache.firstVisibleRow == null) { this.derivedDimensionsCache.firstVisibleRow = this.rowForPixelPosition(this.getScrollTop()) } return this.derivedDimensionsCache.firstVisibleRow } getLastVisibleRow () { if (this.derivedDimensionsCache.lastVisibleRow == null) { this.derivedDimensionsCache.lastVisibleRow = Math.min( this.props.model.getApproximateScreenLineCount() - 1, this.rowForPixelPosition(this.getScrollBottom()) ) } return this.derivedDimensionsCache.lastVisibleRow } // We may render more tiles than needed if some contain block decorations, // but keeping this calculation simple ensures the number of tiles remains // fixed for a given editor height, which eliminates situations where a // tile is repeatedly added and removed during scrolling in certain // combinations of editor height and line height. getVisibleTileCount () { if (this.derivedDimensionsCache.visibleTileCount == null) { const editorHeightInTiles = this.getScrollContainerHeight() / this.getLineHeight() / this.getRowsPerTile() this.derivedDimensionsCache.visibleTileCount = Math.ceil(editorHeightInTiles) + 1 } return this.derivedDimensionsCache.visibleTileCount } getFirstVisibleColumn () { return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) } getScrollTop () { this.scrollTop = Math.min(this.getMaxScrollTop(), this.scrollTop) return this.scrollTop } setScrollTop (scrollTop) { if (Number.isNaN(scrollTop) || scrollTop == null) return false scrollTop = roundToPhysicalPixelBoundary(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop))) if (scrollTop !== this.scrollTop) { this.derivedDimensionsCache = {} this.scrollTopPending = true this.scrollTop = scrollTop this.element.emitter.emit('did-change-scroll-top', scrollTop) return true } else { return false } } getMaxScrollTop () { return Math.round(Math.max(0, this.getScrollHeight() - this.getScrollContainerClientHeight())) } getScrollBottom () { return this.getScrollTop() + this.getScrollContainerClientHeight() } setScrollBottom (scrollBottom) { return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight()) } getScrollLeft () { return this.scrollLeft } setScrollLeft (scrollLeft) { if (Number.isNaN(scrollLeft) || scrollLeft == null) return false scrollLeft = roundToPhysicalPixelBoundary(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft))) if (scrollLeft !== this.scrollLeft) { this.scrollLeftPending = true this.scrollLeft = scrollLeft this.element.emitter.emit('did-change-scroll-left', scrollLeft) return true } else { return false } } getMaxScrollLeft () { return Math.round(Math.max(0, this.getScrollWidth() - this.getScrollContainerClientWidth())) } getScrollRight () { return this.getScrollLeft() + this.getScrollContainerClientWidth() } setScrollRight (scrollRight) { return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth()) } setScrollTopRow (scrollTopRow, scheduleUpdate = true) { if (this.hasInitialMeasurements) { const didScroll = this.setScrollTop(this.pixelPositionBeforeBlocksForRow(scrollTopRow)) if (didScroll && scheduleUpdate) { this.scheduleUpdate() } return didScroll } else { this.pendingScrollTopRow = scrollTopRow return false } } getScrollTopRow () { if (this.hasInitialMeasurements) { return this.rowForPixelPosition(this.getScrollTop()) } else { return this.pendingScrollTopRow || 0 } } setScrollLeftColumn (scrollLeftColumn, scheduleUpdate = true) { if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { const didScroll = this.setScrollLeft(scrollLeftColumn * this.getBaseCharacterWidth()) if (didScroll && scheduleUpdate) { this.scheduleUpdate() } return didScroll } else { this.pendingScrollLeftColumn = scrollLeftColumn return false } } getScrollLeftColumn () { if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { return Math.round(this.getScrollLeft() / this.getBaseCharacterWidth()) } else { return this.pendingScrollLeftColumn || 0 } } // Ensure the spatial index is populated with rows that are currently visible populateVisibleRowRange (renderedStartRow) { const {model} = this.props const previousScreenLineCount = model.getApproximateScreenLineCount() const renderedEndRow = renderedStartRow + (this.getVisibleTileCount() * this.getRowsPerTile()) this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, renderedEndRow) // If the approximate screen line count changes, previously-cached derived // dimensions could now be out of date. if (model.getApproximateScreenLineCount() !== previousScreenLineCount) { this.derivedDimensionsCache = {} } } populateVisibleTiles () { const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const freeTileIds = [] for (let i = 0; i < this.renderedTileStartRows.length; i++) { const tileStartRow = this.renderedTileStartRows[i] if (tileStartRow < startRow || tileStartRow >= endRow) { const tileId = this.idsByTileStartRow.get(tileStartRow) freeTileIds.push(tileId) this.idsByTileStartRow.delete(tileStartRow) } } const rowsPerTile = this.getRowsPerTile() this.renderedTileStartRows.length = this.getRenderedTileCount() for (let tileStartRow = startRow, i = 0; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile, i++) { this.renderedTileStartRows[i] = tileStartRow if (!this.idsByTileStartRow.has(tileStartRow)) { if (freeTileIds.length > 0) { this.idsByTileStartRow.set(tileStartRow, freeTileIds.shift()) } else { this.idsByTileStartRow.set(tileStartRow, this.nextTileId++) } } } this.renderedTileStartRows.sort((a, b) => this.idsByTileStartRow.get(a) - this.idsByTileStartRow.get(b)) } getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { this.resolveNextUpdatePromise = () => { this.nextUpdatePromise = null this.resolveNextUpdatePromise = null resolve() } }) } return this.nextUpdatePromise } setInputEnabled (inputEnabled) { this.props.model.update({readOnly: !inputEnabled}) } isInputEnabled (inputEnabled) { return !this.props.model.isReadOnly() } getHiddenInput () { return this.refs.cursorsAndInput.refs.hiddenInput } getPlatform () { return this.props.platform || process.platform } getChromeVersion () { return this.props.chromeVersion || parseInt(process.versions.chrome) } } class DummyScrollbarComponent { constructor (props) { this.props = props etch.initialize(this) } update (newProps) { const oldProps = this.props this.props = newProps etch.updateSync(this) const shouldFlushScrollPosition = ( newProps.scrollTop !== oldProps.scrollTop || newProps.scrollLeft !== oldProps.scrollLeft ) if (shouldFlushScrollPosition) this.flushScrollPosition() } flushScrollPosition () { if (this.props.orientation === 'horizontal') { this.element.scrollLeft = this.props.scrollLeft } else { this.element.scrollTop = this.props.scrollTop } } render () { const { orientation, scrollWidth, scrollHeight, verticalScrollbarWidth, horizontalScrollbarHeight, canScroll, forceScrollbarVisible, didScroll } = this.props const outerStyle = { position: 'absolute', contain: 'content', zIndex: 1, willChange: 'transform' } if (!canScroll) outerStyle.visibility = 'hidden' const innerStyle = {} if (orientation === 'horizontal') { let right = (verticalScrollbarWidth || 0) outerStyle.bottom = 0 outerStyle.left = 0 outerStyle.right = right + 'px' outerStyle.height = '15px' outerStyle.overflowY = 'hidden' outerStyle.overflowX = forceScrollbarVisible ? 'scroll' : 'auto' outerStyle.cursor = 'default' innerStyle.height = '15px' innerStyle.width = (scrollWidth || 0) + 'px' } else { let bottom = (horizontalScrollbarHeight || 0) outerStyle.right = 0 outerStyle.top = 0 outerStyle.bottom = bottom + 'px' outerStyle.width = '15px' outerStyle.overflowX = 'hidden' outerStyle.overflowY = forceScrollbarVisible ? 'scroll' : 'auto' outerStyle.cursor = 'default' innerStyle.width = '15px' innerStyle.height = (scrollHeight || 0) + 'px' } return $.div( { className: `${orientation}-scrollbar`, style: outerStyle, on: { scroll: didScroll, mousedown: this.didMouseDown } }, $.div({style: innerStyle}) ) } didMouseDown (event) { let {bottom, right} = this.element.getBoundingClientRect() const clickedOnScrollbar = (this.props.orientation === 'horizontal') ? event.clientY >= (bottom - this.getRealScrollbarHeight()) : event.clientX >= (right - this.getRealScrollbarWidth()) if (!clickedOnScrollbar) this.props.didMouseDown(event) } getRealScrollbarWidth () { return this.element.offsetWidth - this.element.clientWidth } getRealScrollbarHeight () { return this.element.offsetHeight - this.element.clientHeight } } class GutterContainerComponent { constructor (props) { this.props = props etch.initialize(this) } update (props) { if (this.shouldUpdate(props)) { this.props = props etch.updateSync(this) } } shouldUpdate (props) { return ( !props.measuredContent || props.lineNumberGutterWidth !== this.props.lineNumberGutterWidth ) } render () { const {hasInitialMeasurements, scrollTop, scrollHeight, guttersToRender, decorationsToRender} = this.props const innerStyle = { willChange: 'transform', display: 'flex' } if (hasInitialMeasurements) { innerStyle.transform = `translateY(${-roundToPhysicalPixelBoundary(scrollTop)}px)` } return $.div( { ref: 'gutterContainer', key: 'gutterContainer', className: 'gutter-container', style: { position: 'relative', zIndex: 1, backgroundColor: 'inherit' } }, $.div({style: innerStyle}, guttersToRender.map((gutter) => { if (gutter.name === 'line-number') { return this.renderLineNumberGutter(gutter) } else { return $(CustomGutterComponent, { key: gutter, element: gutter.getElement(), name: gutter.name, visible: gutter.isVisible(), height: scrollHeight, decorations: decorationsToRender.customGutter.get(gutter.name) }) } }) ) ) } renderLineNumberGutter (gutter) { const { rootComponent, isLineNumberGutterVisible, showLineNumbers, hasInitialMeasurements, lineNumbersToRender, renderedStartRow, renderedEndRow, rowsPerTile, decorationsToRender, didMeasureVisibleBlockDecoration, scrollHeight, lineNumberGutterWidth, lineHeight } = this.props if (!isLineNumberGutterVisible) return null if (hasInitialMeasurements) { const {maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags} = lineNumbersToRender return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), rootComponent: rootComponent, startRow: renderedStartRow, endRow: renderedEndRow, rowsPerTile: rowsPerTile, maxDigits: maxDigits, keys: keys, bufferRows: bufferRows, screenRows: screenRows, softWrappedFlags: softWrappedFlags, foldableFlags: foldableFlags, decorations: decorationsToRender.lineNumbers, blockDecorations: decorationsToRender.blocks, didMeasureVisibleBlockDecoration: didMeasureVisibleBlockDecoration, height: scrollHeight, width: lineNumberGutterWidth, lineHeight: lineHeight, showLineNumbers }) } else { return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), maxDigits: lineNumbersToRender.maxDigits, showLineNumbers }) } } } class LineNumberGutterComponent { constructor (props) { this.props = props this.element = this.props.element this.virtualNode = $.div(null) this.virtualNode.domNode = this.element this.nodePool = new NodePool() etch.updateSync(this) } update (newProps) { if (this.shouldUpdate(newProps)) { this.props = newProps etch.updateSync(this) } } render () { const { rootComponent, showLineNumbers, height, width, startRow, endRow, rowsPerTile, maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags, decorations } = this.props let children = null if (bufferRows) { children = new Array(rootComponent.renderedTileStartRows.length) for (let i = 0; i < rootComponent.renderedTileStartRows.length; i++) { const tileStartRow = rootComponent.renderedTileStartRows[i] const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileChildren = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { const indexInTile = row - tileStartRow const j = row - startRow const key = keys[j] const softWrapped = softWrappedFlags[j] const foldable = foldableFlags[j] const bufferRow = bufferRows[j] const screenRow = screenRows[j] let className = 'line-number' if (foldable) className = className + ' foldable' const decorationsForRow = decorations[row - startRow] if (decorationsForRow) className = className + ' ' + decorationsForRow let number = null if (showLineNumbers) { number = softWrapped ? '•' : bufferRow + 1 number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number } // We need to adjust the line number position to account for block // decorations preceding the current row and following the preceding // row. Note that we ignore the latter when the line number starts at // the beginning of the tile, because the tile will already be // positioned to take into account block decorations added after the // last row of the previous tile. let marginTop = rootComponent.heightForBlockDecorationsBeforeRow(row) if (indexInTile > 0) marginTop += rootComponent.heightForBlockDecorationsAfterRow(row - 1) tileChildren[row - tileStartRow] = $(LineNumberComponent, { key, className, width, bufferRow, screenRow, number, marginTop, nodePool: this.nodePool }) } const tileTop = rootComponent.pixelPositionBeforeBlocksForRow(tileStartRow) const tileBottom = rootComponent.pixelPositionBeforeBlocksForRow(tileEndRow) const tileHeight = tileBottom - tileTop children[i] = $.div({ key: rootComponent.idsByTileStartRow.get(tileStartRow), style: { contain: 'layout style', position: 'absolute', top: 0, height: tileHeight + 'px', width: width + 'px', transform: `translateY(${tileTop}px)` } }, ...tileChildren) } } return $.div( { className: 'gutter line-numbers', attributes: {'gutter-name': 'line-number'}, style: {position: 'relative', height: ceilToPhysicalPixelBoundary(height) + 'px'}, on: { mousedown: this.didMouseDown } }, $.div({key: 'placeholder', className: 'line-number dummy', style: {visibility: 'hidden'}}, showLineNumbers ? '0'.repeat(maxDigits) : null, $.div({className: 'icon-right'}) ), children ) } shouldUpdate (newProps) { const oldProps = this.props if (oldProps.showLineNumbers !== newProps.showLineNumbers) return true if (oldProps.height !== newProps.height) return true if (oldProps.width !== newProps.width) return true if (oldProps.lineHeight !== newProps.lineHeight) return true if (oldProps.startRow !== newProps.startRow) return true if (oldProps.endRow !== newProps.endRow) return true if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true if (oldProps.maxDigits !== newProps.maxDigits) return true if (newProps.didMeasureVisibleBlockDecoration) return true if (!arraysEqual(oldProps.keys, newProps.keys)) return true if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true if (!arraysEqual(oldProps.decorations, newProps.decorations)) return true let oldTileStartRow = oldProps.startRow let newTileStartRow = newProps.startRow while (oldTileStartRow < oldProps.endRow || newTileStartRow < newProps.endRow) { let oldTileBlockDecorations = oldProps.blockDecorations.get(oldTileStartRow) let newTileBlockDecorations = newProps.blockDecorations.get(newTileStartRow) if (oldTileBlockDecorations && newTileBlockDecorations) { if (oldTileBlockDecorations.size !== newTileBlockDecorations.size) return true let blockDecorationsChanged = false oldTileBlockDecorations.forEach((oldDecorations, screenLineId) => { if (!blockDecorationsChanged) { const newDecorations = newTileBlockDecorations.get(screenLineId) blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) } }) if (blockDecorationsChanged) return true newTileBlockDecorations.forEach((newDecorations, screenLineId) => { if (!blockDecorationsChanged) { const oldDecorations = oldTileBlockDecorations.get(screenLineId) blockDecorationsChanged = (oldDecorations == null) } }) if (blockDecorationsChanged) return true } else if (oldTileBlockDecorations) { return true } else if (newTileBlockDecorations) { return true } oldTileStartRow += oldProps.rowsPerTile newTileStartRow += newProps.rowsPerTile } return false } didMouseDown (event) { this.props.rootComponent.didMouseDownOnLineNumberGutter(event) } } class LineNumberComponent { constructor (props) { const {className, width, marginTop, bufferRow, screenRow, number, nodePool} = props this.props = props const style = {width: width + 'px'} if (marginTop != null && marginTop > 0) style.marginTop = marginTop + 'px' this.element = nodePool.getElement('DIV', className, style) this.element.dataset.bufferRow = bufferRow this.element.dataset.screenRow = screenRow if (number) this.element.appendChild(nodePool.getTextNode(number)) this.element.appendChild(nodePool.getElement('DIV', 'icon-right', null)) } destroy () { this.element.remove() this.props.nodePool.release(this.element) } update (props) { const {nodePool, className, width, marginTop, bufferRow, screenRow, number} = props if (this.props.bufferRow !== bufferRow) this.element.dataset.bufferRow = bufferRow if (this.props.screenRow !== screenRow) this.element.dataset.screenRow = screenRow if (this.props.className !== className) this.element.className = className if (this.props.width !== width) this.element.style.width = width + 'px' if (this.props.marginTop !== marginTop) { if (marginTop != null) { this.element.style.marginTop = marginTop + 'px' } else { this.element.style.marginTop = '' } } if (this.props.number !== number) { if (number) { this.element.insertBefore(nodePool.getTextNode(number), this.element.firstChild) } else { const numberNode = this.element.firstChild numberNode.remove() nodePool.release(numberNode) } } this.props = props } } class CustomGutterComponent { constructor (props) { this.props = props this.element = this.props.element this.virtualNode = $.div(null) this.virtualNode.domNode = this.element etch.updateSync(this) } update (props) { this.props = props etch.updateSync(this) } destroy () { etch.destroy(this) } render () { return $.div( { className: 'gutter', attributes: {'gutter-name': this.props.name}, style: { display: this.props.visible ? '' : 'none' } }, $.div( { className: 'custom-decorations', style: {height: this.props.height + 'px'} }, this.renderDecorations() ) ) } renderDecorations () { if (!this.props.decorations) return null return this.props.decorations.map(({className, element, top, height}) => { return $(CustomGutterDecorationComponent, { className, element, top, height }) }) } } class CustomGutterDecorationComponent { constructor (props) { this.props = props this.element = document.createElement('div') const {top, height, className, element} = this.props this.element.style.position = 'absolute' this.element.style.top = top + 'px' this.element.style.height = height + 'px' if (className != null) this.element.className = className if (element != null) { this.element.appendChild(element) element.style.height = height + 'px' } } update (newProps) { const oldProps = this.props this.props = newProps if (newProps.top !== oldProps.top) this.element.style.top = newProps.top + 'px' if (newProps.height !== oldProps.height) { this.element.style.height = newProps.height + 'px' if (newProps.element) newProps.element.style.height = newProps.height + 'px' } if (newProps.className !== oldProps.className) this.element.className = newProps.className || '' if (newProps.element !== oldProps.element) { if (this.element.firstChild) this.element.firstChild.remove() if (newProps.element != null) { this.element.appendChild(newProps.element) newProps.element.style.height = newProps.height + 'px' } } } } class CursorsAndInputComponent { constructor (props) { this.props = props etch.initialize(this) } update (props) { if (props.measuredContent) { this.props = props etch.updateSync(this) } } updateCursorBlinkSync (cursorsBlinkedOff) { this.props.cursorsBlinkedOff = cursorsBlinkedOff const className = this.getCursorsClassName() this.refs.cursors.className = className this.virtualNode.props.className = className } render () { const {lineHeight, decorationsToRender, scrollHeight, scrollWidth} = this.props const className = this.getCursorsClassName() const cursorHeight = lineHeight + 'px' const children = [this.renderHiddenInput()] for (let i = 0; i < decorationsToRender.cursors.length; i++) { const {pixelLeft, pixelTop, pixelWidth, className: extraCursorClassName, style: extraCursorStyle} = decorationsToRender.cursors[i] let cursorClassName = 'cursor' if (extraCursorClassName) cursorClassName += ' ' + extraCursorClassName const cursorStyle = { height: cursorHeight, width: Math.min(pixelWidth, scrollWidth - pixelLeft) + 'px', transform: `translate(${pixelLeft}px, ${pixelTop}px)` } if (extraCursorStyle) Object.assign(cursorStyle, extraCursorStyle) children.push($.div({ className: cursorClassName, style: cursorStyle })) } return $.div({ key: 'cursors', ref: 'cursors', className, style: { position: 'absolute', contain: 'strict', zIndex: 1, width: scrollWidth + 'px', height: scrollHeight + 'px', pointerEvents: 'none', userSelect: 'none' } }, children) } getCursorsClassName () { return this.props.cursorsBlinkedOff ? 'cursors blink-off' : 'cursors' } renderHiddenInput () { const { lineHeight, hiddenInputPosition, didBlurHiddenInput, didFocusHiddenInput, didPaste, didTextInput, didKeydown, didKeyup, didKeypress, didCompositionStart, didCompositionUpdate, didCompositionEnd, tabIndex } = this.props let top, left if (hiddenInputPosition) { top = hiddenInputPosition.pixelTop left = hiddenInputPosition.pixelLeft } else { top = 0 left = 0 } return $.input({ ref: 'hiddenInput', key: 'hiddenInput', className: 'hidden-input', on: { blur: didBlurHiddenInput, focus: didFocusHiddenInput, paste: didPaste, textInput: didTextInput, keydown: didKeydown, keyup: didKeyup, keypress: didKeypress, compositionstart: didCompositionStart, compositionupdate: didCompositionUpdate, compositionend: didCompositionEnd }, tabIndex: tabIndex, style: { position: 'absolute', width: '1px', height: lineHeight + 'px', top: top + 'px', left: left + 'px', opacity: 0, padding: 0, border: 0 } }) } } class LinesTileComponent { constructor (props) { this.props = props etch.initialize(this) this.createLines() this.updateBlockDecorations({}, props) } update (newProps) { if (this.shouldUpdate(newProps)) { const oldProps = this.props this.props = newProps etch.updateSync(this) if (!newProps.measuredContent) { this.updateLines(oldProps, newProps) this.updateBlockDecorations(oldProps, newProps) } } } destroy () { for (let i = 0; i < this.lineComponents.length; i++) { this.lineComponents[i].destroy() } this.lineComponents.length = 0 return etch.destroy(this) } render () { const {height, width, top} = this.props return $.div( { style: { contain: 'layout style', position: 'absolute', height: height + 'px', width: width + 'px', transform: `translateY(${top}px)` } } // Lines and block decorations will be manually inserted here for efficiency ) } createLines () { const { tileStartRow, screenLines, lineDecorations, textDecorations, nodePool, displayLayer, lineComponentsByScreenLineId } = this.props this.lineComponents = [] for (let i = 0, length = screenLines.length; i < length; i++) { const component = new LineComponent({ screenLine: screenLines[i], screenRow: tileStartRow + i, lineDecoration: lineDecorations[i], textDecorations: textDecorations[i], displayLayer, nodePool, lineComponentsByScreenLineId }) this.element.appendChild(component.element) this.lineComponents.push(component) } } updateLines (oldProps, newProps) { var { screenLines, tileStartRow, lineDecorations, textDecorations, nodePool, displayLayer, lineComponentsByScreenLineId } = newProps var oldScreenLines = oldProps.screenLines var newScreenLines = screenLines var oldScreenLinesEndIndex = oldScreenLines.length var newScreenLinesEndIndex = newScreenLines.length var oldScreenLineIndex = 0 var newScreenLineIndex = 0 var lineComponentIndex = 0 while (oldScreenLineIndex < oldScreenLinesEndIndex || newScreenLineIndex < newScreenLinesEndIndex) { var oldScreenLine = oldScreenLines[oldScreenLineIndex] var newScreenLine = newScreenLines[newScreenLineIndex] if (oldScreenLineIndex >= oldScreenLinesEndIndex) { var newScreenLineComponent = new LineComponent({ screenLine: newScreenLine, screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], textDecorations: textDecorations[newScreenLineIndex], displayLayer, nodePool, lineComponentsByScreenLineId }) this.element.appendChild(newScreenLineComponent.element) this.lineComponents.push(newScreenLineComponent) newScreenLineIndex++ lineComponentIndex++ } else if (newScreenLineIndex >= newScreenLinesEndIndex) { this.lineComponents[lineComponentIndex].destroy() this.lineComponents.splice(lineComponentIndex, 1) oldScreenLineIndex++ } else if (oldScreenLine === newScreenLine) { var lineComponent = this.lineComponents[lineComponentIndex] lineComponent.update({ screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], textDecorations: textDecorations[newScreenLineIndex] }) oldScreenLineIndex++ newScreenLineIndex++ lineComponentIndex++ } else { var oldScreenLineIndexInNewScreenLines = newScreenLines.indexOf(oldScreenLine) var newScreenLineIndexInOldScreenLines = oldScreenLines.indexOf(newScreenLine) if (newScreenLineIndex < oldScreenLineIndexInNewScreenLines && oldScreenLineIndexInNewScreenLines < newScreenLinesEndIndex) { var newScreenLineComponents = [] while (newScreenLineIndex < oldScreenLineIndexInNewScreenLines) { var newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare screenLine: newScreenLines[newScreenLineIndex], screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], textDecorations: textDecorations[newScreenLineIndex], displayLayer, nodePool, lineComponentsByScreenLineId }) this.element.insertBefore(newScreenLineComponent.element, this.getFirstElementForScreenLine(oldProps, oldScreenLine)) newScreenLineComponents.push(newScreenLineComponent) newScreenLineIndex++ } this.lineComponents.splice(lineComponentIndex, 0, ...newScreenLineComponents) lineComponentIndex = lineComponentIndex + newScreenLineComponents.length } else if (oldScreenLineIndex < newScreenLineIndexInOldScreenLines && newScreenLineIndexInOldScreenLines < oldScreenLinesEndIndex) { while (oldScreenLineIndex < newScreenLineIndexInOldScreenLines) { this.lineComponents[lineComponentIndex].destroy() this.lineComponents.splice(lineComponentIndex, 1) oldScreenLineIndex++ } } else { var oldScreenLineComponent = this.lineComponents[lineComponentIndex] var newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare screenLine: newScreenLines[newScreenLineIndex], screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], textDecorations: textDecorations[newScreenLineIndex], displayLayer, nodePool, lineComponentsByScreenLineId }) this.element.insertBefore(newScreenLineComponent.element, oldScreenLineComponent.element) oldScreenLineComponent.destroy() this.lineComponents[lineComponentIndex] = newScreenLineComponent oldScreenLineIndex++ newScreenLineIndex++ lineComponentIndex++ } } } } getFirstElementForScreenLine (oldProps, screenLine) { var blockDecorations = oldProps.blockDecorations ? oldProps.blockDecorations.get(screenLine.id) : null if (blockDecorations) { var blockDecorationElementsBeforeOldScreenLine = [] for (let i = 0; i < blockDecorations.length; i++) { var decoration = blockDecorations[i] if (decoration.position !== 'after') { blockDecorationElementsBeforeOldScreenLine.push( TextEditor.viewForItem(decoration.item) ) } } for (let i = 0; i < blockDecorationElementsBeforeOldScreenLine.length; i++) { var blockDecorationElement = blockDecorationElementsBeforeOldScreenLine[i] if (!blockDecorationElementsBeforeOldScreenLine.includes(blockDecorationElement.previousSibling)) { return blockDecorationElement } } } return oldProps.lineComponentsByScreenLineId.get(screenLine.id).element } updateBlockDecorations (oldProps, newProps) { var {blockDecorations, lineComponentsByScreenLineId} = newProps if (oldProps.blockDecorations) { oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { var newDecorations = newProps.blockDecorations ? newProps.blockDecorations.get(screenLineId) : null for (var i = 0; i < oldDecorations.length; i++) { var oldDecoration = oldDecorations[i] if (newDecorations && newDecorations.includes(oldDecoration)) continue var element = TextEditor.viewForItem(oldDecoration.item) if (element.parentElement !== this.element) continue element.remove() } }) } if (blockDecorations) { blockDecorations.forEach((newDecorations, screenLineId) => { var oldDecorations = oldProps.blockDecorations ? oldProps.blockDecorations.get(screenLineId) : null for (var i = 0; i < newDecorations.length; i++) { var newDecoration = newDecorations[i] if (oldDecorations && oldDecorations.includes(newDecoration)) continue var element = TextEditor.viewForItem(newDecoration.item) var lineNode = lineComponentsByScreenLineId.get(screenLineId).element if (newDecoration.position === 'after') { this.element.insertBefore(element, lineNode.nextSibling) } else { this.element.insertBefore(element, lineNode) } } }) } } shouldUpdate (newProps) { const oldProps = this.props if (oldProps.top !== newProps.top) return true if (oldProps.height !== newProps.height) return true if (oldProps.width !== newProps.width) return true if (oldProps.lineHeight !== newProps.lineHeight) return true if (oldProps.tileStartRow !== newProps.tileStartRow) return true if (oldProps.tileEndRow !== newProps.tileEndRow) return true if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true if (oldProps.blockDecorations && newProps.blockDecorations) { if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) return true let blockDecorationsChanged = false oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { if (!blockDecorationsChanged) { const newDecorations = newProps.blockDecorations.get(screenLineId) blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) } }) if (blockDecorationsChanged) return true newProps.blockDecorations.forEach((newDecorations, screenLineId) => { if (!blockDecorationsChanged) { const oldDecorations = oldProps.blockDecorations.get(screenLineId) blockDecorationsChanged = (oldDecorations == null) } }) if (blockDecorationsChanged) return true } else if (oldProps.blockDecorations) { return true } else if (newProps.blockDecorations) { return true } if (oldProps.textDecorations.length !== newProps.textDecorations.length) return true for (let i = 0; i < oldProps.textDecorations.length; i++) { if (!textDecorationsEqual(oldProps.textDecorations[i], newProps.textDecorations[i])) return true } return false } } class LineComponent { constructor (props) { const {nodePool, screenRow, screenLine, lineComponentsByScreenLineId, offScreen} = props this.props = props this.element = nodePool.getElement('DIV', this.buildClassName(), null) this.element.dataset.screenRow = screenRow this.textNodes = [] if (offScreen) { this.element.style.position = 'absolute' this.element.style.visibility = 'hidden' this.element.dataset.offScreen = true } this.appendContents() lineComponentsByScreenLineId.set(screenLine.id, this) } update (newProps) { if (this.props.lineDecoration !== newProps.lineDecoration) { this.props.lineDecoration = newProps.lineDecoration this.element.className = this.buildClassName() } if (this.props.screenRow !== newProps.screenRow) { this.props.screenRow = newProps.screenRow this.element.dataset.screenRow = newProps.screenRow } if (!textDecorationsEqual(this.props.textDecorations, newProps.textDecorations)) { this.props.textDecorations = newProps.textDecorations this.element.firstChild.remove() this.appendContents() } } destroy () { const {nodePool, lineComponentsByScreenLineId, screenLine} = this.props if (lineComponentsByScreenLineId.get(screenLine.id) === this) { lineComponentsByScreenLineId.delete(screenLine.id) } this.element.remove() nodePool.release(this.element) } appendContents () { const {displayLayer, nodePool, screenLine, textDecorations} = this.props this.textNodes.length = 0 const {lineText, tags} = screenLine let openScopeNode = nodePool.getElement('SPAN', null, null) this.element.appendChild(openScopeNode) let decorationIndex = 0 let column = 0 let activeClassName = null let activeStyle = null let nextDecoration = textDecorations ? textDecorations[decorationIndex] : null if (nextDecoration && nextDecoration.column === 0) { column = nextDecoration.column activeClassName = nextDecoration.className activeStyle = nextDecoration.style nextDecoration = textDecorations[++decorationIndex] } for (let i = 0; i < tags.length; i++) { const tag = tags[i] if (tag !== 0) { if (displayLayer.isCloseTag(tag)) { openScopeNode = openScopeNode.parentElement } else if (displayLayer.isOpenTag(tag)) { const newScopeNode = nodePool.getElement('SPAN', displayLayer.classNameForTag(tag), null) openScopeNode.appendChild(newScopeNode) openScopeNode = newScopeNode } else { const nextTokenColumn = column + tag while (nextDecoration && nextDecoration.column <= nextTokenColumn) { const text = lineText.substring(column, nextDecoration.column) this.appendTextNode(openScopeNode, text, activeClassName, activeStyle) column = nextDecoration.column activeClassName = nextDecoration.className activeStyle = nextDecoration.style nextDecoration = textDecorations[++decorationIndex] } if (column < nextTokenColumn) { const text = lineText.substring(column, nextTokenColumn) this.appendTextNode(openScopeNode, text, activeClassName, activeStyle) column = nextTokenColumn } } } } if (column === 0) { const textNode = nodePool.getTextNode(' ') this.element.appendChild(textNode) this.textNodes.push(textNode) } if (lineText.endsWith(displayLayer.foldCharacter)) { // Insert a zero-width non-breaking whitespace, so that LinesYardstick can // take the fold-marker::after pseudo-element into account during // measurements when such marker is the last character on the line. const textNode = nodePool.getTextNode(ZERO_WIDTH_NBSP_CHARACTER) this.element.appendChild(textNode) this.textNodes.push(textNode) } } appendTextNode (openScopeNode, text, activeClassName, activeStyle) { const {nodePool} = this.props if (activeClassName || activeStyle) { const decorationNode = nodePool.getElement('SPAN', activeClassName, activeStyle) openScopeNode.appendChild(decorationNode) openScopeNode = decorationNode } const textNode = nodePool.getTextNode(text) openScopeNode.appendChild(textNode) this.textNodes.push(textNode) } buildClassName () { const {lineDecoration} = this.props let className = 'line' if (lineDecoration != null) className = className + ' ' + lineDecoration return className } } class HighlightsComponent { constructor (props) { this.props = {} this.element = document.createElement('div') this.element.className = 'highlights' this.element.style.contain = 'strict' this.element.style.position = 'absolute' this.element.style.overflow = 'hidden' this.element.style.userSelect = 'none' this.highlightComponentsByKey = new Map() this.update(props) } destroy () { this.highlightComponentsByKey.forEach((highlightComponent) => { highlightComponent.destroy() }) this.highlightComponentsByKey.clear() } update (newProps) { if (this.shouldUpdate(newProps)) { this.props = newProps const {height, width, lineHeight, highlightDecorations} = this.props this.element.style.height = height + 'px' this.element.style.width = width + 'px' const visibleHighlightDecorations = new Set() if (highlightDecorations) { for (let i = 0; i < highlightDecorations.length; i++) { const highlightDecoration = highlightDecorations[i] const highlightProps = Object.assign({lineHeight}, highlightDecorations[i]) let highlightComponent = this.highlightComponentsByKey.get(highlightDecoration.key) if (highlightComponent) { highlightComponent.update(highlightProps) } else { highlightComponent = new HighlightComponent(highlightProps) this.element.appendChild(highlightComponent.element) this.highlightComponentsByKey.set(highlightDecoration.key, highlightComponent) } highlightDecorations[i].flashRequested = false visibleHighlightDecorations.add(highlightDecoration.key) } } this.highlightComponentsByKey.forEach((highlightComponent, key) => { if (!visibleHighlightDecorations.has(key)) { highlightComponent.destroy() this.highlightComponentsByKey.delete(key) } }) } } shouldUpdate (newProps) { const oldProps = this.props if (!newProps.hasInitialMeasurements) return false if (oldProps.width !== newProps.width) return true if (oldProps.height !== newProps.height) return true if (oldProps.lineHeight !== newProps.lineHeight) return true if (!oldProps.highlightDecorations && newProps.highlightDecorations) return true if (oldProps.highlightDecorations && !newProps.highlightDecorations) return true if (oldProps.highlightDecorations && newProps.highlightDecorations) { if (oldProps.highlightDecorations.length !== newProps.highlightDecorations.length) return true for (let i = 0, length = oldProps.highlightDecorations.length; i < length; i++) { const oldHighlight = oldProps.highlightDecorations[i] const newHighlight = newProps.highlightDecorations[i] if (oldHighlight.className !== newHighlight.className) return true if (newHighlight.flashRequested) return true if (oldHighlight.startPixelTop !== newHighlight.startPixelTop) return true if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) return true if (oldHighlight.endPixelTop !== newHighlight.endPixelTop) return true if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true } } } } class HighlightComponent { constructor (props) { this.props = props etch.initialize(this) if (this.props.flashRequested) this.performFlash() } destroy () { if (this.timeoutsByClassName) { this.timeoutsByClassName.forEach((timeout) => { window.clearTimeout(timeout) }) this.timeoutsByClassName.clear() } return etch.destroy(this) } update (newProps) { this.props = newProps etch.updateSync(this) if (newProps.flashRequested) this.performFlash() } performFlash () { const {flashClass, flashDuration} = this.props if (!this.timeoutsByClassName) this.timeoutsByClassName = new Map() // If a flash of this class is already in progress, clear it early and // flash again on the next frame to ensure CSS transitions apply to the // second flash. if (this.timeoutsByClassName.has(flashClass)) { window.clearTimeout(this.timeoutsByClassName.get(flashClass)) this.timeoutsByClassName.delete(flashClass) this.element.classList.remove(flashClass) requestAnimationFrame(() => this.performFlash()) } else { this.element.classList.add(flashClass) this.timeoutsByClassName.set(flashClass, window.setTimeout(() => { this.element.classList.remove(flashClass) }, flashDuration)) } } render () { const { className, screenRange, lineHeight, startPixelTop, startPixelLeft, endPixelTop, endPixelLeft } = this.props const regionClassName = 'region ' + className let children if (screenRange.start.row === screenRange.end.row) { children = $.div({ className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', top: startPixelTop + 'px', left: startPixelLeft + 'px', width: endPixelLeft - startPixelLeft + 'px', height: lineHeight + 'px' } }) } else { children = [] children.push($.div({ className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', top: startPixelTop + 'px', left: startPixelLeft + 'px', right: 0, height: lineHeight + 'px' } })) if (screenRange.end.row - screenRange.start.row > 1) { children.push($.div({ className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', top: startPixelTop + lineHeight + 'px', left: 0, right: 0, height: endPixelTop - startPixelTop - (lineHeight * 2) + 'px' } })) } if (endPixelLeft > 0) { children.push($.div({ className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', top: endPixelTop - lineHeight + 'px', left: 0, width: endPixelLeft + 'px', height: lineHeight + 'px' } })) } } return $.div({className: 'highlight ' + className}, children) } } class OverlayComponent { constructor (props) { this.props = props this.element = document.createElement('atom-overlay') if (this.props.className != null) this.element.classList.add(this.props.className) this.element.appendChild(this.props.element) this.element.style.position = 'fixed' this.element.style.zIndex = 4 this.element.style.top = (this.props.pixelTop || 0) + 'px' this.element.style.left = (this.props.pixelLeft || 0) + 'px' this.currentContentRect = null // Synchronous DOM updates in response to resize events might trigger a // "loop limit exceeded" error. We disconnect the observer before // potentially mutating the DOM, and then reconnect it on the next tick. // Note: ResizeObserver calls its callback when .observe is called this.resizeObserver = new ResizeObserver((entries) => { const {contentRect} = entries[0] if ( this.currentContentRect && (this.currentContentRect.width !== contentRect.width || this.currentContentRect.height !== contentRect.height) ) { this.resizeObserver.disconnect() this.props.didResize(this) process.nextTick(() => { this.resizeObserver.observe(this.props.element) }) } this.currentContentRect = contentRect }) this.didAttach() this.props.overlayComponents.add(this) } destroy () { this.props.overlayComponents.delete(this) this.didDetach() } getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { this.resolveNextUpdatePromise = () => { this.nextUpdatePromise = null this.resolveNextUpdatePromise = null resolve() } }) } return this.nextUpdatePromise } update (newProps) { const oldProps = this.props this.props = Object.assign({}, oldProps, newProps) if (this.props.pixelTop != null) this.element.style.top = this.props.pixelTop + 'px' if (this.props.pixelLeft != null) this.element.style.left = this.props.pixelLeft + 'px' if (newProps.className !== oldProps.className) { if (oldProps.className != null) this.element.classList.remove(oldProps.className) if (newProps.className != null) this.element.classList.add(newProps.className) } if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() } didAttach () { this.resizeObserver.observe(this.props.element) } didDetach () { this.resizeObserver.disconnect() } } let rangeForMeasurement function clientRectForRange (textNode, startIndex, endIndex) { if (!rangeForMeasurement) rangeForMeasurement = document.createRange() rangeForMeasurement.setStart(textNode, startIndex) rangeForMeasurement.setEnd(textNode, endIndex) return rangeForMeasurement.getBoundingClientRect() } function textDecorationsEqual (oldDecorations, newDecorations) { if (!oldDecorations && newDecorations) return false if (oldDecorations && !newDecorations) return false if (oldDecorations && newDecorations) { if (oldDecorations.length !== newDecorations.length) return false for (let j = 0; j < oldDecorations.length; j++) { if (oldDecorations[j].column !== newDecorations[j].column) return false if (oldDecorations[j].className !== newDecorations[j].className) return false if (!objectsEqual(oldDecorations[j].style, newDecorations[j].style)) return false } } return true } function arraysEqual (a, b) { if (a.length !== b.length) return false for (let i = 0, length = a.length; i < length; i++) { if (a[i] !== b[i]) return false } return true } function objectsEqual (a, b) { if (!a && b) return false if (a && !b) return false if (a && b) { for (const key in a) { if (a[key] !== b[key]) return false } for (const key in b) { if (a[key] !== b[key]) return false } } return true } function constrainRangeToRows (range, startRow, endRow) { if (range.start.row < startRow || range.end.row >= endRow) { range = range.copy() if (range.start.row < startRow) { range.start.row = startRow range.start.column = 0 } if (range.end.row >= endRow) { range.end.row = endRow range.end.column = 0 } } return range } function debounce (fn, wait) { let timestamp, timeout function later () { const last = Date.now() - timestamp if (last < wait && last >= 0) { timeout = setTimeout(later, wait - last) } else { timeout = null fn() } } return function () { timestamp = Date.now() if (!timeout) timeout = setTimeout(later, wait) } } class NodePool { constructor () { this.elementsByType = {} this.textNodes = [] } getElement (type, className, style) { var element var elementsByDepth = this.elementsByType[type] if (elementsByDepth) { while (elementsByDepth.length > 0) { var elements = elementsByDepth[elementsByDepth.length - 1] if (elements && elements.length > 0) { element = elements.pop() if (elements.length === 0) elementsByDepth.pop() break } else { elementsByDepth.pop() } } } if (element) { element.className = className || '' element.styleMap.forEach((value, key) => { if (!style || style[key] == null) element.style[key] = '' }) if (style) Object.assign(element.style, style) for (const key in element.dataset) delete element.dataset[key] while (element.firstChild) element.firstChild.remove() return element } else { var newElement = document.createElement(type) if (className) newElement.className = className if (style) Object.assign(newElement.style, style) return newElement } } getTextNode (text) { if (this.textNodes.length > 0) { var node = this.textNodes.pop() node.textContent = text return node } else { return document.createTextNode(text) } } release (node, depth = 0) { var {nodeName} = node if (nodeName === '#text') { this.textNodes.push(node) } else { var elementsByDepth = this.elementsByType[nodeName] if (!elementsByDepth) { elementsByDepth = [] this.elementsByType[nodeName] = elementsByDepth } var elements = elementsByDepth[depth] if (!elements) { elements = [] elementsByDepth[depth] = elements } elements.push(node) for (var i = 0; i < node.childNodes.length; i++) { this.release(node.childNodes[i], depth + 1) } } } } function roundToPhysicalPixelBoundary (virtualPixelPosition) { const virtualPixelsPerPhysicalPixel = (1 / window.devicePixelRatio) return Math.round(virtualPixelPosition / virtualPixelsPerPhysicalPixel) * virtualPixelsPerPhysicalPixel } function ceilToPhysicalPixelBoundary (virtualPixelPosition) { const virtualPixelsPerPhysicalPixel = (1 / window.devicePixelRatio) return Math.ceil(virtualPixelPosition / virtualPixelsPerPhysicalPixel) * virtualPixelsPerPhysicalPixel }