From f7577fc6358b5bed9c2467e25b2165c17b7cc8ae Mon Sep 17 00:00:00 2001 From: Kamil Piechaczek Date: Sat, 18 Jan 2025 09:20:17 +0100 Subject: [PATCH] Moved responsibility of the Emoji UI from a plugin to a view. --- packages/ckeditor5-emoji/src/emojidatabase.ts | 1 + packages/ckeditor5-emoji/src/emojimention.ts | 1 + packages/ckeditor5-emoji/src/emojipicker.ts | 270 +++++++----------- .../ckeditor5-emoji/src/ui/emojigridview.ts | 30 +- .../ckeditor5-emoji/src/ui/emojipickerview.ts | 109 ++++++- .../ckeditor5-emoji/src/ui/emojisearchview.ts | 17 +- 6 files changed, 214 insertions(+), 214 deletions(-) diff --git a/packages/ckeditor5-emoji/src/emojidatabase.ts b/packages/ckeditor5-emoji/src/emojidatabase.ts index ab444a0d76e..b3b64e3eaee 100644 --- a/packages/ckeditor5-emoji/src/emojidatabase.ts +++ b/packages/ckeditor5-emoji/src/emojidatabase.ts @@ -77,6 +77,7 @@ export default class EmojiDatabase extends Plugin { * @inheritDoc */ public async init(): Promise { + // TODO: Add error handling in case when a database is not loaded. const container = createEmojiWidthTestingContainer(); const emojiVersion = this.editor.config.get( 'emoji.version' )!; diff --git a/packages/ckeditor5-emoji/src/emojimention.ts b/packages/ckeditor5-emoji/src/emojimention.ts index a7474febda9..7f59afbe819 100644 --- a/packages/ckeditor5-emoji/src/emojimention.ts +++ b/packages/ckeditor5-emoji/src/emojimention.ts @@ -177,6 +177,7 @@ export default class EmojiMention extends Plugin { */ private _getQueryEmojiFn(): ( searchQuery: string ) => Array { return ( searchQuery: string ) => { + // TODO: Add error handling if the database was not initialized properly. const emojiDatabasePlugin = this.editor.plugins.get( EmojiDatabase ); const emojis = emojiDatabasePlugin.getEmojiBySearchQuery( searchQuery ) diff --git a/packages/ckeditor5-emoji/src/emojipicker.ts b/packages/ckeditor5-emoji/src/emojipicker.ts index a31d5830430..feb617f2bc4 100644 --- a/packages/ckeditor5-emoji/src/emojipicker.ts +++ b/packages/ckeditor5-emoji/src/emojipicker.ts @@ -7,24 +7,13 @@ * @module emoji/emojipicker */ -import { - ButtonView, - clickOutsideHandler, - ContextualBalloon, - Dialog, - MenuBarMenuListItemButtonView, - SearchInfoView, - type SearchTextViewSearchEvent -} from 'ckeditor5/src/ui.js'; -import type { Locale, ObservableChangeEvent, PositionOptions } from 'ckeditor5/src/utils.js'; +import { ButtonView, clickOutsideHandler, ContextualBalloon, Dialog, MenuBarMenuListItemButtonView } from 'ckeditor5/src/ui.js'; +import type { PositionOptions } from 'ckeditor5/src/utils.js'; import { type Editor, icons, Plugin } from 'ckeditor5/src/core.js'; -import EmojiGridView, { type EmojiGridViewExecuteEvent } from './ui/emojigridview.js'; -import EmojiDatabase, { type EmojiCategory } from './emojidatabase.js'; -import EmojiSearchView from './ui/emojisearchview.js'; -import EmojiCategoriesView from './ui/emojicategoriesview.js'; -import EmojiPickerView, { type EmojiDropdownPanelContent } from './ui/emojipickerview.js'; -import EmojiToneView from './ui/emojitoneview.js'; +import EmojiDatabase from './emojidatabase.js'; +import EmojiPickerView from './ui/emojipickerview.js'; +import { type EmojiGridViewExecuteEvent } from './ui/emojigridview.js'; import type { SkinToneId } from './emojiconfig.js'; import '../theme/emojipicker.css'; @@ -37,35 +26,6 @@ const VISUAL_SELECTION_MARKER_NAME = 'emoji-picker'; * Introduces the `'emoji'` dropdown. */ export default class EmojiPicker extends Plugin { - /** - * Active skin tone. - * - * @observable - * @default 'default' - */ - declare public skinTone: SkinToneId; - - /** - * Active category. - * - * @observable - * @default '' - */ - declare public categoryName: string; - - /** - * A query provided by a user in the search field. - * - * @observable - * @default '' - */ - declare public searchQuery: string; - - /** - * An array containing all emojis grouped by their categories. - */ - declare public emojiGroups: Array; - /** * The contextual balloon plugin instance. */ @@ -102,6 +62,20 @@ export default class EmojiPicker extends Plugin { return true; } + /** + * Represents an active skin tone. Its value depends on the emoji UI plugin. + * + * Before opening the UI for the first time, the returned value is read from the editor configuration. + * Otherwise, it reflects the user's intention. + */ + public get skinTone(): SkinToneId { + if ( !this._emojiPickerView ) { + return this.editor.config.get( 'emoji.skinTone' )!; + } + + return this._emojiPickerView.gridView.skinTone; + } + /** * @inheritDoc */ @@ -111,10 +85,6 @@ export default class EmojiPicker extends Plugin { this.editor.config.define( 'emoji', { skinTone: 'default' } ); - - this.set( 'searchQuery', '' ); - this.set( 'categoryName', '' ); - this.set( 'skinTone', editor.config.get( 'emoji.skinTone' )! ); } /** @@ -123,10 +93,9 @@ export default class EmojiPicker extends Plugin { public async init(): Promise { const editor = this.editor; + // TODO: Add error handling if the database was not initialized properly. this._emojiDatabase = editor.plugins.get( EmojiDatabase ); this._balloon = editor.plugins.get( ContextualBalloon ); - this.emojiGroups = this._emojiDatabase.getEmojiGroups(); - this.categoryName = this.emojiGroups[ 0 ].title; editor.ui.componentFactory.add( 'emoji', () => { const button = this._createDialogButton( ButtonView ); @@ -145,6 +114,43 @@ export default class EmojiPicker extends Plugin { this._setupConversion(); } + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + if ( this._emojiPickerView ) { + this._emojiPickerView.destroy(); + } + } + + /** + * Displays the balloon with the emoji picker. + * + * @param [searchValue=''] A default query used to filer the grid when opening the UI. + */ + public showUI( searchValue: string = '' ): void { + // TODO: Create a command for opening the UI using a command instead of a plugin. + if ( !this._emojiPickerView ) { + this._emojiPickerView = this._createEmojiPickerView(); + } + + if ( searchValue ) { + this._emojiPickerView.searchView.setInputValue( searchValue ); + } + + this._emojiPickerView.searchView.search( searchValue ); + + this._balloon.add( { + view: this._emojiPickerView, + position: this._getBalloonPositionData() + } ); + + setTimeout( () => this._emojiPickerView!.focus() ); + this._showFakeVisualSelection(); + } + /** * Creates a button for toolbar and menu bar that will show the emoji dialog. */ @@ -166,54 +172,67 @@ export default class EmojiPicker extends Plugin { } /** - * Displays the balloon with the emoji picker. + * Creates an instance of the `EmojiPickerView` class that represents an emoji balloon. */ - public showUI( searchValue?: string ): void { - const dropdownPanelContent = this._createDropdownPanelContent( this.editor.locale ); - this._emojiPickerView = new EmojiPickerView( this.editor.locale, dropdownPanelContent ); + private _createEmojiPickerView(): EmojiPickerView { + const emojiGroups = this._emojiDatabase.getEmojiGroups(); + const skinTones = this._emojiDatabase.getSkinTones(); - this._balloon.add( { - view: this._emojiPickerView, - position: this._getBalloonPositionData() + const skinTone = this.editor.config.get( 'emoji.skinTone' )!; + + const emojiPickerView = new EmojiPickerView( this.editor.locale, { + emojiGroups, + skinTone, + skinTones, + getEmojiBySearchQuery: ( query: string ) => { + return this._emojiDatabase.getEmojiBySearchQuery( query ); + } } ); - // Close the panel on esc key press when the **actions have focus**. - this._emojiPickerView.keystrokes.set( 'Esc', ( data, cancel ) => { + // Insert an emoji on a tile click. + this.listenTo( emojiPickerView.gridView, 'execute', ( evt, data ) => { + const editor = this.editor; + const model = editor.model; + const textToInsert = data.emoji; + + model.change( writer => { + model.insertContent( writer.createText( textToInsert ) ); + } ); + + this._hideUI(); + } ); + + // TODO: How to resolve it smartly? + // this.listenTo( emojiPickerView, 'update', () => { + // this._balloon.updatePosition(); + // } ); + + // Close the panel on `Esc` key press when the **actions have focus**. + emojiPickerView.keystrokes.set( 'Esc', ( data, cancel ) => { this._hideUI(); cancel(); } ); // Close the dialog when clicking outside of it. clickOutsideHandler( { - emitter: this._emojiPickerView, + emitter: emojiPickerView, contextElements: [ this._balloon.view.element! ], callback: () => this._hideUI(), - activator: () => this._balloon.visibleView === this._emojiPickerView + activator: () => this._balloon.visibleView === emojiPickerView } ); - if ( searchValue ) { - this.searchQuery = searchValue; - this._emojiPickerView.searchView.setInputValue( this.searchQuery ); - } - - // To trigger an initial search to render the grid. - this._emojiPickerView.searchView.search( this.searchQuery ); - - setTimeout( () => this._emojiPickerView!.focus() ); - this._showFakeVisualSelection(); + return emojiPickerView; } /** * Hides the balloon with the emoji picker. */ private _hideUI(): void { - if ( this._emojiPickerView ) { - this._balloon.remove( this._emojiPickerView ); - } + this._balloon.remove( this._emojiPickerView! ); - this.editor.editing.view.focus(); - this.searchQuery = ''; + this._emojiPickerView!.searchView.setInputValue( '' ); + this.editor.editing.view.focus(); this._hideFakeVisualSelection(); } @@ -251,103 +270,6 @@ export default class EmojiPicker extends Plugin { } ); } - /** - * Initializes the dropdown, used for lazy loading. - * - * @returns An object with `categoriesView` and `gridView`properties, containing UI parts. - */ - private _createDropdownPanelContent( locale: Locale ): EmojiDropdownPanelContent { - const t = locale.t; - - const gridView = new EmojiGridView( locale, { - emojiGroups: this.emojiGroups, - categoryName: this.categoryName, - getEmojiBySearchQuery: ( query: string ) => { - return this._emojiDatabase.getEmojiBySearchQuery( query ); - } - } ); - - const resultsView = new SearchInfoView(); - const searchView = new EmojiSearchView( locale, { - gridView, - resultsView - } ); - const toneView = new EmojiToneView( locale, { - skinTone: this.skinTone, - skinTones: this._emojiDatabase.getSkinTones() - } ); - const categoriesView = new EmojiCategoriesView( locale, { - emojiGroups: this.emojiGroups, - categoryName: this.categoryName - } ); - - // Bind the "current" plugin settings specific views to avoid manual updates. - gridView.bind( 'categoryName' ).to( this, 'categoryName' ); - gridView.bind( 'skinTone' ).to( this, 'skinTone' ); - gridView.bind( 'searchQuery' ).to( this, 'searchQuery' ); - - // Disable the category switcher when filtering by a query. - searchView.on( 'search', ( evt, data ) => { - if ( data.query ) { - categoriesView.disableCategories(); - } else { - categoriesView.enableCategories(); - } - - this.searchQuery = data.query; - this._balloon.updatePosition(); - } ); - - // Show a user-friendly message when emojis are not found. - searchView.on( 'search', ( evt, data ) => { - if ( !data.resultsCount ) { - resultsView.set( { - primaryText: t( 'No emojis were found matching "%0".', data.query ), - secondaryText: t( 'Please try a different phrase or check the spelling.' ), - isVisible: true - } ); - } else { - resultsView.set( { - isVisible: false - } ); - } - } ); - - // Update the grid of emojis when selected category changes. - categoriesView.on>( 'change:categoryName', ( ev, args, categoryName ) => { - this.categoryName = categoryName; - this._balloon.updatePosition(); - } ); - - // Update the grid of emojis when selected skin tone changes. - toneView.on( 'change:skinTone', ( evt, propertyName, newValue ) => { - this.skinTone = newValue; - - searchView.search( this.searchQuery ); - } ); - - // Insert an emoji on a tile click. - gridView.on( 'execute', ( evt, data ) => { - const editor = this.editor; - const model = editor.model; - const textToInsert = data.emoji; - - model.change( writer => { - model.insertContent( writer.createText( textToInsert ) ); - } ); - - this._hideUI(); - } ); - - return { - searchView, - toneView, - categoriesView, - gridView, - resultsView - }; - } - /** * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached * to the target element or selection. diff --git a/packages/ckeditor5-emoji/src/ui/emojigridview.ts b/packages/ckeditor5-emoji/src/ui/emojigridview.ts index dbd9c800642..c5ca7e2e05f 100644 --- a/packages/ckeditor5-emoji/src/ui/emojigridview.ts +++ b/packages/ckeditor5-emoji/src/ui/emojigridview.ts @@ -25,24 +25,17 @@ export default class EmojiGridView extends View implements Filte */ declare public categoryName: string; - /** - * A query provided by a user in the search field. - * - * @observable - * @default '' - */ - declare public searchQuery: string; - /** * Active skin tone. * * @observable - * @default 'default' */ declare public skinTone: SkinToneId; /** - * Set to `true` when the {@link #tiles} collection is empty. + * Set to `true` when the {@link #tiles} collection does not contain items to display. + * + * @observable */ declare public isEmpty: boolean; @@ -74,14 +67,17 @@ export default class EmojiGridView extends View implements Filte /** * @inheritDoc */ - constructor( locale: Locale, { emojiGroups, categoryName, getEmojiBySearchQuery }: { - emojiGroups: Array; + constructor( locale: Locale, { categoryName, emojiGroups, getEmojiBySearchQuery, skinTone }: { categoryName: string; + emojiGroups: Array; getEmojiBySearchQuery: EmojiSearchQueryCallback; + skinTone: SkinToneId; } ) { super( locale ); this.set( 'isEmpty', true ); + this.set( 'categoryName', categoryName ); + this.set( 'skinTone', skinTone ); this.tiles = this.createCollection() as ViewCollection; this.focusTracker = new FocusTracker(); @@ -116,14 +112,6 @@ export default class EmojiGridView extends View implements Filte } } ); - this.on( 'change:categoryName', () => { - this.filter( null ); - } ); - - this.set( 'searchQuery', '' ); - this.set( 'categoryName', categoryName ); - this.set( 'skinTone', 'default' ); - addKeyboardHandlingForGrid( { keystrokeHandler: this.keystrokes, focusTracker: this.focusTracker, @@ -217,6 +205,8 @@ export default class EmojiGridView extends View implements Filte * @param items An array of items to insert. */ private _updateGrid( items: Array ): void { + // TODO: `isVisible` instead of `remove()` to improve performance. + // Clean-up. [ ...this.tiles ].forEach( item => { this.focusTracker.remove( item ); diff --git a/packages/ckeditor5-emoji/src/ui/emojipickerview.ts b/packages/ckeditor5-emoji/src/ui/emojipickerview.ts index 985cb5b0199..5836da6220d 100644 --- a/packages/ckeditor5-emoji/src/ui/emojipickerview.ts +++ b/packages/ckeditor5-emoji/src/ui/emojipickerview.ts @@ -7,12 +7,24 @@ * @module emoji/ui/emojipickerview */ -import { View, FocusCycler, type SearchInfoView, type ViewCollection, type FocusableView } from 'ckeditor5/src/ui.js'; -import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils.js'; -import type EmojiGridView from './emojigridview.js'; -import type EmojiCategoriesView from './emojicategoriesview.js'; -import type EmojiSearchView from './emojisearchview.js'; -import type EmojiToneView from './emojitoneview.js'; +import { + FocusCycler, + SearchInfoView, + View, + type FocusableView, + type ViewCollection, + type SearchTextViewSearchEvent +} from 'ckeditor5/src/ui.js'; +import { + FocusTracker, + KeystrokeHandler, + type Locale, + type ObservableChangeEvent +} from 'ckeditor5/src/utils.js'; +import EmojiGridView from './emojigridview.js'; +import EmojiCategoriesView from './emojicategoriesview.js'; +import EmojiSearchView from './emojisearchview.js'; +import EmojiToneView from './emojitoneview.js'; export type EmojiDropdownPanelContent = { searchView: EmojiSearchView; @@ -74,14 +86,30 @@ export default class EmojiPickerView extends View { /** * @inheritDoc */ - constructor( locale: Locale, dropdownPanelContent: EmojiDropdownPanelContent ) { + constructor( locale: Locale, { emojiGroups, getEmojiBySearchQuery, skinTone, skinTones }: any ) { super( locale ); - this.searchView = dropdownPanelContent.searchView; - this.categoriesView = dropdownPanelContent.categoriesView; - this.gridView = dropdownPanelContent.gridView; - this.toneView = dropdownPanelContent.toneView; - this.resultsView = dropdownPanelContent.resultsView; + const categoryName = emojiGroups[ 0 ].title; + + this.gridView = new EmojiGridView( locale, { + categoryName, + emojiGroups, + getEmojiBySearchQuery, + skinTone + } ); + this.resultsView = new SearchInfoView(); + this.searchView = new EmojiSearchView( locale, { + gridView: this.gridView, + resultsView: this.resultsView + } ); + this.categoriesView = new EmojiCategoriesView( locale, { + emojiGroups, + categoryName + } ); + this.toneView = new EmojiToneView( locale, { + skinTone, + skinTones + } ); this.items = this.createCollection(); this.focusTracker = new FocusTracker(); @@ -134,6 +162,8 @@ export default class EmojiPickerView extends View { this.items.add( this.categoriesView ); this.items.add( this.gridView ); this.items.add( this.resultsView ); + + this._setupEventListeners(); } /** @@ -150,6 +180,7 @@ export default class EmojiPickerView extends View { // We need to disable listening for all events within the `SearchTextView` view. // Otherwise, its own focus tracker interfere with `EmojiPickerView` which leads to unexpected results. + // TODO: Could we reuse `keystrokes` from `inputView` instead creating a new one? this.searchView.inputView.keystrokes.stopListening(); // Start listening for the keystrokes coming from #element. @@ -172,4 +203,58 @@ export default class EmojiPickerView extends View { public focus(): void { this.focusCycler.focusFirst(); } + + /** + * Initializes interactions between sub-views. + */ + private _setupEventListeners(): void { + const t = this.locale!.t; + + // Disable the category switcher when filtering by a query. + this.searchView.on( 'search', ( evt, data ) => { + if ( data.query ) { + this.categoriesView.disableCategories(); + } else { + this.categoriesView.enableCategories(); + } + } ); + + // Show a user-friendly message depending on the search query. + this.searchView.on( 'search', ( evt, data ) => { + if ( data.query.length === 1 ) { + this.resultsView.set( { + primaryText: t( 'Keep on typing to see the results.' ), + secondaryText: t( 'The query must contain at least two characters.' ), + isVisible: true + } ); + } else if ( !data.resultsCount ) { + this.resultsView.set( { + primaryText: t( 'No emojis were found matching "%0".', data.query ), + secondaryText: t( 'Please try a different phrase or check the spelling.' ), + isVisible: true + } ); + } else { + this.resultsView.set( { + isVisible: false + } ); + } + + // TODO: So far, it does not work as expected. + // Messaging can impact a balloon's position. Let's update it. + // this.fire( 'update' ); + } ); + + // Update the grid of emojis when the selected category is changed. + this.categoriesView.on>( 'change:categoryName', ( ev, args, categoryName ) => { + this.gridView.categoryName = categoryName; + this.searchView.search( '' ); + } ); + + // Update the grid of emojis when the selected skin tone is changed. + // In such a case, the displayed emoji should use an updated skin tone value. + this.toneView.on( 'change:skinTone', ( evt, propertyName, newValue ) => { + this.gridView.skinTone = newValue; + this.searchView.search( this.searchView.getInputValue() ); + } ); + } } diff --git a/packages/ckeditor5-emoji/src/ui/emojisearchview.ts b/packages/ckeditor5-emoji/src/ui/emojisearchview.ts index b98183405d4..0d2cd00b92c 100644 --- a/packages/ckeditor5-emoji/src/ui/emojisearchview.ts +++ b/packages/ckeditor5-emoji/src/ui/emojisearchview.ts @@ -23,11 +23,6 @@ export default class EmojiSearchView extends View { */ public readonly gridView: EmojiGridView; - /** - * An instance of the `EmojiGridView`. - */ - public readonly resultsView: SearchInfoView; - /** * @inheritDoc */ @@ -35,7 +30,6 @@ export default class EmojiSearchView extends View { super( locale ); this.gridView = gridView; - this.resultsView = resultsView; const t = locale.t; @@ -46,7 +40,7 @@ export default class EmojiSearchView extends View { }, filteredView: this.gridView, infoView: { - instance: this.resultsView + instance: resultsView } } ); @@ -60,7 +54,7 @@ export default class EmojiSearchView extends View { ] } ); - // Pass through the `search` event to handle it by a controller (parent). + // Pass through the `search` event to handle it by a parent view. this.inputView.delegate( 'search' ).to( this ); } @@ -85,6 +79,13 @@ export default class EmojiSearchView extends View { this.inputView.queryView.fieldView.value = value; } + /** + * Returns an input provided by a user in the search text field. + */ + public getInputValue(): string { + return this.inputView.queryView.fieldView.element!.value; + } + /** * @inheritDoc */