8
0
Просмотр исходного кода

Merge pull request #4 from ckeditor/t/1

Other: Initial implementation. Closes #1.
Piotrek Koszuliński 7 лет назад
Родитель
Сommit
b14cfe13dd

+ 33 - 0
packages/ckeditor5-ckfinder/.travis.yml

@@ -0,0 +1,33 @@
+sudo: required
+dist: trusty
+addons:
+  firefox: latest
+  apt:
+    sources:
+    - google-chrome
+    packages:
+    - google-chrome-stable
+language: node_js
+node_js:
+- '8'
+cache:
+- node_modules
+branches:
+   except:
+   - stable
+before_install:
+- export DISPLAY=:99.0
+- sh -e /etc/init.d/xvfb start
+- npm config set package-lock false
+- npm i -g npm@^5.7.1
+install:
+- npm install @ckeditor/ckeditor5-dev-tests
+- ckeditor5-dev-tests-install-dependencies
+script:
+- ckeditor5-dev-tests-travis
+after_success:
+- ckeditor5-dev-tests-save-revision
+env:
+  global:
+  - secure: 2B2J4L85DoU7hgeZdoczdDbq3M/MV45hZ1k+RngBq4Nb33JiqHJq4M9IvaJWmiFJijwoyKILPZc80B3JcJslb34+zoFTIVgvO/YRJLOdGj9hXHyfelt2uKO+jsGYE1DU/MLDwwzm0gguM0QoFMzo7OUTj80Rlp0BdH/yCIlsohv+A1b0avVOwsk4lkdasRr5xpRw0bTq/hzSTChCF4W2Z4ru7rD2hOew1EStKTVhSOhsZylaTgeRrghpdigsCAlGwxkd3CIdaGkfMP2V/NwRHU9d4rSVbkoQLJUanBXjVDPpILA1LwOW3JiLwBvEAlkc/9CNmWgSzlPrVNrBO4T2JqCzQhZ2/oBUbfId7ylKuRwvJsOObNlVdLs2w3zrSRY1T5RTYRUGCuPzecwsOYW3KRIcCp9r3PnUJ5O5fTD31uC6saMiHUbnDOqzRpu4ypp7eYm7ZmwIsN9T29pqly3m/XMEc7YdpsTyDd3A+mxNb2TcnyqbbBnzH6E/WMsjv/TLBYh897VaTAwCMtxFQ0CIPoYklXOH4E69TL54w2lK+W5NE5+1fPi/mULDfwB/eyQZ16iFLvCtsSDrc9US8TJm9QBrHhBzZA9KGo/TnJVNVg6R9J06IvixUHP+RZja7UbQ00gfRkCJc4fIGL47UjBNuhayhlR0GIJ1Bf1s/vjA7S0=
+  - secure: qVBid2b/hiCAohgaF7S07xnvm4kVppS2tGniJ31RDTAVbYXcxnNJr69kHqLlAy9Fby2diOFxy0Td6lpyjFn1piqBTR5x32YN8P+SnFgrzKCCkOc3wCNmESaAPuVLrMAk6wxqyEvwhWpd2/bgZyK3LT3jE2vrvK9SQhM19TwyoBrVGyo5MiFANCtECN8SfCkj20Ndpc5Mj0P+ECg/0OQO4YiTq5vzQpV11hf/ElDLg3Q2KkoefBTYDo/FuuMvnTd14IZlKmNV+MQEazIWyygT4cAaaVuKpHTqzvkH419XvFJ4Bo4QxNsafCHH+AM+ryaAdiQ5D8qYRzbVLuDkKqXU2nkSZbxpK0St8s1WNofXDOXqfNKSQsJP4lWrvV4LMbshnxJGZEvbVN2fzaMl5mjS1MT2NES+wxW0vSdA4k2IagOELzeQGOAQ6WIePx0jE0yaLaM3wpZwwleITCOlEmpZ1VGgQDcQ9g2MFypDbmzXObmGpjc88AvvPAgyPozXr8DEn3nCViQtcnm42/kLMbc38Yca5fBBsXHpDMbQOFDk5qJIeDfLdxypExu4IMamqQ6zhCeW4M7IoRapLBZRHdA+K1KAYrYvDLMFbSf7c1QKGSXY+HJzeudzUKNRI80dgIoy4uzIbA4Z7ksUoQfJqINHUu5r5NFqOFJCfBF3o+5Fpoc=

