@atlaskit/editor-plugin-type-ahead
Version:
Type-ahead plugin for @atlaskit/editor-core
360 lines (349 loc) • 17.1 kB
JavaScript
import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
/**
* @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';
var DEFAULT_TYPEAHEAD_MENU_HEIGHT = 380;
var VIEWMORE_BUTTON_HEIGHT = 53;
var DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW = 480;
var ITEM_PADDING = 12;
var 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)".concat(" 0"),
width: '320px',
maxHeight: '380px' /* ~5.5 visibile items */,
overflowY: 'auto',
MsOverflowStyle: '-ms-autohiding-scrollbar',
position: 'relative'
});
var typeAheadContentOverride = css({
maxHeight: "".concat(DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW, "px")
});
var typeAheadWrapperWithViewMoreOverride = css({
display: 'flex',
flexDirection: 'column'
});
var Highlight = function Highlight(_ref) {
var state = _ref.state,
triggerHandler = _ref.triggerHandler;
if (!(triggerHandler !== null && triggerHandler !== void 0 && triggerHandler.getHighlight)) {
return null;
}
return triggerHandler.getHighlight(state);
};
var OFFSET = [0, 8];
export var TypeAheadPopup = /*#__PURE__*/React.memo(function (props) {
var _getPluginState;
var editorView = props.editorView,
triggerHandler = props.triggerHandler,
anchorElement = props.anchorElement,
popupsMountPoint = props.popupsMountPoint,
popupsBoundariesElement = props.popupsBoundariesElement,
popupsScrollableElement = props.popupsScrollableElement,
items = props.items,
errorInfo = props.errorInfo,
selectedIndex = props.selectedIndex,
onItemInsert = props.onItemInsert,
isEmptyQuery = props.isEmptyQuery,
cancel = props.cancel,
api = props.api,
showMoreOptionsButton = props.showMoreOptionsButton;
var ref = useRef(null);
var _useSharedPluginState = useSharedPluginStateWithSelector(api, ['featureFlags'], function (states) {
var _states$featureFlagsS;
return {
moreElementsInQuickInsertView: (_states$featureFlagsS = states.featureFlagsState) === null || _states$featureFlagsS === void 0 ? void 0 : _states$featureFlagsS.moreElementsInQuickInsertView
};
}),
moreElementsInQuickInsertView = _useSharedPluginState.moreElementsInQuickInsertView;
var moreElementsInQuickInsertViewEnabled = moreElementsInQuickInsertView && triggerHandler.id === TypeAheadAvailableNodes.QUICK_INSERT;
var isEditorControlsEnabled = editorExperiment('platform_editor_controls', 'variant1');
var defaultMenuHeight = useMemo(function () {
return moreElementsInQuickInsertViewEnabled ? DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW : DEFAULT_TYPEAHEAD_MENU_HEIGHT;
}, [moreElementsInQuickInsertViewEnabled]);
var activityStateRef = useRef({
inputMethod: null,
closeAction: null,
invocationMethod: (_getPluginState = getPluginState(editorView.state)) === null || _getPluginState === void 0 ? void 0 : _getPluginState.inputMethod
});
var startTime = useMemo(function () {
return performance.now();
},
// In case those props changes
// we need to recreate the startTime
[items, isEmptyQuery, triggerHandler] // eslint-disable-line react-hooks/exhaustive-deps
);
useEffect(function () {
var _api$analytics, _api$analytics2;
if (!(api !== null && api !== void 0 && (_api$analytics = api.analytics) !== null && _api$analytics !== void 0 && (_api$analytics = _api$analytics.actions) !== null && _api$analytics !== void 0 && _api$analytics.fireAnalyticsEvent)) {
return;
}
var stopTime = performance.now();
var 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 || (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 || (_api$analytics2 = _api$analytics2.actions) === null || _api$analytics2 === void 0 || _api$analytics2.fireAnalyticsEvent({
action: ACTION.RENDERED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
eventType: EVENT_TYPE.OPERATIONAL,
attributes: {
time: 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(function () {
var _api$analytics3, _api$analytics4;
if (!(api !== null && api !== void 0 && (_api$analytics3 = api.analytics) !== null && _api$analytics3 !== void 0 && (_api$analytics3 = _api$analytics3.actions) !== null && _api$analytics3 !== void 0 && _api$analytics3.fireAnalyticsEvent)) {
return;
}
api === null || api === void 0 || (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 || (_api$analytics4 = _api$analytics4.actions) === null || _api$analytics4 === void 0 || _api$analytics4.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]);
var _useState = useState(defaultMenuHeight),
_useState2 = _slicedToArray(_useState, 2),
fitHeight = _useState2[0],
setFitHeight = _useState2[1];
var fitHeightWithViewMore = useMemo(function () {
if (showMoreOptionsButton) {
return fitHeight - VIEWMORE_BUTTON_HEIGHT;
}
return fitHeight;
}, [fitHeight, showMoreOptionsButton]);
var getFitHeight = useCallback(function () {
if (!anchorElement || !popupsMountPoint) {
return;
}
var target = anchorElement;
var _target$getBoundingCl = target.getBoundingClientRect(),
targetTop = _target$getBoundingCl.top,
targetHeight = _target$getBoundingCl.height;
var boundariesElement = popupsBoundariesElement || document.body;
var _boundariesElement$ge = boundariesElement.getBoundingClientRect(),
boundariesHeight = _boundariesElement$ge.height,
boundariesTop = _boundariesElement$ge.top;
// Calculating the space above and space below our decoration
var spaceAbove = targetTop - (boundariesTop - boundariesElement.scrollTop);
var 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.
var 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
var fitHeightWithSpace = newFitHeight - ITEM_PADDING * 2;
// Ensure typeahead height is max its default height
var minFitHeight = Math.min(defaultMenuHeight, fitHeightWithSpace);
return setFitHeight(minFitHeight);
}, [anchorElement, defaultMenuHeight, popupsBoundariesElement, popupsMountPoint]);
var getFitHeightDebounced = rafSchedule(getFitHeight);
useLayoutEffect(function () {
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
var 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 function () {
// 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(function () {
var _ref2 = getPluginState(editorView.state) || {},
removePrefixTriggerOnCancel = _ref2.removePrefixTriggerOnCancel;
var focusOut = function focusOut(event) {
var _window$getSelection;
var relatedTarget = event.relatedTarget;
// 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(".".concat(TYPE_AHEAD_POPUP_CONTENT_CLASS)) || relatedTarget.closest("[data-type-ahead=\"".concat(TYPE_AHEAD_DECORATION_DATA_ATTRIBUTE, "\"]")))) {
return;
}
// Handles cases like emoji (:) and mention (@) typeaheads that open new typeaheads
var isTextSelected = ((_window$getSelection = window.getSelection()) === null || _window$getSelection === void 0 ? void 0 : _window$getSelection.type) === 'Range';
var innerEditor = anchorElement.closest('.extension-editable-area');
if (innerEditor) {
// When there is no related target, we default to not closing the popup
var 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
});
};
var element = ref.current;
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
element === null || element === void 0 || element.addEventListener('focusout', focusOut);
return function () {
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
element === null || element === 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(function () {
var _ref3 = getPluginState(editorView.state) || {},
removePrefixTriggerOnCancel = _ref3.removePrefixTriggerOnCancel;
var escape = function escape(event) {
if (event.key === 'Escape') {
activityStateRef.current.inputMethod = INPUT_METHOD.KEYBOARD;
cancel({
addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true,
setSelectionAt: CloseSelectionOptions.AFTER_TEXT_INSERTED,
forceFocusOnEditor: true
});
}
};
var element = ref.current;
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
element === null || element === void 0 || element.addEventListener('keydown', escape);
return function () {
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
element === null || element === void 0 || element.removeEventListener('keydown', escape);
};
}, [ref, cancel, editorView.state, isEditorControlsEnabled]);
// @ts-ignore
var openElementBrowserModal = triggerHandler === null || triggerHandler === void 0 ? void 0 : triggerHandler.openElementBrowserModal;
var onMoreOptionsClicked = useCallback(function () {
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: function 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: function 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';