@atlaskit/editor-plugin-type-ahead
Version:
Type-ahead plugin for @atlaskit/editor-core
370 lines (357 loc) • 18.6 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _typeof = require("@babel/runtime/helpers/typeof");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.TypeAheadPopup = void 0;
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
var _react = _interopRequireWildcard(require("react"));
var _react2 = require("@emotion/react");
var _rafSchd = _interopRequireDefault(require("raf-schd"));
var _analytics = require("@atlaskit/editor-common/analytics");
var _hooks = require("@atlaskit/editor-common/hooks");
var _typeAhead = require("@atlaskit/editor-common/type-ahead");
var _ui = require("@atlaskit/editor-common/ui");
var _editorSharedStyles = require("@atlaskit/editor-shared-styles");
var _featureGateJsClient = _interopRequireDefault(require("@atlaskit/feature-gate-js-client"));
var _experiments = require("@atlaskit/tmp-editor-statsig/experiments");
var _analytics2 = require("../pm-plugins/analytics");
var _constants = require("../pm-plugins/constants");
var _utils = require("../pm-plugins/utils");
var _TypeAheadErrorFallback = require("./TypeAheadErrorFallback");
var _TypeAheadList = require("./TypeAheadList");
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != _typeof(e) && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t in e) "default" !== _t && {}.hasOwnProperty.call(e, _t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t)) && (i.get || i.set) ? o(f, _t, i) : f[_t] = e[_t]); return f; })(e, t); }
/**
* @jsxRuntime classic
* @jsx jsx
*/
// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports
var DEFAULT_TYPEAHEAD_MENU_HEIGHT = 380;
var VIEWMORE_BUTTON_HEIGHT = 53;
var DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW = 480;
var ITEM_PADDING = 12;
var typeAheadContent = (0, _react2.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 = (0, _react2.css)({
maxHeight: "".concat(DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW, "px")
});
var typeAheadWrapperWithViewMoreOverride = (0, _react2.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];
var TypeAheadPopup = exports.TypeAheadPopup = /*#__PURE__*/_react.default.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 = (0, _react.useRef)(null);
var _useSharedPluginState = (0, _hooks.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 === _typeAhead.TypeAheadAvailableNodes.QUICK_INSERT;
var isEditorControlsEnabled = (0, _experiments.editorExperiment)('platform_editor_controls', 'variant1');
var defaultMenuHeight = (0, _react.useMemo)(function () {
return moreElementsInQuickInsertViewEnabled ? DEFAULT_TYPEAHEAD_MENU_HEIGHT_NEW : DEFAULT_TYPEAHEAD_MENU_HEIGHT;
}, [moreElementsInQuickInsertViewEnabled]);
var activityStateRef = (0, _react.useRef)({
inputMethod: null,
closeAction: null,
invocationMethod: (_getPluginState = (0, _utils.getPluginState)(editorView.state)) === null || _getPluginState === void 0 ? void 0 : _getPluginState.inputMethod
});
var startTime = (0, _react.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
);
(0, _react.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
_featureGateJsClient.default.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: _analytics.ACTION.RENDERED,
actionSubject: _analytics.ACTION_SUBJECT.TYPEAHEAD,
eventType: _analytics.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]);
(0, _react.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: _analytics.ACTION.VIEWED,
actionSubject: _analytics.ACTION_SUBJECT.TYPEAHEAD_ITEM,
eventType: _analytics.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 = (0, _react.useState)(defaultMenuHeight),
_useState2 = (0, _slicedToArray2.default)(_useState, 2),
fitHeight = _useState2[0],
setFitHeight = _useState2[1];
var fitHeightWithViewMore = (0, _react.useMemo)(function () {
if (showMoreOptionsButton) {
return fitHeight - VIEWMORE_BUTTON_HEIGHT;
}
return fitHeight;
}, [fitHeight, showMoreOptionsButton]);
var getFitHeight = (0, _react.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 = (0, _rafSchd.default)(getFitHeight);
(0, _react.useLayoutEffect)(function () {
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
var scrollableElement = popupsScrollableElement || (0, _ui.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]);
(0, _react.useLayoutEffect)(function () {
var _ref2 = (0, _utils.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(_constants.TYPE_AHEAD_POPUP_CONTENT_CLASS)) || relatedTarget.closest("[data-type-ahead=\"".concat(_constants.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: _constants.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
(0, _react.useLayoutEffect)(function () {
var _ref3 = (0, _utils.getPluginState)(editorView.state) || {},
removePrefixTriggerOnCancel = _ref3.removePrefixTriggerOnCancel;
var escape = function escape(event) {
if (event.key === 'Escape') {
activityStateRef.current.inputMethod = _analytics.INPUT_METHOD.KEYBOARD;
cancel({
addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true,
setSelectionAt: _constants.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 = (0, _react.useCallback)(function () {
if (
// eslint-disable-next-line @atlaskit/platform/no-preconditioning
openElementBrowserModal && (0, _experiments.editorExperiment)('platform_editor_controls', 'variant1') && triggerHandler.id === _typeAhead.TypeAheadAvailableNodes.QUICK_INSERT) {
activityStateRef.current = {
inputMethod: _analytics.INPUT_METHOD.MOUSE,
closeAction: _analytics.ACTION.VIEW_MORE,
invocationMethod: activityStateRef.current.invocationMethod
};
}
}, [openElementBrowserModal, triggerHandler.id]);
return (0, _react2.jsx)(_ui.Popup, {
zIndex: _editorSharedStyles.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 && (0, _experiments.editorExperiment)('platform_editor_controls', 'variant1')) {
// if selectedIndex is -1, it means that the user has not selected any item
// will be handled by WrapperTypeAhead
(0, _analytics2.fireTypeAheadClosedAnalyticsEvent)(api, activityStateRef.current.closeAction || _analytics.ACTION.CANCELLED, !isEmptyQuery, activityStateRef.current.inputMethod || _analytics.INPUT_METHOD.MOUSE, activityStateRef.current.invocationMethod);
// reset activity state
activityStateRef.current = {
inputMethod: null,
closeAction: null,
invocationMethod: null
};
}
}
}, (0, _react2.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: _constants.TYPE_AHEAD_POPUP_CONTENT_CLASS,
ref: ref
}, errorInfo ? (0, _react2.jsx)(_TypeAheadErrorFallback.TypeAheadErrorFallback, null) : (0, _react2.jsx)(_react.default.Fragment, null, (0, _react2.jsx)(Highlight, {
state: editorView.state,
triggerHandler: triggerHandler
}), (0, _react2.jsx)(_TypeAheadList.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 ((0, _experiments.editorExperiment)('platform_editor_controls', 'variant1')) {
activityStateRef.current = {
inputMethod: inputMethod || null,
closeAction: _analytics.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';