/** * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /* global document */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; import Widget from '../src/widget'; import WidgetToolbarRepository from '../src/widgettoolbarrepository'; import { isWidget, toWidget } from '../src/utils'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import View from '@ckeditor/ckeditor5-ui/src/view'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'WidgetToolbarRepository', () => { let editor, model, balloon, widgetToolbarRepository, editorElement; testUtils.createSinonSandbox(); beforeEach( () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); return ClassicTestEditor .create( editorElement, { plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget, FakeChildWidget ], fake: { toolbar: [ 'fake_button' ] } } ) .then( newEditor => { editor = newEditor; model = newEditor.model; widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository ); balloon = editor.plugins.get( 'ContextualBalloon' ); } ); } ); afterEach( () => { editorElement.remove(); return editor.destroy(); } ); it( 'should be loaded', () => { expect( editor.plugins.get( WidgetToolbarRepository ) ).to.be.instanceOf( WidgetToolbarRepository ); } ); it( 'should work if balloon toolbar is not available', () => { editorElement.remove(); editor.destroy(); editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); expect( editor.plugins.has( 'BalloonToolbar' ) ).to.be.false; expect( editor.plugins.has( WidgetToolbarRepository ) ).to.be.true; } ); describe( 'register()', () => { it( 'should create a widget toolbar and add it to the collection', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: () => null, } ); expect( widgetToolbarRepository._toolbarDefinitions.size ).to.equal( 1 ); expect( widgetToolbarRepository._toolbarDefinitions.get( 'fake' ) ).to.be.an( 'object' ); } ); it( 'should throw when adding two times widget with the same id', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: () => null } ); expectToThrowCKEditorError( () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: () => null } ); }, /^widget-toolbar-duplicated/, editor ); } ); } ); describe( 'integration tests', () => { beforeEach( () => { editor.ui.focusTracker.isFocused = true; } ); it( 'toolbar should be visible when the `getRelatedElement` callback returns a selected widget element', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); it( 'toolbar should be hidden when the `getRelatedElement` callback returns null', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, '[foo]' ); expect( balloon.visibleView ).to.equal( null ); } ); it( 'toolbar should be hidden when the `getRelatedElement` callback returns null #2', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); model.change( writer => { // Select the foo. writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' ); } ); expect( balloon.visibleView ).to.equal( null ); } ); it( 'toolbar should be removed from not visible balloon stack when the `getRelatedElement` callback returns null', () => { balloon.add( { view: new View(), stackId: 'secondary', position: { target: {} } } ); widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.hasView( fakeWidgetToolbarView ) ); expect( balloon.visibleView ).to.not.equal( fakeWidgetToolbarView ); model.change( writer => { // Select the foo. writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' ); } ); expect( balloon.hasView( fakeWidgetToolbarView ) ).to.equal( false ); } ); it( 'toolbar should be hidden when the editor ui lost focus', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); editor.ui.focusTracker.isFocused = false; expect( balloon.visibleView ).to.equal( null ); } ); it( 'toolbar should do nothing with toolbar when the editor ui lost focus but toolbar is not a visible view', () => { balloon.add( { view: new View(), stackId: 'secondary', position: { target: {} } } ); widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; editor.ui.focusTracker.isFocused = false; expect( balloon.hasView( fakeWidgetToolbarView ) ).to.equal( true ); } ); it( 'toolbar should update its position when other widget is selected', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); setData( model, '[]' ); model.change( writer => { // Select the second widget. writer.setSelection( model.document.getRoot().getChild( 1 ), 'on' ); } ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); it( 'it should be possible to create a widget toolbar for content inside the widget', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidgetContent } ); setData( model, '[foo]' ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); it( 'toolbar should not engage when is in the balloon yet invisible', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; setData( model, '[]' ); expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); const lastView = new View(); lastView.element = document.createElement( 'div' ); balloon.add( { view: lastView, position: { target: document.body } } ); expect( balloon.visibleView ).to.equal( lastView ); editor.ui.fire( 'update' ); expect( balloon.visibleView ).to.equal( lastView ); } ); // #60 it( 'should show up only for the related element which is deepest in the view document', () => { // The point of this widget is to provide a getRelatedElement function that // returns a super–shallow related element which is ignored but satisfies code coverage. widgetToolbarRepository.register( 'dummy', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: () => editor.editing.view.document.getRoot() } ); widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); widgetToolbarRepository.register( 'fake-child', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeChildWidget } ); setData( model, 'foo' + '' + 'foo' + '[]' + '' ); const fakeChildWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake-child' ).view; expect( balloon.visibleView ).to.equal( fakeChildWidgetToolbarView ); } ); // #60 it( 'should attach to the new related view element upon selecting another widget', () => { const view = editor.editing.view; widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); widgetToolbarRepository.register( 'fake-child', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeChildWidget } ); setData( model, 'foo' + '[' + 'foo' + '' + ']' ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; const fakeChildWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake-child' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); const fakeChildViewElement = view.document.getRoot().getChild( 1 ).getChild( 1 ); const updatePositionSpy = sinon.spy( balloon, 'add' ); view.change( writer => { // [] writer.setSelection( fakeChildViewElement, 'on' ); } ); expect( balloon.visibleView ).to.equal( fakeChildWidgetToolbarView ); expect( updatePositionSpy.firstCall.args[ 0 ].position.target ).to.equal( view.domConverter.viewToDom( fakeChildViewElement ) ); } ); } ); } ); describe( 'WidgetToolbarRepository - integration with the BalloonToolbar', () => { let clock, editor, model, balloon, balloonToolbar, widgetToolbarRepository, editorElement; testUtils.createSinonSandbox(); beforeEach( () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); clock = testUtils.sinon.useFakeTimers(); return BalloonEditor .create( editorElement, { plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget, Bold ], balloonToolbar: [ 'bold' ], fake: { toolbar: [ 'fake_button' ] } } ) .then( newEditor => { editor = newEditor; model = newEditor.model; widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository ); balloon = editor.plugins.get( 'ContextualBalloon' ); balloonToolbar = editor.plugins.get( 'BalloonToolbar' ); editor.ui.focusTracker.isFocused = true; } ); } ); afterEach( () => { editorElement.remove(); return editor.destroy(); } ); it( 'balloon toolbar should be hidden when the widget is selected', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget, } ); const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; editor.editing.view.document.isFocused = true; setData( model, '[]foo' ); clock.tick( 200 ); expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); it( 'balloon toolbar should be visible when the widget is not selected', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), getRelatedElement: getSelectedFakeWidget } ); editor.editing.view.document.isFocused = true; setData( model, '[foo]' ); clock.tick( 200 ); expect( balloon.visibleView ).to.equal( balloonToolbar.toolbarView ); } ); } ); function getSelectedFakeWidget( selection ) { const viewElement = selection.getSelectedElement(); if ( viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( 'fakeWidget' ) ) { return viewElement; } return null; } function getSelectedFakeChildWidget( selection ) { const viewElement = selection.getSelectedElement(); if ( viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( 'fakeChildWidget' ) ) { return viewElement; } return null; } function getSelectedFakeWidgetContent( selection ) { const pos = selection.getFirstPosition(); let node = pos.parent; while ( node ) { if ( node.is( 'element' ) && isWidget( node ) && node.getCustomProperty( 'fakeWidget' ) ) { return node; } node = node.parent; } return null; } // Plugin that adds fake_button to editor's component factory. class FakeButton extends Plugin { init() { this.editor.ui.componentFactory.add( 'fake_button', locale => { const view = new ButtonView( locale ); view.set( { label: 'fake button' } ); return view; } ); } } // Simple widget plugin // It registers `` block in model and represents `div` in the view. // It allows having text inside self. class FakeWidget extends Plugin { static get requires() { return [ Widget ]; } init() { const editor = this.editor; const schema = editor.model.schema; schema.register( 'fake-widget', { isObject: true, isBlock: true, allowWhere: '$block' } ); schema.extend( '$text', { allowIn: 'fake-widget' } ); schema.extend( 'paragraph', { allowIn: 'fake-widget' } ); const conversion = editor.conversion; conversion.for( 'dataDowncast' ).elementToElement( { model: 'fake-widget', view: ( modelElement, viewWriter ) => { return viewWriter.createContainerElement( 'div' ); } } ); conversion.for( 'editingDowncast' ).elementToElement( { model: 'fake-widget', view: ( modelElement, viewWriter ) => { const fakeWidget = viewWriter.createContainerElement( 'div' ); viewWriter.setCustomProperty( 'fakeWidget', true, fakeWidget ); return toWidget( fakeWidget, viewWriter, { label: 'fake-widget' } ); } } ); conversion.for( 'upcast' ).elementToElement( { view: { name: 'div' }, model: ( view, modelWriter ) => { return modelWriter.createElement( 'fake-widget' ); } } ); } } // A simple child widget plugin // It registers `` block in model and represents `div` in the view. // It allows having text inside self. class FakeChildWidget extends Plugin { static get requires() { return [ Widget ]; } init() { const editor = this.editor; const schema = editor.model.schema; schema.register( 'fake-child-widget', { isObject: true, isBlock: true, allowWhere: '$block', allowIn: 'fake-widget' } ); schema.extend( '$text', { allowIn: 'fake-child-widget' } ); schema.extend( 'paragraph', { allowIn: 'fake-child-widget' } ); const conversion = editor.conversion; conversion.for( 'dataDowncast' ).elementToElement( { model: 'fake-child-widget', view: ( modelElement, viewWriter ) => { return viewWriter.createContainerElement( 'div' ); } } ); conversion.for( 'editingDowncast' ).elementToElement( { model: 'fake-child-widget', view: ( modelElement, viewWriter ) => { const fakeWidget = viewWriter.createContainerElement( 'div' ); viewWriter.setCustomProperty( 'fakeChildWidget', true, fakeWidget ); return toWidget( fakeWidget, viewWriter, { label: 'fake-child-widget' } ); } } ); conversion.for( 'upcast' ).elementToElement( { view: { name: 'div' }, model: ( view, modelWriter ) => { return modelWriter.createElement( 'fake-child-widget' ); } } ); } }