UNPKG

@atlaskit/editor-plugin-extension

Version:

editor-plugin-extension plugin for @atlaskit/editor-core

450 lines (438 loc) 19.8 kB
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; };