/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /* global document, window, Event */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import BlockToolbar from '../../../src/toolbar/block/blocktoolbar'; import ToolbarView from '../../../src/toolbar/toolbarview'; import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview'; import BlockButtonView from '../../../src/toolbar/block/blockbuttonview'; import Heading from '@ckeditor/ckeditor5-heading/src/heading'; import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui'; import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; import Image from '@ckeditor/ckeditor5-image/src/image'; import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; describe( 'BlockToolbar', () => { let editor, element, blockToolbar; let resizeCallback; testUtils.createSinonSandbox(); beforeEach( () => { element = document.createElement( 'div' ); document.body.appendChild( element ); // Make sure other tests of the editor do not affect tests that follow. // Without it, if an instance of ResizeObserver already exists somewhere undestroyed // in DOM, the following DOM mock will have no effect. ResizeObserver._observerInstance = null; testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( callback => { resizeCallback = callback; return { observe: sinon.spy(), unobserve: sinon.spy() }; } ); return ClassicTestEditor.create( element, { plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote, Image, ImageCaption ], blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ] } ).then( newEditor => { editor = newEditor; blockToolbar = editor.plugins.get( BlockToolbar ); editor.ui.focusTracker.isFocused = true; } ); } ); afterEach( () => { // Blur editor so `blockToolbar.buttonView` `window.resize` listener is detached. editor.ui.focusTracker.isFocused = false; element.remove(); return editor.destroy(); } ); it( 'should have pluginName property', () => { expect( BlockToolbar.pluginName ).to.equal( 'BlockToolbar' ); } ); it( 'should not throw when empty config is provided', async () => { // Remove default editor instance. await editor.destroy(); editor = await ClassicTestEditor.create( element, { plugins: [ BlockToolbar ] } ); } ); it( 'should accept the extended format of the toolbar config', () => { return ClassicTestEditor .create( element, { plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote ], blockToolbar: { items: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ] } } ) .then( editor => { blockToolbar = editor.plugins.get( BlockToolbar ); expect( blockToolbar.toolbarView.items ).to.length( 4 ); element.remove(); return editor.destroy(); } ); } ); it( 'should not group items when the config.shouldNotGroupWhenFull option is enabled', () => { return ClassicTestEditor.create( element, { plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote ], blockToolbar: { items: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ], shouldNotGroupWhenFull: true } } ).then( editor => { const blockToolbar = editor.plugins.get( BlockToolbar ); expect( blockToolbar.toolbarView.options.shouldGroupWhenFull ).to.be.false; element.remove(); return editor.destroy(); } ); } ); describe( 'child views', () => { describe( 'panelView', () => { it( 'should create a view instance', () => { expect( blockToolbar.panelView ).to.instanceof( BalloonPanelView ); } ); it( 'should have an additional class name', () => { expect( blockToolbar.panelView.class ).to.equal( 'ck-toolbar-container' ); } ); it( 'should be added to the ui.view.body collection', () => { expect( Array.from( editor.ui.view.body ) ).to.include( blockToolbar.panelView ); } ); it( 'should add the #panelView to ui.focusTracker', () => { editor.ui.focusTracker.isFocused = false; blockToolbar.panelView.element.dispatchEvent( new Event( 'focus' ) ); expect( editor.ui.focusTracker.isFocused ).to.be.true; } ); it( 'should close the #panelView after `Esc` is pressed and focus view document', () => { const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); blockToolbar.panelView.isVisible = true; blockToolbar.toolbarView.keystrokes.press( { keyCode: keyCodes.esc, preventDefault: () => {}, stopPropagation: () => {} } ); expect( blockToolbar.panelView.isVisible ).to.be.false; sinon.assert.calledOnce( spy ); } ); it( 'should close the #panelView upon click outside the panel and not focus view document', () => { const spy = testUtils.sinon.spy(); editor.editing.view.on( 'focus', spy ); blockToolbar.panelView.isVisible = true; document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); expect( blockToolbar.panelView.isVisible ).to.be.false; sinon.assert.notCalled( spy ); } ); it( 'should not close the #panelView upon click on panel element', () => { blockToolbar.panelView.isVisible = true; blockToolbar.panelView.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); expect( blockToolbar.panelView.isVisible ).to.be.true; } ); } ); describe( 'toolbarView', () => { it( 'should create the view instance', () => { expect( blockToolbar.toolbarView ).to.instanceof( ToolbarView ); } ); it( 'should add an additional class to toolbar element', () => { expect( blockToolbar.toolbarView.element.classList.contains( 'ck-toolbar_floating' ) ).to.be.true; } ); it( 'should be added to the panelView#content collection', () => { expect( Array.from( blockToolbar.panelView.content ) ).to.include( blockToolbar.toolbarView ); } ); it( 'should initialize the toolbar items based on Editor#blockToolbar config', () => { expect( Array.from( blockToolbar.toolbarView.items ) ).to.length( 4 ); } ); it( 'should hide the panel after clicking on the button from toolbar', () => { blockToolbar.buttonView.fire( 'execute' ); expect( blockToolbar.panelView.isVisible ).to.be.true; blockToolbar.toolbarView.items.get( 0 ).fire( 'execute' ); expect( blockToolbar.panelView.isVisible ).to.be.false; } ); it( 'should hide the panel on toolbar blur', () => { blockToolbar.toolbarView.focusTracker.isFocused = true; blockToolbar.buttonView.fire( 'execute' ); expect( blockToolbar.panelView.isVisible ).to.be.true; blockToolbar.toolbarView.focusTracker.isFocused = false; expect( blockToolbar.panelView.isVisible ).to.be.false; } ); } ); describe( 'buttonView', () => { it( 'should create a view instance', () => { expect( blockToolbar.buttonView ).to.instanceof( BlockButtonView ); } ); it( 'should be added to the editor ui.view.body collection', () => { expect( Array.from( editor.ui.view.body ) ).to.include( blockToolbar.buttonView ); } ); it( 'should add the #buttonView to the ui.focusTracker', () => { editor.ui.focusTracker.isFocused = false; blockToolbar.buttonView.element.dispatchEvent( new Event( 'focus' ) ); expect( editor.ui.focusTracker.isFocused ).to.be.true; } ); it( 'should pin the #panelView to the button and focus first item in toolbar on #execute event', () => { expect( blockToolbar.panelView.isVisible ).to.be.false; const pinSpy = testUtils.sinon.spy( blockToolbar.panelView, 'pin' ); const focusSpy = testUtils.sinon.spy( blockToolbar.toolbarView.items.get( 0 ), 'focus' ); blockToolbar.buttonView.fire( 'execute' ); expect( blockToolbar.panelView.isVisible ).to.be.true; sinon.assert.calledWith( pinSpy, { target: blockToolbar.buttonView.element, limiter: editor.ui.getEditableElement() } ); sinon.assert.calledOnce( focusSpy ); } ); it( 'should hide the #panelView and focus the editable on #execute event when panel was visible', () => { blockToolbar.panelView.isVisible = true; const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); blockToolbar.buttonView.fire( 'execute' ); expect( blockToolbar.panelView.isVisible ).to.be.false; sinon.assert.calledOnce( spy ); } ); it( 'should bind #isOn to panelView#isVisible', () => { blockToolbar.panelView.isVisible = false; expect( blockToolbar.buttonView.isOn ).to.be.false; blockToolbar.panelView.isVisible = true; expect( blockToolbar.buttonView.isOn ).to.be.true; } ); it( 'should hide the #button tooltip when the #panelView is open', () => { blockToolbar.panelView.isVisible = false; expect( blockToolbar.buttonView.tooltip ).to.be.true; blockToolbar.panelView.isVisible = true; expect( blockToolbar.buttonView.tooltip ).to.be.false; } ); it( 'should hide the #button if empty config was passed', async () => { // Remove default editor instance. await editor.destroy(); editor = await ClassicTestEditor.create( element, { plugins: [ BlockToolbar ] } ); const blockToolbar = editor.plugins.get( BlockToolbar ); expect( blockToolbar.buttonView.isVisible ).to.be.false; } ); } ); } ); describe( 'allowed elements', () => { it( 'should display the button when the first selected block is a block element', () => { editor.model.schema.register( 'foo', { inheritAllFrom: '$block' } ); editor.conversion.elementToElement( { model: 'foo', view: 'foo' } ); setData( editor.model, 'foo[]bar' ); expect( blockToolbar.buttonView.isVisible ).to.be.true; } ); it( 'should display the button when the first selected block is an object', () => { setData( editor.model, '[foo]' ); expect( blockToolbar.buttonView.isVisible ).to.be.true; } ); // This test makes no sense now, but so do all other tests here (see https://github.com/ckeditor/ckeditor5/issues/1522). it( 'should not display the button when the selection is inside a limit element', () => { setData( editor.model, 'f[]oo' ); expect( blockToolbar.buttonView.isVisible ).to.be.false; } ); it( 'should not display the button when the selection is placed in the root element', () => { editor.model.schema.extend( '$text', { allowIn: '$root' } ); setData( editor.model, 'foo[]bar' ); expect( blockToolbar.buttonView.isVisible ).to.be.false; } ); it( 'should not display the button when all toolbar items are disabled for the selected element', () => { const element = document.createElement( 'div' ); document.body.appendChild( element ); return ClassicTestEditor.create( element, { plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, Image ], blockToolbar: [ 'paragraph', 'heading1', 'heading2' ] } ).then( editor => { const blockToolbar = editor.plugins.get( BlockToolbar ); setData( editor.model, '[]' ); expect( blockToolbar.buttonView.isVisible ).to.be.false; element.remove(); return editor.destroy(); } ); } ); } ); describe( 'attaching the button to the content', () => { beforeEach( () => { // Be sure that window is not scrolled. testUtils.sinon.stub( window, 'scrollX' ).get( () => 0 ); testUtils.sinon.stub( window, 'scrollY' ).get( () => 0 ); } ); it( 'should attach the right side of the button to the left side of the editable and center with the first line ' + 'of the selected block #1', () => { setData( editor.model, 'foo[]bar' ); const target = editor.ui.getEditableElement().querySelector( 'p' ); const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' ); styleMock.withArgs( target ).returns( { lineHeight: '20px', paddingTop: '10px' } ); styleMock.callThrough(); testUtils.sinon.stub( editor.ui.getEditableElement(), 'getBoundingClientRect' ).returns( { left: 200 } ); testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( { top: 500, left: 300 } ); testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( { width: 100, height: 100 } ); editor.ui.fire( 'update' ); expect( blockToolbar.buttonView.top ).to.equal( 470 ); expect( blockToolbar.buttonView.left ).to.equal( 100 ); } ); it( 'should attach the right side of the button to the left side of the editable and center with the first line ' + 'of the selected block #2', () => { setData( editor.model, 'foo[]bar' ); const target = editor.ui.getEditableElement().querySelector( 'p' ); const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' ); styleMock.withArgs( target ).returns( { lineHeight: 'normal', fontSize: '20px', paddingTop: '10px' } ); styleMock.callThrough(); testUtils.sinon.stub( editor.ui.getEditableElement(), 'getBoundingClientRect' ).returns( { left: 200 } ); testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( { top: 500, left: 300 } ); testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( { width: 100, height: 100 } ); editor.ui.fire( 'update' ); expect( blockToolbar.buttonView.top ).to.equal( 472 ); expect( blockToolbar.buttonView.left ).to.equal( 100 ); } ); it( 'should attach the left side of the button to the right side of the editable when language direction is RTL', () => { editor.locale.uiLanguageDirection = 'rtl'; setData( editor.model, 'foo[]bar' ); const target = editor.ui.getEditableElement().querySelector( 'p' ); const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' ); styleMock.withArgs( target ).returns( { lineHeight: 'normal', fontSize: '20px', paddingTop: '10px' } ); styleMock.callThrough(); testUtils.sinon.stub( editor.ui.getEditableElement(), 'getBoundingClientRect' ).returns( { left: 200, right: 600 } ); testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( { top: 500, left: 300 } ); testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( { width: 100, height: 100 } ); editor.ui.fire( 'update' ); expect( blockToolbar.buttonView.top ).to.equal( 472 ); expect( blockToolbar.buttonView.left ).to.equal( 600 ); } ); describe( 'toolbarView#maxWidth', () => { it( 'should be set when the panel shows up', () => { expect( blockToolbar.toolbarView.maxWidth ).to.equal( 'auto' ); blockToolbar.buttonView.fire( 'execute' ); expect( blockToolbar.panelView.isVisible ).to.be.true; expect( blockToolbar.toolbarView.maxWidth ).to.match( /.+px/ ); } ); it( 'should be set after the toolbar was shown (but before panel#pin()) to let the items grouping do its job', () => { const maxWidthSetSpy = sinon.spy( blockToolbar.toolbarView, 'maxWidth', [ 'set' ] ); const panelViewShowSpy = sinon.spy( blockToolbar.panelView, 'show' ); const panelViewPinSpy = sinon.spy( blockToolbar.panelView, 'pin' ); blockToolbar.buttonView.fire( 'execute' ); sinon.assert.callOrder( panelViewShowSpy, maxWidthSetSpy.set, panelViewPinSpy ); } ); it( 'should set a proper toolbar max-width', () => { const viewElement = editor.ui.getEditableElement(); testUtils.sinon.stub( viewElement, 'getBoundingClientRect' ).returns( { left: 100, width: 400 } ); testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( { left: 60, width: 40 } ); resizeCallback( [ { target: viewElement, contentRect: new Rect( viewElement ) } ] ); blockToolbar.buttonView.fire( 'execute' ); // The expected width should be equal the distance between // left edge of the block toolbar button and right edge of the editable. // --------------------------- // | | // ____ | | // |__| | EDITABLE | // button | | // | | // <--------------max-width------------> expect( blockToolbar.toolbarView.maxWidth ).to.equal( '440px' ); } ); it( 'should set a proper toolbar max-width in RTL', () => { const viewElement = editor.ui.getEditableElement(); editor.locale.uiLanguageDirection = 'rtl'; testUtils.sinon.stub( viewElement, 'getBoundingClientRect' ).returns( { right: 450, width: 400 } ); testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( { left: 450, width: 40 } ); resizeCallback( [ { target: viewElement, contentRect: new Rect( viewElement ) } ] ); blockToolbar.buttonView.fire( 'execute' ); // The expected width should be equal the distance between // left edge of the editable and right edge of the block toolbar button. // --------------------------- // | | // | | ____ // | EDITABLE | |__| // | | button // | | // <--------------max-width------------> expect( blockToolbar.toolbarView.maxWidth ).to.equal( '440px' ); } ); } ); it( 'should reposition the #panelView when open on ui#update', () => { blockToolbar.panelView.isVisible = false; const spy = testUtils.sinon.spy( blockToolbar.panelView, 'pin' ); editor.ui.fire( 'update' ); sinon.assert.notCalled( spy ); blockToolbar.panelView.isVisible = true; editor.ui.fire( 'update' ); sinon.assert.calledWith( spy, { target: blockToolbar.buttonView.element, limiter: editor.ui.getEditableElement() } ); } ); it( 'should not reset the toolbar focus on ui#update', () => { blockToolbar.panelView.isVisible = true; const spy = testUtils.sinon.spy( blockToolbar.toolbarView, 'focus' ); editor.ui.fire( 'update' ); sinon.assert.notCalled( spy ); } ); it( 'should hide the open panel on a direct selection change', () => { blockToolbar.panelView.isVisible = true; editor.model.document.selection.fire( 'change:range', { directChange: true } ); expect( blockToolbar.panelView.isVisible ).to.be.false; } ); it( 'should not hide the open panel on an indirect selection change', () => { blockToolbar.panelView.isVisible = true; editor.model.document.selection.fire( 'change:range', { directChange: false } ); expect( blockToolbar.panelView.isVisible ).to.be.true; } ); it( 'should hide the UI when editor switched to readonly', () => { setData( editor.model, 'foo[]bar' ); blockToolbar.buttonView.isVisible = true; blockToolbar.panelView.isVisible = true; editor.isReadOnly = true; expect( blockToolbar.buttonView.isVisible ).to.be.false; expect( blockToolbar.panelView.isVisible ).to.be.false; } ); it( 'should show the button when the editor switched back from readonly', () => { setData( editor.model, 'foo[]bar' ); expect( blockToolbar.buttonView.isVisible ).to.true; editor.isReadOnly = true; expect( blockToolbar.buttonView.isVisible ).to.false; editor.isReadOnly = false; expect( blockToolbar.buttonView.isVisible ).to.be.true; } ); it( 'should show/hide the button on the editor focus/blur', () => { setData( editor.model, 'foo[]bar' ); editor.ui.focusTracker.isFocused = true; expect( blockToolbar.buttonView.isVisible ).to.true; editor.ui.focusTracker.isFocused = false; expect( blockToolbar.buttonView.isVisible ).to.false; editor.ui.focusTracker.isFocused = true; expect( blockToolbar.buttonView.isVisible ).to.true; } ); it( 'should hide the UI when the editor switched to the readonly when the panel is not visible', () => { setData( editor.model, 'foo[]bar' ); blockToolbar.buttonView.isVisible = true; blockToolbar.panelView.isVisible = false; editor.isReadOnly = true; expect( blockToolbar.buttonView.isVisible ).to.be.false; expect( blockToolbar.panelView.isVisible ).to.be.false; } ); it( 'should update the button position on browser resize only when the button is visible', () => { editor.model.schema.extend( '$text', { allowIn: '$root' } ); const spy = testUtils.sinon.spy( blockToolbar, '_attachButtonToElement' ); // Place the selection outside of any block because the toolbar will not be shown in this case. setData( editor.model, '[]bar' ); window.dispatchEvent( new Event( 'resize' ) ); sinon.assert.notCalled( spy ); setData( editor.model, 'ba[]r' ); spy.resetHistory(); window.dispatchEvent( new Event( 'resize' ) ); sinon.assert.called( spy ); setData( editor.model, '[]bar' ); spy.resetHistory(); window.dispatchEvent( new Event( 'resize' ) ); sinon.assert.notCalled( spy ); } ); } ); describe( 'destroy()', () => { it( 'should destroy #resizeObserver if is available', () => { const editable = editor.ui.getEditableElement(); const resizeObserver = new ResizeObserver( editable, () => {} ); const destroySpy = sinon.spy( resizeObserver, 'destroy' ); blockToolbar._resizeObserver = resizeObserver; blockToolbar.destroy(); sinon.assert.calledOnce( destroySpy ); } ); } ); } );