pulsar/spec/text-editor-component-spec.js
2019-02-28 19:30:03 +01:00

6128 lines
222 KiB
JavaScript

const {
it,
beforeEach,
afterEach,
conditionPromise
} = require('./async-spec-helpers')
const Random = require('../script/node_modules/random-seed')
const { getRandomBufferRange, buildRandomLines } = require('./helpers/random')
const TextEditorComponent = require('../src/text-editor-component')
const TextEditorElement = require('../src/text-editor-element')
const TextEditor = require('../src/text-editor')
const TextBuffer = require('text-buffer')
const { Point } = TextBuffer
const fs = require('fs')
const path = require('path')
const Grim = require('grim')
const electron = require('electron')
const clipboard = electron.clipboard
const SAMPLE_TEXT = fs.readFileSync(
path.join(__dirname, 'fixtures', 'sample.js'),
'utf8'
)
document.registerElement('text-editor-component-test-element', {
prototype: Object.create(HTMLElement.prototype, {
attachedCallback: {
value: function () {
this.didAttach()
}
}
})
})
const editors = []
let verticalScrollbarWidth, horizontalScrollbarHeight
describe('TextEditorComponent', () => {
beforeEach(() => {
jasmine.useRealClock()
// Force scrollbars to be visible regardless of local system configuration
const scrollbarStyle = document.createElement('style')
scrollbarStyle.textContent =
'atom-text-editor ::-webkit-scrollbar { -webkit-appearance: none }'
jasmine.attachToDOM(scrollbarStyle)
if (verticalScrollbarWidth == null) {
const { component, element } = buildComponent({
text: 'abcdefgh\n'.repeat(10),
width: 30,
height: 30
})
verticalScrollbarWidth = getVerticalScrollbarWidth(component)
horizontalScrollbarHeight = getHorizontalScrollbarHeight(component)
element.remove()
}
})
afterEach(() => {
for (const editor of editors) {
editor.destroy()
}
editors.length = 0
})
describe('rendering', () => {
it('renders lines and line numbers for the visible region', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
expect(queryOnScreenLineNumberElements(element).length).toBe(13)
expect(queryOnScreenLineElements(element).length).toBe(13)
element.style.height = 4 * component.measurements.lineHeight + 'px'
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(9)
expect(queryOnScreenLineElements(element).length).toBe(9)
await setScrollTop(component, 5 * component.getLineHeight())
// After scrolling down beyond > 3 rows, the order of line numbers and lines
// in the DOM is a bit weird because the first tile is recycled to the bottom
// when it is scrolled out of view
expect(
queryOnScreenLineNumberElements(element).map(element =>
element.textContent.trim()
)
).toEqual(['10', '11', '12', '4', '5', '6', '7', '8', '9'])
expect(
queryOnScreenLineElements(element).map(
element => element.dataset.screenRow
)
).toEqual(['9', '10', '11', '3', '4', '5', '6', '7', '8'])
expect(
queryOnScreenLineElements(element).map(element => element.textContent)
).toEqual([
editor.lineTextForScreenRow(9),
' ', // this line is blank in the model, but we render a space to prevent the line from collapsing vertically
editor.lineTextForScreenRow(11),
editor.lineTextForScreenRow(3),
editor.lineTextForScreenRow(4),
editor.lineTextForScreenRow(5),
editor.lineTextForScreenRow(6),
editor.lineTextForScreenRow(7),
editor.lineTextForScreenRow(8)
])
await setScrollTop(component, 2.5 * component.getLineHeight())
expect(
queryOnScreenLineNumberElements(element).map(element =>
element.textContent.trim()
)
).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
expect(
queryOnScreenLineElements(element).map(
element => element.dataset.screenRow
)
).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8'])
expect(
queryOnScreenLineElements(element).map(element => element.textContent)
).toEqual([
editor.lineTextForScreenRow(0),
editor.lineTextForScreenRow(1),
editor.lineTextForScreenRow(2),
editor.lineTextForScreenRow(3),
editor.lineTextForScreenRow(4),
editor.lineTextForScreenRow(5),
editor.lineTextForScreenRow(6),
editor.lineTextForScreenRow(7),
editor.lineTextForScreenRow(8)
])
})
it('bases the width of the lines div on the width of the longest initially-visible screen line', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 2,
height: 20,
width: 100
})
{
expect(editor.getApproximateLongestScreenRow()).toBe(3)
const expectedWidth = Math.ceil(
component.pixelPositionForScreenPosition(Point(3, Infinity)).left +
component.getBaseCharacterWidth()
)
expect(element.querySelector('.lines').style.width).toBe(
expectedWidth + 'px'
)
}
{
// Get the next update promise synchronously here to ensure we don't
// miss the update while polling the condition.
const nextUpdatePromise = component.getNextUpdatePromise()
await conditionPromise(
() => editor.getApproximateLongestScreenRow() === 6
)
await nextUpdatePromise
// Capture the width of the lines before requesting the width of
// longest line, because making that request forces a DOM update
const actualWidth = element.querySelector('.lines').style.width
const expectedWidth = Math.ceil(
component.pixelPositionForScreenPosition(Point(6, Infinity)).left +
component.getBaseCharacterWidth()
)
expect(actualWidth).toBe(expectedWidth + 'px')
}
// Make sure we do not throw an error if a synchronous update is
// triggered before measuring the longest line from a
// previously-scheduled update.
editor.getBuffer().insert(Point(12, Infinity), 'x'.repeat(100))
expect(editor.getLongestScreenRow()).toBe(12)
TextEditorComponent.getScheduler().readDocument(() => {
// This will happen before the measurement phase of the update
// triggered above.
component.pixelPositionForScreenPosition(Point(11, Infinity))
})
await component.getNextUpdatePromise()
})
it('re-renders lines when their height changes', async () => {
const { component, element } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
element.style.height = 4 * component.measurements.lineHeight + 'px'
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(9)
expect(queryOnScreenLineElements(element).length).toBe(9)
element.style.lineHeight = '2.0'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(6)
expect(queryOnScreenLineElements(element).length).toBe(6)
element.style.lineHeight = '0.7'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(12)
expect(queryOnScreenLineElements(element).length).toBe(12)
element.style.lineHeight = '0.05'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(13)
expect(queryOnScreenLineElements(element).length).toBe(13)
element.style.lineHeight = '0'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(13)
expect(queryOnScreenLineElements(element).length).toBe(13)
element.style.lineHeight = '1'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(queryOnScreenLineNumberElements(element).length).toBe(9)
expect(queryOnScreenLineElements(element).length).toBe(9)
})
it('makes the content at least as tall as the scroll container client height', async () => {
const { component, editor } = buildComponent({
text: 'a'.repeat(100),
width: 50,
height: 100
})
expect(component.refs.content.offsetHeight).toBe(
100 - getHorizontalScrollbarHeight(component)
)
editor.setText('a\n'.repeat(30))
await component.getNextUpdatePromise()
expect(component.refs.content.offsetHeight).toBeGreaterThan(100)
expect(component.refs.content.offsetHeight).toBe(
component.getContentHeight()
)
})
it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => {
const { component, editor } = buildComponent({
autoHeight: false,
autoWidth: false
})
await editor.update({ scrollPastEnd: true })
await setEditorHeightInLines(component, 6)
// scroll to end
await setScrollTop(component, Infinity)
expect(component.getFirstVisibleRow()).toBe(
editor.getScreenLineCount() - 3
)
editor.update({ scrollPastEnd: false })
await component.getNextUpdatePromise() // wait for scrollable content resize
expect(component.getFirstVisibleRow()).toBe(
editor.getScreenLineCount() - 6
)
// Always allows at least 3 lines worth of overscroll if the editor is short
await setEditorHeightInLines(component, 2)
await editor.update({ scrollPastEnd: true })
await setScrollTop(component, Infinity)
expect(component.getFirstVisibleRow()).toBe(
editor.getScreenLineCount() + 1
)
})
it('does not fire onDidChangeScrollTop listeners when assigning the same maximal value and the content height has fractional pixels (regression)', async () => {
const { component, element, editor } = buildComponent({
autoHeight: false,
autoWidth: false
})
await setEditorHeightInLines(component, 3)
// Force a fractional content height with a block decoration
const item = document.createElement('div')
item.style.height = '10.6px'
editor.decorateMarker(editor.markBufferPosition([0, 0]), {
type: 'block',
item
})
await component.getNextUpdatePromise()
component.setScrollTop(Infinity)
element.onDidChangeScrollTop(newScrollTop => {
throw new Error('Scroll top should not have changed')
})
component.setScrollTop(component.getScrollTop())
})
it('gives the line number tiles an explicit width and height so their layout can be strictly contained', async () => {
const { component, editor } = buildComponent({ rowsPerTile: 3 })
const lineNumberGutterElement =
component.refs.gutterContainer.refs.lineNumberGutter.element
expect(lineNumberGutterElement.offsetHeight).toBe(
component.getScrollHeight()
)
for (const child of lineNumberGutterElement.children) {
expect(child.offsetWidth).toBe(lineNumberGutterElement.offsetWidth)
if (!child.classList.contains('line-number')) {
for (const lineNumberElement of child.children) {
expect(lineNumberElement.offsetWidth).toBe(
lineNumberGutterElement.offsetWidth
)
}
}
}
editor.setText('x\n'.repeat(99))
await component.getNextUpdatePromise()
expect(lineNumberGutterElement.offsetHeight).toBe(
component.getScrollHeight()
)
for (const child of lineNumberGutterElement.children) {
expect(child.offsetWidth).toBe(lineNumberGutterElement.offsetWidth)
if (!child.classList.contains('line-number')) {
for (const lineNumberElement of child.children) {
expect(lineNumberElement.offsetWidth).toBe(
lineNumberGutterElement.offsetWidth
)
}
}
}
})
it('keeps the number of tiles stable when the visible line count changes during vertical scrolling', async () => {
const { component } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
await setEditorHeightInLines(component, 5.5)
expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers
await setScrollTop(component, 0.5 * component.getLineHeight())
expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers
await setScrollTop(component, 1 * component.getLineHeight())
expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers
})
it('recycles tiles on resize', async () => {
const { component } = buildComponent({
rowsPerTile: 2,
autoHeight: false
})
await setEditorHeightInLines(component, 7)
await setScrollTop(component, 3.5 * component.getLineHeight())
const lineNode = lineNodeForScreenRow(component, 7)
await setEditorHeightInLines(component, 4)
expect(lineNodeForScreenRow(component, 7)).toBe(lineNode)
})
it("updates lines numbers when a row's foldability changes (regression)", async () => {
const { component, editor } = buildComponent({ text: 'abc\n' })
editor.setCursorBufferPosition([1, 0])
await component.getNextUpdatePromise()
expect(
lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')
).toBeNull()
editor.insertText(' def')
await component.getNextUpdatePromise()
expect(
lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')
).toBeDefined()
editor.undo()
await component.getNextUpdatePromise()
expect(
lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')
).toBeNull()
})
it('shows the foldable icon on the last screen row of a buffer row that can be folded', async () => {
const { component } = buildComponent({
text: 'abc\n de\nfghijklm\n no',
softWrapped: true
})
await setEditorWidthInCharacters(component, 5)
expect(
lineNumberNodeForScreenRow(component, 0).classList.contains('foldable')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('foldable')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 2).classList.contains('foldable')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 3).classList.contains('foldable')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 4).classList.contains('foldable')
).toBe(false)
})
it('renders dummy vertical and horizontal scrollbars when content overflows', async () => {
const { component, editor } = buildComponent({
height: 100,
width: 100
})
const verticalScrollbar = component.refs.verticalScrollbar.element
const horizontalScrollbar = component.refs.horizontalScrollbar.element
expect(verticalScrollbar.scrollHeight).toBe(component.getContentHeight())
expect(horizontalScrollbar.scrollWidth).toBe(component.getContentWidth())
expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0)
expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0)
expect(verticalScrollbar.style.bottom).toBe(
getVerticalScrollbarWidth(component) + 'px'
)
expect(verticalScrollbar.style.visibility).toBe('')
expect(horizontalScrollbar.style.right).toBe(
getHorizontalScrollbarHeight(component) + 'px'
)
expect(horizontalScrollbar.style.visibility).toBe('')
expect(component.refs.scrollbarCorner).toBeDefined()
setScrollTop(component, 100)
await setScrollLeft(component, 100)
expect(verticalScrollbar.scrollTop).toBe(100)
expect(horizontalScrollbar.scrollLeft).toBe(100)
verticalScrollbar.scrollTop = 120
horizontalScrollbar.scrollLeft = 120
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(120)
expect(component.getScrollLeft()).toBe(120)
editor.setText('a\n'.repeat(15))
await component.getNextUpdatePromise()
expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0)
expect(getHorizontalScrollbarHeight(component)).toBe(0)
expect(verticalScrollbar.style.visibility).toBe('')
expect(horizontalScrollbar.style.visibility).toBe('hidden')
editor.setText('a'.repeat(100))
await component.getNextUpdatePromise()
expect(getVerticalScrollbarWidth(component)).toBe(0)
expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0)
expect(verticalScrollbar.style.visibility).toBe('hidden')
expect(horizontalScrollbar.style.visibility).toBe('')
editor.setText('')
await component.getNextUpdatePromise()
expect(getVerticalScrollbarWidth(component)).toBe(0)
expect(getHorizontalScrollbarHeight(component)).toBe(0)
expect(verticalScrollbar.style.visibility).toBe('hidden')
expect(horizontalScrollbar.style.visibility).toBe('hidden')
})
describe('when scrollbar styles change or the editor element is detached and then reattached', () => {
it('updates the bottom/right of dummy scrollbars and client height/width measurements', async () => {
const { component, element, editor } = buildComponent({
height: 100,
width: 100
})
expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(10)
expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(10)
setScrollTop(component, 20)
setScrollLeft(component, 10)
await component.getNextUpdatePromise()
// Updating scrollbar styles.
const style = document.createElement('style')
style.textContent = '::-webkit-scrollbar { height: 10px; width: 10px; }'
jasmine.attachToDOM(style)
TextEditor.didUpdateScrollbarStyles()
await component.getNextUpdatePromise()
expect(getHorizontalScrollbarHeight(component)).toBe(10)
expect(getVerticalScrollbarWidth(component)).toBe(10)
expect(component.refs.horizontalScrollbar.element.style.right).toBe(
'10px'
)
expect(component.refs.verticalScrollbar.element.style.bottom).toBe(
'10px'
)
expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(10)
expect(component.refs.verticalScrollbar.element.scrollTop).toBe(20)
expect(component.getScrollContainerClientHeight()).toBe(100 - 10)
expect(component.getScrollContainerClientWidth()).toBe(
100 - component.getGutterContainerWidth() - 10
)
// Detaching and re-attaching the editor element.
element.remove()
jasmine.attachToDOM(element)
expect(getHorizontalScrollbarHeight(component)).toBe(10)
expect(getVerticalScrollbarWidth(component)).toBe(10)
expect(component.refs.horizontalScrollbar.element.style.right).toBe(
'10px'
)
expect(component.refs.verticalScrollbar.element.style.bottom).toBe(
'10px'
)
expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(10)
expect(component.refs.verticalScrollbar.element.scrollTop).toBe(20)
expect(component.getScrollContainerClientHeight()).toBe(100 - 10)
expect(component.getScrollContainerClientWidth()).toBe(
100 - component.getGutterContainerWidth() - 10
)
// Ensure we don't throw an error trying to remeasure non-existent scrollbars for mini editors.
await editor.update({ mini: true })
TextEditor.didUpdateScrollbarStyles()
component.scheduleUpdate()
await component.getNextUpdatePromise()
})
})
it('renders cursors within the visible row range', async () => {
const { component, element, editor } = buildComponent({
height: 40,
rowsPerTile: 2
})
await setScrollTop(component, 100)
expect(component.getRenderedStartRow()).toBe(4)
expect(component.getRenderedEndRow()).toBe(10)
editor.setCursorScreenPosition([0, 0], { autoscroll: false }) // out of view
editor.addCursorAtScreenPosition([2, 2], { autoscroll: false }) // out of view
editor.addCursorAtScreenPosition([4, 0], { autoscroll: false }) // line start
editor.addCursorAtScreenPosition([4, 4], { autoscroll: false }) // at token boundary
editor.addCursorAtScreenPosition([4, 6], { autoscroll: false }) // within token
editor.addCursorAtScreenPosition([5, Infinity], { autoscroll: false }) // line end
editor.addCursorAtScreenPosition([10, 2], { autoscroll: false }) // out of view
await component.getNextUpdatePromise()
let cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(4)
verifyCursorPosition(component, cursorNodes[0], 4, 0)
verifyCursorPosition(component, cursorNodes[1], 4, 4)
verifyCursorPosition(component, cursorNodes[2], 4, 6)
verifyCursorPosition(component, cursorNodes[3], 5, 30)
editor.setCursorScreenPosition([8, 11], { autoscroll: false })
await component.getNextUpdatePromise()
cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(1)
verifyCursorPosition(component, cursorNodes[0], 8, 11)
editor.setCursorScreenPosition([0, 0], { autoscroll: false })
await component.getNextUpdatePromise()
cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(0)
editor.setSelectedScreenRange([[8, 0], [12, 0]], { autoscroll: false })
await component.getNextUpdatePromise()
cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(0)
})
it('hides cursors with non-empty selections when showCursorOnSelection is false', async () => {
const { component, element, editor } = buildComponent()
editor.setSelectedScreenRanges([[[0, 0], [0, 3]], [[1, 0], [1, 0]]])
await component.getNextUpdatePromise()
{
const cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(2)
verifyCursorPosition(component, cursorNodes[0], 0, 3)
verifyCursorPosition(component, cursorNodes[1], 1, 0)
}
editor.update({ showCursorOnSelection: false })
await component.getNextUpdatePromise()
{
const cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(1)
verifyCursorPosition(component, cursorNodes[0], 1, 0)
}
editor.setSelectedScreenRanges([[[0, 0], [0, 3]], [[1, 0], [1, 4]]])
await component.getNextUpdatePromise()
{
const cursorNodes = Array.from(element.querySelectorAll('.cursor'))
expect(cursorNodes.length).toBe(0)
}
})
it('blinks cursors when the editor is focused and the cursors are not moving', async () => {
assertDocumentFocused()
const { component, element, editor } = buildComponent()
component.props.cursorBlinkPeriod = 40
component.props.cursorBlinkResumeDelay = 40
editor.addCursorAtScreenPosition([1, 0])
element.focus()
await component.getNextUpdatePromise()
const [cursor1, cursor2] = element.querySelectorAll('.cursor')
await conditionPromise(
() =>
getComputedStyle(cursor1).opacity === '1' &&
getComputedStyle(cursor2).opacity === '1'
)
await conditionPromise(
() =>
getComputedStyle(cursor1).opacity === '0' &&
getComputedStyle(cursor2).opacity === '0'
)
await conditionPromise(
() =>
getComputedStyle(cursor1).opacity === '1' &&
getComputedStyle(cursor2).opacity === '1'
)
editor.moveRight()
await component.getNextUpdatePromise()
expect(getComputedStyle(cursor1).opacity).toBe('1')
expect(getComputedStyle(cursor2).opacity).toBe('1')
})
it('gives cursors at the end of lines the width of an "x" character', async () => {
const { component, element, editor } = buildComponent()
editor.setText('abcde')
await setEditorWidthInCharacters(component, 5.5)
editor.setCursorScreenPosition([0, Infinity])
await component.getNextUpdatePromise()
expect(element.querySelector('.cursor').offsetWidth).toBe(
Math.round(component.getBaseCharacterWidth())
)
// Clip cursor width when soft-wrap is on and the cursor is at the end of
// the line. This prevents the parent tile from disabling sub-pixel
// anti-aliasing. For some reason, adding overflow: hidden to the cursor
// container doesn't solve this issue so we're adding this workaround instead.
editor.setSoftWrapped(true)
await component.getNextUpdatePromise()
expect(element.querySelector('.cursor').offsetWidth).toBeLessThan(
Math.round(component.getBaseCharacterWidth())
)
})
it('positions and sizes cursors correctly when they are located next to a fold marker', async () => {
const { component, element, editor } = buildComponent()
editor.foldBufferRange([[0, 3], [0, 6]])
editor.setCursorScreenPosition([0, 3])
await component.getNextUpdatePromise()
verifyCursorPosition(component, element.querySelector('.cursor'), 0, 3)
editor.setCursorScreenPosition([0, 4])
await component.getNextUpdatePromise()
verifyCursorPosition(component, element.querySelector('.cursor'), 0, 4)
})
it('positions cursors and placeholder text correctly when the lines container has a margin and/or is padded', async () => {
const { component, element, editor } = buildComponent({
placeholderText: 'testing'
})
component.refs.lineTiles.style.marginLeft = '10px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
editor.setCursorBufferPosition([0, 3])
await component.getNextUpdatePromise()
verifyCursorPosition(component, element.querySelector('.cursor'), 0, 3)
editor.setCursorScreenPosition([1, 0])
await component.getNextUpdatePromise()
verifyCursorPosition(component, element.querySelector('.cursor'), 1, 0)
component.refs.lineTiles.style.paddingTop = '5px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
verifyCursorPosition(component, element.querySelector('.cursor'), 1, 0)
editor.setCursorScreenPosition([2, 2])
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
verifyCursorPosition(component, element.querySelector('.cursor'), 2, 2)
editor.setText('')
await component.getNextUpdatePromise()
const placeholderTextLeft = element
.querySelector('.placeholder-text')
.getBoundingClientRect().left
const linesLeft = component.refs.lineTiles.getBoundingClientRect().left
expect(placeholderTextLeft).toBe(linesLeft)
})
it('places the hidden input element at the location of the last cursor if it is visible', async () => {
const { component, editor } = buildComponent({
height: 60,
width: 120,
rowsPerTile: 2
})
const { hiddenInput } = component.refs.cursorsAndInput.refs
setScrollTop(component, 100)
await setScrollLeft(component, 40)
expect(component.getRenderedStartRow()).toBe(4)
expect(component.getRenderedEndRow()).toBe(10)
// When out of view, the hidden input is positioned at 0, 0
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
expect(hiddenInput.offsetTop).toBe(0)
expect(hiddenInput.offsetLeft).toBe(0)
// Otherwise it is positioned at the last cursor position
editor.addCursorAtScreenPosition([7, 4])
await component.getNextUpdatePromise()
expect(hiddenInput.getBoundingClientRect().top).toBe(
clientTopForLine(component, 7)
)
expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(
clientLeftForCharacter(component, 7, 4)
)
})
it('soft wraps lines based on the content width when soft wrap is enabled', async () => {
let baseCharacterWidth, gutterContainerWidth
{
const { component, editor } = buildComponent()
baseCharacterWidth = component.getBaseCharacterWidth()
gutterContainerWidth = component.getGutterContainerWidth()
editor.destroy()
}
const { component, element, editor } = buildComponent({
width: gutterContainerWidth + baseCharacterWidth * 55,
attach: false
})
editor.setSoftWrapped(true)
jasmine.attachToDOM(element)
expect(getEditorWidthInBaseCharacters(component)).toBe(55)
expect(lineNodeForScreenRow(component, 3).textContent).toBe(
' var pivot = items.shift(), current, left = [], '
)
expect(lineNodeForScreenRow(component, 4).textContent).toBe(
' right = [];'
)
await setEditorWidthInCharacters(component, 45)
expect(lineNodeForScreenRow(component, 3).textContent).toBe(
' var pivot = items.shift(), current, left '
)
expect(lineNodeForScreenRow(component, 4).textContent).toBe(
' = [], right = [];'
)
const { scrollContainer } = component.refs
expect(scrollContainer.clientWidth).toBe(scrollContainer.scrollWidth)
})
it('correctly forces the display layer to index visible rows when resizing (regression)', async () => {
const text = 'a'.repeat(30) + '\n' + 'b'.repeat(1000)
const { component, element, editor } = buildComponent({
height: 300,
width: 800,
attach: false,
text
})
editor.setSoftWrapped(true)
jasmine.attachToDOM(element)
element.style.width = 200 + 'px'
await component.getNextUpdatePromise()
expect(queryOnScreenLineElements(element).length).toBe(24)
})
it('decorates the line numbers of folded lines', async () => {
const { component, editor } = buildComponent()
editor.foldBufferRow(1)
await component.getNextUpdatePromise()
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('folded')
).toBe(true)
})
it('makes lines at least as wide as the scrollContainer', async () => {
const { component, element, editor } = buildComponent()
const { scrollContainer } = component.refs
editor.setText('a')
await component.getNextUpdatePromise()
expect(element.querySelector('.line').offsetWidth).toBe(
scrollContainer.offsetWidth - verticalScrollbarWidth
)
})
it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => {
const { component, element, editor } = buildComponent({
autoHeight: true,
autoWidth: true
})
const editorPadding = 3
element.style.padding = editorPadding + 'px'
const initialWidth = element.offsetWidth
const initialHeight = element.offsetHeight
expect(initialWidth).toBe(
component.getGutterContainerWidth() +
component.getContentWidth() +
verticalScrollbarWidth +
2 * editorPadding
)
expect(initialHeight).toBe(
component.getContentHeight() +
horizontalScrollbarHeight +
2 * editorPadding
)
// When autoWidth is enabled, width adjusts to content
editor.setCursorScreenPosition([6, Infinity])
editor.insertText('x'.repeat(50))
await component.getNextUpdatePromise()
expect(element.offsetWidth).toBe(
component.getGutterContainerWidth() +
component.getContentWidth() +
verticalScrollbarWidth +
2 * editorPadding
)
expect(element.offsetWidth).toBeGreaterThan(initialWidth)
// When autoHeight is enabled, height adjusts to content
editor.insertText('\n'.repeat(5))
await component.getNextUpdatePromise()
expect(element.offsetHeight).toBe(
component.getContentHeight() +
horizontalScrollbarHeight +
2 * editorPadding
)
expect(element.offsetHeight).toBeGreaterThan(initialHeight)
})
it('does not render the line number gutter at all if the isLineNumberGutterVisible parameter is false', () => {
const { element } = buildComponent({
lineNumberGutterVisible: false
})
expect(element.querySelector('.line-number')).toBe(null)
})
it('does not render the line numbers but still renders the line number gutter if showLineNumbers is false', async () => {
function checkScrollContainerLeft (component) {
const { scrollContainer, gutterContainer } = component.refs
expect(scrollContainer.getBoundingClientRect().left).toBe(
Math.round(gutterContainer.element.getBoundingClientRect().right)
)
}
const { component, element, editor } = buildComponent({
showLineNumbers: false
})
expect(
Array.from(element.querySelectorAll('.line-number')).every(
e => e.textContent === ''
)
).toBe(true)
checkScrollContainerLeft(component)
await editor.update({ showLineNumbers: true })
expect(
Array.from(element.querySelectorAll('.line-number')).map(
e => e.textContent
)
).toEqual([
'00',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13'
])
checkScrollContainerLeft(component)
await editor.update({ showLineNumbers: false })
expect(
Array.from(element.querySelectorAll('.line-number')).every(
e => e.textContent === ''
)
).toBe(true)
checkScrollContainerLeft(component)
})
it('supports the placeholderText parameter', () => {
const placeholderText = 'Placeholder Test'
const { element } = buildComponent({ placeholderText, text: '' })
expect(element.textContent).toContain(placeholderText)
})
it('adds the data-grammar attribute and updates it when the grammar changes', async () => {
await atom.packages.activatePackage('language-javascript')
const { editor, element, component } = buildComponent()
expect(element.dataset.grammar).toBe('text plain null-grammar')
atom.grammars.assignLanguageMode(editor.getBuffer(), 'source.js')
await component.getNextUpdatePromise()
expect(element.dataset.grammar).toBe('source js')
})
it('adds the data-encoding attribute and updates it when the encoding changes', async () => {
const { editor, element, component } = buildComponent()
expect(element.dataset.encoding).toBe('utf8')
editor.setEncoding('ascii')
await component.getNextUpdatePromise()
expect(element.dataset.encoding).toBe('ascii')
})
it('adds the has-selection class when the editor has a non-empty selection', async () => {
const { editor, element, component } = buildComponent()
expect(element.classList.contains('has-selection')).toBe(false)
editor.setSelectedBufferRanges([[[0, 0], [0, 0]], [[1, 0], [1, 10]]])
await component.getNextUpdatePromise()
expect(element.classList.contains('has-selection')).toBe(true)
editor.setSelectedBufferRanges([[[0, 0], [0, 0]], [[1, 0], [1, 0]]])
await component.getNextUpdatePromise()
expect(element.classList.contains('has-selection')).toBe(false)
})
it('assigns buffer-row and screen-row to each line number as data fields', async () => {
const { editor, element, component } = buildComponent()
editor.setSoftWrapped(true)
await component.getNextUpdatePromise()
await setEditorWidthInCharacters(component, 40)
{
const bufferRows = queryOnScreenLineNumberElements(element).map(
e => e.dataset.bufferRow
)
const screenRows = queryOnScreenLineNumberElements(element).map(
e => e.dataset.screenRow
)
expect(bufferRows).toEqual([
'0',
'1',
'2',
'3',
'3',
'4',
'5',
'6',
'6',
'6',
'7',
'8',
'8',
'8',
'9',
'10',
'11',
'11',
'12'
])
expect(screenRows).toEqual([
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18'
])
}
editor.getBuffer().insert([2, 0], '\n')
await component.getNextUpdatePromise()
{
const bufferRows = queryOnScreenLineNumberElements(element).map(
e => e.dataset.bufferRow
)
const screenRows = queryOnScreenLineNumberElements(element).map(
e => e.dataset.screenRow
)
expect(bufferRows).toEqual([
'0',
'1',
'2',
'3',
'4',
'4',
'5',
'6',
'7',
'7',
'7',
'8',
'9',
'9',
'9',
'10',
'11',
'12',
'12',
'13'
])
expect(screenRows).toEqual([
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19'
])
}
})
it('does not blow away class names added to the element by packages when changing the class name', async () => {
assertDocumentFocused()
const { component, element } = buildComponent()
element.classList.add('a', 'b')
expect(element.className).toBe('editor a b')
element.focus()
await component.getNextUpdatePromise()
expect(element.className).toBe('editor a b is-focused')
document.body.focus()
await component.getNextUpdatePromise()
expect(element.className).toBe('editor a b')
})
it('does not blow away class names managed by the component when packages change the element class name', async () => {
assertDocumentFocused()
const { component, element } = buildComponent({ mini: true })
element.classList.add('a', 'b')
element.focus()
await component.getNextUpdatePromise()
expect(element.className).toBe('editor mini a b is-focused')
element.className = 'a c d'
await component.getNextUpdatePromise()
expect(element.className).toBe('a c d editor is-focused mini')
})
it('ignores resize events when the editor is hidden', async () => {
const { component, element } = buildComponent({
autoHeight: false
})
element.style.height = 5 * component.getLineHeight() + 'px'
await component.getNextUpdatePromise()
const originalClientContainerHeight = component.getClientContainerHeight()
const originalGutterContainerWidth = component.getGutterContainerWidth()
const originalLineNumberGutterWidth = component.getLineNumberGutterWidth()
expect(originalClientContainerHeight).toBeGreaterThan(0)
expect(originalGutterContainerWidth).toBeGreaterThan(0)
expect(originalLineNumberGutterWidth).toBeGreaterThan(0)
element.style.display = 'none'
// In production, resize events are triggered before the intersection
// observer detects the editor's visibility has changed. In tests, we are
// unable to reproduce this scenario and so we simulate them.
expect(component.visible).toBe(true)
component.didResize()
component.didResizeGutterContainer()
expect(component.getClientContainerHeight()).toBe(
originalClientContainerHeight
)
expect(component.getGutterContainerWidth()).toBe(
originalGutterContainerWidth
)
expect(component.getLineNumberGutterWidth()).toBe(
originalLineNumberGutterWidth
)
// Ensure measurements stay the same after receiving the intersection
// observer events.
await conditionPromise(() => !component.visible)
expect(component.getClientContainerHeight()).toBe(
originalClientContainerHeight
)
expect(component.getGutterContainerWidth()).toBe(
originalGutterContainerWidth
)
expect(component.getLineNumberGutterWidth()).toBe(
originalLineNumberGutterWidth
)
})
describe('randomized tests', () => {
let originalTimeout
beforeEach(() => {
originalTimeout = jasmine.getEnv().defaultTimeoutInterval
jasmine.getEnv().defaultTimeoutInterval = 60 * 1000
})
afterEach(() => {
jasmine.getEnv().defaultTimeoutInterval = originalTimeout
})
it('renders the visible rows correctly after randomly mutating the editor', async () => {
const initialSeed = Date.now()
for (var i = 0; i < 20; i++) {
let seed = initialSeed + i
// seed = 1520247533732
const failureMessage = 'Randomized test failed with seed: ' + seed
const random = Random(seed)
const rowsPerTile = random.intBetween(1, 6)
const { component, element, editor } = buildComponent({
rowsPerTile,
autoHeight: false
})
editor.setSoftWrapped(Boolean(random(2)))
await setEditorWidthInCharacters(component, random(20))
await setEditorHeightInLines(component, random(10))
element.style.fontSize = random(20) + 'px'
element.style.lineHeight = random.floatBetween(0.1, 2.0)
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
element.focus()
for (var j = 0; j < 5; j++) {
const k = random(100)
const range = getRandomBufferRange(random, editor.buffer)
if (k < 10) {
editor.setSoftWrapped(!editor.isSoftWrapped())
} else if (k < 15) {
if (random(2)) setEditorWidthInCharacters(component, random(20))
if (random(2)) setEditorHeightInLines(component, random(10))
} else if (k < 40) {
editor.setSelectedBufferRange(range)
editor.backspace()
} else if (k < 80) {
const linesToInsert = buildRandomLines(random, 5)
editor.setCursorBufferPosition(range.start)
editor.insertText(linesToInsert)
} else if (k < 90) {
if (random(2)) {
editor.foldBufferRange(range)
} else {
editor.destroyFoldsIntersectingBufferRange(range)
}
} else if (k < 95) {
editor.setSelectedBufferRange(range)
} else {
if (random(2)) {
component.setScrollTop(random(component.getScrollHeight()))
}
if (random(2)) {
component.setScrollLeft(random(component.getScrollWidth()))
}
}
component.scheduleUpdate()
await component.getNextUpdatePromise()
const renderedLines = queryOnScreenLineElements(element).sort(
(a, b) => a.dataset.screenRow - b.dataset.screenRow
)
const renderedLineNumbers = queryOnScreenLineNumberElements(
element
).sort((a, b) => a.dataset.screenRow - b.dataset.screenRow)
const renderedStartRow = component.getRenderedStartRow()
const expectedLines = editor.displayLayer.getScreenLines(
renderedStartRow,
component.getRenderedEndRow()
)
expect(renderedLines.length).toBe(
expectedLines.length,
failureMessage
)
expect(renderedLineNumbers.length).toBe(
expectedLines.length,
failureMessage
)
for (let k = 0; k < renderedLines.length; k++) {
const expectedLine = expectedLines[k]
const expectedText = expectedLine.lineText || ' '
const renderedLine = renderedLines[k]
const renderedLineNumber = renderedLineNumbers[k]
let renderedText = renderedLine.textContent
// We append zero width NBSPs after folds at the end of the
// line in order to support measurement.
if (expectedText.endsWith(editor.displayLayer.foldCharacter)) {
renderedText = renderedText.substring(
0,
renderedText.length - 1
)
}
expect(renderedText).toBe(expectedText, failureMessage)
expect(parseInt(renderedLine.dataset.screenRow)).toBe(
renderedStartRow + k,
failureMessage
)
expect(parseInt(renderedLineNumber.dataset.screenRow)).toBe(
renderedStartRow + k,
failureMessage
)
}
}
element.remove()
editor.destroy()
}
})
})
})
describe('mini editors', () => {
it('adds the mini attribute and class even when the element is not attached', () => {
{
const { element } = buildComponent({ mini: true })
expect(element.hasAttribute('mini')).toBe(true)
expect(element.classList.contains('mini')).toBe(true)
}
{
const { element } = buildComponent({
mini: true,
attach: false
})
expect(element.hasAttribute('mini')).toBe(true)
expect(element.classList.contains('mini')).toBe(true)
}
})
it('does not render the gutter container', () => {
const { component, element } = buildComponent({ mini: true })
expect(component.refs.gutterContainer).toBeUndefined()
expect(element.querySelector('gutter-container')).toBeNull()
})
it('does not render line decorations for the cursor line', async () => {
const { component, element, editor } = buildComponent({ mini: true })
expect(
element.querySelector('.line').classList.contains('cursor-line')
).toBe(false)
editor.update({ mini: false })
await component.getNextUpdatePromise()
expect(
element.querySelector('.line').classList.contains('cursor-line')
).toBe(true)
editor.update({ mini: true })
await component.getNextUpdatePromise()
expect(
element.querySelector('.line').classList.contains('cursor-line')
).toBe(false)
})
it('does not render scrollbars', async () => {
const { component, editor } = buildComponent({
mini: true,
autoHeight: false
})
await setEditorWidthInCharacters(component, 10)
editor.setText('x'.repeat(20) + 'y'.repeat(20))
await component.getNextUpdatePromise()
expect(component.canScrollVertically()).toBe(false)
expect(component.canScrollHorizontally()).toBe(false)
expect(component.refs.horizontalScrollbar).toBeUndefined()
expect(component.refs.verticalScrollbar).toBeUndefined()
})
})
describe('focus', () => {
beforeEach(() => {
assertDocumentFocused()
})
it('focuses the hidden input element and adds the is-focused class when focused', async () => {
const { component, element } = buildComponent()
const { hiddenInput } = component.refs.cursorsAndInput.refs
expect(document.activeElement).not.toBe(hiddenInput)
element.focus()
expect(document.activeElement).toBe(hiddenInput)
await component.getNextUpdatePromise()
expect(element.classList.contains('is-focused')).toBe(true)
element.focus() // focusing back to the element does not blur
expect(document.activeElement).toBe(hiddenInput)
expect(element.classList.contains('is-focused')).toBe(true)
document.body.focus()
expect(document.activeElement).not.toBe(hiddenInput)
await component.getNextUpdatePromise()
expect(element.classList.contains('is-focused')).toBe(false)
})
it('updates the component when the hidden input is focused directly', async () => {
const { component, element } = buildComponent()
const { hiddenInput } = component.refs.cursorsAndInput.refs
expect(element.classList.contains('is-focused')).toBe(false)
expect(document.activeElement).not.toBe(hiddenInput)
hiddenInput.focus()
await component.getNextUpdatePromise()
expect(element.classList.contains('is-focused')).toBe(true)
})
it('gracefully handles a focus event that occurs prior to the attachedCallback of the element', () => {
const { component, element } = buildComponent({ attach: false })
const parent = document.createElement(
'text-editor-component-test-element'
)
parent.appendChild(element)
parent.didAttach = () => element.focus()
jasmine.attachToDOM(parent)
expect(document.activeElement).toBe(
component.refs.cursorsAndInput.refs.hiddenInput
)
})
it('gracefully handles a focus event that occurs prior to detecting the element has become visible', async () => {
const { component, element } = buildComponent({ attach: false })
element.style.display = 'none'
jasmine.attachToDOM(element)
element.style.display = 'block'
element.focus()
await component.getNextUpdatePromise()
expect(document.activeElement).toBe(
component.refs.cursorsAndInput.refs.hiddenInput
)
})
it('emits blur events only when focus shifts to something other than the editor itself or its hidden input', () => {
const { element } = buildComponent()
let blurEventCount = 0
element.addEventListener('blur', () => blurEventCount++)
element.focus()
expect(blurEventCount).toBe(0)
element.focus()
expect(blurEventCount).toBe(0)
document.body.focus()
expect(blurEventCount).toBe(1)
})
})
describe('autoscroll', () => {
it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => {
const { component, editor } = buildComponent({
height: 120 + horizontalScrollbarHeight
})
expect(component.getLastVisibleRow()).toBe(7)
editor.scrollToScreenRange([[4, 0], [6, 0]])
await component.getNextUpdatePromise()
expect(component.getScrollBottom()).toBe(
(6 + 1 + editor.verticalScrollMargin) * component.getLineHeight()
)
editor.scrollToScreenPosition([8, 0])
await component.getNextUpdatePromise()
expect(component.getScrollBottom()).toBe(
(8 + 1 + editor.verticalScrollMargin) *
component.measurements.lineHeight
)
editor.scrollToScreenPosition([3, 0])
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(
(3 - editor.verticalScrollMargin) * component.measurements.lineHeight
)
editor.scrollToScreenPosition([2, 0])
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(0)
})
it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => {
const { component, element, editor } = buildComponent({
autoHeight: false
})
element.style.height =
5.5 * component.measurements.lineHeight +
horizontalScrollbarHeight +
'px'
await component.getNextUpdatePromise()
expect(component.getLastVisibleRow()).toBe(5)
const scrollMarginInLines = 2
editor.scrollToScreenPosition([6, 0])
await component.getNextUpdatePromise()
expect(component.getScrollBottom()).toBe(
(6 + 1 + scrollMarginInLines) * component.measurements.lineHeight
)
editor.scrollToScreenPosition([6, 4])
await component.getNextUpdatePromise()
expect(component.getScrollBottom()).toBe(
(6 + 1 + scrollMarginInLines) * component.measurements.lineHeight
)
editor.scrollToScreenRange([[4, 4], [6, 4]])
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(
(4 - scrollMarginInLines) * component.measurements.lineHeight
)
editor.scrollToScreenRange([[4, 4], [6, 4]], { reversed: false })
await component.getNextUpdatePromise()
expect(component.getScrollBottom()).toBe(
(6 + 1 + scrollMarginInLines) * component.measurements.lineHeight
)
})
it('autoscrolls the given range to the center of the screen if the `center` option is true', async () => {
const { component, editor } = buildComponent({ height: 50 })
expect(component.getLastVisibleRow()).toBe(2)
editor.scrollToScreenRange([[4, 0], [6, 0]], { center: true })
await component.getNextUpdatePromise()
const actualScrollCenter =
(component.getScrollTop() + component.getScrollBottom()) / 2
const expectedScrollCenter = ((4 + 7) / 2) * component.getLineHeight()
expect(actualScrollCenter).toBeCloseTo(expectedScrollCenter, 0)
})
it('automatically scrolls horizontally when the requested range is within the horizontal scroll margin of the right edge of the gutter or right edge of the scroll container', async () => {
const { component, element, editor } = buildComponent()
element.style.width =
component.getGutterContainerWidth() +
3 *
editor.horizontalScrollMargin *
component.measurements.baseCharacterWidth +
'px'
await component.getNextUpdatePromise()
editor.scrollToScreenRange([[1, 12], [2, 28]])
await component.getNextUpdatePromise()
let expectedScrollLeft =
clientLeftForCharacter(component, 1, 12) -
lineNodeForScreenRow(component, 1).getBoundingClientRect().left -
editor.horizontalScrollMargin *
component.measurements.baseCharacterWidth
expect(component.getScrollLeft()).toBeCloseTo(expectedScrollLeft, 0)
editor.scrollToScreenRange([[1, 12], [2, 28]], { reversed: false })
await component.getNextUpdatePromise()
expectedScrollLeft =
component.getGutterContainerWidth() +
clientLeftForCharacter(component, 2, 28) -
lineNodeForScreenRow(component, 2).getBoundingClientRect().left +
editor.horizontalScrollMargin *
component.measurements.baseCharacterWidth -
component.getScrollContainerClientWidth()
expect(component.getScrollLeft()).toBeCloseTo(expectedScrollLeft, 0)
})
it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => {
const { component, editor } = buildComponent({ autoHeight: false })
await setEditorWidthInCharacters(
component,
1.5 * editor.horizontalScrollMargin
)
const editorWidthInChars =
component.getScrollContainerClientWidth() /
component.getBaseCharacterWidth()
expect(Math.round(editorWidthInChars)).toBe(9)
editor.scrollToScreenRange([[6, 10], [6, 15]])
await component.getNextUpdatePromise()
let expectedScrollLeft = Math.floor(
clientLeftForCharacter(component, 6, 10) -
lineNodeForScreenRow(component, 1).getBoundingClientRect().left -
Math.floor((editorWidthInChars - 1) / 2) *
component.getBaseCharacterWidth()
)
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
})
it('correctly autoscrolls after inserting a line that exceeds the current content width', async () => {
const { component, element, editor } = buildComponent()
element.style.width =
component.getGutterContainerWidth() + component.getContentWidth() + 'px'
await component.getNextUpdatePromise()
editor.setCursorScreenPosition([0, Infinity])
editor.insertText('x'.repeat(100))
await component.getNextUpdatePromise()
expect(component.getScrollLeft()).toBe(
component.getScrollWidth() - component.getScrollContainerClientWidth()
)
})
it('does not try to measure lines that do not exist when the animation frame is delivered', async () => {
const { component, editor } = buildComponent({
autoHeight: false,
height: 30,
rowsPerTile: 2
})
editor.scrollToBufferPosition([11, 5])
editor.getBuffer().deleteRows(11, 12)
await component.getNextUpdatePromise()
expect(component.getScrollBottom()).toBe(
(10 + 1) * component.measurements.lineHeight
)
})
it('accounts for the presence of horizontal scrollbars that appear during the same frame as the autoscroll', async () => {
const { component, element, editor } = buildComponent({
autoHeight: false
})
element.style.height = component.getContentHeight() / 2 + 'px'
element.style.width = component.getScrollWidth() + 'px'
await component.getNextUpdatePromise()
editor.setCursorScreenPosition([10, Infinity])
editor.insertText('\n\n' + 'x'.repeat(100))
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(
component.getScrollHeight() - component.getScrollContainerClientHeight()
)
expect(component.getScrollLeft()).toBe(
component.getScrollWidth() - component.getScrollContainerClientWidth()
)
// Scrolling to the top should not throw an error. This failed
// previously due to horizontalPositionsToMeasure not being empty after
// autoscrolling vertically to account for the horizontal scrollbar.
spyOn(window, 'onerror')
await setScrollTop(component, 0)
expect(window.onerror).not.toHaveBeenCalled()
})
})
describe('logical scroll positions', () => {
it('allows the scrollTop to be changed and queried in terms of rows via setScrollTopRow and getScrollTopRow', () => {
const { component, element } = buildComponent({
attach: false,
height: 80
})
// Caches the scrollTopRow if we don't have measurements
component.setScrollTopRow(6)
expect(component.getScrollTopRow()).toBe(6)
// Assigns the scrollTop based on the logical position when attached
jasmine.attachToDOM(element)
const expectedScrollTop = Math.round(6 * component.getLineHeight())
expect(component.getScrollTopRow()).toBe(6)
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
// Allows the scrollTopRow to be updated while attached
component.setScrollTopRow(4)
expect(component.getScrollTopRow()).toBe(4)
expect(component.getScrollTop()).toBe(
Math.round(4 * component.getLineHeight())
)
// Preserves the scrollTopRow when detached
element.remove()
expect(component.getScrollTopRow()).toBe(4)
expect(component.getScrollTop()).toBe(
Math.round(4 * component.getLineHeight())
)
component.setScrollTopRow(6)
expect(component.getScrollTopRow()).toBe(6)
expect(component.getScrollTop()).toBe(
Math.round(6 * component.getLineHeight())
)
jasmine.attachToDOM(element)
element.style.height = '60px'
expect(component.getScrollTopRow()).toBe(6)
expect(component.getScrollTop()).toBe(
Math.round(6 * component.getLineHeight())
)
})
it('allows the scrollLeft to be changed and queried in terms of base character columns via setScrollLeftColumn and getScrollLeftColumn', () => {
const { component, element } = buildComponent({
attach: false,
width: 80
})
// Caches the scrollTopRow if we don't have measurements
component.setScrollLeftColumn(2)
expect(component.getScrollLeftColumn()).toBe(2)
// Assigns the scrollTop based on the logical position when attached
jasmine.attachToDOM(element)
expect(component.getScrollLeft()).toBeCloseTo(
2 * component.getBaseCharacterWidth(),
0
)
// Allows the scrollTopRow to be updated while attached
component.setScrollLeftColumn(4)
expect(component.getScrollLeft()).toBeCloseTo(
4 * component.getBaseCharacterWidth(),
0
)
// Preserves the scrollTopRow when detached
element.remove()
expect(component.getScrollLeft()).toBeCloseTo(
4 * component.getBaseCharacterWidth(),
0
)
component.setScrollLeftColumn(6)
expect(component.getScrollLeft()).toBeCloseTo(
6 * component.getBaseCharacterWidth(),
0
)
jasmine.attachToDOM(element)
element.style.width = '60px'
expect(component.getScrollLeft()).toBeCloseTo(
6 * component.getBaseCharacterWidth(),
0
)
})
})
describe('scrolling via the mouse wheel', () => {
it('scrolls vertically or horizontally depending on whether deltaX or deltaY is larger', () => {
const scrollSensitivity = 30
const { component } = buildComponent({
height: 50,
width: 50,
scrollSensitivity
})
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
const expectedScrollLeft = component.getScrollLeft()
component.didMouseWheel({ wheelDeltaX: -5, wheelDeltaY: -20 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`
)
}
{
const expectedScrollTop =
component.getScrollTop() - 10 * (scrollSensitivity / 100)
const expectedScrollLeft = component.getScrollLeft()
component.didMouseWheel({ wheelDeltaX: -5, wheelDeltaY: 10 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`
)
}
{
const expectedScrollTop = component.getScrollTop()
const expectedScrollLeft = 20 * (scrollSensitivity / 100)
component.didMouseWheel({ wheelDeltaX: -20, wheelDeltaY: 10 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`
)
}
{
const expectedScrollTop = component.getScrollTop()
const expectedScrollLeft =
component.getScrollLeft() - 10 * (scrollSensitivity / 100)
component.didMouseWheel({ wheelDeltaX: 10, wheelDeltaY: -8 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`
)
}
})
it('inverts deltaX and deltaY when holding shift on Windows and Linux', async () => {
const scrollSensitivity = 50
const { component } = buildComponent({
height: 50,
width: 50,
scrollSensitivity
})
component.props.platform = 'linux'
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
component.didMouseWheel({ wheelDeltaX: 0, wheelDeltaY: -20 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
await setScrollTop(component, 0)
}
{
const expectedScrollLeft = 20 * (scrollSensitivity / 100)
component.didMouseWheel({
wheelDeltaX: 0,
wheelDeltaY: -20,
shiftKey: true
})
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(-${expectedScrollLeft}px, 0px)`
)
await setScrollLeft(component, 0)
}
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
component.didMouseWheel({
wheelDeltaX: -20,
wheelDeltaY: 0,
shiftKey: true
})
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
await setScrollTop(component, 0)
}
component.props.platform = 'win32'
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
component.didMouseWheel({ wheelDeltaX: 0, wheelDeltaY: -20 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
await setScrollTop(component, 0)
}
{
const expectedScrollLeft = 20 * (scrollSensitivity / 100)
component.didMouseWheel({
wheelDeltaX: 0,
wheelDeltaY: -20,
shiftKey: true
})
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(-${expectedScrollLeft}px, 0px)`
)
await setScrollLeft(component, 0)
}
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
component.didMouseWheel({
wheelDeltaX: -20,
wheelDeltaY: 0,
shiftKey: true
})
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
await setScrollTop(component, 0)
}
component.props.platform = 'darwin'
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
component.didMouseWheel({ wheelDeltaX: 0, wheelDeltaY: -20 })
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
await setScrollTop(component, 0)
}
{
const expectedScrollTop = 20 * (scrollSensitivity / 100)
component.didMouseWheel({
wheelDeltaX: 0,
wheelDeltaY: -20,
shiftKey: true
})
expect(component.getScrollTop()).toBe(expectedScrollTop)
expect(component.refs.content.style.transform).toBe(
`translate(0px, -${expectedScrollTop}px)`
)
await setScrollTop(component, 0)
}
{
const expectedScrollLeft = 20 * (scrollSensitivity / 100)
component.didMouseWheel({
wheelDeltaX: -20,
wheelDeltaY: 0,
shiftKey: true
})
expect(component.getScrollLeft()).toBe(expectedScrollLeft)
expect(component.refs.content.style.transform).toBe(
`translate(-${expectedScrollLeft}px, 0px)`
)
await setScrollLeft(component, 0)
}
})
})
describe('scrolling via the API', () => {
it('ignores scroll requests to NaN, null or undefined positions', async () => {
const { component } = buildComponent({
rowsPerTile: 2,
autoHeight: false
})
await setEditorHeightInLines(component, 3)
await setEditorWidthInCharacters(component, 10)
const initialScrollTop = Math.round(2 * component.getLineHeight())
const initialScrollLeft = Math.round(
5 * component.getBaseCharacterWidth()
)
setScrollTop(component, initialScrollTop)
setScrollLeft(component, initialScrollLeft)
await component.getNextUpdatePromise()
setScrollTop(component, NaN)
setScrollLeft(component, NaN)
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(initialScrollTop)
expect(component.getScrollLeft()).toBe(initialScrollLeft)
setScrollTop(component, null)
setScrollLeft(component, null)
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(initialScrollTop)
expect(component.getScrollLeft()).toBe(initialScrollLeft)
setScrollTop(component, undefined)
setScrollLeft(component, undefined)
await component.getNextUpdatePromise()
expect(component.getScrollTop()).toBe(initialScrollTop)
expect(component.getScrollLeft()).toBe(initialScrollLeft)
})
})
describe('line and line number decorations', () => {
it('adds decoration classes on screen lines spanned by decorated markers', async () => {
const { component, editor } = buildComponent({
softWrapped: true
})
await setEditorWidthInCharacters(component, 55)
expect(lineNodeForScreenRow(component, 3).textContent).toBe(
' var pivot = items.shift(), current, left = [], '
)
expect(lineNodeForScreenRow(component, 4).textContent).toBe(
' right = [];'
)
const marker1 = editor.markScreenRange([[1, 10], [3, 10]])
const layer = editor.addMarkerLayer()
layer.markScreenPosition([5, 0])
layer.markScreenPosition([8, 0])
const marker4 = layer.markScreenPosition([10, 0])
editor.decorateMarker(marker1, {
type: ['line', 'line-number'],
class: 'a'
})
const layerDecoration = editor.decorateMarkerLayer(layer, {
type: ['line', 'line-number'],
class: 'b'
})
layerDecoration.setPropertiesForMarker(marker4, {
type: 'line',
class: 'c'
})
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 4).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 5).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 8).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 10).classList.contains('b')).toBe(
false
)
expect(lineNodeForScreenRow(component, 10).classList.contains('c')).toBe(
true
)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 2).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 3).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 4).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 5).classList.contains('b')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 8).classList.contains('b')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 10).classList.contains('b')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 10).classList.contains('c')
).toBe(false)
marker1.setScreenRange([[5, 0], [8, 0]])
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 4).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 5).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 5).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 6).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 7).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 8).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 8).classList.contains('b')).toBe(
true
)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 2).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 3).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 4).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 5).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 5).classList.contains('b')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 6).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 7).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 8).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 8).classList.contains('b')
).toBe(true)
})
it('honors the onlyEmpty and onlyNonEmpty decoration options', async () => {
const { component, editor } = buildComponent()
const marker = editor.markScreenPosition([1, 0])
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'a',
onlyEmpty: true
})
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'b',
onlyNonEmpty: true
})
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'c'
})
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 1).classList.contains('b')).toBe(
false
)
expect(lineNodeForScreenRow(component, 1).classList.contains('c')).toBe(
true
)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('a')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('b')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('c')
).toBe(true)
marker.setScreenRange([[1, 0], [2, 4]])
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 1).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 1).classList.contains('c')).toBe(
true
)
expect(lineNodeForScreenRow(component, 2).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 2).classList.contains('c')).toBe(
true
)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('b')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('c')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 2).classList.contains('b')
).toBe(true)
expect(
lineNumberNodeForScreenRow(component, 2).classList.contains('c')
).toBe(true)
})
it('honors the onlyHead option', async () => {
const { component, editor } = buildComponent()
const marker = editor.markScreenRange([[1, 4], [3, 4]])
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'a',
onlyHead: true
})
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(
true
)
expect(
lineNumberNodeForScreenRow(component, 1).classList.contains('a')
).toBe(false)
expect(
lineNumberNodeForScreenRow(component, 3).classList.contains('a')
).toBe(true)
})
it('only decorates the last row of non-empty ranges that end at column 0 if omitEmptyLastRow is false', async () => {
const { component, editor } = buildComponent()
const marker = editor.markScreenRange([[1, 0], [3, 0]])
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'a'
})
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'b',
omitEmptyLastRow: false
})
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(
true
)
expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(
false
)
expect(lineNodeForScreenRow(component, 1).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 2).classList.contains('b')).toBe(
true
)
expect(lineNodeForScreenRow(component, 3).classList.contains('b')).toBe(
true
)
})
it('does not decorate invalidated markers', async () => {
const { component, editor } = buildComponent()
const marker = editor.markScreenRange([[1, 0], [3, 0]], {
invalidate: 'touch'
})
editor.decorateMarker(marker, {
type: ['line', 'line-number'],
class: 'a'
})
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(
true
)
editor.getBuffer().insert([2, 0], 'x')
expect(marker.isValid()).toBe(false)
await component.getNextUpdatePromise()
expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(
false
)
})
})
describe('highlight decorations', () => {
it('renders single-line highlights', async () => {
const { component, element, editor } = buildComponent()
const marker = editor.markScreenRange([[1, 2], [1, 10]])
editor.decorateMarker(marker, { type: 'highlight', class: 'a' })
await component.getNextUpdatePromise()
{
const regions = element.querySelectorAll('.highlight.a .region.a')
expect(regions.length).toBe(1)
const regionRect = regions[0].getBoundingClientRect()
expect(regionRect.top).toBe(
lineNodeForScreenRow(component, 1).getBoundingClientRect().top
)
expect(Math.round(regionRect.left)).toBe(
clientLeftForCharacter(component, 1, 2)
)
expect(Math.round(regionRect.right)).toBe(
clientLeftForCharacter(component, 1, 10)
)
}
marker.setScreenRange([[1, 4], [1, 8]])
await component.getNextUpdatePromise()
{
const regions = element.querySelectorAll('.highlight.a .region.a')
expect(regions.length).toBe(1)
const regionRect = regions[0].getBoundingClientRect()
expect(regionRect.top).toBe(
lineNodeForScreenRow(component, 1).getBoundingClientRect().top
)
expect(regionRect.bottom).toBe(
lineNodeForScreenRow(component, 1).getBoundingClientRect().bottom
)
expect(Math.round(regionRect.left)).toBe(
clientLeftForCharacter(component, 1, 4)
)
expect(Math.round(regionRect.right)).toBe(
clientLeftForCharacter(component, 1, 8)
)
}
})
it('renders multi-line highlights', async () => {
const { component, element, editor } = buildComponent({ rowsPerTile: 3 })
const marker = editor.markScreenRange([[2, 4], [3, 4]])
editor.decorateMarker(marker, { type: 'highlight', class: 'a' })
await component.getNextUpdatePromise()
{
expect(element.querySelectorAll('.highlight.a').length).toBe(1)
const regions = element.querySelectorAll('.highlight.a .region.a')
expect(regions.length).toBe(2)
const region0Rect = regions[0].getBoundingClientRect()
expect(region0Rect.top).toBe(
lineNodeForScreenRow(component, 2).getBoundingClientRect().top
)
expect(region0Rect.bottom).toBe(
lineNodeForScreenRow(component, 2).getBoundingClientRect().bottom
)
expect(Math.round(region0Rect.left)).toBe(
clientLeftForCharacter(component, 2, 4)
)
expect(Math.round(region0Rect.right)).toBe(
component.refs.content.getBoundingClientRect().right
)
const region1Rect = regions[1].getBoundingClientRect()
expect(region1Rect.top).toBe(
lineNodeForScreenRow(component, 3).getBoundingClientRect().top
)
expect(region1Rect.bottom).toBe(
lineNodeForScreenRow(component, 3).getBoundingClientRect().bottom
)
expect(Math.round(region1Rect.left)).toBe(
clientLeftForCharacter(component, 3, 0)
)
expect(Math.round(region1Rect.right)).toBe(
clientLeftForCharacter(component, 3, 4)
)
}
marker.setScreenRange([[2, 4], [5, 4]])
await component.getNextUpdatePromise()
{
expect(element.querySelectorAll('.highlight.a').length).toBe(1)
const regions = element.querySelectorAll('.highlight.a .region.a')
expect(regions.length).toBe(3)
const region0Rect = regions[0].getBoundingClientRect()
expect(region0Rect.top).toBe(
lineNodeForScreenRow(component, 2).getBoundingClientRect().top
)
expect(region0Rect.bottom).toBe(
lineNodeForScreenRow(component, 2).getBoundingClientRect().bottom
)
expect(Math.round(region0Rect.left)).toBe(
clientLeftForCharacter(component, 2, 4)
)
expect(Math.round(region0Rect.right)).toBe(
component.refs.content.getBoundingClientRect().right
)
const region1Rect = regions[1].getBoundingClientRect()
expect(region1Rect.top).toBe(
lineNodeForScreenRow(component, 3).getBoundingClientRect().top
)
expect(region1Rect.bottom).toBe(
lineNodeForScreenRow(component, 5).getBoundingClientRect().top
)
expect(Math.round(region1Rect.left)).toBe(
component.refs.content.getBoundingClientRect().left
)
expect(Math.round(region1Rect.right)).toBe(
component.refs.content.getBoundingClientRect().right
)
const region2Rect = regions[2].getBoundingClientRect()
expect(region2Rect.top).toBe(
lineNodeForScreenRow(component, 5).getBoundingClientRect().top
)
expect(region2Rect.bottom).toBe(
lineNodeForScreenRow(component, 6).getBoundingClientRect().top
)
expect(Math.round(region2Rect.left)).toBe(
component.refs.content.getBoundingClientRect().left
)
expect(Math.round(region2Rect.right)).toBe(
clientLeftForCharacter(component, 5, 4)
)
}
})
it('can flash highlight decorations', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
height: 200
})
const marker = editor.markScreenRange([[2, 4], [3, 4]])
const decoration = editor.decorateMarker(marker, {
type: 'highlight',
class: 'a'
})
decoration.flash('b', 10)
// Flash on initial appearance of highlight
await component.getNextUpdatePromise()
const highlights = element.querySelectorAll('.highlight.a')
expect(highlights.length).toBe(1)
expect(highlights[0].classList.contains('b')).toBe(true)
await conditionPromise(() => !highlights[0].classList.contains('b'))
// Don't flash on next update if another flash wasn't requested
await setScrollTop(component, 100)
expect(highlights[0].classList.contains('b')).toBe(false)
// Flashing the same class again before the first flash completes
// removes the flash class and adds it back on the next frame to ensure
// CSS transitions apply to the second flash.
decoration.flash('e', 100)
await component.getNextUpdatePromise()
expect(highlights[0].classList.contains('e')).toBe(true)
decoration.flash('e', 100)
await component.getNextUpdatePromise()
expect(highlights[0].classList.contains('e')).toBe(false)
await conditionPromise(() => highlights[0].classList.contains('e'))
await conditionPromise(() => !highlights[0].classList.contains('e'))
})
it("flashing a highlight decoration doesn't unflash other highlight decorations", async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
height: 200
})
const marker = editor.markScreenRange([[2, 4], [3, 4]])
const decoration = editor.decorateMarker(marker, {
type: 'highlight',
class: 'a'
})
// Flash one class
decoration.flash('c', 1000)
await component.getNextUpdatePromise()
const highlights = element.querySelectorAll('.highlight.a')
expect(highlights.length).toBe(1)
expect(highlights[0].classList.contains('c')).toBe(true)
// Flash another class while the previously-flashed class is still highlighted
decoration.flash('d', 100)
await component.getNextUpdatePromise()
expect(highlights[0].classList.contains('c')).toBe(true)
expect(highlights[0].classList.contains('d')).toBe(true)
})
it('supports layer decorations', async () => {
const { component, element, editor } = buildComponent({ rowsPerTile: 12 })
const markerLayer = editor.addMarkerLayer()
const marker1 = markerLayer.markScreenRange([[2, 4], [3, 4]])
const marker2 = markerLayer.markScreenRange([[5, 6], [7, 8]])
const decoration = editor.decorateMarkerLayer(markerLayer, {
type: 'highlight',
class: 'a'
})
await component.getNextUpdatePromise()
const highlights = element.querySelectorAll('.highlight')
expect(highlights[0].classList.contains('a')).toBe(true)
expect(highlights[1].classList.contains('a')).toBe(true)
decoration.setPropertiesForMarker(marker1, {
type: 'highlight',
class: 'b'
})
await component.getNextUpdatePromise()
expect(highlights[0].classList.contains('b')).toBe(true)
expect(highlights[1].classList.contains('a')).toBe(true)
decoration.setPropertiesForMarker(marker1, null)
decoration.setPropertiesForMarker(marker2, {
type: 'highlight',
class: 'c'
})
await component.getNextUpdatePromise()
expect(highlights[0].classList.contains('a')).toBe(true)
expect(highlights[1].classList.contains('c')).toBe(true)
})
it('clears highlights when recycling a tile that previously contained highlights and now does not', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 2,
autoHeight: false
})
await setEditorHeightInLines(component, 2)
const marker = editor.markScreenRange([[1, 2], [1, 10]])
editor.decorateMarker(marker, { type: 'highlight', class: 'a' })
await component.getNextUpdatePromise()
expect(element.querySelectorAll('.highlight.a').length).toBe(1)
await setScrollTop(component, component.getLineHeight() * 3)
expect(element.querySelectorAll('.highlight.a').length).toBe(0)
})
it('does not move existing highlights when adding or removing other highlight decorations (regression)', async () => {
const { component, element, editor } = buildComponent()
const marker1 = editor.markScreenRange([[1, 6], [1, 10]])
editor.decorateMarker(marker1, { type: 'highlight', class: 'a' })
await component.getNextUpdatePromise()
const marker1Region = element.querySelector('.highlight.a')
expect(
Array.from(marker1Region.parentElement.children).indexOf(marker1Region)
).toBe(0)
const marker2 = editor.markScreenRange([[1, 2], [1, 4]])
editor.decorateMarker(marker2, { type: 'highlight', class: 'b' })
await component.getNextUpdatePromise()
const marker2Region = element.querySelector('.highlight.b')
expect(
Array.from(marker1Region.parentElement.children).indexOf(marker1Region)
).toBe(0)
expect(
Array.from(marker2Region.parentElement.children).indexOf(marker2Region)
).toBe(1)
marker2.destroy()
await component.getNextUpdatePromise()
expect(
Array.from(marker1Region.parentElement.children).indexOf(marker1Region)
).toBe(0)
})
it('correctly positions highlights that end on rows preceding or following block decorations', async () => {
const { editor, element, component } = buildComponent()
const item1 = document.createElement('div')
item1.style.height = '30px'
item1.style.backgroundColor = 'blue'
editor.decorateMarker(editor.markBufferPosition([4, 0]), {
type: 'block',
position: 'after',
item: item1
})
const item2 = document.createElement('div')
item2.style.height = '30px'
item2.style.backgroundColor = 'yellow'
editor.decorateMarker(editor.markBufferPosition([4, 0]), {
type: 'block',
position: 'before',
item: item2
})
editor.decorateMarker(editor.markBufferRange([[3, 0], [4, Infinity]]), {
type: 'highlight',
class: 'highlight'
})
await component.getNextUpdatePromise()
const regions = element.querySelectorAll('.highlight .region')
expect(regions[0].offsetTop).toBe(3 * component.getLineHeight())
expect(regions[0].offsetHeight).toBe(component.getLineHeight())
expect(regions[1].offsetTop).toBe(4 * component.getLineHeight() + 30)
})
})
describe('overlay decorations', () => {
function attachFakeWindow (component) {
const fakeWindow = document.createElement('div')
fakeWindow.style.position = 'absolute'
fakeWindow.style.padding = 20 + 'px'
fakeWindow.style.backgroundColor = 'blue'
fakeWindow.appendChild(component.element)
jasmine.attachToDOM(fakeWindow)
spyOn(component, 'getWindowInnerWidth').andCallFake(
() => fakeWindow.getBoundingClientRect().width
)
spyOn(component, 'getWindowInnerHeight').andCallFake(
() => fakeWindow.getBoundingClientRect().height
)
return fakeWindow
}
it('renders overlay elements at the specified screen position unless it would overflow the window', async () => {
const { component, editor } = buildComponent({
width: 200,
height: 100,
attach: false
})
const fakeWindow = attachFakeWindow(component)
await setScrollTop(component, 50)
await setScrollLeft(component, 100)
const marker = editor.markScreenPosition([4, 25])
const overlayElement = document.createElement('div')
overlayElement.style.width = '50px'
overlayElement.style.height = '50px'
overlayElement.style.margin = '3px'
overlayElement.style.backgroundColor = 'red'
const decoration = editor.decorateMarker(marker, {
type: 'overlay',
item: overlayElement,
class: 'a'
})
await component.getNextUpdatePromise()
const overlayComponent = component.overlayComponents.values().next().value
const overlayWrapper = overlayElement.parentElement
expect(overlayWrapper.classList.contains('a')).toBe(true)
expect(overlayWrapper.getBoundingClientRect().top).toBe(
clientTopForLine(component, 5)
)
expect(overlayWrapper.getBoundingClientRect().left).toBe(
clientLeftForCharacter(component, 4, 25)
)
// Updates the horizontal position on scroll
await setScrollLeft(component, 150)
expect(overlayWrapper.getBoundingClientRect().left).toBe(
clientLeftForCharacter(component, 4, 25)
)
// Shifts the overlay horizontally to ensure the overlay element does not
// overflow the window
await setScrollLeft(component, 30)
expect(overlayElement.getBoundingClientRect().right).toBe(
fakeWindow.getBoundingClientRect().right
)
await setScrollLeft(component, 280)
expect(overlayElement.getBoundingClientRect().left).toBe(
fakeWindow.getBoundingClientRect().left
)
// Updates the vertical position on scroll
await setScrollTop(component, 60)
expect(overlayWrapper.getBoundingClientRect().top).toBe(
clientTopForLine(component, 5)
)
// Flips the overlay vertically to ensure the overlay element does not
// overflow the bottom of the window
setScrollLeft(component, 100)
await setScrollTop(component, 0)
expect(overlayWrapper.getBoundingClientRect().bottom).toBe(
clientTopForLine(component, 4)
)
// Flips the overlay vertically on overlay resize if necessary
await setScrollTop(component, 20)
expect(overlayWrapper.getBoundingClientRect().top).toBe(
clientTopForLine(component, 5)
)
overlayElement.style.height = 60 + 'px'
await overlayComponent.getNextUpdatePromise()
expect(overlayWrapper.getBoundingClientRect().bottom).toBe(
clientTopForLine(component, 4)
)
// Does not flip the overlay vertically if it would overflow the top of the window
overlayElement.style.height = 80 + 'px'
await overlayComponent.getNextUpdatePromise()
expect(overlayWrapper.getBoundingClientRect().top).toBe(
clientTopForLine(component, 5)
)
// Can update overlay wrapper class
decoration.setProperties({
type: 'overlay',
item: overlayElement,
class: 'b'
})
await component.getNextUpdatePromise()
expect(overlayWrapper.classList.contains('a')).toBe(false)
expect(overlayWrapper.classList.contains('b')).toBe(true)
decoration.setProperties({ type: 'overlay', item: overlayElement })
await component.getNextUpdatePromise()
expect(overlayWrapper.classList.contains('b')).toBe(false)
})
it('does not attempt to avoid overflowing the window if `avoidOverflow` is false on the decoration', async () => {
const { component, editor } = buildComponent({
width: 200,
height: 100,
attach: false
})
const fakeWindow = attachFakeWindow(component)
const overlayElement = document.createElement('div')
overlayElement.style.width = '50px'
overlayElement.style.height = '50px'
overlayElement.style.margin = '3px'
overlayElement.style.backgroundColor = 'red'
const marker = editor.markScreenPosition([4, 25])
editor.decorateMarker(marker, {
type: 'overlay',
item: overlayElement,
avoidOverflow: false
})
await component.getNextUpdatePromise()
await setScrollLeft(component, 30)
expect(overlayElement.getBoundingClientRect().right).toBeGreaterThan(
fakeWindow.getBoundingClientRect().right
)
await setScrollLeft(component, 280)
expect(overlayElement.getBoundingClientRect().left).toBeLessThan(
fakeWindow.getBoundingClientRect().left
)
})
})
describe('custom gutter decorations', () => {
it('arranges custom gutters based on their priority', async () => {
const { component, editor } = buildComponent()
editor.addGutter({ name: 'e', priority: 2 })
editor.addGutter({ name: 'a', priority: -2 })
editor.addGutter({ name: 'd', priority: 1 })
editor.addGutter({ name: 'b', priority: -1 })
editor.addGutter({ name: 'c', priority: 0 })
await component.getNextUpdatePromise()
const gutters = component.refs.gutterContainer.element.querySelectorAll(
'.gutter'
)
expect(
Array.from(gutters).map(g => g.getAttribute('gutter-name'))
).toEqual(['a', 'b', 'c', 'line-number', 'd', 'e'])
})
it('adjusts the left edge of the scroll container based on changes to the gutter container width', async () => {
const { component, editor } = buildComponent()
const { scrollContainer, gutterContainer } = component.refs
function checkScrollContainerLeft () {
expect(scrollContainer.getBoundingClientRect().left).toBe(
Math.round(gutterContainer.element.getBoundingClientRect().right)
)
}
checkScrollContainerLeft()
const gutterA = editor.addGutter({ name: 'a' })
await component.getNextUpdatePromise()
checkScrollContainerLeft()
const gutterB = editor.addGutter({ name: 'b' })
await component.getNextUpdatePromise()
checkScrollContainerLeft()
gutterA.getElement().style.width = 100 + 'px'
await component.getNextUpdatePromise()
checkScrollContainerLeft()
gutterA.hide()
await component.getNextUpdatePromise()
checkScrollContainerLeft()
gutterA.show()
await component.getNextUpdatePromise()
checkScrollContainerLeft()
gutterA.destroy()
await component.getNextUpdatePromise()
checkScrollContainerLeft()
gutterB.destroy()
await component.getNextUpdatePromise()
checkScrollContainerLeft()
})
it('allows the element of custom gutters to be retrieved before being rendered in the editor component', async () => {
const { component, element, editor } = buildComponent()
const [lineNumberGutter] = editor.getGutters()
const gutterA = editor.addGutter({ name: 'a', priority: -1 })
const gutterB = editor.addGutter({ name: 'b', priority: 1 })
const lineNumberGutterElement = lineNumberGutter.getElement()
const gutterAElement = gutterA.getElement()
const gutterBElement = gutterB.getElement()
await component.getNextUpdatePromise()
expect(element.contains(lineNumberGutterElement)).toBe(true)
expect(element.contains(gutterAElement)).toBe(true)
expect(element.contains(gutterBElement)).toBe(true)
})
it('can show and hide custom gutters', async () => {
const { component, editor } = buildComponent()
const gutterA = editor.addGutter({ name: 'a', priority: -1 })
const gutterB = editor.addGutter({ name: 'b', priority: 1 })
const gutterAElement = gutterA.getElement()
const gutterBElement = gutterB.getElement()
await component.getNextUpdatePromise()
expect(gutterAElement.style.display).toBe('')
expect(gutterBElement.style.display).toBe('')
gutterA.hide()
await component.getNextUpdatePromise()
expect(gutterAElement.style.display).toBe('none')
expect(gutterBElement.style.display).toBe('')
gutterB.hide()
await component.getNextUpdatePromise()
expect(gutterAElement.style.display).toBe('none')
expect(gutterBElement.style.display).toBe('none')
gutterA.show()
await component.getNextUpdatePromise()
expect(gutterAElement.style.display).toBe('')
expect(gutterBElement.style.display).toBe('none')
})
it('renders decorations in custom gutters', async () => {
const { component, element, editor } = buildComponent()
const gutterA = editor.addGutter({ name: 'a', priority: -1 })
const gutterB = editor.addGutter({ name: 'b', priority: 1 })
const marker1 = editor.markScreenRange([[2, 0], [4, 0]])
const marker2 = editor.markScreenRange([[6, 0], [7, 0]])
const marker3 = editor.markScreenRange([[9, 0], [12, 0]])
const decorationElement1 = document.createElement('div')
const decorationElement2 = document.createElement('div')
// Packages may adopt this class name for decorations to be styled the same as line numbers
decorationElement2.className = 'line-number'
const decoration1 = gutterA.decorateMarker(marker1, { class: 'a' })
const decoration2 = gutterA.decorateMarker(marker2, {
class: 'b',
item: decorationElement1
})
const decoration3 = gutterB.decorateMarker(marker3, {
item: decorationElement2
})
await component.getNextUpdatePromise()
let [
decorationNode1,
decorationNode2
] = gutterA.getElement().firstChild.children
const [decorationNode3] = gutterB.getElement().firstChild.children
expect(decorationNode1.className).toBe('decoration a')
expect(decorationNode1.getBoundingClientRect().top).toBe(
clientTopForLine(component, 2)
)
expect(decorationNode1.getBoundingClientRect().bottom).toBe(
clientTopForLine(component, 5)
)
expect(decorationNode1.firstChild).toBeNull()
expect(decorationNode2.className).toBe('decoration b')
expect(decorationNode2.getBoundingClientRect().top).toBe(
clientTopForLine(component, 6)
)
expect(decorationNode2.getBoundingClientRect().bottom).toBe(
clientTopForLine(component, 8)
)
expect(decorationNode2.firstChild).toBe(decorationElement1)
expect(decorationElement1.offsetHeight).toBe(decorationNode2.offsetHeight)
expect(decorationElement1.offsetWidth).toBe(decorationNode2.offsetWidth)
expect(decorationNode3.className).toBe('decoration')
expect(decorationNode3.getBoundingClientRect().top).toBe(
clientTopForLine(component, 9)
)
expect(decorationNode3.getBoundingClientRect().bottom).toBe(
clientTopForLine(component, 12) + component.getLineHeight()
)
expect(decorationNode3.firstChild).toBe(decorationElement2)
expect(decorationElement2.offsetHeight).toBe(decorationNode3.offsetHeight)
expect(decorationElement2.offsetWidth).toBe(decorationNode3.offsetWidth)
// Inline styled height is updated when line height changes
element.style.fontSize =
parseInt(getComputedStyle(element).fontSize) + 10 + 'px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(decorationElement1.offsetHeight).toBe(decorationNode2.offsetHeight)
expect(decorationElement2.offsetHeight).toBe(decorationNode3.offsetHeight)
decoration1.setProperties({
type: 'gutter',
gutterName: 'a',
class: 'c',
item: decorationElement1
})
decoration2.setProperties({ type: 'gutter', gutterName: 'a' })
decoration3.destroy()
await component.getNextUpdatePromise()
expect(decorationNode1.className).toBe('decoration c')
expect(decorationNode1.firstChild).toBe(decorationElement1)
expect(decorationElement1.offsetHeight).toBe(decorationNode1.offsetHeight)
expect(decorationNode2.className).toBe('decoration')
expect(decorationNode2.firstChild).toBeNull()
expect(gutterB.getElement().firstChild.children.length).toBe(0)
})
it('renders custom line number gutters', async () => {
const { component, editor } = buildComponent()
const gutterA = editor.addGutter({
name: 'a',
priority: 1,
type: 'line-number',
class: 'a-number',
labelFn: ({ bufferRow }) => `a - ${bufferRow}`
})
const gutterB = editor.addGutter({
name: 'b',
priority: 1,
type: 'line-number',
class: 'b-number',
labelFn: ({ bufferRow }) => `b - ${bufferRow}`
})
editor.setText('0000\n0001\n0002\n0003\n0004\n')
await component.getNextUpdatePromise()
const gutterAElement = gutterA.getElement()
const aNumbers = gutterAElement.querySelectorAll(
'div.line-number[data-buffer-row]'
)
const aLabels = Array.from(aNumbers, e => e.textContent)
expect(aLabels).toEqual([
'a - 0',
'a - 1',
'a - 2',
'a - 3',
'a - 4',
'a - 5'
])
const gutterBElement = gutterB.getElement()
const bNumbers = gutterBElement.querySelectorAll(
'div.line-number[data-buffer-row]'
)
const bLabels = Array.from(bNumbers, e => e.textContent)
expect(bLabels).toEqual([
'b - 0',
'b - 1',
'b - 2',
'b - 3',
'b - 4',
'b - 5'
])
})
it("updates the editor's soft wrap width when a custom gutter's measurement is available", () => {
const { component, element, editor } = buildComponent({
lineNumberGutterVisible: false,
width: 400,
softWrapped: true,
attach: false
})
const gutter = editor.addGutter({ name: 'a', priority: 10 })
gutter.getElement().style.width = '100px'
jasmine.attachToDOM(element)
expect(component.getGutterContainerWidth()).toBe(100)
// Component client width - gutter container width - vertical scrollbar width
const softWrapColumn = Math.floor(
(400 - 100 - component.getVerticalScrollbarWidth()) /
component.getBaseCharacterWidth()
)
expect(editor.getSoftWrapColumn()).toBe(softWrapColumn)
})
})
describe('block decorations', () => {
it('renders visible block decorations between the appropriate lines, refreshing and measuring them as needed', async () => {
const editor = buildEditor({ autoHeight: false })
const {
item: item1,
decoration: decoration1
} = createBlockDecorationAtScreenRow(editor, 0, {
height: 11,
position: 'before'
})
const {
item: item2,
decoration: decoration2
} = createBlockDecorationAtScreenRow(editor, 2, {
height: 22,
margin: 10,
position: 'before'
})
// render an editor that already contains some block decorations
const { component, element } = buildComponent({ editor, rowsPerTile: 3 })
element.style.height =
4 * component.getLineHeight() + horizontalScrollbarHeight + 'px'
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item1) +
getElementHeight(item2)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item1) +
getElementHeight(item2)
},
{ tileStartRow: 3, height: 3 * component.getLineHeight() }
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(item1.previousSibling).toBeNull()
expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2))
// add block decorations
const {
item: item3,
decoration: decoration3
} = createBlockDecorationAtScreenRow(editor, 4, {
height: 33,
position: 'before'
})
const { item: item4 } = createBlockDecorationAtScreenRow(editor, 7, {
height: 44,
position: 'before'
})
const { item: item5 } = createBlockDecorationAtScreenRow(editor, 7, {
height: 50,
marginBottom: 5,
position: 'after'
})
const { item: item6 } = createBlockDecorationAtScreenRow(editor, 12, {
height: 60,
marginTop: 6,
position: 'after'
})
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item1) +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item1) +
getElementHeight(item2)
},
{
tileStartRow: 3,
height: 3 * component.getLineHeight() + getElementHeight(item3)
}
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(item1.previousSibling).toBeNull()
expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2))
expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3))
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7))
expect(element.contains(item6)).toBe(false)
// destroy decoration1
decoration1.destroy()
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height: 3 * component.getLineHeight() + getElementHeight(item2)
},
{
tileStartRow: 3,
height: 3 * component.getLineHeight() + getElementHeight(item3)
}
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2))
expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3))
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7))
expect(element.contains(item6)).toBe(false)
// move decoration2 and decoration3
decoration2.getMarker().setHeadScreenPosition([1, 0])
decoration3.getMarker().setHeadScreenPosition([0, 0])
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3)
},
{ tileStartRow: 3, height: 3 * component.getLineHeight() }
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item3.previousSibling).toBeNull()
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7))
expect(element.contains(item6)).toBe(false)
// change the text
editor.getBuffer().setTextInRange([[0, 5], [0, 5]], '\n\n')
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height: 3 * component.getLineHeight() + getElementHeight(item3)
},
{
tileStartRow: 3,
height: 3 * component.getLineHeight() + getElementHeight(item2)
}
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBeNull()
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3))
expect(item3.previousSibling).toBeNull()
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(element.contains(item4)).toBe(false)
expect(element.contains(item5)).toBe(false)
expect(element.contains(item6)).toBe(false)
// scroll past the first tile
await setScrollTop(
component,
3 * component.getLineHeight() + getElementHeight(item3)
)
expect(component.getRenderedStartRow()).toBe(3)
expect(component.getRenderedEndRow()).toBe(12)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 3,
height: 3 * component.getLineHeight() + getElementHeight(item2)
},
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBeNull()
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3))
expect(element.contains(item3)).toBe(false)
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 9))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 9))
expect(element.contains(item6)).toBe(false)
await setScrollTop(component, 0)
// undo the previous change
editor.undo()
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3)
},
{ tileStartRow: 3, height: 3 * component.getLineHeight() }
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item3.previousSibling).toBeNull()
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7))
expect(element.contains(item6)).toBe(false)
// invalidate decorations. this also tests a case where two decorations in
// the same tile change their height without affecting the tile height nor
// the content height.
item3.style.height = '22px'
item3.style.margin = '10px'
item2.style.height = '33px'
item2.style.margin = '0px'
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3)
},
{ tileStartRow: 3, height: 3 * component.getLineHeight() }
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item3.previousSibling).toBeNull()
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7))
expect(element.contains(item6)).toBe(false)
// make decoration before row 0 as wide as the editor, and insert some text into it so that it wraps.
item3.style.height = ''
item3.style.margin = ''
item3.style.width = ''
item3.style.wordWrap = 'break-word'
const contentWidthInCharacters = Math.floor(
component.getScrollContainerClientWidth() /
component.getBaseCharacterWidth()
)
item3.textContent = 'x'.repeat(contentWidthInCharacters * 2)
await component.getNextUpdatePromise()
// make the editor wider, so that the decoration doesn't wrap anymore.
component.element.style.width =
component.getGutterContainerWidth() +
component.getScrollContainerClientWidth() * 2 +
verticalScrollbarWidth +
'px'
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3)
},
{ tileStartRow: 3, height: 3 * component.getLineHeight() }
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(9)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item3.previousSibling).toBeNull()
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(element.contains(item6)).toBe(false)
// make the editor taller and wider and the same time, ensuring the number
// of rendered lines is correct.
setEditorHeightInLines(component, 13)
await setEditorWidthInCharacters(component, 50)
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(13)
expect(component.getScrollHeight()).toBe(
editor.getScreenLineCount() * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3) +
getElementHeight(item4) +
getElementHeight(item5) +
getElementHeight(item6)
)
assertTilesAreSizedAndPositionedCorrectly(component, [
{
tileStartRow: 0,
height:
3 * component.getLineHeight() +
getElementHeight(item2) +
getElementHeight(item3)
},
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{
tileStartRow: 6,
height:
3 * component.getLineHeight() +
getElementHeight(item4) +
getElementHeight(item5)
}
])
assertLinesAreAlignedWithLineNumbers(component)
expect(queryOnScreenLineElements(element).length).toBe(13)
expect(element.contains(item1)).toBe(false)
expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1))
expect(item3.previousSibling).toBeNull()
expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0))
expect(item4.previousSibling).toBe(lineNodeForScreenRow(component, 6))
expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7))
expect(item5.nextSibling).toBe(lineNodeForScreenRow(component, 8))
expect(item6.previousSibling).toBe(lineNodeForScreenRow(component, 12))
})
it('correctly positions line numbers when block decorations are located at tile boundaries', async () => {
const { editor, component } = buildComponent({ rowsPerTile: 3 })
createBlockDecorationAtScreenRow(editor, 0, {
height: 5,
position: 'before'
})
createBlockDecorationAtScreenRow(editor, 2, {
height: 7,
position: 'after'
})
createBlockDecorationAtScreenRow(editor, 3, {
height: 9,
position: 'before'
})
createBlockDecorationAtScreenRow(editor, 3, {
height: 11,
position: 'after'
})
createBlockDecorationAtScreenRow(editor, 5, {
height: 13,
position: 'after'
})
await component.getNextUpdatePromise()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() + 5 + 7 },
{
tileStartRow: 3,
height: 3 * component.getLineHeight() + 9 + 11 + 13
},
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
})
it('removes block decorations whose markers have been destroyed', async () => {
const { editor, component } = buildComponent({ rowsPerTile: 3 })
const { marker } = createBlockDecorationAtScreenRow(editor, 2, {
height: 5,
position: 'before'
})
await component.getNextUpdatePromise()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() + 5 },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
marker.destroy()
await component.getNextUpdatePromise()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
})
it('removes block decorations whose markers are invalidated, and adds them back when they become valid again', async () => {
const editor = buildEditor({ rowsPerTile: 3, autoHeight: false })
const { item, decoration, marker } = createBlockDecorationAtScreenRow(
editor,
3,
{ height: 44, position: 'before', invalidate: 'touch' }
)
const { component } = buildComponent({ editor, rowsPerTile: 3 })
// Invalidating the marker removes the block decoration.
editor.getBuffer().deleteRows(2, 3)
await component.getNextUpdatePromise()
expect(item.parentElement).toBeNull()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
// Moving invalid markers is ignored.
marker.setScreenRange([[2, 0], [2, 0]])
await component.getNextUpdatePromise()
expect(item.parentElement).toBeNull()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
// Making the marker valid again adds back the block decoration.
marker.bufferMarker.valid = true
marker.setScreenRange([[3, 0], [3, 0]])
await component.getNextUpdatePromise()
expect(item.nextSibling).toBe(lineNodeForScreenRow(component, 3))
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() + 44 },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
// Destroying the decoration and invalidating the marker at the same time
// removes the block decoration correctly.
editor.getBuffer().deleteRows(2, 3)
decoration.destroy()
await component.getNextUpdatePromise()
expect(item.parentElement).toBeNull()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
})
it('does not render block decorations when decorating invalid markers', async () => {
const editor = buildEditor({ rowsPerTile: 3, autoHeight: false })
const { component } = buildComponent({ editor, rowsPerTile: 3 })
const marker = editor.markScreenPosition([3, 0], { invalidate: 'touch' })
const item = document.createElement('div')
item.style.height = 30 + 'px'
item.style.width = 30 + 'px'
editor.getBuffer().deleteRows(1, 4)
editor.decorateMarker(marker, {
type: 'block',
item,
position: 'before'
})
await component.getNextUpdatePromise()
expect(item.parentElement).toBeNull()
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
// Making the marker valid again causes the corresponding block decoration
// to be added to the editor.
marker.bufferMarker.valid = true
marker.setScreenRange([[2, 0], [2, 0]])
await component.getNextUpdatePromise()
expect(item.nextSibling).toBe(lineNodeForScreenRow(component, 2))
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() + 30 },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
})
it('does not try to remeasure block decorations whose markers are invalid (regression)', async () => {
const editor = buildEditor({ rowsPerTile: 3, autoHeight: false })
const { component } = buildComponent({ editor, rowsPerTile: 3 })
createBlockDecorationAtScreenRow(editor, 2, {
height: '12px',
invalidate: 'touch'
})
editor.getBuffer().deleteRows(0, 3)
await component.getNextUpdatePromise()
// Trigger a re-measurement of all block decorations.
await setEditorWidthInCharacters(component, 20)
assertLinesAreAlignedWithLineNumbers(component)
assertTilesAreSizedAndPositionedCorrectly(component, [
{ tileStartRow: 0, height: 3 * component.getLineHeight() },
{ tileStartRow: 3, height: 3 * component.getLineHeight() },
{ tileStartRow: 6, height: 3 * component.getLineHeight() }
])
})
it('does not throw exceptions when destroying a block decoration inside a marker change event (regression)', async () => {
const { editor, component } = buildComponent({ rowsPerTile: 3 })
const marker = editor.markScreenPosition([2, 0])
marker.onDidChange(() => {
marker.destroy()
})
const item = document.createElement('div')
editor.decorateMarker(marker, { type: 'block', item })
await component.getNextUpdatePromise()
expect(item.nextSibling).toBe(lineNodeForScreenRow(component, 2))
marker.setBufferRange([[0, 0], [0, 0]])
expect(marker.isDestroyed()).toBe(true)
await component.getNextUpdatePromise()
expect(item.parentElement).toBeNull()
})
it('does not attempt to render block decorations located outside the visible range', async () => {
const { editor, component } = buildComponent({
autoHeight: false,
rowsPerTile: 2
})
await setEditorHeightInLines(component, 2)
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(4)
const marker1 = editor.markScreenRange([[3, 0], [5, 0]], {
reversed: false
})
const item1 = document.createElement('div')
editor.decorateMarker(marker1, { type: 'block', item: item1 })
const marker2 = editor.markScreenRange([[3, 0], [5, 0]], {
reversed: true
})
const item2 = document.createElement('div')
editor.decorateMarker(marker2, { type: 'block', item: item2 })
await component.getNextUpdatePromise()
expect(item1.parentElement).toBeNull()
expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3))
await setScrollTop(component, 4 * component.getLineHeight())
expect(component.getRenderedStartRow()).toBe(4)
expect(component.getRenderedEndRow()).toBe(8)
expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 5))
expect(item2.parentElement).toBeNull()
})
it('measures block decorations correctly when they are added before the component width has been updated', async () => {
{
const { editor, component, element } = buildComponent({
autoHeight: false,
width: 500,
attach: false
})
const marker = editor.markScreenPosition([0, 0])
const item = document.createElement('div')
item.textContent = 'block decoration'
editor.decorateMarker(marker, {
type: 'block',
item
})
jasmine.attachToDOM(element)
assertLinesAreAlignedWithLineNumbers(component)
}
{
const { editor, component, element } = buildComponent({
autoHeight: false,
width: 800
})
const marker = editor.markScreenPosition([0, 0])
const item = document.createElement('div')
item.textContent = 'block decoration that could wrap many times'
editor.decorateMarker(marker, {
type: 'block',
item
})
element.style.width = '50px'
await component.getNextUpdatePromise()
assertLinesAreAlignedWithLineNumbers(component)
}
})
it('bases the width of the block decoration measurement area on the editor scroll width', async () => {
const { component, element } = buildComponent({
autoHeight: false,
width: 150
})
expect(component.refs.blockDecorationMeasurementArea.offsetWidth).toBe(
component.getScrollWidth()
)
element.style.width = '800px'
await component.getNextUpdatePromise()
expect(component.refs.blockDecorationMeasurementArea.offsetWidth).toBe(
component.getScrollWidth()
)
})
it('does not change the cursor position when clicking on a block decoration', async () => {
const { editor, component } = buildComponent()
const decorationElement = document.createElement('div')
decorationElement.textContent = 'Parent'
const childElement = document.createElement('div')
childElement.textContent = 'Child'
decorationElement.appendChild(childElement)
const marker = editor.markScreenPosition([4, 0])
editor.decorateMarker(marker, { type: 'block', item: decorationElement })
await component.getNextUpdatePromise()
const decorationElementClientRect = decorationElement.getBoundingClientRect()
component.didMouseDownOnContent({
target: decorationElement,
detail: 1,
button: 0,
clientX: decorationElementClientRect.left,
clientY: decorationElementClientRect.top
})
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
const childElementClientRect = childElement.getBoundingClientRect()
component.didMouseDownOnContent({
target: childElement,
detail: 1,
button: 0,
clientX: childElementClientRect.left,
clientY: childElementClientRect.top
})
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
})
it('uses the order property to control the order of block decorations at the same screen row', async () => {
const editor = buildEditor({ autoHeight: false })
const { component, element } = buildComponent({ editor })
element.style.height =
10 * component.getLineHeight() + horizontalScrollbarHeight + 'px'
await component.getNextUpdatePromise()
// Order parameters that differ from creation order; that collide; and that are not provided.
const [beforeItems, beforeDecorations] = [
30,
20,
undefined,
20,
10,
undefined
]
.map(order => {
return createBlockDecorationAtScreenRow(editor, 2, {
height: 10,
position: 'before',
order
})
})
.reduce((lists, result) => {
lists[0].push(result.item)
lists[1].push(result.decoration)
return lists
}, [[], []])
const [afterItems] = [undefined, 1, 6, undefined, 6, 2]
.map(order => {
return createBlockDecorationAtScreenRow(editor, 2, {
height: 10,
position: 'after',
order
})
})
.reduce((lists, result) => {
lists[0].push(result.item)
lists[1].push(result.decoration)
return lists
}, [[], []])
await component.getNextUpdatePromise()
expect(beforeItems[4].previousSibling).toBe(
lineNodeForScreenRow(component, 1)
)
expect(beforeItems[4].nextSibling).toBe(beforeItems[1])
expect(beforeItems[1].nextSibling).toBe(beforeItems[3])
expect(beforeItems[3].nextSibling).toBe(beforeItems[0])
expect(beforeItems[0].nextSibling).toBe(beforeItems[2])
expect(beforeItems[2].nextSibling).toBe(beforeItems[5])
expect(beforeItems[5].nextSibling).toBe(
lineNodeForScreenRow(component, 2)
)
expect(afterItems[1].previousSibling).toBe(
lineNodeForScreenRow(component, 2)
)
expect(afterItems[1].nextSibling).toBe(afterItems[5])
expect(afterItems[5].nextSibling).toBe(afterItems[2])
expect(afterItems[2].nextSibling).toBe(afterItems[4])
expect(afterItems[4].nextSibling).toBe(afterItems[0])
expect(afterItems[0].nextSibling).toBe(afterItems[3])
// Create a decoration somewhere else and move it to the same screen row as the existing decorations
const { item: later, decoration } = createBlockDecorationAtScreenRow(
editor,
4,
{ height: 20, position: 'after', order: 3 }
)
await component.getNextUpdatePromise()
expect(later.previousSibling).toBe(lineNodeForScreenRow(component, 4))
expect(later.nextSibling).toBe(lineNodeForScreenRow(component, 5))
decoration.getMarker().setHeadScreenPosition([2, 0])
await component.getNextUpdatePromise()
expect(later.previousSibling).toBe(afterItems[5])
expect(later.nextSibling).toBe(afterItems[2])
// Move a decoration away from its screen row and ensure the rest maintain their order
beforeDecorations[3].getMarker().setHeadScreenPosition([5, 0])
await component.getNextUpdatePromise()
expect(beforeItems[3].previousSibling).toBe(
lineNodeForScreenRow(component, 4)
)
expect(beforeItems[3].nextSibling).toBe(
lineNodeForScreenRow(component, 5)
)
expect(beforeItems[4].previousSibling).toBe(
lineNodeForScreenRow(component, 1)
)
expect(beforeItems[4].nextSibling).toBe(beforeItems[1])
expect(beforeItems[1].nextSibling).toBe(beforeItems[0])
expect(beforeItems[0].nextSibling).toBe(beforeItems[2])
expect(beforeItems[2].nextSibling).toBe(beforeItems[5])
expect(beforeItems[5].nextSibling).toBe(
lineNodeForScreenRow(component, 2)
)
})
function createBlockDecorationAtScreenRow (
editor,
screenRow,
{ height, margin, marginTop, marginBottom, position, order, invalidate }
) {
const marker = editor.markScreenPosition([screenRow, 0], {
invalidate: invalidate || 'never'
})
const item = document.createElement('div')
item.style.height = height + 'px'
if (margin != null) item.style.margin = margin + 'px'
if (marginTop != null) item.style.marginTop = marginTop + 'px'
if (marginBottom != null) item.style.marginBottom = marginBottom + 'px'
item.style.width = 30 + 'px'
const decoration = editor.decorateMarker(marker, {
type: 'block',
item,
position,
order
})
return { item, decoration, marker }
}
function assertTilesAreSizedAndPositionedCorrectly (component, tiles) {
let top = 0
for (let tile of tiles) {
const linesTileElement = lineNodeForScreenRow(
component,
tile.tileStartRow
).parentElement
const linesTileBoundingRect = linesTileElement.getBoundingClientRect()
expect(linesTileBoundingRect.height).toBe(tile.height)
expect(linesTileBoundingRect.top).toBe(top)
const lineNumbersTileElement = lineNumberNodeForScreenRow(
component,
tile.tileStartRow
).parentElement
const lineNumbersTileBoundingRect = lineNumbersTileElement.getBoundingClientRect()
expect(lineNumbersTileBoundingRect.height).toBe(tile.height)
expect(lineNumbersTileBoundingRect.top).toBe(top)
top += tile.height
}
}
function assertLinesAreAlignedWithLineNumbers (component) {
const startRow = component.getRenderedStartRow()
const endRow = component.getRenderedEndRow()
for (let row = startRow; row < endRow; row++) {
const lineNode = lineNodeForScreenRow(component, row)
const lineNumberNode = lineNumberNodeForScreenRow(component, row)
expect(lineNumberNode.getBoundingClientRect().top).toBe(
lineNode.getBoundingClientRect().top
)
}
}
})
describe('cursor decorations', () => {
it('allows default cursors to be customized', async () => {
const { component, element, editor } = buildComponent()
editor.addCursorAtScreenPosition([1, 0])
const [cursorMarker1, cursorMarker2] = editor
.getCursors()
.map(c => c.getMarker())
editor.decorateMarker(cursorMarker1, { type: 'cursor', class: 'a' })
editor.decorateMarker(cursorMarker2, {
type: 'cursor',
class: 'b',
style: { visibility: 'hidden' }
})
editor.decorateMarker(cursorMarker2, {
type: 'cursor',
style: { backgroundColor: 'red' }
})
await component.getNextUpdatePromise()
const cursorNodes = element.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe(2)
expect(cursorNodes[0].className).toBe('cursor a')
expect(cursorNodes[1].className).toBe('cursor b')
expect(cursorNodes[1].style.visibility).toBe('hidden')
expect(cursorNodes[1].style.backgroundColor).toBe('red')
})
it('allows markers that are not actually associated with cursors to be decorated as if they were cursors', async () => {
const { component, element, editor } = buildComponent()
const marker = editor.markScreenPosition([1, 0])
editor.decorateMarker(marker, { type: 'cursor', class: 'a' })
await component.getNextUpdatePromise()
const cursorNodes = element.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe(2)
expect(cursorNodes[0].className).toBe('cursor')
expect(cursorNodes[1].className).toBe('cursor a')
})
})
describe('text decorations', () => {
it('injects spans with custom class names and inline styles based on text decorations', async () => {
const { component, element, editor } = buildComponent({ rowsPerTile: 2 })
const markerLayer = editor.addMarkerLayer()
const marker1 = markerLayer.markBufferRange([[0, 2], [2, 7]])
const marker2 = markerLayer.markBufferRange([[0, 2], [3, 8]])
const marker3 = markerLayer.markBufferRange([[1, 13], [2, 7]])
editor.decorateMarker(marker1, {
type: 'text',
class: 'a',
style: { color: 'red' }
})
editor.decorateMarker(marker2, {
type: 'text',
class: 'b',
style: { color: 'blue' }
})
editor.decorateMarker(marker3, {
type: 'text',
class: 'c',
style: { color: 'green' }
})
await component.getNextUpdatePromise()
expect(textContentOnRowMatchingSelector(component, 0, '.a')).toBe(
editor.lineTextForScreenRow(0).slice(2)
)
expect(textContentOnRowMatchingSelector(component, 1, '.a')).toBe(
editor.lineTextForScreenRow(1)
)
expect(textContentOnRowMatchingSelector(component, 2, '.a')).toBe(
editor.lineTextForScreenRow(2).slice(0, 7)
)
expect(textContentOnRowMatchingSelector(component, 3, '.a')).toBe('')
expect(textContentOnRowMatchingSelector(component, 0, '.b')).toBe(
editor.lineTextForScreenRow(0).slice(2)
)
expect(textContentOnRowMatchingSelector(component, 1, '.b')).toBe(
editor.lineTextForScreenRow(1)
)
expect(textContentOnRowMatchingSelector(component, 2, '.b')).toBe(
editor.lineTextForScreenRow(2)
)
expect(textContentOnRowMatchingSelector(component, 3, '.b')).toBe(
editor.lineTextForScreenRow(3).slice(0, 8)
)
expect(textContentOnRowMatchingSelector(component, 0, '.c')).toBe('')
expect(textContentOnRowMatchingSelector(component, 1, '.c')).toBe(
editor.lineTextForScreenRow(1).slice(13)
)
expect(textContentOnRowMatchingSelector(component, 2, '.c')).toBe(
editor.lineTextForScreenRow(2).slice(0, 7)
)
expect(textContentOnRowMatchingSelector(component, 3, '.c')).toBe('')
for (const span of element.querySelectorAll('.a:not(.c)')) {
expect(span.style.color).toBe('red')
}
for (const span of element.querySelectorAll('.b:not(.c):not(.a)')) {
expect(span.style.color).toBe('blue')
}
for (const span of element.querySelectorAll('.c')) {
expect(span.style.color).toBe('green')
}
marker2.setHeadScreenPosition([3, 10])
await component.getNextUpdatePromise()
expect(textContentOnRowMatchingSelector(component, 3, '.b')).toBe(
editor.lineTextForScreenRow(3).slice(0, 10)
)
})
it('correctly handles text decorations starting before the first rendered row and/or ending after the last rendered row', async () => {
const { component, element, editor } = buildComponent({
autoHeight: false,
rowsPerTile: 1
})
element.style.height = 4 * component.getLineHeight() + 'px'
await component.getNextUpdatePromise()
await setScrollTop(component, 4 * component.getLineHeight())
expect(component.getRenderedStartRow()).toBe(4)
expect(component.getRenderedEndRow()).toBe(9)
const markerLayer = editor.addMarkerLayer()
const marker1 = markerLayer.markBufferRange([[0, 0], [4, 5]])
const marker2 = markerLayer.markBufferRange([[7, 2], [10, 8]])
editor.decorateMarker(marker1, { type: 'text', class: 'a' })
editor.decorateMarker(marker2, { type: 'text', class: 'b' })
await component.getNextUpdatePromise()
expect(textContentOnRowMatchingSelector(component, 4, '.a')).toBe(
editor.lineTextForScreenRow(4).slice(0, 5)
)
expect(textContentOnRowMatchingSelector(component, 5, '.a')).toBe('')
expect(textContentOnRowMatchingSelector(component, 6, '.a')).toBe('')
expect(textContentOnRowMatchingSelector(component, 7, '.a')).toBe('')
expect(textContentOnRowMatchingSelector(component, 8, '.a')).toBe('')
expect(textContentOnRowMatchingSelector(component, 4, '.b')).toBe('')
expect(textContentOnRowMatchingSelector(component, 5, '.b')).toBe('')
expect(textContentOnRowMatchingSelector(component, 6, '.b')).toBe('')
expect(textContentOnRowMatchingSelector(component, 7, '.b')).toBe(
editor.lineTextForScreenRow(7).slice(2)
)
expect(textContentOnRowMatchingSelector(component, 8, '.b')).toBe(
editor.lineTextForScreenRow(8)
)
})
it('does not create empty spans when a text decoration contains a row but another text decoration starts or ends at the beginning of it', async () => {
const { component, element, editor } = buildComponent()
const markerLayer = editor.addMarkerLayer()
const marker1 = markerLayer.markBufferRange([[0, 2], [4, 0]])
const marker2 = markerLayer.markBufferRange([[2, 0], [5, 8]])
editor.decorateMarker(marker1, { type: 'text', class: 'a' })
editor.decorateMarker(marker2, { type: 'text', class: 'b' })
await component.getNextUpdatePromise()
for (const decorationSpan of element.querySelectorAll('.a, .b')) {
expect(decorationSpan.textContent).not.toBe('')
}
})
it('does not create empty text nodes when a text decoration ends right after a text tag', async () => {
const { component, editor } = buildComponent()
const marker = editor.markBufferRange([[0, 8], [0, 29]])
editor.decorateMarker(marker, { type: 'text', class: 'a' })
await component.getNextUpdatePromise()
for (const textNode of textNodesForScreenRow(component, 0)) {
expect(textNode.textContent).not.toBe('')
}
})
function textContentOnRowMatchingSelector (component, row, selector) {
return Array.from(
lineNodeForScreenRow(component, row).querySelectorAll(selector)
)
.map(span => span.textContent)
.join('')
}
})
describe('mouse input', () => {
describe('on the lines', () => {
describe('when there is only one cursor', () => {
it('positions the cursor on single-click or when middle-clicking', async () => {
for (const button of [0, 1]) {
const { component, editor } = buildComponent()
const { lineHeight } = component.measurements
editor.setCursorScreenPosition([Infinity, Infinity], {
autoscroll: false
})
component.didMouseDownOnContent({
detail: 1,
button,
clientX: clientLeftForCharacter(component, 0, 0) - 1,
clientY: clientTopForLine(component, 0) - 1
})
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
const maxRow = editor.getLastScreenRow()
editor.setCursorScreenPosition([Infinity, Infinity], {
autoscroll: false
})
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
clientLeftForCharacter(
component,
maxRow,
editor.lineLengthForScreenRow(maxRow)
) + 1,
clientY: clientTopForLine(component, maxRow) + 1
})
expect(editor.getCursorScreenPosition()).toEqual([
maxRow,
editor.lineLengthForScreenRow(maxRow)
])
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
clientLeftForCharacter(
component,
0,
editor.lineLengthForScreenRow(0)
) + 1,
clientY: clientTopForLine(component, 0) + lineHeight / 2
})
expect(editor.getCursorScreenPosition()).toEqual([
0,
editor.lineLengthForScreenRow(0)
])
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
(clientLeftForCharacter(component, 3, 0) +
clientLeftForCharacter(component, 3, 1)) /
2,
clientY: clientTopForLine(component, 1) + lineHeight / 2
})
expect(editor.getCursorScreenPosition()).toEqual([1, 0])
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
(clientLeftForCharacter(component, 3, 14) +
clientLeftForCharacter(component, 3, 15)) /
2,
clientY: clientTopForLine(component, 3) + lineHeight / 2
})
expect(editor.getCursorScreenPosition()).toEqual([3, 14])
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
(clientLeftForCharacter(component, 3, 14) +
clientLeftForCharacter(component, 3, 15)) /
2 +
1,
clientY: clientTopForLine(component, 3) + lineHeight / 2
})
expect(editor.getCursorScreenPosition()).toEqual([3, 15])
editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣')
await component.getNextUpdatePromise()
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
(clientLeftForCharacter(component, 3, 14) +
clientLeftForCharacter(component, 3, 16)) /
2,
clientY: clientTopForLine(component, 3) + lineHeight / 2
})
expect(editor.getCursorScreenPosition()).toEqual([3, 14])
component.didMouseDownOnContent({
detail: 1,
button,
clientX:
(clientLeftForCharacter(component, 3, 14) +
clientLeftForCharacter(component, 3, 16)) /
2 +
1,
clientY: clientTopForLine(component, 3) + lineHeight / 2
})
expect(editor.getCursorScreenPosition()).toEqual([3, 16])
expect(editor.testAutoscrollRequests).toEqual([])
}
})
})
describe('when the input is for the primary mouse button', () => {
it('selects words on double-click', () => {
const { component, editor } = buildComponent()
const { clientX, clientY } = clientPositionForCharacter(
component,
1,
16
)
component.didMouseDownOnContent({
detail: 1,
button: 0,
clientX,
clientY
})
component.didMouseDownOnContent({
detail: 2,
button: 0,
clientX,
clientY
})
expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]])
expect(editor.testAutoscrollRequests).toEqual([])
})
it('selects lines on triple-click', () => {
const { component, editor } = buildComponent()
const { clientX, clientY } = clientPositionForCharacter(
component,
1,
16
)
component.didMouseDownOnContent({
detail: 1,
button: 0,
clientX,
clientY
})
component.didMouseDownOnContent({
detail: 2,
button: 0,
clientX,
clientY
})
component.didMouseDownOnContent({
detail: 3,
button: 0,
clientX,
clientY
})
expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]])
expect(editor.testAutoscrollRequests).toEqual([])
})
it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => {
const { component, editor } = buildComponent({ platform: 'darwin' })
expect(editor.getCursorScreenPositions()).toEqual([[0, 0]])
// add cursor at 1, 16
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 16), {
detail: 1,
button: 0,
metaKey: true
})
)
expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]])
// remove cursor at 0, 0
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 0, 0), {
detail: 1,
button: 0,
metaKey: true
})
)
expect(editor.getCursorScreenPositions()).toEqual([[1, 16]])
// cmd-click cursor at 1, 16 but don't remove it because it's the last one
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 16), {
detail: 1,
button: 0,
metaKey: true
})
)
expect(editor.getCursorScreenPositions()).toEqual([[1, 16]])
// cmd-clicking within a selection destroys it
editor.addSelectionForScreenRange([[2, 10], [2, 15]], {
autoscroll: false
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[1, 16], [1, 16]],
[[2, 10], [2, 15]]
])
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 2, 13), {
detail: 1,
button: 0,
metaKey: true
})
)
expect(editor.getSelectedScreenRanges()).toEqual([[[1, 16], [1, 16]]])
// ctrl-click does not add cursors on macOS, nor does it move the cursor
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 4), {
detail: 1,
button: 0,
ctrlKey: true
})
)
expect(editor.getSelectedScreenRanges()).toEqual([[[1, 16], [1, 16]]])
// ctrl-click adds cursors on platforms *other* than macOS
component.props.platform = 'win32'
editor.setCursorScreenPosition([1, 4], { autoscroll: false })
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 16), {
detail: 1,
button: 0,
ctrlKey: true
})
)
expect(editor.getCursorScreenPositions()).toEqual([[1, 4], [1, 16]])
expect(editor.testAutoscrollRequests).toEqual([])
})
it('adds word selections when holding cmd or ctrl when double-clicking', () => {
const { component, editor } = buildComponent()
editor.addCursorAtScreenPosition([1, 16], { autoscroll: false })
expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]])
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 16), {
detail: 1,
button: 0,
metaKey: true
})
)
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 16), {
detail: 2,
button: 0,
metaKey: true
})
)
expect(editor.getSelectedScreenRanges()).toEqual([
[[0, 0], [0, 0]],
[[1, 13], [1, 21]]
])
expect(editor.testAutoscrollRequests).toEqual([])
})
it('adds line selections when holding cmd or ctrl when triple-clicking', () => {
const { component, editor } = buildComponent()
editor.addCursorAtScreenPosition([1, 16], { autoscroll: false })
expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]])
const { clientX, clientY } = clientPositionForCharacter(
component,
1,
16
)
component.didMouseDownOnContent({
detail: 1,
button: 0,
metaKey: true,
clientX,
clientY
})
component.didMouseDownOnContent({
detail: 2,
button: 0,
metaKey: true,
clientX,
clientY
})
component.didMouseDownOnContent({
detail: 3,
button: 0,
metaKey: true,
clientX,
clientY
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[0, 0], [0, 0]],
[[1, 0], [2, 0]]
])
expect(editor.testAutoscrollRequests).toEqual([])
})
it('expands the last selection on shift-click', () => {
const { component, editor } = buildComponent()
editor.setCursorScreenPosition([2, 18], { autoscroll: false })
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
shiftKey: true
},
clientPositionForCharacter(component, 1, 4)
)
)
expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]])
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
shiftKey: true
},
clientPositionForCharacter(component, 4, 4)
)
)
expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]])
// reorients word-wise selections to keep the word selected regardless of
// where the subsequent shift-click occurs
editor.setCursorScreenPosition([2, 18], { autoscroll: false })
editor.getLastSelection().selectWord({ autoscroll: false })
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
shiftKey: true
},
clientPositionForCharacter(component, 1, 4)
)
)
expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]])
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
shiftKey: true
},
clientPositionForCharacter(component, 3, 11)
)
)
expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]])
// reorients line-wise selections to keep the line selected regardless of
// where the subsequent shift-click occurs
editor.setCursorScreenPosition([2, 18], { autoscroll: false })
editor.getLastSelection().selectLine(null, { autoscroll: false })
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
shiftKey: true
},
clientPositionForCharacter(component, 1, 4)
)
)
expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]])
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
shiftKey: true
},
clientPositionForCharacter(component, 3, 11)
)
)
expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]])
expect(editor.testAutoscrollRequests).toEqual([])
})
it('expands the last selection on drag', () => {
const { component, editor } = buildComponent()
spyOn(component, 'handleMouseDragUntilMouseUp')
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0
},
clientPositionForCharacter(component, 1, 4)
)
)
{
const {
didDrag,
didStopDragging
} = component.handleMouseDragUntilMouseUp.argsForCall[0][0]
didDrag(clientPositionForCharacter(component, 8, 8))
expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]])
didDrag(clientPositionForCharacter(component, 4, 8))
expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]])
didStopDragging()
expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]])
}
// Click-drag a second selection... selections are not merged until the
// drag stops.
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0,
metaKey: 1
},
clientPositionForCharacter(component, 8, 8)
)
)
{
const {
didDrag,
didStopDragging
} = component.handleMouseDragUntilMouseUp.argsForCall[1][0]
didDrag(clientPositionForCharacter(component, 2, 8))
expect(editor.getSelectedScreenRanges()).toEqual([
[[1, 4], [4, 8]],
[[2, 8], [8, 8]]
])
didDrag(clientPositionForCharacter(component, 6, 8))
expect(editor.getSelectedScreenRanges()).toEqual([
[[1, 4], [4, 8]],
[[6, 8], [8, 8]]
])
didDrag(clientPositionForCharacter(component, 2, 8))
expect(editor.getSelectedScreenRanges()).toEqual([
[[1, 4], [4, 8]],
[[2, 8], [8, 8]]
])
didStopDragging()
expect(editor.getSelectedScreenRanges()).toEqual([[[1, 4], [8, 8]]])
}
})
it('expands the selection word-wise on double-click-drag', () => {
const { component, editor } = buildComponent()
spyOn(component, 'handleMouseDragUntilMouseUp')
component.didMouseDownOnContent(
Object.assign(
{
detail: 1,
button: 0
},
clientPositionForCharacter(component, 1, 4)
)
)
component.didMouseDownOnContent(
Object.assign(
{
detail: 2,
button: 0
},
clientPositionForCharacter(component, 1, 4)
)
)
const {
didDrag
} = component.handleMouseDragUntilMouseUp.argsForCall[1][0]
didDrag(clientPositionForCharacter(component, 0, 8))
expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]])
didDrag(clientPositionForCharacter(component, 2, 10))
expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 13]])
})
it('expands the selection line-wise on triple-click-drag', () => {
const { component, editor } = buildComponent()
spyOn(component, 'handleMouseDragUntilMouseUp')
const tripleClickPosition = clientPositionForCharacter(
component,
2,
8
)
component.didMouseDownOnContent(
Object.assign({ detail: 1, button: 0 }, tripleClickPosition)
)
component.didMouseDownOnContent(
Object.assign({ detail: 2, button: 0 }, tripleClickPosition)
)
component.didMouseDownOnContent(
Object.assign({ detail: 3, button: 0 }, tripleClickPosition)
)
const {
didDrag
} = component.handleMouseDragUntilMouseUp.argsForCall[2][0]
didDrag(clientPositionForCharacter(component, 1, 8))
expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]])
didDrag(clientPositionForCharacter(component, 4, 10))
expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]])
})
it('destroys folds when clicking on their fold markers', async () => {
const { component, element, editor } = buildComponent()
editor.foldBufferRow(1)
await component.getNextUpdatePromise()
const target = element.querySelector('.fold-marker')
const { clientX, clientY } = clientPositionForCharacter(
component,
1,
editor.lineLengthForScreenRow(1)
)
component.didMouseDownOnContent({
detail: 1,
button: 0,
target,
clientX,
clientY
})
expect(editor.isFoldedAtBufferRow(1)).toBe(false)
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
})
it('autoscrolls the content when dragging near the edge of the scroll container', async () => {
const { component } = buildComponent({
width: 200,
height: 200
})
spyOn(component, 'handleMouseDragUntilMouseUp')
let previousScrollTop = 0
let previousScrollLeft = 0
function assertScrolledDownAndRight () {
expect(component.getScrollTop()).toBeGreaterThan(previousScrollTop)
previousScrollTop = component.getScrollTop()
expect(component.getScrollLeft()).toBeGreaterThan(
previousScrollLeft
)
previousScrollLeft = component.getScrollLeft()
}
function assertScrolledUpAndLeft () {
expect(component.getScrollTop()).toBeLessThan(previousScrollTop)
previousScrollTop = component.getScrollTop()
expect(component.getScrollLeft()).toBeLessThan(previousScrollLeft)
previousScrollLeft = component.getScrollLeft()
}
component.didMouseDownOnContent({
detail: 1,
button: 0,
clientX: 100,
clientY: 100
})
const {
didDrag
} = component.handleMouseDragUntilMouseUp.argsForCall[0][0]
didDrag({ clientX: 199, clientY: 199 })
assertScrolledDownAndRight()
didDrag({ clientX: 199, clientY: 199 })
assertScrolledDownAndRight()
didDrag({ clientX: 199, clientY: 199 })
assertScrolledDownAndRight()
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
assertScrolledUpAndLeft()
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
assertScrolledUpAndLeft()
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
assertScrolledUpAndLeft()
// Don't artificially update scroll position beyond possible values
expect(component.getScrollTop()).toBe(0)
expect(component.getScrollLeft()).toBe(0)
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
expect(component.getScrollTop()).toBe(0)
expect(component.getScrollLeft()).toBe(0)
const maxScrollTop = component.getMaxScrollTop()
const maxScrollLeft = component.getMaxScrollLeft()
setScrollTop(component, maxScrollTop)
await setScrollLeft(component, maxScrollLeft)
didDrag({ clientX: 199, clientY: 199 })
didDrag({ clientX: 199, clientY: 199 })
didDrag({ clientX: 199, clientY: 199 })
expect(component.getScrollTop()).toBe(maxScrollTop)
expect(component.getScrollLeft()).toBe(maxScrollLeft)
})
})
it('pastes the previously selected text when clicking the middle mouse button on Linux', async () => {
spyOn(electron.ipcRenderer, 'send').andCallFake(function (
eventName,
selectedText
) {
if (eventName === 'write-text-to-selection-clipboard') {
clipboard.writeText(selectedText, 'selection')
}
})
const { component, editor } = buildComponent({ platform: 'linux' })
// Middle mouse pasting.
editor.setSelectedBufferRange([[1, 6], [1, 10]])
await conditionPromise(() => TextEditor.clipboard.read() === 'sort')
component.didMouseDownOnContent({
button: 1,
clientX: clientLeftForCharacter(component, 10, 0),
clientY: clientTopForLine(component, 10)
})
expect(TextEditor.clipboard.read()).toBe('sort')
expect(editor.lineTextForBufferRow(10)).toBe('sort')
editor.undo()
// Ensure left clicks don't interfere.
editor.setSelectedBufferRange([[1, 2], [1, 5]])
await conditionPromise(() => TextEditor.clipboard.read() === 'var')
component.didMouseDownOnContent({
button: 0,
detail: 1,
clientX: clientLeftForCharacter(component, 10, 0),
clientY: clientTopForLine(component, 10)
})
component.didMouseDownOnContent({
button: 1,
clientX: clientLeftForCharacter(component, 10, 0),
clientY: clientTopForLine(component, 10)
})
expect(editor.lineTextForBufferRow(10)).toBe('var')
})
it('does not paste into a read only editor when clicking the middle mouse button on Linux', async () => {
spyOn(electron.ipcRenderer, 'send').andCallFake(function (
eventName,
selectedText
) {
if (eventName === 'write-text-to-selection-clipboard') {
clipboard.writeText(selectedText, 'selection')
}
})
const { component, editor } = buildComponent({
platform: 'linux',
readOnly: true
})
// Select the word 'sort' on line 2 and copy to clipboard
editor.setSelectedBufferRange([[1, 6], [1, 10]])
await conditionPromise(() => TextEditor.clipboard.read() === 'sort')
// Middle-click in the buffer at line 11, column 1
component.didMouseDownOnContent({
button: 1,
clientX: clientLeftForCharacter(component, 10, 0),
clientY: clientTopForLine(component, 10)
})
// Ensure that the correct text was copied but not pasted
expect(TextEditor.clipboard.read()).toBe('sort')
expect(editor.lineTextForBufferRow(10)).toBe('')
})
})
describe('on the line number gutter', () => {
it('selects all buffer rows intersecting the clicked screen row when a line number is clicked', async () => {
const { component, editor } = buildComponent()
spyOn(component, 'handleMouseDragUntilMouseUp')
editor.setSoftWrapped(true)
await component.getNextUpdatePromise()
await setEditorWidthInCharacters(component, 50)
editor.foldBufferRange([[4, Infinity], [7, Infinity]])
await component.getNextUpdatePromise()
// Selects entire buffer line when clicked screen line is soft-wrapped
component.didMouseDownOnLineNumberGutter({
button: 0,
clientY: clientTopForLine(component, 3)
})
expect(editor.getSelectedScreenRange()).toEqual([[3, 0], [5, 0]])
expect(editor.getSelectedBufferRange()).toEqual([[3, 0], [4, 0]])
// Selects entire screen line, even if folds cause that selection to
// span multiple buffer lines
component.didMouseDownOnLineNumberGutter({
button: 0,
clientY: clientTopForLine(component, 5)
})
expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]])
expect(editor.getSelectedBufferRange()).toEqual([[4, 0], [8, 0]])
})
it('adds new selections when a line number is meta-clicked', async () => {
const { component, editor } = buildComponent()
editor.setSoftWrapped(true)
await component.getNextUpdatePromise()
await setEditorWidthInCharacters(component, 50)
editor.foldBufferRange([[4, Infinity], [7, Infinity]])
await component.getNextUpdatePromise()
// Selects entire buffer line when clicked screen line is soft-wrapped
component.didMouseDownOnLineNumberGutter({
button: 0,
metaKey: true,
clientY: clientTopForLine(component, 3)
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[0, 0], [0, 0]],
[[3, 0], [5, 0]]
])
expect(editor.getSelectedBufferRanges()).toEqual([
[[0, 0], [0, 0]],
[[3, 0], [4, 0]]
])
// Selects entire screen line, even if folds cause that selection to
// span multiple buffer lines
component.didMouseDownOnLineNumberGutter({
button: 0,
metaKey: true,
clientY: clientTopForLine(component, 5)
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[0, 0], [0, 0]],
[[3, 0], [5, 0]],
[[5, 0], [6, 0]]
])
expect(editor.getSelectedBufferRanges()).toEqual([
[[0, 0], [0, 0]],
[[3, 0], [4, 0]],
[[4, 0], [8, 0]]
])
})
it('expands the last selection when a line number is shift-clicked', async () => {
const { component, editor } = buildComponent()
spyOn(component, 'handleMouseDragUntilMouseUp')
editor.setSoftWrapped(true)
await component.getNextUpdatePromise()
await setEditorWidthInCharacters(component, 50)
editor.foldBufferRange([[4, Infinity], [7, Infinity]])
await component.getNextUpdatePromise()
editor.setSelectedScreenRange([[3, 4], [3, 8]])
editor.addCursorAtScreenPosition([2, 10])
component.didMouseDownOnLineNumberGutter({
button: 0,
shiftKey: true,
clientY: clientTopForLine(component, 5)
})
expect(editor.getSelectedBufferRanges()).toEqual([
[[3, 4], [3, 8]],
[[2, 10], [8, 0]]
])
// Original selection is preserved when shift-click-dragging
const {
didDrag,
didStopDragging
} = component.handleMouseDragUntilMouseUp.argsForCall[0][0]
didDrag({
clientY: clientTopForLine(component, 1)
})
expect(editor.getSelectedBufferRanges()).toEqual([
[[3, 4], [3, 8]],
[[1, 0], [2, 10]]
])
didDrag({
clientY: clientTopForLine(component, 5)
})
didStopDragging()
expect(editor.getSelectedBufferRanges()).toEqual([[[2, 10], [8, 0]]])
})
it('expands the selection when dragging', async () => {
const { component, editor } = buildComponent()
spyOn(component, 'handleMouseDragUntilMouseUp')
editor.setSoftWrapped(true)
await component.getNextUpdatePromise()
await setEditorWidthInCharacters(component, 50)
editor.foldBufferRange([[4, Infinity], [7, Infinity]])
await component.getNextUpdatePromise()
editor.setSelectedScreenRange([[3, 4], [3, 6]])
component.didMouseDownOnLineNumberGutter({
button: 0,
metaKey: true,
clientY: clientTopForLine(component, 2)
})
const {
didDrag,
didStopDragging
} = component.handleMouseDragUntilMouseUp.argsForCall[0][0]
didDrag({
clientY: clientTopForLine(component, 1)
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[3, 4], [3, 6]],
[[1, 0], [3, 0]]
])
didDrag({
clientY: clientTopForLine(component, 5)
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[3, 4], [3, 6]],
[[2, 0], [6, 0]]
])
expect(editor.isFoldedAtBufferRow(4)).toBe(true)
didDrag({
clientY: clientTopForLine(component, 3)
})
expect(editor.getSelectedScreenRanges()).toEqual([
[[3, 4], [3, 6]],
[[2, 0], [4, 4]]
])
didStopDragging()
expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [4, 4]]])
})
it('toggles folding when clicking on the right icon of a foldable line number', async () => {
const { component, element, editor } = buildComponent()
let target = element
.querySelectorAll('.line-number')[1]
.querySelector('.icon-right')
expect(editor.isFoldedAtScreenRow(1)).toBe(false)
component.didMouseDownOnLineNumberGutter({
target,
button: 0,
clientY: clientTopForLine(component, 1)
})
expect(editor.isFoldedAtScreenRow(1)).toBe(true)
await component.getNextUpdatePromise()
component.didMouseDownOnLineNumberGutter({
target,
button: 0,
clientY: clientTopForLine(component, 1)
})
await component.getNextUpdatePromise()
expect(editor.isFoldedAtScreenRow(1)).toBe(false)
editor.foldBufferRange([[5, 12], [5, 17]])
await component.getNextUpdatePromise()
expect(editor.isFoldedAtScreenRow(5)).toBe(true)
target = element
.querySelectorAll('.line-number')[4]
.querySelector('.icon-right')
component.didMouseDownOnLineNumberGutter({
target,
button: 0,
clientY: clientTopForLine(component, 4)
})
expect(editor.isFoldedAtScreenRow(4)).toBe(false)
})
it('autoscrolls when dragging near the top or bottom of the gutter', async () => {
const { component } = buildComponent({
width: 200,
height: 200
})
spyOn(component, 'handleMouseDragUntilMouseUp')
let previousScrollTop = 0
let previousScrollLeft = 0
function assertScrolledDown () {
expect(component.getScrollTop()).toBeGreaterThan(previousScrollTop)
previousScrollTop = component.getScrollTop()
expect(component.getScrollLeft()).toBe(previousScrollLeft)
previousScrollLeft = component.getScrollLeft()
}
function assertScrolledUp () {
expect(component.getScrollTop()).toBeLessThan(previousScrollTop)
previousScrollTop = component.getScrollTop()
expect(component.getScrollLeft()).toBe(previousScrollLeft)
previousScrollLeft = component.getScrollLeft()
}
component.didMouseDownOnLineNumberGutter({
detail: 1,
button: 0,
clientX: 0,
clientY: 100
})
const {
didDrag
} = component.handleMouseDragUntilMouseUp.argsForCall[0][0]
didDrag({ clientX: 199, clientY: 199 })
assertScrolledDown()
didDrag({ clientX: 199, clientY: 199 })
assertScrolledDown()
didDrag({ clientX: 199, clientY: 199 })
assertScrolledDown()
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
assertScrolledUp()
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
assertScrolledUp()
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
assertScrolledUp()
// Don't artificially update scroll measurements beyond the minimum or
// maximum possible scroll positions
expect(component.getScrollTop()).toBe(0)
expect(component.getScrollLeft()).toBe(0)
didDrag({
clientX: component.getGutterContainerWidth() + 1,
clientY: 1
})
expect(component.getScrollTop()).toBe(0)
expect(component.getScrollLeft()).toBe(0)
const maxScrollTop = component.getMaxScrollTop()
const maxScrollLeft = component.getMaxScrollLeft()
setScrollTop(component, maxScrollTop)
await setScrollLeft(component, maxScrollLeft)
didDrag({ clientX: 199, clientY: 199 })
didDrag({ clientX: 199, clientY: 199 })
didDrag({ clientX: 199, clientY: 199 })
expect(component.getScrollTop()).toBe(maxScrollTop)
expect(component.getScrollLeft()).toBe(maxScrollLeft)
})
})
describe('on the scrollbars', () => {
it('delegates the mousedown events to the parent component unless the mousedown was on the actual scrollbar', async () => {
const { component, editor } = buildComponent({ height: 100 })
await setEditorWidthInCharacters(component, 6)
const verticalScrollbar = component.refs.verticalScrollbar
const horizontalScrollbar = component.refs.horizontalScrollbar
const leftEdgeOfVerticalScrollbar =
verticalScrollbar.element.getBoundingClientRect().right -
verticalScrollbarWidth
const topEdgeOfHorizontalScrollbar =
horizontalScrollbar.element.getBoundingClientRect().bottom -
horizontalScrollbarHeight
verticalScrollbar.didMouseDown({
button: 0,
detail: 1,
clientY: clientTopForLine(component, 4),
clientX: leftEdgeOfVerticalScrollbar
})
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
verticalScrollbar.didMouseDown({
button: 0,
detail: 1,
clientY: clientTopForLine(component, 4),
clientX: leftEdgeOfVerticalScrollbar - 1
})
expect(editor.getCursorScreenPosition()).toEqual([4, 6])
horizontalScrollbar.didMouseDown({
button: 0,
detail: 1,
clientY: topEdgeOfHorizontalScrollbar,
clientX: component.refs.content.getBoundingClientRect().left
})
expect(editor.getCursorScreenPosition()).toEqual([4, 6])
horizontalScrollbar.didMouseDown({
button: 0,
detail: 1,
clientY: topEdgeOfHorizontalScrollbar - 1,
clientX: component.refs.content.getBoundingClientRect().left
})
expect(editor.getCursorScreenPosition()).toEqual([4, 0])
})
})
})
describe('paste event', () => {
it("prevents the browser's default processing for the event on Linux", () => {
const { component } = buildComponent({ platform: 'linux' })
const event = { preventDefault: () => {} }
spyOn(event, 'preventDefault')
component.didPaste(event)
expect(event.preventDefault).toHaveBeenCalled()
})
})
describe('keyboard input', () => {
it('handles inserted accented characters via the press-and-hold menu on macOS correctly', () => {
const { editor, component } = buildComponent({
text: '',
chromeVersion: 57
})
editor.insertText('x')
editor.setCursorBufferPosition([0, 1])
// Simulate holding the A key to open the press-and-hold menu,
// then closing it via ESC.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeyup({ code: 'KeyA' })
component.didKeydown({ code: 'Escape' })
component.didKeyup({ code: 'Escape' })
expect(editor.getText()).toBe('xa')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
expect(editor.getText()).toBe('xaa')
editor.undo()
expect(editor.getText()).toBe('x')
// Simulate holding the A key to open the press-and-hold menu,
// then selecting an alternative by typing a number.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeyup({ code: 'KeyA' })
component.didKeydown({ code: 'Digit2' })
component.didKeyup({ code: 'Digit2' })
component.didTextInput({
data: 'á',
stopPropagation: () => {},
preventDefault: () => {}
})
expect(editor.getText()).toBe('xá')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
expect(editor.getText()).toBe('xáa')
editor.undo()
expect(editor.getText()).toBe('x')
// Simulate holding the A key to open the press-and-hold menu,
// then selecting an alternative by clicking on it.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeyup({ code: 'KeyA' })
component.didTextInput({
data: 'á',
stopPropagation: () => {},
preventDefault: () => {}
})
expect(editor.getText()).toBe('xá')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
expect(editor.getText()).toBe('xáa')
editor.undo()
expect(editor.getText()).toBe('x')
// Simulate holding the A key to open the press-and-hold menu,
// cycling through the alternatives with the arrows, then selecting one of them with Enter.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeyup({ code: 'KeyA' })
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionStart({ data: '' })
component.didCompositionUpdate({ data: 'à' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xà')
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionUpdate({ data: 'á' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xá')
component.didKeydown({ code: 'Enter' })
component.didCompositionUpdate({ data: 'á' })
component.didTextInput({
data: 'á',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didCompositionEnd({
data: 'á',
target: component.refs.cursorsAndInput.refs.hiddenInput
})
component.didKeyup({ code: 'Enter' })
expect(editor.getText()).toBe('xá')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
expect(editor.getText()).toBe('xáa')
editor.undo()
expect(editor.getText()).toBe('x')
// Simulate holding the A key to open the press-and-hold menu,
// cycling through the alternatives with the arrows, then closing it via ESC.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeyup({ code: 'KeyA' })
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionStart({ data: '' })
component.didCompositionUpdate({ data: 'à' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xà')
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionUpdate({ data: 'á' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xá')
component.didKeydown({ code: 'Escape' })
component.didCompositionUpdate({ data: 'a' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didCompositionEnd({
data: 'a',
target: component.refs.cursorsAndInput.refs.hiddenInput
})
component.didKeyup({ code: 'Escape' })
expect(editor.getText()).toBe('xa')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
expect(editor.getText()).toBe('xaa')
editor.undo()
expect(editor.getText()).toBe('x')
// Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key,
// cycling through the alternatives with the arrows, then closing it via ESC.
component.didKeydown({ code: 'KeyO' })
component.didKeypress({ code: 'KeyO' })
component.didTextInput({
data: 'o',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyO' })
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionStart({ data: '' })
component.didCompositionUpdate({ data: 'à' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xoà')
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionUpdate({ data: 'á' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xoá')
component.didKeydown({ code: 'Escape' })
component.didCompositionUpdate({ data: 'a' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didCompositionEnd({
data: 'a',
target: component.refs.cursorsAndInput.refs.hiddenInput
})
component.didKeyup({ code: 'Escape' })
expect(editor.getText()).toBe('xoa')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
editor.undo()
expect(editor.getText()).toBe('x')
// Simulate holding the A key to open the press-and-hold menu,
// cycling through the alternatives with the arrows, then closing it by changing focus.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeydown({ code: 'KeyA' })
component.didKeydown({ code: 'KeyA' })
component.didKeyup({ code: 'KeyA' })
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionStart({ data: '' })
component.didCompositionUpdate({ data: 'à' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xà')
component.didKeydown({ code: 'ArrowRight' })
component.didCompositionUpdate({ data: 'á' })
component.didKeyup({ code: 'ArrowRight' })
expect(editor.getText()).toBe('xá')
component.didCompositionUpdate({ data: 'á' })
component.didTextInput({
data: 'á',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didCompositionEnd({
data: 'á',
target: component.refs.cursorsAndInput.refs.hiddenInput
})
expect(editor.getText()).toBe('xá')
// Ensure another "a" can be typed correctly.
component.didKeydown({ code: 'KeyA' })
component.didKeypress({ code: 'KeyA' })
component.didTextInput({
data: 'a',
stopPropagation: () => {},
preventDefault: () => {}
})
component.didKeyup({ code: 'KeyA' })
expect(editor.getText()).toBe('xáa')
editor.undo()
expect(editor.getText()).toBe('x')
})
})
describe('styling changes', () => {
it('updates the rendered content based on new measurements when the font dimensions change', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 1,
autoHeight: false
})
await setEditorHeightInLines(component, 3)
editor.setCursorScreenPosition([1, 29], { autoscroll: false })
await component.getNextUpdatePromise()
let cursorNode = element.querySelector('.cursor')
const initialBaseCharacterWidth = editor.getDefaultCharWidth()
const initialDoubleCharacterWidth = editor.getDoubleWidthCharWidth()
const initialHalfCharacterWidth = editor.getHalfWidthCharWidth()
const initialKoreanCharacterWidth = editor.getKoreanCharWidth()
const initialRenderedLineCount = queryOnScreenLineElements(element).length
const initialFontSize = parseInt(getComputedStyle(element).fontSize)
expect(initialKoreanCharacterWidth).toBeDefined()
expect(initialDoubleCharacterWidth).toBeDefined()
expect(initialHalfCharacterWidth).toBeDefined()
expect(initialBaseCharacterWidth).toBeDefined()
expect(initialDoubleCharacterWidth).not.toBe(initialBaseCharacterWidth)
expect(initialHalfCharacterWidth).not.toBe(initialBaseCharacterWidth)
expect(initialKoreanCharacterWidth).not.toBe(initialBaseCharacterWidth)
verifyCursorPosition(component, cursorNode, 1, 29)
element.style.fontSize = initialFontSize - 5 + 'px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(editor.getDefaultCharWidth()).toBeLessThan(
initialBaseCharacterWidth
)
expect(editor.getDoubleWidthCharWidth()).toBeLessThan(
initialDoubleCharacterWidth
)
expect(editor.getHalfWidthCharWidth()).toBeLessThan(
initialHalfCharacterWidth
)
expect(editor.getKoreanCharWidth()).toBeLessThan(
initialKoreanCharacterWidth
)
expect(queryOnScreenLineElements(element).length).toBeGreaterThan(
initialRenderedLineCount
)
verifyCursorPosition(component, cursorNode, 1, 29)
element.style.fontSize = initialFontSize + 10 + 'px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(editor.getDefaultCharWidth()).toBeGreaterThan(
initialBaseCharacterWidth
)
expect(editor.getDoubleWidthCharWidth()).toBeGreaterThan(
initialDoubleCharacterWidth
)
expect(editor.getHalfWidthCharWidth()).toBeGreaterThan(
initialHalfCharacterWidth
)
expect(editor.getKoreanCharWidth()).toBeGreaterThan(
initialKoreanCharacterWidth
)
expect(queryOnScreenLineElements(element).length).toBeLessThan(
initialRenderedLineCount
)
verifyCursorPosition(component, cursorNode, 1, 29)
})
it('maintains the scrollTopRow and scrollLeftColumn when the font size changes', async () => {
const { component, element } = buildComponent({
rowsPerTile: 1,
autoHeight: false
})
await setEditorHeightInLines(component, 3)
await setEditorWidthInCharacters(component, 20)
component.setScrollTopRow(4)
component.setScrollLeftColumn(10)
await component.getNextUpdatePromise()
const initialFontSize = parseInt(getComputedStyle(element).fontSize)
element.style.fontSize = initialFontSize - 5 + 'px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(component.getScrollTopRow()).toBe(4)
element.style.fontSize = initialFontSize + 5 + 'px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
expect(component.getScrollTopRow()).toBe(4)
})
it('gracefully handles the editor being hidden after a styling change', async () => {
const { component, element } = buildComponent({
autoHeight: false
})
element.style.fontSize =
parseInt(getComputedStyle(element).fontSize) + 5 + 'px'
TextEditor.didUpdateStyles()
element.style.display = 'none'
await component.getNextUpdatePromise()
})
it('does not throw an exception when the editor is soft-wrapped and changing the font size changes also the longest screen line', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
editor.setText(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do\n' +
'eiusmod tempor incididunt ut labore et dolore magna' +
'aliqua. Ut enim ad minim veniam, quis nostrud exercitation'
)
editor.setSoftWrapped(true)
await setEditorHeightInLines(component, 2)
await setEditorWidthInCharacters(component, 56)
await setScrollTop(component, 3 * component.getLineHeight())
element.style.fontSize = '20px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
})
it('updates the width of the lines div based on the longest screen line', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 1,
autoHeight: false
})
editor.setText(
'Lorem ipsum dolor sit\n' +
'amet, consectetur adipisicing\n' +
'elit, sed do\n' +
'eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation'
)
await setEditorHeightInLines(component, 2)
element.style.fontSize = '20px'
TextEditor.didUpdateStyles()
await component.getNextUpdatePromise()
// Capture the width of the lines before requesting the width of
// longest line, because making that request forces a DOM update
const actualWidth = element.querySelector('.lines').style.width
const expectedWidth = Math.ceil(
component.pixelPositionForScreenPosition(Point(3, Infinity)).left +
component.getBaseCharacterWidth()
)
expect(actualWidth).toBe(expectedWidth + 'px')
})
})
describe('synchronous updates', () => {
let editorElementWasUpdatedSynchronously
beforeEach(() => {
editorElementWasUpdatedSynchronously =
TextEditorElement.prototype.updatedSynchronously
})
afterEach(() => {
TextEditorElement.prototype.setUpdatedSynchronously(
editorElementWasUpdatedSynchronously
)
})
it('updates synchronously when updatedSynchronously is true', () => {
const editor = buildEditor()
const { element } = new TextEditorComponent({
model: editor,
updatedSynchronously: true
})
jasmine.attachToDOM(element)
editor.setText('Lorem ipsum dolor')
expect(
queryOnScreenLineElements(element).map(l => l.textContent)
).toEqual([editor.lineTextForScreenRow(0)])
})
it('does not throw an exception on attachment when setting the soft-wrap column', () => {
const { element, editor } = buildComponent({
width: 435,
attach: false,
updatedSynchronously: true
})
editor.setSoftWrapped(true)
spyOn(window, 'onerror').andCallThrough()
jasmine.attachToDOM(element) // should not throw an exception
expect(window.onerror).not.toHaveBeenCalled()
})
it('updates synchronously when creating a component via TextEditor and TextEditorElement.prototype.updatedSynchronously is true', () => {
TextEditorElement.prototype.setUpdatedSynchronously(true)
const editor = buildEditor()
const element = editor.element
jasmine.attachToDOM(element)
editor.setText('Lorem ipsum dolor')
expect(
queryOnScreenLineElements(element).map(l => l.textContent)
).toEqual([editor.lineTextForScreenRow(0)])
})
it('measures dimensions synchronously when measureDimensions is called on the component', () => {
TextEditorElement.prototype.setUpdatedSynchronously(true)
const editor = buildEditor({ autoHeight: false })
const element = editor.element
jasmine.attachToDOM(element)
element.style.height = '100px'
expect(element.component.getClientContainerHeight()).not.toBe(100)
element.component.measureDimensions()
expect(element.component.getClientContainerHeight()).toBe(100)
})
})
describe('pixelPositionForScreenPosition(point)', () => {
it('returns the pixel position for the given point, regardless of whether or not it is currently on screen', async () => {
const { component, editor } = buildComponent({
rowsPerTile: 2,
autoHeight: false
})
await setEditorHeightInLines(component, 3)
await setScrollTop(component, 3 * component.getLineHeight())
const { component: referenceComponent } = buildComponent()
const referenceContentRect = referenceComponent.refs.content.getBoundingClientRect()
{
const { top, left } = component.pixelPositionForScreenPosition({
row: 0,
column: 0
})
expect(top).toBe(
clientTopForLine(referenceComponent, 0) - referenceContentRect.top
)
expect(left).toBe(
clientLeftForCharacter(referenceComponent, 0, 0) -
referenceContentRect.left
)
}
{
const { top, left } = component.pixelPositionForScreenPosition({
row: 0,
column: 5
})
expect(top).toBe(
clientTopForLine(referenceComponent, 0) - referenceContentRect.top
)
expect(left).toBe(
clientLeftForCharacter(referenceComponent, 0, 5) -
referenceContentRect.left
)
}
{
const { top, left } = component.pixelPositionForScreenPosition({
row: 12,
column: 1
})
expect(top).toBe(
clientTopForLine(referenceComponent, 12) - referenceContentRect.top
)
expect(left).toBe(
clientLeftForCharacter(referenceComponent, 12, 1) -
referenceContentRect.left
)
}
// Measuring a currently rendered line while an autoscroll that causes
// that line to go off-screen is in progress.
{
editor.setCursorScreenPosition([10, 0])
const { top, left } = component.pixelPositionForScreenPosition({
row: 3,
column: 5
})
expect(top).toBe(
clientTopForLine(referenceComponent, 3) - referenceContentRect.top
)
expect(left).toBe(
clientLeftForCharacter(referenceComponent, 3, 5) -
referenceContentRect.left
)
}
})
it('does not get the component into an inconsistent state when the model has unflushed changes (regression)', async () => {
const { component, editor } = buildComponent({
rowsPerTile: 2,
autoHeight: false,
text: ''
})
await setEditorHeightInLines(component, 10)
const updatePromise = editor.getBuffer().append('hi\n')
component.screenPositionForPixelPosition({ top: 800, left: 1 })
await updatePromise
})
it('does not shift cursors downward or render off-screen content when measuring off-screen lines (regression)', async () => {
const { component, element } = buildComponent({
rowsPerTile: 2,
autoHeight: false
})
await setEditorHeightInLines(component, 3)
component.pixelPositionForScreenPosition({
row: 12,
column: 1
})
expect(element.querySelector('.cursor').getBoundingClientRect().top).toBe(
component.refs.lineTiles.getBoundingClientRect().top
)
expect(
element.querySelector('.line[data-screen-row="12"]').style.visibility
).toBe('hidden')
// Ensure previously measured off screen lines don't have any weird
// styling when they come on screen in the next frame
await setEditorHeightInLines(component, 13)
const previouslyMeasuredLineElement = element.querySelector(
'.line[data-screen-row="12"]'
)
expect(previouslyMeasuredLineElement.style.display).toBe('')
expect(previouslyMeasuredLineElement.style.visibility).toBe('')
})
})
describe('screenPositionForPixelPosition', () => {
it('returns the screen position for the given pixel position, regardless of whether or not it is currently on screen', async () => {
const { component, editor } = buildComponent({
rowsPerTile: 2,
autoHeight: false
})
await setEditorHeightInLines(component, 3)
await setScrollTop(component, 3 * component.getLineHeight())
const { component: referenceComponent } = buildComponent()
{
const pixelPosition = referenceComponent.pixelPositionForScreenPosition(
{ row: 0, column: 0 }
)
pixelPosition.top += component.getLineHeight() / 3
pixelPosition.left += component.getBaseCharacterWidth() / 3
expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual(
[0, 0]
)
}
{
const pixelPosition = referenceComponent.pixelPositionForScreenPosition(
{ row: 0, column: 5 }
)
pixelPosition.top += component.getLineHeight() / 3
pixelPosition.left += component.getBaseCharacterWidth() / 3
expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual(
[0, 5]
)
}
{
const pixelPosition = referenceComponent.pixelPositionForScreenPosition(
{ row: 5, column: 7 }
)
pixelPosition.top += component.getLineHeight() / 3
pixelPosition.left += component.getBaseCharacterWidth() / 3
expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual(
[5, 7]
)
}
{
const pixelPosition = referenceComponent.pixelPositionForScreenPosition(
{ row: 12, column: 1 }
)
pixelPosition.top += component.getLineHeight() / 3
pixelPosition.left += component.getBaseCharacterWidth() / 3
expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual(
[12, 1]
)
}
// Measuring a currently rendered line while an autoscroll that causes
// that line to go off-screen is in progress.
{
const pixelPosition = referenceComponent.pixelPositionForScreenPosition(
{ row: 3, column: 4 }
)
pixelPosition.top += component.getLineHeight() / 3
pixelPosition.left += component.getBaseCharacterWidth() / 3
editor.setCursorBufferPosition([10, 0])
expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual(
[3, 4]
)
}
})
})
describe('model methods that delegate to the component / element', () => {
it('delegates setHeight and getHeight to the component', async () => {
const { component, editor } = buildComponent({
autoHeight: false
})
spyOn(Grim, 'deprecate')
expect(editor.getHeight()).toBe(component.getScrollContainerHeight())
expect(Grim.deprecate.callCount).toBe(1)
editor.setHeight(100)
await component.getNextUpdatePromise()
expect(component.getScrollContainerHeight()).toBe(100)
expect(Grim.deprecate.callCount).toBe(2)
})
it('delegates setWidth and getWidth to the component', async () => {
const { component, editor } = buildComponent()
spyOn(Grim, 'deprecate')
expect(editor.getWidth()).toBe(component.getScrollContainerWidth())
expect(Grim.deprecate.callCount).toBe(1)
editor.setWidth(100)
await component.getNextUpdatePromise()
expect(component.getScrollContainerWidth()).toBe(100)
expect(Grim.deprecate.callCount).toBe(2)
})
it('delegates getFirstVisibleScreenRow, getLastVisibleScreenRow, and getVisibleRowRange to the component', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
element.style.height = 4 * component.measurements.lineHeight + 'px'
await component.getNextUpdatePromise()
await setScrollTop(component, 5 * component.getLineHeight())
expect(editor.getFirstVisibleScreenRow()).toBe(
component.getFirstVisibleRow()
)
expect(editor.getLastVisibleScreenRow()).toBe(
component.getLastVisibleRow()
)
expect(editor.getVisibleRowRange()).toEqual([
component.getFirstVisibleRow(),
component.getLastVisibleRow()
])
})
it('assigns scrollTop on the component when calling setFirstVisibleScreenRow', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
element.style.height =
4 * component.measurements.lineHeight + horizontalScrollbarHeight + 'px'
await component.getNextUpdatePromise()
expect(component.getMaxScrollTop() / component.getLineHeight()).toBe(9)
expect(component.refs.verticalScrollbar.element.scrollTop).toBe(
0 * component.getLineHeight()
)
editor.setFirstVisibleScreenRow(1)
expect(component.getFirstVisibleRow()).toBe(1)
await component.getNextUpdatePromise()
expect(component.refs.verticalScrollbar.element.scrollTop).toBe(
1 * component.getLineHeight()
)
editor.setFirstVisibleScreenRow(5)
expect(component.getFirstVisibleRow()).toBe(5)
await component.getNextUpdatePromise()
expect(component.refs.verticalScrollbar.element.scrollTop).toBe(
5 * component.getLineHeight()
)
editor.setFirstVisibleScreenRow(11)
expect(component.getFirstVisibleRow()).toBe(9)
await component.getNextUpdatePromise()
expect(component.refs.verticalScrollbar.element.scrollTop).toBe(
9 * component.getLineHeight()
)
})
it('delegates setFirstVisibleScreenColumn and getFirstVisibleScreenColumn to the component', async () => {
const { component, element, editor } = buildComponent({
rowsPerTile: 3,
autoHeight: false
})
element.style.width = 30 * component.getBaseCharacterWidth() + 'px'
await component.getNextUpdatePromise()
expect(editor.getFirstVisibleScreenColumn()).toBe(0)
expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(0)
setScrollLeft(component, 5.5 * component.getBaseCharacterWidth())
expect(editor.getFirstVisibleScreenColumn()).toBe(5)
await component.getNextUpdatePromise()
expect(component.refs.horizontalScrollbar.element.scrollLeft).toBeCloseTo(
5.5 * component.getBaseCharacterWidth(),
-1
)
editor.setFirstVisibleScreenColumn(12)
expect(component.getScrollLeft()).toBeCloseTo(
12 * component.getBaseCharacterWidth(),
-1
)
await component.getNextUpdatePromise()
expect(component.refs.horizontalScrollbar.element.scrollLeft).toBeCloseTo(
12 * component.getBaseCharacterWidth(),
-1
)
})
})
describe('handleMouseDragUntilMouseUp', () => {
it('repeatedly schedules `didDrag` calls on new animation frames after moving the mouse, and calls `didStopDragging` on mouseup', async () => {
const { component } = buildComponent()
let dragEvents
let dragging = false
component.handleMouseDragUntilMouseUp({
didDrag: event => {
dragging = true
dragEvents.push(event)
},
didStopDragging: () => {
dragging = false
}
})
expect(dragging).toBe(false)
dragEvents = []
const moveEvent1 = new MouseEvent('mousemove')
window.dispatchEvent(moveEvent1)
expect(dragging).toBe(false)
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
expect(dragEvents).toEqual([moveEvent1])
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
expect(dragEvents).toEqual([moveEvent1, moveEvent1])
dragEvents = []
const moveEvent2 = new MouseEvent('mousemove')
window.dispatchEvent(moveEvent2)
expect(dragging).toBe(true)
expect(dragEvents).toEqual([])
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
expect(dragEvents).toEqual([moveEvent2])
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
expect(dragEvents).toEqual([moveEvent2, moveEvent2])
dragEvents = []
window.dispatchEvent(new MouseEvent('mouseup'))
expect(dragging).toBe(false)
expect(dragEvents).toEqual([])
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(false)
expect(dragEvents).toEqual([])
})
it('calls `didStopDragging` if the user interacts with the keyboard while dragging', async () => {
const { component, editor } = buildComponent()
let dragging = false
function startDragging () {
component.handleMouseDragUntilMouseUp({
didDrag: event => {
dragging = true
},
didStopDragging: () => {
dragging = false
}
})
}
startDragging()
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
// Buffer changes don't cause dragging to be stopped.
editor.insertText('X')
expect(dragging).toBe(true)
// Keyboard interaction prevents users from dragging further.
component.didKeydown({ code: 'KeyX' })
expect(dragging).toBe(false)
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(false)
// Pressing a modifier key does not terminate dragging, (to ensure we can add new selections with the mouse)
startDragging()
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
component.didKeydown({ key: 'Control' })
component.didKeydown({ key: 'Alt' })
component.didKeydown({ key: 'Shift' })
component.didKeydown({ key: 'Meta' })
expect(dragging).toBe(true)
})
function getNextAnimationFramePromise () {
return new Promise(resolve => requestAnimationFrame(resolve))
}
})
})
function buildEditor (params = {}) {
const text = params.text != null ? params.text : SAMPLE_TEXT
const buffer = new TextBuffer({ text })
const editorParams = { buffer, readOnly: params.readOnly }
if (params.height != null) params.autoHeight = false
for (const paramName of [
'mini',
'autoHeight',
'autoWidth',
'lineNumberGutterVisible',
'showLineNumbers',
'placeholderText',
'softWrapped',
'scrollSensitivity'
]) {
if (params[paramName] != null) editorParams[paramName] = params[paramName]
}
atom.grammars.autoAssignLanguageMode(buffer)
const editor = new TextEditor(editorParams)
editor.testAutoscrollRequests = []
editor.onDidRequestAutoscroll(request => {
editor.testAutoscrollRequests.push(request)
})
editors.push(editor)
return editor
}
function buildComponent (params = {}) {
const editor = params.editor || buildEditor(params)
const component = new TextEditorComponent({
model: editor,
rowsPerTile: params.rowsPerTile,
updatedSynchronously: params.updatedSynchronously || false,
platform: params.platform,
chromeVersion: params.chromeVersion
})
const { element } = component
if (!editor.getAutoHeight()) {
element.style.height = params.height ? params.height + 'px' : '600px'
}
if (!editor.getAutoWidth()) {
element.style.width = params.width ? params.width + 'px' : '800px'
}
if (params.attach !== false) jasmine.attachToDOM(element)
return { component, element, editor }
}
function getEditorWidthInBaseCharacters (component) {
return Math.round(
component.getScrollContainerWidth() / component.getBaseCharacterWidth()
)
}
async function setEditorHeightInLines (component, heightInLines) {
component.element.style.height =
component.getLineHeight() * heightInLines + 'px'
await component.getNextUpdatePromise()
}
async function setEditorWidthInCharacters (component, widthInCharacters) {
component.element.style.width =
component.getGutterContainerWidth() +
widthInCharacters * component.measurements.baseCharacterWidth +
verticalScrollbarWidth +
'px'
await component.getNextUpdatePromise()
}
function verifyCursorPosition (component, cursorNode, row, column) {
const rect = cursorNode.getBoundingClientRect()
expect(Math.round(rect.top)).toBe(clientTopForLine(component, row))
expect(Math.round(rect.left)).toBe(
Math.round(clientLeftForCharacter(component, row, column))
)
}
function clientTopForLine (component, row) {
return lineNodeForScreenRow(component, row).getBoundingClientRect().top
}
function clientLeftForCharacter (component, row, column) {
const textNodes = textNodesForScreenRow(component, row)
let textNodeStartColumn = 0
for (const textNode of textNodes) {
const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length
if (column < textNodeEndColumn) {
const range = document.createRange()
range.setStart(textNode, column - textNodeStartColumn)
range.setEnd(textNode, column - textNodeStartColumn)
return range.getBoundingClientRect().left
}
textNodeStartColumn = textNodeEndColumn
}
const lastTextNode = textNodes[textNodes.length - 1]
const range = document.createRange()
range.setStart(lastTextNode, 0)
range.setEnd(lastTextNode, lastTextNode.textContent.length)
return range.getBoundingClientRect().right
}
function clientPositionForCharacter (component, row, column) {
return {
clientX: clientLeftForCharacter(component, row, column),
clientY: clientTopForLine(component, row)
}
}
function lineNumberNodeForScreenRow (component, row) {
const gutterElement =
component.refs.gutterContainer.refs.lineNumberGutter.element
const tileStartRow = component.tileStartRowForRow(row)
const tileIndex = component.renderedTileStartRows.indexOf(tileStartRow)
return gutterElement.children[tileIndex + 1].children[row - tileStartRow]
}
function lineNodeForScreenRow (component, row) {
const renderedScreenLine = component.renderedScreenLineForRow(row)
return component.lineComponentsByScreenLineId.get(renderedScreenLine.id)
.element
}
function textNodesForScreenRow (component, row) {
const screenLine = component.renderedScreenLineForRow(row)
return component.lineComponentsByScreenLineId.get(screenLine.id).textNodes
}
function setScrollTop (component, scrollTop) {
component.setScrollTop(scrollTop)
component.scheduleUpdate()
return component.getNextUpdatePromise()
}
function setScrollLeft (component, scrollLeft) {
component.setScrollLeft(scrollLeft)
component.scheduleUpdate()
return component.getNextUpdatePromise()
}
function getHorizontalScrollbarHeight (component) {
const element = component.refs.horizontalScrollbar.element
return element.offsetHeight - element.clientHeight
}
function getVerticalScrollbarWidth (component) {
const element = component.refs.verticalScrollbar.element
return element.offsetWidth - element.clientWidth
}
function assertDocumentFocused () {
if (!document.hasFocus()) {
throw new Error('The document needs to be focused to run this test')
}
}
function getElementHeight (element) {
const topRuler = document.createElement('div')
const bottomRuler = document.createElement('div')
let height
if (document.body.contains(element)) {
element.parentElement.insertBefore(topRuler, element)
element.parentElement.insertBefore(bottomRuler, element.nextSibling)
height = bottomRuler.offsetTop - topRuler.offsetTop
} else {
jasmine.attachToDOM(topRuler)
jasmine.attachToDOM(element)
jasmine.attachToDOM(bottomRuler)
height = bottomRuler.offsetTop - topRuler.offsetTop
element.remove()
}
topRuler.remove()
bottomRuler.remove()
return height
}
function queryOnScreenLineNumberElements (element) {
return Array.from(element.querySelectorAll('.line-number:not(.dummy)'))
}
function queryOnScreenLineElements (element) {
return Array.from(
element.querySelectorAll('.line:not(.dummy):not([data-off-screen])')
)
}