const TextEditor = require('../src/text-editor'); const TextEditorElement = require('../src/text-editor-element'); describe('TextEditorElement', () => { let jasmineContent; beforeEach(() => { jasmineContent = document.body.querySelector('#jasmine-content'); // 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); }); function buildTextEditorElement(options = {}) { const element = TextEditorElement.createTextEditorElement(); element.setUpdatedSynchronously(false); if (options.attach !== false) jasmine.attachToDOM(element); return element; } it("honors the 'mini' attribute", () => { jasmineContent.innerHTML = ''; const element = jasmineContent.firstChild; expect(element.getModel().isMini()).toBe(true); element.removeAttribute('mini'); expect(element.getModel().isMini()).toBe(false); expect(element.getComponent().getGutterContainerWidth()).toBe(0); element.setAttribute('mini', ''); expect(element.getModel().isMini()).toBe(true); }); it('sets the editor to mini if the model is accessed prior to attaching the element', () => { const parent = document.createElement('div'); parent.innerHTML = ''; const element = parent.firstChild; expect(element.getModel().isMini()).toBe(true); }); it("honors the 'placeholder-text' attribute", () => { jasmineContent.innerHTML = ""; const element = jasmineContent.firstChild; expect(element.getModel().getPlaceholderText()).toBe('testing'); element.setAttribute('placeholder-text', 'placeholder'); expect(element.getModel().getPlaceholderText()).toBe('placeholder'); element.removeAttribute('placeholder-text'); expect(element.getModel().getPlaceholderText()).toBeNull(); }); it("only assigns 'placeholder-text' on the model if the attribute is present", () => { const editor = new TextEditor({ placeholderText: 'placeholder' }); editor.getElement(); expect(editor.getPlaceholderText()).toBe('placeholder'); }); it("honors the 'gutter-hidden' attribute", () => { jasmineContent.innerHTML = ''; const element = jasmineContent.firstChild; expect(element.getModel().isLineNumberGutterVisible()).toBe(false); element.removeAttribute('gutter-hidden'); expect(element.getModel().isLineNumberGutterVisible()).toBe(true); element.setAttribute('gutter-hidden', ''); expect(element.getModel().isLineNumberGutterVisible()).toBe(false); }); it("honors the 'readonly' attribute", async function() { jasmineContent.innerHTML = ''; const element = jasmineContent.firstChild; expect(element.getComponent().isInputEnabled()).toBe(false); element.removeAttribute('readonly'); expect(element.getComponent().isInputEnabled()).toBe(true); element.setAttribute('readonly', true); expect(element.getComponent().isInputEnabled()).toBe(false); }); it('honors the text content', () => { jasmineContent.innerHTML = 'testing'; const element = jasmineContent.firstChild; expect(element.getModel().getText()).toBe('testing'); }); describe('tabIndex', () => { it('uses a default value of -1', () => { jasmineContent.innerHTML = ''; const element = jasmineContent.firstChild; expect(element.tabIndex).toBe(-1); expect(element.querySelector('input').tabIndex).toBe(-1); }); it('uses the custom value when given', () => { jasmineContent.innerHTML = ''; const element = jasmineContent.firstChild; expect(element.tabIndex).toBe(-1); expect(element.querySelector('input').tabIndex).toBe(42); }); }); describe('when the model is assigned', () => it("adds the 'mini' attribute if .isMini() returns true on the model", async () => { const element = buildTextEditorElement(); element.getModel().update({ mini: true }); await atom.views.getNextUpdatePromise(); expect(element.hasAttribute('mini')).toBe(true); })); describe('when the editor is attached to the DOM', () => it('mounts the component and unmounts when removed from the dom', () => { const element = buildTextEditorElement(); const { component } = element; expect(component.attached).toBe(true); element.remove(); expect(component.attached).toBe(false); jasmine.attachToDOM(element); expect(element.component.attached).toBe(true); })); describe('when the editor is detached from the DOM and then reattached', () => { it('does not render duplicate line numbers', () => { const editor = new TextEditor(); editor.setText('1\n2\n3'); const element = editor.getElement(); jasmine.attachToDOM(element); const initialCount = element.querySelectorAll('.line-number').length; element.remove(); jasmine.attachToDOM(element); expect(element.querySelectorAll('.line-number').length).toBe( initialCount ); }); it('does not render duplicate decorations in custom gutters', () => { const editor = new TextEditor(); editor.setText('1\n2\n3'); editor.addGutter({ name: 'test-gutter' }); const marker = editor.markBufferRange([[0, 0], [2, 0]]); editor.decorateMarker(marker, { type: 'gutter', gutterName: 'test-gutter' }); const element = editor.getElement(); jasmine.attachToDOM(element); const initialDecorationCount = element.querySelectorAll('.decoration') .length; element.remove(); jasmine.attachToDOM(element); expect(element.querySelectorAll('.decoration').length).toBe( initialDecorationCount ); }); it('can be re-focused using the previous `document.activeElement`', () => { const editorElement = buildTextEditorElement(); editorElement.focus(); const { activeElement } = document; editorElement.remove(); jasmine.attachToDOM(editorElement); activeElement.focus(); expect(editorElement.hasFocus()).toBe(true); }); }); describe('focus and blur handling', () => { it('proxies focus/blur events to/from the hidden input', () => { const element = buildTextEditorElement(); jasmineContent.appendChild(element); let blurCalled = false; element.addEventListener('blur', () => { blurCalled = true; }); element.focus(); expect(blurCalled).toBe(false); expect(element.hasFocus()).toBe(true); expect(document.activeElement).toBe(element.querySelector('input')); document.body.focus(); expect(blurCalled).toBe(true); }); it("doesn't trigger a blur event on the editor element when focusing an already focused editor element", () => { let blurCalled = false; const element = buildTextEditorElement(); element.addEventListener('blur', () => { blurCalled = true; }); jasmineContent.appendChild(element); expect(document.activeElement).toBe(document.body); expect(blurCalled).toBe(false); element.focus(); expect(document.activeElement).toBe(element.querySelector('input')); expect(blurCalled).toBe(false); element.focus(); expect(document.activeElement).toBe(element.querySelector('input')); expect(blurCalled).toBe(false); }); describe('when focused while a parent node is being attached to the DOM', () => { class ElementThatFocusesChild extends HTMLElement { connectedCallback() { this.firstChild.focus(); } } window.customElements.define( 'element-that-focuses-child', ElementThatFocusesChild ); it('proxies the focus event to the hidden input', () => { const element = buildTextEditorElement(); const parentElement = document.createElement( 'element-that-focuses-child' ); parentElement.appendChild(element); jasmineContent.appendChild(parentElement); expect(document.activeElement).toBe(element.querySelector('input')); }); }); describe('if focused when invisible due to a zero height and width', () => { it('focuses the hidden input and does not throw an exception', () => { const parentElement = document.createElement('div'); parentElement.style.position = 'absolute'; parentElement.style.width = '0px'; parentElement.style.height = '0px'; const element = buildTextEditorElement({ attach: false }); parentElement.appendChild(element); jasmineContent.appendChild(parentElement); element.focus(); expect(document.activeElement).toBe(element.component.getHiddenInput()); }); }); }); describe('::setModel', () => { describe('when the element does not have an editor yet', () => { it('uses the supplied one', () => { const element = buildTextEditorElement({ attach: false }); const editor = new TextEditor(); element.setModel(editor); jasmine.attachToDOM(element); expect(editor.element).toBe(element); expect(element.getModel()).toBe(editor); }); }); describe('when the element already has an editor', () => { it('unbinds it and then swaps it with the supplied one', async () => { const element = buildTextEditorElement({ attach: true }); const previousEditor = element.getModel(); expect(previousEditor.element).toBe(element); const newEditor = new TextEditor(); element.setModel(newEditor); expect(previousEditor.element).not.toBe(element); expect(newEditor.element).toBe(element); expect(element.getModel()).toBe(newEditor); }); }); }); describe('::onDidAttach and ::onDidDetach', () => it('invokes callbacks when the element is attached and detached', () => { const element = buildTextEditorElement({ attach: false }); const attachedCallback = jasmine.createSpy('attachedCallback'); const detachedCallback = jasmine.createSpy('detachedCallback'); element.onDidAttach(attachedCallback); element.onDidDetach(detachedCallback); jasmine.attachToDOM(element); expect(attachedCallback).toHaveBeenCalled(); expect(detachedCallback).not.toHaveBeenCalled(); attachedCallback.reset(); element.remove(); expect(attachedCallback).not.toHaveBeenCalled(); expect(detachedCallback).toHaveBeenCalled(); })); describe('::setUpdatedSynchronously', () => { it('controls whether the text editor is updated synchronously', () => { spyOn(window, 'requestAnimationFrame').andCallFake(fn => fn()); const element = buildTextEditorElement(); expect(element.isUpdatedSynchronously()).toBe(false); element.getModel().setText('hello'); expect(window.requestAnimationFrame).toHaveBeenCalled(); expect(element.textContent).toContain('hello'); window.requestAnimationFrame.reset(); element.setUpdatedSynchronously(true); element.getModel().setText('goodbye'); expect(window.requestAnimationFrame).not.toHaveBeenCalled(); expect(element.textContent).toContain('goodbye'); }); }); describe('::getDefaultCharacterWidth', () => { it('returns 0 before the element is attached', () => { const element = buildTextEditorElement({ attach: false }); expect(element.getDefaultCharacterWidth()).toBe(0); }); it('returns the width of a character in the root scope', () => { const element = buildTextEditorElement(); jasmine.attachToDOM(element); expect(element.getDefaultCharacterWidth()).toBeGreaterThan(0); }); }); describe('::getMaxScrollTop', () => it('returns the maximum scroll top that can be applied to the element', async () => { const editor = new TextEditor(); editor.setText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16'); const element = editor.getElement(); element.style.lineHeight = '10px'; element.style.width = '200px'; jasmine.attachToDOM(element); const horizontalScrollbarHeight = element.component.getHorizontalScrollbarHeight(); expect(element.getMaxScrollTop()).toBe(0); await editor.update({ autoHeight: false }); element.style.height = 100 + horizontalScrollbarHeight + 'px'; await element.getNextUpdatePromise(); expect(element.getMaxScrollTop()).toBe(60); element.style.height = 120 + horizontalScrollbarHeight + 'px'; await element.getNextUpdatePromise(); expect(element.getMaxScrollTop()).toBe(40); element.style.height = 200 + horizontalScrollbarHeight + 'px'; await element.getNextUpdatePromise(); expect(element.getMaxScrollTop()).toBe(0); })); describe('::setScrollTop and ::setScrollLeft', () => { it('changes the scroll position', async () => { const element = buildTextEditorElement(); element.getModel().update({ autoHeight: false }); element.getModel().setText('lorem\nipsum\ndolor\nsit\namet'); element.setHeight(20); await element.getNextUpdatePromise(); element.setWidth(20); await element.getNextUpdatePromise(); element.setScrollTop(22); await element.getNextUpdatePromise(); expect(element.getScrollTop()).toBe(22); element.setScrollLeft(32); await element.getNextUpdatePromise(); expect(element.getScrollLeft()).toBe(32); }); }); describe('on TextEditor::setMini', () => it("changes the element's 'mini' attribute", async () => { const element = buildTextEditorElement(); expect(element.hasAttribute('mini')).toBe(false); element.getModel().setMini(true); await element.getNextUpdatePromise(); expect(element.hasAttribute('mini')).toBe(true); element.getModel().setMini(false); await element.getNextUpdatePromise(); expect(element.hasAttribute('mini')).toBe(false); })); describe('::intersectsVisibleRowRange(start, end)', () => { it('returns true if the given row range intersects the visible row range', async () => { const element = buildTextEditorElement(); const editor = element.getModel(); const horizontalScrollbarHeight = element.component.getHorizontalScrollbarHeight(); editor.update({ autoHeight: false }); element.getModel().setText('x\n'.repeat(20)); element.style.height = 120 + horizontalScrollbarHeight + 'px'; await element.getNextUpdatePromise(); element.setScrollTop(80); await element.getNextUpdatePromise(); expect(element.getVisibleRowRange()).toEqual([4, 11]); expect(element.intersectsVisibleRowRange(0, 4)).toBe(false); expect(element.intersectsVisibleRowRange(0, 5)).toBe(true); expect(element.intersectsVisibleRowRange(5, 8)).toBe(true); expect(element.intersectsVisibleRowRange(11, 12)).toBe(false); expect(element.intersectsVisibleRowRange(12, 13)).toBe(false); }); }); describe('::pixelRectForScreenRange(range)', () => { it('returns a {top/left/width/height} object describing the rectangle between two screen positions, even if they are not on screen', async () => { const element = buildTextEditorElement(); const editor = element.getModel(); const horizontalScrollbarHeight = element.component.getHorizontalScrollbarHeight(); editor.update({ autoHeight: false }); element.getModel().setText('xxxxxxxxxxxxxxxxxxxxxx\n'.repeat(20)); element.style.height = 120 + horizontalScrollbarHeight + 'px'; await element.getNextUpdatePromise(); element.setScrollTop(80); await element.getNextUpdatePromise(); expect(element.getVisibleRowRange()).toEqual([4, 11]); const top = 2 * editor.getLineHeightInPixels(); const bottom = 13 * editor.getLineHeightInPixels(); const left = Math.round(3 * editor.getDefaultCharWidth()); const right = Math.round(11 * editor.getDefaultCharWidth()); const pixelRect = element.pixelRectForScreenRange([[2, 3], [13, 11]]); expect(pixelRect.top).toEqual(top); expect(pixelRect.left).toEqual(left); expect(pixelRect.height).toEqual( bottom + editor.getLineHeightInPixels() - top ); expect(pixelRect.width).toBeNear(right - left); }); }); describe('events', () => { let element = null; beforeEach(async () => { element = buildTextEditorElement(); element.getModel().update({ autoHeight: false }); element.getModel().setText('lorem\nipsum\ndolor\nsit\namet'); element.setHeight(20); await element.getNextUpdatePromise(); element.setWidth(20); await element.getNextUpdatePromise(); }); describe('::onDidChangeScrollTop(callback)', () => it('triggers even when subscribing before attaching the element', () => { const positions = []; const subscription1 = element.onDidChangeScrollTop(p => positions.push(p) ); element.onDidChangeScrollTop(p => positions.push(p)); positions.length = 0; element.setScrollTop(10); expect(positions).toEqual([10, 10]); element.remove(); jasmine.attachToDOM(element); positions.length = 0; element.setScrollTop(20); expect(positions).toEqual([20, 20]); subscription1.dispose(); positions.length = 0; element.setScrollTop(30); expect(positions).toEqual([30]); })); describe('::onDidChangeScrollLeft(callback)', () => it('triggers even when subscribing before attaching the element', () => { const positions = []; const subscription1 = element.onDidChangeScrollLeft(p => positions.push(p) ); element.onDidChangeScrollLeft(p => positions.push(p)); positions.length = 0; element.setScrollLeft(10); expect(positions).toEqual([10, 10]); element.remove(); jasmine.attachToDOM(element); positions.length = 0; element.setScrollLeft(20); expect(positions).toEqual([20, 20]); subscription1.dispose(); positions.length = 0; element.setScrollLeft(30); expect(positions).toEqual([30]); })); }); });