+ 34 - 0
packages/ckeditor5-ckfinder/docs/api/ckfinder.md

@@ -0,0 +1,34 @@
+---
+category: api-reference
+---
+
+# CKEditor 5 CKFinder
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-ckfinder.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-ckfinder)
+
+This package implements the {@link features/image#image-upload CKFinder} feature for CKEditor 5.
+
+CKFinder lets you easily insert images which are automatically rescaled, optimized, responsive and delivered through a blazing-fast CDN. It integrates automatically with [CKFinder](https://ckeditor.com/ckfinder/).
+
+## Documentation
+
+See the {@link features/image-upload Image upload} guide.
+
+You can also check the {@link module:ckfinder/ckfinder~CKFinder} plugin documentation.
+
+## Installation
+
+```bash
+npm install --save @ckeditor/ckeditor5-ckfinder
+```
+
+## Contribute
+
+The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5-ckfinder.
+
+## External links
+
+* [`@ckeditor/ckeditor5-ckfinder` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-ckfinder)
+* [`ckeditor/ckeditor5-ckfinder` on GitHub](https://github.com/ckeditor/ckeditor5-ckfinder)
+* [Issue tracker](https://github.com/ckeditor/ckeditor5-ckfinder/issues)
+* [Changelog](https://github.com/ckeditor/ckeditor5-ckfinder/blob/master/CHANGELOG.md)

+ 8 - 1
packages/ckeditor5-ckfinder/package.json

@@ -10,11 +10,18 @@
     "ckeditor5-plugin"
     "ckeditor5-plugin"
   ],
   ],
   "dependencies": {
   "dependencies": {
-    "@ckeditor/ckeditor5-core": "^11.0.1"
+    "@ckeditor/ckeditor5-core": "^11.0.1",
+    "@ckeditor/ckeditor5-ui": "^11.1.0",
+    "@ckeditor/ckeditor5-widget": "^10.3.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@ckeditor/ckeditor5-editor-classic": "^11.0.1",
     "@ckeditor/ckeditor5-editor-classic": "^11.0.1",
+    "@ckeditor/ckeditor5-cloud-services": "^10.1.0",
     "@ckeditor/ckeditor5-engine": "^11.0.0",
     "@ckeditor/ckeditor5-engine": "^11.0.0",
+    "@ckeditor/ckeditor5-image": "^11.0.0",
+    "@ckeditor/ckeditor5-link": "^10.0.4",
+    "@ckeditor/ckeditor5-paragraph": "^10.0.3",
+    "@ckeditor/ckeditor5-utils": "^11.0.0",
     "eslint": "^5.5.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.7",
     "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",
     "husky": "^0.14.3",

+ 84 - 0
packages/ckeditor5-ckfinder/src/ckfinder.js

@@ -0,0 +1,84 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ckfinder/ckfinder
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import CKFinderUI from './ckfinderui';
+import CKFinderEditing from './ckfinderediting';
+
+/**
+ * The CKFinder feature.
+ *
+ * This is a "glue" plugin which enables:
+ *
+ * * {@link module:ckfinder/ckfinderediting~CKFinderEditing},
+ * * {@link module:ckfinder/ckfinderui~CKFinderUI}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CKFinder extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'CKFinder';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ CKFinderEditing, CKFinderUI ];
+	}
+}
+
+/**
+ * The configuration of the {@link module:ckfinder/ckfinder~CKFinder CKFinder feature}.
+ *
+ * Read more in {@link module:ckfinder/ckfinder~CKFinderConfig}.
+ *
+ * @member {module:ckfinder/ckfinder~CKFinderConfig} module:core/editor/editorconfig~EditorConfig#ckfinder
+ */
+
+/**
+ * The configuration of the {@link module:ckfinder/ckfinder~CKFinderUploadAdapter CKFinder upload adapter}.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				ckfinder: {
+ *					options: {
+ *						resourceType: 'Images'
+ *					}
+ * 				}
+ *			} )
+ *			.then( ... )
+ 			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface CKFinderConfig
+ */
+
+/**
+ * The configuration options to pass to the CKFinder instance.
+ *
+ * @member {Object} module:ckfinder/ckfinder~CKFinderConfig#options
+ */
+
+/**
+ * The type of the CKFinder opener method.
+ *
+ * Supported types are:
+ * * `"modal"` - opens a CKFinder modal
+ * * `"popup"` - opens a CKFinder popup window
+ *
+ * Defaults to "'modal'".
+ *
+ * @member {String} module:ckfinder/ckfinder~CKFinderConfig#openerMethod
+ */

+ 152 - 0
packages/ckeditor5-ckfinder/src/ckfindercommand.js

@@ -0,0 +1,152 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window */
+
+/**
+ * @module ckfinder/ckfindercommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
+
+/**
+ * The CKFinder command. It is used by the {@link module:ckfinder/ckfinderediting~CKFinderEditing ckfinder editng feature}
+ * to open a CKFinder file browser to insert an image or a link to a file into content.
+ *
+ *		editor.execute( 'ckfinder' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class CKFinderCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		// Remove default document listener to lower its priority.
+		this.stopListening( this.editor.model.document, 'change' );
+
+		// Lower this command listener priority to be sure that refresh() will be called after link & image refresh.
+		this.listenTo( this.editor.model.document, 'change', () => {
+			this.refresh();
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const imageCommand = this.editor.commands.get( 'imageUpload' );
+		const linkCommand = this.editor.commands.get( 'link' );
+
+		this.isEnabled = ( !imageCommand || !linkCommand ) ? false : ( imageCommand.isEnabled || linkCommand.isEnabled );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const editor = this.editor;
+
+		const openerMethod = this.editor.config.get( 'ckfinder.openerMethod' ) || 'modal';
+
+		if ( openerMethod != 'popup' && openerMethod != 'modal' ) {
+			throw new CKEditorError( 'ckfinder-unknown-openerMethod: The openerMethod config option must by "popup" or "modal".' );
+		}
+
+		const options = this.editor.config.get( 'ckfinder.options' ) || {};
+
+		options.chooseFiles = true;
+
+		// The onInit method allows to extend CKFinder's behavior. It is used to attach event listeners to file choosing related events.
+		options.onInit = finder => {
+			finder.on( 'files:choose', evt => {
+				const files = evt.data.files.toArray();
+
+				// Insert links
+				const links = files.filter( file => !file.isImage() );
+				const images = files.filter( file => file.isImage() );
+
+				for ( const linkFile of links ) {
+					editor.execute( 'link', linkFile.getUrl() );
+				}
+
+				const imagesUrls = [];
+
+				for ( const image of images ) {
+					const url = image.getUrl();
+
+					imagesUrls.push( url ? url : finder.request( 'file:getProxyUrl', { file: image } ) );
+				}
+
+				if ( imagesUrls.length ) {
+					insertImages( editor, imagesUrls );
+				}
+			} );
+
+			finder.on( 'file:choose:resizedImage', evt => {
+				const resizedUrl = evt.data.resizedUrl;
+
+				if ( !resizedUrl ) {
+					const notification = editor.plugins.get( Notification );
+					const t = editor.locale.t;
+
+					notification.showWarning( t( 'Could not obtain resized image URL. Try different image or folder.' ), {
+						title: t( 'Selecting resized image failed' ),
+						namespace: 'ckfinder'
+					} );
+
+					return;
+				}
+
+				insertImages( editor, [ resizedUrl ] );
+			} );
+		};
+
+		window.CKFinder[ openerMethod ]( options );
+	}
+}
+
+function insertImages( editor, urls ) {
+	const imageCommand = editor.commands.get( 'imageUpload' );
+
+	// Check if inserting an image is actually possible - it might be possible to only insert a link.
+	if ( !imageCommand.isEnabled ) {
+		const notification = editor.plugins.get( Notification );
+		const t = editor.locale.t;
+
+		notification.showWarning( t( 'Could not insert image at current selection.' ), {
+			title: t( 'Inserting image failed' ),
+			namespace: 'ckfinder'
+		} );
+
+		return;
+	}
+
+	const model = editor.model;
+
+	// The first image will be inserted according to image inserting algorithm. Next one after the previous one.
+	let insertAt = findOptimalInsertionPosition( model.document.selection, model );
+
+	model.change( writer => {
+		for ( const url of urls ) {
+			const imageElement = writer.createElement( 'image', { src: url } );
+
+			// Insert image & update the selection.
+			model.insertContent( imageElement, insertAt );
+
+			// Inserting an image might've failed due to schema regulations.
+			if ( imageElement.parent ) {
+				writer.setSelection( imageElement, 'on' );
+				insertAt = writer.createPositionAfter( imageElement );
+			}
+		}
+	} );
+}

+ 43 - 0
packages/ckeditor5-ckfinder/src/ckfinderediting.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ckfinder/ckfinderediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+
+import CKFinderCommand from './ckfindercommand';
+
+/**
+ * The CKFinder editing feature. It introduces the {@link module:ckfinder/ckfindercommand~CKFinderCommand ckfinder command}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CKFinderEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'CKFinderEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Notification ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.commands.add( 'ckfinder', new CKFinderCommand( editor ) );
+	}
+}

+ 57 - 0
packages/ckeditor5-ckfinder/src/ckfinderui.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ckfinder/ckfinderui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import ckfinderIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg';
+
+/**
+ * The CKFinder UI plugin. It introduces he `'ckfinder'` toolbar button.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CKFinderUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'CKFinderUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const componentFactory = editor.ui.componentFactory;
+		const t = editor.t;
+
+		componentFactory.add( 'ckfinder', locale => {
+			const command = editor.commands.get( 'ckfinder' );
+
+			const button = new ButtonView( locale );
+
+			button.set( {
+				label: t( 'CKFinder' ),
+				icon: ckfinderIcon,
+				tooltip: true
+			} );
+
+			button.bind( 'isEnabled' ).to( command );
+
+			button.on( 'execute', () => {
+				editor.execute( 'ckfinder' );
+				editor.editing.view.focus();
+			} );
+
+			return button;
+		} );
+	}
+}

+ 50 - 0
packages/ckeditor5-ckfinder/tests/ckfinder.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import CKFinder from '../src/ckfinder';
+import CKFinderUI from '../src/ckfinderui';
+import CKFinderEditing from '../src/ckfinderediting';
+
+describe( 'CKFinder', () => {
+	let editorElement, editor;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ CKFinder ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( CKFinder ) ).to.instanceOf( CKFinder );
+	} );
+
+	it( 'should load CKFinderUI plugin', () => {
+		expect( editor.plugins.get( CKFinderUI ) ).to.instanceOf( CKFinderUI );
+	} );
+
+	it( 'should load CKFinderEditing plugin', () => {
+		expect( editor.plugins.get( CKFinderEditing ) ).to.instanceOf( CKFinderEditing );
+	} );
+
+	it( 'has proper name', () => {
+		expect( CKFinder.pluginName ).to.equal( 'CKFinder' );
+	} );
+} );

+ 346 - 0
packages/ckeditor5-ckfinder/tests/ckfindercommand.js

@@ -0,0 +1,346 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import ImageUploadEditing from '@ckeditor/ckeditor5-image/src/imageupload/imageuploadediting';
+import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+
+import CKFinderCommand from '../src/ckfindercommand';
+
+describe( 'CKFinderCommand', () => {
+	let editor, command, model;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, ImageEditing, ImageUploadEditing, LinkEditing, Notification ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				command = new CKFinderCommand( editor );
+
+				const schema = model.schema;
+				schema.extend( 'image' );
+
+				setModelData( model, '<paragraph>f[o]o</paragraph>' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true when the selection directly in the root', () => {
+			model.enqueueChange( 'transparent', () => {
+				setModelData( model, '[]' );
+
+				command.refresh();
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		it( 'should be true when the selection is in empty block', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true where only image is allowed', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+			model.schema.extend( 'image', { allowIn: 'block' } );
+
+			// Block link attribute.
+			model.schema.addAttributeCheck( ( ctx, attributeName ) => ( attributeName !== 'linkHref' ) );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true where only link is allowed', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+
+			// Block image in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'image' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false where link & image are not allowed', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+
+			// Block link attribute - image is not allowed in 'block'.
+			model.schema.addAttributeCheck( ( ctx, attributeName ) => ( attributeName !== 'linkHref' ) );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		const finderMock = {
+			on: ( eventName, callback ) => {
+				finderMock[ eventName ] = callback;
+			}
+		};
+
+		beforeEach( () => {
+			window.CKFinder = {
+				modal: config => {
+					config.onInit( finderMock );
+				},
+				popup: config => {
+					config.onInit( finderMock );
+				}
+			};
+		} );
+
+		it( 'should register proper listeners on CKFinder instance', () => {
+			command.execute();
+
+			expect( finderMock ).to.have.property( 'files:choose' );
+			expect( finderMock ).to.have.property( 'file:choose:resizedImage' );
+		} );
+
+		it( 'should use "CKFinder.modal" as default CKFinder opener method', () => {
+			const spy = sinon.spy( window.CKFinder, 'modal' );
+
+			command.execute();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should use "CKFinder.modal" as default CKFinder opener method', () => {
+			const spy = sinon.spy( window.CKFinder, 'popup' );
+
+			editor.config.set( 'ckfinder.openerMethod', 'popup' );
+
+			command.execute();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should throw if unsupported CKFinder opener method was set', () => {
+			editor.config.set( 'ckfinder.openerMethod', 'foobar' );
+
+			expect( () => {
+				command.execute();
+			} ).to.throw( CKEditorError, /ckfinder-unknown-openerMethod/ );
+		} );
+
+		it( 'should insert single chosen image', () => {
+			const url = 'foo/bar.jpg';
+
+			command.execute();
+
+			mockFilesChooseEvent( [ mockFinderFile( url ) ] );
+
+			expect( getModelData( model ) )
+				.to.equal( `[<image src="${ url }"></image>]<paragraph>foo</paragraph>` );
+		} );
+
+		it( 'should insert link if chosen file is not an image', () => {
+			const url = 'foo/bar.pdf';
+
+			command.execute();
+
+			mockFilesChooseEvent( [ mockFinderFile( url, false ) ] );
+
+			expect( getModelData( model ) )
+				.to.equal( `<paragraph>f[<$text linkHref="${ url }">o</$text>]o</paragraph>` );
+		} );
+
+		it( 'should pass CKFinder configuration options', () => {
+			const spy = sinon.spy( window.CKFinder, 'modal' );
+
+			const connectorPath = 'foo/bar.php';
+			editor.config.set( 'ckfinder.options', { connectorPath } );
+
+			command.execute();
+
+			const openerMethodOptions = spy.args[ 0 ][ 0 ];
+
+			expect( openerMethodOptions ).to.have.property( 'chooseFiles', true );
+			expect( openerMethodOptions ).to.have.property( 'onInit' );
+			expect( openerMethodOptions ).to.have.property( 'connectorPath', connectorPath );
+		} );
+
+		it( 'should insert multiple chosen images as image widget', () => {
+			const url1 = 'foo/bar1.jpg';
+			const url2 = 'foo/bar2.jpg';
+			const url3 = 'foo/bar3.jpg';
+
+			command.execute();
+
+			mockFilesChooseEvent( [ mockFinderFile( url1 ), mockFinderFile( url2 ), mockFinderFile( url3 ) ] );
+
+			expect( getModelData( model ) ).to.equal(
+				`<image src="${ url1 }"></image><image src="${ url2 }"></image>[<image src="${ url3 }"></image>]<paragraph>foo</paragraph>`
+			);
+		} );
+
+		it( 'should insert images and links to a files from chosen files', () => {
+			const url1 = 'foo/bar1.jpg';
+			const url2 = 'foo/bar2.pdf';
+			const url3 = 'foo/bar3.jpg';
+
+			command.execute();
+
+			mockFilesChooseEvent( [ mockFinderFile( url1 ), mockFinderFile( url2, false ), mockFinderFile( url3 ) ] );
+
+			expect( getModelData( model ) ).to.equal(
+				`<image src="${ url1 }"></image>` +
+				`[<image src="${ url3 }"></image>]` +
+				`<paragraph>f<$text linkHref="${ url2 }">o</$text>o</paragraph>`
+			);
+		} );
+
+		it( 'should use CKFinder Proxy for privately hosted files', () => {
+			const proxyUrl = 'bar/foo.jpg';
+
+			finderMock.request = () => proxyUrl;
+
+			command.execute();
+
+			mockFilesChooseEvent( [ mockFinderFile( false ) ] );
+
+			expect( getModelData( model ) ).to.equal(
+				`[<image src="${ proxyUrl }"></image>]<paragraph>foo</paragraph>`
+			);
+		} );
+
+		it( 'should insert resized image as image widget', () => {
+			const url = 'foo/bar.jpg';
+
+			command.execute();
+
+			mockFinderEvent( 'file:choose:resizedImage', { resizedUrl: url } );
+
+			expect( getModelData( model ) )
+				.to.equal( `[<image src="${ url }"></image>]<paragraph>foo</paragraph>` );
+		} );
+
+		it( 'should show warning notification if no resized image URL was returned', done => {
+			const notification = editor.plugins.get( Notification );
+
+			notification.on( 'show:warning', ( evt, data ) => {
+				expect( data.message ).to.equal( 'Could not obtain resized image URL. Try different image or folder.' );
+				expect( data.title ).to.equal( 'Selecting resized image failed' );
+				evt.stop();
+
+				done();
+			}, { priority: 'high' } );
+
+			command.execute();
+
+			mockFinderEvent( 'file:choose:resizedImage', { resizedUrl: undefined } );
+
+			expect( getModelData( model ) )
+				.to.equal( '<paragraph>f[o]o</paragraph>' );
+		} );
+
+		it( 'should show warning notification if image cannot be inserted', done => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+
+			// Block image in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'image' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			const notification = editor.plugins.get( Notification );
+
+			notification.on( 'show:warning', ( evt, data ) => {
+				expect( data.message ).to.equal( 'Could not insert image at current selection.' );
+				expect( data.title ).to.equal( 'Inserting image failed' );
+				evt.stop();
+
+				done();
+			}, { priority: 'high' } );
+
+			command.execute();
+
+			mockFinderEvent( 'file:choose:resizedImage', { resizedUrl: 'foo/bar.jpg' } );
+
+			expect( getModelData( model ) )
+				.to.equal( '<paragraph>f[o]o</paragraph>' );
+		} );
+
+		it( 'should not insert image nor crash when image could not be inserted', () => {
+			model.schema.register( 'other', {
+				allowIn: '$root',
+				isLimit: true
+			} );
+			model.schema.extend( '$text', { allowIn: 'other' } );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'other', view: 'p' } ) );
+
+			setModelData( model, '<other>[]</other>' );
+
+			command.execute();
+
+			mockFilesChooseEvent( [ mockFinderFile( 'foo/bar.jpg' ) ] );
+
+			expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
+		} );
+
+		function mockFinderFile( url = 'foo/bar.jpg', isImage = true ) {
+			return {
+				isImage: () => isImage,
+				getUrl: () => url
+			};
+		}
+
+		function mockFinderEvent( eventName, data ) {
+			finderMock[ eventName ]( { data } );
+		}
+
+		function mockFilesChooseEvent( files ) {
+			const data = {
+				files: {
+					toArray: () => files
+				}
+			};
+
+			mockFinderEvent( 'files:choose', data );
+		}
+	} );
+} );

+ 53 - 0
packages/ckeditor5-ckfinder/tests/ckfinderediting.js

@@ -0,0 +1,53 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import CKFinder from '../src/ckfinder';
+import CKFinderEditing from '../src/ckfinderediting';
+
+describe( 'CKFinderEditing', () => {
+	let editorElement, editor;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ CKFinder ]
+
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( CKFinderEditing ) ).to.be.instanceOf( CKFinderEditing );
+	} );
+
+	it( 'should load Notification plugin', () => {
+		expect( editor.plugins.get( Notification ) ).to.instanceOf( Notification );
+	} );
+
+	it( 'should register command', () => {
+		const command = editor.commands.get( 'ckfinder' );
+
+		expect( command ).to.be.instanceOf( Command );
+	} );
+} );

+ 82 - 0
packages/ckeditor5-ckfinder/tests/ckfinderui.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import ckfinderIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg';
+
+import CKFinder from '../src/ckfinder';
+
+describe( 'CKFinderUI', () => {
+	let editorElement, editor, button;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ CKFinder ]
+
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				button = editor.ui.componentFactory.create( 'ckfinder' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should add the "ckfinder" component to the factory', () => {
+		expect( button ).to.be.instanceOf( ButtonView );
+	} );
+
+	describe( 'button', () => {
+		it( 'should bind #isEnabled to the command', () => {
+			const command = editor.commands.get( 'ckfinder' );
+
+			command.isEnabled = true;
+			expect( button.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( button.isEnabled ).to.be.false;
+		} );
+
+		it( 'should set a #label of the #buttonView', () => {
+			expect( button.label ).to.equal( 'CKFinder' );
+		} );
+
+		it( 'should set an #icon of the #buttonView', () => {
+			expect( button.icon ).to.equal( ckfinderIcon );
+		} );
+
+		it( 'should enable tooltips for the #buttonView', () => {
+			expect( button.tooltip ).to.be.true;
+		} );
+
+		it( 'should execute bold command on model execute event', () => {
+			window.CKFinder = {
+				modal: () => {}
+			};
+
+			const executeStub = testUtils.sinon.spy( editor, 'execute' );
+
+			button.fire( 'execute' );
+
+			sinon.assert.calledOnce( executeStub );
+		} );
+	} );
+} );

+ 11 - 0
packages/ckeditor5-ckfinder/tests/manual/ckfinder.html

@@ -0,0 +1,11 @@
+<script src="https://ckeditor.com/docs/ckfinder/demo/ckfinder3/ckfinder.js"></script>
+
+<div id="editor">
+	<h2>CKFinder sample</h2>
+	<p>Use the toolbar icon to insert an image or link to a file to the content.</p>
+</div>
+
+<div style="margin-top:1em;">
+	CKFinder opener method: <span id="opener-method-label" style="font-weight:bold;">modal</span> -
+	<button id="opener-method">Switch to popup</button>
+</div>

+ 44 - 0
packages/ckeditor5-ckfinder/tests/manual/ckfinder.js

@@ -0,0 +1,44 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import CKFinder from '../../src/ckfinder';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		cloudServices: CS_CONFIG,
+		plugins: [ ArticlePluginSet, ImageUpload, CKFinder ],
+		toolbar: [ 'heading', '|', 'undo', 'redo', 'ckfinder' ],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const button = document.querySelector( '#opener-method' );
+		const label = document.querySelector( '#opener-method-label' );
+
+		button.addEventListener( 'click', () => {
+			const method = editor.config.get( 'ckfinder.openerMethod' );
+			const isPopup = method === 'popup';
+
+			const newMethod = isPopup ? 'modal' : 'popup';
+			editor.config.set( 'ckfinder.openerMethod', newMethod );
+
+			button.innerText = 'Switch to ' + ( isPopup ? 'popup' : 'modal' );
+			label.innerText = newMethod;
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 5 - 0
packages/ckeditor5-ckfinder/tests/manual/ckfinder.md

@@ -0,0 +1,5 @@
+### CKFinder sample
+
+1. Use the toolbar icon insert an image.
+1. Use the toolbar icon insert link to a non-image file.
+1. Use the button below the editor to change opener method to "popup" and repeat steps 1-2.