const { 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') } // eslint-disable-next-line no-lone-blocks { // 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])') ) }