UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

1,022 lines (999 loc) 45.6 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import assert from 'assert'; import React from 'react'; import { RawIntlProvider } from 'react-intl'; // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead import uuid from 'uuid'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { getBrowserInfo } from '@atlaskit/editor-common/browser'; import { mediaInlineImagesEnabled } from '@atlaskit/editor-common/media-inline'; import { CAPTION_PLACEHOLDER_ID, getMaxWidthForNestedNodeNext } from '@atlaskit/editor-common/media-single'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { ErrorReporter } from '@atlaskit/editor-common/utils'; import { AllSelection, NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { insertPoint } from '@atlaskit/editor-prosemirror/transform'; import { findDomRefAtPos, findParentNodeOfType, findSelectedNodeOfType, isNodeSelection } from '@atlaskit/editor-prosemirror/utils'; import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { CellSelection } from '@atlaskit/editor-tables/cell-selection'; import { isFileIdentifier } from '@atlaskit/media-client'; import { getMediaFeatureFlag } from '@atlaskit/media-common'; import { fg } from '@atlaskit/platform-feature-flags'; // Ignored via go/ees005 // eslint-disable-next-line import/no-namespace import * as helpers from '../pm-plugins/commands/helpers'; import { updateMediaNodeAttrs } from '../pm-plugins/commands/helpers'; import { getIdentifier, getMediaFromSupportedMediaNodesFromSelection, isNodeDoubleClickSupportedInLivePagesViewMode, removeMediaNode, splitMediaGroup } from '../pm-plugins/utils/media-common'; import { insertMediaGroupNode, insertMediaInlineNode } from '../pm-plugins/utils/media-files'; import { getMediaNodeInsertionType } from '../pm-plugins/utils/media-inline'; import { insertMediaSingleNode } from '../pm-plugins/utils/media-single'; import DropPlaceholder from '../ui/Media/DropPlaceholder'; import { ACTIONS } from './actions'; import { MediaTaskManager } from './mediaTaskManager'; import PickerFacade from './picker-facade'; import { stateKey } from './plugin-key'; export const MEDIA_CONTENT_WRAP_CLASS_NAME = 'media-content-wrap'; export const MEDIA_PLUGIN_IS_RESIZING_KEY = 'mediaSinglePlugin.isResizing'; export const MEDIA_PLUGIN_RESIZING_WIDTH_KEY = 'mediaSinglePlugin.resizing-width'; const createDropPlaceholder = (intl, nodeViewPortalProviderAPI, dropPlaceholderKey, allowDropLine) => { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage const dropPlaceholder = document.createElement('div'); const createElement = React.createElement; if (allowDropLine) { nodeViewPortalProviderAPI.render(() => createElement(RawIntlProvider, { value: intl }, createElement(DropPlaceholder, { type: 'single' })), dropPlaceholder, dropPlaceholderKey); } else { nodeViewPortalProviderAPI.render(() => createElement(RawIntlProvider, { value: intl }, createElement(DropPlaceholder)), dropPlaceholder, dropPlaceholderKey); } return dropPlaceholder; }; const MEDIA_RESOLVED_STATES = ['ready', 'error', 'cancelled']; export class MediaPluginStateImplementation { constructor(_state, options, mediaOptions, _dispatch, pluginInjectionApi) { _defineProperty(this, "allowsUploads", false); _defineProperty(this, "ignoreLinks", false); _defineProperty(this, "waitForMediaUpload", true); _defineProperty(this, "allUploadsFinished", true); _defineProperty(this, "showDropzone", false); _defineProperty(this, "isFullscreen", false); _defineProperty(this, "layout", 'center'); _defineProperty(this, "mediaNodes", []); _defineProperty(this, "isResizing", false); _defineProperty(this, "resizingWidth", 0); _defineProperty(this, "allowInlineImages", false); _defineProperty(this, "uploadInProgressSubscriptions", []); _defineProperty(this, "uploadInProgressSubscriptionsNotified", false); // this is only a temporary variable, which gets cleared after the last inserted node has been selected _defineProperty(this, "lastAddedMediaSingleFileIds", []); _defineProperty(this, "destroyed", false); _defineProperty(this, "removeOnCloseListener", () => {}); _defineProperty(this, "onPopupToggleCallback", () => {}); _defineProperty(this, "identifierCount", new Map()); // This is to enable mediaShallowCopySope to enable only shallow copying media referenced within the edtior // see: trackOutOfScopeIdentifier _defineProperty(this, "outOfEditorScopeIdentifierMap", new Map()); _defineProperty(this, "taskManager", new MediaTaskManager()); _defineProperty(this, "pickers", []); _defineProperty(this, "pickerPromises", []); _defineProperty(this, "getMediaOptions", () => this.options); _defineProperty(this, "isMediaSchemaNode", ({ type }) => { var _this$mediaOptions; const { mediaInline, mediaSingle, media } = this.view.state.schema.nodes; if (getMediaFeatureFlag('mediaInline', (_this$mediaOptions = this.mediaOptions) === null || _this$mediaOptions === void 0 ? void 0 : _this$mediaOptions.featureFlags)) { return type === mediaSingle || type === media || type === mediaInline; } return type === mediaSingle; }); // callback to flag that a node has been inserted _defineProperty(this, "onNodeInserted", (id, selectionPosition) => { this.lastAddedMediaSingleFileIds.unshift({ id, selectionPosition }); }); /** * we insert a new file by inserting a initial state for that file. * * called when we insert a new file via the picker (connected via pickerfacade) */ _defineProperty(this, "insertFile", (mediaState, onMediaStateChanged, pickerType, insertMediaVia) => { var _this$pluginInjection, _this$pluginInjection2, _mediaState$collectio, _this$pluginInjection3, _this$pluginInjection4; const { state } = this.view; const editorAnalyticsAPI = (_this$pluginInjection = this.pluginInjectionApi) === null || _this$pluginInjection === void 0 ? void 0 : (_this$pluginInjection2 = _this$pluginInjection.analytics) === null || _this$pluginInjection2 === void 0 ? void 0 : _this$pluginInjection2.actions; const mediaStateWithContext = { ...mediaState, contextId: this.contextIdentifierProvider ? this.contextIdentifierProvider.objectId : undefined }; const collection = (_mediaState$collectio = mediaState.collection) !== null && _mediaState$collectio !== void 0 ? _mediaState$collectio : this.collectionFromProvider(); if (collection === undefined) { return; } // We need to dispatch the change to event dispatcher only for successful files if (mediaState.status !== 'error') { this.updateAndDispatch({ allUploadsFinished: false }); } if (this.uploadInProgressSubscriptions.length > 0 && !this.uploadInProgressSubscriptionsNotified) { this.uploadInProgressSubscriptions.forEach(fn => fn(true)); this.uploadInProgressSubscriptionsNotified = true; } switch (getMediaNodeInsertionType(state, this.mediaOptions, mediaStateWithContext.fileMimeType)) { case 'inline': insertMediaInlineNode(editorAnalyticsAPI)(this.view, mediaStateWithContext, collection, this.allowInlineImages, this.getInputMethod(pickerType), insertMediaVia); break; case 'block': // read width state right before inserting to get up-to-date and define values const widthPluginState = (_this$pluginInjection3 = this.pluginInjectionApi) === null || _this$pluginInjection3 === void 0 ? void 0 : (_this$pluginInjection4 = _this$pluginInjection3.width) === null || _this$pluginInjection4 === void 0 ? void 0 : _this$pluginInjection4.sharedState.currentState(); insertMediaSingleNode(this.view, mediaStateWithContext, this.getInputMethod(pickerType), collection, this.mediaOptions && this.mediaOptions.alignLeftOnInsert, widthPluginState, editorAnalyticsAPI, this.onNodeInserted, insertMediaVia, this.mediaOptions && this.mediaOptions.allowPixelResizing); break; case 'group': insertMediaGroupNode(editorAnalyticsAPI)(this.view, [mediaStateWithContext], collection, this.getInputMethod(pickerType), insertMediaVia); break; } // do events when media state changes onMediaStateChanged(this.handleMediaState); // handle waiting for upload complete const isEndState = state => state.status && MEDIA_RESOLVED_STATES.indexOf(state.status) !== -1; if (!isEndState(mediaStateWithContext)) { const uploadingPromise = new Promise(resolve => { onMediaStateChanged(newState => { // When media item reaches its final state, remove listener and resolve if (isEndState(newState)) { resolve(newState); } }); }); if (fg('platform_editor_media_disable_save_during_upload')) { this.taskManager.addPendingTask(uploadingPromise, mediaStateWithContext.id).then(() => { this.updateAndDispatch({ allUploadsFinished: true }); this.waitForPendingTasks().then(() => { if (this.uploadInProgressSubscriptions.length > 0 && this.uploadInProgressSubscriptionsNotified) { this.uploadInProgressSubscriptions.forEach(fn => fn(false)); this.uploadInProgressSubscriptionsNotified = false; } }); }); } else { this.taskManager.addPendingTask(uploadingPromise, mediaStateWithContext.id).then(() => { this.updateAndDispatch({ allUploadsFinished: true }); }); } } // refocus the view const { view } = this; if (!view.hasFocus()) { view.focus(); } if (isEndState(mediaStateWithContext) || !fg('platform_editor_media_disable_save_during_upload')) { this.waitForPendingTasks().then(() => { if (this.uploadInProgressSubscriptions.length > 0 && this.uploadInProgressSubscriptionsNotified) { this.uploadInProgressSubscriptions.forEach(fn => fn(false)); this.uploadInProgressSubscriptionsNotified = false; } }); } this.selectLastAddedMediaNode(); }); // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any _defineProperty(this, "addPendingTask", task => { this.taskManager.addPendingTask(task); }); _defineProperty(this, "splitMediaGroup", () => splitMediaGroup(this.view)); _defineProperty(this, "onPopupPickerClose", () => { this.onPopupToggleCallback(false); }); _defineProperty(this, "showMediaPicker", () => { if (this.openMediaPickerBrowser) { return this.openMediaPickerBrowser(); } this.onPopupToggleCallback(true); }); _defineProperty(this, "setBrowseFn", browseFn => { this.openMediaPickerBrowser = browseFn; }); _defineProperty(this, "onPopupToggle", onPopupToggleCallback => { this.onPopupToggleCallback = onPopupToggleCallback; }); /** * Returns a promise that is resolved after all pending operations have been finished. * An optional timeout will cause the promise to reject if the operation takes too long * * NOTE: The promise will resolve even if some of the media have failed to process. */ _defineProperty(this, "waitForPendingTasks", this.taskManager.waitForPendingTasks); /** * Called from React UI Component when user clicks on "Delete" icon * inside of it */ _defineProperty(this, "handleMediaNodeRemoval", (node, getPos) => { let getNode = node; if (!getNode) { const pos = getPos(); if (typeof pos !== 'number') { return; } getNode = this.view.state.doc.nodeAt(pos); } removeMediaNode(this.view, getNode, getPos); }); _defineProperty(this, "getIdentifierKey", identifier => { if (identifier.mediaItemType === 'file') { return identifier.id; } else { return identifier.dataURI; } }); _defineProperty(this, "trackMediaNodeAddition", node => { var _this$identifierCount; const identifier = getIdentifier(node.attrs); const key = this.getIdentifierKey(identifier); const { count } = (_this$identifierCount = this.identifierCount.get(key)) !== null && _this$identifierCount !== void 0 ? _this$identifierCount : { count: 0 }; if (count === 0) { this.taskManager.resumePendingTask(key); } this.identifierCount.set(key, { identifier, count: count + 1 }); }); _defineProperty(this, "trackMediaNodeRemoval", node => { var _this$identifierCount2; const identifier = getIdentifier(node.attrs); const key = this.getIdentifierKey(identifier); const { count } = (_this$identifierCount2 = this.identifierCount.get(key)) !== null && _this$identifierCount2 !== void 0 ? _this$identifierCount2 : { count: 0 }; if (count === 1) { this.taskManager.cancelPendingTask(key); } this.identifierCount.set(key, { identifier, count: count - 1 }); }); _defineProperty(this, "isIdentifierInEditorScope", identifier => { const key = this.getIdentifierKey(identifier); // rely on has instead of count > 0 because if the user cuts and pastes the same media // the count will temporarily be 0 but the media is still in the scope of editor. return !this.outOfEditorScopeIdentifierMap.has(key) && this.identifierCount.has(key); }); /** * This is used in on Paste of media, this tracks which if the pasted media originated from a outside the editor * i.e. the pasted media was not uplaoded to the current editor. * This is to enable mediaShallowCopySope to enable only shallow copying media referenced within the edtior */ _defineProperty(this, "trackOutOfScopeIdentifier", identifier => { const key = this.getIdentifierKey(identifier); this.outOfEditorScopeIdentifierMap.set(key, { identifier }); }); /** * Called from React UI Component on componentDidMount */ _defineProperty(this, "handleMediaNodeMount", (node, getPos) => { this.trackMediaNodeAddition(node); this.mediaNodes.unshift({ node, getPos }); }); /** * Called from React UI Component on componentWillUnmount and UNSAFE_componentWillReceiveProps * when React component's underlying node property is replaced with a new node */ _defineProperty(this, "handleMediaNodeUnmount", oldNode => { this.trackMediaNodeRemoval(oldNode); this.mediaNodes = this.mediaNodes.filter(({ node }) => oldNode !== node); }); _defineProperty(this, "handleMediaGroupUpdate", (oldNodes, newNodes) => { const addedNodes = newNodes.filter(node => oldNodes.every(oldNode => oldNode.attrs.id !== node.attrs.id)); const removedNodes = oldNodes.filter(node => newNodes.every(newNode => newNode.attrs.id !== node.attrs.id)); addedNodes.forEach(node => { this.trackMediaNodeAddition(node); }); removedNodes.forEach(oldNode => { this.trackMediaNodeRemoval(oldNode); }); }); _defineProperty(this, "findMediaNode", id => { return helpers.findMediaNode(this, id); }); _defineProperty(this, "destroyAllPickers", pickers => { pickers.forEach(picker => picker.destroy()); this.pickers.splice(0, this.pickers.length); }); _defineProperty(this, "destroyPickers", () => { const { pickers, pickerPromises } = this; // If pickerPromises and pickers are the same length // All pickers have resolved and we safely destroy them // Otherwise wait for them to resolve then destroy. if (pickerPromises.length === pickers.length) { this.destroyAllPickers(this.pickers); } else { Promise.all(pickerPromises).then(resolvedPickers => this.destroyAllPickers(resolvedPickers)); } this.customPicker = undefined; }); _defineProperty(this, "getInputMethod", pickerType => { switch (pickerType) { case INPUT_METHOD.PICKER_CLOUD: return INPUT_METHOD.PICKER_CLOUD; case INPUT_METHOD.MEDIA_PICKER: return INPUT_METHOD.MEDIA_PICKER; case 'clipboard': return INPUT_METHOD.CLIPBOARD; case 'dropzone': return INPUT_METHOD.DRAG_AND_DROP; case 'browser': return INPUT_METHOD.BROWSER; } return; }); _defineProperty(this, "updateMediaSingleNodeAttrs", (id, attrs) => { const { view } = this; if (!view) { return; } return updateMediaNodeAttrs(id, attrs)(view.state, view.dispatch); }); _defineProperty(this, "handleMediaState", state => { switch (state.status) { case 'error': const { uploadErrorHandler } = this.options; if (uploadErrorHandler) { uploadErrorHandler(state); } break; } }); _defineProperty(this, "removeSelectedMediaContainer", () => { const { view } = this; const selectedNode = this.selectedMediaContainerNode(); if (!selectedNode) { return false; } const { from } = view.state.selection; // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion removeMediaNode(view, selectedNode.firstChild, () => from + 1); return true; }); _defineProperty(this, "selectedMediaContainerNode", () => { var _this$view, _this$view$state; const selection = (_this$view = this.view) === null || _this$view === void 0 ? void 0 : (_this$view$state = _this$view.state) === null || _this$view$state === void 0 ? void 0 : _this$view$state.selection; if (selection instanceof NodeSelection && this.isMediaSchemaNode(selection.node)) { return selection.node; } return; }); _defineProperty(this, "handleDrag", dragState => { const isActive = dragState === 'enter'; if (this.showDropzone === isActive) { return; } this.showDropzone = isActive; const { dispatch, state } = this.view; const { tr, selection, doc } = state; const { media, mediaGroup } = state.schema.nodes; // Workaround for wrong upload position // @see https://product-fabric.atlassian.net/browse/MEX-2457 // If the media node is the last selectable item in the current cursor position and it is located within a mediaGroup, // we relocate the cursor to the first child of the mediaGroup. const pos = Math.max(0, selection.$from.pos - 1); const sel = Selection.findFrom(doc.resolve(pos), -1); if (sel && findSelectedNodeOfType(media)(sel)) { const parent = findParentNodeOfType(mediaGroup)(sel); if (parent) { tr.setSelection(NodeSelection.create(tr.doc, parent.start)); } } // Trigger state change to be able to pick it up in the decorations handler dispatch(tr); }); this.options = options; this.mediaOptions = mediaOptions; this.dispatch = _dispatch; this.pluginInjectionApi = pluginInjectionApi; this.waitForMediaUpload = options.waitForMediaUpload === undefined ? true : options.waitForMediaUpload; const { nodes } = _state.schema; assert(nodes.media && (nodes.mediaGroup || nodes.mediaSingle), 'Editor: unable to init media plugin - media or mediaGroup/mediaSingle node absent in schema'); if (mediaOptions !== null && mediaOptions !== void 0 && mediaOptions.syncProvider) { this.setMediaProvider(mediaOptions === null || mediaOptions === void 0 ? void 0 : mediaOptions.syncProvider); } else if (mediaOptions !== null && mediaOptions !== void 0 && mediaOptions.provider) { this.setMediaProvider(mediaOptions === null || mediaOptions === void 0 ? void 0 : mediaOptions.provider); } if (fg('platform_editor_remove_media_inline_feature_flag')) { var _this$mediaOptions2; if ((_this$mediaOptions2 = this.mediaOptions) !== null && _this$mediaOptions2 !== void 0 && _this$mediaOptions2.allowMediaInlineImages) { this.allowInlineImages = true; } } else { var _this$mediaOptions3, _this$mediaOptions4; if (mediaInlineImagesEnabled(getMediaFeatureFlag('mediaInline', (_this$mediaOptions3 = this.mediaOptions) === null || _this$mediaOptions3 === void 0 ? void 0 : _this$mediaOptions3.featureFlags), (_this$mediaOptions4 = this.mediaOptions) === null || _this$mediaOptions4 === void 0 ? void 0 : _this$mediaOptions4.allowMediaInlineImages)) { this.allowInlineImages = true; } } this.errorReporter = options.errorReporter || new ErrorReporter(); this.singletonCreatedAt = (performance || Date).now(); } clone() { var _originalTarget; const clonedAt = (performance || Date).now(); // Prevent double wrapping // If clone is repeatedly called, we want to proxy the underlying MediaPluginStateImplementation target, rather than the proxy itself // If we proxy the proxy, then calling get in future will need to recursively unwrap proxies to find the original target, which causes performance issues // Instead, we check if there is an original target stored on "this", and if so, we use that as the proxy target instead return new Proxy((_originalTarget = this.originalTarget) !== null && _originalTarget !== void 0 ? _originalTarget : this, { get(target, prop, receiver) { if (prop === 'singletonCreatedAt') { return clonedAt; } if (prop === 'originalTarget') { return target; } return Reflect.get(target, prop, receiver); } }); } subscribeToUploadInProgressState(fn) { this.uploadInProgressSubscriptions.push(fn); } unsubscribeFromUploadInProgressState(fn) { this.uploadInProgressSubscriptions = this.uploadInProgressSubscriptions.filter(subscribedFn => subscribedFn !== fn); } async setMediaProvider(mediaProvider) { // Prevent someone trying to set the exact same provider twice for performance reasons if (this.previousMediaProvider === mediaProvider) { return; } this.previousMediaProvider = mediaProvider; if (!mediaProvider) { this.destroyPickers(); this.allowsUploads = false; if (!this.destroyed) { this.view.dispatch(this.view.state.tr.setMeta(stateKey, { allowsUploads: this.allowsUploads })); } return; } // Ignored via go/ees007 // eslint-disable-next-line @atlaskit/editor/enforce-todo-comment-format // TODO disable (not destroy!) pickers until mediaProvider is resolved try { if (mediaProvider instanceof Promise) { this.mediaProvider = await mediaProvider; } else { this.mediaProvider = mediaProvider; } // Ignored via go/ees007 // eslint-disable-next-line @atlaskit/editor/enforce-todo-comment-format // TODO [MS-2038]: remove once context api is removed // We want to re assign the view and upload configs if they are missing for backwards compatibility // as currently integrators can pass context || mediaClientConfig if (!this.mediaProvider.viewMediaClientConfig) { const viewMediaClientConfig = this.mediaProvider.viewMediaClientConfig; if (viewMediaClientConfig) { this.mediaProvider.viewMediaClientConfig = viewMediaClientConfig; } } assert(this.mediaProvider.viewMediaClientConfig, `MediaProvider promise did not resolve to a valid instance of MediaProvider - ${this.mediaProvider}`); } catch (err) { const wrappedError = new Error(`Media functionality disabled due to rejected provider: ${err instanceof Error ? err.message : String(err)}`); this.errorReporter.captureException(wrappedError); this.destroyPickers(); this.allowsUploads = false; if (!this.destroyed) { this.view.dispatch(this.view.state.tr.setMeta(stateKey, { allowsUploads: this.allowsUploads })); } return; } this.mediaClientConfig = this.mediaProvider.viewMediaClientConfig; this.allowsUploads = !!this.mediaProvider.uploadMediaClientConfig; const { view, allowsUploads } = this; // make sure editable DOM node is mounted if (!this.destroyed && view && view.dom.parentNode) { // make PM plugin aware of the state change to update UI during 'apply' hook view.dispatch(view.state.tr.setMeta(stateKey, { allowsUploads })); } if (this.allowsUploads) { this.uploadMediaClientConfig = this.mediaProvider.uploadMediaClientConfig; if (this.mediaProvider.uploadParams && this.uploadMediaClientConfig) { await this.initPickers(this.mediaProvider.uploadParams, PickerFacade); } else { this.destroyPickers(); } } else { this.destroyPickers(); } } setIsResizing(isResizing) { this.isResizing = isResizing; } setResizingWidth(width) { this.resizingWidth = width; } updateElement() { let newElement; const selectedContainer = this.selectedMediaContainerNode(); if (selectedContainer && this.isMediaSchemaNode(selectedContainer)) { newElement = this.getDomElement(this.view.domAtPos.bind(this.view)); if (selectedContainer.type === this.view.state.schema.nodes.mediaSingle) { this.currentMaxWidth = getMaxWidthForNestedNodeNext(this.view, this.view.state.selection.$anchor.pos) || undefined; } else { this.currentMaxWidth = undefined; } } if (this.element !== newElement) { this.element = newElement; } } getDomElement(domAtPos) { const { selection } = this.view.state; if (!(selection instanceof NodeSelection)) { return; } if (!this.isMediaSchemaNode(selection.node)) { return; } const node = findDomRefAtPos(selection.from, domAtPos); if (node) { if (!node.childNodes.length) { return node.parentNode; } return node; } return; } get contextIdentifierProvider() { var _this$pluginInjection5, _this$pluginInjection6, _this$pluginInjection7; return (_this$pluginInjection5 = this.pluginInjectionApi) === null || _this$pluginInjection5 === void 0 ? void 0 : (_this$pluginInjection6 = _this$pluginInjection5.contextIdentifier) === null || _this$pluginInjection6 === void 0 ? void 0 : (_this$pluginInjection7 = _this$pluginInjection6.sharedState.currentState()) === null || _this$pluginInjection7 === void 0 ? void 0 : _this$pluginInjection7.contextIdentifierProvider; } selectLastAddedMediaNode() { var _this$mediaOptions5; // if preventAutoFocusOnUpload is enabled, skip auto-selection and just clear the tracking array if ((_this$mediaOptions5 = this.mediaOptions) !== null && _this$mediaOptions5 !== void 0 && _this$mediaOptions5.preventAutoFocusOnUpload) { this.lastAddedMediaSingleFileIds = []; return; } // if lastAddedMediaSingleFileIds is empty exit because there are no added media single nodes to be selected if (this.lastAddedMediaSingleFileIds.length !== 0) { this.waitForPendingTasks().then(() => { const lastTrackedAddedNode = this.lastAddedMediaSingleFileIds[0]; // execute selection only if selection did not change after the node has been inserted if ((lastTrackedAddedNode === null || lastTrackedAddedNode === void 0 ? void 0 : lastTrackedAddedNode.selectionPosition) === this.view.state.selection.from) { const lastAddedNode = this.mediaNodes.find(node => { return node.node.attrs.id === lastTrackedAddedNode.id; }); const lastAddedNodePos = lastAddedNode === null || lastAddedNode === void 0 ? void 0 : lastAddedNode.getPos(); if (lastAddedNodePos) { const { dispatch, state } = this.view; const tr = state.tr; tr.setSelection(NodeSelection.create(tr.doc, lastAddedNodePos)); if (dispatch) { dispatch(tr); } } } // reset temp constant after uploads finished this.lastAddedMediaSingleFileIds = []; }); } } setView(view) { this.view = view; } destroy() { if (this.destroyed) { return; } this.destroyed = true; const { mediaNodes } = this; mediaNodes.splice(0, mediaNodes.length); this.removeOnCloseListener(); this.destroyPickers(); } async initPickers(uploadParams, Picker) { if (this.destroyed || !this.uploadMediaClientConfig) { return; } const { errorReporter, pickers, pickerPromises } = this; // create pickers if they don't exist, re-use otherwise if (!pickers.length) { const pickerFacadeConfig = { mediaClientConfig: this.uploadMediaClientConfig, errorReporter }; if (this.options.customMediaPicker) { const customPicker = new Picker('customMediaPicker', pickerFacadeConfig, this.options.customMediaPicker).init(); pickerPromises.push(customPicker); pickers.push(this.customPicker = await customPicker); } pickers.forEach(picker => { picker.onNewMedia(this.insertFile); }); } // set new upload params for the pickers pickers.forEach(picker => picker.setUploadParams(uploadParams)); } collectionFromProvider() { return this.mediaProvider && this.mediaProvider.uploadParams && this.mediaProvider.uploadParams.collection; } updateAndDispatch(props) { // update plugin state Object.keys(props).forEach(_key => { const key = _key; const value = props[key]; if (value !== undefined) { this[key] = value; } }); if (this.dispatch) { this.dispatch(stateKey, { ...this }); } } } export const getMediaPluginState = state => stateKey.getState(state); export const createPlugin = (_schema, options, getIntl, pluginInjectionApi, nodeViewPortalProviderAPI, dispatch, mediaOptions) => { const intl = getIntl(); return new SafePlugin({ state: { init(_config, state) { return new MediaPluginStateImplementation(state, options, mediaOptions, dispatch, pluginInjectionApi); }, apply(tr, pluginState) { var _tr$getMeta; const isResizing = tr.getMeta(MEDIA_PLUGIN_IS_RESIZING_KEY); const resizingWidth = tr.getMeta(MEDIA_PLUGIN_RESIZING_WIDTH_KEY); const mediaProvider = (_tr$getMeta = tr.getMeta(stateKey)) === null || _tr$getMeta === void 0 ? void 0 : _tr$getMeta.mediaProvider; // Yes, I agree with you; this approach, using the clone() fuction, below is horrifying. // However, we needed to implement this workaround to solve the singleton Media PluginState. // The entire PluginInjectionAPI relies on the following axiom: "A PluginState that reflects a new EditorState.". We can not have the mutable singleton instance for all EditorState. // Unfortunately, we can't implement a proper fix for this media state situation. So, we are faking a new object using a Proxy instance. let nextPluginState = pluginState; if (isResizing !== undefined) { pluginState.setIsResizing(isResizing); nextPluginState = nextPluginState.clone(); } if (mediaProvider) { pluginState.setMediaProvider(mediaProvider); } if (resizingWidth) { pluginState.setResizingWidth(resizingWidth); nextPluginState = nextPluginState.clone(); } // remap editing media single position if we're in collab if (typeof pluginState.editingMediaSinglePos === 'number') { pluginState.editingMediaSinglePos = tr.mapping.map(pluginState.editingMediaSinglePos); nextPluginState = nextPluginState.clone(); } const meta = tr.getMeta(stateKey); if (meta) { const { allowsUploads } = meta; pluginState.updateAndDispatch({ allowsUploads: typeof allowsUploads === 'undefined' ? pluginState.allowsUploads : allowsUploads }); nextPluginState = nextPluginState.clone(); } // ACTIONS switch (meta === null || meta === void 0 ? void 0 : meta.type) { case ACTIONS.SHOW_MEDIA_VIEWER: pluginState.mediaViewerSelectedMedia = meta.mediaViewerSelectedMedia; pluginState.isMediaViewerVisible = meta.isMediaViewerVisible; nextPluginState = nextPluginState.clone(); break; case ACTIONS.HIDE_MEDIA_VIEWER: pluginState.mediaViewerSelectedMedia = undefined; pluginState.isMediaViewerVisible = meta.isMediaViewerVisible; nextPluginState = nextPluginState.clone(); break; case ACTIONS.TRACK_MEDIA_PASTE: const { identifier } = meta; const isIdentifierInEditorScope = pluginState.isIdentifierInEditorScope(identifier); if (!isIdentifierInEditorScope && isFileIdentifier(identifier)) { pluginState.trackOutOfScopeIdentifier(identifier); nextPluginState = pluginState.clone(); } break; } // NOTE: We're not calling passing new state to the Editor, because we depend on the view.state reference // throughout the lifetime of view. We injected the view into the plugin state, because we dispatch() // transformations from within the plugin state (i.e. when adding a new file). return nextPluginState; } }, appendTransaction(transactions, _oldState, newState) { for (const transaction of transactions) { const isSelectionOnMediaInsideMediaSingle = transaction.selectionSet && isNodeSelection(transaction.selection) && transaction.selection.node.type === newState.schema.nodes.media && transaction.selection.$anchor.parent.type === newState.schema.nodes.mediaSingle; // Note: this causes an additional transaction when selecting a media node // through clicking on it with the cursor. if (isSelectionOnMediaInsideMediaSingle) { // If a selection has been placed on a media inside a media single, // we shift it to the media single parent as other code is opinionated about // the selection landing there. In particular the caption insertion and selection // action. return newState.tr.setSelection(NodeSelection.create(newState.doc, transaction.selection.$from.pos - 1)); } } return; }, key: stateKey, view: view => { const pluginState = getMediaPluginState(view.state); pluginState.setView(view); pluginState.updateElement(); return { update: () => { pluginState.updateElement(); } }; }, props: { decorations: state => { // Use this to indicate that the media node is selected const mediaNodes = []; const { schema, selection: { $anchor }, doc } = state; // Find any media nodes in the current selection if (state.selection instanceof TextSelection || state.selection instanceof AllSelection || state.selection instanceof CellSelection) { doc.nodesBetween(state.selection.from, state.selection.to, (node, pos) => { if (node.type === schema.nodes.media) { mediaNodes.push(Decoration.node(pos, pos + node.nodeSize, {}, { type: 'media', selected: true })); return false; } return true; }); } else if (state.selection instanceof NodeSelection) { const { node, $from } = state.selection; if (node.type === schema.nodes.mediaSingle || node.type === schema.nodes.mediaGroup) { doc.nodesBetween($from.pos, $from.pos + node.nodeSize, (mediaNode, mediaPos) => { if (mediaNode.type === schema.nodes.media) { mediaNodes.push(Decoration.node(mediaPos, mediaPos + mediaNode.nodeSize, {}, { type: 'media', selected: true })); return false; } return true; }); } } const pluginState = getMediaPluginState(state); if (!pluginState.showDropzone) { return DecorationSet.create(state.doc, mediaNodes); } // When a media is already selected if (state.selection instanceof NodeSelection) { const node = state.selection.node; if (node.type === schema.nodes.mediaSingle) { const deco = Decoration.node(state.selection.from, state.selection.to, { class: 'richMedia-selected' }); return DecorationSet.create(state.doc, [deco, ...mediaNodes]); } return DecorationSet.create(state.doc, mediaNodes); } let pos = $anchor.pos; if ($anchor.parent.type !== schema.nodes.paragraph && $anchor.parent.type !== schema.nodes.codeBlock) { pos = insertPoint(state.doc, pos, schema.nodes.mediaGroup); } if (pos === null || pos === undefined) { return DecorationSet.create(state.doc, mediaNodes); } // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead const dropPlaceholderKey = uuid(); const dropPlaceholders = [Decoration.widget(pos, () => { return createDropPlaceholder(intl, nodeViewPortalProviderAPI, dropPlaceholderKey, mediaOptions && mediaOptions.allowDropzoneDropLine); }, { key: 'drop-placeholder', destroy: elem => { if (elem instanceof HTMLElement) { nodeViewPortalProviderAPI.remove(dropPlaceholderKey); } } }), ...mediaNodes]; return DecorationSet.create(state.doc, dropPlaceholders); }, nodeViews: options.nodeViews, handleTextInput(view, from, to, text) { const { selection } = view.state; if (text === ' ' && selection instanceof NodeSelection && selection.node.type.name === 'mediaSingle') { var _stateKey$getState; const videoControlsWrapperRef = (_stateKey$getState = stateKey.getState(view.state)) === null || _stateKey$getState === void 0 ? void 0 : _stateKey$getState.element; const videoControls = videoControlsWrapperRef === null || videoControlsWrapperRef === void 0 ? void 0 : videoControlsWrapperRef.querySelectorAll('button, [tabindex]:not([tabindex="-1"])'); if (videoControls) { const isVideoControl = Array.from(videoControls).some(videoControl => { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage return document.activeElement === videoControl; }); if (isVideoControl) { return true; } } } getMediaPluginState(view.state).splitMediaGroup(); return false; }, handleClick: (_editorView, _pos, event) => { var _event$target; // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting const clickedInsideCaptionPlaceholder = (_event$target = event.target) === null || _event$target === void 0 ? void 0 : _event$target.closest(`[data-id="${CAPTION_PLACEHOLDER_ID}"]`); const browser = getBrowserInfo(); // Workaround for Chrome given a regression introduced in prosemirror-view@1.18.6 // Returning true prevents that updateSelection() is getting called in the commit below: // @see https://github.com/ProseMirror/prosemirror-view/compare/1.18.5...1.18.6 if ((browser.chrome || browser.safari) && clickedInsideCaptionPlaceholder) { return true; } // Workaound for iOS 16 Caption selection issue // @see https://product-fabric.atlassian.net/browse/MEX-2012 if (browser.ios) { var _event$target2; // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting return !!((_event$target2 = event.target) !== null && _event$target2 !== void 0 && _event$target2.closest(`[class="${MEDIA_CONTENT_WRAP_CLASS_NAME}"]`)); } return false; }, handleDoubleClickOn: view => { var _pluginState$mediaOpt, _pluginInjectionApi$e, _pluginInjectionApi$e2; // Check if media viewer is enabled const pluginState = getMediaPluginState(view.state); if (!((_pluginState$mediaOpt = pluginState.mediaOptions) !== null && _pluginState$mediaOpt !== void 0 && _pluginState$mediaOpt.allowImagePreview)) { return false; } const isLivePagesViewMode = (pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e = pluginInjectionApi.editorViewMode) === null || _pluginInjectionApi$e === void 0 ? void 0 : (_pluginInjectionApi$e2 = _pluginInjectionApi$e.sharedState.currentState()) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.mode) === 'view'; // Double Click support for Media Viewer Nodes const maybeMediaNode = getMediaFromSupportedMediaNodesFromSelection(view.state); if (maybeMediaNode) { var _pluginInjectionApi$a; // If media type is video, do not open media viewer if (!isNodeDoubleClickSupportedInLivePagesViewMode(isLivePagesViewMode, maybeMediaNode)) { return false; } // Show media viewer pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.core.actions.execute(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.media.commands.showMediaViewer(maybeMediaNode.attrs)); // Call analytics event pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$a = pluginInjectionApi.analytics) === null || _pluginInjectionApi$a === void 0 ? void 0 : _pluginInjectionApi$a.actions.fireAnalyticsEvent({ action: ACTION.OPENED, actionSubject: ACTION_SUBJECT.MEDIA_VIEWER, actionSubjectId: ACTION_SUBJECT_ID.MEDIA, eventType: EVENT_TYPE.UI, attributes: { nodeType: maybeMediaNode.type.name, inputMethod: INPUT_METHOD.DOUBLE_CLICK } }); return true; } return false; }, handleDOMEvents: { keydown: (view, event) => { const { selection } = view.state; if (selection instanceof NodeSelection && selection.node.type.name === 'mediaSingle') { // handle keydown events for video controls panel to prevent fire of rest prosemirror listeners; if ((event === null || event === void 0 ? void 0 : event.target) instanceof HTMLElement) { const a11yDefaultKeys = ['Tab', 'Space', 'Enter', 'Shift', 'Esc']; const targetsAndButtons = { button: a11yDefaultKeys, range: [...a11yDefaultKeys, 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'], combobox: [...a11yDefaultKeys, 'ArrowDown', 'ArrowUp', 'Esc'], slider: ['Tab', 'Shift', 'ArrowLeft', 'ArrowRight'] }; const targetRole = event.target.role; const targetType = event.target.type; const allowedTargets = targetRole || targetType; // only if targeting interactive elements fe. button, slider, range, dropdown if (allowedTargets && allowedTargets in targetsAndButtons) { const targetRelatedA11YKeys = targetsAndButtons[allowedTargets]; const allowedKeys = new Set(targetRelatedA11YKeys); if (allowedKeys.has(event.key) || allowedKeys.has(event.code)) { // allow event to bubble to be handled by react handlers return true; } else { // otherwise focus editor to allow setting gapCursor. (e.g.: arrowRightFromMediaSingle) view.focus(); } } } } // fire regular prosemirror listeners; return false; } } } }); };