UNPKG

@atlaskit/editor-plugin-card

Version:

Card plugin for @atlaskit/editor-core

553 lines (548 loc) 21.3 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import _extends from "@babel/runtime/helpers/extends"; import React from 'react'; import rafSchedule from 'raf-schd'; // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead import uuid from 'uuid/v4'; import { SetAttrsStep } from '@atlaskit/adf-schema/steps'; import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks'; import ReactNodeView from '@atlaskit/editor-common/react-node-view'; import { findOverflowScrollParent, MediaSingle as RichMediaWrapper, UnsupportedBlock } from '@atlaskit/editor-common/ui'; import { useSharedPluginStateSelector } from '@atlaskit/editor-common/use-shared-plugin-state-selector'; import { floatingLayouts, isRichMediaInsideOfBlockNode } from '@atlaskit/editor-common/utils'; import { akEditorFullPageNarrowBreakout, DEFAULT_EMBED_CARD_HEIGHT, DEFAULT_EMBED_CARD_WIDTH } from '@atlaskit/editor-shared-styles'; import { SmartLinkDraggable, SMART_LINK_DRAG_TYPES, SMART_LINK_APPEARANCE } from '@atlaskit/editor-smart-link-draggable'; import { fg } from '@atlaskit/platform-feature-flags'; import { componentWithCondition } from '@atlaskit/platform-feature-flags-react'; import { EmbedResizeMessageListener, Card as SmartCard } from '@atlaskit/smart-card'; import { CardSSR } from '@atlaskit/smart-card/ssr'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { registerCard, removeCard } from '../pm-plugins/actions'; import ResizableEmbedCard from '../ui/ResizableEmbedCard'; import { BlockCardComponent } from './blockCard'; import { Card } from './genericCard'; const selector = states => { var _states$widthState, _states$widthState2, _states$editorDisable; return { widthStateLineLength: ((_states$widthState = states.widthState) === null || _states$widthState === void 0 ? void 0 : _states$widthState.lineLength) || 0, widthStateWidth: ((_states$widthState2 = states.widthState) === null || _states$widthState2 === void 0 ? void 0 : _states$widthState2.width) || 0, editorDisabled: (_states$editorDisable = states.editorDisabledState) === null || _states$editorDisable === void 0 ? void 0 : _states$editorDisable.editorDisabled }; }; const CardInner = ({ pluginInjectionApi, getPosSafely, getLineLength, view, smartCard, updateSize, getPos, aspectRatio, allowResizing, hasPreview, heightAlone, cardProps, dispatchAnalyticsEvent }) => { const { widthStateLineLength, widthStateWidth, editorDisabled } = useSharedPluginStateWithSelector(pluginInjectionApi, ['width', 'editorDisabled'], selector); const pos = getPosSafely(); if (pos === undefined) { return null; } const lineLength = getLineLength(view, pos, widthStateLineLength); const containerWidth = isRichMediaInsideOfBlockNode(view, pos) ? lineLength : widthStateWidth; if (!allowResizing || !hasPreview) { // There are two ways `width` and `height` can be defined here: // 1) Either as `heightAlone` as height value and no width // 2) or as `1` for height and aspectRation (defined or a default one) as a width // See above for how aspectRation is calculated. const defaultAspectRatio = DEFAULT_EMBED_CARD_WIDTH / DEFAULT_EMBED_CARD_HEIGHT; let richMediaWrapperHeight = 1; let richMediaWrapperWidth = aspectRatio || defaultAspectRatio; if (heightAlone) { richMediaWrapperHeight = heightAlone; richMediaWrapperWidth = undefined; } return /*#__PURE__*/React.createElement(RichMediaWrapper // Ignored via go/ees005 // eslint-disable-next-line react/jsx-props-no-spreading , _extends({}, cardProps, { height: richMediaWrapperHeight, width: richMediaWrapperWidth, nodeType: "embedCard", hasFallbackContainer: hasPreview, lineLength: lineLength, containerWidth: containerWidth }), smartCard); } const displayGrid = (visible, gridType, highlight) => { var _pluginInjectionApi$g, _pluginInjectionApi$g2; return pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$g = pluginInjectionApi.grid) === null || _pluginInjectionApi$g === void 0 ? void 0 : (_pluginInjectionApi$g2 = _pluginInjectionApi$g.actions) === null || _pluginInjectionApi$g2 === void 0 ? void 0 : _pluginInjectionApi$g2.displayGrid(view)({ visible, gridType, highlight: highlight }); }; return /*#__PURE__*/React.createElement(ResizableEmbedCard // Ignored via go/ees005 // eslint-disable-next-line react/jsx-props-no-spreading , _extends({}, cardProps, { height: heightAlone, aspectRatio: aspectRatio, view: view, getPos: getPos, lineLength: lineLength, gridSize: 12, containerWidth: containerWidth, displayGrid: displayGrid, updateSize: updateSize, dispatchAnalyticsEvent: dispatchAnalyticsEvent, isResizeDisabled: editorDisabled }), smartCard); }; // eslint-disable-next-line @repo/internal/react/no-class-components export class EmbedCardComponent extends React.PureComponent { constructor(props) { super(props); // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting _defineProperty(this, "embedIframeRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "getPosSafely", () => { const { getPos } = this.props; if (!getPos || typeof getPos === 'boolean') { return; } try { return getPos(); } catch { // Can blow up in rare cases, when node has been removed. } }); _defineProperty(this, "onResolve", data => { const { view } = this.props; const { title, url, aspectRatio } = data; const { originalHeight, originalWidth } = this.props.node.attrs; if (aspectRatio && !originalHeight && !originalWidth) { // Assumption here is if ADF already have both height and width set, // we will going to use that later on in this class as aspectRatio // Most likely we dealing with an embed that received aspectRatio via onResolve previously // and now this information already stored in ADF. this.setState({ initialAspectRatio: aspectRatio }); this.saveOriginalDimensionsAttributes(DEFAULT_EMBED_CARD_HEIGHT, DEFAULT_EMBED_CARD_HEIGHT * aspectRatio); } // don't dispatch immediately since we might be in the middle of // rendering a nodeview rafSchedule(() => { const pos = this.getPosSafely(); if (pos === undefined) { return; } return view.dispatch(registerCard({ title, url, pos, id: this.props.id })(view.state.tr)); })(); try { var _this$props$cardConte, _this$props$cardConte2; const cardContext = (_this$props$cardConte = this.props.cardContext) !== null && _this$props$cardConte !== void 0 && _this$props$cardConte.value ? (_this$props$cardConte2 = this.props.cardContext) === null || _this$props$cardConte2 === void 0 ? void 0 : _this$props$cardConte2.value : undefined; const hasPreview = url && cardContext && cardContext.extractors.getPreview(url, 'web'); if (!hasPreview) { this.setState({ hasPreview: false }); } } catch {} }); _defineProperty(this, "updateSize", (pctWidth, layout) => { const { state, dispatch } = this.props.view; const pos = this.getPosSafely(); if (pos === undefined) { return; } const tr = state.tr.setNodeMarkup(pos, undefined, { ...this.props.node.attrs, width: pctWidth, layout }); tr.setMeta('scrollIntoView', false); dispatch(tr); return true; }); /** * Defers line-length measurement until the embed card DOM has fully rendered. * * When put embed in the expand, reload the page and open that expand, the embed was collapsed. * Because the `.rich-media-item` can temporarily report `offsetWidth = 1`, as proportional width styles have not been applied yet. * Measuring at that moment would cache the bogus width and break resize calculations. * Scheduling a measurement on the next animation frame ensures layout has * settled. We then force the node view to re-render so `getLineLength` * re-runs and captures the correct width. */ _defineProperty(this, "scheduleLineLengthRemeasureRaf", rafSchedule(view => { if (view) { this.forceUpdate(); } })); _defineProperty(this, "getLineLength", (view, pos, originalLineLength) => { if (typeof pos === 'number' && isRichMediaInsideOfBlockNode(view, pos)) { const $pos = view.state.doc.resolve(pos); const domNode = view.nodeDOM($pos.pos); if ($pos.nodeAfter && floatingLayouts.indexOf($pos.nodeAfter.attrs.layout) > -1 && domNode && domNode.parentElement) { return domNode.parentElement.offsetWidth; } if (domNode instanceof HTMLElement) { const measuredWidth = domNode.offsetWidth; if (measuredWidth <= 1) { this.scheduleLineLengthRemeasureRaf(view); return originalLineLength; } return measuredWidth; } } return originalLineLength; }); /** * Even though render is capable of listening and reacting to iframely wrapper iframe sent `resize` events * it's good idea to store latest actual height in ADF, so that when renderer (well, editor as well) is loading * we will show embed window of appropriate size and avoid unnecessary content jumping. */ _defineProperty(this, "saveOriginalDimensionsAttributes", (height, width) => { const { view } = this.props; // Please, do not copy or use this kind of code below // @ts-ignore const fakeTableResizePluginKey = { key: 'tableFlexiColumnResizing$', getState: state => { // eslint-disable-next-line return state['tableFlexiColumnResizing$']; } }; const fakeTableResizeState = fakeTableResizePluginKey.getState(view.state); // We are not updating ADF when this function fired while table is resizing. // Changing ADF in the middle of resize will break table resize plugin logic // (tables will be considered different at the end of the drag and cell size won't be stored) // But this is not a big problem, editor user will be seeing latest height anyway (via updated state) // And even if page to be saved with slightly outdated height, renderer is capable of reading latest height value // when embed loads, and so it won't be a problem. if (fakeTableResizeState !== null && fakeTableResizeState !== void 0 && fakeTableResizeState.dragging) { return; } rafSchedule(() => { const pos = this.getPosSafely(); if (pos === undefined) { return; } view.dispatch(view.state.tr.step(new SetAttrsStep(pos, { originalHeight: height, originalWidth: width })).setMeta('addToHistory', false)); })(); }); _defineProperty(this, "onHeightUpdate", height => { this.setState({ liveHeight: height }); this.saveOriginalDimensionsAttributes(height, undefined); }); _defineProperty(this, "onError", ({ err }) => { if (err) { throw err; } }); _defineProperty(this, "removeCardDispatched", false); this.scrollContainer = findOverflowScrollParent(props.view.dom) || undefined; this.state = { hasPreview: true }; } componentWillUnmount() { this.removeCard(); } removeCard() { if (this.removeCardDispatched) { return; } this.removeCardDispatched = true; const { tr } = this.props.view.state; removeCard({ id: this.props.id })(tr); this.props.view.dispatch(tr); } render() { const { node, allowResizing, fullWidthMode, view, dispatchAnalyticsEvent, getPos, pluginInjectionApi, actionOptions, onClick, CompetitorPrompt, isPageSSRed } = this.props; const { url, width: pctWidth, layout, originalHeight, originalWidth } = node.attrs; const { hasPreview, liveHeight, initialAspectRatio } = this.state; // We don't want to use `originalHeight` when `originalWidth` also present, // since `heightAlone` is defined only when just height is available. const heightAlone = liveHeight !== null && liveHeight !== void 0 ? liveHeight : !originalWidth && originalHeight || undefined; const aspectRatio = !heightAlone && ( // No need getting aspectRatio if heightAlone defined already initialAspectRatio || // If we have initialAspectRatio (coming from iframely) we should go with that originalHeight && originalWidth && originalWidth / originalHeight) || // If ADF contains both width and height we get ratio from that undefined; const cardProps = { layout, pctWidth, fullWidthMode }; const smartCard = isPageSSRed ? /*#__PURE__*/React.createElement(CardSSR, { key: url, url: url, appearance: "embed", onClick: onClick, onResolve: this.onResolve, onError: this.onError, frameStyle: "show", inheritDimensions: true, platform: 'web', container: this.scrollContainer, embedIframeRef: this.embedIframeRef, actionOptions: actionOptions, CompetitorPrompt: CompetitorPrompt, hideIconLoadingSkeleton: true }) : /*#__PURE__*/React.createElement(SmartCard, { key: url, url: url, appearance: "embed", onClick: onClick, onResolve: this.onResolve, onError: this.onError, frameStyle: "show", inheritDimensions: true, platform: 'web', container: this.scrollContainer, embedIframeRef: this.embedIframeRef, actionOptions: actionOptions, CompetitorPrompt: CompetitorPrompt }); return /*#__PURE__*/React.createElement(SmartLinkDraggable, { url: url, appearance: SMART_LINK_APPEARANCE.EMBED, source: SMART_LINK_DRAG_TYPES.EDITOR }, /*#__PURE__*/React.createElement(EmbedResizeMessageListener, { embedIframeRef: this.embedIframeRef, onHeightUpdate: this.onHeightUpdate }, /*#__PURE__*/React.createElement(CardInner, { pluginInjectionApi: pluginInjectionApi, smartCard: smartCard, hasPreview: hasPreview, getPosSafely: this.getPosSafely, view: view, getLineLength: this.getLineLength, eventDispatcher: this.props.eventDispatcher, updateSize: this.updateSize, getPos: getPos, aspectRatio: aspectRatio, allowResizing: allowResizing, heightAlone: heightAlone, cardProps: cardProps, dispatchAnalyticsEvent: dispatchAnalyticsEvent }))); } } export const EmbedOrBlockCardComponent = props => { const width = useSharedPluginStateSelector(props.pluginInjectionApi, 'width.width'); const viewAsBlockCard = width && width <= akEditorFullPageNarrowBreakout; return viewAsBlockCard ? /*#__PURE__*/React.createElement(BlockCardComponent, { id: props.id, node: props.node, view: props.view, getPos: props.getPos, pluginInjectionApi: props.pluginInjectionApi, actionOptions: props.actionOptions, onClick: props.onClick, CompetitorPrompt: props.CompetitorPrompt, allowResizing: props.allowResizing, fullWidthMode: props.fullWidthMode, dispatchAnalyticsEvent: props.dispatchAnalyticsEvent, eventDispatcher: props.eventDispatcher, cardContext: props.cardContext, smartCard: props.smartCard, hasPreview: props.hasPreview, liveHeight: props.liveHeight, initialAspectRatio: props.initialAspectRatio, isPageSSRed: props.isPageSSRed, provider: props.provider }) : /*#__PURE__*/React.createElement(EmbedCardComponent, { id: props.id, node: props.node, view: props.view, getPos: props.getPos, pluginInjectionApi: props.pluginInjectionApi, actionOptions: props.actionOptions, onClick: props.onClick, CompetitorPrompt: props.CompetitorPrompt, allowResizing: props.allowResizing, fullWidthMode: props.fullWidthMode, dispatchAnalyticsEvent: props.dispatchAnalyticsEvent, eventDispatcher: props.eventDispatcher, cardContext: props.cardContext, smartCard: props.smartCard, hasPreview: props.hasPreview, liveHeight: props.liveHeight, initialAspectRatio: props.initialAspectRatio, isPageSSRed: props.isPageSSRed, provider: props.provider }); }; const WrappedEmbedCardWithCondition = componentWithCondition(() => editorExperiment('platform_editor_preview_panel_responsiveness', true, { exposure: true }), EmbedOrBlockCardComponent, EmbedCardComponent); const WrappedEmbedCard = Card(WrappedEmbedCardWithCondition, UnsupportedBlock); export class EmbedCard extends ReactNodeView { constructor(...args) { super(...args); // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead _defineProperty(this, "id", uuid()); _defineProperty(this, "updateContentEditable", (editorViewModeState, divElement) => { divElement.contentEditable = (editorViewModeState === null || editorViewModeState === void 0 ? void 0 : editorViewModeState.mode) === 'view' ? 'false' : 'true'; }); } viewShouldUpdate(nextNode) { if (this.node.attrs !== nextNode.attrs) { return true; } return super.viewShouldUpdate(nextNode); } createDomRef() { var _this$reactComponentP, _this$reactComponentP2, _this$reactComponentP3, _this$reactComponentP4; const domRef = document.createElement('div'); // It is a tradeoff for the bug mentioned that occurs in Chrome: https://product-fabric.atlassian.net/browse/ED-5379, https://github.com/ProseMirror/prosemirror/issues/884 this.unsubscribe = (_this$reactComponentP = this.reactComponentProps.pluginInjectionApi) === null || _this$reactComponentP === void 0 ? void 0 : (_this$reactComponentP2 = _this$reactComponentP.editorViewMode) === null || _this$reactComponentP2 === void 0 ? void 0 : _this$reactComponentP2.sharedState.onChange(({ nextSharedState }) => this.updateContentEditable(nextSharedState, domRef)); this.updateContentEditable((_this$reactComponentP3 = this.reactComponentProps.pluginInjectionApi) === null || _this$reactComponentP3 === void 0 ? void 0 : (_this$reactComponentP4 = _this$reactComponentP3.editorViewMode) === null || _this$reactComponentP4 === void 0 ? void 0 : _this$reactComponentP4.sharedState.currentState(), domRef); domRef.setAttribute('spellcheck', 'false'); return domRef; } render() { const { eventDispatcher, allowResizing, fullWidthMode, dispatchAnalyticsEvent, pluginInjectionApi, onClickCallback, CompetitorPrompt, isPageSSRed, provider } = this.reactComponentProps; return /*#__PURE__*/React.createElement(WrappedEmbedCard, { node: this.node, view: this.view, eventDispatcher: eventDispatcher, getPos: this.getPos, allowResizing: allowResizing, fullWidthMode: fullWidthMode, dispatchAnalyticsEvent: dispatchAnalyticsEvent, pluginInjectionApi: pluginInjectionApi, onClickCallback: onClickCallback, id: this.id, CompetitorPrompt: CompetitorPrompt, isPageSSRed: isPageSSRed, provider: provider }); } /** * Prevent ProseMirror from handling drag events on the smart-element-link, * allowing native drag to work so SmartLinkDraggable can intercept it. * @see {@link https://prosemirror.net/docs/ref/#view.NodeView.stopEvent} */ stopEvent(event) { if (event.type === 'dragstart') { const target = event.target; if (target instanceof HTMLElement && target.closest('[data-smart-element-link]') && fg('cc_drag_and_drop_smart_link_from_content_to_tree')) { return true; } } return false; } destroy() { var _this$unsubscribe; (_this$unsubscribe = this.unsubscribe) === null || _this$unsubscribe === void 0 ? void 0 : _this$unsubscribe.call(this); super.destroy(); } } export const embedCardNodeView = ({ allowResizing, fullWidthMode, pmPluginFactoryParams, pluginInjectionApi, actionOptions, onClickCallback, CompetitorPrompt, isPageSSRed, provider }) => (node, view, getPos) => { const { portalProviderAPI, eventDispatcher, dispatchAnalyticsEvent } = pmPluginFactoryParams; const reactComponentProps = { eventDispatcher, allowResizing, fullWidthMode, dispatchAnalyticsEvent, pluginInjectionApi, actionOptions, onClickCallback: onClickCallback, CompetitorPrompt, isPageSSRed, provider }; return new EmbedCard(node, view, getPos, portalProviderAPI, eventDispatcher, reactComponentProps).init(); };