diff --git a/packages/ckeditor5-block-quote/docs/_snippets/features/block-quote-source.js b/packages/ckeditor5-block-quote/docs/_snippets/features/block-quote-source.js index 05f5a5b8eb2..8619b7be28f 100644 --- a/packages/ckeditor5-block-quote/docs/_snippets/features/block-quote-source.js +++ b/packages/ckeditor5-block-quote/docs/_snippets/features/block-quote-source.js @@ -32,11 +32,6 @@ ClassicEditor.defaultConfig = { '|', 'bulletedList', 'numberedList', 'outdent', 'indent' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, ui: { viewportOffset: { top: window.getViewportTopOffsetConfig() diff --git a/packages/ckeditor5-ckbox/src/ckboxui.ts b/packages/ckeditor5-ckbox/src/ckboxui.ts index 4be1e84590a..2154c89a980 100644 --- a/packages/ckeditor5-ckbox/src/ckboxui.ts +++ b/packages/ckeditor5-ckbox/src/ckboxui.ts @@ -7,9 +7,11 @@ * @module ckbox/ckboxui */ -import { Plugin } from 'ckeditor5/src/core'; +import { icons, Plugin } from 'ckeditor5/src/core'; import { ButtonView } from 'ckeditor5/src/ui'; +import type { ImageInsertUI } from '@ckeditor/ckeditor5-image'; + import browseFilesIcon from '../theme/icons/browse-files.svg'; import type CKBoxCommand from './ckboxcommand'; @@ -57,5 +59,43 @@ export default class CKBoxUI extends Plugin { return button; } ); + + if ( editor.plugins.has( 'ImageInsertUI' ) ) { + const imageInsertUI: ImageInsertUI = editor.plugins.get( 'ImageInsertUI' ); + + imageInsertUI.registerIntegration( { + name: 'assetManager', + observable: command, + + buttonViewCreator: () => { + const button = this.editor.ui.componentFactory.create( 'ckbox' ) as ButtonView; + + button.icon = icons.imageAssetManager; + button.bind( 'label' ).to( imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace image with file manager' ) : + t( 'Insert image with file manager' ) + ); + + return button; + }, + + formViewCreator: () => { + const button = this.editor.ui.componentFactory.create( 'ckbox' ) as ButtonView; + + button.icon = icons.imageAssetManager; + button.withText = true; + button.bind( 'label' ).to( imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace with file manager' ) : + t( 'Insert with file manager' ) + ); + + button.on( 'execute', () => { + imageInsertUI.dropdownView!.isOpen = false; + } ); + + return button; + } + } ); + } } } diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js index b2c14fc39e8..16dc6350d47 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js @@ -338,6 +338,33 @@ describe( 'CKBoxImageEditCommand', () => { sinon.assert.calledOnce( focusSpy ); } ); + + it( 'should refresh the command after closing the CKBox Image Editor dialog', async () => { + const ckboxImageId = 'example-id'; + + setModelData( model, + `[]` + ); + + const imageElement = editor.model.document.selection.getSelectedElement(); + + const options = await command._prepareOptions( { + element: imageElement, + ckboxImageId, + controller: new AbortController() + } ); + + const refreshSpy = testUtils.sinon.spy( command, 'refresh' ); + + expect( command.value ).to.be.false; + + command.execute(); + expect( command.value ).to.be.true; + + options.onClose(); + expect( command.value ).to.be.false; + sinon.assert.calledOnce( refreshSpy ); + } ); } ); describe( 'saving edited asset', () => { diff --git a/packages/ckeditor5-ckbox/tests/ckboxui.js b/packages/ckeditor5-ckbox/tests/ckboxui.js index 922f0b58139..6cecd91c595 100644 --- a/packages/ckeditor5-ckbox/tests/ckboxui.js +++ b/packages/ckeditor5-ckbox/tests/ckboxui.js @@ -15,7 +15,10 @@ import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlinee import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import CloudServicesCoreMock from './_utils/cloudservicescoremock'; +import ImageInsertUI from '@ckeditor/ckeditor5-image/src/imageinsert/imageinsertui'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { icons } from 'ckeditor5/src/core'; import CKBoxUI from '../src/ckboxui'; import CKBoxEditing from '../src/ckboxediting'; @@ -41,6 +44,7 @@ describe( 'CKBoxUI', () => { ImageInlineEditing, ImageUploadEditing, ImageUploadProgress, + ImageInsertUI, CloudServices, CKBoxUI, CKBoxEditing @@ -144,4 +148,106 @@ describe( 'CKBoxUI', () => { expect( executeSpy.args[ 0 ][ 0 ] ).to.equal( 'ckbox' ); } ); } ); + + describe( 'InsertImageUI integration', () => { + it( 'should create CKBox button in split button dropdown button', () => { + mockAssetManagerIntegration(); + + const spy = sinon.spy( editor.ui.componentFactory, 'create' ); + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + const dropdownButton = dropdown.buttonView.actionView; + + expect( dropdownButton ).to.be.instanceOf( ButtonView ); + expect( dropdownButton.withText ).to.be.false; + expect( dropdownButton.icon ).to.equal( icons.imageAssetManager ); + + expect( spy.calledTwice ).to.be.true; + expect( spy.firstCall.args[ 0 ] ).to.equal( 'insertImage' ); + expect( spy.secondCall.args[ 0 ] ).to.equal( 'ckbox' ); + expect( spy.firstCall.returnValue ).to.equal( dropdown.buttonView.actionView ); + } ); + + it( 'should create CKBox button in dropdown panel', () => { + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + const spy = sinon.spy( editor.ui.componentFactory, 'create' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + expect( buttonView ).to.be.instanceOf( ButtonView ); + expect( buttonView.withText ).to.be.true; + expect( buttonView.icon ).to.equal( icons.imageAssetManager ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.firstCall.args[ 0 ] ).to.equal( 'ckbox' ); + expect( spy.firstCall.returnValue ).to.equal( buttonView ); + } ); + + it( 'should bind to #isImageSelected', () => { + const insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const dropdownButton = dropdown.buttonView.actionView; + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + insertImageUI.isImageSelected = false; + expect( dropdownButton.label ).to.equal( 'Insert image with file manager' ); + expect( buttonView.label ).to.equal( 'Insert with file manager' ); + + insertImageUI.isImageSelected = true; + expect( dropdownButton.label ).to.equal( 'Replace image with file manager' ); + expect( buttonView.label ).to.equal( 'Replace with file manager' ); + } ); + + it( 'should close dropdown on execute', () => { + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + sinon.stub( editor, 'execute' ); + + buttonView.fire( 'execute' ); + + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + + function mockAssetManagerIntegration() { + const insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + const observable = new Model( { isEnabled: true } ); + + insertImageUI.registerIntegration( { + name: 'url', + observable, + buttonViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'foo'; + + return button; + }, + formViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'bar'; + + return button; + } + } ); + } } ); diff --git a/packages/ckeditor5-ckbox/theme/icons/ckbox-image-edit.svg b/packages/ckeditor5-ckbox/theme/icons/ckbox-image-edit.svg index ece03d6ca14..9bfaba8f5ce 100644 --- a/packages/ckeditor5-ckbox/theme/icons/ckbox-image-edit.svg +++ b/packages/ckeditor5-ckbox/theme/icons/ckbox-image-edit.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ckeditor5-ckfinder/src/ckfinderui.ts b/packages/ckeditor5-ckfinder/src/ckfinderui.ts index 24f3c695c34..3862d35a2b3 100644 --- a/packages/ckeditor5-ckfinder/src/ckfinderui.ts +++ b/packages/ckeditor5-ckfinder/src/ckfinderui.ts @@ -7,8 +7,9 @@ * @module ckfinder/ckfinderui */ -import { Plugin } from 'ckeditor5/src/core'; +import { icons, Plugin } from 'ckeditor5/src/core'; import { ButtonView } from 'ckeditor5/src/ui'; +import type { ImageInsertUI } from '@ckeditor/ckeditor5-image'; import type CKFinderCommand from './ckfindercommand'; @@ -53,5 +54,44 @@ export default class CKFinderUI extends Plugin { return button; } ); + + if ( editor.plugins.has( 'ImageInsertUI' ) ) { + const imageInsertUI: ImageInsertUI = editor.plugins.get( 'ImageInsertUI' ); + const command: CKFinderCommand = editor.commands.get( 'ckfinder' )!; + + imageInsertUI.registerIntegration( { + name: 'assetManager', + observable: command, + + buttonViewCreator: () => { + const button = this.editor.ui.componentFactory.create( 'ckfinder' ) as ButtonView; + + button.icon = icons.imageAssetManager; + button.bind( 'label' ).to( imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace image with file manager' ) : + t( 'Insert image with file manager' ) + ); + + return button; + }, + + formViewCreator: () => { + const button = this.editor.ui.componentFactory.create( 'ckfinder' ) as ButtonView; + + button.icon = icons.imageAssetManager; + button.withText = true; + button.bind( 'label' ).to( imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace with file manager' ) : + t( 'Insert with file manager' ) + ); + + button.on( 'execute', () => { + imageInsertUI.dropdownView!.isOpen = false; + } ); + + return button; + } + } ); + } } } diff --git a/packages/ckeditor5-ckfinder/tests/ckfinderui.js b/packages/ckeditor5-ckfinder/tests/ckfinderui.js index 51de804ff6c..ff47a9f8e77 100644 --- a/packages/ckeditor5-ckfinder/tests/ckfinderui.js +++ b/packages/ckeditor5-ckfinder/tests/ckfinderui.js @@ -11,11 +11,13 @@ import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import Image from '@ckeditor/ckeditor5-image/src/image'; import Link from '@ckeditor/ckeditor5-link/src/link'; import CKFinderUploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter'; +import { icons } from 'ckeditor5/src/core'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import CKFinder from '../src/ckfinder'; import browseFilesIcon from '../theme/icons/browse-files.svg'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; describe( 'CKFinderUI', () => { let editorElement, editor, button; @@ -82,4 +84,106 @@ describe( 'CKFinderUI', () => { sinon.assert.calledOnce( executeStub ); } ); } ); + + describe( 'InsertImageUI integration', () => { + it( 'should create CKFinder button in split button dropdown button', () => { + mockAssetManagerIntegration(); + + const spy = sinon.spy( editor.ui.componentFactory, 'create' ); + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + const dropdownButton = dropdown.buttonView.actionView; + + expect( dropdownButton ).to.be.instanceOf( ButtonView ); + expect( dropdownButton.withText ).to.be.false; + expect( dropdownButton.icon ).to.equal( icons.imageAssetManager ); + + expect( spy.calledTwice ).to.be.true; + expect( spy.firstCall.args[ 0 ] ).to.equal( 'insertImage' ); + expect( spy.secondCall.args[ 0 ] ).to.equal( 'ckfinder' ); + expect( spy.firstCall.returnValue ).to.equal( dropdown.buttonView.actionView ); + } ); + + it( 'should create CKFinder button in dropdown panel', () => { + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + const spy = sinon.spy( editor.ui.componentFactory, 'create' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + expect( buttonView ).to.be.instanceOf( ButtonView ); + expect( buttonView.withText ).to.be.true; + expect( buttonView.icon ).to.equal( icons.imageAssetManager ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.firstCall.args[ 0 ] ).to.equal( 'ckfinder' ); + expect( spy.firstCall.returnValue ).to.equal( buttonView ); + } ); + + it( 'should bind to #isImageSelected', () => { + const insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const dropdownButton = dropdown.buttonView.actionView; + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + insertImageUI.isImageSelected = false; + expect( dropdownButton.label ).to.equal( 'Insert image with file manager' ); + expect( buttonView.label ).to.equal( 'Insert with file manager' ); + + insertImageUI.isImageSelected = true; + expect( dropdownButton.label ).to.equal( 'Replace image with file manager' ); + expect( buttonView.label ).to.equal( 'Replace with file manager' ); + } ); + + it( 'should close dropdown on execute', () => { + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + sinon.stub( editor, 'execute' ); + + buttonView.fire( 'execute' ); + + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + + function mockAssetManagerIntegration() { + const insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + const observable = new Model( { isEnabled: true } ); + + insertImageUI.registerIntegration( { + name: 'url', + observable, + buttonViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'foo'; + + return button; + }, + formViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'bar'; + + return button; + } + } ); + } } ); diff --git a/packages/ckeditor5-clipboard/docs/_snippets/features/build-drag-drop-source.js b/packages/ckeditor5-clipboard/docs/_snippets/features/build-drag-drop-source.js index 1ce771fbee9..92e5ff879a5 100644 --- a/packages/ckeditor5-clipboard/docs/_snippets/features/build-drag-drop-source.js +++ b/packages/ckeditor5-clipboard/docs/_snippets/features/build-drag-drop-source.js @@ -124,11 +124,6 @@ const defaultConfig = { table: { contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, ui: { viewportOffset: { top: window.getViewportTopOffsetConfig() diff --git a/packages/ckeditor5-clipboard/tests/manual/dragdrop-blocks.js b/packages/ckeditor5-clipboard/tests/manual/dragdrop-blocks.js index 15c9dceb0f5..469497bf194 100644 --- a/packages/ckeditor5-clipboard/tests/manual/dragdrop-blocks.js +++ b/packages/ckeditor5-clipboard/tests/manual/dragdrop-blocks.js @@ -133,12 +133,7 @@ ClassicEditor 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] }, placeholder: 'Type the content here!', mention: { diff --git a/packages/ckeditor5-core/lang/contexts.json b/packages/ckeditor5-core/lang/contexts.json index 9406575f765..f69cab2a449 100644 --- a/packages/ckeditor5-core/lang/contexts.json +++ b/packages/ckeditor5-core/lang/contexts.json @@ -7,5 +7,9 @@ "Show more items": "Label of a toolbar button which reveals more toolbar items.", "%0 of %1": "Label for an ‘X of Y’ status of a typical next/previous navigation. For instance, ‘Page 5 of 20’ or 'Search result 5 of 20'.", "Cannot upload file:": "A generic error message displayed on upload failure. The file name is concatenated to this text.", - "Rich Text Editor. Editing area: %0": "Accessible label of the specific editing area of the editor acting as a root of the entire application." + "Rich Text Editor. Editing area: %0": "Accessible label of the specific editing area of the editor acting as a root of the entire application.", + "Insert with file manager": "The label for the insert image with the file manager toolbar button with visible label in insert image dropdown.", + "Replace with file manager": "The label for the replace image with the file manager toolbar button with visible label in insert image dropdown.", + "Insert image with file manager": "The label for the insert image with the file manager toolbar button.", + "Replace image with file manager": "The label for the replace image with the file manager toolbar button." } diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index 43754757d52..c40f41b5f97 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -43,6 +43,9 @@ import lowVision from './../theme/icons/low-vision.svg'; import textAlternative from './../theme/icons/text-alternative.svg'; import loupe from './../theme/icons/loupe.svg'; import image from './../theme/icons/image.svg'; +import imageUpload from './../theme/icons/image-upload.svg'; +import imageAssetManager from './../theme/icons/image-asset-manager.svg'; +import imageUrl from './../theme/icons/image-url.svg'; import alignBottom from './../theme/icons/align-bottom.svg'; import alignMiddle from './../theme/icons/align-middle.svg'; @@ -86,6 +89,9 @@ export const icons = { eraser, history, image, + imageUpload, + imageAssetManager, + imageUrl, lowVision, textAlternative, loupe, diff --git a/packages/ckeditor5-core/theme/icons/image-asset-manager.svg b/packages/ckeditor5-core/theme/icons/image-asset-manager.svg new file mode 100644 index 00000000000..c24ae034fc9 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/image-asset-manager.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-core/theme/icons/image-upload.svg b/packages/ckeditor5-core/theme/icons/image-upload.svg new file mode 100644 index 00000000000..6d784f751f9 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/image-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-core/theme/icons/image-url.svg b/packages/ckeditor5-core/theme/icons/image-url.svg new file mode 100644 index 00000000000..6dc5a0f73ed --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/image-url.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-core/theme/icons/image.svg b/packages/ckeditor5-core/theme/icons/image.svg index 1449860d776..d4c065a4399 100644 --- a/packages/ckeditor5-core/theme/icons/image.svg +++ b/packages/ckeditor5-core/theme/icons/image.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ckeditor5-core/theme/icons/text-alternative.svg b/packages/ckeditor5-core/theme/icons/text-alternative.svg index 03c2fa36852..f1a83fb48b5 100644 --- a/packages/ckeditor5-core/theme/icons/text-alternative.svg +++ b/packages/ckeditor5-core/theme/icons/text-alternative.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/packages/ckeditor5-heading/docs/_snippets/features/title.js b/packages/ckeditor5-heading/docs/_snippets/features/title.js index b69c239d8f2..1d33e85566f 100644 --- a/packages/ckeditor5-heading/docs/_snippets/features/title.js +++ b/packages/ckeditor5-heading/docs/_snippets/features/title.js @@ -107,11 +107,6 @@ BalloonEditor.defaultConfig = { 'mergeTableCells' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, // This value must be kept in sync with the language defined in webpack.config.js. language: 'en' }; diff --git a/packages/ckeditor5-image/lang/contexts.json b/packages/ckeditor5-image/lang/contexts.json index 95139a0ea44..b0c3a1c1d3a 100644 --- a/packages/ckeditor5-image/lang/contexts.json +++ b/packages/ckeditor5-image/lang/contexts.json @@ -12,6 +12,11 @@ "Text alternative": "The label for the image text alternative input.", "Enter image caption": "The placeholder text for the image caption displayed when the caption is empty.", "Insert image": "The label for the insert image toolbar button.", + "Replace image": "The label for the replace image toolbar button.", + "Upload from computer": "The label for the upload image from computer toolbar button with visible label in insert image dropdown.", + "Replace from computer": "The label for the replace image by upload from computer toolbar button with visible label in insert image dropdown.", + "Upload image from computer": "The label for the upload image from computer toolbar button.", + "Replace image from computer": "The label for the replace image by upload from computer toolbar button.", "Upload failed": "The title of the notification displayed when upload fails.", "Image toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget.", "Resize image": "The label used for the dropdown in the image toolbar containing defined resize options.", diff --git a/packages/ckeditor5-image/src/imageblock.ts b/packages/ckeditor5-image/src/imageblock.ts index 2a1cc3a777a..70a9304371e 100644 --- a/packages/ckeditor5-image/src/imageblock.ts +++ b/packages/ckeditor5-image/src/imageblock.ts @@ -12,6 +12,7 @@ import { Widget } from 'ckeditor5/src/widget'; import ImageTextAlternative from './imagetextalternative'; import ImageBlockEditing from './image/imageblockediting'; +import ImageInsertUI from './imageinsert/imageinsertui'; import '../theme/image.css'; @@ -31,7 +32,7 @@ export default class ImageBlock extends Plugin { * @inheritDoc */ public static get requires() { - return [ ImageBlockEditing, Widget, ImageTextAlternative ] as const; + return [ ImageBlockEditing, Widget, ImageTextAlternative, ImageInsertUI ] as const; } /** diff --git a/packages/ckeditor5-image/src/imageconfig.ts b/packages/ckeditor5-image/src/imageconfig.ts index 88e027d234b..f492b274a5a 100644 --- a/packages/ckeditor5-image/src/imageconfig.ts +++ b/packages/ckeditor5-image/src/imageconfig.ts @@ -458,30 +458,26 @@ export interface ImageInsertConfig { * The image insert panel view configuration contains a list of {@link module:image/imageinsert~ImageInsert} integrations. * * The option accepts string tokens. - * * for predefined integrations, we have two special strings: `insertImageViaUrl` and `openCKFinder`. - * The former adds the **Insert image via URL** feature, while the latter adds the built-in **CKFinder** integration. - * * for custom integrations, each string should be a name of the component registered in the - * {@link module:ui/componentfactory~ComponentFactory component factory}. - * If you have a plugin `PluginX` that registers `pluginXButton` component, then the integration token - * in that case should be `pluginXButton`. + * * for predefined integrations, we have 3 special strings: `upload`, `url`, and `assetManager`. + * * for custom integrations, each string should be a name of the integration registered by the + * {@link module:image/imageinsert/imageinsertui~ImageInsertUI#registerIntegration `ImageInsertUI#registerIntegration()`}. * * ```ts - * // Add `insertImageViaUrl`, `openCKFinder` and custom `pluginXButton` integrations. + * // Add `upload`, `assetManager` and `url` integrations. * const imageInsertConfig = { * insert: { * integrations: [ - * 'insertImageViaUrl', - * 'openCKFinder', - * 'pluginXButton' + * 'upload', + * 'assetManager', + * 'url' * ] * } * }; * ``` * - * @internal - * @default [ 'insertImageViaUrl' ] + * @default [ 'upload', 'assetManager', 'url' ] */ - integrations: Array; + integrations?: Array; /** * This option allows to override the image type used by the {@link module:image/image/insertimagecommand~InsertImageCommand} diff --git a/packages/ckeditor5-image/src/imageinline.ts b/packages/ckeditor5-image/src/imageinline.ts index 0b74c64f2b3..9d6f2cc1010 100644 --- a/packages/ckeditor5-image/src/imageinline.ts +++ b/packages/ckeditor5-image/src/imageinline.ts @@ -12,6 +12,7 @@ import { Widget } from 'ckeditor5/src/widget'; import ImageTextAlternative from './imagetextalternative'; import ImageInlineEditing from './image/imageinlineediting'; +import ImageInsertUI from './imageinsert/imageinsertui'; import '../theme/image.css'; @@ -31,7 +32,7 @@ export default class ImageInline extends Plugin { * @inheritDoc */ public static get requires() { - return [ ImageInlineEditing, Widget, ImageTextAlternative ] as const; + return [ ImageInlineEditing, Widget, ImageTextAlternative, ImageInsertUI ] as const; } /** diff --git a/packages/ckeditor5-image/src/imageinsert/imageinsertui.ts b/packages/ckeditor5-image/src/imageinsert/imageinsertui.ts index eba93beb57b..6f2e2524c1c 100644 --- a/packages/ckeditor5-image/src/imageinsert/imageinsertui.ts +++ b/packages/ckeditor5-image/src/imageinsert/imageinsertui.ts @@ -7,16 +7,26 @@ * @module image/imageinsert/imageinsertui */ -import { Plugin, icons, type Command } from 'ckeditor5/src/core'; -import type { Locale } from 'ckeditor5/src/utils'; -import { SplitButtonView, createDropdown, type DropdownView, type LabeledFieldView } from 'ckeditor5/src/ui'; +import { + Plugin, + type Editor +} from 'ckeditor5/src/core'; +import { + logWarning, + type Locale, + type Observable +} from 'ckeditor5/src/utils'; +import { + createDropdown, + SplitButtonView, + type ButtonView, + type DropdownButtonView, + type DropdownView, + type FocusableView +} from 'ckeditor5/src/ui'; -import ImageInsertPanelView from './ui/imageinsertpanelview'; -import { prepareIntegrations } from './utils'; -import type ImageUtils from '../imageutils'; -import type ReplaceImageSourceCommand from '../image/replaceimagesourcecommand'; -import type UploadImageCommand from '../imageupload/uploadimagecommand'; -import type InsertImageCommand from '../image/insertimagecommand'; +import ImageInsertFormView from './ui/imageinsertformview'; +import ImageUtils from '../imageutils'; /** * The image insert dropdown plugin. @@ -35,19 +45,58 @@ export default class ImageInsertUI extends Plugin { return 'ImageInsertUI' as const; } + /** + * @inheritDoc + */ + public static get requires() { + return [ ImageUtils ] as const; + } + /** * The dropdown view responsible for displaying the image insert UI. */ public dropdownView?: DropdownView; + /** + * Observable property used to alter labels while some image is selected and when it is not. + * + * @observable + */ + declare public isImageSelected: boolean; + + /** + * Registered integrations map. + */ + private _integrations = new Map(); + + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + super( editor ); + + editor.config.define( 'image.insert.integrations', [ + 'upload', + 'assetManager', + 'url' + ] ); + } + /** * @inheritDoc */ public init(): void { const editor = this.editor; - const componentCreator = ( locale: Locale ) => { - return this._createDropdownView( locale ); - }; + const selection = editor.model.document.selection; + const imageUtils: ImageUtils = editor.plugins.get( 'ImageUtils' ); + + this.set( 'isImageSelected', false ); + + this.listenTo( editor.model.document, 'change', () => { + this.isImageSelected = imageUtils.isImage( selection.getSelectedElement() ); + } ); + + const componentCreator = ( locale: Locale ) => this._createToolbarComponent( locale ); // Register `insertImage` dropdown and add `imageInsert` dropdown as an alias for backward compatibility. editor.ui.componentFactory.add( 'insertImage', componentCreator ); @@ -55,124 +104,154 @@ export default class ImageInsertUI extends Plugin { } /** - * Creates the dropdown view. - * - * @param locale The localization services instance. + * Registers the insert image dropdown integration. + */ + public registerIntegration( { + name, + observable, + buttonViewCreator, + formViewCreator, + requiresForm + }: { + name: string; + observable: Observable & { isEnabled: boolean }; + buttonViewCreator: ( isOnlyOne: boolean ) => ButtonView; + formViewCreator: ( isOnlyOne: boolean ) => FocusableView; + requiresForm?: boolean; +} ): void { + if ( this._integrations.has( name ) ) { + /** + * There are two insert-image integrations registered with the same name. + * + * Make sure that you do not load multiple asset manager plugins. + * + * @error image-insert-integration-exists + */ + logWarning( 'image-insert-integration-exists', { name } ); + } + + this._integrations.set( name, { + observable, + buttonViewCreator, + formViewCreator, + requiresForm: !!requiresForm + } ); + } + + /** + * Creates the toolbar component. */ - private _createDropdownView( locale: Locale ): DropdownView { + private _createToolbarComponent( locale: Locale ): DropdownView | FocusableView { const editor = this.editor; const t = locale.t; - const uploadImageCommand: UploadImageCommand | undefined = editor.commands.get( 'uploadImage' ); - const insertImageCommand: InsertImageCommand = editor.commands.get( 'insertImage' )!; + const integrations = this._prepareIntegrations(); - this.dropdownView = createDropdown( locale, uploadImageCommand ? SplitButtonView : undefined ); + if ( !integrations.length ) { + return null as any; + } - const buttonView = this.dropdownView.buttonView; - const panelView = this.dropdownView.panelView; + let dropdownButton: SplitButtonView | DropdownButtonView | undefined; + const firstIntegration = integrations[ 0 ]; - buttonView.set( { - label: t( 'Insert image' ), - icon: icons.image, - tooltip: true - } ); - - panelView.extendTemplate( { - attributes: { - class: 'ck-image-insert__panel' + if ( integrations.length == 1 ) { + // Do not use dropdown for a single integration button (integration that does not require form view). + if ( !firstIntegration.requiresForm ) { + return firstIntegration.buttonViewCreator( true ); } - } ); - if ( uploadImageCommand ) { - const splitButtonView = this.dropdownView.buttonView as SplitButtonView; - - // We are injecting custom button replacement to readonly field. - ( splitButtonView as any ).actionView = editor.ui.componentFactory.create( 'uploadImage' ); - // After we replaced action button with `uploadImage` component, - // we have lost a proper styling and some minor visual quirks have appeared. - // Brining back original split button classes helps fix the button styling - // See https://github.com/ckeditor/ckeditor5/issues/7986. - splitButtonView.actionView.extendTemplate( { - attributes: { - class: 'ck ck-button ck-splitbutton__action' - } - } ); - } + dropdownButton = firstIntegration.buttonViewCreator( true ) as DropdownButtonView; + } else { + const actionButton = firstIntegration.buttonViewCreator( false ) as ButtonView & FocusableView; - return this._setUpDropdown( uploadImageCommand || insertImageCommand ); - } + dropdownButton = new SplitButtonView( locale, actionButton ); + dropdownButton.tooltip = true; - /** - * Sets up the dropdown view. - * - * @param command An uploadImage or insertImage command. - */ - private _setUpDropdown( command: Command ): DropdownView { - const editor = this.editor; - const t = editor.t; - const dropdownView = this.dropdownView!; - const panelView = dropdownView.panelView; - const imageUtils: ImageUtils = this.editor.plugins.get( 'ImageUtils' ); - const replaceImageSourceCommand: ReplaceImageSourceCommand = editor.commands.get( 'replaceImageSource' )!; + dropdownButton.bind( 'label' ).to( this, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace image' ) : + t( 'Insert image' ) + ); + } - let imageInsertView: ImageInsertPanelView; + const dropdownView = this.dropdownView = createDropdown( locale, dropdownButton ); + const observables = integrations.map( ( { observable } ) => observable ); - dropdownView.bind( 'isEnabled' ).to( command ); + dropdownView.bind( 'isEnabled' ).toMany( observables, 'isEnabled', ( ...isEnabled ) => ( + isEnabled.some( isEnabled => isEnabled ) + ) ); dropdownView.once( 'change:isOpen', () => { - imageInsertView = new ImageInsertPanelView( editor.locale, prepareIntegrations( editor ) ); + const integrationViews = integrations.map( ( { formViewCreator } ) => formViewCreator( integrations.length == 1 ) ); + const imageInsertFormView = new ImageInsertFormView( editor.locale, integrationViews ); - imageInsertView.delegate( 'submit', 'cancel' ).to( dropdownView ); - panelView.children.add( imageInsertView ); + dropdownView.panelView.children.add( imageInsertFormView ); } ); - dropdownView.on( 'change:isOpen', () => { - const selectedElement = editor.model.document.selection.getSelectedElement()!; - const insertButtonView = imageInsertView.insertButtonView; - const insertImageViaUrlForm = imageInsertView.getIntegration( 'insertImageViaUrl' ) as LabeledFieldView; - - if ( dropdownView.isOpen ) { - if ( imageUtils.isImage( selectedElement ) ) { - imageInsertView.imageURLInputValue = replaceImageSourceCommand.value!; - insertButtonView.label = t( 'Update' ); - insertImageViaUrlForm.label = t( 'Update image URL' ); - } else { - imageInsertView.imageURLInputValue = ''; - insertButtonView.label = t( 'Insert' ); - insertImageViaUrlForm.label = t( 'Insert image via URL' ); - } - } - // Note: Use the low priority to make sure the following listener starts working after the - // default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the - // invisible form/input cannot be focused/selected. - }, { priority: 'low' } ); + return dropdownView; + } - this.delegate( 'cancel' ).to( dropdownView ); + /** + * Validates the integrations list. + */ + private _prepareIntegrations(): Array { + const editor = this.editor; + const items = editor.config.get( 'image.insert.integrations' )!; + const result: Array = []; - dropdownView.on( 'submit', () => { - closePanel(); - onSubmit(); - } ); + if ( !items.length ) { + /** + * The insert image feature requires a list of integrations to be provided in the editor configuration. + * + * The default list of integrations is `upload`, `assetManager`, `url`. Those integrations are included + * in the insert image dropdown if the given feature plugin is loaded. You should omit the `integrations` + * configuration key to use the default set or provide a selected list of integrations that should be used. + * + * @error image-insert-integrations-not-specified + */ + logWarning( 'image-insert-integrations-not-specified' ); - dropdownView.on( 'cancel', () => { - closePanel(); - } ); + return result; + } - function onSubmit() { - const selectedElement = editor.model.document.selection.getSelectedElement()!; + for ( const item of items ) { + if ( !this._integrations.has( item ) ) { + if ( ![ 'upload', 'assetManager', 'url' ].includes( item ) ) { + /** + * The specified insert image integration name is unknown or the providing plugin is not loaded in the editor. + * + * @error image-insert-unknown-integration + */ + logWarning( 'image-insert-unknown-integration', { item } ); + } - if ( imageUtils.isImage( selectedElement ) ) { - editor.execute( 'replaceImageSource', { source: imageInsertView.imageURLInputValue } ); - } else { - editor.execute( 'insertImage', { source: imageInsertView.imageURLInputValue } ); + continue; } + + result.push( this._integrations.get( item )! ); } - function closePanel() { - editor.editing.view.focus(); - dropdownView.isOpen = false; + if ( !result.length ) { + /** + * The image insert feature requires integrations to be registered by separate features. + * + * The `insertImage` toolbar button requires integrations to be registered by other features. + * For example {@link module:image/imageupload~ImageUpload ImageUpload}, + * {@link module:image/imageinsert~ImageInsert ImageInsert}, + * {@link module:image/imageinsertviaurl~ImageInsertViaUrl ImageInsertViaUrl}, + * {@link module:ckbox/ckbox~CKBox CKBox} + * + * @error image-insert-integrations-not-registered + */ + logWarning( 'image-insert-integrations-not-registered' ); } - return dropdownView; + return result; } } + +type IntegrationData = { + observable: Observable & { isEnabled: boolean }; + buttonViewCreator: ( isOnlyOne: boolean ) => ButtonView; + formViewCreator: ( isOnlyOne: boolean ) => FocusableView; + requiresForm: boolean; +}; diff --git a/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts b/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts new file mode 100644 index 00000000000..272c6fe02c9 --- /dev/null +++ b/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts @@ -0,0 +1,157 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module image/imageinsert/imageinsertviaurlui + */ + +import { icons, Plugin } from 'ckeditor5/src/core'; +import { ButtonView, CollapsibleView, DropdownButtonView, type FocusableView } from 'ckeditor5/src/ui'; + +import ImageInsertUI from './imageinsertui'; +import type InsertImageCommand from '../image/insertimagecommand'; +import type ReplaceImageSourceCommand from '../image/replaceimagesourcecommand'; +import ImageInsertUrlView, { type ImageInsertUrlViewCancelEvent, type ImageInsertUrlViewSubmitEvent } from './ui/imageinserturlview'; + +/** + * The image insert via URL plugin (UI part). + * + * For a detailed overview, check the {@glink features/images/images-inserting + * Insert images via source URL} documentation. + * + * This plugin registers the {@link module:image/imageinsert/imageinsertui~ImageInsertUI} integration for `url`. + */ +export default class ImageInsertViaUrlUI extends Plugin { + private _imageInsertUI!: ImageInsertUI; + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'ImageInsertViaUrlUI' as const; + } + + /** + * @inheritDoc + */ + public static get requires() { + return [ ImageInsertUI ] as const; + } + + /** + * @inheritDoc + */ + public init(): void { + this._imageInsertUI = this.editor.plugins.get( 'ImageInsertUI' ); + const insertImageCommand: InsertImageCommand = this.editor.commands.get( 'insertImage' )!; + + this._imageInsertUI.registerIntegration( { + name: 'url', + observable: insertImageCommand, + requiresForm: true, + buttonViewCreator: isOnlyOne => this._createInsertUrlButton( isOnlyOne ), + formViewCreator: isOnlyOne => this._createInsertUrlView( isOnlyOne ) + } ); + } + + /** + * Creates the view displayed in the dropdown. + */ + private _createInsertUrlView( isOnlyOne: boolean ): FocusableView { + const editor = this.editor; + const locale = editor.locale; + const t = locale.t; + + const replaceImageSourceCommand: ReplaceImageSourceCommand = editor.commands.get( 'replaceImageSource' )!; + const insertImageCommand: InsertImageCommand = editor.commands.get( 'insertImage' )!; + + const imageInsertUrlView = new ImageInsertUrlView( locale ); + const collapsibleView = isOnlyOne ? null : new CollapsibleView( locale, [ imageInsertUrlView ] ); + + imageInsertUrlView.bind( 'isImageSelected' ).to( this._imageInsertUI ); + imageInsertUrlView.bind( 'isEnabled' ).toMany( [ insertImageCommand, replaceImageSourceCommand ], 'isEnabled', ( ...isEnabled ) => ( + isEnabled.some( isCommandEnabled => isCommandEnabled ) + ) ); + + // Set initial value because integrations are created on first dropdown open. + imageInsertUrlView.imageURLInputValue = replaceImageSourceCommand.value || ''; + + this._imageInsertUI.dropdownView!.on( 'change:isOpen', () => { + if ( this._imageInsertUI.dropdownView!.isOpen ) { + // Make sure that each time the panel shows up, the URL field remains in sync with the value of + // the command. If the user typed in the input, then canceled and re-opened it without changing + // the value of the media command (e.g. because they didn't change the selection), they would see + // the old value instead of the actual value of the command. + imageInsertUrlView.imageURLInputValue = replaceImageSourceCommand.value || ''; + + if ( collapsibleView ) { + collapsibleView.isCollapsed = true; + } + } + + // Note: Use the low priority to make sure the following listener starts working after the + // default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the + // invisible form/input cannot be focused/selected. + }, { priority: 'low' } ); + + imageInsertUrlView.on( 'submit', () => { + if ( replaceImageSourceCommand.isEnabled ) { + editor.execute( 'replaceImageSource', { source: imageInsertUrlView.imageURLInputValue } ); + } else { + editor.execute( 'insertImage', { source: imageInsertUrlView.imageURLInputValue } ); + } + + this._closePanel(); + } ); + + imageInsertUrlView.on( 'cancel', () => this._closePanel() ); + + if ( collapsibleView ) { + collapsibleView.set( { + isCollapsed: true + } ); + + collapsibleView.bind( 'label' ).to( this._imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Update image URL' ) : + t( 'Insert image via URL' ) + ); + + return collapsibleView; + } + + return imageInsertUrlView; + } + + /** + * Creates the toolbar button. + */ + private _createInsertUrlButton( isOnlyOne: boolean ): ButtonView { + const ButtonClass = isOnlyOne ? DropdownButtonView : ButtonView; + + const editor = this.editor; + const button = new ButtonClass( editor.locale ); + const t = editor.locale.t; + + button.set( { + icon: icons.imageUrl, + tooltip: true + } ); + + button.bind( 'label' ).to( this._imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Update image URL' ) : + t( 'Insert image via URL' ) + ); + + return button; + } + + /** + * Closes the dropdown. + */ + private _closePanel(): void { + this.editor.editing.view.focus(); + this._imageInsertUI.dropdownView!.isOpen = false; + } +} diff --git a/packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.ts b/packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.ts deleted file mode 100644 index ef153326e5c..00000000000 --- a/packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module image/imageinsert/ui/imageinsertformrowview - */ - -import type { Locale } from 'ckeditor5/src/utils'; -import { View, type ViewCollection, type LabelView } from 'ckeditor5/src/ui'; - -import '../../../theme/imageinsertformrowview.css'; - -/** - * The class representing a single row in a complex form, - * used by {@link module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView}. - * - * **Note**: For now this class is private. When more use cases appear (beyond `ckeditor5-table` and `ckeditor5-image`), - * it will become a component in `ckeditor5-ui`. - * - * @private - */ -export default class ImageUploadFormRowView extends View { - /** - * An additional CSS class added to the {@link #element}. - * - * @observable - */ - declare public class: string | null; - - /** - * A collection of row items (buttons, dropdowns, etc.). - */ - public readonly children: ViewCollection; - - /** - * The role property reflected by the `role` DOM attribute of the {@link #element}. - * - * **Note**: Used only when a `labelView` is passed to constructor `options`. - * - * @observable - * @private - */ - declare public _role: string | null; - - /** - * The ARIA property reflected by the `aria-labelledby` DOM attribute of the {@link #element}. - * - * **Note**: Used only when a `labelView` is passed to constructor `options`. - * - * @observable - * @private - */ - declare public _ariaLabelledBy: string | null; - - /** - * Creates an instance of the form row class. - * - * @param locale The locale instance. - * @param options.labelView When passed, the row gets the `group` and `aria-labelledby` - * DOM attributes and gets described by the label. - */ - constructor( locale: Locale, options: { children?: Array; class?: string; labelView?: LabelView } = {} ) { - super( locale ); - - const bind = this.bindTemplate; - - this.set( 'class', options.class || null ); - - this.children = this.createCollection(); - - if ( options.children ) { - options.children.forEach( child => this.children.add( child ) ); - } - - this.set( '_role', null ); - - this.set( '_ariaLabelledBy', null ); - - if ( options.labelView ) { - this.set( { - _role: 'group', - _ariaLabelledBy: options.labelView.id - } ); - } - - this.setTemplate( { - tag: 'div', - attributes: { - class: [ - 'ck', - 'ck-form__row', - bind.to( 'class' ) - ], - role: bind.to( '_role' ), - 'aria-labelledby': bind.to( '_ariaLabelledBy' ) - }, - children: this.children - } ); - } -} diff --git a/packages/ckeditor5-image/src/imageinsert/ui/imageinsertformview.ts b/packages/ckeditor5-image/src/imageinsert/ui/imageinsertformview.ts new file mode 100644 index 00000000000..c8aeee49ab7 --- /dev/null +++ b/packages/ckeditor5-image/src/imageinsert/ui/imageinsertformview.ts @@ -0,0 +1,169 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module image/imageinsert/ui/imageinsertformview + */ + +import { + View, + ViewCollection, + submitHandler, + FocusCycler, + CollapsibleView, + type FocusCyclerForwardCycleEvent, + type FocusCyclerBackwardCycleEvent +} from 'ckeditor5/src/ui'; +import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils'; + +import '../../../theme/imageinsert.css'; + +/** + * The view displayed in the insert image dropdown. + * + * See {@link module:image/imageinsert/imageinsertui~ImageInsertUI}. + */ +export default class ImageInsertFormView extends View { + /** + * Tracks information about DOM focus in the form. + */ + public readonly focusTracker: FocusTracker; + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + public readonly keystrokes: KeystrokeHandler; + + /** + * A collection of views that can be focused in the form. + */ + protected readonly _focusables: ViewCollection; + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + protected readonly _focusCycler: FocusCycler; + + /** + * A collection of the defined integrations for inserting the images. + */ + private readonly children: ViewCollection; + + /** + * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsertui~ImageInsertUI}. + * + * @param locale The localization services instance. + * @param integrations An integrations object that contains components (or tokens for components) to be shown in the panel view. + */ + constructor( locale: Locale, integrations: Array = [] ) { + super( locale ); + + this.focusTracker = new FocusTracker(); + this.keystrokes = new KeystrokeHandler(); + this._focusables = new ViewCollection(); + this.children = this.createCollection(); + + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + for ( const view of integrations ) { + this.children.add( view ); + this._focusables.add( view ); + + if ( view instanceof CollapsibleView ) { + this._focusables.addMany( view.children ); + } + } + + if ( this._focusables.length > 1 ) { + for ( const view of this._focusables ) { + if ( isViewWithFocusCycler( view ) ) { + view.focusCycler.on( 'forwardCycle', evt => { + this._focusCycler.focusNext(); + evt.stop(); + } ); + + view.focusCycler.on( 'backwardCycle', evt => { + this._focusCycler.focusPrevious(); + evt.stop(); + } ); + } + } + } + + this.setTemplate( { + tag: 'form', + + attributes: { + class: [ + 'ck', + 'ck-image-insert-form' + ], + tabindex: -1 + }, + + children: this.children + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + submitHandler( { + view: this + } ); + + for ( const view of this._focusables ) { + this.focusTracker.add( view.element! ); + } + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element! ); + + const stopPropagation = ( data: KeyboardEvent ) => data.stopPropagation(); + + // Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's + // keystroke handler would take over the key management in the URL input. We need to prevent + // this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible. + this.keystrokes.set( 'arrowright', stopPropagation ); + this.keystrokes.set( 'arrowleft', stopPropagation ); + this.keystrokes.set( 'arrowup', stopPropagation ); + this.keystrokes.set( 'arrowdown', stopPropagation ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Focuses the first {@link #_focusables focusable} in the form. + */ + public focus(): void { + this._focusCycler.focusFirst(); + } +} + +function isViewWithFocusCycler( view: View ): view is View & { focusCycler: FocusCycler } { + return 'focusCycler' in view; +} diff --git a/packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.ts b/packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.ts deleted file mode 100644 index b782c4479f2..00000000000 --- a/packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module image/imageinsert/ui/imageinsertpanelview - */ - -import { icons } from 'ckeditor5/src/core'; -import { ButtonView, View, ViewCollection, submitHandler, FocusCycler, type InputTextView, type LabeledFieldView } from 'ckeditor5/src/ui'; -import { Collection, FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils'; - -import ImageInsertFormRowView from './imageinsertformrowview'; - -import '../../../theme/imageinsert.css'; - -export type ViewWithName = View & { name: string }; - -/** - * The insert an image via URL view controller class. - * - * See {@link module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView}. - */ -export default class ImageInsertPanelView extends View { - /** - * The "insert/update" button view. - */ - public insertButtonView: ButtonView; - - /** - * The "cancel" button view. - */ - public cancelButtonView: ButtonView; - - /** - * The value of the URL input. - * - * @observable - */ - declare public imageURLInputValue: string; - - /** - * Tracks information about DOM focus in the form. - */ - public readonly focusTracker: FocusTracker; - - /** - * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. - */ - public readonly keystrokes: KeystrokeHandler; - - /** - * A collection of views that can be focused in the form. - */ - protected readonly _focusables: ViewCollection; - - /** - * Helps cycling over {@link #_focusables} in the form. - */ - protected readonly _focusCycler: FocusCycler; - - /** - * A collection of the defined integrations for inserting the images. - * - * @private - */ - declare public _integrations: Collection; - - /** - * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsertui~ImageInsertUI}. - * - * @param locale The localization services instance. - * @param integrations An integrations object that contains components (or tokens for components) to be shown in the panel view. - */ - constructor( locale: Locale, integrations: Record = {} ) { - super( locale ); - - const { insertButtonView, cancelButtonView } = this._createActionButtons( locale ); - - this.insertButtonView = insertButtonView; - - this.cancelButtonView = cancelButtonView; - - this.set( 'imageURLInputValue', '' ); - - this.focusTracker = new FocusTracker(); - - this.keystrokes = new KeystrokeHandler(); - - this._focusables = new ViewCollection(); - - this._focusCycler = new FocusCycler( { - focusables: this._focusables, - focusTracker: this.focusTracker, - keystrokeHandler: this.keystrokes, - actions: { - // Navigate form fields backwards using the Shift + Tab keystroke. - focusPrevious: 'shift + tab', - - // Navigate form fields forwards using the Tab key. - focusNext: 'tab' - } - } ); - - this.set( '_integrations', new Collection() ); - - for ( const [ integration, integrationView ] of Object.entries( integrations ) ) { - if ( integration === 'insertImageViaUrl' ) { - ( integrationView as LabeledFieldView ).fieldView - .bind( 'value' ).to( this, 'imageURLInputValue', ( value: string ) => value || '' ); - - ( integrationView as LabeledFieldView ).fieldView.on( 'input', () => { - this.imageURLInputValue = ( integrationView as LabeledFieldView ).fieldView.element!.value.trim(); - } ); - } - - ( integrationView as ViewWithName ).name = integration; - - this._integrations.add( integrationView as ViewWithName ); - } - - this.setTemplate( { - tag: 'form', - - attributes: { - class: [ - 'ck', - 'ck-image-insert-form' - ], - - tabindex: '-1' - }, - - children: [ - ...this._integrations, - new ImageInsertFormRowView( locale, { - children: [ - this.insertButtonView, - this.cancelButtonView - ], - class: 'ck-image-insert-form__action-row' - } ) - ] - } ); - } - - /** - * @inheritDoc - */ - public override render(): void { - super.render(); - - submitHandler( { - view: this - } ); - - const childViews = [ - ...this._integrations, - this.insertButtonView, - this.cancelButtonView - ]; - - childViews.forEach( v => { - // Register the view as focusable. - this._focusables.add( v ); - - // Register the view in the focus tracker. - this.focusTracker.add( v.element! ); - } ); - - // Start listening for the keystrokes coming from #element. - this.keystrokes.listenTo( this.element! ); - - const stopPropagation = ( data: KeyboardEvent ) => data.stopPropagation(); - - // Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's - // keystroke handler would take over the key management in the URL input. We need to prevent - // this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible. - this.keystrokes.set( 'arrowright', stopPropagation ); - this.keystrokes.set( 'arrowleft', stopPropagation ); - this.keystrokes.set( 'arrowup', stopPropagation ); - this.keystrokes.set( 'arrowdown', stopPropagation ); - } - - /** - * @inheritDoc - */ - public override destroy(): void { - super.destroy(); - - this.focusTracker.destroy(); - this.keystrokes.destroy(); - } - - /** - * Returns a view of the integration. - * - * @param name The name of the integration. - */ - public getIntegration( name: string ): View { - return this._integrations.find( integration => integration.name === name )!; - } - - /** - * Creates the following form controls: - * - * * {@link #insertButtonView}, - * * {@link #cancelButtonView}. - * - * @param locale The localization services instance. - */ - private _createActionButtons( locale: Locale ): { insertButtonView: ButtonView; cancelButtonView: ButtonView } { - const t = locale.t; - const insertButtonView = new ButtonView( locale ); - const cancelButtonView = new ButtonView( locale ); - - insertButtonView.set( { - label: t( 'Insert' ), - icon: icons.check, - class: 'ck-button-save', - type: 'submit', - withText: true, - isEnabled: this.imageURLInputValue - } ); - - cancelButtonView.set( { - label: t( 'Cancel' ), - icon: icons.cancel, - class: 'ck-button-cancel', - withText: true - } ); - - insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue', value => !!value ); - insertButtonView.delegate( 'execute' ).to( this, 'submit' ); - cancelButtonView.delegate( 'execute' ).to( this, 'cancel' ); - - return { insertButtonView, cancelButtonView }; - } - - /** - * Focuses the first {@link #_focusables focusable} in the form. - */ - public focus(): void { - this._focusCycler.focusFirst(); - } -} - -/** - * Fired when the form view is submitted (when one of the children triggered the submit event), - * e.g. by a click on {@link ~ImageInsertPanelView#insertButtonView}. - * - * @eventName ~ImageInsertPanelView#submit - */ -export type SubmitEvent = { - name: 'submit'; - args: []; -}; - -/** - * Fired when the form view is canceled, e.g. by a click on {@link ~ImageInsertPanelView#cancelButtonView}. - * - * @eventName ~ImageInsertPanelView#cancel - */ -export type CancelEvent = { - name: 'cancel'; - args: []; -}; diff --git a/packages/ckeditor5-image/src/imageinsert/ui/imageinserturlview.ts b/packages/ckeditor5-image/src/imageinsert/ui/imageinserturlview.ts new file mode 100644 index 00000000000..a755de9e085 --- /dev/null +++ b/packages/ckeditor5-image/src/imageinsert/ui/imageinserturlview.ts @@ -0,0 +1,278 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module image/imageinsert/ui/imageinserturlview + */ + +import { icons } from 'ckeditor5/src/core'; +import { + ButtonView, + View, + ViewCollection, + FocusCycler, + LabeledFieldView, + createLabeledInputText, + type InputTextView +} from 'ckeditor5/src/ui'; +import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils'; + +/** + * The insert an image via URL view. + * + * See {@link module:image/imageinsert/imageinsertviaurlui~ImageInsertViaUrlUI}. + */ +export default class ImageInsertUrlView extends View { + /** + * The URL input field view. + */ + public urlInputView: LabeledFieldView; + + /** + * The "insert/update" button view. + */ + public insertButtonView: ButtonView; + + /** + * The "cancel" button view. + */ + public cancelButtonView: ButtonView; + + /** + * The value of the URL input. + * + * @observable + */ + declare public imageURLInputValue: string; + + /** + * Observable property used to alter labels while some image is selected and when it is not. + * + * @observable + */ + declare public isImageSelected: boolean; + + /** + * Observable property indicating whether the form interactive elements should be enabled. + * + * @observable + */ + declare public isEnabled: boolean; + + /** + * Tracks information about DOM focus in the form. + */ + public readonly focusTracker: FocusTracker; + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + public readonly keystrokes: KeystrokeHandler; + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + public readonly focusCycler: FocusCycler; + + /** + * A collection of views that can be focused in the form. + */ + private readonly _focusables: ViewCollection; + + /** + * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsertui~ImageInsertUI}. + * + * @param locale The localization services instance. + */ + constructor( locale: Locale ) { + super( locale ); + + this.set( 'imageURLInputValue', '' ); + this.set( 'isImageSelected', false ); + this.set( 'isEnabled', true ); + + this.focusTracker = new FocusTracker(); + this.keystrokes = new KeystrokeHandler(); + this._focusables = new ViewCollection(); + + this.focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + this.urlInputView = this._createUrlInputView(); + this.insertButtonView = this._createInsertButton(); + this.cancelButtonView = this._createCancelButton(); + + this._focusables.addMany( [ + this.urlInputView, + this.insertButtonView, + this.cancelButtonView + ] ); + + this.setTemplate( { + tag: 'div', + + attributes: { + class: [ + 'ck', + 'ck-image-insert-url' + ] + }, + + children: [ + this.urlInputView, + { + tag: 'div', + attributes: { + class: [ + 'ck', + 'ck-image-insert-url__action-row' + ] + }, + + children: [ + this.insertButtonView, + this.cancelButtonView + ] + } + ] + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + for ( const view of this._focusables ) { + this.focusTracker.add( view.element! ); + } + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element! ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Creates the {@link #urlInputView}. + */ + private _createUrlInputView() { + const locale = this.locale!; + const t = locale.t; + const urlInputView = new LabeledFieldView( locale, createLabeledInputText ); + + urlInputView.bind( 'label' ).to( this, 'isImageSelected', + value => value ? t( 'Update image URL' ) : t( 'Insert image via URL' ) + ); + + urlInputView.bind( 'isEnabled' ).to( this ); + + urlInputView.fieldView.placeholder = 'https://example.com/image.png'; + + urlInputView.fieldView.bind( 'value' ).to( this, 'imageURLInputValue', ( value: string ) => value || '' ); + urlInputView.fieldView.on( 'input', () => { + this.imageURLInputValue = urlInputView.fieldView.element!.value.trim(); + } ); + + return urlInputView; + } + + /** + * Creates the {@link #insertButtonView}. + */ + private _createInsertButton(): ButtonView { + const locale = this.locale!; + const t = locale.t; + const insertButtonView = new ButtonView( locale ); + + insertButtonView.set( { + icon: icons.check, + class: 'ck-button-save', + type: 'submit', + withText: true + } ); + + insertButtonView.bind( 'label' ).to( this, 'isImageSelected', value => value ? t( 'Update' ) : t( 'Insert' ) ); + insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue', this, 'isEnabled', + ( ...values ) => values.every( value => value ) + ); + + insertButtonView.delegate( 'execute' ).to( this, 'submit' ); + + return insertButtonView; + } + + /** + * Creates the {@link #cancelButtonView}. + */ + private _createCancelButton(): ButtonView { + const locale = this.locale!; + const t = locale.t; + const cancelButtonView = new ButtonView( locale ); + + cancelButtonView.set( { + label: t( 'Cancel' ), + icon: icons.cancel, + class: 'ck-button-cancel', + withText: true + } ); + + cancelButtonView.bind( 'isEnabled' ).to( this ); + + cancelButtonView.delegate( 'execute' ).to( this, 'cancel' ); + + return cancelButtonView; + } + + /** + * Focuses the view. + */ + public focus( direction: 1 | -1 ): void { + if ( direction === -1 ) { + this.focusCycler.focusLast(); + } else { + this.focusCycler.focusFirst(); + } + } +} + +/** + * Fired when the form view is submitted. + * + * @eventName ~ImageInsertUrlView#submit + */ +export type ImageInsertUrlViewSubmitEvent = { + name: 'submit'; + args: []; +}; + +/** + * Fired when the form view is canceled. + * + * @eventName ~ImageInsertUrlView#cancel + */ +export type ImageInsertUrlViewCancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-image/src/imageinsert/utils.ts b/packages/ckeditor5-image/src/imageinsert/utils.ts deleted file mode 100644 index eb9eb390dec..00000000000 --- a/packages/ckeditor5-image/src/imageinsert/utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module image/imageinsert/utils - */ - -import type { Locale } from 'ckeditor5/src/utils'; -import type { Editor } from 'ckeditor5/src/core'; -import { LabeledFieldView, createLabeledInputText, type View, type ButtonView } from 'ckeditor5/src/ui'; - -import type ImageInsertUI from './imageinsertui'; - -/** - * Creates integrations object that will be passed to the - * {@link module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView}. - * - * @param editor Editor instance. - * - * @returns Integrations object. - */ -export function prepareIntegrations( editor: Editor ): Record { - const panelItems = editor.config.get( 'image.insert.integrations' ); - const imageInsertUIPlugin: ImageInsertUI = editor.plugins.get( 'ImageInsertUI' ); - - const PREDEFINED_INTEGRATIONS: Record = { - 'insertImageViaUrl': createLabeledInputView( editor.locale ) - }; - - if ( !panelItems ) { - return PREDEFINED_INTEGRATIONS; - } - - // Prepares ckfinder component for the `openCKFinder` integration token. - if ( panelItems.find( item => item === 'openCKFinder' ) && editor.ui.componentFactory.has( 'ckfinder' ) ) { - const ckFinderButton = editor.ui.componentFactory.create( 'ckfinder' ) as ButtonView; - ckFinderButton.set( { - withText: true, - class: 'ck-image-insert__ck-finder-button' - } ); - - // We want to close the dropdown panel view when user clicks the ckFinderButton. - ckFinderButton.delegate( 'execute' ).to( imageInsertUIPlugin, 'cancel' ); - - PREDEFINED_INTEGRATIONS.openCKFinder = ckFinderButton; - } - - // Creates integrations object of valid views to pass it to the ImageInsertPanelView. - return panelItems.reduce( ( object: Record, key ) => { - if ( PREDEFINED_INTEGRATIONS[ key ] ) { - object[ key ] = PREDEFINED_INTEGRATIONS[ key ]; - } else if ( editor.ui.componentFactory.has( key ) ) { - object[ key ] = editor.ui.componentFactory.create( key ); - } - - return object; - }, {} ); -} - -/** - * Creates labeled field view. - * - * @param locale The localization services instance. - */ -export function createLabeledInputView( locale: Locale ): LabeledFieldView { - const t = locale.t; - const labeledInputView = new LabeledFieldView( locale, createLabeledInputText ); - - labeledInputView.set( { - label: t( 'Insert image via URL' ) - } ); - labeledInputView.fieldView.placeholder = 'https://example.com/image.png'; - - return labeledInputView; -} diff --git a/packages/ckeditor5-image/src/imageinsertviaurl.ts b/packages/ckeditor5-image/src/imageinsertviaurl.ts index cb0a343647a..98d96567a84 100644 --- a/packages/ckeditor5-image/src/imageinsertviaurl.ts +++ b/packages/ckeditor5-image/src/imageinsertviaurl.ts @@ -9,6 +9,7 @@ import { Plugin } from 'ckeditor5/src/core'; import ImageInsertUI from './imageinsert/imageinsertui'; +import ImageInsertViaUrlUI from './imageinsert/imageinsertviaurlui'; /** * The image insert via URL plugin. @@ -33,6 +34,6 @@ export default class ImageInsertViaUrl extends Plugin { * @inheritDoc */ public static get requires() { - return [ ImageInsertUI ] as const; + return [ ImageInsertViaUrlUI, ImageInsertUI ] as const; } } diff --git a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts index e6588ed8612..441038d3fbd 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts +++ b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts @@ -16,9 +16,11 @@ import { type ViewWithCssTransitionDisabler } from 'ckeditor5/src/ui'; -import TextAlternativeFormView from './ui/textalternativeformview'; +import TextAlternativeFormView, { + type TextAlternativeFormViewCancelEvent, + type TextAlternativeFormViewSubmitEvent +} from './ui/textalternativeformview'; import { repositionContextualBalloon, getBalloonPositionData } from '../image/ui/utils'; -import type { CancelEvent, SubmitEvent } from '../imageinsert/ui/imageinsertpanelview'; import type ImageTextAlternativeCommand from './imagetextalternativecommand'; import type ImageUtils from '../imageutils'; @@ -117,7 +119,7 @@ export default class ImageTextAlternativeUI extends Plugin { // Render the form so its #element is available for clickOutsideHandler. this._form.render(); - this.listenTo( this._form, 'submit', () => { + this.listenTo( this._form, 'submit', () => { editor.execute( 'imageTextAlternative', { newValue: this._form!.labeledInput.fieldView.element!.value } ); @@ -125,7 +127,7 @@ export default class ImageTextAlternativeUI extends Plugin { this._hideForm( true ); } ); - this.listenTo( this._form, 'cancel', () => { + this.listenTo( this._form, 'cancel', () => { this._hideForm( true ); } ); diff --git a/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts b/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts index 36a9496a04c..d2f17a24399 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts +++ b/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts @@ -195,3 +195,23 @@ export default class TextAlternativeFormView extends View { return labeledInput; } } + +/** + * Fired when the form view is submitted. + * + * @eventName ~TextAlternativeFormView#submit + */ +export type TextAlternativeFormViewSubmitEvent = { + name: 'submit'; + args: []; +}; + +/** + * Fired when the form view is canceled. + * + * @eventName ~TextAlternativeFormView#cancel + */ +export type TextAlternativeFormViewCancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-image/src/imageupload/imageuploadui.ts b/packages/ckeditor5-image/src/imageupload/imageuploadui.ts index 9a8151bb90c..a7e596134d2 100644 --- a/packages/ckeditor5-image/src/imageupload/imageuploadui.ts +++ b/packages/ckeditor5-image/src/imageupload/imageuploadui.ts @@ -12,6 +12,7 @@ import { Plugin, icons } from 'ckeditor5/src/core'; import { FileDialogButtonView } from 'ckeditor5/src/upload'; import { createImageTypeRegExp } from './utils'; import type UploadImageCommand from './uploadimagecommand'; +import type ImageInsertUI from '../imageinsert/imageinsertui'; /** * The image upload button plugin. @@ -35,6 +36,7 @@ export default class ImageUploadUI extends Plugin { public init(): void { const editor = this.editor; const t = editor.t; + const componentCreator = ( locale: Locale ) => { const view = new FileDialogButtonView( locale ); const command: UploadImageCommand = editor.commands.get( 'uploadImage' )!; @@ -43,16 +45,13 @@ export default class ImageUploadUI extends Plugin { view.set( { acceptedType: imageTypes.map( type => `image/${ type }` ).join( ',' ), - allowMultipleFiles: true - } ); - - view.buttonView.set( { - label: t( 'Insert image' ), - icon: icons.image, + allowMultipleFiles: true, + label: t( 'Upload image from computer' ), + icon: icons.imageUpload, tooltip: true } ); - view.buttonView.bind( 'isEnabled' ).to( command ); + view.bind( 'isEnabled' ).to( command ); view.on( 'done', ( evt, files: FileList ) => { const imagesToUpload = Array.from( files ).filter( file => imageTypesRegExp.test( file.type ) ); @@ -70,5 +69,42 @@ export default class ImageUploadUI extends Plugin { // Setup `uploadImage` button and add `imageUpload` button as an alias for backward compatibility. editor.ui.componentFactory.add( 'uploadImage', componentCreator ); editor.ui.componentFactory.add( 'imageUpload', componentCreator ); + + if ( editor.plugins.has( 'ImageInsertUI' ) ) { + const imageInsertUI: ImageInsertUI = editor.plugins.get( 'ImageInsertUI' ); + const command: UploadImageCommand = editor.commands.get( 'uploadImage' )!; + + imageInsertUI.registerIntegration( { + name: 'upload', + observable: command, + + buttonViewCreator: () => { + const uploadImageButton = editor.ui.componentFactory.create( 'uploadImage' ) as FileDialogButtonView; + + uploadImageButton.bind( 'label' ).to( imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace image from computer' ) : + t( 'Upload image from computer' ) + ); + + return uploadImageButton; + }, + + formViewCreator: () => { + const uploadImageButton = editor.ui.componentFactory.create( 'uploadImage' ) as FileDialogButtonView; + + uploadImageButton.withText = true; + uploadImageButton.bind( 'label' ).to( imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ? + t( 'Replace from computer' ) : + t( 'Upload from computer' ) + ); + + uploadImageButton.on( 'execute', () => { + imageInsertUI.dropdownView!.isOpen = false; + } ); + + return uploadImageButton; + } + } ); + } } } diff --git a/packages/ckeditor5-image/tests/imageblock.js b/packages/ckeditor5-image/tests/imageblock.js new file mode 100644 index 00000000000..0f492f7cfc0 --- /dev/null +++ b/packages/ckeditor5-image/tests/imageblock.js @@ -0,0 +1,53 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import ImageBlock from '../src/imageblock'; +import ImageBlockEditing from '../src/image/imageblockediting'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import ImageTextAlternative from '../src/imagetextalternative'; +import ImageInsertUI from '../src/imageinsert/imageinsertui'; + +describe( 'ImageBlock', () => { + let editorElement, editor; + + beforeEach( async () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ ImageBlock, Paragraph ] + } ); + } ); + + afterEach( async () => { + editorElement.remove(); + await editor.destroy(); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( ImageBlock ) ).to.instanceOf( ImageBlock ); + expect( editor.plugins.get( 'ImageBlock' ) ).to.instanceOf( ImageBlock ); + } ); + + it( 'should load ImageBlockEditing plugin', () => { + expect( editor.plugins.get( ImageBlockEditing ) ).to.instanceOf( ImageBlockEditing ); + } ); + + it( 'should load Widget plugin', () => { + expect( editor.plugins.get( Widget ) ).to.instanceOf( Widget ); + } ); + + it( 'should load ImageTextAlternative plugin', () => { + expect( editor.plugins.get( ImageTextAlternative ) ).to.instanceOf( ImageTextAlternative ); + } ); + + it( 'should load ImageInsertUI plugin', () => { + expect( editor.plugins.get( ImageInsertUI ) ).to.instanceOf( ImageInsertUI ); + } ); +} ); diff --git a/packages/ckeditor5-image/tests/imageinline.js b/packages/ckeditor5-image/tests/imageinline.js new file mode 100644 index 00000000000..f29d06a346b --- /dev/null +++ b/packages/ckeditor5-image/tests/imageinline.js @@ -0,0 +1,53 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import ImageInline from '../src/imageinline'; +import ImageInlineEditing from '../src/image/imageinlineediting'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import ImageTextAlternative from '../src/imagetextalternative'; +import ImageInsertUI from '../src/imageinsert/imageinsertui'; + +describe( 'ImageInline', () => { + let editorElement, editor; + + beforeEach( async () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ ImageInline, Paragraph ] + } ); + } ); + + afterEach( async () => { + editorElement.remove(); + await editor.destroy(); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( ImageInline ) ).to.instanceOf( ImageInline ); + expect( editor.plugins.get( 'ImageInline' ) ).to.instanceOf( ImageInline ); + } ); + + it( 'should load ImageInlineEditing plugin', () => { + expect( editor.plugins.get( ImageInlineEditing ) ).to.instanceOf( ImageInlineEditing ); + } ); + + it( 'should load Widget plugin', () => { + expect( editor.plugins.get( Widget ) ).to.instanceOf( Widget ); + } ); + + it( 'should load ImageTextAlternative plugin', () => { + expect( editor.plugins.get( ImageTextAlternative ) ).to.instanceOf( ImageTextAlternative ); + } ); + + it( 'should load ImageInsertUI plugin', () => { + expect( editor.plugins.get( ImageInsertUI ) ).to.instanceOf( ImageInsertUI ); + } ); +} ); diff --git a/packages/ckeditor5-image/tests/imageinsert/imageinsertui.js b/packages/ckeditor5-image/tests/imageinsert/imageinsertui.js index 281633876b2..0e4550a8475 100644 --- a/packages/ckeditor5-image/tests/imageinsert/imageinsertui.js +++ b/packages/ckeditor5-image/tests/imageinsert/imageinsertui.js @@ -3,84 +3,64 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals document */ +/* globals document, console */ -import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; - -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import Image from '../../src/image'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview'; -import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview'; -import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository'; -import ImageInsert from '../../src/imageinsert'; -import ImageInsertViaUrl from '../../src/imageinsertviaurl'; -import ImageInsertUI from '../../src/imageinsert/imageinsertui'; -import ImageInsertPanelView from '../../src/imageinsert/ui/imageinsertpanelview'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; -import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; -import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; -import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder'; -import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import DropdownButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/dropdownbuttonview'; import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview'; -import { UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks'; -import CKFinderUploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter'; -import Link from '@ckeditor/ckeditor5-link/src/link'; - -describe( 'ImageInsertUI', () => { - let editor, editorElement, fileRepository, dropdown; - - describe( 'dropdown (with uploadImage command)', () => { - class UploadAdapterPluginMock extends Plugin { - init() { - fileRepository = this.editor.plugins.get( FileRepository ); - fileRepository.createUploadAdapter = loader => { - return new UploadAdapterMock( loader ); - }; - } - } +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; - beforeEach( async () => { - editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - editor = await ClassicEditor.create( editorElement, { - plugins: [ Paragraph, Image, ImageInsert, FileRepository, UploadAdapterPluginMock, Clipboard ], - toolbar: [ 'insertImage' ], - image: { - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } - } - } ); +import Image from '../../src/image'; +import ImageInsertUI from '../../src/imageinsert/imageinsertui'; +import ImageInsertFormView from '../../src/imageinsert/ui/imageinsertformview'; - dropdown = editor.ui.view.toolbar.children.first.children.first; +describe( 'ImageInsertUI', () => { + let editor, editorElement, insertImageUI; - // Hide all notifications (prevent alert() calls). - const notification = editor.plugins.get( Notification ); - notification.on( 'show', evt => evt.stop() ); - } ); + testUtils.createSinonSandbox(); - afterEach( async () => { + afterEach( async () => { + if ( editorElement ) { editorElement.remove(); + } + if ( editor ) { await editor.destroy(); + } + } ); + + it( 'should have pluginName', () => { + expect( ImageInsertUI.pluginName ).to.equal( 'ImageInsertUI' ); + } ); + + describe( '#constructor()', () => { + beforeEach( async () => { + await createEditor( { plugins: [ ImageInsertUI ] } ); } ); - it( 'should register the "insertImage" dropdown', () => { - const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + it( 'should define config', () => { + expect( editor.config.get( 'image.insert.integrations' ) ).to.deep.equal( [ + 'upload', + 'assetManager', + 'url' + ] ); + } ); + } ); - expect( dropdown ).to.be.instanceOf( DropdownView ); + describe( '#init()', () => { + beforeEach( async () => { + await createEditor( { plugins: [ ImageInsertUI ] } ); } ); - it( 'should make the "insertImage" dropdown accessible via the property of the plugin', () => { - expect( editor.plugins.get( 'ImageInsertUI' ).dropdownView ).to.be.instanceOf( DropdownView ); + it( 'should register component in component factory', () => { + expect( editor.ui.componentFactory.has( 'insertImage' ) ).to.be.true; + expect( editor.ui.componentFactory.has( 'imageInsert' ) ).to.be.true; } ); it( 'should register "imageInsert" dropdown as an alias for the "insertImage" dropdown', () => { @@ -89,556 +69,356 @@ describe( 'ImageInsertUI', () => { expect( dropdownCreator.callback ).to.equal( dropdownAliasCreator.callback ); } ); + } ); - it( 'should register the "insertImage" dropdown with basic properties', () => { - const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - const dropdownButtonView = dropdown.buttonView; - - expect( dropdownButtonView ).to.have.property( 'label', 'Insert image' ); - expect( dropdownButtonView ).to.have.property( 'icon' ); - expect( dropdownButtonView ).to.have.property( 'tooltip', true ); + describe( '#isImageSelected', () => { + beforeEach( async () => { + await createEditor( { + plugins: [ ImageInsertUI, Essentials, Paragraph, Image ] + } ); } ); - it( 'should bind the enabled state of the dropdown to the UploadImageCommand command', () => { - const command = editor.commands.get( 'uploadImage' ); + it( 'should be false if image is not selected', () => { + setData( editor.model, + '[foo]' + + '' + ); - expect( command.isEnabled, 'command state' ).to.be.true; - expect( dropdown.isEnabled, 'dropdown state #1' ).to.be.true; + expect( insertImageUI.isImageSelected ).to.be.false; - command.forceDisabled( 'foo' ); + editor.model.change( writer => { + writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' ); + } ); - expect( dropdown.isEnabled, 'dropdown state #2' ).to.be.false; + expect( insertImageUI.isImageSelected ).to.be.false; } ); - it( 'should insert panel view children on first dropdown open', () => { - expect( dropdown.panelView.children.length ).to.equal( 0 ); + it( 'should be true if block image is selected', () => { + setData( editor.model, + 'foo' + + '[]' + ); - dropdown.isOpen = true; - - expect( dropdown.panelView.children.length ).to.equal( 1 ); - expect( dropdown.panelView.children.first ).to.be.instanceOf( ImageInsertPanelView ); - - dropdown.isOpen = false; - dropdown.isOpen = true; - - // Make sure it happens only once. - expect( dropdown.panelView.children.length ).to.equal( 1 ); - expect( dropdown.panelView.children.first ).to.be.instanceOf( ImageInsertPanelView ); + expect( insertImageUI.isImageSelected ).to.be.true; } ); - describe( 'dropdown action button', () => { - it( 'should belong to a split button', () => { - expect( dropdown.buttonView ).to.be.instanceOf( SplitButtonView ); - } ); + it( 'should change on selection change', () => { + setData( editor.model, + 'foo[]' + + '' + ); - it( 'should be an instance of FileDialogButtonView', () => { - const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + expect( insertImageUI.isImageSelected ).to.be.false; - expect( dropdown.buttonView.actionView ).to.be.instanceOf( FileDialogButtonView ); + editor.model.change( writer => { + writer.setSelection( editor.model.document.getRoot().getChild( 1 ), 'on' ); } ); - } ); - - describe( 'dropdown panel buttons', () => { - it( 'should have "Update" label on submit button when URL input is already filled', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '
' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); - } ); - const img = viewDocument.selection.getSelectedElement(); + expect( insertImageUI.isImageSelected ).to.be.true; - const data = fakeEventData(); - const eventInfo = new EventInfo( img, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); - - viewDocument.fire( 'click', domEventDataMock ); - - dropdown.buttonView.fire( 'open' ); - - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - - expect( inputValue ).to.equal( '/assets/sample.png' ); - expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Update' ); + editor.model.change( writer => { + writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' ); } ); - it( 'should have "Insert" label on submit button on uploading a new image', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '

test

' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' ); - } ); - - const el = viewDocument.selection.getSelectedElement(); - - const data = fakeEventData(); - const eventInfo = new EventInfo( el, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); - - viewDocument.fire( 'click', domEventDataMock ); - - dropdown.buttonView.fire( 'open' ); - - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - - expect( dropdown.isOpen ).to.be.true; - expect( inputValue ).to.equal( '' ); - expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Insert' ); - } ); + expect( insertImageUI.isImageSelected ).to.be.false; } ); + } ); - describe( 'dropdown panel integrations', () => { - describe( 'insert image via URL form', () => { - it( 'should have "Insert image via URL" label on inserting new image', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '

