From f888d4cebc78e7f7ae825b930b686cb893a29702 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Thu, 23 Nov 2023 16:02:30 +0100 Subject: [PATCH 01/10] Editing images not in CKBox: initial implementation. --- packages/ckeditor5-ckbox/src/ckboxconfig.ts | 7 + .../ckboximageedit/ckboximageeditcommand.ts | 66 ++++--- .../src/ckboximageedit/utils.ts | 113 ++++++++++++ .../ckeditor5-ckbox/src/ckboxuploadadapter.ts | 109 +++--------- packages/ckeditor5-ckbox/src/utils.ts | 168 ++++++++++++++++++ .../ckboximageedit/ckboximageeditcommand.js | 87 ++++----- .../ckeditor5-ckbox/tests/manual/ckbox.html | 2 +- .../ckeditor5-ckbox/tests/manual/ckbox.js | 3 +- 8 files changed, 404 insertions(+), 151 deletions(-) create mode 100644 packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts diff --git a/packages/ckeditor5-ckbox/src/ckboxconfig.ts b/packages/ckeditor5-ckbox/src/ckboxconfig.ts index 2c41560c19a..8b6047c41d9 100644 --- a/packages/ckeditor5-ckbox/src/ckboxconfig.ts +++ b/packages/ckeditor5-ckbox/src/ckboxconfig.ts @@ -98,6 +98,13 @@ export interface CKBoxConfig { */ forceDemoLabel?: boolean; + /** + * TODO. + * + * @default [] + */ + allowExternalImagesEditing?: RegExp | Array | ( ( src: string ) => boolean ); + /** * Inserts the unique asset ID as the `data-ckbox-resource-id` attribute. To disable this behavior, set it to `true`. * diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts index 0ea42a46927..648bd7e63d9 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts @@ -21,6 +21,7 @@ import { prepareImageAssetAttributes } from '../ckboxcommand'; import type { CKBoxRawAssetDefinition, CKBoxRawAssetDataDefinition } from '../ckboxconfig'; import type { ImageUtils } from '@ckeditor/ckeditor5-image'; +import { createEditabilityChecker, getImageEditorMountOptions } from './utils'; /** * The CKBox edit image command. @@ -41,7 +42,10 @@ export default class CKBoxImageEditCommand extends Command { /** * The states of image processing in progress. */ - private _processInProgress = new Map(); + private _processInProgress = new Set(); + + /** TODO */ + private _canEdit: ( element: ModelElement ) => boolean; /** * @inheritDoc @@ -51,6 +55,8 @@ export default class CKBoxImageEditCommand extends Command { this.value = false; + this._canEdit = createEditabilityChecker( editor.config.get( 'ckbox.allowExternalImagesEditing' ) ); + this._prepareListeners(); } @@ -63,18 +69,11 @@ export default class CKBoxImageEditCommand extends Command { this.value = this._getValue(); const selectedElement = editor.model.document.selection.getSelectedElement(); - const isImageElement = selectedElement && ( - selectedElement.is( 'element', 'imageInline' ) || - selectedElement.is( 'element', 'imageBlock' ) - ); - const isBeingProcessed = Array.from( this._processInProgress.values() ) - .some( ( { element } ) => isEqual( element, selectedElement ) ); - if ( isImageElement && selectedElement.hasAttribute( 'ckboxImageId' ) && !isBeingProcessed ) { - this.isEnabled = true; - } else { - this.isEnabled = false; - } + this.isEnabled = + !!selectedElement && + this._canEdit( selectedElement ) && + !this._checkIfElementIsBeingProcessed( selectedElement ); } /** @@ -85,21 +84,27 @@ export default class CKBoxImageEditCommand extends Command { return; } + const wrapper = createElement( document, 'div', { class: 'ck ckbox-wrapper' } ); + + this._wrapper = wrapper; this.value = true; - this._wrapper = createElement( document, 'div', { class: 'ck ckbox-wrapper' } ); document.body.appendChild( this._wrapper ); const imageElement = this.editor.model.document.selection.getSelectedElement()!; - const ckboxImageId = imageElement.getAttribute( 'ckboxImageId' ) as string; const processingState: ProcessingState = { - ckboxImageId, element: imageElement, controller: new AbortController() }; - window.CKBox.mountImageEditor( this._wrapper, this._prepareOptions( processingState ) ); + this._prepareOptions( processingState ).then( + options => window.CKBox.mountImageEditor( wrapper, options ), + error => { + console.error( error ); + this._handleImageEditorClose(); + } + ); } /** @@ -125,12 +130,22 @@ export default class CKBoxImageEditCommand extends Command { /** * Creates the options object for the CKBox Image Editor dialog. */ - private _prepareOptions( state: ProcessingState ) { + private async _prepareOptions( state: ProcessingState ) { const editor = this.editor; const ckboxConfig = editor.config.get( 'ckbox' )!; + const ckboxEditing = editor.plugins.get( CKBoxEditing ); + const token = ckboxEditing.getToken(); + + const imageMountOptions = await getImageEditorMountOptions( state.element, { + token, + serviceOrigin: ckboxConfig.serviceOrigin!, + defaultCategories: ckboxConfig.defaultUploadCategories, + defaultWorkspaceId: ckboxConfig.defaultUploadWorkspaceId, + /** TODO */ signal: ( new AbortController() ).signal + } ); return { - assetId: state.ckboxImageId, + ...imageMountOptions, imageEditing: { allowOverwrite: false }, @@ -169,6 +184,16 @@ export default class CKBoxImageEditCommand extends Command { return states; } + private _checkIfElementIsBeingProcessed( selectedElement: ModelElement ) { + for ( const { element } of this._processInProgress ) { + if ( isEqual( element, selectedElement ) ) { + return true; + } + } + + return false; + } + /** * Closes the CKBox Image Editor dialog. */ @@ -193,7 +218,7 @@ export default class CKBoxImageEditCommand extends Command { const pendingActions = this.editor.plugins.get( PendingActions ); const action = pendingActions.add( t( 'Processing the edited image.' ) ); - this._processInProgress.set( state.ckboxImageId, state ); + this._processInProgress.add( state ); this._showImageProcessingIndicator( state.element, asset ); this.refresh(); @@ -219,7 +244,7 @@ export default class CKBoxImageEditCommand extends Command { } } ).finally( () => { - this._processInProgress.delete( state.ckboxImageId ); + this._processInProgress.delete( state ); pendingActions.remove( action ); this.refresh(); } ); @@ -347,7 +372,6 @@ export default class CKBoxImageEditCommand extends Command { } interface ProcessingState { - ckboxImageId: string; element: ModelElement; controller: AbortController; } diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts new file mode 100644 index 00000000000..98cf354fff5 --- /dev/null +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts @@ -0,0 +1,113 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ckbox/ckboximageedit/utils + */ + +import { toArray } from 'ckeditor5/src/utils'; +import { getCategoryIdForFile, getWorkspaceId } from '../utils'; + +import type { Element } from 'ckeditor5/src/engine'; +import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; +import type { CKBoxConfig } from '../ckboxconfig'; + +/** + * @internal + */ +export function createEditabilityChecker( + allowExternalImagesEditing: CKBoxConfig[ 'allowExternalImagesEditing' ] +): ( element: Element ) => boolean { + const checkUrl = createUrlChecker(); + + return element => { + const isImageElement = + element.is( 'element', 'imageInline' ) || + element.is( 'element', 'imageBlock' ); + + if ( !isImageElement ) { + return false; + } + + if ( element.hasAttribute( 'ckboxImageId' ) ) { + return true; + } + + if ( element.hasAttribute( 'src' ) ) { + return checkUrl( element.getAttribute( 'src' ) as string ); + } + + return false; + }; + + function createUrlChecker(): ( src: string ) => boolean { + if ( !allowExternalImagesEditing ) { + return () => false; + } + + if ( typeof allowExternalImagesEditing == 'function' ) { + return allowExternalImagesEditing; + } + + const urlRegExps = toArray( allowExternalImagesEditing ); + + return src => urlRegExps.some( pattern => + src.match( pattern ) || + src.replace( /^https?:\/\//, '' ).match( pattern ) + ); + } +} + +/** + * @internal + */ +export async function getImageEditorMountOptions( element: Element, options: { + token: InitializedToken; + serviceOrigin: string; + signal: AbortSignal; + defaultWorkspaceId?: string; + defaultCategories?: Record> | null; +} ): Promise { + const ckboxImageId = element.getAttribute( 'ckboxImageId' ); + + if ( ckboxImageId ) { + return { + assetId: ckboxImageId + }; + } + + const imageUrl = element.getAttribute( 'src' ) as string; + const uploadCategoryId = await getUploadCategoryId( imageUrl, options ); + + return { + imageUrl, + uploadCategoryId + }; +} + +async function getUploadCategoryId( imageUrl: string, options: Parameters[ 1 ] ): Promise { + const { token, serviceOrigin, signal, defaultWorkspaceId, defaultCategories } = options; + + // TODO: refactor this (it's duplicated in upload adapter). + const workspaceId = getWorkspaceId( token, defaultWorkspaceId ); + + if ( !workspaceId ) { + throw new Error( 'TODO' ); + } + + const category = await getCategoryIdForFile( imageUrl, { + token, + serviceOrigin, + workspaceId, + signal, + defaultCategories + } ); + + if ( !category ) { + throw new Error( 'TODO' ); + } + + return category; +} diff --git a/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts b/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts index 17e7bff386f..874cb25dbcc 100644 --- a/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts +++ b/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts @@ -22,7 +22,14 @@ import type { ImageUploadCompleteEvent, ImageUploadEditing } from '@ckeditor/cke import { logError } from 'ckeditor5/src/utils'; import CKBoxEditing from './ckboxediting'; -import { getImageUrls, getWorkspaceId, sendHttpRequest } from './utils'; +import { + getAvailableCategories, + getImageUrls, + getWorkspaceId, + sendHttpRequest, + getCategoryIdForFile, + type AvailableCategory +} from './utils'; /** * A plugin that enables file uploads in CKEditor 5 using the CKBox server–side connector. @@ -154,86 +161,30 @@ class Adapter implements UploadAdapter { * * If the API returns limited results, the method will collect all items. */ - public async getAvailableCategories( offset: number = 0 ): Promise> { - const ITEMS_PER_REQUEST = 50; - const categoryUrl = new URL( 'categories', this.serviceOrigin ); - - categoryUrl.searchParams.set( 'limit', ITEMS_PER_REQUEST.toString() ); - categoryUrl.searchParams.set( 'offset', offset.toString() ); - categoryUrl.searchParams.set( 'workspaceId', this.getWorkspaceId() ); - - return sendHttpRequest( { - url: categoryUrl, + public async getAvailableCategories( offset: number = 0 ): Promise | null> { + return getAvailableCategories( { + token: this.token, + serviceOrigin: this.serviceOrigin, + workspaceId: this.getWorkspaceId(), signal: this.controller.signal, - authorization: this.token.value - } ) - .then( async data => { - const remainingItems = data.totalCount - ( offset + ITEMS_PER_REQUEST ); - - if ( remainingItems > 0 ) { - const offsetItems = await this.getAvailableCategories( offset + ITEMS_PER_REQUEST ); - - return [ - ...data.items, - ...offsetItems - ]; - } - - return data.items; - } ) - .catch( () => { - this.controller.signal.throwIfAborted(); - - /** - * Fetching a list of available categories with which an uploaded file can be associated failed. - * - * @error ckbox-fetch-category-http-error - */ - logError( 'ckbox-fetch-category-http-error' ); - } ); + offset + } ); } /** * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code. */ public async getCategoryIdForFile( file: File ): Promise { - const extension = getFileExtension( file.name ); - const allCategories = await this.getAvailableCategories(); - - // Couldn't fetch all categories. Perhaps the authorization token is invalid. - if ( !allCategories ) { - return null; - } - // The plugin allows defining to which category the uploaded file should be assigned. const defaultCategories = this.editor.config.get( 'ckbox.defaultUploadCategories' ); - // If a user specifies the plugin configuration, find the first category that accepts the uploaded file. - if ( defaultCategories ) { - const userCategory = Object.keys( defaultCategories ).find( category => { - return defaultCategories[ category ].find( e => e.toLowerCase() == extension ); - } ); - - // If found, return its ID if the category exists on the server side. - if ( userCategory ) { - const serverCategory = allCategories.find( category => category.id === userCategory || category.name === userCategory ); - - if ( !serverCategory ) { - return null; - } - - return serverCategory.id; - } - } - - // Otherwise, find the first category that accepts the uploaded file and returns its ID. - const category = allCategories.find( category => category.extensions.find( e => e.toLowerCase() == extension ) ); - - if ( !category ) { - return null; - } - - return category.id; + return getCategoryIdForFile( file, { + token: this.token, + serviceOrigin: this.serviceOrigin, + workspaceId: this.getWorkspaceId(), + signal: this.controller.signal, + defaultCategories + } ); } /** @@ -300,19 +251,3 @@ class Adapter implements UploadAdapter { this.controller.abort(); } } - -export interface AvailableCategory { - id: string; - name: string; - extensions: Array; -} - -/** - * Returns an extension from the given value. - */ -function getFileExtension( value: string ) { - const extensionRegExp = /\.(?[^.]+)$/; - const match = value.match( extensionRegExp ); - - return match!.groups!.ext.toLowerCase(); -} diff --git a/packages/ckeditor5-ckbox/src/utils.ts b/packages/ckeditor5-ckbox/src/utils.ts index 48b2cfb72af..bf5ecf81fd4 100644 --- a/packages/ckeditor5-ckbox/src/utils.ts +++ b/packages/ckeditor5-ckbox/src/utils.ts @@ -12,6 +12,7 @@ import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; import type { CKBoxImageUrls } from './ckboxconfig'; +import { logError } from 'ckeditor5/src/utils'; import { decode } from 'blurhash'; /** @@ -194,3 +195,170 @@ export function sendHttpRequest( { xhr.send( data ); } ); } + +/** + * Resolves a promise with an array containing available categories with which the uploaded file can be associated. + * + * If the API returns limited results, the method will collect all items. + * + * @internal + */ +export async function getAvailableCategories( options: { + token: InitializedToken; + serviceOrigin: string; + workspaceId: string; + signal: AbortSignal; + offset?: number; +} ): Promise> { + const ITEMS_PER_REQUEST = 50; + const { token, serviceOrigin, workspaceId, signal, offset = 0 } = options; + const categoryUrl = new URL( 'categories', serviceOrigin ); + + categoryUrl.searchParams.set( 'limit', ITEMS_PER_REQUEST.toString() ); + categoryUrl.searchParams.set( 'offset', offset.toString() ); + categoryUrl.searchParams.set( 'workspaceId', workspaceId ); + + return sendHttpRequest( { + url: categoryUrl, + signal, + authorization: token.value + } ) + .then( async data => { + const remainingItems = data.totalCount - ( offset + ITEMS_PER_REQUEST ); + + if ( remainingItems > 0 ) { + const offsetItems = await getAvailableCategories( { + ...options, + offset: offset + ITEMS_PER_REQUEST + } ); + + return [ + ...data.items, + ...offsetItems + ]; + } + + return data.items; + } ) + .catch( () => { + signal.throwIfAborted(); + + /** + * Fetching a list of available categories with which an uploaded file can be associated failed. + * + * @error ckbox-fetch-category-http-error + */ + logError( 'ckbox-fetch-category-http-error' ); + } ); +} + +/** + * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code. + * + * @internal + */ +export async function getCategoryIdForFile( fileOrUrl: File | string, options: { + token: InitializedToken; + serviceOrigin: string; + workspaceId: string; + signal: AbortSignal; + defaultCategories?: Record> | null; +} ): Promise { + const { defaultCategories, signal } = options; + + const allCategoriesPromise = getAvailableCategories( options ); + + const extension = typeof fileOrUrl == 'string' ? + convertMimeTypeToExtension( await getContentTypeOfUrl( fileOrUrl, { signal } ) ) : + getFileExtension( fileOrUrl ); + + const allCategories = await allCategoriesPromise; + + // Couldn't fetch all categories. Perhaps the authorization token is invalid. + if ( !allCategories ) { + return null; + } + + // If a user specifies the plugin configuration, find the first category that accepts the uploaded file. + if ( defaultCategories ) { + const userCategory = Object.keys( defaultCategories ).find( category => { + return defaultCategories[ category ].find( e => e.toLowerCase() == extension ); + } ); + + // If found, return its ID if the category exists on the server side. + if ( userCategory ) { + const serverCategory = allCategories.find( category => category.id === userCategory || category.name === userCategory ); + + if ( !serverCategory ) { + return null; + } + + return serverCategory.id; + } + } + + // Otherwise, find the first category that accepts the uploaded file and returns its ID. + const category = allCategories.find( category => category.extensions.find( e => e.toLowerCase() == extension ) ); + + if ( !category ) { + return null; + } + + return category.id; +} + +/** + * @internal + */ +export interface AvailableCategory { + id: string; + name: string; + extensions: Array; +} + +const MIME_TO_EXTENSION: Record = { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tiff' +}; + +function convertMimeTypeToExtension( mimeType: string ) { + const result = MIME_TO_EXTENSION[ mimeType ]; + + if ( !result ) { + throw new Error( 'TODO' ); + } + + return result; +} + +/** + * Gets the Content-Type of the specified url. + */ +async function getContentTypeOfUrl( url: string, options: { signal: AbortSignal } ): Promise { + const response = await fetch( url, { + method: 'HEAD', + cache: 'force-cache', + ...options + } ); + + if ( !response.ok ) { + throw new Error( `HTTP error. Status: ${ response.status }` ); + } + + return response.headers.get( 'content-type' ) || ''; +} + +/** + * Returns an extension from the given value. + */ +function getFileExtension( file: File ) { + const fileName = file.name; + const extensionRegExp = /\.(?[^.]+)$/; + const match = fileName.match( extensionRegExp ); + + return match!.groups!.ext.toLowerCase(); +} diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js index f60b795ab9d..bbfa5b7665b 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global window, console, btoa, AbortController */ +/* global window, console, btoa, setTimeout, AbortController */ import { global } from '@ckeditor/ckeditor5-utils'; import { Command } from 'ckeditor5/src/core'; @@ -26,8 +26,8 @@ import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock import _ from 'lodash-es'; import CloudServicesCoreMock from '../_utils/cloudservicescoremock'; import CKBoxEditing from '../../src/ckboxediting'; +import CKBoxImageEditEditing from '../../src/ckboximageedit/ckboximageeditediting'; -import CKBoxImageEditCommand from '../../src/ckboximageedit/ckboximageeditcommand'; import { blurHashToDataUrl } from '../../src/utils'; const CKBOX_API_URL = 'https://upload.example.com'; @@ -65,7 +65,8 @@ describe( 'CKBoxImageEditCommand', () => { PictureEditing, ImageUploadEditing, ImageUploadProgress, - CKBoxEditing + CKBoxEditing, + CKBoxImageEditEditing ], ckbox: { serviceOrigin: CKBOX_API_URL, @@ -76,9 +77,8 @@ describe( 'CKBoxImageEditCommand', () => { ] } ); - command = new CKBoxImageEditCommand( editor ); + command = editor.commands.get( 'ckboxImageEdit' ); command.isEnabled = true; - editor.commands.add( 'ckboxImageEdit', command ); model = editor.model; dataMock = { @@ -139,18 +139,23 @@ describe( 'CKBoxImageEditCommand', () => { } ); describe( 'execute', () => { - it( 'should open CKBox image editor', () => { + it( 'should open CKBox image editor', async () => { setModelData( model, '[]' ); command.execute(); + await new Promise( resolve => setTimeout( resolve, 0 ) ); + expect( window.CKBox.mountImageEditor.callCount ).to.equal( 1 ); + expect( window.CKBox.mountImageEditor.firstCall.args[ 1 ] ).to.have.property( 'assetId' ).that.equals( 'example-id' ); } ); } ); describe( 'save edited image logic', () => { describe( 'opening dialog', () => { + let clock; + beforeEach( () => { - sinon.useFakeTimers( { now: Date.now() } ); + clock = sinon.useFakeTimers( { now: Date.now() } ); } ); afterEach( () => { @@ -201,17 +206,19 @@ describe( 'CKBoxImageEditCommand', () => { expect( command._wrapper ).to.equal( wrapper ); } ); - it( 'should open the CKBox Image Editor dialog instance only once', () => { + it( 'should open the CKBox Image Editor dialog instance only once', async () => { setModelData( model, '[]' ); command.execute(); command.execute(); command.execute(); + await clock.tickAsync( 0 ); + expect( window.CKBox.mountImageEditor.callCount ).to.equal( 1 ); } ); - it( 'should prepare options for the CKBox Image Editing dialog instance', () => { + it( 'should prepare options for the CKBox Image Editing dialog instance', async () => { const ckboxImageId = 'example-id'; setModelData( model, @@ -220,12 +227,13 @@ describe( 'CKBoxImageEditCommand', () => { const imageElement = editor.model.document.selection.getSelectedElement(); - const options = command._prepareOptions( { + const options = await command._prepareOptions( { element: imageElement, ckboxImageId, controller: new AbortController() } ); + expect( options ).to.have.property( 'assetId', ckboxImageId ); expect( options ).to.have.property( 'tokenUrl', 'foo' ); expect( options.imageEditing.allowOverwrite ).to.be.false; expect( options.onSave ).to.be.a( 'function' ); @@ -234,7 +242,7 @@ describe( 'CKBoxImageEditCommand', () => { } ); describe( 'closing dialog', () => { - it( 'should remove the wrapper after closing the CKBox Image Editor dialog', () => { + it( 'should remove the wrapper after closing the CKBox Image Editor dialog', async () => { const ckboxImageId = 'example-id'; setModelData( model, @@ -243,11 +251,10 @@ describe( 'CKBoxImageEditCommand', () => { const imageElement = editor.model.document.selection.getSelectedElement(); - const onClose = command._prepareOptions( { + const options = await command._prepareOptions( { element: imageElement, - ckboxImageId, controller: new AbortController() - } ).onClose; + } ); command.execute(); @@ -255,13 +262,13 @@ describe( 'CKBoxImageEditCommand', () => { const spy = sinon.spy( command._wrapper, 'remove' ); - onClose(); + options.onClose(); expect( spy.callCount ).to.equal( 1 ); expect( command._wrapper ).to.equal( null ); } ); - it( 'should focus view after closing the CKBox Image Editor dialog', () => { + it( 'should focus view after closing the CKBox Image Editor dialog', async () => { const ckboxImageId = 'example-id'; setModelData( model, @@ -270,26 +277,26 @@ describe( 'CKBoxImageEditCommand', () => { const imageElement = editor.model.document.selection.getSelectedElement(); - const onClose = command._prepareOptions( { + const options = await command._prepareOptions( { element: imageElement, ckboxImageId, controller: new AbortController() - } ).onClose; + } ); const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); command.execute(); - onClose(); + options.onClose(); sinon.assert.calledOnce( focusSpy ); } ); } ); describe( 'saving edited asset', () => { - let onSave, sinonXHR, jwtToken, clock; + let options, sinonXHR, jwtToken, clock; - beforeEach( () => { + beforeEach( async () => { const ckboxImageId = 'example-id'; setModelData( model, @@ -300,11 +307,11 @@ describe( 'CKBoxImageEditCommand', () => { const imageElement = editor.model.document.selection.getSelectedElement(); jwtToken = createToken( { auth: { ckbox: { workspaces: [ 'workspace1' ] } } } ); - onSave = command._prepareOptions( { + options = await command._prepareOptions( { element: imageElement, ckboxImageId, controller: new AbortController() - } ).onSave; + } ); sinonXHR = testUtils.sinon.useFakeServer(); sinonXHR.autoRespond = true; } ); @@ -317,7 +324,7 @@ describe( 'CKBoxImageEditCommand', () => { } } ); - it( 'should pool data for edited image and if success status, save it', done => { + it( 'should poll data for edited image and if success status, save it', async () => { clock = sinon.useFakeTimers(); sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/assets/image-id1', xhr => { @@ -332,11 +339,9 @@ describe( 'CKBoxImageEditCommand', () => { ); } ); - onSave( dataMock ); - - clock.tick( 1500 ); + options.onSave( dataMock ); - done(); + await clock.tickAsync( 1500 ); } ); it( 'should abort when image was removed while processing on server', async () => { @@ -352,7 +357,7 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 100 ); @@ -390,7 +395,7 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 20000 ); @@ -407,7 +412,7 @@ describe( 'CKBoxImageEditCommand', () => { throw new Error( 'unhandled' ); } ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 20000 ); @@ -432,7 +437,7 @@ describe( 'CKBoxImageEditCommand', () => { expect( command.isEnabled ).to.be.true; - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 10 ); @@ -453,7 +458,7 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 10 ); @@ -479,7 +484,7 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 10 ); @@ -499,7 +504,7 @@ describe( 'CKBoxImageEditCommand', () => { return xhr.error(); } ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 20000 ); @@ -523,14 +528,14 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 20000 ); sinon.assert.calledOnce( spy ); } ); - it( 'should stop pooling if limit was reached', async () => { + it( 'should stop polling if limit was reached', async () => { clock = sinon.useFakeTimers(); const respondSpy = sinon.spy( sinonXHR, 'respond' ); @@ -545,7 +550,7 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); await clock.tickAsync( 15000 ); @@ -586,7 +591,7 @@ describe( 'CKBoxImageEditCommand', () => { } ) ] ); - onSave( dataMock ); + options.onSave( dataMock ); expect( pendingActions.hasAny ).to.be.true; expect( pendingActions._actions.length ).to.equal( 1 ); @@ -594,7 +599,7 @@ describe( 'CKBoxImageEditCommand', () => { await clock.tickAsync( 1000 ); - onSave( dataMock2 ); + options.onSave( dataMock2 ); expect( pendingActions.hasAny ).to.be.true; expect( pendingActions._actions.length ).to.equal( 2 ); @@ -745,7 +750,7 @@ describe( 'CKBoxImageEditCommand', () => { '' ); - onSave( dataMock ); + options.onSave( dataMock ); expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '
- + diff --git a/packages/ckeditor5-ckbox/tests/manual/ckbox.js b/packages/ckeditor5-ckbox/tests/manual/ckbox.js index 43d96d3592e..014bd86d20f 100644 --- a/packages/ckeditor5-ckbox/tests/manual/ckbox.js +++ b/packages/ckeditor5-ckbox/tests/manual/ckbox.js @@ -53,7 +53,8 @@ ClassicEditor ] }, ckbox: { - tokenUrl: TOKEN_URL + tokenUrl: TOKEN_URL, + allowExternalImagesEditing: [ /^i.imgur.com\// ] } } ) .then( editor => { From 9fd703cea77fe5e6abb7fea5670cb8538cfef699 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Tue, 28 Nov 2023 15:39:14 +0100 Subject: [PATCH 02/10] Editing images not in CKBox: added data-url support to manual test. --- packages/ckeditor5-ckbox/tests/manual/ckbox.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-ckbox/tests/manual/ckbox.js b/packages/ckeditor5-ckbox/tests/manual/ckbox.js index 014bd86d20f..79c3c4e7858 100644 --- a/packages/ckeditor5-ckbox/tests/manual/ckbox.js +++ b/packages/ckeditor5-ckbox/tests/manual/ckbox.js @@ -54,7 +54,7 @@ ClassicEditor }, ckbox: { tokenUrl: TOKEN_URL, - allowExternalImagesEditing: [ /^i.imgur.com\// ] + allowExternalImagesEditing: [ /^data:/, /^i.imgur.com\// ] } } ) .then( editor => { From a5d42994e43ab6dc3cbc37e5a42f288fbf46035c Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Thu, 30 Nov 2023 11:08:25 +0100 Subject: [PATCH 03/10] Added 'abortableDebounce' to utils. --- .../ckeditor5-utils/src/abortabledebounce.ts | 35 ++++++ packages/ckeditor5-utils/src/index.ts | 1 + .../tests/abortabledebounce.js | 102 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 packages/ckeditor5-utils/src/abortabledebounce.ts create mode 100644 packages/ckeditor5-utils/tests/abortabledebounce.js diff --git a/packages/ckeditor5-utils/src/abortabledebounce.ts b/packages/ckeditor5-utils/src/abortabledebounce.ts new file mode 100644 index 00000000000..d4b8e7875e2 --- /dev/null +++ b/packages/ckeditor5-utils/src/abortabledebounce.ts @@ -0,0 +1,35 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module utils/abortabledebounce + */ + +/** + * Returns a function wrapper that will execute the provided function and abort any previous call that is still in progress. + * + * @param func The function to be called. It will be provided with `AbortSignal` as the first parameter. + */ +export default function abortableDebounce, Ret>( + func: ( signal: AbortSignal, ...args: Args ) => Ret +): AbortableFunc { + let controller = new AbortController(); + + function abortable( ...args: Args ) { + controller.abort(); + controller = new AbortController(); + + return func( controller.signal, ...args ); + } + + abortable.abort = () => controller.abort(); + + return abortable; +} + +export interface AbortableFunc, Ret> { + ( ...args: Args ): Ret; + abort(): void; +} diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 51dc540122d..521634bb4ef 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -42,6 +42,7 @@ export { default as CKEditorError, logError, logWarning } from './ckeditorerror' export { default as ElementReplacer } from './elementreplacer'; +export { default as abortableDebounce, type AbortableFunc } from './abortabledebounce'; export { default as count } from './count'; export { default as compareArrays } from './comparearrays'; export { default as createElement } from './dom/createelement'; diff --git a/packages/ckeditor5-utils/tests/abortabledebounce.js b/packages/ckeditor5-utils/tests/abortabledebounce.js new file mode 100644 index 00000000000..b0e49b6bc15 --- /dev/null +++ b/packages/ckeditor5-utils/tests/abortabledebounce.js @@ -0,0 +1,102 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals AbortSignal, DOMException */ + +import abortableDebounce from '../src/abortabledebounce'; + +describe( 'abortableDebounce()', () => { + it( 'should forward arguments and return type', () => { + const callback = sinon.stub(); + + callback.onCall( 0 ).returns( 1 ); + callback.onCall( 1 ).returns( 2 ); + callback.returns( 3 ); + + const abortable = abortableDebounce( callback ); + + const result1 = abortable( 'a', 'b' ); + const result2 = abortable( 'x', 'y', 'z' ); + const result3 = abortable( 1, 2 ); + + expect( result1, '1st result' ).to.equal( 1 ); + expect( result2, '2nd result' ).to.equal( 2 ); + expect( result3, '3rd result' ).to.equal( 3 ); + + expect( callback.getCall( 0 ).args.length, '1st call, no. of args' ).to.equal( 3 ); + expect( callback.getCall( 0 ).args[ 0 ], '1st call, 1st arg' ).to.be.an.instanceof( AbortSignal ); + expect( callback.getCall( 0 ).args[ 1 ], '1st call, 2nd arg' ).to.equal( 'a' ); + expect( callback.getCall( 0 ).args[ 2 ], '1st call, 3rd arg' ).to.equal( 'b' ); + + expect( callback.getCall( 1 ).args.length, '2nd call, no. of args' ).to.equal( 4 ); + expect( callback.getCall( 1 ).args[ 0 ], '2nd call, 1st arg' ).to.be.an.instanceof( AbortSignal ); + expect( callback.getCall( 1 ).args[ 1 ], '2nd call, 2nd arg' ).to.equal( 'x' ); + expect( callback.getCall( 1 ).args[ 2 ], '2nd call, 3rd arg' ).to.equal( 'y' ); + expect( callback.getCall( 1 ).args[ 3 ], '2nd call, 4rd arg' ).to.equal( 'z' ); + + expect( callback.getCall( 2 ).args.length, '3rd call, no. of args' ).to.equal( 3 ); + expect( callback.getCall( 2 ).args[ 0 ], '3rd call, 1st arg' ).to.be.an.instanceof( AbortSignal ); + expect( callback.getCall( 2 ).args[ 1 ], '3rd call, 2nd arg' ).to.equal( 1 ); + expect( callback.getCall( 2 ).args[ 2 ], '3rd call, 3rd arg' ).to.equal( 2 ); + } ); + + it( 'should abort previous call', () => { + const signals = []; + + const abortable = abortableDebounce( s => signals.push( s ) ); + + abortable(); + + expect( signals[ 0 ].aborted, '1st call - current signal' ).to.be.false; + + abortable(); + + expect( signals[ 0 ].aborted, '2nd call - previous signal' ).to.be.true; + expect( signals[ 1 ].aborted, '2nd call - current signal' ).to.be.false; + + abortable(); + + expect( signals[ 1 ].aborted, '3rd call - previous signal' ).to.be.true; + expect( signals[ 2 ].aborted, '3rd call - current signal' ).to.be.false; + } ); + + it( '`abort()` should abort last call', () => { + let signal; + + const abortable = abortableDebounce( s => { + signal = s; + } ); + + abortable(); + + expect( signal.aborted, 'before `abort()' ).to.be.false; + + abortable.abort(); + + expect( signal.aborted, 'after `abort()`' ).to.be.true; + + abortable(); + + expect( signal.aborted, 'after next call' ).to.be.false; + } ); + + it( 'should provide default abort reason', () => { + const signals = []; + + const abortable = abortableDebounce( s => signals.push( s ) ); + + abortable(); + abortable(); + abortable(); + abortable.abort(); + + expect( signals[ 0 ].reason ).to.be.instanceof( DOMException ); + expect( signals[ 0 ].reason.name, '1st signal name' ).to.equal( 'AbortError' ); + expect( signals[ 1 ].reason ).to.be.instanceof( DOMException ); + expect( signals[ 1 ].reason.name, '2nd signal name' ).to.equal( 'AbortError' ); + expect( signals[ 2 ].reason ).to.be.instanceof( DOMException ); + expect( signals[ 2 ].reason.name, '3rd signal name' ).to.equal( 'AbortError' ); + } ); +} ); From 437b4bf3cc06e6ac753f857d18ce91fda91afef8 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Thu, 30 Nov 2023 11:18:50 +0100 Subject: [PATCH 04/10] Editing images not in CKBox: abort previous _prepareOptions. --- .../src/ckboximageedit/ckboximageeditcommand.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts index 648bd7e63d9..92b06f4ecf3 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts @@ -10,7 +10,7 @@ */ import { Command, PendingActions, type Editor } from 'ckeditor5/src/core'; -import { CKEditorError, createElement, retry } from 'ckeditor5/src/utils'; +import { CKEditorError, abortableDebounce, createElement, retry, type AbortableFunc } from 'ckeditor5/src/utils'; import type { Element as ModelElement } from 'ckeditor5/src/engine'; import { Notification } from 'ckeditor5/src/ui'; import { isEqual } from 'lodash-es'; @@ -47,6 +47,8 @@ export default class CKBoxImageEditCommand extends Command { /** TODO */ private _canEdit: ( element: ModelElement ) => boolean; + private _prepareOptions: AbortableFunc<[ ProcessingState ], Promise>>; + /** * @inheritDoc */ @@ -56,6 +58,7 @@ export default class CKBoxImageEditCommand extends Command { this.value = false; this._canEdit = createEditabilityChecker( editor.config.get( 'ckbox.allowExternalImagesEditing' ) ); + this._prepareOptions = abortableDebounce( ( signal, state ) => this._prepareOptionsImpl( signal, state ) ); this._prepareListeners(); } @@ -113,6 +116,8 @@ export default class CKBoxImageEditCommand extends Command { public override destroy(): void { this._handleImageEditorClose(); + this._prepareOptions.abort(); + for ( const state of this._processInProgress.values() ) { state.controller.abort(); } @@ -130,7 +135,7 @@ export default class CKBoxImageEditCommand extends Command { /** * Creates the options object for the CKBox Image Editor dialog. */ - private async _prepareOptions( state: ProcessingState ) { + private async _prepareOptionsImpl( signal: AbortSignal, state: ProcessingState ) { const editor = this.editor; const ckboxConfig = editor.config.get( 'ckbox' )!; const ckboxEditing = editor.plugins.get( CKBoxEditing ); @@ -141,7 +146,7 @@ export default class CKBoxImageEditCommand extends Command { serviceOrigin: ckboxConfig.serviceOrigin!, defaultCategories: ckboxConfig.defaultUploadCategories, defaultWorkspaceId: ckboxConfig.defaultUploadWorkspaceId, - /** TODO */ signal: ( new AbortController() ).signal + signal } ); return { From 0a9f862e9555852aeb28a43bf9a293e1c61b7f75 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Thu, 30 Nov 2023 13:16:33 +0100 Subject: [PATCH 05/10] Editing images not in CKBox: fixing existing tests. --- .../ckeditor5-ckbox/src/ckboximageedit.ts | 3 +-- .../ckboximageedit/ckboximageeditediting.ts | 3 ++- .../ckeditor5-ckbox/tests/ckboximageedit.js | 2 -- .../ckboximageedit/ckboximageeditediting.js | 23 +++++++++++++++++-- .../tests/ckboximageedit/ckboximageeditui.js | 15 ++++++++++-- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit.ts b/packages/ckeditor5-ckbox/src/ckboximageedit.ts index 9d92db5c4d8..409c91f2b4d 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit.ts @@ -9,7 +9,6 @@ import { Plugin } from 'ckeditor5/src/core'; -import CKBoxEditing from './ckboxediting'; import CKBoxImageEditEditing from './ckboximageedit/ckboximageeditediting'; import CKBoxImageEditUI from './ckboximageedit/ckboximageeditui'; @@ -30,6 +29,6 @@ export default class CKBoxImageEdit extends Plugin { * @inheritDoc */ public static get requires() { - return [ CKBoxEditing, CKBoxImageEditEditing, CKBoxImageEditUI ] as const; + return [ CKBoxImageEditEditing, CKBoxImageEditUI ] as const; } } diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts index 69bb32c6b05..3531b642896 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts @@ -10,6 +10,7 @@ import { PendingActions, Plugin } from 'ckeditor5/src/core'; import { Notification } from 'ckeditor5/src/ui'; import CKBoxImageEditCommand from './ckboximageeditcommand'; +import CKBoxEditing from '../ckboxediting'; /** * The CKBox image edit editing plugin. @@ -26,7 +27,7 @@ export default class CKBoxImageEditEditing extends Plugin { * @inheritDoc */ public static get requires() { - return [ PendingActions, Notification, 'ImageUtils', 'ImageEditing' ] as const; + return [ CKBoxEditing, PendingActions, Notification, 'ImageUtils', 'ImageEditing' ] as const; } /** diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit.js b/packages/ckeditor5-ckbox/tests/ckboximageedit.js index 7b1c5bfe8d3..46acaf7f8ec 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit.js @@ -14,7 +14,6 @@ import ImageUploadProgress from '@ckeditor/ckeditor5-image/src/imageupload/image import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import { global } from '@ckeditor/ckeditor5-utils'; -import { CKBoxEditing } from '../src'; import CKBoxImageEdit from '../src/ckboximageedit'; import CKBoxImageEditEditing from '../src/ckboximageedit/ckboximageeditediting'; import CKBoxImageEditUI from '../src/ckboximageedit/ckboximageeditui.ts'; @@ -59,7 +58,6 @@ describe( 'CKBoxImageEdit', () => { it( 'should have proper "requires" value', () => { expect( CKBoxImageEdit.requires ).to.deep.equal( [ - CKBoxEditing, CKBoxImageEditEditing, CKBoxImageEditUI ] ); diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditediting.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditediting.js index cba7f521fd4..ad269ab50bb 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditediting.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditediting.js @@ -12,11 +12,18 @@ import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting'; import CKBoxImageEditEditing from '../../src/ckboximageedit/ckboximageeditediting'; import CKBoxImageEditCommand from '../../src/ckboximageedit/ckboximageeditcommand'; +import { CloudServices } from '@ckeditor/ckeditor5-cloud-services'; +import { LinkEditing } from '@ckeditor/ckeditor5-link'; +import { ImageBlockEditing, ImageUploadEditing, ImageUploadProgress, PictureEditing } from '@ckeditor/ckeditor5-image'; +import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock'; +import CloudServicesCoreMock from '../_utils/cloudservicescoremock'; describe( 'CKBoxImageEditEditing', () => { let editor, domElement; beforeEach( async () => { + TokenMock.initialToken = 'ckbox-token'; + domElement = global.document.createElement( 'div' ); global.document.body.appendChild( domElement ); @@ -25,9 +32,21 @@ describe( 'CKBoxImageEditEditing', () => { Paragraph, Heading, Essentials, + ImageBlockEditing, ImageEditing, - CKBoxImageEditEditing - ] + ImageUploadEditing, + ImageUploadProgress, + PictureEditing, + LinkEditing, + CKBoxImageEditEditing, + CloudServices + ], + substitutePlugins: [ + CloudServicesCoreMock + ], + ckbox: { + tokenUrl: 'http://cs.example.com' + } } ); } ); diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditui.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditui.js index 3a5c3fdbb55..95cfa26a596 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditui.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditui.js @@ -9,8 +9,9 @@ import { global } from '@ckeditor/ckeditor5-utils'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import { LinkEditing } from '@ckeditor/ckeditor5-link'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import { Image } from '@ckeditor/ckeditor5-image'; +import { Image, ImageUploadEditing, ImageUploadProgress, PictureEditing } from '@ckeditor/ckeditor5-image'; import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { ButtonView } from '@ckeditor/ckeditor5-ui'; import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock'; @@ -43,7 +44,17 @@ describe( 'CKBoxImageEditUI', () => { return ClassicTestEditor .create( element, { - plugins: [ CKBoxImageEditEditing, CKBoxImageEditUI, Image, Paragraph, CloudServices ], + plugins: [ + CKBoxImageEditEditing, + CKBoxImageEditUI, + Image, + ImageUploadEditing, + ImageUploadProgress, + Paragraph, + PictureEditing, + LinkEditing, + CloudServices + ], ckbox: { tokenUrl: 'foo' }, From f8fdc2c3e07a9a7202b71fc4ccd7d9009c0d7ae2 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Sat, 2 Dec 2023 16:40:26 +0100 Subject: [PATCH 06/10] Editing images not in CKBox: major refactor. --- packages/ckeditor5-ckbox/lang/contexts.json | 3 +- packages/ckeditor5-ckbox/src/ckboxediting.ts | 73 +- .../ckboximageedit/ckboximageeditcommand.ts | 48 +- .../ckboximageedit/ckboximageeditediting.ts | 3 +- .../src/ckboximageedit/utils.ts | 79 +- .../ckeditor5-ckbox/src/ckboxuploadadapter.ts | 89 +-- packages/ckeditor5-ckbox/src/ckboxutils.ts | 246 ++++++ packages/ckeditor5-ckbox/src/utils.ts | 160 +--- .../ckeditor5-ckbox/tests/ckboxediting.js | 280 +------ .../ckboximageedit/ckboximageeditcommand.js | 53 +- .../tests/ckboximageedit/utils.js | 76 ++ .../tests/ckboxuploadadapter.js | 18 +- packages/ckeditor5-ckbox/tests/ckboxutils.js | 705 ++++++++++++++++++ packages/ckeditor5-ckbox/tests/utils.js | 94 ++- 14 files changed, 1274 insertions(+), 653 deletions(-) create mode 100644 packages/ckeditor5-ckbox/src/ckboxutils.ts create mode 100644 packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js create mode 100644 packages/ckeditor5-ckbox/tests/ckboxutils.js diff --git a/packages/ckeditor5-ckbox/lang/contexts.json b/packages/ckeditor5-ckbox/lang/contexts.json index 64337b3c259..6ade29b8c27 100644 --- a/packages/ckeditor5-ckbox/lang/contexts.json +++ b/packages/ckeditor5-ckbox/lang/contexts.json @@ -4,5 +4,6 @@ "Cannot access default workspace.": "A message is displayed when the user is not authorised to access the CKBox workspace configured as default one.", "Edit image": "Image toolbar button tooltip for opening a dialog to manipulate the image.", "Processing the edited image.": "A message stating that image editing is in progress.", - "Server failed to process the image.": "A message is displayed when the server fails to process an image or doesn't respond." + "Server failed to process the image.": "A message is displayed when the server fails to process an image or doesn't respond.", + "Failed to determine category of edited image.": "A message is displayed when category of the image user wants to edit can't be determined." } diff --git a/packages/ckeditor5-ckbox/src/ckboxediting.ts b/packages/ckeditor5-ckbox/src/ckboxediting.ts index 8a658cc00f9..bf1b7ff6cae 100644 --- a/packages/ckeditor5-ckbox/src/ckboxediting.ts +++ b/packages/ckeditor5-ckbox/src/ckboxediting.ts @@ -9,7 +9,7 @@ * @module ckbox/ckboxediting */ -import type { CloudServices, CloudServicesCore, InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; +import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; import { Plugin, type Editor } from 'ckeditor5/src/core'; import { Range, @@ -23,15 +23,15 @@ import { type ViewElement, type Writer } from 'ckeditor5/src/engine'; -import { CKEditorError, logError, type DecoratedMethodEvent } from 'ckeditor5/src/utils'; +import { logError, type DecoratedMethodEvent } from 'ckeditor5/src/utils'; import type { CKBoxAssetDefinition } from './ckboxconfig'; import CKBoxCommand from './ckboxcommand'; import CKBoxUploadAdapter from './ckboxuploadadapter'; -import type { ReplaceImageSourceCommand } from '@ckeditor/ckeditor5-image'; +import CKBoxUtils from './ckboxutils'; -const DEFAULT_CKBOX_THEME_NAME = 'lark'; +import type { ReplaceImageSourceCommand } from '@ckeditor/ckeditor5-image'; /** * The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and @@ -54,13 +54,13 @@ export default class CKBoxEditing extends Plugin { * @inheritDoc */ public static get requires() { - return [ 'CloudServices', 'LinkEditing', 'PictureEditing', CKBoxUploadAdapter ] as const; + return [ 'LinkEditing', 'PictureEditing', CKBoxUploadAdapter, CKBoxUtils ] as const; } /** * @inheritDoc */ - public async init(): Promise { + public init(): void { const editor = this.editor; const hasConfiguration = !!editor.config.get( 'ckbox' ); const isLibraryLoaded = !!window.CKBox; @@ -71,23 +71,7 @@ export default class CKBoxEditing extends Plugin { return; } - this._initConfig(); - - const cloudServicesCore: CloudServicesCore = editor.plugins.get( 'CloudServicesCore' ); - const ckboxTokenUrl = editor.config.get( 'ckbox.tokenUrl' )!; - const cloudServicesTokenUrl = editor.config.get( 'cloudServices.tokenUrl' ); - - // To avoid fetching the same token twice we need to compare the `ckbox.tokenUrl` and `cloudServices.tokenUrl` values. - // If they are equal, it's enough to take the token generated by the `CloudServices` plugin. - if ( ckboxTokenUrl === cloudServicesTokenUrl ) { - const cloudServices: CloudServices = editor.plugins.get( 'CloudServices' ); - - this._token = cloudServices.token!; - } - // Otherwise, create a new token manually. - else { - this._token = await cloudServicesCore.createToken( ckboxTokenUrl ).init(); - } + this._checkImagePlugins(); // Extending the schema, registering converters and applying fixers only make sense if the configuration option to assign // the assets ID with the model elements is enabled. @@ -104,50 +88,11 @@ export default class CKBoxEditing extends Plugin { } /** - * Returns a token used by the CKBox plugin for communication with the CKBox service. - */ - public getToken(): InitializedToken { - return this._token; - } - - /** - * Initializes the `ckbox` editor configuration. + * Checks if the at least one image plugin is loaded. */ - private _initConfig() { + private _checkImagePlugins() { const editor = this.editor; - editor.config.define( 'ckbox', { - serviceOrigin: 'https://api.ckbox.io', - defaultUploadCategories: null, - ignoreDataId: false, - language: editor.locale.uiLanguage, - theme: DEFAULT_CKBOX_THEME_NAME, - tokenUrl: editor.config.get( 'cloudServices.tokenUrl' ) - } ); - - const tokenUrl = editor.config.get( 'ckbox.tokenUrl' ); - - if ( !tokenUrl ) { - /** - * The {@link module:ckbox/ckboxconfig~CKBoxConfig#tokenUrl `config.ckbox.tokenUrl`} or the - * {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl `config.cloudServices.tokenUrl`} - * configuration is required for the CKBox plugin. - * - * ```ts - * ClassicEditor.create( document.createElement( 'div' ), { - * ckbox: { - * tokenUrl: "YOUR_TOKEN_URL" - * // ... - * } - * // ... - * } ); - * ``` - * - * @error ckbox-plugin-missing-token-url - */ - throw new CKEditorError( 'ckbox-plugin-missing-token-url', this ); - } - if ( !editor.plugins.has( 'ImageBlockEditing' ) && !editor.plugins.has( 'ImageInlineEditing' ) ) { /** * The CKBox feature requires one of the following plugins to be loaded to work correctly: diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts index 92b06f4ecf3..d3d89161410 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts @@ -15,13 +15,13 @@ import type { Element as ModelElement } from 'ckeditor5/src/engine'; import { Notification } from 'ckeditor5/src/ui'; import { isEqual } from 'lodash-es'; -import CKBoxEditing from '../ckboxediting'; import { sendHttpRequest } from '../utils'; import { prepareImageAssetAttributes } from '../ckboxcommand'; import type { CKBoxRawAssetDefinition, CKBoxRawAssetDataDefinition } from '../ckboxconfig'; import type { ImageUtils } from '@ckeditor/ckeditor5-image'; -import { createEditabilityChecker, getImageEditorMountOptions } from './utils'; +import { createEditabilityChecker } from './utils'; +import CKBoxUtils from '../ckboxutils'; /** * The CKBox edit image command. @@ -58,7 +58,7 @@ export default class CKBoxImageEditCommand extends Command { this.value = false; this._canEdit = createEditabilityChecker( editor.config.get( 'ckbox.allowExternalImagesEditing' ) ); - this._prepareOptions = abortableDebounce( ( signal, state ) => this._prepareOptionsImpl( signal, state ) ); + this._prepareOptions = abortableDebounce( ( signal, state ) => this._prepareOptionsAbortable( signal, state ) ); this._prepareListeners(); } @@ -104,6 +104,13 @@ export default class CKBoxImageEditCommand extends Command { this._prepareOptions( processingState ).then( options => window.CKBox.mountImageEditor( wrapper, options ), error => { + const editor = this.editor; + const t = editor.t; + const notification = editor.plugins.get( Notification ); + + notification.showWarning( t( 'Failed to determine category of edited image.' ), { + namespace: 'ckbox' + } ); console.error( error ); this._handleImageEditorClose(); } @@ -135,19 +142,28 @@ export default class CKBoxImageEditCommand extends Command { /** * Creates the options object for the CKBox Image Editor dialog. */ - private async _prepareOptionsImpl( signal: AbortSignal, state: ProcessingState ) { + private async _prepareOptionsAbortable( signal: AbortSignal, state: ProcessingState ) { const editor = this.editor; const ckboxConfig = editor.config.get( 'ckbox' )!; - const ckboxEditing = editor.plugins.get( CKBoxEditing ); - const token = ckboxEditing.getToken(); - - const imageMountOptions = await getImageEditorMountOptions( state.element, { - token, - serviceOrigin: ckboxConfig.serviceOrigin!, - defaultCategories: ckboxConfig.defaultUploadCategories, - defaultWorkspaceId: ckboxConfig.defaultUploadWorkspaceId, - signal - } ); + const ckboxUtils = editor.plugins.get( CKBoxUtils ); + const { element } = state; + + let imageMountOptions; + const ckboxImageId = element.getAttribute( 'ckboxImageId' ); + + if ( ckboxImageId ) { + imageMountOptions = { + assetId: ckboxImageId + }; + } else { + const imageUrl = element.getAttribute( 'src' ) as string; + const uploadCategoryId = await ckboxUtils.getCategoryIdForFile( imageUrl, { signal } ); + + imageMountOptions = { + imageUrl, + uploadCategoryId + }; + } return { ...imageMountOptions, @@ -260,13 +276,13 @@ export default class CKBoxImageEditCommand extends Command { * image is already proceeded and ready for saving. */ private async _getAssetStatusFromServer( id: string, signal: AbortSignal ): Promise { - const ckboxEditing = this.editor.plugins.get( CKBoxEditing ); + const ckboxUtils = this.editor.plugins.get( CKBoxUtils ); const url = new URL( 'assets/' + id, this.editor.config.get( 'ckbox.serviceOrigin' )! ); const response: CKBoxRawAssetDataDefinition = await sendHttpRequest( { url, signal, - authorization: ckboxEditing.getToken().value + authorization: ckboxUtils.getToken().value } ); const status = response.metadata!.metadataProcessingStatus; diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts index 3531b642896..721f798d14c 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditediting.ts @@ -11,6 +11,7 @@ import { PendingActions, Plugin } from 'ckeditor5/src/core'; import { Notification } from 'ckeditor5/src/ui'; import CKBoxImageEditCommand from './ckboximageeditcommand'; import CKBoxEditing from '../ckboxediting'; +import CKBoxUtils from '../ckboxutils'; /** * The CKBox image edit editing plugin. @@ -27,7 +28,7 @@ export default class CKBoxImageEditEditing extends Plugin { * @inheritDoc */ public static get requires() { - return [ CKBoxEditing, PendingActions, Notification, 'ImageUtils', 'ImageEditing' ] as const; + return [ CKBoxEditing, CKBoxUtils, PendingActions, Notification, 'ImageUtils', 'ImageEditing' ] as const; } /** diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts index 98cf354fff5..bbe70e4bee8 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts @@ -8,10 +8,8 @@ */ import { toArray } from 'ckeditor5/src/utils'; -import { getCategoryIdForFile, getWorkspaceId } from '../utils'; import type { Element } from 'ckeditor5/src/engine'; -import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; import type { CKBoxConfig } from '../ckboxconfig'; /** @@ -20,7 +18,7 @@ import type { CKBoxConfig } from '../ckboxconfig'; export function createEditabilityChecker( allowExternalImagesEditing: CKBoxConfig[ 'allowExternalImagesEditing' ] ): ( element: Element ) => boolean { - const checkUrl = createUrlChecker(); + const checkUrl = createUrlChecker( allowExternalImagesEditing ); return element => { const isImageElement = @@ -41,73 +39,24 @@ export function createEditabilityChecker( return false; }; - - function createUrlChecker(): ( src: string ) => boolean { - if ( !allowExternalImagesEditing ) { - return () => false; - } - - if ( typeof allowExternalImagesEditing == 'function' ) { - return allowExternalImagesEditing; - } - - const urlRegExps = toArray( allowExternalImagesEditing ); - - return src => urlRegExps.some( pattern => - src.match( pattern ) || - src.replace( /^https?:\/\//, '' ).match( pattern ) - ); - } } -/** - * @internal - */ -export async function getImageEditorMountOptions( element: Element, options: { - token: InitializedToken; - serviceOrigin: string; - signal: AbortSignal; - defaultWorkspaceId?: string; - defaultCategories?: Record> | null; -} ): Promise { - const ckboxImageId = element.getAttribute( 'ckboxImageId' ); - - if ( ckboxImageId ) { - return { - assetId: ckboxImageId - }; +function createUrlChecker( + allowExternalImagesEditing: CKBoxConfig[ 'allowExternalImagesEditing' ] +): ( src: string ) => boolean { + if ( !allowExternalImagesEditing ) { + return () => false; } - const imageUrl = element.getAttribute( 'src' ) as string; - const uploadCategoryId = await getUploadCategoryId( imageUrl, options ); - - return { - imageUrl, - uploadCategoryId - }; -} - -async function getUploadCategoryId( imageUrl: string, options: Parameters[ 1 ] ): Promise { - const { token, serviceOrigin, signal, defaultWorkspaceId, defaultCategories } = options; - - // TODO: refactor this (it's duplicated in upload adapter). - const workspaceId = getWorkspaceId( token, defaultWorkspaceId ); - - if ( !workspaceId ) { - throw new Error( 'TODO' ); + if ( typeof allowExternalImagesEditing == 'function' ) { + return allowExternalImagesEditing; } - const category = await getCategoryIdForFile( imageUrl, { - token, - serviceOrigin, - workspaceId, - signal, - defaultCategories - } ); + const urlRegExps = toArray( allowExternalImagesEditing ); - if ( !category ) { - throw new Error( 'TODO' ); - } - - return category; + return src => urlRegExps.some( pattern => + src.match( pattern ) || + src.replace( /^https?:\/\//, '' ).match( pattern ) + ); } + diff --git a/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts b/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts index 874cb25dbcc..6e0697bdacd 100644 --- a/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts +++ b/packages/ckeditor5-ckbox/src/ckboxuploadadapter.ts @@ -20,16 +20,12 @@ import { import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; import type { ImageUploadCompleteEvent, ImageUploadEditing } from '@ckeditor/ckeditor5-image'; -import { logError } from 'ckeditor5/src/utils'; import CKBoxEditing from './ckboxediting'; import { - getAvailableCategories, getImageUrls, - getWorkspaceId, - sendHttpRequest, - getCategoryIdForFile, - type AvailableCategory + sendHttpRequest } from './utils'; +import CKBoxUtils from './ckboxutils'; /** * A plugin that enables file uploads in CKEditor 5 using the CKBox server–side connector. @@ -72,11 +68,9 @@ export default class CKBoxUploadAdapter extends Plugin { } const fileRepository = editor.plugins.get( FileRepository ); - const ckboxEditing = editor.plugins.get( CKBoxEditing ); + const ckboxUtils = editor.plugins.get( CKBoxUtils ); - fileRepository.createUploadAdapter = loader => { - return new Adapter( loader, ckboxEditing.getToken(), editor ); - }; + fileRepository.createUploadAdapter = loader => new Adapter( loader, editor, ckboxUtils ); const shouldInsertDataId = !editor.config.get( 'ckbox.ignoreDataId' ); const imageUploadEditing: ImageUploadEditing = editor.plugins.get( 'ImageUploadEditing' ); @@ -121,91 +115,40 @@ class Adapter implements UploadAdapter { */ public serviceOrigin: string; + /** + * The reference to CKBoxUtils plugin. + */ + public ckboxUtils: CKBoxUtils; + /** * Creates a new adapter instance. */ - constructor( loader: FileLoader, token: InitializedToken, editor: Editor ) { + constructor( loader: FileLoader, editor: Editor, ckboxUtils: CKBoxUtils ) { this.loader = loader; - this.token = token; + this.token = ckboxUtils.getToken(); + this.ckboxUtils = ckboxUtils; this.editor = editor; this.controller = new AbortController(); this.serviceOrigin = editor.config.get( 'ckbox.serviceOrigin' )!; } - /** - * The ID of workspace to use. - */ - public getWorkspaceId(): string { - const t = this.editor.t; - const cannotAccessDefaultWorkspaceError = t( 'Cannot access default workspace.' ); - const defaultWorkspaceId = this.editor.config.get( 'ckbox.defaultUploadWorkspaceId' ); - const workspaceId = getWorkspaceId( this.token, defaultWorkspaceId ); - - if ( workspaceId == null ) { - /** - * The user is not authorized to access the workspace defined in the`ckbox.defaultUploadWorkspaceId` configuration. - * - * @error ckbox-access-default-workspace-error - */ - logError( 'ckbox-access-default-workspace-error' ); - - throw cannotAccessDefaultWorkspaceError; - } - - return workspaceId; - } - - /** - * Resolves a promise with an array containing available categories with which the uploaded file can be associated. - * - * If the API returns limited results, the method will collect all items. - */ - public async getAvailableCategories( offset: number = 0 ): Promise | null> { - return getAvailableCategories( { - token: this.token, - serviceOrigin: this.serviceOrigin, - workspaceId: this.getWorkspaceId(), - signal: this.controller.signal, - offset - } ); - } - - /** - * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code. - */ - public async getCategoryIdForFile( file: File ): Promise { - // The plugin allows defining to which category the uploaded file should be assigned. - const defaultCategories = this.editor.config.get( 'ckbox.defaultUploadCategories' ); - - return getCategoryIdForFile( file, { - token: this.token, - serviceOrigin: this.serviceOrigin, - workspaceId: this.getWorkspaceId(), - signal: this.controller.signal, - defaultCategories - } ); - } - /** * Starts the upload process. * * @see module:upload/filerepository~UploadAdapter#upload */ public async upload(): Promise { + const ckboxUtils = this.ckboxUtils; + const t = this.editor.t; - const cannotFindCategoryError = t( 'Cannot determine a category for the uploaded file.' ); const file = ( await this.loader.file )!; - const category = await this.getCategoryIdForFile( file ); - - if ( !category ) { - return Promise.reject( cannotFindCategoryError ); - } + const category = await ckboxUtils.getCategoryIdForFile( file, { signal: this.controller.signal } ); const uploadUrl = new URL( 'assets', this.serviceOrigin ); const formData = new FormData(); - uploadUrl.searchParams.set( 'workspaceId', this.getWorkspaceId() ); + uploadUrl.searchParams.set( 'workspaceId', ckboxUtils.getWorkspaceId() ); formData.append( 'categoryId', category ); formData.append( 'file', file ); diff --git a/packages/ckeditor5-ckbox/src/ckboxutils.ts b/packages/ckeditor5-ckbox/src/ckboxutils.ts new file mode 100644 index 00000000000..55fb14784a9 --- /dev/null +++ b/packages/ckeditor5-ckbox/src/ckboxutils.ts @@ -0,0 +1,246 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window */ + +/** + * @module ckbox/ckboxediting + */ + +import type { CloudServices, InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; +import { CKEditorError, logError } from 'ckeditor5/src/utils'; +import { Plugin } from 'ckeditor5/src/core'; +import { + convertMimeTypeToExtension, + getContentTypeOfUrl, + getFileExtension, + getWorkspaceId, + sendHttpRequest +} from './utils'; + +const DEFAULT_CKBOX_THEME_NAME = 'lark'; + +/** + * The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and + * {@link module:ckbox/ckboxuploadadapter~CKBoxUploadAdapter CKBox upload adapter}. + */ +export default class CKBoxUtils extends Plugin { + /** + * CKEditor Cloud Services access token. + */ + private _token!: InitializedToken; + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'CKBoxUtils' as const; + } + + /** + * @inheritDoc + */ + public static get requires() { + return [ 'CloudServices' ] as const; + } + + /** + * @inheritDoc + */ + public async init(): Promise { + const editor = this.editor; + const hasConfiguration = !!editor.config.get( 'ckbox' ); + const isLibraryLoaded = !!window.CKBox; + + // Proceed with plugin initialization only when the integrator intentionally wants to use it, i.e. when the `config.ckbox` exists or + // the CKBox JavaScript library is loaded. + if ( !hasConfiguration && !isLibraryLoaded ) { + return; + } + + editor.config.define( 'ckbox', { + serviceOrigin: 'https://api.ckbox.io', + defaultUploadCategories: null, + ignoreDataId: false, + language: editor.locale.uiLanguage, + theme: DEFAULT_CKBOX_THEME_NAME, + tokenUrl: editor.config.get( 'cloudServices.tokenUrl' ) + } ); + + const cloudServices: CloudServices = editor.plugins.get( 'CloudServices' ); + const cloudServicesTokenUrl = editor.config.get( 'cloudServices.tokenUrl' ); + const ckboxTokenUrl = editor.config.get( 'ckbox.tokenUrl' ); + + if ( !ckboxTokenUrl ) { + /** + * The {@link module:ckbox/ckboxconfig~CKBoxConfig#tokenUrl `config.ckbox.tokenUrl`} or the + * {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl `config.cloudServices.tokenUrl`} + * configuration is required for the CKBox plugin. + * + * ```ts + * ClassicEditor.create( document.createElement( 'div' ), { + * ckbox: { + * tokenUrl: "YOUR_TOKEN_URL" + * // ... + * } + * // ... + * } ); + * ``` + * + * @error ckbox-plugin-missing-token-url + */ + throw new CKEditorError( 'ckbox-plugin-missing-token-url', this ); + } + + if ( ckboxTokenUrl == cloudServicesTokenUrl ) { + this._token = cloudServices.token!; + } else { + this._token = await cloudServices.registerTokenUrl( ckboxTokenUrl ); + } + } + + /** + * Returns a token used by the CKBox plugin for communication with the CKBox service. + */ + public getToken(): InitializedToken { + return this._token; + } + + /** + * The ID of workspace to use when uploading an image. + */ + public getWorkspaceId(): string { + const t = this.editor.t; + const cannotAccessDefaultWorkspaceError = t( 'Cannot access default workspace.' ); + const defaultWorkspaceId = this.editor.config.get( 'ckbox.defaultUploadWorkspaceId' ); + const workspaceId = getWorkspaceId( this._token, defaultWorkspaceId ); + + if ( workspaceId == null ) { + /** + * The user is not authorized to access the workspace defined in the`ckbox.defaultUploadWorkspaceId` configuration. + * + * @error ckbox-access-default-workspace-error + */ + logError( 'ckbox-access-default-workspace-error' ); + + throw cannotAccessDefaultWorkspaceError; + } + + return workspaceId; + } + + /** + * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code. + */ + public async getCategoryIdForFile( fileOrUrl: File | string, options: { signal: AbortSignal } ): Promise { + const t = this.editor.t; + const cannotFindCategoryError = t( 'Cannot determine a category for the uploaded file.' ); + + const defaultCategories = this.editor.config.get( 'ckbox.defaultUploadCategories' ); + + const allCategoriesPromise = this._getAvailableCategories( options ); + + const extension = typeof fileOrUrl == 'string' ? + convertMimeTypeToExtension( await getContentTypeOfUrl( fileOrUrl, options ) ) : + getFileExtension( fileOrUrl ); + + const allCategories = await allCategoriesPromise; + + // Couldn't fetch all categories. Perhaps the authorization token is invalid. + if ( !allCategories ) { + throw cannotFindCategoryError; + } + + // If a user specifies the plugin configuration, find the first category that accepts the uploaded file. + if ( defaultCategories ) { + const userCategory = Object.keys( defaultCategories ).find( category => { + return defaultCategories[ category ].find( e => e.toLowerCase() == extension ); + } ); + + // If found, return its ID if the category exists on the server side. + if ( userCategory ) { + const serverCategory = allCategories.find( category => category.id === userCategory || category.name === userCategory ); + + if ( !serverCategory ) { + throw cannotFindCategoryError; + } + + return serverCategory.id; + } + } + + // Otherwise, find the first category that accepts the uploaded file and returns its ID. + const category = allCategories.find( category => category.extensions.find( e => e.toLowerCase() == extension ) ); + + if ( !category ) { + throw cannotFindCategoryError; + } + + return category.id; + } + + /** + * Resolves a promise with an array containing available categories with which the uploaded file can be associated. + * + * If the API returns limited results, the method will collect all items. + */ + private async _getAvailableCategories( options: { signal: AbortSignal } ): Promise | undefined> { + const ITEMS_PER_REQUEST = 50; + + const editor = this.editor; + const token = this._token; + const { signal } = options; + const serviceOrigin = editor.config.get( 'ckbox.serviceOrigin' )!; + const workspaceId = this.getWorkspaceId(); + + try { + const result: Array = []; + + let offset = 0; + let remainingItems: number; + + do { + const data = await fetchCategories( offset ); + + result.push( ...data.items ); + remainingItems = data.totalCount - ( offset + ITEMS_PER_REQUEST ); + offset += ITEMS_PER_REQUEST; + } while ( remainingItems > 0 ); + + return result; + } catch { + signal.throwIfAborted(); + + /** + * Fetching a list of available categories with which an uploaded file can be associated failed. + * + * @error ckbox-fetch-category-http-error + */ + logError( 'ckbox-fetch-category-http-error' ); + + return undefined; + } + + function fetchCategories( offset: number ): Promise<{ totalCount: number; items: Array }> { + const categoryUrl = new URL( 'categories', serviceOrigin ); + + categoryUrl.searchParams.set( 'limit', ITEMS_PER_REQUEST.toString() ); + categoryUrl.searchParams.set( 'offset', offset.toString() ); + categoryUrl.searchParams.set( 'workspaceId', workspaceId ); + + return sendHttpRequest( { + url: categoryUrl, + signal, + authorization: token.value + } ); + } + } +} + +interface AvailableCategory { + id: string; + name: string; + extensions: Array; +} diff --git a/packages/ckeditor5-ckbox/src/utils.ts b/packages/ckeditor5-ckbox/src/utils.ts index bf5ecf81fd4..53b37fe6003 100644 --- a/packages/ckeditor5-ckbox/src/utils.ts +++ b/packages/ckeditor5-ckbox/src/utils.ts @@ -12,7 +12,6 @@ import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; import type { CKBoxImageUrls } from './ckboxconfig'; -import { logError } from 'ckeditor5/src/utils'; import { decode } from 'blurhash'; /** @@ -196,126 +195,6 @@ export function sendHttpRequest( { } ); } -/** - * Resolves a promise with an array containing available categories with which the uploaded file can be associated. - * - * If the API returns limited results, the method will collect all items. - * - * @internal - */ -export async function getAvailableCategories( options: { - token: InitializedToken; - serviceOrigin: string; - workspaceId: string; - signal: AbortSignal; - offset?: number; -} ): Promise> { - const ITEMS_PER_REQUEST = 50; - const { token, serviceOrigin, workspaceId, signal, offset = 0 } = options; - const categoryUrl = new URL( 'categories', serviceOrigin ); - - categoryUrl.searchParams.set( 'limit', ITEMS_PER_REQUEST.toString() ); - categoryUrl.searchParams.set( 'offset', offset.toString() ); - categoryUrl.searchParams.set( 'workspaceId', workspaceId ); - - return sendHttpRequest( { - url: categoryUrl, - signal, - authorization: token.value - } ) - .then( async data => { - const remainingItems = data.totalCount - ( offset + ITEMS_PER_REQUEST ); - - if ( remainingItems > 0 ) { - const offsetItems = await getAvailableCategories( { - ...options, - offset: offset + ITEMS_PER_REQUEST - } ); - - return [ - ...data.items, - ...offsetItems - ]; - } - - return data.items; - } ) - .catch( () => { - signal.throwIfAborted(); - - /** - * Fetching a list of available categories with which an uploaded file can be associated failed. - * - * @error ckbox-fetch-category-http-error - */ - logError( 'ckbox-fetch-category-http-error' ); - } ); -} - -/** - * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code. - * - * @internal - */ -export async function getCategoryIdForFile( fileOrUrl: File | string, options: { - token: InitializedToken; - serviceOrigin: string; - workspaceId: string; - signal: AbortSignal; - defaultCategories?: Record> | null; -} ): Promise { - const { defaultCategories, signal } = options; - - const allCategoriesPromise = getAvailableCategories( options ); - - const extension = typeof fileOrUrl == 'string' ? - convertMimeTypeToExtension( await getContentTypeOfUrl( fileOrUrl, { signal } ) ) : - getFileExtension( fileOrUrl ); - - const allCategories = await allCategoriesPromise; - - // Couldn't fetch all categories. Perhaps the authorization token is invalid. - if ( !allCategories ) { - return null; - } - - // If a user specifies the plugin configuration, find the first category that accepts the uploaded file. - if ( defaultCategories ) { - const userCategory = Object.keys( defaultCategories ).find( category => { - return defaultCategories[ category ].find( e => e.toLowerCase() == extension ); - } ); - - // If found, return its ID if the category exists on the server side. - if ( userCategory ) { - const serverCategory = allCategories.find( category => category.id === userCategory || category.name === userCategory ); - - if ( !serverCategory ) { - return null; - } - - return serverCategory.id; - } - } - - // Otherwise, find the first category that accepts the uploaded file and returns its ID. - const category = allCategories.find( category => category.extensions.find( e => e.toLowerCase() == extension ) ); - - if ( !category ) { - return null; - } - - return category.id; -} - -/** - * @internal - */ -export interface AvailableCategory { - id: string; - name: string; - extensions: Array; -} - const MIME_TO_EXTENSION: Record = { 'image/gif': 'gif', 'image/jpeg': 'jpg', @@ -325,37 +204,32 @@ const MIME_TO_EXTENSION: Record = { 'image/tiff': 'tiff' }; -function convertMimeTypeToExtension( mimeType: string ) { - const result = MIME_TO_EXTENSION[ mimeType ]; - - if ( !result ) { - throw new Error( 'TODO' ); - } - - return result; +export function convertMimeTypeToExtension( mimeType: string ): string { + return MIME_TO_EXTENSION[ mimeType ]; } -/** - * Gets the Content-Type of the specified url. - */ -async function getContentTypeOfUrl( url: string, options: { signal: AbortSignal } ): Promise { - const response = await fetch( url, { - method: 'HEAD', - cache: 'force-cache', - ...options - } ); +export async function getContentTypeOfUrl( url: string, options: { signal: AbortSignal } ): Promise { + try { + const response = await fetch( url, { + method: 'HEAD', + cache: 'force-cache', + ...options + } ); - if ( !response.ok ) { - throw new Error( `HTTP error. Status: ${ response.status }` ); - } + if ( !response.ok ) { + return ''; + } - return response.headers.get( 'content-type' ) || ''; + return response.headers.get( 'content-type' ) || ''; + } catch { + return ''; + } } /** * Returns an extension from the given value. */ -function getFileExtension( file: File ) { +export function getFileExtension( file: File ): string { const fileName = file.name; const extensionRegExp = /\.(?[^.]+)$/; const match = fileName.match( extensionRegExp ); diff --git a/packages/ckeditor5-ckbox/tests/ckboxediting.js b/packages/ckeditor5-ckbox/tests/ckboxediting.js index 57e563d3f86..c26af1ebf64 100644 --- a/packages/ckeditor5-ckbox/tests/ckboxediting.js +++ b/packages/ckeditor5-ckbox/tests/ckboxediting.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals console, window, document */ +/* globals window */ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; @@ -26,12 +26,8 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils import CKBoxEditing from '../src/ckboxediting'; import CKBoxCommand from '../src/ckboxcommand'; import CKBoxUploadAdapter from '../src/ckboxuploadadapter'; -import Token from '@ckeditor/ckeditor5-cloud-services/src/token/token'; -import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; -import Image from '@ckeditor/ckeditor5-image/src/image'; import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock'; - -const CKBOX_API_URL = 'https://upload.example.com'; +import CKBoxUtils from '../src/ckboxutils'; describe( 'CKBoxEditing', () => { let editor, model, view, originalCKBox, replaceImageSourceCommand; @@ -69,7 +65,7 @@ describe( 'CKBoxEditing', () => { } ); it( 'should load link and picture features', () => { - expect( CKBoxEditing.requires ).to.deep.equal( [ 'CloudServices', 'LinkEditing', 'PictureEditing', CKBoxUploadAdapter ] ); + expect( CKBoxEditing.requires ).to.deep.equal( [ 'LinkEditing', 'PictureEditing', CKBoxUploadAdapter, CKBoxUtils ] ); } ); it( 'should register the "ckbox" command if CKBox lib is loaded', () => { @@ -88,276 +84,6 @@ describe( 'CKBoxEditing', () => { expect( editor.commands.get( 'ckbox' ) ).to.be.undefined; } ); - describe( 'getToken()', () => { - it( 'should return an instance of token', () => { - const ckboxEditing = editor.plugins.get( CKBoxEditing ); - - expect( ckboxEditing.getToken() ).to.be.instanceOf( Token ); - } ); - } ); - - describe( 'fetching token', () => { - it( 'should create an instance of Token class which is ready to use (specified ckbox.tokenUrl)', () => { - const ckboxEditing = editor.plugins.get( CKBoxEditing ); - - expect( ckboxEditing.getToken() ).to.be.instanceOf( Token ); - expect( ckboxEditing.getToken().value ).to.equal( 'ckbox-token' ); - expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://cs.example.com' ); - } ); - - it( 'should not create a new token if already created (specified cloudServices.tokenUrl)', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicTestEditor - .create( editorElement, { - plugins: [ - LinkEditing, - Image, - PictureEditing, - ImageUploadEditing, - ImageUploadProgress, - CloudServices, - CKBoxEditing, - CKBoxUploadAdapter - ], - substitutePlugins: [ - CloudServicesCoreMock - ], - cloudServices: { - tokenUrl: 'http://cs.example.com' - }, - ckbox: { - serviceOrigin: CKBOX_API_URL - } - } ); - - const ckboxEditing = editor.plugins.get( CKBoxEditing ); - expect( ckboxEditing.getToken() ).to.be.instanceOf( Token ); - expect( ckboxEditing.getToken().value ).to.equal( 'ckbox-token' ); - expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://cs.example.com' ); - - editorElement.remove(); - return editor.destroy(); - } ); - - it( 'should create a new token when passed "ckbox.tokenUrl" and "cloudServices.tokenUrl" values are different', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicTestEditor - .create( editorElement, { - plugins: [ - LinkEditing, - Image, - PictureEditing, - ImageUploadEditing, - ImageUploadProgress, - CloudServices, - CKBoxEditing, - CKBoxUploadAdapter - ], - substitutePlugins: [ - CloudServicesCoreMock - ], - cloudServices: { - tokenUrl: 'http://cs.example.com' - }, - ckbox: { - tokenUrl: 'http://ckbox.example.com', - serviceOrigin: CKBOX_API_URL - } - } ); - - const ckboxEditing = editor.plugins.get( CKBoxEditing ); - expect( ckboxEditing.getToken() ).to.be.instanceOf( Token ); - expect( ckboxEditing.getToken().value ).to.equal( 'ckbox-token' ); - expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://ckbox.example.com' ); - - editorElement.remove(); - return editor.destroy(); - } ); - - it( 'should not create a new token when passed "ckbox.tokenUrl" and "cloudServices.tokenUrl" values are equal', async () => { - const editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - const editor = await ClassicTestEditor - .create( editorElement, { - plugins: [ - LinkEditing, - Image, - PictureEditing, - ImageUploadEditing, - ImageUploadProgress, - CloudServices, - CKBoxEditing, - CKBoxUploadAdapter - ], - substitutePlugins: [ - CloudServicesCoreMock - ], - cloudServices: { - tokenUrl: 'http://example.com' - }, - ckbox: { - tokenUrl: 'http://example.com', - serviceOrigin: CKBOX_API_URL - } - } ); - - const ckboxEditing = editor.plugins.get( CKBoxEditing ); - expect( ckboxEditing.getToken() ).to.be.instanceOf( Token ); - expect( ckboxEditing.getToken().value ).to.equal( 'ckbox-token' ); - expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://example.com' ); - - editorElement.remove(); - return editor.destroy(); - } ); - } ); - - describe( 'config', () => { - it( 'should set default values', async () => { - const editor = await createTestEditor( { - language: 'pl', - cloudServices: { - tokenUrl: 'http://cs.example.com' - } - } ); - - expect( editor.config.get( 'ckbox' ) ).to.deep.equal( { - serviceOrigin: 'https://api.ckbox.io', - defaultUploadCategories: null, - ignoreDataId: false, - language: 'pl', - theme: 'lark', - tokenUrl: 'http://cs.example.com' - } ); - - await editor.destroy(); - } ); - - it( 'should set default values if CKBox lib is missing but `config.ckbox` is set', async () => { - delete window.CKBox; - - const editor = await createTestEditor( { - ckbox: { - tokenUrl: 'http://cs.example.com' - } - } ); - - expect( editor.config.get( 'ckbox' ) ).to.deep.equal( { - serviceOrigin: 'https://api.ckbox.io', - defaultUploadCategories: null, - ignoreDataId: false, - language: 'en', - theme: 'lark', - tokenUrl: 'http://cs.example.com' - } ); - - await editor.destroy(); - } ); - - it( 'should not set default values if CKBox lib and `config.ckbox` are missing', async () => { - delete window.CKBox; - - const editor = await createTestEditor( { - cloudServices: { - tokenUrl: 'http://cs.example.com' - } - } ); - - expect( editor.config.get( 'ckbox' ) ).to.be.undefined; - - await editor.destroy(); - } ); - - it( 'should prefer own language configuration over the one from the editor locale', async () => { - const editor = await createTestEditor( { - language: 'pl', - cloudServices: { - tokenUrl: 'http://cs.example.com' - }, - ckbox: { - language: 'de' - } - } ); - - expect( editor.config.get( 'ckbox' ).language ).to.equal( 'de' ); - - await editor.destroy(); - } ); - - it( 'should prefer own "tokenUrl" configuration over the one from the "cloudServices"', async () => { - const editor = await createTestEditor( { - language: 'pl', - cloudServices: { - tokenUrl: 'http://cs.example.com' - }, - ckbox: { - tokenUrl: 'bar' - } - } ); - - expect( editor.config.get( 'ckbox' ).tokenUrl ).to.equal( 'bar' ); - - await editor.destroy(); - } ); - - it( 'should set "theme" value based on `config.ckbox.theme`', async () => { - const editor = await createTestEditor( { - ckbox: { - theme: 'newTheme', - tokenUrl: 'http://cs.example.com' - } - } ); - - expect( editor.config.get( 'ckbox' ).theme ).to.equal( 'newTheme' ); - - await editor.destroy(); - } ); - - it( 'should throw if the "tokenUrl" is not provided', async () => { - await createTestEditor() - .then( - () => { - throw new Error( 'Expected to be rejected' ); - }, - error => { - expect( error.message ).to.match( /ckbox-plugin-missing-token-url/ ); - } - ); - } ); - - it( 'should log an error if there is no image feature loaded in the editor', async () => { - sinon.stub( console, 'error' ); - - const editor = await createTestEditor( { - plugins: [ - Paragraph, - ImageCaptionEditing, - LinkEditing, - LinkImageEditing, - PictureEditing, - ImageUploadEditing, - ImageUploadProgress, - CloudServices, - CKBoxUploadAdapter, - CKBoxEditing - ], - ckbox: { - tokenUrl: 'http://cs.example.com' - } - } ); - - expect( console.error.callCount ).to.equal( 1 ); - expect( console.error.args[ 0 ][ 0 ] ).to.equal( 'ckbox-plugin-image-feature-missing' ); - expect( console.error.args[ 0 ][ 1 ] ).to.equal( editor ); - - await editor.destroy(); - } ); - } ); - describe( 'schema', () => { it( 'should extend the schema rules for image', () => { const linkedImageBlockElement = new ModelElement( 'imageBlock', { linkHref: 'http://cs.example.com' } ); diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js index bbfa5b7665b..b2c14fc39e8 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/ckboximageeditcommand.js @@ -70,7 +70,8 @@ describe( 'CKBoxImageEditCommand', () => { ], ckbox: { serviceOrigin: CKBOX_API_URL, - tokenUrl: 'foo' + tokenUrl: 'foo', + allowExternalImagesEditing: () => true }, substitutePlugins: [ CloudServicesCoreMock @@ -218,7 +219,7 @@ describe( 'CKBoxImageEditCommand', () => { expect( window.CKBox.mountImageEditor.callCount ).to.equal( 1 ); } ); - it( 'should prepare options for the CKBox Image Editing dialog instance', async () => { + it( 'should prepare options for the CKBox Image Editing dialog instance (ckbox image)', async () => { const ckboxImageId = 'example-id'; setModelData( model, @@ -229,7 +230,6 @@ describe( 'CKBoxImageEditCommand', () => { const options = await command._prepareOptions( { element: imageElement, - ckboxImageId, controller: new AbortController() } ); @@ -239,6 +239,53 @@ describe( 'CKBoxImageEditCommand', () => { expect( options.onSave ).to.be.a( 'function' ); expect( options.onClose ).to.be.a( 'function' ); } ); + + it( 'should prepare options for the CKBox Image Editing dialog instance (external image)', async () => { + const imageUrl = 'https://example.com/assets/sample.png'; + const categoryId = 'id-category-1'; + + sinon.stub( editor.plugins.get( 'CKBoxUtils' ), 'getCategoryIdForFile' ).resolves( categoryId ); + + setModelData( model, + `[]` + ); + + const imageElement = editor.model.document.selection.getSelectedElement(); + + const options = await command._prepareOptions( { + element: imageElement, + controller: new AbortController() + } ); + + expect( options ).to.not.have.property( 'assetId' ); + expect( options ).to.have.property( 'imageUrl', imageUrl ); + expect( options ).to.have.property( 'uploadCategoryId', categoryId ); + expect( options ).to.have.property( 'tokenUrl', 'foo' ); + expect( options.imageEditing.allowOverwrite ).to.be.false; + expect( options.onSave ).to.be.a( 'function' ); + expect( options.onClose ).to.be.a( 'function' ); + } ); + + it( 'should handle error when preparing options', async () => { + const notification = editor.plugins.get( Notification ); + const notificationStub = sinon.stub( notification, 'showWarning' ); + const consoleStub = sinon.stub( console, 'error' ); + const reason = 'getCategoryIdForFile behavied very badly.'; + + sinon.stub( editor.plugins.get( 'CKBoxUtils' ), 'getCategoryIdForFile' ).returns( Promise.reject( reason ) ); + + setModelData( model, + '[]' + ); + + command.execute(); + + await clock.tickAsync( 0 ); + + expect( command._wrapper ).to.be.null; + expect( consoleStub.calledOnceWith( reason ) ).to.be.true; + expect( notificationStub.calledOnce ).to.be.true; + } ); } ); describe( 'closing dialog', () => { diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js new file mode 100644 index 00000000000..24d192ffc55 --- /dev/null +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js @@ -0,0 +1,76 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { createEditabilityChecker } from '../../src/ckboximageedit/utils'; +import { Element } from '@ckeditor/ckeditor5-engine'; + +describe( 'image edit utils', () => { + testUtils.createSinonSandbox(); + + describe( 'createEditabilityChecker()', () => { + it( 'should return false for non-image elements', () => { + const checker = createEditabilityChecker( undefined ); + + expect( checker( new Element( 'paragraph' ) ) ).to.be.false; + expect( checker( new Element( 'codeBlock' ) ) ).to.be.false; + } ); + + it( 'should return true for images in ckbox', () => { + const checker = createEditabilityChecker( undefined ); + + expect( checker( new Element( 'imageInline', { ckboxImageId: 'abc' } ) ) ).to.be.true; + expect( checker( new Element( 'imageBlock', { ckboxImageId: 'xyz' } ) ) ).to.be.true; + } ); + + it( 'should return false for external images by default', () => { + const checker = createEditabilityChecker( undefined ); + + expect( checker( new Element( 'imageInline', { src: 'https://ckeditor.com/abc' } ) ) ).to.be.false; + expect( checker( new Element( 'imageBlock', { src: 'https://ckeditor.com/xyz' } ) ) ).to.be.false; + } ); + + it( 'should check if external images match RegExp', () => { + const checker = createEditabilityChecker( /^ckeditor/ ); + + expect( checker( new Element( 'imageInline', { src: 'https://ckeditor.com/abc' } ) ) ).to.be.true; + expect( checker( new Element( 'imageBlock', { src: 'https://ckeditor.com/xyz' } ) ) ).to.be.true; + expect( checker( new Element( 'imageInline', { src: 'https://example.com/abc' } ) ) ).to.be.false; + expect( checker( new Element( 'imageBlock', { src: 'https://cksource.com/xyz' } ) ) ).to.be.false; + expect( checker( new Element( 'imageInline', { src: 'ckeditor.com/abc' } ) ) ).to.be.true; + expect( checker( new Element( 'imageInline', { src: 'example.com/abc' } ) ) ).to.be.false; + } ); + + it( 'should check if external images match one of RegExps', () => { + const checker = createEditabilityChecker( [ /ckeditor/, /^cksource/ ] ); + + expect( checker( new Element( 'imageInline', { src: 'https://ckeditor.com/abc' } ) ) ).to.be.true; + expect( checker( new Element( 'imageBlock', { src: 'https://ckeditor.com/xyz' } ) ) ).to.be.true; + expect( checker( new Element( 'imageInline', { src: 'https://example.com/abc' } ) ) ).to.be.false; + expect( checker( new Element( 'imageBlock', { src: 'https://cksource.com/xyz' } ) ) ).to.be.true; + expect( checker( new Element( 'imageInline', { src: 'example.com/abc' } ) ) ).to.be.false; + expect( checker( new Element( 'imageBlock', { src: 'cksource.com/xyz' } ) ) ).to.be.true; + } ); + + it( 'should use the function to check external images', () => { + const callback = sinon.stub(); + + callback.withArgs( 'https://ckeditor.com/abc' ).returns( true ); + callback.returns( false ); + + const checker = createEditabilityChecker( callback ); + + expect( checker( new Element( 'imageInline', { src: 'https://ckeditor.com/abc' } ) ) ).to.be.true; + expect( checker( new Element( 'imageInline', { src: 'https://cksource.com/abc' } ) ) ).to.be.false; + } ); + + it( 'should return false if image has no `src` attribute', () => { + const checker = createEditabilityChecker( () => true ); + + expect( checker( new Element( 'imageInline' ) ) ).to.be.false; + expect( checker( new Element( 'imageBlock' ) ) ).to.be.false; + } ); + } ); +} ); diff --git a/packages/ckeditor5-ckbox/tests/ckboxuploadadapter.js b/packages/ckeditor5-ckbox/tests/ckboxuploadadapter.js index 26fa8d96406..176a2410775 100644 --- a/packages/ckeditor5-ckbox/tests/ckboxuploadadapter.js +++ b/packages/ckeditor5-ckbox/tests/ckboxuploadadapter.js @@ -26,6 +26,7 @@ import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock import CloudServicesCoreMock from './_utils/cloudservicescoremock'; import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import CKBoxUtils from '../src/ckboxutils'; const BASE64_SAMPLE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; const CKBOX_API_URL = 'https://upload.example.com'; @@ -212,7 +213,7 @@ describe( 'CKBoxUploadAdapter', () => { } ); describe( 'Adapter', () => { - let adapter, file, loader, sinonXHR; + let adapter, file, loader, sinonXHR, ckboxUtils; beforeEach( () => { file = createNativeFileMock(); @@ -220,6 +221,7 @@ describe( 'CKBoxUploadAdapter', () => { loader = fileRepository.createLoader( file ); adapter = editor.plugins.get( FileRepository ).createUploadAdapter( loader ); + ckboxUtils = editor.plugins.get( CKBoxUtils ); sinonXHR = testUtils.sinon.useFakeServer(); sinonXHR.autoRespond = true; @@ -910,7 +912,7 @@ describe( 'CKBoxUploadAdapter', () => { } ); it( 'should throw an error on abort (while uploading)', () => { - sinon.stub( adapter, 'getCategoryIdForFile' ).resolves( 'id-category-2' ); + sinon.stub( ckboxUtils, 'getCategoryIdForFile' ).resolves( 'id-category-2' ); sinonXHR.respondWith( 'POST', CKBOX_API_URL + '/assets?workspaceId=workspace1', xhr => { adapter.abort(); @@ -929,7 +931,7 @@ describe( 'CKBoxUploadAdapter', () => { it( 'should throw an error on generic request error (while uploading)', () => { sinon.stub( console, 'error' ); - sinon.stub( adapter, 'getCategoryIdForFile' ).resolves( 'id-category-2' ); + sinon.stub( ckboxUtils, 'getCategoryIdForFile' ).resolves( 'id-category-2' ); sinonXHR.respondWith( 'POST', CKBOX_API_URL + '/assets?workspaceId=workspace1', xhr => { xhr.error(); @@ -951,7 +953,7 @@ describe( 'CKBoxUploadAdapter', () => { } ); it( 'should update progress', () => { - sinon.stub( adapter, 'getCategoryIdForFile' ).resolves( 'id-category-2' ); + sinon.stub( ckboxUtils, 'getCategoryIdForFile' ).resolves( 'id-category-2' ); sinonXHR.respondWith( 'POST', CKBOX_API_URL + '/assets?workspaceId=workspace1', xhr => { xhr.uploadProgress( { loaded: 4, total: 10 } ); @@ -994,7 +996,7 @@ describe( 'CKBoxUploadAdapter', () => { for ( const { testName, workspaceId, tokenClaims } of testData ) { it( testName, async () => { TokenMock.initialToken = createToken( tokenClaims ); - adapter.token.refreshToken(); + ckboxUtils._token.refreshToken(); sinonXHR.respondWith( 'GET', /\/categories/, [ 200, @@ -1035,7 +1037,7 @@ describe( 'CKBoxUploadAdapter', () => { describe( 'defaultUploadWorkspaceId is defined', () => { it( 'should use the default workspace', () => { TokenMock.initialToken = createToken( { auth: { ckbox: { workspaces: [ 'workspace1', 'workspace2' ] } } } ); - adapter.token.refreshToken(); + ckboxUtils._token.refreshToken(); sinonXHR.respondWith( 'GET', /\/categories/, [ 200, @@ -1075,7 +1077,7 @@ describe( 'CKBoxUploadAdapter', () => { it( 'should use the default workspace when the user is superadmin', () => { TokenMock.initialToken = createToken( { auth: { ckbox: { role: 'superadmin' } } } ); - adapter.token.refreshToken(); + ckboxUtils._token.refreshToken(); sinonXHR.respondWith( 'GET', /\/categories/, [ 200, @@ -1117,7 +1119,7 @@ describe( 'CKBoxUploadAdapter', () => { sinon.stub( console, 'error' ); TokenMock.initialToken = createToken( { auth: { ckbox: { workspaces: [ 'workspace1', 'workspace2' ] } } } ); - adapter.token.refreshToken(); + ckboxUtils._token.refreshToken(); sinonXHR.respondWith( 'GET', /\/categories/, [ 200, diff --git a/packages/ckeditor5-ckbox/tests/ckboxutils.js b/packages/ckeditor5-ckbox/tests/ckboxutils.js new file mode 100644 index 00000000000..fb3664571d5 --- /dev/null +++ b/packages/ckeditor5-ckbox/tests/ckboxutils.js @@ -0,0 +1,705 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals btoa, console, document, window, AbortController, Response */ + +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; +import LinkImageEditing from '@ckeditor/ckeditor5-link/src/linkimageediting'; +import PictureEditing from '@ckeditor/ckeditor5-image/src/pictureediting'; +import ImageUploadEditing from '@ckeditor/ckeditor5-image/src/imageupload/imageuploadediting'; +import ImageUploadProgress from '@ckeditor/ckeditor5-image/src/imageupload/imageuploadprogress'; +import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockediting'; +import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; +import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import CloudServicesCoreMock from './_utils/cloudservicescoremock'; + +import CKBoxEditing from '../src/ckboxediting'; +import CKBoxUploadAdapter from '../src/ckboxuploadadapter'; +import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock'; +import CKBoxUtils from '../src/ckboxutils'; +import Token from '@ckeditor/ckeditor5-cloud-services/src/token/token'; +import { Image } from '@ckeditor/ckeditor5-image'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; + +const CKBOX_API_URL = 'https://upload.example.com'; + +describe( 'CKBoxUtils', () => { + let editor, ckboxUtils, originalCKBox; + const token = createToken( { auth: { ckbox: { workspaces: [ 'workspace1' ] } } } ); + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + TokenMock.initialToken = token; + + originalCKBox = window.CKBox; + window.CKBox = {}; + + editor = await createTestEditor( { + ckbox: { + tokenUrl: 'http://cs.example.com', + serviceOrigin: CKBOX_API_URL + } + } ); + + ckboxUtils = editor.plugins.get( CKBoxUtils ); + } ); + + afterEach( async () => { + window.CKBox = originalCKBox; + await editor.destroy(); + } ); + + it( 'should have proper name', () => { + expect( CKBoxUtils.pluginName ).to.equal( 'CKBoxUtils' ); + } ); + + it( 'should be loaded', () => { + expect( ckboxUtils ).to.be.instanceOf( CKBoxUtils ); + } ); + + describe( 'getToken()', () => { + it( 'should return an instance of token', () => { + expect( ckboxUtils.getToken() ).to.be.instanceOf( Token ); + } ); + } ); + + describe( 'fetching token', () => { + it( 'should create an instance of Token class which is ready to use (specified ckbox.tokenUrl)', () => { + expect( ckboxUtils.getToken() ).to.be.instanceOf( Token ); + expect( ckboxUtils.getToken().value ).to.equal( token ); + expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://cs.example.com' ); + } ); + + it( 'should not create a new token if already created (specified cloudServices.tokenUrl)', async () => { + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + const editor = await ClassicTestEditor + .create( editorElement, { + plugins: [ + LinkEditing, + Image, + PictureEditing, + ImageUploadEditing, + ImageUploadProgress, + CloudServices, + CKBoxEditing, + CKBoxUploadAdapter + ], + substitutePlugins: [ + CloudServicesCoreMock + ], + cloudServices: { + tokenUrl: 'http://cs.example.com' + }, + ckbox: { + serviceOrigin: CKBOX_API_URL + } + } ); + + const ckboxUtils = editor.plugins.get( CKBoxUtils ); + expect( ckboxUtils.getToken() ).to.be.instanceOf( Token ); + expect( ckboxUtils.getToken().value ).to.equal( token ); + expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://cs.example.com' ); + + editorElement.remove(); + return editor.destroy(); + } ); + + it( 'should create a new token when passed "ckbox.tokenUrl" and "cloudServices.tokenUrl" values are different', async () => { + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + const editor = await ClassicTestEditor + .create( editorElement, { + plugins: [ + LinkEditing, + Image, + PictureEditing, + ImageUploadEditing, + ImageUploadProgress, + CloudServices, + CKBoxEditing, + CKBoxUploadAdapter + ], + substitutePlugins: [ + CloudServicesCoreMock + ], + cloudServices: { + tokenUrl: 'http://cs.example.com' + }, + ckbox: { + tokenUrl: 'http://ckbox.example.com', + serviceOrigin: CKBOX_API_URL + } + } ); + + const ckboxUtils = editor.plugins.get( CKBoxUtils ); + expect( ckboxUtils.getToken() ).to.be.instanceOf( Token ); + expect( ckboxUtils.getToken().value ).to.equal( token ); + expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://ckbox.example.com' ); + + editorElement.remove(); + return editor.destroy(); + } ); + + it( 'should not create a new token when passed "ckbox.tokenUrl" and "cloudServices.tokenUrl" values are equal', async () => { + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + const editor = await ClassicTestEditor + .create( editorElement, { + plugins: [ + LinkEditing, + Image, + PictureEditing, + ImageUploadEditing, + ImageUploadProgress, + CloudServices, + CKBoxEditing, + CKBoxUploadAdapter + ], + substitutePlugins: [ + CloudServicesCoreMock + ], + cloudServices: { + tokenUrl: 'http://example.com' + }, + ckbox: { + tokenUrl: 'http://example.com', + serviceOrigin: CKBOX_API_URL + } + } ); + + const ckboxUtils = editor.plugins.get( CKBoxUtils ); + expect( ckboxUtils.getToken() ).to.be.instanceOf( Token ); + expect( ckboxUtils.getToken().value ).to.equal( token ); + expect( editor.plugins.get( 'CloudServicesCore' ).tokenUrl ).to.equal( 'http://example.com' ); + + editorElement.remove(); + return editor.destroy(); + } ); + } ); + + describe( 'config', () => { + it( 'should set default values', async () => { + const editor = await createTestEditor( { + language: 'pl', + cloudServices: { + tokenUrl: 'http://cs.example.com' + } + } ); + + expect( editor.config.get( 'ckbox' ) ).to.deep.equal( { + serviceOrigin: 'https://api.ckbox.io', + defaultUploadCategories: null, + ignoreDataId: false, + language: 'pl', + theme: 'lark', + tokenUrl: 'http://cs.example.com' + } ); + + await editor.destroy(); + } ); + + it( 'should set default values if CKBox lib is missing but `config.ckbox` is set', async () => { + delete window.CKBox; + + const editor = await createTestEditor( { + ckbox: { + tokenUrl: 'http://cs.example.com' + } + } ); + + expect( editor.config.get( 'ckbox' ) ).to.deep.equal( { + serviceOrigin: 'https://api.ckbox.io', + defaultUploadCategories: null, + ignoreDataId: false, + language: 'en', + theme: 'lark', + tokenUrl: 'http://cs.example.com' + } ); + + await editor.destroy(); + } ); + + it( 'should not set default values if CKBox lib and `config.ckbox` are missing', async () => { + delete window.CKBox; + + const editor = await createTestEditor( { + cloudServices: { + tokenUrl: 'http://cs.example.com' + } + } ); + + expect( editor.config.get( 'ckbox' ) ).to.be.undefined; + + await editor.destroy(); + } ); + + it( 'should prefer own language configuration over the one from the editor locale', async () => { + const editor = await createTestEditor( { + language: 'pl', + cloudServices: { + tokenUrl: 'http://cs.example.com' + }, + ckbox: { + language: 'de' + } + } ); + + expect( editor.config.get( 'ckbox' ).language ).to.equal( 'de' ); + + await editor.destroy(); + } ); + + it( 'should prefer own "tokenUrl" configuration over the one from the "cloudServices"', async () => { + const editor = await createTestEditor( { + language: 'pl', + cloudServices: { + tokenUrl: 'http://cs.example.com' + }, + ckbox: { + tokenUrl: 'bar' + } + } ); + + expect( editor.config.get( 'ckbox' ).tokenUrl ).to.equal( 'bar' ); + + await editor.destroy(); + } ); + + it( 'should set "theme" value based on `config.ckbox.theme`', async () => { + const editor = await createTestEditor( { + ckbox: { + theme: 'newTheme', + tokenUrl: 'http://cs.example.com' + } + } ); + + expect( editor.config.get( 'ckbox' ).theme ).to.equal( 'newTheme' ); + + await editor.destroy(); + } ); + + it( 'should throw if the "tokenUrl" is not provided', async () => { + await createTestEditor() + .then( + () => { + throw new Error( 'Expected to be rejected' ); + }, + error => { + expect( error.message ).to.match( /ckbox-plugin-missing-token-url/ ); + } + ); + } ); + + it( 'should log an error if there is no image feature loaded in the editor', async () => { + sinon.stub( console, 'error' ); + + const editor = await createTestEditor( { + plugins: [ + Paragraph, + ImageCaptionEditing, + LinkEditing, + LinkImageEditing, + PictureEditing, + ImageUploadEditing, + ImageUploadProgress, + CloudServices, + CKBoxUploadAdapter, + CKBoxEditing + ], + ckbox: { + tokenUrl: 'http://cs.example.com' + } + } ); + + expect( console.error.callCount ).to.equal( 1 ); + expect( console.error.args[ 0 ][ 0 ] ).to.equal( 'ckbox-plugin-image-feature-missing' ); + expect( console.error.args[ 0 ][ 1 ] ).to.equal( editor ); + + await editor.destroy(); + } ); + } ); + + describe( 'getCategoryIdForFile', () => { + let fetchStub; + const file = { name: 'image.jpg' }; + const url = 'https://example.com/image'; + const options = { signal: new AbortController().signal }; + + beforeEach( () => { + fetchStub = sinon.stub( window, 'fetch' ).resolves( new Response( null, { headers: { 'content-type': 'image/jpeg' } } ) ); + } ); + + it( 'should pass abort signal to other calls (file)', async () => { + const getCategoriesStub = sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'category 2', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'category 3', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + await ckboxUtils.getCategoryIdForFile( file, options ); + + expect( getCategoriesStub.firstCall.args[ 0 ].signal ).to.equal( options.signal ); + } ); + + it( 'should pass abort signal to other calls (url)', async () => { + const getCategoriesStub = sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'category 2', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'category 3', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( getCategoriesStub.firstCall.args[ 0 ].signal ).to.equal( options.signal ); + expect( fetchStub.firstCall.args[ 1 ].signal ).to.equal( options.signal ); + } ); + + it( 'should return the first category if many of them accepts a jpg file', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'category 2', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'category 3', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-2' ); + expect( urlResult ).to.equal( 'id-category-2' ); + } ); + + it( 'should return the first category if many of them accepts a jpg file (uppercase file extension)', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'category 2', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'category 3', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + const fileResult = await ckboxUtils.getCategoryIdForFile( { name: 'image.JPG' }, options ); + + expect( fileResult ).to.equal( 'id-category-2' ); + } ); + + it( 'should return the first category if many of them accepts a JPG file', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', id: 'id-category-1', extensions: [ 'PNG' ] }, + { name: 'category 2', id: 'id-category-2', extensions: [ 'WEBP', 'JPG' ] }, + { name: 'category 3', id: 'id-category-3', extensions: [ 'GIF', 'JPG' ] } + ] ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-2' ); + expect( urlResult ).to.equal( 'id-category-2' ); + } ); + + it( 'should return the first category that allows uploading the file if provided configuration is empty', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', id: 'id-category-1', extensions: [ 'png', 'jpg' ] }, + { name: 'category 2', id: 'id-category-2', extensions: [ 'webp' ] }, + { name: 'category 3', id: 'id-category-3', extensions: [ 'gif' ] } + ] ); + + // An integrator does not define supported extensions. + editor.config.set( 'ckbox.defaultUploadCategories', { + 'id-category-1': [] + } ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-1' ); + expect( urlResult ).to.equal( 'id-category-1' ); + } ); + + it( 'should return the first category matching with the configuration (category specified as a name)', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'Covers', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'Albums', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'Albums (to print)', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + editor.config.set( 'ckbox.defaultUploadCategories', { + 'Albums (to print)': [ 'gif', 'jpg' ] + } ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-3' ); + expect( urlResult ).to.equal( 'id-category-3' ); + } ); + + it( 'should return the first category matching with the configuration (category specified as ID)', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'Covers', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'Albums', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'Albums (to print)', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + editor.config.set( 'ckbox.defaultUploadCategories', { + 'id-category-3': [ 'gif', 'jpg' ] + } ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-3' ); + expect( urlResult ).to.equal( 'id-category-3' ); + } ); + + it( 'should return the first category matching with the configuration (uppercase file extension)', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'Covers', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'Albums', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'Albums (to print)', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + editor.config.set( 'ckbox.defaultUploadCategories', { + 'id-category-3': [ 'gif', 'jpg' ] + } ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( { name: 'image.JPG' }, options ); + + expect( fileResult ).to.equal( 'id-category-3' ); + } ); + + it( 'should return the first category matching with the configuration (uppercase configuration)', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'Covers', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'Albums', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'Albums (to print)', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + editor.config.set( 'ckbox.defaultUploadCategories', { + 'id-category-3': [ 'GIF', 'JPG' ] + } ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-3' ); + expect( urlResult ).to.equal( 'id-category-3' ); + } ); + + it( 'should return the first allowed category for a file not covered by the plugin configuration', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'Covers', id: 'id-category-1', extensions: [ 'png' ] }, + { name: 'Albums', id: 'id-category-2', extensions: [ 'webp', 'jpg' ] }, + { name: 'Albums (to print)', id: 'id-category-3', extensions: [ 'gif', 'jpg' ] } + ] ); + + editor.config.set( 'ckbox.defaultUploadCategories', { + 'Albums (to print)': [ 'bmp' ] + } ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ); + + expect( fileResult ).to.equal( 'id-category-2' ); + expect( urlResult ).to.equal( 'id-category-2' ); + } ); + + it( 'should fail when no category accepts a jpg file', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( [ + { name: 'category 1', extensions: [ 'png' ] }, + { name: 'category 2', extensions: [ 'webp' ] }, + { name: 'category 3', extensions: [ 'gif' ] } + ] ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ).then( + () => { throw new Error( 'Expected to be rejected.' ); }, + err => err + ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ).then( + () => { throw new Error( 'Expected to be rejected.' ); }, + err => err + ); + + expect( fileResult ).to.equal( 'Cannot determine a category for the uploaded file.' ); + expect( urlResult ).to.equal( 'Cannot determine a category for the uploaded file.' ); + } ); + + it( 'should fail when cannot load categories', async () => { + sinon.stub( ckboxUtils, '_getAvailableCategories' ).resolves( undefined ); + + const fileResult = await ckboxUtils.getCategoryIdForFile( file, options ).then( + () => { throw new Error( 'Expected to be rejected.' ); }, + err => err + ); + const urlResult = await ckboxUtils.getCategoryIdForFile( url, options ).then( + () => { throw new Error( 'Expected to be rejected.' ); }, + err => err + ); + + expect( fileResult ).to.equal( 'Cannot determine a category for the uploaded file.' ); + expect( urlResult ).to.equal( 'Cannot determine a category for the uploaded file.' ); + } ); + } ); + + describe( '_getAvailableCategories', () => { + let sinonXHR; + const options = { signal: new AbortController().signal }; + + beforeEach( () => { + sinonXHR = testUtils.sinon.useFakeServer(); + sinonXHR.autoRespond = true; + sinonXHR.respondImmediately = true; + } ); + + afterEach( () => { + sinonXHR.restore(); + } ); + + it( 'should return categories in one call', async () => { + const categories = createCategories( 10 ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=0&workspaceId=workspace1', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify( { + items: categories, offset: 0, limit: 50, totalCount: 10 + } ) + ] ); + + const result = await ckboxUtils._getAvailableCategories( options ); + + expect( result ).to.deep.equal( categories ); + } ); + + it( 'should return categories in three calls', async () => { + const categories = createCategories( 120 ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=0&workspaceId=workspace1', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify( { + items: categories.slice( 0, 50 ), offset: 0, limit: 50, totalCount: 120 + } ) + ] ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=50&workspaceId=workspace1', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify( { + items: categories.slice( 50, 100 ), offset: 50, limit: 50, totalCount: 120 + } ) + ] ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=100&workspaceId=workspace1', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify( { + items: categories.slice( 100 ), offset: 100, limit: 50, totalCount: 120 + } ) + ] ); + + const result = await ckboxUtils._getAvailableCategories( options ); + + expect( result ).to.deep.equal( categories ); + } ); + + it( 'should return undefined if first request fails', async () => { + const consoleStub = sinon.stub( console, 'error' ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=0&workspaceId=workspace1', r => r.error() ); + + const result = await ckboxUtils._getAvailableCategories( options ); + + expect( result ).to.be.undefined; + expect( consoleStub.firstCall.args[ 0 ] ).to.match( /^ckbox-fetch-category-http-error/ ); + } ); + + it( 'should return undefined if third request fails', async () => { + const consoleStub = sinon.stub( console, 'error' ); + + const categories = createCategories( 120 ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=0&workspaceId=workspace1', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify( { + items: categories.slice( 0, 50 ), offset: 0, limit: 50, totalCount: 120 + } ) + ] ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=50&workspaceId=workspace1', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify( { + items: categories.slice( 50, 100 ), offset: 50, limit: 50, totalCount: 120 + } ) + ] ); + + sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/categories?limit=50&offset=100&workspaceId=workspace1', r => r.error() ); + + const result = await ckboxUtils._getAvailableCategories( options ); + + expect( result ).to.be.undefined; + expect( consoleStub.firstCall.args[ 0 ] ).to.match( /^ckbox-fetch-category-http-error/ ); + } ); + + function createCategories( count ) { + const result = []; + let i = 0; + + while ( count > 0 ) { + result.push( { + name: 'Category ' + i, + id: 'id-category-' + i, + extensions: [ 'png' ] + } ); + + i++; + count--; + } + + return result; + } + } ); +} ); + +function createTestEditor( config = {} ) { + return VirtualTestEditor.create( { + plugins: [ + Paragraph, + ImageBlockEditing, + ImageInlineEditing, + ImageCaptionEditing, + LinkEditing, + LinkImageEditing, + PictureEditing, + ImageUploadEditing, + ImageUploadProgress, + CloudServices, + CKBoxUploadAdapter, + CKBoxEditing + ], + substitutePlugins: [ + CloudServicesCoreMock + ], + ...config + } ); +} + +function createToken( tokenClaims ) { + return [ + // Header. + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + // Payload. + btoa( JSON.stringify( tokenClaims ) ), + // Signature. + 'signature' + ].join( '.' ); +} diff --git a/packages/ckeditor5-ckbox/tests/utils.js b/packages/ckeditor5-ckbox/tests/utils.js index fb617f4de5b..f31efa05403 100644 --- a/packages/ckeditor5-ckbox/tests/utils.js +++ b/packages/ckeditor5-ckbox/tests/utils.js @@ -3,11 +3,11 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global btoa, atob */ +/* global btoa, atob, window, AbortController, Response */ import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import { getWorkspaceId, getImageUrls, blurHashToDataUrl } from '../src/utils'; +import { getWorkspaceId, getImageUrls, blurHashToDataUrl, convertMimeTypeToExtension, getContentTypeOfUrl } from '../src/utils'; describe( 'utils', () => { testUtils.createSinonSandbox(); @@ -189,4 +189,94 @@ describe( 'utils', () => { expect( binary.substring( 0, 8 ) ).to.equal( '\x89PNG\r\n\u001a\n' ); } ); } ); + + describe( 'convertMimeTypeToExtension()', () => { + const testData = [ + [ 'image/gif', 'gif' ], + [ 'image/jpeg', 'jpg' ], + [ 'image/png', 'png' ], + [ 'image/webp', 'webp' ], + [ 'image/bmp', 'bmp' ], + [ 'image/tiff', 'tiff' ], + [ 'image/unknown', undefined ], + [ 'text/html', undefined ], + [ '', undefined ] + ]; + + for ( const [ mimeType, extension ] of testData ) { + const returnDescription = extension ? `'${ extension }'` : 'undefined'; + + it( `should return ${ returnDescription } for '${ mimeType }' type`, () => { + expect( convertMimeTypeToExtension( mimeType ) ).to.equal( extension ); + } ); + } + } ); + + describe( 'getContentTypeOfUrl()', () => { + it( 'should fetch content type', async () => { + const imageUrl = 'https://example.com/sample.jpb'; + const mimeType = 'image/myformat'; + const controller = new AbortController(); + sinon.stub( window, 'fetch' ).resolves( + new Response( null, { headers: { 'content-type': mimeType } } ) + ); + + const result = await getContentTypeOfUrl( imageUrl, { signal: controller.signal } ); + + expect( result ).to.equal( mimeType ); + } ); + + it( 'should call `fetch` with correct arguments', async () => { + const imageUrl = 'https://example.com/sample.jpb'; + const mimeType = 'image/myformat'; + const controller = new AbortController(); + const stub = sinon.stub( window, 'fetch' ).resolves( + new Response( null, { headers: { 'content-type': mimeType } } ) + ); + + await getContentTypeOfUrl( imageUrl, { signal: controller.signal } ); + + expect( stub.calledOnce ).to.be.true; + expect( stub.firstCall.args[ 0 ] ).to.equal( imageUrl ); + expect( stub.firstCall.args[ 1 ] ).to.deep.include( { + method: 'HEAD', + cache: 'force-cache', + signal: controller.signal + } ); + } ); + + it( 'should return empty string when `Content-Type` is missing in response', async () => { + const imageUrl = 'https://example.com/sample.jpb'; + const controller = new AbortController(); + sinon.stub( window, 'fetch' ).resolves( + new Response( null, { headers: {} } ) + ); + + const result = await getContentTypeOfUrl( imageUrl, { signal: controller.signal } ); + + expect( result ).to.equal( '' ); + } ); + + it( 'should return empty string when `fetch` fails', async () => { + const imageUrl = 'https://example.com/sample.jpb'; + const controller = new AbortController(); + sinon.stub( window, 'fetch' ).resolves( + new Response( null, { status: 500 } ) + ); + + const result = await getContentTypeOfUrl( imageUrl, { signal: controller.signal } ); + + expect( result ).to.equal( '' ); + } ); + + it( 'should return empty string on network error', async () => { + const imageUrl = 'https://example.com/sample.jpb'; + const controller = new AbortController(); + sinon.stub( window, 'fetch' ).rejects( 'failed' ); + + const result = await getContentTypeOfUrl( imageUrl, { signal: controller.signal } ); + + expect( result ).to.equal( '' ); + } ); + } ); } ); From 351c42cc10bc9770ef99fc112c0c2d22c1004be8 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Sat, 2 Dec 2023 16:50:31 +0100 Subject: [PATCH 07/10] Editing images not in CKBox: add some api docs. --- packages/ckeditor5-ckbox/src/ckboxconfig.ts | 5 ++++- .../src/ckboximageedit/ckboximageeditcommand.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-ckbox/src/ckboxconfig.ts b/packages/ckeditor5-ckbox/src/ckboxconfig.ts index 8b6047c41d9..afaa9c47a04 100644 --- a/packages/ckeditor5-ckbox/src/ckboxconfig.ts +++ b/packages/ckeditor5-ckbox/src/ckboxconfig.ts @@ -99,7 +99,10 @@ export interface CKBoxConfig { forceDemoLabel?: boolean; /** - * TODO. + * Allows editing images that are not hosted in CKBox service. + * + * The provided function or regular expression should whitelist image URL(s) that should be editable. + * Make sure that allowed image resources have CORS enabled. * * @default [] */ diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts index d3d89161410..b65787417f9 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts @@ -44,7 +44,9 @@ export default class CKBoxImageEditCommand extends Command { */ private _processInProgress = new Set(); - /** TODO */ + /** + * Determines if the element can be edited. + */ private _canEdit: ( element: ModelElement ) => boolean; private _prepareOptions: AbortableFunc<[ ProcessingState ], Promise>>; From 0ddfd510a7cd6512c96b7cedfba843a8e9d303f6 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Mon, 4 Dec 2023 12:46:58 +0100 Subject: [PATCH 08/10] Fixed ckboxutils module name. --- packages/ckeditor5-ckbox/src/ckboxutils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-ckbox/src/ckboxutils.ts b/packages/ckeditor5-ckbox/src/ckboxutils.ts index 55fb14784a9..b3fe40283cf 100644 --- a/packages/ckeditor5-ckbox/src/ckboxutils.ts +++ b/packages/ckeditor5-ckbox/src/ckboxutils.ts @@ -6,7 +6,7 @@ /* globals window */ /** - * @module ckbox/ckboxediting + * @module ckbox/ckboxutils */ import type { CloudServices, InitializedToken } from '@ckeditor/ckeditor5-cloud-services'; From 5a04e3a12bd13944e07e62583188455367604d2b Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Mon, 4 Dec 2023 14:32:54 +0100 Subject: [PATCH 09/10] Editing images not in CKBox: allow 'origin' config option. --- packages/ckeditor5-ckbox/src/ckboxconfig.ts | 13 ++++++-- .../src/ckboximageedit/utils.ts | 30 +++++++++++++------ .../tests/ckboximageedit/utils.js | 16 +++++++--- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/ckeditor5-ckbox/src/ckboxconfig.ts b/packages/ckeditor5-ckbox/src/ckboxconfig.ts index afaa9c47a04..d0fc0184aa5 100644 --- a/packages/ckeditor5-ckbox/src/ckboxconfig.ts +++ b/packages/ckeditor5-ckbox/src/ckboxconfig.ts @@ -8,6 +8,7 @@ */ import type { TokenUrl } from '@ckeditor/ckeditor5-cloud-services'; +import type { ArrayOrItem } from 'ckeditor5/src/utils'; /** * The configuration of the {@link module:ckbox/ckbox~CKBox CKBox feature}. @@ -101,12 +102,20 @@ export interface CKBoxConfig { /** * Allows editing images that are not hosted in CKBox service. * - * The provided function or regular expression should whitelist image URL(s) that should be editable. + * This configuration option should whitelist URL(s) of images that should be editable. * Make sure that allowed image resources have CORS enabled. * + * The image is editable if this option is: + * * a regular expression and it matches the image URL, or + * * a custom function that returns `true` for the image URL, or + * * `'origin'` literal and the image URL is from the same origin, or + * * an array of the above and the image URL matches one of the array elements. + * + * Images hosted in CKBox are always editable. + * * @default [] */ - allowExternalImagesEditing?: RegExp | Array | ( ( src: string ) => boolean ); + allowExternalImagesEditing?: ArrayOrItem boolean )>; /** * Inserts the unique asset ID as the `data-ckbox-resource-id` attribute. To disable this behavior, set it to `true`. diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts index bbe70e4bee8..d38fbec54d3 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/utils.ts @@ -7,7 +7,7 @@ * @module ckbox/ckboximageedit/utils */ -import { toArray } from 'ckeditor5/src/utils'; +import { global } from 'ckeditor5/src/utils'; import type { Element } from 'ckeditor5/src/engine'; import type { CKBoxConfig } from '../ckboxconfig'; @@ -44,19 +44,31 @@ export function createEditabilityChecker( function createUrlChecker( allowExternalImagesEditing: CKBoxConfig[ 'allowExternalImagesEditing' ] ): ( src: string ) => boolean { - if ( !allowExternalImagesEditing ) { - return () => false; + if ( Array.isArray( allowExternalImagesEditing ) ) { + const urlMatchers = allowExternalImagesEditing.map( createUrlChecker ); + + return src => urlMatchers.some( matcher => matcher( src ) ); + } + + if ( allowExternalImagesEditing == 'origin' ) { + const origin = global.window.location.origin; + + return src => src.startsWith( origin + '/' ); } if ( typeof allowExternalImagesEditing == 'function' ) { return allowExternalImagesEditing; } - const urlRegExps = toArray( allowExternalImagesEditing ); + if ( allowExternalImagesEditing instanceof RegExp ) { + return src => !!( + src.match( allowExternalImagesEditing ) || + src.replace( /^https?:\/\//, '' ).match( allowExternalImagesEditing ) + ); + } - return src => urlRegExps.some( pattern => - src.match( pattern ) || - src.replace( /^https?:\/\//, '' ).match( pattern ) - ); -} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const shouldBeUndefned: undefined = allowExternalImagesEditing; + return () => false; +} diff --git a/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js b/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js index 24d192ffc55..22a3d367ab7 100644 --- a/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js +++ b/packages/ckeditor5-ckbox/tests/ckboximageedit/utils.js @@ -6,6 +6,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { createEditabilityChecker } from '../../src/ckboximageedit/utils'; import { Element } from '@ckeditor/ckeditor5-engine'; +import { global } from 'ckeditor5/src/utils'; describe( 'image edit utils', () => { testUtils.createSinonSandbox(); @@ -39,8 +40,6 @@ describe( 'image edit utils', () => { expect( checker( new Element( 'imageBlock', { src: 'https://ckeditor.com/xyz' } ) ) ).to.be.true; expect( checker( new Element( 'imageInline', { src: 'https://example.com/abc' } ) ) ).to.be.false; expect( checker( new Element( 'imageBlock', { src: 'https://cksource.com/xyz' } ) ) ).to.be.false; - expect( checker( new Element( 'imageInline', { src: 'ckeditor.com/abc' } ) ) ).to.be.true; - expect( checker( new Element( 'imageInline', { src: 'example.com/abc' } ) ) ).to.be.false; } ); it( 'should check if external images match one of RegExps', () => { @@ -50,8 +49,17 @@ describe( 'image edit utils', () => { expect( checker( new Element( 'imageBlock', { src: 'https://ckeditor.com/xyz' } ) ) ).to.be.true; expect( checker( new Element( 'imageInline', { src: 'https://example.com/abc' } ) ) ).to.be.false; expect( checker( new Element( 'imageBlock', { src: 'https://cksource.com/xyz' } ) ) ).to.be.true; - expect( checker( new Element( 'imageInline', { src: 'example.com/abc' } ) ) ).to.be.false; - expect( checker( new Element( 'imageBlock', { src: 'cksource.com/xyz' } ) ) ).to.be.true; + } ); + + it( 'should check if external images match current origin', () => { + sinon.stub( global, 'window' ).get( () => ( { location: { origin: 'https://ckeditor.com' } } ) ); + + const checker = createEditabilityChecker( 'origin' ); + + expect( checker( new Element( 'imageInline', { src: 'https://ckeditor.com/abc' } ) ) ).to.be.true; + expect( checker( new Element( 'imageBlock', { src: 'https://ckeditor.com/xyz' } ) ) ).to.be.true; + expect( checker( new Element( 'imageInline', { src: 'https://example.com/abc' } ) ) ).to.be.false; + expect( checker( new Element( 'imageBlock', { src: 'https://cksource.com/xyz' } ) ) ).to.be.false; } ); it( 'should use the function to check external images', () => { From 02444813bbfe4e1eaffa0412db0a6fbe2d6e2e15 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Mon, 4 Dec 2023 16:07:06 +0100 Subject: [PATCH 10/10] Editing images not in CKBox: misc. changes after code review. --- .../src/ckboximageedit/ckboximageeditcommand.ts | 5 +++++ packages/ckeditor5-ckbox/tests/manual/ckbox.html | 2 +- packages/ckeditor5-cloud-services/src/cloudservicesconfig.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts index b65787417f9..19dc9e4df76 100644 --- a/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts +++ b/packages/ckeditor5-ckbox/src/ckboximageedit/ckboximageeditcommand.ts @@ -49,6 +49,9 @@ export default class CKBoxImageEditCommand extends Command { */ private _canEdit: ( element: ModelElement ) => boolean; + /** + * A wrapper function to prepare mount options. Ensures that at most one preparation is in-flight. + */ private _prepareOptions: AbortableFunc<[ ProcessingState ], Promise>>; /** @@ -229,6 +232,8 @@ export default class CKBoxImageEditCommand extends Command { this._wrapper = null; this.editor.editing.view.focus(); + + this.refresh(); } /** diff --git a/packages/ckeditor5-ckbox/tests/manual/ckbox.html b/packages/ckeditor5-ckbox/tests/manual/ckbox.html index 25b96716a06..64a3afcc101 100644 --- a/packages/ckeditor5-ckbox/tests/manual/ckbox.html +++ b/packages/ckeditor5-ckbox/tests/manual/ckbox.html @@ -7,6 +7,6 @@

CKBox sample

-

Use the toolbar icon to insert an image or link to a file to the content.

+

Use the toolbar icon to insert an image or link to a file to the content.

Example link

diff --git a/packages/ckeditor5-cloud-services/src/cloudservicesconfig.ts b/packages/ckeditor5-cloud-services/src/cloudservicesconfig.ts index a9d24698bc2..bb2e79d7604 100644 --- a/packages/ckeditor5-cloud-services/src/cloudservicesconfig.ts +++ b/packages/ckeditor5-cloud-services/src/cloudservicesconfig.ts @@ -37,7 +37,7 @@ export interface CloudServicesConfig { * As a string, it should be a URL to the security token endpoint in your application. * The role of this endpoint is to securely authorize * the end users of your application to use [CKEditor Cloud Services](https://ckeditor.com/ckeditor-cloud-services) only - * if they should have access e.g. to upload files with {@glink @cs guides/easy-image/quick-start Easy Image} or to use the + * if they should have access e.g. to upload files with {@glink features/file-management/ckbox CKBox} or to use the * {@glink @cs guides/collaboration/quick-start Collaboration} service. * * ```ts