@atlaskit/editor-plugin-media
Version:
Media plugin for @atlaskit/editor-core
570 lines (566 loc) • 22.4 kB
JavaScript
/**
* @jsxRuntime classic
* @jsx jsx
* @jsxFrag
*/
import React, { Fragment } from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports
import { css, jsx } from '@emotion/react';
import { getBrowserInfo } from '@atlaskit/editor-common/browser';
import { usePreviousState } from '@atlaskit/editor-common/hooks';
import { captionMessages } from '@atlaskit/editor-common/media';
import { calcMediaSinglePixelWidth, DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_WIDTH, ExternalImageBadge, getMaxWidthForNestedNode, MEDIA_SINGLE_GUTTER_SIZE, MediaBadges } from '@atlaskit/editor-common/media-single';
import { MediaSingle } from '@atlaskit/editor-common/ui';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfTypeClosestToPos } from '@atlaskit/editor-prosemirror/utils';
import { getAttrsFromUrl } from '@atlaskit/media-client';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { insertAndSelectCaptionFromMediaSinglePos } from '../pm-plugins/commands/captions';
import { isMediaBlobUrlFromAttrs } from '../pm-plugins/utils/media-common';
import { CaptionPlaceholder, CaptionPlaceholderButton } from '../ui/CaptionPlaceholder';
import { CommentBadgeWrapper } from '../ui/CommentBadge';
import ResizableMediaSingle from '../ui/ResizableMediaSingle';
import ResizableMediaSingleNext from '../ui/ResizableMediaSingle/ResizableMediaSingleNext';
import { hasPrivateAttrsChanged } from './helpers';
import { createMediaNodeUpdater } from './mediaNodeUpdater';
import { MediaSingleNodeSelector } from './styles';
const figureWrapperStyles = css({
margin: 0
});
const useMediaNodeUpdater = ({
mediaProvider,
mediaNode,
dispatchAnalyticsEvent,
mediaSingleNodeProps
}) => {
const previousMediaProvider = usePreviousState(mediaProvider);
const previousMediaNode = usePreviousState(mediaNode);
const mediaNodeUpdaterRef = React.useRef(null);
const createOrUpdateMediaNodeUpdater = React.useCallback(props => {
const mediaChildNode = mediaNode.firstChild;
const updaterProps = {
...props,
isMediaSingle: true,
node: mediaChildNode ? mediaChildNode : mediaNode,
dispatchAnalyticsEvent
};
if (!mediaNodeUpdaterRef.current) {
mediaNodeUpdaterRef.current = createMediaNodeUpdater(updaterProps);
} else {
mediaNodeUpdaterRef.current.setProps(updaterProps);
}
}, [mediaNode, dispatchAnalyticsEvent]);
React.useEffect(() => {
// Forced updates not required on mobile
if (mediaSingleNodeProps.isCopyPasteEnabled === false) {
return;
}
if (!mediaNodeUpdaterRef.current || previousMediaProvider !== mediaProvider) {
var _mediaNodeUpdaterRef$;
createOrUpdateMediaNodeUpdater(mediaSingleNodeProps);
(_mediaNodeUpdaterRef$ = mediaNodeUpdaterRef.current) === null || _mediaNodeUpdaterRef$ === void 0 ? void 0 : _mediaNodeUpdaterRef$.updateMediaSingleFileAttrs();
} else if (mediaNode.firstChild && previousMediaNode !== null && previousMediaNode !== void 0 && previousMediaNode.firstChild && mediaNode.firstChild !== (previousMediaNode === null || previousMediaNode === void 0 ? void 0 : previousMediaNode.firstChild)) {
const attrsChanged = hasPrivateAttrsChanged(previousMediaNode.firstChild.attrs, mediaNode.firstChild.attrs);
if (attrsChanged) {
var _mediaNodeUpdaterRef$2;
createOrUpdateMediaNodeUpdater(mediaSingleNodeProps);
// We need to call this method on any prop change since attrs can get removed with collab editing
(_mediaNodeUpdaterRef$2 = mediaNodeUpdaterRef.current) === null || _mediaNodeUpdaterRef$2 === void 0 ? void 0 : _mediaNodeUpdaterRef$2.updateMediaSingleFileAttrs();
}
}
}, [createOrUpdateMediaNodeUpdater, mediaNode, mediaProvider, mediaSingleNodeProps, previousMediaNode, previousMediaProvider]);
return mediaNodeUpdaterRef.current;
};
const mediaAsyncOperations = async props => {
const updatedDimensions = await props.updater.getRemoteDimensions();
const currentAttrs = props.mediaChildNode.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)) {
props.updater.updateDimensions(updatedDimensions);
}
if (props.mediaChildNode.attrs.type === 'external' && props.mediaChildNode.attrs.__external) {
const updatingNode = props.updater.handleExternalMedia(props.getPos);
props.addPendingTask(updatingNode);
await updatingNode;
return;
}
const contextId = props.updater.getNodeContextId();
if (!contextId) {
await props.updater.updateContextId();
}
const shouldNodeBeDeepCopied = await props.updater.shouldNodeBeDeepCopied();
if (shouldNodeBeDeepCopied) {
try {
const copyNode = props.updater.copyNode({
traceId: props.mediaNode.attrs.__mediaTraceId
});
props.addPendingTask(copyNode);
await copyNode;
} catch {}
}
};
const useMediaAsyncOperations = ({
mediaNode,
mediaNodeUpdater,
addPendingTask,
getPos
}) => {
React.useEffect(() => {
if (!mediaNodeUpdater) {
return;
}
// we want the first child of MediaSingle (type "media")
const childNode = mediaNode.firstChild;
if (!childNode) {
return;
}
mediaAsyncOperations({
mediaChildNode: childNode,
updater: mediaNodeUpdater,
getPos,
mediaNode,
addPendingTask
});
}, [mediaNode, addPendingTask, mediaNodeUpdater, getPos]);
};
const noop = () => {};
/**
* Keep returning the same ProseMirror Node, unless the node content changed.
*
* React uses shallow comparation with `Object.is`,
* but that can cause multiple re-renders when the same node is given in a different instance.
*
* To avoid unnecessary re-renders, this hook uses the `Node.eq` from ProseMirror API to compare
* previous and new values.
*/
const useLatestMediaNode = nextMediaNode => {
const previousMediaNode = usePreviousState(nextMediaNode);
const [mediaNode, setMediaNode] = React.useState(nextMediaNode);
React.useEffect(() => {
if (!previousMediaNode || typeof previousMediaNode.eq !== 'function') {
return;
}
if (!previousMediaNode.eq(nextMediaNode)) {
setMediaNode(nextMediaNode);
}
}, [previousMediaNode, nextMediaNode]);
return mediaNode;
};
const useMediaDimensionsLogic = ({
childMediaNodeAttrs
}) => {
const {
width: originalWidth,
height: originalHeight
} = childMediaNodeAttrs;
const isExternalMedia = childMediaNodeAttrs.type === 'external';
const hasMediaUrlBlob = isExternalMedia && typeof childMediaNodeAttrs.url === 'string' && isMediaBlobUrlFromAttrs(childMediaNodeAttrs);
const urlBlobAttrs = React.useMemo(() => {
if (!hasMediaUrlBlob) {
return null;
}
return getAttrsFromUrl(childMediaNodeAttrs.url);
}, [hasMediaUrlBlob, childMediaNodeAttrs]);
const {
width,
height
} = React.useMemo(() => {
// original width and height of child media node (scaled)
let width = originalWidth;
let height = originalHeight;
if (isExternalMedia) {
if (urlBlobAttrs) {
if (urlBlobAttrs) {
const {
width: urlWidth,
height: urlHeight
} = urlBlobAttrs;
width = width || urlWidth;
height = height || urlHeight;
}
}
if (width === null) {
width = DEFAULT_IMAGE_WIDTH;
}
if (height === null) {
height = DEFAULT_IMAGE_HEIGHT;
}
}
if (!width || !height) {
width = DEFAULT_IMAGE_WIDTH;
height = DEFAULT_IMAGE_HEIGHT;
}
return {
width,
height
};
}, [originalWidth, originalHeight, isExternalMedia, urlBlobAttrs]);
return {
width,
height
};
};
const useUpdateSizeCallback = ({
mediaNode,
view,
getPos
}) => {
const updateSize = React.useCallback((width, layout) => {
const {
state,
dispatch
} = view;
const pos = getPos();
if (typeof pos === 'undefined') {
return;
}
const tr = state.tr.setNodeMarkup(pos, undefined, {
...mediaNode.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);
}, [view, getPos, mediaNode]);
return updateSize;
};
/**
* This value is used to fallback when widthState is undefined.
*
* Previously, the old MediaSingle was ignoring the undefined situation:
*
* <MediaSingleNode
* width={widthState!.width}
* lineLength={widthState!.lineLength}
*/
const FALLBACK_MOST_COMMON_WIDTH = 760;
export const MediaSingleNodeNext = mediaSingleNodeNextProps => {
var _pluginInjectionApi$m, _pluginInjectionApi$m2, _mediaNode$firstChild;
const {
selected,
getPos,
node: nextMediaNode,
mediaOptions,
fullWidthMode,
view,
pluginInjectionApi,
width: containerWidth,
lineLength,
dispatchAnalyticsEvent,
editorViewMode,
editorDisabled,
isDrafting,
targetNodeId,
editorAppearance,
mediaProvider: mediaProviderPromise,
forwardRef,
contextIdentifierProvider: contextIdentifierProviderPromise,
addPendingTask
} = mediaSingleNodeNextProps;
const [mediaProvider, setMediaProvider] = React.useState(null);
const [_contextIdentifierProvider, setContextIdentifierProvider] = React.useState(null);
const [viewMediaClientConfig, setViewMediaClientConfig] = React.useState();
const mountedRef = React.useRef(true);
const pos = getPos();
const isSelected = selected();
const contentWidthForLegacyExperience = getMaxWidthForNestedNode(view, getPos()) || lineLength;
const mediaNode = useLatestMediaNode(nextMediaNode);
const mediaNodeUpdater = useMediaNodeUpdater({
mediaNode,
mediaSingleNodeProps: mediaSingleNodeNextProps,
mediaProvider,
dispatchAnalyticsEvent
});
useMediaAsyncOperations({
mediaNodeUpdater,
getPos,
mediaNode,
addPendingTask: addPendingTask || noop
});
React.useLayoutEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
React.useLayoutEffect(() => {
if (!mediaProviderPromise) {
return;
}
mediaProviderPromise.then(resolvedProvider => {
const {
viewMediaClientConfig
} = resolvedProvider;
if (mountedRef.current) {
setViewMediaClientConfig(viewMediaClientConfig);
setMediaProvider(resolvedProvider);
}
});
}, [mediaProviderPromise]);
React.useEffect(() => {
if (!contextIdentifierProviderPromise) {
return;
}
contextIdentifierProviderPromise.then(provider => {
if (mountedRef.current) {
setContextIdentifierProvider(provider);
}
});
}, [contextIdentifierProviderPromise]);
const {
layout,
widthType,
width: mediaSingleWidthAttribute
} = mediaNode.attrs;
const childNode = mediaNode.firstChild;
const childMediaNodeAttrs = React.useMemo(() => {
return (childNode === null || childNode === void 0 ? void 0 : childNode.attrs) || {};
}, [childNode]);
const {
width,
height
} = useMediaDimensionsLogic({
childMediaNodeAttrs
});
const updateSize = useUpdateSizeCallback({
view,
getPos,
mediaNode
});
const canResize = React.useMemo(() => {
if (typeof pos !== 'number') {
return false;
}
const result = Boolean(!!mediaOptions.allowResizing && !editorDisabled && !editorViewMode);
if (mediaOptions.allowResizingInTables) {
return result;
}
// If resizing not allowed in tables, check parents for tables
const $pos = view.state.doc.resolve(pos);
const {
table
} = view.state.schema.nodes;
const disabledNode = !!findParentNodeOfTypeClosestToPos($pos, [table]);
return Boolean(result && !disabledNode);
}, [mediaOptions, pos, view, editorDisabled, editorViewMode]);
const shouldShowPlaceholder = React.useMemo(() => {
const result = mediaOptions.allowCaptions && mediaNode.childCount !== 2 && isSelected && view.state.selection instanceof NodeSelection;
return !editorDisabled && result;
}, [editorDisabled, mediaOptions.allowCaptions, mediaNode, view, isSelected]);
const isInsideTable = React.useMemo(() => {
if (typeof pos !== 'number') {
return false;
}
return findParentNodeOfTypeClosestToPos(view.state.doc.resolve(pos), [view.state.schema.nodes.table]);
}, [pos, view]);
const currentMediaElement = React.useCallback(() => {
if (typeof pos !== 'number') {
return null;
}
const mediaNode = view.domAtPos(pos + 1).node;
return mediaNode instanceof HTMLElement ? mediaNode : null;
}, [view, pos]);
const mediaSingleWidth = React.useMemo(() => {
return 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
});
}, [mediaSingleWidthAttribute, widthType, width, layout, contentWidthForLegacyExperience, containerWidth]);
const currentMaxWidth = isSelected ? pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$m = pluginInjectionApi.media) === null || _pluginInjectionApi$m === void 0 ? void 0 : (_pluginInjectionApi$m2 = _pluginInjectionApi$m.sharedState.currentState()) === null || _pluginInjectionApi$m2 === void 0 ? void 0 : _pluginInjectionApi$m2.currentMaxWidth : undefined;
const contentWidth = currentMaxWidth || lineLength;
const isCurrentNodeDrafting = Boolean(isDrafting && targetNodeId === (mediaNode === null || mediaNode === void 0 ? void 0 : (_mediaNode$firstChild = mediaNode.firstChild) === null || _mediaNode$firstChild === void 0 ? void 0 : _mediaNode$firstChild.attrs.id));
const mediaSingleWrapperRef = /*#__PURE__*/React.createRef();
const captionPlaceHolderRef = /*#__PURE__*/React.createRef();
const browser = getBrowserInfo();
const notIos = !browser.ios;
const onMediaSingleClicked = React.useCallback(event => {
var _captionPlaceHolderRe;
const browser = getBrowserInfo();
// Workaround for iOS 16 Caption selection issue
// @see https://product-fabric.atlassian.net/browse/MEX-2012
if (!browser.ios) {
return;
}
if (mediaSingleWrapperRef.current !== event.target) {
return;
}
(_captionPlaceHolderRe = captionPlaceHolderRef.current) === null || _captionPlaceHolderRe === void 0 ? void 0 : _captionPlaceHolderRe.click();
}, [mediaSingleWrapperRef, captionPlaceHolderRef]);
const onMediaSingleKeyDown = React.useCallback(event => {
if (mediaSingleWrapperRef.current !== event.target) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
var _captionPlaceHolderRe2;
event.preventDefault();
(_captionPlaceHolderRe2 = captionPlaceHolderRef.current) === null || _captionPlaceHolderRe2 === void 0 ? void 0 : _captionPlaceHolderRe2.click();
}
}, [mediaSingleWrapperRef, captionPlaceHolderRef]);
const clickPlaceholder = React.useCallback(() => {
var _pluginInjectionApi$a;
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(), mediaNode)(view.state, view.dispatch);
}, [view, getPos, mediaNode, pluginInjectionApi]);
const legacySize = React.useMemo(() => {
return {
width: mediaSingleWidthAttribute,
widthType: widthType
};
}, [widthType, mediaSingleWidthAttribute]);
const MediaChildren = jsx("figure", {
ref: mediaSingleWrapperRef,
css: figureWrapperStyles
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766
,
className: MediaSingleNodeSelector,
onClick: notIos ? undefined : onMediaSingleClicked,
onKeyDown: notIos ? undefined : onMediaSingleKeyDown
}, jsx(MediaBadges, {
mediaElement: currentMediaElement(),
mediaHeight: height,
mediaWidth: width,
extendedResizeOffset: mediaOptions.allowPixelResizing && !isInsideTable
}, ({
visible
}) => jsx(React.Fragment, null, visible && jsx(ExternalImageBadge, {
type: childMediaNodeAttrs.type,
url: childMediaNodeAttrs.type === 'external' ? childMediaNodeAttrs.url : undefined
}), mediaOptions.allowCommentsOnMedia && jsx(CommentBadgeWrapper, {
view: view,
api: pluginInjectionApi,
mediaNode: mediaNode === null || mediaNode === void 0 ? void 0 : mediaNode.firstChild,
getPos: getPos,
isDrafting: isCurrentNodeDrafting
}))), jsx("div", {
ref: forwardRef
}), shouldShowPlaceholder && (fg('platform_editor_typography_ugc') ? jsx(CaptionPlaceholderButton
// platform_editor_typography_ugc clean up
// remove typecasting
, {
ref: captionPlaceHolderRef,
onClick: clickPlaceholder,
placeholderMessage: mediaOptions.allowImagePreview ? captionMessages.placeholderWithDoubleClickPrompt : captionMessages.placeholder
}) : jsx(CaptionPlaceholder, {
ref: captionPlaceHolderRef,
onClick: clickPlaceholder,
placeholderMessage: mediaOptions.allowImagePreview ? captionMessages.placeholderWithDoubleClickPrompt : captionMessages.placeholder
})));
if (!expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true) || widthType !== 'pixel' && expValEquals('platform_editor_media_vc_fixes_patch1', 'isEnabled', true)) {
return jsx(Fragment, null, canResize ? mediaOptions.allowPixelResizing ? jsx(ResizableMediaSingleNext, {
view: view,
getPos: getPos,
updateSize: updateSize,
gridSize: 12,
viewMediaClientConfig: viewMediaClientConfig,
allowBreakoutSnapPoints: mediaOptions && mediaOptions.allowBreakoutSnapPoints,
selected: isSelected,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
pluginInjectionApi: pluginInjectionApi,
layout: layout,
width: width,
height: height,
containerWidth: containerWidth,
lineLength: contentWidth || FALLBACK_MOST_COMMON_WIDTH,
fullWidthMode: fullWidthMode,
hasFallbackContainer: false,
mediaSingleWidth: mediaSingleWidth,
editorAppearance: editorAppearance,
showLegacyNotification: widthType !== 'pixel',
forceHandlePositioning: mediaOptions === null || mediaOptions === void 0 ? void 0 : mediaOptions.forceHandlePositioning
}, MediaChildren) : jsx(ResizableMediaSingle, {
view: view,
getPos: getPos,
updateSize: updateSize,
gridSize: 12,
viewMediaClientConfig: viewMediaClientConfig,
allowBreakoutSnapPoints: mediaOptions && mediaOptions.allowBreakoutSnapPoints,
selected: isSelected,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
pluginInjectionApi: pluginInjectionApi,
layout: layout,
width: width,
height: height,
containerWidth: containerWidth,
fullWidthMode: fullWidthMode,
hasFallbackContainer: false,
mediaSingleWidth: mediaSingleWidth,
editorAppearance: editorAppearance,
lineLength: contentWidthForLegacyExperience || FALLBACK_MOST_COMMON_WIDTH,
pctWidth: mediaSingleWidthAttribute
}, MediaChildren) : jsx(MediaSingle, {
layout: layout,
width: width,
height: height,
containerWidth: containerWidth,
fullWidthMode: fullWidthMode,
hasFallbackContainer: false,
editorAppearance: editorAppearance,
pctWidth: mediaSingleWidthAttribute,
lineLength: lineLength || FALLBACK_MOST_COMMON_WIDTH,
size: legacySize
}, MediaChildren));
}
return mediaOptions.allowPixelResizing ? jsx(ResizableMediaSingleNext, {
view: view,
getPos: getPos,
updateSize: updateSize,
gridSize: 12,
viewMediaClientConfig: viewMediaClientConfig,
allowBreakoutSnapPoints: mediaOptions && mediaOptions.allowBreakoutSnapPoints,
selected: isSelected,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
pluginInjectionApi: pluginInjectionApi,
layout: layout,
width: width,
height: height,
containerWidth: containerWidth,
lineLength: contentWidth || FALLBACK_MOST_COMMON_WIDTH,
fullWidthMode: fullWidthMode,
hasFallbackContainer: false,
mediaSingleWidth: mediaSingleWidth,
editorAppearance: editorAppearance,
showLegacyNotification: widthType !== 'pixel',
forceHandlePositioning: mediaOptions === null || mediaOptions === void 0 ? void 0 : mediaOptions.forceHandlePositioning,
disableHandles: !canResize
}, MediaChildren) : jsx(ResizableMediaSingle, {
view: view,
getPos: getPos,
updateSize: updateSize,
gridSize: 12,
viewMediaClientConfig: viewMediaClientConfig,
allowBreakoutSnapPoints: mediaOptions && mediaOptions.allowBreakoutSnapPoints,
selected: isSelected,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
pluginInjectionApi: pluginInjectionApi,
layout: layout,
width: width,
height: height,
containerWidth: containerWidth,
fullWidthMode: fullWidthMode,
hasFallbackContainer: false,
mediaSingleWidth: mediaSingleWidth,
editorAppearance: editorAppearance,
lineLength: contentWidthForLegacyExperience || FALLBACK_MOST_COMMON_WIDTH,
pctWidth: mediaSingleWidthAttribute,
disableHandles: !canResize
}, MediaChildren);
};