mirror of
https://github.com/atom/atom.git
synced 2026-02-05 20:25:04 -05:00
When typing on a single line, only a single tile needs to be updated. When moving the cursor no tiles need to be updated.
1149 lines
36 KiB
JavaScript
1149 lines
36 KiB
JavaScript
const etch = require('etch')
|
|
const {CompositeDisposable} = require('event-kit')
|
|
const $ = etch.dom
|
|
const TextEditorElement = require('./text-editor-element')
|
|
const resizeDetector = require('element-resize-detector')({strategy: 'scroll'})
|
|
|
|
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'
|
|
|
|
module.exports =
|
|
class TextEditorComponent {
|
|
constructor (props) {
|
|
this.props = props
|
|
this.element = props.element || new TextEditorElement()
|
|
this.element.initialize(this)
|
|
this.virtualNode = $('atom-text-editor')
|
|
this.virtualNode.domNode = this.element
|
|
this.refs = {}
|
|
|
|
this.disposables = new CompositeDisposable()
|
|
this.updateScheduled = false
|
|
this.measurements = null
|
|
this.visible = false
|
|
this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure
|
|
this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions
|
|
this.lineNodesByScreenLineId = new Map()
|
|
this.textNodesByScreenLineId = new Map()
|
|
this.pendingAutoscroll = null
|
|
this.autoscrollTop = null
|
|
this.scrollWidthOrHeightChanged = false
|
|
this.previousScrollWidth = 0
|
|
this.previousScrollHeight = 0
|
|
this.lastKeydown = null
|
|
this.lastKeydownBeforeKeypress = null
|
|
this.openedAccentedCharacterMenu = false
|
|
this.cursorsToRender = []
|
|
|
|
if (this.props.model) this.observeModel()
|
|
resizeDetector.listenTo(this.element, this.didResize.bind(this))
|
|
|
|
etch.updateSync(this)
|
|
}
|
|
|
|
update (props) {
|
|
this.props = props
|
|
this.scheduleUpdate()
|
|
}
|
|
|
|
scheduleUpdate () {
|
|
if (this.updatedSynchronously) {
|
|
this.updateSync()
|
|
} else if (!this.updateScheduled) {
|
|
this.updateScheduled = true
|
|
etch.getScheduler().updateDocument(() => {
|
|
if (this.updateScheduled) this.updateSync()
|
|
})
|
|
}
|
|
}
|
|
|
|
updateSync () {
|
|
this.updateScheduled = false
|
|
if (this.nextUpdatePromise) {
|
|
this.resolveNextUpdatePromise()
|
|
this.nextUpdatePromise = null
|
|
this.resolveNextUpdatePromise = null
|
|
}
|
|
|
|
if (this.scrollWidthOrHeightChanged) {
|
|
this.measureClientDimensions()
|
|
this.scrollWidthOrHeightChanged = false
|
|
}
|
|
|
|
const longestLineRow = this.getLongestScreenLineRow()
|
|
const longestLine = this.getModel().screenLineForScreenRow(longestLineRow)
|
|
let measureLongestLine = false
|
|
if (longestLine !== this.previousLongestLine) {
|
|
this.longestLineToMeasure = longestLine
|
|
this.longestLineToMeasureRow = longestLineRow
|
|
this.previousLongestLine = longestLine
|
|
measureLongestLine = true
|
|
}
|
|
|
|
if (this.pendingAutoscroll) {
|
|
this.autoscrollVertically()
|
|
}
|
|
|
|
this.horizontalPositionsToMeasure.clear()
|
|
etch.updateSync(this)
|
|
|
|
if (this.autoscrollTop != null) {
|
|
this.refs.scroller.scrollTop = this.autoscrollTop
|
|
this.autoscrollTop = null
|
|
}
|
|
if (measureLongestLine) {
|
|
this.measureLongestLineWidth(longestLine)
|
|
this.longestLineToMeasureRow = null
|
|
this.longestLineToMeasure = null
|
|
}
|
|
this.queryCursorsToRender()
|
|
this.measureHorizontalPositions()
|
|
this.positionCursorsToRender()
|
|
|
|
etch.updateSync(this)
|
|
|
|
this.pendingAutoscroll = null
|
|
}
|
|
|
|
render () {
|
|
let style
|
|
if (!this.getModel().getAutoHeight() && !this.getModel().getAutoWidth()) {
|
|
style = {contain: 'strict'}
|
|
}
|
|
|
|
let className = 'editor'
|
|
if (this.focused) {
|
|
className += ' is-focused'
|
|
}
|
|
|
|
return $('atom-text-editor', {
|
|
className,
|
|
style,
|
|
tabIndex: -1,
|
|
on: {focus: this.didFocus}
|
|
},
|
|
$.div({ref: 'scroller', on: {scroll: this.didScroll}, className: 'scroll-view'},
|
|
$.div({
|
|
style: {
|
|
isolate: 'content',
|
|
width: 'max-content',
|
|
height: 'max-content',
|
|
backgroundColor: 'inherit'
|
|
}
|
|
},
|
|
this.renderGutterContainer(),
|
|
this.renderContent()
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
renderGutterContainer () {
|
|
const props = {ref: 'gutterContainer', className: 'gutter-container'}
|
|
|
|
if (this.measurements) {
|
|
props.style = {
|
|
position: 'relative',
|
|
willChange: 'transform',
|
|
transform: `translateX(${this.measurements.scrollLeft}px)`,
|
|
zIndex: 1
|
|
}
|
|
}
|
|
|
|
return $.div(props, this.renderLineNumberGutter())
|
|
}
|
|
|
|
renderLineNumberGutter () {
|
|
const model = this.getModel()
|
|
const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length)
|
|
|
|
if (this.measurements) {
|
|
const startRow = this.getRenderedStartRow()
|
|
const endRow = this.getRenderedEndRow()
|
|
|
|
const bufferRows = new Array(endRow - startRow)
|
|
const foldableFlags = new Array(endRow - startRow)
|
|
const softWrappedFlags = new Array(endRow - startRow)
|
|
|
|
let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1
|
|
for (let row = startRow; row < endRow; row++) {
|
|
const i = row - startRow
|
|
const bufferRow = model.bufferRowForScreenRow(row)
|
|
bufferRows[i] = bufferRow
|
|
softWrappedFlags[i] = bufferRow === previousBufferRow
|
|
foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow)
|
|
previousBufferRow = bufferRow
|
|
}
|
|
|
|
const rowsPerTile = this.getRowsPerTile()
|
|
|
|
return $(LineNumberGutterComponent, {
|
|
height: this.getScrollHeight(),
|
|
width: this.measurements.lineNumberGutterWidth,
|
|
lineHeight: this.measurements.lineHeight,
|
|
startRow, endRow, rowsPerTile, maxLineNumberDigits,
|
|
bufferRows, softWrappedFlags, foldableFlags
|
|
})
|
|
} else {
|
|
return $.div(
|
|
{
|
|
ref: 'lineNumberGutter',
|
|
className: 'gutter line-numbers',
|
|
'gutter-name': 'line-number'
|
|
},
|
|
$.div({className: 'line-number'},
|
|
'0'.repeat(maxLineNumberDigits),
|
|
$.div({className: 'icon-right'})
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
renderContent () {
|
|
let children
|
|
let style = {
|
|
contain: 'strict',
|
|
overflow: 'hidden'
|
|
}
|
|
if (this.measurements) {
|
|
const scrollWidth = this.getScrollWidth()
|
|
const scrollHeight = this.getScrollHeight()
|
|
if (scrollWidth !== this.previousScrollWidth || scrollHeight !== this.previousScrollHeight) {
|
|
this.scrollWidthOrHeightChanged = true
|
|
this.previousScrollWidth = scrollWidth
|
|
this.previousScrollHeight = scrollHeight
|
|
}
|
|
|
|
const width = scrollWidth + 'px'
|
|
const height = scrollHeight + 'px'
|
|
style.width = width
|
|
style.height = height
|
|
children = [
|
|
this.renderCursorsAndInput(width, height),
|
|
this.renderLineTiles(width, height)
|
|
]
|
|
} else {
|
|
children = $.div({ref: 'characterMeasurementLine', className: 'line'},
|
|
$.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER),
|
|
$.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER),
|
|
$.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER),
|
|
$.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER)
|
|
)
|
|
}
|
|
|
|
return $.div({style}, children)
|
|
}
|
|
|
|
renderLineTiles (width, height) {
|
|
if (!this.measurements) return []
|
|
|
|
const {lineNodesByScreenLineId, textNodesByScreenLineId} = this
|
|
|
|
const startRow = this.getRenderedStartRow()
|
|
const endRow = this.getRenderedEndRow()
|
|
// const firstTileStartRow = this.getFirstTileStartRow()
|
|
const visibleTileCount = this.getVisibleTileCount()
|
|
// const lastTileStartRow = this.getLastTileStartRow()
|
|
const rowsPerTile = this.getRowsPerTile()
|
|
const tileHeight = this.measurements.lineHeight * rowsPerTile
|
|
const tileWidth = this.getScrollWidth()
|
|
|
|
const displayLayer = this.getModel().displayLayer
|
|
const screenLines = displayLayer.getScreenLines(startRow, endRow)
|
|
|
|
const tileNodes = new Array(visibleTileCount)
|
|
|
|
for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) {
|
|
const tileEndRow = tileStartRow + rowsPerTile
|
|
const tileHeight = rowsPerTile * this.measurements.lineHeight
|
|
const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount
|
|
|
|
tileNodes[tileIndex] = $(LinesTileComponent, {
|
|
key: tileIndex,
|
|
height: tileHeight,
|
|
width: tileWidth,
|
|
top: this.topPixelPositionForRow(tileStartRow),
|
|
screenLines: screenLines.slice(tileStartRow - startRow, tileEndRow - startRow),
|
|
displayLayer,
|
|
lineNodesByScreenLineId,
|
|
textNodesByScreenLineId
|
|
})
|
|
}
|
|
|
|
if (this.longestLineToMeasure != null && (this.longestLineToMeasureRow < startRow || this.longestLineToMeasureRow >= endRow)) {
|
|
tileNodes.push($(LineComponent, {
|
|
key: this.longestLineToMeasure.id,
|
|
screenLine: this.longestLineToMeasure,
|
|
displayLayer,
|
|
lineNodesByScreenLineId,
|
|
textNodesByScreenLineId
|
|
}))
|
|
}
|
|
|
|
return $.div({
|
|
key: 'lineTiles',
|
|
className: 'lines',
|
|
style: {
|
|
position: 'absolute',
|
|
contain: 'strict',
|
|
width, height
|
|
}
|
|
}, tileNodes)
|
|
}
|
|
|
|
renderCursorsAndInput (width, height) {
|
|
const cursorHeight = this.measurements.lineHeight + 'px'
|
|
|
|
const children = [this.renderHiddenInput()]
|
|
|
|
for (let i = 0; i < this.cursorsToRender.length; i++) {
|
|
const {pixelLeft, pixelTop, pixelWidth} = this.cursorsToRender[i]
|
|
children.push($.div({
|
|
className: 'cursor',
|
|
style: {
|
|
height: cursorHeight,
|
|
width: pixelWidth + 'px',
|
|
transform: `translate(${pixelLeft}px, ${pixelTop}px)`
|
|
}
|
|
}))
|
|
}
|
|
|
|
return $.div({
|
|
key: 'cursors',
|
|
className: 'cursors',
|
|
style: {
|
|
position: 'absolute',
|
|
contain: 'strict',
|
|
width, height
|
|
}
|
|
}, children)
|
|
}
|
|
|
|
renderHiddenInput () {
|
|
let top, left
|
|
const hiddenInputState = this.getHiddenInputState()
|
|
if (hiddenInputState) {
|
|
top = hiddenInputState.pixelTop
|
|
left = hiddenInputState.pixelLeft
|
|
} else {
|
|
top = 0
|
|
left = 0
|
|
}
|
|
|
|
return $.input({
|
|
ref: 'hiddenInput',
|
|
key: 'hiddenInput',
|
|
className: 'hidden-input',
|
|
on: {
|
|
blur: this.didBlurHiddenInput,
|
|
focus: this.didFocusHiddenInput,
|
|
textInput: this.didTextInput,
|
|
keydown: this.didKeydown,
|
|
keyup: this.didKeyup,
|
|
keypress: this.didKeypress
|
|
},
|
|
tabIndex: -1,
|
|
style: {
|
|
position: 'absolute',
|
|
width: '1px',
|
|
height: this.measurements.lineHeight + 'px',
|
|
top: top + 'px',
|
|
left: left + 'px',
|
|
opacity: 0,
|
|
padding: 0,
|
|
border: 0
|
|
}
|
|
})
|
|
}
|
|
|
|
queryCursorsToRender () {
|
|
const model = this.getModel()
|
|
const cursorMarkers = model.selectionsMarkerLayer.findMarkers({
|
|
intersectsScreenRowRange: [
|
|
this.getRenderedStartRow(),
|
|
this.getRenderedEndRow() - 1,
|
|
]
|
|
})
|
|
const lastCursorMarker = model.getLastCursor().getMarker()
|
|
|
|
this.cursorsToRender.length = cursorMarkers.length
|
|
this.lastCursorIndex = -1
|
|
for (let i = 0; i < cursorMarkers.length; i++) {
|
|
const cursorMarker = cursorMarkers[i]
|
|
if (cursorMarker === lastCursorMarker) this.lastCursorIndex = i
|
|
const screenPosition = cursorMarker.getHeadScreenPosition()
|
|
const {row, column} = screenPosition
|
|
this.requestHorizontalMeasurement(row, column)
|
|
let columnWidth = 0
|
|
if (model.lineLengthForScreenRow(row) > column) {
|
|
columnWidth = 1
|
|
this.requestHorizontalMeasurement(row, column + 1)
|
|
}
|
|
this.cursorsToRender[i] = {
|
|
screenPosition, columnWidth,
|
|
pixelTop: 0, pixelLeft: 0, pixelWidth: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
positionCursorsToRender () {
|
|
const height = this.measurements.lineHeight + 'px'
|
|
for (let i = 0; i < this.cursorsToRender.length; i++) {
|
|
const cursorToRender = this.cursorsToRender[i]
|
|
const {row, column} = cursorToRender.screenPosition
|
|
|
|
const pixelTop = this.pixelTopForScreenRow(row)
|
|
const pixelLeft = this.pixelLeftForScreenRowAndColumn(row, column)
|
|
const pixelRight = (cursorToRender.columnWidth === 0)
|
|
? pixelLeft
|
|
: this.pixelLeftForScreenRowAndColumn(row, column + 1)
|
|
const pixelWidth = pixelRight - pixelLeft
|
|
|
|
cursorToRender.pixelTop = pixelTop
|
|
cursorToRender.pixelLeft = pixelLeft
|
|
cursorToRender.pixelWidth = pixelWidth
|
|
}
|
|
}
|
|
|
|
getHiddenInputState () {
|
|
if (this.lastCursorIndex >= 0) {
|
|
return this.cursorsToRender[this.lastCursorIndex]
|
|
}
|
|
}
|
|
|
|
didAttach () {
|
|
if (!this.attached) {
|
|
this.attached = true
|
|
this.intersectionObserver = new IntersectionObserver((entries) => {
|
|
const {intersectionRect} = entries[entries.length - 1]
|
|
if (intersectionRect.width > 0 || intersectionRect.height > 0) {
|
|
this.didShow()
|
|
} else {
|
|
this.didHide()
|
|
}
|
|
})
|
|
this.intersectionObserver.observe(this.element)
|
|
if (this.isVisible()) this.didShow()
|
|
}
|
|
}
|
|
|
|
didShow () {
|
|
if (!this.visible) {
|
|
this.visible = true
|
|
this.getModel().setVisible(true)
|
|
if (!this.measurements) this.performInitialMeasurements()
|
|
this.updateSync()
|
|
}
|
|
}
|
|
|
|
didHide () {
|
|
if (this.visible) {
|
|
this.visible = false
|
|
this.getModel().setVisible(false)
|
|
}
|
|
}
|
|
|
|
didFocus () {
|
|
// This element can be focused from a parent custom element's
|
|
// attachedCallback before *its* attachedCallback is fired. This protects
|
|
// against that case.
|
|
if (!this.attached) this.didAttach()
|
|
|
|
if (!this.focused) {
|
|
this.focused = true
|
|
this.scheduleUpdate()
|
|
}
|
|
|
|
// Transfer focus to the hidden input, but first ensure the input is in the
|
|
// visible part of the scrolled content to avoid the browser trying to
|
|
// auto-scroll to the form-field.
|
|
const {hiddenInput} = this.refs
|
|
hiddenInput.style.top = this.getScrollTop() + 'px'
|
|
hiddenInput.style.left = this.getScrollLeft() + 'px'
|
|
|
|
hiddenInput.focus()
|
|
|
|
// Restore the previous position of the field now that it is already focused
|
|
// and won't cause unwanted scrolling.
|
|
const currentHiddenInputState = this.getHiddenInputState()
|
|
if (currentHiddenInputState) {
|
|
hiddenInput.style.top = currentHiddenInputState.pixelTop + 'px'
|
|
hiddenInput.style.left = currentHiddenInputState.pixelLeft + 'px'
|
|
} else {
|
|
hiddenInput.style.top = 0
|
|
hiddenInput.style.left = 0
|
|
}
|
|
}
|
|
|
|
didBlurHiddenInput (event) {
|
|
if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) {
|
|
this.focused = false
|
|
this.scheduleUpdate()
|
|
}
|
|
}
|
|
|
|
didFocusHiddenInput () {
|
|
if (!this.focused) {
|
|
this.focused = true
|
|
this.scheduleUpdate()
|
|
}
|
|
}
|
|
|
|
didScroll () {
|
|
if (this.measureScrollPosition(true)) {
|
|
this.updateSync()
|
|
}
|
|
}
|
|
|
|
didResize () {
|
|
if (this.measureEditorDimensions()) {
|
|
this.measureClientDimensions()
|
|
this.scheduleUpdate()
|
|
}
|
|
}
|
|
|
|
didTextInput (event) {
|
|
event.stopPropagation()
|
|
|
|
// WARNING: If we call preventDefault on the input of a space character,
|
|
// then the browser interprets the spacebar keypress as a page-down command,
|
|
// causing spaces to scroll elements containing editors. This is impossible
|
|
// to test.
|
|
if (event.data !== ' ') event.preventDefault()
|
|
|
|
// if (!this.isInputEnabled()) return
|
|
|
|
// Workaround of the accented character suggestion feature in macOS. This
|
|
// will only occur when the user is not composing in IME mode. When the user
|
|
// selects a modified character from the macOS menu, `textInput` will occur
|
|
// twice, once for the initial character, and once for the modified
|
|
// character. However, only a single keypress will have fired. If this is
|
|
// the case, select backward to replace the original character.
|
|
if (this.openedAccentedCharacterMenu) {
|
|
this.getModel().selectLeft()
|
|
this.openedAccentedCharacterMenu = false
|
|
}
|
|
|
|
this.getModel().insertText(event.data, {groupUndo: true})
|
|
}
|
|
|
|
// We need to get clever to detect when the accented character menu is
|
|
// opened on macOS. Usually, every keydown event that could cause input is
|
|
// followed by a corresponding keypress. However, pressing and holding
|
|
// long enough to open the accented character menu causes additional keydown
|
|
// events to fire that aren't followed by their own keypress and textInput
|
|
// events.
|
|
//
|
|
// Therefore, we assume the accented character menu has been deployed if,
|
|
// before observing any keyup event, we observe events in the following
|
|
// sequence:
|
|
//
|
|
// keydown(keyCode: X), keypress, keydown(keyCode: X)
|
|
//
|
|
// The keyCode X must be the same in the keydown events that bracket the
|
|
// keypress, meaning we're *holding* the _same_ key we intially pressed.
|
|
// Got that?
|
|
didKeydown (event) {
|
|
if (this.lastKeydownBeforeKeypress != null) {
|
|
if (this.lastKeydownBeforeKeypress.keyCode === event.keyCode) {
|
|
this.openedAccentedCharacterMenu = true
|
|
}
|
|
this.lastKeydownBeforeKeypress = null
|
|
} else {
|
|
this.lastKeydown = event
|
|
}
|
|
}
|
|
|
|
didKeypress () {
|
|
this.lastKeydownBeforeKeypress = this.lastKeydown
|
|
this.lastKeydown = null
|
|
|
|
// This cancels the accented character behavior if we type a key normally
|
|
// with the menu open.
|
|
this.openedAccentedCharacterMenu = false
|
|
}
|
|
|
|
didKeyup () {
|
|
this.lastKeydownBeforeKeypress = null
|
|
this.lastKeydown = null
|
|
}
|
|
|
|
didRequestAutoscroll (autoscroll) {
|
|
this.pendingAutoscroll = autoscroll
|
|
this.scheduleUpdate()
|
|
}
|
|
|
|
autoscrollVertically () {
|
|
const {screenRange, options} = this.pendingAutoscroll
|
|
|
|
const screenRangeTop = this.pixelTopForScreenRow(screenRange.start.row)
|
|
const screenRangeBottom = this.pixelTopForScreenRow(screenRange.end.row) + this.measurements.lineHeight
|
|
const verticalScrollMargin = this.getVerticalScrollMargin()
|
|
|
|
let desiredScrollTop, desiredScrollBottom
|
|
if (options && options.center) {
|
|
const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2
|
|
if (desiredScrollCenter < this.getScrollTop() || desiredScrollCenter > this.getScrollBottom()) {
|
|
desiredScrollTop = desiredScrollCenter - this.measurements.clientHeight / 2
|
|
desiredScrollBottom = desiredScrollCenter + this.measurements.clientHeight / 2
|
|
}
|
|
} else {
|
|
desiredScrollTop = screenRangeTop - verticalScrollMargin
|
|
desiredScrollBottom = screenRangeBottom + verticalScrollMargin
|
|
}
|
|
|
|
if (desiredScrollTop != null) {
|
|
desiredScrollTop = Math.max(0, Math.min(desiredScrollTop, this.getScrollHeight() - this.getClientHeight()))
|
|
}
|
|
|
|
if (desiredScrollBottom != null) {
|
|
desiredScrollBottom = Math.max(this.getClientHeight(), Math.min(desiredScrollBottom, this.getScrollHeight()))
|
|
}
|
|
|
|
if (!options || options.reversed !== false) {
|
|
if (desiredScrollBottom > this.getScrollBottom()) {
|
|
this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight
|
|
}
|
|
if (desiredScrollTop < this.getScrollTop()) {
|
|
this.autoscrollTop = desiredScrollTop
|
|
}
|
|
} else {
|
|
if (desiredScrollTop < this.getScrollTop()) {
|
|
this.autoscrollTop = desiredScrollTop
|
|
}
|
|
if (desiredScrollBottom > this.getScrollBottom()) {
|
|
this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight
|
|
}
|
|
}
|
|
|
|
if (this.autoscrollTop != null) {
|
|
this.measurements.scrollTop = this.autoscrollTop
|
|
}
|
|
}
|
|
|
|
getVerticalScrollMargin () {
|
|
const {clientHeight, lineHeight} = this.measurements
|
|
const marginInLines = Math.min(
|
|
this.getModel().verticalScrollMargin,
|
|
Math.floor(((clientHeight / lineHeight) - 1) / 2)
|
|
)
|
|
return marginInLines * this.measurements.lineHeight
|
|
}
|
|
|
|
performInitialMeasurements () {
|
|
this.measurements = {}
|
|
this.measureEditorDimensions()
|
|
this.measureClientDimensions()
|
|
this.measureScrollPosition()
|
|
this.measureCharacterDimensions()
|
|
this.measureGutterDimensions()
|
|
}
|
|
|
|
measureEditorDimensions () {
|
|
let dimensionsChanged = false
|
|
const scrollerHeight = this.refs.scroller.offsetHeight
|
|
const scrollerWidth = this.refs.scroller.offsetWidth
|
|
if (scrollerHeight !== this.measurements.scrollerHeight) {
|
|
this.measurements.scrollerHeight = scrollerHeight
|
|
dimensionsChanged = true
|
|
}
|
|
if (scrollerWidth !== this.measurements.scrollerWidth) {
|
|
this.measurements.scrollerWidth = scrollerWidth
|
|
dimensionsChanged = true
|
|
}
|
|
return dimensionsChanged
|
|
}
|
|
|
|
measureScrollPosition () {
|
|
let scrollPositionChanged = false
|
|
const {scrollTop, scrollLeft} = this.refs.scroller
|
|
if (scrollTop !== this.measurements.scrollTop) {
|
|
this.measurements.scrollTop = scrollTop
|
|
scrollPositionChanged = true
|
|
}
|
|
if (scrollLeft !== this.measurements.scrollLeft) {
|
|
this.measurements.scrollLeft = scrollLeft
|
|
scrollPositionChanged = true
|
|
}
|
|
return scrollPositionChanged
|
|
}
|
|
|
|
measureClientDimensions () {
|
|
let clientDimensionsChanged = false
|
|
const {clientHeight, clientWidth} = this.refs.scroller
|
|
if (clientHeight !== this.measurements.clientHeight) {
|
|
this.measurements.clientHeight = clientHeight
|
|
clientDimensionsChanged = true
|
|
}
|
|
if (clientWidth !== this.measurements.clientWidth) {
|
|
this.measurements.clientWidth = clientWidth
|
|
clientDimensionsChanged = true
|
|
}
|
|
return clientDimensionsChanged
|
|
}
|
|
|
|
measureCharacterDimensions () {
|
|
this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height
|
|
this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width
|
|
this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width
|
|
this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width
|
|
this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt
|
|
}
|
|
|
|
measureLongestLineWidth (screenLine) {
|
|
this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth
|
|
}
|
|
|
|
measureGutterDimensions () {
|
|
this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth
|
|
}
|
|
|
|
requestHorizontalMeasurement (row, column) {
|
|
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.getModel().displayLayer.getScreenLine(row)
|
|
const lineNode = this.lineNodesByScreenLineId.get(screenLine.id)
|
|
const textNodes = this.textNodesByScreenLineId.get(screenLine.id)
|
|
let positionsForLine = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id)
|
|
if (positionsForLine == null) {
|
|
positionsForLine = new Map()
|
|
this.horizontalPixelPositionsByScreenLineId.set(screenLine.id, positionsForLine)
|
|
}
|
|
|
|
this.measureHorizontalPositionsOnLine(lineNode, textNodes, columnsToMeasure, positionsForLine)
|
|
})
|
|
}
|
|
|
|
measureHorizontalPositionsOnLine (lineNode, textNodes, columnsToMeasure, positions) {
|
|
let lineNodeClientLeft = -1
|
|
let textNodeStartColumn = 0
|
|
let textNodesIndex = 0
|
|
|
|
columnLoop:
|
|
for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) {
|
|
while (textNodesIndex < textNodes.length) {
|
|
const nextColumnToMeasure = columnsToMeasure[columnsIndex]
|
|
if (nextColumnToMeasure === 0) {
|
|
positions.set(0, 0)
|
|
continue columnLoop
|
|
}
|
|
if (positions.has(nextColumnToMeasure)) continue columnLoop
|
|
const textNode = textNodes[textNodesIndex]
|
|
const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length
|
|
|
|
if (nextColumnToMeasure <= textNodeEndColumn) {
|
|
let clientPixelPosition
|
|
if (nextColumnToMeasure === textNodeStartColumn) {
|
|
const range = getRangeForMeasurement()
|
|
range.setStart(textNode, 0)
|
|
range.setEnd(textNode, 1)
|
|
clientPixelPosition = range.getBoundingClientRect().left
|
|
} else {
|
|
const range = getRangeForMeasurement()
|
|
range.setStart(textNode, 0)
|
|
range.setEnd(textNode, nextColumnToMeasure - textNodeStartColumn)
|
|
clientPixelPosition = range.getBoundingClientRect().right
|
|
}
|
|
if (lineNodeClientLeft === -1) lineNodeClientLeft = lineNode.getBoundingClientRect().left
|
|
positions.set(nextColumnToMeasure, clientPixelPosition - lineNodeClientLeft)
|
|
continue columnLoop
|
|
} else {
|
|
textNodesIndex++
|
|
textNodeStartColumn = textNodeEndColumn
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pixelTopForScreenRow (row) {
|
|
return row * this.measurements.lineHeight
|
|
}
|
|
|
|
pixelLeftForScreenRowAndColumn (row, column) {
|
|
if (column === 0) return 0
|
|
const screenLine = this.getModel().displayLayer.getScreenLine(row)
|
|
|
|
if (!this.horizontalPixelPositionsByScreenLineId.has(screenLine.id)) debugger
|
|
return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column)
|
|
}
|
|
|
|
getModel () {
|
|
if (!this.props.model) {
|
|
const TextEditor = require('./text-editor')
|
|
this.props.model = new TextEditor()
|
|
this.observeModel()
|
|
}
|
|
return this.props.model
|
|
}
|
|
|
|
observeModel () {
|
|
const {model} = this.props
|
|
const scheduleUpdate = this.scheduleUpdate.bind(this)
|
|
this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate))
|
|
this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate))
|
|
this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this)))
|
|
}
|
|
|
|
isVisible () {
|
|
return this.element.offsetWidth > 0 || this.element.offsetHeight > 0
|
|
}
|
|
|
|
getBaseCharacterWidth () {
|
|
return this.measurements ? this.measurements.baseCharacterWidth : null
|
|
}
|
|
|
|
getScrollTop () {
|
|
if (this.measurements != null) {
|
|
return this.measurements.scrollTop
|
|
}
|
|
}
|
|
|
|
getScrollBottom () {
|
|
return this.measurements
|
|
? this.getScrollTop() + this.measurements.clientHeight
|
|
: null
|
|
}
|
|
|
|
getScrollLeft () {
|
|
return this.measurements ? this.measurements.scrollLeft : null
|
|
}
|
|
|
|
getScrollHeight () {
|
|
return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight
|
|
}
|
|
|
|
getScrollWidth () {
|
|
return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth)
|
|
}
|
|
|
|
getClientHeight () {
|
|
return this.measurements.clientHeight
|
|
}
|
|
|
|
getRowsPerTile () {
|
|
return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE
|
|
}
|
|
|
|
getTileStartRow (row) {
|
|
return row - (row % this.getRowsPerTile())
|
|
}
|
|
|
|
getVisibleTileCount () {
|
|
return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2
|
|
}
|
|
|
|
getFirstTileStartRow () {
|
|
return this.getTileStartRow(this.getFirstVisibleRow())
|
|
}
|
|
|
|
getLastTileStartRow () {
|
|
return this.getFirstTileStartRow() + ((this.getVisibleTileCount() - 1) * this.getRowsPerTile())
|
|
}
|
|
|
|
getRenderedStartRow () {
|
|
return this.getFirstTileStartRow()
|
|
}
|
|
|
|
getRenderedEndRow () {
|
|
return this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile()
|
|
}
|
|
|
|
getFirstVisibleRow () {
|
|
const scrollTop = this.getScrollTop()
|
|
const lineHeight = this.measurements.lineHeight
|
|
return Math.floor(scrollTop / lineHeight)
|
|
}
|
|
|
|
getLastVisibleRow () {
|
|
const {scrollerHeight, lineHeight} = this.measurements
|
|
return Math.min(
|
|
this.getModel().getApproximateScreenLineCount() - 1,
|
|
this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight)
|
|
)
|
|
}
|
|
|
|
topPixelPositionForRow (row) {
|
|
return row * this.measurements.lineHeight
|
|
}
|
|
|
|
getLongestScreenLineRow () {
|
|
const model = this.getModel()
|
|
// Ensure the spatial index is populated with rows that are currently
|
|
// visible so we *at least* get the longest row in the visible range.
|
|
const renderedEndRow = this.getTileStartRow(this.getLastVisibleRow()) + this.getRowsPerTile()
|
|
model.displayLayer.populateSpatialIndexIfNeeded(Infinity, renderedEndRow)
|
|
return model.getApproximateLongestScreenRow()
|
|
}
|
|
|
|
getNextUpdatePromise () {
|
|
if (!this.nextUpdatePromise) {
|
|
this.nextUpdatePromise = new Promise((resolve) => {
|
|
this.resolveNextUpdatePromise = resolve
|
|
})
|
|
}
|
|
return this.nextUpdatePromise
|
|
}
|
|
}
|
|
|
|
class LineNumberGutterComponent {
|
|
constructor (props) {
|
|
this.props = props
|
|
etch.initialize(this)
|
|
}
|
|
|
|
update (newProps) {
|
|
if (this.shouldUpdate(newProps)) {
|
|
this.props = newProps
|
|
etch.updateSync(this)
|
|
}
|
|
}
|
|
|
|
render () {
|
|
const {
|
|
height, width, lineHeight, startRow, endRow, rowsPerTile,
|
|
maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags
|
|
} = this.props
|
|
|
|
const visibleTileCount = (endRow - startRow) / rowsPerTile
|
|
const children = new Array(visibleTileCount)
|
|
const tileHeight = rowsPerTile * lineHeight + 'px'
|
|
const tileWidth = width + 'px'
|
|
|
|
let softWrapCount = 0
|
|
for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) {
|
|
const tileChildren = new Array(rowsPerTile)
|
|
const tileEndRow = tileStartRow + rowsPerTile
|
|
for (let row = tileStartRow; row < tileEndRow; row++) {
|
|
const i = row - startRow
|
|
const bufferRow = bufferRows[i]
|
|
const softWrapped = softWrappedFlags[i]
|
|
const foldable = foldableFlags[i]
|
|
let key, lineNumber
|
|
let className = 'line-number'
|
|
if (softWrapped) {
|
|
softWrapCount++
|
|
key = `${bufferRow}-${softWrapCount}`
|
|
lineNumber = '•'
|
|
} else {
|
|
softWrapCount = 0
|
|
key = bufferRow
|
|
lineNumber = (bufferRow + 1).toString()
|
|
if (foldable) className += ' foldable'
|
|
}
|
|
lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber
|
|
|
|
tileChildren[row - tileStartRow] = $.div({key, className},
|
|
lineNumber,
|
|
$.div({className: 'icon-right'})
|
|
)
|
|
}
|
|
|
|
const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount
|
|
const top = tileStartRow * lineHeight
|
|
|
|
children[tileIndex] = $.div({
|
|
key: tileIndex,
|
|
style: {
|
|
contain: 'strict',
|
|
overflow: 'hidden',
|
|
position: 'absolute',
|
|
height: tileHeight,
|
|
width: tileWidth,
|
|
willChange: 'transform',
|
|
transform: `translateY(${top}px)`,
|
|
backgroundColor: 'inherit'
|
|
}
|
|
}, ...tileChildren)
|
|
}
|
|
|
|
return $.div(
|
|
{
|
|
className: 'gutter line-numbers',
|
|
'gutter-name': 'line-number',
|
|
style: {
|
|
contain: 'strict',
|
|
overflow: 'hidden',
|
|
height: height + 'px',
|
|
width: tileWidth
|
|
}
|
|
},
|
|
...children
|
|
)
|
|
}
|
|
|
|
shouldUpdate (newProps) {
|
|
const oldProps = this.props
|
|
|
|
if (oldProps.height !== newProps.height) return true
|
|
if (oldProps.width !== newProps.width) return true
|
|
if (oldProps.lineHeight !== newProps.lineHeight) return true
|
|
if (oldProps.startRow !== newProps.startRow) return true
|
|
if (oldProps.endRow !== newProps.endRow) return true
|
|
if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true
|
|
if (oldProps.maxLineNumberDigits !== newProps.maxLineNumberDigits) return true
|
|
if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true
|
|
if (!arraysEqual(oldProps.softWrappedFlags, newProps.softWrappedFlags)) return true
|
|
if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true
|
|
return false
|
|
}
|
|
}
|
|
|
|
class LinesTileComponent {
|
|
constructor (props) {
|
|
this.props = props
|
|
etch.initialize(this)
|
|
}
|
|
|
|
update (newProps) {
|
|
if (this.shouldUpdate(newProps)) {
|
|
this.props = newProps
|
|
etch.updateSync(this)
|
|
}
|
|
}
|
|
|
|
render () {
|
|
const {
|
|
height, width, top,
|
|
screenLines, displayLayer,
|
|
lineNodesByScreenLineId, textNodesByScreenLineId
|
|
} = this.props
|
|
|
|
const children = new Array(screenLines.length)
|
|
for (let i = 0, length = screenLines.length; i < length; i++) {
|
|
const screenLine = screenLines[i]
|
|
if (!screenLine) {
|
|
children.length = i
|
|
break
|
|
}
|
|
children[i] = $(LineComponent, {
|
|
key: screenLine.id,
|
|
screenLine,
|
|
displayLayer,
|
|
lineNodesByScreenLineId,
|
|
textNodesByScreenLineId
|
|
})
|
|
}
|
|
|
|
return $.div({
|
|
style: {
|
|
contain: 'strict',
|
|
position: 'absolute',
|
|
height: height + 'px',
|
|
width: width + 'px',
|
|
willChange: 'transform',
|
|
transform: `translateY(${top}px)`,
|
|
backgroundColor: 'inherit'
|
|
}
|
|
}, children)
|
|
}
|
|
|
|
shouldUpdate (newProps) {
|
|
const oldProps = this.props
|
|
if (oldProps.top !== newProps.top) return true
|
|
if (oldProps.height !== newProps.height) return true
|
|
if (oldProps.width !== newProps.width) return true
|
|
if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true
|
|
return false
|
|
}
|
|
}
|
|
|
|
class LineComponent {
|
|
constructor (props) {
|
|
const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props
|
|
this.props = props
|
|
this.element = document.createElement('div')
|
|
this.element.classList.add('line')
|
|
lineNodesByScreenLineId.set(screenLine.id, this.element)
|
|
|
|
const textNodes = []
|
|
textNodesByScreenLineId.set(screenLine.id, textNodes)
|
|
|
|
const {lineText, tagCodes} = screenLine
|
|
let startIndex = 0
|
|
let openScopeNode = document.createElement('span')
|
|
this.element.appendChild(openScopeNode)
|
|
for (let i = 0; i < tagCodes.length; i++) {
|
|
const tagCode = tagCodes[i]
|
|
if (tagCode !== 0) {
|
|
if (displayLayer.isCloseTagCode(tagCode)) {
|
|
openScopeNode = openScopeNode.parentElement
|
|
} else if (displayLayer.isOpenTagCode(tagCode)) {
|
|
const scope = displayLayer.tagForCode(tagCode)
|
|
const newScopeNode = document.createElement('span')
|
|
newScopeNode.className = classNameForScopeName(scope)
|
|
openScopeNode.appendChild(newScopeNode)
|
|
openScopeNode = newScopeNode
|
|
} else {
|
|
const textNode = document.createTextNode(lineText.substr(startIndex, tagCode))
|
|
startIndex += tagCode
|
|
openScopeNode.appendChild(textNode)
|
|
textNodes.push(textNode)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (startIndex === 0) {
|
|
const textNode = document.createTextNode(' ')
|
|
this.element.appendChild(textNode)
|
|
textNodes.push(textNode)
|
|
}
|
|
|
|
if (lineText.endsWith(displayLayer.foldCharacter)) {
|
|
// Insert a zero-width non-breaking whitespace, so that LinesYardstick can
|
|
// take the fold-marker::after pseudo-element into account during
|
|
// measurements when such marker is the last character on the line.
|
|
const textNode = document.createTextNode(ZERO_WIDTH_NBSP)
|
|
this.element.appendChild(textNode)
|
|
textNodes.push(textNode)
|
|
}
|
|
}
|
|
|
|
update () {}
|
|
|
|
destroy () {
|
|
const {lineNodesByScreenLineId, textNodesByScreenLineId, screenLine} = this.props
|
|
if (lineNodesByScreenLineId.get(screenLine.id) === this.element) {
|
|
lineNodesByScreenLineId.delete(screenLine.id)
|
|
textNodesByScreenLineId.delete(screenLine.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
const classNamesByScopeName = new Map()
|
|
function classNameForScopeName (scopeName) {
|
|
let classString = classNamesByScopeName.get(scopeName)
|
|
if (classString == null) {
|
|
classString = scopeName.replace(/\.+/g, ' ')
|
|
classNamesByScopeName.set(scopeName, classString)
|
|
}
|
|
return classString
|
|
}
|
|
|
|
let rangeForMeasurement
|
|
function getRangeForMeasurement () {
|
|
if (!rangeForMeasurement) rangeForMeasurement = document.createRange()
|
|
return rangeForMeasurement
|
|
}
|
|
|
|
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
|
|
}
|