@atlaskit/editor-plugin-emoji
Version:
Emoji plugin for @atlaskit/editor-core
259 lines (251 loc) • 12 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import isEqual from 'lodash/isEqual';
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 { isOfflineMode } from '@atlaskit/editor-plugin-connectivity';
import { DOMSerializer } from '@atlaskit/editor-prosemirror/model';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { emojiToDom } from './emojiNodeSpec';
/**
* 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) {
// Regular expression to match a single emoji character
const emojiRegex =
// @ts-ignore - TS1501 TypeScript 5.9.2 upgrade
/^(\p{Emoji_Presentation}|\p{Extended_Pictographic}\u{FE0F}?(?:\u{200D}\p{Extended_Pictographic}\u{FE0F}?)+|\p{Regional_Indicator}\p{Regional_Indicator})$/u;
return emojiRegex.test(fallbackText);
}
/**
* Emoji node view for renderering emoji nodes
*/
export class EmojiNodeView {
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", () => {});
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();
};
}
}
/** 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 emojiRepresentation = 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);
}
// Pay attention, this method should be called only when the emoji provider returns
// emoji data to prevent rendering empty emoji during loading.
cleanUpAndRenderCommonAttributes() {
// 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');
}
}
renderFallback() {
this.renderingFallback = true;
this.cleanUpAndRenderCommonAttributes();
const fallbackElement = document.createElement('span');
fallbackElement.innerText = this.node.attrs.text || this.node.attrs.shortName;
fallbackElement.setAttribute('data-testid', `fallback-emoji-${this.node.attrs.shortName}`);
fallbackElement.setAttribute('data-emoji-type', 'fallback');
this.dom.appendChild(fallbackElement);
}
renderEmoji(description, representation) {
this.renderingFallback = false;
this.cleanUpAndRenderCommonAttributes();
const emojiType = 'sprite' in representation ? 'sprite' : 'image';
// Add wrapper for the emoji
const containerElement = document.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 = 'sprite' in representation ? this.createSpriteEmojiElement(representation) : this.createImageEmojiElement(description, representation);
containerElement.appendChild(emojiElement);
this.dom.appendChild(containerElement);
}
createSpriteEmojiElement(representation) {
const spriteElement = document.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) {
const imageElement = document.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;
}
}