/** * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ ( function() { 'use strict'; var stylesLoaded = false, arrTools = CKEDITOR.tools.array, htmlEncode = CKEDITOR.tools.htmlEncodeAttr, EmojiDropdown = CKEDITOR.tools.createClass( { $: function( editor, plugin ) { var lang = this.lang = editor.lang.emoji, self = this, ICON_SIZE = 21; this.listeners = []; this.plugin = plugin; this.editor = editor; this.groups = [ { name: 'people', sectionName: lang.groups.people, svgId: 'cke4-icon-emoji-2', position: { x: -1 * ICON_SIZE, y: 0 }, items: [] }, { name: 'nature', sectionName: lang.groups.nature, svgId: 'cke4-icon-emoji-3', position: { x: -2 * ICON_SIZE, y: 0 }, items: [] }, { name: 'food', sectionName: lang.groups.food, svgId: 'cke4-icon-emoji-4', position: { x: -3 * ICON_SIZE, y: 0 }, items: [] }, { name: 'travel', sectionName: lang.groups.travel, svgId: 'cke4-icon-emoji-6', position: { x: -2 * ICON_SIZE, y: -1 * ICON_SIZE }, items: [] }, { name: 'activities', sectionName: lang.groups.activities, svgId: 'cke4-icon-emoji-5', position: { x: -4 * ICON_SIZE, y: 0 }, items: [] }, { name: 'objects', sectionName: lang.groups.objects, svgId: 'cke4-icon-emoji-7', position: { x: 0, y: -1 * ICON_SIZE }, items: [] }, { name: 'symbols', sectionName: lang.groups.symbols, svgId: 'cke4-icon-emoji-8', position: { x: -1 * ICON_SIZE, y: -1 * ICON_SIZE }, items: [] }, { name: 'flags', sectionName: lang.groups.flags, svgId: 'cke4-icon-emoji-9', position: { x: -3 * ICON_SIZE, y: -1 * ICON_SIZE }, items: [] } ]; // Keeps html elements references to not find them again. this.elements = {}; // Below line might be removable editor.ui.addToolbarGroup( 'emoji', 'insert' ); // Name is responsible for icon name also. editor.ui.add( 'emoji', CKEDITOR.UI_PANELBUTTON, { label: 'emoji', title: lang.title, modes: { wysiwyg: 1 }, editorFocus: 0, toolbar: 'insert', panel: { css: [ CKEDITOR.skin.getPath( 'editor' ), plugin.path + 'skins/default.css' ], attributes: { role: 'listbox', 'aria-label': lang.title }, markFirst: false }, onBlock: function( panel, block ) { var keys = block.keys, rtl = editor.lang.dir === 'rtl'; keys[ rtl ? 37 : 39 ] = 'next'; // ARROW-RIGHT keys[ 40 ] = 'next'; // ARROW-DOWN keys[ 9 ] = 'next'; // TAB keys[ rtl ? 39 : 37 ] = 'prev'; // ARROW-LEFT keys[ 38 ] = 'prev'; // ARROW-UP keys[ CKEDITOR.SHIFT + 9 ] = 'prev'; // SHIFT + TAB keys[ 32 ] = 'click'; // SPACE self.blockElement = block.element; self.emojiList = self.editor._.emoji.list; self.addEmojiToGroups(); block.element.getAscendant( 'html' ).addClass( 'cke_emoji' ); block.element.getDocument().appendStyleSheet( CKEDITOR.getUrl( CKEDITOR.basePath + 'contents.css' ) ); block.element.addClass( 'cke_emoji-panel_block' ); block.element.setHtml( self.createEmojiBlock() ); block.element.removeAttribute( 'title' ); panel.element.addClass( 'cke_emoji-panel' ); self.items = block._.getItems(); self.blockObject = block; self.elements.emojiItems = block.element.find( '.cke_emoji-outer_emoji_block li > a' ); self.elements.sectionHeaders = block.element.find( '.cke_emoji-outer_emoji_block h2' ); self.elements.input = block.element.findOne( 'input' ); self.inputIndex = self.getItemIndex( self.items, self.elements.input ); self.elements.emojiBlock = block.element.findOne( '.cke_emoji-outer_emoji_block' ); self.elements.navigationItems = block.element.find( 'nav li' ); self.elements.statusIcon = block.element.findOne( '.cke_emoji-status_icon' ); self.elements.statusDescription = block.element.findOne( 'p.cke_emoji-status_description' ); self.elements.statusName = block.element.findOne( 'p.cke_emoji-status_full_name' ); self.elements.sections = block.element.find( 'section' ); self.registerListeners(); }, onOpen: self.openReset() } ); }, proto: { registerListeners: function() { arrTools.forEach( this.listeners, function( item ) { var root = this.blockElement, selector = item.selector, listener = item.listener, event = item.event, ctx = item.ctx || this; arrTools.forEach( root.find( selector ).toArray(), function( node ) { node.on( event, listener, ctx ); } ); }, this ); }, createEmojiBlock: function() { var output = []; // (#2607) this.loadSVGNavigationIcons(); output.push( this.createGroupsNavigation() ); output.push( this.createSearchSection() ); output.push( this.createEmojiListBlock() ); output.push( this.createStatusBar() ); return '
' + output.join( '' ) + '
'; }, createGroupsNavigation: function() { var itemTemplate, items, imgUrl, useAttr; if ( !this.editor.plugins.emoji.isSVGSupported() ) { imgUrl = CKEDITOR.getUrl( this.plugin.path + 'assets/iconsall.png' ); itemTemplate = new CKEDITOR.template( '
  • ' + '' + '' + '
  • ' ); items = arrTools.reduce( this.groups, function( acc, item ) { if ( !item.items.length ) { return acc; } else { return acc + itemTemplate.output( { group: htmlEncode( item.name ), name: htmlEncode( item.sectionName ), positionX: item.position.x, positionY: item.position.y } ); } }, '' ); } else { // iOS has problem with reading `href` attribute, that's why, // its necessary to use `xlink:href` even its deprecated: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href useAttr = CKEDITOR.env.safari ? 'xlink:href="#{svgId}"' : 'href="#{svgId}"'; itemTemplate = new CKEDITOR.template( '
  • ' + '' + '{name}
  • ' ); items = arrTools.reduce( this.groups, function( acc, item ) { if ( !item.items.length ) { return acc; } else { return acc + itemTemplate.output( { group: htmlEncode( item.name ), name: htmlEncode( item.sectionName ), svgId: htmlEncode( item.svgId ), translateX: item.translate && item.translate.x ? htmlEncode( item.translate.x ) : 0, translateY: item.translate && item.translate.y ? htmlEncode( item.translate.y ) : 0 } ); } }, '' ); } this.listeners.push( { selector: 'nav', event: 'click', listener: function( event ) { var activeElement = event.data.getTarget().getAscendant( 'li', true ); if ( !activeElement ) { return; } arrTools.forEach( this.elements.navigationItems.toArray(), function( node ) { if ( node.equals( activeElement ) ) { node.addClass( 'active' ); } else { node.removeClass( 'active' ); } } ); this.clearSearchAndMoveFocus( activeElement ); event.data.preventDefault(); } } ); return ''; }, createSearchSection: function() { var self = this; this.listeners.push( { selector: 'input', event: 'input', listener: ( function() { var buffer = CKEDITOR.tools.throttle( 200, self.filter, self ); return buffer.input; } )() } ); this.listeners.push( { selector: 'input', event: 'click', listener: function() { this.blockObject._.markItem( this.inputIndex ); } } ); return ''; }, createEmojiListBlock: function() { var self = this; this.listeners.push( { selector: '.cke_emoji-outer_emoji_block', event: 'scroll', listener: ( function() { var buffer = CKEDITOR.tools.throttle( 150, self.refreshNavigationStatus, self ); return buffer.input; } )() } ); this.listeners.push( { selector: '.cke_emoji-outer_emoji_block', event: 'click', listener: function( event ) { if ( event.data.getTarget().data( 'cke-emoji-name' ) ) { this.editor.execCommand( 'insertEmoji', { emojiText: event.data.getTarget().data( 'cke-emoji-symbol' ) } ); } } } ); this.listeners.push( { selector: '.cke_emoji-outer_emoji_block', event: 'mouseover', listener: function( event ) { this.updateStatusbar( event.data.getTarget() ); } } ); this.listeners.push( { selector: '.cke_emoji-outer_emoji_block', event: 'keyup', listener: function() { this.updateStatusbar( this.items.getItem( this.blockObject._.focusIndex ) ); } } ); return '
    ' + this.getEmojiSections() + '
    '; }, createStatusBar: function() { return '
    ' + '
    ' + '

    ' + '
    '; }, getLoupeIcon: function() { var loupePngUrl = CKEDITOR.getUrl( this.plugin.path + 'assets/iconsall.png' ), useAttr; if ( !this.editor.plugins.emoji.isSVGSupported() ) { return ''; } else { useAttr = CKEDITOR.env.safari ? 'xlink:href="#cke4-icon-emoji-10"' : 'href="#cke4-icon-emoji-10"'; return ''; } }, getEmojiSections: function() { return arrTools.reduce( this.groups, function( acc, item ) { // If group is empty skip it. if ( !item.items.length ) { return acc; } else { return acc + this.getEmojiSection( item ); } }, '', this ); }, getEmojiSection: function( item ) { var groupName = htmlEncode( item.name ), sectionName = htmlEncode( item.sectionName ), group = this.getEmojiListGroup( item.items ); return '

    ' + sectionName + '

    '; }, getEmojiListGroup: function( items ) { var emojiTpl = new CKEDITOR.template( '
  • ' + '{symbol}' + '
  • ' ); return arrTools.reduce( items, function( acc, item ) { addEncodedName( item ); return acc + emojiTpl.output( { symbol: htmlEncode( item.symbol ), id: htmlEncode( item.id ), name: item.name, group: htmlEncode( item.group ), keywords: htmlEncode( ( item.keywords || [] ).join( ',' ) ) } ); }, '', this ); }, filter: function( evt ) { // Apply filters to emoji items in dropdown. // Hiding not searched one. // Can accept input event or string var groups = {}, query = typeof evt === 'string' ? evt : evt.sender.getValue(); arrTools.forEach( this.elements.emojiItems.toArray(), function( element ) { if ( isNameOrKeywords( query, element.data( 'cke-emoji-name' ), element.data( 'cke-emoji-keywords' ) ) || query === '' ) { element.removeClass( 'hidden' ); element.getParent().removeClass( 'hidden' ); groups[ element.data( 'cke-emoji-group' ) ] = true; } else { element.addClass( 'hidden' ); element.getParent().addClass( 'hidden' ); } function isNameOrKeywords( query, name, keywordsString ) { var keywords, i; if ( name.indexOf( query ) !== -1 ) { return true; } if ( keywordsString ) { keywords = keywordsString.split( ',' ); for ( i = 0; i < keywords.length; i++ ) { if ( keywords[ i ].indexOf( query ) !== -1 ) { return true; } } } return false; } } ); arrTools.forEach( this.elements.sectionHeaders.toArray(), function( element ) { if ( groups[ element.getId() ] ) { element.getParent().removeClass( 'hidden' ); element.removeClass( 'hidden' ); } else { element.addClass( 'hidden' ); element.getParent().addClass( 'hidden' ); } } ); this.refreshNavigationStatus(); }, clearSearchInput: function() { this.elements.input.setValue( '' ); this.filter( '' ); }, openReset: function() { // Resets state of emoji dropdown. // Clear filters, reset focus, etc. var self = this, firstCall; return function() { if ( !firstCall ) { self.filter( '' ); firstCall = true; } self.elements.emojiBlock.$.scrollTop = 0; self.refreshNavigationStatus(); // Clear search results: self.clearSearchInput(); // Reset focus: CKEDITOR.tools.setTimeout( function() { self.elements.input.focus( true ); self.blockObject._.markItem( self.inputIndex ); }, 0, self ); // Remove statusbar icons: self.clearStatusbar(); }; }, refreshNavigationStatus: function() { var containerOffset = this.elements.emojiBlock.getClientRect().top, section, groupName; section = arrTools.filter( this.elements.sections.toArray(), function( element ) { var rect = element.getClientRect(); if ( !rect.height || element.findOne( 'h2' ).hasClass( 'hidden' ) ) { return false; } return rect.height + rect.top > containerOffset; } ); groupName = section.length ? section[ 0 ].data( 'cke-emoji-group' ) : false; arrTools.forEach( this.elements.navigationItems.toArray(), function( node ) { if ( node.data( 'cke-emoji-group' ) === groupName ) { node.addClass( 'active' ); } else { node.removeClass( 'active' ); } } ); }, updateStatusbar: function( element ) { if ( element.getName() !== 'a' || !element.hasAttribute( 'data-cke-emoji-name' ) ) { return; } this.elements.statusIcon.setText( htmlEncode( element.getText() ) ); this.elements.statusDescription.setText( htmlEncode( element.data( 'cke-emoji-name' ) ) ); this.elements.statusName.setText( htmlEncode( element.data( 'cke-emoji-full-name' ) ) ); }, clearStatusbar: function() { this.elements.statusIcon.setText( '' ); this.elements.statusDescription.setText( '' ); this.elements.statusName.setText( '' ); }, clearSearchAndMoveFocus: function( activeElement ) { this.clearSearchInput(); this.moveFocus( activeElement.data( 'cke-emoji-group' ) ); }, moveFocus: function( groupName ) { var firstSectionItem = this.blockElement.findOne( 'a[data-cke-emoji-group="' + htmlEncode( groupName ) + '"]' ), itemIndex; if ( !firstSectionItem ) { return; } itemIndex = this.getItemIndex( this.items, firstSectionItem ); firstSectionItem.focus( true ); firstSectionItem.getAscendant( 'section' ).getFirst().scrollIntoView( true ); this.blockObject._.markItem( itemIndex ); }, getItemIndex: function( nodeList, item ) { return arrTools.indexOf( nodeList.toArray(), function( element ) { return element.equals( item ); } ); }, // To avoid CORS issues due to XML-based SVG icons, they should be loaded into the panel document. // This method ensures that the icons are loaded locally. loadSVGNavigationIcons: function() { if ( !this.editor.plugins.emoji.isSVGSupported() ) { return; } var doc = this.blockElement.getDocument(); CKEDITOR.ajax.load( CKEDITOR.getUrl( this.plugin.path + 'assets/iconsall.svg' ), function( html ) { var container = new CKEDITOR.dom.element( 'div' ); container.addClass( 'cke_emoji-navigation_icons' ); container.setHtml( html ); doc.getBody().append( container ); } ); }, addEmojiToGroups: function() { var groupObj = {}; arrTools.forEach( this.groups, function( group ) { groupObj[ group.name ] = group.items; }, this ); arrTools.forEach( this.emojiList, function( emojiObj ) { groupObj[ emojiObj.group ].push( emojiObj ); }, this ); } } } ); CKEDITOR.plugins.add( 'emoji', { requires: 'autocomplete,textmatch,ajax,panelbutton,floatpanel', lang: 'cs,da,de,de-ch,el,en,en-au,et,fa,fr,gl,hr,hu,it,nl,pl,pt-br,sk,sr,sr-latn,sv,tr,uk,zh,zh-cn', // %REMOVE_LINE_CORE% icons: 'emojipanel', hidpi: true, isSupportedEnvironment: function() { return !CKEDITOR.env.ie || CKEDITOR.env.version >= 11; }, beforeInit: function() { if ( !this.isSupportedEnvironment() ) { return; } if ( !stylesLoaded ) { CKEDITOR.document.appendStyleSheet( this.path + 'skins/default.css' ); stylesLoaded = true; } }, init: function( editor ) { if ( !this.isSupportedEnvironment() ) { return; } var emojiListUrl = editor.config.emoji_emojiListUrl || 'plugins/emoji/emoji.json', arrTools = CKEDITOR.tools.array; CKEDITOR.ajax.load( CKEDITOR.getUrl( emojiListUrl ), function( data ) { if ( data === null ) { return; } if ( editor._.emoji === undefined ) { editor._.emoji = {}; } if ( editor._.emoji.list === undefined ) { editor._.emoji.list = JSON.parse( data ); } var emojiList = editor._.emoji.list, charactersToStart = editor.config.emoji_minChars === undefined ? 2 : editor.config.emoji_minChars; if ( editor.status !== 'ready' ) { editor.once( 'instanceReady', initPlugin ); } else { initPlugin(); } // HELPER FUNCTIONS: function initPlugin() { editor._.emoji.autocomplete = new CKEDITOR.plugins.autocomplete( editor, { textTestCallback: getTextTestCallback(), dataCallback: dataCallback, itemTemplate: '
  • {symbol} {name}
  • ', outputTemplate: '{symbol}' } ); } function getTextTestCallback() { return function( range ) { if ( !range.collapsed ) { return null; } return CKEDITOR.plugins.textMatch.match( range, matchCallback ); }; } function matchCallback( text, offset ) { var left = text.slice( 0, offset ), // Emoji should be started with space or newline, but space shouldn't leak to output, hence it is in non captured group (#2195). match = left.match( new RegExp( '(?:\\s\|^)(:\\S{' + charactersToStart + '}\\S*)$' ) ); if ( !match ) { return null; } // In case of space preceding colon we need to return the last index (#2394) of capturing group. return { start: left.lastIndexOf( match[ 1 ] ), end: offset }; } function dataCallback( matchInfo, callback ) { var emojiName = matchInfo.query.substr( 1 ).toLowerCase(), data = arrTools.filter( emojiList, function( item ) { // Comparing lowercase strings, because emoji should be case insensitive (#2167). return item.id.toLowerCase().indexOf( emojiName ) !== -1; } ).sort( function( a, b ) { var aStartsWithEmojiName = !a.id.substr( 1 ).indexOf( emojiName ), bStartsWithEmojiName = !b.id.substr( 1 ).indexOf( emojiName ); if ( aStartsWithEmojiName != bStartsWithEmojiName ) { return aStartsWithEmojiName ? -1 : 1; } else { return a.id > b.id ? 1 : -1; } } ); data = arrTools.map( data, addEncodedName ); callback( data ); } } ); editor.addCommand( 'insertEmoji', { exec: function( editor, data ) { editor.insertHtml( data.emojiText ); } } ); if ( editor.plugins.toolbar ) { new EmojiDropdown( editor, this ); } }, isSVGSupported: function() { return !CKEDITOR.env.ie || CKEDITOR.env.edge; } } ); function addEncodedName( item ) { if ( !item.name ) { item.name = htmlEncode( item.id.replace( /::.*$/, ':' ).replace( /^:|:$/g, '' ) ); } return item; } } )(); /** * A number that defines how many characters are required to start displaying emoji's autocomplete suggestion box. * Delimiter `:`, which activates the emoji suggestion box, is not included in this value. * * ```js * editor.emoji_minChars = 0; // Emoji suggestion box appears after typing ':'. * ``` * * @since 4.10.0 * @cfg {Number} [emoji_minChars=2] * @member CKEDITOR.config */ /** * Address of the JSON file containing the emoji list. The file is downloaded through the {@link CKEDITOR.ajax#load} method * and the URL address is processed by {@link CKEDITOR#getUrl}. * Emoji list has to be an array of objects with the `id` and `symbol` properties. These keys represent the text to match and the * UTF symbol for its replacement. * An emoji has to start with the `:` (colon) symbol. * * ```json * [ * { * "id": ":grinning_face:", * "symbol":"😀" * }, * { * "id": ":bug:", * "symbol":"🐛" * }, * { * "id": ":star:", * "symbol":"⭐" * } * ] * ``` * * ```js * editor.emoji_emojiListUrl = 'https://my.custom.domain/ckeditor/emoji.json'; * ``` * * @since 4.10.0 * @cfg {String} [emoji_emojiListUrl='plugins/emoji/emoji.json'] * @member CKEDITOR.config */