@atlaskit/editor-plugin-media
Version:
Media plugin for @atlaskit/editor-core
580 lines (574 loc) • 21.7 kB
JavaScript
/**
* @jsxRuntime classic
* @jsx jsx
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports
import { jsx } from '@emotion/react';
import classnames from 'classnames';
import throttle from 'lodash/throttle';
import { findClosestSnap, generateDefaultGuidelines, generateDynamicGuidelines, getGuidelineSnaps, getGuidelinesWithHighlights, getGuidelineTypeFromKey, getRelativeGuidelines, getRelativeGuideSnaps } from '@atlaskit/editor-common/guideline';
import { calcMediaSingleMaxWidth, DEFAULT_IMAGE_WIDTH, MEDIA_SINGLE_ADJACENT_HANDLE_MARGIN, MEDIA_SINGLE_DEFAULT_MIN_PIXEL_WIDTH, MEDIA_SINGLE_RESIZE_THROTTLE_TIME, MEDIA_SINGLE_SNAP_GAP, MEDIA_SINGLE_VIDEO_MIN_PIXEL_WIDTH } from '@atlaskit/editor-common/media-single';
import { ResizerNext } from '@atlaskit/editor-common/resizer';
import { resizerItemClassName, richMediaClassName } from '@atlaskit/editor-common/styles';
import { calcPctFromPx, handleSides, imageAlignmentMap, wrappedLayouts } from '@atlaskit/editor-common/ui';
import { nonWrappedLayouts } from '@atlaskit/editor-common/utils';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfTypeClosestToPos } from '@atlaskit/editor-prosemirror/utils';
import { akEditorDefaultLayoutWidth, akEditorFullWidthLayoutWidth, akEditorGutterPaddingDynamic, akEditorGutterPaddingReduced, akEditorFullPageNarrowBreakout } from '@atlaskit/editor-shared-styles';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { MEDIA_PLUGIN_IS_RESIZING_KEY, MEDIA_PLUGIN_RESIZING_WIDTH_KEY } from '../../pm-plugins/main';
import { getMediaResizeAnalyticsEvent } from '../../pm-plugins/utils/analytics';
import { checkMediaType } from '../../pm-plugins/utils/check-media-type';
import { ResizableMediaMigrationNotification } from './ResizableMediaMigrationNotification';
import { wrapperStyle } from './styled';
export const resizerNextTestId = 'mediaSingle.resizerNext.testid';
const getNodePosition = getPos => {
if (typeof getPos !== 'function') {
return null;
}
const pos = getPos();
if (Number.isNaN(pos) || typeof pos !== 'number') {
return null;
}
return pos;
};
const calcPxHeight = props => {
const {
newWidth,
previousWidth,
previousHeight
} = props;
return Math.round(previousHeight / previousWidth * newWidth);
};
const calcMinWidth = ({
isVideoFile,
contentWidth
}) => {
return Math.min(contentWidth || akEditorDefaultLayoutWidth, isVideoFile ? MEDIA_SINGLE_VIDEO_MIN_PIXEL_WIDTH : MEDIA_SINGLE_DEFAULT_MIN_PIXEL_WIDTH);
};
const calcMaxWidth = ({
containerWidth,
editorAppearance
}) => {
return calcMediaSingleMaxWidth(containerWidth, editorAppearance);
};
const setIsResizingPluginState = ({
isResizing,
nodePosition,
initialWidth
}) => (state, dispatch) => {
const tr = state.tr;
tr.setMeta(MEDIA_PLUGIN_IS_RESIZING_KEY, isResizing);
tr.setMeta('is-resizer-resizing', isResizing);
if (isResizing && typeof nodePosition === 'number') {
tr.setSelection(NodeSelection.create(state.doc, nodePosition));
}
if (isResizing && typeof initialWidth === 'number') {
tr.setMeta(MEDIA_PLUGIN_RESIZING_WIDTH_KEY, initialWidth);
}
if (dispatch) {
dispatch(tr);
}
return true;
};
const calcUnwrappedLayout = (width, containerWidth, contentWidth, fullWidthMode, isNestedNode) => {
if (isNestedNode) {
return 'center';
}
if (fullWidthMode) {
if (width < contentWidth) {
return 'center';
}
return 'full-width';
}
// handle top-level node in fixed-width editor
if (width <= contentWidth) {
return 'center';
}
const padding = containerWidth <= akEditorFullPageNarrowBreakout && editorExperiment('platform_editor_preview_panel_responsiveness', true, {
exposure: true
}) ? akEditorGutterPaddingReduced : akEditorGutterPaddingDynamic();
if (width < Math.min(containerWidth - padding * 2, akEditorFullWidthLayoutWidth)) {
return 'wide';
}
// set full width to be containerWidth - padding * 2
// instead of containerWidth - akEditorBreakoutPadding,
// so that we have image aligned with text
return 'full-width';
};
const calcNewLayout = ({
layout,
containerWidth,
lineLength,
fullWidthMode,
isNestedNode
}) => (newWidth, stop) => {
const newPct = calcPctFromPx(newWidth, lineLength) * 100;
const wrappedLayout = wrappedLayouts.indexOf(layout) > -1;
if (newPct <= 100 && wrappedLayout) {
if (!stop || newPct !== 100) {
return layout;
}
}
return calcUnwrappedLayout(newWidth, containerWidth, lineLength, fullWidthMode, isNestedNode);
};
const calculateSizeState = props => (size, delta, onResizeStop = false, aspectRatio) => {
const calculatedWidth = Math.round(size.width + delta.width);
const calculatedWidthWithLayout = calcNewLayout(props)(calculatedWidth, onResizeStop);
return {
width: calculatedWidth,
height: calculatedWidth / aspectRatio,
layout: calculatedWidthWithLayout
};
};
const getAspectRatio = ({
width,
height
}) => {
if (width && height > 0) {
return width / height;
}
// TODO: ED-26962 - handle this case
return 1;
};
const updateSizeInPluginState = throttle(({
width,
view
}) => {
const {
state,
dispatch
} = view;
const tr = state.tr;
tr.setMeta(MEDIA_PLUGIN_RESIZING_WIDTH_KEY, width);
return dispatch(tr);
}, MEDIA_SINGLE_RESIZE_THROTTLE_TIME);
export const ResizableMediaSingleNextFunctional = props => {
const {
width: origWidth,
children,
containerWidth,
fullWidthMode,
layout,
selected,
showLegacyNotification,
className,
dispatchAnalyticsEvent,
editorAppearance,
getPos,
lineLength,
mediaSingleWidth,
height,
nodeType,
pluginInjectionApi,
updateSize,
view,
viewMediaClientConfig,
forceHandlePositioning,
disableHandles
} = props;
const initialWidth = useMemo(() => {
return mediaSingleWidth || DEFAULT_IMAGE_WIDTH;
}, [mediaSingleWidth]);
const [dimensions, setDimensions] = useState({
width: initialWidth,
height: calcPxHeight({
newWidth: initialWidth,
previousWidth: initialWidth,
previousHeight: height
})
});
const dimensionsRef = useRef(dimensions);
const lastSnappedGuidelineKeysRef = useRef([]);
const [snaps, setSnaps] = useState({});
const [isResizing, setIsResizing] = useState(false);
const [isVideoFile, setIsVideoFile] = useState(false);
const [hasResized, setHasResized] = useState(false);
const nodePosition = expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true) ? getNodePosition(getPos) :
// eslint-disable-next-line react-hooks/rules-of-hooks
useMemo(() => getNodePosition(getPos), [getPos]);
const isNestedNode = useMemo(() => {
if (nodePosition === null) {
return false;
}
const $pos = view.state.doc.resolve(nodePosition);
return !!($pos && $pos.depth !== 0);
}, [nodePosition, view]);
const isAdjacentMode = useMemo(() => {
if (forceHandlePositioning === 'adjacent') {
return true;
}
return isNestedNode;
}, [isNestedNode, forceHandlePositioning]);
const maybeContainerWidth = containerWidth || origWidth;
const memoizedCss = useMemo(() => {
return wrapperStyle({
layout,
containerWidth: maybeContainerWidth,
fullWidthMode,
mediaSingleWidth: dimensions.width,
isNestedNode: isAdjacentMode,
isExtendedResizeExperienceOn: true
});
}, [layout, maybeContainerWidth, fullWidthMode, dimensions.width, isAdjacentMode]);
const maxWidth = useMemo(() => {
if (editorAppearance === 'chromeless' && forceHandlePositioning === 'adjacent') {
return containerWidth - MEDIA_SINGLE_ADJACENT_HANDLE_MARGIN * 2;
}
if (!isResizing && isAdjacentMode) {
return undefined;
}
if (isAdjacentMode || fullWidthMode) {
return lineLength;
}
if (!isResizing && expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true)) {
return `var(--ak-editor-max-container-width)`;
}
return calcMaxWidth({
containerWidth,
editorAppearance
});
}, [isAdjacentMode, fullWidthMode, lineLength, editorAppearance, containerWidth, isResizing, forceHandlePositioning]);
const minWidth = calcMinWidth({
isVideoFile,
contentWidth: lineLength
});
// while is not resizing, we take 100% as min-width if the container width is less than the min-width
const minViewWidth = isResizing ? minWidth : `min(${minWidth}px, 100%)`;
const resizerNextClassName = useMemo(() => {
const classNameNext = classnames(richMediaClassName, `image-${layout}`, isResizing ? 'is-resizing' : 'not-resizing', className, resizerItemClassName, {
'display-handle': expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true) ? selected && !disableHandles : selected,
'richMedia-selected': selected,
'rich-media-wrapped': layout === 'wrap-left' || layout === 'wrap-right'
});
return classNameNext;
}, [className, disableHandles, isResizing, layout, selected]);
const isInsideInlineLike = useMemo(() => {
if (nodePosition === null) {
return false;
}
const $pos = view.state.doc.resolve(nodePosition);
const {
listItem
} = view.state.schema.nodes;
return !!findParentNodeOfTypeClosestToPos($pos, [listItem]);
}, [nodePosition, view]);
const enable = useMemo(() => {
if (disableHandles && expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true)) {
return {
left: false,
right: false
};
}
return handleSides.reduce((acc, side) => {
const oppositeSide = side === 'left' ? 'right' : 'left';
acc[side] = nonWrappedLayouts.concat(`wrap-${oppositeSide}`).concat(`align-${imageAlignmentMap[oppositeSide]}`).indexOf(layout) > -1;
if (side === 'left' && isInsideInlineLike) {
acc[side] = false;
}
return acc;
}, {});
}, [disableHandles, layout, isInsideInlineLike]);
const defaultGuidelines = useMemo(() => {
if (isAdjacentMode) {
return [];
}
return generateDefaultGuidelines(lineLength, containerWidth, fullWidthMode);
}, [isAdjacentMode, lineLength, containerWidth, fullWidthMode]);
const relativeGuidesRef = useRef({});
const guidelinesRef = useRef([]);
const updateGuidelines = useCallback(() => {
const {
relativeGuides,
dynamicGuides
} = generateDynamicGuidelines(view.state, lineLength, {
styles: {
lineStyle: 'dashed'
},
show: false
});
// disable guidelines for nested media single node
const dynamicGuidelines = isAdjacentMode ? [] : dynamicGuides;
relativeGuidesRef.current = relativeGuides;
guidelinesRef.current = [...defaultGuidelines, ...dynamicGuidelines];
}, [view, lineLength, defaultGuidelines, isAdjacentMode]);
const isGuidelineEnabled = useMemo(() => {
return !!(pluginInjectionApi !== null && pluginInjectionApi !== void 0 && pluginInjectionApi.guideline);
}, [pluginInjectionApi]);
const handleResizeStart = useCallback(() => {
setIsResizing(true);
setIsResizingPluginState({
isResizing: true,
nodePosition,
initialWidth: dimensionsRef.current.width
})(view.state, view.dispatch);
if (isGuidelineEnabled) {
updateGuidelines();
}
}, [view, nodePosition, updateGuidelines, isGuidelineEnabled]);
const getRelativeGuides = useCallback(() => {
var _pluginInjectionApi$g, _pluginInjectionApi$g2;
if (typeof nodePosition !== 'number') {
return [];
}
const guidelinePluginState = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$g = pluginInjectionApi.guideline) === null || _pluginInjectionApi$g === void 0 ? void 0 : (_pluginInjectionApi$g2 = _pluginInjectionApi$g.sharedState) === null || _pluginInjectionApi$g2 === void 0 ? void 0 : _pluginInjectionApi$g2.currentState();
const {
top: topOffset
} = (guidelinePluginState === null || guidelinePluginState === void 0 ? void 0 : guidelinePluginState.rect) || {
top: 0,
left: 0
};
const $pos = view.state.doc.resolve(nodePosition);
const relativeGuides = $pos && $pos.nodeAfter && dimensionsRef.current.width ? getRelativeGuidelines(relativeGuidesRef.current, {
node: $pos.nodeAfter,
pos: $pos.pos
}, view, lineLength, topOffset, dimensionsRef.current) : [];
return relativeGuides;
}, [pluginInjectionApi, nodePosition, view, lineLength]);
const updateActiveGuidelines = useCallback((width = 0, guidelines, guidelineSnapsReference) => {
var _pluginInjectionApi$g3, _pluginInjectionApi$g4;
if (!guidelineSnapsReference.snaps.x) {
return;
}
const {
gap,
keys: activeGuidelineKeys
} = findClosestSnap(width, guidelineSnapsReference.snaps.x, guidelineSnapsReference.guidelineReference, MEDIA_SINGLE_SNAP_GAP);
const relativeGuidelines = activeGuidelineKeys.length ? [] : getRelativeGuides();
lastSnappedGuidelineKeysRef.current = activeGuidelineKeys.length ? activeGuidelineKeys : relativeGuidelines.map(rg => rg.key);
const nextGuideLines = [...getGuidelinesWithHighlights(gap, MEDIA_SINGLE_SNAP_GAP, activeGuidelineKeys, guidelines), ...relativeGuidelines];
pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$g3 = pluginInjectionApi.guideline) === null || _pluginInjectionApi$g3 === void 0 ? void 0 : (_pluginInjectionApi$g4 = _pluginInjectionApi$g3.actions) === null || _pluginInjectionApi$g4 === void 0 ? void 0 : _pluginInjectionApi$g4.displayGuideline(view)({
guidelines: nextGuideLines
});
}, [getRelativeGuides, pluginInjectionApi, view]);
const aspectRatioRef = useRef(getAspectRatio({
width: props.width,
height: props.height
}));
const resizerContainerRef = useRef(null);
const handleResize = useCallback((size, delta) => {
const {
width,
height,
layout: newLayout
} = calculateSizeState({
layout,
containerWidth,
lineLength,
fullWidthMode,
isNestedNode: isAdjacentMode
})(size, delta, false, aspectRatioRef.current);
const resizerDomEl = resizerContainerRef.current;
if (resizerDomEl && !hasResized && expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true)) {
// dispatch resize event to media node DOM element inside resizerDom
const mediaDomEl = resizerDomEl.querySelector('div[data-prosemirror-node-name="media"]');
const event = new CustomEvent('resized');
mediaDomEl === null || mediaDomEl === void 0 ? void 0 : mediaDomEl.dispatchEvent(event);
setHasResized(true);
}
if (isGuidelineEnabled) {
const guidelineSnaps = getGuidelineSnaps(guidelinesRef.current, lineLength, layout);
updateActiveGuidelines(width, guidelinesRef.current, guidelineSnaps);
const relativeSnaps = getRelativeGuideSnaps(relativeGuidesRef.current, aspectRatioRef.current);
setSnaps({
x: [...(guidelineSnaps.snaps.x || []), ...relativeSnaps]
});
}
setDimensions({
width,
height
});
updateSizeInPluginState({
width,
view
});
if (newLayout !== layout) {
updateSize(width, newLayout);
}
}, [layout, containerWidth, lineLength, fullWidthMode, isAdjacentMode, hasResized, isGuidelineEnabled, view, updateActiveGuidelines, updateSize]);
const handleResizeStop = useCallback((size, delta) => {
var _pluginInjectionApi$g5, _pluginInjectionApi$g6;
if (typeof nodePosition !== 'number') {
return;
}
const {
width,
height,
layout: newLayout
} = calculateSizeState({
layout,
containerWidth,
lineLength,
fullWidthMode,
isNestedNode: isAdjacentMode
})(size, delta, false, aspectRatioRef.current);
if (dispatchAnalyticsEvent) {
const $pos = view.state.doc.resolve(nodePosition);
const event = getMediaResizeAnalyticsEvent(nodeType || 'mediaSingle', {
width,
layout: newLayout,
widthType: 'pixel',
snapType: getGuidelineTypeFromKey(lastSnappedGuidelineKeysRef.current, guidelinesRef.current),
parentNode: $pos ? $pos.parent.type.name : undefined,
inputMethod: 'mouse'
});
if (event) {
dispatchAnalyticsEvent(event);
}
}
setIsResizing(false);
setIsResizingPluginState({
isResizing: false
})(view.state, view.dispatch);
pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$g5 = pluginInjectionApi.guideline) === null || _pluginInjectionApi$g5 === void 0 ? void 0 : (_pluginInjectionApi$g6 = _pluginInjectionApi$g5.actions) === null || _pluginInjectionApi$g6 === void 0 ? void 0 : _pluginInjectionApi$g6.displayGuideline(view)({
guidelines: []
});
let newWidth = width;
if (newLayout === 'full-width') {
// When a node reaches full width in current viewport,
// update its width with 1800 to align with pixel entry
newWidth = akEditorFullWidthLayoutWidth;
}
setDimensions({
width: newWidth,
height
});
updateSize(newWidth, newLayout);
}, [nodeType, dispatchAnalyticsEvent, containerWidth, fullWidthMode, isAdjacentMode, layout, lineLength, view, nodePosition, pluginInjectionApi, updateSize]);
const mountedRef = React.useRef(true);
useLayoutEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useLayoutEffect(() => {
setDimensions({
width: initialWidth,
height: calcPxHeight({
newWidth: initialWidth,
previousWidth: initialWidth,
previousHeight: height
})
});
}, [initialWidth, height]);
useEffect(() => {
dimensionsRef.current = dimensions;
}, [dimensions]);
useEffect(() => {
if (!viewMediaClientConfig || typeof nodePosition !== 'number') {
return;
}
const mediaNode = view.state.doc.nodeAt(nodePosition + 1);
if (!mediaNode) {
return;
}
checkMediaType(mediaNode, viewMediaClientConfig).then(mediaType => {
if (mountedRef.current) {
const isVideoFile = mediaType !== 'external' && mediaType !== 'image';
setIsVideoFile(isVideoFile);
}
});
}, [view, viewMediaClientConfig, nodePosition]);
const handlePositioning = useMemo(() => {
if (forceHandlePositioning) {
return forceHandlePositioning;
}
return isAdjacentMode ? 'adjacent' : undefined;
}, [forceHandlePositioning, isAdjacentMode]);
return jsx("div", {
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/design-system/consistent-css-prop-usage -- Ignored via go/DSP-18766
css: memoizedCss,
ref: resizerContainerRef
}, jsx(ResizerNext, {
minWidth: minViewWidth,
maxWidth: maxWidth
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766
,
className: resizerNextClassName,
snapGap: MEDIA_SINGLE_SNAP_GAP,
enable: enable,
width: dimensions.width,
handleResizeStart: handleResizeStart,
handleResize: handleResize,
handleResizeStop: handleResizeStop,
snap: snaps,
resizeRatio: nonWrappedLayouts.includes(layout) ? 2 : 1,
"data-testid": resizerNextTestId,
isHandleVisible: expValEquals('platform_editor_media_vc_fixes', 'isEnabled', true) ? selected && !disableHandles : selected,
handlePositioning: handlePositioning,
handleHighlight: "full-height"
}, children, showLegacyNotification && jsx(ResizableMediaMigrationNotification, null)));
};
const ResizableMediaSingleToggle = ({
allowBreakoutSnapPoints,
children,
className,
containerWidth,
dataAttributes,
disableHandles,
dispatchAnalyticsEvent,
editorAppearance,
fullWidthMode,
getPos,
gridSize,
handleMediaSingleRef,
hasFallbackContainer,
height,
isInsideOfInlineExtension,
isLoading,
layout,
lineLength,
mediaSingleWidth,
nodeType,
pctWidth,
pluginInjectionApi,
selected,
showLegacyNotification,
size,
updateSize,
view,
viewMediaClientConfig,
width,
forceHandlePositioning
}) => {
return jsx(ResizableMediaSingleNextFunctional, {
allowBreakoutSnapPoints: allowBreakoutSnapPoints
// eslint-disable-next-line react/no-children-prop
,
children: children
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766
,
className: className,
containerWidth: containerWidth,
dataAttributes: dataAttributes,
disableHandles: disableHandles,
dispatchAnalyticsEvent: dispatchAnalyticsEvent,
editorAppearance: editorAppearance,
fullWidthMode: fullWidthMode,
getPos: getPos,
gridSize: gridSize,
handleMediaSingleRef: handleMediaSingleRef,
hasFallbackContainer: hasFallbackContainer,
height: height,
isInsideOfInlineExtension: isInsideOfInlineExtension,
isLoading: isLoading,
layout: layout,
lineLength: lineLength,
mediaSingleWidth: mediaSingleWidth,
nodeType: nodeType,
pctWidth: pctWidth,
pluginInjectionApi: pluginInjectionApi,
selected: selected,
showLegacyNotification: showLegacyNotification,
size: size,
updateSize: updateSize,
view: view,
viewMediaClientConfig: viewMediaClientConfig,
width: width,
forceHandlePositioning: forceHandlePositioning
});
};
export default ResizableMediaSingleToggle;