UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

580 lines (574 loc) 21.7 kB
/** * @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;