UNPKG

@atlaskit/editor-plugin-type-ahead

Version:

Type-ahead plugin for @atlaskit/editor-core

366 lines (355 loc) 16.4 kB
/** * @jsxRuntime classic * @jsx jsx */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; // eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports import { css, jsx } from '@emotion/react'; import rafSchedule from 'raf-schd'; import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks'; import { TypeAheadAvailableNodes } from '@atlaskit/editor-common/type-ahead'; import { findOverflowScrollParent, Popup } from '@atlaskit/editor-common/ui'; import { akEditorFloatingDialogZIndex } from '@atlaskit/editor-shared-styles'; import FeatureGates from '@atlaskit/feature-gate-js-client'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { fireTypeAheadClosedAnalyticsEvent } from '../pm-plugins/analytics'; import { CloseSelectionOptions, TYPE_AHEAD_DECORATION_DATA_ATTRIBUTE, TYPE_AHEAD_POPUP_CONTENT_CLASS } from '../pm-plugins/constants'; import { getPluginState } from '../pm-plugins/utils'; import { TypeAheadErrorFallback } from './TypeAheadErrorFallback'; import { TypeAheadList } from './TypeAheadList'; const DEFAULT_TYPEAHEAD_MENU_HEIGHT = 380; const VIEWMORE_BUTTON_HEIGHT = 53; const DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW = 480; const ITEM_PADDING = 12; const typeAheadContent = css({ background: "var(--ds-surface-overlay, #FFFFFF)", borderRadius: "var(--ds-radius-small, 3px)", boxShadow: "var(--ds-shadow-overlay, 0px 8px 12px #1E1F2126, 0px 0px 1px #1E1F214f)", padding: `${"var(--ds-space-050, 4px)"} 0`, width: '320px', maxHeight: '380px' /* ~5.5 visibile items */, overflowY: 'auto', MsOverflowStyle: '-ms-autohiding-scrollbar', position: 'relative' }); const typeAheadContentOverride = css({ maxHeight: `${DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW}px` }); const typeAheadWrapperWithViewMoreOverride = css({ display: 'flex', flexDirection: 'column' }); const Highlight = ({ state, triggerHandler }) => { if (!(triggerHandler !== null && triggerHandler !== void 0 && triggerHandler.getHighlight)) { return null; } return triggerHandler.getHighlight(state); }; const OFFSET = [0, 8]; export const TypeAheadPopup = /*#__PURE__*/React.memo(props => { var _getPluginState; const { editorView, triggerHandler, anchorElement, popupsMountPoint, popupsBoundariesElement, popupsScrollableElement, items, errorInfo, selectedIndex, onItemInsert, isEmptyQuery, cancel, api, showMoreOptionsButton } = props; const ref = useRef(null); const { moreElementsInQuickInsertView } = useSharedPluginStateWithSelector(api, ['featureFlags'], states => { var _states$featureFlagsS; return { moreElementsInQuickInsertView: (_states$featureFlagsS = states.featureFlagsState) === null || _states$featureFlagsS === void 0 ? void 0 : _states$featureFlagsS.moreElementsInQuickInsertView }; }); const moreElementsInQuickInsertViewEnabled = moreElementsInQuickInsertView && triggerHandler.id === TypeAheadAvailableNodes.QUICK_INSERT; const isEditorControlsEnabled = editorExperiment('platform_editor_controls', 'variant1'); const defaultMenuHeight = useMemo(() => moreElementsInQuickInsertViewEnabled ? DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW : DEFAULT_TYPEAHEAD_MENU_HEIGHT, [moreElementsInQuickInsertViewEnabled]); const activityStateRef = useRef({ inputMethod: null, closeAction: null, invocationMethod: (_getPluginState = getPluginState(editorView.state)) === null || _getPluginState === void 0 ? void 0 : _getPluginState.inputMethod }); const startTime = useMemo(() => performance.now(), // In case those props changes // we need to recreate the startTime [items, isEmptyQuery, triggerHandler] // eslint-disable-line react-hooks/exhaustive-deps ); useEffect(() => { var _api$analytics, _api$analytics$action, _api$analytics2, _api$analytics2$actio; if (!(api !== null && api !== void 0 && (_api$analytics = api.analytics) !== null && _api$analytics !== void 0 && (_api$analytics$action = _api$analytics.actions) !== null && _api$analytics$action !== void 0 && _api$analytics$action.fireAnalyticsEvent)) { return; } const stopTime = performance.now(); const time = stopTime - startTime; // eslint-disable-next-line @atlaskit/platform/use-recommended-utils FeatureGates.getExperimentValue('cc_fd_db_quick_insert_options', 'isEnabled', false); api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : (_api$analytics2$actio = _api$analytics2.actions) === null || _api$analytics2$actio === void 0 ? void 0 : _api$analytics2$actio.fireAnalyticsEvent({ action: ACTION.RENDERED, actionSubject: ACTION_SUBJECT.TYPEAHEAD, eventType: EVENT_TYPE.OPERATIONAL, attributes: { time, items: items.length, initial: isEmptyQuery } }); }, [startTime, items, isEmptyQuery, // In case the current triggerHandler changes // e.g: Inserting a mention using the quick insert // we need to send the event again // eslint-disable-next-line react-hooks/exhaustive-deps triggerHandler, api]); useEffect(() => { var _api$analytics3, _api$analytics3$actio, _api$analytics4, _api$analytics4$actio; if (!(api !== null && api !== void 0 && (_api$analytics3 = api.analytics) !== null && _api$analytics3 !== void 0 && (_api$analytics3$actio = _api$analytics3.actions) !== null && _api$analytics3$actio !== void 0 && _api$analytics3$actio.fireAnalyticsEvent)) { return; } api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : (_api$analytics4$actio = _api$analytics4.actions) === null || _api$analytics4$actio === void 0 ? void 0 : _api$analytics4$actio.fireAnalyticsEvent({ action: ACTION.VIEWED, actionSubject: ACTION_SUBJECT.TYPEAHEAD_ITEM, eventType: EVENT_TYPE.OPERATIONAL, attributes: { index: selectedIndex, items: items.length } }); }, [items, api, selectedIndex, // In case the current triggerHandler changes // e.g: Inserting a mention using the quick insert // we need to send the event again // eslint-disable-next-line react-hooks/exhaustive-deps triggerHandler]); const [fitHeight, setFitHeight] = useState(defaultMenuHeight); const fitHeightWithViewMore = useMemo(() => { if (showMoreOptionsButton) { return fitHeight - VIEWMORE_BUTTON_HEIGHT; } return fitHeight; }, [fitHeight, showMoreOptionsButton]); const getFitHeight = useCallback(() => { if (!anchorElement || !popupsMountPoint) { return; } const target = anchorElement; const { top: targetTop, height: targetHeight } = target.getBoundingClientRect(); const boundariesElement = popupsBoundariesElement || document.body; const { height: boundariesHeight, top: boundariesTop } = boundariesElement.getBoundingClientRect(); // Calculating the space above and space below our decoration const spaceAbove = targetTop - (boundariesTop - boundariesElement.scrollTop); const spaceBelow = boundariesTop + boundariesHeight - (targetTop + targetHeight); // Keep default height if more than enough space if (spaceBelow >= defaultMenuHeight) { return setFitHeight(defaultMenuHeight); } // Determines whether typeahead will fit above or below decoration // and return the space available. const newFitHeight = spaceBelow >= spaceAbove ? spaceBelow : spaceAbove; // Each typeahead item has some padding // We want to leave some space at the top so first item // is not partially cropped const fitHeightWithSpace = newFitHeight - ITEM_PADDING * 2; // Ensure typeahead height is max its default height const minFitHeight = Math.min(defaultMenuHeight, fitHeightWithSpace); return setFitHeight(minFitHeight); }, [anchorElement, defaultMenuHeight, popupsBoundariesElement, popupsMountPoint]); const getFitHeightDebounced = rafSchedule(getFitHeight); useLayoutEffect(() => { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const scrollableElement = popupsScrollableElement || findOverflowScrollParent(anchorElement); getFitHeight(); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners window.addEventListener('resize', getFitHeightDebounced); if (scrollableElement) { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners scrollableElement.addEventListener('scroll', getFitHeightDebounced); } return () => { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners window.removeEventListener('resize', getFitHeightDebounced); if (scrollableElement) { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners scrollableElement.removeEventListener('scroll', getFitHeightDebounced); } }; }, [anchorElement, popupsScrollableElement, getFitHeightDebounced, getFitHeight]); useLayoutEffect(() => { const { removePrefixTriggerOnCancel } = getPluginState(editorView.state) || {}; const focusOut = event => { var _window$getSelection; const { relatedTarget } = event; // Given the user is changing the focus // When the target is inside the TypeAhead Popup // Then the popup should stay open if (relatedTarget instanceof HTMLElement && relatedTarget.closest && (relatedTarget.closest(`.${TYPE_AHEAD_POPUP_CONTENT_CLASS}`) || relatedTarget.closest(`[data-type-ahead="${TYPE_AHEAD_DECORATION_DATA_ATTRIBUTE}"]`))) { return; } // Handles cases like emoji (:) and mention (@) typeaheads that open new typeaheads const isTextSelected = ((_window$getSelection = window.getSelection()) === null || _window$getSelection === void 0 ? void 0 : _window$getSelection.type) === 'Range'; const innerEditor = anchorElement.closest('.extension-editable-area'); if (innerEditor) { // When there is no related target, we default to not closing the popup let newFocusInsideCurrentEditor = !relatedTarget; if (relatedTarget instanceof HTMLElement) { // check if the new focus is inside inner editor, keep popup opens newFocusInsideCurrentEditor = innerEditor.contains(relatedTarget); } if (!isTextSelected && newFocusInsideCurrentEditor) { return; } } else { // if the current focus in outer editor, keep the existing behaviour, do not close the pop up if text is not selected if (!isTextSelected) { return; } } cancel({ addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true, setSelectionAt: CloseSelectionOptions.AFTER_TEXT_INSERTED, forceFocusOnEditor: false }); }; const { current: element } = ref; // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element === null || element === void 0 ? void 0 : element.addEventListener('focusout', focusOut); return () => { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element === null || element === void 0 ? void 0 : element.removeEventListener('focusout', focusOut); }; }, [ref, cancel, editorView.state, isEditorControlsEnabled, anchorElement]); // TODO: ED-17443 - When you press escape on typeahead panel, it should remove focus and close the panel // This is the expected keyboard behaviour advised by the Accessibility team useLayoutEffect(() => { const { removePrefixTriggerOnCancel } = getPluginState(editorView.state) || {}; const escape = event => { if (event.key === 'Escape') { activityStateRef.current.inputMethod = INPUT_METHOD.KEYBOARD; cancel({ addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true, setSelectionAt: CloseSelectionOptions.AFTER_TEXT_INSERTED, forceFocusOnEditor: true }); } }; const { current: element } = ref; // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element === null || element === void 0 ? void 0 : element.addEventListener('keydown', escape); return () => { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element === null || element === void 0 ? void 0 : element.removeEventListener('keydown', escape); }; }, [ref, cancel, editorView.state, isEditorControlsEnabled]); // @ts-ignore const openElementBrowserModal = triggerHandler === null || triggerHandler === void 0 ? void 0 : triggerHandler.openElementBrowserModal; const onMoreOptionsClicked = useCallback(() => { if ( // eslint-disable-next-line @atlaskit/platform/no-preconditioning openElementBrowserModal && editorExperiment('platform_editor_controls', 'variant1') && triggerHandler.id === TypeAheadAvailableNodes.QUICK_INSERT) { activityStateRef.current = { inputMethod: INPUT_METHOD.MOUSE, closeAction: ACTION.VIEW_MORE, invocationMethod: activityStateRef.current.invocationMethod }; } }, [openElementBrowserModal, triggerHandler.id]); return jsx(Popup, { zIndex: akEditorFloatingDialogZIndex, target: anchorElement, mountTo: popupsMountPoint, boundariesElement: popupsBoundariesElement, scrollableElement: popupsScrollableElement, fitHeight: fitHeight, fitWidth: 340, offset: OFFSET, ariaLabel: null, preventOverflow: true // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onUnmount: () => { if (selectedIndex > -1 && editorExperiment('platform_editor_controls', 'variant1')) { // if selectedIndex is -1, it means that the user has not selected any item // will be handled by WrapperTypeAhead fireTypeAheadClosedAnalyticsEvent(api, activityStateRef.current.closeAction || ACTION.CANCELLED, !isEmptyQuery, activityStateRef.current.inputMethod || INPUT_METHOD.MOUSE, activityStateRef.current.invocationMethod); // reset activity state activityStateRef.current = { inputMethod: null, closeAction: null, invocationMethod: null }; } } }, jsx("div", { css: [typeAheadContent, moreElementsInQuickInsertViewEnabled && typeAheadContentOverride, showMoreOptionsButton && typeAheadWrapperWithViewMoreOverride] // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766 , className: TYPE_AHEAD_POPUP_CONTENT_CLASS, ref: ref }, errorInfo ? jsx(TypeAheadErrorFallback, null) : jsx(React.Fragment, null, jsx(Highlight, { state: editorView.state, triggerHandler: triggerHandler }), jsx(TypeAheadList, { items: items, selectedIndex: selectedIndex // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onItemClick: (mode, index, inputMethod) => { if (editorExperiment('platform_editor_controls', 'variant1')) { activityStateRef.current = { inputMethod: inputMethod || null, closeAction: ACTION.INSERTED, invocationMethod: activityStateRef.current.invocationMethod }; } onItemInsert(mode, index); }, fitHeight: fitHeightWithViewMore, editorView: editorView, decorationElement: anchorElement, triggerHandler: triggerHandler, moreElementsInQuickInsertViewEnabled: moreElementsInQuickInsertViewEnabled, api: api, showMoreOptionsButton: showMoreOptionsButton, onMoreOptionsClicked: onMoreOptionsClicked })))); }); TypeAheadPopup.displayName = 'TypeAheadPopup';