UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

535 lines (528 loc) 21.1 kB
import _extends from "@babel/runtime/helpers/extends"; import _defineProperty from "@babel/runtime/helpers/defineProperty"; /** @jsx jsx */ import React, { Component } from 'react'; import { jsx } from '@emotion/react'; import { useSharedPluginState } from '@atlaskit/editor-common/hooks'; import { calcMediaSinglePixelWidth, DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_WIDTH, getMaxWidthForNestedNode, MEDIA_SINGLE_GUTTER_SIZE } from '@atlaskit/editor-common/media-single'; import { WithProviders } from '@atlaskit/editor-common/provider-factory'; import ReactNodeView from '@atlaskit/editor-common/react-node-view'; import { MediaSingle } from '@atlaskit/editor-common/ui'; import { browser, isNodeSelectedOrInRange, setNodeSelection, setTextSelection } from '@atlaskit/editor-common/utils'; import { NodeSelection } from '@atlaskit/editor-prosemirror/state'; import { findParentNodeOfTypeClosestToPos } from '@atlaskit/editor-prosemirror/utils'; import { CellSelection } from '@atlaskit/editor-tables/cell-selection'; import { getAttrsFromUrl } from '@atlaskit/media-client'; import { getBooleanFF } from '@atlaskit/platform-feature-flags'; import { insertAndSelectCaptionFromMediaSinglePos } from '../commands/captions'; import { MEDIA_CONTENT_WRAP_CLASS_NAME } from '../pm-plugins/main'; import CaptionPlaceholder from '../ui/CaptionPlaceholder'; import ResizableMediaSingle from '../ui/ResizableMediaSingle'; import ResizableMediaSingleNext from '../ui/ResizableMediaSingle/ResizableMediaSingleNext'; import { isMediaBlobUrlFromAttrs } from '../utils/media-common'; import { hasPrivateAttrsChanged } from './helpers'; import { MediaNodeUpdater } from './mediaNodeUpdater'; import { figureWrapper, MediaSingleNodeSelector } from './styles'; // eslint-disable-next-line @repo/internal/react/no-class-components export default class MediaSingleNode extends Component { constructor(...args) { super(...args); _defineProperty(this, "mediaNodeUpdater", null); _defineProperty(this, "state", { width: undefined, height: undefined, viewMediaClientConfig: undefined, isCopying: false }); _defineProperty(this, "mediaSingleWrapperRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "captionPlaceHolderRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "createOrUpdateMediaNodeUpdater", props => { const node = this.props.node.firstChild; const updaterProps = { ...props, isMediaSingle: true, node: node ? node : this.props.node, dispatchAnalyticsEvent: this.props.dispatchAnalyticsEvent }; if (!this.mediaNodeUpdater) { this.mediaNodeUpdater = new MediaNodeUpdater(updaterProps); } else { var _this$mediaNodeUpdate; (_this$mediaNodeUpdate = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate === void 0 ? void 0 : _this$mediaNodeUpdate.setProps(updaterProps); } }); _defineProperty(this, "setViewMediaClientConfig", async props => { const mediaProvider = await props.mediaProvider; if (mediaProvider) { const viewMediaClientConfig = mediaProvider.viewMediaClientConfig; this.setState({ viewMediaClientConfig }); } }); _defineProperty(this, "updateMediaNodeAttributes", async props => { var _this$mediaNodeUpdate2, _this$props$node$firs, _this$mediaNodeUpdate4, _this$mediaNodeUpdate6; this.createOrUpdateMediaNodeUpdater(props); const { addPendingTask } = this.props.mediaPluginState; // we want the first child of MediaSingle (type "media") const node = this.props.node.firstChild; if (!node) { return; } const updatedDimensions = await ((_this$mediaNodeUpdate2 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate2 === void 0 ? void 0 : _this$mediaNodeUpdate2.getRemoteDimensions()); const currentAttrs = (_this$props$node$firs = this.props.node.firstChild) === null || _this$props$node$firs === void 0 ? void 0 : _this$props$node$firs.attrs; if (updatedDimensions && ((currentAttrs === null || currentAttrs === void 0 ? void 0 : currentAttrs.width) !== updatedDimensions.width || (currentAttrs === null || currentAttrs === void 0 ? void 0 : currentAttrs.height) !== updatedDimensions.height)) { var _this$mediaNodeUpdate3; (_this$mediaNodeUpdate3 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate3 === void 0 ? void 0 : _this$mediaNodeUpdate3.updateDimensions(updatedDimensions); } if (node.attrs.type === 'external' && node.attrs.__external) { const updatingNode = this.mediaNodeUpdater.handleExternalMedia(this.props.getPos); addPendingTask(updatingNode); await updatingNode; return; } const contextId = (_this$mediaNodeUpdate4 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate4 === void 0 ? void 0 : _this$mediaNodeUpdate4.getNodeContextId(); if (!contextId) { var _this$mediaNodeUpdate5; await ((_this$mediaNodeUpdate5 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate5 === void 0 ? void 0 : _this$mediaNodeUpdate5.updateContextId()); } const hasDifferentContextId = await ((_this$mediaNodeUpdate6 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate6 === void 0 ? void 0 : _this$mediaNodeUpdate6.hasDifferentContextId()); if (hasDifferentContextId) { this.setState({ isCopying: true }); try { const copyNode = this.mediaNodeUpdater.copyNode({ traceId: node.attrs.__mediaTraceId }); addPendingTask(copyNode); await copyNode; } catch (e) { // if copyNode fails, let's set isCopying false so we can show the eventual error this.setState({ isCopying: false }); } } }); _defineProperty(this, "selectMediaSingle", ({ event }) => { const propPos = this.props.getPos(); if (typeof propPos !== 'number') { return; } // We need to call "stopPropagation" here in order to prevent the browser from navigating to // another URL if the media node is wrapped in a link mark. event.stopPropagation(); const { state } = this.props.view; if (event.shiftKey) { // don't select text if there is current selection in a table (as this would override selected cells) if (state.selection instanceof CellSelection) { return; } setTextSelection(this.props.view, state.selection.from < propPos ? state.selection.from : propPos, // + 3 needed for offset of the media inside mediaSingle and cursor to make whole mediaSingle selected state.selection.to > propPos ? state.selection.to : propPos + 3); } else { setNodeSelection(this.props.view, propPos); } }); _defineProperty(this, "updateSize", (width, layout) => { const { state, dispatch } = this.props.view; const pos = this.props.getPos(); if (typeof pos === 'undefined') { return; } const tr = state.tr.setNodeMarkup(pos, undefined, { ...this.props.node.attrs, layout, width, widthType: 'pixel' }); tr.setMeta('scrollIntoView', false); /** * Any changes to attributes of a node count the node as "recreated" in Prosemirror[1] * This makes it so Prosemirror resets the selection to the child i.e. "media" instead of "media-single" * The recommended fix is to reset the selection.[2] * * [1] https://discuss.prosemirror.net/t/setnodemarkup-loses-current-nodeselection/976 * [2] https://discuss.prosemirror.net/t/setnodemarkup-and-deselect/3673 */ tr.setSelection(NodeSelection.create(tr.doc, pos)); return dispatch(tr); }); // Workaround for iOS 16 Caption selection issue // @see https://product-fabric.atlassian.net/browse/MEX-2012 _defineProperty(this, "onMediaSingleClicked", event => { var _this$captionPlaceHol; if (!browser.ios) { return; } if (this.mediaSingleWrapperRef.current !== event.target) { return; } (_this$captionPlaceHol = this.captionPlaceHolderRef.current) === null || _this$captionPlaceHol === void 0 ? void 0 : _this$captionPlaceHol.click(); }); _defineProperty(this, "clickPlaceholder", () => { var _pluginInjectionApi$a; const { view, getPos, node, pluginInjectionApi } = this.props; if (typeof getPos === 'boolean') { return; } insertAndSelectCaptionFromMediaSinglePos(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$a = pluginInjectionApi.analytics) === null || _pluginInjectionApi$a === void 0 ? void 0 : _pluginInjectionApi$a.actions)(getPos(), node)(view.state, view.dispatch); }); } UNSAFE_componentWillReceiveProps(nextProps) { if (!this.mediaNodeUpdater) { this.createOrUpdateMediaNodeUpdater(nextProps); } if (nextProps.mediaProvider !== this.props.mediaProvider) { this.setViewMediaClientConfig(nextProps); } // Forced updates not required on mobile if (nextProps.isCopyPasteEnabled === false) { return; } if (nextProps.mediaProvider !== this.props.mediaProvider) { var _this$mediaNodeUpdate7; this.createOrUpdateMediaNodeUpdater(nextProps); (_this$mediaNodeUpdate7 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate7 === void 0 ? void 0 : _this$mediaNodeUpdate7.updateMediaSingleFileAttrs(); } else if (nextProps.node.firstChild && this.props.node.firstChild) { const attrsChanged = hasPrivateAttrsChanged(this.props.node.firstChild.attrs, nextProps.node.firstChild.attrs); if (attrsChanged) { var _this$mediaNodeUpdate8; this.createOrUpdateMediaNodeUpdater(nextProps); // We need to call this method on any prop change since attrs can get removed with collab editing (_this$mediaNodeUpdate8 = this.mediaNodeUpdater) === null || _this$mediaNodeUpdate8 === void 0 ? void 0 : _this$mediaNodeUpdate8.updateMediaSingleFileAttrs(); } } } async componentDidMount() { const { contextIdentifierProvider } = this.props; this.createOrUpdateMediaNodeUpdater(this.props); await Promise.all([this.setViewMediaClientConfig(this.props), this.updateMediaNodeAttributes(this.props)]); this.setState({ contextIdentifierProvider: await contextIdentifierProvider }); } render() { var _pluginInjectionApi$m; const { selected, getPos, node, mediaOptions, fullWidthMode, view: { state }, view, pluginInjectionApi, width: containerWidth, lineLength, dispatchAnalyticsEvent } = this.props; const { layout, widthType, width: mediaSingleWidthAttribute } = node.attrs; const childNode = node.firstChild; const attrs = (childNode === null || childNode === void 0 ? void 0 : childNode.attrs) || {}; // original width and height of child media node (scaled) let { width, height } = attrs; if (attrs.type === 'external') { if (isMediaBlobUrlFromAttrs(attrs)) { const urlAttrs = getAttrsFromUrl(attrs.url); if (urlAttrs) { const { width: urlWidth, height: urlHeight } = urlAttrs; width = width || urlWidth; height = height || urlHeight; } } const { width: stateWidth, height: stateHeight } = this.state; if (width === null) { width = stateWidth || DEFAULT_IMAGE_WIDTH; } if (height === null) { height = stateHeight || DEFAULT_IMAGE_HEIGHT; } } if (!width || !height) { width = DEFAULT_IMAGE_WIDTH; height = DEFAULT_IMAGE_HEIGHT; } const isSelected = selected(); const currentMaxWidth = isSelected ? pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$m = pluginInjectionApi.media.sharedState.currentState()) === null || _pluginInjectionApi$m === void 0 ? void 0 : _pluginInjectionApi$m.currentMaxWidth : undefined; const contentWidthForLegacyExperience = getMaxWidthForNestedNode(view, getPos()) || lineLength; const contentWidth = currentMaxWidth || lineLength; const mediaSingleProps = { layout, width, height, containerWidth: containerWidth, lineLength: contentWidth, fullWidthMode, hasFallbackContainer: false, mediaSingleWidth: calcMediaSinglePixelWidth({ width: mediaSingleWidthAttribute, widthType, origWidth: width, layout, // This will only be used when calculating legacy media single width // thus we use the legacy value (exclude table as container node) contentWidth: contentWidthForLegacyExperience, containerWidth, gutterOffset: MEDIA_SINGLE_GUTTER_SIZE }), allowCaptions: mediaOptions.allowCaptions }; const resizableMediaSingleProps = { view: view, getPos: getPos, updateSize: this.updateSize, gridSize: 12, viewMediaClientConfig: this.state.viewMediaClientConfig, allowBreakoutSnapPoints: mediaOptions && mediaOptions.allowBreakoutSnapPoints, selected: isSelected, dispatchAnalyticsEvent: dispatchAnalyticsEvent, pluginInjectionApi: pluginInjectionApi, ...mediaSingleProps }; let canResize = !!this.props.mediaOptions.allowResizing; if (!this.props.mediaOptions.allowResizingInTables) { // If resizing not allowed in tables, check parents for tables const pos = getPos(); if (pos) { const $pos = state.doc.resolve(pos); const { table } = state.schema.nodes; const disabledNode = !!findParentNodeOfTypeClosestToPos($pos, [table]); canResize = canResize && !disabledNode; } } const shouldShowPlaceholder = mediaOptions.allowCaptions && node.childCount !== 2 && isSelected && state.selection instanceof NodeSelection; const MediaChildren = jsx("figure", { ref: this.mediaSingleWrapperRef, css: [figureWrapper], className: MediaSingleNodeSelector, onClick: this.onMediaSingleClicked }, jsx("div", { ref: this.props.forwardRef }), shouldShowPlaceholder && jsx(CaptionPlaceholder, { ref: this.captionPlaceHolderRef, onClick: this.clickPlaceholder })); return canResize ? getBooleanFF('platform.editor.media.extended-resize-experience') ? jsx(ResizableMediaSingleNext, _extends({}, resizableMediaSingleProps, { showLegacyNotification: widthType !== 'pixel' }), MediaChildren) : jsx(ResizableMediaSingle, _extends({}, resizableMediaSingleProps, { lineLength: contentWidthForLegacyExperience, pctWidth: mediaSingleWidthAttribute }), MediaChildren) : jsx(MediaSingle, _extends({}, mediaSingleProps, { pctWidth: mediaSingleWidthAttribute }), MediaChildren); } } _defineProperty(MediaSingleNode, "defaultProps", { mediaOptions: {} }); _defineProperty(MediaSingleNode, "displayName", 'MediaSingleNode'); const MediaSingleNodeWrapper = ({ pluginInjectionApi, mediaProvider, contextIdentifierProvider, node, getPos, mediaOptions, view, fullWidthMode, selected, eventDispatcher, dispatchAnalyticsEvent, forwardRef }) => { const { widthState, mediaState } = useSharedPluginState(pluginInjectionApi, ['width', 'media']); return jsx(MediaSingleNode, { width: widthState.width, lineLength: widthState.lineLength, node: node, getPos: getPos, mediaProvider: mediaProvider, contextIdentifierProvider: contextIdentifierProvider, mediaOptions: mediaOptions, view: view, fullWidthMode: fullWidthMode, selected: selected, eventDispatcher: eventDispatcher, mediaPluginState: mediaState !== null && mediaState !== void 0 ? mediaState : undefined, dispatchAnalyticsEvent: dispatchAnalyticsEvent, forwardRef: forwardRef, pluginInjectionApi: pluginInjectionApi }); }; class MediaSingleNodeView extends ReactNodeView { constructor(...args) { super(...args); _defineProperty(this, "lastOffsetLeft", 0); _defineProperty(this, "forceViewUpdate", false); _defineProperty(this, "selectionType", null); _defineProperty(this, "checkAndUpdateSelectionType", () => { const getPos = this.getPos; const { selection } = this.view.state; /** * ED-19831 * There is a getPos issue coming from this code. We need to apply this workaround for now and apply a patch * directly to confluence since this bug is now in production. */ let pos; try { pos = getPos ? getPos() : undefined; } catch (e) { pos = undefined; } const isNodeSelected = isNodeSelectedOrInRange(selection.$anchor.pos, selection.$head.pos, pos, this.node.nodeSize); this.selectionType = isNodeSelected; return isNodeSelected; }); _defineProperty(this, "isNodeSelected", () => { this.checkAndUpdateSelectionType(); return this.selectionType !== null; }); } createDomRef() { const domRef = document.createElement('div'); if (this.reactComponentProps.mediaOptions && this.reactComponentProps.mediaOptions.allowMediaSingleEditable) { // workaround Chrome bug in https://product-fabric.atlassian.net/browse/ED-5379 // see also: https://github.com/ProseMirror/prosemirror/issues/884 domRef.contentEditable = 'true'; } if (getBooleanFF('platform.editor.media.extended-resize-experience')) { domRef.classList.add('media-extended-resize-experience'); } return domRef; } getContentDOM() { const dom = document.createElement('div'); dom.classList.add(MEDIA_CONTENT_WRAP_CLASS_NAME); return { dom }; } viewShouldUpdate(nextNode) { if (this.forceViewUpdate) { this.forceViewUpdate = false; return true; } if (this.node.attrs !== nextNode.attrs) { return true; } if (this.selectionType !== this.checkAndUpdateSelectionType()) { return true; } if (this.node.childCount !== nextNode.childCount) { return true; } return super.viewShouldUpdate(nextNode); } getNodeMediaId(node) { if (node.firstChild) { return node.firstChild.attrs.id; } return undefined; } update(node, decorations, _innerDecorations, isValidUpdate) { if (!isValidUpdate) { isValidUpdate = (currentNode, newNode) => this.getNodeMediaId(currentNode) === this.getNodeMediaId(newNode); } return super.update(node, decorations, _innerDecorations, isValidUpdate); } render(props, forwardRef) { const { eventDispatcher, fullWidthMode, providerFactory, mediaOptions, dispatchAnalyticsEvent, pluginInjectionApi } = this.reactComponentProps; // getPos is a boolean for marks, since this is a node we know it must be a function const getPos = this.getPos; return jsx(WithProviders, { providers: ['mediaProvider', 'contextIdentifierProvider'], providerFactory: providerFactory, renderNode: ({ mediaProvider, contextIdentifierProvider }) => { return jsx(MediaSingleNodeWrapper, { pluginInjectionApi: pluginInjectionApi, mediaProvider: mediaProvider, contextIdentifierProvider: contextIdentifierProvider, node: this.node, getPos: getPos, mediaOptions: mediaOptions, view: this.view, fullWidthMode: fullWidthMode, selected: this.isNodeSelected, eventDispatcher: eventDispatcher, dispatchAnalyticsEvent: dispatchAnalyticsEvent, forwardRef: forwardRef }); } }); } ignoreMutation() { // DOM has changed; recalculate if we need to re-render if (this.dom) { const offsetLeft = this.dom.offsetLeft; if (offsetLeft !== this.lastOffsetLeft) { this.lastOffsetLeft = offsetLeft; this.forceViewUpdate = true; this.update(this.node, [], undefined, () => true); } } return true; } } export const ReactMediaSingleNode = (portalProviderAPI, eventDispatcher, providerFactory, pluginInjectionApi, dispatchAnalyticsEvent, mediaOptions = {}) => (node, view, getPos) => { const hasIntlContext = true; return new MediaSingleNodeView(node, view, getPos, portalProviderAPI, eventDispatcher, { eventDispatcher, fullWidthMode: mediaOptions.fullWidthEnabled, providerFactory, mediaOptions, dispatchAnalyticsEvent, isCopyPasteEnabled: mediaOptions.isCopyPasteEnabled, pluginInjectionApi }, undefined, undefined, undefined, hasIntlContext).init(); };