test

' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' ); - } ); - - const el = viewDocument.selection.getSelectedElement(); - - const data = fakeEventData(); - const eventInfo = new EventInfo( el, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); - - viewDocument.fire( 'click', domEventDataMock ); - - dropdown.buttonView.fire( 'open' ); - - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - - const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' ); - - expect( dropdown.isOpen ).to.be.true; - expect( inputValue ).to.equal( '' ); - expect( insertImageViaUrlForm.label ).to.equal( 'Insert image via URL' ); - } ); - - it( 'should have "Update image URL" label on updating the image source URL', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '
' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); - } ); - - const el = viewDocument.selection.getSelectedElement(); - - const data = fakeEventData(); - const eventInfo = new EventInfo( el, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); - - viewDocument.fire( 'click', domEventDataMock ); - - dropdown.buttonView.fire( 'open' ); - - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' ); - - expect( dropdown.isOpen ).to.be.true; - expect( inputValue ).to.equal( '/assets/sample.png' ); - expect( insertImageViaUrlForm.label ).to.equal( 'Update image URL' ); - } ); - } ); + describe( '#registerIntegration()', () => { + beforeEach( async () => { + await createEditor( { plugins: [ ImageInsertUI ] } ); } ); - it( 'should remove all attributes from model except "src" when updating the image source URL', () => { - const viewDocument = editor.editing.view.document; - const commandSpy = sinon.spy( editor.commands.get( 'insertImage' ), 'execute' ); - const submitSpy = sinon.spy(); - - dropdown.buttonView.fire( 'open' ); + it( 'should store the integration definition', () => { + const observable = new Model( { isEnabled: true } ); + const buttonViewCreator = () => {}; + const formViewCreator = () => {}; - const insertButtonView = dropdown.panelView.children.first.insertButtonView; - - editor.setData( '
' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); + insertImageUI.registerIntegration( { + name: 'foobar', + observable, + buttonViewCreator, + formViewCreator } ); - const selectedElement = editor.model.document.selection.getSelectedElement(); - - expect( selectedElement.getAttribute( 'src' ) ).to.equal( 'image-url-800w.jpg' ); - expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.true; - - dropdown.panelView.children.first.imageURLInputValue = '/assets/sample3.png'; + expect( insertImageUI._integrations.has( 'foobar' ) ).to.be.true; - dropdown.on( 'submit', submitSpy ); + const integrationData = insertImageUI._integrations.get( 'foobar' ); - insertButtonView.fire( 'execute' ); - - sinon.assert.notCalled( commandSpy ); - sinon.assert.calledOnce( submitSpy ); - expect( dropdown.isOpen ).to.be.false; - expect( selectedElement.getAttribute( 'src' ) ).to.equal( '/assets/sample3.png' ); - expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.false; - expect( selectedElement.hasAttribute( 'sizes' ) ).to.be.false; + expect( integrationData.observable ).to.equal( observable ); + expect( integrationData.buttonViewCreator ).to.equal( buttonViewCreator ); + expect( integrationData.formViewCreator ).to.equal( formViewCreator ); + expect( integrationData.requiresForm ).to.be.false; } ); - describe( 'events', () => { - it( 'should emit "submit" event when clicking on submit button', () => { - const commandSpy = sinon.spy( editor.commands.get( 'insertImage' ), 'execute' ); - const submitSpy = sinon.spy(); - - dropdown.buttonView.fire( 'open' ); - - dropdown.on( 'submit', submitSpy ); - - const insertButtonView = dropdown.panelView.children.first.insertButtonView; - - insertButtonView.fire( 'execute' ); - - expect( dropdown.isOpen ).to.be.false; - sinon.assert.calledOnce( commandSpy ); - sinon.assert.calledOnce( submitSpy ); + it( 'should store the integration definition (with optional data)', () => { + const observable = new Model( { isEnabled: true } ); + const buttonViewCreator = () => {}; + const formViewCreator = () => {}; + + insertImageUI.registerIntegration( { + name: 'foobar', + observable, + buttonViewCreator, + formViewCreator, + requiresForm: true } ); - it( 'should emit "cancel" event when clicking on cancel button', () => { - const commandSpy = sinon.spy( editor.commands.get( 'insertImage' ), 'execute' ); - const cancelSpy = sinon.spy(); - - dropdown.buttonView.fire( 'open' ); - - dropdown.on( 'cancel', cancelSpy ); + expect( insertImageUI._integrations.has( 'foobar' ) ).to.be.true; - const cancelButtonView = dropdown.panelView.children.first.cancelButtonView; + const integrationData = insertImageUI._integrations.get( 'foobar' ); - cancelButtonView.fire( 'execute' ); + expect( integrationData.observable ).to.equal( observable ); + expect( integrationData.buttonViewCreator ).to.equal( buttonViewCreator ); + expect( integrationData.formViewCreator ).to.equal( formViewCreator ); + expect( integrationData.requiresForm ).to.be.true; + } ); - expect( dropdown.isOpen ).to.be.false; - sinon.assert.notCalled( commandSpy ); - sinon.assert.calledOnce( cancelSpy ); + it( 'should warn if multiple integrations with the same name are registered', () => { + const observable = new Model( { isEnabled: true } ); + const buttonViewCreator = () => {}; + const formViewCreator = () => {}; + const warnStub = sinon.stub( console, 'warn' ); + + insertImageUI.registerIntegration( { + name: 'foobar', + observable, + buttonViewCreator, + formViewCreator, + requiresForm: true } ); - it( 'should focus on "insert image via URL" input after opening', () => { - let spy; - - // The ImageInsertPanelView is added on first open. - // See https://github.com/ckeditor/ckeditor5/pull/8019#discussion_r484069652 - dropdown.on( 'change:isOpen', () => { - const imageInsertPanelView = dropdown.panelView.children.first; - spy = sinon.spy( imageInsertPanelView, 'focus' ); - } ); + expect( warnStub.notCalled ).to.be.true; - dropdown.buttonView.fire( 'open' ); - sinon.assert.calledOnce( spy ); + insertImageUI.registerIntegration( { + name: 'foobar', + observable, + buttonViewCreator, + formViewCreator, + requiresForm: true } ); - } ); - - it( 'should inject integrations to the dropdown panel view from the config', async () => { - const editor = await ClassicEditor - .create( editorElement, { - plugins: [ - Link, - Image, - CKFinderUploadAdapter, - CKFinder, - Paragraph, - ImageInsert, - ImageInsertUI, - FileRepository, - UploadAdapterPluginMock, - Clipboard - ], - image: { - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ] - } - } - } ); - - const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - - dropdown.isOpen = true; - expect( dropdown.panelView.children.first._integrations.length ).to.equal( 2 ); - expect( dropdown.panelView.children.first._integrations.first ).to.be.instanceOf( LabeledFieldView ); - expect( dropdown.panelView.children.first._integrations.last ).to.be.instanceOf( ButtonView ); - - editor.destroy(); + expect( warnStub.calledOnce ).to.be.true; + expect( warnStub.firstCall.args[ 0 ] ).to.equal( 'image-insert-integration-exists' ); } ); } ); - describe( 'dropdown (without uploadImage command)', () => { - beforeEach( async () => { - editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); + describe( 'integrations', () => { + let observableUpload, observableUrl; - editor = await ClassicEditor.create( editorElement, { - plugins: [ Paragraph, Image, ImageInsertViaUrl ], - toolbar: [ 'insertImage' ] - } ); - - dropdown = editor.ui.view.toolbar.children.first.children.first; + beforeEach( async () => { + await createEditor( { plugins: [ Image, Essentials, Paragraph ] } ); } ); - afterEach( async () => { - editorElement.remove(); + it( 'should warn if empty list of integrations is configured', () => { + editor.config.set( 'image.insert.integrations', [] ); - await editor.destroy(); - } ); - - it( 'should register the "insertImage" dropdown', () => { + const warnStub = sinon.stub( console, 'warn' ); const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - expect( dropdown ).to.be.instanceOf( DropdownView ); + expect( dropdown ).to.be.null; + expect( warnStub.calledOnce ).to.be.true; + expect( warnStub.firstCall.args[ 0 ] ).to.equal( 'image-insert-integrations-not-specified' ); } ); - it( 'should make the "insertImage" dropdown accessible via the property of the plugin', () => { - expect( editor.plugins.get( 'ImageInsertUI' ).dropdownView ).to.be.instanceOf( DropdownView ); - } ); + it( 'should warn if unknown integration is requested by config', () => { + editor.config.set( 'image.insert.integrations', [ 'foo' ] ); - it( 'should register "imageInsert" dropdown as an alias for the "insertImage" dropdown', () => { - const dropdownCreator = editor.ui.componentFactory._components.get( 'insertImage'.toLowerCase() ); - const dropdownAliasCreator = editor.ui.componentFactory._components.get( 'imageInsert'.toLowerCase() ); + const warnStub = sinon.stub( console, 'warn' ); + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - expect( dropdownCreator.callback ).to.equal( dropdownAliasCreator.callback ); + expect( dropdown ).to.be.null; + expect( warnStub.calledTwice ).to.be.true; + expect( warnStub.firstCall.args[ 0 ] ).to.equal( 'image-insert-unknown-integration' ); + expect( warnStub.firstCall.args[ 1 ].item ).to.equal( 'foo' ); + expect( warnStub.secondCall.args[ 0 ] ).to.equal( 'image-insert-integrations-not-registered' ); } ); - it( 'should bind the enabled state of the dropdown to the InsertImageCommand command', () => { - const command = editor.commands.get( 'insertImage' ); + it( 'should not warn if known but not registered integration is requested by config', () => { + editor.config.set( 'image.insert.integrations', [ 'url', 'assetManager', 'upload' ] ); - expect( command.isEnabled, 'command state' ).to.be.true; - expect( dropdown.isEnabled, 'dropdown state #1' ).to.be.true; - - command.forceDisabled( 'foo' ); + const warnStub = sinon.stub( console, 'warn' ); + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - expect( dropdown.isEnabled, 'dropdown state #2' ).to.be.false; + expect( dropdown ).to.be.null; + expect( warnStub.calledOnce ).to.be.true; + expect( warnStub.firstCall.args[ 0 ] ).to.equal( 'image-insert-integrations-not-registered' ); } ); - it( 'should not insert panel view children until dropdown is not open for the first time', () => { - expect( dropdown.panelView.children.length ).to.equal( 0 ); - - dropdown.buttonView.fire( 'open' ); + describe( 'single integration without form view required', () => { + beforeEach( async () => { + registerUploadIntegration(); + } ); - expect( dropdown.panelView.children.length ).to.equal( 1 ); - expect( dropdown.panelView.children.first ).to.be.instanceOf( ImageInsertPanelView ); - } ); + it( 'should create a toolbar button', () => { + const button = editor.ui.componentFactory.create( 'insertImage' ); - describe( 'dropdown button', () => { - it( 'should be an instance of DropdownButtonView', () => { - expect( dropdown.buttonView ).to.be.instanceOf( DropdownButtonView ); + expect( button ).to.be.instanceOf( ButtonView ); + expect( button.label ).to.equal( 'button upload single' ); } ); } ); - describe( 'dropdown panel buttons', () => { - it( 'should have "Update" label on submit button when URL input is already filled', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '
' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); - } ); - - const img = viewDocument.selection.getSelectedElement(); - - const data = fakeEventData(); - const eventInfo = new EventInfo( img, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); - - viewDocument.fire( 'click', domEventDataMock ); - - dropdown.buttonView.fire( 'open' ); - - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - - expect( inputValue ).to.equal( '/assets/sample.png' ); - expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Update' ); + describe( 'single integration with form view required', () => { + beforeEach( async () => { + registerUrlIntegration(); } ); - it( 'should have "Insert" label on submit button on uploading a new image', () => { - const viewDocument = editor.editing.view.document; + it( 'should create a toolbar dropdown', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - editor.setData( '

test

' ); + expect( dropdown ).to.be.instanceOf( DropdownView ); + expect( dropdown.buttonView.label ).to.equal( 'button url single' ); + expect( dropdown.isEnabled ).to.be.true; + } ); - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' ); - } ); + it( 'should bind isEnabled state to observable', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - const el = viewDocument.selection.getSelectedElement(); + observableUrl.isEnabled = false; + expect( dropdown.isEnabled ).to.be.false; - const data = fakeEventData(); - const eventInfo = new EventInfo( el, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); + observableUrl.isEnabled = true; + expect( dropdown.isEnabled ).to.be.true; + } ); - viewDocument.fire( 'click', domEventDataMock ); + it( 'should create panel view on dropdown first open', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - dropdown.buttonView.fire( 'open' ); + expect( dropdown.panelView.children.length ).to.equal( 0 ); - const inputValue = dropdown.panelView.children.first.imageURLInputValue; + dropdown.isOpen = true; + expect( dropdown.panelView.children.length ).to.equal( 1 ); - expect( dropdown.isOpen ).to.be.true; - expect( inputValue ).to.equal( '' ); - expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Insert' ); + const formView = dropdown.panelView.children.get( 0 ); + expect( formView ).to.be.instanceOf( ImageInsertFormView ); + expect( formView.children.get( 0 ) ).to.be.instanceOf( ButtonView ); + expect( formView.children.get( 0 ).label ).to.equal( 'dropdown url single' ); } ); } ); - describe( 'dropdown panel integrations', () => { - describe( 'insert image via URL form', () => { - it( 'should have "Insert image via URL" label on inserting new image', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '

test

' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' ); - } ); - - const el = viewDocument.selection.getSelectedElement(); - - const data = fakeEventData(); - const eventInfo = new EventInfo( el, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); - - viewDocument.fire( 'click', domEventDataMock ); - - dropdown.buttonView.fire( 'open' ); - - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - - const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' ); - - expect( dropdown.isOpen ).to.be.true; - expect( inputValue ).to.equal( '' ); - expect( insertImageViaUrlForm.label ).to.equal( 'Insert image via URL' ); - } ); - - it( 'should have "Update image URL" label on updating the image source URL', () => { - const viewDocument = editor.editing.view.document; - - editor.setData( '
' ); - - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); - } ); + describe( 'multiple integrations', () => { + beforeEach( async () => { + registerUploadIntegration(); + registerUrlIntegration(); + } ); - const el = viewDocument.selection.getSelectedElement(); + it( 'should create a toolbar split button dropdown', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - const data = fakeEventData(); - const eventInfo = new EventInfo( el, 'click' ); - const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); + expect( dropdown ).to.be.instanceOf( DropdownView ); + expect( dropdown.buttonView ).to.be.instanceOf( SplitButtonView ); + expect( dropdown.buttonView.label ).to.equal( 'Insert image' ); + expect( dropdown.buttonView.tooltip ).to.be.true; + expect( dropdown.buttonView.actionView.label ).to.equal( 'button upload multiple' ); + expect( dropdown.isEnabled ).to.be.true; + } ); - viewDocument.fire( 'click', domEventDataMock ); + it( 'should bind split button label to #isImageSelected', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - dropdown.buttonView.fire( 'open' ); + expect( insertImageUI.isImageSelected ).to.be.false; + expect( dropdown.buttonView.label ).to.equal( 'Insert image' ); - const inputValue = dropdown.panelView.children.first.imageURLInputValue; - const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' ); + insertImageUI.isImageSelected = true; + expect( dropdown.buttonView.label ).to.equal( 'Replace image' ); - expect( dropdown.isOpen ).to.be.true; - expect( inputValue ).to.equal( '/assets/sample.png' ); - expect( insertImageViaUrlForm.label ).to.equal( 'Update image URL' ); - } ); + insertImageUI.isImageSelected = false; + expect( dropdown.buttonView.label ).to.equal( 'Insert image' ); } ); - } ); - it( 'should remove all attributes from model except "src" when updating the image source URL', () => { - const viewDocument = editor.editing.view.document; - const commandSpy = sinon.spy( editor.commands.get( 'insertImage' ), 'execute' ); - const submitSpy = sinon.spy(); + it( 'should bind isEnabled state to observables', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - dropdown.buttonView.fire( 'open' ); + observableUrl.isEnabled = false; + observableUpload.isEnabled = false; + expect( dropdown.isEnabled ).to.be.false; - const insertButtonView = dropdown.panelView.children.first.insertButtonView; + observableUrl.isEnabled = true; + observableUpload.isEnabled = false; + expect( dropdown.isEnabled ).to.be.true; - editor.setData( '
' ); + observableUrl.isEnabled = false; + observableUpload.isEnabled = true; + expect( dropdown.isEnabled ).to.be.true; - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); + observableUrl.isEnabled = true; + observableUpload.isEnabled = true; + expect( dropdown.isEnabled ).to.be.true; } ); - const selectedElement = editor.model.document.selection.getSelectedElement(); - - expect( selectedElement.getAttribute( 'src' ) ).to.equal( 'image-url-800w.jpg' ); - expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.true; + it( 'should create panel view on dropdown first open', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); - dropdown.panelView.children.first.imageURLInputValue = '/assets/sample3.png'; + expect( dropdown.panelView.children.length ).to.equal( 0 ); - dropdown.on( 'submit', submitSpy ); + dropdown.isOpen = true; + expect( dropdown.panelView.children.length ).to.equal( 1 ); - insertButtonView.fire( 'execute' ); + const formView = dropdown.panelView.children.get( 0 ); + expect( formView ).to.be.instanceOf( ImageInsertFormView ); - sinon.assert.notCalled( commandSpy ); - sinon.assert.calledOnce( submitSpy ); - expect( dropdown.isOpen ).to.be.false; - expect( selectedElement.getAttribute( 'src' ) ).to.equal( '/assets/sample3.png' ); - expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.false; - expect( selectedElement.hasAttribute( 'sizes' ) ).to.be.false; + expect( formView.children.length ).to.equal( 2 ); + expect( formView.children.get( 0 ) ).to.be.instanceOf( ButtonView ); + expect( formView.children.get( 0 ).label ).to.equal( 'dropdown upload multiple' ); + expect( formView.children.get( 1 ) ).to.be.instanceOf( ButtonView ); + expect( formView.children.get( 1 ).label ).to.equal( 'dropdown url multiple' ); + } ); } ); - describe( 'events', () => { - it( 'should emit "submit" event when clicking on submit button', () => { - const commandSpy = sinon.spy( editor.commands.get( 'insertImage' ), 'execute' ); - const submitSpy = sinon.spy(); + function registerUrlIntegration() { + observableUrl = new Model( { isEnabled: true } ); - dropdown.buttonView.fire( 'open' ); + insertImageUI.registerIntegration( { + name: 'url', + observable: observableUrl, + requiresForm: true, + buttonViewCreator( isOnlyOne ) { + const button = new ButtonView( editor.locale ); - dropdown.on( 'submit', submitSpy ); + button.label = 'button url ' + ( isOnlyOne ? 'single' : 'multiple' ); - const insertButtonView = dropdown.panelView.children.first.insertButtonView; + return button; + }, + formViewCreator( isOnlyOne ) { + const button = new ButtonView( editor.locale ); - insertButtonView.fire( 'execute' ); + button.label = 'dropdown url ' + ( isOnlyOne ? 'single' : 'multiple' ); - expect( dropdown.isOpen ).to.be.false; - sinon.assert.calledOnce( commandSpy ); - sinon.assert.calledOnce( submitSpy ); + return button; + } } ); + } - it( 'should emit "cancel" event when clicking on cancel button', () => { - const commandSpy = sinon.spy( editor.commands.get( 'insertImage' ), 'execute' ); - const cancelSpy = sinon.spy(); + function registerUploadIntegration() { + observableUpload = new Model( { isEnabled: true } ); - dropdown.buttonView.fire( 'open' ); + insertImageUI.registerIntegration( { + name: 'upload', + observable: observableUpload, + buttonViewCreator( isOnlyOne ) { + const button = new ButtonView( editor.locale ); - dropdown.on( 'cancel', cancelSpy ); + button.label = 'button upload ' + ( isOnlyOne ? 'single' : 'multiple' ); - const cancelButtonView = dropdown.panelView.children.first.cancelButtonView; + return button; + }, + formViewCreator( isOnlyOne ) { + const button = new ButtonView( editor.locale ); - cancelButtonView.fire( 'execute' ); + button.label = 'dropdown upload ' + ( isOnlyOne ? 'single' : 'multiple' ); - expect( dropdown.isOpen ).to.be.false; - sinon.assert.notCalled( commandSpy ); - sinon.assert.calledOnce( cancelSpy ); + return button; + } } ); + } + } ); - it( 'should focus on "insert image via URL" input after opening', () => { - let spy; + async function createEditor( config ) { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); - // The ImageInsertPanelView is added on first open. - // See https://github.com/ckeditor/ckeditor5/pull/8019#discussion_r484069652 - dropdown.on( 'change:isOpen', () => { - const imageInsertPanelView = dropdown.panelView.children.first; - spy = sinon.spy( imageInsertPanelView, 'focus' ); - } ); + editor = await ClassicTestEditor.create( editorElement, config ); - dropdown.buttonView.fire( 'open' ); - sinon.assert.calledOnce( spy ); - } ); - } ); - } ); + insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + } } ); - -function fakeEventData() { - return { - preventDefault: sinon.spy() - }; -} diff --git a/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js b/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js new file mode 100644 index 00000000000..24980785553 --- /dev/null +++ b/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js @@ -0,0 +1,417 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview'; + +import { CollapsibleView, DropdownButtonView } from '@ckeditor/ckeditor5-ui'; +import { icons } from '@ckeditor/ckeditor5-core'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import Image from '../../src/image'; +import ImageInsertFormView from '../../src/imageinsert/ui/imageinsertformview'; +import ImageInsertViaUrlUI from '../../src/imageinsert/imageinsertviaurlui'; +import { ImageInsertViaUrl } from '../../src'; +import ImageInsertUrlView from '../../src/imageinsert/ui/imageinserturlview'; + +describe( 'ImageInsertViaUrlUI', () => { + let editor, editorElement, insertImageUI; + + testUtils.createSinonSandbox(); + + afterEach( async () => { + if ( editorElement ) { + editorElement.remove(); + } + + if ( editor ) { + await editor.destroy(); + } + } ); + + it( 'should have pluginName', () => { + expect( ImageInsertViaUrlUI.pluginName ).to.equal( 'ImageInsertViaUrlUI' ); + } ); + + describe( 'single integration', () => { + beforeEach( async () => { + await createEditor( { + plugins: [ Image, ImageInsertViaUrl ] + } ); + } ); + + it( 'should create toolbar dropdown button', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + expect( dropdown.buttonView ).to.be.instanceOf( DropdownButtonView ); + expect( dropdown.buttonView.icon ).to.equal( icons.imageUrl ); + expect( dropdown.buttonView.tooltip ).to.be.true; + expect( dropdown.buttonView.label ).to.equal( 'Insert image via URL' ); + } ); + + it( 'should bind button label to ImageInsertUI#isImageSelected', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + expect( dropdown.buttonView.label ).to.equal( 'Insert image via URL' ); + + insertImageUI.isImageSelected = true; + expect( dropdown.buttonView.label ).to.equal( 'Update image URL' ); + + insertImageUI.isImageSelected = false; + expect( dropdown.buttonView.label ).to.equal( 'Insert image via URL' ); + } ); + + it( 'should create form view on first open of dropdown', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + expect( dropdown.panelView.children.length ).to.equal( 0 ); + + dropdown.isOpen = true; + expect( dropdown.panelView.children.length ).to.equal( 1 ); + + const formView = dropdown.panelView.children.get( 0 ); + expect( formView ).to.be.instanceOf( ImageInsertFormView ); + expect( formView.children.length ).to.equal( 1 ); + expect( formView.children.get( 0 ) ).to.be.instanceOf( ImageInsertUrlView ); + } ); + + describe( 'form bindings', () => { + let dropdown, formView, urlView; + + beforeEach( () => { + dropdown = editor.ui.componentFactory.create( 'insertImage' ); + dropdown.isOpen = true; + formView = dropdown.panelView.children.get( 0 ); + urlView = formView.children.get( 0 ); + } ); + + it( 'should bind #isImageSelected', () => { + expect( urlView.isImageSelected ).to.be.false; + + insertImageUI.isImageSelected = true; + expect( urlView.isImageSelected ).to.be.true; + + insertImageUI.isImageSelected = false; + expect( urlView.isImageSelected ).to.be.false; + } ); + + it( 'should bind #isEnabled', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + const insertImageCommand = editor.commands.get( 'insertImage' ); + + replaceImageSourceCommand.isEnabled = false; + insertImageCommand.isEnabled = false; + expect( urlView.isEnabled ).to.be.false; + + replaceImageSourceCommand.isEnabled = true; + insertImageCommand.isEnabled = false; + expect( urlView.isEnabled ).to.be.true; + + replaceImageSourceCommand.isEnabled = false; + insertImageCommand.isEnabled = true; + expect( urlView.isEnabled ).to.be.true; + + replaceImageSourceCommand.isEnabled = true; + insertImageCommand.isEnabled = true; + expect( urlView.isEnabled ).to.be.true; + } ); + + it( 'should set #imageURLInputValue at first open', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + + replaceImageSourceCommand.value = 'foobar'; + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const urlView = formView.children.get( 0 ); + + expect( urlView.imageURLInputValue ).to.equal( 'foobar' ); + } ); + + it( 'should reset #imageURLInputValue on dropdown reopen', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + + replaceImageSourceCommand.value = 'abc'; + dropdown.isOpen = false; + dropdown.isOpen = true; + expect( urlView.imageURLInputValue ).to.equal( 'abc' ); + + replaceImageSourceCommand.value = '123'; + dropdown.isOpen = false; + dropdown.isOpen = true; + expect( urlView.imageURLInputValue ).to.equal( '123' ); + + replaceImageSourceCommand.value = undefined; + dropdown.isOpen = false; + dropdown.isOpen = true; + expect( urlView.imageURLInputValue ).to.equal( '' ); + } ); + + it( 'should execute replaceImageSource command and close dropdown', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + const stubExecute = sinon.stub( editor, 'execute' ); + const stubFocus = sinon.stub( editor.editing.view, 'focus' ); + + replaceImageSourceCommand.isEnabled = true; + urlView.imageURLInputValue = 'foo'; + + urlView.fire( 'submit' ); + + expect( stubExecute.calledOnce ).to.be.true; + expect( stubExecute.firstCall.args[ 0 ] ).to.equal( 'replaceImageSource' ); + expect( stubExecute.firstCall.args[ 1 ] ).to.deep.equal( { source: 'foo' } ); + expect( stubFocus.calledOnce ).to.be.true; + expect( dropdown.isOpen ).to.be.false; + } ); + + it( 'should execute insertImage command', () => { + const replaceImageSourceCommand = editor.commands.get( 'insertImage' ); + const stubExecute = sinon.stub( editor, 'execute' ); + const stubFocus = sinon.stub( editor.editing.view, 'focus' ); + + replaceImageSourceCommand.isEnabled = true; + urlView.imageURLInputValue = 'foo'; + + urlView.fire( 'submit' ); + + expect( stubExecute.calledOnce ).to.be.true; + expect( stubExecute.firstCall.args[ 0 ] ).to.equal( 'insertImage' ); + expect( stubExecute.firstCall.args[ 1 ] ).to.deep.equal( { source: 'foo' } ); + expect( stubFocus.calledOnce ).to.be.true; + expect( dropdown.isOpen ).to.be.false; + } ); + + it( 'should close dropdown', () => { + const stubExecute = sinon.stub( editor, 'execute' ); + const stubFocus = sinon.stub( editor.editing.view, 'focus' ); + + urlView.fire( 'cancel' ); + + expect( stubExecute.notCalled ).to.be.true; + expect( stubFocus.calledOnce ).to.be.true; + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + } ); + + describe( 'multiple integrations', () => { + beforeEach( async () => { + await createEditor( { + plugins: [ Image, ImageInsertViaUrl ] + } ); + + const observable = new Model( { isEnabled: true } ); + + insertImageUI.registerIntegration( { + name: 'foo', + observable, + buttonViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'foo'; + + return button; + }, + formViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'bar'; + + return button; + } + } ); + + editor.config.set( 'image.insert.integrations', [ 'url', 'foo' ] ); + } ); + + it( 'should create toolbar dropdown button', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + expect( dropdown.buttonView ).to.be.instanceOf( SplitButtonView ); + expect( dropdown.buttonView.tooltip ).to.be.true; + expect( dropdown.buttonView.label ).to.equal( 'Insert image' ); + expect( dropdown.buttonView.actionView.icon ).to.equal( icons.imageUrl ); + expect( dropdown.buttonView.actionView.tooltip ).to.be.true; + expect( dropdown.buttonView.actionView.label ).to.equal( 'Insert image via URL' ); + } ); + + it( 'should bind button label to ImageInsertUI#isImageSelected', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + expect( dropdown.buttonView.label ).to.equal( 'Insert image' ); + expect( dropdown.buttonView.actionView.label ).to.equal( 'Insert image via URL' ); + + insertImageUI.isImageSelected = true; + expect( dropdown.buttonView.label ).to.equal( 'Replace image' ); + expect( dropdown.buttonView.actionView.label ).to.equal( 'Update image URL' ); + + insertImageUI.isImageSelected = false; + expect( dropdown.buttonView.label ).to.equal( 'Insert image' ); + expect( dropdown.buttonView.actionView.label ).to.equal( 'Insert image via URL' ); + } ); + + it( 'should create form view on first open of dropdown', () => { + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + expect( dropdown.panelView.children.length ).to.equal( 0 ); + + dropdown.isOpen = true; + expect( dropdown.panelView.children.length ).to.equal( 1 ); + + const formView = dropdown.panelView.children.get( 0 ); + expect( formView ).to.be.instanceOf( ImageInsertFormView ); + expect( formView.children.length ).to.equal( 2 ); + + const collapsibleView = formView.children.get( 0 ); + expect( collapsibleView ).to.be.instanceOf( CollapsibleView ); + expect( collapsibleView.children.get( 0 ) ).to.be.instanceOf( ImageInsertUrlView ); + } ); + + describe( 'form bindings', () => { + let dropdown, formView, collapsibleView, urlView; + + beforeEach( () => { + dropdown = editor.ui.componentFactory.create( 'insertImage' ); + dropdown.isOpen = true; + formView = dropdown.panelView.children.get( 0 ); + collapsibleView = formView.children.get( 0 ); + urlView = collapsibleView.children.get( 0 ); + } ); + + it( 'should bind #isImageSelected', () => { + expect( urlView.isImageSelected ).to.be.false; + + insertImageUI.isImageSelected = true; + expect( urlView.isImageSelected ).to.be.true; + expect( collapsibleView.label ).to.equal( 'Update image URL' ); + + insertImageUI.isImageSelected = false; + expect( urlView.isImageSelected ).to.be.false; + expect( collapsibleView.label ).to.equal( 'Insert image via URL' ); + } ); + + it( 'should bind #isEnabled', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + const insertImageCommand = editor.commands.get( 'insertImage' ); + + replaceImageSourceCommand.isEnabled = false; + insertImageCommand.isEnabled = false; + expect( urlView.isEnabled ).to.be.false; + + replaceImageSourceCommand.isEnabled = true; + insertImageCommand.isEnabled = false; + expect( urlView.isEnabled ).to.be.true; + + replaceImageSourceCommand.isEnabled = false; + insertImageCommand.isEnabled = true; + expect( urlView.isEnabled ).to.be.true; + + replaceImageSourceCommand.isEnabled = true; + insertImageCommand.isEnabled = true; + expect( urlView.isEnabled ).to.be.true; + } ); + + it( 'should set #imageURLInputValue and CollapsibleView#isCollapsed at first open', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + + replaceImageSourceCommand.value = 'foobar'; + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const collapsibleView = formView.children.get( 0 ); + const urlView = collapsibleView.children.get( 0 ); + + expect( urlView.imageURLInputValue ).to.equal( 'foobar' ); + expect( collapsibleView.isCollapsed ).to.be.true; + } ); + + it( 'should reset #imageURLInputValue and CollapsibleView#isCollapsed on dropdown reopen', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + + replaceImageSourceCommand.value = 'abc'; + dropdown.isOpen = false; + dropdown.isOpen = true; + expect( urlView.imageURLInputValue ).to.equal( 'abc' ); + expect( collapsibleView.isCollapsed ).to.be.true; + + replaceImageSourceCommand.value = '123'; + dropdown.isOpen = false; + dropdown.isOpen = true; + expect( urlView.imageURLInputValue ).to.equal( '123' ); + expect( collapsibleView.isCollapsed ).to.be.true; + + replaceImageSourceCommand.value = undefined; + dropdown.isOpen = false; + dropdown.isOpen = true; + expect( urlView.imageURLInputValue ).to.equal( '' ); + expect( collapsibleView.isCollapsed ).to.be.true; + } ); + + it( 'should execute replaceImageSource command and close dropdown', () => { + const replaceImageSourceCommand = editor.commands.get( 'replaceImageSource' ); + const stubExecute = sinon.stub( editor, 'execute' ); + const stubFocus = sinon.stub( editor.editing.view, 'focus' ); + + replaceImageSourceCommand.isEnabled = true; + urlView.imageURLInputValue = 'foo'; + + urlView.fire( 'submit' ); + + expect( stubExecute.calledOnce ).to.be.true; + expect( stubExecute.firstCall.args[ 0 ] ).to.equal( 'replaceImageSource' ); + expect( stubExecute.firstCall.args[ 1 ] ).to.deep.equal( { source: 'foo' } ); + expect( stubFocus.calledOnce ).to.be.true; + expect( dropdown.isOpen ).to.be.false; + } ); + + it( 'should execute insertImage command', () => { + const replaceImageSourceCommand = editor.commands.get( 'insertImage' ); + const stubExecute = sinon.stub( editor, 'execute' ); + const stubFocus = sinon.stub( editor.editing.view, 'focus' ); + + replaceImageSourceCommand.isEnabled = true; + urlView.imageURLInputValue = 'foo'; + + urlView.fire( 'submit' ); + + expect( stubExecute.calledOnce ).to.be.true; + expect( stubExecute.firstCall.args[ 0 ] ).to.equal( 'insertImage' ); + expect( stubExecute.firstCall.args[ 1 ] ).to.deep.equal( { source: 'foo' } ); + expect( stubFocus.calledOnce ).to.be.true; + expect( dropdown.isOpen ).to.be.false; + } ); + + it( 'should close dropdown', () => { + const stubExecute = sinon.stub( editor, 'execute' ); + const stubFocus = sinon.stub( editor.editing.view, 'focus' ); + + urlView.fire( 'cancel' ); + + expect( stubExecute.notCalled ).to.be.true; + expect( stubFocus.calledOnce ).to.be.true; + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + } ); + + async function createEditor( config ) { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, config ); + + insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + } +} ); diff --git a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js b/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js deleted file mode 100644 index 2d52d391bda..00000000000 --- a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -import View from '@ckeditor/ckeditor5-ui/src/view'; -import ImageUploadFormRowView from '../../../src/imageinsert/ui/imageinsertformrowview'; -import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; - -describe( 'ImageUploadFormRowView', () => { - let view, locale; - - beforeEach( () => { - locale = { t: val => val }; - view = new ImageUploadFormRowView( locale ); - view.render(); - } ); - - afterEach( () => { - view.element.remove(); - } ); - - describe( 'constructor()', () => { - it( 'should set view#locale', () => { - expect( view.locale ).to.equal( locale ); - } ); - - it( 'should create view#children collection', () => { - expect( view.children ).to.be.instanceOf( ViewCollection ); - expect( view.children ).to.have.length( 0 ); - } ); - - it( 'should set view#class', () => { - expect( view.class ).to.be.null; - } ); - - it( 'should set the template', () => { - expect( view.element.classList.contains( 'ck' ) ).to.be.true; - expect( view.element.classList.contains( 'ck-form__row' ) ).to.be.true; - } ); - - describe( 'options', () => { - it( 'should set view#class when class was passed', () => { - const view = new ImageUploadFormRowView( locale, { - class: 'foo' - } ); - - expect( view.class ).to.equal( 'foo' ); - - view.destroy(); - } ); - - it( 'should fill view#children when children were passed', () => { - const view = new ImageUploadFormRowView( locale, { - children: [ - new View() - ] - } ); - - expect( view.children ).to.have.length( 1 ); - - view.destroy(); - } ); - - it( 'should use a label view when passed', () => { - const labelView = new View(); - labelView.id = '123'; - - const view = new ImageUploadFormRowView( locale, { - labelView - } ); - - view.render(); - - expect( view.element.getAttribute( 'role' ) ).to.equal( 'group' ); - expect( view.element.getAttribute( 'aria-labelledby' ) ).to.equal( '123' ); - - view.destroy(); - } ); - } ); - - describe( 'template bindings', () => { - it( 'should bind #class to the template', () => { - view.class = 'foo'; - expect( view.element.classList.contains( 'foo' ) ).to.be.true; - } ); - - it( 'should bind #children to the template', () => { - const child = new View(); - child.setTemplate( { tag: 'div' } ); - - view.children.add( child ); - - expect( view.element.firstChild ).to.equal( child.element ); - - view.destroy(); - } ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformview.js b/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformview.js new file mode 100644 index 00000000000..641d109bee8 --- /dev/null +++ b/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformview.js @@ -0,0 +1,622 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document, Event */ + +import ImageInsertFormView from '../../../src/imageinsert/ui/imageinsertformview'; +import ImageInsertUrlView from '../../../src/imageinsert/ui/imageinserturlview'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; + +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; +import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; +import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; +import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; +import CollapsibleView from '@ckeditor/ckeditor5-ui/src/collapsible/collapsibleview'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'ImageInsertFormView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new ImageInsertFormView( { t: val => val } ); + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create #_focusCycler instance', () => { + expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should have #children view collection', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + } ); + + describe( 'integrations', () => { + it( 'single integrations', () => { + const inputIntegrationView = new ImageInsertUrlView( { t: val => val } ); + + const view = new ImageInsertFormView( { t: val => val }, [ + inputIntegrationView + ] ); + + expect( view._focusables.map( f => f ) ).to.have.members( [ + inputIntegrationView + ] ); + + expect( view.children.map( f => f ) ).to.have.members( [ + inputIntegrationView + ] ); + } ); + + it( 'multiple integrations', () => { + const buttonIntegrationView = new ButtonView( { t: val => val } ); + const inputIntegrationView = new ImageInsertUrlView( { t: val => val } ); + + const view = new ImageInsertFormView( { t: val => val }, [ + buttonIntegrationView, + inputIntegrationView + ] ); + + expect( view._focusables.map( f => f ) ).to.have.members( [ + buttonIntegrationView, + inputIntegrationView + ] ); + + expect( view.children.map( f => f ) ).to.have.members( [ + buttonIntegrationView, + inputIntegrationView + ] ); + } ); + + it( 'integrations with collapsible view', () => { + const buttonIntegrationView = new ButtonView( { t: val => val } ); + const inputIntegrationView = new ImageInsertUrlView( { t: val => val } ); + const collapsibleIntegrationView = new CollapsibleView( { t: val => val }, [ + inputIntegrationView + ] ); + + const view = new ImageInsertFormView( { t: val => val }, [ + buttonIntegrationView, + collapsibleIntegrationView + ] ); + + expect( view._focusables.map( f => f ) ).to.have.members( [ + buttonIntegrationView, + collapsibleIntegrationView, + inputIntegrationView + ] ); + + expect( view.children.map( f => f ) ).to.have.members( [ + buttonIntegrationView, + collapsibleIntegrationView + ] ); + } ); + } ); + + describe( 'template', () => { + it( 'should create element from the template', () => { + expect( view.element.tagName ).to.equal( 'FORM' ); + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-image-insert-form' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should bind #children', () => { + expect( view.template.children[ 0 ] ).to.equal( view.children ); + } ); + } ); + + describe( 'render()', () => { + it( 'should handle and delegate DOM submit event', () => { + const spy = sinon.spy(); + + view.on( 'submit', spy ); + view.element.dispatchEvent( new Event( 'submit' ) ); + + expect( spy.calledOnce ).to.true; + } ); + + it( 'should register focusables in #focusTracker', () => { + const buttonIntegrationView = new ButtonView( { t: val => val } ); + const inputIntegrationView = new ImageInsertUrlView( { t: val => val } ); + const collapsibleIntegrationView = new CollapsibleView( { t: val => val }, [ + inputIntegrationView + ] ); + + const view = new ImageInsertFormView( { t: () => {} }, [ + buttonIntegrationView, + collapsibleIntegrationView + ] ); + + const spy = sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), buttonIntegrationView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), collapsibleIntegrationView.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), inputIntegrationView.element ); + sinon.assert.calledThrice( spy ); + + view.destroy(); + } ); + + describe( 'activates keyboard navigation', () => { + let view, firstIntegrationView, secondIntegrationView; + + beforeEach( () => { + firstIntegrationView = new ButtonView( { t: val => val } ); + secondIntegrationView = new ButtonView( { t: val => val } ); + + view = new ImageInsertFormView( { t: () => {} }, [ + firstIntegrationView, + secondIntegrationView + ] ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'so "tab" focuses the next focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first integration focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = firstIntegrationView.element; + + const spy = sinon.spy( secondIntegrationView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the cancel button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = secondIntegrationView.element; + + const spy = sinon.spy( firstIntegrationView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => { + const keyEvtData = { + stopPropagation: sinon.spy() + }; + + keyEvtData.keyCode = keyCodes.arrowdown; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowup; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledTwice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowleft; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledThrice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowright; + view.keystrokes.press( keyEvtData ); + sinon.assert.callCount( keyEvtData.stopPropagation, 4 ); + } ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'focus()', () => { + it( 'should focus first focusable', () => { + const spy = sinon.spy( view._focusCycler, 'focusFirst' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'focus cycling', () => { + let view, inputIntegrationView, buttonIntegrationView, otherButtonIntegrationView, collapsibleIntegrationView; + + beforeEach( () => { + inputIntegrationView = new ImageInsertUrlView( { t: val => val } ); + buttonIntegrationView = new ButtonView( { t: val => val } ); + otherButtonIntegrationView = new ButtonView( { t: val => val } ); + } ); + + describe( 'single button integration', () => { + beforeEach( () => { + view = new ImageInsertFormView( { t: val => val }, [ + buttonIntegrationView + ] ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'forward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: false, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + } ); + + it( 'backward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + } ); + } ); + + describe( 'single URL input integration', () => { + beforeEach( () => { + view = new ImageInsertFormView( { t: val => val }, [ + inputIntegrationView + ] ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'forward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: false, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.insertButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.cancelButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + } ); + + it( 'backward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.cancelButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.insertButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + } ); + } ); + + describe( 'multiple button integrations', () => { + beforeEach( () => { + view = new ImageInsertFormView( { t: val => val }, [ + buttonIntegrationView, + otherButtonIntegrationView + ] ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'forward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: false, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( otherButtonIntegrationView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + } ); + + it( 'backward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( otherButtonIntegrationView.element ); + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + } ); + } ); + + describe( 'mixed integrations (button and URL input)', () => { + beforeEach( () => { + view = new ImageInsertFormView( { t: val => val }, [ + buttonIntegrationView, + inputIntegrationView + ] ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'forward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: false, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.insertButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.cancelButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + } ); + + it( 'backward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.cancelButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.insertButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + } ); + } ); + + describe( 'mixed integrations (button and URL input inside a collapsible view)', () => { + beforeEach( () => { + collapsibleIntegrationView = new CollapsibleView( { t: val => val }, [ inputIntegrationView ] ); + + view = new ImageInsertFormView( { t: val => val }, [ + buttonIntegrationView, + collapsibleIntegrationView + ] ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'forward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: false, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( collapsibleIntegrationView.element ); + expect( collapsibleIntegrationView.focusTracker ).to.be.undefined; + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.insertButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.cancelButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + } ); + + it( 'backward cycling', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + stopPropagation: sinon.spy(), + preventDefault: sinon.spy() + }; + + view.focus(); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.cancelButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.insertButtonView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( inputIntegrationView.element ); + expect( inputIntegrationView.focusTracker.focusedElement ).to.equal( inputIntegrationView.urlInputView.element ); + + inputIntegrationView.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( collapsibleIntegrationView.element ); + expect( collapsibleIntegrationView.focusTracker ).to.be.undefined; + + view.keystrokes.press( keyEvtData ); + expect( view.focusTracker.focusedElement ).to.equal( buttonIntegrationView.element ); + expect( buttonIntegrationView.focusTracker ).to.be.undefined; + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js b/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js deleted file mode 100644 index a6cd2122e17..00000000000 --- a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js +++ /dev/null @@ -1,329 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/* globals document */ - -import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; - -import ImageInsertPanelView from '../../../src/imageinsert/ui/imageinsertpanelview'; -import ImageUploadFormRowView from '../../../src/imageinsert/ui/imageinsertformrowview'; -import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import Collection from '@ckeditor/ckeditor5-utils/src/collection'; - -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; -import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; -import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; -import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; -import View from '@ckeditor/ckeditor5-ui/src/view'; - -import { createLabeledInputView } from '../../../src/imageinsert/utils'; - -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; - -describe( 'ImageInsertPanelView', () => { - let view; - - beforeEach( () => { - view = new ImageInsertPanelView( { t: val => val }, { - 'insertImageViaUrl': createLabeledInputView( { t: val => val } ) - } ); - view.render(); - document.body.appendChild( view.element ); - } ); - - afterEach( () => { - view.element.remove(); - view.destroy(); - sinon.restore(); - } ); - - describe( 'constructor()', () => { - it( 'should contain instance of ButtonView as #insertButtonView', () => { - expect( view.insertButtonView ).to.be.instanceOf( ButtonView ); - expect( view.insertButtonView.label ).to.equal( 'Insert' ); - } ); - - it( 'should contain instance of ButtonView as #cancelButtonView', () => { - expect( view.cancelButtonView ).to.be.instanceOf( ButtonView ); - expect( view.cancelButtonView.label ).to.equal( 'Cancel' ); - } ); - - it( 'should contain #imageURLInputValue', () => { - expect( view.imageURLInputValue ).to.equal( '' ); - } ); - - it( 'should contain #_integrations as an instance of Collection', () => { - expect( view._integrations ).to.be.instanceOf( Collection ); - } ); - - describe( 'integrations', () => { - it( 'should contain 2 integrations when they were passed to the ImageInsertPanelView as integrations object', () => { - const view = new ImageInsertPanelView( { t: val => val }, { - 'integration1': new View(), - 'integration2': new ButtonView() - } ); - - expect( view._integrations ).to.be.instanceOf( Collection ); - expect( view._integrations.length ).to.equal( 2 ); - } ); - - it( 'should contain insertImageViaUrl view when it is passed via integrations object', () => { - const view = new ImageInsertPanelView( { t: val => val }, { - 'insertImageViaUrl': createLabeledInputView( { t: val => val } ), - 'integration1': new View(), - 'integration2': new ButtonView() - } ); - - expect( view._integrations ).to.be.instanceOf( Collection ); - expect( view._integrations.length ).to.equal( 3 ); - expect( view._integrations.first ).to.be.instanceOf( LabeledFieldView ); - } ); - - it( 'should contain no integrations when they were not provided', () => { - const view = new ImageInsertPanelView( { t: val => val } ); - - expect( view._integrations ).to.be.instanceOf( Collection ); - expect( view._integrations.length ).to.equal( 0 ); - } ); - } ); - - it( 'should create #focusTracker instance', () => { - expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); - } ); - - it( 'should create #keystrokes instance', () => { - expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); - } ); - - it( 'should create #_focusCycler instance', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); - } ); - - it( 'should create #_focusables view collection', () => { - expect( view._focusables ).to.be.instanceOf( ViewCollection ); - } ); - - describe( 'events', () => { - it( 'should fire "submit" event on insertButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'submit', spy ); - - view.insertButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - - it( 'should fire "cancel" event on cancelButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'cancel', spy ); - - view.cancelButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - } ); - } ); - - describe( 'template', () => { - it( 'should create element from the template', () => { - expect( view.element.classList.contains( 'ck' ) ).to.true; - expect( view.element.classList.contains( 'ck-image-insert-form' ) ).to.true; - expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); - } ); - - it( 'should have form row view with buttons', () => { - expect( view.template.children[ 1 ] ).to.be.instanceOf( ImageUploadFormRowView ); - expect( view.template.children[ 1 ].children.first ).to.equal( view.insertButtonView ); - expect( view.template.children[ 1 ].children.last ).to.equal( view.cancelButtonView ); - } ); - } ); - - describe( 'render()', () => { - it( 'should register child views in #_focusables', () => { - expect( view._focusables.map( f => f ) ).to.have.members( [ - ...view._integrations, - view.insertButtonView, - view.cancelButtonView - ] ); - } ); - - it( 'should register child views\' #element in #focusTracker with no integrations', () => { - const view = new ImageInsertPanelView( { t: () => {} } ); - - const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); - view.render(); - - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.insertButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.cancelButtonView.element ); - - view.destroy(); - } ); - - it( 'should register child views\' #element in #focusTracker with "insertImageViaUrl" integration', () => { - const view = new ImageInsertPanelView( { t: () => {} }, { - 'insertImageViaUrl': createLabeledInputView( { t: val => val } ) - } ); - - const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); - - view.render(); - - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.getIntegration( 'insertImageViaUrl' ).element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.insertButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); - - view.destroy(); - } ); - - it( 'starts listening for #keystrokes coming from #element', () => { - const view = new ImageInsertPanelView( { t: () => {} } ); - - const spy = sinon.spy( view.keystrokes, 'listenTo' ); - - view.render(); - sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, view.element ); - - view.destroy(); - } ); - - it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => { - const keyEvtData = { - stopPropagation: sinon.spy() - }; - - keyEvtData.keyCode = keyCodes.arrowdown; - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - - keyEvtData.keyCode = keyCodes.arrowup; - view.keystrokes.press( keyEvtData ); - sinon.assert.calledTwice( keyEvtData.stopPropagation ); - - keyEvtData.keyCode = keyCodes.arrowleft; - view.keystrokes.press( keyEvtData ); - sinon.assert.calledThrice( keyEvtData.stopPropagation ); - - keyEvtData.keyCode = keyCodes.arrowright; - view.keystrokes.press( keyEvtData ); - sinon.assert.callCount( keyEvtData.stopPropagation, 4 ); - } ); - - describe( 'activates keyboard navigation for the toolbar', () => { - it( 'so "tab" focuses the next focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Mock the url input is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.getIntegration( 'insertImageViaUrl' ).element; - - const spy = sinon.spy( view.insertButtonView, 'focus' ); - - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - } ); - - it( 'so "shift + tab" focuses the previous focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - shiftKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Mock the cancel button is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.cancelButtonView.element; - - const spy = sinon.spy( view.insertButtonView, 'focus' ); - - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - } ); - } ); - } ); - - describe( 'destroy()', () => { - it( 'should destroy the FocusTracker instance', () => { - const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); - - view.destroy(); - - sinon.assert.calledOnce( destroySpy ); - } ); - - it( 'should destroy the KeystrokeHandler instance', () => { - const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); - - view.destroy(); - - sinon.assert.calledOnce( destroySpy ); - } ); - } ); - - describe( 'focus()', () => { - it( 'should focus on the first integration', () => { - const spy = sinon.spy( view.getIntegration( 'insertImageViaUrl' ), 'focus' ); - - view.focus(); - - sinon.assert.calledOnce( spy ); - } ); - } ); - - describe( 'Insert image via URL integration input', () => { - it( 'should be bound with #imageURLInputValue', () => { - const form = view.getIntegration( 'insertImageViaUrl' ); - - form.fieldView.element.value = 'abc'; - form.fieldView.fire( 'input' ); - - expect( view.imageURLInputValue ).to.equal( 'abc' ); - - form.fieldView.element.value = 'xyz'; - form.fieldView.fire( 'input' ); - - expect( view.imageURLInputValue ).to.equal( 'xyz' ); - } ); - - it( 'should trim input value', () => { - const form = view.getIntegration( 'insertImageViaUrl' ); - - form.fieldView.element.value = ' '; - form.fieldView.fire( 'input' ); - - expect( view.imageURLInputValue ).to.equal( '' ); - - form.fieldView.element.value = ' test '; - form.fieldView.fire( 'input' ); - - expect( view.imageURLInputValue ).to.equal( 'test' ); - } ); - - it( 'binds saveButtonView#isEnabled to URL input value', () => { - const form = view.getIntegration( 'insertImageViaUrl' ); - const saveButtonView = view.template.children[ 1 ].children.first; - - expect( saveButtonView.isEnabled ).to.be.false; - - form.fieldView.element.value = 'test'; - form.fieldView.fire( 'input' ); - - expect( view.imageURLInputValue ).to.equal( 'test' ); - expect( !!saveButtonView.isEnabled ).to.be.true; - } ); - } ); -} ); diff --git a/packages/ckeditor5-image/tests/imageinsert/ui/imageinserturlview.js b/packages/ckeditor5-image/tests/imageinsert/ui/imageinserturlview.js new file mode 100644 index 00000000000..a8ee56ed608 --- /dev/null +++ b/packages/ckeditor5-image/tests/imageinsert/ui/imageinserturlview.js @@ -0,0 +1,377 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document */ + +import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; + +import ImageInsertUrlView from '../../../src/imageinsert/ui/imageinserturlview'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; + +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; +import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; +import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; +import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview'; +import { icons } from '@ckeditor/ckeditor5-core'; + +describe( 'ImageInsertUrlView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new ImageInsertUrlView( { t: val => val } ); + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should have #imageURLInputValue', () => { + expect( view.imageURLInputValue ).to.equal( '' ); + } ); + + it( 'should have #isImageSelected', () => { + expect( view.isImageSelected ).to.be.false; + } ); + + it( 'should have #isEnabled', () => { + expect( view.isEnabled ).to.be.true; + } ); + + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create #focusCycler instance', () => { + expect( view.focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + } ); + + describe( 'template', () => { + it( 'should create element from the template', () => { + expect( view.element.tagName ).to.equal( 'DIV' ); + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-image-insert-url' ) ).to.true; + + const childNodes = view.element.childNodes; + + expect( childNodes[ 0 ] ).to.equal( view.urlInputView.element ); + expect( childNodes[ 1 ].tagName ).to.equal( 'DIV' ); + expect( childNodes[ 1 ].classList.contains( 'ck' ) ).to.be.true; + expect( childNodes[ 1 ].classList.contains( 'ck-image-insert-url__action-row' ) ).to.be.true; + + const childNodes2 = childNodes[ 1 ].childNodes; + + expect( childNodes2[ 0 ] ).to.equal( view.insertButtonView.element ); + expect( childNodes2[ 1 ] ).to.equal( view.cancelButtonView.element ); + } ); + + it( 'should use dedicated views', () => { + expect( view.template.children[ 0 ] ).to.equal( view.urlInputView ); + expect( view.template.children[ 1 ].children[ 0 ] ).to.equal( view.insertButtonView ); + expect( view.template.children[ 1 ].children[ 1 ] ).to.equal( view.cancelButtonView ); + } ); + } ); + + describe( 'render()', () => { + it( 'should register child views in #_focusables', () => { + expect( view._focusables.map( f => f ) ).to.have.members( [ + view.urlInputView, + view.insertButtonView, + view.cancelButtonView + ] ); + } ); + + it( 'should register child views\' #element in #focusTracker', () => { + const view = new ImageInsertUrlView( { t: () => {} } ); + + const spy = sinon.spy( view.focusTracker, 'add' ); + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.insertButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + + view.destroy(); + } ); + + describe( 'activates keyboard navigation for the toolbar', () => { + it( 'so "tab" focuses the next focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the url input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.urlInputView.element; + + const spy = sinon.spy( view.insertButtonView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the cancel button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.cancelButtonView.element; + + const spy = sinon.spy( view.insertButtonView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'focus()', () => { + it( 'should focus the url input', () => { + const spy = sinon.spy( view.urlInputView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should focus the last focusable', () => { + const spy = sinon.spy( view.cancelButtonView, 'focus' ); + + view.focus( -1 ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( '#urlInputView', () => { + it( 'should be an instance of the LabeledFieldView', () => { + expect( view.urlInputView ).to.be.instanceOf( LabeledFieldView ); + } ); + + it( 'should accept text', () => { + expect( view.urlInputView.fieldView ).to.be.instanceOf( InputTextView ); + } ); + + it( 'should bind label to #isImageSelected', () => { + view.isImageSelected = false; + + expect( view.urlInputView.label ).to.equal( 'Insert image via URL' ); + + view.isImageSelected = true; + + expect( view.urlInputView.label ).to.equal( 'Update image URL' ); + } ); + + it( 'should bind isEnabled to #isEnabled', () => { + view.isEnabled = false; + + expect( view.urlInputView.isEnabled ).to.be.false; + + view.isEnabled = true; + + expect( view.urlInputView.isEnabled ).to.be.true; + } ); + + it( 'should set placeholder', () => { + expect( view.urlInputView.placeholder ).to.equal( 'https://example.com/image.png' ); + expect( view.urlInputView.fieldView.placeholder ).to.equal( 'https://example.com/image.png' ); + } ); + + it( 'should bind value to #imageURLInputValue', () => { + view.imageURLInputValue = 'abc'; + + expect( view.urlInputView.fieldView.value ).to.equal( 'abc' ); + + view.imageURLInputValue = null; + + expect( view.urlInputView.fieldView.value ).to.equal( '' ); + } ); + + it( 'should be bound with #imageURLInputValue', () => { + view.urlInputView.fieldView.element.value = 'abc'; + view.urlInputView.fieldView.fire( 'input' ); + + expect( view.imageURLInputValue ).to.equal( 'abc' ); + + view.urlInputView.fieldView.element.value = 'xyz'; + view.urlInputView.fieldView.fire( 'input' ); + + expect( view.imageURLInputValue ).to.equal( 'xyz' ); + } ); + + it( 'should trim input value', () => { + view.urlInputView.fieldView.element.value = ' '; + view.urlInputView.fieldView.fire( 'input' ); + + expect( view.imageURLInputValue ).to.equal( '' ); + + view.urlInputView.fieldView.element.value = ' test '; + view.urlInputView.fieldView.fire( 'input' ); + + expect( view.imageURLInputValue ).to.equal( 'test' ); + } ); + } ); + + describe( '#insertButtonView', () => { + it( 'should be an instance of the ButtonView', () => { + expect( view.insertButtonView ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should have an icon', () => { + expect( view.insertButtonView.icon ).to.equal( icons.check ); + } ); + + it( 'should have a class', () => { + expect( view.insertButtonView.class ).to.equal( 'ck-button-save' ); + } ); + + it( 'should be a submit button', () => { + expect( view.insertButtonView.type ).to.equal( 'submit' ); + } ); + + it( 'should have text', () => { + expect( view.insertButtonView.withText ).to.be.true; + } ); + + it( 'should bind label to #isImageSelected', () => { + view.isImageSelected = false; + + expect( view.insertButtonView.label ).to.equal( 'Insert' ); + + view.isImageSelected = true; + + expect( view.insertButtonView.label ).to.equal( 'Update' ); + } ); + + it( 'should bind isEnabled to #isEnabled and #imageURLInputValue', () => { + view.isEnabled = false; + view.imageURLInputValue = ''; + + expect( view.insertButtonView.isEnabled ).to.be.false; + + view.isEnabled = true; + view.imageURLInputValue = 'abc'; + + expect( view.insertButtonView.isEnabled ).to.be.true; + + view.isEnabled = false; + view.imageURLInputValue = 'abc'; + + expect( view.insertButtonView.isEnabled ).to.be.false; + + view.isEnabled = true; + view.imageURLInputValue = ''; + + expect( view.insertButtonView.isEnabled ).to.be.false; + } ); + + it( 'should fire "submit" event on insertButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'submit', spy ); + + view.insertButtonView.fire( 'execute' ); + + expect( spy.calledOnce ).to.true; + } ); + } ); + + describe( '#cancelButtonView', () => { + it( 'should be an instance of the ButtonView', () => { + expect( view.cancelButtonView ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should have an icon', () => { + expect( view.cancelButtonView.icon ).to.equal( icons.cancel ); + } ); + + it( 'should have a class', () => { + expect( view.cancelButtonView.class ).to.equal( 'ck-button-cancel' ); + } ); + + it( 'should be a plain button', () => { + expect( view.cancelButtonView.type ).to.equal( 'button' ); + } ); + + it( 'should have text', () => { + expect( view.cancelButtonView.withText ).to.be.true; + } ); + + it( 'should have label', () => { + expect( view.cancelButtonView.label ).to.equal( 'Cancel' ); + } ); + + it( 'should bind isEnabled to #isEnabled', () => { + view.isEnabled = false; + + expect( view.cancelButtonView.isEnabled ).to.be.false; + + view.isEnabled = true; + + expect( view.cancelButtonView.isEnabled ).to.be.true; + + view.isEnabled = false; + + expect( view.cancelButtonView.isEnabled ).to.be.false; + } ); + + it( 'should fire "cancel" event on cancelButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'cancel', spy ); + + view.cancelButtonView.fire( 'execute' ); + + expect( spy.calledOnce ).to.true; + } ); + } ); +} ); diff --git a/packages/ckeditor5-image/tests/imageinsert/utils.js b/packages/ckeditor5-image/tests/imageinsert/utils.js deleted file mode 100644 index 4bd56c46995..00000000000 --- a/packages/ckeditor5-image/tests/imageinsert/utils.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/* globals document */ - -import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; - -import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import Image from '../../src/image'; -import ImageUploadUI from '../../src/imageinsert/imageinsertui'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import Link from '@ckeditor/ckeditor5-link/src/link'; -import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder'; -import { prepareIntegrations, createLabeledInputView } from '../../src/imageinsert/utils'; -import CKFinderUploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter'; - -describe( 'Upload utils', () => { - describe( 'prepareIntegrations()', () => { - it( 'should return "insetImageViaUrl" and "openCKFinder" integrations', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicEditor - .create( editorElement, { - plugins: [ - Paragraph, - Link, - Image, - ImageUploadUI, - CKFinderUploadAdapter, - CKFinder - ], - image: { - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ] - } - } - } ); - - const openCKFinderExtendedView = Object.values( prepareIntegrations( editor ) )[ 1 ]; - - expect( openCKFinderExtendedView.class ).contains( 'ck-image-insert__ck-finder-button' ); - expect( openCKFinderExtendedView.label ).to.equal( 'Insert image or file' ); - expect( openCKFinderExtendedView.withText ).to.be.true; - - editor.destroy(); - editorElement.remove(); - } ); - - it( 'should return only "insertImageViaUrl" integration and throw warning ' + - 'for "image-upload-integrations-invalid-view" error', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicEditor - .create( editorElement, { - plugins: [ - Paragraph, - Image, - ImageUploadUI - ], - image: { - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ] - } - } - } ); - - expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 ); - - editor.destroy(); - editorElement.remove(); - } ); - - it( 'should return only "link" integration', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicEditor - .create( editorElement, { - plugins: [ - Paragraph, - Link, - Image, - ImageUploadUI - ], - image: { - insert: { - integrations: [ - 'link' - ] - } - } - } ); - - expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 ); - expect( Object.values( prepareIntegrations( editor ) )[ 0 ].label ).to.equal( 'Link' ); - expect( Object.values( prepareIntegrations( editor ) )[ 0 ] ).to.be.instanceOf( ButtonView ); - - editor.destroy(); - editorElement.remove(); - } ); - - it( 'should return "insertImageViaUrl" integration, when no integrations were configured', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicEditor - .create( editorElement, { - plugins: [ - Paragraph, - Image, - ImageUploadUI - ] - } ); - - expect( Object.keys( prepareIntegrations( editor ) ).length ).to.equal( 1 ); - - editor.destroy(); - editorElement.remove(); - } ); - } ); - - describe( 'createLabeledInputView()', () => { - describe( 'image URL input view', () => { - it( 'should have placeholder', () => { - const view = createLabeledInputView( { t: val => val } ); - expect( view.fieldView.placeholder ).to.equal( 'https://example.com/image.png' ); - } ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-image/tests/imageinsertviaurl.js b/packages/ckeditor5-image/tests/imageinsertviaurl.js index 2f760789851..8b614080785 100644 --- a/packages/ckeditor5-image/tests/imageinsertviaurl.js +++ b/packages/ckeditor5-image/tests/imageinsertviaurl.js @@ -6,6 +6,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import ImageInsertUI from '../src/imageinsert/imageinsertui'; import ImageInsertViaUrl from '../src/imageinsertviaurl'; +import ImageInsertViaUrlUI from '../src/imageinsert/imageinsertviaurlui'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; @@ -23,7 +24,6 @@ describe( 'ImageInsertViaUrl', () => { afterEach( async () => { editorElement.remove(); - await editor.destroy(); } ); @@ -35,6 +35,10 @@ describe( 'ImageInsertViaUrl', () => { expect( editor.plugins.get( 'ImageInsertUI' ) ).to.instanceOf( ImageInsertUI ); } ); + it( 'should load ImageInsertViaUrlUI plugin', () => { + expect( editor.plugins.get( 'ImageInsertViaUrlUI' ) ).to.instanceOf( ImageInsertViaUrlUI ); + } ); + it( 'should not load ImageUpload plugin', () => { expect( editor.plugins.has( 'ImageUpload' ) ).to.be.false; } ); diff --git a/packages/ckeditor5-image/tests/imageupload/imageuploadui.js b/packages/ckeditor5-image/tests/imageupload/imageuploadui.js index ea08d8f86ef..b5cac6f247b 100644 --- a/packages/ckeditor5-image/tests/imageupload/imageuploadui.js +++ b/packages/ckeditor5-image/tests/imageupload/imageuploadui.js @@ -16,13 +16,19 @@ import ImageUploadEditing from '../../src/imageupload/imageuploadediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; +import { icons } from 'ckeditor5/src/core'; import { createNativeFileMock, UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks'; import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'ImageUploadUI', () => { let editor, model, editorElement, fileRepository; + testUtils.createSinonSandbox(); + class UploadAdapterPluginMock extends Plugin { init() { fileRepository = this.editor.plugins.get( FileRepository ); @@ -83,11 +89,11 @@ describe( 'ImageUploadUI', () => { command.isEnabled = true; - expect( button.buttonView.isEnabled ).to.true; + expect( button.isEnabled ).to.true; command.isEnabled = false; - expect( button.buttonView.isEnabled ).to.false; + expect( button.isEnabled ).to.false; } ); // ckeditor5-upload/#77 @@ -98,11 +104,11 @@ describe( 'ImageUploadUI', () => { button.render(); - button.buttonView.on( 'execute', spy ); + button.on( 'execute', spy ); command.isEnabled = false; - button.buttonView.element.dispatchEvent( new Event( 'click' ) ); + button.element.dispatchEvent( new Event( 'click' ) ); sinon.assert.notCalled( spy ); } ); @@ -201,4 +207,106 @@ describe( 'ImageUploadUI', () => { expect( spy ).to.be.calledOnce; } ); + + describe( 'InsertImageUI integration', () => { + it( 'should create FileDialogButtonView in split button dropdown button', () => { + mockAssetManagerIntegration(); + + const spy = sinon.spy( editor.ui.componentFactory, 'create' ); + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + const dropdownButton = dropdown.buttonView.actionView; + + expect( dropdownButton ).to.be.instanceOf( FileDialogButtonView ); + expect( dropdownButton.withText ).to.be.false; + expect( dropdownButton.icon ).to.equal( icons.imageUpload ); + + expect( spy.calledTwice ).to.be.true; + expect( spy.firstCall.args[ 0 ] ).to.equal( 'insertImage' ); + expect( spy.secondCall.args[ 0 ] ).to.equal( 'uploadImage' ); + expect( spy.firstCall.returnValue ).to.equal( dropdown.buttonView.actionView ); + } ); + + it( 'should create FileDialogButtonView in dropdown panel', () => { + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + const spy = sinon.spy( editor.ui.componentFactory, 'create' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + expect( buttonView ).to.be.instanceOf( FileDialogButtonView ); + expect( buttonView.withText ).to.be.true; + expect( buttonView.icon ).to.equal( icons.imageUpload ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.firstCall.args[ 0 ] ).to.equal( 'uploadImage' ); + expect( spy.firstCall.returnValue ).to.equal( buttonView ); + } ); + + it( 'should bind to #isImageSelected', () => { + const insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const dropdownButton = dropdown.buttonView.actionView; + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + insertImageUI.isImageSelected = false; + expect( dropdownButton.label ).to.equal( 'Upload image from computer' ); + expect( buttonView.label ).to.equal( 'Upload from computer' ); + + insertImageUI.isImageSelected = true; + expect( dropdownButton.label ).to.equal( 'Replace image from computer' ); + expect( buttonView.label ).to.equal( 'Replace from computer' ); + } ); + + it( 'should close dropdown on execute', () => { + mockAssetManagerIntegration(); + + const dropdown = editor.ui.componentFactory.create( 'insertImage' ); + + dropdown.isOpen = true; + + const formView = dropdown.panelView.children.get( 0 ); + const buttonView = formView.children.get( 0 ); + + sinon.stub( editor, 'execute' ); + + buttonView.fire( 'execute' ); + + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + + function mockAssetManagerIntegration() { + const insertImageUI = editor.plugins.get( 'ImageInsertUI' ); + const observable = new Model( { isEnabled: true } ); + + insertImageUI.registerIntegration( { + name: 'assetManager', + observable, + buttonViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'foo'; + + return button; + }, + formViewCreator() { + const button = new ButtonView( editor.locale ); + + button.label = 'bar'; + + return button; + } + } ); + } } ); diff --git a/packages/ckeditor5-image/tests/manual/imageblock.js b/packages/ckeditor5-image/tests/manual/imageblock.js index 4cbf2e36f0a..9b057a77b6c 100644 --- a/packages/ckeditor5-image/tests/manual/imageblock.js +++ b/packages/ckeditor5-image/tests/manual/imageblock.js @@ -65,13 +65,7 @@ ClassicEditor 'redo' ], image: { - toolbar: [ 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ] - } + toolbar: [ 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ] }, ckfinder: { // eslint-disable-next-line max-len diff --git a/packages/ckeditor5-image/tests/manual/imageinline.js b/packages/ckeditor5-image/tests/manual/imageinline.js index 83f50205013..3633477567e 100644 --- a/packages/ckeditor5-image/tests/manual/imageinline.js +++ b/packages/ckeditor5-image/tests/manual/imageinline.js @@ -67,13 +67,7 @@ ClassicEditor 'redo' ], image: { - toolbar: [ 'imageStyle:inline', 'imageStyle:wrapText', '|', 'imageTextAlternative' ], - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ] - } + toolbar: [ 'imageStyle:inline', 'imageStyle:wrapText', '|', 'imageTextAlternative' ] }, ckfinder: { // eslint-disable-next-line max-len diff --git a/packages/ckeditor5-image/tests/manual/imageinsert.html b/packages/ckeditor5-image/tests/manual/imageinsert.html index de7cc867e63..f95b98c8ef4 100644 --- a/packages/ckeditor5-image/tests/manual/imageinsert.html +++ b/packages/ckeditor5-image/tests/manual/imageinsert.html @@ -10,6 +10,13 @@ } +

+ Insert image integrations: + + + +

+

Insert configured as "auto"

Image upload via URL with CKFinder integration

diff --git a/packages/ckeditor5-image/tests/manual/imageinsert.js b/packages/ckeditor5-image/tests/manual/imageinsert.js index 948d39ae120..6127f8cad75 100644 --- a/packages/ckeditor5-image/tests/manual/imageinsert.js +++ b/packages/ckeditor5-image/tests/manual/imageinsert.js @@ -13,50 +13,80 @@ import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder'; import ImageInsert from '../../src/imageinsert'; import AutoImage from '../../src/autoimage'; -createEditor( 'editor1', 'auto' ); -createEditor( 'editor2', 'block' ); -createEditor( 'editor3', 'inline' ); - -function createEditor( elementId, imageType ) { - ClassicEditor - .create( document.querySelector( '#' + elementId ), { - plugins: [ ArticlePluginSet, ImageInsert, AutoImage, LinkImage, CKFinderUploadAdapter, CKFinder ], - toolbar: [ - 'heading', - '|', - 'bold', - 'italic', - 'link', - 'bulletedList', - 'numberedList', - 'blockQuote', - 'insertImage', - 'insertTable', - 'mediaEmbed', - 'undo', - 'redo' - ], - image: { - toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ], - type: imageType - } - }, - ckfinder: { - // eslint-disable-next-line max-len - uploadUrl: 'https://ckeditor.com/apps/ckfinder/3.5.0/core/connector/php/connector.php?command=QuickUpload&type=Files&responseType=json' +async function createEditor( elementId, imageType ) { + const editor = await ClassicEditor.create( document.querySelector( '#' + elementId ), { + plugins: [ ArticlePluginSet, ImageInsert, AutoImage, LinkImage, CKFinderUploadAdapter, CKFinder ], + toolbar: [ + 'heading', + '|', + 'bold', + 'italic', + 'link', + 'bulletedList', + 'numberedList', + 'blockQuote', + 'insertImage', + 'insertTable', + 'mediaEmbed', + 'undo', + 'redo' + ], + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], + insert: { + integrations: getSelectedIntegrations(), + type: imageType } - } ) - .then( editor => { - window[ elementId ] = editor; - - CKEditorInspector.attach( { [ imageType ]: editor } ); - } ) - .catch( err => { - console.error( err ); + }, + ckfinder: { + // eslint-disable-next-line max-len + uploadUrl: 'https://ckeditor.com/apps/ckfinder/3.5.0/core/connector/php/connector.php?command=QuickUpload&type=Files&responseType=json' + }, + updateSourceElementOnDestroy: true + } ); + + window[ elementId ] = editor; + + CKEditorInspector.attach( { [ imageType ]: editor } ); +} + +setupEditors( { + editor1: 'auto', + editor2: 'block', + editor3: 'inline' +} ).catch( err => { + console.error( err ); +} ); + +async function setupEditors( opt ) { + await startEditors(); + + for ( const element of document.querySelectorAll( 'input[name=imageInsertIntegration]' ) ) { + element.addEventListener( 'change', () => { + restartEditors().catch( err => console.error( err ) ); } ); + } + + async function restartEditors() { + await stopEditors(); + await startEditors(); + } + + async function startEditors() { + for ( const [ elementId, imageType ] of Object.entries( opt ) ) { + await createEditor( elementId, imageType ); + } + } + + async function stopEditors( ) { + for ( const editorId of Object.keys( opt ) ) { + await window[ editorId ].destroy(); + } + } +} + +function getSelectedIntegrations() { + return Array.from( document.querySelectorAll( 'input[name=imageInsertIntegration]' ) ) + .filter( element => element.checked ) + .map( element => element.value ); } diff --git a/packages/ckeditor5-image/tests/manual/imagetypetoggle.js b/packages/ckeditor5-image/tests/manual/imagetypetoggle.js index c6e2a80c5a2..4e2601c3f92 100644 --- a/packages/ckeditor5-image/tests/manual/imagetypetoggle.js +++ b/packages/ckeditor5-image/tests/manual/imagetypetoggle.js @@ -69,13 +69,7 @@ ClassicEditor 'redo' ], image: { - toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], - insert: { - integrations: [ - 'insertImageViaUrl', - 'openCKFinder' - ] - } + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ] }, ckfinder: { // eslint-disable-next-line max-len diff --git a/packages/ckeditor5-image/tests/manual/picture.js b/packages/ckeditor5-image/tests/manual/picture.js index 4538ea87093..c8473361d5f 100644 --- a/packages/ckeditor5-image/tests/manual/picture.js +++ b/packages/ckeditor5-image/tests/manual/picture.js @@ -78,12 +78,7 @@ ClassicEditor 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] } } ) .then( editor => { diff --git a/packages/ckeditor5-image/theme/imageinsert.css b/packages/ckeditor5-image/theme/imageinsert.css index db5f6b4fa3d..ae013ba39ac 100644 --- a/packages/ckeditor5-image/theme/imageinsert.css +++ b/packages/ckeditor5-image/theme/imageinsert.css @@ -3,21 +3,9 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -.ck.ck-image-insert__panel { - padding: var(--ck-spacing-large); -} - -.ck.ck-image-insert__ck-finder-button { - display: block; - width: 100%; - margin: var(--ck-spacing-standard) auto; - border: 1px solid hsl(0, 0%, 80%); - border-radius: var(--ck-border-radius); -} - -/* https://github.com/ckeditor/ckeditor5/issues/7986 */ -.ck.ck-splitbutton > .ck-file-dialog-button.ck-button { - padding: 0; - margin: 0; - border: none; +.ck.ck-image-insert-url { + & .ck-image-insert-url__action-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + } } diff --git a/packages/ckeditor5-image/theme/imageinsertformrowview.css b/packages/ckeditor5-image/theme/imageinsertformrowview.css deleted file mode 100644 index 28716183f64..00000000000 --- a/packages/ckeditor5-image/theme/imageinsertformrowview.css +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -.ck.ck-image-insert-form { - &:focus { - /* See: https://github.com/ckeditor/ckeditor5/issues/4773 */ - outline: none; - } -} - -.ck.ck-form__row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: space-between; - - /* Ignore labels that work as fieldset legends */ - & > *:not(.ck-label) { - flex-grow: 1; - } - - &.ck-image-insert-form__action-row { - margin-top: var(--ck-spacing-standard); - - & .ck-button-save, - & .ck-button-cancel { - justify-content: center; - } - - & .ck-button .ck-button__label { - color: var(--ck-color-text); - } - } -} diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index 9c49e51c664..93b8156afbb 100644 --- a/packages/ckeditor5-list/package.json +++ b/packages/ckeditor5-list/package.json @@ -12,8 +12,7 @@ ], "main": "src/index.ts", "dependencies": { - "ckeditor5": "40.1.0", - "@ckeditor/ckeditor5-ui": "40.1.0" + "ckeditor5": "40.1.0" }, "devDependencies": { "@ckeditor/ckeditor5-alignment": "40.1.0", @@ -47,6 +46,7 @@ "@ckeditor/ckeditor5-table": "40.1.0", "@ckeditor/ckeditor5-theme-lark": "40.1.0", "@ckeditor/ckeditor5-typing": "40.1.0", + "@ckeditor/ckeditor5-ui": "40.1.0", "@ckeditor/ckeditor5-undo": "40.1.0", "@ckeditor/ckeditor5-utils": "40.1.0", "@ckeditor/ckeditor5-widget": "40.1.0", diff --git a/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.ts b/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.ts index 0c2aa1c51b3..98da0004418 100644 --- a/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.ts +++ b/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.ts @@ -15,6 +15,7 @@ import { LabeledFieldView, createLabeledInputNumber, addKeyboardHandlingForGrid, + CollapsibleView, type ButtonView, type InputNumberView } from 'ckeditor5/src/ui'; @@ -26,8 +27,6 @@ import { type Locale } from 'ckeditor5/src/utils'; -import CollapsibleView from './collapsibleview'; - import type { ListPropertiesConfig } from '../../listconfig'; import '../../../theme/listproperties.css'; diff --git a/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js b/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js index ad933ab7472..17dc548218c 100644 --- a/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js +++ b/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js @@ -6,10 +6,10 @@ /* globals document */ import ListPropertiesView from '../../../src/listproperties/ui/listpropertiesview'; -import CollapsibleView from '../../../src/listproperties/ui/collapsibleview'; import { ButtonView, + CollapsibleView, FocusCycler, LabeledFieldView, SwitchButtonView, diff --git a/packages/ckeditor5-minimap/docs/_snippets/features/minimap.js b/packages/ckeditor5-minimap/docs/_snippets/features/minimap.js index 782408bfbca..39612e79fdc 100644 --- a/packages/ckeditor5-minimap/docs/_snippets/features/minimap.js +++ b/packages/ckeditor5-minimap/docs/_snippets/features/minimap.js @@ -90,11 +90,6 @@ const config = { extraClasses: 'live-snippet formatted' }, cloudServices: CS_CONFIG, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, ui: { viewportOffset: { top: window.getViewportTopOffsetConfig() diff --git a/packages/ckeditor5-show-blocks/tests/manual/showblocks.js b/packages/ckeditor5-show-blocks/tests/manual/showblocks.js index a3c16dff738..87464ff0c6d 100644 --- a/packages/ckeditor5-show-blocks/tests/manual/showblocks.js +++ b/packages/ckeditor5-show-blocks/tests/manual/showblocks.js @@ -178,12 +178,7 @@ ClassicEditor 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] }, list: { properties: { diff --git a/packages/ckeditor5-source-editing/docs/_snippets/features/source-editing-imports.js b/packages/ckeditor5-source-editing/docs/_snippets/features/source-editing-imports.js index 25023665368..a1c89986aea 100644 --- a/packages/ckeditor5-source-editing/docs/_snippets/features/source-editing-imports.js +++ b/packages/ckeditor5-source-editing/docs/_snippets/features/source-editing-imports.js @@ -59,11 +59,6 @@ ClassicEditor.defaultConfig = { '|', 'bulletedList', 'numberedList', 'outdent', 'indent' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, ui: { viewportOffset: { top: window.getViewportTopOffsetConfig() diff --git a/packages/ckeditor5-style/tests/manual/styledropdown.js b/packages/ckeditor5-style/tests/manual/styledropdown.js index c3e9b1167db..2f345e2246d 100644 --- a/packages/ckeditor5-style/tests/manual/styledropdown.js +++ b/packages/ckeditor5-style/tests/manual/styledropdown.js @@ -171,12 +171,7 @@ const config = { 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] }, placeholder: 'Type the content here!', mention: { diff --git a/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js b/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js index 78bfa1c92da..026cf034cfe 100644 --- a/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js +++ b/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js @@ -33,11 +33,6 @@ ClassicEditor.defaultConfig = { '|', 'bulletedList', 'numberedList', 'outdent', 'indent' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, ui: { viewportOffset: { top: window.getViewportTopOffsetConfig() diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageinsert.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageinsert.css new file mode 100644 index 00000000000..32896f67690 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageinsert.css @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; + +:root { + --ck-image-insert-insert-by-url-width: 250px; +} + +.ck.ck-image-insert-url { + --ck-input-width: 100%; + + & .ck-image-insert-url__action-row { + grid-column-gap: var(--ck-spacing-large); + margin-top: var(--ck-spacing-large); + + & .ck-button-save, + & .ck-button-cancel { + justify-content: center; + min-width: auto; + } + + & .ck-button .ck-button__label { + color: var(--ck-color-text); + } + } +} + +.ck.ck-image-insert-form { + & > .ck.ck-button { + display: block; + width: 100%; + padding: var(--ck-list-button-padding); + + @mixin ck-dir ltr { + text-align: left; + } + + @mixin ck-dir rtl { + text-align: right; + } + } + + & > .ck.ck-collapsible { + &:not(:first-child) { + border-top: 1px solid var(--ck-color-base-border); + } + + &:not(:last-child) { + border-bottom: 1px solid var(--ck-color-base-border); + } + + min-width: var(--ck-image-insert-insert-by-url-width); + } + + /* This is the case when there are no other integrations configured than insert by URL */ + & > .ck.ck-image-insert-url { + min-width: var(--ck-image-insert-insert-by-url-width); + padding: var(--ck-spacing-large); + } + + &:focus { + outline: none; + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/collapsible.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/collapsible/collapsible.css similarity index 86% rename from packages/ckeditor5-theme-lark/theme/ckeditor5-list/collapsible.css rename to packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/collapsible/collapsible.css index 52e53ad85d6..768b6bd6190 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/collapsible.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/collapsible/collapsible.css @@ -11,7 +11,7 @@ & > .ck.ck-button { width: 100%; font-weight: bold; - padding: var(--ck-spacing-medium) var(--ck-spacing-large); + padding: var(--ck-list-button-padding); border-radius: 0; color: inherit; @@ -32,7 +32,7 @@ } & > .ck-collapsible__children { - padding: 0 var(--ck-spacing-large) var(--ck-spacing-large); + padding: var(--ck-spacing-medium) var(--ck-spacing-large) var(--ck-spacing-large); } &.ck-collapsible_collapsed { diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css index 8ebd48858c9..63d144d03df 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css @@ -87,7 +87,7 @@ /* Fields that are disabled or not focused and without a placeholder should have full-sized labels. */ /* stylelint-disable-next-line no-descending-specificity */ - &.ck-disabled.ck-labeled-field-view_empty > .ck.ck-labeled-field-view__input-wrapper > .ck.ck-label, + &.ck-disabled.ck-labeled-field-view_empty:not(.ck-labeled-field-view_placeholder) > .ck.ck-labeled-field-view__input-wrapper > .ck.ck-label, &.ck-labeled-field-view_empty:not(.ck-labeled-field-view_focused):not(.ck-labeled-field-view_placeholder) > .ck.ck-labeled-field-view__input-wrapper > .ck.ck-label { @mixin ck-dir ltr { transform: translate(var(--ck-labeled-field-label-default-position-x), var(--ck-labeled-field-label-default-position-y)) scale(1); diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css index f0108787875..d42164f952f 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css @@ -8,6 +8,12 @@ @import "../../../mixins/_shadow.css"; @import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; +:root { + --ck-list-button-padding: + calc(.2 * var(--ck-line-height-base) * var(--ck-font-size-base)) + calc(.4 * var(--ck-line-height-base) * var(--ck-font-size-base)); +} + .ck.ck-list { @mixin ck-rounded-corners; @@ -35,9 +41,7 @@ /* List items should have the same height. Use absolute units to make sure it is so because e.g. different heading styles may have different height https://github.com/ckeditor/ckeditor5-heading/issues/63 */ - padding: - calc(.2 * var(--ck-line-height-base) * var(--ck-font-size-base)) - calc(.4 * var(--ck-line-height-base) * var(--ck-font-size-base)); + padding: var(--ck-list-button-padding); & .ck-button__label { /* https://github.com/ckeditor/ckeditor5-heading/issues/63 */ diff --git a/packages/ckeditor5-list/src/listproperties/ui/collapsibleview.ts b/packages/ckeditor5-ui/src/collapsible/collapsibleview.ts similarity index 87% rename from packages/ckeditor5-list/src/listproperties/ui/collapsibleview.ts rename to packages/ckeditor5-ui/src/collapsible/collapsibleview.ts index 4bab348e0c8..96177ae6a19 100644 --- a/packages/ckeditor5-list/src/listproperties/ui/collapsibleview.ts +++ b/packages/ckeditor5-ui/src/collapsible/collapsibleview.ts @@ -4,17 +4,18 @@ */ /** - * @module list/listproperties/ui/collapsibleview + * @module ui/collapsible/collapsibleview */ -import type { Locale } from 'ckeditor5/src/utils'; +import type { Locale } from '@ckeditor/ckeditor5-utils'; -import { View, ButtonView, type ViewCollection } from 'ckeditor5/src/ui'; +import View from '../view'; +import ButtonView from '../button/buttonview'; +import type ViewCollection from '../viewcollection'; -// eslint-disable-next-line ckeditor5-rules/ckeditor-imports -import dropdownArrowIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg'; +import dropdownArrowIcon from '../../theme/icons/dropdown-arrow.svg'; -import '../../../theme/collapsible.css'; +import '../../theme/components/collapsible/collapsible.css'; /** * A collapsible UI component. Consists of a labeled button and a container which can be collapsed @@ -118,6 +119,13 @@ export default class CollapsibleView extends View { this._collapsibleAriaLabelUid = this.buttonView.labelView.element!.id; } + /** + * Focuses the first focusable. + */ + public focus(): void { + this.buttonView.focus(); + } + /** * Creates the main {@link #buttonView} of the collapsible. */ diff --git a/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.ts b/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.ts index 7fa91274ebc..ecbe0a248b2 100644 --- a/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.ts +++ b/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.ts @@ -12,6 +12,7 @@ import ButtonView from '../../button/buttonview'; import type ViewCollection from '../../viewcollection'; import type Button from '../../button/button'; import type DropdownButton from './dropdownbutton'; +import type { FocusableView } from '../../focuscycler'; import { KeystrokeHandler, @@ -170,7 +171,7 @@ export default class SplitButtonView extends View implements Dro /** * @inheritDoc */ - constructor( locale?: Locale ) { + constructor( locale?: Locale, actionButton?: ButtonView & FocusableView ) { super( locale ); const bind = this.bindTemplate; @@ -193,7 +194,7 @@ export default class SplitButtonView extends View implements Dro this.set( 'withText', false ); this.children = this.createCollection(); - this.actionView = this._createActionView(); + this.actionView = this._createActionView( actionButton ); this.arrowView = this._createArrowView(); this.keystrokes = new KeystrokeHandler(); this.focusTracker = new FocusTracker(); @@ -269,22 +270,24 @@ export default class SplitButtonView extends View implements Dro * Creates a {@link module:ui/button/buttonview~ButtonView} instance as {@link #actionView} and binds it with main split button * attributes. */ - private _createActionView() { - const actionView = new ButtonView(); - - actionView.bind( - 'icon', - 'isEnabled', - 'isOn', - 'isToggleable', - 'keystroke', - 'label', - 'tabindex', - 'tooltip', - 'tooltipPosition', - 'type', - 'withText' - ).to( this ); + private _createActionView( actionButton?: ButtonView & FocusableView ) { + const actionView = actionButton || new ButtonView(); + + if ( !actionButton ) { + actionView.bind( + 'icon', + 'isEnabled', + 'isOn', + 'isToggleable', + 'keystroke', + 'label', + 'tabindex', + 'tooltip', + 'tooltipPosition', + 'type', + 'withText' + ).to( this ); + } actionView.extendTemplate( { attributes: { diff --git a/packages/ckeditor5-ui/src/dropdown/utils.ts b/packages/ckeditor5-ui/src/dropdown/utils.ts index 4942b95f78f..efa9a303872 100644 --- a/packages/ckeditor5-ui/src/dropdown/utils.ts +++ b/packages/ckeditor5-ui/src/dropdown/utils.ts @@ -105,15 +105,16 @@ import ListItemGroupView from '../list/listitemgroupview'; * {@link module:ui/dropdown/utils~addToolbarToDropdown} utils. * * @param locale The locale instance. - * @param ButtonClass The dropdown button view class. Needs to implement the + * @param ButtonClassOrInstance The dropdown button view class. Needs to implement the * {@link module:ui/dropdown/button/dropdownbutton~DropdownButton} interface. * @returns The dropdown view instance. */ export function createDropdown( locale: Locale | undefined, - ButtonClass: new ( locale?: Locale ) => DropdownButton & FocusableView = DropdownButtonView + ButtonClassOrInstance: + ( new ( locale?: Locale ) => DropdownButton & FocusableView ) | DropdownButton & FocusableView = DropdownButtonView ): DropdownView { - const buttonView = new ButtonClass( locale ); + const buttonView = typeof ButtonClassOrInstance == 'function' ? new ButtonClassOrInstance( locale ) : ButtonClassOrInstance; const panelView = new DropdownPanelView( locale ); const dropdownView = new DropdownView( locale, buttonView, panelView ); diff --git a/packages/ckeditor5-ui/src/index.ts b/packages/ckeditor5-ui/src/index.ts index a269d0a8bf1..3f7e5f6a05d 100644 --- a/packages/ckeditor5-ui/src/index.ts +++ b/packages/ckeditor5-ui/src/index.ts @@ -16,7 +16,7 @@ export { default as addKeyboardHandlingForGrid } from './bindings/addkeyboardhan export { default as BodyCollection } from './editorui/bodycollection'; export { type ButtonExecuteEvent } from './button/button'; -export { type default as ButtonLabel } from './button/buttonlabel'; +export type { default as ButtonLabel } from './button/buttonlabel'; export { default as ButtonView } from './button/buttonview'; export { default as ButtonLabelView } from './button/buttonlabelview'; export { default as SwitchButtonView } from './button/switchbuttonview'; @@ -86,6 +86,8 @@ export { default as SearchInfoView } from './search/searchinfoview'; export { default as FilteredView, type FilteredViewExecuteEvent } from './search/filteredview'; export { default as HighlightedTextView } from './highlightedtext/highlightedtextview'; +export { default as CollapsibleView } from './collapsible/collapsibleview'; + export { default as TooltipManager } from './tooltipmanager'; export { default as Template, type TemplateDefinition } from './template'; diff --git a/packages/ckeditor5-list/tests/listproperties/ui/collapsibleview.js b/packages/ckeditor5-ui/tests/collapsible/collapsibleview.js similarity index 91% rename from packages/ckeditor5-list/tests/listproperties/ui/collapsibleview.js rename to packages/ckeditor5-ui/tests/collapsible/collapsibleview.js index 2a9a53d1be8..688bdff7b48 100644 --- a/packages/ckeditor5-list/tests/listproperties/ui/collapsibleview.js +++ b/packages/ckeditor5-ui/tests/collapsible/collapsibleview.js @@ -3,10 +3,11 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import CollapsibleView from '../../../src/listproperties/ui/collapsibleview'; +import CollapsibleView from '../../src/collapsible/collapsibleview'; +import ButtonView from '../../src/button/buttonview'; -import { ButtonView, ViewCollection } from '@ckeditor/ckeditor5-ui'; -import dropdownArrowIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg'; +import dropdownArrowIcon from '../../theme/icons/dropdown-arrow.svg'; +import ViewCollection from '../../src/viewcollection'; describe( 'CollapsibleView', () => { let view, locale; @@ -81,6 +82,16 @@ describe( 'CollapsibleView', () => { } ); } ); + describe( 'focus()', () => { + it( 'focuses the button', () => { + const spy = sinon.spy( view.buttonView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + describe( 'DOM bindings', () => { describe( 'button label', () => { it( 'should react on view#label', () => { diff --git a/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js b/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js index b5e6c1dbd67..203fbd73419 100644 --- a/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js +++ b/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js @@ -284,6 +284,154 @@ describe( 'SplitButtonView', () => { } ); } ); + describe( 'custom actionView button', () => { + let customButton; + + class CustomButtonView extends ButtonView {} + + beforeEach( () => { + customButton = new CustomButtonView( locale ); + view = new SplitButtonView( locale, customButton ); + + view.render(); + } ); + + it( 'creates custom view#actionView', () => { + expect( view.actionView ).to.be.instanceOf( CustomButtonView ); + expect( view.actionView ).to.equal( customButton ); + expect( view.actionView.element.classList.contains( 'ck-splitbutton__action' ) ).to.be.true; + } ); + + it( 'does not adds isToggleable to view#actionView', () => { + expect( view.actionView.isToggleable ).to.be.false; + + view.isToggleable = true; + + expect( view.actionView.isToggleable ).to.be.false; + } ); + + it( 'creates view#arrowView', () => { + expect( view.arrowView ).to.be.instanceOf( ButtonView ); + expect( view.arrowView.element.classList.contains( 'ck-splitbutton__arrow' ) ).to.be.true; + expect( view.arrowView.element.attributes[ 'aria-haspopup' ].value ).to.equal( 'true' ); + expect( view.arrowView.icon ).to.be.not.undefined; + expect( view.arrowView.tooltip ).to.equal( view.tooltip ); + expect( view.arrowView.label ).to.equal( view.label ); + } ); + + it( 'creates element from template', () => { + expect( view.element.tagName ).to.equal( 'DIV' ); + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-splitbutton' ) ).to.be.true; + } ); + + it( 'binds #isVisible to the template', () => { + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false; + + view.isVisible = false; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true; + + // There should be no binding to the action view. Only the entire split button should react. + expect( view.actionView.element.classList.contains( 'ck-hidden' ) ).to.be.false; + } ); + + describe( 'bindings', () => { + it( 'delegates actionView#execute to view#execute', () => { + const spy = sinon.spy(); + + view.on( 'execute', spy ); + + view.actionView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'does not bind actionView#icon to view', () => { + expect( view.actionView.icon ).to.be.undefined; + + view.icon = 'foo'; + + expect( view.actionView.icon ).to.be.undefined; + } ); + + it( 'does not bind actionView#isEnabled to view', () => { + expect( view.actionView.isEnabled ).to.be.true; + + view.isEnabled = false; + + expect( view.actionView.isEnabled ).to.be.true; + } ); + + it( 'does not bind actionView#label to view', () => { + expect( view.actionView.label ).to.be.undefined; + + view.label = 'foo'; + + expect( view.actionView.label ).to.be.undefined; + } ); + + it( 'delegates arrowView#execute to view#open', () => { + const spy = sinon.spy(); + + view.on( 'open', spy ); + + view.arrowView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'binds arrowView#isEnabled to view', () => { + expect( view.arrowView.isEnabled ).to.be.true; + + view.isEnabled = false; + + expect( view.arrowView.isEnabled ).to.be.false; + } ); + + it( 'does not bind actionView#tabindex to view', () => { + expect( view.actionView.tabindex ).to.equal( -1 ); + + view.tabindex = 1; + + expect( view.actionView.tabindex ).to.equal( -1 ); + } ); + + // Makes little sense for split button but the Button interface specifies it, so let's support it. + it( 'does not bind actionView#type to view', () => { + expect( view.actionView.type ).to.equal( 'button' ); + + view.type = 'submit'; + + expect( view.actionView.type ).to.equal( 'button' ); + } ); + + it( 'does not bind actionView#withText to view', () => { + expect( view.actionView.withText ).to.be.false; + + view.withText = true; + + expect( view.actionView.withText ).to.be.false; + } ); + + it( 'does not bind actionView#tooltip to view', () => { + expect( view.actionView.tooltip ).to.be.false; + + view.tooltip = true; + + expect( view.actionView.tooltip ).to.be.false; + } ); + + it( 'does not bind actionView#tooltipPosition to view', () => { + expect( view.actionView.tooltipPosition ).to.equal( 's' ); + + view.tooltipPosition = 'n'; + + expect( view.actionView.tooltipPosition ).to.equal( 's' ); + } ); + } ); + } ); + describe( 'focus()', () => { it( 'focuses the actionButton', () => { const spy = sinon.spy( view.actionView, 'focus' ); diff --git a/packages/ckeditor5-ui/tests/dropdown/utils.js b/packages/ckeditor5-ui/tests/dropdown/utils.js index 455c724d8ee..d1c088d6839 100644 --- a/packages/ckeditor5-ui/tests/dropdown/utils.js +++ b/packages/ckeditor5-ui/tests/dropdown/utils.js @@ -68,6 +68,15 @@ describe( 'utils', () => { expect( dropdownView.buttonView ).to.be.instanceOf( SplitButtonView ); } ); + it( 'creates dropdown#buttonView out of passed ButtonView instance', () => { + const buttonView = new SplitButtonView( locale ); + + dropdownView = createDropdown( locale, buttonView ); + + expect( dropdownView.buttonView ).to.be.instanceOf( SplitButtonView ); + expect( dropdownView.buttonView ).to.equal( buttonView ); + } ); + it( 'binds #isEnabled to the buttonView', () => { dropdownView = createDropdown( locale ); diff --git a/packages/ckeditor5-ui/tests/manual/toolbar/nested.js b/packages/ckeditor5-ui/tests/manual/toolbar/nested.js index fbf4c6a692a..3c61929cee2 100644 --- a/packages/ckeditor5-ui/tests/manual/toolbar/nested.js +++ b/packages/ckeditor5-ui/tests/manual/toolbar/nested.js @@ -158,12 +158,7 @@ ClassicEditor image: { toolbar: [ 'imageTextAlternative' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] } } ) .then( editor => { diff --git a/packages/ckeditor5-list/theme/collapsible.css b/packages/ckeditor5-ui/theme/components/collapsible/collapsible.css similarity index 100% rename from packages/ckeditor5-list/theme/collapsible.css rename to packages/ckeditor5-ui/theme/components/collapsible/collapsible.css diff --git a/packages/ckeditor5-upload/src/ui/filedialogbuttonview.ts b/packages/ckeditor5-upload/src/ui/filedialogbuttonview.ts index 8342c74ee69..41f8b1456bd 100644 --- a/packages/ckeditor5-upload/src/ui/filedialogbuttonview.ts +++ b/packages/ckeditor5-upload/src/ui/filedialogbuttonview.ts @@ -23,9 +23,6 @@ import type { Locale } from '@ckeditor/ckeditor5-utils'; * view.set( { * acceptedType: 'image/*', * allowMultipleFiles: true - * } ); - * - * view.buttonView.set( { * label: t( 'Insert image' ), * icon: imageIcon, * tooltip: true @@ -38,9 +35,11 @@ import type { Locale } from '@ckeditor/ckeditor5-utils'; * } ); * ``` */ -export default class FileDialogButtonView extends View { +export default class FileDialogButtonView extends ButtonView { /** * The button view of the component. + * + * @deprecated */ public buttonView: ButtonView; @@ -72,7 +71,8 @@ export default class FileDialogButtonView extends View { constructor( locale?: Locale ) { super( locale ); - this.buttonView = new ButtonView( locale ); + // For backward compatibility. + this.buttonView = this; this._fileInputView = new FileInputView( locale ); this._fileInputView.bind( 'acceptedType' ).to( this ); @@ -80,27 +80,24 @@ export default class FileDialogButtonView extends View { this._fileInputView.delegate( 'done' ).to( this ); - this.setTemplate( { - tag: 'span', - attributes: { - class: 'ck-file-dialog-button' - }, - children: [ - this.buttonView, - this._fileInputView - ] + this.on( 'execute', () => { + this._fileInputView.open(); } ); - this.buttonView.on( 'execute', () => { - this._fileInputView.open(); + this.extendTemplate( { + attributes: { + class: 'ck-file-dialog-button' + } } ); } /** - * Focuses the {@link #buttonView}. + * @inheritDoc */ - public focus(): void { - this.buttonView.focus(); + public override render(): void { + super.render(); + + this.children.add( this._fileInputView ); } } diff --git a/packages/ckeditor5-upload/tests/ui/filedialogbuttonview.js b/packages/ckeditor5-upload/tests/ui/filedialogbuttonview.js index c69909eee45..2efe4478f7c 100644 --- a/packages/ckeditor5-upload/tests/ui/filedialogbuttonview.js +++ b/packages/ckeditor5-upload/tests/ui/filedialogbuttonview.js @@ -24,14 +24,13 @@ describe( 'FileDialogButtonView', () => { describe( 'child views', () => { describe( 'button view', () => { it( 'should be rendered', () => { - expect( view.buttonView ).to.instanceof( ButtonView ); - expect( view.buttonView ).to.equal( view.template.children[ 0 ] ); + expect( view ).to.instanceof( ButtonView ); } ); it( 'should open file dialog on execute', () => { const spy = sinon.spy( view._fileInputView, 'open' ); const stub = sinon.stub( view._fileInputView.element, 'click' ); - view.buttonView.fire( 'execute' ); + view.fire( 'execute' ); sinon.assert.calledOnce( spy ); stub.restore(); @@ -41,7 +40,7 @@ describe( 'FileDialogButtonView', () => { describe( 'file dialog', () => { it( 'should be rendered', () => { expect( view._fileInputView ).to.instanceof( View ); - expect( view._fileInputView ).to.equal( view.template.children[ 1 ] ); + expect( view._fileInputView ).to.equal( view.children.get( 1 ) ); } ); it( 'should be bound to view#acceptedType', () => { @@ -68,14 +67,4 @@ describe( 'FileDialogButtonView', () => { } ); } ); } ); - - describe( 'focus()', () => { - it( 'should focus view#buttonView', () => { - const spy = sinon.spy( view.buttonView, 'focus' ); - - view.focus(); - - sinon.assert.calledOnce( spy ); - } ); - } ); } ); diff --git a/packages/ckeditor5-word-count/docs/_snippets/features/build-word-count-source.js b/packages/ckeditor5-word-count/docs/_snippets/features/build-word-count-source.js index c5b2bb575e0..16863832bb8 100644 --- a/packages/ckeditor5-word-count/docs/_snippets/features/build-word-count-source.js +++ b/packages/ckeditor5-word-count/docs/_snippets/features/build-word-count-source.js @@ -86,11 +86,6 @@ BalloonEditor.defaultConfig = { 'redo' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, image: { toolbar: [ 'imageStyle:inline', @@ -139,11 +134,6 @@ ClassicEditor.defaultConfig = { 'redo' ] }, - insert: { - integrations: [ - 'insertImageViaUrl' - ] - }, image: { toolbar: [ 'imageStyle:inline', diff --git a/scripts/clean-up-svg-icons.js b/scripts/clean-up-svg-icons.js index 7c6f9390cfd..9b6f6dd7abf 100644 --- a/scripts/clean-up-svg-icons.js +++ b/scripts/clean-up-svg-icons.js @@ -49,8 +49,7 @@ const { execSync } = require( 'child_process' ); // because, for instance, CSS animations may depend on it. const EXCLUDED_ICONS = [ 'return-arrow.svg', - 'project-logo.svg', - 'text-alternative.svg' + 'project-logo.svg' ]; // A pattern to match all the icons. diff --git a/tests/manual/all-features-dll.js b/tests/manual/all-features-dll.js index 11362ea8329..2c40faaf64b 100644 --- a/tests/manual/all-features-dll.js +++ b/tests/manual/all-features-dll.js @@ -196,12 +196,7 @@ const config = { 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] }, placeholder: 'Type the content here!', mention: { diff --git a/tests/manual/all-features.js b/tests/manual/all-features.js index 17569304afe..3bc74817105 100644 --- a/tests/manual/all-features.js +++ b/tests/manual/all-features.js @@ -122,12 +122,7 @@ ClassicEditor 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] }, placeholder: 'Type the content here!', mention: { diff --git a/tests/manual/memory/memory-semi-automated.js b/tests/manual/memory/memory-semi-automated.js index bb9645aed8f..36f3a520fa2 100644 --- a/tests/manual/memory/memory-semi-automated.js +++ b/tests/manual/memory/memory-semi-automated.js @@ -124,12 +124,7 @@ function initEditor() { 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', 'resizeImage' - ], - insert: { - integrations: [ - 'insertImageViaUrl' - ] - } + ] }, placeholder: 'Type the content here!', mention: {