diff --git a/package.json b/package.json index 6f13816..ef6fa60 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,30 @@ "version": "0.0.1", "description": "Upload Feature for CKEditor 5.", "keywords": [], - "dependencies": {}, + "dependencies": { + "@ckeditor/ckeditor5-core": "^0.7.0", + "@ckeditor/ckeditor5-engine": "^0.8.0", + "@ckeditor/ckeditor5-ui": "^v0.7.1", + "@ckeditor/ckeditor5-utils": "^0.8.0" + }, "devDependencies": { - "@ckeditor/ckeditor5-dev-lint": "^2.0.2", - "gulp": "^3.9.1", - "guppy-pre-commit": "^0.4.0" + "@ckeditor/ckeditor5-basic-styles": "^0.8.0", + "@ckeditor/ckeditor5-clipboard": "^0.5.0", + "@ckeditor/ckeditor5-dev-lint": "^2.0.2", + "@ckeditor/ckeditor5-editor-classic": "^0.7.2", + "@ckeditor/ckeditor5-enter": "^0.9.0", + "@ckeditor/ckeditor5-heading": "^0.9.0", + "@ckeditor/ckeditor5-image": "^0.5.0", + "@ckeditor/ckeditor5-list": "^0.6.0", + "@ckeditor/ckeditor5-paragraph": "^0.7.0", + "@ckeditor/ckeditor5-typing": "^0.9.0", + "@ckeditor/ckeditor5-undo": "^0.8.0", + "gulp": "^3.9.1", + "guppy-pre-commit": "^0.4.0" }, "engines": { - "node": ">=6.0.0", - "npm": ">=3.0.0" + "node": ">=6.0.0", + "npm": ">=3.0.0" }, "author": "CKSource (http://cksource.com/)", "license": "(GPL-2.0 OR LGPL-2.1 OR MPL-1.1)", diff --git a/src/filereader.js b/src/filereader.js new file mode 100644 index 0000000..8911650 --- /dev/null +++ b/src/filereader.js @@ -0,0 +1,89 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module upload/filereader + */ + +/* globals window */ + +import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import mix from '@ckeditor/ckeditor5-utils/src/mix'; + +/** + * FileReader class - wrapper over native FileReader. + */ +export default class FileReader { + constructor() { + const reader = new window.FileReader(); + + /** + * Instance of native FileReader. + * + * @private + * @member {FileReader} #_reader + */ + this._reader = reader; + + /** + * Number of bytes loaded. + * + * @readonly + * @observable + * @member {Number} #loaded + */ + this.set( 'loaded', 0 ); + + reader.onprogress = evt => { + this.loaded = evt.loaded; + }; + } + + /** + * Returns error that occurred during file reading. + * + * @returns {Error} + */ + get error() { + return this._reader.error; + } + + /** + * Reads provided file. + * + * @param {File} file Native File object. + * @returns {Promise} Returns a promise that will resolve with file's contents. Promise can be rejected in case of + * error or when reading process is aborted. + */ + read( file ) { + const reader = this._reader; + this.total = file.size; + + return new Promise( ( resolve, reject ) => { + reader.onload = () => { + resolve( reader.result ); + }; + + reader.onerror = () => { + reject( 'error' ); + }; + + reader.onabort = () => { + reject( 'aborted' ); + }; + + this._reader.readAsDataURL( file ); + } ); + } + + /** + * Aborts file reader. + */ + abort() { + this._reader.abort(); + } +} + +mix( FileReader, ObservableMixin ); diff --git a/src/filerepository.js b/src/filerepository.js new file mode 100644 index 0000000..4ef2988 --- /dev/null +++ b/src/filerepository.js @@ -0,0 +1,462 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module upload/filerepository + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import Collection from '@ckeditor/ckeditor5-utils/src/collection'; +import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import log from '@ckeditor/ckeditor5-utils/src/log'; + +import FileReader from './filereader.js'; + +import uid from '@ckeditor/ckeditor5-utils/src/uid.js'; + +/** + * FileRepository plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class FileRepository extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'upload/filerepository'; + } + + /** + * @inheritDoc + */ + init() { + /** + * Collection of loaders associated with this repository. + * + * @member {module:utils/collection~Collection} #loaders + */ + this.loaders = new Collection(); + + /** + * Function that should be defined before using FileRepository. It should return new instance of + * {@link module:upload/filerepository~Adapter Adapter} that will be used to upload files. + * {@link module:upload/filerepository~FileLoader FileLoader} instance associated with the adapter + * will be passed to that function. + * For more information and example see {@link module:upload/filerepository~Adapter Adapter}. + * + * @abstract + * @function + * @name #createAdapter + */ + + /** + * Number of bytes uploaded. + * + * @readonly + * @observable + * @member {Number} #uploaded + */ + this.set( 'uploaded', 0 ); + + /** + * Number of total bytes to upload. + * It might be different than the file size because of headers and additional data. + * It contains `null` if value is not available yet, so it's better to use {@link #uploadPercent} to monitor + * the progress. + * + * @readonly + * @observable + * @member {Number|null} #uploadTotal + */ + this.set( 'uploadTotal', null ); + + /** + * Upload progress in percents. + * + * @readonly + * @observable + * @member {Number} #uploadedPercent + */ + this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => { + return total ? ( uploaded / total * 100 ) : 0; + } ); + } + + /** + * Returns the loader associated with specified file. + * To get loader by id use `fileRepository.loaders.get( id )`. + * + * @param {File} file Native File object. + * @returns {module:upload/filerepository~FileLoader|null} + */ + getLoader( file ) { + for ( const loader of this.loaders ) { + if ( loader.file == file ) { + return loader; + } + } + + return null; + } + + /** + * Creates loader for specified file. + * Shows console warning and returns `null` if {@link #createAdapter} method is not defined. + * + * @param {File} file Native File object. + * @returns {module:upload/filerepository~FileLoader|null} + */ + createLoader( file ) { + if ( !this.createAdapter ) { + log.warn( 'FileRepository: no createAdapter method found. Please define it before creating a loader.' ); + + return null; + } + + const loader = new FileLoader( file ); + loader._adapter = this.createAdapter( loader ); + + this.loaders.add( loader ); + + loader.on( 'change:uploaded', () => { + let aggregatedUploaded = 0; + + for ( const loader of this.loaders ) { + aggregatedUploaded += loader.uploaded; + } + + this.uploaded = aggregatedUploaded; + } ); + + loader.on( 'change:uploadTotal', () => { + let aggregatedTotal = 0; + + for ( const loader of this.loaders ) { + if ( loader.uploadTotal ) { + aggregatedTotal += loader.uploadTotal; + } + } + + this.uploadTotal = aggregatedTotal; + } ); + + return loader; + } + + /** + * Destroys loader. + * + * @param {File|module:upload/filerepository~FileLoader} fileOrLoader File associated with that loader or loader + * itself. + */ + destroyLoader( fileOrLoader ) { + const loader = fileOrLoader instanceof FileLoader ? fileOrLoader : this.getLoader( fileOrLoader ); + + loader._destroy(); + + this.loaders.remove( loader ); + } +} + +mix( FileRepository, ObservableMixin ); + +/** + * File loader class. + * It is used to control the process of file reading and uploading using specified adapter. + */ +class FileLoader { + /** + * Creates instance of FileLoader. + * + * @param {File} file + * @param {module:upload/filerepository~Adapter} adapter + */ + constructor( file, adapter ) { + /** + * Unique id of FileLoader instance. + * + * @readonly + * @member {Number} + */ + this.id = uid(); + + /** + * File instance associated with FileLoader. + * + * @readonly + * @member {File} + */ + this.file = file; + + /** + * Adapter instance associated with FileLoader. + * + * @private + * @member {module:upload/filerepository~Adapter} + */ + this._adapter = adapter; + + /** + * FileReader used by FileLoader. + * + * @protected + * @member {module:upload/filereader~FileReader} + */ + this._reader = new FileReader(); + + /** + * Current status of FileLoader. It can be one of the following: + * * 'idle', + * * 'reading', + * * 'uploading', + * * 'aborted', + * * 'error'. + * + * When reading status can change in a following way: + * `idle` -> `reading` -> `idle` + * `idle` -> `reading -> `aborted` + * `idle` -> `reading -> `error` + * + * When uploading status can change in a following way: + * `idle` -> `uploading` -> `idle` + * `idle` -> `uploading` -> `aborted` + * `idle` -> `uploading` -> `error` + * + * @readonly + * @observable + * @member {String} #status + */ + this.set( 'status', 'idle' ); + + /** + * Number of bytes uploaded. + * + * @readonly + * @observable + * @member {Number} #uploaded + */ + this.set( 'uploaded', 0 ); + + /** + * Number of total bytes to upload. + * + * @readonly + * @observable + * @member {Number|null} #uploadTotal + */ + this.set( 'uploadTotal', null ); + + /** + * Upload progress in percents. + * + * @readonly + * @observable + * @member {Number} #uploadedPercent + */ + this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => { + return total ? ( uploaded / total * 100 ) : 0; + } ); + + /** + * Response of the upload. + * + * @readonly + * @observable + * @member {Object|null} #uploadResponse + */ + this.set( 'uploadResponse', null ); + } + + /** + * Reads file using {@link module:upload/filereader~FileReader}. + * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-read-wrong-status` when status + * is different than `idle`. + * Example usage: + * + * fileLoader.read() + * .then( data => { ... } ) + * .catch( e => { + * if ( e === 'aborted' ) { + * console.log( 'Reading aborted.' ); + * } else { + * console.log( 'Reading error.', e ); + * } + * } ); + * + * @returns {Promise} Returns promise that will be resolved with read data. Promise will be rejected if error + * occurs or if read process is aborted. + */ + read() { + if ( this.status != 'idle' ) { + throw new CKEditorError( 'filerepository-read-wrong-status: You cannot call read if the status is different than idle.' ); + } + + this.status = 'reading'; + + return this._reader.read( this.file ) + .then( data => { + this.status = 'idle'; + + return data; + } ) + .catch( err => { + if ( err === 'aborted' ) { + this.status = 'aborted'; + throw 'aborted'; + } + + this.status = 'error'; + throw this._reader.error; + } ); + } + + /** + * Reads file using provided {@link module:upload/filereader~Adapter}. + * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-upload-wrong-status` when status + * is different than `idle`. + * Example usage: + * + * fileLoader.upload() + * .then( data => { ... } ) + * .catch( e => { + * if ( e === 'aborted' ) { + * console.log( 'Uploading aborted.' ); + * } else { + * console.log( 'Uploading error.', e ); + * } + * } ); + * + * @returns {Promise} Returns promise that will be resolved with response data. Promise will be rejected if error + * occurs or if read process is aborted. + */ + upload() { + if ( this.status != 'idle' ) { + throw new CKEditorError( 'filerepository-upload-wrong-status: You cannot call upload if the status is different than idle.' ); + } + + this.status = 'uploading'; + + return this._adapter.upload() + .then( data => { + this.uploadResponse = data; + this.status = 'idle'; + + return data; + } ) + .catch( err => { + if ( this.status === 'aborted' ) { + throw 'aborted'; + } + + this.status = 'error'; + throw err; + } ); + } + + /** + * Aborts loading process. + */ + abort() { + const status = this.status; + this.status = 'aborted'; + + if ( status == 'reading' ) { + this._reader.abort(); + } + + if ( status == 'uploading' && this._adapter.abort ) { + this._adapter.abort(); + } + + this._destroy(); + } + + /** + * Performs cleanup. + * + * @private + */ + _destroy() { + this._reader = undefined; + this._adapter = undefined; + this.data = undefined; + this.uploadResponse = undefined; + this.file = undefined; + } +} + +mix( FileLoader, ObservableMixin ); + +/** + * Adapter interface used by FileRepository to handle file upload. Adapter is a bridge between the editor and server that + * handles file uploads. It should contain logic necessary to initiate upload process and monitor its progress. + * + * It should implement two methods: + * * {@link module:upload/filerepository~Adapter#upload upload()}, + * * {@link module:upload/filerepository~Adapter#abort abort()}. + * + * Example adapter implementation: + * + * class Adapter { + * constructor( loader ) { + * // Save Loader instance to update upload progress. + * this.loader = loader; + * } + * + * upload() { + * // Update loader's progress. + * server.onUploadProgress( data => { + * loader.uploadTotal = data.total; + * loader.uploaded = data.uploaded; + * } ): + * + * // Return promise that will be resolved when file is uploaded. + * return server.upload( loader.file ); + * } + * + * abort() { + * // Reject promise returned from upload() method. + * server.abortUpload(); + * } + * } + * + * Then adapter can be set to be used by {@link module:upload/filerepository~FileRepository FileRepository}: + * + * editor.plugins.get( 'upload/filerepository' ).createAdapter = function( loader ) { + * return new Adapter( loader ); + * }; + * + * @interface Adapter + */ + +/** + * Executes the upload process. + * This method should return a promise that will resolve when data will be uploaded to server. Promise should be + * resolved with an object containing information about uploaded file: + * + * { + * original: 'http://server/orginal-size.image.png' + * } + * + * Take a look at {@link module:upload/filerepository~Adapter example Adapter implementation} and + * {@link module:upload/filerepository~FileRepository#createAdapter createAdapter method}. + * + * @method module:upload/filerepository~Adapter#upload + * @returns {Promise} Promise that should be resolved when data is uploaded. + */ + +/** + * Aborts the upload process. + * After aborting it should reject promise returned from {@link #upload upload()}. + * + * Take a look at {@link module:upload/filerepository~Adapter example Adapter implementation} and + * {@link module:upload/filerepository~FileRepository#createAdapter createAdapter method}. + * + * @method module:upload/filerepository~Adapter#abort + */ diff --git a/src/imageuploadcommand.js b/src/imageuploadcommand.js new file mode 100644 index 0000000..6a4c022 --- /dev/null +++ b/src/imageuploadcommand.js @@ -0,0 +1,54 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelDocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment'; +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; +import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; +import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection'; +import FileRepository from './filerepository'; +import { isImageType } from './utils'; +import Command from '@ckeditor/ckeditor5-core/src/command/command'; + +/** + * Image upload command. + * + * @extends module:core/command/command~Command + */ +export default class ImageUploadCommand extends Command { + /** + * Executes command. + * + * @protected + * @param {Object} options Options for executed command. + * @param {File} options.file Image file to upload. + * @param {module:engine/model/batch~Batch} [options.batch] Batch to collect all the change steps. + * New batch will be created if this option is not set. + */ + _doExecute( options ) { + const editor = this.editor; + const doc = editor.document; + const batch = options.batch || doc.batch(); + const file = options.file; + const fileRepository = editor.plugins.get( FileRepository ); + + if ( !isImageType( file ) ) { + return; + } + + doc.enqueueChanges( () => { + const imageElement = new ModelElement( 'image', { + uploadId: fileRepository.createLoader( file ).id + } ); + const documentFragment = new ModelDocumentFragment( [ imageElement ] ); + + const firstBlock = doc.selection.getSelectedBlocks().next().value; + const range = ModelRange.createFromParentsAndOffsets( firstBlock, 0, firstBlock, 0 ); + const insertSelection = new ModelSelection(); + insertSelection.setRanges( [ range ] ); + + editor.data.insertContent( documentFragment, insertSelection, batch ); + } ); + } +} diff --git a/src/imageuploadengine.js b/src/imageuploadengine.js new file mode 100644 index 0000000..332a82c --- /dev/null +++ b/src/imageuploadengine.js @@ -0,0 +1,147 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import { eventNameToConsumableType } from '@ckeditor/ckeditor5-engine/src/conversion/model-to-view-converters'; +import FileRepository from './filerepository'; +import ImageUploadCommand from './imageuploadcommand'; +import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; +import uploadingPlaceholder from '../theme/icons/image_placeholder.svg'; +import { isImageType } from './utils'; + +/** + * Image upload engine plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class ImageUploadEngine extends Plugin { + constructor( editor ) { + super( editor ); + + /** + * Image's placeholder that is displayed before real image data can be accessed. + * + * @protected + * @member {String} #placeholder + */ + this.placeholder = 'data:image/svg+xml;utf8,' + uploadingPlaceholder; + } + + /** + * @inheritDoc + */ + static get requires() { + return [ FileRepository, Notification ]; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const doc = editor.document; + const schema = doc.schema; + + // Setup schema to allow uploadId for images. + schema.allow( { name: 'image', attributes: [ 'uploadId' ], inside: '$root' } ); + schema.requireAttributes( 'image', [ 'uploadId' ] ); + + // Register imageUpload command. + editor.commands.set( 'imageUpload', new ImageUploadCommand( editor ) ); + + // Execute imageUpload command when image is dropped or pasted. + editor.editing.view.on( 'input', ( evt, data ) => { + for ( const file of data.dataTransfer.files ) { + if ( isImageType( file ) ) { + editor.execute( 'imageUpload', { file } ); + evt.stop(); + } + } + } ); + + // Listen on document changes and start upload process when image with `uploadId` attribute is present. + doc.on( 'change', ( evt, type, data, batch ) => { + if ( type === 'insert' ) { + for ( const value of data.range ) { + if ( value.type === 'elementStart' && value.item.name === 'image' ) { + const imageElement = value.item; + const uploadId = imageElement.getAttribute( 'uploadId' ); + const fileRepository = editor.plugins.get( FileRepository ); + + if ( uploadId ) { + const loader = fileRepository.loaders.get( uploadId ); + + if ( loader && loader.status == 'idle' ) { + this.load( loader, batch, imageElement ); + } + } + } + } + } + } ); + + // Model to view converter for image's `uploadId` attribute. + editor.editing.modelToView.on( 'addAttribute:uploadId:image', ( evt, data, consumable ) => { + if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) { + return; + } + + const modelImage = data.item; + const viewFigure = editor.editing.mapper.toViewElement( modelImage ); + const viewImg = viewFigure.getChild( 0 ); + + viewImg.setAttribute( 'src', this.placeholder ); + } ); + } + + /** + * Performs image loading. Image is read from the disk and temporary data is displayed, after uploading process + * is complete we replace temporary data with target image from the server. + * + * @protected + * @param {module:upload/filerepository~FileLoader} loader + * @param {module:engine/model/batch~Batch} batch + * @param {module:engine/model/element~Element} imageElement + */ + load( loader, batch, imageElement ) { + const editor = this.editor; + const doc = editor.document; + const fileRepository = editor.plugins.get( FileRepository ); + const notification = editor.plugins.get( Notification ); + + loader.read() + .then( data => { + const viewFigure = editor.editing.mapper.toViewElement( imageElement ); + const viewImg = viewFigure.getChild( 0 ); + viewImg.setAttribute( 'src', data ); + editor.editing.view.render(); + + return loader.upload(); + } ) + .then( data => { + doc.enqueueChanges( () => { + batch.setAttribute( imageElement, 'src', data.original ); + } ); + + clean(); + } ) + .catch( msg => { + // Might be 'aborted'. + if ( loader.status == 'error' ) { + notification.showWarning( msg, { namespace: 'upload' } ); + } + + clean(); + } ); + + function clean() { + doc.enqueueChanges( () => { + batch.removeAttribute( imageElement, 'uploadId' ); + } ); + + fileRepository.destroyLoader( loader ); + } + } +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..0fb200c --- /dev/null +++ b/src/utils.js @@ -0,0 +1,17 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * Checks if given file is an image. + * + * @param {File} file + * @returns {Boolean} + */ +export function isImageType( file ) { + const types = /^image\/(jpeg|png|gif|bmp)$/; + + return types.test( file.type ); +} + diff --git a/tests/_utils/mocks.js b/tests/_utils/mocks.js new file mode 100644 index 0000000..f189d31 --- /dev/null +++ b/tests/_utils/mocks.js @@ -0,0 +1,119 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * Returns object that mocks native File object. + */ +export const createNativeFileMock = () => ( { + type: 'image/jpeg', + size: 1024 +} ); + +/** + * AdapterMock class. + * Simulates adapter behaviour without any server-side communications. + */ +export class AdapterMock { + constructor( loader ) { + this.loader = loader; + } + + /** + * Starts mocked upload process. + * + * @returns {Promise} + */ + upload() { + return new Promise( ( resolve, reject ) => { + this._resolveCallback = resolve; + this._rejectCallback = reject; + + if ( this.uploadStartedCallback ) { + this.uploadStartedCallback(); + } + } ); + } + + /** + * Aborts reading. + */ + abort() { + this._rejectCallback( 'aborted' ); + } + + /** + * Allows to mock error during file upload. + * + * @param { Object } error + */ + mockError( error ) { + this._rejectCallback( error ); + } + + /** + * Allows to mock file upload success. + * + * @param { Object } data Mock data returned from server passed to resolved promise. + */ + mockSuccess( data ) { + this._resolveCallback( data ); + } + + /** + * Allows to mock file upload progress. + * + * @param {Number} uploaded Bytes uploaded. + * @param {Number} total Total bytes to upload. + */ + mockProgress( uploaded, total ) { + this.loader.uploaded = uploaded; + this.loader.uploadTotal = total; + } +} + +/** + * NativeFileReaderMock class. + * Simulates FileReader behaviour. + */ +export class NativeFileReaderMock { + /** + * Mock method used to initialize reading. + */ + readAsDataURL() {} + + /** + * Aborts reading process. + */ + abort() { + this.onabort(); + } + + /** + * Allows to mock file reading success. + * @param {*} result File reading result. + */ + mockSuccess( result ) { + this.result = result; + this.onload(); + } + + /** + * Allows to mock error during file read. + * + * @param { Object } error + */ + mockError( error ) { + this.error = error; + this.onerror(); + } + + /** + * Allows to mock file upload progress. + */ + mockProgress( progress ) { + this.onprogress( { loaded: progress } ); + } +} + diff --git a/tests/filereader.js b/tests/filereader.js new file mode 100644 index 0000000..5c09ea2 --- /dev/null +++ b/tests/filereader.js @@ -0,0 +1,98 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +import FileReader from '../src/filereader'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { NativeFileReaderMock, createNativeFileMock } from './_utils/mocks'; + +describe( 'FileReader', () => { + let reader, fileMock, nativeReaderMock; + testUtils.createSinonSandbox(); + + beforeEach( () => { + testUtils.sinon.stub( window, 'FileReader', () => { + nativeReaderMock = new NativeFileReaderMock(); + + return nativeReaderMock; + } ); + + fileMock = createNativeFileMock(); + reader = new FileReader(); + } ); + + it( 'should initialize loaded property', () => { + expect( reader.loaded ).to.equal( 0 ); + } ); + + it( 'should update loaded property', () => { + nativeReaderMock.mockProgress( 10 ); + expect( reader.loaded ).to.equal( 10 ); + nativeReaderMock.mockProgress( 20 ); + expect( reader.loaded ).to.equal( 20 ); + nativeReaderMock.mockProgress( 55 ); + expect( reader.loaded ).to.equal( 55 ); + } ); + + describe( 'read', () => { + it( 'should return a promise', () => { + expect( reader.read( fileMock ) ).to.be.instanceOf( Promise ); + } ); + + it( 'should resolve on loading complete', () => { + const promise = reader.read( fileMock ) + .then( result => { + expect( result ).to.equal( 'File contents.' ); + } ); + + nativeReaderMock.mockSuccess( 'File contents.' ); + + return promise; + } ); + + it( 'should reject on loading error', () => { + const promise = reader.read( fileMock ) + .then( () => { + throw new Error( 'Reader should not resolve.' ); + }, ( status ) => { + expect( status ).to.equal( 'error' ); + expect( reader.error ).to.equal( 'Error during file reading.' ); + } ); + + nativeReaderMock.mockError( 'Error during file reading.' ); + + return promise; + } ); + + it( 'should reject promise on loading abort', () => { + const promise = reader.read( fileMock ) + .then( () => { + throw new Error( 'Reader should not resolve.' ); + }, ( status ) => { + expect( status ).to.equal( 'aborted' ); + } ); + + nativeReaderMock.abort(); + + return promise; + } ); + } ); + + describe( 'abort', () => { + it( 'should allow to abort reading', () => { + const promise = reader.read( fileMock ) + .then( () => { + throw new Error( 'Reader should not resolve.' ); + }, ( status ) => { + expect( status ).to.equal( 'aborted' ); + } ); + + reader.abort(); + + return promise; + } ); + } ); +} ); diff --git a/tests/filerepository.js b/tests/filerepository.js new file mode 100644 index 0000000..8c409ce --- /dev/null +++ b/tests/filerepository.js @@ -0,0 +1,368 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import FileRepository from '../src/filerepository'; +import Collection from '@ckeditor/ckeditor5-utils/src/collection'; +import { createNativeFileMock, AdapterMock, NativeFileReaderMock } from './_utils/mocks'; +import log from '@ckeditor/ckeditor5-utils/src/log'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import FileReader from '../src/filereader'; + +describe( 'FileRepository', () => { + let editor, fileRepository, adapterMock; + testUtils.createSinonSandbox(); + + beforeEach( () => { + return VirtualTestEditor.create( { + plugins: [ FileRepository ] + } ) + .then( newEditor => { + editor = newEditor; + fileRepository = editor.plugins.get( 'upload/filerepository' ); + fileRepository.createAdapter = loader => { + adapterMock = new AdapterMock( loader ); + + return adapterMock; + }; + } ); + } ); + + it( 'should be initialized', () => { + expect( fileRepository ).to.be.instanceOf( FileRepository ); + } ); + + describe( 'init', () => { + it( 'should create loaders collection', () => { + expect( fileRepository.loaders ).to.be.instanceOf( Collection ); + } ); + + it( 'should initialize uploaded observable', ( done ) => { + expect( fileRepository.uploaded ).to.equal( 0 ); + + fileRepository.on( 'change:uploaded', ( evt, name, value ) => { + expect( value ).to.equal( 10 ); + done(); + } ); + + fileRepository.uploaded = 10; + } ); + + it( 'should initialize uploadTotal', ( done ) => { + expect( fileRepository.uploadTotal ).to.be.null; + + fileRepository.on( 'change:uploadTotal', ( evt, name, value ) => { + expect( value ).to.equal( 10 ); + done(); + } ); + + fileRepository.uploadTotal = 10; + } ); + + it( 'should initialize uploadedPercent', ( done ) => { + expect( fileRepository.uploadedPercent ).to.equal( 0 ); + + fileRepository.on( 'change:uploadedPercent', ( evt, name, value ) => { + expect( value ).to.equal( 20 ); + done(); + } ); + + fileRepository.uploadTotal = 200; + fileRepository.uploaded = 40; + } ); + } ); + + describe( 'createLoader', () => { + it( 'should show warning if adapter is not present', () => { + const stub = testUtils.sinon.stub( log, 'warn' ); + fileRepository.createAdapter = undefined; + fileRepository.createLoader( createNativeFileMock() ); + + sinon.assert.calledOnce( stub ); + sinon.assert.calledWithExactly( stub, 'FileRepository: no createAdapter method found. Please define it before creating a loader.' ); + } ); + + it( 'should setup listeners to update progress observables', () => { + const loader1 = fileRepository.createLoader( createNativeFileMock() ); + const loader2 = fileRepository.createLoader( createNativeFileMock() ); + const loader3 = fileRepository.createLoader( createNativeFileMock() ); + + loader1.uploaded = 10; + loader1.uploadTotal = 100; + loader2.uploaded = 50; + loader2.uploadTotal = 200; + loader3.uploaded = 40; + loader3.uploadTotal = 200; + + expect( fileRepository.uploaded ).to.equal( 100 ); + expect( fileRepository.uploadTotal ).to.equal( 500 ); + expect( fileRepository.uploadedPercent ).to.equal( 20 ); + } ); + } ); + + describe( 'getLoader', () => { + it( 'should return null if loader does not exists', () => { + const file1 = createNativeFileMock(); + const file2 = createNativeFileMock(); + fileRepository.createLoader( file2 ); + + expect( fileRepository.getLoader( file1 ) ).to.be.null; + } ); + + it( 'should return loader by file instance', () => { + const file = createNativeFileMock(); + const loader = fileRepository.createLoader( file ); + + expect( fileRepository.getLoader( file ) ).to.equal( loader ); + } ); + } ); + + describe( 'destroyLoader', () => { + let file, loader, destroySpy; + + beforeEach( () => { + file = createNativeFileMock(); + loader = fileRepository.createLoader( file ); + destroySpy = testUtils.sinon.spy( loader, '_destroy' ); + } ); + + it( 'should destroy provided loader', () => { + fileRepository.destroyLoader( loader ); + + sinon.assert.calledOnce( destroySpy ); + expect( fileRepository.getLoader( file ) ).to.be.null; + } ); + + it( 'should destroy loader by provided file', () => { + fileRepository.destroyLoader( file ); + + sinon.assert.calledOnce( destroySpy ); + expect( fileRepository.getLoader( file ) ).to.be.null; + } ); + } ); + + describe( 'Loader', () => { + let loader, file, nativeReaderMock; + + beforeEach( () => { + testUtils.sinon.stub( window, 'FileReader', () => { + nativeReaderMock = new NativeFileReaderMock(); + + return nativeReaderMock; + } ); + + file = createNativeFileMock(); + loader = fileRepository.createLoader( file ); + } ); + + describe( 'constructor', () => { + it( 'should initialize id', () => { + expect( loader.id ).to.be.number; + } ); + + it( 'should initialize file', () => { + expect( loader.file ).to.equal( file ); + } ); + + it( 'should initialize adapter', () => { + expect( loader._adapter ).to.equal( adapterMock ); + } ); + + it( 'should initialize reader', () => { + expect( loader._reader ).to.be.instanceOf( FileReader ); + } ); + + it( 'should initialize status observable', ( done ) => { + expect( loader.status ).to.equal( 'idle' ); + + loader.on( 'change:status', ( evt, name, value ) => { + expect( value ).to.equal( 'uploading' ); + done(); + } ); + + loader.status = 'uploading'; + } ); + + it( 'should initialize uploaded observable', ( done ) => { + expect( loader.uploaded ).to.equal( 0 ); + + loader.on( 'change:uploaded', ( evt, name, value ) => { + expect( value ).to.equal( 100 ); + done(); + } ); + + loader.uploaded = 100; + } ); + + it( 'should initialize uploadTotal observable', ( done ) => { + expect( loader.uploadTotal ).to.equal( null ); + + loader.on( 'change:uploadTotal', ( evt, name, value ) => { + expect( value ).to.equal( 100 ); + done(); + } ); + + loader.uploadTotal = 100; + } ); + + it( 'should initialize uploadedPercent observable', ( done ) => { + expect( loader.uploadedPercent ).to.equal( 0 ); + + loader.on( 'change:uploadedPercent', ( evt, name, value ) => { + expect( value ).to.equal( 23 ); + done(); + } ); + + loader.uploaded = 23; + loader.uploadTotal = 100; + } ); + + it( 'should initialize uploadResponse observable', ( done ) => { + expect( loader.uploadResponse ).to.equal( null ); + + loader.on( 'change:uploadResponse', ( evt, name, value ) => { + expect( value ).to.equal( response ); + done(); + } ); + + const response = {}; + loader.uploadResponse = response; + } ); + } ); + + describe( 'read', () => { + it( 'should throw error when status is defferent than idle', () => { + loader.status = 'uploading'; + + expect( () => { + loader.read(); + } ).to.throw( 'filerepository-read-wrong-status: You cannot call read if the status is different than idle.' ); + } ); + + it( 'should return a promise', () => { + expect( loader.read() ).to.be.instanceof( Promise ); + } ); + + it( 'should set status to "reading"', () => { + loader.read(); + + expect( loader.status ).to.equal( 'reading' ); + } ); + + it( 'should reject promise when reading is aborted', () => { + const promise = loader.read().catch( e => { + expect( e ).to.equal( 'aborted' ); + expect( loader.status ).to.equal( 'aborted' ); + } ); + + loader.abort(); + + return promise; + } ); + + it( 'should reject promise on reading error', () => { + const promise = loader.read().catch( e => { + expect( e ).to.equal( 'reading error' ); + expect( loader.status ).to.equal( 'error' ); + } ); + + nativeReaderMock.mockError( 'reading error' ); + + return promise; + } ); + + it( 'should resolve promise on reading complete', () => { + const promise = loader.read() + .then( data => { + expect( data ).to.equal( 'result data' ); + expect( loader.status ).to.equal( 'idle' ); + } ); + + nativeReaderMock.mockSuccess( 'result data' ); + + return promise; + } ); + } ); + + describe( 'upload', () => { + it( 'should throw error when status is defferent than idle', () => { + loader.status = 'reading'; + + expect( () => { + loader.upload(); + } ).to.throw( 'filerepository-upload-wrong-status: You cannot call upload if the status is different than idle.' ); + } ); + + it( 'should return a promise', () => { + expect( loader.upload() ).to.be.instanceof( Promise ); + } ); + + it( 'should set status to "uploading"', () => { + loader.upload(); + + expect( loader.status ).to.equal( 'uploading' ); + } ); + + it( 'should reject promise when uploading is aborted', () => { + const promise = loader.upload().catch( e => { + expect( e ).to.equal( 'aborted' ); + expect( loader.status ).to.equal( 'aborted' ); + } ); + + loader.abort(); + + return promise; + } ); + + it( 'should reject promise on reading error', () => { + const promise = loader.upload().catch( e => { + expect( e ).to.equal( 'uploading error' ); + expect( loader.status ).to.equal( 'error' ); + } ); + + adapterMock.mockError( 'uploading error' ); + + return promise; + } ); + + it( 'should resolve promise on reading complete', () => { + const promise = loader.upload() + .then( data => { + expect( data ).to.equal( 'result data' ); + expect( loader.status ).to.equal( 'idle' ); + } ); + + adapterMock.mockSuccess( 'result data' ); + + return promise; + } ); + + it( 'should monitor upload progress', () => { + const promise = loader.upload() + .then( data => { + expect( data ).to.equal( 'result data' ); + expect( loader.status ).to.equal( 'idle' ); + } ); + + expect( loader.uploaded ).to.equal( 0 ); + expect( loader.uploadTotal ).to.be.null; + + adapterMock.mockProgress( 1, 10 ); + expect( loader.uploaded ).to.equal( 1 ); + expect( loader.uploadTotal ).to.equal( 10 ); + + adapterMock.mockProgress( 6, 10 ); + expect( loader.uploaded ).to.equal( 6 ); + expect( loader.uploadTotal ).to.equal( 10 ); + + adapterMock.mockSuccess( 'result data' ); + + return promise; + } ); + } ); + } ); +} ); diff --git a/tests/imageuploadcommand.js b/tests/imageuploadcommand.js new file mode 100644 index 0000000..bb30444 --- /dev/null +++ b/tests/imageuploadcommand.js @@ -0,0 +1,73 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ImageUploadCommand from '../src/imageuploadcommand'; +import FileRepository from '../src/filerepository'; +import { createNativeFileMock, AdapterMock } from './_utils/mocks'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import Image from '@ckeditor/ckeditor5-image/src/image/imageengine'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +describe( 'ImageUploadCommand', () => { + let editor, command, adapterMock, document, fileRepository; + + beforeEach( () => { + return VirtualTestEditor.create( { + plugins: [ FileRepository, Image, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + command = new ImageUploadCommand( editor ); + fileRepository = editor.plugins.get( FileRepository ); + fileRepository.createAdapter = loader => { + adapterMock = new AdapterMock( loader ); + + return adapterMock; + }; + + document = editor.document; + + const schema = document.schema; + schema.allow( { name: 'image', attributes: [ 'uploadId' ], inside: '$root' } ); + schema.requireAttributes( 'image', [ 'uploadId' ] ); + } ); + } ); + + describe( '_doExecute', () => { + it( 'should insert image', () => { + const file = createNativeFileMock(); + setModelData( document, 'foo[]' ); + command._doExecute( { file } ); + + const id = fileRepository.getLoader( file ).id; + + expect( getModelData( document ) ).to.equal( `foo[]` ); + } ); + + it( 'should not insert non-image', () => { + const file = createNativeFileMock(); + file.type = 'audio/mpeg3'; + setModelData( document, 'foo[]' ); + command._doExecute( { file } ); + + expect( getModelData( document ) ).to.equal( 'foo[]' ); + } ); + + it( 'should allow to provide batch instance', () => { + const batch = document.batch(); + const file = createNativeFileMock(); + const spy = sinon.spy( batch, 'insert' ); + + setModelData( document, 'foo[]' ); + + command._doExecute( { batch, file } ); + const id = fileRepository.getLoader( file ).id; + + expect( getModelData( document ) ).to.equal( `foo[]` ); + sinon.assert.calledOnce( spy ); + } ); + } ); +} ); diff --git a/tests/imageuploadengine.js b/tests/imageuploadengine.js new file mode 100644 index 0000000..84b0816 --- /dev/null +++ b/tests/imageuploadengine.js @@ -0,0 +1,212 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window, setTimeout */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ImageEngine from '@ckeditor/ckeditor5-image/src/image/imageengine'; +import ImageUploadEngine from '../src/imageuploadengine'; +import ImageUploadCommand from '../src/imageuploadcommand'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import DataTransfer from '@ckeditor/ckeditor5-clipboard/src/datatransfer'; +import FileRepository from '../src/filerepository'; +import { AdapterMock, createNativeFileMock, NativeFileReaderMock } from './_utils/mocks'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import imagePlaceholder from '../theme/icons/image_placeholder.svg'; +import { eventNameToConsumableType } from '@ckeditor/ckeditor5-engine/src/conversion/model-to-view-converters'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; + +describe( 'ImageUploadEngine', () => { + const base64Sample = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + let editor, document, fileRepository, viewDocument, nativeReaderMock, loader, adapterMock; + testUtils.createSinonSandbox(); + + beforeEach( () => { + testUtils.sinon.stub( window, 'FileReader', () => { + nativeReaderMock = new NativeFileReaderMock(); + + return nativeReaderMock; + } ); + + return ClassicTestEditor.create( { + plugins: [ ImageEngine, ImageUploadEngine, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + document = editor.document; + viewDocument = editor.editing.view; + + fileRepository = editor.plugins.get( FileRepository ); + fileRepository.createAdapter = newLoader => { + loader = newLoader; + adapterMock = new AdapterMock( loader ); + + return adapterMock; + }; + } ); + } ); + + it( 'should register proper schema rules', () => { + expect( document.schema.check( { name: 'image', attributes: [ 'uploadId' ], inside: '$root' } ) ).to.be.true; + } ); + + it( 'should register imageUpload command', () => { + expect( editor.commands.get( 'imageUpload' ) ).to.be.instanceOf( ImageUploadCommand ); + } ); + + it( 'should execute imageUpload command when image is pasted', () => { + const spy = sinon.spy( editor, 'execute' ); + const fileMock = createNativeFileMock(); + const dataTransfer = new DataTransfer( { files: [ fileMock ] } ); + setModelData( document, 'foo bar baz[]' ); + + viewDocument.fire( 'input', { dataTransfer } ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWith( spy, 'imageUpload' ); + + const id = fileRepository.getLoader( fileMock ).id; + expect( getModelData( document ) ).to.equal( + `foo bar baz[]` + ); + } ); + + it( 'should not execute imageUpload command when file is not an image', () => { + const spy = sinon.spy( editor, 'execute' ); + const viewDocument = editor.editing.view; + const fileMock = { + type: 'media/mp3', + size: 1024 + }; + const dataTransfer = new DataTransfer( { files: [ fileMock ] } ); + setModelData( document, 'foo bar baz[]' ); + + viewDocument.fire( 'input', { dataTransfer } ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should convert image\'s uploadId attribute from model to view', () => { + setModelData( document, '' ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + `` + + '
' + ); + } ); + + it( 'should not convert image\'s uploadId attribute if is consumed already', () => { + editor.editing.modelToView.on( 'addAttribute:uploadId:image', ( evt, data, consumable ) => { + consumable.consume( data.item, eventNameToConsumableType( evt.name ) ); + }, { priority: 'high' } ); + + setModelData( document, '' ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + '' + + '
' ); + } ); + + it( 'should replace placeholder with read data once it is present', ( done ) => { + const file = createNativeFileMock(); + setModelData( document, '{}foo bar' ); + editor.execute( 'imageUpload', { file } ); + + adapterMock.uploadStartedCallback = () => { + expect( getViewData( viewDocument ) ).to.equal( + '
' + + `` + + '
' + + '

{}foo bar

' ); + expect( loader.status ).to.equal( 'uploading' ); + done(); + }; + + expect( loader.status ).to.equal( 'reading' ); + nativeReaderMock.mockSuccess( base64Sample ); + } ); + + it( 'should replace read data with server response once it is present', ( done ) => { + const file = createNativeFileMock(); + setModelData( document, '{}foo bar' ); + editor.execute( 'imageUpload', { file } ); + + adapterMock.uploadStartedCallback = () => { + document.once( 'changesDone', () => { + expect( getViewData( viewDocument ) ).to.equal( + '

{}foo bar

' + ); + expect( loader.status ).to.equal( 'idle' ); + + done(); + } ); + + adapterMock.mockSuccess( { original: 'image.png' } ); + }; + + nativeReaderMock.mockSuccess( base64Sample ); + } ); + + it( 'should fire notification event in case of error', ( done ) => { + const notification = editor.plugins.get( Notification ); + const file = createNativeFileMock(); + + notification.on( 'show:warning', ( evt, data ) => { + expect( data.message ).to.equal( 'Reading error.' ); + evt.stop(); + + done(); + }, { priority: 'high' } ); + + setModelData( document, '{}foo bar' ); + editor.execute( 'imageUpload', { file } ); + + nativeReaderMock.mockError( 'Reading error.' ); + } ); + + it( 'should not fire notification on abort', ( done ) => { + const notification = editor.plugins.get( Notification ); + const file = createNativeFileMock(); + const spy = testUtils.sinon.spy(); + + notification.on( 'show:warning', evt => { + spy(); + evt.stop(); + }, { priority: 'high' } ); + + setModelData( document, '{}foo bar' ); + editor.execute( 'imageUpload', { file } ); + nativeReaderMock.abort(); + + setTimeout( () => { + sinon.assert.notCalled( spy ); + done(); + }, 0 ); + } ); + + it( 'should do nothing if image does not have uploadId', () => { + setModelData( document, '' ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + ); + } ); + + it( 'should allow to customize placeholder image', () => { + const uploadEngine = editor.plugins.get( ImageUploadEngine ); + uploadEngine.placeholder = base64Sample; + setModelData( document, '' ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + `` + + '
' + ); + } ); +} ); diff --git a/tests/manual/imageupload.html b/tests/manual/imageupload.html new file mode 100644 index 0000000..59d9686 --- /dev/null +++ b/tests/manual/imageupload.html @@ -0,0 +1,8 @@ +
+

Image upload

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.

+
+ +
+ +
diff --git a/tests/manual/imageupload.js b/tests/manual/imageupload.js new file mode 100644 index 0000000..807513c --- /dev/null +++ b/tests/manual/imageupload.js @@ -0,0 +1,67 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals document, window, console */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Typing from '@ckeditor/ckeditor5-typing/src/typing'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import Undo from '@ckeditor/ckeditor5-undo/src/undo'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import List from '@ckeditor/ckeditor5-list/src/list'; +import ImageUploadEngine from '../../src/imageuploadengine'; +import { AdapterMock } from '../_utils/mocks'; + +ClassicEditor.create( document.querySelector( '#editor' ), { + plugins: [ + Enter, Typing, Paragraph, Heading, Undo, Bold, Italic, Heading, List, Image, ImageToolbar, Clipboard, + ImageCaption, ImageStyle, ImageUploadEngine + ], + toolbar: [ 'headings', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList' ] +} ) +.then( editor => { + let adapterMock, progress; + const total = 500; + + window.editor = editor; + + const progressButton = document.getElementById( 'progress' ); + + // Register fake adapter. + editor.plugins.get( 'upload/filerepository' ).createAdapter = loader => { + adapterMock = new AdapterMock( loader ); + progress = 0; + loader.on( 'change:uploadedPercent', () => { + console.log( `Loader upload progress: ${ loader.uploadedPercent }%` ); + } ); + + progressButton.removeAttribute( 'disabled' ); + + return adapterMock; + }; + + progressButton.addEventListener( 'click', () => { + if ( adapterMock ) { + progress += 100; + adapterMock.mockProgress( progress, total ); + + if ( progress == total ) { + progressButton.setAttribute( 'disabled', 'true' ); + adapterMock.mockSuccess( { original: './sample.jpg' } ); + } + } + } ); +} ) +.catch( err => { + console.error( err.stack ); +} ); diff --git a/tests/manual/imageupload.md b/tests/manual/imageupload.md new file mode 100644 index 0000000..75749d5 --- /dev/null +++ b/tests/manual/imageupload.md @@ -0,0 +1,7 @@ +## Image upload + +1. Drop an image into editor. +1. Image should be read and displayed. +1. Open console. +1. Press "Upload progress" button couple times to simulate upload process. +1. After uploading is complete your image should be replaced with sample image from server. diff --git a/tests/manual/sample.jpg b/tests/manual/sample.jpg new file mode 100644 index 0000000..b77d07e Binary files /dev/null and b/tests/manual/sample.jpg differ diff --git a/tests/utils.js b/tests/utils.js new file mode 100644 index 0000000..e50dcb8 --- /dev/null +++ b/tests/utils.js @@ -0,0 +1,31 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { isImageType } from '../src/utils'; + +describe( 'utils', () => { + describe( 'isImageType', () => { + it( 'should return true for png mime type', () => { + expect( isImageType( { type: 'image/png' } ) ).to.be.true; + } ); + + it( 'should return true for jpeg mime type', () => { + expect( isImageType( { type: 'image/jpeg' } ) ).to.be.true; + } ); + + it( 'should return true for gif mime type', () => { + expect( isImageType( { type: 'image/gif' } ) ).to.be.true; + } ); + + it( 'should return true for bmp mime type', () => { + expect( isImageType( { type: 'image/bmp' } ) ).to.be.true; + } ); + + it( 'should return false for other mime types', () => { + expect( isImageType( { type: 'audio/mp3' } ) ).to.be.false; + expect( isImageType( { type: 'video/mpeg' } ) ).to.be.false; + } ); + } ); +} ); diff --git a/theme/icons/image_placeholder.svg b/theme/icons/image_placeholder.svg new file mode 100644 index 0000000..3b40f1f --- /dev/null +++ b/theme/icons/image_placeholder.svg @@ -0,0 +1,15 @@ + + + + image_placeholder_ck5 + Created with Sketch. + + + + + + Uploading image… + + + + \ No newline at end of file