Files
atom/src/text-editor-component.js
Nathan Sobo 5a1459cf0a Clear the dimensions cache after updating the soft wrap column
Updating the soft wrap column could cause us to compute different values
for derived dimensions, so any dimensions that were cached *in the
process* of updating the soft wrap column need to be cleared.
2017-10-05 15:01:57 -06:00

4397 lines
147 KiB
JavaScript

/* 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})
}
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.overlayDimensionsByElement = new WeakMap()
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.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(() => {
this.measureContentDuringUpdateSync()
scheduler.updateDocument(() => {
this.updateSyncAfterMeasuringContent()
})
})
} else {
this.measureContentDuringUpdateSync()
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()
if (this.pendingAutoscroll) {
this.derivedDimensionsCache = {}
const {screenRange, options} = this.pendingAutoscroll
this.autoscrollHorizontally(screenRange, options)
const isHorizontalScrollbarVisible = (
this.canScrollHorizontally() &&
this.getHorizontalScrollbarHeight() > 0
)
if (!wasHorizontalScrollbarVisible && isHorizontalScrollbarVisible) {
this.autoscrollVertically(screenRange, options)
}
this.pendingAutoscroll = null
}
this.linesToMeasure.clear()
this.measuredContent = true
}
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 = null
if (model.isMini()) {
attributes = {mini: ''}
}
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.renderHighlightDecorations(),
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 children = []
const style = {
position: 'absolute',
contain: 'strict',
overflow: 'hidden'
}
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
})
}
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,
measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element),
didResize: () => { this.updateSync() }
},
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
})
}
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.pixelPositionAfterBlocksForRow(row) + this.getLineHeight()
let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column)
const clientRect = element.getBoundingClientRect()
this.overlayDimensionsByElement.set(element, clientRect)
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)
}
}
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 {deltaX, deltaY} = event
if (Math.abs(deltaX) > Math.abs(deltaY)) {
deltaX = (Math.sign(deltaX) === 1)
? Math.max(1, deltaX * scrollSensitivity)
: Math.min(-1, deltaX * scrollSensitivity)
deltaY = 0
} else {
deltaX = 0
deltaY = (Math.sign(deltaY) === 1)
? Math.max(1, deltaY * scrollSensitivity)
: Math.min(-1, deltaY * scrollSensitivity)
}
if (this.getPlatform() !== 'darwin' && event.shiftKey) {
let temp = deltaX
deltaX = deltaY
deltaY = temp
}
const scrollLeftChanged = deltaX !== 0 && this.setScrollLeft(this.getScrollLeft() + deltaX)
const scrollTopChanged = deltaY !== 0 && this.setScrollTop(this.getScrollTop() + deltaY)
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.isInputEnabled()) return
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()
if (this.compositionCheckpoint) {
this.props.model.revertToCheckpoint(this.compositionCheckpoint)
this.compositionCheckpoint = null
}
// 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) {
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
}
if (this.getChromeVersion() === 56) {
this.getHiddenInput().value = ''
}
this.compositionCheckpoint = this.props.model.createCheckpoint()
if (this.accentedCharacterMenuIsOpen) {
this.props.model.selectLeft()
}
}
didCompositionUpdate (event) {
if (this.getChromeVersion() === 56) {
process.nextTick(() => {
if (this.compositionCheckpoint != null) {
const previewText = this.getHiddenInput().value
this.props.model.insertText(previewText, {select: true})
}
})
} else {
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
}
}
// On Linux, position the cursor on middle mouse button click. A
// textInput event with the contents of the selection clipboard will be
// dispatched by the browser automatically on mouseup.
if (platform === 'linux' && button === 1) {
const selection = clipboard.readText('selection')
const screenPosition = this.screenPositionForMouseEvent(event)
model.setCursorScreenPosition(screenPosition, {autoscroll: false})
model.insertText(selection)
return
}
// Only handle mousedown events for left mouse button (or the middle mouse
// button on Linux where it pastes the selection clipboard).
if (button !== 0) return
// Ctrl-click brings up the context menu on macOS
if (platform === 'darwin' && ctrlKey) 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 && 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
let bufferWillChangeDisposable
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, {capture: true})
bufferWillChangeDisposable.dispose()
if (dragging) {
dragging = false
didStopDragging()
}
}
window.addEventListener('mousemove', didMouseMove)
window.addEventListener('mouseup', didMouseUp, {capture: true})
// Simulate a mouse-up event if the buffer is about to change. This prevents
// unwanted selections when users perform edits while holding the left mouse
// button at the same time.
bufferWillChangeDisposable = this.props.model.getBuffer().onWillChange(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 {start, oldExtent, newExtent} = changes[i]
this.spliceLineTopIndex(start.row, oldExtent.row, newExtent.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) {
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.round(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 = Math.round(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 = Math.round(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.inputEnabled = inputEnabled
}
isInputEnabled (inputEnabled) {
return this.props.inputEnabled != null ? this.props.inputEnabled : true
}
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: 'strict',
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: pixelWidth + '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'
}
}, children)
}
getCursorsClassName () {
return this.props.cursorsBlinkedOff ? 'cursors blink-off' : 'cursors'
}
renderHiddenInput () {
const {
lineHeight, hiddenInputPosition, didBlurHiddenInput, didFocusHiddenInput,
didPaste, didTextInput, didKeydown, didKeyup, didKeypress,
didCompositionStart, didCompositionUpdate, didCompositionEnd
} = 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: -1,
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.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'
// 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.
this.resizeObserver = new ResizeObserver((entries) => {
const {contentRect} = entries[0]
if (contentRect.width !== this.props.measuredDimensions.width || contentRect.height !== this.props.measuredDimensions.height) {
this.resizeObserver.disconnect()
this.props.didResize()
process.nextTick(() => { this.resizeObserver.observe(this.element) })
}
})
this.didAttach()
this.props.overlayComponents.add(this)
}
destroy () {
this.props.overlayComponents.delete(this)
this.didDetach()
}
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)
}
}
didAttach () {
this.resizeObserver.observe(this.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
}