const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') 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 = '::-webkit-scrollbar { -webkit-appearance: none }' jasmine.attachToDOM(scrollbarStyle) }) function buildTextEditorElement (options = {}) { const element = new TextEditorElement() 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 HTMLDivElement { attachedCallback () { this.firstChild.focus() } } document.registerElement('element-that-focuses-child', {prototype: ElementThatFocusesChild.prototype} ) 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) expect(element.getMaxScrollTop()).toBe(0) await editor.update({autoHeight: false}) element.style.height = '100px' await element.getNextUpdatePromise() expect(element.getMaxScrollTop()).toBe(60) element.style.height = '120px' await element.getNextUpdatePromise() expect(element.getMaxScrollTop()).toBe(40) element.style.height = '200px' await element.getNextUpdatePromise() expect(element.getMaxScrollTop()).toBe(0) }) ) describe('::setScrollTop and ::setScrollLeft', () => { it('changes the scroll position', 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() 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() editor.update({autoHeight: false}) element.getModel().setText('x\n'.repeat(20)) element.style.height = '120px' 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() editor.update({autoHeight: false}) element.getModel().setText('xxxxxxxxxxxxxxxxxxxxxx\n'.repeat(20)) element.style.height = '120px' 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()) expect(element.pixelRectForScreenRange([[2, 3], [13, 11]])).toEqual({ top, left, height: bottom + editor.getLineHeightInPixels() - top, width: 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]) }) ) }) })