const etch = require('etch') const {CompositeDisposable} = require('event-kit') const {Point, Range} = require('text-buffer') const ResizeDetector = require('element-resize-detector') const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') 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 MOUSE_WHEEL_SCROLL_SENSITIVITY = 0.8 function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 } module.exports = class TextEditorComponent { static setScheduler (scheduler) { etch.setScheduler(scheduler) } static didUpdateScrollbarStyles () { if (this.attachedComponents) { this.attachedComponents.forEach((component) => { component.didUpdateScrollbarStyles() }) } } constructor (props) { this.props = props if (!props.model) props.model = new TextEditor() 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.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.disposables = new CompositeDisposable() this.updateScheduled = false this.measurements = null this.visible = false 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 horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.shouldRenderDummyScrollbars = true this.refreshedScrollbarStyle = 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.decorationsToRender = { lineNumbers: new Map(), lines: new Map(), highlights: new Map(), cursors: [], overlays: [] } this.decorationsToMeasure = { highlights: new Map(), cursors: [] } this.observeModel() getElementResizeDetector().listenTo(this.element, this.didResize.bind(this)) etch.updateSync(this) } update (props) { this.props = props this.scheduleUpdate() } scheduleUpdate () { if (!this.visible) return if (this.updatedSynchronously) { this.updateSync() } else if (!this.updateScheduled) { this.updateScheduled = true etch.getScheduler().updateDocument(() => { if (this.updateScheduled) this.updateSync(true) }) } } updateSync (useScheduler = false) { this.updateScheduled = false if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { const scheduler = etch.getScheduler() scheduler.readDocument(() => { this.measureContentDuringUpdateSync() scheduler.updateDocument(() => { this.updateSyncAfterMeasuringContent() }) }) } else { this.measureContentDuringUpdateSync() this.updateSyncAfterMeasuringContent() } } updateSyncBeforeMeasuringContent () { this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() this.queryDecorationsToRender() this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle etch.updateSync(this) this.shouldRenderDummyScrollbars = true } measureContentDuringUpdateSync () { this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() this.measureLongestLineWidth() if (this.pendingAutoscroll) { this.autoscrollHorizontally() if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { this.autoscrollVertically() } this.pendingAutoscroll = null } } updateSyncAfterMeasuringContent () { etch.updateSync(this) this.currentFrameLineNumberGutterProps = null this.scrollTopPending = false this.scrollLeftPending = false if (this.refreshedScrollbarStyle) { this.measureScrollbarDimensions() this.refreshedScrollbarStyle = false etch.updateSync(this) } } render () { const {model} = this.props const style = {} if (!model.getAutoHeight() && !model.getAutoWidth()) { style.contain = 'size' } if (this.measurements) { if (model.getAutoHeight()) { style.height = this.getContentHeight() + 'px' } if (model.getAutoWidth()) { style.width = this.getGutterContainerWidth() + this.getContentWidth() + 'px' } } let attributes = null let className = 'editor' if (this.focused) className += ' is-focused' if (model.isMini()) { attributes = {mini: ''} className += ' mini' } return $('atom-text-editor', { className, style, attributes, tabIndex: -1, on: { focus: this.didFocus, blur: this.didBlur, mousewheel: this.didMouseWheel } }, $.div( { ref: 'clientContainer', style: { position: 'relative', contain: 'strict', overflow: 'hidden', backgroundColor: 'inherit', width: '100%', height: '100%' } }, this.renderGutterContainer(), this.renderScrollContainer() ), this.renderOverlayDecorations() ) } renderGutterContainer () { if (this.props.model.isMini()) return null const innerStyle = { willChange: 'transform', backgroundColor: 'inherit' } if (this.measurements) { innerStyle.transform = `translateY(${-this.getScrollTop()}px)` } return $.div( { ref: 'gutterContainer', className: 'gutter-container', style: { position: 'relative', zIndex: 1, backgroundColor: 'inherit' } }, $.div({style: innerStyle}, this.renderLineNumberGutter() ) ) } renderLineNumberGutter () { const {model} = this.props if (!model.isLineNumberGutterVisible()) return null if (this.currentFrameLineNumberGutterProps) { return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) } const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length) if (this.measurements) { const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const renderedRowCount = this.getRenderedRowCount() const bufferRows = new Array(renderedRowCount) const foldableFlags = new Array(renderedRowCount) const softWrappedFlags = new Array(renderedRowCount) const lineNumberDecorations = new Array(renderedRowCount) let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 for (let row = startRow; row < endRow; row++) { const i = row - startRow const bufferRow = model.bufferRowForScreenRow(row) bufferRows[i] = bufferRow softWrappedFlags[i] = bufferRow === previousBufferRow foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) lineNumberDecorations[i] = this.decorationsToRender.lineNumbers.get(row) previousBufferRow = bufferRow } const rowsPerTile = this.getRowsPerTile() this.currentFrameLineNumberGutterProps = { ref: 'lineNumberGutter', parentComponent: this, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), lineHeight: this.getLineHeight(), startRow, endRow, rowsPerTile, maxLineNumberDigits, bufferRows, lineNumberDecorations, softWrappedFlags, foldableFlags } return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) } else { return $.div( { ref: 'lineNumberGutter', className: 'gutter line-numbers', 'gutter-name': 'line-number' }, $.div({className: 'line-number'}, '0'.repeat(maxLineNumberDigits), $.div({className: 'icon-right'}) ) ) } } renderScrollContainer () { const style = { position: 'absolute', contain: 'strict', overflow: 'hidden', top: 0, bottom: 0, backgroundColor: 'inherit' } if (this.measurements) { style.left = this.getGutterContainerWidth() + 'px' style.width = this.getScrollContainerWidth() + 'px' } return $.div( { ref: 'scrollContainer', className: 'scroll-view', style }, this.renderContent(), this.renderDummyScrollbars() ) } renderContent () { let children let style = { contain: 'strict', overflow: 'hidden', backgroundColor: 'inherit' } if (this.measurements) { style.width = this.getScrollWidth() + 'px' style.height = this.getScrollHeight() + 'px' style.willChange = 'transform' style.transform = `translate(${-this.getScrollLeft()}px, ${-this.getScrollTop()}px)` children = [ this.renderCursorsAndInput(), this.renderLineTiles(), this.renderPlaceholderText() ] } else { children = $.div({ref: 'characterMeasurementLine', className: 'line'}, $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) ) } return $.div( { ref: 'content', on: {mousedown: this.didMouseDownOnContent}, style }, children ) } renderLineTiles () { if (!this.measurements) return [] const {lineNodesByScreenLineId, textNodesByScreenLineId} = this const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const rowsPerTile = this.getRowsPerTile() const tileHeight = this.getLineHeight() * rowsPerTile const tileWidth = this.getScrollWidth() const displayLayer = this.props.model.displayLayer const tileNodes = new Array(this.getRenderedTileCount()) for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileIndex = this.tileIndexForTileStartRow(tileStartRow) const lineDecorations = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { lineDecorations[row - tileStartRow] = this.decorationsToRender.lines.get(row) } const highlightDecorations = this.decorationsToRender.highlights.get(tileStartRow) tileNodes[tileIndex] = $(LinesTileComponent, { key: tileIndex, height: tileHeight, width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), lineHeight: this.getLineHeight(), screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations, highlightDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId }) } if (this.longestLineToMeasure != null && (this.longestLineToMeasureRow < startRow || this.longestLineToMeasureRow >= endRow)) { tileNodes.push($(LineComponent, { key: this.longestLineToMeasure.id, screenLine: this.longestLineToMeasure, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId })) } return $.div({ key: 'lineTiles', ref: 'lineTiles', className: 'lines', style: { position: 'absolute', contain: 'strict', overflow: 'hidden', width: this.getScrollWidth() + 'px', height: this.getScrollHeight() + 'px', backgroundColor: 'inherit' } }, tileNodes) } renderCursorsAndInput () { const cursorHeight = this.getLineHeight() + 'px' const children = [this.renderHiddenInput()] for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { const {pixelLeft, pixelTop, pixelWidth} = this.decorationsToRender.cursors[i] children.push($.div({ className: 'cursor', style: { height: cursorHeight, width: pixelWidth + 'px', transform: `translate(${pixelLeft}px, ${pixelTop}px)` } })) } return $.div({ key: 'cursors', className: 'cursors', style: { position: 'absolute', contain: 'strict', zIndex: 1, width: this.getScrollWidth() + 'px', height: this.getScrollHeight() + 'px' } }, children) } renderPlaceholderText () { const {model} = this.props if (model.isEmpty()) { const placeholderText = model.getPlaceholderText() if (placeholderText != null) { return $.div({className: 'placeholder-text'}, placeholderText) } } return null } renderHiddenInput () { let top, left if (this.hiddenInputPosition) { top = this.hiddenInputPosition.pixelTop left = this.hiddenInputPosition.pixelLeft } else { top = 0 left = 0 } return $.input({ ref: 'hiddenInput', key: 'hiddenInput', className: 'hidden-input', on: { blur: this.didBlurHiddenInput, focus: this.didFocusHiddenInput, textInput: this.didTextInput, keydown: this.didKeydown, keyup: this.didKeyup, keypress: this.didKeypress, compositionstart: this.didCompositionStart, compositionupdate: this.didCompositionUpdate, compositionend: this.didCompositionEnd }, tabIndex: -1, style: { position: 'absolute', width: '1px', height: this.getLineHeight() + 'px', top: top + 'px', left: left + 'px', opacity: 0, padding: 0, border: 0 } }) } renderDummyScrollbars () { if (this.shouldRenderDummyScrollbars) { let scrollHeight, scrollTop, horizontalScrollbarHeight, scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible if (this.measurements) { scrollHeight = this.getScrollHeight() scrollWidth = this.getScrollWidth() scrollTop = this.getScrollTop() scrollLeft = this.getScrollLeft() horizontalScrollbarHeight = this.isHorizontalScrollbarVisible() ? this.getHorizontalScrollbarHeight() : 0 verticalScrollbarWidth = this.isVerticalScrollbarVisible() ? this.getVerticalScrollbarWidth() : 0 forceScrollbarVisible = this.refreshedScrollbarStyle } else { forceScrollbarVisible = true } const elements = [ $(DummyScrollbarComponent, { ref: 'verticalScrollbar', orientation: 'vertical', didScroll: this.didScrollDummyScrollbar, didMousedown: this.didMouseDownOnContent, scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible }), $(DummyScrollbarComponent, { ref: 'horizontalScrollbar', orientation: 'horizontal', didScroll: this.didScrollDummyScrollbar, didMousedown: this.didMouseDownOnContent, 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) { elements.push($.div( { ref: 'scrollbarCorner', style: { position: 'absolute', height: '20px', width: '20px', bottom: 0, right: 0, overflow: 'scroll' } } )) } return elements } else { return null } } renderOverlayDecorations () { return this.decorationsToRender.overlays.map((overlayProps) => $(OverlayComponent, Object.assign( {key: overlayProps.element, didResize: this.updateSync}, overlayProps )) ) } getPlatform () { return process.platform } queryScreenLinesToRender () { const {model} = this.props this.renderedScreenLines = model.displayLayer.getScreenLines( this.getRenderedStartRow(), this.getRenderedEndRow() ) const longestLineRow = model.getApproximateLongestScreenRow() const longestLine = model.screenLineForScreenRow(longestLineRow) if (longestLine !== this.previousLongestLine) { this.longestLineToMeasure = longestLine this.longestLineToMeasureRow = longestLineRow this.previousLongestLine = longestLine } } renderedScreenLineForRow (row) { return this.renderedScreenLines[row - this.getRenderedStartRow()] } queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() this.decorationsToRender.overlays.length = 0 this.decorationsToMeasure.highlights.clear() this.decorationsToMeasure.cursors.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, length = decorations.length; i < decorations.length; i++) { const decoration = decorations[i] this.addDecorationToRender(decoration.type, decoration, marker, screenRange, reversed) } }) } 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(marker, screenRange, reversed) break case 'overlay': this.addOverlayDecorationToRender(decoration, marker) break } } } addLineDecorationToRender (type, decoration, screenRange, reversed) { const decorationsByRow = (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 } } let startRow = screenRange.start.row let endRow = screenRange.end.row if (decoration.onlyHead) { if (reversed) { endRow = startRow } else { startRow = endRow } } startRow = Math.max(startRow, this.getRenderedStartRow()) endRow = Math.min(endRow, this.getRenderedEndRow() - 1) for (let row = startRow; row <= endRow; row++) { if (omitLastRow && row === screenRange.end.row) break const currentClassName = decorationsByRow.get(row) const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class decorationsByRow.set(row, 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 let tileStartRow = this.tileStartRowForRow(screenRange.start.row) const rowsPerTile = this.getRowsPerTile() while (tileStartRow <= screenRange.end.row) { const tileEndRow = tileStartRow + rowsPerTile const screenRangeInTile = constrainRangeToRows(screenRange, tileStartRow, tileEndRow) let tileHighlights = this.decorationsToMeasure.highlights.get(tileStartRow) if (!tileHighlights) { tileHighlights = [] this.decorationsToMeasure.highlights.set(tileStartRow, tileHighlights) } tileHighlights.push({ screenRange: screenRangeInTile, key, className, flashRequested, flashClass, flashDuration }) this.requestHorizontalMeasurement(screenRangeInTile.start.row, screenRangeInTile.start.column) this.requestHorizontalMeasurement(screenRangeInTile.end.row, screenRangeInTile.end.column) tileStartRow += rowsPerTile } } addCursorDecorationToMeasure (marker, screenRange, reversed) { const {model} = this.props 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) } this.decorationsToMeasure.cursors.push({screenPosition, columnWidth, isLastCursor}) } addOverlayDecorationToRender (decoration, marker) { const {class: className, item, position, avoidOverflow} = decoration const element = TextEditor.viewForOverlayItem(item) const screenPosition = (position === 'tail') ? marker.getTailScreenPosition() : marker.getHeadScreenPosition() this.requestHorizontalMeasurement(screenPosition.row, screenPosition.column) this.decorationsToRender.overlays.push({className, element, avoidOverflow, screenPosition}) } updateAbsolutePositionedDecorations () { this.updateHighlightsToRender() this.updateCursorsToRender() this.updateOverlaysToRender() } updateHighlightsToRender () { this.decorationsToRender.highlights.clear() this.decorationsToMeasure.highlights.forEach((highlights, tileRow) => { for (let i = 0, length = highlights.length; i < length; i++) { const highlight = highlights[i] const {start, end} = highlight.screenRange highlight.startPixelTop = this.pixelTopForRow(start.row) highlight.startPixelLeft = this.pixelLeftForRowAndColumn(start.row, start.column) highlight.endPixelTop = this.pixelTopForRow(end.row + 1) highlight.endPixelLeft = this.pixelLeftForRowAndColumn(end.row, end.column) } this.decorationsToRender.highlights.set(tileRow, highlights) }) } updateCursorsToRender () { this.decorationsToRender.cursors.length = 0 const height = this.getLineHeight() + 'px' for (let i = 0; i < this.decorationsToMeasure.cursors.length; i++) { const cursor = this.decorationsToMeasure.cursors[i] const {row, column} = cursor.screenPosition const pixelTop = this.pixelTopForRow(row) const pixelLeft = this.pixelLeftForRowAndColumn(row, column) const pixelRight = (cursor.columnWidth === 0) ? pixelLeft : this.pixelLeftForRowAndColumn(row, column + 1) const pixelWidth = pixelRight - pixelLeft const cursorPosition = {pixelTop, pixelLeft, pixelWidth} this.decorationsToRender.cursors[i] = cursorPosition if (cursor.isLastCursor) this.hiddenInputPosition = cursorPosition } } updateOverlaysToRender () { const overlayCount = this.decorationsToRender.overlays.length if (overlayCount === 0) return null const windowInnerHeight = this.getWindowInnerHeight() const windowInnerWidth = this.getWindowInnerWidth() const contentClientRect = this.refs.content.getBoundingClientRect() for (let i = 0; i < overlayCount; i++) { const decoration = this.decorationsToRender.overlays[i] const {element, screenPosition, avoidOverflow} = decoration const {row, column} = screenPosition let wrapperTop = contentClientRect.top + this.pixelTopForRow(row) + this.getLineHeight() let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column) if (avoidOverflow !== false) { const computedStyle = window.getComputedStyle(element) const elementHeight = element.offsetHeight const elementTop = wrapperTop + parseInt(computedStyle.marginTop) const elementBottom = elementTop + elementHeight const flippedElementTop = wrapperTop - this.getLineHeight() - elementHeight - parseInt(computedStyle.marginBottom) const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft) const elementRight = elementLeft + element.offsetWidth if (elementBottom > windowInnerHeight && flippedElementTop >= 0) { wrapperTop -= (elementTop - flippedElementTop) } if (elementLeft < 0) { wrapperLeft -= elementLeft } else if (elementRight > windowInnerWidth) { wrapperLeft -= (elementRight - windowInnerWidth) } } decoration.pixelTop = wrapperTop decoration.pixelLeft = wrapperLeft } } 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) if (this.isVisible()) { this.didShow() } else { this.didHide() } if (!this.constructor.attachedComponents) { this.constructor.attachedComponents = new Set() } this.constructor.attachedComponents.add(this) } } didDetach () { if (this.attached) { this.didHide() this.attached = false this.constructor.attachedComponents.delete(this) } } didShow () { if (!this.visible) { this.visible = true if (!this.measurements) this.performInitialMeasurements() this.props.model.setVisible(true) this.updateSync() } } didHide () { if (this.visible) { this.visible = false this.props.model.setVisible(false) } } 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.scheduleUpdate() } // Transfer focus to the hidden input, but first ensure the input is in the // visible part of the scrolled content to avoid the browser trying to // auto-scroll to the form-field. const {hiddenInput} = this.refs hiddenInput.style.top = this.getScrollTop() + 'px' hiddenInput.style.left = this.getScrollLeft() + 'px' hiddenInput.focus() // Restore the previous position of the field now that it is already focused // and won't cause unwanted scrolling. if (this.hiddenInputPosition) { hiddenInput.style.top = this.hiddenInputPosition.pixelTop + 'px' hiddenInput.style.left = this.hiddenInputPosition.pixelLeft + 'px' } else { hiddenInput.style.top = 0 hiddenInput.style.left = 0 } } didBlur (event) { if (event.relatedTarget === this.refs.hiddenInput) { event.stopImmediatePropagation() } } didBlurHiddenInput (event) { if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { this.focused = false this.scheduleUpdate() this.element.dispatchEvent(new FocusEvent(event.type, event)) } } didFocusHiddenInput () { if (!this.focused) { this.focused = true this.scheduleUpdate() } } didMouseWheel (eveWt) { let {deltaX, deltaY} = event deltaX = deltaX * MOUSE_WHEEL_SCROLL_SENSITIVITY deltaY = deltaY * MOUSE_WHEEL_SCROLL_SENSITIVITY const scrollPositionChanged = this.setScrollLeft(this.getScrollLeft() + deltaX) || this.setScrollTop(this.getScrollTop() + deltaY) if (scrollPositionChanged) this.updateSync() } didResize () { if (this.measureClientContainerDimensions()) { this.scheduleUpdate() } } 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() } didUpdateScrollbarStyles () { this.refreshedScrollbarStyle = true this.scheduleUpdate() } didTextInput (event) { 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 is impossible // to test. if (event.data !== ' ') event.preventDefault() // TODO: Deal with disabled input // if (!this.isInputEnabled()) return if (this.compositionCheckpoint) { this.props.model.revertToCheckpoint(this.compositionCheckpoint) this.compositionCheckpoint = null } // Undo insertion of the original non-accented character so it is discarded // from the history and does not reappear on undo if (this.accentedCharacterMenuIsOpen) { this.props.model.undo() } 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(keyCode: X), keypress, keydown(keyCode: X) // // The keyCode 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) { if (this.lastKeydownBeforeKeypress != null) { if (this.lastKeydownBeforeKeypress.keyCode === event.keyCode) { this.accentedCharacterMenuIsOpen = true this.props.model.selectLeft() } this.lastKeydownBeforeKeypress = null } else { this.lastKeydown = event } } didKeypress () { this.lastKeydownBeforeKeypress = this.lastKeydown this.lastKeydown = null // This cancels the accented character behavior if we type a key normally // with the menu open. this.accentedCharacterMenuIsOpen = false } didKeyup () { this.lastKeydownBeforeKeypress = null this.lastKeydown = 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 // 4. compositionend fired // OR User chooses a completion // 4. compositionend fired // 5. textInput fired; event.data == the completion string didCompositionStart () { this.compositionCheckpoint = this.props.model.createCheckpoint() } 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 // Only handle mousedown events for left mouse button (or the middle mouse // button on Linux where it pastes the selection clipboard). if (!(button === 0 || (this.getPlatform() === 'linux' && button === 1))) return const screenPosition = this.screenPositionForMouseEvent(event) if (target && target.matches('.fold-marker')) { const bufferPosition = model.bufferPositionForScreenPosition(screenPosition) model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition)) return } const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') switch (detail) { case 1: if (addOrRemoveSelection) { const existingSelection = model.getSelectionAtScreenPosition(screenPosition) if (existingSelection) { if (model.hasMultipleCursors()) existingSelection.destroy() } else { model.addCursorAtScreenPosition(screenPosition) } } else { if (shiftKey) { model.selectToScreenPosition(screenPosition) } else { model.setCursorScreenPosition(screenPosition) } } break case 2: if (addOrRemoveSelection) model.addCursorAtScreenPosition(screenPosition) model.getLastSelection().selectWord({autoscroll: false}) break case 3: if (addOrRemoveSelection) model.addCursorAtScreenPosition(screenPosition) 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')) { 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 () { window.removeEventListener('mousemove', didMouseMove) window.removeEventListener('mouseup', didMouseUp) if (dragging) { dragging = false didStopDragging() } } window.addEventListener('mousemove', didMouseMove) window.addEventListener('mouseup', didMouseUp) } autoscrollOnMouseDrag ({clientX, clientY}, verticalOnly = false) { let {top, bottom, left, right} = this.refs.scrollContainer.getBoundingClientRect() 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 ({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 this.screenPositionForPixelPosition({ top: clientY - linesRect.top, left: clientX - linesRect.left }) } didRequestAutoscroll (autoscroll) { this.pendingAutoscroll = autoscroll this.scheduleUpdate() } autoscrollVertically () { const {screenRange, options} = this.pendingAutoscroll const screenRangeTop = this.pixelTopForRow(screenRange.start.row) const screenRangeBottom = this.pixelTopForRow(screenRange.end.row) + this.getLineHeight() const verticalScrollMargin = this.getVerticalAutoscrollMargin() this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) let desiredScrollTop, desiredScrollBottom if (options && options.center) { const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2 if (desiredScrollCenter < this.getScrollTop() || desiredScrollCenter > this.getScrollBottom()) { desiredScrollTop = desiredScrollCenter - this.measurements.clientHeight / 2 desiredScrollBottom = desiredScrollCenter + this.measurements.clientHeight / 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 () { const horizontalScrollMargin = this.getHorizontalAutoscrollMargin() const {screenRange, options} = this.pendingAutoscroll 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() } performInitialMeasurements () { this.measurements = {} this.measureCharacterDimensions() this.measureGutterDimensions() this.measureClientContainerDimensions() this.measureScrollbarDimensions() } measureCharacterDimensions () { this.measurements.lineHeight = 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().widt this.props.model.setDefaultCharWidth( this.measurements.baseCharacterWidth, this.measurements.doubleWidthCharacterWidth, this.measurements.halfWidthCharacterWidth, this.measurements.koreanCharacterWidth ) } measureGutterDimensions () { if (this.refs.lineNumberGutter) { this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth } else { this.measurements.lineNumberGutterWidth = 0 } } measureClientContainerDimensions () { if (!this.measurements) return false let dimensionsChanged = false const clientContainerHeight = this.refs.clientContainer.offsetHeight const clientContainerWidth = this.refs.clientContainer.offsetWidth if (clientContainerHeight !== this.measurements.clientContainerHeight) { this.measurements.clientContainerHeight = clientContainerHeight dimensionsChanged = true } if (clientContainerWidth !== this.measurements.clientContainerWidth) { this.measurements.clientContainerWidth = clientContainerWidth this.props.model.setEditorWidthInChars(this.getScrollContainerWidth() / this.getBaseCharacterWidth()) dimensionsChanged = true } return dimensionsChanged } measureScrollbarDimensions () { this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth() this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() } measureLongestLineWidth () { if (this.longestLineToMeasure) { this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(this.longestLineToMeasure.id).firstChild.offsetWidth this.longestLineToMeasureRow = null this.longestLineToMeasure = null } } requestHorizontalMeasurement (row, column) { if (column === 0) return 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 lineNode = this.lineNodesByScreenLineId.get(screenLine.id) if (!lineNode) { const error = new Error('Requested measurement of a line that is not currently rendered') error.metadata = {row, columnsToMeasure} throw error } const textNodes = this.textNodesByScreenLineId.get(screenLine.id) 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) }) } measureHorizontalPositionsOnLine (lineNode, textNodes, columnsToMeasure, positions) { let lineNodeClientLeft = -1 let textNodeStartColumn = 0 let textNodesIndex = 0 columnLoop: for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { while (textNodesIndex < textNodes.length) { const nextColumnToMeasure = columnsToMeasure[columnsIndex] if (nextColumnToMeasure === 0) { positions.set(0, 0) continue columnLoop } if (nextColumnToMeasure >= lineNode.textContent.length) { } if (positions.has(nextColumnToMeasure)) continue columnLoop 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, clientPixelPosition - lineNodeClientLeft) continue columnLoop } else { textNodesIndex++ textNodeStartColumn = textNodeEndColumn } } } } pixelTopForRow (row) { return row * this.getLineHeight() } pixelLeftForRowAndColumn (row, column) { if (column === 0) return 0 const screenLine = this.renderedScreenLineForRow(row) return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } screenPositionForPixelPosition({top, left}) { const {model} = this.props const row = Math.min( Math.max(0, Math.floor(top / this.measurements.lineHeight)), model.getApproximateScreenLineCount() - 1 ) const linesClientLeft = this.refs.lineTiles.getBoundingClientRect().left const targetClientLeft = linesClientLeft + Math.max(0, left) const screenLine = this.renderedScreenLineForRow(row) const textNodes = this.textNodesByScreenLineId.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 += textNodes[i].length } const column = textNodeStartColumn + characterIndex return Point(row, column) } observeModel () { const {model} = this.props model.component = this const scheduleUpdate = this.scheduleUpdate.bind(this) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) this.disposables.add(model.onDidUpdateDecorations(scheduleUpdate)) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) } 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 ? this.measurements.baseCharacterWidth : null } 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() } } getScrollContainerHeightInLines () { return Math.ceil(this.getScrollContainerHeight() / this.getLineHeight()) } getScrollContainerClientWidth () { if (this.isVerticalScrollbarVisible()) { return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() } else { return this.getScrollContainerWidth() } } getScrollContainerClientHeight () { if (this.isHorizontalScrollbarVisible()) { return this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() } else { return this.getScrollContainerHeight() } } isVerticalScrollbarVisible () { return ( this.getContentHeight() > this.getScrollContainerHeight() || ( this.getContentWidth() > this.getScrollContainerWidth() && this.getContentHeight() > (this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()) ) ) } isHorizontalScrollbarVisible () { return ( !this.props.model.isSoftWrapped() && ( this.getContentWidth() > this.getScrollContainerWidth() || ( 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 { return this.getContentHeight() } } 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.props.model.getApproximateScreenLineCount() * this.getLineHeight() } getContentWidth () { return Math.round(this.getLongestLineWidth() + this.getBaseCharacterWidth()) } getGutterContainerWidth () { return this.getLineNumberGutterWidth() } 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()) } tileIndexForTileStartRow (startRow) { return (startRow / this.getRowsPerTile()) % this.getRenderedTileCount() } getFirstTileStartRow () { return this.tileStartRowForRow(this.getFirstVisibleRow()) } getRenderedStartRow () { return this.getFirstTileStartRow() } getRenderedEndRow () { return Math.min( this.props.model.getApproximateScreenLineCount(), this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() ) } getRenderedRowCount () { return Math.max(0, this.getRenderedEndRow() - this.getRenderedStartRow()) } getRenderedTileCount () { return Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) } getFirstVisibleRow () { return Math.floor(this.getScrollTop() / this.getLineHeight()) } getLastVisibleRow () { return Math.min( this.props.model.getApproximateScreenLineCount() - 1, this.getFirstVisibleRow() + this.getScrollContainerHeightInLines() ) } getVisibleTileCount () { return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 } getScrollTop () { this.scrollTop = Math.min(this.getMaxScrollTop(), this.scrollTop) return this.scrollTop } setScrollTop (scrollTop) { scrollTop = Math.round(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop))) if (scrollTop !== this.scrollTop) { this.scrollTopPending = true this.scrollTop = scrollTop return true } else { return false } } getMaxScrollTop () { return Math.max(0, this.getScrollHeight() - this.getScrollContainerClientHeight()) } getScrollBottom () { return this.getScrollTop() + this.getScrollContainerClientHeight() } setScrollBottom (scrollBottom) { return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight()) } getScrollLeft () { // this.scrollLeft = Math.min(this.getMaxScrollLeft(), this.scrollLeft) return this.scrollLeft } setScrollLeft (scrollLeft) { scrollLeft = Math.round(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft))) if (scrollLeft !== this.scrollLeft) { this.scrollLeftPending = true this.scrollLeft = scrollLeft return true } else { return false } } getMaxScrollLeft () { return Math.max(0, this.getScrollWidth() - this.getScrollContainerClientWidth()) } getScrollRight () { return this.getScrollLeft() + this.getScrollContainerClientWidth() } setScrollRight (scrollRight) { return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth()) } // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { const endRow = this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) } topPixelPositionForRow (row) { return row * this.getLineHeight() } getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { this.resolveNextUpdatePromise = () => { this.nextUpdatePromise = null this.resolveNextUpdatePromise = null resolve() } }) } return this.nextUpdatePromise } } class DummyScrollbarComponent { constructor (props) { this.props = props etch.initialize(this) this.updateScrollPosition() } update (props) { this.props = props etch.updateSync(this) this.updateScrollPosition() } // Scroll position must be updated after the inner element is updated to // ensure the element has an adequate scrollHeight/scrollWidth updateScrollPosition () { if (this.props.orientation === 'horizontal') { this.element.scrollLeft = this.props.scrollLeft } else { this.element.scrollTop = this.props.scrollTop } } render () { const outerStyle = { position: 'absolute', contain: 'strict', zIndex: 1 } const innerStyle = {} if (this.props.orientation === 'horizontal') { let right = (this.props.verticalScrollbarWidth || 0) outerStyle.bottom = 0 outerStyle.left = 0 outerStyle.right = right + 'px' outerStyle.height = '20px' outerStyle.overflowY = 'hidden' outerStyle.overflowX = this.props.forceScrollbarVisible ? 'scroll' : 'auto' innerStyle.height = '20px' innerStyle.width = (this.props.scrollWidth || 0) + 'px' } else { let bottom = (this.props.horizontalScrollbarHeight || 0) outerStyle.right = 0 outerStyle.top = 0 outerStyle.bottom = bottom + 'px' outerStyle.width = '20px' outerStyle.overflowX = 'hidden' outerStyle.overflowY = this.props.forceScrollbarVisible ? 'scroll' : 'auto' innerStyle.width = '20px' innerStyle.height = (this.props.scrollHeight || 0) + 'px' } return $.div( { style: outerStyle, on: { scroll: this.props.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 LineNumberGutterComponent { constructor (props) { this.props = props etch.initialize(this) } update (newProps) { if (this.shouldUpdate(newProps)) { this.props = newProps etch.updateSync(this) } } render () { const { parentComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags, lineNumberDecorations } = this.props const renderedTileCount = parentComponent.getRenderedTileCount() const children = new Array(renderedTileCount) const tileHeight = rowsPerTile * lineHeight + 'px' const tileWidth = width + 'px' let softWrapCount = 0 for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileChildren = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { const i = row - startRow const bufferRow = bufferRows[i] const softWrapped = softWrappedFlags[i] const foldable = foldableFlags[i] let key, lineNumber let className = 'line-number' if (softWrapped) { softWrapCount++ key = `${bufferRow}-${softWrapCount}` lineNumber = '•' } else { softWrapCount = 0 key = bufferRow lineNumber = (bufferRow + 1).toString() if (foldable) className += ' foldable' } const lineNumberDecoration = lineNumberDecorations[i] if (lineNumberDecoration != null) className += ' ' + lineNumberDecoration lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber tileChildren[row - tileStartRow] = $.div({key, className}, lineNumber, $.div({className: 'icon-right'}) ) } const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) const top = tileStartRow * lineHeight children[tileIndex] = $.div({ key: tileIndex, on: { mousedown: this.didMouseDown }, style: { contain: 'strict', overflow: 'hidden', position: 'absolute', height: tileHeight, width: tileWidth, willChange: 'transform', transform: `translateY(${top}px)`, backgroundColor: 'inherit' } }, ...tileChildren) } return $.div( { className: 'gutter line-numbers', 'gutter-name': 'line-number', style: { contain: 'strict', overflow: 'hidden', height: height + 'px', width: tileWidth } }, ...children ) } shouldUpdate (newProps) { const oldProps = this.props 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.maxLineNumberDigits !== newProps.maxLineNumberDigits) return true if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true if (!arraysEqual(oldProps.softWrappedFlags, newProps.softWrappedFlags)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true if (!arraysEqual(oldProps.lineNumberDecorations, newProps.lineNumberDecorations)) return true return false } didMouseDown (event) { this.props.parentComponent.didMouseDownOnLineNumberGutter(event) } } class LinesTileComponent { constructor (props) { this.props = props etch.initialize(this) } update (newProps) { if (this.shouldUpdate(newProps)) { this.props = newProps etch.updateSync(this) } } render () { const {height, width, top} = this.props return $.div( { style: { contain: 'strict', position: 'absolute', height: height + 'px', width: width + 'px', willChange: 'transform', transform: `translateY(${top}px)`, backgroundColor: 'inherit' } }, this.renderHighlights(), this.renderLines() ) } renderHighlights () { const {top, height, width, lineHeight, highlightDecorations} = this.props let children = null if (highlightDecorations) { const decorationCount = highlightDecorations.length children = new Array(decorationCount) for (let i = 0; i < decorationCount; i++) { const highlightProps = Object.assign( {parentTileTop: top, lineHeight}, highlightDecorations[i] ) children[i] = $(HighlightComponent, highlightProps) highlightDecorations[i].flashRequested = false } } return $.div( { style: { position: 'absolute', contain: 'strict', height: height + 'px', width: width + 'px' }, }, children ) } renderLines () { const { height, width, top, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId, } = this.props const children = new Array(screenLines.length) for (let i = 0, length = screenLines.length; i < length; i++) { const screenLine = screenLines[i] if (!screenLine) { children.length = i break } children[i] = $(LineComponent, { key: screenLine.id, screenLine, lineDecoration: lineDecorations[i], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId }) } return $.div({ style: { position: 'absolute', contain: 'strict', height: height + 'px', width: width + 'px' } }, children) } 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 (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) 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.startPixelLeft !== newHighlight.startPixelLeft) return true if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true } } return false } } class LineComponent { constructor (props) { const {displayLayer, screenLine, lineDecoration, lineNodesByScreenLineId, textNodesByScreenLineId} = props this.props = props this.element = document.createElement('div') this.element.className = this.buildClassName() lineNodesByScreenLineId.set(screenLine.id, this.element) const textNodes = [] textNodesByScreenLineId.set(screenLine.id, textNodes) const {lineText, tagCodes} = screenLine let startIndex = 0 let openScopeNode = document.createElement('span') this.element.appendChild(openScopeNode) for (let i = 0; i < tagCodes.length; i++) { const tagCode = tagCodes[i] if (tagCode !== 0) { if (displayLayer.isCloseTagCode(tagCode)) { openScopeNode = openScopeNode.parentElement } else if (displayLayer.isOpenTagCode(tagCode)) { const scope = displayLayer.tagForCode(tagCode) const newScopeNode = document.createElement('span') newScopeNode.className = classNameForScopeName(scope) openScopeNode.appendChild(newScopeNode) openScopeNode = newScopeNode } else { const textNode = document.createTextNode(lineText.substr(startIndex, tagCode)) startIndex += tagCode openScopeNode.appendChild(textNode) textNodes.push(textNode) } } } if (startIndex === 0) { const textNode = document.createTextNode(' ') this.element.appendChild(textNode) 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 = document.createTextNode(ZERO_WIDTH_NBSP_CHARACTER) this.element.appendChild(textNode) textNodes.push(textNode) } } update (newProps) { if (this.props.lineDecoration !== newProps.lineDecoration) { this.props = newProps this.element.className = this.buildClassName() } } destroy () { const {lineNodesByScreenLineId, textNodesByScreenLineId, screenLine} = this.props if (lineNodesByScreenLineId.get(screenLine.id) === this.element) { lineNodesByScreenLineId.delete(screenLine.id) textNodesByScreenLineId.delete(screenLine.id) } } buildClassName () { const {lineDecoration} = this.props let className = 'line' if (lineDecoration != null) className += ' ' + lineDecoration return className } } class HighlightComponent { constructor (props) { this.props = props etch.initialize(this) if (this.props.flashRequested) this.performFlash() } 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 () { let {startPixelTop, endPixelTop} = this.props const { className, screenRange, parentTileTop, lineHeight, startPixelLeft, endPixelLeft, } = this.props startPixelTop -= parentTileTop endPixelTop -= parentTileTop let children if (screenRange.start.row === screenRange.end.row) { children = $.div({ className: 'region', 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: 'region', 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: 'region', 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: 'region', 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' getElementResizeDetector().listenTo(this.element, this.props.didResize) } update (newProps) { const oldProps = this.props this.props = 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) } } } const classNamesByScopeName = new Map() function classNameForScopeName (scopeName) { let classString = classNamesByScopeName.get(scopeName) if (classString == null) { classString = scopeName.replace(/\.+/g, ' ') classNamesByScopeName.set(scopeName, classString) } return classString } let rangeForMeasurement function clientRectForRange (textNode, startIndex, endIndex) { if (!rangeForMeasurement) rangeForMeasurement = document.createRange() rangeForMeasurement.setStart(textNode, startIndex) rangeForMeasurement.setEnd(textNode, endIndex) return rangeForMeasurement.getBoundingClientRect() } let resizeDetector function getElementResizeDetector () { if (resizeDetector == null) resizeDetector = ResizeDetector({strategy: 'scroll'}) return resizeDetector } 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 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 }