@atlaskit/editor-plugin-extension
Version:
editor-plugin-extension plugin for @atlaskit/editor-core
450 lines (438 loc) • 19.8 kB
JavaScript
import React from 'react';
import { INPUT_METHOD, ACTION, ACTION_SUBJECT, EVENT_TYPE, ACTION_SUBJECT_ID } from '@atlaskit/editor-common/analytics';
import { messages, NATIVE_EMBED_EXTENSION_KEY, NATIVE_EMBED_EXTENSION_TYPE } from '@atlaskit/editor-common/extensions';
import commonMessages from '@atlaskit/editor-common/messages';
import { BODIED_EXT_MBE_MARGIN_TOP } from '@atlaskit/editor-common/styles';
import { areToolbarFlagsEnabled } from '@atlaskit/editor-common/toolbar-flag-check';
import { getChildrenInfo, getNodeName, isReferencedSource } from '@atlaskit/editor-common/utils';
import { isOfflineMode } from '@atlaskit/editor-plugin-connectivity';
import { findParentNodeOfType, hasParentNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import ContentWidthNarrowIcon from '@atlaskit/icon/core/content-width-narrow';
import ContentWidthWideIcon from '@atlaskit/icon/core/content-width-wide';
import DeleteIcon from '@atlaskit/icon/core/delete';
import EditIcon from '@atlaskit/icon/core/edit';
import ExpandHorizontalIcon from '@atlaskit/icon/core/expand-horizontal';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { editExtension } from '../editor-actions/actions';
import { removeDescendantNodes, removeExtension, updateExtensionLayout } from '../editor-commands/commands';
import { pluginKey as macroPluginKey } from './macro/plugin-key';
import { getPluginState } from './plugin-factory';
import { copyUnsupportedContentToClipboard, getSelectedExtension, onCopyFailed } from './utils';
// non-bodied extensions nested inside panels, blockquotes and lists do not support layouts
const isNestedNBM = (state, selectedExtNode) => {
const {
schema: {
nodes: {
extension,
panel,
blockquote,
listItem
}
},
selection
} = state;
if (!selectedExtNode) {
return false;
}
return selectedExtNode.node.type === extension && hasParentNodeOfType([panel, blockquote, listItem].filter(Boolean))(selection);
};
const isLayoutSupported = (state, selectedExtNode) => {
const {
schema: {
nodes: {
bodiedExtension,
extension,
layoutSection,
table,
expand,
multiBodiedExtension
}
},
selection
} = state;
if (!selectedExtNode) {
return false;
}
const isMultiBodiedExtension = selectedExtNode.node.type === multiBodiedExtension;
const isNonEmbeddedBodiedExtension = selectedExtNode.node.type === bodiedExtension && !hasParentNodeOfType([multiBodiedExtension].filter(Boolean))(selection);
const isNonEmbeddedExtension = selectedExtNode.node.type === extension && !hasParentNodeOfType([bodiedExtension, table, expand, multiBodiedExtension].filter(Boolean))(selection);
// if selection belongs to layout supported extension category
// and not inside a layoutSection
return !!((isMultiBodiedExtension || isNonEmbeddedBodiedExtension || isNonEmbeddedExtension) && !hasParentNodeOfType([layoutSection])(selection));
};
const breakoutButtonListOptions = (state, formatMessage, extensionState, breakoutEnabled, editorAnalyticsAPI) => {
const nodeWithPos = getSelectedExtension(state, true);
// we should only return breakout options when breakouts are enabled and the node supports them
if (nodeWithPos && breakoutEnabled && isLayoutSupported(state, nodeWithPos) && !isNestedNBM(state, nodeWithPos)) {
const {
layout
} = nodeWithPos.node.attrs;
return [{
type: 'button',
icon: () => /*#__PURE__*/React.createElement(ContentWidthNarrowIcon, {
label: formatMessage(commonMessages.layoutFixedWidth),
spacing: "spacious"
}),
iconFallback: ContentWidthNarrowIcon,
onClick: updateExtensionLayout('default', editorAnalyticsAPI),
selected: layout === 'default',
title: formatMessage(commonMessages.layoutFixedWidth),
tabIndex: null
}, {
type: 'button',
icon: () => /*#__PURE__*/React.createElement(ContentWidthWideIcon, {
label: formatMessage(commonMessages.layoutWide),
spacing: "spacious"
}),
iconFallback: ContentWidthWideIcon,
onClick: updateExtensionLayout('wide', editorAnalyticsAPI),
selected: layout === 'wide',
title: formatMessage(commonMessages.layoutWide),
tabIndex: null
}, {
type: 'button',
icon: () => /*#__PURE__*/React.createElement(ExpandHorizontalIcon, {
label: formatMessage(commonMessages.layoutFullWidth),
spacing: "spacious"
}),
iconFallback: ExpandHorizontalIcon,
onClick: updateExtensionLayout('full-width', editorAnalyticsAPI),
selected: layout === 'full-width',
title: formatMessage(commonMessages.layoutFullWidth),
tabIndex: null
}];
}
return [];
};
const breakoutDropdownOptions = (state, formatMessage, breakoutEnabled, editorAnalyticsAPI) => {
const nodeWithPos = getSelectedExtension(state, true);
// we should only return breakout options when breakouts are enabled and the node supports them
if (!nodeWithPos || !breakoutEnabled || !isLayoutSupported(state, nodeWithPos) || isNestedNBM(state, nodeWithPos)) {
return [];
}
const {
layout
} = nodeWithPos.node.attrs;
let title = '';
let IconComponent;
switch (layout) {
case 'wide':
title = formatMessage(commonMessages.layoutStateWide);
IconComponent = ContentWidthWideIcon;
break;
case 'full-width':
title = formatMessage(commonMessages.layoutStateFullWidth);
IconComponent = ExpandHorizontalIcon;
break;
case 'default':
default:
title = formatMessage(commonMessages.layoutStateFixedWidth);
IconComponent = ContentWidthNarrowIcon;
break;
}
const options = [{
id: 'editor.extensions.width.default',
title: formatMessage(commonMessages.layoutFixedWidth),
onClick: updateExtensionLayout('default', editorAnalyticsAPI),
selected: layout === 'default',
icon: /*#__PURE__*/React.createElement(ContentWidthNarrowIcon, {
color: "currentColor",
spacing: "spacious",
label: formatMessage(commonMessages.layoutFixedWidth)
})
}, {
id: 'editor.extensions.width.wide',
title: formatMessage(commonMessages.layoutWide),
onClick: updateExtensionLayout('wide', editorAnalyticsAPI),
selected: layout === 'wide',
icon: /*#__PURE__*/React.createElement(ContentWidthWideIcon, {
color: "currentColor",
spacing: "spacious",
label: formatMessage(commonMessages.layoutWide)
})
}, {
id: 'editor.extensions.width.full-width',
title: formatMessage(commonMessages.layoutFullWidth),
onClick: updateExtensionLayout('full-width', editorAnalyticsAPI),
selected: layout === 'full-width',
icon: /*#__PURE__*/React.createElement(ExpandHorizontalIcon, {
color: "currentColor",
spacing: "spacious",
label: formatMessage(commonMessages.layoutFullWidth)
})
}];
return [{
id: 'extensions-width-options-toolbar-item',
testId: 'extensions-width-options-toolbar-dropdown',
type: 'dropdown',
options: options,
title,
iconBefore: () => /*#__PURE__*/React.createElement(IconComponent, {
color: "currentColor",
spacing: "spacious",
label: title
})
}];
};
const breakoutOptions = (state, formatMessage, extensionState, breakoutEnabled, editorAnalyticsAPI, api) => {
return areToolbarFlagsEnabled(Boolean(api === null || api === void 0 ? void 0 : api.toolbar)) ? breakoutDropdownOptions(state, formatMessage, breakoutEnabled, editorAnalyticsAPI) : breakoutButtonListOptions(state, formatMessage, extensionState, breakoutEnabled, editorAnalyticsAPI);
};
const editButton = (formatMessage, extensionState, applyChangeToContextPanel, editorAnalyticsAPI, isDisabled = false, api) => {
if (!extensionState.showEditButton) {
return [];
}
const toolbarFlagsEnabled = areToolbarFlagsEnabled(Boolean(api === null || api === void 0 ? void 0 : api.toolbar));
const editButtonItems = [{
id: 'editor.extension.edit',
type: 'button',
icon: EditIcon,
iconFallback: EditIcon,
testId: 'extension-toolbar-edit-button',
// Taking the latest `updateExtension` from plugin state to avoid race condition @see ED-8501
onClick: (state, dispatch, view) => {
const macroState = macroPluginKey.getState(state);
const {
updateExtension
} = getPluginState(state);
editExtension(macroState && macroState.macroProvider, applyChangeToContextPanel, editorAnalyticsAPI, updateExtension)(state, dispatch, view);
return true;
},
title: formatMessage(messages.edit),
tabIndex: null,
focusEditoronEnter: true,
disabled: isDisabled,
isRadioButton: expValEquals('platform_editor_august_a11y', 'isEnabled', true) ? false : undefined
}];
if (toolbarFlagsEnabled) {
editButtonItems.push({
type: 'separator',
fullHeight: true
});
}
return editButtonItems;
};
/**
* Calculates the position for the toolbar when dealing with nested extensions
* @param editorView
* @param nextPos
* @param state
* @param extensionNode
* @example
*/
const calculateToolbarPosition = (editorView, nextPos, state, extensionNode) => {
const {
state: {
schema,
selection
}
} = editorView;
const possibleMbeParent = findParentNodeOfType(schema.nodes.extensionFrame)(selection);
// We only want to use calculated position in case of a bodiedExtension present inside an MBE node
const isBodiedExtensionInsideMBE = possibleMbeParent && (extensionNode === null || extensionNode === void 0 ? void 0 : extensionNode.node.type.name) === 'bodiedExtension';
let scrollWrapper = editorView.dom.closest('.fabric-editor-popup-scroll-parent') || document.body;
if (!extensionNode) {
return nextPos;
}
const isInsideEditableExtensionArea = !!editorView.dom.closest('.extension-editable-area');
if (!isBodiedExtensionInsideMBE && !isInsideEditableExtensionArea) {
return nextPos;
}
if (isInsideEditableExtensionArea && scrollWrapper.parentElement) {
// The editable extension area may have its own scroll wrapper, so we want to keep searching up the tree for the page level scroll wrapper.
scrollWrapper = scrollWrapper.parentElement.closest('.fabric-editor-popup-scroll-parent') || scrollWrapper;
}
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const nestedBodiedExtensionDomElement = editorView.nodeDOM(extensionNode.pos);
const nestedBodiedExtensionRect = nestedBodiedExtensionDomElement === null || nestedBodiedExtensionDomElement === void 0 ? void 0 : nestedBodiedExtensionDomElement.getBoundingClientRect();
const wrapperBounds = scrollWrapper.getBoundingClientRect();
const toolbarTopPos = nestedBodiedExtensionRect.bottom - wrapperBounds.top + scrollWrapper.scrollTop + BODIED_EXT_MBE_MARGIN_TOP;
return {
top: toolbarTopPos,
left: nextPos.left
};
};
/**
* Creates a function that copies the text content of the unsupported content extension to the clipboard
* if the current selected extension is an unsupported content extension.
*/
export const createOnClickCopyButton = ({
formatMessage,
extensionApi,
extensionProvider,
getUnsupportedContent,
state,
locale
}) => {
if (!extensionProvider) {
return;
}
const nodeWithPos = getSelectedExtension(state, true);
if (!nodeWithPos) {
return;
}
const {
node
} = nodeWithPos;
const {
extensionType,
extensionKey
} = node.attrs;
const extensionParams = {
type: node.type.name,
extensionKey,
extensionType,
parameters: node.attrs.parameters,
content: node.content,
localId: node.attrs.localId
};
const adf = getUnsupportedContent === null || getUnsupportedContent === void 0 ? void 0 : getUnsupportedContent(extensionParams);
if (!adf) {
return;
}
// this command copies the text content of the unsupported content extension to the clipboard
return editorState => {
var _extensionApi$analyti;
extensionApi === null || extensionApi === void 0 ? void 0 : (_extensionApi$analyti = extensionApi.analytics) === null || _extensionApi$analyti === void 0 ? void 0 : _extensionApi$analyti.actions.fireAnalyticsEvent({
action: ACTION.CLICKED,
actionSubject: ACTION_SUBJECT.COPY_BUTTON,
eventType: EVENT_TYPE.UI,
actionSubjectId: ACTION_SUBJECT_ID.EXTENSION,
attributes: {
extensionDynamicType: node.type.name,
extensionType: node.attrs.extensionType,
extensionKey: node.attrs.extensionKey
}
});
copyUnsupportedContentToClipboard({
locale,
unsupportedContent: adf,
schema: state.schema,
api: extensionApi
}).then(() => {
var _extensionApi$copyBut;
extensionApi === null || extensionApi === void 0 ? void 0 : (_extensionApi$copyBut = extensionApi.copyButton) === null || _extensionApi$copyBut === void 0 ? void 0 : _extensionApi$copyBut.actions.afterCopy(formatMessage(commonMessages.copiedToClipboard));
}).catch(error => {
onCopyFailed({
error,
extensionApi,
state: editorState
});
});
return true;
};
};
export const getToolbarConfig = ({
breakoutEnabled = true,
extensionApi,
getUnsupportedContent
}) => (state, intl) => {
var _extensionApi$decorat, _extensionApi$context, _extensionApi$analyti2, _extensionApi$connect, _extensionApi$connect2, _extensionApi$connect3;
const {
formatMessage,
locale
} = intl;
const extensionState = getPluginState(state);
const {
extensionProvider
} = extensionState;
const hoverDecoration = extensionApi === null || extensionApi === void 0 ? void 0 : (_extensionApi$decorat = extensionApi.decorations) === null || _extensionApi$decorat === void 0 ? void 0 : _extensionApi$decorat.actions.hoverDecoration;
const applyChangeToContextPanel = extensionApi === null || extensionApi === void 0 ? void 0 : (_extensionApi$context = extensionApi.contextPanel) === null || _extensionApi$context === void 0 ? void 0 : _extensionApi$context.actions.applyChange;
const editorAnalyticsAPI = extensionApi === null || extensionApi === void 0 ? void 0 : (_extensionApi$analyti2 = extensionApi.analytics) === null || _extensionApi$analyti2 === void 0 ? void 0 : _extensionApi$analyti2.actions;
if (!extensionState || extensionState.showContextPanel || !extensionState.element) {
return;
}
const nodeType = [state.schema.nodes.extension, state.schema.nodes.inlineExtension, state.schema.nodes.bodiedExtension, state.schema.nodes.multiBodiedExtension];
const editButtonItems = editButton(formatMessage, extensionState, applyChangeToContextPanel, editorAnalyticsAPI, editorExperiment('platform_editor_offline_editing_web', true) && isOfflineMode(extensionApi === null || extensionApi === void 0 ? void 0 : (_extensionApi$connect = extensionApi.connectivity) === null || _extensionApi$connect === void 0 ? void 0 : (_extensionApi$connect2 = _extensionApi$connect.sharedState) === null || _extensionApi$connect2 === void 0 ? void 0 : (_extensionApi$connect3 = _extensionApi$connect2.currentState()) === null || _extensionApi$connect3 === void 0 ? void 0 : _extensionApi$connect3.mode), extensionApi);
const breakoutItems = breakoutOptions(state, formatMessage, extensionState, breakoutEnabled, editorAnalyticsAPI, extensionApi);
const extensionObj = getSelectedExtension(state, true);
// If this is a native-embed extension, skip providing a toolbar config to allow
// the native-embed plugin to provide a custom toolbar config.
if ((extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node.attrs.extensionType) === NATIVE_EMBED_EXTENSION_TYPE && extensionObj !== null && extensionObj !== void 0 && extensionObj.node.attrs.extensionKey.includes(NATIVE_EMBED_EXTENSION_KEY)) {
return;
}
// Check if we need to show confirm dialog for delete button
let confirmDialog;
if (isReferencedSource(state, extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node)) {
confirmDialog = () => {
const localSourceName = formatMessage(messages.unnamedSource);
return {
title: formatMessage(messages.deleteElementTitle),
okButtonLabel: formatMessage(messages.confirmDeleteLinkedModalOKButton),
message: formatMessage(messages.confirmDeleteLinkedModalMessage, {
nodeName: getNodeName(state, extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node) || localSourceName
}),
isReferentialityDialog: true,
getChildrenInfo: () => getChildrenInfo(state, extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node),
checkboxLabel: formatMessage(messages.confirmModalCheckboxLabel),
onConfirm: (isChecked = false) => clickWithCheckboxHandler(isChecked, extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node)
};
};
}
// disable copy button for legacy content macro
const isLegacyContentMacro = (extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node.attrs.extensionType) === 'com.atlassian.confluence.migration' && (extensionObj === null || extensionObj === void 0 ? void 0 : extensionObj.node.attrs.extensionKey) === 'legacy-content';
const shouldHideCopyButton = isLegacyContentMacro && expValEquals('platform_editor_disable_lcm_copy_button', 'isEnabled', true);
return {
title: 'Extension floating controls',
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDomRef: () => extensionState.element.parentElement || undefined,
nodeType,
onPositionCalculated: (editorView, nextPos) => calculateToolbarPosition(editorView, nextPos, state, extensionObj),
items: [...editButtonItems, ...breakoutItems, {
type: 'separator',
hidden: editButtonItems.length === 0 && breakoutItems.length === 0
}, {
type: 'extensions-placeholder',
separator: 'end'
}, {
type: 'copy-button',
items: [{
state,
formatMessage: intl.formatMessage,
nodeType,
onClick: expValEquals('platform_editor_ai_edit_unsupported_content', 'isEnabled', true) ? createOnClickCopyButton({
formatMessage,
extensionApi,
extensionProvider,
getUnsupportedContent,
state,
locale
}) : undefined
}],
...(shouldHideCopyButton && {
hidden: shouldHideCopyButton
})
}, {
type: 'separator'
}, {
id: 'editor.extension.delete',
type: 'button',
icon: DeleteIcon,
iconFallback: DeleteIcon,
appearance: 'danger',
onClick: removeExtension(editorAnalyticsAPI, INPUT_METHOD.FLOATING_TB),
onMouseEnter: hoverDecoration === null || hoverDecoration === void 0 ? void 0 : hoverDecoration(nodeType, true),
onMouseLeave: hoverDecoration === null || hoverDecoration === void 0 ? void 0 : hoverDecoration(nodeType, false),
onFocus: hoverDecoration === null || hoverDecoration === void 0 ? void 0 : hoverDecoration(nodeType, true),
onBlur: hoverDecoration === null || hoverDecoration === void 0 ? void 0 : hoverDecoration(nodeType, false),
focusEditoronEnter: true,
title: formatMessage(commonMessages.remove),
tabIndex: null,
confirmDialog
}],
scrollable: true
};
};
const clickWithCheckboxHandler = (isChecked, node) => (state, dispatch) => {
if (!node) {
return false;
}
if (!isChecked) {
removeExtension()(state, dispatch);
} else {
removeDescendantNodes(node)(state, dispatch);
}
return true;
};