UNPKG

@atlaskit/editor-plugin-emoji

Version:

Emoji plugin for @atlaskit/editor-core

423 lines (411 loc) 19.9 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import { bind } from 'bind-event-listener'; import isEqual from 'lodash/isEqual'; import uniqueId from 'lodash/uniqueId'; import { getDocument } from '@atlaskit/browser-apis'; import { isSSR } from '@atlaskit/editor-common/core-utils'; import { messages, EmojiSharedCssClassName, defaultEmojiHeight } from '@atlaskit/editor-common/emoji'; import { logException } from '@atlaskit/editor-common/monitoring'; import { VanillaTooltip } from '@atlaskit/editor-common/vanilla-tooltip'; import { isOfflineMode } from '@atlaskit/editor-plugin-connectivity'; import { DOMSerializer } from '@atlaskit/editor-prosemirror/model'; import { emojiIdToEmoji } from '@atlaskit/emoji/emoji-id-to-emoji'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { emojiToDom } from './emojiNodeSpec'; const SINGLE_EMOJI_REGEX = // Regular expression to match a single emoji character // @ts-ignore - TS1501 TypeScript 5.9.2 upgrade /^(\p{Emoji_Presentation}(?:[\u{1F3FB}-\u{1F3FF}])?|\p{Extended_Pictographic}\u{FE0F}(?:[\u{1F3FB}-\u{1F3FF}])?(?:\u{200D}\p{Extended_Pictographic}\u{FE0F}?(?:[\u{1F3FB}-\u{1F3FF}])?)*|\p{Extended_Pictographic}\u{FE0F}?(?:[\u{1F3FB}-\u{1F3FF}])?(?:\u{200D}\p{Extended_Pictographic}\u{FE0F}?(?:[\u{1F3FB}-\u{1F3FF}])?)+|\p{Regional_Indicator}\p{Regional_Indicator})$/u; /** * Check if we can nicely fallback to the nodes text * * @param fallbackText string of the nodes fallback text * * @example * isSingleEmoji('😀') // true */ export function isSingleEmoji(fallbackText) { return SINGLE_EMOJI_REGEX.test(fallbackText); } /** * Emoji node view for renderering emoji nodes */ const EMOJI_TOOLTIP_CLASS = 'emoji-tooltip-editor'; export class EmojiNodeView { destroyTooltip() { var _this$destroyLazyTool, _this$tooltipInstance, _this$tooltipTarget, _this$tooltipTarget2; (_this$destroyLazyTool = this.destroyLazyTooltipListeners) === null || _this$destroyLazyTool === void 0 ? void 0 : _this$destroyLazyTool.call(this); (_this$tooltipInstance = this.tooltipInstance) === null || _this$tooltipInstance === void 0 ? void 0 : _this$tooltipInstance.destroy(); (_this$tooltipTarget = this.tooltipTarget) === null || _this$tooltipTarget === void 0 ? void 0 : _this$tooltipTarget.removeAttribute('popovertarget'); (_this$tooltipTarget2 = this.tooltipTarget) === null || _this$tooltipTarget2 === void 0 ? void 0 : _this$tooltipTarget2.removeAttribute('aria-describedby'); this.destroyLazyTooltipListeners = undefined; this.tooltipInstance = undefined; this.tooltipTarget = undefined; } static logError(error) { void logException(error, { location: 'editor-plugin-emoji/EmojiNodeView' }); } /** * Prosemirror node view for rendering emoji nodes. This class is responsible for * rendering emoji nodes in the editor, handling updates, and managing fallback rendering. * * @param node - The ProseMirror node representing the emoji. * @param extraProps - An object containing additional parameters. * @param extraProps.intl - The internationalization object for formatting messages. * @param extraProps.api - The editor API for accessing shared state and connectivity features. * @param extraProps.emojiNodeDataProvider - (Optional) A provider for fetching emoji data. * * @example * const emojiNodeView = new EmojiNodeView(node, { intl, api, emojiNodeDataProvider }); */ constructor(node, { intl, api, emojiNodeDataProvider }) { _defineProperty(this, "renderingFallback", false); _defineProperty(this, "destroy", () => { if (this.tooltipInstance || this.tooltipTarget || this.destroyLazyTooltipListeners) { this.destroyTooltip(); } }); this.node = node; this.intl = intl; const { dom } = DOMSerializer.renderSpec(document, emojiToDom(this.node)); this.dom = dom; this.domElement = this.isHTMLElement(dom) ? dom : undefined; if (emojiNodeDataProvider) { let previousEmojiDescription; emojiNodeDataProvider.getData(node, payload => { if (payload.error) { EmojiNodeView.logError(payload.error); this.renderFallback(); return; } const optionalEmojiDescription = payload.data; if (!optionalEmojiDescription) { this.renderFallback(); return; } const emojiRepresentation = optionalEmojiDescription === null || optionalEmojiDescription === void 0 ? void 0 : optionalEmojiDescription.representation; if (!EmojiNodeView.isEmojiRepresentationSupported(emojiRepresentation)) { this.renderFallback(); return; } if (isEqual(previousEmojiDescription, optionalEmojiDescription)) { // Do not re-render if the emoji description is the same as before return; } previousEmojiDescription = optionalEmojiDescription; this.renderEmoji(optionalEmojiDescription, emojiRepresentation); }); } else { var _api$emoji, _sharedState$currentS, _api$connectivity; if (isSSR()) { // The provider doesn't work in SSR, and we don't want to render fallback in SSR, // that's why we don't need to continue node rendering. // In SSR we want to show a placeholder, that `emojiToDom()` returns. return; } // We use the `emojiProvider` from the shared state // because it supports the `emojiProvider` prop in the `ComposableEditor` options // as well as the `emojiProvider` in the `EmojiPlugin` options. const sharedState = api === null || api === void 0 ? void 0 : (_api$emoji = api.emoji) === null || _api$emoji === void 0 ? void 0 : _api$emoji.sharedState; if (!sharedState) { return; } let emojiProvider = (_sharedState$currentS = sharedState.currentState()) === null || _sharedState$currentS === void 0 ? void 0 : _sharedState$currentS.emojiProvider; if (emojiProvider) { void this.updateDom(emojiProvider); } const unsubscribe = sharedState.onChange(({ nextSharedState }) => { if (emojiProvider === (nextSharedState === null || nextSharedState === void 0 ? void 0 : nextSharedState.emojiProvider)) { // Do not update if the provider is the same return; } emojiProvider = nextSharedState === null || nextSharedState === void 0 ? void 0 : nextSharedState.emojiProvider; void this.updateDom(emojiProvider); }); // Refresh emojis if we go back online const subscribeToConnection = api === null || api === void 0 ? void 0 : (_api$connectivity = api.connectivity) === null || _api$connectivity === void 0 ? void 0 : _api$connectivity.sharedState.onChange(({ prevSharedState, nextSharedState }) => { if (isOfflineMode(prevSharedState === null || prevSharedState === void 0 ? void 0 : prevSharedState.mode) && (nextSharedState === null || nextSharedState === void 0 ? void 0 : nextSharedState.mode) === 'online' && this.renderingFallback && editorExperiment('platform_editor_offline_editing_web', true)) { var _sharedState$currentS2; this.updateDom((_sharedState$currentS2 = sharedState.currentState()) === null || _sharedState$currentS2 === void 0 ? void 0 : _sharedState$currentS2.emojiProvider); } }); this.destroy = () => { unsubscribe(); subscribeToConnection === null || subscribeToConnection === void 0 ? void 0 : subscribeToConnection(); this.destroyTooltip(); }; } } /** Type guard to check if a Node is an HTMLElement in a safe way. */ isHTMLElement(element) { if (element === null) { return false; } // In SSR `HTMLElement` is not defined, so we need to use duck typing here return 'innerHTML' in element && 'style' in element && 'classList' in element; } async updateDom(emojiProvider) { try { const { shortName, id, text: fallback } = this.node.attrs; const emojiDescription = await (emojiProvider === null || emojiProvider === void 0 ? void 0 : emojiProvider.fetchByEmojiId({ id, shortName, fallback }, true)); if (!emojiDescription) { EmojiNodeView.logError(new Error('Emoji description is not loaded')); this.renderFallback(); return; } const unicodeEmoji = id && expValEqualsNoExposure('platform_use_unicode_emojis', 'isEnabled', true) ? emojiIdToEmoji(id) : undefined; const emojiRepresentation = unicodeEmoji ? { unicodeEmoji } : emojiDescription === null || emojiDescription === void 0 ? void 0 : emojiDescription.representation; if (!EmojiNodeView.isEmojiRepresentationSupported(emojiRepresentation)) { EmojiNodeView.logError(new Error('Emoji representation is not supported')); this.renderFallback(); return; } this.renderEmoji(emojiDescription, emojiRepresentation); } catch (error) { EmojiNodeView.logError(error instanceof Error ? error : new Error('Unknown error on EmojiNodeView updateDom')); this.renderFallback(); } } static isEmojiRepresentationSupported(representation) { return !!representation && ('sprite' in representation || 'imagePath' in representation || 'mediaPath' in representation || 'unicodeEmoji' in representation); } static shouldRecordUnicodeEmojiExposure(description, representation) { return description.type === 'STANDARD' || 'unicodeEmoji' in representation; } // Pay attention, this method should be called only when the emoji provider returns // emoji data to prevent rendering empty emoji during loading. cleanUpAndRenderCommonAttributes() { this.destroyTooltip(); // Clean up the DOM before rendering the new emoji if (this.domElement) { this.domElement.innerHTML = ''; this.domElement.style.cssText = ''; this.domElement.classList.remove(EmojiSharedCssClassName.EMOJI_PLACEHOLDER); this.domElement.removeAttribute('aria-label'); // The label is set in the renderEmoji method this.domElement.removeAttribute('aria-busy'); } } /** * Lazily creates a VanillaTooltip on the given element showing the emoji shortName. * Gated behind the platform_editor_emoji_hover_show_tooltip experiment. * When the tooltip is active, the native `title` attribute is removed to avoid * showing both the browser tooltip and the custom tooltip. */ createTooltip(element, shortName) { if (!expValEquals('platform_editor_emoji_hover_show_tooltip', 'isEnabled', true)) { return; } if (isSSR()) { return; } const initTooltip = event => { var _this$destroyLazyTool2; (_this$destroyLazyTool2 = this.destroyLazyTooltipListeners) === null || _this$destroyLazyTool2 === void 0 ? void 0 : _this$destroyLazyTool2.call(this); this.destroyLazyTooltipListeners = undefined; const tooltipId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? `emoji-tooltip-${crypto.randomUUID()}` : uniqueId('emoji-tooltip-'); try { const tooltipInstance = new VanillaTooltip( // VanillaTooltip types expect HTMLButtonElement but works with any HTMLElement element, shortName, tooltipId, EMOJI_TOOLTIP_CLASS, // default timeout 300, // Inline styles are required because the Popover API promotes the tooltip // to the browser's top layer, where ancestor CSS selectors cannot reach it. { boxSizing: 'border-box', maxWidth: '240px', backgroundColor: "var(--ds-background-neutral-bold, #292A2E)", border: 'none', borderRadius: "var(--ds-radius-small, 3px)", color: "var(--ds-text-inverse, #FFFFFF)", font: "var(--ds-font-body-small, normal 400 12px/16px \"Atlassian Sans\", ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Ubuntu, \"Helvetica Neue\", sans-serif)", fontFamily: "var(--ds-font-family-body, \"Atlassian Sans\", ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Ubuntu, \"Helvetica Neue\", sans-serif)", insetBlockStart: "var(--ds-space-0, 0px)", insetInlineStart: "var(--ds-space-0, 0px)", overflowWrap: 'break-word', paddingBlockStart: "var(--ds-space-050, 4px)", paddingBlockEnd: "var(--ds-space-050, 4px)", paddingInlineEnd: "var(--ds-space-075, 6px)", paddingInlineStart: "var(--ds-space-075, 6px)", whiteSpace: 'normal' }); this.tooltipInstance = tooltipInstance; this.tooltipTarget = element; element.removeAttribute('title'); element.dispatchEvent(new Event(event.type)); } catch (error) { element.removeAttribute('popovertarget'); element.removeAttribute('aria-describedby'); EmojiNodeView.logError(error instanceof Error ? error : new Error(String(error))); } }; const unbindMouseEnter = bind(element, { type: 'mouseenter', listener: initTooltip, options: { once: true } }); const unbindFocus = bind(element, { type: 'focus', listener: initTooltip, options: { once: true } }); this.destroyLazyTooltipListeners = () => { unbindMouseEnter(); unbindFocus(); }; } renderFallback() { this.renderingFallback = true; this.cleanUpAndRenderCommonAttributes(); let doc = getDocument(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } const fallbackElement = doc.createElement('span'); const { text, shortName } = this.node.attrs; // When the gate is enabled and the emoji is "custom" (i.e. its fallback // text is not a single standard Unicode emoji), render the Unicode // Replacement Character (U+FFFD) instead of the shortName text. Standard // emojis continue to fall back to their Unicode text representation. const fallbackText = text || shortName; const useReplacementChar = fg('platform_editor_custom_emoji_unicode_fallback') && !isSingleEmoji(fallbackText); const renderedFallbackText = useReplacementChar ? '\uFFFD' : fallbackText; fallbackElement.innerText = renderedFallbackText; fallbackElement.setAttribute('role', 'img'); fallbackElement.setAttribute('title', shortName); fallbackElement.setAttribute('aria-label', shortName); fallbackElement.setAttribute('data-testid', `fallback-emoji-${shortName}`); fallbackElement.setAttribute('data-emoji-type', 'fallback'); this.dom.appendChild(fallbackElement); this.createTooltip(fallbackElement, shortName); } renderEmoji(description, representation) { this.renderingFallback = false; this.cleanUpAndRenderCommonAttributes(); const emojiType = 'unicodeEmoji' in representation ? 'unicode' : 'sprite' in representation ? 'sprite' : 'image'; let doc = getDocument(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } // Add wrapper for the emoji const containerElement = doc.createElement('span'); containerElement.setAttribute('role', 'img'); containerElement.setAttribute('title', description.shortName); containerElement.classList.add(EmojiSharedCssClassName.EMOJI_CONTAINER); containerElement.setAttribute('data-testid', `${emojiType}-emoji-${description.shortName}`); containerElement.setAttribute('data-emoji-type', emojiType); containerElement.setAttribute('aria-label', `${this.intl.formatMessage(messages.emojiNodeLabel)} ${description.shortName}`); const emojiElement = 'unicodeEmoji' in representation ? this.createUnicodeEmojiElement(representation.unicodeEmoji) : 'sprite' in representation ? this.createSpriteEmojiElement(representation) : this.createImageEmojiElement(description, representation); containerElement.appendChild(emojiElement); this.dom.appendChild(containerElement); if (EmojiNodeView.shouldRecordUnicodeEmojiExposure(description, representation)) { expValEquals('platform_use_unicode_emojis', 'isEnabled', true); } this.createTooltip(containerElement, description.shortName); } createUnicodeEmojiElement(emoji) { let doc = getDocument(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } const spanElement = doc.createElement('span'); spanElement.classList.add(EmojiSharedCssClassName.EMOJI_UNICODE); spanElement.textContent = emoji; spanElement.style.display = 'inline-flex'; spanElement.style.fontSize = `var(--emoji-common-unicode-size, ${defaultEmojiHeight}px)`; spanElement.style.alignItems = 'center'; spanElement.style.aspectRatio = '1/1'; spanElement.style.lineHeight = '1em'; spanElement.style.margin = '-1px 0'; spanElement.style.verticalAlign = 'middle'; // to keep vertical alignment consistent with images return spanElement; } createSpriteEmojiElement(representation) { let doc = getDocument(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } const spriteElement = doc.createElement('span'); spriteElement.classList.add(EmojiSharedCssClassName.EMOJI_SPRITE); const sprite = representation.sprite; const xPositionInPercent = 100 / (sprite.column - 1) * representation.xIndex; const yPositionInPercent = 100 / (sprite.row - 1) * representation.yIndex; spriteElement.style.backgroundImage = `url(${sprite.url})`; spriteElement.style.backgroundPosition = `${xPositionInPercent}% ${yPositionInPercent}%`; spriteElement.style.backgroundSize = `${sprite.column * 100}% ${sprite.row * 100}%`; spriteElement.style.minWidth = `${defaultEmojiHeight}px`; spriteElement.style.minHeight = `${defaultEmojiHeight}px`; if (!expValEquals('platform_editor_lovability_emoji_scaling', 'isEnabled', true)) { spriteElement.style.width = `${defaultEmojiHeight}px`; spriteElement.style.height = `${defaultEmojiHeight}px`; } return spriteElement; } createImageEmojiElement(emojiDescription, representation) { let doc = getDocument(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } const imageElement = doc.createElement('img'); imageElement.classList.add(EmojiSharedCssClassName.EMOJI_IMAGE); imageElement.src = 'imagePath' in representation ? representation.imagePath : representation.mediaPath; imageElement.loading = 'lazy'; imageElement.alt = emojiDescription.name || emojiDescription.shortName; imageElement.style.minWidth = `${defaultEmojiHeight}px`; imageElement.style.objectFit = 'contain'; imageElement.height = defaultEmojiHeight; imageElement.onerror = () => { if (editorExperiment('platform_editor_offline_editing_web', true)) { // If there's an error (ie. offline) render the ascii fallback if possible, otherwise // mark the node to refresh when returning online. // Create a check that confirms if this.node.attrs.text if an ascii emoji if (isSingleEmoji(this.node.attrs.text)) { this.renderFallback(); } else { this.renderingFallback = true; } } else { this.renderFallback(); } }; return imageElement; } }