@atlaskit/editor-plugin-media-insert
Version:
Media Insert plugin for @atlaskit/editor-core
383 lines (382 loc) • 16 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
import React from 'react';
import { useIntl } from 'react-intl';
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD, MEDIA_INSERT_TAB } from '@atlaskit/editor-common/analytics';
import { getDomRefFromSelection } from '@atlaskit/editor-common/get-dom-ref-from-selection';
import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks';
import { DEFAULT_MEDIA_INSERT_TAB_RANK, MEDIA_INSERT_TAB_RANK } from '@atlaskit/editor-common/media-insert/rank';
import { mediaInsertMessages } from '@atlaskit/editor-common/messages';
import { PlainOutsideClickTargetRefContext, Popup, withOuterListeners } from '@atlaskit/editor-common/ui';
import { akEditorFloatingDialogZIndex } from '@atlaskit/editor-shared-styles';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { Box, Focusable, Text } from '@atlaskit/primitives/compiled';
import Tabs from '@atlaskit/tabs/tabs';
import TabList from '@atlaskit/tabs/tab-list';
import useTab from '@atlaskit/tabs/use-tab';
import useTabPanel from '@atlaskit/tabs/use-tab-panel';
import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
import { useFocus } from './hooks/use-focus';
import { useUnholyAutofocus } from './hooks/use-unholy-autofocus';
import { LocalMedia } from './LocalMedia';
import { MediaFromURL } from './MediaFromURL';
import { MediaInsertWrapper } from './MediaInsertWrapper';
const PopupWithListeners = withOuterListeners(Popup);
const MEDIA_INSERT_PICKER_ANALYTICS_SOURCE = 'MediaInsertPicker';
const EMPTY_REGISTERED_TABS = [];
const getMediaInsertPickerTabSource = selectedTab => `${MEDIA_INSERT_PICKER_ANALYTICS_SOURCE} - ${selectedTab}`;
const getNextTabIndexForKey = (currentTabIndex, tabCount, key) => {
if (key === 'Home') {
return 0;
}
if (key === 'End') {
return tabCount - 1;
}
if (key === 'ArrowRight') {
return currentTabIndex === tabCount - 1 ? 0 : currentTabIndex + 1;
}
if (key === 'ArrowLeft') {
return currentTabIndex === 0 ? tabCount - 1 : currentTabIndex - 1;
}
return undefined;
};
const TabWithAnalytics = ({
children,
onSelectTabForAnalytics,
selectedTabIndex,
tabCount
}) => {
const {
onClick,
id,
'aria-controls': ariaControls,
'aria-posinset': ariaPosinset,
'aria-selected': ariaSelected,
'aria-setsize': ariaSetsize,
onKeyDown,
role,
tabIndex
} = useTab();
const handleClick = React.useCallback(() => {
onSelectTabForAnalytics(selectedTabIndex);
onClick();
}, [onClick, onSelectTabForAnalytics, selectedTabIndex]);
const handleKeyDown = React.useCallback(event => {
const nextTabIndex = getNextTabIndexForKey(selectedTabIndex, tabCount, event.key);
if (nextTabIndex !== undefined) {
onSelectTabForAnalytics(nextTabIndex);
}
onKeyDown(event);
}, [onKeyDown, onSelectTabForAnalytics, selectedTabIndex, tabCount]);
return /*#__PURE__*/React.createElement(Focusable, {
as: "div",
isInset: true,
onClick: handleClick,
id: id,
"aria-controls": ariaControls,
"aria-posinset": ariaPosinset,
"aria-selected": ariaSelected,
"aria-setsize": ariaSetsize,
onKeyDown: handleKeyDown,
role: role,
tabIndex: tabIndex
}, /*#__PURE__*/React.createElement(Text, {
weight: "medium",
color: "inherit",
maxLines: 1
}, children));
};
/**
* A custom TabPanel that is non-focusable.
*/
const CustomTabPanel = ({
children,
disablePaddingBlockEnd = false
}) => {
const tabPanelAttributes = useTabPanel();
return /*#__PURE__*/React.createElement(Box, _extends({
paddingBlockEnd: disablePaddingBlockEnd ? 'space.0' : 'space.150'
// Ignored via go/ees005
// eslint-disable-next-line react/jsx-props-no-spreading
}, tabPanelAttributes, {
tabIndex: -1
}), children);
};
export const MediaInsertPicker = ({
api,
editorView,
dispatchAnalyticsEvent,
popupsMountPoint,
popupsBoundariesElement,
popupsScrollableElement,
closeMediaInsertPicker,
insertMediaSingle,
insertExternalMediaSingle,
insertFile,
isOnlyExternalLinks = false,
customizedUrlValidation,
customizedHelperMessage
}) => {
var _api$mediaInsert$acti, _api$mediaInsert, _api$mediaInsert$acti2, _api$mediaInsert$acti3, _targetRef;
// Tabs registered by other plugins via `api.mediaInsert.actions.registerInsertTab(...)`.
// Read once per render; the registry is mutated only at plugin setup time so this is stable
// for the lifetime of an editor instance.
const registeredTabs = (_api$mediaInsert$acti = api === null || api === void 0 ? void 0 : (_api$mediaInsert = api.mediaInsert) === null || _api$mediaInsert === void 0 ? void 0 : (_api$mediaInsert$acti2 = _api$mediaInsert.actions) === null || _api$mediaInsert$acti2 === void 0 ? void 0 : (_api$mediaInsert$acti3 = _api$mediaInsert$acti2.getInsertTabs) === null || _api$mediaInsert$acti3 === void 0 ? void 0 : _api$mediaInsert$acti3.call(_api$mediaInsert$acti2)) !== null && _api$mediaInsert$acti !== void 0 ? _api$mediaInsert$acti : EMPTY_REGISTERED_TABS;
const {
mediaProvider,
isOpen,
mountInfo
} = useSharedPluginStateWithSelector(api, ['media', 'mediaInsert'], states => {
var _states$mediaState, _states$mediaInsertSt, _states$mediaInsertSt2;
return {
mediaProvider: (_states$mediaState = states.mediaState) === null || _states$mediaState === void 0 ? void 0 : _states$mediaState.mediaProvider,
isOpen: (_states$mediaInsertSt = states.mediaInsertState) === null || _states$mediaInsertSt === void 0 ? void 0 : _states$mediaInsertSt.isOpen,
mountInfo: (_states$mediaInsertSt2 = states.mediaInsertState) === null || _states$mediaInsertSt2 === void 0 ? void 0 : _states$mediaInsertSt2.mountInfo
};
});
let targetRef;
let mountPoint;
if (mountInfo) {
targetRef = mountInfo.ref;
mountPoint = mountInfo.mountPoint;
} else {
var _api$analytics;
// If targetRef is undefined, target the selection in the editor
targetRef = getDomRefFromSelection(editorView, ACTION_SUBJECT_ID.PICKER_MEDIA, api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions);
mountPoint = popupsMountPoint;
}
const focusButton = useFocus({
target: (_targetRef = targetRef) !== null && _targetRef !== void 0 ? _targetRef : editorView
});
const focusEditor = useFocus({
target: editorView
});
// returnFocusRef stores which function we call on unmount: focusButton | focusEditor
// Needs to be a ref so we can instantly toggle it before unmounting triggers the focus trap
const returnFocusRef = React.useRef(focusEditor);
// When inserting media, close picker and focus editor.
const closeAndFocusEditor = React.useCallback(() => {
returnFocusRef.current = focusEditor;
closeMediaInsertPicker();
}, [closeMediaInsertPicker, focusEditor]);
// When cancelling, close picker and return focus to button.
const closeAndReturnFocusToButton = React.useCallback(() => {
returnFocusRef.current = focusButton;
closeMediaInsertPicker();
}, [closeMediaInsertPicker, focusButton]);
const intl = useIntl();
const {
autofocusRef,
onPositionCalculated
} = useUnholyAutofocus();
const orderedTabs = React.useMemo(() => {
const tabs = [];
let tieBreaker = 0;
if (!isOnlyExternalLinks) {
tabs.push({
key: MEDIA_INSERT_TAB.UPLOAD,
rank: MEDIA_INSERT_TAB_RANK[MEDIA_INSERT_TAB.UPLOAD],
tieBreaker: tieBreaker++,
type: 'upload'
});
}
tabs.push({
key: MEDIA_INSERT_TAB.LINK,
rank: MEDIA_INSERT_TAB_RANK[MEDIA_INSERT_TAB.LINK],
tieBreaker: tieBreaker++,
type: 'link'
});
registeredTabs.forEach(registeredTab => {
var _registeredTab$rank;
tabs.push({
key: registeredTab.key,
rank: (_registeredTab$rank = registeredTab.rank) !== null && _registeredTab$rank !== void 0 ? _registeredTab$rank : DEFAULT_MEDIA_INSERT_TAB_RANK,
registeredTab,
tieBreaker: tieBreaker++,
type: 'registered'
});
});
return tabs.sort((a, b) => a.rank - b.rank || a.tieBreaker - b.tieBreaker);
}, [isOnlyExternalLinks, registeredTabs]);
const tabCount = orderedTabs.length;
const getTabAnalyticsMetadata = React.useCallback(selectedTabIndex => {
const selectedTab = orderedTabs[selectedTabIndex];
if (selectedTab) {
return {
selectedTab: selectedTab.key,
selectedTabIndex
};
}
return {
selectedTab: 'unknown',
selectedTabIndex
};
}, [orderedTabs]);
const selectedTabAnalyticsMetadataRef = React.useRef(getTabAnalyticsMetadata(0));
const tabsAnalyticsContext = React.useMemo(() => ({
get source() {
return getMediaInsertPickerTabSource(selectedTabAnalyticsMetadataRef.current.selectedTab);
}
}), []);
const setSelectedTabAnalyticsMetadata = React.useCallback(selectedTabIndex => {
selectedTabAnalyticsMetadataRef.current = getTabAnalyticsMetadata(selectedTabIndex);
}, [getTabAnalyticsMetadata]);
const hasDispatchedInitialTabViewedEventRef = React.useRef(false);
// Atlaskit Tabs only calls `onChange` after the user changes tabs, so the
// initially opened tab needs its viewed analytics event dispatched from an
// effect. The ref keeps this to once per picker open without setting state.
React.useEffect(() => {
if (!isOpen) {
hasDispatchedInitialTabViewedEventRef.current = false;
return;
}
if (!mediaProvider || !dispatchAnalyticsEvent || hasDispatchedInitialTabViewedEventRef.current) {
return;
}
const selectedTabMetadata = getTabAnalyticsMetadata(0);
selectedTabAnalyticsMetadataRef.current = selectedTabMetadata;
const payload = {
action: ACTION.VIEWED,
actionSubject: ACTION_SUBJECT.PICKER,
actionSubjectId: ACTION_SUBJECT_ID.PICKER_MEDIA,
eventType: EVENT_TYPE.UI,
attributes: {
selectedTab: selectedTabMetadata.selectedTab,
selectedTabIndex: selectedTabMetadata.selectedTabIndex
}
};
dispatchAnalyticsEvent(payload);
hasDispatchedInitialTabViewedEventRef.current = true;
}, [dispatchAnalyticsEvent, getTabAnalyticsMetadata, isOpen, mediaProvider]);
const handleTabChange = React.useCallback((selectedTabIndex, analyticsEvent) => {
const selectedTabMetadata = getTabAnalyticsMetadata(selectedTabIndex);
selectedTabAnalyticsMetadataRef.current = selectedTabMetadata;
analyticsEvent.update(payload => ({
...payload,
attributes: {
...payload.attributes,
selectedTab: selectedTabMetadata.selectedTab,
selectedTabIndex
}
})).fire();
if (dispatchAnalyticsEvent) {
const payload = {
action: ACTION.VIEWED,
actionSubject: ACTION_SUBJECT.PICKER,
actionSubjectId: ACTION_SUBJECT_ID.PICKER_MEDIA,
eventType: EVENT_TYPE.UI,
attributes: {
selectedTab: selectedTabMetadata.selectedTab,
selectedTabIndex
}
};
dispatchAnalyticsEvent(payload);
}
}, [dispatchAnalyticsEvent, getTabAnalyticsMetadata]);
if (!isOpen || !mediaProvider) {
return null;
}
const handleClose = exitMethod => event => {
// Same as AIImageGenerationPopup: react-select can detach the option
// before `click` fires, so withOuterListeners treats it as outside.
if (exitMethod === INPUT_METHOD.MOUSE && event.target instanceof Node && !event.target.isConnected) {
return;
}
event.preventDefault();
if (dispatchAnalyticsEvent) {
const payload = {
action: ACTION.CLOSED,
actionSubject: ACTION_SUBJECT.PICKER,
actionSubjectId: ACTION_SUBJECT_ID.PICKER_MEDIA,
eventType: EVENT_TYPE.UI,
attributes: {
exitMethod
}
};
dispatchAnalyticsEvent(payload);
}
if (isExperimentEnabled('platform_editor_fix_focus_MediaInsertPicker')) {
closeAndReturnFocusToButton();
} else {
closeMediaInsertPicker(); // Focuses editor on unmount
focusEditor();
}
};
const closePickerAndFocusEditor = () => {
if (isExperimentEnabled('platform_editor_fix_focus_MediaInsertPicker')) {
closeAndFocusEditor();
} else {
closeMediaInsertPicker();
focusEditor();
}
};
const fileTabTitle = expValEqualsNoExposure('cc_page_experiences_editor_image_generation', 'isEnabled', true) ? intl.formatMessage(mediaInsertMessages.uploadTabTitle) : intl.formatMessage(mediaInsertMessages.fileTabTitle);
return /*#__PURE__*/React.createElement(PopupWithListeners, {
ariaLabel: intl.formatMessage(mediaInsertMessages.mediaPickerPopupAriaLabel)
// eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed)
,
offset: [0, 12],
target: targetRef,
zIndex: akEditorFloatingDialogZIndex,
fitHeight: 390,
fitWidth: 340,
mountTo: mountPoint,
onUnmount: isExperimentEnabled('platform_editor_fix_focus_MediaInsertPicker') ? () => returnFocusRef.current() : undefined,
boundariesElement: popupsBoundariesElement,
handleClickOutside: handleClose(INPUT_METHOD.MOUSE),
handleEscapeKeydown: handleClose(INPUT_METHOD.KEYBOARD),
scrollableElement: popupsScrollableElement,
preventOverflow: true,
onPositionCalculated: onPositionCalculated,
focusTrap: true
}, /*#__PURE__*/React.createElement(PlainOutsideClickTargetRefContext.Consumer, null, setOutsideClickTargetRef => /*#__PURE__*/React.createElement(MediaInsertWrapper, {
ref: setOutsideClickTargetRef
}, /*#__PURE__*/React.createElement(Tabs, {
id: "media-insert-tab-navigation",
analyticsContext: tabsAnalyticsContext,
onChange: handleTabChange
}, /*#__PURE__*/React.createElement(Box, {
paddingBlockEnd: "space.150"
}, /*#__PURE__*/React.createElement(TabList, null, orderedTabs.map((tab, index) => /*#__PURE__*/React.createElement(TabWithAnalytics, {
key: tab.key,
onSelectTabForAnalytics: setSelectedTabAnalyticsMetadata,
selectedTabIndex: index,
tabCount: tabCount
}, tab.type === 'registered' ? tab.registeredTab.label : tab.type === 'upload' ? fileTabTitle : intl.formatMessage(mediaInsertMessages.linkTabTitle))))), orderedTabs.map(tab => {
if (tab.type === 'registered') {
const TabComponent = tab.registeredTab.component;
return /*#__PURE__*/React.createElement(CustomTabPanel, {
key: tab.key,
disablePaddingBlockEnd: true
}, /*#__PURE__*/React.createElement(TabComponent, {
closeMediaInsertPicker: closePickerAndFocusEditor,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
insertMediaSingle: insertMediaSingle,
mediaProvider: mediaProvider
}));
}
if (tab.type === 'upload') {
return /*#__PURE__*/React.createElement(CustomTabPanel, {
key: tab.key
}, /*#__PURE__*/React.createElement(LocalMedia, {
ref: autofocusRef,
mediaProvider: mediaProvider,
closeMediaInsertPicker: closePickerAndFocusEditor,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
insertFile: insertFile
}));
}
return /*#__PURE__*/React.createElement(CustomTabPanel, {
key: tab.key
}, /*#__PURE__*/React.createElement(MediaFromURL, {
mediaProvider: mediaProvider,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
closeMediaInsertPicker: closePickerAndFocusEditor,
cancelMediaInsertPicker: closeAndReturnFocusToButton,
insertMediaSingle: insertMediaSingle,
insertExternalMediaSingle: insertExternalMediaSingle,
isOnlyExternalLinks: isOnlyExternalLinks,
customizedUrlValidation: customizedUrlValidation,
customizedHelperMessage: customizedHelperMessage
}));
})))));
};