diff --git a/_regroup/ckeditor5-file-upload/package.json b/_regroup/ckeditor5-file-upload/package.json new file mode 100644 index 000000000..146106462 --- /dev/null +++ b/_regroup/ckeditor5-file-upload/package.json @@ -0,0 +1,18 @@ +{ + "name": "@ckeditor/ckeditor5-file-upload", + "version": "1.0.0", + "description": "File Upload for CKEditor5", + "main": "src/fileupload.js", + "keywords": [ "ckeditor5" ], + "dependencies": { + "@ckeditor/ckeditor5-core": "^38.0.1", + "@ckeditor/ckeditor5-upload": "^38.0.1", + "@ckeditor/ckeditor5-ui": "^38.0.1", + "@ckeditor/ckeditor5-clipboard": "^38.0.1", + "@ckeditor/ckeditor5-engine": "^38.0.1", + "@ckeditor/ckeditor5-utils": "^38.0.1", + "@ckeditor/ckeditor5-widget": "^38.0.1" + }, + "license": "GPL-3.0", + "devDependencies": {} +} diff --git a/_regroup/ckeditor5-file-upload/src/fileuploadcommand.js b/_regroup/ckeditor5-file-upload/src/fileuploadcommand.js new file mode 100644 index 000000000..e0eec148c --- /dev/null +++ b/_regroup/ckeditor5-file-upload/src/fileuploadcommand.js @@ -0,0 +1,53 @@ +import { FileRepository } from 'ckeditor5/src/upload'; +import { Command } from 'ckeditor5/src/core'; + +export default class FileUploadCommand extends Command { + refresh() { + this.isEnabled = true; + } + + /** + * Executes the command. + * + * @fires execute + * @param {Object} options Options for the executed command. + * @param {File|Array.} options.file The file or an array of files to upload. + */ + execute( options ) { + const editor = this.editor; + const model = editor.model; + + const fileRepository = editor.plugins.get( FileRepository ); + + model.change( writer => { + const filesToUpload = options.file; + for ( const file of filesToUpload ) { + uploadFile( writer, model, fileRepository, file ); + } + } ); + } +} + +/** + * Handles uploading single file. + * + * @param {module:engine/model/writer~writer} writer + * @param {module:engine/model/model~Model} model + * @param {File} file + */ +function uploadFile( writer, model, fileRepository, file ) { + const loader = fileRepository.createLoader( file ); + + // Do not throw when upload adapter is not set. FileRepository will log an error anyway. + if ( !loader ) { + return; + } + + insertFileLink( writer, model, { href: '', uploadId: loader.id }, file ); +} + +function insertFileLink( writer, model, attributes = {}, file ) { + const placeholder = writer.createElement( 'reference', attributes ); + model.insertContent( placeholder, model.document.selection ); + writer.insertText( ' ', placeholder, 'after' ); +} diff --git a/_regroup/ckeditor5-file-upload/src/fileuploadediting.js b/_regroup/ckeditor5-file-upload/src/fileuploadediting.js new file mode 100644 index 000000000..ee1e0ae11 --- /dev/null +++ b/_regroup/ckeditor5-file-upload/src/fileuploadediting.js @@ -0,0 +1,261 @@ +import { Plugin } from 'ckeditor5/src/core'; +import { FileRepository } from 'ckeditor5/src/upload'; +import { Notification } from 'ckeditor5/src/ui'; +import { Clipboard } from 'ckeditor5/src/clipboard'; +import { UpcastWriter } from 'ckeditor5/src/engine'; + +import FileUploadCommand from './fileuploadcommand'; + +export default class FileUploadEditing extends Plugin { + static get requires() { + return [ FileRepository, Notification, Clipboard ]; + } + + static get pluginName() { + return 'FileUploadEditing'; + } + + init() { + const editor = this.editor; + const doc = editor.model.document; + const conversion = editor.conversion; + const fileRepository = editor.plugins.get( FileRepository ); + + // Register fileUpload command. + editor.commands.add( 'fileUpload', new FileUploadCommand( editor ) ); + + // Register upcast converter for uploadId. + conversion.for( 'upcast' ) + .attributeToAttribute( { + view: { + name: 'a', + key: 'uploadId' + }, + model: 'uploadId' + } ); + + this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => { + // Skip if non-empty HTML data is included. + // https://github.com/ckeditor/ckeditor5-upload/issues/68 + if ( isHtmlIncluded( data.dataTransfer ) ) { + return; + } + + const files = Array.from( data.dataTransfer.files ); + + editor.model.change( writer => { + // Set selection to paste target. + if ( data.targetRanges ) { + writer.setSelection( data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) ) ); + } + + if ( files.length ) { + evt.stop(); + + // Upload files after the selection has changed in order to ensure the command's state is refreshed. + editor.model.enqueueChange( 'default', () => { + editor.execute( 'fileUpload', { file: files } ); + } ); + } + } ); + } ); + + this.listenTo( editor.plugins.get( Clipboard ), 'inputTransformation', ( evt, data ) => { + const fetchableFiles = Array.from( editor.editing.view.createRangeIn( data.content ) ) + .filter( value => isLocalFile( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) ) + .map( value => { + return { promise: fetchLocalFile( value.item ), fileElement: value.item }; + } ); + + if ( !fetchableFiles.length ) { + return; + } + + const writer = new UpcastWriter(); + + for ( const fetchableFile of fetchableFiles ) { + // Set attribute marking that the file was processed already. + writer.setAttribute( 'uploadProcessed', true, fetchableFile.fileElement ); + + const loader = fileRepository.createLoader( fetchableFile.promise ); + + if ( loader ) { + writer.setAttribute( 'href', '', fetchableFile.fileElement ); + writer.setAttribute( 'uploadId', loader.id, fetchableFile.fileElement ); + } + } + } ); + + // Prevents from the browser redirecting to the dropped file. + editor.editing.view.document.on( 'dragover', ( evt, data ) => { + data.preventDefault(); + } ); + + // Upload placeholder files that appeared in the model. + doc.on( 'change', () => { + const changes = doc.differ.getChanges( { includeChangesInGraveyard: true } ); + for ( const entry of changes ) { + if ( entry.type == 'insert' ) { + const item = entry.position.nodeAfter; + if ( item ) { + const isInGraveyard = entry.position.root.rootName == '$graveyard'; + for ( const file of getFileLinksFromChangeItem( editor, item ) ) { + // Check if the file element still has upload id. + const uploadId = file.getAttribute( 'uploadId' ); + if ( !uploadId ) { + continue; + } + + // Check if the file is loaded on this client. + const loader = fileRepository.loaders.get( uploadId ); + + if ( !loader ) { + continue; + } + + if ( isInGraveyard ) { + // If the file was inserted to the graveyard - abort the loading process. + loader.abort(); + } else if ( loader.status == 'idle' ) { + // If the file was inserted into content and has not been loaded yet, start loading it. + this._readAndUpload( loader, file ); + } + } + } + } + } + } ); + } + + _readAndUpload( loader, fileElement ) { + const editor = this.editor; + const model = editor.model; + const t = editor.locale.t; + const fileRepository = editor.plugins.get( FileRepository ); + const notification = editor.plugins.get( Notification ); + + model.enqueueChange( 'transparent', writer => { + writer.setAttribute( 'uploadStatus', 'reading', fileElement ); + } ); + + return loader.read() + .then( () => { + const promise = loader.upload(); + + model.enqueueChange( 'transparent', writer => { + writer.setAttribute( 'uploadStatus', 'uploading', fileElement ); + } ); + + return promise; + } ) + .then( data => { + model.enqueueChange( 'transparent', writer => { + writer.setAttributes( { uploadStatus: 'complete', href: data.default }, fileElement ); + } ); + + clean(); + + // wait a bit so that froca has time to load the changes + return new Promise(res => setTimeout(res, 100)); + } ) + .then(() => { + // we're correctly updating the model, but the view remains broken, + // hack around it is to force CKEditor to reload the now updated HTML + editor.setData(editor.getData()); + }) + .catch( error => { + // If status is not 'error' nor 'aborted' - throw error, because it means that something else went wrong, + // it might be a generic error, and it would be real pain to find what is going on. + if ( loader.status !== 'error' && loader.status !== 'aborted' ) { + throw error; + } + + // Might be 'aborted'. + if ( loader.status == 'error' && error ) { + notification.showWarning( error, { + title: t( 'Upload failed' ), + namespace: 'upload' + } ); + } + + clean(); + + // Permanently remove file from insertion batch. + model.enqueueChange( 'transparent', writer => { + writer.remove( fileElement ); + } ); + } ); + + function clean() { + model.enqueueChange( 'transparent', writer => { + writer.removeAttribute( 'uploadId', fileElement ); + writer.removeAttribute( 'uploadStatus', fileElement ); + } ); + + fileRepository.destroyLoader( loader ); + } + } +} + +function fetchLocalFile( link ) { + return new Promise( ( resolve, reject ) => { + const href = link.getAttribute( 'href' ); + + // Fetch works asynchronously and so does not block the browser UI when processing data. + fetch( href ) + .then( resource => resource.blob() ) + .then( blob => { + const mimeType = getFileMimeType( blob, href ); + const ext = mimeType.replace( 'file/', '' ); + const filename = `file.${ ext }`; + const file = createFileFromBlob( blob, filename, mimeType ); + + file ? resolve( file ) : reject(); + } ) + .catch( reject ); + } ); +} + +function isLocalFile( node ) { + if ( !node.is( 'element', 'a' ) || !node.getAttribute( 'href' ) ) { + return false; + } + + return node.getAttribute( 'href' ); +} + +function getFileMimeType( blob, src ) { + if ( blob.type ) { + return blob.type; + } else if ( src.match( /data:(image\/\w+);base64/ ) ) { + return src.match( /data:(image\/\w+);base64/ )[ 1 ].toLowerCase(); + } else { + throw new Error( 'Could not retrieve mime type for file.' ); + } +} + +function createFileFromBlob( blob, filename, mimeType ) { + try { + return new File( [ blob ], filename, { type: mimeType } ); + } catch ( err ) { + // Edge does not support `File` constructor ATM, see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/. + // However, the `File` function is present (so cannot be checked with `!window.File` or `typeof File === 'function'`), but + // calling it with `new File( ... )` throws an error. This try-catch prevents that. Also when the function will + // be implemented correctly in Edge the code will start working without any changes (see #247). + return null; + } +} + +// Returns `true` if non-empty `text/html` is included in the data transfer. +// +// @param {module:clipboard/datatransfer~DataTransfer} dataTransfer +// @returns {Boolean} +export function isHtmlIncluded( dataTransfer ) { + return Array.from( dataTransfer.types ).includes( 'text/html' ) && dataTransfer.getData( 'text/html' ) !== ''; +} + +function getFileLinksFromChangeItem( editor, item ) { + return Array.from( editor.model.createRangeOn( item ) ) + .filter( value => value.item.hasAttribute( 'href' ) ) + .map( value => value.item ); +} diff --git a/_regroup/ckeditor5-file-upload/theme/fileupload.css b/_regroup/ckeditor5-file-upload/theme/fileupload.css new file mode 100644 index 000000000..0be41d6f2 --- /dev/null +++ b/_regroup/ckeditor5-file-upload/theme/fileupload.css @@ -0,0 +1,30 @@ +.ck.ck-sticky-panel > .ck-progress-bar { + display: flex; + flex-direction: row; + justify-content: space-between; + + border-left: 1px solid var(--ck-color-base-border); + border-top: 1px solid var(--ck-color-base-border); + border-right: 1px solid var(--ck-color-base-border); +} + +.ck.ck-sticky-panel > .ck-progress-bar > .ck-uploading-progress { + align-self: center; + padding: 3px 5px; + font-weight: bold; + + color: var(--ck-color-base-foreground); + background-color: var(--ck-color-base-border); + + transition-property: width; + transition-duration: .5s; + transition-timing-function: linear; + +} + +.ck.ck-sticky-panel > .ck-progress-bar > .ck-uploading-cancel { + align-self: flex-end; + padding: 0 5px; + font-weight: bold; + color: var(--ck-color-base-error); +} \ No newline at end of file diff --git a/_regroup/ckeditor5-file-upload/theme/icons/fileupload.svg b/_regroup/ckeditor5-file-upload/theme/icons/fileupload.svg new file mode 100644 index 000000000..a08b1ceef --- /dev/null +++ b/_regroup/ckeditor5-file-upload/theme/icons/fileupload.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/_regroup/ckeditor5-file-upload/theme/progressbarview.js b/_regroup/ckeditor5-file-upload/theme/progressbarview.js new file mode 100644 index 000000000..38419626a --- /dev/null +++ b/_regroup/ckeditor5-file-upload/theme/progressbarview.js @@ -0,0 +1,68 @@ +import View from "@ckeditor/ckeditor5-ui/src/view"; +import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit'; +import ButtonView from "@ckeditor/ckeditor5-ui/src/button/buttonview"; + +import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg'; + +const toPx = toUnit('%'); + +export default class ProgressBarView extends View { + constructor(locale) { + super(locale); + + const bind = this.bindTemplate; + this.cancelButton = this._createCancelButton(locale); + + // Views define their interface (state) using observable attributes. + this.set('width', 100); + this.set('customWidth', 0); + + + this.setTemplate({ + tag: 'div', + + // The element of the view can be defined with its children. + children: [ + { + tag: 'div', + children: ['Uploading...'], + attributes: { + class: ['ck-uploading-progress'], + style: { + width: bind.to('customWidth', toPx), + } + } + }, + this.cancelButton, + ], + attributes: { + class: [ + 'ck-progress-bar', + + // Observable attributes control the state of the view in DOM. + bind.to('elementClass') + ], + style: { + width: bind.to('width', toPx), + } + } + }); + } + + _createCancelButton(locale) { + const view = new ButtonView(locale); + view.set({ + icon: cancelIcon, + tooltip: true, + label: 'Cancel', + attributes: { + class: ['ck', 'ck-button', 'ck-off', 'ck-button-cancel', 'ck-uploading-cancel'] + } + }); + + view.on('execute', () => { + this.fire('cancel') + }); + return view; + } +} diff --git a/_regroup/ckeditor5-file-upload/uploadfileplugin.js b/_regroup/ckeditor5-file-upload/uploadfileplugin.js new file mode 100644 index 000000000..f4616b15d --- /dev/null +++ b/_regroup/ckeditor5-file-upload/uploadfileplugin.js @@ -0,0 +1,12 @@ +import { Plugin } from 'ckeditor5/src/core'; +import FileUploadEditing from './src/fileuploadediting'; + +export default class Uploadfileplugin extends Plugin { + static get requires() { + return [ FileUploadEditing ]; + } + + static get pluginName() { + return 'fileUploadPlugin'; + } +}