UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

570 lines (566 loc) 22.4 kB
/** * @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); };