UNPKG

@atlaskit/editor-plugin-media-insert

Version:

Media Insert plugin for @atlaskit/editor-core

383 lines (382 loc) 16 kB
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 })); }))))); };