UNPKG

@atlaskit/editor-plugin-mentions

Version:

Mentions plugin for @atlaskit/editor-core

306 lines (305 loc) 15 kB
import { getBrowserInfo } from '@atlaskit/editor-common/browser'; import { ZERO_WIDTH_SPACE } from '@atlaskit/editor-common/whitespace'; import { DOMSerializer } from '@atlaskit/editor-prosemirror/model'; import { isResolvingMentionProvider, MentionNameStatus } from '@atlaskit/mention/resource'; import { isRestricted } from '@atlaskit/mention/types'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { expVal } from '@atlaskit/tmp-editor-statsig/expVal'; import { disabledTooltipRenderer } from './disabledTooltipRenderer'; import { profileCardRenderer } from './profileCardRenderer'; const primitiveClassName = 'editor-mention-primitive'; // @ts-ignore - TS1501 TypeScript 5.9.2 upgrade const getAccessibilityLabelFromName = name => name.replace(/^@/u, ''); const toDOM = node => { // 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 }; } if (expVal('platform_editor_agent_mentions', 'isEnabled', false) && node.attrs.userType) { mentionAttrs = { ...mentionAttrs, 'data-user-type': node.attrs.userType }; } const browser = getBrowserInfo(); return ['span', mentionAttrs, ['span', { class: 'zeroWidthSpaceContainer' }, ['span', { class: 'inlineNodeViewAddZeroWidthSpace' }, ZERO_WIDTH_SPACE]], ['span', { spellcheck: 'false', class: primitiveClassName }, node.attrs.text || '@…'], 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 || ''}` : `@_|unknown|_`; }; const handleProviderName = async (mentionProvider, node) => { if (isResolvingMentionProvider(mentionProvider) && node.attrs.id && !node.attrs.text) { 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; const { options, api, portalProviderAPI } = config; const { dom, contentDOM } = DOMSerializer.renderSpec(document, toDOM(node)); 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; 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 } = profileCardRenderer({ dom, options, portalProviderAPI, node, api }); // Accessibility attributes - based on `packages/people-and-teams/profilecard/src/components/User/ProfileCardTrigger.tsx` if (this.domElement && options !== null && options !== void 0 && options.profilecardProvider) { if (node.attrs.text) { 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; } 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) { if (name && !this.node.attrs.text && this.mentionPrimitiveElement) { this.mentionPrimitiveElement.textContent = name; } } 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 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); this.setTextContent(name); // 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 (name && this.domElement && (_this$config$options2 = this.config.options) !== null && _this$config$options2 !== void 0 && _this$config$options2.profilecardProvider && newState !== 'disabled') { this.domElement.setAttribute('aria-label', getAccessibilityLabelFromName(name)); } } 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) { if (!this.nodeIsEqual(node)) { return false; } this.node = node; return true; } destroy() { var _this$cleanup, _this$destroyProfileC, _this$disabledTooltip, _this$unsubscribeFrom2; // 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$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); } }