UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

420 lines (418 loc) 18.7 kB
import React, { useMemo } from 'react'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks'; import { toolbarInsertBlockMessages as messages } from '@atlaskit/editor-common/messages'; import { IconImages } from '@atlaskit/editor-common/quick-insert'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { areToolbarFlagsEnabled } from '@atlaskit/editor-common/toolbar-flag-check'; import { NodeSelection, PluginKey } from '@atlaskit/editor-prosemirror/state'; import { getMediaFeatureFlag } from '@atlaskit/media-common'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { lazyMediaGroupView } from './nodeviews/lazy-media-group'; import { lazyMediaInlineView } from './nodeviews/lazy-media-inline'; import { ReactMediaNode } from './nodeviews/mediaNodeView'; import { ReactMediaSingleNode } from './nodeviews/mediaSingle'; import { mediaSpecWithFixedToDOM } from './nodeviews/toDOM-fixes/media'; import { mediaGroupSpecWithFixedToDOM } from './nodeviews/toDOM-fixes/mediaGroup'; import { mediaInlineSpecWithFixedToDOM } from './nodeviews/toDOM-fixes/mediaInline'; import { mediaSingleSpecWithFixedToDOM } from './nodeviews/toDOM-fixes/mediaSingle'; import { createAIGeneratingDecorationPlugin } from './pm-plugins/ai-generating-decoration'; import { createPlugin as createMediaAltTextPlugin } from './pm-plugins/alt-text'; import keymapMediaAltTextPlugin from './pm-plugins/alt-text/keymap'; import { clearAIGenerating, hideMediaViewer, insertMediaAsMediaSingleCommand, setAIGenerating, showMediaViewer, trackMediaPaste } from './pm-plugins/commands'; import keymapPlugin from './pm-plugins/keymap'; import keymapMediaSinglePlugin from './pm-plugins/keymap-media'; import linkingPlugin from './pm-plugins/linking'; import keymapLinkingPlugin from './pm-plugins/linking/keymap'; import { createPlugin } from './pm-plugins/main'; import { createPlugin as createMediaPixelResizingPlugin } from './pm-plugins/pixel-resizing'; import { stateKey } from './pm-plugins/plugin-key'; import { createMediaIdentifierArray, extractMediaNodes } from './pm-plugins/utils/media-common'; import { insertMediaAsMediaSingle } from './pm-plugins/utils/media-single'; import { MediaPickerComponents } from './ui/MediaPicker'; import { RenderMediaViewer } from './ui/MediaViewer/PortalWrapper'; import { floatingToolbar } from './ui/toolbar'; import ToolbarMedia from './ui/ToolbarMedia'; const selector = states => { var _states$mediaState, _states$mediaState2; return { onPopupToggle: (_states$mediaState = states.mediaState) === null || _states$mediaState === void 0 ? void 0 : _states$mediaState.onPopupToggle, setBrowseFn: (_states$mediaState2 = states.mediaState) === null || _states$mediaState2 === void 0 ? void 0 : _states$mediaState2.setBrowseFn }; }; const MediaPickerFunctionalComponent = ({ api, editorDomElement, appearance }) => { const { onPopupToggle, setBrowseFn } = useSharedPluginStateWithSelector(api, ['media'], selector); if (!onPopupToggle || !setBrowseFn) { return null; } return /*#__PURE__*/React.createElement(MediaPickerComponents, { onPopupToggle: onPopupToggle, setBrowseFn: setBrowseFn, editorDomElement: editorDomElement, appearance: appearance, api: api }); }; const mediaViewerStateSelector = states => { var _states$mediaState3, _states$mediaState4, _states$mediaState5; return { isMediaViewerVisible: (_states$mediaState3 = states.mediaState) === null || _states$mediaState3 === void 0 ? void 0 : _states$mediaState3.isMediaViewerVisible, mediaViewerSelectedMedia: (_states$mediaState4 = states.mediaState) === null || _states$mediaState4 === void 0 ? void 0 : _states$mediaState4.mediaViewerSelectedMedia, mediaClientConfig: (_states$mediaState5 = states.mediaState) === null || _states$mediaState5 === void 0 ? void 0 : _states$mediaState5.mediaClientConfig }; }; const MediaViewerFunctionalComponent = ({ api, editorView, mediaViewerExtensions }) => { // Only traverse document once when media viewer is visible, media viewer items will not update // when document changes are made while media viewer is open const { isMediaViewerVisible, mediaViewerSelectedMedia, mediaClientConfig } = useSharedPluginStateWithSelector(api, ['media'], mediaViewerStateSelector); const mediaItems = useMemo(() => { if (isMediaViewerVisible) { const mediaNodes = extractMediaNodes(editorView.state.doc); return createMediaIdentifierArray(mediaNodes); } // eslint-disable-next-line react-hooks/exhaustive-deps -- only update mediaItems once when media viewer is visible }, [isMediaViewerVisible]); // Viewer does not have required attributes to render the media viewer if (!isMediaViewerVisible || !mediaViewerSelectedMedia || !mediaClientConfig) { return null; } const handleOnClose = () => { // Run Command to hide the media viewer api === null || api === void 0 ? void 0 : api.core.actions.execute(api === null || api === void 0 ? void 0 : api.media.commands.hideMediaViewer); }; return /*#__PURE__*/React.createElement(RenderMediaViewer, { mediaClientConfig: mediaClientConfig, onClose: handleOnClose, selectedNodeAttrs: mediaViewerSelectedMedia, items: mediaItems, extensions: mediaViewerExtensions }); }; export const mediaPlugin = ({ config: options = {}, api }) => { var _api$analytics3; let previousMediaProvider; const mediaErrorLocalIds = new Set(); return { name: 'media', getSharedState(editorState) { if (!editorState) { return null; } return stateKey.getState(editorState) || null; }, actions: { handleMediaNodeRenderError: (node, reason, nestedUnder) => { var _api$analytics; let isDuplicateError = false; if (expValEquals('platform_editor_media_reliability_observability', 'isEnabled', true)) { if (mediaErrorLocalIds.has(node.attrs.localId)) { // we mark duplicate errors in the case of nested media nodes // renderering to avoid firing multiple errored events for the same underlying issue, // which can happen more often with nested media nodes isDuplicateError = true; } else { mediaErrorLocalIds.add(node.attrs.localId); } } api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({ action: ACTION.ERRORED, actionSubject: ACTION_SUBJECT.EDITOR, actionSubjectId: ACTION_SUBJECT_ID.MEDIA, eventType: EVENT_TYPE.UI, attributes: { reason, external: node.attrs.__external, ...(nestedUnder && editorExperiment('platform_synced_block', true) ? { nestedUnder } : {}), ...(editorExperiment('platform_synced_block', true) ? { isDuplicateError } : {}) } }); }, insertMediaAsMediaSingle: (view, node, inputMethod, insertMediaVia) => { var _api$analytics2; return insertMediaAsMediaSingle(view, node, inputMethod, api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions, insertMediaVia, options === null || options === void 0 ? void 0 : options.allowPixelResizing); }, setProvider: provider => { var _api$core$actions$exe; // Prevent someone trying to set the exact same provider twice for performance reasons if (previousMediaProvider === provider) { return false; } previousMediaProvider = provider; return (_api$core$actions$exe = api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => tr.setMeta(stateKey, { mediaProvider: provider }))) !== null && _api$core$actions$exe !== void 0 ? _api$core$actions$exe : false; } }, commands: { showMediaViewer, hideMediaViewer, trackMediaPaste, setAIGenerating, clearAIGenerating, insertMediaSingle: insertMediaAsMediaSingleCommand(api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : _api$analytics3.actions, options.allowPixelResizing) }, nodes() { const { allowMediaGroup = true, allowMediaSingle = false, allowPixelResizing = false, allowCaptions, allowMediaInlineImages, featureFlags: mediaFeatureFlags } = options || {}; const allowMediaInline = fg('platform_editor_remove_media_inline_feature_flag') ? allowMediaInlineImages : getMediaFeatureFlag('mediaInline', mediaFeatureFlags); const mediaSingleOption = { withCaption: allowCaptions, withExtendedWidthTypes: allowPixelResizing }; return [{ name: 'mediaGroup', node: mediaGroupSpecWithFixedToDOM() }, { name: 'mediaSingle', node: mediaSingleSpecWithFixedToDOM(mediaSingleOption) }, { name: 'media', node: mediaSpecWithFixedToDOM() }, { name: 'mediaInline', node: mediaInlineSpecWithFixedToDOM() }].filter(node => { if (node.name === 'mediaGroup') { return allowMediaGroup; } if (node.name === 'mediaSingle') { return allowMediaSingle; } if (node.name === 'mediaInline') { return allowMediaInline; } return true; }); }, pmPlugins() { const pmPlugins = [{ name: 'media', plugin: ({ schema, dispatch, getIntl, eventDispatcher, providerFactory, errorReporter, portalProviderAPI, dispatchAnalyticsEvent, nodeViewPortalProviderAPI }) => { return createPlugin(schema, { providerFactory, nodeViews: { mediaGroup: lazyMediaGroupView(portalProviderAPI, eventDispatcher, providerFactory, options, api), mediaSingle: ReactMediaSingleNode(portalProviderAPI, eventDispatcher, providerFactory, api, dispatchAnalyticsEvent, options), media: ReactMediaNode(portalProviderAPI, eventDispatcher, providerFactory, options, api), mediaInline: lazyMediaInlineView(portalProviderAPI, eventDispatcher, providerFactory, api, undefined, options === null || options === void 0 ? void 0 : options.fallbackMediaNameFetcher) }, errorReporter, uploadErrorHandler: options && options.uploadErrorHandler, waitForMediaUpload: options && options.waitForMediaUpload, customDropzoneContainer: options && options.customDropzoneContainer, customMediaPicker: options && options.customMediaPicker, allowResizing: !!(options && options.allowResizing) }, getIntl, api, nodeViewPortalProviderAPI, dispatch, options); } }, { name: 'mediaKeymap', plugin: ({ getIntl }) => { var _api$analytics4, _api$selection; return keymapPlugin(options, api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : _api$analytics4.actions, api === null || api === void 0 ? void 0 : (_api$selection = api.selection) === null || _api$selection === void 0 ? void 0 : _api$selection.actions, api === null || api === void 0 ? void 0 : api.width, getIntl); } }]; if (options && options.allowMediaSingle) { pmPlugins.push({ name: 'mediaSingleKeymap', plugin: ({ schema }) => keymapMediaSinglePlugin(schema) }); } if (options && options.allowAltTextOnImages) { pmPlugins.push({ name: 'mediaAltText', plugin: createMediaAltTextPlugin }); pmPlugins.push({ name: 'mediaAltTextKeymap', plugin: ({ schema }) => { var _api$analytics5; return keymapMediaAltTextPlugin(schema, api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions); } }); } if (options && options.allowLinking) { pmPlugins.push({ name: 'mediaLinking', plugin: ({ dispatch }) => linkingPlugin(dispatch) }); pmPlugins.push({ name: 'mediaLinkingKeymap', plugin: ({ schema }) => keymapLinkingPlugin(schema) }); } if (options && options.allowAdvancedToolBarOptions && options.allowResizing && areToolbarFlagsEnabled(Boolean(api === null || api === void 0 ? void 0 : api.toolbar)) && options.allowPixelResizing) { pmPlugins.push({ name: 'mediaPixelResizing', plugin: createMediaPixelResizingPlugin }); } pmPlugins.push({ name: 'mediaAIGeneratingDecoration', plugin: () => createAIGeneratingDecorationPlugin() }); pmPlugins.push({ name: 'mediaSelectionHandler', plugin: () => { const mediaSelectionHandlerPlugin = new SafePlugin({ key: new PluginKey('mediaSelectionHandlerPlugin'), props: { handleScrollToSelection: view => { const { state: { selection } } = view; if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'media') { return false; } const { node, offset } = view.domAtPos(selection.from); if ( // Is the media element mounted already? offset === node.childNodes.length) { // Media is not ready, so stop the scroll request return true; } // Media is ready, keep the scrolling request return false; } } }); return mediaSelectionHandlerPlugin; } }); return pmPlugins; }, contentComponent({ editorView, appearance }) { if (!editorView) { return null; } return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(MediaViewerFunctionalComponent, { api: api, editorView: editorView, mediaViewerExtensions: options === null || options === void 0 ? void 0 : options.mediaViewerExtensions }), /*#__PURE__*/React.createElement(MediaPickerFunctionalComponent, { editorDomElement: editorView.dom, appearance: appearance, api: api })); }, secondaryToolbarComponent({ disabled }) { return /*#__PURE__*/React.createElement(ToolbarMedia, { isDisabled: disabled, isReducedSpacing: true, api: api }); }, pluginsOptions: { quickInsert: ({ formatMessage }) => !(api !== null && api !== void 0 && api.mediaInsert) ? [{ id: 'media', title: formatMessage(messages.mediaFiles), description: formatMessage(messages.mediaFilesDescription), priority: 400, keywords: ['attachment', 'gif', 'media', 'picture', 'image', 'video', 'file'], icon: () => /*#__PURE__*/React.createElement(IconImages, null), isDisabledOffline: true, action(insert, state) { var _api$analytics6; const pluginState = stateKey.getState(state); pluginState === null || pluginState === void 0 ? void 0 : pluginState.showMediaPicker(); const tr = insert(''); api === null || api === void 0 ? void 0 : (_api$analytics6 = api.analytics) === null || _api$analytics6 === void 0 ? void 0 : _api$analytics6.actions.attachAnalyticsEvent({ action: ACTION.OPENED, actionSubject: ACTION_SUBJECT.PICKER, actionSubjectId: ACTION_SUBJECT_ID.PICKER_CLOUD, attributes: { inputMethod: INPUT_METHOD.QUICK_INSERT }, eventType: EVENT_TYPE.UI })(tr); return tr; } }] : [], floatingToolbar: (state, intl, providerFactory) => { var _api$editorViewMode, _api$editorViewMode$s; return floatingToolbar(state, intl, { providerFactory, allowMediaInline: options && getMediaFeatureFlag('mediaInline', options.featureFlags), allowResizing: options && options.allowResizing, allowResizingInTables: options && options.allowResizingInTables, allowCommentsOnMedia: options && options.allowCommentsOnMedia, allowLinking: options && options.allowLinking, allowAdvancedToolBarOptions: options && options.allowAdvancedToolBarOptions, allowAltTextOnImages: options && options.allowAltTextOnImages, allowImageEditing: !!(api !== null && api !== void 0 && api.mediaEditing) && options && options.allowImageEditing, allowImagePreview: options && options.allowImagePreview, altTextValidator: options && options.altTextValidator, fullWidthEnabled: options && options.fullWidthEnabled, allowMediaInlineImages: options && options.allowMediaInlineImages, isViewOnly: (api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode) === 'view', allowPixelResizing: options === null || options === void 0 ? void 0 : options.allowPixelResizing, onCommentButtonMount: options === null || options === void 0 ? void 0 : options.onCommentButtonMount, createCommentExperience: options === null || options === void 0 ? void 0 : options.createCommentExperience }, api); } } }; };