const { conditionPromise } = require('./async-spec-helpers'); const Random = require('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' ); class DummyElement extends HTMLElement { connectedCallback() { this.didAttach(); } } window.customElements.define( 'text-editor-component-test-element', DummyElement ); document.createElement('text-editor-component-test-element'); 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).toBeNear( component.getContentHeight(), 2 ); }); 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).toBeNear( 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).toBeNear( 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).toBeNear( component.getContentHeight() ); expect(horizontalScrollbar.scrollWidth).toBeNear( 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)).toBeNear(10); expect(getVerticalScrollbarWidth(component)).toBeNear(10); expect( component.refs.horizontalScrollbar.element.style.right ).toHaveNearPixels('10px'); expect( component.refs.verticalScrollbar.element.style.bottom ).toHaveNearPixels('10px'); expect(component.refs.horizontalScrollbar.element.scrollLeft).toBeNear( 10 ); expect(component.refs.verticalScrollbar.element.scrollTop).toBeNear(20); expect(component.getScrollContainerClientHeight()).toBeNear(100 - 10); expect(component.getScrollContainerClientWidth()).toBeNear( 100 - component.getGutterContainerWidth() - 10 ); // Detaching and re-attaching the editor element. element.remove(); jasmine.attachToDOM(element); expect(getHorizontalScrollbarHeight(component)).toBeNear(10); expect(getVerticalScrollbarWidth(component)).toBeNear(10); expect( component.refs.horizontalScrollbar.element.style.right ).toHaveNearPixels('10px'); expect( component.refs.verticalScrollbar.element.style.bottom ).toHaveNearPixels('10px'); expect(component.refs.horizontalScrollbar.element.scrollLeft).toBeNear( 10 ); expect(component.refs.verticalScrollbar.element.scrollTop).toBeNear(20); expect(component.getScrollContainerClientHeight()).toBeNear(100 - 10); expect(component.getScrollContainerClientWidth()).toBeNear( 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); } }); /** * TODO: FAILING TEST - This test fails with the following output: * Error: Timed out waiting on anonymous condition at * conditionPromise (/home/runner/work/pulsar/pulsar/spec/async-spec-helpers.js:20:13) */ xit('blinks cursors when the editor is focused and the cursors are not moving', async () => { assertDocumentFocused(); const { component, element, editor } = buildComponent(); component.props.cursorBlinkPeriod = 30; component.props.cursorBlinkResumeDelay = 30; 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)).toBeNear( 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 = [];' ); 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).toBeNear( 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).toBeNear( 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).toBeNear( 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', '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', '19' ]); } 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', '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', '20' ]); } }); 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()).toBeNear( (6 + 1 + editor.verticalScrollMargin) * component.getLineHeight() ); editor.scrollToScreenPosition([8, 0]); await component.getNextUpdatePromise(); expect(component.getScrollBottom()).toBeNear( (8 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight ); editor.scrollToScreenPosition([3, 0]); await component.getNextUpdatePromise(); expect(component.getScrollTop()).toBeNear( (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()).toBeNear( (6 + 1 + scrollMarginInLines) * component.measurements.lineHeight ); editor.scrollToScreenPosition([6, 4]); await component.getNextUpdatePromise(); expect(component.getScrollBottom()).toBeNear( (6 + 1 + scrollMarginInLines) * component.measurements.lineHeight ); editor.scrollToScreenRange([[4, 4], [6, 4]]); await component.getNextUpdatePromise(); expect(component.getScrollTop()).toBeNear( (4 - scrollMarginInLines) * component.measurements.lineHeight ); editor.scrollToScreenRange([[4, 4], [6, 4]], { reversed: false }); await component.getNextUpdatePromise(); expect(component.getScrollBottom()).toBeNear( (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()).toBeNear(expectedScrollLeft); 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()).toBeNear(expectedScrollLeft); }); 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()).toBeNear(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()).toBeNear( 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()).toBeNear( (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()).toBeNear( component.getScrollHeight() - component.getScrollContainerClientHeight() ); expect(component.getScrollLeft()).toBeNear( 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()).toBeNear(6); expect(component.getScrollTop()).toBeNear(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()).toBeNear(4); expect(component.getScrollTop()).toBeNear( Math.round(4 * component.getLineHeight()) ); // Preserves the scrollTopRow when detached element.remove(); expect(component.getScrollTopRow()).toBeNear(4); expect(component.getScrollTop()).toBeNear( Math.round(4 * component.getLineHeight()) ); component.setScrollTopRow(6); expect(component.getScrollTopRow()).toBeNear(6); expect(component.getScrollTop()).toBeNear( Math.round(6 * component.getLineHeight()) ); jasmine.attachToDOM(element); element.style.height = '60px'; expect(component.getScrollTopRow()).toBeNear(6); expect(component.getScrollTop()).toBeNear( 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 }); // stub in place for Event.preventDefault() const eventPreventDefaultStub = function() {}; { const expectedScrollTop = 20 * (scrollSensitivity / 100); const expectedScrollLeft = component.getScrollLeft(); component.didMouseWheel({ wheelDeltaX: -5, wheelDeltaY: -20, preventDefault: eventPreventDefaultStub }); expect(component.getScrollTop()).toBeNear(expectedScrollTop); expect(component.getScrollLeft()).toBeNear(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, preventDefault: eventPreventDefaultStub }); expect(component.getScrollTop()).toBeNear(expectedScrollTop); expect(component.getScrollLeft()).toBeNear(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, preventDefault: eventPreventDefaultStub }); expect(component.getScrollTop()).toBeNear(expectedScrollTop); expect(component.getScrollLeft()).toBeNear(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, preventDefault: eventPreventDefaultStub }); expect(component.getScrollTop()).toBeNear(expectedScrollTop); expect(component.getScrollLeft()).toBeNear(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 }); // stub in place for Event.preventDefault() const eventPreventDefaultStub = function() {}; component.props.platform = 'linux'; { const expectedScrollTop = 20 * (scrollSensitivity / 100); component.didMouseWheel({ wheelDeltaX: 0, wheelDeltaY: -20, preventDefault: eventPreventDefaultStub }); expect(component.getScrollTop()).toBeNear(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, preventDefault: eventPreventDefaultStub }); expect(component.getScrollLeft()).toBeNear(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, preventDefault: eventPreventDefaultStub }); 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, preventDefault: eventPreventDefaultStub }); 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, preventDefault: eventPreventDefaultStub }); 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, preventDefault: eventPreventDefaultStub }); 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, preventDefault: eventPreventDefaultStub }); 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, preventDefault: eventPreventDefaultStub }); 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, preventDefault: eventPreventDefaultStub }); 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()).toBeNear(initialScrollTop); expect(component.getScrollLeft()).toBeNear(initialScrollLeft); setScrollTop(component, null); setScrollLeft(component, null); await component.getNextUpdatePromise(); expect(component.getScrollTop()).toBeNear(initialScrollTop); expect(component.getScrollLeft()).toBeNear(initialScrollLeft); setScrollTop(component, undefined); setScrollLeft(component, undefined); await component.getNextUpdatePromise(); expect(component.getScrollTop()).toBeNear(initialScrollTop); expect(component.getScrollLeft()).toBeNear(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)).toBeNear( clientLeftForCharacter(component, 1, 2) ); expect(Math.round(regionRect.right)).toBeNear( 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)).toBeNear( clientLeftForCharacter(component, 1, 4) ); expect(Math.round(regionRect.right)).toBeNear( 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)).toBeNear( clientLeftForCharacter(component, 2, 4) ); expect(Math.round(region0Rect.right)).toBeNear( component.refs.content.getBoundingClientRect().right ); const region1Rect = regions[1].getBoundingClientRect(); expect(region1Rect.top).toBeNear( lineNodeForScreenRow(component, 3).getBoundingClientRect().top ); expect(region1Rect.bottom).toBeNear( lineNodeForScreenRow(component, 3).getBoundingClientRect().bottom ); expect(Math.round(region1Rect.left)).toBeNear( clientLeftForCharacter(component, 3, 0) ); expect(Math.round(region1Rect.right)).toBeNear( 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).toBeNear( lineNodeForScreenRow(component, 2).getBoundingClientRect().top ); expect(region0Rect.bottom).toBeNear( lineNodeForScreenRow(component, 2).getBoundingClientRect().bottom ); expect(Math.round(region0Rect.left)).toBeNear( clientLeftForCharacter(component, 2, 4) ); expect(Math.round(region0Rect.right)).toBeNear( component.refs.content.getBoundingClientRect().right ); const region1Rect = regions[1].getBoundingClientRect(); expect(region1Rect.top).toBeNear( lineNodeForScreenRow(component, 3).getBoundingClientRect().top ); expect(region1Rect.bottom).toBeNear( lineNodeForScreenRow(component, 5).getBoundingClientRect().top ); expect(Math.round(region1Rect.left)).toBeNear( component.refs.content.getBoundingClientRect().left ); expect(Math.round(region1Rect.right)).toBeNear( component.refs.content.getBoundingClientRect().right ); const region2Rect = regions[2].getBoundingClientRect(); expect(region2Rect.top).toBeNear( lineNodeForScreenRow(component, 5).getBoundingClientRect().top ); expect(region2Rect.bottom).toBeNear( lineNodeForScreenRow(component, 6).getBoundingClientRect().top ); expect(Math.round(region2Rect.left)).toBeNear( component.refs.content.getBoundingClientRect().left ); expect(Math.round(region2Rect.right)).toBeNear( 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).toBeNear(3 * component.getLineHeight()); expect(regions[0].offsetHeight).toBeNear(component.getLineHeight()); expect(regions[1].offsetTop).toBeNear(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).toBeNear( clientTopForLine(component, 5) ); expect(overlayWrapper.getBoundingClientRect().left).toBeNear( clientLeftForCharacter(component, 4, 25) ); // Updates the horizontal position on scroll await setScrollLeft(component, 150); expect(overlayWrapper.getBoundingClientRect().left).toBeNear( 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).toBeNear( fakeWindow.getBoundingClientRect().right ); await setScrollLeft(component, 280); expect(overlayElement.getBoundingClientRect().left).toBeNear( fakeWindow.getBoundingClientRect().left ); // Updates the vertical position on scroll await setScrollTop(component, 60); expect(overlayWrapper.getBoundingClientRect().top).toBeNear( 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).toBeNear( clientTopForLine(component, 4) ); // Flips the overlay vertically on overlay resize if necessary await setScrollTop(component, 20); expect(overlayWrapper.getBoundingClientRect().top).toBeNear( clientTopForLine(component, 5) ); overlayElement.style.height = 60 + 'px'; await overlayComponent.getNextUpdatePromise(); expect(overlayWrapper.getBoundingClientRect().bottom).toBeNear( 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).toBeNear( 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).toBeNear( 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).toBeNear( clientTopForLine(component, 2) ); expect(decorationNode1.getBoundingClientRect().bottom).toBeNear( clientTopForLine(component, 5) ); expect(decorationNode1.firstChild).toBeNull(); expect(decorationNode2.className).toBe('decoration b'); expect(decorationNode2.getBoundingClientRect().top).toBeNear( clientTopForLine(component, 6) ); expect(decorationNode2.getBoundingClientRect().bottom).toBeNear( 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).toBeNear( clientTopForLine(component, 9) ); expect(decorationNode3.getBoundingClientRect().bottom).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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()).toBeNear( 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); setEditorWidthInCharacters(component, 50); await conditionPromise( () => component.getRenderedStartRow() === 0 && component.getRenderedEndRow() === 13 ); expect(component.getScrollHeight()).toBeNear( 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).toBeNear(tile.height); expect(linesTileBoundingRect.top).toBeNear(top); const lineNumbersTileElement = lineNumberNodeForScreenRow( component, tile.tileStartRow ).parentElement; const lineNumbersTileBoundingRect = lineNumbersTileElement.getBoundingClientRect(); expect(lineNumbersTileBoundingRect.height).toBeNear(tile.height); expect(lineNumbersTileBoundingRect.top).toBeNear(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).toBeNear( 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()).toBeNear(4); expect(component.getRenderedEndRow()).toBeNear(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 () => { atom.config.set('editor.selectionClipboard', false); 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', () => { atom.config.set('editor.multiCursorOnClick', true); 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', () => { atom.config.set('editor.multiCursorOnClick', true); 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', () => { atom.config.set('editor.multiCursorOnClick', true); 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('does not add cursors when holding cmd or ctrl when single-clicking', () => { atom.config.set('editor.multiCursorOnClick', false); const { component, editor } = buildComponent({ platform: 'darwin' }); expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]); // moves cursor to 1, 16 component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, button: 0, metaKey: true }) ); expect(editor.getCursorScreenPositions()).toEqual([[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 does not add 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, 16]]); expect(editor.testAutoscrollRequests).toEqual([]); }); it('does not add word selections when holding cmd or ctrl when double-clicking', () => { atom.config.set('editor.multiCursorOnClick', false); const { component, editor } = buildComponent(); 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([ [[1, 13], [1, 21]] ]); expect(editor.testAutoscrollRequests).toEqual([]); }); it('does not add line selections when holding cmd or ctrl when triple-clicking', () => { atom.config.set('editor.multiCursorOnClick', false); const { component, editor } = buildComponent(); 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([[[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', () => { atom.config.set('editor.multiCursorOnClick', true); 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()).toBeNear(maxScrollTop); expect(component.getScrollLeft()).toBeNear(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. atom.config.set('editor.selectionClipboard', true); 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(); // Doesn't paste when middle mouse button is clicked atom.config.set('editor.selectionClipboard', false); editor.setSelectedBufferRange([[1, 6], [1, 10]]); component.didMouseDownOnContent({ button: 1, clientX: clientLeftForCharacter(component, 10, 0), clientY: clientTopForLine(component, 10) }); expect(TextEditor.clipboard.read()).toBe('sort'); expect(editor.lineTextForBufferRow(10)).toBe(''); // Ensure left clicks don't interfere. atom.config.set('editor.selectionClipboard', true); 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()).toBeNear(maxScrollTop); expect(component.getScrollLeft()).toBeNear(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', () => { /** * TODO: FAILING TEST - This test fails with the following output: * Expected 7.234375 not to be 7.234375. * Expected 7.234375 not to be 7.234375. * Expected 7.234375 not to be 7.234375. */ xit('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).toBeNear( clientLeftForCharacter(referenceComponent, 0, 5) - referenceContentRect.left ); } { const { top, left } = component.pixelPositionForScreenPosition({ row: 12, column: 1 }); expect(top).toBeNear( clientTopForLine(referenceComponent, 12) - referenceContentRect.top ); expect(left).toBeNear( 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).toBeNear( clientTopForLine(referenceComponent, 3) - referenceContentRect.top ); expect(left).toBeNear( 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()).toBeNear( 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).toBeNear( 1 * component.getLineHeight() ); editor.setFirstVisibleScreenRow(5); expect(component.getFirstVisibleRow()).toBe(5); await component.getNextUpdatePromise(); expect(component.refs.verticalScrollbar.element.scrollTop).toBeNear( 5 * component.getLineHeight() ); editor.setFirstVisibleScreenRow(11); expect(component.getFirstVisibleRow()).toBe(9); await component.getNextUpdatePromise(); expect(component.refs.verticalScrollbar.element.scrollTop).toBeNear( 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)).toBeNear(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])') ); }