mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-11-10 10:17:11 +03:00
484 lines
17 KiB
JavaScript
484 lines
17 KiB
JavaScript
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 = '<atom-text-editor mini>'
|
|
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 = '<atom-text-editor mini>'
|
|
const element = parent.firstChild
|
|
expect(element.getModel().isMini()).toBe(true)
|
|
})
|
|
|
|
it("honors the 'placeholder-text' attribute", () => {
|
|
jasmineContent.innerHTML = "<atom-text-editor placeholder-text='testing'>"
|
|
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 = '<atom-text-editor gutter-hidden>'
|
|
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 = "<atom-text-editor readonly>"
|
|
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 = '<atom-text-editor>testing</atom-text-editor>'
|
|
const element = jasmineContent.firstChild
|
|
expect(element.getModel().getText()).toBe('testing')
|
|
})
|
|
|
|
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])
|
|
})
|
|
)
|
|
})
|
|
})
|