UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

453 lines (445 loc) 17 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; /** @jsx jsx */ import React from 'react'; import { jsx } from '@emotion/react'; import classnames from 'classnames'; import throttle from 'lodash/throttle'; import memoizeOne from 'memoize-one'; import { findClosestSnap, generateDefaultGuidelines, generateDynamicGuidelines, getGuidelineSnaps, getGuidelinesWithHighlights, getGuidelineTypeFromKey, getRelativeGuidelines, getRelativeGuideSnaps } from '@atlaskit/editor-common/guideline'; import { calcMediaSingleMaxWidth, DEFAULT_IMAGE_WIDTH, 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 { resizerStyles, richMediaClassName } from '@atlaskit/editor-common/styles'; import { calcPctFromPx, handleSides, imageAlignmentMap, wrappedLayouts } from '@atlaskit/editor-common/ui'; import { nonWrappedLayouts, setNodeSelection } from '@atlaskit/editor-common/utils'; import { findParentNodeOfTypeClosestToPos } from '@atlaskit/editor-prosemirror/utils'; import { akEditorDefaultLayoutWidth, akEditorFullWidthLayoutWidth, akEditorGutterPadding } from '@atlaskit/editor-shared-styles'; import { MEDIA_PLUGIN_IS_RESIZING_KEY, MEDIA_PLUGIN_RESIZING_WIDTH_KEY } from '../../pm-plugins/main'; import { getMediaResizeAnalyticsEvent } from '../../utils/analytics'; import { checkMediaType } from '../../utils/check-media-type'; import { ResizableMediaMigrationNotification } from './ResizableMediaMigrationNotification'; import { wrapperStyle } from './styled'; export const resizerNextTestId = 'mediaSingle.resizerNext.testid'; // eslint-disable-next-line @repo/internal/react/no-class-components class ResizableMediaSingleNext extends React.Component { constructor(props) { super(props); _defineProperty(this, "lastSnappedGuidelineKeys", []); _defineProperty(this, "updateGuidelines", () => { const { view, lineLength } = this.props; const defaultGuidelines = this.getDefaultGuidelines(); const { relativeGuides, dynamicGuides } = generateDynamicGuidelines(view.state, lineLength, { styles: { lineStyle: 'dashed' }, show: false }); // disable guidelines for nested media single node const dynamicGuidelines = this.isNestedNode() ? [] : dynamicGuides; this.setState({ relativeGuides, guidelines: [...defaultGuidelines, ...dynamicGuidelines] }); }); _defineProperty(this, "calcNewLayout", (newWidth, stop) => { const { layout, containerWidth, lineLength, fullWidthMode } = this.props; const newPct = calcPctFromPx(newWidth, lineLength) * 100; if (newPct <= 100 && this.wrappedLayout) { if (!stop || newPct !== 100) { return layout; } } return this.calcUnwrappedLayout(newWidth, containerWidth, lineLength, fullWidthMode, this.isNestedNode()); }); _defineProperty(this, "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'; } if (width < Math.min(containerWidth - akEditorGutterPadding * 2, akEditorFullWidthLayoutWidth)) { return 'wide'; } // set full width to be containerWidth - akEditorGutterPadding * 2 // instead of containerWidth - akEditorBreakoutPadding, // so that we have image aligned with text return 'full-width'; }); _defineProperty(this, "calcPxHeight", newWidth => { const { width = newWidth, height } = this.props; return Math.round(height / width * newWidth); }); _defineProperty(this, "displayGuideline", guidelines => { var _this$props$pluginInj, _this$props$pluginInj2, _this$props$pluginInj3; return (_this$props$pluginInj = this.props.pluginInjectionApi) === null || _this$props$pluginInj === void 0 ? void 0 : (_this$props$pluginInj2 = _this$props$pluginInj.guideline) === null || _this$props$pluginInj2 === void 0 ? void 0 : (_this$props$pluginInj3 = _this$props$pluginInj2.actions) === null || _this$props$pluginInj3 === void 0 ? void 0 : _this$props$pluginInj3.displayGuideline(this.props.view)({ guidelines }); }); _defineProperty(this, "setIsResizing", isResizing => { const { state, dispatch } = this.props.view; const tr = state.tr; tr.setMeta(MEDIA_PLUGIN_IS_RESIZING_KEY, isResizing); return dispatch(tr); }); _defineProperty(this, "updateSizeInPluginState", throttle(width => { const { state, dispatch } = this.props.view; const tr = state.tr; tr.setMeta(MEDIA_PLUGIN_RESIZING_WIDTH_KEY, width); return dispatch(tr); }, MEDIA_SINGLE_RESIZE_THROTTLE_TIME)); _defineProperty(this, "calcMaxWidth", memoizeOne((contentWidth, containerWidth, fullWidthMode) => { if (this.isNestedNode() || fullWidthMode) { return contentWidth; } return calcMediaSingleMaxWidth(containerWidth); })); _defineProperty(this, "calcMinWidth", memoizeOne((isVideoFile, contentWidth) => { return Math.min(contentWidth || akEditorDefaultLayoutWidth, isVideoFile ? MEDIA_SINGLE_VIDEO_MIN_PIXEL_WIDTH : MEDIA_SINGLE_DEFAULT_MIN_PIXEL_WIDTH); })); _defineProperty(this, "getRelativeGuides", () => { var _this$props$pluginInj4, _this$props$pluginInj5, _this$props$pluginInj6; const guidelinePluginState = (_this$props$pluginInj4 = this.props.pluginInjectionApi) === null || _this$props$pluginInj4 === void 0 ? void 0 : (_this$props$pluginInj5 = _this$props$pluginInj4.guideline) === null || _this$props$pluginInj5 === void 0 ? void 0 : (_this$props$pluginInj6 = _this$props$pluginInj5.sharedState) === null || _this$props$pluginInj6 === void 0 ? void 0 : _this$props$pluginInj6.currentState(); const { top: topOffset } = (guidelinePluginState === null || guidelinePluginState === void 0 ? void 0 : guidelinePluginState.rect) || { top: 0, left: 0 }; const $pos = this.$pos; const relativeGuides = $pos && $pos.nodeAfter && this.state.size.width ? getRelativeGuidelines(this.state.relativeGuides, { node: $pos.nodeAfter, pos: $pos.pos }, this.props.view, this.props.lineLength, topOffset, this.state.size) : []; return relativeGuides; }); _defineProperty(this, "updateActiveGuidelines", (width = 0, guidelines, guidelineSnapsReference) => { if (guidelineSnapsReference.snaps.x) { const { gap, keys: activeGuidelineKeys } = findClosestSnap(width, guidelineSnapsReference.snaps.x, guidelineSnapsReference.guidelineReference, MEDIA_SINGLE_SNAP_GAP); const relativeGuidelines = activeGuidelineKeys.length ? [] : this.getRelativeGuides(); this.lastSnappedGuidelineKeys = activeGuidelineKeys.length ? activeGuidelineKeys : relativeGuidelines.map(rg => rg.key); this.displayGuideline([...getGuidelinesWithHighlights(gap, MEDIA_SINGLE_SNAP_GAP, activeGuidelineKeys, guidelines), ...relativeGuidelines]); } }); _defineProperty(this, "calculateSizeState", (size, delta, onResizeStop = false) => { const calculatedWidth = Math.round(size.width + delta.width); const calculatedWidthWithLayout = this.calcNewLayout(calculatedWidth, onResizeStop); return { width: calculatedWidth, height: calculatedWidth / this.aspectRatio, layout: calculatedWidthWithLayout }; }); _defineProperty(this, "selectCurrentMediaNode", () => { // TODO: if adding !this.props.selected, it doesn't work if media single node is at top postion if (this.pos === null) { return; } setNodeSelection(this.props.view, this.pos); }); _defineProperty(this, "handleResizeStart", () => { this.setState({ isResizing: true }); this.selectCurrentMediaNode(); this.setIsResizing(true); this.updateSizeInPluginState(this.state.size.width); // re-calculate guidelines if (this.isGuidelineEnabled) { this.updateGuidelines(); } }); _defineProperty(this, "handleResize", (size, delta) => { const { layout, updateSize, lineLength } = this.props; const { width, height, layout: newLayout } = this.calculateSizeState(size, delta); if (this.isGuidelineEnabled) { const guidelineSnaps = getGuidelineSnaps(this.state.guidelines, lineLength, layout); this.updateActiveGuidelines(width, this.state.guidelines, guidelineSnaps); const relativeSnaps = getRelativeGuideSnaps(this.state.relativeGuides, this.aspectRatio); this.setState({ size: { width, height }, snaps: { x: [...(guidelineSnaps.snaps.x || []), ...relativeSnaps] } }); } else { this.setState({ size: { width, height } }); } this.updateSizeInPluginState(width); if (newLayout !== layout) { updateSize(width, newLayout); } }); _defineProperty(this, "handleResizeStop", (size, delta) => { const { updateSize, dispatchAnalyticsEvent, nodeType } = this.props; const { width, height, layout: newLayout } = this.calculateSizeState(size, delta, true); if (dispatchAnalyticsEvent) { const $pos = this.$pos; const event = getMediaResizeAnalyticsEvent(nodeType || 'mediaSingle', { width, layout: newLayout, widthType: 'pixel', snapType: getGuidelineTypeFromKey(this.lastSnappedGuidelineKeys, this.state.guidelines), parentNode: $pos ? $pos.parent.type.name : undefined }); if (event) { dispatchAnalyticsEvent(event); } } this.setIsResizing(false); this.displayGuideline([]); 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; } this.setState({ isResizing: false, size: { width: newWidth, height } }, () => { updateSize(newWidth, newLayout); }); }); const initialWidth = props.mediaSingleWidth || DEFAULT_IMAGE_WIDTH; this.state = { isVideoFile: true, isResizing: false, size: { width: initialWidth, height: this.calcPxHeight(initialWidth) }, snaps: {}, relativeGuides: {}, guidelines: [] }; } componentDidUpdate(prevProps) { if (prevProps.mediaSingleWidth !== this.props.mediaSingleWidth && this.props.mediaSingleWidth) { // update size when lineLength becomes defined later // ensures extended experience renders legacy image with the same size as the legacy experience const initialWidth = this.props.mediaSingleWidth; this.setState({ size: { width: initialWidth, height: this.calcPxHeight(initialWidth) } }); } return true; } async componentDidMount() { const { viewMediaClientConfig } = this.props; if (viewMediaClientConfig) { await this.checkVideoFile(viewMediaClientConfig); } } UNSAFE_componentWillReceiveProps(nextProps) { if (this.props.viewMediaClientConfig !== nextProps.viewMediaClientConfig) { this.checkVideoFile(nextProps.viewMediaClientConfig); } } get wrappedLayout() { return wrappedLayouts.indexOf(this.props.layout) > -1; } get pos() { if (typeof this.props.getPos !== 'function') { return null; } const pos = this.props.getPos(); if (Number.isNaN(pos) || typeof pos !== 'number') { return null; } return pos; } get $pos() { const pos = this.pos; // need to pass view because we may not get updated props in time return pos === null ? pos : this.props.view.state.doc.resolve(pos); } get aspectRatio() { const { width, height } = this.props; if (width) { return width / height; } // TODO handle this case return 1; } get insideInlineLike() { const $pos = this.$pos; if (!$pos) { return false; } const { listItem } = this.props.view.state.schema.nodes; return !!findParentNodeOfTypeClosestToPos($pos, [listItem]); } get insideLayout() { const $pos = this.$pos; if (!$pos) { return false; } const { layoutColumn } = this.props.view.state.schema.nodes; return !!findParentNodeOfTypeClosestToPos($pos, [layoutColumn]); } get isGuidelineEnabled() { var _this$props$pluginInj7; return !!((_this$props$pluginInj7 = this.props.pluginInjectionApi) !== null && _this$props$pluginInj7 !== void 0 && _this$props$pluginInj7.guideline); } // check if is inside of layout, table, expand, nestedExpand and list item isNestedNode() { const $pos = this.$pos; return !!($pos && $pos.depth !== 0); } getDefaultGuidelines() { const { lineLength, containerWidth, fullWidthMode } = this.props; // disable guidelines for nested media single node return this.isNestedNode() ? [] : generateDefaultGuidelines(lineLength, containerWidth, fullWidthMode); } async checkVideoFile(viewMediaClientConfig) { if (this.pos === null || !viewMediaClientConfig) { return; } const mediaNode = this.props.view.state.doc.nodeAt(this.pos + 1); const mediaType = mediaNode ? await checkMediaType(mediaNode, viewMediaClientConfig) : undefined; const isVideoFile = mediaType !== 'external' && mediaType !== 'image'; if (this.state.isVideoFile !== isVideoFile) { this.setState({ isVideoFile }); } } render() { const { width: origWidth, layout, containerWidth, fullWidthMode, selected, children, lineLength, showLegacyNotification } = this.props; const { isResizing, size, isVideoFile } = this.state; const enable = {}; handleSides.forEach(side => { const oppositeSide = side === 'left' ? 'right' : 'left'; enable[side] = nonWrappedLayouts.concat(`wrap-${oppositeSide}`).concat(`align-${imageAlignmentMap[oppositeSide]}`).indexOf(layout) > -1; if (side === 'left' && this.insideInlineLike) { enable[side] = false; } }); // TODO: Clean up where this lives and how it gets generated const className = classnames(richMediaClassName, `image-${layout}`, isResizing ? 'is-resizing' : 'not-resizing', this.props.className, { 'richMedia-selected': selected, 'rich-media-wrapped': layout === 'wrap-left' || layout === 'wrap-right' }); const resizerNextClassName = classnames(className, resizerStyles); const isNestedNode = this.isNestedNode(); const maxWidth = !isResizing && isNestedNode ? // set undefined to fall back to 100% undefined : this.calcMaxWidth(lineLength, containerWidth, fullWidthMode); const minWidth = this.calcMinWidth(isVideoFile, 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%)`; return jsx("div", { css: wrapperStyle({ layout, containerWidth: containerWidth || origWidth, fullWidthMode, mediaSingleWidth: this.state.size.width, isNestedNode, isExtendedResizeExperienceOn: true }) }, jsx(ResizerNext, { minWidth: minViewWidth, maxWidth: maxWidth, className: resizerNextClassName, snapGap: MEDIA_SINGLE_SNAP_GAP, enable: enable, width: size.width, handleResizeStart: this.handleResizeStart, handleResize: this.handleResize, handleResizeStop: this.handleResizeStop, snap: this.state.snaps, resizeRatio: nonWrappedLayouts.includes(layout) ? 2 : 1, "data-testid": resizerNextTestId, isHandleVisible: selected, handlePositioning: isNestedNode ? 'adjacent' : undefined, handleHighlight: "full-height" }, children, showLegacyNotification && jsx(ResizableMediaMigrationNotification, null))); } } export default ResizableMediaSingleNext;