UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

704 lines (687 loc) 28.7 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import assert from 'assert'; import React from 'react'; import ReactDOM from 'react-dom'; import { RawIntlProvider } from 'react-intl-next'; import { INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { CAPTION_PLACEHOLDER_ID, getMaxWidthForNestedNodeNext } from '@atlaskit/editor-common/media-single'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { browser, 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 { getMediaFeatureFlag } from '@atlaskit/media-common'; import { getBooleanFF } from '@atlaskit/platform-feature-flags'; import * as helpers from '../commands/helpers'; import { updateMediaSingleNodeAttrs } from '../commands/helpers'; import PickerFacade from '../picker-facade'; import DropPlaceholder from '../ui/Media/DropPlaceholder'; import { removeMediaNode, splitMediaGroup } from '../utils/media-common'; import { insertMediaGroupNode, insertMediaInlineNode } from '../utils/media-files'; import { getMediaNodeInsertionType } from '../utils/media-inline'; import { insertMediaSingleNode } from '../utils/media-single'; import { MediaTaskManager } from './mediaTaskManager'; import { stateKey } from './plugin-key'; export { 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, allowDropLine) => { const dropPlaceholder = document.createElement('div'); const createElement = React.createElement; if (allowDropLine) { ReactDOM.render(createElement(RawIntlProvider, { value: intl }, createElement(DropPlaceholder, { type: 'single' })), dropPlaceholder); } else { ReactDOM.render(createElement(RawIntlProvider, { value: intl }, createElement(DropPlaceholder)), dropPlaceholder); } return dropPlaceholder; }; const MEDIA_RESOLVED_STATES = ['ready', 'error', 'cancelled']; export class MediaPluginStateImplementation { constructor(_state, options, mediaOptions, newInsertionBehaviour, _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, "destroyed", false); _defineProperty(this, "removeOnCloseListener", () => {}); _defineProperty(this, "onPopupToggleCallback", () => {}); _defineProperty(this, "nodeCount", new Map()); _defineProperty(this, "taskManager", new MediaTaskManager()); _defineProperty(this, "pickers", []); _defineProperty(this, "pickerPromises", []); _defineProperty(this, "onContextIdentifierProvider", async (_name, provider) => { if (provider) { this.contextIdentifierProvider = await provider; } }); _defineProperty(this, "setMediaProvider", async mediaProvider => { if (!mediaProvider) { this.destroyPickers(); this.allowsUploads = false; if (!this.destroyed) { this.view.dispatch(this.view.state.tr.setMeta(stateKey, { allowsUploads: this.allowsUploads })); } return; } // TODO disable (not destroy!) pickers until mediaProvider is resolved try { this.mediaProvider = await mediaProvider; // 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(); } }); _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; }); /** * 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) => { var _this$pluginInjection, _this$pluginInjection2, _mediaState$collectio, _this$mediaOptions2, _this$pluginInjection3; 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 }); } switch (getMediaNodeInsertionType(state, (_this$mediaOptions2 = this.mediaOptions) === null || _this$mediaOptions2 === void 0 ? void 0 : _this$mediaOptions2.featureFlags, mediaStateWithContext.fileMimeType)) { case 'inline': insertMediaInlineNode(editorAnalyticsAPI)(this.view, mediaStateWithContext, collection, this.getInputMethod(pickerType)); 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$pluginInjection3.width.sharedState.currentState(); insertMediaSingleNode(this.view, mediaStateWithContext, this.getInputMethod(pickerType), collection, this.mediaOptions && this.mediaOptions.alignLeftOnInsert, this.newInsertionBehaviour, widthPluginState, editorAnalyticsAPI); break; case 'group': insertMediaGroupNode(editorAnalyticsAPI)(this.view, [mediaStateWithContext], collection, this.getInputMethod(pickerType)); 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); } }); }); this.taskManager.addPendingTask(uploadingPromise, mediaStateWithContext.id).then(() => { this.updateAndDispatch({ allUploadsFinished: true }); }); } // refocus the view const { view } = this; if (!view.hasFocus()) { view.focus(); } }); _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, "trackMediaNodeAddition", node => { var _this$nodeCount$get; const id = node.attrs.id; const count = (_this$nodeCount$get = this.nodeCount.get(id)) !== null && _this$nodeCount$get !== void 0 ? _this$nodeCount$get : 0; if (count === 0) { this.taskManager.resumePendingTask(id); } this.nodeCount.set(id, count + 1); }); _defineProperty(this, "trackMediaNodeRemoval", node => { var _this$nodeCount$get2; const id = node.attrs.id; const count = (_this$nodeCount$get2 = this.nodeCount.get(id)) !== null && _this$nodeCount$get2 !== void 0 ? _this$nodeCount$get2 : 0; if (count === 1) { this.taskManager.cancelPendingTask(id); } this.nodeCount.set(id, count - 1); }); /** * 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.findMediaSingleNode(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 'clipboard': return INPUT_METHOD.CLIPBOARD; case 'dropzone': return INPUT_METHOD.DRAG_AND_DROP; } return; }); _defineProperty(this, "updateMediaSingleNodeAttrs", (id, attrs) => { const { view } = this; if (!view) { return; } return updateMediaSingleNodeAttrs(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; } let { from } = view.state.selection; removeMediaNode(view, selectedNode.firstChild, () => from + 1); return true; }); _defineProperty(this, "selectedMediaContainerNode", () => { const { selection } = this.view.state; 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 sel = Selection.findFrom(doc.resolve(selection.$from.pos - 1), -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.newInsertionBehaviour = newInsertionBehaviour; 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'); options.providerFactory.subscribe('mediaProvider', (_name, provider) => this.setMediaProvider(provider)); options.providerFactory.subscribe('contextIdentifierProvider', this.onContextIdentifierProvider); if (getBooleanFF('platform.editor.media.inline-image.base-support')) { this.allowInlineImages = true; } this.errorReporter = options.errorReporter || new ErrorReporter(); this.singletonCreatedAt = (performance || Date).now(); } clone() { const clonedAt = (performance || Date).now(); return new Proxy(this, { get(target, prop, receiver) { if (prop === 'singletonCreatedAt') { return clonedAt; } return Reflect.get(target, prop, receiver); } }); } 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; } 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, reactContext, getIntl, pluginInjectionApi, dispatch, mediaOptions, newInsertionBehaviour) => { const intl = getIntl(); const dropPlaceholder = createDropPlaceholder(intl, mediaOptions && mediaOptions.allowDropzoneDropLine); return new SafePlugin({ state: { init(_config, state) { return new MediaPluginStateImplementation(state, options, mediaOptions, newInsertionBehaviour, dispatch, pluginInjectionApi); }, apply(tr, pluginState) { const isResizing = tr.getMeta(MEDIA_PLUGIN_IS_RESIZING_KEY); const resizingWidth = tr.getMeta(MEDIA_PLUGIN_RESIZING_WIDTH_KEY); // 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 (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(); } // 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 NodeSelection || 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; }); } 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); } const dropPlaceholders = [Decoration.widget(pos, dropPlaceholder, { key: 'drop-placeholder' }), ...mediaNodes]; return DecorationSet.create(state.doc, dropPlaceholders); }, nodeViews: options.nodeViews, handleTextInput(view) { getMediaPluginState(view.state).splitMediaGroup(); return false; }, handleClick: (_editorView, _pos, event) => { var _event$target; const clickedInsideCaptionPlaceholder = (_event$target = event.target) === null || _event$target === void 0 ? void 0 : _event$target.closest(`[data-id="${CAPTION_PLACEHOLDER_ID}"]`); // 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; return !!((_event$target2 = event.target) !== null && _event$target2 !== void 0 && _event$target2.closest(`[class="${MEDIA_CONTENT_WRAP_CLASS_NAME}"]`)); } return false; } } }); };