@atlaskit/editor-plugin-media
Version:
Media plugin for @atlaskit/editor-core
420 lines (418 loc) • 18.7 kB
JavaScript
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);
}
}
};
};