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