diff --git a/.eslintrc.js b/.eslintrc.js index f6f02abe997..844837a705e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,8 @@ module.exports = { // The CKEditor 5 core DLL build is created from JavaScript files. // ESLint should not process compiled TypeScript. 'src/*.js', - '**/*.d.ts' + '**/*.d.ts', + 'packages/ckeditor5-emoji/src/utils/isemojisupported.ts' ], rules: { 'ckeditor5-rules/ckeditor-imports': 'error', diff --git a/LICENSE.md b/LICENSE.md index 3fed3d16383..811fa4b9ee0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -23,6 +23,7 @@ The following libraries are included in CKEditor under the [MIT license](https:/ * color-parse - Copyright (c) 2015 Dmitry Ivanov. * emoji-picker-element-data - Copyright (c) 2020 Nolan Lawson. * Fuse.js - Copyright (c) 2017 Kirollos Risk. +* is-emoji-supported - Copyright (c) 2016-2020 Koala Interactive, Inc. * Lodash - Copyright (c) JS Foundation and other contributors https://js.foundation/. Based on Underscore.js, copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors http://underscorejs.org/. * Marked - Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/), copyright (c) 2011–2018, Christopher Jeffrey (https://github.com/chjj/). * Turndown - Copyright (c) 2017+ Dom Christie. diff --git a/packages/ckeditor5-emoji/LICENSE.md b/packages/ckeditor5-emoji/LICENSE.md index b0638d6f145..95aca804595 100644 --- a/packages/ckeditor5-emoji/LICENSE.md +++ b/packages/ckeditor5-emoji/LICENSE.md @@ -20,6 +20,7 @@ The following libraries are included in CKEditor 5 emoji feature under the * emoji-picker-element-data - Copyright (c) 2020 Nolan Lawson. * Fuse.js - Copyright (c) 2017 Kirollos Risk. +* is-emoji-supported - Copyright (c) 2016-2020 Koala Interactive, Inc. * Lodash - Copyright (c) JS Foundation and other contributors https://js.foundation/. Based on Underscore.js, copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors http://underscorejs.org/. Trademarks diff --git a/packages/ckeditor5-emoji/src/augmentation.ts b/packages/ckeditor5-emoji/src/augmentation.ts index 39635f7a544..c85bbf995c8 100644 --- a/packages/ckeditor5-emoji/src/augmentation.ts +++ b/packages/ckeditor5-emoji/src/augmentation.ts @@ -9,6 +9,7 @@ import type { EmojiMention, EmojiPicker, EmojiRepository, + EmojiUtils, EmojiCommand } from './index.js'; @@ -28,6 +29,7 @@ declare module '@ckeditor/ckeditor5-core' { [ EmojiMention.pluginName ]: EmojiMention; [ EmojiPicker.pluginName ]: EmojiPicker; [ EmojiRepository.pluginName ]: EmojiRepository; + [ EmojiUtils.pluginName ]: EmojiUtils; } interface CommandsMap { diff --git a/packages/ckeditor5-emoji/src/emojimention.ts b/packages/ckeditor5-emoji/src/emojimention.ts index 9559c647f2c..4037f096736 100644 --- a/packages/ckeditor5-emoji/src/emojimention.ts +++ b/packages/ckeditor5-emoji/src/emojimention.ts @@ -29,12 +29,12 @@ export default class EmojiMention extends Plugin { /** * An instance of the {@link module:emoji/emojipicker~EmojiPicker} plugin if it is loaded in the editor. */ - declare private _emojiPickerPlugin: EmojiPicker | null; + declare public emojiPickerPlugin: EmojiPicker | null; /** * An instance of the {@link module:emoji/emojirepository~EmojiRepository} plugin. */ - declare private _emojiRepositoryPlugin: EmojiRepository; + declare public emojiRepositoryPlugin: EmojiRepository; /** * A flag that informs if the {@link module:emoji/emojirepository~EmojiRepository} plugin is loaded correctly. @@ -139,9 +139,9 @@ export default class EmojiMention extends Plugin { public async init(): Promise { const editor = this.editor; - this._emojiPickerPlugin = editor.plugins.has( 'EmojiPicker' ) ? editor.plugins.get( 'EmojiPicker' ) : null; - this._emojiRepositoryPlugin = editor.plugins.get( 'EmojiRepository' ); - this._isEmojiRepositoryAvailable = await this._emojiRepositoryPlugin.isReady(); + this.emojiPickerPlugin = editor.plugins.has( 'EmojiPicker' ) ? editor.plugins.get( 'EmojiPicker' ) : null; + this.emojiRepositoryPlugin = editor.plugins.get( 'EmojiRepository' ); + this._isEmojiRepositoryAvailable = await this.emojiRepositoryPlugin.isReady(); // Override the `mention` command listener if the emoji repository is ready. if ( this._isEmojiRepositoryAvailable ) { @@ -218,7 +218,7 @@ export default class EmojiMention extends Plugin { editor.model.deleteContent( writer.createSelection( eventData.range ) ); } ); - const emojiPickerPlugin = this._emojiPickerPlugin!; + const emojiPickerPlugin = this.emojiPickerPlugin!; emojiPickerPlugin.showUI( text.slice( 1 ) ); @@ -251,12 +251,12 @@ export default class EmojiMention extends Plugin { return []; } - const emojis: Array = this._emojiRepositoryPlugin.getEmojiByQuery( searchQuery ) + const emojis: Array = this.emojiRepositoryPlugin.getEmojiByQuery( searchQuery ) .map( emoji => { let text = emoji.skins[ this._skinTone ] || emoji.skins.default; - if ( this._emojiPickerPlugin ) { - text = emoji.skins[ this._emojiPickerPlugin.skinTone ] || emoji.skins.default; + if ( this.emojiPickerPlugin ) { + text = emoji.skins[ this.emojiPickerPlugin.skinTone ] || emoji.skins.default; } return { @@ -265,7 +265,7 @@ export default class EmojiMention extends Plugin { }; } ); - if ( !this._emojiPickerPlugin ) { + if ( !this.emojiPickerPlugin ) { return emojis.slice( 0, this._emojiDropdownLimit ); } diff --git a/packages/ckeditor5-emoji/src/emojipicker.ts b/packages/ckeditor5-emoji/src/emojipicker.ts index f8448b66df9..68fbdb54be8 100644 --- a/packages/ckeditor5-emoji/src/emojipicker.ts +++ b/packages/ckeditor5-emoji/src/emojipicker.ts @@ -36,12 +36,12 @@ export default class EmojiPicker extends Plugin { /** * The contextual balloon plugin instance. */ - declare public _balloonPlugin: ContextualBalloon; + declare public balloonPlugin: ContextualBalloon; /** * An instance of the {@link module:emoji/emojirepository~EmojiRepository} plugin. */ - declare private _emojiRepositoryPlugin: EmojiRepository; + declare public emojiRepositoryPlugin: EmojiRepository; /** * @inheritDoc @@ -70,11 +70,11 @@ export default class EmojiPicker extends Plugin { public async init(): Promise { const editor = this.editor; - this._balloonPlugin = editor.plugins.get( 'ContextualBalloon' ); - this._emojiRepositoryPlugin = editor.plugins.get( 'EmojiRepository' ); + this.balloonPlugin = editor.plugins.get( 'ContextualBalloon' ); + this.emojiRepositoryPlugin = editor.plugins.get( 'EmojiRepository' ); // Skip registering a button in the toolbar and list item in the menu bar if the emoji repository is not ready. - if ( !await this._emojiRepositoryPlugin.isReady() ) { + if ( !await this.emojiRepositoryPlugin.isReady() ) { return; } @@ -144,8 +144,8 @@ export default class EmojiPicker extends Plugin { this.emojiPickerView.searchView.search( searchValue ); - if ( !this._balloonPlugin.hasView( this.emojiPickerView ) ) { - this._balloonPlugin.add( { + if ( !this.balloonPlugin.hasView( this.emojiPickerView ) ) { + this.balloonPlugin.add( { view: this.emojiPickerView, position: this._getBalloonPositionData() } ); @@ -181,11 +181,11 @@ export default class EmojiPicker extends Plugin { */ private _createEmojiPickerView(): EmojiPickerView { const emojiPickerView = new EmojiPickerView( this.editor.locale, { - emojiCategories: this._emojiRepositoryPlugin.getEmojiCategories(), + emojiCategories: this.emojiRepositoryPlugin.getEmojiCategories(), skinTone: this.editor.config.get( 'emoji.skinTone' )!, - skinTones: this._emojiRepositoryPlugin.getSkinTones(), + skinTones: this.emojiRepositoryPlugin.getSkinTones(), getEmojiByQuery: ( query: string ) => { - return this._emojiRepositoryPlugin.getEmojiByQuery( query ); + return this.emojiRepositoryPlugin.getEmojiByQuery( query ); } } ); @@ -200,8 +200,8 @@ export default class EmojiPicker extends Plugin { // Update the balloon position when layout is changed. this.listenTo( emojiPickerView, 'update', () => { - if ( this._balloonPlugin.visibleView === emojiPickerView ) { - this._balloonPlugin.updatePosition(); + if ( this.balloonPlugin.visibleView === emojiPickerView ) { + this.balloonPlugin.updatePosition(); } } ); @@ -214,9 +214,9 @@ export default class EmojiPicker extends Plugin { // Close the dialog when clicking outside of it. clickOutsideHandler( { emitter: emojiPickerView, - contextElements: [ this._balloonPlugin.view.element! ], + contextElements: [ this.balloonPlugin.view.element! ], callback: () => this._hideUI(), - activator: () => this._balloonPlugin.visibleView === emojiPickerView + activator: () => this.balloonPlugin.visibleView === emojiPickerView } ); return emojiPickerView; @@ -226,7 +226,7 @@ export default class EmojiPicker extends Plugin { * Hides the balloon with the emoji picker. */ private _hideUI(): void { - this._balloonPlugin.remove( this.emojiPickerView! ); + this.balloonPlugin.remove( this.emojiPickerView! ); this.emojiPickerView!.searchView.setInputValue( '' ); this.editor.editing.view.focus(); this._hideFakeVisualSelection(); @@ -267,7 +267,7 @@ export default class EmojiPicker extends Plugin { } /** - * Returns positioning options for the {@link #_balloonPlugin}. They control the way the balloon is attached + * Returns positioning options for the {@link #balloonPlugin}. They control the way the balloon is attached * to the target element or selection. */ private _getBalloonPositionData(): Partial { diff --git a/packages/ckeditor5-emoji/src/emojirepository.ts b/packages/ckeditor5-emoji/src/emojirepository.ts index 1f350d9d769..d77302b5e36 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -10,31 +10,31 @@ import Fuse from 'fuse.js'; import { groupBy } from 'lodash-es'; -import { Plugin, type Editor } from 'ckeditor5/src/core.js'; +import { type Editor, Plugin } from 'ckeditor5/src/core.js'; import { logWarning } from 'ckeditor5/src/utils.js'; +import EmojiUtils from './emojiutils.js'; import type { SkinToneId } from './emojiconfig.js'; // An endpoint from which the emoji database 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'; -const SKIN_TONE_MAP: Record = { - 0: 'default', - 1: 'light', - 2: 'medium-light', - 3: 'medium', - 4: 'medium-dark', - 5: 'dark' -}; - -const BASELINE_EMOJI_WIDTH = 24; - /** * The emoji repository plugin. * * Loads the emoji database 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. + */ + declare private _databasePromiseResolveCallback: ( value: boolean ) => void; + + /** + * An instance of the [Fuse.js](https://www.fusejs.io/) library. + */ + private _fuseSearch: Fuse | null; + /** * Emoji database. */ @@ -44,17 +44,14 @@ export default class EmojiRepository extends Plugin { * A promise resolved after downloading the emoji database. * The promise resolves with `true` when the database is successfully downloaded or `false` otherwise. */ - private _databasePromise: Promise; - - /** - * A callback to resolve the {@link #_databasePromise} to control the return value of this promise. - */ - declare private _databasePromiseResolveCallback: ( value: boolean ) => void; + private readonly _databasePromise: Promise; /** - * An instance of the [Fuse.js](https://www.fusejs.io/) library. + * @inheritDoc */ - private _fuseSearch: Fuse | null; + public static get requires() { + return [ EmojiUtils ] as const; + } /** * @inheritDoc @@ -93,9 +90,12 @@ export default class EmojiRepository extends Plugin { * @inheritDoc */ public async init(): Promise { + 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(); // 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`. @@ -103,13 +103,14 @@ export default class EmojiRepository extends Plugin { return this._databasePromiseResolveCallback( false ); } - const container = createEmojiWidthTestingContainer(); + const container = emojiUtils.createEmojiWidthTestingContainer(); + document.body.appendChild( container ); // Store the emoji database after normalizing the raw data. this._database = emojiDatabase - .filter( item => isEmojiCategoryAllowed( item ) ) - .filter( item => EmojiRepository._isEmojiSupported( item, container ) ) - .map( item => normalizeEmojiSkinTone( item ) ); + .filter( item => emojiUtils.isEmojiCategoryAllowed( item ) ) + .filter( item => emojiUtils.isEmojiSupported( item, emojiSupportedVersionByOs, container ) ) + .map( item => emojiUtils.normalizeEmojiSkinTone( item ) ); container.remove(); @@ -224,13 +225,6 @@ export default class EmojiRepository extends Plugin { public isReady(): Promise { return this._databasePromise; } - - /** - * A function used to check if the given emoji is supported in the operating system. - * - * Referenced for unit testing purposes. - */ - private static _isEmojiSupported = isEmojiSupported; } /** @@ -265,78 +259,6 @@ async function loadEmojiDatabase( emojiDatabaseUrl: string ): Promise= BASELINE_EMOJI_WIDTH ); -} - -/** - * Adds default skin tone property to each emoji. If emoji defines other skin tones, they are added as well. - */ -function normalizeEmojiSkinTone( item: EmojiCdnResource ): EmojiEntry { - const entry: EmojiEntry = { - ...item, - skins: { - default: item.emoji - } - }; - - if ( item.skins ) { - item.skins.forEach( skin => { - const skinTone = SKIN_TONE_MAP[ skin.tone ]; - - entry.skins[ skinTone ] = skin.emoji; - } ); - } - - return entry; -} - -/** - * Checks whether the emoji belongs to a group that is allowed. - */ -function isEmojiCategoryAllowed( item: EmojiCdnResource ): boolean { - // Category group=2 contains skin tones only, which we do not want to render. - return item.group !== 2; -} - /** * Represents a single group of the emoji category, e.g., "Smileys & Expressions". */ diff --git a/packages/ckeditor5-emoji/src/emojiutils.ts b/packages/ckeditor5-emoji/src/emojiutils.ts new file mode 100644 index 00000000000..7e5126c2de7 --- /dev/null +++ b/packages/ckeditor5-emoji/src/emojiutils.ts @@ -0,0 +1,169 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import { Plugin } from 'ckeditor5/src/core.js'; +import type { EmojiCdnResource, EmojiEntry } from './emojirepository.js'; +import type { SkinToneId } from './emojiconfig.js'; +import isEmojiSupported from './utils/isemojisupported.js'; + +/** + * @module emoji/emojiutils + */ + +const SKIN_TONE_MAP: Record = { + 0: 'default', + 1: 'light', + 2: 'medium-light', + 3: 'medium', + 4: 'medium-dark', + 5: 'dark' +}; + +/** + * A map representing an emoji and its release version. + * It's used to identify a user's minimal supported emoji level. + */ +const EMOJI_SUPPORT_LEVEL = { + '🫩': 16, // Face with bags under eyes. + '🫨': 15.1 // Shaking head. Although the version of emoji is 15, it is used to detect versions 15 and 15.1. +}; + +const BASELINE_EMOJI_WIDTH = 24; + +/** + * The Emoji utilities plugin. + */ +export default class EmojiUtils extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName() { + return 'EmojiUtils' as const; + } + + /** + * @inheritDoc + */ + public static override get isOfficialPlugin(): true { + return true; + } + + /** + * Checks if the emoji is supported by verifying the emoji version supported by the system first. + * Then checks if emoji contains a zero width joiner (ZWJ), and if yes, then checks if it is supported by the system. + */ + public isEmojiSupported( item: EmojiCdnResource, emojiSupportedVersionByOs: number, container: HTMLDivElement ): boolean { + const isEmojiVersionSupported = item.version <= emojiSupportedVersionByOs; + + if ( !isEmojiVersionSupported ) { + return false; + } + + if ( !this.hasZwj( item.emoji ) ) { + return true; + } + + return this.isEmojiZwjSupported( item, container ); + } + + /** + * Checks the supported emoji version by the OS, by sampling some representatives from different emoji releases. + */ + public getEmojiSupportedVersionByOs(): number { + return Object.entries( EMOJI_SUPPORT_LEVEL ) + .reduce( ( currentVersion, [ emoji, newVersion ] ) => { + if ( newVersion > currentVersion && EmojiUtils._isEmojiSupported( emoji ) ) { + return newVersion; + } + + return currentVersion; + }, 0 ); + } + + /** + * Check for ZWJ (zero width joiner) character. + */ + public hasZwj( emoji: string ): boolean { + return emoji.includes( '\u200d' ); + } + + /** + * Checks whether the emoji is supported in the operating system. + */ + public isEmojiZwjSupported( item: EmojiCdnResource, container: HTMLDivElement ): boolean { + const emojiWidth = this.getNodeWidth( container, item.emoji ); + + // On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard + // against are the ones that are 2x the size, because those are truly broken (person with red hair = person with + // floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.) + // So here we set the threshold at 1.8 times the size of the baseline emoji. + return emojiWidth < BASELINE_EMOJI_WIDTH * 1.8; + } + + /** + * Returns the width of the provided node. + */ + public getNodeWidth( container: HTMLDivElement, node: string ): number { + const span = document.createElement( 'span' ); + span.textContent = node; + container.appendChild( span ); + const nodeWidth = span.offsetWidth; + container.removeChild( span ); + + return nodeWidth; + } + + /** + * Creates a div for emoji width testing purposes. + */ + public createEmojiWidthTestingContainer(): HTMLDivElement { + const container = document.createElement( 'div' ); + + container.setAttribute( 'aria-hidden', 'true' ); + container.style.position = 'absolute'; + container.style.left = '-9999px'; + container.style.whiteSpace = 'nowrap'; + container.style.fontSize = BASELINE_EMOJI_WIDTH + 'px'; + + return container; + } + + /** + * Adds default skin tone property to each emoji. If emoji defines other skin tones, they are added as well. + */ + public normalizeEmojiSkinTone( item: EmojiCdnResource ): EmojiEntry { + const entry: EmojiEntry = { + ...item, + skins: { + default: item.emoji + } + }; + + if ( item.skins ) { + item.skins.forEach( skin => { + const skinTone = SKIN_TONE_MAP[ skin.tone ]; + + entry.skins[ skinTone ] = skin.emoji; + } ); + } + + return entry; + } + + /** + * Checks whether the emoji belongs to a group that is allowed. + */ + public isEmojiCategoryAllowed( item: EmojiCdnResource ): boolean { + // Category group=2 contains skin tones only, which we do not want to render. + return item.group !== 2; + } + + /** + * A function used to determine if emoji is supported by detecting pixels. + * + * Referenced for unit testing purposes. Kept in a separate file because of licensing. + */ + private static _isEmojiSupported = isEmojiSupported; +} diff --git a/packages/ckeditor5-emoji/src/index.ts b/packages/ckeditor5-emoji/src/index.ts index 0b869f8c40c..de26844f587 100644 --- a/packages/ckeditor5-emoji/src/index.ts +++ b/packages/ckeditor5-emoji/src/index.ts @@ -11,6 +11,7 @@ export { default as Emoji } from './emoji.js'; export { default as EmojiMention } from './emojimention.js'; export { default as EmojiPicker } from './emojipicker.js'; export { default as EmojiRepository } from './emojirepository.js'; +export { default as EmojiUtils } from './emojiutils.js'; export { default as EmojiCommand } from './emojicommand.js'; export type { EmojiConfig } from './emojiconfig.js'; diff --git a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts new file mode 100644 index 00000000000..da2b8484339 --- /dev/null +++ b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts @@ -0,0 +1,81 @@ +/** + * @license Copyright (c) 2023, Koala Interactive SAS + * For licensing, see https://github.com/koala-interactive/is-emoji-supported/blob/master/LICENSE.md + */ + +/** + * @module emoji/utils/isemojisupported + */ + +/** + * Checks if the two pixels parts are the same using canvas. + */ +export default function isEmojiSupported( unicode: string ): boolean { + const ctx = getCanvas(); + + /* istanbul ignore next -- @preserve */ + if ( !ctx ) { + return false; + } + + const CANVAS_HEIGHT = 25; + const CANVAS_WIDTH = 20; + const textSize = Math.floor( CANVAS_HEIGHT / 2 ); + + // Initialize canvas context. + ctx.font = textSize + 'px Arial, Sans-Serif'; + ctx.textBaseline = 'top'; + ctx.canvas.width = CANVAS_WIDTH * 2; + ctx.canvas.height = CANVAS_HEIGHT; + + ctx.clearRect( 0, 0, CANVAS_WIDTH * 2, CANVAS_HEIGHT ); + + // Draw in red on the left. + ctx.fillStyle = '#FF0000'; + ctx.fillText( unicode, 0, 22 ); + + // Draw in blue on right. + ctx.fillStyle = '#0000FF'; + ctx.fillText( unicode, CANVAS_WIDTH, 22 ); + + const a = ctx.getImageData( 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT ).data; + const count = a.length; + let i = 0; + + // Search the first visible pixel. + for ( ; i < count && !a[ i + 3 ]; i += 4 ); + + // No visible pixel. + /* istanbul ignore next -- @preserve */ + if ( i >= count ) { + return false; + } + + // Emoji has immutable color, so we check the color of the emoji in two different colors. + // the result show be the same. + const x = CANVAS_WIDTH + ( ( i / 4 ) % CANVAS_WIDTH ); + const y = Math.floor( i / 4 / CANVAS_WIDTH ); + const b = ctx.getImageData( x, y, 1, 1 ).data; + + if ( a[ i ] !== b[ 0 ] || a[ i + 2 ] !== b[ 2 ]) { + return false; + } + + //Some emojis consist of different ones, so they will show multiple characters if they are not supported. + /* istanbul ignore next -- @preserve */ + if ( ctx.measureText( unicode ).width >= CANVAS_WIDTH ) { + return false; + } + + // Supported. + return true; +}; + +function getCanvas(): CanvasRenderingContext2D | null { + try { + return document.createElement( 'canvas' ).getContext( '2d' ); + } catch { + /* istanbul ignore next -- @preserve */ + return null; + } +} diff --git a/packages/ckeditor5-emoji/tests/emojimention.js b/packages/ckeditor5-emoji/tests/emojimention.js index 35e13284bbd..1d18855113f 100644 --- a/packages/ckeditor5-emoji/tests/emojimention.js +++ b/packages/ckeditor5-emoji/tests/emojimention.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ -/* global document, console */ +/* global window, document, console, Response */ import { Typing } from '@ckeditor/ckeditor5-typing'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; @@ -14,47 +14,71 @@ import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_uti import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; -import { EmojiMention, EmojiPicker } from '../src/index.js'; +import EmojiMention from '../src/emojimention.js'; +import EmojiPicker from '../src/emojipicker.js'; import EmojiRepository from '../src/emojirepository.js'; -class EmojiRepositoryMock extends EmojiRepository { - // Overridden `init()` to prevent the `fetch()` call. - init() { - this.getEmojiByQuery = sinon.stub(); - this.getEmojiCategories = sinon.stub(); - this.isReady = sinon.stub(); - - // Let's define a default behavior as we need this in UI, but we do not check it. - this.getEmojiCategories.returns( [ - { - title: 'Smileys & Expressions', - icon: '😀', - items: [] - } - ] ); - - this.isReady.returns( EmojiRepositoryMock.isReady ); - } - - // Property exposed for testing purposes to control the plugin initialization flow. - static isReady = true; +function mockEmojiRepositoryValues( editor ) { + const repository = editor.plugins.get( 'EmojiRepository' ); + + testUtils.sinon.stub( repository, 'getEmojiByQuery' ); + testUtils.sinon.stub( repository, 'getEmojiCategories' ); + testUtils.sinon.stub( repository, 'getSkinTones' ); + + repository.getEmojiCategories.returns( [ + { + title: 'Smileys & Expressions', + icon: '😀', + items: [] + }, + { + title: 'Food & Drinks', + icon: '🍎', + items: [] + } + ] ); + + repository.getSkinTones.returns( [ + { id: 'default', icon: '👋', tooltip: 'Default skin tone' }, + { id: 'medium', icon: '👋🏽', tooltip: 'Medium skin tone' }, + { id: 'dark', icon: '👋🏿', tooltip: 'Dark skin tone' } + ] ); } describe( 'EmojiMention', () => { testUtils.createSinonSandbox(); - let editor, editorElement; + let editor, editorElement, fetchStub; beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - EmojiRepositoryMock.isReady = true; + const exampleRepositoryEntry = { + shortcodes: [ + 'grinning' + ], + annotation: 'grinning face', + tags: [], + emoji: '😀', + order: 1, + group: 0, + version: 1 + }; + + fetchStub = testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( resolve => { + const results = JSON.stringify( [ exampleRepositoryEntry ] ); + + resolve( new Response( results ) ); + } ); + } ); editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ] + plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ] } ); + + mockEmojiRepositoryValues( editor ); } ); afterEach( async () => { @@ -108,7 +132,6 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mention: { feeds: [ { @@ -120,6 +143,8 @@ describe( 'EmojiMention', () => { } } ); + mockEmojiRepositoryValues( editor ); + const configs = editor.config.get( 'mention.feeds' ); expect( configs.length ).to.equal( 2 ); @@ -142,7 +167,6 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mention: { feeds: [ { @@ -154,6 +178,8 @@ describe( 'EmojiMention', () => { } } ); + mockEmojiRepositoryValues( editor ); + const configs = editor.config.get( 'mention.feeds' ); expect( configs.length ).to.equal( 1 ); @@ -180,12 +206,13 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mergeFields: { prefix: ':' } } ); + mockEmojiRepositoryValues( editor ); + const configs = editor.config.get( 'mention.feeds' ); expect( configs.length ).to.equal( 0 ); @@ -205,13 +232,11 @@ describe( 'EmojiMention', () => { document.body.appendChild( editor1Element ); const editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiMention, Mention ], - substitutePlugins: [ EmojiRepositoryMock ] + plugins: [ EmojiMention, Mention ] } ); const editor1 = await ClassicTestEditor.create( editor1Element, { plugins: [ EmojiMention, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mention: { feeds: editor.config.get( 'mention.feeds' ) } @@ -232,8 +257,7 @@ describe( 'EmojiMention', () => { document.body.appendChild( editorElement ); const editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiMention, Mention ], - substitutePlugins: [ EmojiRepositoryMock ] + plugins: [ EmojiMention, Mention ] } ); expect( editor.config.get( 'mention.feeds' ).length ).to.equal( 1 ); @@ -351,7 +375,6 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mention: { feeds: [ { @@ -363,6 +386,8 @@ describe( 'EmojiMention', () => { } } ); + mockEmojiRepositoryValues( editor ); + setModelData( editor.model, 'Hello world! []' ); expect( getModelData( editor.model ) ).to.equal( 'Hello world! []' ); @@ -384,14 +409,14 @@ describe( 'EmojiMention', () => { } ); it( 'must not override the default mention command execution if emoji repository is not ready', async () => { - EmojiRepositoryMock.isReady = false; + testUtils.sinon.stub( console, 'warn' ); + fetchStub.rejects( 'Failed to load CDN.' ); const editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); const editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ] + plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ] } ); setModelData( editor.model, 'Hello world! []' ); @@ -591,14 +616,14 @@ describe( 'EmojiMention', () => { } ); it( 'should return an empty array when the repository plugin is not loaded correctly', async () => { - EmojiRepositoryMock.isReady = false; + testUtils.sinon.stub( console, 'warn' ); + fetchStub.rejects( 'Failed to load CDN.' ); const editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); const editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiMention, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ] + plugins: [ EmojiMention, Paragraph, Essentials, Mention ] } ); const queryEmoji = editor.plugins.get( 'EmojiMention' )._queryEmojiCallbackFactory(); @@ -679,10 +704,11 @@ describe( 'EmojiMention', () => { document.body.appendChild( editorElement ); const editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiMention, Mention ], - substitutePlugins: [ EmojiRepositoryMock ] + plugins: [ EmojiMention, Mention ] } ); + mockEmojiRepositoryValues( editor ); + const { getEmojiByQuery } = editor.plugins.get( 'EmojiRepository' ); getEmojiByQuery.returns( [ @@ -777,12 +803,13 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], emoji: { skinTone: 'medium' } } ); + mockEmojiRepositoryValues( editor ); + const { getEmojiByQuery } = editor.plugins.get( 'EmojiRepository' ); const thumbUpItem = { annotation: 'thumbs up', @@ -819,12 +846,13 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], emoji: { skinTone: 'medium' } } ); + mockEmojiRepositoryValues( editor ); + const { getEmojiByQuery } = editor.plugins.get( 'EmojiRepository' ); const thumbUpItem = { annotation: 'thumbs up', diff --git a/packages/ckeditor5-emoji/tests/emojipicker.js b/packages/ckeditor5-emoji/tests/emojipicker.js index ca80c6738d8..f18cbd9d6a9 100644 --- a/packages/ckeditor5-emoji/tests/emojipicker.js +++ b/packages/ckeditor5-emoji/tests/emojipicker.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ -/* global document Event KeyboardEvent */ +/* global window document console Event KeyboardEvent Response */ import { ContextualBalloon, Dialog, ButtonView, MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; @@ -13,65 +13,79 @@ import { Essentials } from '@ckeditor/ckeditor5-essentials'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; -import { EmojiPicker } from '../src/index.js'; +import EmojiPicker from '../src/emojipicker.js'; import EmojiRepository from '../src/emojirepository.js'; import EmojiPickerView from '../src/ui/emojipickerview.js'; import EmojiCommand from '../src/emojicommand.js'; -class EmojiRepositoryMock extends EmojiRepository { - // Overridden `init()` to prevent the `fetch()` call. - init() { - this.getEmojiByQuery = sinon.stub(); - this.getEmojiCategories = sinon.stub(); - this.getSkinTones = sinon.stub(); - this.isReady = sinon.stub(); - - // Let's define a default behavior as we need this in UI, but we do not check it. - this.getEmojiCategories.returns( [ - { - title: 'Smileys & Expressions', - icon: '😀', - items: [] - }, - { - title: 'Food & Drinks', - icon: '🍎', - items: [] - } - ] ); - - this.getSkinTones.returns( [ - { id: 'default', icon: '👋', tooltip: 'Default skin tone' }, - { id: 'medium', icon: '👋🏽', tooltip: 'Medium skin tone' }, - { id: 'dark', icon: '👋🏿', tooltip: 'Dark skin tone' } - ] ); - - this.isReady.returns( EmojiRepositoryMock.isReady ); - } - - // Property exposed for testing purposes to control the plugin initialization flow. - static isReady = true; +function mockEmojiRepositoryValues( editor ) { + const repository = editor.plugins.get( 'EmojiRepository' ); + + testUtils.sinon.stub( repository, 'getEmojiByQuery' ); + testUtils.sinon.stub( repository, 'getEmojiCategories' ); + testUtils.sinon.stub( repository, 'getSkinTones' ); + + repository.getEmojiCategories.returns( [ + { + title: 'Smileys & Expressions', + icon: '😀', + items: [] + }, + { + title: 'Food & Drinks', + icon: '🍎', + items: [] + } + ] ); + + repository.getSkinTones.returns( [ + { id: 'default', icon: '👋', tooltip: 'Default skin tone' }, + { id: 'medium', icon: '👋🏽', tooltip: 'Medium skin tone' }, + { id: 'dark', icon: '👋🏿', tooltip: 'Dark skin tone' } + ] ); } describe( 'EmojiPicker', () => { - let editor, editorElement, emojiPicker; + testUtils.createSinonSandbox(); + + let editor, editorElement, emojiPicker, fetchStub; beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - EmojiRepositoryMock.isReady = true; + const exampleRepositoryEntry = { + shortcodes: [ + 'grinning' + ], + annotation: 'grinning face', + tags: [], + emoji: '😀', + order: 1, + group: 0, + version: 1 + }; + + fetchStub = testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( resolve => { + const results = JSON.stringify( [ exampleRepositoryEntry ] ); + + resolve( new Response( results ) ); + } ); + } ); editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiPicker, Essentials, Paragraph ], - substitutePlugins: [ EmojiRepositoryMock ], toolbar: [ 'emoji' ], menuBar: { isVisible: true } } ); + mockEmojiRepositoryValues( editor ); + emojiPicker = editor.plugins.get( EmojiPicker ); } ); @@ -117,12 +131,13 @@ describe( 'EmojiPicker', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiPicker, Essentials, Paragraph ], - substitutePlugins: [ EmojiRepositoryMock ], emoji: { skinTone: 'medium' } } ); + mockEmojiRepositoryValues( editor ); + expect( editor.plugins.get( EmojiPicker ).skinTone ).to.equal( 'medium' ); await editor.destroy(); @@ -135,12 +150,13 @@ describe( 'EmojiPicker', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiPicker, Essentials, Paragraph ], - substitutePlugins: [ EmojiRepositoryMock ], emoji: { skinTone: 'medium' } } ); + mockEmojiRepositoryValues( editor ); + const emojiPicker = editor.plugins.get( EmojiPicker ); emojiPicker.showUI(); emojiPicker._hideUI(); @@ -175,16 +191,20 @@ describe( 'EmojiPicker', () => { } ); it( 'must not register the "emoji" toolbar component if emoji repository is not ready', async () => { - EmojiRepositoryMock.isReady = false; + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); - await editor.destroy(); + testUtils.sinon.stub( console, 'warn' ); + fetchStub.rejects( 'Failed to load CDN.' ); - editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiPicker, Paragraph, Essentials ], - substitutePlugins: [ EmojiRepositoryMock ] + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ EmojiPicker, Paragraph, Essentials ] } ); expect( editor.ui.componentFactory.has( 'emoji' ) ).to.equal( false ); + + await editor.destroy(); + editorElement.remove(); } ); it( 'should disable the button when editor switches to the read-only mode', () => { @@ -208,19 +228,20 @@ describe( 'EmojiPicker', () => { } ); it( 'must not register the "menuBar:emoji" toolbar component if emoji repository is not ready', async () => { - EmojiRepositoryMock.isReady = false; + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); - await editor.destroy(); + testUtils.sinon.stub( console, 'warn' ); + fetchStub.rejects( 'Failed to load CDN.' ); - editor = await ClassicTestEditor.create( editorElement, { - plugins: [ EmojiPicker, Paragraph, Essentials ], - substitutePlugins: [ EmojiRepositoryMock ], - menuBar: { - isVisible: true - } + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ EmojiPicker, Paragraph, Essentials ] } ); expect( editor.ui.componentFactory.has( 'menuBar:emoji' ) ).to.equal( false ); + + await editor.destroy(); + editorElement.remove(); } ); it( 'should disable the menu bar item when editor switches to the read-only mode', () => { @@ -266,11 +287,11 @@ describe( 'EmojiPicker', () => { } ); it( 'should add the emoji UI view to the `ContextualBalloon` plugin when opens UI', () => { - expect( emojiPicker._balloonPlugin.visibleView ).to.equal( null ); + expect( emojiPicker.balloonPlugin.visibleView ).to.equal( null ); emojiPicker.showUI(); - expect( emojiPicker._balloonPlugin.visibleView ).to.be.instanceOf( EmojiPickerView ); + expect( emojiPicker.balloonPlugin.visibleView ).to.be.instanceOf( EmojiPickerView ); } ); it( 'should focus the query input when opens UI', async () => { @@ -314,7 +335,7 @@ describe( 'EmojiPicker', () => { } ); it( 'should update the balloon position on update event', () => { - const updatePositionSpy = sinon.spy( emojiPicker._balloonPlugin, 'updatePosition' ); + const updatePositionSpy = sinon.spy( emojiPicker.balloonPlugin, 'updatePosition' ); emojiPicker.showUI(); emojiPicker.emojiPickerView.fire( 'update' ); @@ -323,10 +344,10 @@ describe( 'EmojiPicker', () => { } ); it( 'should not update the balloon position on update event when visible view is not current emoji picker view', () => { - const updatePositionSpy = sinon.spy( emojiPicker._balloonPlugin, 'updatePosition' ); + const updatePositionSpy = sinon.spy( emojiPicker.balloonPlugin, 'updatePosition' ); emojiPicker.showUI(); - emojiPicker._balloonPlugin.visibleView = {}; + emojiPicker.balloonPlugin.visibleView = {}; emojiPicker.emojiPickerView.fire( 'update' ); @@ -338,18 +359,18 @@ describe( 'EmojiPicker', () => { document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); - expect( emojiPicker._balloonPlugin.visibleView ).to.equal( null ); + expect( emojiPicker.balloonPlugin.visibleView ).to.equal( null ); } ); it( 'should close the picker when focus is on the picker and escape is clicked', () => { emojiPicker.showUI(); - emojiPicker._balloonPlugin.visibleView.element.dispatchEvent( new KeyboardEvent( 'keydown', { + emojiPicker.balloonPlugin.visibleView.element.dispatchEvent( new KeyboardEvent( 'keydown', { keyCode: keyCodes.esc, bubbles: true } ) ); - expect( emojiPicker._balloonPlugin.visibleView ).to.equal( null ); + expect( emojiPicker.balloonPlugin.visibleView ).to.equal( null ); } ); it( 'should load previous category after reopening the emoji picker', () => { diff --git a/packages/ckeditor5-emoji/tests/emojirepository.js b/packages/ckeditor5-emoji/tests/emojirepository.js index 98afb2be600..4acac669bcf 100644 --- a/packages/ckeditor5-emoji/tests/emojirepository.js +++ b/packages/ckeditor5-emoji/tests/emojirepository.js @@ -11,15 +11,14 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { Essentials } from '@ckeditor/ckeditor5-essentials'; import EmojiRepository from '../src/emojirepository.js'; +import EmojiUtils from '../src/emojiutils.ts'; describe( 'EmojiRepository', () => { testUtils.createSinonSandbox(); - let isEmojiSupportedStub, consoleStub, fetchStub; + let consoleStub, fetchStub; beforeEach( () => { - isEmojiSupportedStub = testUtils.sinon.stub( EmojiRepository, '_isEmojiSupported' ).returns( true ); - consoleStub = sinon.stub( console, 'warn' ); fetchStub = testUtils.sinon.stub( window, 'fetch' ).resolves( new Response( '[]' ) ); } ); @@ -78,8 +77,8 @@ describe( 'EmojiRepository', () => { it( 'should fetch the emoji database version 16', async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0 }, - { annotation: 'unamused face', group: 0 } + { emoji: '😐️', annotation: 'neutral face', group: 0, version: 15 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -140,13 +139,12 @@ describe( 'EmojiRepository', () => { expect( hasGroup2 ).to.be.undefined; } ); - it( 'should filter out unsupported emojis from the fetched emoji database', async () => { - isEmojiSupportedStub.callsFake( item => item.annotation !== 'microscope' ); + it( 'should filter out unsupported ZWJ emojis from the fetched emoji database', async () => { + // Head shaking horizontally is mocked to be an unsupported emoji in EmojiUtilsMock. const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0 }, - { annotation: 'unamused face', group: 0 }, - { annotation: 'microscope', group: 7 } + { emoji: '🙂‍↔️', annotation: 'head shaking horizontally', group: 0, version: 16 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -154,15 +152,41 @@ describe( 'EmojiRepository', () => { editor = await editorPromise; const emojiRepositoryPlugin = editor.plugins.get( EmojiRepository ); + const headShakingHorizontallyEmoji = emojiRepositoryPlugin._database + .find( item => item.annotation === 'head shaking horizontally' ); + const unamusedFaceEmoji = emojiRepositoryPlugin._database.find( item => item.annotation === 'unamused face' ); + + expect( unamusedFaceEmoji ).not.to.be.undefined; + expect( headShakingHorizontallyEmoji ).to.be.undefined; + } ); + + it( 'should filter out emojis based on the version supported by the operating system', async () => { + // Emoji version 15 is mocked in the EmojiUtilsMock. + + const response = JSON.stringify( [ + { emoji: '😐️', annotation: 'neutral face', group: 0, version: 16 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 }, + { emoji: '🔬', annotation: 'microscope', group: 7, version: 15 } + ] ); + + fetchStubResolve( new Response( response ) ); + + editor = await editorPromise; + + const emojiRepositoryPlugin = editor.plugins.get( EmojiRepository ); + const hasNeutralFaceEmoji = emojiRepositoryPlugin._database.find( item => item.annotation === 'neutral face' ); const hasMicroscopeEmoji = emojiRepositoryPlugin._database.find( item => item.annotation === 'microscope' ); + const hasUnamusedEmoji = emojiRepositoryPlugin._database.find( item => item.annotation === 'unamused face' ); - expect( hasMicroscopeEmoji ).to.be.undefined; + expect( hasNeutralFaceEmoji ).to.be.undefined; + expect( hasMicroscopeEmoji ).not.to.be.undefined; + expect( hasUnamusedEmoji ).not.to.be.undefined; } ); it( 'should set default skin tone for each emoji', async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', emoji: '😐️', group: 0 }, - { annotation: 'unamused face', emoji: '😒', group: 0 } + { annotation: 'neutral face', emoji: '😐️', group: 0, version: 15 }, + { annotation: 'unamused face', emoji: '😒', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -185,7 +209,7 @@ describe( 'EmojiRepository', () => { const ninjaEmoji5 = '🥷🏿'; const response = JSON.stringify( [ - { annotation: 'ninja', emoji: ninjaEmoji0, group: 1, skins: [ + { annotation: 'ninja', emoji: ninjaEmoji0, group: 1, version: 15, skins: [ { emoji: ninjaEmoji1, tone: 1 }, { emoji: ninjaEmoji2, tone: 2 }, { emoji: ninjaEmoji3, tone: 3 }, @@ -213,8 +237,8 @@ describe( 'EmojiRepository', () => { it( 'should create Fuse.js instance with the emoji database', async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0 }, - { annotation: 'unamused face', group: 0 } + { emoji: '😐️', annotation: 'neutral face', group: 0, version: 15 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -274,8 +298,22 @@ describe( 'EmojiRepository', () => { beforeEach( async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', emoticon: ':|', tags: [ 'awkward', 'blank', 'face', 'meh', 'whatever' ], group: 0 }, - { annotation: 'unamused face', emoticon: ':?', tags: [ 'bored', 'face', 'fine', 'ugh', 'whatever' ], group: 0 } + { + emoji: '😐️', + annotation: 'neutral face', + emoticon: ':|', + tags: [ 'awkward', 'blank', 'face', 'meh', 'whatever' ], + group: 0, + version: 15 + }, + { + emoji: '😒', + annotation: 'unamused face', + emoticon: ':?', + tags: [ 'bored', 'face', 'fine', 'ugh', 'whatever' ], + group: 0, + version: 15 + } ] ); fetchStub.resolves( new Response( response ) ); @@ -368,16 +406,16 @@ describe( 'EmojiRepository', () => { beforeEach( async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0 }, - { annotation: 'ninja', group: 1 }, - { annotation: 'medium-dark skin tone', group: 2 }, - { annotation: 'lobster', group: 3 }, - { annotation: 'salt', group: 4 }, - { annotation: 'watch', group: 5 }, - { annotation: 'magic wand', group: 6 }, - { annotation: 'x-ray', group: 7 }, - { annotation: 'up-left arrow', group: 8 }, - { annotation: 'flag: Poland', group: 9 } + { emoji: '😐️', annotation: 'neutral face', group: 0, version: 15 }, + { emoji: '🥷', annotation: 'ninja', group: 1, version: 15 }, + { emoji: '🤚🏾', annotation: 'medium-dark skin tone', group: 2, version: 15 }, + { emoji: '🦞', annotation: 'lobster', group: 3, version: 15 }, + { emoji: '🧂', annotation: 'salt', group: 4, version: 15 }, + { emoji: '⌚️', annotation: 'watch', group: 5, version: 15 }, + { emoji: '🪄', annotation: 'magic wand', group: 6, version: 15 }, + { emoji: '🩻', annotation: 'x-ray', group: 7, version: 15 }, + { emoji: '↖️', annotation: 'up-left arrow', group: 8, version: 15 }, + { emoji: '🇵🇱', annotation: 'flag: Poland', group: 9, version: 15 } ] ); fetchStub.resolves( new Response( response ) ); @@ -537,6 +575,16 @@ describe( 'EmojiRepository', () => { } ); } ); +class EmojiUtilsMock extends EmojiUtils { + getEmojiSupportedVersionByOs() { + return 15; + } + + isEmojiZwjSupported( item ) { + return item.emoji !== '🙂‍↔️'; + } +} + function createTestEditor( domElement, editorConfig = {} ) { return ClassicTestEditor.create( domElement, { plugins: [ @@ -544,6 +592,7 @@ function createTestEditor( domElement, editorConfig = {} ) { Paragraph, EmojiRepository ], + substitutePlugins: [ EmojiUtilsMock ], ...editorConfig } ); } diff --git a/packages/ckeditor5-emoji/tests/utils/emojiutils.js b/packages/ckeditor5-emoji/tests/utils/emojiutils.js new file mode 100644 index 00000000000..625cf0acfff --- /dev/null +++ b/packages/ckeditor5-emoji/tests/utils/emojiutils.js @@ -0,0 +1,210 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; +import EmojiUtils from '../../src/emojiutils.ts'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; + +/* global document */ + +describe( 'EmojiUtils', () => { + let editor, emojiUtils, editorElement; + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ EmojiUtils ] + } ); + + emojiUtils = editor.plugins.get( EmojiUtils ); + } ); + + afterEach( async () => { + editorElement.remove(); + + await editor.destroy(); + } ); + + it( 'should have proper name', () => { + expect( EmojiUtils.pluginName ).to.equal( 'EmojiUtils' ); + } ); + + it( 'should have `isOfficialPlugin` static flag set to `true`', () => { + expect( EmojiUtils.isOfficialPlugin ).to.be.true; + } ); + + it( 'should have `isPremiumPlugin` static flag set to `false`', () => { + expect( EmojiUtils.isPremiumPlugin ).to.be.false; + } ); + + describe( 'isEmojiSupported()', () => { + it( 'should return false if emoji version is not supported by the os', async () => { + const container = document.createElement( 'div' ); + + const result = emojiUtils.isEmojiSupported( { emoji: '😒', version: '16' }, 15, container ); + + expect( result ).to.be.false; + + container.remove(); + } ); + + it( 'should return true if emoji version is supported by the os and is not ZWJ', async () => { + const container = document.createElement( 'div' ); + + const result = emojiUtils.isEmojiSupported( { emoji: '😒', version: '15' }, 15, container ); + + expect( result ).to.be.true; + + container.remove(); + } ); + + it( 'should return true if emoji version is supported by the os and is supported ZWJ', async () => { + sinon.stub( editor.plugins.get( EmojiUtils ), 'isEmojiZwjSupported' ).returns( true ); + + const container = document.createElement( 'div' ); + + const result = emojiUtils.isEmojiSupported( { emoji: '🙂‍↔️', version: '15' }, 15, container ); + + expect( result ).to.be.true; + + container.remove(); + } ); + + it( 'should return false if emoji version is supported by the os and is not supported ZWJ', async () => { + sinon.stub( editor.plugins.get( EmojiUtils ), 'isEmojiZwjSupported' ).returns( false ); + + const container = document.createElement( 'div' ); + + const result = emojiUtils.isEmojiSupported( { emoji: '🙂‍↔️', version: '15' }, 15, container ); + + expect( result ).to.be.false; + + container.remove(); + } ); + } ); + + describe( 'getEmojiSupportedVersionByOs()', () => { + it( 'should return version 15 for shaking head', async () => { + sinon.stub( EmojiUtils, '_isEmojiSupported' ).callsFake( emoji => emoji === '🫨' ); + + const result = emojiUtils.getEmojiSupportedVersionByOs(); + + expect( result ).to.equal( 15.1 ); + } ); + + it( 'should return version 16 for face with bags under eyes', async () => { + sinon.stub( EmojiUtils, '_isEmojiSupported' ).callsFake( emoji => emoji === '🫩' ); + + const result = emojiUtils.getEmojiSupportedVersionByOs(); + + expect( result ).to.equal( 16 ); + } ); + } ); + + describe( 'hasZwj()', () => { + it( 'should return false for a simple emoji', async () => { + const result = emojiUtils.hasZwj( '🙂' ); + + expect( result ).to.be.false; + } ); + + it( 'should return true for a compound emoji', async () => { + const result = emojiUtils.hasZwj( '😮‍💨' ); + + expect( result ).to.be.true; + } ); + } ); + + describe( '_isEmojiZwjSupported()', () => { + it( 'should return true when emoji is standard width', async () => { + const container = document.createElement( 'div' ); + const emojiItem = { emoji: '🙂' }; + + document.body.appendChild( container ); + + const result = emojiUtils.isEmojiZwjSupported( emojiItem, container ); + + expect( result ).to.be.true; + + container.remove(); + } ); + + it( 'should return false when emoji is abnormally wide (size larger than 2 emoji)', async () => { + const container = document.createElement( 'div' ); + const emojiItem = { emoji: '🙂🙂🙂' }; + + document.body.appendChild( container ); + + const result = emojiUtils.isEmojiZwjSupported( emojiItem, container ); + + expect( result ).to.be.false; + + container.remove(); + } ); + } ); + + describe( 'createEmojiWidthTestingContainer()', () => { + it( 'should create a width testing container with correct attributes', async () => { + const container = emojiUtils.createEmojiWidthTestingContainer(); + + expect( container ).to.have.attribute( 'aria-hidden', 'true' ); + expect( container.style.position ).to.equal( 'absolute' ); + expect( container.style.left ).to.equal( '-9999px' ); + expect( container.style.whiteSpace ).to.equal( 'nowrap' ); + expect( container.style.fontSize ).to.equal( '24px' ); + + container.remove(); + } ); + } ); + + describe( 'isEmojiCategoryAllowed()', () => { + it( 'should return true if emoji group is different than 2', async () => { + const result = emojiUtils.isEmojiCategoryAllowed( { group: 1 } ); + + expect( result ).to.be.true; + } ); + + it( 'should return false if emoji group is equal to 2', async () => { + const result = emojiUtils.isEmojiCategoryAllowed( { group: 2 } ); + + expect( result ).to.be.false; + } ); + } ); + + describe( 'normalizeEmojiSkinTone()', () => { + it( 'normalize add default emoji to skins if property does not exist', async () => { + const result = emojiUtils.normalizeEmojiSkinTone( { + emoji: '👋' + } ); + + expect( result.skins.default ).to.equal( '👋' ); + expect( Object.keys( result.skins ) ).to.have.length( 1 ); + } ); + + it( 'normalize emoji skin tone if property exists', async () => { + const result = emojiUtils.normalizeEmojiSkinTone( { + emoji: '👋', + skins: [ + { emoji: '👋🏻', version: 1, tone: 1 }, + { emoji: '👋🏼', version: 1, tone: 2 }, + { emoji: '👋🏽', version: 1, tone: 3 }, + { emoji: '👋🏾', version: 1, tone: 4 }, + { emoji: '👋🏿', version: 1, tone: 5 } + ] + } ); + + expect( result.skins.default ).to.equal( '👋' ); + expect( result.skins.light ).to.equal( '👋🏻' ); + expect( result.skins[ 'medium-light' ] ).to.equal( '👋🏼' ); + expect( result.skins.medium ).to.equal( '👋🏽' ); + expect( result.skins[ 'medium-dark' ] ).to.equal( '👋🏾' ); + expect( result.skins.dark ).to.equal( '👋🏿' ); + expect( Object.keys( result.skins ) ).to.have.length( 6 ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-emoji/tests/utils/isemojisupported.js b/packages/ckeditor5-emoji/tests/utils/isemojisupported.js new file mode 100644 index 00000000000..7cb6546dbf0 --- /dev/null +++ b/packages/ckeditor5-emoji/tests/utils/isemojisupported.js @@ -0,0 +1,12 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import isEmojiSupported from '../../src/utils/isemojisupported.js'; + +describe( 'isEmojiSupported()', () => { + it( 'should return true when emoji is supported', () => { + expect( isEmojiSupported( '🙂' ) ).to.equal( true ); + } ); +} ); diff --git a/scripts/bump-year.mjs b/scripts/bump-year.mjs index e3bb39072cd..b914d3700b1 100644 --- a/scripts/bump-year.mjs +++ b/scripts/bump-year.mjs @@ -19,10 +19,11 @@ bumpYear( { } }, { - pattern: '!(build|coverage|external)/**', + pattern: '!(build|coverage|external|release)/**', options: { ignore: [ - '**/ckeditor5-*/build/**' + '**/ckeditor5-*/build/**', + 'packages/ckeditor5-emoji/src/utils/isemojisupported.ts' ] } },