@atlaskit/editor-plugin-mentions
Version:
Mentions plugin for @atlaskit/editor-core
412 lines (407 loc) • 21.1 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import { getBrowserInfo } from '@atlaskit/editor-common/browser';
import { ZERO_WIDTH_SPACE } from '@atlaskit/editor-common/whitespace';
import { DOMSerializer } from '@atlaskit/editor-prosemirror/model';
// oxlint-disable-next-line import/no-duplicates
import { UNKNOWN_USER_ID } from '@atlaskit/mention/constants';
import { isResolvingMentionProvider, MentionNameStatus } from '@atlaskit/mention/resource';
import { isRestricted } from '@atlaskit/mention/types';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { expVal } from '@atlaskit/tmp-editor-statsig/expVal';
import { disabledTooltipRenderer } from './disabledTooltipRenderer';
import { mentionAvatarRenderer } from './mentionAvatarRenderer';
import { profileCardRenderer } from './profileCardRenderer';
const primitiveClassName = 'editor-mention-primitive';
const primitiveWithAvatarClassName = 'editor-mention-primitive-with-avatar';
const avatarContainerClassName = 'editor-mention-avatar';
const mentionTextClassName = 'editor-mention-text';
const genericMentionIds = ['HipChat', 'all', 'here'];
const unknownMentionText = `@${UNKNOWN_USER_ID}`;
// eslint-disable-next-line require-unicode-regexp
const AT_PREFIX_REGEX = /^@/;
const getAccessibilityLabelFromName = name => name.replace(AT_PREFIX_REGEX, '');
// Workaround: AI-rewritten pages may store the raw AAID (e.g. '@712020:uuid') in attrs.text for
// APP/AGENT mentions instead of the display name. Detect this so we can fall through to provider
// resolution. Pattern: '@digits:uuid' — distinct from plain-UUID human AAIDs (no numeric prefix).
const AAID_MENTION_TEXT_PATTERN =
// eslint-disable-next-line require-unicode-regexp
/^@\d+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Returns true if `text` looks like a raw AAID rather than a display name. */
const isMentionTextAnAaid = text => AAID_MENTION_TEXT_PATTERN.test(text);
const isAgentMentionsExperimentEnabled = () => expVal('platform_editor_agent_mentions', 'isEnabled', false);
/**
* Returns true if the node is an APP/AGENT mention whose stored text is a raw
* AAID (e.g. '@712020:uuid') rather than a resolved display name.
* All AAID-workaround guards should go through this helper so the checks stay
* consistent and can't drift apart.
*/
const isAgentAaidText = (node, isAgentMentionsEnabled) => isAgentMentionsEnabled && (node.attrs.userType === 'APP' || node.attrs.userType === 'AGENT') && !!node.attrs.text && isMentionTextAnAaid(node.attrs.text);
const toDOM = (node, hasAvatarSlot) => {
// packages/elements/mention/src/components/Mention/index.tsx
let mentionAttrs = {
contenteditable: 'false',
'data-access-level': node.attrs.accessLevel,
'data-mention-id': node.attrs.id,
'data-prosemirror-content-type': 'node',
'data-prosemirror-node-inline': 'true',
'data-prosemirror-node-name': 'mention',
'data-prosemirror-node-view-type': 'vanilla',
class: 'mentionView-content-wrap inlineNodeView'
};
if (fg('platform_editor_adf_with_localid')) {
mentionAttrs = {
...mentionAttrs,
'data-local-id': node.attrs.localId
};
}
const isAgentMentionsEnabled = isAgentMentionsExperimentEnabled();
if (isAgentMentionsEnabled && node.attrs.userType) {
mentionAttrs = {
...mentionAttrs,
'data-user-type': node.attrs.userType
};
}
const browser = getBrowserInfo();
const hasAgentAaidText = isAgentAaidText(node, isAgentMentionsEnabled);
const mentionText = node.attrs.text && !hasAgentAaidText ? node.attrs.text : '@…';
const visibleMentionText = hasAvatarSlot && mentionText.startsWith('@') ? mentionText.slice(1) : mentionText;
const mentionContentSpec = hasAvatarSlot ? ['span', {
spellcheck: 'false',
class: `${primitiveClassName} ${primitiveWithAvatarClassName}`
}, ['span', {
class: avatarContainerClassName,
'aria-hidden': 'true'
}], ['span', {
class: mentionTextClassName
}, visibleMentionText]] : ['span', {
spellcheck: 'false',
class: primitiveClassName
}, mentionText];
return ['span', mentionAttrs, ['span', {
class: 'zeroWidthSpaceContainer'
}, ['span', {
class: 'inlineNodeViewAddZeroWidthSpace'
}, ZERO_WIDTH_SPACE]], mentionContentSpec, browser.android ? ['span', {
class: 'zeroWidthSpaceContainer',
contenteditable: 'false'
}, ['span', {
class: 'inlineNodeViewAddZeroWidthSpace'
}, ZERO_WIDTH_SPACE]] : ['span', {
class: 'inlineNodeViewAddZeroWidthSpace'
}, ZERO_WIDTH_SPACE]];
};
const processName = name => {
return name.status === MentionNameStatus.OK ? `@${name.name || ''}` : unknownMentionText;
};
const handleProviderName = async (mentionProvider, node, isAgentMentionsEnabled) => {
// Also resolve when text is a raw AAID — provider will return the real display name.
const textIsAaid = isAgentAaidText(node, isAgentMentionsEnabled);
if (isResolvingMentionProvider(mentionProvider) && node.attrs.id && (!node.attrs.text || textIsAaid)) {
const nameDetail = mentionProvider === null || mentionProvider === void 0 ? void 0 : mentionProvider.resolveMentionName(node.attrs.id);
const resolvedNameDetail = await nameDetail;
return processName(resolvedNameDetail);
}
};
const getNewState = (isHighlighted, isRestricted, isDisabled) => {
if (isDisabled) {
return 'disabled';
}
if (isHighlighted) {
return 'self';
}
if (isRestricted) {
return 'restricted';
}
return 'default';
};
export class MentionNodeView {
constructor(node, config) {
var _this$domElement$quer, _api$mention$sharedSt;
_defineProperty(this, "hasAvatarSlot", false);
_defineProperty(this, "isDestroyed", false);
const {
options,
api,
portalProviderAPI,
editorView
} = config;
this.hasAvatarSlot = Boolean(options === null || options === void 0 ? void 0 : options.mentionNodeDataProvider) && node.attrs.userType !== 'SPECIAL' && !genericMentionIds.includes(node.attrs.id) && isExperimentEnabled('platform_editor_mention_node_avatar');
const {
dom,
contentDOM
} = DOMSerializer.renderSpec(document, toDOM(node, this.hasAvatarSlot));
this.dom = dom;
this.contentDOM = contentDOM;
this.config = config;
this.node = node;
this.domElement = dom instanceof HTMLElement ? dom : undefined;
this.mentionPrimitiveElement = this.domElement ? (_this$domElement$quer = this.domElement.querySelector(`.${primitiveClassName}`)) !== null && _this$domElement$quer !== void 0 ? _this$domElement$quer : undefined : undefined;
this.mentionTextElement = this.mentionPrimitiveElement;
if (this.hasAvatarSlot) {
var _this$domElement$quer2, _this$domElement, _this$domElement2;
this.mentionTextElement = (_this$domElement$quer2 = (_this$domElement = this.domElement) === null || _this$domElement === void 0 ? void 0 : _this$domElement.querySelector(`.${mentionTextClassName}`)) !== null && _this$domElement$quer2 !== void 0 ? _this$domElement$quer2 : this.mentionPrimitiveElement;
const avatarContainer = (_this$domElement2 = this.domElement) === null || _this$domElement2 === void 0 ? void 0 : _this$domElement2.querySelector(`.${avatarContainerClassName}`);
if (avatarContainer) {
this.mentionAvatar = mentionAvatarRenderer({
container: avatarContainer
});
this.resolveMentionAvatar(options === null || options === void 0 ? void 0 : options.mentionNodeDataProvider);
}
}
const {
mentionProvider
} = (_api$mention$sharedSt = api === null || api === void 0 ? void 0 : api.mention.sharedState.currentState()) !== null && _api$mention$sharedSt !== void 0 ? _api$mention$sharedSt : {};
this.updateState(mentionProvider);
this.subscribeToProviderDisabledStateChanges(mentionProvider);
this.cleanup = api === null || api === void 0 ? void 0 : api.mention.sharedState.onChange(({
nextSharedState
}) => {
this.updateState(nextSharedState === null || nextSharedState === void 0 ? void 0 : nextSharedState.mentionProvider);
this.subscribeToProviderDisabledStateChanges(nextSharedState === null || nextSharedState === void 0 ? void 0 : nextSharedState.mentionProvider);
});
const {
destroyProfileCard,
removeProfileCard,
updateNode
} = profileCardRenderer({
dom,
options,
portalProviderAPI,
node,
api,
editorView
});
// Accessibility attributes - based on `packages/people-and-teams/profilecard/src/components/User/ProfileCardTrigger.tsx`
if (this.domElement && options !== null && options !== void 0 && options.profilecardProvider) {
const isAgentMentionsEnabled = isAgentMentionsExperimentEnabled();
if (node.attrs.text && !isAgentAaidText(node, isAgentMentionsEnabled)) {
this.domElement.setAttribute('aria-label', getAccessibilityLabelFromName(node.attrs.text));
}
this.domElement.setAttribute('aria-expanded', 'false');
this.domElement.setAttribute('role', 'button');
this.domElement.setAttribute('tabindex', '0');
this.domElement.setAttribute('aria-haspopup', 'dialog');
}
this.destroyProfileCard = destroyProfileCard;
this.removeProfileCard = removeProfileCard;
this.updateProfileCardNode = updateNode;
}
setClassList(state, disabledTooltip) {
var _this$mentionPrimitiv, _this$mentionPrimitiv2, _this$mentionPrimitiv3;
(_this$mentionPrimitiv = this.mentionPrimitiveElement) === null || _this$mentionPrimitiv === void 0 ? void 0 : _this$mentionPrimitiv.classList.toggle('mention-self', state === 'self');
(_this$mentionPrimitiv2 = this.mentionPrimitiveElement) === null || _this$mentionPrimitiv2 === void 0 ? void 0 : _this$mentionPrimitiv2.classList.toggle('mention-restricted', state === 'restricted');
(_this$mentionPrimitiv3 = this.mentionPrimitiveElement) === null || _this$mentionPrimitiv3 === void 0 ? void 0 : _this$mentionPrimitiv3.classList.toggle('mention-disabled', state === 'disabled');
// Mirror the React `<Mention>` a11y behaviour: when the chip is
// disabled, expose `aria-disabled` so assistive tech announces it as
// such. Also surface the tooltip text via `aria-label` so screen-reader
// users hear *why* the chip is disabled, matching the React `<Mention>`
// behaviour at `Mention/index.tsx` line 152.
if (this.domElement) {
if (state === 'disabled') {
this.domElement.setAttribute('aria-disabled', 'true');
if (disabledTooltip) {
const text = this.node.attrs.text || '@...';
this.domElement.setAttribute('aria-label', `${text} — ${disabledTooltip}`);
}
} else {
this.domElement.removeAttribute('aria-disabled');
this.domElement.removeAttribute('aria-label');
}
}
}
getDisabledState(mentionProvider) {
var _mentionProvider$getM;
const input = {
id: this.node.attrs.id,
userType: this.node.attrs.userType
};
return mentionProvider === null || mentionProvider === void 0 ? void 0 : (_mentionProvider$getM = mentionProvider.getMentionDisabledState) === null || _mentionProvider$getM === void 0 ? void 0 : _mentionProvider$getM.call(mentionProvider, input);
}
/**
* Subscribes this NodeView to disabled-state-change notifications on the
* supplied provider so already-rendered chips can re-evaluate themselves
* when the consumer's predicate inputs change (e.g. the active agent
* selection toggling in Rovo Chat). No-op for providers that don't
* implement `subscribeToDisabledStateChanges`.
*
* Idempotent: re-calling with the same provider keeps the existing
* subscription; passing a different provider tears the old subscription
* down before attaching the new one. Safe to call from the sharedState
* `onChange` handler when the editor swaps providers.
*/
subscribeToProviderDisabledStateChanges(mentionProvider) {
var _this$unsubscribeFrom;
if (this.subscribedProvider === mentionProvider) {
return;
}
(_this$unsubscribeFrom = this.unsubscribeFromDisabledStateChanges) === null || _this$unsubscribeFrom === void 0 ? void 0 : _this$unsubscribeFrom.call(this);
this.unsubscribeFromDisabledStateChanges = undefined;
this.subscribedProvider = mentionProvider;
if (!(mentionProvider !== null && mentionProvider !== void 0 && mentionProvider.subscribeToDisabledStateChanges)) {
return;
}
this.unsubscribeFromDisabledStateChanges = mentionProvider.subscribeToDisabledStateChanges(() => {
this.updateState(this.subscribedProvider);
});
}
syncDisabledTooltip(disabledState) {
// Capture the tooltip text into a local so the rest of the method can
// branch on a truthy string instead of re-asserting non-null fields
// off of `disabledState`.
const tooltipText = disabledState !== null && disabledState !== void 0 && disabledState.disabled ? disabledState.tooltip : undefined;
const chip = this.mentionPrimitiveElement;
const {
portalProviderAPI
} = this.config;
if (!chip || !portalProviderAPI) {
return;
}
if (tooltipText) {
if (!this.disabledTooltip) {
this.disabledTooltip = disabledTooltipRenderer({
chipElement: chip,
portalProviderAPI
});
}
this.disabledTooltip.setTooltip(tooltipText);
} else if (this.disabledTooltip) {
this.disabledTooltip.destroy();
this.disabledTooltip = undefined;
}
}
setTextContent(name, isAgentMentionsEnabled) {
// Also overwrite when text is a raw AAID so the resolved name takes precedence.
const textIsAaid = isAgentAaidText(this.node, isAgentMentionsEnabled);
if (name && (!this.node.attrs.text || textIsAaid) && this.mentionTextElement) {
this.setVisibleText(name);
}
}
setVisibleText(text) {
if (!this.mentionTextElement) {
return;
}
this.mentionTextElement.textContent = this.hasAvatarSlot && text.startsWith('@') && text !== unknownMentionText ? text.slice(1) : text;
}
resolveMentionAvatar(mentionNodeDataProvider) {
if (!this.hasAvatarSlot || !mentionNodeDataProvider || this.node.attrs.userType === 'SPECIAL') {
return;
}
const mention = {
id: this.node.attrs.id,
userType: this.node.attrs.userType
};
const applyData = data => {
var _this$mentionAvatar;
if (this.isDestroyed || !(data !== null && data !== void 0 && data.avatarUrl)) {
return;
}
(_this$mentionAvatar = this.mentionAvatar) === null || _this$mentionAvatar === void 0 ? void 0 : _this$mentionAvatar.render(data);
};
mentionNodeDataProvider.getMentionData(mention, payload => {
if (payload.data) {
applyData(payload.data);
}
});
}
shouldHighlightMention(mentionProvider) {
var _this$config$options;
const {
currentUserId
} = (_this$config$options = this.config.options) !== null && _this$config$options !== void 0 ? _this$config$options : {};
// Check options first (immediate), then provider (async), then default to false
if (currentUserId && this.node.attrs.id === currentUserId) {
return true;
} else {
var _mentionProvider$shou;
return (_mentionProvider$shou = mentionProvider === null || mentionProvider === void 0 ? void 0 : mentionProvider.shouldHighlightMention({
id: this.node.attrs.id
})) !== null && _mentionProvider$shou !== void 0 ? _mentionProvider$shou : false;
}
}
async updateState(mentionProvider) {
var _mentionProvider$shou2, _this$config$options2;
const isAgentMentionsEnabled = isAgentMentionsExperimentEnabled();
const isHighlighted = expValEquals('platform_editor_vc90_transition_mentions', 'isEnabled', true) ? this.shouldHighlightMention(mentionProvider) : (_mentionProvider$shou2 = mentionProvider === null || mentionProvider === void 0 ? void 0 : mentionProvider.shouldHighlightMention({
id: this.node.attrs.id
})) !== null && _mentionProvider$shou2 !== void 0 ? _mentionProvider$shou2 : false;
const disabledState = this.getDisabledState(mentionProvider);
const isDisabled = !!(disabledState !== null && disabledState !== void 0 && disabledState.disabled);
const newState = getNewState(isHighlighted, isRestricted(this.node.attrs.accessLevel), isDisabled);
const disabledTooltip = disabledState !== null && disabledState !== void 0 && disabledState.disabled ? disabledState.tooltip : undefined;
// `setClassList` always runs so the aria-label (which depends on the
// tooltip text) stays in sync when the tooltip reason changes while
// the chip remains disabled. State-change-only writes would leave a
// stale aria-label after a tooltip-text-only update.
this.setClassList(newState, disabledTooltip);
// Tooltip wiring runs every update (not just on state change) so that
// the tooltip text stays in sync if the disabled reason changes while
// the chip is already disabled.
this.syncDisabledTooltip(disabledState);
const name = await handleProviderName(mentionProvider, this.node, isAgentMentionsEnabled);
this.setTextContent(name, isAgentMentionsEnabled);
// Only overwrite the disabled-state aria-label with the name-based one
// when the chip is NOT disabled; otherwise the disabled reason set in
// `setClassList` would be silently clobbered, regressing a11y.
if (this.domElement && (_this$config$options2 = this.config.options) !== null && _this$config$options2 !== void 0 && _this$config$options2.profilecardProvider && newState !== 'disabled') {
const text = name !== null && name !== void 0 ? name : this.node.attrs.text;
if (text && !isAgentAaidText(this.node, isAgentMentionsEnabled)) {
this.domElement.setAttribute('aria-label', getAccessibilityLabelFromName(text));
}
}
}
nodeIsEqual(nextNode) {
var _this$config$options3;
if ((_this$config$options3 = this.config.options) !== null && _this$config$options3 !== void 0 && _this$config$options3.sanitizePrivateContent) {
// Compare nodes but ignore the text parameter as it may be sanitized
const nextNodeAttrs = {
...nextNode.attrs,
text: this.node.attrs.text
};
return this.node.hasMarkup(nextNode.type, nextNodeAttrs, nextNode.marks);
}
return this.node.sameMarkup(nextNode);
}
update(node) {
var _this$updateProfileCa;
if (!this.nodeIsEqual(node)) {
return false;
}
this.node = node;
// Keep the profile card renderer's node reference in sync so that the
// click handler always reads up-to-date attrs.
(_this$updateProfileCa = this.updateProfileCardNode) === null || _this$updateProfileCa === void 0 ? void 0 : _this$updateProfileCa.call(this, node);
return true;
}
destroy() {
var _this$cleanup, _this$destroyProfileC, _this$mentionAvatar2, _this$disabledTooltip, _this$unsubscribeFrom2;
this.isDestroyed = true;
// Surface the destruction to the provider before tearing down so the
// chat layer can react (e.g. drop the agent id from `selectedAgentIds`).
// This is the lowest-level deletion signal — fires for backspace,
// select-and-delete, programmatic doc replaces, and editor unmount.
try {
var _this$subscribedProvi, _this$subscribedProvi2;
(_this$subscribedProvi = this.subscribedProvider) === null || _this$subscribedProvi === void 0 ? void 0 : (_this$subscribedProvi2 = _this$subscribedProvi.notifyMentionDestroyed) === null || _this$subscribedProvi2 === void 0 ? void 0 : _this$subscribedProvi2.call(_this$subscribedProvi, {
id: this.node.attrs.id
});
} catch (_error) {
// Defensive: never let consumer-side notification errors prevent
// the NodeView from cleaning up its own resources below.
}
(_this$cleanup = this.cleanup) === null || _this$cleanup === void 0 ? void 0 : _this$cleanup.call(this);
(_this$destroyProfileC = this.destroyProfileCard) === null || _this$destroyProfileC === void 0 ? void 0 : _this$destroyProfileC.call(this);
(_this$mentionAvatar2 = this.mentionAvatar) === null || _this$mentionAvatar2 === void 0 ? void 0 : _this$mentionAvatar2.destroy();
this.mentionAvatar = undefined;
(_this$disabledTooltip = this.disabledTooltip) === null || _this$disabledTooltip === void 0 ? void 0 : _this$disabledTooltip.destroy();
this.disabledTooltip = undefined;
(_this$unsubscribeFrom2 = this.unsubscribeFromDisabledStateChanges) === null || _this$unsubscribeFrom2 === void 0 ? void 0 : _this$unsubscribeFrom2.call(this);
this.unsubscribeFromDisabledStateChanges = undefined;
this.subscribedProvider = undefined;
}
deselectNode() {
var _this$removeProfileCa;
(_this$removeProfileCa = this.removeProfileCard) === null || _this$removeProfileCa === void 0 ? void 0 : _this$removeProfileCa.call(this);
}
}