UNPKG

@atlaskit/editor-plugin-emoji

Version:

Emoji plugin for @atlaskit/editor-core

452 lines (439 loc) • 22.6 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.EmojiNodeView = void 0; var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator")); var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator")); var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck")); var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass")); var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _bindEventListener = require("bind-event-listener"); var _isEqual = _interopRequireDefault(require("lodash/isEqual")); var _uniqueId = _interopRequireDefault(require("lodash/uniqueId")); var _browserApis = require("@atlaskit/browser-apis"); var _isOfflineMode = require("@atlaskit/editor-common/connectivity/isOfflineMode"); var _messages = require("@atlaskit/editor-common/emoji/messages"); var _isSsr = require("@atlaskit/editor-common/is-ssr"); var _error = require("@atlaskit/editor-common/monitoring/error"); var _emoji = require("@atlaskit/editor-common/styles/emoji"); var _isSingleEmoji = require("@atlaskit/editor-common/utils/isSingleEmoji"); var _vanillaTooltip = require("@atlaskit/editor-common/vanilla-tooltip"); var _model = require("@atlaskit/editor-prosemirror/model"); var _constants = require("@atlaskit/emoji/constants"); var _isExperimentEnabled = require("@atlaskit/platform-feature-experiments/is-experiment-enabled"); var _fg = require("@atlaskit/platform-feature-flags/fg"); var _expValEquals = require("@atlaskit/tmp-editor-statsig/exp-val-equals"); var _emojiNodeSpec = require("./emojiNodeSpec"); /** * Emoji node view for renderering emoji nodes */ var EMOJI_TOOLTIP_CLASS = 'emoji-tooltip-editor'; /** * Shared default look plus our own hook, replacing the inline styles the control arm passes. */ var EMOJI_TOOLTIP_CLASS_NAMES = "".concat(_vanillaTooltip.VANILLA_TOOLTIP_DEFAULT_CLASS, " ").concat(EMOJI_TOOLTIP_CLASS); /** * Control arm only — `VanillaTooltip` generates its own id when none is supplied. * `crypto.randomUUID()` is undefined outside a secure context, hence the `uniqueId` fallback. */ var nextFallbackTooltipId = function nextFallbackTooltipId() { return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? "emoji-tooltip-".concat(crypto.randomUUID()) : (0, _uniqueId.default)('emoji-tooltip-'); }; var EmojiNodeView = exports.EmojiNodeView = /*#__PURE__*/function () { /** * 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 }); */ function EmojiNodeView(node, _ref) { var _this = this; var intl = _ref.intl, api = _ref.api, emojiNodeDataProvider = _ref.emojiNodeDataProvider; (0, _classCallCheck2.default)(this, EmojiNodeView); (0, _defineProperty2.default)(this, "renderingFallback", false); (0, _defineProperty2.default)(this, "destroy", function () { if (_this.tooltipInstance || _this.tooltipTarget || _this.destroyLazyTooltipListeners) { _this.destroyTooltip(); } }); this.node = node; this.intl = intl; var _DOMSerializer$render = _model.DOMSerializer.renderSpec(document, (0, _emojiNodeSpec.emojiToDom)(this.node)), dom = _DOMSerializer$render.dom; this.dom = dom; this.domElement = this.isHTMLElement(dom) ? dom : undefined; if (emojiNodeDataProvider) { var previousEmojiDescription; emojiNodeDataProvider.getData(node, function (payload) { if (payload.error) { EmojiNodeView.logError(payload.error); _this.renderFallback(); return; } var optionalEmojiDescription = payload.data; if (!optionalEmojiDescription) { _this.renderFallback(); return; } var emojiRepresentation = optionalEmojiDescription === null || optionalEmojiDescription === void 0 ? void 0 : optionalEmojiDescription.representation; if (!EmojiNodeView.isEmojiRepresentationSupported(emojiRepresentation)) { _this.renderFallback(); return; } if ((0, _isEqual.default)(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 ((0, _isSsr.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. var sharedState = api === null || api === void 0 || (_api$emoji = api.emoji) === null || _api$emoji === void 0 ? void 0 : _api$emoji.sharedState; if (!sharedState) { return; } var emojiProvider = (_sharedState$currentS = sharedState.currentState()) === null || _sharedState$currentS === void 0 ? void 0 : _sharedState$currentS.emojiProvider; if (emojiProvider) { void this.updateDom(emojiProvider); } var unsubscribe = sharedState.onChange(function (_ref2) { var nextSharedState = _ref2.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 var subscribeToConnection = api === null || api === void 0 || (_api$connectivity = api.connectivity) === null || _api$connectivity === void 0 ? void 0 : _api$connectivity.sharedState.onChange(function (_ref3) { var prevSharedState = _ref3.prevSharedState, nextSharedState = _ref3.nextSharedState; if ((0, _isOfflineMode.isOfflineMode)(prevSharedState === null || prevSharedState === void 0 ? void 0 : prevSharedState.mode) && (nextSharedState === null || nextSharedState === void 0 ? void 0 : nextSharedState.mode) === 'online' && _this.renderingFallback) { var _sharedState$currentS2; _this.updateDom((_sharedState$currentS2 = sharedState.currentState()) === null || _sharedState$currentS2 === void 0 ? void 0 : _sharedState$currentS2.emojiProvider); } }); this.destroy = function () { unsubscribe(); subscribeToConnection === null || subscribeToConnection === void 0 || subscribeToConnection(); _this.destroyTooltip(); }; } } /** Type guard to check if a Node is an HTMLElement in a safe way. */ return (0, _createClass2.default)(EmojiNodeView, [{ key: "destroyTooltip", value: function destroyTooltip() { var _this$destroyLazyTool, _this$tooltipInstance; (_this$destroyLazyTool = this.destroyLazyTooltipListeners) === null || _this$destroyLazyTool === void 0 || _this$destroyLazyTool.call(this); (_this$tooltipInstance = this.tooltipInstance) === null || _this$tooltipInstance === void 0 || _this$tooltipInstance.destroy(); // with platform_editor_use_vanilla_components the tooltipInstance?.destroy() // will remove the popovertarget and aria-describedby attributes from the tooltipTarget if (!(0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_use_vanilla_components')) { var _this$tooltipTarget, _this$tooltipTarget2; (_this$tooltipTarget = this.tooltipTarget) === null || _this$tooltipTarget === void 0 || _this$tooltipTarget.removeAttribute('popovertarget'); (_this$tooltipTarget2 = this.tooltipTarget) === null || _this$tooltipTarget2 === void 0 || _this$tooltipTarget2.removeAttribute('aria-describedby'); } this.destroyLazyTooltipListeners = undefined; this.tooltipInstance = undefined; this.tooltipTarget = undefined; } }, { key: "isHTMLElement", value: function 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; } }, { key: "updateDom", value: function () { var _updateDom = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee(emojiProvider) { var _this$node$attrs, shortName, id, fallback, emojiDescription, emojiRepresentation, _t; return _regenerator.default.wrap(function (_context) { while (1) switch (_context.prev = _context.next) { case 0: _context.prev = 0; _this$node$attrs = this.node.attrs, shortName = _this$node$attrs.shortName, id = _this$node$attrs.id, fallback = _this$node$attrs.text; _context.next = 1; return emojiProvider === null || emojiProvider === void 0 ? void 0 : emojiProvider.fetchByEmojiId({ id: id, shortName: shortName, fallback: fallback }, true); case 1: emojiDescription = _context.sent; if (emojiDescription) { _context.next = 2; break; } EmojiNodeView.logError(new Error('Emoji description is not loaded')); this.renderFallback(); return _context.abrupt("return"); case 2: emojiRepresentation = emojiDescription === null || emojiDescription === void 0 ? void 0 : emojiDescription.representation; if (EmojiNodeView.isEmojiRepresentationSupported(emojiRepresentation)) { _context.next = 3; break; } EmojiNodeView.logError(new Error('Emoji representation is not supported')); this.renderFallback(); return _context.abrupt("return"); case 3: this.renderEmoji(emojiDescription, emojiRepresentation); _context.next = 5; break; case 4: _context.prev = 4; _t = _context["catch"](0); EmojiNodeView.logError(_t instanceof Error ? _t : new Error('Unknown error on EmojiNodeView updateDom')); this.renderFallback(); case 5: case "end": return _context.stop(); } }, _callee, this, [[0, 4]]); })); function updateDom(_x) { return _updateDom.apply(this, arguments); } return updateDom; }() }, { key: "cleanUpAndRenderCommonAttributes", value: // Pay attention, this method should be called only when the emoji provider returns // emoji data to prevent rendering empty emoji during loading. function 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(_emoji.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. */ }, { key: "createTooltip", value: function createTooltip(element, shortName) { var _this2 = this; if (!(0, _expValEquals.expValEquals)('platform_editor_emoji_hover_show_tooltip', 'isEnabled', true)) { return; } if ((0, _isSsr.isSSR)()) { return; } var initTooltip = function initTooltip(event) { var _this2$destroyLazyToo; (_this2$destroyLazyToo = _this2.destroyLazyTooltipListeners) === null || _this2$destroyLazyToo === void 0 || _this2$destroyLazyToo.call(_this2); _this2.destroyLazyTooltipListeners = undefined; // `undefined` lets `VanillaTooltip` generate the id. var tooltipId = (0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_use_vanilla_components') ? undefined : nextFallbackTooltipId(); // Control arm only — the experiment arm gets the same declarations from // `VANILLA_TOOLTIP_DEFAULT_CLASS`. var tooltipStyles = (0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_use_vanilla_components') ? undefined : { 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' }; try { // Flag-on (platform-dst-top-layer), positioning is async via a React root; // safe here as VanillaTooltip stays hidden until positioned. var tooltipInstance = new _vanillaTooltip.VanillaTooltip(element, shortName, tooltipId, (0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_use_vanilla_components') ? EMOJI_TOOLTIP_CLASS_NAMES : EMOJI_TOOLTIP_CLASS, // default timeout 300, tooltipStyles); _this2.tooltipInstance = tooltipInstance; _this2.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))); } }; var unbindMouseEnter = (0, _bindEventListener.bind)(element, { type: 'mouseenter', listener: initTooltip, options: { once: true } }); var unbindFocus = (0, _bindEventListener.bind)(element, { type: 'focus', listener: initTooltip, options: { once: true } }); this.destroyLazyTooltipListeners = function () { unbindMouseEnter(); unbindFocus(); }; } }, { key: "renderFallback", value: function renderFallback() { this.renderingFallback = true; this.cleanUpAndRenderCommonAttributes(); var doc = (0, _browserApis.getDocument)(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } var fallbackElement = doc.createElement('span'); var _this$node$attrs2 = this.node.attrs, text = _this$node$attrs2.text, shortName = _this$node$attrs2.shortName; // 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. var fallbackText = text || shortName; var useReplacementChar = (0, _fg.fg)('platform_editor_custom_emoji_unicode_fallback') && !(0, _isSingleEmoji.isSingleEmoji)(fallbackText); var 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-".concat(shortName)); fallbackElement.setAttribute('data-emoji-type', 'fallback'); this.dom.appendChild(fallbackElement); this.createTooltip(fallbackElement, shortName); } }, { key: "renderEmoji", value: function renderEmoji(description, representation) { this.renderingFallback = false; this.cleanUpAndRenderCommonAttributes(); var emojiType = 'sprite' in representation ? 'sprite' : 'image'; var doc = (0, _browserApis.getDocument)(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } // Add wrapper for the emoji var containerElement = doc.createElement('span'); containerElement.setAttribute('role', 'img'); containerElement.setAttribute('title', description.shortName); containerElement.classList.add(_emoji.EmojiSharedCssClassName.EMOJI_CONTAINER); containerElement.setAttribute('data-testid', "".concat(emojiType, "-emoji-").concat(description.shortName)); containerElement.setAttribute('data-emoji-type', emojiType); containerElement.setAttribute('aria-label', "".concat(this.intl.formatMessage(_messages.messages.emojiNodeLabel), " ").concat(description.shortName)); var emojiElement = 'sprite' in representation ? this.createSpriteEmojiElement(representation) : this.createImageEmojiElement(description, representation); containerElement.appendChild(emojiElement); this.dom.appendChild(containerElement); this.createTooltip(containerElement, description.shortName); } }, { key: "createSpriteEmojiElement", value: function createSpriteEmojiElement(representation) { var doc = (0, _browserApis.getDocument)(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } var spriteElement = doc.createElement('span'); spriteElement.classList.add(_emoji.EmojiSharedCssClassName.EMOJI_SPRITE); var sprite = representation.sprite; var xPositionInPercent = 100 / (sprite.column - 1) * representation.xIndex; var yPositionInPercent = 100 / (sprite.row - 1) * representation.yIndex; spriteElement.style.backgroundImage = "url(".concat(sprite.url, ")"); spriteElement.style.backgroundPosition = "".concat(xPositionInPercent, "% ").concat(yPositionInPercent, "%"); spriteElement.style.backgroundSize = "".concat(sprite.column * 100, "% ").concat(sprite.row * 100, "%"); spriteElement.style.minWidth = "".concat(_constants.defaultEmojiHeight, "px"); spriteElement.style.minHeight = "".concat(_constants.defaultEmojiHeight, "px"); if (!(0, _expValEquals.expValEquals)('platform_editor_lovability_emoji_scaling', 'isEnabled', true)) { spriteElement.style.width = "".concat(_constants.defaultEmojiHeight, "px"); spriteElement.style.height = "".concat(_constants.defaultEmojiHeight, "px"); } return spriteElement; } }, { key: "createImageEmojiElement", value: function createImageEmojiElement(emojiDescription, representation) { var _this3 = this; var doc = (0, _browserApis.getDocument)(); if (!doc) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage doc = document; } var imageElement = doc.createElement('img'); imageElement.classList.add(_emoji.EmojiSharedCssClassName.EMOJI_IMAGE); imageElement.src = 'imagePath' in representation ? representation.imagePath : representation.mediaPath; imageElement.loading = 'lazy'; imageElement.alt = emojiDescription.name || emojiDescription.shortName; imageElement.style.minWidth = "".concat(_constants.defaultEmojiHeight, "px"); imageElement.style.objectFit = 'contain'; imageElement.height = _constants.defaultEmojiHeight; imageElement.onerror = function () { // 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 ((0, _isSingleEmoji.isSingleEmoji)(_this3.node.attrs.text)) { _this3.renderFallback(); } else { _this3.renderingFallback = true; } }; return imageElement; } }], [{ key: "logError", value: function logError(error) { void (0, _error.logException)(error, { location: 'editor-plugin-emoji/EmojiNodeView' }); } }, { key: "isEmojiRepresentationSupported", value: function isEmojiRepresentationSupported(representation) { return !!representation && ('sprite' in representation || 'imagePath' in representation || 'mediaPath' in representation); } }]); }();