Skip to content
4 changes: 3 additions & 1 deletion packages/ckeditor5-emoji/src/emojiconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export interface EmojiConfig {
*
* @default 16
*/
version?: 15 | 16;
version?: EmojiVersion;
}

export type SkinToneId = 'default' | 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark';

export type EmojiVersion = 15 | 16;
172 changes: 104 additions & 68 deletions packages/ckeditor5-emoji/src/emojirepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,40 @@ import Fuse from 'fuse.js';
import { groupBy } from 'lodash-es';

import { type Editor, Plugin } from 'ckeditor5/src/core.js';
import { logWarning } from 'ckeditor5/src/utils.js';
import { logWarning, version } from 'ckeditor5/src/utils.js';
import EmojiUtils from './emojiutils.js';
import type { SkinToneId } from './emojiconfig.js';
import type { EmojiVersion, SkinToneId } from './emojiconfig.js';

// An endpoint from which the emoji database will be downloaded during plugin initialization.
// An endpoint from which the emoji data will be downloaded during plugin initialization.
// The `{version}` placeholder is replaced with the value from editor config.
const EMOJI_DATABASE_URL = 'https://cdn.ckeditor.com/ckeditor5/data/emoji/{version}/en.json';

/**
* The emoji repository plugin.
*
* Loads the emoji database from URL during plugin initialization and provides utility methods to search it.
* Loads the emoji repository from URL during plugin initialization and provides utility methods to search it.
*/
export default class EmojiRepository extends Plugin {
/**
* A callback to resolve the {@link #_databasePromise} to control the return value of this promise.
* A callback to resolve the {@link #_repositoryPromise} to control the return value of this promise.
*/
declare private _databasePromiseResolveCallback: ( value: boolean ) => void;
declare private _repositoryPromiseResolveCallback: ( value: boolean ) => void;

/**
* An instance of the [Fuse.js](https://www.fusejs.io/) library.
*/
private _fuseSearch: Fuse<EmojiEntry> | null;

/**
* Emoji database.
* The emoji version that is used to prepare the emoji repository.
*/
private _database: Array<EmojiEntry>;
private readonly _version: EmojiVersion;

/**
* A promise resolved after downloading the emoji database.
* The promise resolves with `true` when the database is successfully downloaded or `false` otherwise.
* A promise resolved after downloading the emoji collection.
* The promise resolves with `true` when the repository is successfully downloaded or `false` otherwise.
*/
private readonly _databasePromise: Promise<boolean>;
private readonly _repositoryPromise: Promise<boolean>;

/**
* @inheritDoc
Expand Down Expand Up @@ -73,14 +73,15 @@ export default class EmojiRepository extends Plugin {
constructor( editor: Editor ) {
super( editor );

this.editor.config.define( 'emoji', {
editor.config.define( 'emoji', {
version: 16,
skinTone: 'default'
} );

this._database = [];
this._databasePromise = new Promise<boolean>( resolve => {
this._databasePromiseResolveCallback = resolve;
this._version = editor.config.get( 'emoji.version' )!;

this._repositoryPromise = new Promise<boolean>( resolve => {
this._repositoryPromiseResolveCallback = resolve;
} );

this._fuseSearch = null;
Expand All @@ -90,32 +91,22 @@ export default class EmojiRepository extends Plugin {
* @inheritDoc
*/
public async init(): Promise<void> {
const emojiUtils = this.editor.plugins.get( 'EmojiUtils' );
const emojiVersion = this.editor.config.get( 'emoji.version' )!;

const emojiDatabaseUrl = EMOJI_DATABASE_URL.replace( '{version}', `${ emojiVersion }` );
const emojiDatabase = await loadEmojiDatabase( emojiDatabaseUrl );
const emojiSupportedVersionByOs = emojiUtils.getEmojiSupportedVersionByOs();
if ( !( this._version in EmojiRepository._results ) ) {
const cdnResult = await this._loadItemsFromCdn();

// Skip the initialization if the emoji database download has failed.
// An empty database prevents the initialization of other dependent plugins, such as `EmojiMention` and `EmojiPicker`.
if ( !emojiDatabase.length ) {
return this._databasePromiseResolveCallback( false );
EmojiRepository._results[ this._version ] = this._normalizeEmoji( cdnResult );
}

const container = emojiUtils.createEmojiWidthTestingContainer();
document.body.appendChild( container );

// Store the emoji database after normalizing the raw data.
this._database = emojiDatabase
.filter( item => emojiUtils.isEmojiCategoryAllowed( item ) )
.filter( item => emojiUtils.isEmojiSupported( item, emojiSupportedVersionByOs, container ) )
.map( item => emojiUtils.normalizeEmojiSkinTone( item ) );
const items = this._getItems();

container.remove();
// Skip plugin initialization if the emoji repository is not available.
// The initialization of other dependent plugins, such as `EmojiMention` and `EmojiPicker`, is prevented as well.
if ( !items ) {
return this._repositoryPromiseResolveCallback( false );
}

// Create instance of the Fuse.js library with configured weighted search keys and disabled fuzzy search.
this._fuseSearch = new Fuse( this._database, {
this._fuseSearch = new Fuse( items, {
keys: [
{ name: 'emoticon', weight: 5 },
{ name: 'annotation', weight: 3 },
Expand All @@ -126,12 +117,12 @@ export default class EmojiRepository extends Plugin {
ignoreLocation: true
} );

return this._databasePromiseResolveCallback( true );
return this._repositoryPromiseResolveCallback( true );
}

/**
* Returns an array of emoji entries that match the search query.
* If the emoji database is not loaded, the [Fuse.js](https://www.fusejs.io/) instance is not created,
* If the emoji repository is not loaded, the [Fuse.js](https://www.fusejs.io/) instance is not created,
* hence this method returns an empty array.
*
* @param searchQuery A search query to match emoji.
Expand Down Expand Up @@ -170,12 +161,14 @@ export default class EmojiRepository extends Plugin {

/**
* Groups all emojis by categories.
* If the emoji database is not loaded, it returns an empty array.
* If the emoji repository is not loaded, it returns an empty array.
*
* @returns An array of emoji entries grouped by categories.
*/
public getEmojiCategories(): Array<EmojiCategory> {
if ( !this._database.length ) {
const repository = this._getItems();

if ( !repository ) {
return [];
}

Expand All @@ -193,7 +186,7 @@ export default class EmojiRepository extends Plugin {
{ title: t( 'Flags' ), icon: '🏁', groupId: 9 }
];

const groups = groupBy( this._database, 'group' );
const groups = groupBy( repository, 'group' );

return categories.map( category => {
return {
Expand All @@ -220,43 +213,86 @@ export default class EmojiRepository extends Plugin {
}

/**
* Indicates whether the emoji database has been successfully downloaded and the plugin is operational.
* Indicates whether the emoji repository has been successfully downloaded and the plugin is operational.
*/
public isReady(): Promise<boolean> {
return this._databasePromise;
return this._repositoryPromise;
}
}

/**
* Makes the HTTP request to download the emoji database.
*/
async function loadEmojiDatabase( emojiDatabaseUrl: string ): Promise<Array<EmojiCdnResource>> {
const result = await fetch( emojiDatabaseUrl )
.then( response => {
if ( !response.ok ) {
/**
* Returns the emoji repository in a configured version if it is a non-empty array. Returns `null` otherwise.
*/
private _getItems(): Array<EmojiEntry> | null {
const repository = EmojiRepository._results[ this._version ];

return repository && repository.length ? repository : null;
}

/**
* Makes the HTTP request to download the emoji repository in a configured version.
*/
private async _loadItemsFromCdn(): Promise<Array<EmojiCdnResource>> {
const repositoryUrl = new URL( EMOJI_DATABASE_URL.replace( '{version}', `${ this._version }` ) );

repositoryUrl.searchParams.set( 'editorVersion', version );

const result: Array<EmojiCdnResource> = await fetch( repositoryUrl )
.then( response => {
if ( !response.ok ) {
return [];
}

return response.json();
} )
.catch( () => {
return [];
}
} );

if ( !result.length ) {
/**
* Unable to load the emoji repository from CDN.
*
* If the CDN works properly and there is no disruption of communication, please check your
* {@glink getting-started/setup/csp Content Security Policy (CSP)} setting and make sure
* the CDN connection is allowed by the editor.
*
* @error emoji-repository-load-failed
*/
logWarning( 'emoji-repository-load-failed' );
}

return response.json();
} )
.catch( () => {
return [];
} );
return result;
}

/**
* Normalizes the raw data fetched from CDN. By normalization, we meant:
*
* * Filter out unsupported emoji (these that will not render correctly),
* * Prepare skin tone variants if an emoji defines them.
*/
private _normalizeEmoji( data: Array<EmojiCdnResource> ): Array<EmojiEntry> {
const emojiUtils = this.editor.plugins.get( 'EmojiUtils' );
const emojiSupportedVersionByOs = emojiUtils.getEmojiSupportedVersionByOs();

const container = emojiUtils.createEmojiWidthTestingContainer();
document.body.appendChild( container );

const results = data
.filter( item => emojiUtils.isEmojiCategoryAllowed( item ) )
.filter( item => emojiUtils.isEmojiSupported( item, emojiSupportedVersionByOs, container ) )
.map( item => emojiUtils.normalizeEmojiSkinTone( item ) );

container.remove();

if ( !result.length ) {
/**
* Unable to load the emoji database from CDN.
*
* If the CDN works properly and there is no disruption of communication, please check your
* {@glink getting-started/setup/csp Content Security Policy (CSP)} setting and make sure
* the CDN connection is allowed by the editor.
*
* @error emoji-database-load-failed
*/
logWarning( 'emoji-database-load-failed' );
return results;
}

return result;
/**
* Versioned emoji repository.
*/
private static _results: {
[ key in EmojiVersion ]?: Array<EmojiEntry>
} = {};
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-emoji/src/utils/isemojisupported.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default function isEmojiSupported( unicode: string ): boolean {

function getCanvas(): CanvasRenderingContext2D | null {
try {
return document.createElement( 'canvas' ).getContext( '2d' );
return document.createElement( 'canvas' ).getContext( '2d', { willReadFrequently: true } );
} catch {
/* istanbul ignore next -- @preserve */
return null;
Expand Down
4 changes: 3 additions & 1 deletion packages/ckeditor5-emoji/tests/emojimention.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ describe( 'EmojiMention', () => {
expect( queryEmoji( ' see' ) ).to.deep.equal( [] );
} );

it( 'should return an empty array when the repository plugin is not loaded correctly', async () => {
it( 'should return an empty array when the repository plugin is not available', async () => {
testUtils.sinon.stub( console, 'warn' );
fetchStub.rejects( 'Failed to load CDN.' );

Expand All @@ -626,6 +626,8 @@ describe( 'EmojiMention', () => {
plugins: [ EmojiMention, Paragraph, Essentials, Mention ]
} );

editor.plugins.get( 'EmojiMention' )._isEmojiRepositoryAvailable = false;

const queryEmoji = editor.plugins.get( 'EmojiMention' )._queryEmojiCallbackFactory();

expect( queryEmoji( '' ) ).to.deep.equal( [] );
Expand Down
14 changes: 14 additions & 0 deletions packages/ckeditor5-emoji/tests/emojipicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ describe( 'EmojiPicker', () => {
let editor, editorElement, emojiPicker, fetchStub;

beforeEach( async () => {
EmojiRepository._results = {};

editorElement = document.createElement( 'div' );
document.body.appendChild( editorElement );

Expand Down Expand Up @@ -129,6 +131,9 @@ describe( 'EmojiPicker', () => {
const editorElement = document.createElement( 'div' );
document.body.appendChild( editorElement );

// As the data are shored between editors creation, let's manually clear it before creating a new editor.
EmojiRepository._results = {};

const editor = await ClassicTestEditor.create( editorElement, {
plugins: [ EmojiPicker, Essentials, Paragraph ],
emoji: {
Expand All @@ -148,6 +153,9 @@ describe( 'EmojiPicker', () => {
const editorElement = document.createElement( 'div' );
document.body.appendChild( editorElement );

// As the data are shored between editors creation, let's manually clear it before creating a new editor.
EmojiRepository._results = {};

const editor = await ClassicTestEditor.create( editorElement, {
plugins: [ EmojiPicker, Essentials, Paragraph ],
emoji: {
Expand Down Expand Up @@ -197,6 +205,9 @@ describe( 'EmojiPicker', () => {
testUtils.sinon.stub( console, 'warn' );
fetchStub.rejects( 'Failed to load CDN.' );

// As the data are shored between editors creation, let's manually clear it before creating a new editor.
EmojiRepository._results = {};

const editor = await ClassicTestEditor.create( editorElement, {
plugins: [ EmojiPicker, Paragraph, Essentials ]
} );
Expand Down Expand Up @@ -234,6 +245,9 @@ describe( 'EmojiPicker', () => {
testUtils.sinon.stub( console, 'warn' );
fetchStub.rejects( 'Failed to load CDN.' );

// As the data are shored between editors creation, let's manually clear it before creating a new editor.
EmojiRepository._results = {};

const editor = await ClassicTestEditor.create( editorElement, {
plugins: [ EmojiPicker, Paragraph, Essentials ]
} );
Expand Down
Loading