@atlaskit/editor-plugin-card
Version:
Card plugin for @atlaskit/editor-core
553 lines (548 loc) • 21.3 kB
JavaScript
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();
};