diff --git a/app.ts b/app.ts index 8f820ed..f4e7356 100644 --- a/app.ts +++ b/app.ts @@ -31,6 +31,7 @@ import { RemoveFormat, Table, Undo, + Emoji, WoltlabAttachment, WoltlabAutoLink, WoltlabAutosave, @@ -39,7 +40,6 @@ import { WoltlabCode, WoltlabCodeBlock, WoltlabFontSize, - WoltlabEmoji, WoltlabHtmlEmbed, WoltlabImage, WoltlabMagicParagraph, @@ -62,6 +62,7 @@ const defaultConfig: Core.EditorConfig = { Paragraph.Paragraph, PasteFromOffice.PasteFromOffice, Undo.Undo, + Emoji.Emoji, // Formatting Alignment.Alignment, @@ -118,7 +119,6 @@ const defaultConfig: Core.EditorConfig = { WoltlabToolbarGroup.WoltlabToolbarGroup, WoltlabUpload.WoltlabUpload, WoltlabFontSize.WoltlabFontSize, - WoltlabEmoji.WoltlabEmoji, ], }; diff --git a/modules.ts b/modules.ts index e32b255..d7ae370 100644 --- a/modules.ts +++ b/modules.ts @@ -33,6 +33,7 @@ export * as Undo from "@ckeditor/ckeditor5-undo"; export * as Upload from "@ckeditor/ckeditor5-upload"; export * as Utils from "@ckeditor/ckeditor5-utils"; export * as Widget from "@ckeditor/ckeditor5-widget"; +export * as Emoji from "@ckeditor/ckeditor5-emoji"; export * as WoltlabAttachment from "./plugins/ckeditor5-woltlab-attachment/src"; export * as WoltlabAutoLink from "./plugins/ckeditor5-woltlab-autolink/src"; @@ -41,7 +42,6 @@ export * as WoltlabBbcode from "./plugins/ckeditor5-woltlab-bbcode/src"; export * as WoltlabBlockQuote from "./plugins/ckeditor5-woltlab-block-quote/src"; export * as WoltlabCode from "./plugins/ckeditor5-woltlab-code/src"; export * as WoltlabCodeBlock from "./plugins/ckeditor5-woltlab-code-block/src"; -export * as WoltlabEmoji from "./plugins/ckeditor5-woltlab-emoji/src"; export * as WoltlabHtmlEmbed from "./plugins/ckeditor5-woltlab-html-embed/src"; export * as WoltlabImage from "./plugins/ckeditor5-woltlab-image/src"; export * as WoltlabMagicParagraph from "./plugins/ckeditor5-woltlab-magic-paragraph/src"; diff --git a/package-lock.json b/package-lock.json index ad78a49..993180d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@ckeditor/ckeditor5-block-quote": "^45.0.0", "@ckeditor/ckeditor5-code-block": "^45.0.0", "@ckeditor/ckeditor5-editor-classic": "^45.0.0", + "@ckeditor/ckeditor5-emoji": "^45.0.0", "@ckeditor/ckeditor5-engine": "^45.0.0", "@ckeditor/ckeditor5-essentials": "^45.0.0", "@ckeditor/ckeditor5-font": "^45.0.0", @@ -32,8 +33,7 @@ "@ckeditor/ckeditor5-ui": "^45.0.0", "@ckeditor/ckeditor5-undo": "^45.0.0", "@ckeditor/ckeditor5-utils": "^45.0.0", - "@ckeditor/ckeditor5-widget": "^45.0.0", - "emoji-picker-element": "^1.25.0" + "@ckeditor/ckeditor5-widget": "^45.0.0" }, "devDependencies": { "@ckeditor/ckeditor5-dev-translations": "^43.0.1", @@ -3687,12 +3687,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-picker-element": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.25.0.tgz", - "integrity": "sha512-UcUMxqIuneLCsEJ5KpqTD1xaHZyUpg6Oa7uCVe5AMXXpsW3C2TNegbNLXj2/rlbyr6qVMf7lXTFyzvFEarOIUg==", - "license": "Apache-2.0" - }, "node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", diff --git a/package.json b/package.json index 38c3b3b..c3b52c9 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@ckeditor/ckeditor5-undo": "^45.0.0", "@ckeditor/ckeditor5-utils": "^45.0.0", "@ckeditor/ckeditor5-widget": "^45.0.0", - "emoji-picker-element": "^1.25.0" + "@ckeditor/ckeditor5-emoji": "^45.0.0" }, "devDependencies": { "@ckeditor/ckeditor5-dev-translations": "^43.0.1", @@ -50,4 +50,4 @@ "webpack": "^5.98.0", "webpack-cli": "^6.0.1" } -} \ No newline at end of file +} diff --git a/plugins/ckeditor5-woltlab-emoji/package.json b/plugins/ckeditor5-woltlab-emoji/package.json deleted file mode 100644 index 4bc0cf8..0000000 --- a/plugins/ckeditor5-woltlab-emoji/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "author": "WoltLab GmbH", - "license": "LGPL-2.1-or-later", - "main": "src/index.ts", - "private": true -} diff --git a/plugins/ckeditor5-woltlab-emoji/src/index.ts b/plugins/ckeditor5-woltlab-emoji/src/index.ts deleted file mode 100644 index 72e1fd0..0000000 --- a/plugins/ckeditor5-woltlab-emoji/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @author Olaf Braun - * @copyright 2001-2024 WoltLab GmbH - * @license LGPL-2.1-or-later - * @since 6.2 - */ - -export { WoltlabEmoji } from "./woltlabemoji"; diff --git a/plugins/ckeditor5-woltlab-emoji/src/ui/woltlabcoreemojipickerview.ts b/plugins/ckeditor5-woltlab-emoji/src/ui/woltlabcoreemojipickerview.ts deleted file mode 100644 index a1dcfe8..0000000 --- a/plugins/ckeditor5-woltlab-emoji/src/ui/woltlabcoreemojipickerview.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @author Olaf Braun - * @copyright 2001-2024 WoltLab GmbH - * @license LGPL-2.1-or-later - * @since 6.2 - */ - -import { View, FocusableView } from "@ckeditor/ckeditor5-ui"; -import { Locale } from "@ckeditor/ckeditor5-utils"; - -import "../../theme/woltlabemoji.css"; -import { Picker } from "emoji-picker-element"; - -export class WoltlabCoreEmojiPickerView - extends View - implements FocusableView -{ - constructor(locale: Locale) { - super(locale); - - const bind = this.bindTemplate; - - this.setTemplate({ - tag: "woltlab-core-emoji-picker", - attributes: { - class: ["ck", "ck-woltlab-core-emoji-picker"], - }, - on: { - "emoji-click": bind.to("emoji-click"), - }, - }); - } - - focus(): void { - this.element?.focus(); - } -} - -export default WoltlabCoreEmojiPickerView; diff --git a/plugins/ckeditor5-woltlab-emoji/src/woltlabemoji.ts b/plugins/ckeditor5-woltlab-emoji/src/woltlabemoji.ts deleted file mode 100644 index 7817ac6..0000000 --- a/plugins/ckeditor5-woltlab-emoji/src/woltlabemoji.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @author Olaf Braun - * @copyright 2001-2024 WoltLab GmbH - * @license LGPL-2.1-or-later - * @since 6.2 - */ -import { Plugin } from "@ckeditor/ckeditor5-core"; -import { Database } from "emoji-picker-element"; -import { Typing } from "@ckeditor/ckeditor5-typing"; -import { createDropdown } from "@ckeditor/ckeditor5-ui"; - -import emojiIcon from "../theme/icons/smile.svg"; -import WoltlabCoreEmojiPickerView from "./ui/woltlabcoreemojipickerview"; -import { EventInfo } from "@ckeditor/ckeditor5-utils"; -import { EmojiClickEvent } from "emoji-picker-element/shared"; - -export class WoltlabEmoji extends Plugin { - public static get pluginName() { - return "WoltlabEmoji" as const; - } - - public static get requires() { - return [Typing] as const; - } - - public init(): void { - const editor = this.editor; - - const inputCommand = editor.commands.get("input")!; - - editor.ui.componentFactory.add("WoltlabEmoji", (locale) => { - const dropdownView = createDropdown(locale); - dropdownView.buttonView.set({ - label: editor.t("Emoji"), - icon: emojiIcon, - tooltip: true, - }); - dropdownView.bind("isEnabled").to(inputCommand); - - const emojiPickerView = new WoltlabCoreEmojiPickerView(locale); - this.listenTo( - emojiPickerView, - "emoji-click", - this.#emojiClicked.bind(this), - ); - - if (!emojiPickerView.isRendered) { - emojiPickerView.render(); - } - - dropdownView.panelView.children.add(emojiPickerView); - - return dropdownView; - }); - } - - #emojiClicked(evt: EventInfo, emojiClickData: EmojiClickEvent) { - const editor = this.editor; - - if ("unicode" in emojiClickData.detail) { - editor.execute("input", { text: emojiClickData.detail.unicode }); - } - - editor.editing.view.focus(); - } -} - -export type WoltlabEmojiConfig = { - database: Database; -}; - -declare module "@ckeditor/ckeditor5-core" { - interface EditorConfig { - woltlabEmojis?: WoltlabEmojiConfig; - } -} diff --git a/plugins/ckeditor5-woltlab-emoji/theme/icons/smile.svg b/plugins/ckeditor5-woltlab-emoji/theme/icons/smile.svg deleted file mode 100644 index 94a8f00..0000000 --- a/plugins/ckeditor5-woltlab-emoji/theme/icons/smile.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/plugins/ckeditor5-woltlab-emoji/theme/woltlabemoji.css b/plugins/ckeditor5-woltlab-emoji/theme/woltlabemoji.css deleted file mode 100644 index feb1138..0000000 --- a/plugins/ckeditor5-woltlab-emoji/theme/woltlabemoji.css +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @author Olaf Braun - * @copyright 2001-2024 WoltLab GmbH - * @license LGPL-2.1-or-later - * @since 6.2 - */ - -.ck.ck-woltlab-core-emoji-picker { - height: 400px; -} diff --git a/plugins/ckeditor5-woltlab-smiley/src/index.ts b/plugins/ckeditor5-woltlab-smiley/src/index.ts index ac22e6d..0e69047 100644 --- a/plugins/ckeditor5-woltlab-smiley/src/index.ts +++ b/plugins/ckeditor5-woltlab-smiley/src/index.ts @@ -6,5 +6,8 @@ */ export { WoltlabSmiley } from "./woltlabsmiley"; -export { WoltlabSmileyItem } from "./woltlabsmileyui"; +export { + WoltlabSmileyItem, + WoltlabSmileyMention, +} from "./woltlabsmileymention"; export { default as WoltlabSmileyCommand } from "./woltlabsmileycommand"; diff --git a/plugins/ckeditor5-woltlab-smiley/src/woltlabsmiley.ts b/plugins/ckeditor5-woltlab-smiley/src/woltlabsmiley.ts index 7750757..abfb794 100644 --- a/plugins/ckeditor5-woltlab-smiley/src/woltlabsmiley.ts +++ b/plugins/ckeditor5-woltlab-smiley/src/woltlabsmiley.ts @@ -10,10 +10,10 @@ import { Plugin } from "@ckeditor/ckeditor5-core"; import type { DowncastInsertEvent } from "@ckeditor/ckeditor5-engine"; import { Image } from "@ckeditor/ckeditor5-image"; -import { WoltlabSmileyUi } from "./woltlabsmileyui"; import "../theme/woltlabsmiley.css"; import { toWidget } from "@ckeditor/ckeditor5-widget"; +import WoltlabSmileyMention from "./woltlabsmileymention"; export class WoltlabSmiley extends Plugin { static get pluginName() { @@ -21,7 +21,7 @@ export class WoltlabSmiley extends Plugin { } static get requires() { - return [Image, WoltlabSmileyUi] as const; + return [Image, WoltlabSmileyMention] as const; } init() { diff --git a/plugins/ckeditor5-woltlab-smiley/src/woltlabsmileymention.ts b/plugins/ckeditor5-woltlab-smiley/src/woltlabsmileymention.ts new file mode 100644 index 0000000..b4b4fda --- /dev/null +++ b/plugins/ckeditor5-woltlab-smiley/src/woltlabsmileymention.ts @@ -0,0 +1,240 @@ +/** + * Autocomplete for emojis and smileys. + * Overrides the default emoji mention feed to include smileys. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license LGPL-2.1-or-later + * @since 6.2 + * + * @see https://raw.githubusercontent.com/ckeditor/ckeditor5/refs/tags/v45.0.0/packages/ckeditor5-emoji/src/emojimention.ts + */ + +import { Plugin, Editor } from "@ckeditor/ckeditor5-core"; +import { + EmojiMention, + EmojiRepository, + EmojiPicker, +} from "@ckeditor/ckeditor5-emoji"; +import { + MentionFeed, + MentionFeedObjectItem, + ItemRenderer, +} from "@ckeditor/ckeditor5-mention"; +import { SkinToneId } from "@ckeditor/ckeditor5-emoji/src/emojiconfig"; +import { LocaleTranslate } from "@ckeditor/ckeditor5-utils"; +import { WoltlabSmileyCommand } from "./index"; + +const EMOJI_MENTION_MARKER = ":"; +const EMOJI_SHOW_ALL_OPTION_ID = ":__EMOJI_SHOW_ALL:"; +const EMOJI_HINT_OPTION_ID = ":__EMOJI_HINT:"; + +export class WoltlabSmileyMention extends Plugin { + declare public emojiPickerPlugin: EmojiPicker | null; + declare public emojiRepositoryPlugin: EmojiRepository; + declare private _isEmojiRepositoryAvailable: boolean; + declare private _emojiDropdownLimit: number; + private readonly _skinTone: SkinToneId; + + constructor(editor: Editor) { + super(editor); + + this._skinTone = editor.config.get("emoji.skinTone")!; + + this.#overrideMentionFeedConfig(); + } + + static get pluginName() { + return "WoltlabSmileyMention"; + } + + static get requires() { + return [EmojiRepository, "Mention", EmojiMention] as const; + } + + public async init(): Promise { + this.editor.commands.add("smiley", new WoltlabSmileyCommand(this.editor)); + this.emojiPickerPlugin = this.editor.plugins.has("EmojiPicker") + ? this.editor.plugins.get("EmojiPicker") + : null; + this.emojiRepositoryPlugin = this.editor.plugins.get("EmojiRepository"); + this._isEmojiRepositoryAvailable = + await this.emojiRepositoryPlugin.isReady(); + + if (this._isEmojiRepositoryAvailable) { + this.editor.once("ready", () => this.#registerMentionCommand()); + } + } + + #overrideMentionFeedConfig(): void { + const mentionFeedsConfigs = this.editor.config.get( + "mention.feeds", + )! as Array; + + if (!mentionFeedsConfigs.some((config) => config._isEmojiMarker)) { + return; + } + + const config = mentionFeedsConfigs.find((config) => config._isEmojiMarker)!; + this._emojiDropdownLimit = config.dropdownLimit!; + config.feed = (searchString) => this.#queryEmojiAndSmileys(searchString); + config.itemRenderer = this._customItemRendererFactory(this.editor.t); + + this.editor.config.set("mention.feeds", mentionFeedsConfigs); + } + + private _customItemRendererFactory(t: LocaleTranslate): ItemRenderer { + return (item: SmileyFeedObjectItem) => { + const itemElement = document.createElement("button"); + + itemElement.classList.add("ck"); + itemElement.classList.add("ck-button"); + itemElement.classList.add("ck-button_with-text"); + + itemElement.id = `mention-list-item-id${item.id.slice(0, -1)}`; + itemElement.type = "button"; + itemElement.tabIndex = -1; + + const labelElement = document.createElement("span"); + + labelElement.classList.add("ck"); + labelElement.classList.add("ck-button__label"); + + itemElement.appendChild(labelElement); + + if (item.id === EMOJI_HINT_OPTION_ID) { + itemElement.classList.add("ck-list-item-button"); + itemElement.classList.add("ck-disabled"); + labelElement.textContent = t("Keep on typing to see the emoji."); + } else if (item.id === EMOJI_SHOW_ALL_OPTION_ID) { + labelElement.textContent = t("Show all emoji..."); + } else { + if (item.isSmiley) { + labelElement.classList.add("ck-smiley"); + labelElement.innerHTML = `${item.text} ${item.id}`; + } else { + labelElement.classList.add("ck-emoji"); + labelElement.textContent = `${item.text} ${item.id}`; + } + } + + return itemElement; + }; + } + + #registerMentionCommand(): void { + this.editor.commands.get("mention")!.on( + "execute", + (event, data) => { + const eventData = data[0]; + + if (eventData.marker !== EMOJI_MENTION_MARKER) { + return; + } + + if (!eventData.mention.isSmiley) { + return; + } + + event.stop(); + + this.editor.execute("smiley", { + smiley: eventData.mention.id, + html: eventData.mention.text, + range: eventData.range, + }); + }, + { priority: "highest" }, + ); + } + + #queryEmojiAndSmileys(searchQuery: string): Array { + // Do not show anything when a query starts with a space. + if (searchQuery.startsWith(" ")) { + return []; + } + + // Do not show anything when a query starts with a marker character. + if (searchQuery.startsWith(EMOJI_MENTION_MARKER)) { + return []; + } + + const result = [ + ...this.#filterEmojis(searchQuery), + ...this.#filterSmileys(searchQuery), + ]; + + if (!this.emojiPickerPlugin) { + return result.slice(0, this._emojiDropdownLimit); + } + + const actionItem: SmileyFeedObjectItem = { + id: + searchQuery.length > 1 + ? EMOJI_SHOW_ALL_OPTION_ID + : EMOJI_HINT_OPTION_ID, + }; + + return [...result.slice(0, this._emojiDropdownLimit - 1), actionItem]; + } + + #filterEmojis(searchQuery: string): Array { + if (!this._isEmojiRepositoryAvailable) { + return []; + } + + return 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; + } + + return { + id: `:${emoji.annotation}:`, + text, + }; + }); + } + + #filterSmileys(searchQuery: string): Array { + const woltlabSmileys = this.editor.config.get("woltlabSmileys") || []; + + return woltlabSmileys + .filter((emoji) => { + const code = emoji.code.substring(1, emoji.code.length - 1); + return code.startsWith(searchQuery); + }) + .map((emoji) => { + return { + isSmiley: true, + id: emoji.code, + text: emoji.html, + }; + }); + } +} + +export default WoltlabSmileyMention; + +export type WoltlabSmileyItem = { + code: string; + html: string; +}; + +declare module "@ckeditor/ckeditor5-core" { + interface EditorConfig { + woltlabSmileys?: WoltlabSmileyItem[]; + } +} + +type EmojiMentionFeed = MentionFeed & { + _isEmojiMarker?: boolean; +}; + +type SmileyFeedObjectItem = MentionFeedObjectItem & { + isSmiley?: boolean; +}; diff --git a/plugins/ckeditor5-woltlab-smiley/src/woltlabsmileyui.ts b/plugins/ckeditor5-woltlab-smiley/src/woltlabsmileyui.ts deleted file mode 100644 index d351e68..0000000 --- a/plugins/ckeditor5-woltlab-smiley/src/woltlabsmileyui.ts +++ /dev/null @@ -1,483 +0,0 @@ -/** - * @author Olaf Braun - * @copyright 2001-2024 WoltLab GmbH - * @license LGPL-2.1-or-later - * @since 6.1 - */ - -import { Editor, Plugin } from "@ckeditor/ckeditor5-core"; -import { clickOutsideHandler, ContextualBalloon } from "@ckeditor/ckeditor5-ui"; -import { - TextWatcher, - TextWatcherMatchedEvent, -} from "@ckeditor/ckeditor5-typing"; -import { - Collection, - env, - keyCodes, - PositionOptions, - Rect, -} from "@ckeditor/ckeditor5-utils"; -import { Marker, ViewDocumentKeyDownEvent } from "@ckeditor/ckeditor5-engine"; -import { - DomWrapperView, - MentionFeedObjectItem, - MentionListItemView, - MentionsView, -} from "@ckeditor/ckeditor5-mention"; -import WoltlabSmileyCommand from "./woltlabsmileycommand"; - -const MARKER_NAME = "smiley"; -const VERTICAL_SPACING = 3; - -// Dropdown commit key codes. -const CommitKeyCodes = [keyCodes.enter, keyCodes.tab]; -const HandledKeyCodes = [ - keyCodes.arrowup, - keyCodes.arrowdown, - keyCodes.esc, - ...CommitKeyCodes, -]; - -export class WoltlabSmileyUi extends Plugin { - #balloon: ContextualBalloon | undefined; - readonly #smileyView: MentionsView; - #items = new Collection<{ - item: MentionFeedObjectItem; - marker: string; - }>(); - /** - * @inheritDoc - */ - constructor(editor: Editor) { - super(editor); - this.#smileyView = this.#createSmileyView(); - } - - /** - * @inheritDoc - */ - public static get pluginName() { - return "WoltlabSmileyUI" as const; - } - - /** - * @inheritDoc - */ - public static get requires() { - return [ContextualBalloon, "WoltlabEmoji"] as const; - } - - get #isUIVisible(): boolean { - return this.#balloon!.visibleView === this.#smileyView; - } - - /** - * @inheritDoc - */ - public init(): void { - this.#balloon = this.editor.plugins.get(ContextualBalloon); - this.editor.commands.add("smiley", new WoltlabSmileyCommand(this.editor)); - - this.editor.editing.view.document.on( - "keydown", - (evt, data) => { - if (isHandledKey(data.keyCode) && this.#isUIVisible) { - data.preventDefault(); - evt.stop(); // Required for Enter key overriding. - - if (data.keyCode == keyCodes.arrowdown) { - this.#smileyView.selectNext(); - } - - if (data.keyCode == keyCodes.arrowup) { - this.#smileyView.selectPrevious(); - } - - if (CommitKeyCodes.includes(data.keyCode)) { - this.#smileyView.executeSelected(); - } - - if (data.keyCode == keyCodes.esc) { - this.#hideBalloon(); - } - } - }, - { priority: "highest" }, - ); - - this.#registerTextWatcher(); - - clickOutsideHandler({ - emitter: this.#smileyView, - activator: () => this.#isUIVisible, - contextElements: () => [this.#balloon!.view.element!], - callback: () => this.#hideBalloon(), - }); - this.listenTo(this.editor, "change:isReadOnly", () => { - this.#hideBalloon(); - }); - } - - /** - * @inheritDoc - */ - public override destroy(): void { - super.destroy(); - - this.#smileyView?.destroy(); - } - - /** - * {@link module:mention/mentionui#_createMentionView() - */ - #createSmileyView(): MentionsView { - const mentionsView = new MentionsView(this.editor.locale); - mentionsView.items.bindTo(this.#items).using((data) => { - const { item, marker } = data; - - if (mentionsView.items.length >= 10) { - return null; - } - - const listItemView = new MentionListItemView(this.editor.locale); - - const view = this.#renderItem(item); - view.delegate("execute").to(listItemView); - - listItemView.children.add(view); - listItemView.item = item; - listItemView.marker = marker; - - listItemView.on("execute", () => { - mentionsView.fire("execute", { - item, - marker, - }); - }); - - return listItemView; - }); - - mentionsView.on("execute", (evt, data) => { - const editor = this.editor; - const model = editor.model; - const item = data.item; - - const smileyMarker = editor.model.markers.get(MARKER_NAME); - - // Create a range on matched text. - const end = model.createPositionAt(model.document.selection.focus!); - const start = model.createPositionAt(smileyMarker!.getStart()); - const range = model.createRange(start, end); - - this.#hideBalloon(); - - editor.execute("smiley", { - smiley: item, - html: item.text, - range, - }); - - editor.editing.view.focus(); - }); - - return mentionsView; - } - - #renderItem(item: MentionFeedObjectItem): DomWrapperView { - const editor = this.editor; - const span = document.createElement("span"); - span.classList.add("ckeditor5__mention", "ckeditor5__smiley"); - span.innerHTML = `${item.text} ${item.id}`; - - return new DomWrapperView(editor.locale, span); - } - - #registerTextWatcher() { - const editor = this.editor; - const watcher = new TextWatcher(editor.model, (text: string) => { - return getLastPosition(text) !== undefined; - }); - watcher.on("matched", (evt, data) => { - const position = getLastPosition(data.text)!; - const smileyCode = data.text.substring(position).match(getRegexExp())![0]; - const start = data.range.start.getShiftedBy(position); - const markerRange = editor.model.createRange( - start, - start.getShiftedBy(1), - ); - - if (checkIfMarkerExists(editor)) { - // Update marker position - const mentionMarker = editor.model.markers.get(MARKER_NAME)!; - editor.model.change((writer) => { - writer.updateMarker(mentionMarker, { range: markerRange }); - }); - } else { - // Create new marker - editor.model.change((writer) => { - writer.addMarker(MARKER_NAME, { - range: markerRange, - usingOperation: false, - affectsData: false, - }); - }); - } - - this.#items.clear(); - const woltlabSmileys = editor.config.get("woltlabSmileys") || []; - const emojisDatabase = editor.config.get("woltlabEmojis")!.database; - - woltlabSmileys - .filter((emoji) => { - return emoji.code.startsWith(smileyCode); - }) - .forEach((emoji) => { - this.#items.add({ - item: { - id: emoji.code, - text: emoji.html, - }, - marker: MARKER_NAME, - }); - }); - - emojisDatabase - .getEmojiBySearchQuery(smileyCode) - .then((emojis) => { - emojis.forEach((emoji) => { - if (!("unicode" in emoji)) { - return; - } - - this.#items.add({ - item: { - id: emoji.annotation, - text: emoji.unicode, - }, - marker: MARKER_NAME, - }); - }); - }) - .finally(() => { - if (this.#items.length) { - this.#showBalloon(); - } else { - this.#hideBalloon(); - } - }); - }); - watcher.on("unmatched", () => { - this.#hideBalloon(); - }); - const command = editor.commands.get("smiley")!; - watcher.bind("isEnabled").to(command); - } - - #hideBalloon() { - if (this.#balloon!.hasView(this.#smileyView)) { - this.#balloon!.remove(this.#smileyView); - } - - if (checkIfMarkerExists(this.editor)) { - this.editor.model.change((writer) => writer.removeMarker(MARKER_NAME)); - } - } - - /** - * {@link module:mention/mentionui#_showOrUpdateUI()} - */ - #showBalloon() { - const marker = this.editor.model.markers.get(MARKER_NAME); - if (!marker) { - this.#hideBalloon(); - return; - } - - if (this.#isUIVisible) { - this.#balloon!.updatePosition( - this._getBalloonPanelPositionData(marker, this.#smileyView.position), - ); - } else { - this.#balloon!.add({ - view: this.#smileyView, - position: this._getBalloonPanelPositionData( - marker, - this.#smileyView.position, - ), - singleViewMode: true, - }); - } - - this.#smileyView.position = this.#balloon!.view.position; - this.#smileyView.selectFirst(); - } - - /** - * {@link module:mention/mentionui#_getBalloonPanelPositionData()} - */ - private _getBalloonPanelPositionData( - mentionMarker: Marker, - preferredPosition: MentionsView["position"], - ): Partial { - const editor = this.editor; - const editing = editor.editing; - const domConverter = editing.view.domConverter; - const mapper = editing.mapper; - const uiLanguageDirection = editor.locale.uiLanguageDirection; - - return { - target: () => { - let modelRange = mentionMarker.getRange(); - - // Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway. - // The logic is used by ContextualBalloon to display another panel in the same place. - if (modelRange.start.root.rootName == "$graveyard") { - modelRange = editor.model.document.selection.getFirstRange()!; - } - - const viewRange = mapper.toViewRange(modelRange); - const rangeRects = Rect.getDomRangeRects( - domConverter.viewRangeToDom(viewRange), - ); - - return rangeRects.pop()!; - }, - limiter: () => { - const view = this.editor.editing.view; - const viewDocument = view.document; - const editableElement = viewDocument.selection.editableElement; - - if (editableElement) { - return view.domConverter.mapViewToDom( - editableElement.root, - ) as HTMLElement; - } - - return null; - }, - positions: getBalloonPanelPositions( - preferredPosition, - uiLanguageDirection, - ), - }; - } -} - -function getLastPosition(text: string): number | undefined { - const lastIndex = text.lastIndexOf(":"); - if (lastIndex === -1 || !text.substring(lastIndex - 1).match(getRegexExp())) { - return undefined; - } - - return lastIndex; -} - -function checkIfMarkerExists(editor: Editor): boolean { - return editor.model.markers.has(MARKER_NAME); -} - -/** - * {@link module:mention/mentionui#getBalloonPanelPositions()} - */ -function getBalloonPanelPositions( - preferredPosition: string | undefined, - uiLanguageDirection: string, -): PositionOptions["positions"] { - const positions: Record = { - // Positions the panel to the southeast of the caret rectangle. - caret_se: (targetRect: Rect) => { - return { - top: targetRect.bottom + VERTICAL_SPACING, - left: targetRect.right, - name: "caret_se", - config: { - withArrow: false, - }, - }; - }, - - // Positions the panel to the northeast of the caret rectangle. - caret_ne: (targetRect: Rect, balloonRect: Rect) => { - return { - top: targetRect.top - balloonRect.height - VERTICAL_SPACING, - left: targetRect.right, - name: "caret_ne", - config: { - withArrow: false, - }, - }; - }, - - // Positions the panel to the southwest of the caret rectangle. - caret_sw: (targetRect: Rect, balloonRect: Rect) => { - return { - top: targetRect.bottom + VERTICAL_SPACING, - left: targetRect.right - balloonRect.width, - name: "caret_sw", - config: { - withArrow: false, - }, - }; - }, - - // Positions the panel to the northwest of the caret rect. - caret_nw: (targetRect: Rect, balloonRect: Rect) => { - return { - top: targetRect.top - balloonRect.height - VERTICAL_SPACING, - left: targetRect.right - balloonRect.width, - name: "caret_nw", - config: { - withArrow: false, - }, - }; - }, - }; - - // Returns only the last position if it was matched to prevent the panel from jumping after the first match. - if (Object.prototype.hasOwnProperty.call(positions, preferredPosition!)) { - return [positions[preferredPosition!]]; - } - - // By default, return all position callbacks ordered depending on the UI language direction. - return uiLanguageDirection !== "rtl" - ? [ - positions.caret_se, - positions.caret_sw, - positions.caret_ne, - positions.caret_nw, - ] - : [ - positions.caret_sw, - positions.caret_se, - positions.caret_nw, - positions.caret_ne, - ]; -} - -/** - * {@link module:mention/mentionui#createRegExp()} - */ -export function getRegexExp(): RegExp { - const openAfterCharacters = env.features.isRegExpUnicodePropertySupported - ? "\\p{Ps}\\p{Pi}\"'" - : "\\(\\[{\"'"; - const pattern = `(?:^|[ ${openAfterCharacters}])(:)([a-z][a-z0-9]*(?:_[a-z0-9]+)*)$`; - return new RegExp(pattern, "u"); -} - -function isHandledKey(keyCode: number): boolean { - return HandledKeyCodes.includes(keyCode); -} - -export type WoltlabSmileyItem = { - code: string; - html: string; -}; - -declare module "@ckeditor/ckeditor5-core" { - interface EditorConfig { - woltlabSmileys?: WoltlabSmileyItem[]; - } -}