From a421188813305d9c54d0e19102197369e462e43b Mon Sep 17 00:00:00 2001 From: Marcin Panek Date: Fri, 31 Jan 2025 10:23:02 +0100 Subject: [PATCH 1/8] Fixed emoji filtering and improved the filtering performance. --- .eslintrc.js | 3 +- LICENSE.md | 1 + packages/ckeditor5-emoji/LICENSE.md | 1 + .../ckeditor5-emoji/src/emojirepository.ts | 60 +++++++++-- .../src/utils/isemojisupported.ts | 78 ++++++++++++++ .../ckeditor5-emoji/tests/emojirepository.js | 100 +++++++++++++----- .../tests/utils/isemojisupported.js | 12 +++ scripts/bump-year.mjs | 5 +- 8 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 packages/ckeditor5-emoji/src/utils/isemojisupported.ts create mode 100644 packages/ckeditor5-emoji/tests/utils/isemojisupported.js 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..6f05bf9f37b 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) 2023, Koala Interactive SAS * 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..b9dc68b647c 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) 2023, Koala Interactive SAS * 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/emojirepository.ts b/packages/ckeditor5-emoji/src/emojirepository.ts index 611f4fc2347..b9b3bb99db1 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -10,9 +10,10 @@ 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 type { SkinToneId } from './emojiconfig.js'; +import isEmojiSupported from './utils/isemojisupported.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. @@ -29,6 +30,16 @@ const SKIN_TONE_MAP: Record = { const BASELINE_EMOJI_WIDTH = 24; +/** + * Object storing emoji support level by the operating system. + * Some emojis that are perceived as supported are compound-emoji from previous version. For those emojis + * there has to be performed an additional width check to verify system support. + */ +const EMOJI_SUPPORT_LEVEL = { + '🫩': 16, // face with bags under eyes + '🫨': 15 // shaking head +}; + /** * The emoji repository plugin. * @@ -96,6 +107,7 @@ export default class EmojiRepository extends Plugin { const emojiVersion = this.editor.config.get( 'emoji.version' )!; const emojiDatabaseUrl = EMOJI_DATABASE_URL.replace( '{version}', `${ emojiVersion }` ); const emojiDatabase = await loadEmojiDatabase( emojiDatabaseUrl ); + const emojiSupportedVersionByOs = EmojiRepository._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`. @@ -108,7 +120,8 @@ export default class EmojiRepository extends Plugin { // Store the emoji database after normalizing the raw data. this._database = emojiDatabase .filter( item => isEmojiCategoryAllowed( item ) ) - .filter( item => EmojiRepository._isEmojiSupported( item, container ) ) + .filter( item => item.version <= emojiSupportedVersionByOs ) + .filter( item => !EmojiRepository._hasZwj( item.emoji ) ? true : EmojiRepository._isEmojiZwjSupported( item, container ) ) .map( item => normalizeEmojiSkinTone( item ) ); container.remove(); @@ -226,11 +239,25 @@ export default class EmojiRepository extends Plugin { } /** - * A function used to check if the given emoji is supported in the operating system. + * A function used to check if the given ZWJ emoji is supported in the operating system. + * + * Referenced for unit testing purposes. + */ + private static _isEmojiZwjSupported = isEmojiZwjSupported; + + /** + * A function used to get the emoji version supported by the operating system. + * + * Referenced for unit testing purposes. + */ + private static _getEmojiSupportedVersionByOs = getEmojiSupportedVersionByOs; + + /** + * A function used to determine if emoji has a zero width joiner. * * Referenced for unit testing purposes. */ - private static _isEmojiSupported = isEmojiSupported; + private static _hasZwj = hasZwj; } /** @@ -264,6 +291,27 @@ async function loadEmojiDatabase( emojiDatabaseUrl: string ): Promise { + if ( isEmojiSupported( emoji ) && version > supportedVersion ) { + supportedVersion = version; + } + + return supportedVersion; + }, 0 ); +} + +/** + * Check for ZWJ (zero width joiner) character. + */ +function hasZwj( emoji: string ) { + return emoji.includes( '\u200d' ); +} + /** * Creates a div for emoji width testing purposes. */ @@ -296,14 +344,14 @@ function getNodeWidth( container: HTMLDivElement, node: string ): number { /** * Checks whether the emoji is supported in the operating system. */ -function isEmojiSupported( item: EmojiCdnResource, container: HTMLDivElement ): boolean { +function isEmojiZwjSupported( item: EmojiCdnResource, container: HTMLDivElement ): boolean { const emojiWidth = 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 / 1.8 < BASELINE_EMOJI_WIDTH ) && ( emojiWidth >= BASELINE_EMOJI_WIDTH ); + return emojiWidth < BASELINE_EMOJI_WIDTH * 1.8; } /** diff --git a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts new file mode 100644 index 00000000000..231e121f5dd --- /dev/null +++ b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts @@ -0,0 +1,78 @@ +/** + * @license Copyright (c) 2023, Koala Interactive SAS + * For licensing, see https://github.com/koala-interactive/is-emoji-supported/blob/master/LICENSE.md + */ + +/** + * Checks if the two pixels parts are the same using pixel comparisons. + */ +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 are a contraction of different ones, so if it's not + // supported, it will show multiple characters + /* 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/emojirepository.js b/packages/ckeditor5-emoji/tests/emojirepository.js index 98afb2be600..03cf8a5b21d 100644 --- a/packages/ckeditor5-emoji/tests/emojirepository.js +++ b/packages/ckeditor5-emoji/tests/emojirepository.js @@ -15,10 +15,12 @@ import EmojiRepository from '../src/emojirepository.js'; describe( 'EmojiRepository', () => { testUtils.createSinonSandbox(); - let isEmojiSupportedStub, consoleStub, fetchStub; + let isEmojiSupportedStub, getEmojiSupportedVersionByOsStub, hasZwjStub, consoleStub, fetchStub; beforeEach( () => { - isEmojiSupportedStub = testUtils.sinon.stub( EmojiRepository, '_isEmojiSupported' ).returns( true ); + isEmojiSupportedStub = testUtils.sinon.stub( EmojiRepository, '_isEmojiZwjSupported' ).returns( true ); + getEmojiSupportedVersionByOsStub = testUtils.sinon.stub( EmojiRepository, '_getEmojiSupportedVersionByOs' ).returns( 100 ); + hasZwjStub = testUtils.sinon.stub( EmojiRepository, '_hasZwj' ).returns( false ); consoleStub = sinon.stub( console, 'warn' ); fetchStub = testUtils.sinon.stub( window, 'fetch' ).resolves( new Response( '[]' ) ); @@ -78,8 +80,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 } + { annotation: 'neutral face', group: 0, version: 16 }, + { annotation: 'unamused face', group: 0, version: 16 } ] ); fetchStubResolve( new Response( response ) ); @@ -140,13 +142,41 @@ 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 () => { + // Neutral face is mocked to be the only non ZWJ icon. + hasZwjStub.callsFake( emoji => emoji !== '😐️' ); + + // Unamused face is the only supported ZWJ emoji. + isEmojiSupportedStub.callsFake( item => item.annotation === 'unamused face' ); const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0 }, - { annotation: 'unamused face', group: 0 }, - { annotation: 'microscope', group: 7 } + { 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( hasNeutralFaceEmoji ).not.to.be.undefined; // Neutral face is not ZWJ, so it should not be filtered out. + expect( hasUnamusedEmoji ).not.to.be.undefined; // Unamused face is ZWJ, and it is supported, so it should not be filtered out. + expect( hasMicroscopeEmoji ).to.be.undefined; // Microscope is ZWJ, and it is not supported, so it should be filtered out + } ); + + it( 'should filter out emojis based on the version supported by the operating system', async () => { + getEmojiSupportedVersionByOsStub.reset(); + getEmojiSupportedVersionByOsStub.returns( 15 ); + + const response = JSON.stringify( [ + { annotation: 'neutral face', group: 0, version: 16 }, + { annotation: 'unamused face', group: 0, version: 15 }, + { annotation: 'microscope', group: 7, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -154,15 +184,19 @@ describe( 'EmojiRepository', () => { 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 +219,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 +247,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 } + { annotation: 'neutral face', group: 0, version: 15 }, + { annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -274,8 +308,20 @@ 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 } + { + annotation: 'neutral face', + emoticon: ':|', + tags: [ 'awkward', 'blank', 'face', 'meh', 'whatever' ], + group: 0, + version: 15 + }, + { + annotation: 'unamused face', + emoticon: ':?', + tags: [ 'bored', 'face', 'fine', 'ugh', 'whatever' ], + group: 0, + version: 15 + } ] ); fetchStub.resolves( new Response( response ) ); @@ -368,16 +414,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 } + { annotation: 'neutral face', group: 0, version: 15 }, + { annotation: 'ninja', group: 1, version: 15 }, + { annotation: 'medium-dark skin tone', group: 2, version: 15 }, + { annotation: 'lobster', group: 3, version: 15 }, + { annotation: 'salt', group: 4, version: 15 }, + { annotation: 'watch', group: 5, version: 15 }, + { annotation: 'magic wand', group: 6, version: 15 }, + { annotation: 'x-ray', group: 7, version: 15 }, + { annotation: 'up-left arrow', group: 8, version: 15 }, + { annotation: 'flag: Poland', group: 9, version: 15 } ] ); fetchStub.resolves( new Response( response ) ); 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' ] } }, From c85851068ac765ff7ca28559c3ae79a3d9a2f35d Mon Sep 17 00:00:00 2001 From: Marcin Panek Date: Fri, 31 Jan 2025 10:28:09 +0100 Subject: [PATCH 2/8] Wording. --- .../ckeditor5-emoji/src/emojirepository.ts | 5 ++--- .../src/utils/isemojisupported.ts | 19 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/ckeditor5-emoji/src/emojirepository.ts b/packages/ckeditor5-emoji/src/emojirepository.ts index b9b3bb99db1..ab39b796d4c 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -32,7 +32,7 @@ const BASELINE_EMOJI_WIDTH = 24; /** * Object storing emoji support level by the operating system. - * Some emojis that are perceived as supported are compound-emoji from previous version. For those emojis + * Some emojis that are perceived as supported, are compound-emoji from previous version. For those emojis * there has to be performed an additional width check to verify system support. */ const EMOJI_SUPPORT_LEVEL = { @@ -292,8 +292,7 @@ async function loadEmojiDatabase( emojiDatabaseUrl: string ): Promise { diff --git a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts index 231e121f5dd..a046b00d206 100644 --- a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts +++ b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts @@ -4,7 +4,7 @@ */ /** - * Checks if the two pixels parts are the same using pixel comparisons. + * Checks if the two pixels parts are the same using canvas. */ export default function isEmojiSupported( unicode: string ): boolean { const ctx = getCanvas(); @@ -18,7 +18,7 @@ export default function isEmojiSupported( unicode: string ): boolean { const CANVAS_WIDTH = 20; const textSize = Math.floor( CANVAS_HEIGHT / 2 ); - // Initialize canvas context + // Initialize canvas context. ctx.font = textSize + 'px Arial, Sans-Serif'; ctx.textBaseline = 'top'; ctx.canvas.width = CANVAS_WIDTH * 2; @@ -26,11 +26,11 @@ export default function isEmojiSupported( unicode: string ): boolean { ctx.clearRect( 0, 0, CANVAS_WIDTH * 2, CANVAS_HEIGHT ); - // Draw in red on the left + // Draw in red on the left. ctx.fillStyle = '#FF0000'; ctx.fillText( unicode, 0, 22 ); - // Draw in blue on right + // Draw in blue on right. ctx.fillStyle = '#0000FF'; ctx.fillText( unicode, CANVAS_WIDTH, 22 ); @@ -38,16 +38,16 @@ export default function isEmojiSupported( unicode: string ): boolean { const count = a.length; let i = 0; - // Search the first visible pixel + // Search the first visible pixel. for ( ; i < count && !a[ i + 3 ]; i += 4 ) ; - // No visible pixel + // 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 + // 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 ); @@ -57,14 +57,13 @@ export default function isEmojiSupported( unicode: string ): boolean { return false; } - // Some emojis are a contraction of different ones, so if it's not - // supported, it will show multiple characters + // Some emojis are a contraction of different ones, so if it's not supported, it will show multiple characters. /* istanbul ignore next -- @preserve */ if ( ctx.measureText( unicode ).width >= CANVAS_WIDTH ) { return false; } - // Supported + // Supported. return true; }; From cac496e2587e78ce4dafb7634dbc5d5845c3c078 Mon Sep 17 00:00:00 2001 From: Marcin Panek Date: Mon, 3 Feb 2025 11:40:21 +0100 Subject: [PATCH 3/8] Modified `EMOJI_SUPPORT_LEVEL` to properly filter emojis in version 15.1. --- packages/ckeditor5-emoji/src/emojirepository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-emoji/src/emojirepository.ts b/packages/ckeditor5-emoji/src/emojirepository.ts index ab39b796d4c..64b879da6fa 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -36,8 +36,8 @@ const BASELINE_EMOJI_WIDTH = 24; * there has to be performed an additional width check to verify system support. */ const EMOJI_SUPPORT_LEVEL = { - '🫩': 16, // face with bags under eyes - '🫨': 15 // shaking head + '🫩': 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. }; /** From 7343da3b2734aaf3d93b6e1e8e455c9a2bf881df Mon Sep 17 00:00:00 2001 From: Marcin Panek Date: Mon, 3 Feb 2025 17:55:27 +0100 Subject: [PATCH 4/8] Introduced `EmojiUtils` class in emoji picker. --- LICENSE.md | 2 +- .../ckeditor5-emoji/src/emojirepository.ts | 164 ++------------ .../ckeditor5-emoji/src/utils/emojiutils.ts | 170 ++++++++++++++ .../src/utils/isemojisupported.ts | 4 +- .../ckeditor5-emoji/tests/emojirepository.js | 79 +++---- .../ckeditor5-emoji/tests/utils/emojiutils.js | 213 ++++++++++++++++++ 6 files changed, 450 insertions(+), 182 deletions(-) create mode 100644 packages/ckeditor5-emoji/src/utils/emojiutils.ts create mode 100644 packages/ckeditor5-emoji/tests/utils/emojiutils.js diff --git a/LICENSE.md b/LICENSE.md index 6f05bf9f37b..811fa4b9ee0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -23,7 +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) 2023, Koala Interactive SAS +* 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/src/emojirepository.ts b/packages/ckeditor5-emoji/src/emojirepository.ts index 64b879da6fa..d0261f392b0 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -13,33 +13,12 @@ import { groupBy } from 'lodash-es'; import { type Editor, Plugin } from 'ckeditor5/src/core.js'; import { logWarning } from 'ckeditor5/src/utils.js'; import type { SkinToneId } from './emojiconfig.js'; -import isEmojiSupported from './utils/isemojisupported.js'; +import EmojiUtils from './utils/emojiutils.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; - -/** - * Object storing emoji support level by the operating system. - * Some emojis that are perceived as supported, are compound-emoji from previous version. For those emojis - * there has to be performed an additional width check to verify system support. - */ -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. -}; - /** * The emoji repository plugin. * @@ -67,6 +46,11 @@ export default class EmojiRepository extends Plugin { */ private _fuseSearch: Fuse | null; + /** + * Emoji utils. + */ + private _emojiUtils: EmojiUtils | null; + /** * @inheritDoc */ @@ -81,6 +65,13 @@ export default class EmojiRepository extends Plugin { return true; } + /** + * @inheritDoc + */ + public static get requires() { + return [ EmojiUtils ] as const; + } + /** * @inheritDoc */ @@ -98,16 +89,21 @@ export default class EmojiRepository extends Plugin { } ); this._fuseSearch = null; + + // TODO fix casting + this._emojiUtils = this.editor.plugins.get( 'EmojiUtils' ) as EmojiUtils; } /** * @inheritDoc */ public async init(): Promise { + // TODO how to get rid of ! char + const emojiUtils = this._emojiUtils!; const emojiVersion = this.editor.config.get( 'emoji.version' )!; const emojiDatabaseUrl = EMOJI_DATABASE_URL.replace( '{version}', `${ emojiVersion }` ); const emojiDatabase = await loadEmojiDatabase( emojiDatabaseUrl ); - const emojiSupportedVersionByOs = EmojiRepository._getEmojiSupportedVersionByOs(); + 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`. @@ -115,14 +111,13 @@ export default class EmojiRepository extends Plugin { return this._databasePromiseResolveCallback( false ); } - const container = createEmojiWidthTestingContainer(); + const container = emojiUtils.createEmojiWidthTestingContainer(); // Store the emoji database after normalizing the raw data. this._database = emojiDatabase - .filter( item => isEmojiCategoryAllowed( item ) ) - .filter( item => item.version <= emojiSupportedVersionByOs ) - .filter( item => !EmojiRepository._hasZwj( item.emoji ) ? true : EmojiRepository._isEmojiZwjSupported( 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(); @@ -237,27 +232,6 @@ export default class EmojiRepository extends Plugin { public isReady(): Promise { return this._databasePromise; } - - /** - * A function used to check if the given ZWJ emoji is supported in the operating system. - * - * Referenced for unit testing purposes. - */ - private static _isEmojiZwjSupported = isEmojiZwjSupported; - - /** - * A function used to get the emoji version supported by the operating system. - * - * Referenced for unit testing purposes. - */ - private static _getEmojiSupportedVersionByOs = getEmojiSupportedVersionByOs; - - /** - * A function used to determine if emoji has a zero width joiner. - * - * Referenced for unit testing purposes. - */ - private static _hasZwj = hasZwj; } /** @@ -291,98 +265,6 @@ async function loadEmojiDatabase( emojiDatabaseUrl: string ): Promise { - if ( isEmojiSupported( emoji ) && version > supportedVersion ) { - supportedVersion = version; - } - - return supportedVersion; - }, 0 ); -} - -/** - * Check for ZWJ (zero width joiner) character. - */ -function hasZwj( emoji: string ) { - return emoji.includes( '\u200d' ); -} - -/** - * Creates a div for emoji width testing purposes. - */ -function 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'; - document.body.appendChild( container ); - - return container; -} - -/** - * Returns the width of the provided node. - */ -function 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; -} - -/** - * Checks whether the emoji is supported in the operating system. - */ -function isEmojiZwjSupported( item: EmojiCdnResource, container: HTMLDivElement ): boolean { - const emojiWidth = 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; -} - -/** - * 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/utils/emojiutils.ts b/packages/ckeditor5-emoji/src/utils/emojiutils.ts new file mode 100644 index 00000000000..07fa52cc945 --- /dev/null +++ b/packages/ckeditor5-emoji/src/utils/emojiutils.ts @@ -0,0 +1,170 @@ +/** + * @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 isEmojiSupported from './isemojisupported.js'; +import type { EmojiCdnResource, EmojiEntry } from '../emojirepository.js'; +import type { SkinToneId } from '../emojiconfig.js'; + +/** + * @module emoji/utils/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 CKBox 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'; + document.body.appendChild( container ); + + 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/utils/isemojisupported.ts b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts index a046b00d206..1a8c1fb6d33 100644 --- a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts +++ b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts @@ -39,7 +39,7 @@ export default function isEmojiSupported( unicode: string ): boolean { let i = 0; // Search the first visible pixel. - for ( ; i < count && !a[ i + 3 ]; i += 4 ) ; + for ( ; i < count && !a[ i + 3 ]; i += 4 ); // No visible pixel. /* istanbul ignore next -- @preserve */ @@ -57,7 +57,7 @@ export default function isEmojiSupported( unicode: string ): boolean { return false; } - // Some emojis are a contraction of different ones, so if it's not supported, it will show multiple characters. + //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; diff --git a/packages/ckeditor5-emoji/tests/emojirepository.js b/packages/ckeditor5-emoji/tests/emojirepository.js index 03cf8a5b21d..b7ad95ae2da 100644 --- a/packages/ckeditor5-emoji/tests/emojirepository.js +++ b/packages/ckeditor5-emoji/tests/emojirepository.js @@ -11,17 +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/utils/emojiutils.js'; describe( 'EmojiRepository', () => { testUtils.createSinonSandbox(); - let isEmojiSupportedStub, getEmojiSupportedVersionByOsStub, hasZwjStub, consoleStub, fetchStub; + let consoleStub, fetchStub; beforeEach( () => { - isEmojiSupportedStub = testUtils.sinon.stub( EmojiRepository, '_isEmojiZwjSupported' ).returns( true ); - getEmojiSupportedVersionByOsStub = testUtils.sinon.stub( EmojiRepository, '_getEmojiSupportedVersionByOs' ).returns( 100 ); - hasZwjStub = testUtils.sinon.stub( EmojiRepository, '_hasZwj' ).returns( false ); - consoleStub = sinon.stub( console, 'warn' ); fetchStub = testUtils.sinon.stub( window, 'fetch' ).resolves( new Response( '[]' ) ); } ); @@ -80,8 +77,8 @@ describe( 'EmojiRepository', () => { it( 'should fetch the emoji database version 16', async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0, version: 16 }, - { annotation: 'unamused face', group: 0, version: 16 } + { emoji: '😐️', annotation: 'neutral face', group: 0, version: 15 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -143,16 +140,11 @@ describe( 'EmojiRepository', () => { } ); it( 'should filter out unsupported ZWJ emojis from the fetched emoji database', async () => { - // Neutral face is mocked to be the only non ZWJ icon. - hasZwjStub.callsFake( emoji => emoji !== '😐️' ); - - // Unamused face is the only supported ZWJ emoji. - isEmojiSupportedStub.callsFake( item => item.annotation === 'unamused face' ); + // Head shaking horizontally is mocked to be an unsupported emoji in 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 } + { emoji: '🙂‍↔️', annotation: 'head shaking horizontally', group: 0, version: 16 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -160,23 +152,21 @@ describe( 'EmojiRepository', () => { 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' ); + const headShakingHorizontallyEmoji = emojiRepositoryPlugin._database + .find( item => item.annotation === 'head shaking horizontally' ); + const unamusedFaceEmoji = emojiRepositoryPlugin._database.find( item => item.annotation === 'unamused face' ); - expect( hasNeutralFaceEmoji ).not.to.be.undefined; // Neutral face is not ZWJ, so it should not be filtered out. - expect( hasUnamusedEmoji ).not.to.be.undefined; // Unamused face is ZWJ, and it is supported, so it should not be filtered out. - expect( hasMicroscopeEmoji ).to.be.undefined; // Microscope is ZWJ, and it is not supported, so it should be filtered out + 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 () => { - getEmojiSupportedVersionByOsStub.reset(); - getEmojiSupportedVersionByOsStub.returns( 15 ); + // Emoji version 15 is mocked in the EmojiUtilsMock. const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0, version: 16 }, - { annotation: 'unamused face', group: 0, version: 15 }, - { annotation: 'microscope', group: 7, version: 15 } + { 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 ) ); @@ -247,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, version: 15 }, - { annotation: 'unamused face', group: 0, version: 15 } + { emoji: '😐️', annotation: 'neutral face', group: 0, version: 15 }, + { emoji: '😒', annotation: 'unamused face', group: 0, version: 15 } ] ); fetchStubResolve( new Response( response ) ); @@ -309,6 +299,7 @@ describe( 'EmojiRepository', () => { beforeEach( async () => { const response = JSON.stringify( [ { + emoji: '😐️', annotation: 'neutral face', emoticon: ':|', tags: [ 'awkward', 'blank', 'face', 'meh', 'whatever' ], @@ -316,6 +307,7 @@ describe( 'EmojiRepository', () => { version: 15 }, { + emoji: '😒', annotation: 'unamused face', emoticon: ':?', tags: [ 'bored', 'face', 'fine', 'ugh', 'whatever' ], @@ -414,16 +406,16 @@ describe( 'EmojiRepository', () => { beforeEach( async () => { const response = JSON.stringify( [ - { annotation: 'neutral face', group: 0, version: 15 }, - { annotation: 'ninja', group: 1, version: 15 }, - { annotation: 'medium-dark skin tone', group: 2, version: 15 }, - { annotation: 'lobster', group: 3, version: 15 }, - { annotation: 'salt', group: 4, version: 15 }, - { annotation: 'watch', group: 5, version: 15 }, - { annotation: 'magic wand', group: 6, version: 15 }, - { annotation: 'x-ray', group: 7, version: 15 }, - { annotation: 'up-left arrow', group: 8, version: 15 }, - { annotation: 'flag: Poland', group: 9, version: 15 } + { 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 ) ); @@ -583,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: [ @@ -590,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..38668f9fc77 --- /dev/null +++ b/packages/ckeditor5-emoji/tests/utils/emojiutils.js @@ -0,0 +1,213 @@ +/** + * @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/utils/emojiutils.js'; +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 appendSpy = sinon.spy( document.body, 'appendChild' ); + + 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' ); + sinon.assert.calledOnceWithExactly( appendSpy, container ); + + 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 ); + } ); + } ); +} ); From 8533e60791b37a3ad851ea27eafc9c90ded48991 Mon Sep 17 00:00:00 2001 From: Marcin Panek Date: Mon, 3 Feb 2025 18:12:41 +0100 Subject: [PATCH 5/8] Wording. --- packages/ckeditor5-emoji/src/utils/emojiutils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-emoji/src/utils/emojiutils.ts b/packages/ckeditor5-emoji/src/utils/emojiutils.ts index 07fa52cc945..85fca6e1a14 100644 --- a/packages/ckeditor5-emoji/src/utils/emojiutils.ts +++ b/packages/ckeditor5-emoji/src/utils/emojiutils.ts @@ -33,7 +33,7 @@ const EMOJI_SUPPORT_LEVEL = { const BASELINE_EMOJI_WIDTH = 24; /** - * The CKBox utilities plugin. + * The Emoji utilities plugin. */ export default class EmojiUtils extends Plugin { /** From c72fb15102be39c4deabf9e37674199c40dfb09b Mon Sep 17 00:00:00 2001 From: Kamil Piechaczek Date: Tue, 4 Feb 2025 08:59:05 +0100 Subject: [PATCH 6/8] Removed "substitutePlugins" from tests. --- packages/ckeditor5-emoji/src/emojimention.ts | 20 +-- packages/ckeditor5-emoji/src/emojipicker.ts | 32 ++-- .../ckeditor5-emoji/src/emojirepository.ts | 38 ++--- .../src/utils/isemojisupported.ts | 4 + .../ckeditor5-emoji/tests/emojimention.js | 107 ++++++++----- packages/ckeditor5-emoji/tests/emojipicker.js | 143 ++++++++++-------- 6 files changed, 200 insertions(+), 144 deletions(-) diff --git a/packages/ckeditor5-emoji/src/emojimention.ts b/packages/ckeditor5-emoji/src/emojimention.ts index 93f0a7e61eb..d614296ce7f 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; /** * Defines a number of displayed items in the auto complete dropdown. @@ -108,11 +108,11 @@ 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.emojiPickerPlugin = editor.plugins.has( 'EmojiPicker' ) ? editor.plugins.get( 'EmojiPicker' ) : null; + this.emojiRepositoryPlugin = editor.plugins.get( 'EmojiRepository' ); // Skip overriding the `mention` command listener if the emoji repository is not ready. - if ( !await this._emojiRepositoryPlugin.isReady() ) { + if ( !await this.emojiRepositoryPlugin.isReady() ) { return; } @@ -202,7 +202,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 ) ); @@ -230,12 +230,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 { @@ -244,7 +244,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 d0261f392b0..a3e832f9d5d 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -12,8 +12,8 @@ import { groupBy } from 'lodash-es'; import { type Editor, Plugin } from 'ckeditor5/src/core.js'; import { logWarning } from 'ckeditor5/src/utils.js'; -import type { SkinToneId } from './emojiconfig.js'; import EmojiUtils from './utils/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. @@ -26,15 +26,9 @@ const EMOJI_DATABASE_URL = 'https://cdn.ckeditor.com/ckeditor5/data/emoji/{versi */ export default class EmojiRepository extends Plugin { /** - * Emoji database. - */ - private _database: Array; - - /** - * A promise resolved after downloading the emoji database. - * The promise resolves with `true` when the database is successfully downloaded or `false` otherwise. + * An instance of the {@link module:emoji/utils/emojiutils~EmojiUtils} plugin. */ - private _databasePromise: Promise; + declare public emojiUtilsPlugin: EmojiUtils; /** * A callback to resolve the {@link #_databasePromise} to control the return value of this promise. @@ -47,29 +41,35 @@ export default class EmojiRepository extends Plugin { private _fuseSearch: Fuse | null; /** - * Emoji utils. + * Emoji database. + */ + private _database: Array; + + /** + * A promise resolved after downloading the emoji database. + * The promise resolves with `true` when the database is successfully downloaded or `false` otherwise. */ - private _emojiUtils: EmojiUtils | null; + private readonly _databasePromise: Promise; /** * @inheritDoc */ - public static get pluginName() { - return 'EmojiRepository' as const; + public static get requires() { + return [ EmojiUtils ] as const; } /** * @inheritDoc */ - public static override get isOfficialPlugin(): true { - return true; + public static get pluginName() { + return 'EmojiRepository' as const; } /** * @inheritDoc */ - public static get requires() { - return [ EmojiUtils ] as const; + public static override get isOfficialPlugin(): true { + return true; } /** @@ -91,7 +91,7 @@ export default class EmojiRepository extends Plugin { this._fuseSearch = null; // TODO fix casting - this._emojiUtils = this.editor.plugins.get( 'EmojiUtils' ) as EmojiUtils; + this.emojiUtilsPlugin = this.editor.plugins.get( 'EmojiUtils' ) as EmojiUtils; } /** @@ -99,7 +99,7 @@ export default class EmojiRepository extends Plugin { */ public async init(): Promise { // TODO how to get rid of ! char - const emojiUtils = this._emojiUtils!; + const emojiUtils = this.emojiUtilsPlugin; const emojiVersion = this.editor.config.get( 'emoji.version' )!; const emojiDatabaseUrl = EMOJI_DATABASE_URL.replace( '{version}', `${ emojiVersion }` ); const emojiDatabase = await loadEmojiDatabase( emojiDatabaseUrl ); diff --git a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts index 1a8c1fb6d33..da2b8484339 100644 --- a/packages/ckeditor5-emoji/src/utils/isemojisupported.ts +++ b/packages/ckeditor5-emoji/src/utils/isemojisupported.ts @@ -3,6 +3,10 @@ * 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. */ diff --git a/packages/ckeditor5-emoji/tests/emojimention.js b/packages/ckeditor5-emoji/tests/emojimention.js index 1e98a4fcc4f..821eaa04ba3 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 ); @@ -141,7 +166,6 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mention: { feeds: [ { @@ -153,6 +177,8 @@ describe( 'EmojiMention', () => { } } ); + mockEmojiRepositoryValues( editor ); + const configs = editor.config.get( 'mention.feeds' ); expect( configs.length ).to.equal( 1 ); @@ -179,12 +205,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 ); @@ -299,7 +326,6 @@ describe( 'EmojiMention', () => { const editor = await ClassicTestEditor.create( editorElement, { plugins: [ EmojiMention, EmojiPicker, Paragraph, Essentials, Mention ], - substitutePlugins: [ EmojiRepositoryMock ], mention: { feeds: [ { @@ -311,6 +337,8 @@ describe( 'EmojiMention', () => { } } ); + mockEmojiRepositoryValues( editor ); + setModelData( editor.model, 'Hello world! []' ); expect( getModelData( editor.model ) ).to.equal( 'Hello world! []' ); @@ -332,14 +360,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! []' ); @@ -607,10 +635,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( [ @@ -705,12 +734,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', @@ -747,12 +777,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', () => { From 6e5549ea9ca95bd2fa235e6488229e5bbf187b38 Mon Sep 17 00:00:00 2001 From: Kamil Piechaczek Date: Tue, 4 Feb 2025 09:54:43 +0100 Subject: [PATCH 7/8] Minor changes. --- packages/ckeditor5-emoji/src/augmentation.ts | 2 ++ packages/ckeditor5-emoji/src/emojirepository.ts | 15 ++++----------- .../ckeditor5-emoji/src/{utils => }/emojiutils.ts | 9 ++++----- packages/ckeditor5-emoji/src/index.ts | 1 + packages/ckeditor5-emoji/tests/emojirepository.js | 2 +- .../ckeditor5-emoji/tests/utils/emojiutils.js | 5 +---- 6 files changed, 13 insertions(+), 21 deletions(-) rename packages/ckeditor5-emoji/src/{utils => }/emojiutils.ts (94%) 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/emojirepository.ts b/packages/ckeditor5-emoji/src/emojirepository.ts index 37960bcb86a..d77302b5e36 100644 --- a/packages/ckeditor5-emoji/src/emojirepository.ts +++ b/packages/ckeditor5-emoji/src/emojirepository.ts @@ -12,7 +12,7 @@ import { groupBy } from 'lodash-es'; import { type Editor, Plugin } from 'ckeditor5/src/core.js'; import { logWarning } from 'ckeditor5/src/utils.js'; -import EmojiUtils from './utils/emojiutils.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. @@ -25,11 +25,6 @@ const EMOJI_DATABASE_URL = 'https://cdn.ckeditor.com/ckeditor5/data/emoji/{versi * Loads the emoji database from URL during plugin initialization and provides utility methods to search it. */ export default class EmojiRepository extends Plugin { - /** - * An instance of the {@link module:emoji/utils/emojiutils~EmojiUtils} plugin. - */ - declare public emojiUtilsPlugin: EmojiUtils; - /** * A callback to resolve the {@link #_databasePromise} to control the return value of this promise. */ @@ -89,18 +84,15 @@ export default class EmojiRepository extends Plugin { } ); this._fuseSearch = null; - - // TODO fix casting - this.emojiUtilsPlugin = this.editor.plugins.get( 'EmojiUtils' ) as EmojiUtils; } /** * @inheritDoc */ public async init(): Promise { - // TODO how to get rid of ! char - const emojiUtils = this.emojiUtilsPlugin; + 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(); @@ -112,6 +104,7 @@ export default class EmojiRepository extends Plugin { } const container = emojiUtils.createEmojiWidthTestingContainer(); + document.body.appendChild( container ); // Store the emoji database after normalizing the raw data. this._database = emojiDatabase diff --git a/packages/ckeditor5-emoji/src/utils/emojiutils.ts b/packages/ckeditor5-emoji/src/emojiutils.ts similarity index 94% rename from packages/ckeditor5-emoji/src/utils/emojiutils.ts rename to packages/ckeditor5-emoji/src/emojiutils.ts index 85fca6e1a14..7e5126c2de7 100644 --- a/packages/ckeditor5-emoji/src/utils/emojiutils.ts +++ b/packages/ckeditor5-emoji/src/emojiutils.ts @@ -4,12 +4,12 @@ */ import { Plugin } from 'ckeditor5/src/core.js'; -import isEmojiSupported from './isemojisupported.js'; -import type { EmojiCdnResource, EmojiEntry } from '../emojirepository.js'; -import type { SkinToneId } from '../emojiconfig.js'; +import type { EmojiCdnResource, EmojiEntry } from './emojirepository.js'; +import type { SkinToneId } from './emojiconfig.js'; +import isEmojiSupported from './utils/isemojisupported.js'; /** - * @module emoji/utils/emojiutils + * @module emoji/emojiutils */ const SKIN_TONE_MAP: Record = { @@ -126,7 +126,6 @@ export default class EmojiUtils extends Plugin { container.style.left = '-9999px'; container.style.whiteSpace = 'nowrap'; container.style.fontSize = BASELINE_EMOJI_WIDTH + 'px'; - document.body.appendChild( container ); return container; } 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/tests/emojirepository.js b/packages/ckeditor5-emoji/tests/emojirepository.js index b7ad95ae2da..4acac669bcf 100644 --- a/packages/ckeditor5-emoji/tests/emojirepository.js +++ b/packages/ckeditor5-emoji/tests/emojirepository.js @@ -11,7 +11,7 @@ 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/utils/emojiutils.js'; +import EmojiUtils from '../src/emojiutils.ts'; describe( 'EmojiRepository', () => { testUtils.createSinonSandbox(); diff --git a/packages/ckeditor5-emoji/tests/utils/emojiutils.js b/packages/ckeditor5-emoji/tests/utils/emojiutils.js index 38668f9fc77..625cf0acfff 100644 --- a/packages/ckeditor5-emoji/tests/utils/emojiutils.js +++ b/packages/ckeditor5-emoji/tests/utils/emojiutils.js @@ -4,7 +4,7 @@ */ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; -import EmojiUtils from '../../src/utils/emojiutils.js'; +import EmojiUtils from '../../src/emojiutils.ts'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; /* global document */ @@ -150,8 +150,6 @@ describe( 'EmojiUtils', () => { describe( 'createEmojiWidthTestingContainer()', () => { it( 'should create a width testing container with correct attributes', async () => { - const appendSpy = sinon.spy( document.body, 'appendChild' ); - const container = emojiUtils.createEmojiWidthTestingContainer(); expect( container ).to.have.attribute( 'aria-hidden', 'true' ); @@ -159,7 +157,6 @@ describe( 'EmojiUtils', () => { expect( container.style.left ).to.equal( '-9999px' ); expect( container.style.whiteSpace ).to.equal( 'nowrap' ); expect( container.style.fontSize ).to.equal( '24px' ); - sinon.assert.calledOnceWithExactly( appendSpy, container ); container.remove(); } ); From 1d889277edbaae397cb0a12c42ed6ad8caee7e8d Mon Sep 17 00:00:00 2001 From: Kamil Piechaczek Date: Tue, 4 Feb 2025 09:55:24 +0100 Subject: [PATCH 8/8] Fixed license. --- packages/ckeditor5-emoji/LICENSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-emoji/LICENSE.md b/packages/ckeditor5-emoji/LICENSE.md index b9dc68b647c..95aca804595 100644 --- a/packages/ckeditor5-emoji/LICENSE.md +++ b/packages/ckeditor5-emoji/LICENSE.md @@ -20,7 +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) 2023, Koala Interactive SAS +* 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