Skip to content

Commit a9b3963

Browse files
committed
Autocomplete for emojis and smileys
1 parent 97067c5 commit a9b3963

File tree

3 files changed

+246
-1
lines changed

3 files changed

+246
-1
lines changed

plugins/ckeditor5-woltlab-smiley/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@
66
*/
77

88
export { WoltlabSmiley } from "./woltlabsmiley";
9+
export {
10+
WoltlabSmileyItem,
11+
WoltlabSmileyMention,
12+
} from "./woltlabsmileymention";
913
export { default as WoltlabSmileyCommand } from "./woltlabsmileycommand";

plugins/ckeditor5-woltlab-smiley/src/woltlabsmiley.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import { Image } from "@ckeditor/ckeditor5-image";
1313

1414
import "../theme/woltlabsmiley.css";
1515
import { toWidget } from "@ckeditor/ckeditor5-widget";
16+
import WoltlabSmileyMention from "./woltlabsmileymention";
1617

1718
export class WoltlabSmiley extends Plugin {
1819
static get pluginName() {
1920
return "WoltlabSmiley";
2021
}
2122

2223
static get requires() {
23-
return [Image] as const;
24+
return [Image, WoltlabSmileyMention] as const;
2425
}
2526

2627
init() {
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* Autocomplete for emojis and smileys.
3+
* Overrides the default emoji mention feed to include smileys.
4+
*
5+
* @author Olaf Braun
6+
* @copyright 2001-2025 WoltLab GmbH
7+
* @license LGPL-2.1-or-later
8+
* @since 6.2
9+
*
10+
* @see https://raw.githubusercontent.com/ckeditor/ckeditor5/refs/tags/v45.0.0/packages/ckeditor5-emoji/src/emojimention.ts
11+
*/
12+
13+
import { Plugin, Editor } from "@ckeditor/ckeditor5-core";
14+
import {
15+
EmojiMention,
16+
EmojiRepository,
17+
EmojiPicker,
18+
} from "@ckeditor/ckeditor5-emoji";
19+
import {
20+
MentionFeed,
21+
MentionFeedObjectItem,
22+
ItemRenderer,
23+
} from "@ckeditor/ckeditor5-mention";
24+
import { SkinToneId } from "@ckeditor/ckeditor5-emoji/src/emojiconfig";
25+
import { LocaleTranslate } from "@ckeditor/ckeditor5-utils";
26+
import { WoltlabSmileyCommand } from "./index";
27+
28+
const EMOJI_MENTION_MARKER = ":";
29+
const EMOJI_SHOW_ALL_OPTION_ID = ":__EMOJI_SHOW_ALL:";
30+
const EMOJI_HINT_OPTION_ID = ":__EMOJI_HINT:";
31+
32+
export class WoltlabSmileyMention extends Plugin {
33+
declare public emojiPickerPlugin: EmojiPicker | null;
34+
declare public emojiRepositoryPlugin: EmojiRepository;
35+
declare private _isEmojiRepositoryAvailable: boolean;
36+
declare private _emojiDropdownLimit: number;
37+
private readonly _skinTone: SkinToneId;
38+
39+
constructor(editor: Editor) {
40+
super(editor);
41+
42+
this._skinTone = editor.config.get("emoji.skinTone")!;
43+
44+
this.#overrideMentionFeedConfig();
45+
}
46+
47+
static get pluginName() {
48+
return "WoltlabSmileyMention";
49+
}
50+
51+
static get requires() {
52+
return [EmojiRepository, "Mention", EmojiMention] as const;
53+
}
54+
55+
public async init(): Promise<void> {
56+
this.editor.commands.add("smiley", new WoltlabSmileyCommand(this.editor));
57+
this.emojiPickerPlugin = this.editor.plugins.has("EmojiPicker")
58+
? this.editor.plugins.get("EmojiPicker")
59+
: null;
60+
this.emojiRepositoryPlugin = this.editor.plugins.get("EmojiRepository");
61+
this._isEmojiRepositoryAvailable =
62+
await this.emojiRepositoryPlugin.isReady();
63+
64+
if (this._isEmojiRepositoryAvailable) {
65+
this.editor.once("ready", () => this.#registerMentionCommand());
66+
}
67+
}
68+
69+
#overrideMentionFeedConfig(): void {
70+
const mentionFeedsConfigs = this.editor.config.get(
71+
"mention.feeds",
72+
)! as Array<EmojiMentionFeed>;
73+
74+
if (!mentionFeedsConfigs.some((config) => config._isEmojiMarker)) {
75+
return;
76+
}
77+
78+
const config = mentionFeedsConfigs.find((config) => config._isEmojiMarker)!;
79+
this._emojiDropdownLimit = config.dropdownLimit!;
80+
config.feed = (searchString) => this.#queryEmojiAndSmileys(searchString);
81+
config.itemRenderer = this._customItemRendererFactory(this.editor.t);
82+
83+
this.editor.config.set("mention.feeds", mentionFeedsConfigs);
84+
}
85+
86+
private _customItemRendererFactory(t: LocaleTranslate): ItemRenderer {
87+
return (item: SmileyFeedObjectItem) => {
88+
const itemElement = document.createElement("button");
89+
90+
itemElement.classList.add("ck");
91+
itemElement.classList.add("ck-button");
92+
itemElement.classList.add("ck-button_with-text");
93+
94+
itemElement.id = `mention-list-item-id${item.id.slice(0, -1)}`;
95+
itemElement.type = "button";
96+
itemElement.tabIndex = -1;
97+
98+
const labelElement = document.createElement("span");
99+
100+
labelElement.classList.add("ck");
101+
labelElement.classList.add("ck-button__label");
102+
103+
itemElement.appendChild(labelElement);
104+
105+
if (item.id === EMOJI_HINT_OPTION_ID) {
106+
itemElement.classList.add("ck-list-item-button");
107+
itemElement.classList.add("ck-disabled");
108+
labelElement.textContent = t("Keep on typing to see the emoji.");
109+
} else if (item.id === EMOJI_SHOW_ALL_OPTION_ID) {
110+
labelElement.textContent = t("Show all emoji...");
111+
} else {
112+
if (item.isSmiley) {
113+
labelElement.classList.add("ck-smiley");
114+
labelElement.innerHTML = `${item.text} ${item.id}`;
115+
} else {
116+
labelElement.classList.add("ck-emoji");
117+
labelElement.textContent = `${item.text} ${item.id}`;
118+
}
119+
}
120+
121+
return itemElement;
122+
};
123+
}
124+
125+
#registerMentionCommand(): void {
126+
this.editor.commands.get("mention")!.on(
127+
"execute",
128+
(event, data) => {
129+
const eventData = data[0];
130+
131+
if (eventData.marker !== EMOJI_MENTION_MARKER) {
132+
return;
133+
}
134+
135+
if (!eventData.mention.isSmiley) {
136+
return;
137+
}
138+
139+
event.stop();
140+
141+
this.editor.execute("smiley", {
142+
smiley: eventData.mention.id,
143+
html: eventData.mention.text,
144+
range: eventData.range,
145+
});
146+
},
147+
{ priority: "highest" },
148+
);
149+
}
150+
151+
#queryEmojiAndSmileys(searchQuery: string): Array<SmileyFeedObjectItem> {
152+
// Do not show anything when a query starts with a space.
153+
if (searchQuery.startsWith(" ")) {
154+
return [];
155+
}
156+
157+
// Do not show anything when a query starts with a marker character.
158+
if (searchQuery.startsWith(EMOJI_MENTION_MARKER)) {
159+
return [];
160+
}
161+
162+
const result = [
163+
...this.#filterEmojis(searchQuery),
164+
...this.#filterSmileys(searchQuery),
165+
];
166+
167+
if (!this.emojiPickerPlugin) {
168+
return result.slice(0, this._emojiDropdownLimit);
169+
}
170+
171+
const actionItem: SmileyFeedObjectItem = {
172+
id:
173+
searchQuery.length > 1
174+
? EMOJI_SHOW_ALL_OPTION_ID
175+
: EMOJI_HINT_OPTION_ID,
176+
};
177+
178+
return [...result.slice(0, this._emojiDropdownLimit - 1), actionItem];
179+
}
180+
181+
#filterEmojis(searchQuery: string): Array<SmileyFeedObjectItem> {
182+
if (!this._isEmojiRepositoryAvailable) {
183+
return [];
184+
}
185+
186+
return this.emojiRepositoryPlugin
187+
.getEmojiByQuery(searchQuery)
188+
.map((emoji) => {
189+
let text = emoji.skins[this._skinTone] || emoji.skins.default;
190+
191+
if (this.emojiPickerPlugin) {
192+
text =
193+
emoji.skins[this.emojiPickerPlugin.skinTone] || emoji.skins.default;
194+
}
195+
196+
return {
197+
id: `:${emoji.annotation}:`,
198+
text,
199+
};
200+
});
201+
}
202+
203+
#filterSmileys(searchQuery: string): Array<SmileyFeedObjectItem> {
204+
const woltlabSmileys = this.editor.config.get("woltlabSmileys") || [];
205+
206+
return woltlabSmileys
207+
.filter((emoji) => {
208+
const code = emoji.code.substring(1, emoji.code.length - 1);
209+
return code.startsWith(searchQuery);
210+
})
211+
.map((emoji) => {
212+
return {
213+
isSmiley: true,
214+
id: emoji.code,
215+
text: emoji.html,
216+
};
217+
});
218+
}
219+
}
220+
221+
export default WoltlabSmileyMention;
222+
223+
export type WoltlabSmileyItem = {
224+
code: string;
225+
html: string;
226+
};
227+
228+
declare module "@ckeditor/ckeditor5-core" {
229+
interface EditorConfig {
230+
woltlabSmileys?: WoltlabSmileyItem[];
231+
}
232+
}
233+
234+
type EmojiMentionFeed = MentionFeed & {
235+
_isEmojiMarker?: boolean;
236+
};
237+
238+
type SmileyFeedObjectItem = MentionFeedObjectItem & {
239+
isSmiley?: boolean;
240+
};

0 commit comments

Comments
 (0)